diff --git a/ControlPanel/B端实现说明.md b/ControlPanel/B端实现说明.md index 930e8ae..576eb03 100644 --- a/ControlPanel/B端实现说明.md +++ b/ControlPanel/B端实现说明.md @@ -1,250 +1,256 @@ -# B 端需求拆解与实现说明 - -## 1. 需求翻译 - -系统包含两个流程: - -1. 容积测量:A 端发起一次配置请求,B 端将严格 8 字段的配置 JSON 提交到该请求,A 端下载参数并调用 `start_volume_measurement`。 -2. 辨识:B 端发布“函数 2 参数”;A 端采集稳定压力 JSON 和辨识 CSV 并上传;B 端打印稳定压力、下载新 CSV、绘图并人工返回 0/1。 - -辨识结果约定: - -- `1`:参数通过,A 端结束本次辨识。 -- `0`:参数未通过,B 端必须同时提交一套新的函数 2 参数,A 端下载后重新辨识。 - -## 2. 已实现内容 - -### Server 中转接口 - -- `getPendingVolumeConfigRequest`:返回指定设备当前等待 B 端响应的容积请求。 -- `submitVolumeConfigFile`:将 B 端上传的容积配置绑定到对应请求。 -- `publishIdentificationConfig`:按设备发布函数 2 的 CSV 配置。 -- `getIdentificationConfig`:A 端按设备读取函数 2 配置。 -- `setIdentificationFeedback`:B 端按设备与运行 ID 返回 0/1。 -- `getPendingPanelFile`:按设备返回下一条待处理 CSV/JSON 消息。 -- `ackPanelFile`:确认处理完成并删除 server 暂存文件。 -- 参数发布和评审写入要求 `B_ADMIN_TOKEN`。 - -server 使用 `fileRecords` 保存上传文件索引,使用 `identificationFeedback` -保存当前设备待消费的辨识反馈。 - -### B 端本地程序 - -- `b-admin.js`:响应设备容积请求并上传配置 JSON,同时发布和读取函数 2 参数。 -- `poll-panel-inbox.js`:获取 server 中待处理的数组 JSON 与辨识 CSV 消息。 -- `plot-json.js`:将数字数组、数值对象数组或多个数值数组绘制为折线图。 -- 新 CSV 到达后自动下载并生成上下组合时序图。 -- 人工输入 0/1;输入 0 时读取新函数 2 JSON 并提交。 -- 只有下载、绘图、评审提交全部成功后,文件才标记为已处理。 - -## 3. 参数契约 - -函数 1,对应 `start_volume_measurement`: - -```json -{ - "q_in_val": 91, - "dt": 0.1, - "xa_full": 1000, - "p_max": 200, - "fit_low": 50, - "fit_high": 200, - "T_delta": 30, - "num_runs": 6 -} -``` - -函数 2,对应 `start_identification`: - -```json -{ - "q_in_val": 91, - "dt": 0.1, - "n_order": 8, - "t_c": 2.5, - "levels": [10, 20, 30, 40, 50, 60, 70, 80], - "dead_area": 0, - "xa_full": 1000, - "V_val": 1, - "repeat": 2 -} -``` - -示例数值仅用于联调,正式值需要算法或产品确认。 - -## 4. A 端调用契约 - -函数 1 使用一次性请求,不监听或扫描文件路径: - -```json -{ "type": "createVolumeConfigRequest", "deviceId": "设备 ID" } -{ "type": "getVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" } -{ "type": "ackVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" } -``` - -B 端只能在请求有效期内提交容积配置;A 端确认接收后,server 删除请求和临时文件。 - -读取函数 2 参数: - -```json -{ "type": "getIdentificationConfig", "deviceId": "设备 ID" } -``` - -查询某个辨识 CSV 的结果: - -```json -{ - "type": "getIdentificationFeedback", - "deviceId": "设备 ID", - "runId": "CSV 文件名" -} -``` - -未评审时返回 `ready: false`;评审后返回 `ready: true` 和 `result: 0|1`。 -当结果为 0 时,A 端重新调用 `getIdentificationConfig` 获取已经更新的 CSV。 - -## 5. Server 部署 - -1. 配置 server 环境变量 `B_ADMIN_TOKEN`、`HOST`、`PORT` 和可选的 `DATA_DIR`。 -2. ControlPanel 与 ReinLoop 使用相同的 server URL 和设备 ID。 -3. Panel 不扫描文件目录,仅按设备 ID 消费 server 消息队列。 -4. CSV 处理完成后由 server 自动删除暂存文件。 - -## 6. B 端运行 - -PowerShell 环境变量: - -```powershell -$env:REINLOOP_API_URL="http://服务器地址:3000/api" -$env:B_ADMIN_TOKEN="与 server 相同的管理令牌" -$env:REINLOOP_DEVICE_ID="设备 ID" -$env:POLL_INTERVAL_MS="1000" -``` - -响应 A 端当前待处理的函数 1 参数请求: - -```powershell -node .\b-admin.js publish-volume .\volume-config.example.json -``` - -发布函数 2 参数: - -```powershell -node .\b-admin.js publish-identification .\identification-config.example.json -``` - -读取当前函数 2 参数: - -```powershell -node .\b-admin.js get-identification -``` - -启动监听与评审: - -```powershell -npm start -``` - -### Electron 图形界面 - -首次使用安装依赖: - -```powershell -cd ControlPanel -npm install -``` - -启动桌面应用: - -```powershell -npm run gui -``` - -图形界面提供以下功能: - -- 选择本地辨识 CSV,调用现有绘图模块生成并预览上下组合时序图。 -- 打开并预览已有 PNG/JPG 绘图结果。 -- 导入或直接编辑容积测量、系统辨识 JSON 配置。 -- 读取当前系统辨识配置并发布新配置。 -- 响应 ReinLoop 已发起的容积请求;没有待处理请求时拒绝上传。 -- 容积配置发布前强制校验 8 个字段、数值类型以及 `num_runs` 整数类型。 - -Server API URL 和 Admin Token 可以在界面顶部输入,也可以在启动应用前设置 -`REINLOOP_API_URL`、`B_ADMIN_TOKEN` 环境变量。Token 仅由 Electron 主进程用于请求, -不会保存到浏览器存储或配置文件。 - -### 打包 Windows EXE - -在 `ControlPanel` 目录执行: - -```powershell -npm install -npm run pack:win -``` - -构建结果输出到仓库根目录的 `Build` 文件夹: - -- 安装版 EXE:运行后可选择安装目录,并创建桌面和开始菜单快捷方式。 -- 便携版 EXE:无需安装,可直接运行。 -- `win-unpacked`:未压缩的应用目录,适合排查打包后的运行问题。 - -应用包含原生 `canvas` 绘图模块,打包配置会自动将它从 ASAR 中解包。不要手动删除 -`win-unpacked/resources/app.asar.unpacked`。未配置代码签名证书时,Windows 首次运行可能 -显示 SmartScreen 提示;正式对外分发时应配置可信的 Windows 代码签名证书。 - -## 7. 仍需产品/A 端确认 - -- A 端数组 JSON 的最终结构尚未定义;当前兼容数字数组、数值对象数组和对象内多个数值数组。 -- B→A 配置、A→B CSV、A→B 数组 JSON 都保存在 server 的 `DATA_DIR` 下。 -- 两套参数示例中的正式默认值、单位和合法范围尚未定义。 -- A 端上传稳定压力 JSON 与辨识 CSV 到 `<设备 ID>/ind_data`。 -- A 端按 CSV 文件名登记 `runId`,B 端以相同 `runId` 提交反馈。 -- 当前图像是否通过由 B 端人工判断;产品未提供自动判断算法或阈值。 - -## 8. 公司、产线、许可证与模型管理 - -Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID。Server 返回的 -`deviceId` 固定为 `/`,配置发布、模型目录、 -绘图收件箱和辨识反馈均使用同一个值。 - -应用启动时首先显示连接门禁页,只提供 Server API URL 和 Admin Token。点击 -“连接并校验”后,主进程调用需要管理权限的 `listOrganizations` 接口同时检查 -网络、API 地址和 Token;只有请求成功才显示公司、产线以及后续业务标签页。 -连接失败时业务区保持隐藏并显示 Server 返回的错误。本次应用会话不提供更改连接 -入口,需要切换 Server 或 Token 时重新启动应用。 - -公司与产线菜单会显示 `● 在线`、`○ 离线` 或 `◇ 状态未知`,并在连接成功后 -每 10 秒静默刷新。在线状态来自 `listOrganizations` 中每条产线的 `online` 字段, -可选的 `lastSeenAt` 用于 Server 判断心跳是否超时;旧 Server 未返回该字段时显示 -“状态未知”,不会误报在线。 - -组织管理页支持: - -- 添加公司,编码只允许小写字母、数字、下划线和连字符。 -- 在公司下添加产线;同一公司的产线编码必须唯一。 -- 刷新组织后,顶部公司和产线菜单同步更新。 - -许可证页支持: - -- 根据当前公司和产线签发许可证。 -- 每次签发由操作者选择外部 RSA 私钥和本地保存位置;Panel 不保存或上传私钥。 -- 许可证采用与 ReinLoop 相同的 RSA-PSS SHA-256 格式,并包含公司、产线和组合设备 ID。 -- 本地文件写入成功后才登记 Server;登记失败会删除本次本地文件,避免半完成状态。 -- 查看已签发许可证详情和撤销许可证。 - -模型管理页按当前产线列出 `/model_config`,支持上传、下载和删除。 - -绘图页收到辨识 CSV 后提供“通过/未通过”操作。选择未通过时必须先导入并成功 -发布一份新的系统辨识配置,随后才提交数字 `0`;通过则提交数字 `1`。CSV 在结论 -提交成功前不会从 Server 收件箱删除,应用中途退出后仍可重新获取。行程 JSON 在 -绘图成功后直接确认。 - -Panel 当前依赖以下新增 Server type: - -`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、 -`listLicenses`、`getLicense`、`revokeLicense`。既有模型和反馈接口继续使用。 - -## 9. 已知依赖风险 - -依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的 +# B 端需求拆解与实现说明 + +## 1. 需求翻译 + +系统包含两个流程: + +1. 容积测量:A 端发起一次配置请求,B 端将严格 8 字段的配置 JSON 提交到该请求,A 端下载参数并调用 `start_volume_measurement`。 +2. 辨识:B 端发布“函数 2 参数”;A 端采集稳定压力 JSON 和辨识 CSV 并上传;B 端打印稳定压力、下载新 CSV、绘图并人工返回 0/1。 + +辨识结果约定: + +- `1`:参数通过,A 端结束本次辨识。 +- `0`:参数未通过,B 端必须同时提交一套新的函数 2 参数,A 端下载后重新辨识。 + +## 2. 已实现内容 + +### Server 中转接口 + +- `getPendingVolumeConfigRequest`:返回指定设备当前等待 B 端响应的容积请求。 +- `submitVolumeConfigFile`:将 B 端上传的容积配置绑定到对应请求。 +- `publishIdentificationConfig`:按设备发布函数 2 的 CSV 配置。 +- `getIdentificationConfig`:A 端按设备读取函数 2 配置。 +- `setIdentificationFeedback`:B 端按设备与运行 ID 返回 0/1。 +- `getPendingPanelFile`:按设备返回下一条待处理 CSV/JSON 消息。 +- `ackPanelFile`:确认处理完成并删除 server 暂存文件。 +- 参数发布和评审写入要求 `B_ADMIN_TOKEN`。 + +server 使用 `fileRecords` 保存上传文件索引,使用 `identificationFeedback` +保存当前设备待消费的辨识反馈。 + +### B 端本地程序 + +- `b-admin.js`:响应设备容积请求并上传配置 JSON,同时发布和读取函数 2 参数。 +- `poll-panel-inbox.js`:获取 server 中待处理的数组 JSON 与辨识 CSV 消息。 +- `plot-json.js`:将数字数组、数值对象数组或多个数值数组绘制为折线图。 +- 新 CSV 到达后自动下载并生成上下组合时序图。 +- 人工输入 0/1;输入 0 时读取新函数 2 JSON 并提交。 +- 只有下载、绘图、评审提交全部成功后,文件才标记为已处理。 + +## 3. 参数契约 + +函数 1,对应 `start_volume_measurement`: + +```json +{ + "q_in_val": 91, + "dt": 0.1, + "xa_full": 1000, + "p_max": 200, + "fit_low": 50, + "fit_high": 200, + "T_delta": 30, + "num_runs": 6 +} +``` + +函数 2,对应 `start_identification`: + +```json +{ + "q_in_val": 91, + "dt": 0.1, + "n_order": 8, + "t_c": 2.5, + "levels": [10, 20, 30, 40, 50, 60, 70, 80], + "dead_area": 0, + "xa_full": 1000, + "V_val": 1, + "repeat": 2 +} +``` + +示例数值仅用于联调,正式值需要算法或产品确认。 + +## 4. A 端调用契约 + +函数 1 使用一次性请求,不监听或扫描文件路径: + +```json +{ "type": "createVolumeConfigRequest", "deviceId": "设备 ID" } +{ "type": "getVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" } +{ "type": "ackVolumeConfigRequest", "deviceId": "设备 ID", "requestId": "请求 ID" } +``` + +B 端只能在请求有效期内提交容积配置;A 端确认接收后,server 删除请求和临时文件。 + +读取函数 2 参数: + +```json +{ "type": "getIdentificationConfig", "deviceId": "设备 ID" } +``` + +查询某个辨识 CSV 的结果: + +```json +{ + "type": "getIdentificationFeedback", + "deviceId": "设备 ID", + "runId": "CSV 文件名" +} +``` + +未评审时返回 `ready: false`;评审后返回 `ready: true` 和 `result: 0|1`。 +当结果为 0 时,A 端重新调用 `getIdentificationConfig` 获取已经更新的 CSV。 + +## 5. Server 部署 + +1. 配置 server 环境变量 `B_ADMIN_TOKEN`、`HOST`、`PORT` 和可选的 `DATA_DIR`。 +2. ControlPanel 与 ReinLoop 使用相同的 server URL 和设备 ID。 +3. Panel 不扫描文件目录,仅按设备 ID 消费 server 消息队列。 +4. CSV 处理完成后由 server 自动删除暂存文件。 + +## 6. B 端运行 + +PowerShell 环境变量: + +```powershell +$env:REINLOOP_API_URL="http://服务器地址:3000/api" +$env:B_ADMIN_TOKEN="与 server 相同的管理令牌" +$env:REINLOOP_DEVICE_ID="设备 ID" +$env:POLL_INTERVAL_MS="1000" +``` + +响应 A 端当前待处理的函数 1 参数请求: + +```powershell +node .\b-admin.js publish-volume .\volume-config.example.json +``` + +发布函数 2 参数: + +```powershell +node .\b-admin.js publish-identification .\identification-config.example.json +``` + +读取当前函数 2 参数: + +```powershell +node .\b-admin.js get-identification +``` + +启动监听与评审: + +```powershell +npm start +``` + +### Electron 图形界面 + +首次使用安装依赖: + +```powershell +cd ControlPanel +npm install +``` + +启动桌面应用: + +```powershell +npm run gui +``` + +图形界面提供以下功能: + +- 选择本地辨识 CSV,调用现有绘图模块生成并预览上下组合时序图。 +- 打开并预览已有 PNG/JPG 绘图结果。 +- 导入或直接编辑容积测量、系统辨识 JSON 配置。 +- 读取当前系统辨识配置并发布新配置。 +- 响应 ReinLoop 已发起的容积请求;没有待处理请求时拒绝上传。 +- 容积配置发布前强制校验 8 个字段、数值类型以及 `num_runs` 整数类型。 + +Server API URL 和 Admin Token 可以在界面顶部输入,也可以在启动应用前设置 +`REINLOOP_API_URL`、`B_ADMIN_TOKEN` 环境变量。Token 仅由 Electron 主进程用于请求, +不会保存到浏览器存储或配置文件。 + +### 打包 Windows EXE + +在 `ControlPanel` 目录执行: + +```powershell +npm install +npm run pack:win +``` + +构建结果输出到仓库根目录的 `Build` 文件夹: + +- 安装版 EXE:运行后可选择安装目录,并创建桌面和开始菜单快捷方式。 +- 便携版 EXE:无需安装,可直接运行。 +- `win-unpacked`:未压缩的应用目录,适合排查打包后的运行问题。 + +应用包含原生 `canvas` 绘图模块,打包配置会自动将它从 ASAR 中解包。不要手动删除 +`win-unpacked/resources/app.asar.unpacked`。未配置代码签名证书时,Windows 首次运行可能 +显示 SmartScreen 提示;正式对外分发时应配置可信的 Windows 代码签名证书。 + +## 7. 仍需产品/A 端确认 + +- A 端数组 JSON 的最终结构尚未定义;当前兼容数字数组、数值对象数组和对象内多个数值数组。 +- B→A 配置、A→B CSV、A→B 数组 JSON 都保存在 server 的 `DATA_DIR` 下。 +- 两套参数示例中的正式默认值、单位和合法范围尚未定义。 +- A 端上传稳定压力 JSON 与辨识 CSV 到 `<设备 ID>/ind_data`。 +- A 端按 CSV 文件名登记 `runId`,B 端以相同 `runId` 提交反馈。 +- 当前图像是否通过由 B 端人工判断;产品未提供自动判断算法或阈值。 + +## 8. 公司、产线、许可证与模型管理 + +Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID。Server 返回的 +`deviceId` 固定为 `/`,配置发布、模型目录、 +绘图收件箱和辨识反馈均使用同一个值。 + +应用启动时首先显示连接门禁页,只提供 Server API URL 和 Admin Token。点击 +“连接并校验”后,主进程调用需要管理权限的 `listOrganizations` 接口同时检查 +网络、API 地址和 Token;只有请求成功才显示公司、产线以及后续业务标签页。 +连接失败时业务区保持隐藏并显示 Server 返回的错误。本次应用会话不提供更改连接 +入口,需要切换 Server 或 Token 时重新启动应用。 + +公司与产线菜单会显示 `● 在线`、`○ 离线` 或 `◇ 状态未知`,并在连接成功后 +每 10 秒静默刷新。在线状态来自 `listOrganizations` 中每条产线的 `online` 字段, +可选的 `lastSeenAt` 用于 Server 判断心跳是否超时;旧 Server 未返回该字段时显示 +“状态未知”,不会误报在线。 + +组织管理页支持: + +- 添加公司,编码只允许小写字母、数字、下划线和连字符。 +- 在公司下添加产线;同一公司的产线编码必须唯一。 +- 刷新组织后,顶部公司和产线菜单同步更新。 + +许可证页支持: + +- 根据当前公司和产线签发许可证。 +- 每次签发由操作者选择外部 RSA 私钥和本地保存位置;Panel 不保存或上传私钥。 +- 许可证采用与 ReinLoop 相同的 RSA-PSS SHA-256 格式,并包含公司、产线和组合设备 ID。 +- 本地文件写入成功后才登记 Server;登记失败会删除本次本地文件,避免半完成状态。 +- 查看已签发许可证详情和撤销许可证。 + +模型管理页按当前产线列出 `/model_config`,支持上传、下载和删除。 + +绘图页收到辨识 CSV 后提供“通过/未通过”操作。选择未通过时必须先导入并成功 +发布一份新的系统辨识配置,随后才提交数字 `0`;通过则提交数字 `1`。CSV 在结论 +提交成功前不会从 Server 收件箱删除,应用中途退出后仍可重新获取。行程 JSON 在 +绘图成功后直接确认。 + +Panel 当前依赖以下新增 Server type: + +`listOrganizations`、`createCompany`、`createProductionLine`、`createLicense`、 +`listLicenses`、`getLicense`、`revokeLicense`、`listControlFiles`、 +`getControlFileDownload`、`deleteControlFile`。既有模型和反馈接口继续使用。 + +控制数据接口约定:`listControlFiles` 按 `deviceId` 分页返回控制结束后上传到 +`/data_record/` 的文件元数据;`getControlFileDownload` 按 `fileID` 返回带时效的 +下载 URL;`deleteControlFile` 按 `fileID` 删除文件和元数据。三者均应要求 B 端管理令牌,且 +服务端必须验证文件归属控制数据目录,避免使用该接口操作其他业务文件。 + +## 9. 已知依赖风险 + +依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的 `npm audit fix --force`,发布前应结合 Electron Builder 兼容性单独评估。 \ No newline at end of file diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js index 9da9e43..7f37785 100644 --- a/ControlPanel/electron-main.js +++ b/ControlPanel/electron-main.js @@ -1,332 +1,391 @@ -const fs = require("node:fs"); -const path = require("node:path"); -const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); -const { getConfig, publishConfig } = require("./b-admin"); -const { callServer, downloadFromUrl, downloadToPath } = require("./server-client"); -const { signLicense } = require("./license-manager"); -const { plotCsv } = require("./plot-csv"); -const { plotJson } = require("./plot-json"); - -const VOLUME_FIELDS = [ - "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 API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL; -const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000); -const connectionState = { - apiUrl: API_URL, - adminToken: process.env.B_ADMIN_TOKEN || "", - deviceId: process.env.REINLOOP_DEVICE_ID || "" -}; -let pendingReview = null; - -function startPanelInboxPoller(window) { - if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) { - window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500"); - return () => {}; - } - - let polling = false; - const poll = async () => { - if (polling || window.isDestroyed()) return; - if (!connectionState.deviceId || !connectionState.adminToken) return; - if (pendingReview) return; - const requestContext = { ...connectionState }; - polling = true; - try { - const pending = await callServer( - { type: "getPendingPanelFile", deviceId: requestContext.deviceId }, - requestContext - ); - if (!pending.pending) return; - const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext); - const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath)); - window.webContents.send("csv:updated", { - filePath: imagePath, - dataUrl: toDataUrl(imagePath), - fileName: pending.fileName, - mediaType: pending.mediaType, - uploadTime: pending.uploadTime, - deviceId: requestContext.deviceId, - reviewable: pending.mediaType === "csv" - }); - if (pending.mediaType === "csv") { - pendingReview = { - deviceId: requestContext.deviceId, - fileID: pending.fileID, - runId: pending.fileName, - credentials: requestContext - }; - } else { - await callServer({ - type: "ackPanelFile", - deviceId: requestContext.deviceId, - fileID: pending.fileID - }, requestContext); - } - } catch (error) { - window.webContents.send("csv:watch-error", error.message); - } finally { - polling = false; - } - }; - - void poll(); - const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS); - return () => clearInterval(timer); -} - -function createWindow() { - const window = new BrowserWindow({ - width: 1240, - height: 820, - minWidth: 960, - minHeight: 680, - backgroundColor: "#f2f4f1", - title: "ReinLoop B 端工作台", - webPreferences: { - preload: path.join(__dirname, "electron-preload.js"), - contextIsolation: true, - nodeIntegration: false, - sandbox: true - } - }); - - window.removeMenu(); - void window.loadFile(path.join(__dirname, "electron-ui", "index.html")); - window.webContents.once("did-finish-load", () => { - const stopPoller = startPanelInboxPoller(window); - window.once("closed", stopPoller); - }); -} - -function validateParameters(configType, parameters) { - if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { - throw new Error("配置必须是 JSON 对象"); - } - - if (configType === "volume") { - const fields = Object.keys(parameters); - const missing = VOLUME_FIELDS.filter((field) => !(field in parameters)); - const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field)); - if (missing.length || extra.length) { - throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`); - } - for (const field of VOLUME_FIELDS) { - if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) { - throw new Error(`${field} 必须是有效数字`); - } - } - if (!Number.isInteger(parameters.num_runs)) { - throw new Error("num_runs 必须是整数"); - } - } -} - -function toDataUrl(filePath) { - const extension = path.extname(filePath).toLowerCase(); - const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png"; - return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`; -} - -function registerHandlers() { - ipcMain.handle("app:get-defaults", () => ({ - apiUrl: API_URL, - deviceId: process.env.REINLOOP_DEVICE_ID || "", - hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN) - })); - - ipcMain.handle("image:show-in-folder", async (_event, filePath) => { - if (filePath) shell.showItemInFolder(path.resolve(filePath)); - }); - - ipcMain.handle("connection:set", (_event, request) => { - connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); - connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || ""); - connectionState.deviceId = String(request.deviceId || "").trim(); - return { success: true }; - }); - - ipcMain.handle("connection:test", async (_event, request) => { - const result = await callServer({ type: "listOrganizations" }, request); - connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); - connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || ""); - connectionState.deviceId = ""; - return result; - }); - - ipcMain.handle("organization:list", (_event, credentials) => - callServer({ type: "listOrganizations" }, credentials)); - ipcMain.handle("organization:create-company", (_event, request) => - callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials)); - ipcMain.handle("organization:create-line", (_event, request) => - callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials)); - - ipcMain.handle("license:issue", async (_event, request) => { - const keySelection = await dialog.showOpenDialog({ - title: "选择许可证 RSA 私钥", - properties: ["openFile"], - filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }] - }); - if (keySelection.canceled) return null; - const saveSelection = await dialog.showSaveDialog({ - title: "保存签发的许可证", - defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`, - filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }] - }); - if (saveSelection.canceled) return null; - const signed = signLicense({ - customer: request.customer, - company_id: request.companyId, - production_line_id: request.productionLineId, - device_id: request.deviceId, - issued: request.issued, - expiry: request.expiry, - features: request.features - }, keySelection.filePaths[0]); - await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 }); - try { - const result = await callServer({ - type: "createLicense", licenseId: signed.payload.license_id, - companyId: request.companyId, productionLineId: request.productionLineId, - customer: request.customer, issued: request.issued, expiry: request.expiry, - features: request.features, license: signed.content - }, request.credentials); - return { ...result, filePath: saveSelection.filePath }; - } catch (error) { - await fs.promises.rm(saveSelection.filePath, { force: true }); - throw error; - } - }); - ipcMain.handle("license:list", (_event, credentials) => - callServer({ type: "listLicenses" }, credentials)); - ipcMain.handle("license:get", (_event, request) => - callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials)); - ipcMain.handle("license:revoke", (_event, request) => - callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials)); - - ipcMain.handle("review:submit", async (_event, request) => { - if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) { - throw new Error("待审核记录已变化,请等待 Panel 重新载入数据"); - } - const result = await callServer({ - type: "setIdentificationFeedback", - deviceId: request.deviceId, - runId: request.runId, - result: request.result - }, request.credentials); - await callServer({ - type: "ackPanelFile", - deviceId: pendingReview.deviceId, - fileID: pendingReview.fileID - }, pendingReview.credentials); - pendingReview = null; - return result; - }); - - ipcMain.handle("model:list", (_event, request) => - callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials)); - ipcMain.handle("model:choose-upload-file", async () => { - const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] }); - if (selection.canceled) return null; - const sourcePath = selection.filePaths[0]; - return { sourcePath, fileName: path.basename(sourcePath) }; - }); - ipcMain.handle("model:upload", async (_event, request) => { - if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件"); - const sourcePath = path.resolve(request.sourcePath); - const fileName = path.basename(request.fileName); - await fs.promises.access(sourcePath, fs.constants.R_OK); - const issued = await callServer({ - type: "uploadDataFile", fileName, folder: `${request.deviceId}/model_config`, - overwrite: request.overwrite === true - }, request.credentials); - const form = new FormData(); - form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName); - const response = await fetch(issued.uploadMetadata.url, { method: "POST", body: form }); - if (![200, 204].includes(response.status)) throw new Error(`模型上传失败: HTTP ${response.status}`); - return { success: true, fileID: issued.fileID, fileName }; - }); - ipcMain.handle("model:download", async (_event, request) => { - const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials); - return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) }; - }); - ipcMain.handle("model:delete", (_event, request) => - callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials)); - - ipcMain.handle("identification:list", (_event, request) => - callServer({ - type: "listIdentificationFiles", - deviceId: request.deviceId, - mediaType: request.mediaType, - status: request.status, - page: request.page, - pageSize: request.pageSize - }, request.credentials)); - ipcMain.handle("identification:preview", async (_event, request) => { - const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials); - const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials); - const mediaType = download.mediaType || request.mediaType; - const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath)); - return { - filePath: imagePath, - dataUrl: toDataUrl(imagePath), - fileName: download.fileName || request.fileName, - mediaType, - uploadTime: download.uploadTime || request.uploadTime, - deviceId: request.deviceId - }; - }); - ipcMain.handle("identification:download", async (_event, request) => { - const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials); - const fileName = download.fileName || request.fileName; - const selection = await dialog.showSaveDialog({ - title: "保存辨识原始数据", - defaultPath: fileName, - filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }] - }); - if (selection.canceled) return null; - await downloadToPath(download.url, selection.filePath, request.credentials); - return { filePath: selection.filePath }; - }); - ipcMain.handle("identification:delete", (_event, request) => - callServer({ type: "deleteIdentificationFile", 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(); +const fs = require("node:fs"); +const path = require("node:path"); +const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); +const { getConfig, publishConfig } = require("./b-admin"); +const { callServer, downloadFromUrl, downloadToPath } = require("./server-client"); +const { signLicense } = require("./license-manager"); +const { plotCsv } = require("./plot-csv"); +const { plotJson } = require("./plot-json"); + +const VOLUME_FIELDS = [ + "q_in_val", "dt", "p_max", "fit_low", "fit_high", "T_delta", "xa_full", "num_runs" +]; +const DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com"; +const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL; +const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000); +const connectionState = { + apiUrl: API_URL, + adminToken: process.env.B_ADMIN_TOKEN || "", + deviceId: process.env.REINLOOP_DEVICE_ID || "" +}; +let pendingReview = null; + +function startPanelInboxPoller(window) { + if (!Number.isFinite(INBOX_POLL_INTERVAL_MS) || INBOX_POLL_INTERVAL_MS < 500) { + window.webContents.send("csv:watch-error", "POLL_INTERVAL_MS 必须大于或等于 500"); + return () => {}; + } + + let polling = false; + const poll = async () => { + if (polling || window.isDestroyed()) return; + if (!connectionState.deviceId || !connectionState.adminToken) return; + if (pendingReview) return; + const requestContext = { ...connectionState }; + polling = true; + try { + const pending = await callServer( + { type: "getPendingPanelFile", deviceId: requestContext.deviceId }, + requestContext + ); + if (!pending.pending) return; + const sourcePath = await downloadFromUrl(pending.url, pending.fileName, requestContext); + const imagePath = await (pending.mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath)); + window.webContents.send("csv:updated", { + filePath: imagePath, + dataUrl: toDataUrl(imagePath), + fileName: pending.fileName, + mediaType: pending.mediaType, + uploadTime: pending.uploadTime, + deviceId: requestContext.deviceId, + reviewable: pending.mediaType === "csv" + }); + if (pending.mediaType === "csv") { + pendingReview = { + deviceId: requestContext.deviceId, + fileID: pending.fileID, + runId: pending.fileName, + credentials: requestContext + }; + } else { + await callServer({ + type: "ackPanelFile", + deviceId: requestContext.deviceId, + fileID: pending.fileID + }, requestContext); + } + } catch (error) { + window.webContents.send("csv:watch-error", error.message); + } finally { + polling = false; + } + }; + + void poll(); + const timer = setInterval(poll, INBOX_POLL_INTERVAL_MS); + return () => clearInterval(timer); +} + +function createWindow() { + const window = new BrowserWindow({ + width: 1240, + height: 820, + minWidth: 960, + minHeight: 680, + backgroundColor: "#f2f4f1", + title: "ReinLoop B 端工作台", + webPreferences: { + preload: path.join(__dirname, "electron-preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true + } + }); + + window.removeMenu(); + void window.loadFile(path.join(__dirname, "electron-ui", "index.html")); + window.webContents.once("did-finish-load", () => { + const stopPoller = startPanelInboxPoller(window); + window.once("closed", stopPoller); + }); +} + +function validateParameters(configType, parameters) { + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { + throw new Error("配置必须是 JSON 对象"); + } + + if (configType === "volume") { + const fields = Object.keys(parameters); + const missing = VOLUME_FIELDS.filter((field) => !(field in parameters)); + const extra = fields.filter((field) => !VOLUME_FIELDS.includes(field)); + if (missing.length || extra.length) { + throw new Error(`容积配置字段不匹配。缺少: ${missing.join(", ") || "无"};多余: ${extra.join(", ") || "无"}`); + } + for (const field of VOLUME_FIELDS) { + if (typeof parameters[field] !== "number" || !Number.isFinite(parameters[field])) { + throw new Error(`${field} 必须是有效数字`); + } + } + if (!Number.isInteger(parameters.num_runs)) { + throw new Error("num_runs 必须是整数"); + } + } +} + +function toDataUrl(filePath) { + const extension = path.extname(filePath).toLowerCase(); + const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : "image/png"; + return `data:${mime};base64,${fs.readFileSync(filePath).toString("base64")}`; +} + +function registerHandlers() { + ipcMain.handle("app:get-defaults", () => ({ + apiUrl: API_URL, + deviceId: process.env.REINLOOP_DEVICE_ID || "", + hasAdminToken: Boolean(process.env.B_ADMIN_TOKEN) + })); + + ipcMain.handle("image:show-in-folder", async (_event, filePath) => { + if (filePath) shell.showItemInFolder(path.resolve(filePath)); + }); + + ipcMain.handle("connection:set", (_event, request) => { + connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); + connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || ""); + connectionState.deviceId = String(request.deviceId || "").trim(); + return { success: true }; + }); + + ipcMain.handle("connection:test", async (_event, request) => { + const result = await callServer({ type: "listOrganizations" }, request); + connectionState.apiUrl = String(request.apiUrl || API_URL).trim(); + connectionState.adminToken = String(request.adminToken || process.env.B_ADMIN_TOKEN || ""); + connectionState.deviceId = ""; + return result; + }); + + ipcMain.handle("organization:list", (_event, credentials) => + callServer({ type: "listOrganizations" }, credentials)); + ipcMain.handle("organization:create-company", (_event, request) => + callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials)); + ipcMain.handle("organization:create-line", (_event, request) => + callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials)); + + ipcMain.handle("license:issue", async (_event, request) => { + const keySelection = await dialog.showOpenDialog({ + title: "选择许可证 RSA 私钥", + properties: ["openFile"], + filters: [{ name: "PEM 私钥", extensions: ["pem", "key"] }] + }); + if (keySelection.canceled) return null; + const saveSelection = await dialog.showSaveDialog({ + title: "保存签发的许可证", + defaultPath: `${request.companyCode}-${request.lineCode}-license.lic`, + filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }] + }); + if (saveSelection.canceled) return null; + const signed = signLicense({ + customer: request.customer, + company_id: request.companyId, + production_line_id: request.productionLineId, + device_id: request.deviceId, + issued: request.issued, + expiry: request.expiry, + features: request.features + }, keySelection.filePaths[0]); + await fs.promises.writeFile(saveSelection.filePath, signed.content, { encoding: "utf8", mode: 0o600 }); + try { + const result = await callServer({ + type: "createLicense", licenseId: signed.payload.license_id, + companyId: request.companyId, productionLineId: request.productionLineId, + customer: request.customer, issued: request.issued, expiry: request.expiry, + features: request.features, license: signed.content + }, request.credentials); + return { ...result, filePath: saveSelection.filePath }; + } catch (error) { + await fs.promises.rm(saveSelection.filePath, { force: true }); + throw error; + } + }); + ipcMain.handle("license:list", (_event, credentials) => + callServer({ type: "listLicenses" }, credentials)); + ipcMain.handle("license:get", (_event, request) => + callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials)); + ipcMain.handle("license:revoke", (_event, request) => + callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials)); + ipcMain.handle("license:download", async (_event, request) => { + const result = await callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials); + if (typeof result.license?.license !== "string" || !result.license.license) { + throw new Error("Server 未返回许可证原文,无法下载"); + } + const selection = await dialog.showSaveDialog({ + title: "保存许可证", + defaultPath: `${request.licenseId}.lic`, + filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }] + }); + if (selection.canceled) return null; + await fs.promises.writeFile(selection.filePath, result.license.license, { encoding: "utf8", mode: 0o600 }); + return { filePath: selection.filePath }; + }); + + ipcMain.handle("review:submit", async (_event, request) => { + if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) { + throw new Error("待审核记录已变化,请等待 Panel 重新载入数据"); + } + const result = await callServer({ + type: "setIdentificationFeedback", + deviceId: request.deviceId, + runId: request.runId, + result: request.result + }, request.credentials); + await callServer({ + type: "ackPanelFile", + deviceId: pendingReview.deviceId, + fileID: pendingReview.fileID + }, pendingReview.credentials); + pendingReview = null; + return result; + }); + + ipcMain.handle("model:list", (_event, request) => + callServer({ type: "listModels", folder: `${request.deviceId}/model_config` }, request.credentials)); + ipcMain.handle("model:choose-upload-file", async () => { + const selection = await dialog.showOpenDialog({ title: "选择模型文件", properties: ["openFile"] }); + if (selection.canceled) return null; + const sourcePath = selection.filePaths[0]; + return { sourcePath, fileName: path.basename(sourcePath) }; + }); + ipcMain.handle("model:upload", async (_event, request) => { + if (!request.sourcePath || !request.fileName) throw new Error("请先选择模型文件"); + const sourcePath = path.resolve(request.sourcePath); + const fileName = path.basename(request.fileName); + await fs.promises.access(sourcePath, fs.constants.R_OK); + const issued = await callServer({ + type: "uploadDataFile", fileName, folder: `${request.deviceId}/model_config`, + overwrite: request.overwrite === true + }, request.credentials); + const form = new FormData(); + form.append("file", new Blob([await fs.promises.readFile(sourcePath)]), fileName); + const response = await fetch(issued.uploadMetadata.url, { method: "POST", body: form }); + if (![200, 204].includes(response.status)) throw new Error(`模型上传失败: HTTP ${response.status}`); + return { success: true, fileID: issued.fileID, fileName }; + }); + ipcMain.handle("model:download", async (_event, request) => { + const result = await callServer({ type: "downloadModel", fileID: request.fileID }, request.credentials); + return { filePath: await downloadFromUrl(result.url, request.fileName, request.credentials) }; + }); + ipcMain.handle("model:delete", (_event, request) => + callServer({ type: "deleteFile", fileID: request.fileID }, request.credentials)); + + ipcMain.handle("identification:list", (_event, request) => + callServer({ + type: "listIdentificationFiles", + deviceId: request.deviceId, + mediaType: request.mediaType, + status: request.status, + page: request.page, + pageSize: request.pageSize + }, request.credentials)); + ipcMain.handle("identification:preview", async (_event, request) => { + const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials); + const sourcePath = await downloadFromUrl(download.url, download.fileName || request.fileName, request.credentials); + const mediaType = download.mediaType || request.mediaType; + const imagePath = await (mediaType === "json" ? plotJson(sourcePath) : plotCsv(sourcePath)); + return { + filePath: imagePath, + dataUrl: toDataUrl(imagePath), + fileName: download.fileName || request.fileName, + mediaType, + uploadTime: download.uploadTime || request.uploadTime, + deviceId: request.deviceId + }; + }); + ipcMain.handle("identification:download", async (_event, request) => { + const download = await callServer({ type: "getIdentificationFileDownload", fileID: request.fileID }, request.credentials); + const fileName = download.fileName || request.fileName; + const selection = await dialog.showSaveDialog({ + title: "保存辨识原始数据", + defaultPath: fileName, + filters: [{ name: request.mediaType === "json" ? "JSON 文件" : "CSV 文件", extensions: [request.mediaType === "json" ? "json" : "csv"] }] + }); + if (selection.canceled) return null; + await downloadToPath(download.url, selection.filePath, request.credentials); + return { filePath: selection.filePath }; + }); + ipcMain.handle("identification:delete", (_event, request) => + callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials)); + + ipcMain.handle("control-data:list", (_event, request) => + callServer({ + type: "listControlFiles", + deviceId: request.deviceId, + page: request.page, + pageSize: request.pageSize + }, request.credentials)); + ipcMain.handle("control-data:preview", async (_event, request) => { + const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials); + const fileName = download.fileName || request.fileName; + const sourcePath = await downloadFromUrl(download.url, fileName, request.credentials); + const extension = path.extname(fileName).toLowerCase(); + let content = null; + if (extension === ".json") { + const text = await fs.promises.readFile(sourcePath, "utf8"); + try { + content = JSON.stringify(JSON.parse(text), null, 2); + } catch (_error) { + content = text; + } + } + return { + 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(); }); \ No newline at end of file diff --git a/ControlPanel/electron-preload.js b/ControlPanel/electron-preload.js index 93ce17e..d89a11e 100644 --- a/ControlPanel/electron-preload.js +++ b/ControlPanel/electron-preload.js @@ -1,30 +1,35 @@ -const { contextBridge, ipcRenderer } = require("electron"); - -contextBridge.exposeInMainWorld("reinloop", { - getDefaults: () => ipcRenderer.invoke("app:get-defaults"), - showInFolder: (filePath) => ipcRenderer.invoke("image:show-in-folder", filePath), - chooseConfig: () => ipcRenderer.invoke("config:choose"), - publishConfig: (request) => ipcRenderer.invoke("config:publish", request), - getConfig: (request) => ipcRenderer.invoke("config:get", request), - setConnection: (request) => ipcRenderer.invoke("connection:set", request), - testConnection: (request) => ipcRenderer.invoke("connection:test", request), - listOrganizations: (credentials) => ipcRenderer.invoke("organization:list", credentials), - createCompany: (request) => ipcRenderer.invoke("organization:create-company", request), - createProductionLine: (request) => ipcRenderer.invoke("organization:create-line", request), - issueLicense: (request) => ipcRenderer.invoke("license:issue", request), - listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials), - getLicense: (request) => ipcRenderer.invoke("license:get", request), - revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request), - submitReview: (request) => ipcRenderer.invoke("review:submit", request), - listModels: (request) => ipcRenderer.invoke("model:list", request), - chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"), - uploadModel: (request) => ipcRenderer.invoke("model:upload", request), - downloadModel: (request) => ipcRenderer.invoke("model:download", request), - deleteModel: (request) => ipcRenderer.invoke("model:delete", request), - listIdentificationFiles: (request) => ipcRenderer.invoke("identification:list", request), - previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request), - downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request), - deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request), - onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)), - onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message)) +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("reinloop", { + getDefaults: () => ipcRenderer.invoke("app:get-defaults"), + showInFolder: (filePath) => ipcRenderer.invoke("image:show-in-folder", filePath), + chooseConfig: () => ipcRenderer.invoke("config:choose"), + publishConfig: (request) => ipcRenderer.invoke("config:publish", request), + getConfig: (request) => ipcRenderer.invoke("config:get", request), + setConnection: (request) => ipcRenderer.invoke("connection:set", request), + testConnection: (request) => ipcRenderer.invoke("connection:test", request), + listOrganizations: (credentials) => ipcRenderer.invoke("organization:list", credentials), + createCompany: (request) => ipcRenderer.invoke("organization:create-company", request), + createProductionLine: (request) => ipcRenderer.invoke("organization:create-line", request), + issueLicense: (request) => ipcRenderer.invoke("license:issue", request), + listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials), + getLicense: (request) => ipcRenderer.invoke("license:get", request), + revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request), + downloadLicense: (request) => ipcRenderer.invoke("license:download", request), + submitReview: (request) => ipcRenderer.invoke("review:submit", request), + listModels: (request) => ipcRenderer.invoke("model:list", request), + chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"), + uploadModel: (request) => ipcRenderer.invoke("model:upload", request), + downloadModel: (request) => ipcRenderer.invoke("model:download", request), + deleteModel: (request) => ipcRenderer.invoke("model:delete", request), + listIdentificationFiles: (request) => ipcRenderer.invoke("identification:list", request), + previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request), + downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request), + deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request), + 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)) }); \ No newline at end of file diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html index ce22998..b8a67e2 100644 --- a/ControlPanel/electron-ui/index.html +++ b/ControlPanel/electron-ui/index.html @@ -1,219 +1,232 @@ - - - - - - - ReinLoop B 端工作台 - - - -
-
-

