update panel

This commit is contained in:
rangang
2026-07-30 11:40:00 +08:00
parent 4312cb878c
commit e12134e40d
16 changed files with 3327 additions and 2775 deletions
+255 -249
View File
@@ -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
View File
@@ -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();
}); });
+34 -29
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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 模块 TCPAI 读压力/流量,AO 写电机) │ # MT2AM8Client - MT2-AM8 模块 TCPAI 读压力/流量,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 压力寄存器 50432-bit float)、写线圈控制 | | `Easy521ModbusClient` | Modbus TCP | `192.168.1.88:502` | 读 PLC 压力寄存器 50432-bit float)、写线圈控制 |
| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4115200 | 通过 RS-485 直接读写电机驱动器寄存器 | | `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4115200 | 通过 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -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()
+2 -2
View File
@@ -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
+178
View File
@@ -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 接口访问文件。