update panel
This commit is contained in:
+255
-249
@@ -1,250 +1,256 @@
|
|||||||
# B 端需求拆解与实现说明
|
# B 端需求拆解与实现说明
|
||||||
|
|
||||||
## 1. 需求翻译
|
## 1. 需求翻译
|
||||||
|
|
||||||
系统包含两个流程:
|
系统包含两个流程:
|
||||||
|
|
||||||
1. 容积测量:A 端发起一次配置请求,B 端将严格 8 字段的配置 JSON 提交到该请求,A 端下载参数并调用 `start_volume_measurement`。
|
1. 容积测量:A 端发起一次配置请求,B 端将严格 8 字段的配置 JSON 提交到该请求,A 端下载参数并调用 `start_volume_measurement`。
|
||||||
2. 辨识:B 端发布“函数 2 参数”;A 端采集稳定压力 JSON 和辨识 CSV 并上传;B 端打印稳定压力、下载新 CSV、绘图并人工返回 0/1。
|
2. 辨识:B 端发布“函数 2 参数”;A 端采集稳定压力 JSON 和辨识 CSV 并上传;B 端打印稳定压力、下载新 CSV、绘图并人工返回 0/1。
|
||||||
|
|
||||||
辨识结果约定:
|
辨识结果约定:
|
||||||
|
|
||||||
- `1`:参数通过,A 端结束本次辨识。
|
- `1`:参数通过,A 端结束本次辨识。
|
||||||
- `0`:参数未通过,B 端必须同时提交一套新的函数 2 参数,A 端下载后重新辨识。
|
- `0`:参数未通过,B 端必须同时提交一套新的函数 2 参数,A 端下载后重新辨识。
|
||||||
|
|
||||||
## 2. 已实现内容
|
## 2. 已实现内容
|
||||||
|
|
||||||
### Server 中转接口
|
### Server 中转接口
|
||||||
|
|
||||||
- `getPendingVolumeConfigRequest`:返回指定设备当前等待 B 端响应的容积请求。
|
- `getPendingVolumeConfigRequest`:返回指定设备当前等待 B 端响应的容积请求。
|
||||||
- `submitVolumeConfigFile`:将 B 端上传的容积配置绑定到对应请求。
|
- `submitVolumeConfigFile`:将 B 端上传的容积配置绑定到对应请求。
|
||||||
- `publishIdentificationConfig`:按设备发布函数 2 的 CSV 配置。
|
- `publishIdentificationConfig`:按设备发布函数 2 的 CSV 配置。
|
||||||
- `getIdentificationConfig`:A 端按设备读取函数 2 配置。
|
- `getIdentificationConfig`:A 端按设备读取函数 2 配置。
|
||||||
- `setIdentificationFeedback`:B 端按设备与运行 ID 返回 0/1。
|
- `setIdentificationFeedback`:B 端按设备与运行 ID 返回 0/1。
|
||||||
- `getPendingPanelFile`:按设备返回下一条待处理 CSV/JSON 消息。
|
- `getPendingPanelFile`:按设备返回下一条待处理 CSV/JSON 消息。
|
||||||
- `ackPanelFile`:确认处理完成并删除 server 暂存文件。
|
- `ackPanelFile`:确认处理完成并删除 server 暂存文件。
|
||||||
- 参数发布和评审写入要求 `B_ADMIN_TOKEN`。
|
- 参数发布和评审写入要求 `B_ADMIN_TOKEN`。
|
||||||
|
|
||||||
server 使用 `fileRecords` 保存上传文件索引,使用 `identificationFeedback`
|
server 使用 `fileRecords` 保存上传文件索引,使用 `identificationFeedback`
|
||||||
保存当前设备待消费的辨识反馈。
|
保存当前设备待消费的辨识反馈。
|
||||||
|
|
||||||
### B 端本地程序
|
### B 端本地程序
|
||||||
|
|
||||||
- `b-admin.js`:响应设备容积请求并上传配置 JSON,同时发布和读取函数 2 参数。
|
- `b-admin.js`:响应设备容积请求并上传配置 JSON,同时发布和读取函数 2 参数。
|
||||||
- `poll-panel-inbox.js`:获取 server 中待处理的数组 JSON 与辨识 CSV 消息。
|
- `poll-panel-inbox.js`:获取 server 中待处理的数组 JSON 与辨识 CSV 消息。
|
||||||
- `plot-json.js`:将数字数组、数值对象数组或多个数值数组绘制为折线图。
|
- `plot-json.js`:将数字数组、数值对象数组或多个数值数组绘制为折线图。
|
||||||
- 新 CSV 到达后自动下载并生成上下组合时序图。
|
- 新 CSV 到达后自动下载并生成上下组合时序图。
|
||||||
- 人工输入 0/1;输入 0 时读取新函数 2 JSON 并提交。
|
- 人工输入 0/1;输入 0 时读取新函数 2 JSON 并提交。
|
||||||
- 只有下载、绘图、评审提交全部成功后,文件才标记为已处理。
|
- 只有下载、绘图、评审提交全部成功后,文件才标记为已处理。
|
||||||
|
|
||||||
## 3. 参数契约
|
## 3. 参数契约
|
||||||
|
|
||||||
函数 1,对应 `start_volume_measurement`:
|
函数 1,对应 `start_volume_measurement`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"q_in_val": 91,
|
"q_in_val": 91,
|
||||||
"dt": 0.1,
|
"dt": 0.1,
|
||||||
"xa_full": 1000,
|
"xa_full": 1000,
|
||||||
"p_max": 200,
|
"p_max": 200,
|
||||||
"fit_low": 50,
|
"fit_low": 50,
|
||||||
"fit_high": 200,
|
"fit_high": 200,
|
||||||
"T_delta": 30,
|
"T_delta": 30,
|
||||||
"num_runs": 6
|
"num_runs": 6
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
函数 2,对应 `start_identification`:
|
函数 2,对应 `start_identification`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"q_in_val": 91,
|
"q_in_val": 91,
|
||||||
"dt": 0.1,
|
"dt": 0.1,
|
||||||
"n_order": 8,
|
"n_order": 8,
|
||||||
"t_c": 2.5,
|
"t_c": 2.5,
|
||||||
"levels": [10, 20, 30, 40, 50, 60, 70, 80],
|
"levels": [10, 20, 30, 40, 50, 60, 70, 80],
|
||||||
"dead_area": 0,
|
"dead_area": 0,
|
||||||
"xa_full": 1000,
|
"xa_full": 1000,
|
||||||
"V_val": 1,
|
"V_val": 1,
|
||||||
"repeat": 2
|
"repeat": 2
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
示例数值仅用于联调,正式值需要算法或产品确认。
|
示例数值仅用于联调,正式值需要算法或产品确认。
|
||||||
|
|
||||||
## 4. A 端调用契约
|
## 4. A 端调用契约
|
||||||
|
|
||||||
函数 1 使用一次性请求,不监听或扫描文件路径:
|
函数 1 使用一次性请求,不监听或扫描文件路径:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "createVolumeConfigRequest", "deviceId": "设备 ID" }
|
{ "type": "createVolumeConfigRequest", "deviceId": "设备 ID" }
|
||||||
{ "type": "getVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
{ "type": "getVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
||||||
{ "type": "ackVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
{ "type": "ackVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" }
|
||||||
```
|
```
|
||||||
|
|
||||||
B 端只能在请求有效期内提交容积配置;A 端确认接收后,server 删除请求和临时文件。
|
B 端只能在请求有效期内提交容积配置;A 端确认接收后,server 删除请求和临时文件。
|
||||||
|
|
||||||
读取函数 2 参数:
|
读取函数 2 参数:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "getIdentificationConfig", "deviceId": "设备 ID" }
|
{ "type": "getIdentificationConfig", "deviceId": "设备 ID" }
|
||||||
```
|
```
|
||||||
|
|
||||||
查询某个辨识 CSV 的结果:
|
查询某个辨识 CSV 的结果:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "getIdentificationFeedback",
|
"type": "getIdentificationFeedback",
|
||||||
"deviceId": "设备 ID",
|
"deviceId": "设备 ID",
|
||||||
"runId": "CSV 文件名"
|
"runId": "CSV 文件名"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
未评审时返回 `ready: false`;评审后返回 `ready: true` 和 `result: 0|1`。
|
未评审时返回 `ready: false`;评审后返回 `ready: true` 和 `result: 0|1`。
|
||||||
当结果为 0 时,A 端重新调用 `getIdentificationConfig` 获取已经更新的 CSV。
|
当结果为 0 时,A 端重新调用 `getIdentificationConfig` 获取已经更新的 CSV。
|
||||||
|
|
||||||
## 5. Server 部署
|
## 5. Server 部署
|
||||||
|
|
||||||
1. 配置 server 环境变量 `B_ADMIN_TOKEN`、`HOST`、`PORT` 和可选的 `DATA_DIR`。
|
1. 配置 server 环境变量 `B_ADMIN_TOKEN`、`HOST`、`PORT` 和可选的 `DATA_DIR`。
|
||||||
2. ControlPanel 与 ReinLoop 使用相同的 server URL 和设备 ID。
|
2. ControlPanel 与 ReinLoop 使用相同的 server URL 和设备 ID。
|
||||||
3. Panel 不扫描文件目录,仅按设备 ID 消费 server 消息队列。
|
3. Panel 不扫描文件目录,仅按设备 ID 消费 server 消息队列。
|
||||||
4. CSV 处理完成后由 server 自动删除暂存文件。
|
4. CSV 处理完成后由 server 自动删除暂存文件。
|
||||||
|
|
||||||
## 6. B 端运行
|
## 6. B 端运行
|
||||||
|
|
||||||
PowerShell 环境变量:
|
PowerShell 环境变量:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:REINLOOP_API_URL="http://服务器地址:3000/api"
|
$env:REINLOOP_API_URL="http://服务器地址:3000/api"
|
||||||
$env:B_ADMIN_TOKEN="与 server 相同的管理令牌"
|
$env:B_ADMIN_TOKEN="与 server 相同的管理令牌"
|
||||||
$env:REINLOOP_DEVICE_ID="设备 ID"
|
$env:REINLOOP_DEVICE_ID="设备 ID"
|
||||||
$env:POLL_INTERVAL_MS="1000"
|
$env:POLL_INTERVAL_MS="1000"
|
||||||
```
|
```
|
||||||
|
|
||||||
响应 A 端当前待处理的函数 1 参数请求:
|
响应 A 端当前待处理的函数 1 参数请求:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
node .\b-admin.js publish-volume .\volume-config.example.json
|
node .\b-admin.js publish-volume .\volume-config.example.json
|
||||||
```
|
```
|
||||||
|
|
||||||
发布函数 2 参数:
|
发布函数 2 参数:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
node .\b-admin.js publish-identification .\identification-config.example.json
|
node .\b-admin.js publish-identification .\identification-config.example.json
|
||||||
```
|
```
|
||||||
|
|
||||||
读取当前函数 2 参数:
|
读取当前函数 2 参数:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
node .\b-admin.js get-identification
|
node .\b-admin.js get-identification
|
||||||
```
|
```
|
||||||
|
|
||||||
启动监听与评审:
|
启动监听与评审:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Electron 图形界面
|
### Electron 图形界面
|
||||||
|
|
||||||
首次使用安装依赖:
|
首次使用安装依赖:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd ControlPanel
|
cd ControlPanel
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
启动桌面应用:
|
启动桌面应用:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run gui
|
npm run gui
|
||||||
```
|
```
|
||||||
|
|
||||||
图形界面提供以下功能:
|
图形界面提供以下功能:
|
||||||
|
|
||||||
- 选择本地辨识 CSV,调用现有绘图模块生成并预览上下组合时序图。
|
- 选择本地辨识 CSV,调用现有绘图模块生成并预览上下组合时序图。
|
||||||
- 打开并预览已有 PNG/JPG 绘图结果。
|
- 打开并预览已有 PNG/JPG 绘图结果。
|
||||||
- 导入或直接编辑容积测量、系统辨识 JSON 配置。
|
- 导入或直接编辑容积测量、系统辨识 JSON 配置。
|
||||||
- 读取当前系统辨识配置并发布新配置。
|
- 读取当前系统辨识配置并发布新配置。
|
||||||
- 响应 ReinLoop 已发起的容积请求;没有待处理请求时拒绝上传。
|
- 响应 ReinLoop 已发起的容积请求;没有待处理请求时拒绝上传。
|
||||||
- 容积配置发布前强制校验 8 个字段、数值类型以及 `num_runs` 整数类型。
|
- 容积配置发布前强制校验 8 个字段、数值类型以及 `num_runs` 整数类型。
|
||||||
|
|
||||||
Server API URL 和 Admin Token 可以在界面顶部输入,也可以在启动应用前设置
|
Server API URL 和 Admin Token 可以在界面顶部输入,也可以在启动应用前设置
|
||||||
`REINLOOP_API_URL`、`B_ADMIN_TOKEN` 环境变量。Token 仅由 Electron 主进程用于请求,
|
`REINLOOP_API_URL`、`B_ADMIN_TOKEN` 环境变量。Token 仅由 Electron 主进程用于请求,
|
||||||
不会保存到浏览器存储或配置文件。
|
不会保存到浏览器存储或配置文件。
|
||||||
|
|
||||||
### 打包 Windows EXE
|
### 打包 Windows EXE
|
||||||
|
|
||||||
在 `ControlPanel` 目录执行:
|
在 `ControlPanel` 目录执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm install
|
npm install
|
||||||
npm run pack:win
|
npm run pack:win
|
||||||
```
|
```
|
||||||
|
|
||||||
构建结果输出到仓库根目录的 `Build` 文件夹:
|
构建结果输出到仓库根目录的 `Build` 文件夹:
|
||||||
|
|
||||||
- 安装版 EXE:运行后可选择安装目录,并创建桌面和开始菜单快捷方式。
|
- 安装版 EXE:运行后可选择安装目录,并创建桌面和开始菜单快捷方式。
|
||||||
- 便携版 EXE:无需安装,可直接运行。
|
- 便携版 EXE:无需安装,可直接运行。
|
||||||
- `win-unpacked`:未压缩的应用目录,适合排查打包后的运行问题。
|
- `win-unpacked`:未压缩的应用目录,适合排查打包后的运行问题。
|
||||||
|
|
||||||
应用包含原生 `canvas` 绘图模块,打包配置会自动将它从 ASAR 中解包。不要手动删除
|
应用包含原生 `canvas` 绘图模块,打包配置会自动将它从 ASAR 中解包。不要手动删除
|
||||||
`win-unpacked/resources/app.asar.unpacked`。未配置代码签名证书时,Windows 首次运行可能
|
`win-unpacked/resources/app.asar.unpacked`。未配置代码签名证书时,Windows 首次运行可能
|
||||||
显示 SmartScreen 提示;正式对外分发时应配置可信的 Windows 代码签名证书。
|
显示 SmartScreen 提示;正式对外分发时应配置可信的 Windows 代码签名证书。
|
||||||
|
|
||||||
## 7. 仍需产品/A 端确认
|
## 7. 仍需产品/A 端确认
|
||||||
|
|
||||||
- A 端数组 JSON 的最终结构尚未定义;当前兼容数字数组、数值对象数组和对象内多个数值数组。
|
- A 端数组 JSON 的最终结构尚未定义;当前兼容数字数组、数值对象数组和对象内多个数值数组。
|
||||||
- B→A 配置、A→B CSV、A→B 数组 JSON 都保存在 server 的 `DATA_DIR` 下。
|
- B→A 配置、A→B CSV、A→B 数组 JSON 都保存在 server 的 `DATA_DIR` 下。
|
||||||
- 两套参数示例中的正式默认值、单位和合法范围尚未定义。
|
- 两套参数示例中的正式默认值、单位和合法范围尚未定义。
|
||||||
- A 端上传稳定压力 JSON 与辨识 CSV 到 `<设备 ID>/ind_data`。
|
- A 端上传稳定压力 JSON 与辨识 CSV 到 `<设备 ID>/ind_data`。
|
||||||
- A 端按 CSV 文件名登记 `runId`,B 端以相同 `runId` 提交反馈。
|
- A 端按 CSV 文件名登记 `runId`,B 端以相同 `runId` 提交反馈。
|
||||||
- 当前图像是否通过由 B 端人工判断;产品未提供自动判断算法或阈值。
|
- 当前图像是否通过由 B 端人工判断;产品未提供自动判断算法或阈值。
|
||||||
|
|
||||||
## 8. 公司、产线、许可证与模型管理
|
## 8. 公司、产线、许可证与模型管理
|
||||||
|
|
||||||
Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID。Server 返回的
|
Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID。Server 返回的
|
||||||
`deviceId` 固定为 `<company_code>/<production_line_code>`,配置发布、模型目录、
|
`deviceId` 固定为 `<company_code>/<production_line_code>`,配置发布、模型目录、
|
||||||
绘图收件箱和辨识反馈均使用同一个值。
|
绘图收件箱和辨识反馈均使用同一个值。
|
||||||
|
|
||||||
应用启动时首先显示连接门禁页,只提供 Server API URL 和 Admin Token。点击
|
应用启动时首先显示连接门禁页,只提供 Server API URL 和 Admin Token。点击
|
||||||
“连接并校验”后,主进程调用需要管理权限的 `listOrganizations` 接口同时检查
|
“连接并校验”后,主进程调用需要管理权限的 `listOrganizations` 接口同时检查
|
||||||
网络、API 地址和 Token;只有请求成功才显示公司、产线以及后续业务标签页。
|
网络、API 地址和 Token;只有请求成功才显示公司、产线以及后续业务标签页。
|
||||||
连接失败时业务区保持隐藏并显示 Server 返回的错误。本次应用会话不提供更改连接
|
连接失败时业务区保持隐藏并显示 Server 返回的错误。本次应用会话不提供更改连接
|
||||||
入口,需要切换 Server 或 Token 时重新启动应用。
|
入口,需要切换 Server 或 Token 时重新启动应用。
|
||||||
|
|
||||||
公司与产线菜单会显示 `● 在线`、`○ 离线` 或 `◇ 状态未知`,并在连接成功后
|
公司与产线菜单会显示 `● 在线`、`○ 离线` 或 `◇ 状态未知`,并在连接成功后
|
||||||
每 10 秒静默刷新。在线状态来自 `listOrganizations` 中每条产线的 `online` 字段,
|
每 10 秒静默刷新。在线状态来自 `listOrganizations` 中每条产线的 `online` 字段,
|
||||||
可选的 `lastSeenAt` 用于 Server 判断心跳是否超时;旧 Server 未返回该字段时显示
|
可选的 `lastSeenAt` 用于 Server 判断心跳是否超时;旧 Server 未返回该字段时显示
|
||||||
“状态未知”,不会误报在线。
|
“状态未知”,不会误报在线。
|
||||||
|
|
||||||
组织管理页支持:
|
组织管理页支持:
|
||||||
|
|
||||||
- 添加公司,编码只允许小写字母、数字、下划线和连字符。
|
- 添加公司,编码只允许小写字母、数字、下划线和连字符。
|
||||||
- 在公司下添加产线;同一公司的产线编码必须唯一。
|
- 在公司下添加产线;同一公司的产线编码必须唯一。
|
||||||
- 刷新组织后,顶部公司和产线菜单同步更新。
|
- 刷新组织后,顶部公司和产线菜单同步更新。
|
||||||
|
|
||||||
许可证页支持:
|
许可证页支持:
|
||||||
|
|
||||||
- 根据当前公司和产线签发许可证。
|
- 根据当前公司和产线签发许可证。
|
||||||
- 每次签发由操作者选择外部 RSA 私钥和本地保存位置;Panel 不保存或上传私钥。
|
- 每次签发由操作者选择外部 RSA 私钥和本地保存位置;Panel 不保存或上传私钥。
|
||||||
- 许可证采用与 ReinLoop 相同的 RSA-PSS SHA-256 格式,并包含公司、产线和组合设备 ID。
|
- 许可证采用与 ReinLoop 相同的 RSA-PSS SHA-256 格式,并包含公司、产线和组合设备 ID。
|
||||||
- 本地文件写入成功后才登记 Server;登记失败会删除本次本地文件,避免半完成状态。
|
- 本地文件写入成功后才登记 Server;登记失败会删除本次本地文件,避免半完成状态。
|
||||||
- 查看已签发许可证详情和撤销许可证。
|
- 查看已签发许可证详情和撤销许可证。
|
||||||
|
|
||||||
模型管理页按当前产线列出 `<deviceId>/model_config`,支持上传、下载和删除。
|
模型管理页按当前产线列出 `<deviceId>/model_config`,支持上传、下载和删除。
|
||||||
|
|
||||||
绘图页收到辨识 CSV 后提供“通过/未通过”操作。选择未通过时必须先导入并成功
|
绘图页收到辨识 CSV 后提供“通过/未通过”操作。选择未通过时必须先导入并成功
|
||||||
发布一份新的系统辨识配置,随后才提交数字 `0`;通过则提交数字 `1`。CSV 在结论
|
发布一份新的系统辨识配置,随后才提交数字 `0`;通过则提交数字 `1`。CSV 在结论
|
||||||
提交成功前不会从 Server 收件箱删除,应用中途退出后仍可重新获取。行程 JSON 在
|
提交成功前不会从 Server 收件箱删除,应用中途退出后仍可重新获取。行程 JSON 在
|
||||||
绘图成功后直接确认。
|
绘图成功后直接确认。
|
||||||
|
|
||||||
Panel 当前依赖以下新增 Server type:
|
Panel 当前依赖以下新增 Server type:
|
||||||
|
|
||||||
`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、
|
`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、
|
||||||
`listLicenses`、`getLicense`、`revokeLicense`。既有模型和反馈接口继续使用。
|
`listLicenses`、`getLicense`、`revokeLicense`、`listControlFiles`、
|
||||||
|
`getControlFileDownload`、`deleteControlFile`。既有模型和反馈接口继续使用。
|
||||||
## 9. 已知依赖风险
|
|
||||||
|
控制数据接口约定:`listControlFiles` 按 `deviceId` 分页返回控制结束后上传到
|
||||||
依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的
|
`<deviceId>/data_record/` 的文件元数据;`getControlFileDownload` 按 `fileID` 返回带时效的
|
||||||
|
下载 URL;`deleteControlFile` 按 `fileID` 删除文件和元数据。三者均应要求 B 端管理令牌,且
|
||||||
|
服务端必须验证文件归属控制数据目录,避免使用该接口操作其他业务文件。
|
||||||
|
|
||||||
|
## 9. 已知依赖风险
|
||||||
|
|
||||||
|
依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的
|
||||||
`npm audit fix --force`,发布前应结合 Electron Builder 兼容性单独评估。
|
`npm audit fix --force`,发布前应结合 Electron Builder 兼容性单独评估。
|
||||||
+390
-331
@@ -1,332 +1,391 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
|
const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron");
|
||||||
const { getConfig, publishConfig } = require("./b-admin");
|
const { getConfig, publishConfig } = require("./b-admin");
|
||||||
const { callServer, downloadFromUrl, downloadToPath } = require("./server-client");
|
const { callServer, downloadFromUrl, downloadToPath } = require("./server-client");
|
||||||
const { signLicense } = require("./license-manager");
|
const { signLicense } = require("./license-manager");
|
||||||
const { plotCsv } = require("./plot-csv");
|
const { plotCsv } = require("./plot-csv");
|
||||||
const { plotJson } = require("./plot-json");
|
const { plotJson } = require("./plot-json");
|
||||||
|
|
||||||
const VOLUME_FIELDS = [
|
const VOLUME_FIELDS = [
|
||||||
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs"
|
"q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs"
|
||||||
];
|
];
|
||||||
const DEFAULT_API_URL = "http://ReinLoop.dominatedconvergence.com/api";
|
const DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com";
|
||||||
const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL;
|
const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL;
|
||||||
const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000);
|
const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000);
|
||||||
const connectionState = {
|
const connectionState = {
|
||||||
apiUrl: API_URL,
|
apiUrl: API_URL,
|
||||||
adminToken: process.env.B_ADMIN_TOKEN || "",
|
adminToken: process.env.B_ADMIN_TOKEN || "",
|
||||||
deviceId: process.env.REINLOOP_DEVICE_ID || ""
|
deviceId: process.env.REINLOOP_DEVICE_ID || ""
|
||||||
};
|
};
|
||||||
let pendingReview = null;
|
let pendingReview = null;
|
||||||
|
|
||||||
function startPanelInboxPoller(window) {
|
function startPanelInboxPoller(window) {
|
||||||
if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) {
|
if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) {
|
||||||
window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500");
|
window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500");
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let polling = false;
|
let polling = false;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
if (polling || window.isDestroyed()) return;
|
if (polling || window.isDestroyed()) return;
|
||||||
if (!connectionState.deviceId || !connectionState.adminToken) return;
|
if (!connectionState.deviceId || !connectionState.adminToken) return;
|
||||||
if (pendingReview) return;
|
if (pendingReview) return;
|
||||||
const requestContext = { ...connectionState };
|
const requestContext = { ...connectionState };
|
||||||
polling = true;
|
polling = true;
|
||||||
try {
|
try {
|
||||||
const pending = await callServer(
|
const pending = await callServer(
|
||||||
{ type: "getPendingPanelFile", deviceId: requestContext.deviceId },
|
{ type: "getPendingPanelFile", deviceId: requestContext.deviceId },
|
||||||
requestContext
|
requestContext
|
||||||
);
|
);
|
||||||
if (!pending.pending) return;
|
if (!pending.pending) return;
|
||||||
const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext);
|
const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext);
|
||||||
const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
||||||
window.webContents.send("csv:updated", {
|
window.webContents.send("csv:updated", {
|
||||||
filePath: imagePath,
|
filePath: imagePath,
|
||||||
dataUrl: toDataUrl(imagePath),
|
dataUrl: toDataUrl(imagePath),
|
||||||
fileName: pending.fileName,
|
fileName: pending.fileName,
|
||||||
mediaType: pending.mediaType,
|
mediaType: pending.mediaType,
|
||||||
uploadTime: pending.uploadTime,
|
uploadTime: pending.uploadTime,
|
||||||
deviceId: requestContext.deviceId,
|
deviceId: requestContext.deviceId,
|
||||||
reviewable: pending.mediaType === "csv"
|
reviewable: pending.mediaType === "csv"
|
||||||
});
|
});
|
||||||
if (pending.mediaType === "csv") {
|
if (pending.mediaType === "csv") {
|
||||||
pendingReview = {
|
pendingReview = {
|
||||||
deviceId: requestContext.deviceId,
|
deviceId: requestContext.deviceId,
|
||||||
fileID: pending.fileID,
|
fileID: pending.fileID,
|
||||||
runId: pending.fileName,
|
runId: pending.fileName,
|
||||||
credentials: requestContext
|
credentials: requestContext
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await callServer({
|
await callServer({
|
||||||
type: "ackPanelFile",
|
type: "ackPanelFile",
|
||||||
deviceId: requestContext.deviceId,
|
deviceId: requestContext.deviceId,
|
||||||
fileID: pending.fileID
|
fileID: pending.fileID
|
||||||
}, requestContext);
|
}, requestContext);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.webContents.send("csv:watch-error", error.message);
|
window.webContents.send("csv:watch-error", error.message);
|
||||||
} finally {
|
} finally {
|
||||||
polling = false;
|
polling = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void poll();
|
void poll();
|
||||||
const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS);
|
const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
width: 1240,
|
width: 1240,
|
||||||
height: 820,
|
height: 820,
|
||||||
minWidth: 960,
|
minWidth: 960,
|
||||||
minHeight: 680,
|
minHeight: 680,
|
||||||
backgroundColor: "#f2f4f1",
|
backgroundColor: "#f2f4f1",
|
||||||
title: "ReinLoop B 端工作台",
|
title: "ReinLoop B 端工作台",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "electron-preload.js"),
|
preload: path.join(__dirname, "electron-preload.js"),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true
|
sandbox: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.removeMenu();
|
window.removeMenu();
|
||||||
void window.loadFile(path.join(__dirname, "electron-ui", "index.html"));
|
void window.loadFile(path.join(__dirname, "electron-ui", "index.html"));
|
||||||
window.webContents.once("did-finish-load", () => {
|
window.webContents.once("did-finish-load", () => {
|
||||||
const stopPoller = startPanelInboxPoller(window);
|
const stopPoller = startPanelInboxPoller(window);
|
||||||
window.once("closed", stopPoller);
|
window.once("closed", stopPoller);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateParameters(configType, parameters) {
|
function validateParameters(configType, parameters) {
|
||||||
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
||||||
throw new Error("配置必须是 JSON 对象");
|
throw new Error("配置必须是 JSON 对象");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configType === "volume") {
|
if (configType === "volume") {
|
||||||
const fields = Object.keys(parameters);
|
const fields = Object.keys(parameters);
|
||||||
const missing = VOLUME_FIELDS.filter((field) => !(field in parameters));
|
const missing = VOLUME_FIELDS.filter((field) => !(field in parameters));
|
||||||
const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field));
|
const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field));
|
||||||
if (missing.length || extra.length) {
|
if (missing.length || extra.length) {
|
||||||
throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`);
|
throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`);
|
||||||
}
|
}
|
||||||
for (const field of VOLUME_FIELDS) {
|
for (const field of VOLUME_FIELDS) {
|
||||||
if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) {
|
if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) {
|
||||||
throw new Error(`${field} 必须是有效数字`);
|
throw new Error(`${field} 必须是有效数字`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(parameters.num_runs)) {
|
if (!Number.isInteger(parameters.num_runs)) {
|
||||||
throw new Error("num_runs 必须是整数");
|
throw new Error("num_runs 必须是整数");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDataUrl(filePath) {
|
function toDataUrl(filePath) {
|
||||||
const extension = path.extname(filePath).toLowerCase();
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png";
|
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png";
|
||||||
return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerHandlers() {
|
function registerHandlers() {
|
||||||
ipcMain.handle("app:get-defaults", () => ({
|
ipcMain.handle("app:get-defaults", () => ({
|
||||||
apiUrl: API_URL,
|
apiUrl: API_URL,
|
||||||
deviceId: process.env.REINLOOP_DEVICE_ID || "",
|
deviceId: process.env.REINLOOP_DEVICE_ID || "",
|
||||||
hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN)
|
hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
ipcMain.handle("image:show-in-folder", async (_event, filePath) => {
|
ipcMain.handle("image:show-in-folder", async (_event, filePath) => {
|
||||||
if (filePath) shell.showItemInFolder(path.resolve(filePath));
|
if (filePath) shell.showItemInFolder(path.resolve(filePath));
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("connection:set", (_event, request) => {
|
ipcMain.handle("connection:set", (_event, request) => {
|
||||||
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
||||||
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
||||||
connectionState.deviceId = String(request.deviceId || "").trim();
|
connectionState.deviceId = String(request.deviceId || "").trim();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("connection:test", async (_event, request) => {
|
ipcMain.handle("connection:test", async (_event, request) => {
|
||||||
const result = await callServer({ type: "listOrganizations" }, request);
|
const result = await callServer({ type: "listOrganizations" }, request);
|
||||||
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
connectionState.apiUrl = String(request.apiUrl || API_URL).trim();
|
||||||
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || "");
|
||||||
connectionState.deviceId = "";
|
connectionState.deviceId = "";
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("organization:list", (_event, credentials) =>
|
ipcMain.handle("organization:list", (_event, credentials) =>
|
||||||
callServer({ type: "listOrganizations" }, credentials));
|
callServer({ type: "listOrganizations" }, credentials));
|
||||||
ipcMain.handle("organization:create-company", (_event, request) =>
|
ipcMain.handle("organization:create-company", (_event, request) =>
|
||||||
callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials));
|
callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials));
|
||||||
ipcMain.handle("organization:create-line", (_event, request) =>
|
ipcMain.handle("organization:create-line", (_event, request) =>
|
||||||
callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials));
|
callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials));
|
||||||
|
|
||||||
ipcMain.handle("license:issue", async (_event, request) => {
|
ipcMain.handle("license:issue", async (_event, request) => {
|
||||||
const keySelection = await dialog.showOpenDialog({
|
const keySelection = await dialog.showOpenDialog({
|
||||||
title: "选择许可证 RSA 私钥",
|
title: "选择许可证 RSA 私钥",
|
||||||
properties: ["openFile"],
|
properties: ["openFile"],
|
||||||
filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }]
|
filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }]
|
||||||
});
|
});
|
||||||
if (keySelection.canceled) return null;
|
if (keySelection.canceled) return null;
|
||||||
const saveSelection = await dialog.showSaveDialog({
|
const saveSelection = await dialog.showSaveDialog({
|
||||||
title: "保存签发的许可证",
|
title: "保存签发的许可证",
|
||||||
defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`,
|
defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`,
|
||||||
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
||||||
});
|
});
|
||||||
if (saveSelection.canceled) return null;
|
if (saveSelection.canceled) return null;
|
||||||
const signed = signLicense({
|
const signed = signLicense({
|
||||||
customer: request.customer,
|
customer: request.customer,
|
||||||
company_id: request.companyId,
|
company_id: request.companyId,
|
||||||
production_line_id: request.productionLineId,
|
production_line_id: request.productionLineId,
|
||||||
device_id: request.deviceId,
|
device_id: request.deviceId,
|
||||||
issued: request.issued,
|
issued: request.issued,
|
||||||
expiry: request.expiry,
|
expiry: request.expiry,
|
||||||
features: request.features
|
features: request.features
|
||||||
}, keySelection.filePaths[0]);
|
}, keySelection.filePaths[0]);
|
||||||
await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 });
|
await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 });
|
||||||
try {
|
try {
|
||||||
const result = await callServer({
|
const result = await callServer({
|
||||||
type: "createLicense", licenseId: signed.payload.license_id,
|
type: "createLicense", licenseId: signed.payload.license_id,
|
||||||
companyId: request.companyId, productionLineId: request.productionLineId,
|
companyId: request.companyId, productionLineId: request.productionLineId,
|
||||||
customer: request.customer, issued: request.issued, expiry: request.expiry,
|
customer: request.customer, issued: request.issued, expiry: request.expiry,
|
||||||
features: request.features, license: signed.content
|
features: request.features, license: signed.content
|
||||||
}, request.credentials);
|
}, request.credentials);
|
||||||
return { ...result, filePath: saveSelection.filePath };
|
return { ...result, filePath: saveSelection.filePath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fs.promises.rm(saveSelection.filePath, { force: true });
|
await fs.promises.rm(saveSelection.filePath, { force: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ipcMain.handle("license:list", (_event, credentials) =>
|
ipcMain.handle("license:list", (_event, credentials) =>
|
||||||
callServer({ type: "listLicenses" }, credentials));
|
callServer({ type: "listLicenses" }, credentials));
|
||||||
ipcMain.handle("license:get", (_event, request) =>
|
ipcMain.handle("license:get", (_event, request) =>
|
||||||
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
|
||||||
ipcMain.handle("license:revoke", (_event, request) =>
|
ipcMain.handle("license:revoke", (_event, request) =>
|
||||||
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
|
||||||
|
ipcMain.handle("license:download", async (_event, request) => {
|
||||||
ipcMain.handle("review:submit", async (_event, request) => {
|
const result = await callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials);
|
||||||
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
|
if (typeof result.license?.license !== "string" || !result.license.license) {
|
||||||
throw new Error("待审核记录已变化,请等待 Panel 重新载入数据");
|
throw new Error("Server 未返回许可证原文,无法下载");
|
||||||
}
|
}
|
||||||
const result = await callServer({
|
const selection = await dialog.showSaveDialog({
|
||||||
type: "setIdentificationFeedback",
|
title: "保存许可证",
|
||||||
deviceId: request.deviceId,
|
defaultPath: `${request.licenseId}.lic`,
|
||||||
runId: request.runId,
|
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
|
||||||
result: request.result
|
});
|
||||||
}, request.credentials);
|
if (selection.canceled) return null;
|
||||||
await callServer({
|
await fs.promises.writeFile(selection.filePath, result.license.license, { encoding: "utf8", mode: 0o600 });
|
||||||
type: "ackPanelFile",
|
return { filePath: selection.filePath };
|
||||||
deviceId: pendingReview.deviceId,
|
});
|
||||||
fileID: pendingReview.fileID
|
|
||||||
}, pendingReview.credentials);
|
ipcMain.handle("review:submit", async (_event, request) => {
|
||||||
pendingReview = null;
|
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
|
||||||
return result;
|
throw new Error("待审核记录已变化,请等待 Panel 重新载入数据");
|
||||||
});
|
}
|
||||||
|
const result = await callServer({
|
||||||
ipcMain.handle("model:list", (_event, request) =>
|
type: "setIdentificationFeedback",
|
||||||
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
|
deviceId: request.deviceId,
|
||||||
ipcMain.handle("model:choose-upload-file", async () => {
|
runId: request.runId,
|
||||||
const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] });
|
result: request.result
|
||||||
if (selection.canceled) return null;
|
}, request.credentials);
|
||||||
const sourcePath = selection.filePaths[0];
|
await callServer({
|
||||||
return { sourcePath, fileName: path.basename(sourcePath) };
|
type: "ackPanelFile",
|
||||||
});
|
deviceId: pendingReview.deviceId,
|
||||||
ipcMain.handle("model:upload", async (_event, request) => {
|
fileID: pendingReview.fileID
|
||||||
if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件");
|
}, pendingReview.credentials);
|
||||||
const sourcePath = path.resolve(request.sourcePath);
|
pendingReview = null;
|
||||||
const fileName = path.basename(request.fileName);
|
return result;
|
||||||
await fs.promises.access(sourcePath, fs.constants.R_OK);
|
});
|
||||||
const issued = await callServer({
|
|
||||||
type: "uploadDataFile", fileName, folder: `${request.deviceId}/model_config`,
|
ipcMain.handle("model:list", (_event, request) =>
|
||||||
overwrite: request.overwrite === true
|
callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials));
|
||||||
}, request.credentials);
|
ipcMain.handle("model:choose-upload-file", async () => {
|
||||||
const form = new FormData();
|
const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] });
|
||||||
form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName);
|
if (selection.canceled) return null;
|
||||||
const response = await fetch(issued.uploadMetadata.url, { method: "POST", body: form });
|
const sourcePath = selection.filePaths[0];
|
||||||
if (![200, 204].includes(response.status)) throw new Error(`模型上传失败: HTTP ${response.status}`);
|
return { sourcePath, fileName: path.basename(sourcePath) };
|
||||||
return { success: true, fileID: issued.fileID, fileName };
|
});
|
||||||
});
|
ipcMain.handle("model:upload", async (_event, request) => {
|
||||||
ipcMain.handle("model:download", async (_event, request) => {
|
if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件");
|
||||||
const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials);
|
const sourcePath = path.resolve(request.sourcePath);
|
||||||
return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) };
|
const fileName = path.basename(request.fileName);
|
||||||
});
|
await fs.promises.access(sourcePath, fs.constants.R_OK);
|
||||||
ipcMain.handle("model:delete", (_event, request) =>
|
const issued = await callServer({
|
||||||
callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials));
|
type: "uploadDataFile", fileName, folder: `${request.deviceId}/model_config`,
|
||||||
|
overwrite: request.overwrite === true
|
||||||
ipcMain.handle("identification:list", (_event, request) =>
|
}, request.credentials);
|
||||||
callServer({
|
const form = new FormData();
|
||||||
type: "listIdentificationFiles",
|
form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName);
|
||||||
deviceId: request.deviceId,
|
const response = await fetch(issued.uploadMetadata.url, { method: "POST", body: form });
|
||||||
mediaType: request.mediaType,
|
if (![200, 204].includes(response.status)) throw new Error(`模型上传失败: HTTP ${response.status}`);
|
||||||
status: request.status,
|
return { success: true, fileID: issued.fileID, fileName };
|
||||||
page: request.page,
|
});
|
||||||
pageSize: request.pageSize
|
ipcMain.handle("model:download", async (_event, request) => {
|
||||||
}, request.credentials));
|
const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials);
|
||||||
ipcMain.handle("identification:preview", async (_event, request) => {
|
return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) };
|
||||||
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
});
|
||||||
const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials);
|
ipcMain.handle("model:delete", (_event, request) =>
|
||||||
const mediaType = download.mediaType || request.mediaType;
|
callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials));
|
||||||
const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
|
||||||
return {
|
ipcMain.handle("identification:list", (_event, request) =>
|
||||||
filePath: imagePath,
|
callServer({
|
||||||
dataUrl: toDataUrl(imagePath),
|
type: "listIdentificationFiles",
|
||||||
fileName: download.fileName || request.fileName,
|
deviceId: request.deviceId,
|
||||||
mediaType,
|
mediaType: request.mediaType,
|
||||||
uploadTime: download.uploadTime || request.uploadTime,
|
status: request.status,
|
||||||
deviceId: request.deviceId
|
page: request.page,
|
||||||
};
|
pageSize: request.pageSize
|
||||||
});
|
}, request.credentials));
|
||||||
ipcMain.handle("identification:download", async (_event, request) => {
|
ipcMain.handle("identification:preview", async (_event, request) => {
|
||||||
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
||||||
const fileName = download.fileName || request.fileName;
|
const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials);
|
||||||
const selection = await dialog.showSaveDialog({
|
const mediaType = download.mediaType || request.mediaType;
|
||||||
title: "保存辨识原始数据",
|
const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath));
|
||||||
defaultPath: fileName,
|
return {
|
||||||
filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }]
|
filePath: imagePath,
|
||||||
});
|
dataUrl: toDataUrl(imagePath),
|
||||||
if (selection.canceled) return null;
|
fileName: download.fileName || request.fileName,
|
||||||
await downloadToPath(download.url, selection.filePath, request.credentials);
|
mediaType,
|
||||||
return { filePath: selection.filePath };
|
uploadTime: download.uploadTime || request.uploadTime,
|
||||||
});
|
deviceId: request.deviceId
|
||||||
ipcMain.handle("identification:delete", (_event, request) =>
|
};
|
||||||
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
|
});
|
||||||
|
ipcMain.handle("identification:download", async (_event, request) => {
|
||||||
ipcMain.handle("config:choose", async () => {
|
const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials);
|
||||||
const result = await dialog.showOpenDialog({
|
const fileName = download.fileName || request.fileName;
|
||||||
title: "导入配置 JSON",
|
const selection = await dialog.showSaveDialog({
|
||||||
properties: ["openFile"],
|
title: "保存辨识原始数据",
|
||||||
filters: [{ name: "JSON 配置", extensions: ["json"] }]
|
defaultPath: fileName,
|
||||||
});
|
filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }]
|
||||||
if (result.canceled) return null;
|
});
|
||||||
const filePath = result.filePaths[0];
|
if (selection.canceled) return null;
|
||||||
const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf8"));
|
await downloadToPath(download.url, selection.filePath, request.credentials);
|
||||||
return { filePath, parameters: parsed.parameters || parsed };
|
return { filePath: selection.filePath };
|
||||||
});
|
});
|
||||||
|
ipcMain.handle("identification:delete", (_event, request) =>
|
||||||
ipcMain.handle("config:publish", async (_event, request) => {
|
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
|
||||||
validateParameters(request.configType, request.parameters);
|
|
||||||
const result = await publishConfig(request.configType, request.parameters, request.credentials);
|
ipcMain.handle("control-data:list", (_event, request) =>
|
||||||
return {
|
callServer({
|
||||||
storagePath: result.fileID || null,
|
type: "listControlFiles",
|
||||||
message: request.configType === "volume" ? "容积配置已提交给请求设备" : "辨识配置已发布"
|
deviceId: request.deviceId,
|
||||||
};
|
page: request.page,
|
||||||
});
|
pageSize: request.pageSize
|
||||||
|
}, request.credentials));
|
||||||
ipcMain.handle("config:get", async (_event, request) => {
|
ipcMain.handle("control-data:preview", async (_event, request) => {
|
||||||
const result = await getConfig(request.configType, request.credentials);
|
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
|
||||||
return result.parameters || result.config?.parameters || result.config || result;
|
const fileName = download.fileName || request.fileName;
|
||||||
});
|
const sourcePath = await downloadFromUrl(download.url, fileName, request.credentials);
|
||||||
}
|
const extension = path.extname(fileName).toLowerCase();
|
||||||
|
let content = null;
|
||||||
app.whenReady().then(() => {
|
if (extension === ".json") {
|
||||||
registerHandlers();
|
const text = await fs.promises.readFile(sourcePath, "utf8");
|
||||||
createWindow();
|
try {
|
||||||
app.on("activate", () => {
|
content = JSON.stringify(JSON.parse(text), null, 2);
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
} catch (_error) {
|
||||||
});
|
content = text;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
app.on("window-all-closed", () => {
|
return {
|
||||||
if (process.platform !== "darwin") app.quit();
|
filePath: sourcePath,
|
||||||
|
fileName,
|
||||||
|
uploadTime: download.uploadTime || request.uploadTime,
|
||||||
|
size: download.size ?? request.size,
|
||||||
|
content
|
||||||
|
};
|
||||||
|
});
|
||||||
|
ipcMain.handle("control-data:download", async (_event, request) => {
|
||||||
|
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
|
||||||
|
const fileName = download.fileName || request.fileName;
|
||||||
|
const extension = path.extname(fileName).replace(/^\./, "") || "bin";
|
||||||
|
const selection = await dialog.showSaveDialog({
|
||||||
|
title: "保存控制原始数据",
|
||||||
|
defaultPath: fileName,
|
||||||
|
filters: [{ name: `${extension.toUpperCase()} 文件`, extensions: [extension] }]
|
||||||
|
});
|
||||||
|
if (selection.canceled) return null;
|
||||||
|
await downloadToPath(download.url, selection.filePath, request.credentials);
|
||||||
|
return { filePath: selection.filePath };
|
||||||
|
});
|
||||||
|
ipcMain.handle("control-data:delete", (_event, request) =>
|
||||||
|
callServer({ type: "deleteControlFile", fileID: request.fileID }, request.credentials));
|
||||||
|
|
||||||
|
ipcMain.handle("config:choose", async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
title: "导入配置 JSON",
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [{ name: "JSON 配置", extensions: ["json"] }]
|
||||||
|
});
|
||||||
|
if (result.canceled) return null;
|
||||||
|
const filePath = result.filePaths[0];
|
||||||
|
const parsed = JSON.parse(await fs.promises.readFile(filePath, "utf8"));
|
||||||
|
return { filePath, parameters: parsed.parameters || parsed };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("config:publish", async (_event, request) => {
|
||||||
|
validateParameters(request.configType, request.parameters);
|
||||||
|
const result = await publishConfig(request.configType, request.parameters, request.credentials);
|
||||||
|
return {
|
||||||
|
storagePath: result.fileID || null,
|
||||||
|
message: request.configType === "volume" ? "容积配置已提交给请求设备" : "辨识配置已发布"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("config:get", async (_event, request) => {
|
||||||
|
const result = await getConfig(request.configType, request.credentials);
|
||||||
|
return result.parameters || result.config?.parameters || result.config || result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
registerHandlers();
|
||||||
|
createWindow();
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
@@ -1,30 +1,35 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("reinloop", {
|
contextBridge.exposeInMainWorld("reinloop", {
|
||||||
getDefaults: () => ipcRenderer.invoke("app:get-defaults"),
|
getDefaults: () => ipcRenderer.invoke("app:get-defaults"),
|
||||||
showInFolder: (filePath) => ipcRenderer.invoke("image:show-in-folder", filePath),
|
showInFolder: (filePath) => ipcRenderer.invoke("image:show-in-folder", filePath),
|
||||||
chooseConfig: () => ipcRenderer.invoke("config:choose"),
|
chooseConfig: () => ipcRenderer.invoke("config:choose"),
|
||||||
publishConfig: (request) => ipcRenderer.invoke("config:publish", request),
|
publishConfig: (request) => ipcRenderer.invoke("config:publish", request),
|
||||||
getConfig: (request) => ipcRenderer.invoke("config:get", request),
|
getConfig: (request) => ipcRenderer.invoke("config:get", request),
|
||||||
setConnection: (request) => ipcRenderer.invoke("connection:set", request),
|
setConnection: (request) => ipcRenderer.invoke("connection:set", request),
|
||||||
testConnection: (request) => ipcRenderer.invoke("connection:test", request),
|
testConnection: (request) => ipcRenderer.invoke("connection:test", request),
|
||||||
listOrganizations: (credentials) => ipcRenderer.invoke("organization:list", credentials),
|
listOrganizations: (credentials) => ipcRenderer.invoke("organization:list", credentials),
|
||||||
createCompany: (request) => ipcRenderer.invoke("organization:create-company", request),
|
createCompany: (request) => ipcRenderer.invoke("organization:create-company", request),
|
||||||
createProductionLine: (request) => ipcRenderer.invoke("organization:create-line", request),
|
createProductionLine: (request) => ipcRenderer.invoke("organization:create-line", request),
|
||||||
issueLicense: (request) => ipcRenderer.invoke("license:issue", request),
|
issueLicense: (request) => ipcRenderer.invoke("license:issue", request),
|
||||||
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
|
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
|
||||||
getLicense: (request) => ipcRenderer.invoke("license:get", request),
|
getLicense: (request) => ipcRenderer.invoke("license:get", request),
|
||||||
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
|
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
|
||||||
submitReview: (request) => ipcRenderer.invoke("review:submit", request),
|
downloadLicense: (request) => ipcRenderer.invoke("license:download", request),
|
||||||
listModels: (request) => ipcRenderer.invoke("model:list", request),
|
submitReview: (request) => ipcRenderer.invoke("review:submit", request),
|
||||||
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
|
listModels: (request) => ipcRenderer.invoke("model:list", request),
|
||||||
uploadModel: (request) => ipcRenderer.invoke("model:upload", request),
|
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
|
||||||
downloadModel: (request) => ipcRenderer.invoke("model:download", request),
|
uploadModel: (request) => ipcRenderer.invoke("model:upload", request),
|
||||||
deleteModel: (request) => ipcRenderer.invoke("model:delete", request),
|
downloadModel: (request) => ipcRenderer.invoke("model:download", request),
|
||||||
listIdentificationFiles: (request) => ipcRenderer.invoke("identification:list", request),
|
deleteModel: (request) => ipcRenderer.invoke("model:delete", request),
|
||||||
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
|
listIdentificationFiles: (request) => ipcRenderer.invoke("identification:list", request),
|
||||||
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
|
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
|
||||||
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
|
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
|
||||||
onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)),
|
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
|
||||||
onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message))
|
listControlFiles: (request) => ipcRenderer.invoke("control-data:list", request),
|
||||||
|
previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request),
|
||||||
|
downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request),
|
||||||
|
deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", request),
|
||||||
|
onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)),
|
||||||
|
onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message))
|
||||||
});
|
});
|
||||||
+231
-218
@@ -1,219 +1,232 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'">
|
||||||
<title>ReinLoop B 端工作台</title>
|
<title>ReinLoop B 端工作台</title>
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">REINLOOP / B CONSOLE</p>
|
<p class="eyebrow">REINLOOP / B CONSOLE</p>
|
||||||
<h1>数据评审工作台</h1>
|
<h1>数据评审工作台</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="status" class="status" data-tone="idle">未连接</div>
|
<div id="status" class="status" data-tone="idle">未连接</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="connection-screen" class="connection-screen" aria-labelledby="connection-title">
|
<section id="connection-screen" class="connection-screen" aria-labelledby="connection-title">
|
||||||
<form id="connection-form" class="connection-form">
|
<form id="connection-form" class="connection-form">
|
||||||
<p class="section-kicker">SERVER ACCESS</p>
|
<p class="section-kicker">SERVER ACCESS</p>
|
||||||
<h2 id="connection-title">连接管理服务</h2>
|
<h2 id="connection-title">连接管理服务</h2>
|
||||||
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com/api" required></label>
|
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com" required></label>
|
||||||
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
||||||
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
||||||
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main id="workspace" hidden>
|
<main id="workspace" hidden>
|
||||||
<section class="connection-band" aria-label="当前业务目标">
|
<section class="connection-band" aria-label="当前业务目标">
|
||||||
<label>
|
<label>
|
||||||
<span>公司</span>
|
<span>公司</span>
|
||||||
<select id="company-select" aria-label="选择公司"><option value="">请先刷新组织</option></select>
|
<select id="company-select" aria-label="选择公司"><option value="">请先刷新组织</option></select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>产线</span>
|
<span>产线</span>
|
||||||
<select id="device-id" aria-label="选择产线"><option value="">请选择产线</option></select>
|
<select id="device-id" aria-label="选择产线"><option value="">请选择产线</option></select>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav class="tabs" aria-label="功能切换">
|
<nav class="tabs" aria-label="功能切换">
|
||||||
<button class="tab active" data-target="plot-panel">绘图预览</button>
|
<button class="tab active" data-target="plot-panel">绘图预览</button>
|
||||||
<button class="tab" data-target="identification-data-panel">辨识数据</button>
|
<button class="tab" data-target="identification-data-panel">辨识数据</button>
|
||||||
<button class="tab" data-target="config-panel">配置发布</button>
|
<button class="tab" data-target="control-data-panel">控制数据</button>
|
||||||
<button class="tab" data-target="model-panel">模型管理</button>
|
<button class="tab" data-target="config-panel">配置发布</button>
|
||||||
<button class="tab" data-target="license-panel">许可证</button>
|
<button class="tab" data-target="model-panel">模型管理</button>
|
||||||
<button class="tab" data-target="organization-panel">组织管理</button>
|
<button class="tab" data-target="license-panel">许可证</button>
|
||||||
</nav>
|
<button class="tab" data-target="organization-panel">组织管理</button>
|
||||||
|
</nav>
|
||||||
<section id="plot-panel" class="panel active">
|
|
||||||
<div class="panel-head">
|
<section id="plot-panel" class="panel active">
|
||||||
<div>
|
<div class="panel-head">
|
||||||
<p class="section-kicker">IDENTIFICATION REVIEW</p>
|
<div>
|
||||||
<h2>数据曲线</h2>
|
<p class="section-kicker">IDENTIFICATION REVIEW</p>
|
||||||
</div>
|
<h2>数据曲线</h2>
|
||||||
<div class="review-actions">
|
</div>
|
||||||
<span id="review-target">等待辨识结果</span>
|
<div class="review-actions">
|
||||||
<button id="reject-review" class="button danger" disabled>未通过</button>
|
<span id="review-target">等待辨识结果</span>
|
||||||
<button id="approve-review" class="button primary" disabled>通过</button>
|
<button id="reject-review" class="button danger" disabled>未通过</button>
|
||||||
</div>
|
<button id="approve-review" class="button primary" disabled>通过</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plot-grid">
|
</div>
|
||||||
<article class="plot-item">
|
<div class="plot-grid">
|
||||||
<div class="plot-title">
|
<article class="plot-item">
|
||||||
<div>
|
<div class="plot-title">
|
||||||
<span>辨识 CSV</span>
|
<div>
|
||||||
<strong>阀门开度与压力</strong>
|
<span>辨识 CSV</span>
|
||||||
</div>
|
<strong>阀门开度与压力</strong>
|
||||||
<div class="plot-actions"><button class="button icon" data-open-plot="csv" title="放大图像" disabled aria-label="放大辨识图">+</button><button id="show-csv-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示辨识图">↗</button></div>
|
</div>
|
||||||
</div>
|
<div class="plot-actions"><button class="button icon" data-open-plot="csv" title="放大图像" disabled aria-label="放大辨识图">+</button><button id="show-csv-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示辨识图">↗</button></div>
|
||||||
<div class="plot-stage">
|
</div>
|
||||||
<div id="empty-csv-plot" class="empty-state">
|
<div class="plot-stage">
|
||||||
<strong>尚未载入辨识曲线</strong>
|
<div id="empty-csv-plot" class="empty-state">
|
||||||
<span>等待 ReinLoop 上传 CSV</span>
|
<strong>尚未载入辨识曲线</strong>
|
||||||
</div>
|
<span>等待 ReinLoop 上传 CSV</span>
|
||||||
<img id="csv-plot-image" alt="辨识 CSV 绘图预览" hidden>
|
</div>
|
||||||
</div>
|
<img id="csv-plot-image" alt="辨识 CSV 绘图预览" hidden>
|
||||||
<div class="plot-meta">
|
</div>
|
||||||
<p id="csv-image-path" class="file-path">未接收 CSV</p>
|
<div class="plot-meta">
|
||||||
<p id="csv-upload-time" class="upload-time">等待数据</p>
|
<p id="csv-image-path" class="file-path">未接收 CSV</p>
|
||||||
</div>
|
<p id="csv-upload-time" class="upload-time">等待数据</p>
|
||||||
</article>
|
</div>
|
||||||
<article class="plot-item">
|
</article>
|
||||||
<div class="plot-title">
|
<article class="plot-item">
|
||||||
<div>
|
<div class="plot-title">
|
||||||
<span>行程 JSON</span>
|
<div>
|
||||||
<strong>行程与稳态压力</strong>
|
<span>行程 JSON</span>
|
||||||
</div>
|
<strong>行程与稳态压力</strong>
|
||||||
<div class="plot-actions"><button class="button icon" data-open-plot="json" title="放大图像" disabled aria-label="放大行程图">+</button><button id="show-json-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示行程图">↗</button></div>
|
</div>
|
||||||
</div>
|
<div class="plot-actions"><button class="button icon" data-open-plot="json" title="放大图像" disabled aria-label="放大行程图">+</button><button id="show-json-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示行程图">↗</button></div>
|
||||||
<div class="plot-stage">
|
</div>
|
||||||
<div id="empty-json-plot" class="empty-state">
|
<div class="plot-stage">
|
||||||
<strong>尚未载入行程曲线</strong>
|
<div id="empty-json-plot" class="empty-state">
|
||||||
<span>等待 ReinLoop 上传行程 JSON</span>
|
<strong>尚未载入行程曲线</strong>
|
||||||
</div>
|
<span>等待 ReinLoop 上传行程 JSON</span>
|
||||||
<img id="json-plot-image" alt="行程稳态压力绘图预览" hidden>
|
</div>
|
||||||
</div>
|
<img id="json-plot-image" alt="行程稳态压力绘图预览" hidden>
|
||||||
<div class="plot-meta">
|
</div>
|
||||||
<p id="json-image-path" class="file-path">未接收行程 JSON</p>
|
<div class="plot-meta">
|
||||||
<p id="json-upload-time" class="upload-time">等待数据</p>
|
<p id="json-image-path" class="file-path">未接收行程 JSON</p>
|
||||||
</div>
|
<p id="json-upload-time" class="upload-time">等待数据</p>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
<section id="identification-data-panel" class="panel">
|
|
||||||
<div class="panel-head">
|
<section id="identification-data-panel" class="panel">
|
||||||
<div><p class="section-kicker">IDENTIFICATION ARCHIVE</p><h2>辨识数据暂存</h2></div>
|
<div class="panel-head">
|
||||||
<button id="refresh-identification-files" class="button secondary">刷新</button>
|
<div><p class="section-kicker">IDENTIFICATION ARCHIVE</p><h2>辨识数据暂存</h2></div>
|
||||||
</div>
|
<button id="refresh-identification-files" class="button secondary">刷新</button>
|
||||||
<div class="data-surface">
|
</div>
|
||||||
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table>
|
<div class="data-surface">
|
||||||
<p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p>
|
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table>
|
||||||
</div>
|
<p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
<section id="config-panel" class="panel">
|
|
||||||
<div class="panel-head">
|
<section id="control-data-panel" class="panel">
|
||||||
<div>
|
<div class="panel-head">
|
||||||
<p class="section-kicker">FUNCTION PARAMETERS</p>
|
<div><p class="section-kicker">CONTROL DATA ARCHIVE</p><h2>控制数据暂存</h2></div>
|
||||||
<h2>配置数据</h2>
|
<button id="refresh-control-files" class="button secondary">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="segmented" aria-label="配置类型">
|
<div class="data-surface">
|
||||||
<button class="segment active" data-type="volume">容积测量</button>
|
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>操作</th></tr></thead><tbody id="control-file-list"></tbody></table>
|
||||||
<button class="segment" data-type="identification">系统辨识</button>
|
<p id="control-file-empty" class="empty-row">选择公司和产线后刷新控制数据</p>
|
||||||
</div>
|
<pre id="control-file-detail" class="detail-view" hidden></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-layout">
|
</section>
|
||||||
<div class="editor-column">
|
|
||||||
<div class="editor-toolbar">
|
<section id="config-panel" class="panel">
|
||||||
<span id="config-label">容积测量配置</span>
|
<div class="panel-head">
|
||||||
<button id="import-config" class="text-button">导入 JSON</button>
|
<div>
|
||||||
</div>
|
<p class="section-kicker">FUNCTION PARAMETERS</p>
|
||||||
<textarea id="config-editor" spellcheck="false" aria-label="JSON 配置编辑器"></textarea>
|
<h2>配置数据</h2>
|
||||||
<p id="config-path" class="file-path">可直接编辑,或从本地 JSON 导入</p>
|
</div>
|
||||||
</div>
|
<div class="segmented" aria-label="配置类型">
|
||||||
<aside class="publish-aside">
|
<button class="segment active" data-type="volume">容积测量</button>
|
||||||
<h3>发布检查</h3>
|
<button class="segment" data-type="identification">系统辨识</button>
|
||||||
<dl>
|
</div>
|
||||||
<div><dt>目标</dt><dd id="publish-target">Server 配置文件</dd></div>
|
</div>
|
||||||
<div><dt>格式</dt><dd>JSON</dd></div>
|
<div class="editor-layout">
|
||||||
<div><dt>鉴权</dt><dd>Admin Token</dd></div>
|
<div class="editor-column">
|
||||||
</dl>
|
<div class="editor-toolbar">
|
||||||
<button id="load-server" class="button secondary full">读取 Server 配置</button>
|
<span id="config-label">容积测量配置</span>
|
||||||
<button id="publish-config" class="button primary full">上传容积配置</button>
|
<button id="import-config" class="text-button">导入 JSON</button>
|
||||||
<p id="publish-result" class="result">等待操作</p>
|
</div>
|
||||||
</aside>
|
<textarea id="config-editor" spellcheck="false" aria-label="JSON 配置编辑器"></textarea>
|
||||||
</div>
|
<p id="config-path" class="file-path">可直接编辑,或从本地 JSON 导入</p>
|
||||||
</section>
|
</div>
|
||||||
|
<aside class="publish-aside">
|
||||||
<section id="model-panel" class="panel">
|
<h3>发布检查</h3>
|
||||||
<div class="panel-head">
|
<dl>
|
||||||
<div><p class="section-kicker">MODEL CONTROL</p><h2>产线模型</h2></div>
|
<div><dt>目标</dt><dd id="publish-target">Server 配置文件</dd></div>
|
||||||
<div class="actions">
|
<div><dt>格式</dt><dd>JSON</dd></div>
|
||||||
<button id="refresh-models" class="button secondary">刷新</button>
|
<div><dt>鉴权</dt><dd>Admin Token</dd></div>
|
||||||
<button id="upload-model" class="button primary">上传模型</button>
|
</dl>
|
||||||
</div>
|
<button id="load-server" class="button secondary full">读取 Server 配置</button>
|
||||||
</div>
|
<button id="publish-config" class="button primary full">上传容积配置</button>
|
||||||
<div class="data-surface">
|
<p id="publish-result" class="result">等待操作</p>
|
||||||
<table><thead><tr><th>文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table>
|
</aside>
|
||||||
<p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
<section id="model-panel" class="panel">
|
||||||
<section id="license-panel" class="panel">
|
<div class="panel-head">
|
||||||
<div class="panel-head">
|
<div><p class="section-kicker">MODEL CONTROL</p><h2>产线模型</h2></div>
|
||||||
<div><p class="section-kicker">LICENSE REGISTRY</p><h2>许可证签发与管理</h2></div>
|
<div class="actions">
|
||||||
<button id="refresh-licenses" class="button secondary">刷新列表</button>
|
<button id="refresh-models" class="button secondary">刷新</button>
|
||||||
</div>
|
<button id="upload-model" class="button primary">上传模型</button>
|
||||||
<div class="management-layout">
|
</div>
|
||||||
<form id="license-form" class="form-surface">
|
</div>
|
||||||
<h3>签发许可证</h3>
|
<div class="data-surface">
|
||||||
<label><span>当前目标</span><input id="license-target" readonly placeholder="请先选择公司和产线"></label>
|
<table><thead><tr><th>文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table>
|
||||||
<label><span>签发时间</span><input id="license-issued" type="datetime-local" required></label>
|
<p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p>
|
||||||
<label><span>到期时间</span><input id="license-expiry" type="datetime-local" required></label>
|
</div>
|
||||||
<label><span>功能</span><input id="license-features" value="*" required></label>
|
</section>
|
||||||
<button class="button primary" type="submit">选择私钥并签发</button>
|
|
||||||
<p class="form-note">许可证保存到本地后同步登记到 Server;私钥不会上传或保存。</p>
|
<section id="license-panel" class="panel">
|
||||||
</form>
|
<div class="panel-head">
|
||||||
<div class="data-surface">
|
<div><p class="section-kicker">LICENSE REGISTRY</p><h2>许可证签发与管理</h2></div>
|
||||||
<table><thead><tr><th>公司 / 产线</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="license-list"></tbody></table>
|
<button id="refresh-licenses" class="button secondary">刷新列表</button>
|
||||||
<p id="license-empty" class="empty-row">尚未读取许可证</p>
|
</div>
|
||||||
<pre id="license-detail" class="detail-view" hidden></pre>
|
<div class="management-layout">
|
||||||
</div>
|
<form id="license-form" class="form-surface">
|
||||||
</div>
|
<h3>签发许可证</h3>
|
||||||
</section>
|
<label><span>当前目标</span><input id="license-target" readonly placeholder="请先选择公司和产线"></label>
|
||||||
|
<label><span>签发时间</span><input id="license-issued" type="datetime-local" required></label>
|
||||||
<section id="organization-panel" class="panel">
|
<label><span>到期时间</span><input id="license-expiry" type="datetime-local" required></label>
|
||||||
<div class="panel-head">
|
<label><span>功能</span><input id="license-features" value="*" required></label>
|
||||||
<div><p class="section-kicker">ORGANIZATION</p><h2>公司与产线</h2></div>
|
<button class="button primary" type="submit">选择私钥并签发</button>
|
||||||
<button id="refresh-organizations" class="button secondary">刷新组织</button>
|
<p class="form-note">许可证保存到本地后同步登记到 Server;私钥不会上传或保存。</p>
|
||||||
</div>
|
</form>
|
||||||
<div class="management-layout equal">
|
<div class="data-surface">
|
||||||
<form id="company-form" class="form-surface">
|
<table><thead><tr><th>公司 / 产线</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="license-list"></tbody></table>
|
||||||
<h3>添加公司</h3>
|
<p id="license-empty" class="empty-row">尚未读取许可证</p>
|
||||||
<label><span>公司名称</span><input id="company-name" required></label>
|
<pre id="license-detail" class="detail-view" hidden></pre>
|
||||||
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
</div>
|
||||||
<button class="button primary" type="submit">添加公司</button>
|
</div>
|
||||||
</form>
|
</section>
|
||||||
<form id="line-form" class="form-surface">
|
|
||||||
<h3>添加产线</h3>
|
<section id="organization-panel" class="panel">
|
||||||
<label><span>所属公司</span><select id="line-company" required><option value="">请选择公司</option></select></label>
|
<div class="panel-head">
|
||||||
<label><span>产线名称</span><input id="line-name" required></label>
|
<div><p class="section-kicker">ORGANIZATION</p><h2>公司与产线</h2></div>
|
||||||
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
<button id="refresh-organizations" class="button secondary">刷新组织</button>
|
||||||
<button class="button primary" type="submit">添加产线</button>
|
</div>
|
||||||
</form>
|
<div class="management-layout equal">
|
||||||
</div>
|
<form id="company-form" class="form-surface">
|
||||||
</section>
|
<h3>添加公司</h3>
|
||||||
</main>
|
<label><span>公司名称</span><input id="company-name" required></label>
|
||||||
<div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title">
|
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||||
<div class="lightbox-shell">
|
<button class="button primary" type="submit">添加公司</button>
|
||||||
<div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div>
|
</form>
|
||||||
<div id="lightbox-canvas" class="lightbox-canvas"><img id="lightbox-image" alt="放大的图像预览"></div>
|
<form id="line-form" class="form-surface">
|
||||||
</div>
|
<h3>添加产线</h3>
|
||||||
</div>
|
<label><span>所属公司</span><select id="line-company" required><option value="">请选择公司</option></select></label>
|
||||||
<script src="renderer.js"></script>
|
<label><span>产线名称</span><input id="line-name" required></label>
|
||||||
</body>
|
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||||
|
<button class="button primary" type="submit">添加产线</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title">
|
||||||
|
<div class="lightbox-shell">
|
||||||
|
<div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div>
|
||||||
|
<div id="lightbox-canvas" class="lightbox-canvas"><img id="lightbox-image" alt="放大的图像预览"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="renderer.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,7 @@ base64(JSON)|base64(signature)
|
|||||||
|
|
||||||
- 刷新许可证列表
|
- 刷新许可证列表
|
||||||
- 查看许可证详情
|
- 查看许可证详情
|
||||||
|
- 下载已签发许可证
|
||||||
- 撤销有效许可证
|
- 撤销有效许可证
|
||||||
- 填写撤销原因
|
- 填写撤销原因
|
||||||
- 区分有效和已撤销状态
|
- 区分有效和已撤销状态
|
||||||
@@ -192,6 +193,33 @@ getLicense
|
|||||||
revokeLicense
|
revokeLicense
|
||||||
```
|
```
|
||||||
|
|
||||||
|
下载已签发许可证复用 `getLicense` 返回的许可证原文;Panel 在本地选择保存位置后写入 `.lic` 文件。
|
||||||
|
|
||||||
|
## 5.1 控制数据暂存
|
||||||
|
|
||||||
|
“控制数据”页面按当前产线显示 ReinLoop 在控制结束后上传到
|
||||||
|
`<deviceId>/data_record/` 的 Episode 分片和清单文件。页面支持:
|
||||||
|
|
||||||
|
- 刷新控制数据列表
|
||||||
|
- 查看 JSON 清单内容;`.pkl` 分片显示文件元数据和本地缓存位置
|
||||||
|
- 下载任意控制原始文件
|
||||||
|
- 删除指定控制数据文件
|
||||||
|
|
||||||
|
控制 Episode 分片采用 Python pickle 格式,Panel 不在渲染进程反序列化该二进制数据;
|
||||||
|
需要详细分析时,应下载后使用 ReinLoop/Python 读取。JSON manifest 可直接在页面中查看。
|
||||||
|
|
||||||
|
Panel 需要 Server 提供以下 Admin 接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
listControlFiles deviceId, page, pageSize -> files, total, page, pageSize
|
||||||
|
getControlFileDownload fileID -> fileID, fileName, uploadTime, size, url
|
||||||
|
deleteControlFile fileID -> deletedCount
|
||||||
|
```
|
||||||
|
|
||||||
|
每条 `files` 记录至少包含 `fileID`、`fileName`、`uploadTime` 和 `size`。下载接口必须返回
|
||||||
|
可下载原始文件的短期签名 URL;删除接口必须同时删除文件本体和元数据,并仅允许删除该设备的
|
||||||
|
控制数据目录中的文件。
|
||||||
|
|
||||||
## 6. 模型管理
|
## 6. 模型管理
|
||||||
|
|
||||||
“模型管理”页面按当前产线操作:
|
“模型管理”页面按当前产线操作:
|
||||||
|
|||||||
+85
-85
@@ -1,85 +1,85 @@
|
|||||||
# ReinLoop V1.0 — 收敛有界
|
# ReinLoop V1.0 — 收敛有界
|
||||||
|
|
||||||
基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。
|
基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
pressure_control_gui/
|
pressure_control_gui/
|
||||||
├── main.py # 应用入口
|
├── main.py # 应用入口
|
||||||
├── PcControl.py # Modbus 通讯类
|
├── PcControl.py # Modbus 通讯类
|
||||||
│ # MT2AM8Client - MT2-AM8 模块 TCP(AI 读压力/流量,AO 写电机)
|
│ # MT2AM8Client - MT2-AM8 模块 TCP(AI 读压力/流量,AO 写电机)
|
||||||
│ # Easy521ModbusClient - PLC TCP(读压力/流量,备用)
|
│ # Easy521ModbusClient - PLC TCP(读压力/流量,备用)
|
||||||
│ # MotorModbusRTUClient - 电机 RTU(直接写位置,备用)
|
│ # MotorModbusRTUClient - 电机 RTU(直接写位置,备用)
|
||||||
│ # PressureModbusRTUClient - 压力变送器 RTU(备用)
|
│ # PressureModbusRTUClient - 压力变送器 RTU(备用)
|
||||||
├── controllers.py # 增量式 PID 控制器
|
├── controllers.py # 增量式 PID 控制器
|
||||||
├── api.py # Express Server API 配置
|
├── api.py # Express Server API 配置
|
||||||
├── styles.py # 全局 QSS 样式表
|
├── styles.py # 全局 QSS 样式表
|
||||||
├── ind_collector.py # PRBS 辨识数据采集
|
├── ind_collector.py # PRBS 辨识数据采集
|
||||||
├── get_V.py # 容积测量
|
├── get_V.py # 容积测量
|
||||||
├── license_utils.py # 许可证签发与校验
|
├── license_utils.py # 许可证签发与校验
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波
|
│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波
|
||||||
│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client)
|
│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client)
|
||||||
│ ├── model_manager.py # RL 模型管理
|
│ ├── model_manager.py # RL 模型管理
|
||||||
│ ├── data_collector.py # 数据采集与云端上传
|
│ ├── data_collector.py # 数据采集与云服务器上传
|
||||||
│ └── identification.py # 系统辨识与容积测量管理
|
│ └── identification.py # 系统辨识与容积测量管理
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── main_window.py # 主窗口(布局与信号槽绑定)
|
│ ├── main_window.py # 主窗口(布局与信号槽绑定)
|
||||||
│ ├── connection_tab.py # 连接设置页(Modbus TCP)
|
│ ├── connection_tab.py # 连接设置页(Modbus TCP)
|
||||||
│ ├── control_tab.py # 控制设置页
|
│ ├── control_tab.py # 控制设置页
|
||||||
│ ├── debug_tab.py # 模型调试页(高级参数:死区/限幅/模拟量映射)
|
│ ├── debug_tab.py # 模型调试页(高级参数:死区/限幅/模拟量映射)
|
||||||
│ ├── status_bar.py # 底部状态栏
|
│ ├── status_bar.py # 底部状态栏
|
||||||
│ └── plot_window.py # 数据绘图窗口
|
│ └── plot_window.py # 数据绘图窗口
|
||||||
├── src/ # SVG 图标资产
|
├── src/ # SVG 图标资产
|
||||||
├── model_config/ # RL 模型配置文件
|
├── model_config/ # RL 模型配置文件
|
||||||
├── ind_data/ # 辨识数据本地输出目录
|
├── ind_data/ # 辨识数据本地输出目录
|
||||||
└── tool/ # 本地调试与诊断工具
|
└── tool/ # 本地调试与诊断工具
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## 控制模式
|
## 控制模式
|
||||||
|
|
||||||
| 模式 | 说明 |
|
| 模式 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **PID** | 增量式 PID,参数可在线调整,死区/限幅可配置 |
|
| **PID** | 增量式 PID,参数可在线调整,死区/限幅可配置 |
|
||||||
| **RL** | 强化学习模型实时预测 Kp/Ki,需先加载模型 |
|
| **RL** | 强化学习模型实时预测 Kp/Ki,需先加载模型 |
|
||||||
| **手动** | 直接设定阀门开度百分比 |
|
| **手动** | 直接设定阀门开度百分比 |
|
||||||
|
|
||||||
控制周期由 PID 的 `dt` 参数决定(默认 0.1s),QTimer 驱动主线程执行,每周期末自动 sleep 补足时长保证精确周期。
|
控制周期由 PID 的 `dt` 参数决定(默认 0.1s),QTimer 驱动主线程执行,每周期末自动 sleep 补足时长保证精确周期。
|
||||||
|
|
||||||
## 硬件连接
|
## 硬件连接
|
||||||
|
|
||||||
GUI 主程序使用 **MT2-AM8 模拟量模块**(艾莫迅),通过 Modbus TCP 统一 IO:
|
GUI 主程序使用 **MT2-AM8 模拟量模块**(艾莫迅),通过 Modbus TCP 统一 IO:
|
||||||
|
|
||||||
- **MT2-AM8 模块**:Modbus TCP,默认 `192.168.1.12:502`,模块地址 1
|
- **MT2-AM8 模块**:Modbus TCP,默认 `192.168.1.12:502`,模块地址 1
|
||||||
- AI(输入寄存器 0x00~0x03):读压力传感器(4-20mA → 0-4095 → kPa)、读流量计
|
- AI(输入寄存器 0x00~0x03):读压力传感器(4-20mA → 0-4095 → kPa)、读流量计
|
||||||
- AO(保持寄存器 0x00~0x03):写电机伺服驱动器(0-10V 模拟量控制行程)
|
- AO(保持寄存器 0x00~0x03):写电机伺服驱动器(0-10V 模拟量控制行程)
|
||||||
- 模拟量映射范围、压力/流量量程可在界面中配置
|
- 模拟量映射范围、压力/流量量程可在界面中配置
|
||||||
|
|
||||||
### PcControl.py 中其他可用通讯类
|
### PcControl.py 中其他可用通讯类
|
||||||
|
|
||||||
以下类在 GUI 主循环中**未直接使用**,但可供独立脚本(如 `get_V.py` 的 `main()`)或调试调用:
|
以下类在 GUI 主循环中**未直接使用**,但可供独立脚本(如 `get_V.py` 的 `main()`)或调试调用:
|
||||||
|
|
||||||
| 类 | 协议 | 默认参数 | 用途 |
|
| 类 | 协议 | 默认参数 | 用途 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `Easy521ModbusClient` | Modbus TCP | `192.168.1.88:502` | 读 PLC 压力寄存器 504(32-bit float)、写线圈控制 |
|
| `Easy521ModbusClient` | Modbus TCP | `192.168.1.88:502` | 读 PLC 压力寄存器 504(32-bit float)、写线圈控制 |
|
||||||
| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4,115200 | 通过 RS-485 直接读写电机驱动器寄存器 |
|
| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4,115200 | 通过 RS-485 直接读写电机驱动器寄存器 |
|
||||||
| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) |
|
| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) |
|
||||||
|
|
||||||
## 数据上传
|
## 数据上传
|
||||||
|
|
||||||
控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存,
|
控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存,
|
||||||
服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由
|
服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由
|
||||||
ControlPanel 完成,不由客户端工具执行。
|
ControlPanel 完成,不由客户端工具执行。
|
||||||
|
|||||||
+28
-25
@@ -1,26 +1,29 @@
|
|||||||
"""ReinLoop server endpoint configuration shared by core modules."""
|
"""ReinLoop cloud-server endpoint configuration shared by core modules."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from license_utils import get_verified_license
|
from license_utils import get_verified_license
|
||||||
|
|
||||||
|
|
||||||
base_url = os.environ.get(
|
base_url = os.environ.get(
|
||||||
"REINLOOP_SERVER_URL",
|
"REINLOOP_SERVER_URL",
|
||||||
"http://ReinLoop.dominatedconvergence.com",
|
"https://ReinLoop.dominatedconvergence.com",
|
||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
data_record_url = os.environ.get(
|
server_api_url = os.environ.get(
|
||||||
"REINLOOP_API_URL",
|
"REINLOOP_API_URL",
|
||||||
f"{base_url}/api",
|
f"{base_url}/api",
|
||||||
)
|
)
|
||||||
_license = get_verified_license()
|
# Compatibility alias used by existing modules. It points to the ReinLoop
|
||||||
_license_device_id = (_license or {}).get("device_id", "").strip()
|
# Express server API, not a cloud-function endpoint.
|
||||||
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
data_record_url = server_api_url
|
||||||
|
_license = get_verified_license()
|
||||||
if _license_device_id and _environment_device_id and _license_device_id != _environment_device_id:
|
_license_device_id = (_license or {}).get("device_id", "").strip()
|
||||||
raise RuntimeError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致")
|
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
||||||
|
|
||||||
the_folder = _license_device_id or _environment_device_id or "local-test-device"
|
if _license_device_id and _environment_device_id and _license_device_id != _environment_device_id:
|
||||||
|
raise RuntimeError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致")
|
||||||
if not the_folder:
|
|
||||||
|
the_folder = _license_device_id or _environment_device_id or "local-test-device"
|
||||||
|
|
||||||
|
if not the_folder:
|
||||||
raise RuntimeError("设备 ID 不能为空")
|
raise RuntimeError("设备 ID 不能为空")
|
||||||
+234
-199
@@ -1,199 +1,234 @@
|
|||||||
# data_collector.py
|
# data_collector.py
|
||||||
"""数据采集器:管理 Episode 数据记录与上上传。
|
"""数据采集器:管理 Episode 数据记录与上上传。
|
||||||
|
|
||||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
import datetime
|
import datetime
|
||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from api import base_url, data_record_url, the_folder
|
from api import base_url, data_record_url, the_folder
|
||||||
|
|
||||||
|
|
||||||
class DataCollector:
|
class DataCollector:
|
||||||
"""管理控制过程中的 Episode 数据采集与保存"""
|
"""管理控制过程中的 Episode 数据采集与保存"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.episode_data_raw = [] # 所有已完成的 Episode
|
self.episode_data_raw = [] # 所有已完成的 Episode
|
||||||
self.current_episode = None # 当前正在记录的 Episode
|
self.current_episode = None # 当前正在记录的 Episode
|
||||||
self.last_target_record = None
|
self.last_target_record = None
|
||||||
self._on_log = None
|
self._on_log = None
|
||||||
|
self._on_upload_complete = None
|
||||||
def set_log_callback(self, callback):
|
|
||||||
"""设置日志回调"""
|
def set_log_callback(self, callback):
|
||||||
self._on_log = callback
|
"""设置日志回调"""
|
||||||
|
self._on_log = callback
|
||||||
def log(self, message):
|
|
||||||
if self._on_log:
|
def set_upload_complete_callback(self, callback):
|
||||||
self._on_log(message)
|
"""设置控制数据上传完成回调。
|
||||||
|
|
||||||
def _upload_to_cos(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
callback(success, manifest, error) 会在后台上传线程中调用。成功时
|
||||||
"""通过云函数获取直传凭证,再将数据直传到腾讯云 COS。"""
|
manifest 是已上传的清单字典;失败时 error 为可展示的错误信息。
|
||||||
try:
|
"""
|
||||||
resp = requests.post(data_record_url, json={
|
self._on_upload_complete = callback
|
||||||
"type": "uploadDataFile",
|
|
||||||
"fileName": filename,
|
def log(self, message):
|
||||||
"folder": folder,
|
if self._on_log:
|
||||||
}, timeout=30)
|
self._on_log(message)
|
||||||
result = resp.json()
|
|
||||||
except Exception as e:
|
def _notify_upload_complete(self, success, manifest=None, error=None):
|
||||||
self.log(f"向云函数申请凭证异常: {e}")
|
if self._on_upload_complete:
|
||||||
return False
|
self._on_upload_complete(success, manifest, error)
|
||||||
|
|
||||||
if not result.get("success"):
|
def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
||||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
"""向 ReinLoop 云服务器申请上传地址并上传控制数据。"""
|
||||||
return False
|
try:
|
||||||
|
resp = requests.post(data_record_url, json={
|
||||||
meta = result.get("uploadMetadata")
|
"type": "uploadDataFile",
|
||||||
if not meta or "url" not in meta or "authorization" not in meta:
|
"fileName": filename,
|
||||||
self.log("云端未返回有效的上传元数据")
|
"folder": folder,
|
||||||
return False
|
}, timeout=30)
|
||||||
|
result = resp.json()
|
||||||
try:
|
except Exception as e:
|
||||||
form_data = {
|
self.log(f"向云服务器申请上传地址异常: {e}")
|
||||||
"key": meta["cosFileId"],
|
return False
|
||||||
"Signature": meta["authorization"],
|
|
||||||
"x-cos-security-token": meta["token"],
|
if not result.get("success"):
|
||||||
"x-cos-meta-fileid": meta["fileId"],
|
self.log(f"申请服务器上传地址失败: {result.get('errMsg', result)}")
|
||||||
}
|
return False
|
||||||
files = {"file": (filename, io.BytesIO(data_bytes))}
|
|
||||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
meta = result.get("uploadMetadata")
|
||||||
|
if not meta or "url" not in meta:
|
||||||
if cos_resp.status_code in [200, 204]:
|
self.log("云服务器未返回有效的上传地址")
|
||||||
return True
|
return False
|
||||||
else:
|
|
||||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
try:
|
||||||
return False
|
files = {"file": (filename, io.BytesIO(data_bytes))}
|
||||||
except Exception as e:
|
upload_resp = requests.post(meta["url"], files=files, timeout=60)
|
||||||
self.log(f"COS 直传异常: {e}")
|
|
||||||
return False
|
if upload_resp.status_code in [200, 204]:
|
||||||
|
return True
|
||||||
def reset(self):
|
else:
|
||||||
"""重置所有采集状态(控制启动时调用)"""
|
self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}")
|
||||||
self.episode_data_raw = []
|
return False
|
||||||
self.current_episode = None
|
except Exception as e:
|
||||||
self.last_target_record = None
|
self.log(f"云服务器上传异常: {e}")
|
||||||
|
return False
|
||||||
def record_step(self, cycle_count: int, current_pressure: float,
|
|
||||||
target_pressure: float, valve_opening: float,
|
def reset(self):
|
||||||
kp: float, ki: float, kd: float,
|
"""重置所有采集状态(控制启动时调用)"""
|
||||||
q_in: float, v: float):
|
self.episode_data_raw = []
|
||||||
"""记录一个控制周期的数据点
|
self.current_episode = None
|
||||||
|
self.last_target_record = None
|
||||||
Args:
|
|
||||||
cycle_count: 控制周期计数
|
def record_step(self, cycle_count: int, current_pressure: float,
|
||||||
current_pressure: 当前压力
|
target_pressure: float, valve_opening: float,
|
||||||
target_pressure: 目标压力
|
kp: float, ki: float, kd: float,
|
||||||
valve_opening: 阀门开度
|
q_in: float, v: float):
|
||||||
kp, ki, kd: PID 参数
|
"""记录一个控制周期的数据点
|
||||||
q_in: 流量
|
|
||||||
v: 容积
|
Args:
|
||||||
"""
|
cycle_count: 控制周期计数
|
||||||
# 目标压力变化时自动切分 Episode
|
current_pressure: 当前压力
|
||||||
if self.current_episode is None or target_pressure != self.last_target_record:
|
target_pressure: 目标压力
|
||||||
if self.current_episode is not None:
|
valve_opening: 阀门开度
|
||||||
self.episode_data_raw.append(self.current_episode)
|
kp, ki, kd: PID 参数
|
||||||
self.log(f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点")
|
q_in: 流量
|
||||||
|
v: 容积
|
||||||
self.current_episode = {
|
"""
|
||||||
'pid': [float(kp), float(ki), float(kd)],
|
# 目标压力变化时自动切分 Episode
|
||||||
'target_pressure': target_pressure,
|
if self.current_episode is None or target_pressure != self.last_target_record:
|
||||||
'Q_in': q_in,
|
if self.current_episode is not None:
|
||||||
'V': v,
|
self.episode_data_raw.append(self.current_episode)
|
||||||
'steps': [],
|
self.log(f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点")
|
||||||
'pressures': [],
|
|
||||||
'errors': [],
|
self.current_episode = {
|
||||||
'valves': []
|
'pid': [float(kp), float(ki), float(kd)],
|
||||||
}
|
'target_pressure': target_pressure,
|
||||||
self.last_target_record = target_pressure
|
'Q_in': q_in,
|
||||||
|
'V': v,
|
||||||
# 记录当前步数据
|
'steps': [],
|
||||||
error = -(target_pressure - current_pressure)
|
'pressures': [],
|
||||||
self.current_episode['steps'].append(cycle_count)
|
'errors': [],
|
||||||
self.current_episode['pressures'].append(current_pressure)
|
'valves': []
|
||||||
self.current_episode['errors'].append(error)
|
}
|
||||||
self.current_episode['valves'].append(float(valve_opening))
|
self.last_target_record = target_pressure
|
||||||
|
|
||||||
def finalize_and_upload(self, flow: float, vol: float):
|
# 记录当前步数据
|
||||||
"""停止控制时:闭合最后一个 Episode,分片上传到云存储。
|
error = -(target_pressure - current_pressure)
|
||||||
|
self.current_episode['steps'].append(cycle_count)
|
||||||
单文件超过 5MB 时自动拆分为多个分片,
|
self.current_episode['pressures'].append(current_pressure)
|
||||||
同时上传一个 manifest.json 记录所有分片信息。
|
self.current_episode['errors'].append(error)
|
||||||
|
self.current_episode['valves'].append(float(valve_opening))
|
||||||
Args:
|
|
||||||
flow: 流量值 (用于文件名/路径)
|
def finalize_and_upload(self, flow: float, vol: float):
|
||||||
vol: 容积值 (用于文件名/路径)
|
"""停止控制时:闭合最后一个 Episode,分片上传到云存储。
|
||||||
"""
|
|
||||||
# 闭合最后一个 Episode
|
单文件超过 5MB 时自动拆分为多个分片,
|
||||||
if self.current_episode and len(self.current_episode['pressures']) > 0:
|
同时上传一个 manifest.json 记录所有分片信息。
|
||||||
self.episode_data_raw.append(self.current_episode)
|
|
||||||
self.current_episode = None
|
Args:
|
||||||
|
flow: 流量值 (用于文件名/路径)
|
||||||
if not self.episode_data_raw:
|
vol: 容积值 (用于文件名/路径)
|
||||||
return
|
"""
|
||||||
|
# 闭合最后一个 Episode
|
||||||
try:
|
if self.current_episode and len(self.current_episode['pressures']) > 0:
|
||||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
self.episode_data_raw.append(self.current_episode)
|
||||||
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
|
self.current_episode = None
|
||||||
|
|
||||||
# 拆成每片尽量不超过 5MB 的 episode 分组
|
if not self.episode_data_raw:
|
||||||
MAX_CHUNK_BYTES = 5 * 1024 * 1024 # 5MB
|
return
|
||||||
|
|
||||||
chunks = [] # [(chunk_index, episodes_subset)]
|
# 上传在线程中继续执行,因此必须持有本轮数据快照。否则 finally
|
||||||
current_chunk = []
|
# 清空缓存后,异步线程生成的 manifest 会错误地显示 0 个 Episode。
|
||||||
for ep in self.episode_data_raw:
|
episodes = list(self.episode_data_raw)
|
||||||
current_chunk.append(ep)
|
|
||||||
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
|
try:
|
||||||
# 当前片已满,回退一个 episode 后保存
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
|
||||||
current_chunk.pop()
|
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
|
||||||
chunks.append(current_chunk)
|
|
||||||
current_chunk = [ep]
|
# 拆成每片尽量不超过 5MB 的 episode 分组
|
||||||
if current_chunk:
|
MAX_CHUNK_BYTES = 5 * 1024 * 1024 # 5MB
|
||||||
chunks.append(current_chunk)
|
|
||||||
|
chunks = [] # [(chunk_index, episodes_subset)]
|
||||||
total_chunks = len(chunks)
|
current_chunk = []
|
||||||
self.log(f"控制数据共 {len(self.episode_data_raw)} 个 Episode,"
|
for ep in episodes:
|
||||||
f"拆为 {total_chunks} 个分片上传")
|
current_chunk.append(ep)
|
||||||
|
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
|
||||||
def upload_all():
|
# 当前片已满,回退一个 episode 后保存
|
||||||
part_files = []
|
current_chunk.pop()
|
||||||
for idx, chunk_eps in enumerate(chunks):
|
# 单个 Episode 也可能超过 5 MB;此时仍上传该 Episode,
|
||||||
data_bytes = pickle.dumps(chunk_eps)
|
# 而不是产生一个无内容的空分片。
|
||||||
size_kb = len(data_bytes) / 1024
|
if current_chunk:
|
||||||
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
|
chunks.append(current_chunk)
|
||||||
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
|
current_chunk = [ep]
|
||||||
if self._upload_to_cos(data_bytes, part_filename, base_folder):
|
if current_chunk:
|
||||||
part_files.append(part_filename)
|
chunks.append(current_chunk)
|
||||||
else:
|
|
||||||
self.log(f" 分片 {idx + 1} 上传失败")
|
total_chunks = len(chunks)
|
||||||
|
self.log(f"控制数据共 {len(episodes)} 个 Episode,"
|
||||||
# 上传 manifest
|
f"拆为 {total_chunks} 个分片上传")
|
||||||
manifest = {
|
|
||||||
"timestamp": timestamp,
|
def upload_all():
|
||||||
"total_chunks": total_chunks,
|
part_files = []
|
||||||
"uploaded_chunks": len(part_files),
|
part_metadata = []
|
||||||
"part_files": part_files,
|
for idx, chunk_eps in enumerate(chunks):
|
||||||
"total_episodes": len(self.episode_data_raw),
|
data_bytes = pickle.dumps(chunk_eps)
|
||||||
"flow": flow,
|
size_kb = len(data_bytes) / 1024
|
||||||
"volume": vol,
|
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
|
||||||
}
|
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
|
||||||
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
|
if self._upload_to_server(data_bytes, part_filename, base_folder):
|
||||||
manifest_bytes = manifest_str.encode('utf-8')
|
part_files.append(part_filename)
|
||||||
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
|
part_metadata.append({
|
||||||
self._upload_to_cos(manifest_bytes, manifest_filename, base_folder)
|
"file_name": part_filename,
|
||||||
|
"episode_count": len(chunk_eps),
|
||||||
if len(part_files) == total_chunks:
|
"size_bytes": len(data_bytes),
|
||||||
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
|
})
|
||||||
else:
|
else:
|
||||||
self.log(f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})")
|
self.log(f" 分片 {idx + 1} 上传失败")
|
||||||
|
|
||||||
threading.Thread(target=upload_all, daemon=True).start()
|
# 上传 manifest
|
||||||
|
manifest = {
|
||||||
except Exception as e:
|
"schema_version": 1,
|
||||||
self.log(f"保存收集数据时发生错误: {e}")
|
"data_type": "control_episode",
|
||||||
finally:
|
"run_id": timestamp,
|
||||||
self.episode_data_raw = []
|
"timestamp": timestamp,
|
||||||
|
"folder": base_folder,
|
||||||
|
"total_chunks": total_chunks,
|
||||||
|
"uploaded_chunks": len(part_files),
|
||||||
|
"part_files": part_files,
|
||||||
|
"parts": part_metadata,
|
||||||
|
"total_episodes": len(episodes),
|
||||||
|
"flow": flow,
|
||||||
|
"volume": vol,
|
||||||
|
}
|
||||||
|
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
|
||||||
|
manifest_bytes = manifest_str.encode('utf-8')
|
||||||
|
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
|
||||||
|
manifest_uploaded = self._upload_to_server(
|
||||||
|
manifest_bytes, manifest_filename, base_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(part_files) == total_chunks and manifest_uploaded:
|
||||||
|
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
|
||||||
|
self._notify_upload_complete(True, manifest, None)
|
||||||
|
else:
|
||||||
|
error = (
|
||||||
|
"控制数据清单上传失败"
|
||||||
|
if not manifest_uploaded
|
||||||
|
else f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})"
|
||||||
|
)
|
||||||
|
self.log(error)
|
||||||
|
self._notify_upload_complete(False, manifest, error)
|
||||||
|
|
||||||
|
threading.Thread(target=upload_all, daemon=True).start()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"保存收集数据时发生错误: {e}")
|
||||||
|
self._notify_upload_complete(False, None, str(e))
|
||||||
|
finally:
|
||||||
|
self.episode_data_raw = []
|
||||||
|
|||||||
+254
-260
@@ -1,27 +1,27 @@
|
|||||||
# identification.py
|
# identification.py
|
||||||
"""辨识与容积测量管理器。
|
"""辨识与容积测量管理器。
|
||||||
|
|
||||||
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
import requests
|
import requests
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from get_V import measure_volume
|
from get_V import measure_volume
|
||||||
from ind_collector import collect_data_with_prbs
|
from ind_collector import collect_data_with_prbs
|
||||||
|
|
||||||
from api import base_url, data_record_url, the_folder
|
from api import base_url, data_record_url, the_folder
|
||||||
|
|
||||||
|
|
||||||
class IdentificationManager:
|
class IdentificationManager:
|
||||||
"""管理系统辨识与容积测量任务"""
|
"""管理系统辨识与容积测量任务"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._identifying = False
|
self._identifying = False
|
||||||
self._task_thread = None
|
self._task_thread = None
|
||||||
@@ -29,16 +29,16 @@ class IdentificationManager:
|
|||||||
self._on_sample = None # 采样回调: (valve_cmd, pressure)
|
self._on_sample = None # 采样回调: (valve_cmd, pressure)
|
||||||
self._on_volume_result = None # 容积结果回调: (volume_L: float)
|
self._on_volume_result = None # 容积结果回调: (volume_L: float)
|
||||||
self._on_identification_upload = None
|
self._on_identification_upload = None
|
||||||
|
|
||||||
# ---- 回调设置 ----
|
# ---- 回调设置 ----
|
||||||
def set_log_callback(self, callback):
|
def set_log_callback(self, callback):
|
||||||
"""设置日志回调"""
|
"""设置日志回调"""
|
||||||
self._on_log = callback
|
self._on_log = callback
|
||||||
|
|
||||||
def set_sample_callback(self, callback):
|
def set_sample_callback(self, callback):
|
||||||
"""设置采样时段 UI 更新回调: callback(valve_cmd, pressure)"""
|
"""设置采样时段 UI 更新回调: callback(valve_cmd, pressure)"""
|
||||||
self._on_sample = callback
|
self._on_sample = callback
|
||||||
|
|
||||||
def set_volume_result_callback(self, callback):
|
def set_volume_result_callback(self, callback):
|
||||||
"""设置容积测量结果回调: callback(volume_L: float)"""
|
"""设置容积测量结果回调: callback(volume_L: float)"""
|
||||||
self._on_volume_result = callback
|
self._on_volume_result = callback
|
||||||
@@ -46,67 +46,61 @@ class IdentificationManager:
|
|||||||
def set_identification_upload_callback(self, callback):
|
def set_identification_upload_callback(self, callback):
|
||||||
"""设置辨识 CSV 上传结果回调: callback(success, filename, error)"""
|
"""设置辨识 CSV 上传结果回调: callback(success, filename, error)"""
|
||||||
self._on_identification_upload = callback
|
self._on_identification_upload = callback
|
||||||
|
|
||||||
def log(self, message):
|
def log(self, message):
|
||||||
if self._on_log:
|
if self._on_log:
|
||||||
self._on_log(message)
|
self._on_log(message)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""当前是否正在辨识/测量中"""
|
"""当前是否正在辨识/测量中"""
|
||||||
thread_alive = (
|
thread_alive = (
|
||||||
self._task_thread is not None and self._task_thread.is_alive()
|
self._task_thread is not None and self._task_thread.is_alive()
|
||||||
)
|
)
|
||||||
return self._identifying or thread_alive
|
return self._identifying or thread_alive
|
||||||
|
|
||||||
def _upload_to_cos(self, content, filename: str, folder: str) -> bool:
|
def _upload_to_server(self, content, filename: str, folder: str) -> bool:
|
||||||
"""通过云函数获取直传凭证,再将文本或字节数据直传到 COS。
|
"""向 ReinLoop 云服务器申请上传地址并上传文本或字节数据。
|
||||||
|
|
||||||
返回 True 表示上传成功,False 表示失败(已内部记 log)。
|
返回 True 表示上传成功,False 表示失败(已内部记 log)。
|
||||||
"""
|
"""
|
||||||
# Step 1: 向云函数申请直传凭证(不传文件内容)
|
# Step 1: 向业务服务器申请一次性上传地址(不传文件内容)
|
||||||
try:
|
try:
|
||||||
resp = requests.post(data_record_url, json={
|
resp = requests.post(data_record_url, json={
|
||||||
"type": "uploadDataFile",
|
"type": "uploadDataFile",
|
||||||
"fileName": filename,
|
"fileName": filename,
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
}, timeout=30)
|
}, timeout=30)
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"向云函数申请凭证异常: {e}")
|
self.log(f"向云服务器申请上传地址异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
|
self.log(f"申请服务器上传地址失败: {result.get('errMsg', result)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
meta = result.get("uploadMetadata")
|
meta = result.get("uploadMetadata")
|
||||||
if not meta or "url" not in meta or "authorization" not in meta:
|
if not meta or "url" not in meta:
|
||||||
self.log("云端未返回有效的上传元数据")
|
self.log("云服务器未返回有效的上传地址")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Step 2: 直传到 COS
|
# Step 2: multipart 上传到云服务器提供的一次性地址
|
||||||
try:
|
try:
|
||||||
form_data = {
|
|
||||||
"key": meta["cosFileId"],
|
|
||||||
"Signature": meta["authorization"],
|
|
||||||
"x-cos-security-token": meta["token"],
|
|
||||||
"x-cos-meta-fileid": meta["fileId"],
|
|
||||||
}
|
|
||||||
content_bytes = (
|
content_bytes = (
|
||||||
content if isinstance(content, bytes)
|
content if isinstance(content, bytes)
|
||||||
else str(content).encode("utf-8")
|
else str(content).encode("utf-8")
|
||||||
)
|
)
|
||||||
files = {"file": (filename, io.BytesIO(content_bytes))}
|
files = {"file": (filename, io.BytesIO(content_bytes))}
|
||||||
cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60)
|
upload_resp = requests.post(meta["url"], files=files, timeout=60)
|
||||||
|
|
||||||
if cos_resp.status_code in [200, 204]:
|
if upload_resp.status_code in [200, 204]:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
|
self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"COS 直传异常: {e}")
|
self.log(f"云服务器上传异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _run_initial_travel_scan(self, conn_mgr):
|
def _run_initial_travel_scan(self, conn_mgr):
|
||||||
@@ -226,7 +220,7 @@ class IdentificationManager:
|
|||||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
filename = f"travel_stability_pressures_{timestamp}.json"
|
filename = f"travel_stability_pressures_{timestamp}.json"
|
||||||
payload = {"stable_pressures": stable_pressure_records}
|
payload = {"stable_pressures": stable_pressure_records}
|
||||||
uploaded = self._upload_to_cos(
|
uploaded = self._upload_to_server(
|
||||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
filename,
|
filename,
|
||||||
f"{the_folder}/ind_data",
|
f"{the_folder}/ind_data",
|
||||||
@@ -236,31 +230,31 @@ class IdentificationManager:
|
|||||||
else:
|
else:
|
||||||
self.log("行程稳态压力 JSON 上传失败,继续执行 PRBS 辨识")
|
self.log("行程稳态压力 JSON 上传失败,继续执行 PRBS 辨识")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
# ---- 系统辨识 ----
|
# ---- 系统辨识 ----
|
||||||
def start_identification(self, *,
|
def start_identification(self, *,
|
||||||
conn_mgr,
|
conn_mgr,
|
||||||
running_flag_check,
|
running_flag_check,
|
||||||
q_in_val: float, dt: float,
|
q_in_val: float, dt: float,
|
||||||
n_order: int, t_c: float,
|
n_order: int, t_c: float,
|
||||||
levels: list, dead_area: float,
|
levels: list, dead_area: float,
|
||||||
xa_full: float, V_val: float,
|
xa_full: float, V_val: float,
|
||||||
repeat: int = 2):
|
repeat: int = 2):
|
||||||
"""启动辨识数据采集(在后台线程中运行)
|
"""启动辨识数据采集(在后台线程中运行)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn_mgr: ConnectionManager 实例
|
conn_mgr: ConnectionManager 实例
|
||||||
running_flag_check: 检查是否应该停止的可调用对象, 返回 bool
|
running_flag_check: 检查是否应该停止的可调用对象, 返回 bool
|
||||||
q_in_val: 流量 (L/min)
|
q_in_val: 流量 (L/min)
|
||||||
dt: 控制周期
|
dt: 控制周期
|
||||||
n_order: 阶数
|
n_order: 阶数
|
||||||
t_c: 周期 (s)
|
t_c: 周期 (s)
|
||||||
levels: 序列 (阀门开度列表)
|
levels: 序列 (阀门开度列表)
|
||||||
dead_area: 死区
|
dead_area: 死区
|
||||||
xa_full: 总限幅
|
xa_full: 总限幅
|
||||||
V_val: 容积 (L)
|
V_val: 容积 (L)
|
||||||
repeat: 整段复合序列重复次数,默认 2
|
repeat: 整段复合序列重复次数,默认 2
|
||||||
"""
|
"""
|
||||||
if running_flag_check():
|
if running_flag_check():
|
||||||
self.log("错误:请先停止控制再进行辨识")
|
self.log("错误:请先停止控制再进行辨识")
|
||||||
return False
|
return False
|
||||||
@@ -272,14 +266,14 @@ class IdentificationManager:
|
|||||||
if not conn_mgr or not conn_mgr.is_connected():
|
if not conn_mgr or not conn_mgr.is_connected():
|
||||||
self.log("错误:请先连接设备")
|
self.log("错误:请先连接设备")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._identifying = True
|
self._identifying = True
|
||||||
self.log("开始辨识数据采集...")
|
self.log("开始辨识数据采集...")
|
||||||
|
|
||||||
def _on_sample_point(t, u_cmd, p):
|
def _on_sample_point(t, u_cmd, p):
|
||||||
if self._on_sample:
|
if self._on_sample:
|
||||||
self._on_sample(u_cmd, p)
|
self._on_sample(u_cmd, p)
|
||||||
|
|
||||||
def collect_thread():
|
def collect_thread():
|
||||||
try:
|
try:
|
||||||
# Independent pre-scan. The PRBS call below is intentionally
|
# Independent pre-scan. The PRBS call below is intentionally
|
||||||
@@ -289,18 +283,18 @@ class IdentificationManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
result = collect_data_with_prbs(
|
result = collect_data_with_prbs(
|
||||||
conn_mgr,
|
conn_mgr,
|
||||||
q_in_val=q_in_val, dt=dt,
|
q_in_val=q_in_val, dt=dt,
|
||||||
n_order=n_order, t_c=t_c,
|
n_order=n_order, t_c=t_c,
|
||||||
levels=levels, dead_area=dead_area,
|
levels=levels, dead_area=dead_area,
|
||||||
xa_full=xa_full,
|
xa_full=xa_full,
|
||||||
V_val=V_val,
|
V_val=V_val,
|
||||||
should_stop=lambda: not self._identifying,
|
should_stop=lambda: not self._identifying,
|
||||||
log=self.log,
|
log=self.log,
|
||||||
on_sample=_on_sample_point,
|
on_sample=_on_sample_point,
|
||||||
repeat=repeat,
|
repeat=repeat,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
csv_data = result.get("csv_data")
|
csv_data = result.get("csv_data")
|
||||||
csv_filename = result.get("filename")
|
csv_filename = result.get("filename")
|
||||||
@@ -309,7 +303,7 @@ class IdentificationManager:
|
|||||||
self.log(error)
|
self.log(error)
|
||||||
if self._on_identification_upload:
|
if self._on_identification_upload:
|
||||||
self._on_identification_upload(False, None, error)
|
self._on_identification_upload(False, None, error)
|
||||||
elif self._upload_to_cos(
|
elif self._upload_to_server(
|
||||||
csv_data, csv_filename, f"{the_folder}/ind_data"):
|
csv_data, csv_filename, f"{the_folder}/ind_data"):
|
||||||
self.log("辨识数据上传成功")
|
self.log("辨识数据上传成功")
|
||||||
if self._on_identification_upload:
|
if self._on_identification_upload:
|
||||||
@@ -334,25 +328,25 @@ class IdentificationManager:
|
|||||||
self.log(f"辨识数据采集失败: {e}")
|
self.log(f"辨识数据采集失败: {e}")
|
||||||
if self._on_identification_upload:
|
if self._on_identification_upload:
|
||||||
self._on_identification_upload(False, None, str(e))
|
self._on_identification_upload(False, None, str(e))
|
||||||
finally:
|
finally:
|
||||||
self._identifying = False
|
self._identifying = False
|
||||||
# self.log("辨识结束")
|
# self.log("辨识结束")
|
||||||
|
|
||||||
thread = threading.Thread(target=collect_thread, daemon=True)
|
thread = threading.Thread(target=collect_thread, daemon=True)
|
||||||
self._task_thread = thread
|
self._task_thread = thread
|
||||||
thread.start()
|
thread.start()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ---- 容积测量 ----
|
# ---- 容积测量 ----
|
||||||
def start_volume_measurement(self, *,
|
def start_volume_measurement(self, *,
|
||||||
conn_mgr,
|
conn_mgr,
|
||||||
running_flag_check,
|
running_flag_check,
|
||||||
q_in_val: float, dt: float,
|
q_in_val: float, dt: float,
|
||||||
p_max: float, fit_low: float,
|
p_max: float, fit_low: float,
|
||||||
fit_high: float, T_delta: float,
|
fit_high: float, T_delta: float,
|
||||||
xa_full: float = 1000,
|
xa_full: float = 1000,
|
||||||
num_runs: int = 3):
|
num_runs: int = 3):
|
||||||
"""启动容积测量(在后台线程中运行)"""
|
"""启动容积测量(在后台线程中运行)"""
|
||||||
if running_flag_check():
|
if running_flag_check():
|
||||||
self.log("错误:请先停止控制再进行测试")
|
self.log("错误:请先停止控制再进行测试")
|
||||||
return False
|
return False
|
||||||
@@ -364,124 +358,124 @@ class IdentificationManager:
|
|||||||
if not conn_mgr or not conn_mgr.is_connected():
|
if not conn_mgr or not conn_mgr.is_connected():
|
||||||
self.log("错误:请先连接设备")
|
self.log("错误:请先连接设备")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._identifying = True
|
self._identifying = True
|
||||||
self.log("开始测量容积...")
|
self.log("开始测量容积...")
|
||||||
|
|
||||||
def _on_vol_sample(t, p):
|
def _on_vol_sample(t, p):
|
||||||
if self._on_sample:
|
if self._on_sample:
|
||||||
self._on_sample(None, p)
|
self._on_sample(None, p)
|
||||||
|
|
||||||
def volume_thread():
|
def volume_thread():
|
||||||
all_results = [] # 存储每次成功的结果
|
all_results = [] # 存储每次成功的结果
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for run_idx in range(num_runs):
|
for run_idx in range(num_runs):
|
||||||
if not self._identifying:
|
if not self._identifying:
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f"--- 第 {run_idx + 1}/{num_runs} 次测量 ---")
|
print(f"--- 第 {run_idx + 1}/{num_runs} 次测量 ---")
|
||||||
|
|
||||||
# 非首次测量前,等待压力回落
|
# 非首次测量前,等待压力回落
|
||||||
if run_idx > 0:
|
if run_idx > 0:
|
||||||
print("等待压力回落...")
|
print("等待压力回落...")
|
||||||
wait_start = time.time()
|
wait_start = time.time()
|
||||||
while time.time() - wait_start < 60: # 最多等 60 秒
|
while time.time() - wait_start < 60: # 最多等 60 秒
|
||||||
p = conn_mgr.read_pressure()
|
p = conn_mgr.read_pressure()
|
||||||
if p is not None and p < fit_low:
|
if p is not None and p < fit_low:
|
||||||
print(f"压力已回落至 {p:.1f} kPa,等待 10 秒稳定...")
|
print(f"压力已回落至 {p:.1f} kPa,等待 10 秒稳定...")
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
print("等待压力回落超时,跳过剩余测量")
|
print("等待压力回落超时,跳过剩余测量")
|
||||||
break
|
break
|
||||||
|
|
||||||
result = measure_volume(
|
result = measure_volume(
|
||||||
conn_mgr,
|
conn_mgr,
|
||||||
q_in_slm=q_in_val,
|
q_in_slm=q_in_val,
|
||||||
dt=dt,
|
dt=dt,
|
||||||
xa=xa_full,
|
xa=xa_full,
|
||||||
p_max=p_max,
|
p_max=p_max,
|
||||||
fit_low=fit_low,
|
fit_low=fit_low,
|
||||||
fit_high=fit_high,
|
fit_high=fit_high,
|
||||||
T_delta=T_delta,
|
T_delta=T_delta,
|
||||||
should_stop=lambda: not self._identifying,
|
should_stop=lambda: not self._identifying,
|
||||||
log=self.log,
|
log=self.log,
|
||||||
on_sample=_on_vol_sample,
|
on_sample=_on_vol_sample,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
all_results.append(result)
|
all_results.append(result)
|
||||||
print(f"第 {run_idx + 1} 次测量成功,V = {result['volume_L']:.4f} L")
|
print(f"第 {run_idx + 1} 次测量成功,V = {result['volume_L']:.4f} L")
|
||||||
else:
|
else:
|
||||||
print(f"第 {run_idx + 1} 次测量失败")
|
print(f"第 {run_idx + 1} 次测量失败")
|
||||||
|
|
||||||
# ---- 汇总 ----
|
# ---- 汇总 ----
|
||||||
if all_results:
|
if all_results:
|
||||||
n = len(all_results)
|
n = len(all_results)
|
||||||
|
|
||||||
# 平均关键参数
|
# 平均关键参数
|
||||||
avg_vol = sum(r['volume_L'] for r in all_results) / n
|
avg_vol = sum(r['volume_L'] for r in all_results) / n
|
||||||
avg_slope = sum(r['slope'] for r in all_results) / n
|
avg_slope = sum(r['slope'] for r in all_results) / n
|
||||||
avg_intercept = sum(r['intercept'] for r in all_results) / n
|
avg_intercept = sum(r['intercept'] for r in all_results) / n
|
||||||
avg_c1 = sum(r['c1'] for r in all_results) / n
|
avg_c1 = sum(r['c1'] for r in all_results) / n
|
||||||
|
|
||||||
# 构建上传数据
|
# 构建上传数据
|
||||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
individual_runs = []
|
individual_runs = []
|
||||||
for i, r in enumerate(all_results):
|
for i, r in enumerate(all_results):
|
||||||
individual_runs.append({
|
individual_runs.append({
|
||||||
"run": i + 1,
|
"run": i + 1,
|
||||||
"volume_L": r['volume_L'],
|
"volume_L": r['volume_L'],
|
||||||
"slope": r['slope'],
|
"slope": r['slope'],
|
||||||
"intercept": r['intercept'],
|
"intercept": r['intercept'],
|
||||||
"c1": r['c1'],
|
"c1": r['c1'],
|
||||||
"valid_points": r['valid_points'],
|
"valid_points": r['valid_points'],
|
||||||
"record_time": r.get('record_time', []),
|
"record_time": r.get('record_time', []),
|
||||||
"p_actual": r.get('p_actual', []),
|
"p_actual": r.get('p_actual', []),
|
||||||
})
|
})
|
||||||
|
|
||||||
full_data = {
|
full_data = {
|
||||||
"num_runs_total": num_runs,
|
"num_runs_total": num_runs,
|
||||||
"num_runs_successful": n,
|
"num_runs_successful": n,
|
||||||
"averaged": {
|
"averaged": {
|
||||||
"volume_L": avg_vol,
|
"volume_L": avg_vol,
|
||||||
"slope": avg_slope,
|
"slope": avg_slope,
|
||||||
"intercept": avg_intercept,
|
"intercept": avg_intercept,
|
||||||
"c1": avg_c1,
|
"c1": avg_c1,
|
||||||
},
|
},
|
||||||
"individual_runs": individual_runs,
|
"individual_runs": individual_runs,
|
||||||
"q_in_slm": all_results[0]['payload_data'].get('q_in_slm'),
|
"q_in_slm": all_results[0]['payload_data'].get('q_in_slm'),
|
||||||
"T_delta": T_delta,
|
"T_delta": T_delta,
|
||||||
}
|
}
|
||||||
json_str = json.dumps(full_data, indent=2, ensure_ascii=False)
|
json_str = json.dumps(full_data, indent=2, ensure_ascii=False)
|
||||||
filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json"
|
filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json"
|
||||||
|
|
||||||
if self._upload_to_cos(json_str, filename, f"{the_folder}/V_config"):
|
if self._upload_to_server(json_str, filename, f"{the_folder}/V_config"):
|
||||||
self.log("体积测量数据上传成功")
|
self.log("体积测量数据上传成功")
|
||||||
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
|
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.log("体积测量数据上传失败")
|
self.log("体积测量数据上传失败")
|
||||||
|
|
||||||
if self._on_volume_result:
|
if self._on_volume_result:
|
||||||
self._on_volume_result(avg_vol)
|
self._on_volume_result(avg_vol)
|
||||||
else:
|
else:
|
||||||
self.log("所有测量均失败:有效数据点不足,无法计算体积")
|
self.log("所有测量均失败:有效数据点不足,无法计算体积")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"容积测量详细错误: {traceback.format_exc()}")
|
self.log(f"容积测量详细错误: {traceback.format_exc()}")
|
||||||
self.log(f"容积测量失败: {e}")
|
self.log(f"容积测量失败: {e}")
|
||||||
finally:
|
finally:
|
||||||
self._identifying = False
|
self._identifying = False
|
||||||
# self.log("测量结束")
|
# self.log("测量结束")
|
||||||
|
|
||||||
thread = threading.Thread(target=volume_thread, daemon=True)
|
thread = threading.Thread(target=volume_thread, daemon=True)
|
||||||
self._task_thread = thread
|
self._task_thread = thread
|
||||||
thread.start()
|
thread.start()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""停止当前辨识/测量任务"""
|
"""停止当前辨识/测量任务"""
|
||||||
self._identifying = False
|
self._identifying = False
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ def parse_identification_config_csv(csv_text: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def download_identification_config(timeout=20) -> dict:
|
def download_identification_config(timeout=20) -> dict:
|
||||||
"""Download the current customer's CSV config through the cloud function."""
|
"""Download the current customer's CSV config through the cloud server."""
|
||||||
import requests
|
import requests
|
||||||
from api import data_record_url, the_folder
|
from api import data_record_url, the_folder
|
||||||
|
|
||||||
@@ -140,10 +140,10 @@ def download_identification_config(timeout=20) -> dict:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError(f"连接云端辨识配置服务失败: {exc}") from exc
|
raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
raise ValueError(result.get("errMsg", "云端未返回辨识配置"))
|
raise ValueError(result.get("errMsg", "云服务器未返回辨识配置"))
|
||||||
try:
|
try:
|
||||||
config_response = requests.get(result["url"], timeout=timeout)
|
config_response = requests.get(result["url"], timeout=timeout)
|
||||||
config_response.raise_for_status()
|
config_response.raise_for_status()
|
||||||
|
|||||||
+21
-3
@@ -5,7 +5,8 @@
|
|||||||
## 约定
|
## 约定
|
||||||
|
|
||||||
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
|
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
|
||||||
`REINLOOP_SERVER_URL + /api`。
|
`REINLOOP_SERVER_URL + /api`;默认地址为
|
||||||
|
`https://ReinLoop.dominatedconvergence.com/api`。
|
||||||
- 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过
|
- 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过
|
||||||
`api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`。
|
`api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`。
|
||||||
- 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。
|
- 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。
|
||||||
@@ -67,11 +68,28 @@ RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 初始化一轮采集 | `DataCollector.reset()` | 清空 Episode 缓存。 |
|
| 初始化一轮采集 | `DataCollector.reset()` | 清空 Episode 缓存。 |
|
||||||
| 记录控制点 | `DataCollector.record_step(cycle_count, current_pressure, target_pressure, valve_opening, kp, ki, kd, q_in, v)` | 目标压力变化时自动切分 Episode。 |
|
| 记录控制点 | `DataCollector.record_step(cycle_count, current_pressure, target_pressure, valve_opening, kp, ki, kd, q_in, v)` | 目标压力变化时自动切分 Episode。 |
|
||||||
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/...`。 |
|
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/data_<flow>SLM_<volume>L`。清单含 `data_type: "control_episode"`、`run_id`、分片元数据和总 Episode 数。 |
|
||||||
| 上传凭证与直传 | `DataCollector._upload_to_cos(data_bytes, filename, folder)` | 内部接口;先请求上传凭证,再将对象直传。 |
|
| 申请上传地址并上传 | `DataCollector._upload_to_server(data_bytes, filename, folder)` | 内部接口;先向 ReinLoop 云服务器申请一次性上传地址,再以 multipart 上传文件。 |
|
||||||
|
| 上传结果通知 | `DataCollector.set_upload_complete_callback(callback)` | 注册 `callback(success, manifest, error)`;在后台上传线程完成时调用。 |
|
||||||
|
|
||||||
上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。
|
上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。
|
||||||
|
|
||||||
|
### 控制数据服务端约定
|
||||||
|
|
||||||
|
控制数据上传目录固定以 `<deviceId>/data_record/` 为前缀;每次停止控制会上传
|
||||||
|
若干 `.pkl` 分片和一个同名时间戳的 `_manifest.json`。服务端在接收二步上传的文件后,
|
||||||
|
应保留文件元数据,并向管理端提供以下仅管理员可调用的接口:
|
||||||
|
|
||||||
|
| `type` | 请求字段 | 成功响应 | 服务端行为 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `listControlFiles` | `deviceId`、可选 `page`、`pageSize` | `files`、`total`、`page`、`pageSize` | 仅返回 `folder` 以 `<deviceId>/data_record/` 开头的记录;每条至少有 `fileID`、`fileName`、`folder`、`uploadTime`、`size`。 |
|
||||||
|
| `getControlFileDownload` | `fileID` | `fileID`、`fileName`、`url` | 仅允许下载控制数据目录内的文件,并返回短期签名下载 URL。 |
|
||||||
|
| `deleteControlFile` | `fileID` | `deletedCount` | 仅允许删除控制数据目录内的文件;同时删除文件本体及对应元数据。 |
|
||||||
|
|
||||||
|
上述三个接口必须校验管理端令牌,并根据 `fileID` 对应记录的目录验证设备边界,不能仅信任
|
||||||
|
调用方传入的设备标识。Panel 可直接展示 JSON manifest;`.pkl` 为 Python pickle 二进制,
|
||||||
|
应仅供下载,不应在管理端进程中反序列化。
|
||||||
|
|
||||||
## 系统辨识
|
## 系统辨识
|
||||||
|
|
||||||
| 功能 | 接口 | 返回或行为 |
|
| 功能 | 接口 | 返回或行为 |
|
||||||
|
|||||||
+656
-656
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "data_collector.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_data_collector_module():
|
||||||
|
api = types.ModuleType("api")
|
||||||
|
api.base_url = "https://cloud.example"
|
||||||
|
api.data_record_url = "https://cloud.example/api"
|
||||||
|
api.the_folder = "customer-a/line-1"
|
||||||
|
requests = types.ModuleType("requests")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"data_collector_under_test", MODULE_PATH
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
with patch.dict(sys.modules, {"api": api, "requests": requests}):
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DATA_COLLECTOR = load_data_collector_module()
|
||||||
|
|
||||||
|
|
||||||
|
class ImmediateThread:
|
||||||
|
def __init__(self, target, daemon):
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.target()
|
||||||
|
|
||||||
|
|
||||||
|
class DataCollectorUploadTests(unittest.TestCase):
|
||||||
|
def test_uploads_control_manifest_with_complete_metadata(self):
|
||||||
|
collector = DATA_COLLECTOR.DataCollector()
|
||||||
|
collector.record_step(0, 10.0, 20.0, 30.0, 1.0, 0.2, 0.0, 50.0, 5.0)
|
||||||
|
collector.record_step(1, 11.0, 20.0, 31.0, 1.0, 0.2, 0.0, 50.0, 5.0)
|
||||||
|
|
||||||
|
uploads = []
|
||||||
|
completions = []
|
||||||
|
collector._upload_to_server = lambda data, name, folder: (
|
||||||
|
uploads.append((data, name, folder)) or True
|
||||||
|
)
|
||||||
|
collector.set_upload_complete_callback(
|
||||||
|
lambda success, manifest, error: completions.append(
|
||||||
|
(success, manifest, error)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(DATA_COLLECTOR.threading, "Thread", ImmediateThread):
|
||||||
|
collector.finalize_and_upload(50.0, 5.0)
|
||||||
|
|
||||||
|
self.assertEqual(len(uploads), 2)
|
||||||
|
part_data, part_name, folder = uploads[0]
|
||||||
|
manifest_data, manifest_name, manifest_folder = uploads[1]
|
||||||
|
self.assertTrue(part_name.endswith(".pkl"))
|
||||||
|
self.assertTrue(manifest_name.endswith("_manifest.json"))
|
||||||
|
self.assertEqual(folder, "customer-a/line-1/data_record/data_50.0SLM_5.0L")
|
||||||
|
self.assertEqual(manifest_folder, folder)
|
||||||
|
self.assertEqual(len(pickle.loads(part_data)), 1)
|
||||||
|
|
||||||
|
manifest = json.loads(manifest_data)
|
||||||
|
self.assertEqual(manifest["schema_version"], 1)
|
||||||
|
self.assertEqual(manifest["data_type"], "control_episode")
|
||||||
|
self.assertEqual(manifest["total_episodes"], 1)
|
||||||
|
self.assertEqual(manifest["uploaded_chunks"], 1)
|
||||||
|
self.assertEqual(manifest["part_files"], [part_name])
|
||||||
|
self.assertEqual(manifest["parts"][0]["file_name"], part_name)
|
||||||
|
self.assertEqual(completions, [(True, manifest, None)])
|
||||||
|
self.assertEqual(collector.episode_data_raw, [])
|
||||||
|
|
||||||
|
def test_does_not_create_an_empty_chunk_for_oversized_episode(self):
|
||||||
|
collector = DATA_COLLECTOR.DataCollector()
|
||||||
|
collector.episode_data_raw = [{"payload": "x" * (5 * 1024 * 1024)}]
|
||||||
|
uploads = []
|
||||||
|
collector._upload_to_server = lambda data, name, folder: (
|
||||||
|
uploads.append((data, name, folder)) or True
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(DATA_COLLECTOR.threading, "Thread", ImmediateThread):
|
||||||
|
collector.finalize_and_upload(1.0, 1.0)
|
||||||
|
|
||||||
|
part_data = uploads[0][0]
|
||||||
|
self.assertEqual(len(pickle.loads(part_data)), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -72,7 +72,7 @@ class InitialTravelScanTests(unittest.TestCase):
|
|||||||
captured.update(body=body, filename=filename, folder=folder)
|
captured.update(body=body, filename=filename, folder=folder)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
manager._upload_to_cos = capture_upload
|
manager._upload_to_server = capture_upload
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \
|
with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \
|
||||||
patch.object(IDENTIFICATION.time, "sleep", clock.sleep):
|
patch.object(IDENTIFICATION.time, "sleep", clock.sleep):
|
||||||
@@ -104,7 +104,7 @@ class InitialTravelScanTests(unittest.TestCase):
|
|||||||
csv_data = b"t,u,p,q_in,V\n0.0,10.0,20.0,50.0,5.0\n"
|
csv_data = b"t,u,p,q_in,V\n0.0,10.0,20.0,50.0,5.0\n"
|
||||||
csv_filename = "identification_data_test.csv"
|
csv_filename = "identification_data_test.csv"
|
||||||
|
|
||||||
manager._upload_to_cos = lambda content, filename, folder: (
|
manager._upload_to_server = lambda content, filename, folder: (
|
||||||
uploaded.update(
|
uploaded.update(
|
||||||
content=content, filename=filename, folder=folder
|
content=content, filename=filename, folder=folder
|
||||||
) or True
|
) or True
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
1. 控制数据(ReinLoop 里的 core/data_collecter)和辨识数据一样,上传服务器后,需要服务器向panel提供删除下载查看功能。
|
||||||
|
2. 控制台许可证撤销界面点了没有反应,需要实现撤销和下载功能。
|
||||||
|
|
||||||
|
修改reinloop和panel,并给出服务器端需要实现的接口及功能。
|
||||||
|
|
||||||
|
## 服务端访问约定
|
||||||
|
|
||||||
|
不再调用微信云函数或云存储接口。ReinLoop 与 ControlPanel 统一访问云服务器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://ReinLoop.dominatedconvergence.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
- 所有业务接口均使用 `POST /api`,请求与响应均为 JSON。
|
||||||
|
- 通过请求体中的 `type` 字段区分业务功能。
|
||||||
|
- 管理端接口必须携带 `adminToken`,由服务端校验 `B_ADMIN_TOKEN`;ReinLoop 客户端上传控制数据时不携带管理令牌。
|
||||||
|
- 所有响应必须包含 `success: true|false`;失败时必须提供可展示的 `errMsg`。
|
||||||
|
- 设备标识 `deviceId` 固定为 `<company-code>/<line-code>`,服务端必须校验其格式,禁止路径遍历。
|
||||||
|
- 服务端需要设置 `PUBLIC_BASE_URL=https://ReinLoop.dominatedconvergence.com`,确保上传地址和下载地址均为可从客户端访问的 HTTPS URL。
|
||||||
|
|
||||||
|
### 通用文件上传接口:`uploadDataFile`
|
||||||
|
|
||||||
|
ReinLoop 的控制数据、辨识数据及配置文件均通过此两步协议上传:
|
||||||
|
|
||||||
|
1. 客户端调用业务接口申请一次性上传地址:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "uploadDataFile",
|
||||||
|
"fileName": "episode_raw_data_20260730_120000_part1of2.pkl",
|
||||||
|
"folder": "<deviceId>/data_record/data_50SLM_5L"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 服务端返回 `uploadMetadata.url` 后,客户端以 `multipart/form-data` 向该 URL 提交 `file` 字段。上传成功应返回 HTTP `204` 或 `200`。
|
||||||
|
|
||||||
|
服务端需要在上传完成时保存文件本体和 `fileRecords` 元数据(包括 `fileID`、`fileName`、`folder`、`uploadTime`、`size`)。控制数据目录必须以 `<deviceId>/data_record/` 为前缀。
|
||||||
|
|
||||||
|
## 服务器端接口需求(控制数据)
|
||||||
|
|
||||||
|
控制数据由 `ReinLoop/core/data_collector.py` 上传到
|
||||||
|
`<deviceId>/data_record/`,包括控制 Episode 的 `.pkl` 分片和对应的 JSON manifest。
|
||||||
|
以下接口均为管理端接口,要求请求体携带有效的 `adminToken`;响应统一包含
|
||||||
|
`success: true|false`,失败时返回 `errMsg`。
|
||||||
|
|
||||||
|
### `listControlFiles`
|
||||||
|
|
||||||
|
按设备分页查询控制数据文件,供 Panel 的“控制数据”列表使用。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "listControlFiles",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"deviceId": "<company-code>/<line-code>",
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/...",
|
||||||
|
"fileName": "episode_raw_data_20260730_120000_part1of2.pkl",
|
||||||
|
"uploadTime": "2026-07-30T04:00:00.000Z",
|
||||||
|
"size": 123456
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端只可返回指定 `deviceId` 的 `data_record` 目录及其子目录中的文件;按上传时间倒序排列,
|
||||||
|
`pageSize` 建议限制在 $1\dots100$。
|
||||||
|
|
||||||
|
### `getControlFileDownload`
|
||||||
|
|
||||||
|
按 `fileID` 获取控制数据原始文件的短期下载地址,供 Panel 查看 JSON manifest 或保存 `.pkl` / `.json` 文件。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "getControlFileDownload",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/...",
|
||||||
|
"fileName": "episode_raw_data_20260730_120000_manifest.json",
|
||||||
|
"uploadTime": "2026-07-30T04:00:00.000Z",
|
||||||
|
"size": 1024,
|
||||||
|
"url": "https://server.example/files/...?..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` 必须是绑定该文件且会过期的签名 URL,不能根据任意路径直接下载。服务端须校验文件存在,
|
||||||
|
且该文件必须属于控制数据目录。
|
||||||
|
|
||||||
|
### `deleteControlFile`
|
||||||
|
|
||||||
|
永久删除指定控制数据文件,供 Panel 的二次确认删除操作使用。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "deleteControlFile",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"deletedCount": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端须同时删除文件本体及 `fileRecords` 中的元数据;必须校验 `fileID` 属于控制数据目录,
|
||||||
|
禁止借此接口删除模型、辨识数据、配置或许可证相关文件。文件不存在时返回明确错误,不应将删除操作视为成功。
|
||||||
|
|
||||||
|
## 许可证接口补充
|
||||||
|
|
||||||
|
### `getLicense`
|
||||||
|
|
||||||
|
Panel 的许可证“下载”复用既有 `getLicense` 接口。请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "getLicense",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"licenseId": "<license-uuid>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应中的 `license` 对象必须包含原始许可证文本字段 `license`;Panel 将该字段保存为 `.lic` 文件。
|
||||||
|
服务端不得将私钥或其他许可证的内容一并返回。
|
||||||
|
|
||||||
|
### `revokeLicense`
|
||||||
|
|
||||||
|
Panel 的许可证撤销使用既有 `revokeLicense` 接口:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "revokeLicense",
|
||||||
|
"adminToken": "<B_ADMIN_TOKEN>",
|
||||||
|
"licenseId": "<license-uuid>",
|
||||||
|
"reason": "管理员撤销原因"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应应返回 `success: true` 及更新后的许可证对象,其中 `status` 为 `revoked`。服务端必须保留
|
||||||
|
`revokedAt` 与 `revocationReason` 审计信息;许可证在线校验接口 `validateLicense` 随后应返回
|
||||||
|
`valid: false`、`status: "revoked"`,使 ReinLoop 客户端在下一次许可证巡检时生效。
|
||||||
|
|
||||||
|
### Panel 对应功能
|
||||||
|
|
||||||
|
- “控制数据”页面:调用 `listControlFiles` 刷新列表;JSON manifest 可请求下载后直接预览,`.pkl` 仅提供下载;删除前需二次确认。
|
||||||
|
- “许可证”页面:调用 `getLicense` 下载 `.lic`;调用 `revokeLicense` 撤销,并在成功后刷新许可证列表。
|
||||||
|
- Panel 不得自行拼接服务器文件路径、下载 URL 或绕过上述 Admin 接口访问文件。
|
||||||
Reference in New Issue
Block a user