REINLOOP / B CONSOLE

-

数据评审工作台

-
-
未连接
-
- -
-
-

SERVER ACCESS

-

连接管理服务

- - - -

校验通过后开放业务工作台

-
-
- -
-
- - -
- - - -
-
-
-

IDENTIFICATION REVIEW

-

数据曲线

-
-
- 等待辨识结果 - - -
-
-
-
-
-
- 辨识 CSV - 阀门开度与压力 -
-
-
-
-
- 尚未载入辨识曲线 - 等待 ReinLoop 上传 CSV -
- -
-
-

未接收 CSV

-

等待数据

-
-
-
-
-
- 行程 JSON - 行程与稳态压力 -
-
-
-
-
- 尚未载入行程曲线 - 等待 ReinLoop 上传行程 JSON -
- -
-
-

未接收行程 JSON

-

等待数据

-
-
-
-
- -
-
-

IDENTIFICATION ARCHIVE

辨识数据暂存

- -
-
-
上传时间文件名类型大小状态操作
-

选择公司和产线后刷新辨识数据

-
-
- -
-
-
-

FUNCTION PARAMETERS

-

配置数据

-
-
- - -
-
-
-
-
- 容积测量配置 - -
- -

可直接编辑,或从本地 JSON 导入

-
- -
-
- -
-
-

MODEL CONTROL

产线模型

-
- - -
-
-
-
文件名上传时间大小操作
-

选择公司和产线后刷新模型列表

-
-
- -
-
-

LICENSE REGISTRY

许可证签发与管理

- -
-
-
-

签发许可证

- - - - - -

许可证保存到本地后同步登记到 Server;私钥不会上传或保存。

-
-
-
公司 / 产线有效期状态操作
-

尚未读取许可证

- -
-
-
- -
-
-

ORGANIZATION

公司与产线

- -
-
-
-

添加公司

- - - -
-
-

添加产线

- - - - -
-
-
-
- - - + + + + + + + ReinLoop B 端工作台 + + + +
+
+

REINLOOP / B CONSOLE

+

数据评审工作台

+
+
未连接
+
+ +
+
+

SERVER ACCESS

+

连接管理服务

+ + + +

校验通过后开放业务工作台

+
+
+ +
+
+ + +
+ + + +
+
+
+

IDENTIFICATION REVIEW

+

数据曲线

+
+
+ 等待辨识结果 + + +
+
+
+
+
+
+ 辨识 CSV + 阀门开度与压力 +
+
+
+
+
+ 尚未载入辨识曲线 + 等待 ReinLoop 上传 CSV +
+ +
+
+

未接收 CSV

+

等待数据

+
+
+
+
+
+ 行程 JSON + 行程与稳态压力 +
+
+
+
+
+ 尚未载入行程曲线 + 等待 ReinLoop 上传行程 JSON +
+ +
+
+

未接收行程 JSON

+

等待数据

+
+
+
+
+ +
+
+

IDENTIFICATION ARCHIVE

辨识数据暂存

+ +
+
+
上传时间文件名类型大小状态操作
+

选择公司和产线后刷新辨识数据

+
+
+ +
+
+

CONTROL DATA ARCHIVE

控制数据暂存

+ +
+
+
上传时间文件名类型大小操作
+

选择公司和产线后刷新控制数据

+ +
+
+ +
+
+
+

FUNCTION PARAMETERS

+

配置数据

+
+
+ + +
+
+
+
+
+ 容积测量配置 + +
+ +

可直接编辑,或从本地 JSON 导入

+
+ +
+
+ +
+
+

MODEL CONTROL

产线模型

+
+ + +
+
+
+
文件名上传时间大小操作
+

选择公司和产线后刷新模型列表

+
+
+ +
+
+

LICENSE REGISTRY

许可证签发与管理

+ +
+
+
+

签发许可证

+ + + + + +

许可证保存到本地后同步登记到 Server;私钥不会上传或保存。

+
+
+
公司 / 产线有效期状态操作
+

尚未读取许可证

+ +
+
+
+ +
+
+

ORGANIZATION

公司与产线

+ +
+
+
+

添加公司

+ + + +
+
+

添加产线

+ + + + +
+
+
+
+ + + \ No newline at end of file diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index 4b2bcb8..fb73af2 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -1,716 +1,833 @@ -const volumeExample = { - q_in_val: 91, - dt: 0.1, - p_max: 200, - fit_low: 50, - fit_high: 200, - T_delta: 30, - xa_full: 1000, - num_runs: 6 -}; - -const identificationExample = { - q_in_val: 91, - dt: 0.1, - n_order: 8, - t_c: 2.5, - levels: [10, 20, 30, 40, 50, 60, 70, 80], - dead_area: 0, - xa_full: 1000, - V_val: 1, - repeat: 2 -}; - -const state = { - configType: "volume", - imagePaths: { csv: null, json: null }, - imageDataUrls: { csv: null, json: null }, - identificationFiles: [], - configs: { volume: volumeExample, identification: identificationExample }, - companies: [], - models: [], - defaultDeviceId: "", - review: null, - retryDeviceId: null, - lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null }, - connected: false -}; - -const elements = { - status: document.querySelector("#status"), - connectionScreen: document.querySelector("#connection-screen"), - connectionForm: document.querySelector("#connection-form"), - connectionMessage: document.querySelector("#connection-message"), - connectButton: document.querySelector("#connect-button"), - workspace: document.querySelector("#workspace"), - apiUrl: document.querySelector("#api-url"), - adminToken: document.querySelector("#admin-token"), - companySelect: document.querySelector("#company-select"), - deviceId: document.querySelector("#device-id"), - plots: { - csv: { - image: document.querySelector("#csv-plot-image"), - empty: document.querySelector("#empty-csv-plot"), - path: document.querySelector("#csv-image-path"), - uploadTime: document.querySelector("#csv-upload-time"), - showImage: document.querySelector("#show-csv-image"), - openImage: document.querySelector('[data-open-plot="csv"]') - }, - json: { - image: document.querySelector("#json-plot-image"), - empty: document.querySelector("#empty-json-plot"), - path: document.querySelector("#json-image-path"), - uploadTime: document.querySelector("#json-upload-time"), - showImage: document.querySelector("#show-json-image"), - openImage: document.querySelector('[data-open-plot="json"]') - } - }, - configEditor: document.querySelector("#config-editor"), - configLabel: document.querySelector("#config-label"), - configPath: document.querySelector("#config-path"), - publishTarget: document.querySelector("#publish-target"), - publishButton: document.querySelector("#publish-config"), - publishResult: document.querySelector("#publish-result"), - lineCompany: document.querySelector("#line-company"), - licenseTarget: document.querySelector("#license-target"), - licenseList: document.querySelector("#license-list"), - licenseEmpty: document.querySelector("#license-empty"), - licenseDetail: document.querySelector("#license-detail"), - modelList: document.querySelector("#model-list"), - modelEmpty: document.querySelector("#model-empty"), - identificationFileList: document.querySelector("#identification-file-list"), - identificationFileEmpty: document.querySelector("#identification-file-empty"), - reviewTarget: document.querySelector("#review-target"), - approveReview: document.querySelector("#approve-review"), - rejectReview: document.querySelector("#reject-review"), - lightbox: document.querySelector("#image-lightbox"), - lightboxTitle: document.querySelector("#lightbox-title"), - lightboxImage: document.querySelector("#lightbox-image"), - lightboxCanvas: document.querySelector("#lightbox-canvas"), - zoomIn: document.querySelector("#zoom-in"), - zoomOut: document.querySelector("#zoom-out"), - zoomFit: document.querySelector("#zoom-fit"), - zoomReset: document.querySelector("#zoom-reset"), - closeLightbox: document.querySelector("#close-lightbox") -}; - -function setStatus(message, tone = "idle") { - elements.status.textContent = message; - elements.status.dataset.tone = tone; -} - -function credentials() { - return { - apiUrl: elements.apiUrl.value.trim(), - adminToken: elements.adminToken.value, - deviceId: elements.deviceId.value.trim() - }; -} - -function selectedCompany() { - return state.companies.find((company) => company.id === elements.companySelect.value); -} - -function selectedLine() { - const company = selectedCompany(); - return company?.productionLines.find((line) => line.deviceId === elements.deviceId.value); -} - -function formatLicenseDate(value) { - return value.replace("T", " "); -} - -function formatSize(value) { - if (!Number.isFinite(value)) return "-"; - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; - return `${(value / 1024 / 1024).toFixed(1)} MB`; -} - -function escapeHtml(value) { - const node = document.createElement("span"); - node.textContent = String(value ?? ""); - return node.innerHTML; -} - -async function syncConnection() { - await window.reinloop.setConnection(credentials()); -} - -function showConnectionError(error) { - const message = (error?.message || String(error)) - .replace(/^Error invoking remote method '[^']+': Error: /, ""); - elements.connectionMessage.textContent = message; - elements.connectionMessage.dataset.tone = "error"; - setStatus("连接失败", "error"); -} - -async function connectWorkspace() { - const apiUrl = elements.apiUrl.value.trim(); - if (!apiUrl) return showConnectionError(new Error("请输入 Server API URL")); - elements.connectButton.disabled = true; - elements.connectionMessage.textContent = "正在校验 Server 与 Admin Token"; - elements.connectionMessage.dataset.tone = "busy"; - setStatus("正在连接", "busy"); - try { - const result = await window.reinloop.testConnection({ - apiUrl, - adminToken: elements.adminToken.value - }); - state.companies = result.companies; - renderOrganizationOptions(); - elements.connectionScreen.hidden = true; - elements.workspace.hidden = false; - state.connected = true; - delete elements.connectionMessage.dataset.tone; - setStatus("连接成功", "success"); - } catch (error) { - showConnectionError(error); - } finally { - elements.connectButton.disabled = false; - } -} - -function renderOrganizationOptions() { - const previousCompany = elements.companySelect.value; - const companyOptions = state.companies.map((company) => { - const lines = company.productionLines || []; - const onlineCount = lines.filter((line) => line.online === true).length; - const hasKnownStatus = lines.some((line) => typeof line.online === "boolean"); - const statusSummary = !lines.length - ? " · 暂无产线" - : hasKnownStatus - ? `${onlineCount > 0 ? " · ● 在线" : " · ○ 离线"} (${onlineCount}/${lines.length})` - : " · ◇ 状态未知"; - return ``; - }).join(""); - elements.companySelect.innerHTML = `${companyOptions}`; - elements.lineCompany.innerHTML = `${companyOptions}`; - if (state.companies.some((company) => company.id === previousCompany)) { - elements.companySelect.value = previousCompany; - } else if (state.defaultDeviceId) { - const defaultCompany = state.companies.find((company) => - company.productionLines.some((line) => line.deviceId === state.defaultDeviceId)); - if (defaultCompany) elements.companySelect.value = defaultCompany.id; - } - renderLineOptions(); -} - -function renderLineOptions() { - const company = selectedCompany(); - const previousDevice = elements.deviceId.value; - const lines = company?.productionLines || []; - elements.deviceId.innerHTML = `${lines.map((line) => - `` - ).join("")}`; - if (lines.some((line) => line.deviceId === previousDevice)) { - elements.deviceId.value = previousDevice; - } else if (lines.some((line) => line.deviceId === state.defaultDeviceId)) { - elements.deviceId.value = state.defaultDeviceId; - } - updateSelectedTarget(); -} - -function updateSelectedTarget() { - const company = selectedCompany(); - const line = selectedLine(); - elements.licenseTarget.value = company && line ? `${company.name} / ${line.name}` : ""; - void syncConnection(); -} - -async function refreshOrganizations(silent = false) { - if (silent) { - try { - const result = await window.reinloop.listOrganizations(credentials()); - state.companies = result.companies; - renderOrganizationOptions(); - } catch (_error) { - // Keep the last known status; explicit operations still report errors. - } - return; - } - const result = await runBusy("正在读取组织", () => window.reinloop.listOrganizations(credentials())); - if (!result) return; - state.companies = result.companies; - renderOrganizationOptions(); - setStatus("组织已刷新", "success"); -} - -async function refreshLicenses() { - const result = await runBusy("正在读取许可证", () => window.reinloop.listLicenses(credentials())); - if (!result) return; - elements.licenseList.innerHTML = result.licenses.map((license) => ` - ${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)} - ${escapeHtml(license.expiry)}${license.status === "active" ? "有效" : "已撤销"} - ${license.status === "active" ? `` : ""} - `).join(""); - elements.licenseEmpty.hidden = result.licenses.length > 0; - setStatus("许可证已刷新", "success"); -} - -async function refreshModels() { - if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); - const result = await runBusy("正在读取模型", () => window.reinloop.listModels({ - deviceId: elements.deviceId.value, credentials: credentials() - })); - if (!result) return; - state.models = result.fileList; - elements.modelList.innerHTML = state.models.map((model) => ` - ${escapeHtml(model.fileName)}${escapeHtml(model.uploadTime || "-")} - ${formatSize(model.size)} - `).join(""); - elements.modelEmpty.hidden = state.models.length > 0; - setStatus("模型已刷新", "success"); -} - -async function refreshIdentificationFiles() { - if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); - const result = await runBusy("正在读取辨识暂存数据", () => window.reinloop.listIdentificationFiles({ - deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials() - })); - if (!result) return; - state.identificationFiles = result.files || result.fileList || []; - elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => ` - ${escapeHtml(file.uploadTime || "-")}${escapeHtml(file.fileName)} - ${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}${formatSize(file.size)} - ${file.status === "processed" ? "已处理" : "待处理"} - - `).join(""); - elements.identificationFileEmpty.hidden = state.identificationFiles.length > 0; - setStatus("辨识数据已刷新", "success"); -} - -function showError(error) { - const message = (error?.message || String(error)) - .replace(/^Error invoking remote method '[^']+': Error: /, ""); - setStatus(message, "error"); - elements.status.title = message; - elements.publishResult.textContent = message; - elements.publishResult.dataset.tone = "error"; -} - -function showImage(result) { - if (!result) return; - const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json") - ? "json" - : "csv"; - const plot = elements.plots[mediaType]; - state.imagePaths[mediaType] = result.filePath; - state.imageDataUrls[mediaType] = result.dataUrl; - plot.image.src = result.dataUrl; - plot.image.hidden = false; - plot.empty.hidden = true; - plot.path.textContent = result.filePath; - if (result.uploadTime) { - const uploadedAt = new Date(result.uploadTime); - plot.uploadTime.textContent = `上传时间:${uploadedAt.toLocaleString("zh-CN", { hour12: false })}`; - } else { - plot.uploadTime.textContent = "已接收"; - } - plot.showImage.disabled = false; - plot.openImage.disabled = false; - if (mediaType === "csv" && result.reviewable) { - state.review = { runId: result.fileName, deviceId: result.deviceId }; - elements.reviewTarget.textContent = result.fileName; - elements.approveReview.disabled = false; - elements.rejectReview.disabled = false; - } - setStatus("图片已载入", "success"); -} - -function updateLightboxImage() { - const image = elements.lightboxImage; - if (state.lightbox.fit) { - image.classList.add("fit-image"); - image.style.width = ""; - return; - } - image.classList.remove("fit-image"); - if (image.complete && image.naturalWidth) image.style.width = `${Math.round(image.naturalWidth * state.lightbox.scale)}px`; -} - -function openLightbox(mediaType) { - const source = state.imageDataUrls[mediaType]; - if (!source) return; - state.lightbox.mediaType = mediaType; - state.lightbox.scale = 1; - state.lightbox.fit = true; - state.lightbox.previousFocus = document.activeElement; - elements.lightboxTitle.textContent = mediaType === "json" ? "行程稳态压力图" : "辨识 CSV 图"; - elements.lightboxImage.src = source; - elements.lightboxImage.onload = updateLightboxImage; - elements.lightbox.hidden = false; - elements.closeLightbox.focus(); -} - -function closeLightbox() { - if (elements.lightbox.hidden) return; - elements.lightbox.hidden = true; - elements.lightboxImage.removeAttribute("src"); - state.lightbox.previousFocus?.focus(); - state.lightbox.previousFocus = null; -} - -function zoomLightbox(direction) { - state.lightbox.fit = false; - state.lightbox.scale = Math.min(4, Math.max(0.25, state.lightbox.scale * direction)); - updateLightboxImage(); -} - -function finishReview(result) { - state.review = null; - elements.reviewTarget.textContent = result === 1 ? "已提交:通过" : "已提交:未通过"; - elements.approveReview.disabled = true; - elements.rejectReview.disabled = true; -} - -function activateTab(target) { - document.querySelectorAll(".tab").forEach((item) => item.classList.toggle("active", item.dataset.target === target)); - document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === target)); -} - -function activateConfigType(configType) { - try { - state.configs[state.configType] = JSON.parse(elements.configEditor.value); - } catch (_error) { - // Keep the last valid configuration when changing views. - } - state.configType = configType; - document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType)); - renderConfig(); -} - -function switchToIdentificationConfigForRetry() { - if (!state.review) return; - state.retryDeviceId = state.review.deviceId; - activateTab("config-panel"); - activateConfigType("identification"); - elements.publishResult.textContent = `本轮辨识未通过。请检查配置后发布到设备 ${state.retryDeviceId},发布成功后将自动要求重测。`; - elements.publishResult.dataset.tone = "error"; - setStatus("请重新发布辨识配置", "busy"); -} - -async function submitReview(result) { - if (!state.review) return; - if (result === 0) return switchToIdentificationConfigForRetry(); - const review = state.review; - const submitted = await runBusy("正在提交辨识结论", () => window.reinloop.submitReview({ - deviceId: review.deviceId, - runId: review.runId, - result, - credentials: credentials() - })); - if (submitted) { - finishReview(result); - state.retryDeviceId = null; - setStatus("辨识结果已通过", "success"); - } -} - -async function submitRetryReview() { - if (!state.review || !state.retryDeviceId) return; - const review = state.review; - const submitted = await runBusy("正在提交未通过结论", () => window.reinloop.submitReview({ - deviceId: review.deviceId, runId: review.runId, result: 0, credentials: credentials() - })); - if (!submitted) return; - finishReview(0); - state.retryDeviceId = null; - setStatus("新配置已发布,已要求设备重测", "success"); -} - -function renderConfig() { - const isVolume = state.configType === "volume"; - elements.configEditor.value = JSON.stringify(state.configs[state.configType], null, 2); - elements.configLabel.textContent = isVolume ? "容积测量配置" : "系统辨识配置"; - elements.publishTarget.textContent = isVolume ? "Server 配置文件" : "设备辨识配置"; - elements.publishButton.textContent = isVolume ? "上传容积配置" : "发布辨识配置"; - elements.configPath.textContent = "可直接编辑,或从本地 JSON 导入"; - elements.publishResult.textContent = "等待操作"; - delete elements.publishResult.dataset.tone; -} - -async function runBusy(label, action) { - setStatus(label, "busy"); - try { - return await action(); - } catch (error) { - showError(error); - return null; - } -} - -document.querySelectorAll(".tab").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.target))); - -document.querySelectorAll(".segment").forEach((button) => button.addEventListener("click", () => activateConfigType(button.dataset.type))); - -Object.entries(elements.plots).forEach(([mediaType, plot]) => { - plot.showImage.addEventListener("click", () => window.reinloop.showInFolder(state.imagePaths[mediaType])); - plot.openImage.addEventListener("click", () => openLightbox(mediaType)); -}); - -elements.approveReview.addEventListener("click", () => void submitReview(1)); -elements.rejectReview.addEventListener("click", () => void submitReview(0)); - -elements.companySelect.addEventListener("change", renderLineOptions); -elements.deviceId.addEventListener("change", updateSelectedTarget); -elements.connectionForm.addEventListener("submit", (event) => { - event.preventDefault(); - void connectWorkspace(); -}); - -document.querySelector("#refresh-organizations").addEventListener("click", refreshOrganizations); -document.querySelector("#company-form").addEventListener("submit", async (event) => { - event.preventDefault(); - const result = await runBusy("正在添加公司", () => window.reinloop.createCompany({ - name: document.querySelector("#company-name").value, - code: document.querySelector("#company-code").value, - credentials: credentials() - })); - if (!result) return; - event.target.reset(); - await refreshOrganizations(); -}); -document.querySelector("#line-form").addEventListener("submit", async (event) => { - event.preventDefault(); - const result = await runBusy("正在添加产线", () => window.reinloop.createProductionLine({ - companyId: elements.lineCompany.value, - name: document.querySelector("#line-name").value, - code: document.querySelector("#line-code").value, - credentials: credentials() - })); - if (!result) return; - event.target.reset(); - await refreshOrganizations(); -}); - -document.querySelector("#license-form").addEventListener("submit", async (event) => { - event.preventDefault(); - const company = selectedCompany(); - const line = selectedLine(); - if (!company || !line) return showError(new Error("请先选择公司和产线")); - const result = await runBusy("正在签发许可证", () => window.reinloop.issueLicense({ - companyId: company.id, companyCode: company.code, customer: company.name, - productionLineId: line.id, lineCode: line.code, deviceId: line.deviceId, - issued: formatLicenseDate(document.querySelector("#license-issued").value), - expiry: formatLicenseDate(document.querySelector("#license-expiry").value), - features: document.querySelector("#license-features").value, - credentials: credentials() - })); - if (!result) return; - setStatus(`许可证已保存: ${result.filePath}`, "success"); - await refreshLicenses(); -}); -document.querySelector("#refresh-licenses").addEventListener("click", refreshLicenses); -elements.licenseList.addEventListener("click", async (event) => { - const detailId = event.target.dataset.licenseDetail; - const revokeId = event.target.dataset.licenseRevoke; - if (detailId) { - const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() })); - if (result) { - elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2); - elements.licenseDetail.hidden = false; - } - } - if (revokeId) { - const reason = window.prompt("请输入撤销原因", "管理员撤销"); - if (reason === null) return; - const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ licenseId: revokeId, reason, credentials: credentials() })); - if (result) await refreshLicenses(); - } -}); - -document.querySelector("#refresh-models").addEventListener("click", refreshModels); -document.querySelector("#upload-model").addEventListener("click", async () => { - if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); - const button = document.querySelector("#upload-model"); - button.disabled = true; - try { - const models = await runBusy("正在读取模型", () => window.reinloop.listModels({ - deviceId: elements.deviceId.value, credentials: credentials() - })); - if (!models) return; - state.models = models.fileList; - const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile()); - if (!selected) return; - const existing = state.models.find((model) => model.fileName === selected.fileName); - let overwrite = false; - if (existing) { - if (!window.confirm(`已存在同名模型 ${selected.fileName},覆盖后无法恢复。是否继续?`)) return; - const confirmation = window.prompt(`请输入完整文件名以确认覆盖:${selected.fileName}`); - if (confirmation !== selected.fileName) { - setStatus("文件名不匹配,已取消覆盖", "idle"); - return; - } - overwrite = true; - } - const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({ - deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName, - overwrite, credentials: credentials() - })); - if (result) { - setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success"); - await refreshModels(); - } - } finally { - button.disabled = false; - } -}); -elements.modelList.addEventListener("click", async (event) => { - const fileID = event.target.dataset.modelDownload || event.target.dataset.modelDelete; - if (!fileID) return; - const model = state.models.find((item) => item.fileID === fileID); - if (event.target.dataset.modelDownload) { - const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() })); - if (result) setStatus(`模型已下载: ${result.filePath}`, "success"); - } else { - if (!window.confirm(`确认删除模型 ${model.fileName}?`)) return; - const confirmation = window.prompt(`删除不可恢复。请输入完整文件名以确认:${model.fileName}`); - if (confirmation !== model.fileName) { - setStatus("文件名不匹配,已取消删除", "idle"); - return; - } - event.target.disabled = true; - try { - const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() })); - if (result) await refreshModels(); - } finally { - event.target.disabled = false; - } - } -}); - -document.querySelector("#refresh-identification-files").addEventListener("click", refreshIdentificationFiles); -elements.identificationFileList.addEventListener("click", async (event) => { - const fileID = event.target.dataset.identificationPreview || event.target.dataset.identificationDownload || event.target.dataset.identificationDelete; - if (!fileID) return; - const file = state.identificationFiles.find((item) => item.fileID === fileID); - if (!file) return; - if (event.target.dataset.identificationPreview) { - const result = await runBusy("正在下载并生成预览", () => window.reinloop.previewIdentificationFile({ - ...file, deviceId: elements.deviceId.value, credentials: credentials() - })); - if (result) { - showImage(result); - activateTab("plot-panel"); - } - } else if (event.target.dataset.identificationDownload) { - const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({ - ...file, credentials: credentials() - })); - if (result) setStatus(`已保存到: ${result.filePath}`, "success"); - } else { - if (!window.confirm(`确认永久删除 ${file.fileName}?`)) return; - if (!window.confirm("删除后无法恢复,确认继续?")) return; - event.target.disabled = true; - try { - const result = await runBusy("正在删除辨识数据", () => window.reinloop.deleteIdentificationFile({ - fileID, credentials: credentials() - })); - if (result) await refreshIdentificationFiles(); - } finally { - event.target.disabled = false; - } - } -}); - -document.querySelector("#import-config").addEventListener("click", async () => { - const result = await runBusy("正在导入配置", () => window.reinloop.chooseConfig()); - if (!result) return; - state.configs[state.configType] = result.parameters; - elements.configEditor.value = JSON.stringify(result.parameters, null, 2); - elements.configPath.textContent = result.filePath; - setStatus("配置已导入", "success"); -}); - -document.querySelector("#load-server").addEventListener("click", async () => { - const parameters = await runBusy("正在读取 Server 配置", () => window.reinloop.getConfig({ - configType: state.configType, - credentials: credentials() - })); - if (!parameters) return; - state.configs[state.configType] = parameters; - elements.configEditor.value = JSON.stringify(parameters, null, 2); - elements.publishResult.textContent = "已读取当前 Server 配置"; - elements.publishResult.dataset.tone = "success"; - setStatus("读取完成", "success"); -}); - -elements.publishButton.addEventListener("click", async () => { - let parameters; - try { - parameters = JSON.parse(elements.configEditor.value); - } catch (error) { - showError(new Error(`JSON 格式错误: ${error.message}`)); - return; - } - - const result = await runBusy("正在发布配置", () => window.reinloop.publishConfig({ - configType: state.configType, - parameters, - credentials: { - ...credentials(), - deviceId: state.configType === "identification" && state.retryDeviceId - ? state.retryDeviceId - : credentials().deviceId - } - })); - if (!result) return; - state.configs[state.configType] = parameters; - elements.publishResult.textContent = result.storagePath - ? `${result.message}: ${result.storagePath}` - : result.message; - elements.publishResult.dataset.tone = "success"; - if (state.configType === "identification" && state.retryDeviceId) { - await submitRetryReview(); - } else { - setStatus("发布完成", "success"); - } -}); - -window.reinloop.getDefaults().then((defaults) => { - elements.apiUrl.value = defaults.apiUrl; - state.defaultDeviceId = defaults.deviceId; - elements.adminToken.placeholder = defaults.hasAdminToken - ? "已使用环境变量中的 Token" - : "请输入 Admin Token"; -}); - -window.reinloop.onCsvUpdated((result) => { - showImage(result); - if (state.connected) void refreshIdentificationFiles(); - setStatus(`已接收 ${result.fileName}`, "success"); -}); - -window.reinloop.onCsvWatchError((message) => { - setStatus("数据接收异常", "error"); - const pendingPlot = Object.values(elements.plots).find((plot) => plot.image.hidden); - if (pendingPlot) pendingPlot.path.textContent = message; -}); - -setInterval(() => { - if (state.connected) void refreshOrganizations(true); -}, 10000); - -renderConfig(); - -elements.closeLightbox.addEventListener("click", closeLightbox); -elements.zoomIn.addEventListener("click", () => zoomLightbox(1.25)); -elements.zoomOut.addEventListener("click", () => zoomLightbox(0.8)); -elements.zoomFit.addEventListener("click", () => { - state.lightbox.fit = true; - updateLightboxImage(); -}); -elements.zoomReset.addEventListener("click", () => { - state.lightbox.fit = false; - state.lightbox.scale = 1; - updateLightboxImage(); -}); -elements.lightbox.addEventListener("click", (event) => { - if (event.target === elements.lightbox) closeLightbox(); -}); -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") closeLightbox(); - if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25); - if (!elements.lightbox.hidden && event.key === "-") zoomLightbox(0.8); +const volumeExample = { + q_in_val: 91, + dt: 0.1, + p_max: 200, + fit_low: 50, + fit_high: 200, + T_delta: 30, + xa_full: 1000, + num_runs: 6 +}; + +const identificationExample = { + q_in_val: 91, + dt: 0.1, + n_order: 8, + t_c: 2.5, + levels: [10, 20, 30, 40, 50, 60, 70, 80], + dead_area: 0, + xa_full: 1000, + V_val: 1, + repeat: 2 +}; + +const state = { + configType: "volume", + imagePaths: { csv: null, json: null }, + imageDataUrls: { csv: null, json: null }, + identificationFiles: [], + controlFiles: [], + configs: { volume: volumeExample, identification: identificationExample }, + companies: [], + models: [], + defaultDeviceId: "", + review: null, + retryDeviceId: null, + lightbox: { mediaType: null, scale: 1, fit: true, previousFocus: null }, + connected: false +}; + +const elements = { + status: document.querySelector("#status"), + connectionScreen: document.querySelector("#connection-screen"), + connectionForm: document.querySelector("#connection-form"), + connectionMessage: document.querySelector("#connection-message"), + connectButton: document.querySelector("#connect-button"), + workspace: document.querySelector("#workspace"), + apiUrl: document.querySelector("#api-url"), + adminToken: document.querySelector("#admin-token"), + companySelect: document.querySelector("#company-select"), + deviceId: document.querySelector("#device-id"), + plots: { + csv: { + image: document.querySelector("#csv-plot-image"), + empty: document.querySelector("#empty-csv-plot"), + path: document.querySelector("#csv-image-path"), + uploadTime: document.querySelector("#csv-upload-time"), + showImage: document.querySelector("#show-csv-image"), + openImage: document.querySelector('[data-open-plot="csv"]') + }, + json: { + image: document.querySelector("#json-plot-image"), + empty: document.querySelector("#empty-json-plot"), + path: document.querySelector("#json-image-path"), + uploadTime: document.querySelector("#json-upload-time"), + showImage: document.querySelector("#show-json-image"), + openImage: document.querySelector('[data-open-plot="json"]') + } + }, + configEditor: document.querySelector("#config-editor"), + configLabel: document.querySelector("#config-label"), + configPath: document.querySelector("#config-path"), + publishTarget: document.querySelector("#publish-target"), + publishButton: document.querySelector("#publish-config"), + publishResult: document.querySelector("#publish-result"), + lineCompany: document.querySelector("#line-company"), + licenseTarget: document.querySelector("#license-target"), + licenseList: document.querySelector("#license-list"), + licenseEmpty: document.querySelector("#license-empty"), + licenseDetail: document.querySelector("#license-detail"), + modelList: document.querySelector("#model-list"), + modelEmpty: document.querySelector("#model-empty"), + identificationFileList: document.querySelector("#identification-file-list"), + identificationFileEmpty: document.querySelector("#identification-file-empty"), + controlFileList: document.querySelector("#control-file-list"), + controlFileEmpty: document.querySelector("#control-file-empty"), + controlFileDetail: document.querySelector("#control-file-detail"), + reviewTarget: document.querySelector("#review-target"), + approveReview: document.querySelector("#approve-review"), + rejectReview: document.querySelector("#reject-review"), + lightbox: document.querySelector("#image-lightbox"), + lightboxTitle: document.querySelector("#lightbox-title"), + lightboxImage: document.querySelector("#lightbox-image"), + lightboxCanvas: document.querySelector("#lightbox-canvas"), + zoomIn: document.querySelector("#zoom-in"), + zoomOut: document.querySelector("#zoom-out"), + zoomFit: document.querySelector("#zoom-fit"), + zoomReset: document.querySelector("#zoom-reset"), + closeLightbox: document.querySelector("#close-lightbox") +}; + +function setStatus(message, tone = "idle") { + elements.status.textContent = message; + elements.status.dataset.tone = tone; +} + +function credentials() { + return { + apiUrl: elements.apiUrl.value.trim(), + adminToken: elements.adminToken.value, + deviceId: elements.deviceId.value.trim() + }; +} + +function selectedCompany() { + return state.companies.find((company) => company.id === elements.companySelect.value); +} + +function selectedLine() { + const company = selectedCompany(); + return company?.productionLines.find((line) => line.deviceId === elements.deviceId.value); +} + +function formatLicenseDate(value) { + return value.replace("T", " "); +} + +function formatSize(value) { + if (!Number.isFinite(value)) return "-"; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function controlFileType(fileName) { + const extension = String(fileName || "").split(".").pop().toLowerCase(); + if (extension === "pkl") return "控制 Episode 分片"; + if (extension === "json") return "控制数据清单"; + return extension ? `${extension.toUpperCase()} 文件` : "控制数据"; +} + +function escapeHtml(value) { + const node = document.createElement("span"); + node.textContent = String(value ?? ""); + return node.innerHTML; +} + +async function syncConnection() { + await window.reinloop.setConnection(credentials()); +} + +function showConnectionError(error) { + const message = (error?.message || String(error)) + .replace(/^Error invoking remote method '[^']+': Error: /, ""); + elements.connectionMessage.textContent = message; + elements.connectionMessage.dataset.tone = "error"; + setStatus("连接失败", "error"); +} + +async function connectWorkspace() { + const apiUrl = elements.apiUrl.value.trim(); + if (!apiUrl) return showConnectionError(new Error("请输入 Server API URL")); + elements.connectButton.disabled = true; + elements.connectionMessage.textContent = "正在校验 Server 与 Admin Token"; + elements.connectionMessage.dataset.tone = "busy"; + setStatus("正在连接", "busy"); + try { + const result = await window.reinloop.testConnection({ + apiUrl, + adminToken: elements.adminToken.value + }); + state.companies = result.companies; + renderOrganizationOptions(); + elements.connectionScreen.hidden = true; + elements.workspace.hidden = false; + state.connected = true; + delete elements.connectionMessage.dataset.tone; + setStatus("连接成功", "success"); + } catch (error) { + showConnectionError(error); + } finally { + elements.connectButton.disabled = false; + } +} + +function renderOrganizationOptions() { + const previousCompany = elements.companySelect.value; + const companyOptions = state.companies.map((company) => { + const lines = company.productionLines || []; + const onlineCount = lines.filter((line) => line.online === true).length; + const hasKnownStatus = lines.some((line) => typeof line.online === "boolean"); + const statusSummary = !lines.length + ? " · 暂无产线" + : hasKnownStatus + ? `${onlineCount > 0 ? " · ● 在线" : " · ○ 离线"} (${onlineCount}/${lines.length})` + : " · ◇ 状态未知"; + return ``; + }).join(""); + elements.companySelect.innerHTML = `${companyOptions}`; + elements.lineCompany.innerHTML = `${companyOptions}`; + if (state.companies.some((company) => company.id === previousCompany)) { + elements.companySelect.value = previousCompany; + } else if (state.defaultDeviceId) { + const defaultCompany = state.companies.find((company) => + company.productionLines.some((line) => line.deviceId === state.defaultDeviceId)); + if (defaultCompany) elements.companySelect.value = defaultCompany.id; + } + renderLineOptions(); +} + +function renderLineOptions() { + const company = selectedCompany(); + const previousDevice = elements.deviceId.value; + const lines = company?.productionLines || []; + elements.deviceId.innerHTML = `${lines.map((line) => + `` + ).join("")}`; + if (lines.some((line) => line.deviceId === previousDevice)) { + elements.deviceId.value = previousDevice; + } else if (lines.some((line) => line.deviceId === state.defaultDeviceId)) { + elements.deviceId.value = state.defaultDeviceId; + } + updateSelectedTarget(); +} + +function updateSelectedTarget() { + const company = selectedCompany(); + const line = selectedLine(); + elements.licenseTarget.value = company && line ? `${company.name} / ${line.name}` : ""; + void syncConnection(); +} + +async function refreshOrganizations(silent = false) { + if (silent) { + try { + const result = await window.reinloop.listOrganizations(credentials()); + state.companies = result.companies; + renderOrganizationOptions(); + } catch (_error) { + // Keep the last known status; explicit operations still report errors. + } + return; + } + const result = await runBusy("正在读取组织", () => window.reinloop.listOrganizations(credentials())); + if (!result) return; + state.companies = result.companies; + renderOrganizationOptions(); + setStatus("组织已刷新", "success"); +} + +async function refreshLicenses() { + const result = await runBusy("正在读取许可证", () => window.reinloop.listLicenses(credentials())); + if (!result) return; + elements.licenseList.innerHTML = result.licenses.map((license) => ` + ${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)} + ${escapeHtml(license.expiry)}${license.status === "active" ? "有效" : "已撤销"} + ${license.status === "active" ? `` : ""} + `).join(""); + elements.licenseEmpty.hidden = result.licenses.length > 0; + setStatus("许可证已刷新", "success"); +} + +async function refreshModels() { + if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); + const result = await runBusy("正在读取模型", () => window.reinloop.listModels({ + deviceId: elements.deviceId.value, credentials: credentials() + })); + if (!result) return; + state.models = result.fileList; + elements.modelList.innerHTML = state.models.map((model) => ` + ${escapeHtml(model.fileName)}${escapeHtml(model.uploadTime || "-")} + ${formatSize(model.size)} + `).join(""); + elements.modelEmpty.hidden = state.models.length > 0; + setStatus("模型已刷新", "success"); +} + +async function refreshIdentificationFiles() { + if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); + const result = await runBusy("正在读取辨识暂存数据", () => window.reinloop.listIdentificationFiles({ + deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials() + })); + if (!result) return; + state.identificationFiles = result.files || result.fileList || []; + elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => ` + ${escapeHtml(file.uploadTime || "-")}${escapeHtml(file.fileName)} + ${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}${formatSize(file.size)} + ${file.status === "processed" ? "已处理" : "待处理"} + + `).join(""); + elements.identificationFileEmpty.hidden = state.identificationFiles.length > 0; + setStatus("辨识数据已刷新", "success"); +} + +async function refreshControlFiles() { + if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); + const result = await runBusy("正在读取控制暂存数据", () => window.reinloop.listControlFiles({ + deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials() + })); + if (!result) return; + state.controlFiles = result.files || result.fileList || []; + elements.controlFileList.innerHTML = state.controlFiles.map((file) => ` + ${escapeHtml(file.uploadTime || "-")}${escapeHtml(file.fileName)} + ${escapeHtml(controlFileType(file.fileName))}${formatSize(file.size)} + + `).join(""); + elements.controlFileEmpty.hidden = state.controlFiles.length > 0; + elements.controlFileDetail.hidden = true; + elements.controlFileDetail.textContent = ""; + setStatus("控制数据已刷新", "success"); +} + +function showControlFilePreview(result) { + const metadata = [ + `文件名:${result.fileName}`, + `上传时间:${result.uploadTime || "-"}`, + `大小:${formatSize(result.size)}`, + `本地缓存:${result.filePath}` + ]; + const detail = result.content === null + ? `${metadata.join("\n")}\n\n此文件为二进制控制 Episode 分片(.pkl),请下载后使用 ReinLoop/Python 分析。` + : `${metadata.join("\n")}\n\n${result.content}`; + elements.controlFileDetail.textContent = detail; + elements.controlFileDetail.hidden = false; +} + +function showError(error) { + const message = (error?.message || String(error)) + .replace(/^Error invoking remote method '[^']+': Error: /, ""); + setStatus(message, "error"); + elements.status.title = message; + elements.publishResult.textContent = message; + elements.publishResult.dataset.tone = "error"; +} + +function showImage(result) { + if (!result) return; + const mediaType = result.mediaType === "json" || result.fileName?.toLowerCase().endsWith(".json") + ? "json" + : "csv"; + const plot = elements.plots[mediaType]; + state.imagePaths[mediaType] = result.filePath; + state.imageDataUrls[mediaType] = result.dataUrl; + plot.image.src = result.dataUrl; + plot.image.hidden = false; + plot.empty.hidden = true; + plot.path.textContent = result.filePath; + if (result.uploadTime) { + const uploadedAt = new Date(result.uploadTime); + plot.uploadTime.textContent = `上传时间:${uploadedAt.toLocaleString("zh-CN", { hour12: false })}`; + } else { + plot.uploadTime.textContent = "已接收"; + } + plot.showImage.disabled = false; + plot.openImage.disabled = false; + if (mediaType === "csv" && result.reviewable) { + state.review = { runId: result.fileName, deviceId: result.deviceId }; + elements.reviewTarget.textContent = result.fileName; + elements.approveReview.disabled = false; + elements.rejectReview.disabled = false; + } + setStatus("图片已载入", "success"); +} + +function updateLightboxImage() { + const image = elements.lightboxImage; + if (state.lightbox.fit) { + image.classList.add("fit-image"); + image.style.width = ""; + return; + } + image.classList.remove("fit-image"); + if (image.complete && image.naturalWidth) image.style.width = `${Math.round(image.naturalWidth * state.lightbox.scale)}px`; +} + +function openLightbox(mediaType) { + const source = state.imageDataUrls[mediaType]; + if (!source) return; + state.lightbox.mediaType = mediaType; + state.lightbox.scale = 1; + state.lightbox.fit = true; + state.lightbox.previousFocus = document.activeElement; + elements.lightboxTitle.textContent = mediaType === "json" ? "行程稳态压力图" : "辨识 CSV 图"; + elements.lightboxImage.src = source; + elements.lightboxImage.onload = updateLightboxImage; + elements.lightbox.hidden = false; + elements.closeLightbox.focus(); +} + +function closeLightbox() { + if (elements.lightbox.hidden) return; + elements.lightbox.hidden = true; + elements.lightboxImage.removeAttribute("src"); + state.lightbox.previousFocus?.focus(); + state.lightbox.previousFocus = null; +} + +function zoomLightbox(direction) { + state.lightbox.fit = false; + state.lightbox.scale = Math.min(4, Math.max(0.25, state.lightbox.scale * direction)); + updateLightboxImage(); +} + +function finishReview(result) { + state.review = null; + elements.reviewTarget.textContent = result === 1 ? "已提交:通过" : "已提交:未通过"; + elements.approveReview.disabled = true; + elements.rejectReview.disabled = true; +} + +function activateTab(target) { + document.querySelectorAll(".tab").forEach((item) => item.classList.toggle("active", item.dataset.target === target)); + document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === target)); +} + +function activateConfigType(configType) { + try { + state.configs[state.configType] = JSON.parse(elements.configEditor.value); + } catch (_error) { + // Keep the last valid configuration when changing views. + } + state.configType = configType; + document.querySelectorAll(".segment").forEach((item) => item.classList.toggle("active", item.dataset.type === configType)); + renderConfig(); +} + +function switchToIdentificationConfigForRetry() { + if (!state.review) return; + state.retryDeviceId = state.review.deviceId; + activateTab("config-panel"); + activateConfigType("identification"); + elements.publishResult.textContent = `本轮辨识未通过。请检查配置后发布到设备 ${state.retryDeviceId},发布成功后将自动要求重测。`; + elements.publishResult.dataset.tone = "error"; + setStatus("请重新发布辨识配置", "busy"); +} + +async function submitReview(result) { + if (!state.review) return; + if (result === 0) return switchToIdentificationConfigForRetry(); + const review = state.review; + const submitted = await runBusy("正在提交辨识结论", () => window.reinloop.submitReview({ + deviceId: review.deviceId, + runId: review.runId, + result, + credentials: credentials() + })); + if (submitted) { + finishReview(result); + state.retryDeviceId = null; + setStatus("辨识结果已通过", "success"); + } +} + +async function submitRetryReview() { + if (!state.review || !state.retryDeviceId) return; + const review = state.review; + const submitted = await runBusy("正在提交未通过结论", () => window.reinloop.submitReview({ + deviceId: review.deviceId, runId: review.runId, result: 0, credentials: credentials() + })); + if (!submitted) return; + finishReview(0); + state.retryDeviceId = null; + setStatus("新配置已发布,已要求设备重测", "success"); +} + +function renderConfig() { + const isVolume = state.configType === "volume"; + elements.configEditor.value = JSON.stringify(state.configs[state.configType], null, 2); + elements.configLabel.textContent = isVolume ? "容积测量配置" : "系统辨识配置"; + elements.publishTarget.textContent = isVolume ? "Server 配置文件" : "设备辨识配置"; + elements.publishButton.textContent = isVolume ? "上传容积配置" : "发布辨识配置"; + elements.configPath.textContent = "可直接编辑,或从本地 JSON 导入"; + elements.publishResult.textContent = "等待操作"; + delete elements.publishResult.dataset.tone; +} + +async function runBusy(label, action) { + setStatus(label, "busy"); + try { + return await action(); + } catch (error) { + showError(error); + return null; + } +} + +document.querySelectorAll(".tab").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.target))); + +document.querySelectorAll(".segment").forEach((button) => button.addEventListener("click", () => activateConfigType(button.dataset.type))); + +Object.entries(elements.plots).forEach(([mediaType, plot]) => { + plot.showImage.addEventListener("click", () => window.reinloop.showInFolder(state.imagePaths[mediaType])); + plot.openImage.addEventListener("click", () => openLightbox(mediaType)); +}); + +elements.approveReview.addEventListener("click", () => void submitReview(1)); +elements.rejectReview.addEventListener("click", () => void submitReview(0)); + +elements.companySelect.addEventListener("change", renderLineOptions); +elements.deviceId.addEventListener("change", updateSelectedTarget); +elements.connectionForm.addEventListener("submit", (event) => { + event.preventDefault(); + void connectWorkspace(); +}); + +document.querySelector("#refresh-organizations").addEventListener("click", refreshOrganizations); +document.querySelector("#company-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const result = await runBusy("正在添加公司", () => window.reinloop.createCompany({ + name: document.querySelector("#company-name").value, + code: document.querySelector("#company-code").value, + credentials: credentials() + })); + if (!result) return; + event.target.reset(); + await refreshOrganizations(); +}); +document.querySelector("#line-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const result = await runBusy("正在添加产线", () => window.reinloop.createProductionLine({ + companyId: elements.lineCompany.value, + name: document.querySelector("#line-name").value, + code: document.querySelector("#line-code").value, + credentials: credentials() + })); + if (!result) return; + event.target.reset(); + await refreshOrganizations(); +}); + +document.querySelector("#license-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const company = selectedCompany(); + const line = selectedLine(); + if (!company || !line) return showError(new Error("请先选择公司和产线")); + const result = await runBusy("正在签发许可证", () => window.reinloop.issueLicense({ + companyId: company.id, companyCode: company.code, customer: company.name, + productionLineId: line.id, lineCode: line.code, deviceId: line.deviceId, + issued: formatLicenseDate(document.querySelector("#license-issued").value), + expiry: formatLicenseDate(document.querySelector("#license-expiry").value), + features: document.querySelector("#license-features").value, + credentials: credentials() + })); + if (!result) return; + setStatus(`许可证已保存: ${result.filePath}`, "success"); + await refreshLicenses(); +}); +document.querySelector("#refresh-licenses").addEventListener("click", refreshLicenses); +elements.licenseList.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + const detailId = button.dataset.licenseDetail; + const downloadId = button.dataset.licenseDownload; + const revokeId = button.dataset.licenseRevoke; + if (detailId) { + const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() })); + if (result) { + elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2); + elements.licenseDetail.hidden = false; + } + return; + } + if (downloadId) { + button.disabled = true; + try { + const result = await runBusy("正在下载许可证", () => window.reinloop.downloadLicense({ + licenseId: downloadId, credentials: credentials() + })); + if (result) setStatus(`许可证已保存: ${result.filePath}`, "success"); + } finally { + button.disabled = false; + } + return; + } + if (revokeId) { + const reason = window.prompt("请输入撤销原因", "管理员撤销"); + if (reason === null) return; + const trimmedReason = reason.trim() || "管理员撤销"; + if (!window.confirm(`确认撤销此许可证?\n原因:${trimmedReason}`)) return; + button.disabled = true; + try { + const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ + licenseId: revokeId, reason: trimmedReason, credentials: credentials() + })); + if (result) { + await refreshLicenses(); + setStatus("许可证已撤销", "success"); + } + } finally { + button.disabled = false; + } + } +}); + +document.querySelector("#refresh-models").addEventListener("click", refreshModels); +document.querySelector("#upload-model").addEventListener("click", async () => { + if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线")); + const button = document.querySelector("#upload-model"); + button.disabled = true; + try { + const models = await runBusy("正在读取模型", () => window.reinloop.listModels({ + deviceId: elements.deviceId.value, credentials: credentials() + })); + if (!models) return; + state.models = models.fileList; + const selected = await runBusy("正在选择模型文件", () => window.reinloop.chooseModelUploadFile()); + if (!selected) return; + const existing = state.models.find((model) => model.fileName === selected.fileName); + let overwrite = false; + if (existing) { + if (!window.confirm(`已存在同名模型 ${selected.fileName},覆盖后无法恢复。是否继续?`)) return; + const confirmation = window.prompt(`请输入完整文件名以确认覆盖:${selected.fileName}`); + if (confirmation !== selected.fileName) { + setStatus("文件名不匹配,已取消覆盖", "idle"); + return; + } + overwrite = true; + } + const result = await runBusy("正在上传模型", () => window.reinloop.uploadModel({ + deviceId: elements.deviceId.value, sourcePath: selected.sourcePath, fileName: selected.fileName, + overwrite, credentials: credentials() + })); + if (result) { + setStatus(overwrite ? "模型已覆盖" : "模型已上传", "success"); + await refreshModels(); + } + } finally { + button.disabled = false; + } +}); +elements.modelList.addEventListener("click", async (event) => { + const fileID = event.target.dataset.modelDownload || event.target.dataset.modelDelete; + if (!fileID) return; + const model = state.models.find((item) => item.fileID === fileID); + if (event.target.dataset.modelDownload) { + const result = await runBusy("正在下载模型", () => window.reinloop.downloadModel({ fileID, fileName: model.fileName, credentials: credentials() })); + if (result) setStatus(`模型已下载: ${result.filePath}`, "success"); + } else { + if (!window.confirm(`确认删除模型 ${model.fileName}?`)) return; + const confirmation = window.prompt(`删除不可恢复。请输入完整文件名以确认:${model.fileName}`); + if (confirmation !== model.fileName) { + setStatus("文件名不匹配,已取消删除", "idle"); + return; + } + event.target.disabled = true; + try { + const result = await runBusy("正在删除模型", () => window.reinloop.deleteModel({ fileID, credentials: credentials() })); + if (result) await refreshModels(); + } finally { + event.target.disabled = false; + } + } +}); + +document.querySelector("#refresh-identification-files").addEventListener("click", refreshIdentificationFiles); +elements.identificationFileList.addEventListener("click", async (event) => { + const fileID = event.target.dataset.identificationPreview || event.target.dataset.identificationDownload || event.target.dataset.identificationDelete; + if (!fileID) return; + const file = state.identificationFiles.find((item) => item.fileID === fileID); + if (!file) return; + if (event.target.dataset.identificationPreview) { + const result = await runBusy("正在下载并生成预览", () => window.reinloop.previewIdentificationFile({ + ...file, deviceId: elements.deviceId.value, credentials: credentials() + })); + if (result) { + showImage(result); + activateTab("plot-panel"); + } + } else if (event.target.dataset.identificationDownload) { + const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({ + ...file, credentials: credentials() + })); + if (result) setStatus(`已保存到: ${result.filePath}`, "success"); + } else { + if (!window.confirm(`确认永久删除 ${file.fileName}?`)) return; + if (!window.confirm("删除后无法恢复,确认继续?")) return; + event.target.disabled = true; + try { + const result = await runBusy("正在删除辨识数据", () => window.reinloop.deleteIdentificationFile({ + fileID, credentials: credentials() + })); + if (result) await refreshIdentificationFiles(); + } finally { + event.target.disabled = false; + } + } +}); + +document.querySelector("#import-config").addEventListener("click", async () => { + const result = await runBusy("正在导入配置", () => window.reinloop.chooseConfig()); + if (!result) return; + state.configs[state.configType] = result.parameters; + elements.configEditor.value = JSON.stringify(result.parameters, null, 2); + elements.configPath.textContent = result.filePath; + setStatus("配置已导入", "success"); +}); + +document.querySelector("#load-server").addEventListener("click", async () => { + const parameters = await runBusy("正在读取 Server 配置", () => window.reinloop.getConfig({ + configType: state.configType, + credentials: credentials() + })); + if (!parameters) return; + state.configs[state.configType] = parameters; + elements.configEditor.value = JSON.stringify(parameters, null, 2); + elements.publishResult.textContent = "已读取当前 Server 配置"; + elements.publishResult.dataset.tone = "success"; + setStatus("读取完成", "success"); +}); + +elements.publishButton.addEventListener("click", async () => { + let parameters; + try { + parameters = JSON.parse(elements.configEditor.value); + } catch (error) { + showError(new Error(`JSON 格式错误: ${error.message}`)); + return; + } + + const result = await runBusy("正在发布配置", () => window.reinloop.publishConfig({ + configType: state.configType, + parameters, + credentials: { + ...credentials(), + deviceId: state.configType === "identification" && state.retryDeviceId + ? state.retryDeviceId + : credentials().deviceId + } + })); + if (!result) return; + state.configs[state.configType] = parameters; + elements.publishResult.textContent = result.storagePath + ? `${result.message}: ${result.storagePath}` + : result.message; + elements.publishResult.dataset.tone = "success"; + if (state.configType === "identification" && state.retryDeviceId) { + await submitRetryReview(); + } else { + setStatus("发布完成", "success"); + } +}); + +window.reinloop.getDefaults().then((defaults) => { + elements.apiUrl.value = defaults.apiUrl; + state.defaultDeviceId = defaults.deviceId; + elements.adminToken.placeholder = defaults.hasAdminToken + ? "已使用环境变量中的 Token" + : "请输入 Admin Token"; +}); + +window.reinloop.onCsvUpdated((result) => { + showImage(result); + if (state.connected) void refreshIdentificationFiles(); + setStatus(`已接收 ${result.fileName}`, "success"); +}); + +window.reinloop.onCsvWatchError((message) => { + setStatus("数据接收异常", "error"); + const pendingPlot = Object.values(elements.plots).find((plot) => plot.image.hidden); + if (pendingPlot) pendingPlot.path.textContent = message; +}); + +setInterval(() => { + if (state.connected) void refreshOrganizations(true); +}, 10000); + +renderConfig(); + +elements.closeLightbox.addEventListener("click", closeLightbox); +elements.zoomIn.addEventListener("click", () => zoomLightbox(1.25)); +elements.zoomOut.addEventListener("click", () => zoomLightbox(0.8)); +elements.zoomFit.addEventListener("click", () => { + state.lightbox.fit = true; + updateLightboxImage(); +}); +elements.zoomReset.addEventListener("click", () => { + state.lightbox.fit = false; + state.lightbox.scale = 1; + updateLightboxImage(); +}); +elements.lightbox.addEventListener("click", (event) => { + if (event.target === elements.lightbox) closeLightbox(); +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeLightbox(); + if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25); + if (!elements.lightbox.hidden && event.key === "-") zoomLightbox(0.8); +}); + +document.querySelector("#refresh-control-files").addEventListener("click", refreshControlFiles); +elements.controlFileList.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + const fileID = button.dataset.controlPreview || button.dataset.controlDownload || button.dataset.controlDelete; + if (!fileID) return; + const file = state.controlFiles.find((item) => item.fileID === fileID); + if (!file) return; + if (button.dataset.controlPreview) { + button.disabled = true; + try { + const result = await runBusy("正在下载控制数据预览", () => window.reinloop.previewControlFile({ + ...file, credentials: credentials() + })); + if (result) { + showControlFilePreview(result); + setStatus("控制数据预览已载入", "success"); + } + } finally { + button.disabled = false; + } + } else if (button.dataset.controlDownload) { + button.disabled = true; + try { + const result = await runBusy("正在保存控制原始数据", () => window.reinloop.downloadControlFile({ + ...file, credentials: credentials() + })); + if (result) setStatus(`已保存到: ${result.filePath}`, "success"); + } finally { + button.disabled = false; + } + } else { + if (!window.confirm(`确认永久删除 ${file.fileName}?`)) return; + if (!window.confirm("删除后无法恢复,确认继续?")) return; + button.disabled = true; + try { + const result = await runBusy("正在删除控制数据", () => window.reinloop.deleteControlFile({ + fileID, credentials: credentials() + })); + if (result) await refreshControlFiles(); + } finally { + button.disabled = false; + } + } }); \ No newline at end of file diff --git a/ControlPanel/features.md b/ControlPanel/features.md index f7b9f0d..a01410a 100644 --- a/ControlPanel/features.md +++ b/ControlPanel/features.md @@ -179,6 +179,7 @@ base64(JSON)|base64(signature) - 刷新许可证列表 - 查看许可证详情 +- 下载已签发许可证 - 撤销有效许可证 - 填写撤销原因 - 区分有效和已撤销状态 @@ -192,6 +193,33 @@ getLicense revokeLicense ``` +下载已签发许可证复用 `getLicense` 返回的许可证原文;Panel 在本地选择保存位置后写入 `.lic` 文件。 + +## 5.1 控制数据暂存 + +“控制数据”页面按当前产线显示 ReinLoop 在控制结束后上传到 +`/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. 模型管理 “模型管理”页面按当前产线操作: diff --git a/ReinLoop/README.md b/ReinLoop/README.md index 572f6ff..9cb33df 100644 --- a/ReinLoop/README.md +++ b/ReinLoop/README.md @@ -1,85 +1,85 @@ -# ReinLoop V1.0 — 收敛有界 - -基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。 - -## 项目结构 - -``` -pressure_control_gui/ -├── main.py # 应用入口 -├── PcControl.py # Modbus 通讯类 -│ # MT2AM8Client - MT2-AM8 模块 TCP(AI 读压力/流量,AO 写电机) -│ # Easy521ModbusClient - PLC TCP(读压力/流量,备用) -│ # MotorModbusRTUClient - 电机 RTU(直接写位置,备用) -│ # PressureModbusRTUClient - 压力变送器 RTU(备用) -├── controllers.py # 增量式 PID 控制器 -├── api.py # Express Server API 配置 -├── styles.py # 全局 QSS 样式表 -├── ind_collector.py # PRBS 辨识数据采集 -├── get_V.py # 容积测量 -├── license_utils.py # 许可证签发与校验 -├── core/ -│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波 -│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client) -│ ├── model_manager.py # RL 模型管理 -│ ├── data_collector.py # 数据采集与云端上传 -│ └── identification.py # 系统辨识与容积测量管理 -├── ui/ -│ ├── main_window.py # 主窗口(布局与信号槽绑定) -│ ├── connection_tab.py # 连接设置页(Modbus TCP) -│ ├── control_tab.py # 控制设置页 -│ ├── debug_tab.py # 模型调试页(高级参数:死区/限幅/模拟量映射) -│ ├── status_bar.py # 底部状态栏 -│ └── plot_window.py # 数据绘图窗口 -├── src/ # SVG 图标资产 -├── model_config/ # RL 模型配置文件 -├── ind_data/ # 辨识数据本地输出目录 -└── tool/ # 本地调试与诊断工具 -``` - -## 环境要求 - -```bash - -``` - -## 运行 - -```bash -python main.py -``` - -## 控制模式 - -| 模式 | 说明 | -|------|------| -| **PID** | 增量式 PID,参数可在线调整,死区/限幅可配置 | -| **RL** | 强化学习模型实时预测 Kp/Ki,需先加载模型 | -| **手动** | 直接设定阀门开度百分比 | - -控制周期由 PID 的 `dt` 参数决定(默认 0.1s),QTimer 驱动主线程执行,每周期末自动 sleep 补足时长保证精确周期。 - -## 硬件连接 - -GUI 主程序使用 **MT2-AM8 模拟量模块**(艾莫迅),通过 Modbus TCP 统一 IO: - -- **MT2-AM8 模块**:Modbus TCP,默认 `192.168.1.12:502`,模块地址 1 - - AI(输入寄存器 0x00~0x03):读压力传感器(4-20mA → 0-4095 → kPa)、读流量计 - - AO(保持寄存器 0x00~0x03):写电机伺服驱动器(0-10V 模拟量控制行程) - - 模拟量映射范围、压力/流量量程可在界面中配置 - -### PcControl.py 中其他可用通讯类 - -以下类在 GUI 主循环中**未直接使用**,但可供独立脚本(如 `get_V.py` 的 `main()`)或调试调用: - -| 类 | 协议 | 默认参数 | 用途 | -|---|---|---|---| -| `Easy521ModbusClient` | Modbus TCP | `192.168.1.88:502` | 读 PLC 压力寄存器 504(32-bit float)、写线圈控制 | -| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4,115200 | 通过 RS-485 直接读写电机驱动器寄存器 | -| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) | - -## 数据上传 - -控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存, -服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由 -ControlPanel 完成,不由客户端工具执行。 +# ReinLoop V1.0 — 收敛有界 + +基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。 + +## 项目结构 + +``` +pressure_control_gui/ +├── main.py # 应用入口 +├── PcControl.py # Modbus 通讯类 +│ # MT2AM8Client - MT2-AM8 模块 TCP(AI 读压力/流量,AO 写电机) +│ # Easy521ModbusClient - PLC TCP(读压力/流量,备用) +│ # MotorModbusRTUClient - 电机 RTU(直接写位置,备用) +│ # PressureModbusRTUClient - 压力变送器 RTU(备用) +├── controllers.py # 增量式 PID 控制器 +├── api.py # Express Server API 配置 +├── styles.py # 全局 QSS 样式表 +├── ind_collector.py # PRBS 辨识数据采集 +├── get_V.py # 容积测量 +├── license_utils.py # 许可证签发与校验 +├── core/ +│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波 +│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client) +│ ├── model_manager.py # RL 模型管理 +│ ├── data_collector.py # 数据采集与云服务器上传 +│ └── identification.py # 系统辨识与容积测量管理 +├── ui/ +│ ├── main_window.py # 主窗口(布局与信号槽绑定) +│ ├── connection_tab.py # 连接设置页(Modbus TCP) +│ ├── control_tab.py # 控制设置页 +│ ├── debug_tab.py # 模型调试页(高级参数:死区/限幅/模拟量映射) +│ ├── status_bar.py # 底部状态栏 +│ └── plot_window.py # 数据绘图窗口 +├── src/ # SVG 图标资产 +├── model_config/ # RL 模型配置文件 +├── ind_data/ # 辨识数据本地输出目录 +└── tool/ # 本地调试与诊断工具 +``` + +## 环境要求 + +```bash + +``` + +## 运行 + +```bash +python main.py +``` + +## 控制模式 + +| 模式 | 说明 | +|------|------| +| **PID** | 增量式 PID,参数可在线调整,死区/限幅可配置 | +| **RL** | 强化学习模型实时预测 Kp/Ki,需先加载模型 | +| **手动** | 直接设定阀门开度百分比 | + +控制周期由 PID 的 `dt` 参数决定(默认 0.1s),QTimer 驱动主线程执行,每周期末自动 sleep 补足时长保证精确周期。 + +## 硬件连接 + +GUI 主程序使用 **MT2-AM8 模拟量模块**(艾莫迅),通过 Modbus TCP 统一 IO: + +- **MT2-AM8 模块**:Modbus TCP,默认 `192.168.1.12:502`,模块地址 1 + - AI(输入寄存器 0x00~0x03):读压力传感器(4-20mA → 0-4095 → kPa)、读流量计 + - AO(保持寄存器 0x00~0x03):写电机伺服驱动器(0-10V 模拟量控制行程) + - 模拟量映射范围、压力/流量量程可在界面中配置 + +### PcControl.py 中其他可用通讯类 + +以下类在 GUI 主循环中**未直接使用**,但可供独立脚本(如 `get_V.py` 的 `main()`)或调试调用: + +| 类 | 协议 | 默认参数 | 用途 | +|---|---|---|---| +| `Easy521ModbusClient` | Modbus TCP | `192.168.1.88:502` | 读 PLC 压力寄存器 504(32-bit float)、写线圈控制 | +| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4,115200 | 通过 RS-485 直接读写电机驱动器寄存器 | +| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) | + +## 数据上传 + +控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存, +服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由 +ControlPanel 完成,不由客户端工具执行。 diff --git a/ReinLoop/api.py b/ReinLoop/api.py index 49fb2e7..097c4ba 100644 --- a/ReinLoop/api.py +++ b/ReinLoop/api.py @@ -1,26 +1,29 @@ -"""ReinLoop server endpoint configuration shared by core modules.""" - -import os - -from license_utils import get_verified_license - - -base_url = os.environ.get( - "REINLOOP_SERVER_URL", - "http://ReinLoop.dominatedconvergence.com", -).rstrip("/") -data_record_url = os.environ.get( - "REINLOOP_API_URL", - f"{base_url}/api", -) -_license = get_verified_license() -_license_device_id = (_license or {}).get("device_id", "").strip() -_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() - -if _license_device_id and _environment_device_id and _license_device_id != _environment_device_id: - raise RuntimeError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致") - -the_folder = _license_device_id or _environment_device_id or "local-test-device" - -if not the_folder: +"""ReinLoop cloud-server endpoint configuration shared by core modules.""" + +import os + +from license_utils import get_verified_license + + +base_url = os.environ.get( + "REINLOOP_SERVER_URL", + "https://ReinLoop.dominatedconvergence.com", +).rstrip("/") +server_api_url = os.environ.get( + "REINLOOP_API_URL", + f"{base_url}/api", +) +# Compatibility alias used by existing modules. It points to the ReinLoop +# Express server API, not a cloud-function endpoint. +data_record_url = server_api_url +_license = get_verified_license() +_license_device_id = (_license or {}).get("device_id", "").strip() +_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() + +if _license_device_id and _environment_device_id and _license_device_id != _environment_device_id: + raise RuntimeError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致") + +the_folder = _license_device_id or _environment_device_id or "local-test-device" + +if not the_folder: raise RuntimeError("设备 ID 不能为空") \ No newline at end of file diff --git a/ReinLoop/core/data_collector.py b/ReinLoop/core/data_collector.py index 59966cb..7a7c037 100644 --- a/ReinLoop/core/data_collector.py +++ b/ReinLoop/core/data_collector.py @@ -1,199 +1,234 @@ -# data_collector.py -"""数据采集器:管理 Episode 数据记录与上上传。 - -纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 -""" - -import io -import json -import pickle -import datetime -import threading -import requests - -from api import base_url, data_record_url, the_folder - - -class DataCollector: - """管理控制过程中的 Episode 数据采集与保存""" - - def __init__(self): - self.episode_data_raw = [] # 所有已完成的 Episode - self.current_episode = None # 当前正在记录的 Episode - self.last_target_record = None - self._on_log = None - - def set_log_callback(self, callback): - """设置日志回调""" - self._on_log = callback - - def log(self, message): - if self._on_log: - self._on_log(message) - - def _upload_to_cos(self, data_bytes: bytes, filename: str, folder: str) -> bool: - """通过云函数获取直传凭证,再将数据直传到腾讯云 COS。""" - try: - resp = requests.post(data_record_url, json={ - "type": "uploadDataFile", - "fileName": filename, - "folder": folder, - }, timeout=30) - result = resp.json() - except Exception as e: - self.log(f"向云函数申请凭证异常: {e}") - return False - - if not result.get("success"): - self.log(f"申请上传凭证失败: {result.get('errMsg', result)}") - return False - - meta = result.get("uploadMetadata") - if not meta or "url" not in meta or "authorization" not in meta: - self.log("云端未返回有效的上传元数据") - return False - - try: - form_data = { - "key": meta["cosFileId"], - "Signature": meta["authorization"], - "x-cos-security-token": meta["token"], - "x-cos-meta-fileid": meta["fileId"], - } - files = {"file": (filename, io.BytesIO(data_bytes))} - cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60) - - if cos_resp.status_code in [200, 204]: - return True - else: - self.log(f"COS 直传失败,状态码: {cos_resp.status_code}") - return False - except Exception as e: - self.log(f"COS 直传异常: {e}") - return False - - def reset(self): - """重置所有采集状态(控制启动时调用)""" - self.episode_data_raw = [] - self.current_episode = None - self.last_target_record = None - - def record_step(self, cycle_count: int, current_pressure: float, - target_pressure: float, valve_opening: float, - kp: float, ki: float, kd: float, - q_in: float, v: float): - """记录一个控制周期的数据点 - - Args: - cycle_count: 控制周期计数 - current_pressure: 当前压力 - target_pressure: 目标压力 - valve_opening: 阀门开度 - kp, ki, kd: PID 参数 - q_in: 流量 - v: 容积 - """ - # 目标压力变化时自动切分 Episode - if self.current_episode is None or target_pressure != self.last_target_record: - if self.current_episode is not None: - self.episode_data_raw.append(self.current_episode) - self.log(f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点") - - self.current_episode = { - 'pid': [float(kp), float(ki), float(kd)], - 'target_pressure': target_pressure, - 'Q_in': q_in, - 'V': v, - 'steps': [], - 'pressures': [], - 'errors': [], - 'valves': [] - } - self.last_target_record = target_pressure - - # 记录当前步数据 - error = -(target_pressure - current_pressure) - self.current_episode['steps'].append(cycle_count) - self.current_episode['pressures'].append(current_pressure) - self.current_episode['errors'].append(error) - self.current_episode['valves'].append(float(valve_opening)) - - def finalize_and_upload(self, flow: float, vol: float): - """停止控制时:闭合最后一个 Episode,分片上传到云存储。 - - 单文件超过 5MB 时自动拆分为多个分片, - 同时上传一个 manifest.json 记录所有分片信息。 - - Args: - flow: 流量值 (用于文件名/路径) - vol: 容积值 (用于文件名/路径) - """ - # 闭合最后一个 Episode - if self.current_episode and len(self.current_episode['pressures']) > 0: - self.episode_data_raw.append(self.current_episode) - self.current_episode = None - - if not self.episode_data_raw: - return - - try: - timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') - base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L" - - # 拆成每片尽量不超过 5MB 的 episode 分组 - MAX_CHUNK_BYTES = 5 * 1024 * 1024 # 5MB - - chunks = [] # [(chunk_index, episodes_subset)] - current_chunk = [] - for ep in self.episode_data_raw: - current_chunk.append(ep) - if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES: - # 当前片已满,回退一个 episode 后保存 - current_chunk.pop() - chunks.append(current_chunk) - current_chunk = [ep] - if current_chunk: - chunks.append(current_chunk) - - total_chunks = len(chunks) - self.log(f"控制数据共 {len(self.episode_data_raw)} 个 Episode," - f"拆为 {total_chunks} 个分片上传") - - def upload_all(): - part_files = [] - for idx, chunk_eps in enumerate(chunks): - data_bytes = pickle.dumps(chunk_eps) - size_kb = len(data_bytes) / 1024 - 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)...") - if self._upload_to_cos(data_bytes, part_filename, base_folder): - part_files.append(part_filename) - else: - self.log(f" 分片 {idx + 1} 上传失败") - - # 上传 manifest - manifest = { - "timestamp": timestamp, - "total_chunks": total_chunks, - "uploaded_chunks": len(part_files), - "part_files": part_files, - "total_episodes": len(self.episode_data_raw), - "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' - self._upload_to_cos(manifest_bytes, manifest_filename, base_folder) - - if len(part_files) == total_chunks: - self.log(f"控制数据上传成功 ({total_chunks} 个分片)") - else: - self.log(f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})") - - threading.Thread(target=upload_all, daemon=True).start() - - except Exception as e: - self.log(f"保存收集数据时发生错误: {e}") - finally: - self.episode_data_raw = [] +# data_collector.py +"""数据采集器:管理 Episode 数据记录与上上传。 + +纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 +""" + +import io +import json +import pickle +import datetime +import threading +import requests + +from api import base_url, data_record_url, the_folder + + +class DataCollector: + """管理控制过程中的 Episode 数据采集与保存""" + + def __init__(self): + self.episode_data_raw = [] # 所有已完成的 Episode + self.current_episode = None # 当前正在记录的 Episode + self.last_target_record = None + self._on_log = None + self._on_upload_complete = None + + def set_log_callback(self, callback): + """设置日志回调""" + self._on_log = callback + + def set_upload_complete_callback(self, callback): + """设置控制数据上传完成回调。 + + callback(success, manifest, error) 会在后台上传线程中调用。成功时 + manifest 是已上传的清单字典;失败时 error 为可展示的错误信息。 + """ + self._on_upload_complete = callback + + def log(self, message): + if self._on_log: + self._on_log(message) + + def _notify_upload_complete(self, success, manifest=None, error=None): + if self._on_upload_complete: + self._on_upload_complete(success, manifest, error) + + def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool: + """向 ReinLoop 云服务器申请上传地址并上传控制数据。""" + try: + resp = requests.post(data_record_url, json={ + "type": "uploadDataFile", + "fileName": filename, + "folder": folder, + }, timeout=30) + result = resp.json() + except Exception as e: + self.log(f"向云服务器申请上传地址异常: {e}") + return False + + if not result.get("success"): + self.log(f"申请服务器上传地址失败: {result.get('errMsg', result)}") + return False + + meta = result.get("uploadMetadata") + if not meta or "url" not in meta: + self.log("云服务器未返回有效的上传地址") + return False + + try: + files = {"file": (filename, io.BytesIO(data_bytes))} + upload_resp = requests.post(meta["url"], files=files, timeout=60) + + if upload_resp.status_code in [200, 204]: + return True + else: + self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}") + return False + except Exception as e: + self.log(f"云服务器上传异常: {e}") + return False + + def reset(self): + """重置所有采集状态(控制启动时调用)""" + self.episode_data_raw = [] + self.current_episode = None + self.last_target_record = None + + def record_step(self, cycle_count: int, current_pressure: float, + target_pressure: float, valve_opening: float, + kp: float, ki: float, kd: float, + q_in: float, v: float): + """记录一个控制周期的数据点 + + Args: + cycle_count: 控制周期计数 + current_pressure: 当前压力 + target_pressure: 目标压力 + valve_opening: 阀门开度 + kp, ki, kd: PID 参数 + q_in: 流量 + v: 容积 + """ + # 目标压力变化时自动切分 Episode + if self.current_episode is None or target_pressure != self.last_target_record: + if self.current_episode is not None: + self.episode_data_raw.append(self.current_episode) + self.log(f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点") + + self.current_episode = { + 'pid': [float(kp), float(ki), float(kd)], + 'target_pressure': target_pressure, + 'Q_in': q_in, + 'V': v, + 'steps': [], + 'pressures': [], + 'errors': [], + 'valves': [] + } + self.last_target_record = target_pressure + + # 记录当前步数据 + error = -(target_pressure - current_pressure) + self.current_episode['steps'].append(cycle_count) + self.current_episode['pressures'].append(current_pressure) + self.current_episode['errors'].append(error) + self.current_episode['valves'].append(float(valve_opening)) + + def finalize_and_upload(self, flow: float, vol: float): + """停止控制时:闭合最后一个 Episode,分片上传到云存储。 + + 单文件超过 5MB 时自动拆分为多个分片, + 同时上传一个 manifest.json 记录所有分片信息。 + + Args: + flow: 流量值 (用于文件名/路径) + vol: 容积值 (用于文件名/路径) + """ + # 闭合最后一个 Episode + if self.current_episode and len(self.current_episode['pressures']) > 0: + self.episode_data_raw.append(self.current_episode) + self.current_episode = None + + if not self.episode_data_raw: + return + + # 上传在线程中继续执行,因此必须持有本轮数据快照。否则 finally + # 清空缓存后,异步线程生成的 manifest 会错误地显示 0 个 Episode。 + episodes = list(self.episode_data_raw) + + try: + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f') + base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L" + + # 拆成每片尽量不超过 5MB 的 episode 分组 + MAX_CHUNK_BYTES = 5 * 1024 * 1024 # 5MB + + chunks = [] # [(chunk_index, episodes_subset)] + current_chunk = [] + for ep in episodes: + current_chunk.append(ep) + if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES: + # 当前片已满,回退一个 episode 后保存 + current_chunk.pop() + # 单个 Episode 也可能超过 5 MB;此时仍上传该 Episode, + # 而不是产生一个无内容的空分片。 + if current_chunk: + chunks.append(current_chunk) + current_chunk = [ep] + if current_chunk: + chunks.append(current_chunk) + + total_chunks = len(chunks) + self.log(f"控制数据共 {len(episodes)} 个 Episode," + f"拆为 {total_chunks} 个分片上传") + + def upload_all(): + part_files = [] + part_metadata = [] + for idx, chunk_eps in enumerate(chunks): + data_bytes = pickle.dumps(chunk_eps) + size_kb = len(data_bytes) / 1024 + 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)...") + if self._upload_to_server(data_bytes, part_filename, base_folder): + part_files.append(part_filename) + part_metadata.append({ + "file_name": part_filename, + "episode_count": len(chunk_eps), + "size_bytes": len(data_bytes), + }) + else: + self.log(f" 分片 {idx + 1} 上传失败") + + # 上传 manifest + manifest = { + "schema_version": 1, + "data_type": "control_episode", + "run_id": timestamp, + "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 = [] diff --git a/ReinLoop/core/identification.py b/ReinLoop/core/identification.py index 85f724c..741b021 100644 --- a/ReinLoop/core/identification.py +++ b/ReinLoop/core/identification.py @@ -1,27 +1,27 @@ -# identification.py -"""辨识与容积测量管理器。 - -纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 -""" - -import io -import time -import threading -import datetime -import json +# identification.py +"""辨识与容积测量管理器。 + +纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 +""" + +import io +import time +import threading +import datetime +import json import traceback import requests from collections import deque - -from get_V import measure_volume -from ind_collector import collect_data_with_prbs - -from api import base_url, data_record_url, the_folder - - -class IdentificationManager: - """管理系统辨识与容积测量任务""" - + +from get_V import measure_volume +from ind_collector import collect_data_with_prbs + +from api import base_url, data_record_url, the_folder + + +class IdentificationManager: + """管理系统辨识与容积测量任务""" + def __init__(self): self._identifying = False self._task_thread = None @@ -29,16 +29,16 @@ class IdentificationManager: self._on_sample = None # 采样回调: (valve_cmd, pressure) self._on_volume_result = None # 容积结果回调: (volume_L: float) self._on_identification_upload = None - - # ---- 回调设置 ---- - def set_log_callback(self, callback): - """设置日志回调""" - self._on_log = callback - - def set_sample_callback(self, callback): - """设置采样时段 UI 更新回调: callback(valve_cmd, pressure)""" - self._on_sample = callback - + + # ---- 回调设置 ---- + def set_log_callback(self, callback): + """设置日志回调""" + self._on_log = callback + + def set_sample_callback(self, callback): + """设置采样时段 UI 更新回调: callback(valve_cmd, pressure)""" + self._on_sample = callback + def set_volume_result_callback(self, callback): """设置容积测量结果回调: callback(volume_L: float)""" self._on_volume_result = callback @@ -46,67 +46,61 @@ class IdentificationManager: def set_identification_upload_callback(self, callback): """设置辨识 CSV 上传结果回调: callback(success, filename, error)""" self._on_identification_upload = callback - - def log(self, message): - if self._on_log: - self._on_log(message) - - @property + + def log(self, message): + if self._on_log: + self._on_log(message) + + @property def is_running(self) -> bool: """当前是否正在辨识/测量中""" thread_alive = ( self._task_thread is not None and self._task_thread.is_alive() ) return self._identifying or thread_alive - - def _upload_to_cos(self, content, filename: str, folder: str) -> bool: - """通过云函数获取直传凭证,再将文本或字节数据直传到 COS。 - - 返回 True 表示上传成功,False 表示失败(已内部记 log)。 - """ - # Step 1: 向云函数申请直传凭证(不传文件内容) - try: - resp = requests.post(data_record_url, json={ - "type": "uploadDataFile", - "fileName": filename, - "folder": folder, - }, timeout=30) - result = resp.json() - except Exception as e: - self.log(f"向云函数申请凭证异常: {e}") - return False - - if not result.get("success"): - self.log(f"申请上传凭证失败: {result.get('errMsg', result)}") - return False - - meta = result.get("uploadMetadata") - if not meta or "url" not in meta or "authorization" not in meta: - self.log("云端未返回有效的上传元数据") - return False - - # Step 2: 直传到 COS - try: - form_data = { - "key": meta["cosFileId"], - "Signature": meta["authorization"], - "x-cos-security-token": meta["token"], - "x-cos-meta-fileid": meta["fileId"], - } + + def _upload_to_server(self, content, filename: str, folder: str) -> bool: + """向 ReinLoop 云服务器申请上传地址并上传文本或字节数据。 + + 返回 True 表示上传成功,False 表示失败(已内部记 log)。 + """ + # Step 1: 向业务服务器申请一次性上传地址(不传文件内容) + try: + resp = requests.post(data_record_url, json={ + "type": "uploadDataFile", + "fileName": filename, + "folder": folder, + }, timeout=30) + result = resp.json() + except Exception as e: + self.log(f"向云服务器申请上传地址异常: {e}") + return False + + if not result.get("success"): + self.log(f"申请服务器上传地址失败: {result.get('errMsg', result)}") + return False + + meta = result.get("uploadMetadata") + if not meta or "url" not in meta: + self.log("云服务器未返回有效的上传地址") + return False + + # Step 2: multipart 上传到云服务器提供的一次性地址 + try: content_bytes = ( content if isinstance(content, bytes) else str(content).encode("utf-8") ) files = {"file": (filename, io.BytesIO(content_bytes))} - cos_resp = requests.post(meta["url"], data=form_data, files=files, timeout=60) - - if cos_resp.status_code in [200, 204]: - return True - else: - self.log(f"COS 直传失败,状态码: {cos_resp.status_code}") - return False - except Exception as e: - self.log(f"COS 直传异常: {e}") + upload_resp = requests.post(meta["url"], files=files, timeout=60) + + if upload_resp.status_code in [200, 204]: + return True + else: + self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}") + return False + except Exception as e: + self.log(f"云服务器上传异常: {e}") return False 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') filename = f"travel_stability_pressures_{timestamp}.json" payload = {"stable_pressures": stable_pressure_records} - uploaded = self._upload_to_cos( + uploaded = self._upload_to_server( json.dumps(payload, ensure_ascii=False, indent=2), filename, f"{the_folder}/ind_data", @@ -236,31 +230,31 @@ class IdentificationManager: else: self.log("行程稳态压力 JSON 上传失败,继续执行 PRBS 辨识") return payload - - # ---- 系统辨识 ---- - def start_identification(self, *, - conn_mgr, - running_flag_check, - q_in_val: float, dt: float, - n_order: int, t_c: float, - levels: list, dead_area: float, - xa_full: float, V_val: float, - repeat: int = 2): - """启动辨识数据采集(在后台线程中运行) - - Args: - conn_mgr: ConnectionManager 实例 - running_flag_check: 检查是否应该停止的可调用对象, 返回 bool - q_in_val: 流量 (L/min) - dt: 控制周期 - n_order: 阶数 - t_c: 周期 (s) - levels: 序列 (阀门开度列表) - dead_area: 死区 - xa_full: 总限幅 - V_val: 容积 (L) - repeat: 整段复合序列重复次数,默认 2 - """ + + # ---- 系统辨识 ---- + def start_identification(self, *, + conn_mgr, + running_flag_check, + q_in_val: float, dt: float, + n_order: int, t_c: float, + levels: list, dead_area: float, + xa_full: float, V_val: float, + repeat: int = 2): + """启动辨识数据采集(在后台线程中运行) + + Args: + conn_mgr: ConnectionManager 实例 + running_flag_check: 检查是否应该停止的可调用对象, 返回 bool + q_in_val: 流量 (L/min) + dt: 控制周期 + n_order: 阶数 + t_c: 周期 (s) + levels: 序列 (阀门开度列表) + dead_area: 死区 + xa_full: 总限幅 + V_val: 容积 (L) + repeat: 整段复合序列重复次数,默认 2 + """ if running_flag_check(): self.log("错误:请先停止控制再进行辨识") return False @@ -272,14 +266,14 @@ class IdentificationManager: if not conn_mgr or not conn_mgr.is_connected(): self.log("错误:请先连接设备") return False - - self._identifying = True - self.log("开始辨识数据采集...") - - def _on_sample_point(t, u_cmd, p): - if self._on_sample: - self._on_sample(u_cmd, p) - + + self._identifying = True + self.log("开始辨识数据采集...") + + def _on_sample_point(t, u_cmd, p): + if self._on_sample: + self._on_sample(u_cmd, p) + def collect_thread(): try: # Independent pre-scan. The PRBS call below is intentionally @@ -289,18 +283,18 @@ class IdentificationManager: return result = collect_data_with_prbs( - conn_mgr, - q_in_val=q_in_val, dt=dt, - n_order=n_order, t_c=t_c, - levels=levels, dead_area=dead_area, - xa_full=xa_full, - V_val=V_val, - should_stop=lambda: not self._identifying, - log=self.log, - on_sample=_on_sample_point, - repeat=repeat, - ) - + conn_mgr, + q_in_val=q_in_val, dt=dt, + n_order=n_order, t_c=t_c, + levels=levels, dead_area=dead_area, + xa_full=xa_full, + V_val=V_val, + should_stop=lambda: not self._identifying, + log=self.log, + on_sample=_on_sample_point, + repeat=repeat, + ) + if result.get('success'): csv_data = result.get("csv_data") csv_filename = result.get("filename") @@ -309,7 +303,7 @@ class IdentificationManager: self.log(error) if self._on_identification_upload: 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"): self.log("辨识数据上传成功") if self._on_identification_upload: @@ -334,25 +328,25 @@ class IdentificationManager: self.log(f"辨识数据采集失败: {e}") if self._on_identification_upload: self._on_identification_upload(False, None, str(e)) - finally: - self._identifying = False - # self.log("辨识结束") - + finally: + self._identifying = False + # self.log("辨识结束") + thread = threading.Thread(target=collect_thread, daemon=True) self._task_thread = thread thread.start() return True - - # ---- 容积测量 ---- - def start_volume_measurement(self, *, - conn_mgr, - running_flag_check, - q_in_val: float, dt: float, - p_max: float, fit_low: float, - fit_high: float, T_delta: float, - xa_full: float = 1000, - num_runs: int = 3): - """启动容积测量(在后台线程中运行)""" + + # ---- 容积测量 ---- + def start_volume_measurement(self, *, + conn_mgr, + running_flag_check, + q_in_val: float, dt: float, + p_max: float, fit_low: float, + fit_high: float, T_delta: float, + xa_full: float = 1000, + num_runs: int = 3): + """启动容积测量(在后台线程中运行)""" if running_flag_check(): self.log("错误:请先停止控制再进行测试") return False @@ -364,124 +358,124 @@ class IdentificationManager: if not conn_mgr or not conn_mgr.is_connected(): self.log("错误:请先连接设备") return False - - self._identifying = True - self.log("开始测量容积...") - - def _on_vol_sample(t, p): - if self._on_sample: - self._on_sample(None, p) - - def volume_thread(): - all_results = [] # 存储每次成功的结果 - - try: - for run_idx in range(num_runs): - if not self._identifying: - break - - print(f"--- 第 {run_idx + 1}/{num_runs} 次测量 ---") - - # 非首次测量前,等待压力回落 - if run_idx > 0: - print("等待压力回落...") - wait_start = time.time() - while time.time() - wait_start < 60: # 最多等 60 秒 - p = conn_mgr.read_pressure() - if p is not None and p < fit_low: - print(f"压力已回落至 {p:.1f} kPa,等待 10 秒稳定...") - time.sleep(10) - break - time.sleep(1) - else: - print("等待压力回落超时,跳过剩余测量") - break - - result = measure_volume( - conn_mgr, - q_in_slm=q_in_val, - dt=dt, - xa=xa_full, - p_max=p_max, - fit_low=fit_low, - fit_high=fit_high, - T_delta=T_delta, - should_stop=lambda: not self._identifying, - log=self.log, - on_sample=_on_vol_sample, - ) - - if result.get('success'): - all_results.append(result) - print(f"第 {run_idx + 1} 次测量成功,V = {result['volume_L']:.4f} L") - else: - print(f"第 {run_idx + 1} 次测量失败") - - # ---- 汇总 ---- - if all_results: - n = len(all_results) - - # 平均关键参数 - avg_vol = sum(r['volume_L'] 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_c1 = sum(r['c1'] for r in all_results) / n - - # 构建上传数据 - timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') - individual_runs = [] - for i, r in enumerate(all_results): - individual_runs.append({ - "run": i + 1, - "volume_L": r['volume_L'], - "slope": r['slope'], - "intercept": r['intercept'], - "c1": r['c1'], - "valid_points": r['valid_points'], - "record_time": r.get('record_time', []), - "p_actual": r.get('p_actual', []), - }) - - full_data = { - "num_runs_total": num_runs, - "num_runs_successful": n, - "averaged": { - "volume_L": avg_vol, - "slope": avg_slope, - "intercept": avg_intercept, - "c1": avg_c1, - }, - "individual_runs": individual_runs, - "q_in_slm": all_results[0]['payload_data'].get('q_in_slm'), - "T_delta": T_delta, - } - json_str = json.dumps(full_data, indent=2, ensure_ascii=False) - filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json" - - if self._upload_to_cos(json_str, filename, f"{the_folder}/V_config"): - self.log("体积测量数据上传成功") - self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L") - - else: - self.log("体积测量数据上传失败") - - if self._on_volume_result: - self._on_volume_result(avg_vol) - else: - self.log("所有测量均失败:有效数据点不足,无法计算体积") - - except Exception as e: - self.log(f"容积测量详细错误: {traceback.format_exc()}") - self.log(f"容积测量失败: {e}") - finally: - self._identifying = False - # self.log("测量结束") - + + self._identifying = True + self.log("开始测量容积...") + + def _on_vol_sample(t, p): + if self._on_sample: + self._on_sample(None, p) + + def volume_thread(): + all_results = [] # 存储每次成功的结果 + + try: + for run_idx in range(num_runs): + if not self._identifying: + break + + print(f"--- 第 {run_idx + 1}/{num_runs} 次测量 ---") + + # 非首次测量前,等待压力回落 + if run_idx > 0: + print("等待压力回落...") + wait_start = time.time() + while time.time() - wait_start < 60: # 最多等 60 秒 + p = conn_mgr.read_pressure() + if p is not None and p < fit_low: + print(f"压力已回落至 {p:.1f} kPa,等待 10 秒稳定...") + time.sleep(10) + break + time.sleep(1) + else: + print("等待压力回落超时,跳过剩余测量") + break + + result = measure_volume( + conn_mgr, + q_in_slm=q_in_val, + dt=dt, + xa=xa_full, + p_max=p_max, + fit_low=fit_low, + fit_high=fit_high, + T_delta=T_delta, + should_stop=lambda: not self._identifying, + log=self.log, + on_sample=_on_vol_sample, + ) + + if result.get('success'): + all_results.append(result) + print(f"第 {run_idx + 1} 次测量成功,V = {result['volume_L']:.4f} L") + else: + print(f"第 {run_idx + 1} 次测量失败") + + # ---- 汇总 ---- + if all_results: + n = len(all_results) + + # 平均关键参数 + avg_vol = sum(r['volume_L'] 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_c1 = sum(r['c1'] for r in all_results) / n + + # 构建上传数据 + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + individual_runs = [] + for i, r in enumerate(all_results): + individual_runs.append({ + "run": i + 1, + "volume_L": r['volume_L'], + "slope": r['slope'], + "intercept": r['intercept'], + "c1": r['c1'], + "valid_points": r['valid_points'], + "record_time": r.get('record_time', []), + "p_actual": r.get('p_actual', []), + }) + + full_data = { + "num_runs_total": num_runs, + "num_runs_successful": n, + "averaged": { + "volume_L": avg_vol, + "slope": avg_slope, + "intercept": avg_intercept, + "c1": avg_c1, + }, + "individual_runs": individual_runs, + "q_in_slm": all_results[0]['payload_data'].get('q_in_slm'), + "T_delta": T_delta, + } + json_str = json.dumps(full_data, indent=2, ensure_ascii=False) + filename = f"volume_test_avg{avg_vol:.2f}L_{timestamp}.json" + + if self._upload_to_server(json_str, filename, f"{the_folder}/V_config"): + self.log("体积测量数据上传成功") + self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L") + + else: + self.log("体积测量数据上传失败") + + if self._on_volume_result: + self._on_volume_result(avg_vol) + else: + self.log("所有测量均失败:有效数据点不足,无法计算体积") + + except Exception as e: + self.log(f"容积测量详细错误: {traceback.format_exc()}") + self.log(f"容积测量失败: {e}") + finally: + self._identifying = False + # self.log("测量结束") + thread = threading.Thread(target=volume_thread, daemon=True) self._task_thread = thread thread.start() return True - - def stop(self): - """停止当前辨识/测量任务""" - self._identifying = False + + def stop(self): + """停止当前辨识/测量任务""" + self._identifying = False diff --git a/ReinLoop/core/identification_config.py b/ReinLoop/core/identification_config.py index c89944f..63b3a45 100644 --- a/ReinLoop/core/identification_config.py +++ b/ReinLoop/core/identification_config.py @@ -128,7 +128,7 @@ def parse_identification_config_csv(csv_text: str) -> 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 from api import data_record_url, the_folder @@ -140,10 +140,10 @@ def download_identification_config(timeout=20) -> dict: response.raise_for_status() result = response.json() except Exception as exc: - raise ValueError(f"连接云端辨识配置服务失败: {exc}") from exc + raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc if not result.get("success"): - raise ValueError(result.get("errMsg", "云端未返回辨识配置")) + raise ValueError(result.get("errMsg", "云服务器未返回辨识配置")) try: config_response = requests.get(result["url"], timeout=timeout) config_response.raise_for_status() diff --git a/ReinLoop/features.md b/ReinLoop/features.md index 650906d..f0d00e6 100644 --- a/ReinLoop/features.md +++ b/ReinLoop/features.md @@ -5,7 +5,8 @@ ## 约定 - 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用 - `REINLOOP_SERVER_URL + /api`。 + `REINLOOP_SERVER_URL + /api`;默认地址为 + `https://ReinLoop.dominatedconvergence.com/api`。 - 新许可证的设备标识为 `/`,在代码中通过 `api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`。 - 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。 @@ -67,11 +68,28 @@ RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力 | --- | --- | --- | | 初始化一轮采集 | `DataCollector.reset()` | 清空 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 到 `/data_record/...`。 | -| 上传凭证与直传 | `DataCollector._upload_to_cos(data_bytes, filename, folder)` | 内部接口;先请求上传凭证,再将对象直传。 | +| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `/data_record/data_SLM_L`。清单含 `data_type: "control_episode"`、`run_id`、分片元数据和总 Episode 数。 | +| 申请上传地址并上传 | `DataCollector._upload_to_server(data_bytes, filename, folder)` | 内部接口;先向 ReinLoop 云服务器申请一次性上传地址,再以 multipart 上传文件。 | +| 上传结果通知 | `DataCollector.set_upload_complete_callback(callback)` | 注册 `callback(success, manifest, error)`;在后台上传线程完成时调用。 | 上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。 +### 控制数据服务端约定 + +控制数据上传目录固定以 `/data_record/` 为前缀;每次停止控制会上传 +若干 `.pkl` 分片和一个同名时间戳的 `_manifest.json`。服务端在接收二步上传的文件后, +应保留文件元数据,并向管理端提供以下仅管理员可调用的接口: + +| `type` | 请求字段 | 成功响应 | 服务端行为 | +| --- | --- | --- | --- | +| `listControlFiles` | `deviceId`、可选 `page`、`pageSize` | `files`、`total`、`page`、`pageSize` | 仅返回 `folder` 以 `/data_record/` 开头的记录;每条至少有 `fileID`、`fileName`、`folder`、`uploadTime`、`size`。 | +| `getControlFileDownload` | `fileID` | `fileID`、`fileName`、`url` | 仅允许下载控制数据目录内的文件,并返回短期签名下载 URL。 | +| `deleteControlFile` | `fileID` | `deletedCount` | 仅允许删除控制数据目录内的文件;同时删除文件本体及对应元数据。 | + +上述三个接口必须校验管理端令牌,并根据 `fileID` 对应记录的目录验证设备边界,不能仅信任 +调用方传入的设备标识。Panel 可直接展示 JSON manifest;`.pkl` 为 Python pickle 二进制, +应仅供下载,不应在管理端进程中反序列化。 + ## 系统辨识 | 功能 | 接口 | 返回或行为 | diff --git a/ReinLoop/license_utils.py b/ReinLoop/license_utils.py index 5b4abdf..df7e0d8 100644 --- a/ReinLoop/license_utils.py +++ b/ReinLoop/license_utils.py @@ -1,656 +1,656 @@ -# license_utils.py -"""RSA 验签许可证模块 —— 嵌入 exe,验签 + 每日巡检 + 防改系统时间。 - -用法(在 main.py 或主窗口 __init__ 中调用一次即可): - from license_utils import check_license, start_license_watchdog - - check_license() # 启动时验签(失败则抛异常退出) - start_license_watchdog(interval_minutes=1440) # 后台每天巡检 -""" - -import json -import base64 -import os -import sys -import threading -import time as _time_module -import datetime -from pathlib import Path - -import requests - -from cryptography.hazmat.primitives.asymmetric import rsa, padding -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.exceptions import InvalidSignature - -# ============================================================ -# 公钥(编译进 exe,可公开)—— 与 ControlPanel 使用的签名私钥配对 -# 公钥更新由受控的发布流程完成,客户端不包含任何签发能力。 -# ============================================================ -# {{LICENSE_PUBLIC_KEY_START}} -PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- -MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArQ4l2ePnl+p9yuUWcuOK -CQ4bzixyJKh8tPSVMwqI7u02v9qdrIPu5j3T0tl+qCNTLMP6WHd6s09M7bWuVTnU -220ljrdBb8opIzNGxTTri1k3JqMI95ljLwdEB6vp+ISyrdFKG4o+B+hDDvxkbyGH -2vKBG71Wrws4ujOEI1H8MDWDRMyGrHFQZZk1Sz6WkgWT+yjoZ8L0K3o0afIYw8F9 -J50aaRQ9fvNW4EB+Pa5Yy5DQrNIs1smMVDpegdYt5uUMwEnfoS6Y6l98Gz7ljZ5n -6/WVaFb55XquAwsF/zq6oDfKBrAOBqzT2YYZklr8swlKKIJ3ExA1sd/dxhfZhidi -pqCvye6+cYa5GTRu9knzBsVPdzzhQC5AqKUuPglVJV8dQPfH7Nb7EP5wvNSgzpLT -9wsIoZXm9GXD0hApHvobSiZnpqY5g9InV7fQZyold2zFhHWpDieNjX0844gQafnH -Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE= ------END PUBLIC KEY-----""" -# {{LICENSE_PUBLIC_KEY_END}} - -# 许可证文件相对路径 -LICENSE_FILE = "license.lic" - -# 巡检间隔(分钟) -DEFAULT_CHECK_INTERVAL = 5 -ONLINE_CHECK_TIMEOUT_SECONDS = 5 -DEFAULT_OFFLINE_HOURS = 72 - -# 过期后宽限期(小时),给用户保存工作的时间 -GRACE_PERIOD_HOURS = 2 - -# ============================================================ -# 内部状态 -# ============================================================ -_startup_monotonic = _time_module.monotonic() # 软件启动时刻(不受系统时间影响) -_last_check_result = None -_verified_license = None -_verified_license_lock = threading.RLock() -_watchdog_started = False -_watchdog_lock = threading.Lock() -_last_online_success_monotonic = None -_on_expired_callback = None # 过期回调,可由外部设置 -_on_grace_callback = None # 缓冲期回调 -_on_log_callback = None # 日志回调,供 UI 状态栏显示 -_warning_shown_states = { - "expiring_today": False, # 到期当天警告是否已弹窗 - "expiring_soon": False, # 即将到期(30天内)警告是否已弹窗 - "expired_grace": False, # 过期缓冲期警告是否已弹窗 -} - -# ============================================================ -# GUI 线程安全工具 -# ============================================================ -def _invoke_on_qt_thread(func): - """在 Qt 主线程中安全执行 func。 - - 使用 QTimer.singleShot 将回调排队到主线程事件循环。 - 关键:必须传入 QApplication 作为 context(receiver),否则 QTimer 会被调度到 - 当前线程(watchdog 是 daemon 线程,没有 Qt event loop),导致静默永不触发。 - """ - try: - from PySide6.QtCore import QTimer - from PySide6.QtWidgets import QApplication - app = QApplication.instance() - if app is not None: - QTimer.singleShot(0, app, func) - return True - except Exception: - pass - - # 兜底:没有 QApplication 时直接调用(GUI 还未初始化) - try: - func() - except Exception: - pass - return False - - -def _show_message_box(icon, title, message): - """线程安全地显示 QMessageBox。 - - Args: - icon: 'critical', 'warning', 'information' - title: 弹窗标题 - message: 弹窗内容 - """ - def _show(): - try: - from PySide6.QtWidgets import QApplication, QMessageBox - app = QApplication.instance() - if app is None: - return - if icon == 'critical': - QMessageBox.critical(None, title, message) - elif icon == 'warning': - QMessageBox.warning(None, title, message) - else: - QMessageBox.information(None, title, message) - except Exception: - pass - - _invoke_on_qt_thread(_show) - - -# ============================================================ -# 验签核心 -# ============================================================ -def _load_public_key(): - """从内嵌的 PEM 加载公钥""" - return serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode()) - - -def _validate_device_id(device_id): - """确保新许可证中的目录键只能是 ``company/line``。""" - if not isinstance(device_id, str): - raise ValueError("许可证 device_id 必须是字符串") - segments = device_id.split("/") - if len(segments) != 2 or any( - not segment or segment in (".", "..") or "\\" in segment - for segment in segments): - raise ValueError("许可证 device_id 必须是 company/production-line 格式") - - -def _validate_payload(payload): - if not isinstance(payload, dict): - raise ValueError("许可证内容必须是对象") - - new_fields = ("license_id", "company_id", "production_line_id", "device_id") - has_new_format = any(field in payload for field in new_fields) - if has_new_format: - missing = [field for field in new_fields - if not isinstance(payload.get(field), str) or not payload[field].strip()] - if missing: - raise ValueError(f"新许可证缺少标识字段: {', '.join(missing)}") - _validate_device_id(payload["device_id"]) - else: - _log("旧许可证不支持在线撤销") - - if not isinstance(payload.get("customer"), str) or not payload["customer"].strip(): - raise ValueError("许可证缺少客户信息") - return has_new_format - - -def verify_license(lic_path=None): - """验证许可证签名 + 有效期。 - - Args: - lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic - - Returns: - dict: 许可证 payload(customer, expiry, issued 等) - - Raises: - FileNotFoundError: 许可证文件不存在 - InvalidSignature: 签名不匹配(伪造/篡改) - RuntimeError: 许可证已过期 - ValueError: 许可证格式错误 - """ - if lic_path is None: - # PyInstaller 打包后 sys.executable 是 exe 路径 - exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd() - lic_path = exe_dir / LICENSE_FILE - - if not os.path.exists(lic_path): - raise FileNotFoundError(f"许可证文件不存在: {lic_path}") - - with open(lic_path, "r", encoding="utf-8") as f: - raw = f.read().strip() - - # 解析: payload_base64 | signature_base64 - if "|" not in raw: - raise ValueError("许可证格式错误") - - payload_b64, signature_b64 = raw.split("|", 1) - signature = base64.b64decode(signature_b64) - - # RSA-PSS SHA256 验签 - try: - pub_key = _load_public_key() - pub_key.verify( - signature, - payload_b64.encode(), # 签名的是 base64 字符串本身 - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH, - ), - hashes.SHA256(), - ) - except InvalidSignature: - raise InvalidSignature("许可证签名验证失败:文件可能被篡改") - - # 解析载荷 - try: - payload = json.loads(base64.b64decode(payload_b64)) - except Exception: - raise ValueError("许可证内容解析失败") - - _validate_payload(payload) - - # 有效期检查(精确到小时) - expiry_str = payload.get("expiry") - if not expiry_str: - raise ValueError("许可证缺少过期时间") - - # 兼容旧格式 YYYY-MM-DD(视为当天 23:59) - if len(expiry_str) == 10: - expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M") - else: - expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M") - - trusted_now = _get_trusted_now() - - if trusted_now > expiry: - gap_days = (trusted_now - expiry).days - uptime_days = _get_uptime_days() - - # 反时钟篡改:需同时满足两个条件才怀疑用户拨慢系统时间 - # ① 软件运行不到 1 天(刚启动) - # ② 过期时间差超过 7 天(差距巨大 → 文件 mtime 暴露了真实时间) - # 缺失任一条件 → 真过期,直接报错: - # - gap 小(几分钟~几小时)→ 刚过期,正常报错 - # - 软件跑了很久 → 正常使用中过期,正常报错 - if uptime_days < 1 and gap_days > 7: - issued_str = payload.get("issued", expiry_str) - if len(issued_str) == 10: - issued = datetime.datetime.strptime(issued_str + " 00:00", "%Y-%m-%d %H:%M") - else: - issued = datetime.datetime.strptime(issued_str, "%Y-%m-%d %H:%M") - estimated_now = issued + datetime.timedelta(days=uptime_days) - if estimated_now <= expiry: - return payload - - raise ExpiredError( - f"许可证已过期 (到期: {expiry_str})", - expiry=expiry, - ) - - return payload - - -def get_verified_license(): - """返回本进程已通过本地验证的许可证载荷,尚未验证时返回 ``None``。""" - with _verified_license_lock: - return dict(_verified_license) if _verified_license is not None else None - - -def _is_new_license(payload): - return all(payload.get(field) for field in ( - "license_id", "company_id", "production_line_id", "device_id")) - - -def _api_url(): - base_url = os.environ.get( - "REINLOOP_SERVER_URL", "http://ReinLoop.dominatedconvergence.com" - ).rstrip("/") - return os.environ.get("REINLOOP_API_URL", f"{base_url}/api") - - -def _offline_limit_seconds(): - try: - hours = float(os.environ.get("REINLOOP_LICENSE_OFFLINE_HOURS", DEFAULT_OFFLINE_HOURS)) - except ValueError: - hours = DEFAULT_OFFLINE_HOURS - return max(0, hours) * 3600 - - -def validate_license_online(payload): - """验证可撤销许可证的在线状态。 - - 网络故障只在超过离线时限后失效;服务端明确拒绝则立即返回 ExpiredError。 - """ - global _last_online_success_monotonic - if not _is_new_license(payload): - return - - try: - response = requests.post(_api_url(), json={ - "type": "validateLicense", - "licenseId": payload["license_id"], - "deviceId": payload["device_id"], - }, timeout=ONLINE_CHECK_TIMEOUT_SECONDS) - response.raise_for_status() - result = response.json() - except (requests.RequestException, ValueError) as exc: - now = _time_module.monotonic() - if _last_online_success_monotonic is None: - _last_online_success_monotonic = _startup_monotonic - if now - _last_online_success_monotonic > _offline_limit_seconds(): - raise ExpiredError("许可证在线校验超过离线宽限期") from exc - _log(f"许可证在线校验暂不可用: {exc}") - return - - status = result.get("status") - if not result.get("success") or not result.get("valid") or status != "active": - reason = status or result.get("errMsg") or "invalid" - raise ExpiredError(f"许可证在线状态无效: {reason}") - if result.get("licenseId") not in (None, payload["license_id"]): - raise ExpiredError("许可证在线校验返回了不匹配的许可证") - _last_online_success_monotonic = _time_module.monotonic() - - -# ============================================================ -# 时间可信度 -# ============================================================ -def _get_uptime_days(): - """软件已连续运行的天数(基于 monotonic,不受系统时间影响)""" - return (_time_module.monotonic() - _startup_monotonic) / 86400 - - -def _get_trusted_now(): - """多源交叉校验获取可信日期时间(精确到小时)。 - - 取系统时间和文件时间的最大值,防止用户回拨系统时间。 - """ - candidates = [] - - # 1. 系统时间 - candidates.append(datetime.datetime.now()) - - # 2. 软件启动时记录的"最早可能时间" - # monotonic 计时推导出的启动时间 - startup_guess = datetime.datetime.now() - datetime.timedelta( - days=_get_uptime_days() - ) - candidates.append(startup_guess) - - # 3. 系统文件的修改时间(不易被用户修改) - system_files = [] - if sys.platform == "darwin": - system_files = [ - "/System/Library/CoreServices/SystemVersion.plist", - "/usr/bin/python3", - ] - elif sys.platform == "win32": - system_files = [ - r"C:\Windows\System32\ntoskrnl.exe", - r"C:\Windows\explorer.exe", - ] - - for sf in system_files: - if os.path.exists(sf): - try: - mtime = os.path.getmtime(sf) - candidates.append(datetime.datetime.fromtimestamp(mtime)) - except OSError: - pass - - # 取最大值(真时间 ≥ 所有候选值。用户可能回拨,但不能让其他文件"变新") - return max(candidates) - - -# ============================================================ -# 后台巡检 -# ============================================================ -def start_license_watchdog(interval_minutes=DEFAULT_CHECK_INTERVAL): - """启动后台许可证巡检线程。 - - Args: - interval_minutes: 检查间隔(分钟),默认 1440(24小时) - """ - global _watchdog_started - with _watchdog_lock: - if _watchdog_started: - return - _watchdog_started = True - t = threading.Thread( - target=_watchdog_loop, - args=(max(5, interval_minutes),), - daemon=True, - name="license-watchdog", - ) - t.start() - - -def _watchdog_loop(interval_minutes): - while True: - _time_module.sleep(interval_minutes * 60) - - try: - payload = verify_license() - with _verified_license_lock: - global _verified_license - _verified_license = dict(payload) - validate_license_online(payload) - expiry_str = payload["expiry"] - # 解析到期时间(兼容旧格式) - if len(expiry_str) == 10: - expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M") - else: - expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M") - - trusted_now = _get_trusted_now() - hours_left = (expiry - trusted_now).total_seconds() / 3600 - days_left = int(hours_left / 24) - - if hours_left < 0: - # 已过期(不应走到这里,verify_license 会抛 ExpiredError) - _log(f"⚠ 许可证已过期 ({expiry_str})") - _show_message_box('critical', "许可证已过期", - f"您的许可证已于 {expiry_str} 到期!\n请尽快联系厂商续期。") - - elif hours_left <= 24: - if not _warning_shown_states["expiring_today"]: - _warning_shown_states["expiring_today"] = True - _log(f"⚠ 许可证将在今天到期 ({expiry_str})") - _show_message_box('warning', "许可证即将到期", - f"您的许可证将于今天 {expiry_str} 到期!\n" - f"剩余约 {int(hours_left)} 小时,请及时联系厂商续期。") - - elif days_left <= 30: - if not _warning_shown_states["expiring_soon"]: - _warning_shown_states["expiring_soon"] = True - _log(f"⚠ 许可证将在 {days_left} 天后到期 ({expiry_str})") - _show_message_box('warning', "许可证即将到期", - f"您的许可证将在 {days_left} 天后({expiry_str})到期\n请提前联系厂商续期。") - - except ExpiredError as e: - _handle_expired(e) - - except Exception as e: - _log(f"许可证巡检异常: {e}") - - -def _handle_expired(error): - """处理许可证过期""" - _log(f"❌ {error}") - - # 弹窗:许可证已过期 - _show_message_box('critical', "许可证已过期", - f"{error}\n\n软件将在 {GRACE_PERIOD_HOURS} 小时缓冲期后自动退出,\n请及时保存工作并联系厂商续期。") - - if _on_expired_callback: - _on_expired_callback(str(error)) - - # 宽限期:给用户时间保存工作 - grace_start = _time_module.monotonic() - grace_seconds = GRACE_PERIOD_HOURS * 3600 - - if not _warning_shown_states["expired_grace"]: - _warning_shown_states["expired_grace"] = True - if _on_grace_callback: - _on_grace_callback(GRACE_PERIOD_HOURS) - - # 半小时后提醒一次 - warned_half = False - while _time_module.monotonic() - grace_start < grace_seconds: - elapsed = _time_module.monotonic() - grace_start - if not warned_half and elapsed > grace_seconds / 2: - warned_half = True - _show_message_box('warning', "许可证已过期", - f"缓冲期剩余约 {GRACE_PERIOD_HOURS // 2} 小时,\n请尽快保存工作!") - _time_module.sleep(60) # 每分钟检查一次 - - # 宽限期过,强制退出 - _log("宽限期已过,软件即将退出") - _show_message_box('critical', "许可证已过期", - "缓冲期已结束,软件即将退出。\n请联系厂商续期后重新启动。") - - # 给 5 秒做最后的清理 - _time_module.sleep(5) - os._exit(1) - - -def set_on_expired(callback): - """设置过期回调: callback(message: str)""" - global _on_expired_callback - _on_expired_callback = callback - - -def set_on_grace(callback): - """设置宽限期回调: callback(hours: int)""" - global _on_grace_callback - _on_grace_callback = callback - - -def set_on_log(callback): - """设置日志回调: callback(message: str) - 所有许可证关键日志会同时输出到此回调,供 UI 状态栏显示。 - """ - global _on_log_callback - _on_log_callback = callback - - -# ============================================================ -# 便捷入口 -# ============================================================ -class ExpiredError(RuntimeError): - """许可证过期异常""" - def __init__(self, message, expiry=None): - super().__init__(message) - self.expiry = expiry - - -def _log(msg): - """终端日志 + UI 回调(不依赖任何 UI 层)""" - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - formatted = f"[License {timestamp}] {msg}" - print(formatted) - # 同步推送到 UI 状态栏(如果已注册回调) - if _on_log_callback: - try: - _on_log_callback(str(msg)) - except Exception: - pass - - -def check_license(lic_path=None): - """启动时调用:验证许可证,通过则返回 payload。 - - 在 main.py 的 main() 函数开头调用一次即可。 - 内部会自动启动后台巡检线程。 - - Returns: - dict: 许可证载荷 - - Raises: - SystemExit: 验签失败或已过期(启动阶段直接退出) - """ - try: - payload = verify_license(lic_path) - environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() - if (_is_new_license(payload) and environment_device_id - and environment_device_id != payload["device_id"]): - raise ValueError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致") - validate_license_online(payload) - with _verified_license_lock: - global _verified_license - _verified_license = dict(payload) - start_license_watchdog() - expiry = payload.get("expiry", "未知") - customer = payload.get("customer", "未知") - _log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}") - return payload - - except FileNotFoundError as e: - _log(f"❌ {e}") - _show_error_and_exit( - "未找到许可证文件", - "请将 license.lic 放到软件根目录,然后重新启动程序。\n\n" - "如有疑问,请联系厂商获取有效的许可证文件。" - ) - - except InvalidSignature as e: - _log(f"❌ {e}") - _show_error_and_exit( - "许可证验证失败", - "许可证签名校验不通过,文件可能已被篡改。\n\n" - "请使用原始签发的 license.lic 文件,\n" - "或联系厂商重新签发。" - ) - - except ExpiredError as e: - _log(f"❌ {e}") - _show_error_and_exit( - "许可证已过期", - f"您的许可证已于 {e.expiry} 到期。\n\n" - "请联系厂商续期,获取新的许可证文件后重新启动。" - ) - - except Exception as e: - _log(f"❌ 许可证检查异常: {e}") - _show_error_and_exit(f"许可证校验失败: {e}", str(e)) - - -def _show_error_and_exit(title, detail=""): - """显示错误弹窗并退出(兼容 GUI 和无 GUI 模式)。 - - Args: - title: 弹窗标题(简短概要) - detail: 弹窗正文(详细说明和操作建议) - """ - message = f"{title}\n\n{detail}" if detail else title - - # 尝试 GUI 弹窗 - try: - from PySide6.QtWidgets import QApplication, QMessageBox - app = QApplication.instance() - if app is not None: - QMessageBox.critical(None, title, detail or title) - else: - # 无 QApplication 实例时,尝试创建一个临时的 - try: - app = QApplication(sys.argv[:1]) - QMessageBox.critical(None, title, detail or title) - except Exception: - pass - except Exception: - pass - - print(f"\n{'='*50}") - print(message) - print(f"{'='*50}\n") - sys.exit(1) - - -# ============================================================ -# 辅助:获取许可证信息(供 UI 显示) -# ============================================================ -def get_license_info(lic_path=None): - """读取许可证信息(不做过期检查),供 UI 显示。 - - Returns: - dict | None: 许可证信息,文件不存在则返回 None - """ - try: - if lic_path is None: - exe_dir = ( - Path(sys.executable).parent - if getattr(sys, 'frozen', False) - else Path.cwd() - ) - lic_path = exe_dir / LICENSE_FILE - - if not os.path.exists(lic_path): - return None - - with open(lic_path, "r", encoding="utf-8") as f: - raw = f.read().strip() - - payload_b64 = raw.split("|")[0] - payload = json.loads(base64.b64decode(payload_b64)) - - # 先验签保证内容可信 - verify_license(lic_path) - - return { - "customer": payload.get("customer", "未知"), - "expiry": payload.get("expiry", "未知"), - "issued": payload.get("issued", "未知"), - "features": payload.get("features", "*"), - } - except Exception: - return None +# license_utils.py +"""RSA 验签许可证模块 —— 嵌入 exe,验签 + 每日巡检 + 防改系统时间。 + +用法(在 main.py 或主窗口 __init__ 中调用一次即可): + from license_utils import check_license, start_license_watchdog + + check_license() # 启动时验签(失败则抛异常退出) + start_license_watchdog(interval_minutes=1440) # 后台每天巡检 +""" + +import json +import base64 +import os +import sys +import threading +import time as _time_module +import datetime +from pathlib import Path + +import requests + +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.exceptions import InvalidSignature + +# ============================================================ +# 公钥(编译进 exe,可公开)—— 与 ControlPanel 使用的签名私钥配对 +# 公钥更新由受控的发布流程完成,客户端不包含任何签发能力。 +# ============================================================ +# {{LICENSE_PUBLIC_KEY_START}} +PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArQ4l2ePnl+p9yuUWcuOK +CQ4bzixyJKh8tPSVMwqI7u02v9qdrIPu5j3T0tl+qCNTLMP6WHd6s09M7bWuVTnU +220ljrdBb8opIzNGxTTri1k3JqMI95ljLwdEB6vp+ISyrdFKG4o+B+hDDvxkbyGH +2vKBG71Wrws4ujOEI1H8MDWDRMyGrHFQZZk1Sz6WkgWT+yjoZ8L0K3o0afIYw8F9 +J50aaRQ9fvNW4EB+Pa5Yy5DQrNIs1smMVDpegdYt5uUMwEnfoS6Y6l98Gz7ljZ5n +6/WVaFb55XquAwsF/zq6oDfKBrAOBqzT2YYZklr8swlKKIJ3ExA1sd/dxhfZhidi +pqCvye6+cYa5GTRu9knzBsVPdzzhQC5AqKUuPglVJV8dQPfH7Nb7EP5wvNSgzpLT +9wsIoZXm9GXD0hApHvobSiZnpqY5g9InV7fQZyold2zFhHWpDieNjX0844gQafnH +Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE= +-----END PUBLIC KEY-----""" +# {{LICENSE_PUBLIC_KEY_END}} + +# 许可证文件相对路径 +LICENSE_FILE = "license.lic" + +# 巡检间隔(分钟) +DEFAULT_CHECK_INTERVAL = 5 +ONLINE_CHECK_TIMEOUT_SECONDS = 5 +DEFAULT_OFFLINE_HOURS = 72 + +# 过期后宽限期(小时),给用户保存工作的时间 +GRACE_PERIOD_HOURS = 2 + +# ============================================================ +# 内部状态 +# ============================================================ +_startup_monotonic = _time_module.monotonic() # 软件启动时刻(不受系统时间影响) +_last_check_result = None +_verified_license = None +_verified_license_lock = threading.RLock() +_watchdog_started = False +_watchdog_lock = threading.Lock() +_last_online_success_monotonic = None +_on_expired_callback = None # 过期回调,可由外部设置 +_on_grace_callback = None # 缓冲期回调 +_on_log_callback = None # 日志回调,供 UI 状态栏显示 +_warning_shown_states = { + "expiring_today": False, # 到期当天警告是否已弹窗 + "expiring_soon": False, # 即将到期(30天内)警告是否已弹窗 + "expired_grace": False, # 过期缓冲期警告是否已弹窗 +} + +# ============================================================ +# GUI 线程安全工具 +# ============================================================ +def _invoke_on_qt_thread(func): + """在 Qt 主线程中安全执行 func。 + + 使用 QTimer.singleShot 将回调排队到主线程事件循环。 + 关键:必须传入 QApplication 作为 context(receiver),否则 QTimer 会被调度到 + 当前线程(watchdog 是 daemon 线程,没有 Qt event loop),导致静默永不触发。 + """ + try: + from PySide6.QtCore import QTimer + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app is not None: + QTimer.singleShot(0, app, func) + return True + except Exception: + pass + + # 兜底:没有 QApplication 时直接调用(GUI 还未初始化) + try: + func() + except Exception: + pass + return False + + +def _show_message_box(icon, title, message): + """线程安全地显示 QMessageBox。 + + Args: + icon: 'critical', 'warning', 'information' + title: 弹窗标题 + message: 弹窗内容 + """ + def _show(): + try: + from PySide6.QtWidgets import QApplication, QMessageBox + app = QApplication.instance() + if app is None: + return + if icon == 'critical': + QMessageBox.critical(None, title, message) + elif icon == 'warning': + QMessageBox.warning(None, title, message) + else: + QMessageBox.information(None, title, message) + except Exception: + pass + + _invoke_on_qt_thread(_show) + + +# ============================================================ +# 验签核心 +# ============================================================ +def _load_public_key(): + """从内嵌的 PEM 加载公钥""" + return serialization.load_pem_public_key(PUBLIC_KEY_PEM.encode()) + + +def _validate_device_id(device_id): + """确保新许可证中的目录键只能是 ``company/line``。""" + if not isinstance(device_id, str): + raise ValueError("许可证 device_id 必须是字符串") + segments = device_id.split("/") + if len(segments) != 2 or any( + not segment or segment in (".", "..") or "\\" in segment + for segment in segments): + raise ValueError("许可证 device_id 必须是 company/production-line 格式") + + +def _validate_payload(payload): + if not isinstance(payload, dict): + raise ValueError("许可证内容必须是对象") + + new_fields = ("license_id", "company_id", "production_line_id", "device_id") + has_new_format = any(field in payload for field in new_fields) + if has_new_format: + missing = [field for field in new_fields + if not isinstance(payload.get(field), str) or not payload[field].strip()] + if missing: + raise ValueError(f"新许可证缺少标识字段: {', '.join(missing)}") + _validate_device_id(payload["device_id"]) + else: + _log("旧许可证不支持在线撤销") + + if not isinstance(payload.get("customer"), str) or not payload["customer"].strip(): + raise ValueError("许可证缺少客户信息") + return has_new_format + + +def verify_license(lic_path=None): + """验证许可证签名 + 有效期。 + + Args: + lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic + + Returns: + dict: 许可证 payload(customer, expiry, issued 等) + + Raises: + FileNotFoundError: 许可证文件不存在 + InvalidSignature: 签名不匹配(伪造/篡改) + RuntimeError: 许可证已过期 + ValueError: 许可证格式错误 + """ + if lic_path is None: + # PyInstaller 打包后 sys.executable 是 exe 路径 + exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd() + lic_path = exe_dir / LICENSE_FILE + + if not os.path.exists(lic_path): + raise FileNotFoundError(f"许可证文件不存在: {lic_path}") + + with open(lic_path, "r", encoding="utf-8") as f: + raw = f.read().strip() + + # 解析: payload_base64 | signature_base64 + if "|" not in raw: + raise ValueError("许可证格式错误") + + payload_b64, signature_b64 = raw.split("|", 1) + signature = base64.b64decode(signature_b64) + + # RSA-PSS SHA256 验签 + try: + pub_key = _load_public_key() + pub_key.verify( + signature, + payload_b64.encode(), # 签名的是 base64 字符串本身 + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH, + ), + hashes.SHA256(), + ) + except InvalidSignature: + raise InvalidSignature("许可证签名验证失败:文件可能被篡改") + + # 解析载荷 + try: + payload = json.loads(base64.b64decode(payload_b64)) + except Exception: + raise ValueError("许可证内容解析失败") + + _validate_payload(payload) + + # 有效期检查(精确到小时) + expiry_str = payload.get("expiry") + if not expiry_str: + raise ValueError("许可证缺少过期时间") + + # 兼容旧格式 YYYY-MM-DD(视为当天 23:59) + if len(expiry_str) == 10: + expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M") + else: + expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M") + + trusted_now = _get_trusted_now() + + if trusted_now > expiry: + gap_days = (trusted_now - expiry).days + uptime_days = _get_uptime_days() + + # 反时钟篡改:需同时满足两个条件才怀疑用户拨慢系统时间 + # ① 软件运行不到 1 天(刚启动) + # ② 过期时间差超过 7 天(差距巨大 → 文件 mtime 暴露了真实时间) + # 缺失任一条件 → 真过期,直接报错: + # - gap 小(几分钟~几小时)→ 刚过期,正常报错 + # - 软件跑了很久 → 正常使用中过期,正常报错 + if uptime_days < 1 and gap_days > 7: + issued_str = payload.get("issued", expiry_str) + if len(issued_str) == 10: + issued = datetime.datetime.strptime(issued_str + " 00:00", "%Y-%m-%d %H:%M") + else: + issued = datetime.datetime.strptime(issued_str, "%Y-%m-%d %H:%M") + estimated_now = issued + datetime.timedelta(days=uptime_days) + if estimated_now <= expiry: + return payload + + raise ExpiredError( + f"许可证已过期 (到期: {expiry_str})", + expiry=expiry, + ) + + return payload + + +def get_verified_license(): + """返回本进程已通过本地验证的许可证载荷,尚未验证时返回 ``None``。""" + with _verified_license_lock: + return dict(_verified_license) if _verified_license is not None else None + + +def _is_new_license(payload): + return all(payload.get(field) for field in ( + "license_id", "company_id", "production_line_id", "device_id")) + + +def _api_url(): + base_url = os.environ.get( + "REINLOOP_SERVER_URL", "https://ReinLoop.dominatedconvergence.com" + ).rstrip("/") + return os.environ.get("REINLOOP_API_URL", f"{base_url}/api") + + +def _offline_limit_seconds(): + try: + hours = float(os.environ.get("REINLOOP_LICENSE_OFFLINE_HOURS", DEFAULT_OFFLINE_HOURS)) + except ValueError: + hours = DEFAULT_OFFLINE_HOURS + return max(0, hours) * 3600 + + +def validate_license_online(payload): + """验证可撤销许可证的在线状态。 + + 网络故障只在超过离线时限后失效;服务端明确拒绝则立即返回 ExpiredError。 + """ + global _last_online_success_monotonic + if not _is_new_license(payload): + return + + try: + response = requests.post(_api_url(), json={ + "type": "validateLicense", + "licenseId": payload["license_id"], + "deviceId": payload["device_id"], + }, timeout=ONLINE_CHECK_TIMEOUT_SECONDS) + response.raise_for_status() + result = response.json() + except (requests.RequestException, ValueError) as exc: + now = _time_module.monotonic() + if _last_online_success_monotonic is None: + _last_online_success_monotonic = _startup_monotonic + if now - _last_online_success_monotonic > _offline_limit_seconds(): + raise ExpiredError("许可证在线校验超过离线宽限期") from exc + _log(f"许可证在线校验暂不可用: {exc}") + return + + status = result.get("status") + if not result.get("success") or not result.get("valid") or status != "active": + reason = status or result.get("errMsg") or "invalid" + raise ExpiredError(f"许可证在线状态无效: {reason}") + if result.get("licenseId") not in (None, payload["license_id"]): + raise ExpiredError("许可证在线校验返回了不匹配的许可证") + _last_online_success_monotonic = _time_module.monotonic() + + +# ============================================================ +# 时间可信度 +# ============================================================ +def _get_uptime_days(): + """软件已连续运行的天数(基于 monotonic,不受系统时间影响)""" + return (_time_module.monotonic() - _startup_monotonic) / 86400 + + +def _get_trusted_now(): + """多源交叉校验获取可信日期时间(精确到小时)。 + + 取系统时间和文件时间的最大值,防止用户回拨系统时间。 + """ + candidates = [] + + # 1. 系统时间 + candidates.append(datetime.datetime.now()) + + # 2. 软件启动时记录的"最早可能时间" + # monotonic 计时推导出的启动时间 + startup_guess = datetime.datetime.now() - datetime.timedelta( + days=_get_uptime_days() + ) + candidates.append(startup_guess) + + # 3. 系统文件的修改时间(不易被用户修改) + system_files = [] + if sys.platform == "darwin": + system_files = [ + "/System/Library/CoreServices/SystemVersion.plist", + "/usr/bin/python3", + ] + elif sys.platform == "win32": + system_files = [ + r"C:\Windows\System32\ntoskrnl.exe", + r"C:\Windows\explorer.exe", + ] + + for sf in system_files: + if os.path.exists(sf): + try: + mtime = os.path.getmtime(sf) + candidates.append(datetime.datetime.fromtimestamp(mtime)) + except OSError: + pass + + # 取最大值(真时间 ≥ 所有候选值。用户可能回拨,但不能让其他文件"变新") + return max(candidates) + + +# ============================================================ +# 后台巡检 +# ============================================================ +def start_license_watchdog(interval_minutes=DEFAULT_CHECK_INTERVAL): + """启动后台许可证巡检线程。 + + Args: + interval_minutes: 检查间隔(分钟),默认 1440(24小时) + """ + global _watchdog_started + with _watchdog_lock: + if _watchdog_started: + return + _watchdog_started = True + t = threading.Thread( + target=_watchdog_loop, + args=(max(5, interval_minutes),), + daemon=True, + name="license-watchdog", + ) + t.start() + + +def _watchdog_loop(interval_minutes): + while True: + _time_module.sleep(interval_minutes * 60) + + try: + payload = verify_license() + with _verified_license_lock: + global _verified_license + _verified_license = dict(payload) + validate_license_online(payload) + expiry_str = payload["expiry"] + # 解析到期时间(兼容旧格式) + if len(expiry_str) == 10: + expiry = datetime.datetime.strptime(expiry_str + " 23:59", "%Y-%m-%d %H:%M") + else: + expiry = datetime.datetime.strptime(expiry_str, "%Y-%m-%d %H:%M") + + trusted_now = _get_trusted_now() + hours_left = (expiry - trusted_now).total_seconds() / 3600 + days_left = int(hours_left / 24) + + if hours_left < 0: + # 已过期(不应走到这里,verify_license 会抛 ExpiredError) + _log(f"⚠ 许可证已过期 ({expiry_str})") + _show_message_box('critical', "许可证已过期", + f"您的许可证已于 {expiry_str} 到期!\n请尽快联系厂商续期。") + + elif hours_left <= 24: + if not _warning_shown_states["expiring_today"]: + _warning_shown_states["expiring_today"] = True + _log(f"⚠ 许可证将在今天到期 ({expiry_str})") + _show_message_box('warning', "许可证即将到期", + f"您的许可证将于今天 {expiry_str} 到期!\n" + f"剩余约 {int(hours_left)} 小时,请及时联系厂商续期。") + + elif days_left <= 30: + if not _warning_shown_states["expiring_soon"]: + _warning_shown_states["expiring_soon"] = True + _log(f"⚠ 许可证将在 {days_left} 天后到期 ({expiry_str})") + _show_message_box('warning', "许可证即将到期", + f"您的许可证将在 {days_left} 天后({expiry_str})到期\n请提前联系厂商续期。") + + except ExpiredError as e: + _handle_expired(e) + + except Exception as e: + _log(f"许可证巡检异常: {e}") + + +def _handle_expired(error): + """处理许可证过期""" + _log(f"❌ {error}") + + # 弹窗:许可证已过期 + _show_message_box('critical', "许可证已过期", + f"{error}\n\n软件将在 {GRACE_PERIOD_HOURS} 小时缓冲期后自动退出,\n请及时保存工作并联系厂商续期。") + + if _on_expired_callback: + _on_expired_callback(str(error)) + + # 宽限期:给用户时间保存工作 + grace_start = _time_module.monotonic() + grace_seconds = GRACE_PERIOD_HOURS * 3600 + + if not _warning_shown_states["expired_grace"]: + _warning_shown_states["expired_grace"] = True + if _on_grace_callback: + _on_grace_callback(GRACE_PERIOD_HOURS) + + # 半小时后提醒一次 + warned_half = False + while _time_module.monotonic() - grace_start < grace_seconds: + elapsed = _time_module.monotonic() - grace_start + if not warned_half and elapsed > grace_seconds / 2: + warned_half = True + _show_message_box('warning', "许可证已过期", + f"缓冲期剩余约 {GRACE_PERIOD_HOURS // 2} 小时,\n请尽快保存工作!") + _time_module.sleep(60) # 每分钟检查一次 + + # 宽限期过,强制退出 + _log("宽限期已过,软件即将退出") + _show_message_box('critical', "许可证已过期", + "缓冲期已结束,软件即将退出。\n请联系厂商续期后重新启动。") + + # 给 5 秒做最后的清理 + _time_module.sleep(5) + os._exit(1) + + +def set_on_expired(callback): + """设置过期回调: callback(message: str)""" + global _on_expired_callback + _on_expired_callback = callback + + +def set_on_grace(callback): + """设置宽限期回调: callback(hours: int)""" + global _on_grace_callback + _on_grace_callback = callback + + +def set_on_log(callback): + """设置日志回调: callback(message: str) + 所有许可证关键日志会同时输出到此回调,供 UI 状态栏显示。 + """ + global _on_log_callback + _on_log_callback = callback + + +# ============================================================ +# 便捷入口 +# ============================================================ +class ExpiredError(RuntimeError): + """许可证过期异常""" + def __init__(self, message, expiry=None): + super().__init__(message) + self.expiry = expiry + + +def _log(msg): + """终端日志 + UI 回调(不依赖任何 UI 层)""" + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + formatted = f"[License {timestamp}] {msg}" + print(formatted) + # 同步推送到 UI 状态栏(如果已注册回调) + if _on_log_callback: + try: + _on_log_callback(str(msg)) + except Exception: + pass + + +def check_license(lic_path=None): + """启动时调用:验证许可证,通过则返回 payload。 + + 在 main.py 的 main() 函数开头调用一次即可。 + 内部会自动启动后台巡检线程。 + + Returns: + dict: 许可证载荷 + + Raises: + SystemExit: 验签失败或已过期(启动阶段直接退出) + """ + try: + payload = verify_license(lic_path) + environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() + if (_is_new_license(payload) and environment_device_id + and environment_device_id != payload["device_id"]): + raise ValueError("REINLOOP_DEVICE_ID 与许可证 device_id 不一致") + validate_license_online(payload) + with _verified_license_lock: + global _verified_license + _verified_license = dict(payload) + start_license_watchdog() + expiry = payload.get("expiry", "未知") + customer = payload.get("customer", "未知") + _log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}") + return payload + + except FileNotFoundError as e: + _log(f"❌ {e}") + _show_error_and_exit( + "未找到许可证文件", + "请将 license.lic 放到软件根目录,然后重新启动程序。\n\n" + "如有疑问,请联系厂商获取有效的许可证文件。" + ) + + except InvalidSignature as e: + _log(f"❌ {e}") + _show_error_and_exit( + "许可证验证失败", + "许可证签名校验不通过,文件可能已被篡改。\n\n" + "请使用原始签发的 license.lic 文件,\n" + "或联系厂商重新签发。" + ) + + except ExpiredError as e: + _log(f"❌ {e}") + _show_error_and_exit( + "许可证已过期", + f"您的许可证已于 {e.expiry} 到期。\n\n" + "请联系厂商续期,获取新的许可证文件后重新启动。" + ) + + except Exception as e: + _log(f"❌ 许可证检查异常: {e}") + _show_error_and_exit(f"许可证校验失败: {e}", str(e)) + + +def _show_error_and_exit(title, detail=""): + """显示错误弹窗并退出(兼容 GUI 和无 GUI 模式)。 + + Args: + title: 弹窗标题(简短概要) + detail: 弹窗正文(详细说明和操作建议) + """ + message = f"{title}\n\n{detail}" if detail else title + + # 尝试 GUI 弹窗 + try: + from PySide6.QtWidgets import QApplication, QMessageBox + app = QApplication.instance() + if app is not None: + QMessageBox.critical(None, title, detail or title) + else: + # 无 QApplication 实例时,尝试创建一个临时的 + try: + app = QApplication(sys.argv[:1]) + QMessageBox.critical(None, title, detail or title) + except Exception: + pass + except Exception: + pass + + print(f"\n{'='*50}") + print(message) + print(f"{'='*50}\n") + sys.exit(1) + + +# ============================================================ +# 辅助:获取许可证信息(供 UI 显示) +# ============================================================ +def get_license_info(lic_path=None): + """读取许可证信息(不做过期检查),供 UI 显示。 + + Returns: + dict | None: 许可证信息,文件不存在则返回 None + """ + try: + if lic_path is None: + exe_dir = ( + Path(sys.executable).parent + if getattr(sys, 'frozen', False) + else Path.cwd() + ) + lic_path = exe_dir / LICENSE_FILE + + if not os.path.exists(lic_path): + return None + + with open(lic_path, "r", encoding="utf-8") as f: + raw = f.read().strip() + + payload_b64 = raw.split("|")[0] + payload = json.loads(base64.b64decode(payload_b64)) + + # 先验签保证内容可信 + verify_license(lic_path) + + return { + "customer": payload.get("customer", "未知"), + "expiry": payload.get("expiry", "未知"), + "issued": payload.get("issued", "未知"), + "features": payload.get("features", "*"), + } + except Exception: + return None diff --git a/ReinLoop/tests/test_data_collector.py b/ReinLoop/tests/test_data_collector.py new file mode 100644 index 0000000..f6156b1 --- /dev/null +++ b/ReinLoop/tests/test_data_collector.py @@ -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() \ No newline at end of file diff --git a/ReinLoop/tests/test_initial_travel_scan.py b/ReinLoop/tests/test_initial_travel_scan.py index 5f5e007..c920f9a 100644 --- a/ReinLoop/tests/test_initial_travel_scan.py +++ b/ReinLoop/tests/test_initial_travel_scan.py @@ -72,7 +72,7 @@ class InitialTravelScanTests(unittest.TestCase): captured.update(body=body, filename=filename, folder=folder) return True - manager._upload_to_cos = capture_upload + manager._upload_to_server = capture_upload clock = FakeClock() with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \ 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_filename = "identification_data_test.csv" - manager._upload_to_cos = lambda content, filename, folder: ( + manager._upload_to_server = lambda content, filename, folder: ( uploaded.update( content=content, filename=filename, folder=folder ) or True diff --git a/需求.md b/需求.md new file mode 100644 index 0000000..afb3df5 --- /dev/null +++ b/需求.md @@ -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` 固定为 `/`,服务端必须校验其格式,禁止路径遍历。 +- 服务端需要设置 `PUBLIC_BASE_URL=https://ReinLoop.dominatedconvergence.com`,确保上传地址和下载地址均为可从客户端访问的 HTTPS URL。 + +### 通用文件上传接口:`uploadDataFile` + +ReinLoop 的控制数据、辨识数据及配置文件均通过此两步协议上传: + +1. 客户端调用业务接口申请一次性上传地址: + +```json +{ + "type": "uploadDataFile", + "fileName": "episode_raw_data_20260730_120000_part1of2.pkl", + "folder": "/data_record/data_50SLM_5L" +} +``` + +2. 服务端返回 `uploadMetadata.url` 后,客户端以 `multipart/form-data` 向该 URL 提交 `file` 字段。上传成功应返回 HTTP `204` 或 `200`。 + +服务端需要在上传完成时保存文件本体和 `fileRecords` 元数据(包括 `fileID`、`fileName`、`folder`、`uploadTime`、`size`)。控制数据目录必须以 `/data_record/` 为前缀。 + +## 服务器端接口需求(控制数据) + +控制数据由 `ReinLoop/core/data_collector.py` 上传到 +`/data_record/`,包括控制 Episode 的 `.pkl` 分片和对应的 JSON manifest。 +以下接口均为管理端接口,要求请求体携带有效的 `adminToken`;响应统一包含 +`success: true|false`,失败时返回 `errMsg`。 + +### `listControlFiles` + +按设备分页查询控制数据文件,供 Panel 的“控制数据”列表使用。 + +请求: + +```json +{ + "type": "listControlFiles", + "adminToken": "", + "deviceId": "/", + "page": 1, + "pageSize": 100 +} +``` + +成功响应: + +```json +{ + "success": true, + "files": [ + { + "fileID": "local://ReinLoop_GUI//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": "", + "fileID": "local://ReinLoop_GUI//data_record/..." +} +``` + +成功响应: + +```json +{ + "success": true, + "fileID": "local://ReinLoop_GUI//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": "", + "fileID": "local://ReinLoop_GUI//data_record/..." +} +``` + +成功响应: + +```json +{ + "success": true, + "deletedCount": 1 +} +``` + +服务端须同时删除文件本体及 `fileRecords` 中的元数据;必须校验 `fileID` 属于控制数据目录, +禁止借此接口删除模型、辨识数据、配置或许可证相关文件。文件不存在时返回明确错误,不应将删除操作视为成功。 + +## 许可证接口补充 + +### `getLicense` + +Panel 的许可证“下载”复用既有 `getLicense` 接口。请求: + +```json +{ + "type": "getLicense", + "adminToken": "", + "licenseId": "" +} +``` + +成功响应中的 `license` 对象必须包含原始许可证文本字段 `license`;Panel 将该字段保存为 `.lic` 文件。 +服务端不得将私钥或其他许可证的内容一并返回。 + +### `revokeLicense` + +Panel 的许可证撤销使用既有 `revokeLicense` 接口: + +```json +{ + "type": "revokeLicense", + "adminToken": "", + "licenseId": "", + "reason": "管理员撤销原因" +} +``` + +成功响应应返回 `success: true` 及更新后的许可证对象,其中 `status` 为 `revoked`。服务端必须保留 +`revokedAt` 与 `revocationReason` 审计信息;许可证在线校验接口 `validateLicense` 随后应返回 +`valid: false`、`status: "revoked"`,使 ReinLoop 客户端在下一次许可证巡检时生效。 + +### Panel 对应功能 + +- “控制数据”页面:调用 `listControlFiles` 刷新列表;JSON manifest 可请求下载后直接预览,`.pkl` 仅提供下载;删除前需二次确认。 +- “许可证”页面:调用 `getLicense` 下载 `.lic`;调用 `revokeLicense` 撤销,并在成功后刷新许可证列表。 +- Panel 不得自行拼接服务器文件路径、下载 URL 或绕过上述 Admin 接口访问文件。 \ No newline at end of file