From 4312cb878c47caf9ca4d73ecd6dd980f3ba1a171 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Thu, 30 Jul 2026 11:12:31 +0800 Subject: [PATCH] update server --- .gitignore | 66 + ControlPanel/B端实现说明.md | 250 ++ ControlPanel/b-admin.js | 191 + ControlPanel/electron-main.js | 332 ++ ControlPanel/electron-preload.js | 30 + ControlPanel/electron-ui/index.html | 219 + ControlPanel/electron-ui/renderer.js | 716 +++ ControlPanel/electron-ui/styles.css | 258 ++ ControlPanel/features.md | 533 +++ .../identification-config.example.json | 11 + ControlPanel/license-manager.js | 56 + ControlPanel/package-lock.json | 3998 +++++++++++++++++ ControlPanel/package.json | 75 + ControlPanel/plot-csv.js | 153 + ControlPanel/plot-json.js | 135 + ControlPanel/poll-panel-inbox.js | 113 + ControlPanel/server-client.js | 69 + ControlPanel/test/license-manager.test.js | 60 + ControlPanel/test/plot-json.test.js | 35 + ControlPanel/volume-config.example.json | 10 + ReinLoop/.gitignore | 80 + ReinLoop/PcControl.py | 818 ++++ ReinLoop/README.md | 85 + ReinLoop/api.py | 26 + ReinLoop/config/identification_config.json | 3 + ReinLoop/config/volume_measurement.json | 3 + ReinLoop/controllers.py | 145 + ReinLoop/core/__init__.py | 39 + ReinLoop/core/connection_manager.py | 132 + ReinLoop/core/control_engine.py | 329 ++ ReinLoop/core/data_collector.py | 199 + ReinLoop/core/device_heartbeat.py | 20 + ReinLoop/core/identification.py | 487 ++ ReinLoop/core/identification_config.py | 152 + ReinLoop/core/identification_feedback.py | 58 + ReinLoop/core/model_manager.py | 139 + ReinLoop/core/volume_config.py | 154 + ReinLoop/design_description.md | 1573 +++++++ ReinLoop/environment.yml | 9 + ReinLoop/features.md | 157 + ReinLoop/get_V.py | 193 + ReinLoop/ind_collector.py | 250 ++ ReinLoop/license_utils.py | 656 +++ ReinLoop/main.py | 99 + ReinLoop/project.config.json | 25 + ReinLoop/requirements.txt | 15 + ReinLoop/setup.py | 69 + ReinLoop/skills-lock.json | 53 + ReinLoop/src/Setting_line_light.svg | 3 + ReinLoop/src/connect_device.svg | 7 + ReinLoop/src/control_icon.svg | 10 + ReinLoop/src/control_icon_gray.svg | 10 + ReinLoop/src/debug_icon.svg | 4 + ReinLoop/src/debug_icon_gray.svg | 4 + ReinLoop/src/link_icon.svg | 4 + ReinLoop/src/link_icon_gray.svg | 4 + ReinLoop/src/load_model.svg | 17 + ReinLoop/src/logo.svg | 1 + ReinLoop/src/plot.svg | 4 + ReinLoop/src/pressure.svg | 9 + ReinLoop/src/refresh.svg | 5 + ReinLoop/src/start_control.svg | 3 + ReinLoop/src/target.svg | 8 + ReinLoop/src/valve.svg | 9 + ReinLoop/styles.py | 569 +++ ReinLoop/tests/test_device_heartbeat.py | 41 + ReinLoop/tests/test_identification_config.py | 157 + .../tests/test_identification_feedback.py | 85 + ReinLoop/tests/test_initial_travel_scan.py | 153 + ReinLoop/tests/test_license_protocol.py | 122 + ReinLoop/tests/test_volume_config.py | 218 + ReinLoop/tool/auto_test.py | 167 + ReinLoop/tool/data_analyze | 285 ++ ReinLoop/tool/gui.py | 2024 +++++++++ .../tool/identification_config.example.csv | 10 + ReinLoop/tool/plt_font.py | 61 + .../tool/submit_identification_feedback.py | 26 + ReinLoop/tool/volume_measurement.example.json | 10 + ReinLoop/ui/__init__.py | 1 + ReinLoop/ui/connection_tab.py | 177 + ReinLoop/ui/control_tab.py | 593 +++ ReinLoop/ui/debug_tab.py | 427 ++ ReinLoop/ui/main_window.py | 1109 +++++ ReinLoop/ui/plot_window.py | 189 + ReinLoop/ui/status_bar.py | 47 + ReinLoop/修改记录.md | 716 +++ server/.env.example | 8 + server/.gitignore | 4 + server/README.md | 125 + server/features.md | 103 + server/migrations/001_normalized_schema.sql | 105 + server/package-lock.json | 1175 +++++ server/package.json | 22 + server/reinloop-server.service | 22 + server/src/app.js | 997 ++++ server/src/postgres-store.js | 110 + server/src/server.js | 51 + server/src/store.js | 80 + server/test/server.test.js | 665 +++ 99 files changed, 24034 insertions(+) create mode 100644 .gitignore create mode 100644 ControlPanel/B端实现说明.md create mode 100644 ControlPanel/b-admin.js create mode 100644 ControlPanel/electron-main.js create mode 100644 ControlPanel/electron-preload.js create mode 100644 ControlPanel/electron-ui/index.html create mode 100644 ControlPanel/electron-ui/renderer.js create mode 100644 ControlPanel/electron-ui/styles.css create mode 100644 ControlPanel/features.md create mode 100644 ControlPanel/identification-config.example.json create mode 100644 ControlPanel/license-manager.js create mode 100644 ControlPanel/package-lock.json create mode 100644 ControlPanel/package.json create mode 100644 ControlPanel/plot-csv.js create mode 100644 ControlPanel/plot-json.js create mode 100644 ControlPanel/poll-panel-inbox.js create mode 100644 ControlPanel/server-client.js create mode 100644 ControlPanel/test/license-manager.test.js create mode 100644 ControlPanel/test/plot-json.test.js create mode 100644 ControlPanel/volume-config.example.json create mode 100644 ReinLoop/.gitignore create mode 100644 ReinLoop/PcControl.py create mode 100644 ReinLoop/README.md create mode 100644 ReinLoop/api.py create mode 100644 ReinLoop/config/identification_config.json create mode 100644 ReinLoop/config/volume_measurement.json create mode 100644 ReinLoop/controllers.py create mode 100644 ReinLoop/core/__init__.py create mode 100644 ReinLoop/core/connection_manager.py create mode 100644 ReinLoop/core/control_engine.py create mode 100644 ReinLoop/core/data_collector.py create mode 100644 ReinLoop/core/device_heartbeat.py create mode 100644 ReinLoop/core/identification.py create mode 100644 ReinLoop/core/identification_config.py create mode 100644 ReinLoop/core/identification_feedback.py create mode 100644 ReinLoop/core/model_manager.py create mode 100644 ReinLoop/core/volume_config.py create mode 100644 ReinLoop/design_description.md create mode 100644 ReinLoop/environment.yml create mode 100644 ReinLoop/features.md create mode 100644 ReinLoop/get_V.py create mode 100644 ReinLoop/ind_collector.py create mode 100644 ReinLoop/license_utils.py create mode 100644 ReinLoop/main.py create mode 100644 ReinLoop/project.config.json create mode 100644 ReinLoop/requirements.txt create mode 100644 ReinLoop/setup.py create mode 100644 ReinLoop/skills-lock.json create mode 100644 ReinLoop/src/Setting_line_light.svg create mode 100644 ReinLoop/src/connect_device.svg create mode 100644 ReinLoop/src/control_icon.svg create mode 100644 ReinLoop/src/control_icon_gray.svg create mode 100644 ReinLoop/src/debug_icon.svg create mode 100644 ReinLoop/src/debug_icon_gray.svg create mode 100644 ReinLoop/src/link_icon.svg create mode 100644 ReinLoop/src/link_icon_gray.svg create mode 100644 ReinLoop/src/load_model.svg create mode 100644 ReinLoop/src/logo.svg create mode 100644 ReinLoop/src/plot.svg create mode 100644 ReinLoop/src/pressure.svg create mode 100644 ReinLoop/src/refresh.svg create mode 100644 ReinLoop/src/start_control.svg create mode 100644 ReinLoop/src/target.svg create mode 100644 ReinLoop/src/valve.svg create mode 100644 ReinLoop/styles.py create mode 100644 ReinLoop/tests/test_device_heartbeat.py create mode 100644 ReinLoop/tests/test_identification_config.py create mode 100644 ReinLoop/tests/test_identification_feedback.py create mode 100644 ReinLoop/tests/test_initial_travel_scan.py create mode 100644 ReinLoop/tests/test_license_protocol.py create mode 100644 ReinLoop/tests/test_volume_config.py create mode 100644 ReinLoop/tool/auto_test.py create mode 100644 ReinLoop/tool/data_analyze create mode 100644 ReinLoop/tool/gui.py create mode 100644 ReinLoop/tool/identification_config.example.csv create mode 100644 ReinLoop/tool/plt_font.py create mode 100644 ReinLoop/tool/submit_identification_feedback.py create mode 100644 ReinLoop/tool/volume_measurement.example.json create mode 100644 ReinLoop/ui/__init__.py create mode 100644 ReinLoop/ui/connection_tab.py create mode 100644 ReinLoop/ui/control_tab.py create mode 100644 ReinLoop/ui/debug_tab.py create mode 100644 ReinLoop/ui/main_window.py create mode 100644 ReinLoop/ui/plot_window.py create mode 100644 ReinLoop/ui/status_bar.py create mode 100644 ReinLoop/修改记录.md create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/README.md create mode 100644 server/features.md create mode 100644 server/migrations/001_normalized_schema.sql create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/reinloop-server.service create mode 100644 server/src/app.js create mode 100644 server/src/postgres-store.js create mode 100644 server/src/server.js create mode 100644 server/src/store.js create mode 100644 server/test/server.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f83e06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Python +__pycache__/ +*.py[cod] +*.pyd +*.so +*.egg +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Python environments and packaging +.venv/ +venv/ +env/ +build/ +dist/ +*.whl +*.spec + +# Node.js / Electron +node_modules/ +coverage/ +*.log +*.etl + +# Environment files, credentials, and local configuration +.env +.env.* +!.env.example +*.lic +*.lic.* +*.key +*.pem +*.local +project.private.config.json + +# Generated and downloaded data +data/ +data_record/ +ind_data/ +model_config/ +downloads/ +ControlPanel/electron-sxs.txt +ControlPanel/identification_data_* +*.zip +*.tar.gz +*.dmg +*.app +installer/ + +# Editor and OS files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +Desktop.ini + +# logs and runtime data +logs/ +需求.md \ No newline at end of file diff --git a/ControlPanel/B端实现说明.md b/ControlPanel/B端实现说明.md new file mode 100644 index 0000000..930e8ae --- /dev/null +++ b/ControlPanel/B端实现说明.md @@ -0,0 +1,250 @@ +# 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. 已知依赖风险 + +依赖审计仍报告第三方构建依赖存在安全告警。未执行可能引入破坏性升级的 +`npm audit fix --force`,发布前应结合 Electron Builder 兼容性单独评估。 \ No newline at end of file diff --git a/ControlPanel/b-admin.js b/ControlPanel/b-admin.js new file mode 100644 index 0000000..765137e --- /dev/null +++ b/ControlPanel/b-admin.js @@ -0,0 +1,191 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { parse } = require("csv-parse/sync"); +const { callServer } = require("./server-client"); + +const IDENTIFICATION_FIELDS = [ + "q_in_val", "dt", "n_order", "t_c", "levels", + "dead_area", "xa_full", "V_val", "repeat" +]; + +function getDeviceId(options = {}) { + const deviceId = String(options.deviceId || process.env.REINLOOP_DEVICE_ID || "").trim(); + if (!deviceId) throw new Error("发布或读取辨识配置时必须提供设备 ID"); + return deviceId; +} + +async function readParameters(filePath) { + if (!filePath) throw new Error("发布参数时必须提供 JSON 文件路径"); + const content = await fs.promises.readFile(path.resolve(filePath), "utf8"); + const config = JSON.parse(content); + return config.parameters || config; +} + +async function uploadVolumeConfig(parameters, options = {}) { + const deviceId = getDeviceId(options); + const request = await callServer({ + type: "getPendingVolumeConfigRequest", + deviceId + }, options); + if (!request.pending) { + throw new Error("ReinLoop 尚未发起容积参数请求,请先在客户端开始容积测试"); + } + + const result = await callServer({ + type: "uploadDataFile", + fileName: "volume_measurement.json", + folder: `${deviceId}/volume_config_requests/${request.requestId}` + }, options); + const metadata = result.uploadMetadata; + if (!metadata || !metadata.url) { + throw new Error("server 未返回有效的配置上传地址"); + } + + const orderedConfig = { + q_in_val: parameters.q_in_val, + dt: parameters.dt, + p_max: parameters.p_max, + fit_low: parameters.fit_low, + fit_high: parameters.fit_high, + T_delta: parameters.T_delta, + xa_full: parameters.xa_full, + num_runs: parameters.num_runs + }; + const form = new FormData(); + form.append( + "file", + new Blob([`${JSON.stringify(orderedConfig, null, 2)}\n`], { type: "application/json" }), + "volume_measurement.json" + ); + + const uploadResponse = await fetch(metadata.url, { method: "POST", body: form }); + if (uploadResponse.status !== 200 && uploadResponse.status !== 204) { + throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`); + } + try { + return await callServer({ + type: "submitVolumeConfigFile", + deviceId, + requestId: request.requestId, + fileID: result.fileID, + fileName: "volume_measurement.json" + }, options); + } catch (error) { + await callServer({ type: "deleteFile", fileID: result.fileID }, options).catch(() => {}); + throw error; + } +} + +function serializeIdentificationConfig(parameters) { + const rows = IDENTIFICATION_FIELDS.map((field) => { + const value = field === "levels" ? parameters[field].join(",") : parameters[field]; + return `${field},${field === "levels" ? `"${value}"` : value}`; + }); + return `parameter,value\n${rows.join("\n")}\n`; +} + +async function uploadIdentificationConfig(parameters, options = {}) { + const result = await callServer({ + type: "publishIdentificationConfig", + deviceId: getDeviceId(options), + parameters + }, options); + const metadata = result.uploadMetadata; + if (!metadata || !metadata.url) { + throw new Error("server 未返回有效的配置上传地址"); + } + + const form = new FormData(); + form.append( + "file", + new Blob([serializeIdentificationConfig(parameters)], { type: "text/csv" }), + "identification_config.csv" + ); + const uploadResponse = await fetch(metadata.url, { method: "POST", body: form }); + if (uploadResponse.status !== 200 && uploadResponse.status !== 204) { + throw new Error(`配置文件直传失败: HTTP ${uploadResponse.status}`); + } + return result; +} + +async function publishConfig(configType, parameters, options = {}) { + if (configType === "volume") { + return uploadVolumeConfig(parameters, options); + } + if (configType !== "identification") { + throw new Error(`不支持的配置类型: ${configType}`); + } + return uploadIdentificationConfig(parameters, options); +} + +async function getConfig(configType, options = {}) { + if (configType === "identification") { + const result = await callServer({ + type: "getIdentificationConfig", + deviceId: getDeviceId(options) + }, options); + const response = await fetch(result.url); + if (!response.ok) throw new Error(`配置文件下载失败: HTTP ${response.status}`); + const records = parse(await response.text(), { columns: true, skip_empty_lines: true }); + const values = Object.fromEntries(records.map((record) => [record.parameter, record.value])); + return { + q_in_val: Number(values.q_in_val), + dt: Number(values.dt), + n_order: Number(values.n_order), + t_c: Number(values.t_c), + levels: String(values.levels).split(",").map(Number), + dead_area: Number(values.dead_area), + xa_full: Number(values.xa_full), + V_val: Number(values.V_val), + repeat: Number(values.repeat) + }; + } + return callServer({ type: "getFunctionConfig", configType }, options); +} + +async function main() { + const [command, filePath] = process.argv.slice(2); + const commands = { + "publish-volume": { action: "upload-volume", configType: "volume" }, + "publish-identification": { action: "publish", configType: "identification" }, + "get-volume": { action: "get", configType: "volume" }, + "get-identification": { action: "get", configType: "identification" } + }; + const selected = commands[command]; + if (!selected) { + throw new Error( + "用法: node b-admin.js [config.json]" + ); + } + + if (selected.action === "upload-volume") { + const result = await publishConfig(selected.configType, await readParameters(filePath)); + console.log(`配置已上传: ${result.fileID}`); + return; + } + + const result = selected.action === "publish" + ? await publishConfig(selected.configType, await readParameters(filePath)) + : await getConfig(selected.configType); + if (selected.action === "publish") { + console.log(`配置已上传: ${result.fileID}`); + return; + } + console.dir(result, { depth: null, colors: true }); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + +module.exports = { + getConfig, + publishConfig, + readParameters, + serializeIdentificationConfig, + uploadIdentificationConfig, + uploadVolumeConfig +}; \ No newline at end of file diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js new file mode 100644 index 0000000..9da9e43 --- /dev/null +++ b/ControlPanel/electron-main.js @@ -0,0 +1,332 @@ +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(); +}); \ No newline at end of file diff --git a/ControlPanel/electron-preload.js b/ControlPanel/electron-preload.js new file mode 100644 index 0000000..93ce17e --- /dev/null +++ b/ControlPanel/electron-preload.js @@ -0,0 +1,30 @@ +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)) +}); \ No newline at end of file diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html new file mode 100644 index 0000000..ce22998 --- /dev/null +++ b/ControlPanel/electron-ui/index.html @@ -0,0 +1,219 @@ + + + + + + + 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

公司与产线

+ +
+
+
+

添加公司

+ + + +
+
+

添加产线

+ + + + +
+
+
+
+ + + + \ No newline at end of file diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js new file mode 100644 index 0000000..4b2bcb8 --- /dev/null +++ b/ControlPanel/electron-ui/renderer.js @@ -0,0 +1,716 @@ +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); +}); \ No newline at end of file diff --git a/ControlPanel/electron-ui/styles.css b/ControlPanel/electron-ui/styles.css new file mode 100644 index 0000000..e0780d6 --- /dev/null +++ b/ControlPanel/electron-ui/styles.css @@ -0,0 +1,258 @@ +:root { + color-scheme: light; + --ink: #15251f; + --muted: #617069; + --line: #cfd7d2; + --paper: #f2f4f1; + --surface: #ffffff; + --green: #146b4a; + --green-dark: #0d4b34; + --amber: #e7a928; + --red: #ad342d; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +body { + margin: 0; + min-width: 900px; + color: var(--ink); + background: + linear-gradient(rgba(20, 107, 74, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(20, 107, 74, 0.035) 1px, transparent 1px), + var(--paper); + background-size: 28px 28px; + font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif; +} + +button, input, textarea, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: 0.45; } + +.topbar { + height: 104px; + padding: 20px 36px; + color: white; + background: var(--ink); + border-bottom: 5px solid var(--amber); + display: flex; + align-items: center; + justify-content: space-between; +} + +h1, h2, h3, p { margin: 0; } +h1 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 28px; font-weight: 600; letter-spacing: 0; } +h2 { font-family: Georgia, "Microsoft YaHei UI", serif; font-size: 25px; font-weight: 600; letter-spacing: 0; } +h3 { font-size: 15px; } +.eyebrow, .section-kicker { font-size: 11px; letter-spacing: 0; font-weight: 700; } +.eyebrow { color: #a9c1b6; margin-bottom: 5px; } +.section-kicker { color: var(--green); margin-bottom: 5px; } + +.status { + min-width: 110px; + max-width: 420px; + padding: 8px 14px; + border: 1px solid #587067; + border-radius: 4px; + color: #d9e4df; + text-align: center; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.status[data-tone="busy"] { border-color: var(--amber); color: #ffd985; } +.status[data-tone="success"] { border-color: #62b78f; color: #9fe1bf; } +.status[data-tone="error"] { border-color: #df766e; color: #ffb5ae; } + +main { max-width: 1500px; margin: 0 auto; padding: 22px 36px 36px; } +.connection-screen { + min-height: calc(100vh - 104px); + display: grid; + place-items: center; + padding: 36px; +} +.connection-form { + width: min(480px, 100%); + padding: 30px; + display: grid; + gap: 18px; + background: var(--surface); + border: 1px solid var(--line); + border-top: 4px solid var(--green); + box-shadow: 0 18px 45px rgba(21, 37, 31, 0.12); +} +.connection-form h2 { margin-bottom: 6px; } +.connection-form label { display: grid; gap: 7px; } +.connection-form label span { color: var(--muted); font-size: 12px; font-weight: 700; } +.connection-form .button { width: 100%; margin-top: 4px; } +.connection-message { min-height: 20px; color: var(--muted); font-size: 12px; text-align: center; } +.connection-message[data-tone="busy"] { color: #8a6414; } +.connection-message[data-tone="error"] { color: var(--red); } +.connection-band { + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 18px; + padding: 15px 18px; + background: #e4e9e5; + border: 1px solid var(--line); + border-left: 4px solid var(--green); +} +.connection-band label { display: grid; grid-template-columns: 118px 1fr; align-items: center; gap: 10px; } +.connection-band span { font-size: 12px; font-weight: 700; color: var(--muted); } +input, select { + min-width: 0; + height: 36px; + padding: 0 10px; + border: 1px solid #b9c5be; + border-radius: 3px; + background: white; + color: var(--ink); + outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--green); box-shadow: 0 0 0 2px rgba(20, 107, 74, 0.12); } + +.tabs { display: flex; gap: 0; margin-top: 22px; border-bottom: 1px solid var(--line); } +.tab { + min-width: 132px; + padding: 12px 20px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: var(--muted); + font-weight: 700; +} +.tab.active { color: var(--green-dark); border-bottom-color: var(--green); } + +.panel { display: none; padding-top: 22px; } +.panel.active { display: block; animation: reveal 180ms ease-out; } +@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } +.panel-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 16px; } +.actions { display: flex; gap: 8px; } +.button { + height: 38px; + padding: 0 16px; + border-radius: 3px; + border: 1px solid transparent; + font-weight: 700; +} +.button.primary { color: white; background: var(--green); border-color: var(--green); } +.button.primary:hover { background: var(--green-dark); } +.button.secondary { color: var(--ink); background: white; border-color: #aebbb4; } +.button.secondary:hover { border-color: var(--green); color: var(--green); } +.button.danger { color: var(--red); background: white; border-color: #d5a7a3; } +.button.danger:hover { color: white; background: var(--red); border-color: var(--red); } +.button.icon { width: 38px; padding: 0; background: white; border-color: #aebbb4; font-size: 19px; } +.plot-actions { display: flex; gap: 6px; } +.button.full { width: 100%; margin-top: 10px; } +.review-actions { display: flex; align-items: center; gap: 8px; } +.review-actions span { max-width: 320px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.plot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.plot-item { min-width: 0; } +.plot-title { + min-height: 54px; + padding: 9px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--surface); + border: 1px solid var(--line); + border-bottom: 0; +} +.plot-title div { display: grid; gap: 3px; } +.plot-title span { color: var(--green); font-size: 11px; font-weight: 700; } +.plot-title strong { font-size: 15px; } +.plot-stage { + height: calc(100vh - 405px); + min-height: 300px; + max-height: 620px; + display: grid; + place-items: center; + overflow: auto; + background-color: #dce2de; + background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; + border: 1px solid #bdc8c1; +} +.plot-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; background: white; } +.empty-state { display: grid; gap: 7px; text-align: center; color: var(--muted); } +.empty-state strong { color: var(--ink); font-size: 18px; } +.empty-state span { font-size: 13px; } +.plot-meta { min-height: 29px; display: flex; align-items: start; justify-content: space-between; gap: 24px; } +.file-path { min-height: 20px; margin-top: 9px; color: var(--muted); font: 12px Consolas, monospace; overflow-wrap: anywhere; } +.upload-time { flex: 0 0 auto; margin-top: 9px; color: var(--green-dark); font-size: 12px; font-weight: 700; } + +.segmented { display: flex; padding: 3px; background: #dfe5e1; border: 1px solid #c6d0ca; border-radius: 4px; } +.segment { height: 34px; padding: 0 16px; border: 0; border-radius: 3px; background: transparent; color: var(--muted); font-weight: 700; } +.segment.active { background: white; color: var(--green-dark); box-shadow: 0 1px 3px rgba(21, 37, 31, 0.14); } +.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 18px; } +.editor-column, .publish-aside { background: var(--surface); border: 1px solid var(--line); } +.editor-toolbar { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 700; } +.text-button { border: 0; background: transparent; color: var(--green); font-size: 13px; font-weight: 700; } +textarea { + display: block; + width: calc(100% - 28px); + height: calc(100vh - 385px); + min-height: 330px; + margin: 14px; + padding: 16px; + resize: vertical; + border: 1px solid #bec9c2; + border-radius: 3px; + background: #f8faf8; + color: #18392d; + font: 14px/1.65 Consolas, "Microsoft YaHei UI", monospace; + tab-size: 2; + outline: none; +} +.editor-column > .file-path { padding: 0 14px 12px; } +.publish-aside { padding: 20px; align-self: start; } +.publish-aside h3 { padding-bottom: 14px; border-bottom: 1px solid var(--line); } +dl { margin: 8px 0 18px; } +dl div { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e6ebe8; font-size: 12px; } +dt { color: var(--muted); } +dd { margin: 0; text-align: right; font-weight: 700; } +.result { min-height: 42px; margin-top: 14px; padding: 10px; background: #eef1ef; color: var(--muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } +.result[data-tone="success"] { color: var(--green-dark); background: #e2f2e9; } +.result[data-tone="error"] { color: var(--red); background: #f8e7e5; } + +.management-layout { display: grid; grid-template-columns: 330px minmax(0, 1fr); gap: 18px; align-items: start; } +.management-layout.equal { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.form-surface, .data-surface { background: var(--surface); border: 1px solid var(--line); } +.form-surface { padding: 20px; display: grid; gap: 14px; } +.form-surface h3 { padding-bottom: 13px; border-bottom: 1px solid var(--line); } +.form-surface label { display: grid; gap: 6px; } +.form-surface label span { color: var(--muted); font-size: 12px; font-weight: 700; } +.form-note { color: var(--muted); font-size: 11px; line-height: 1.6; } +.data-surface { min-width: 0; overflow: auto; } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th, td { padding: 11px 13px; border-bottom: 1px solid #e4e9e6; text-align: left; vertical-align: middle; } +th { color: var(--muted); background: #edf1ee; font-size: 11px; } +td:last-child { white-space: nowrap; } +.table-action { border: 0; background: transparent; color: var(--green); font-weight: 700; margin-right: 10px; } +.table-action.danger { color: var(--red); } +.empty-row { padding: 30px; color: var(--muted); text-align: center; font-size: 13px; } +.detail-view { margin: 0; padding: 16px; max-height: 260px; overflow: auto; background: #18251f; color: #d9e9df; font: 12px/1.6 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } + +.lightbox { position: fixed; inset: 0; z-index: 20; padding: 24px; background: rgba(10, 20, 16, 0.78); } +.lightbox-shell { height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); background: var(--surface); border: 1px solid #9aa9a1; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); } +.lightbox-toolbar { min-height: 58px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); } +.lightbox-actions { display: flex; align-items: center; gap: 7px; } +.lightbox-canvas { min-width: 0; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 22px; background-color: #dce2de; background-image: linear-gradient(45deg, #d3dad5 25%, transparent 25%), linear-gradient(-45deg, #d3dad5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d3dad5 75%), linear-gradient(-45deg, transparent 75%, #d3dad5 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; } +.lightbox-canvas img { display: block; max-width: none; background: white; } +.lightbox-canvas img.fit-image { max-width: 100%; max-height: 100%; object-fit: contain; } + +@media (max-width: 1050px) { + main { padding-left: 22px; padding-right: 22px; } + .connection-band { grid-template-columns: 1fr; } + .plot-grid { grid-template-columns: 1fr; } + .plot-stage { height: 360px; } + .editor-layout { grid-template-columns: minmax(0, 1fr) 240px; } + .management-layout, .management-layout.equal { grid-template-columns: 1fr; } + .lightbox { padding: 12px; } + .lightbox-toolbar { align-items: start; flex-direction: column; } +} \ No newline at end of file diff --git a/ControlPanel/features.md b/ControlPanel/features.md new file mode 100644 index 0000000..f7b9f0d --- /dev/null +++ b/ControlPanel/features.md @@ -0,0 +1,533 @@ +# ReinLoop ControlPanel 功能总览 + +## 1. 产品定位 + +ControlPanel 是 ReinLoop 的 B 端管理工作台,用于管理公司与产线、签发许可证、管理模型、发布参数配置,并接收和审核 ReinLoop 上传的辨识数据。 + +桌面端基于 Electron,业务请求统一发送到 ReinLoop Server。渲染页面不直接访问文件系统、私钥或管理凭据,敏感操作通过 Electron 主进程完成。 + +## 2. 连接与鉴权 + +应用启动后首先显示连接页面,只包含: + +- Server API URL +- Admin Token +- “连接并校验”按钮 + +点击连接后,主进程调用需要管理权限的 `listOrganizations` 接口,同时验证: + +- Server 地址是否可访问 +- API 路径是否正确 +- Admin Token 是否有效 + +只有校验成功才显示后续业务工作台。连接失败时业务区域保持隐藏,并显示 Server 返回的错误信息。 + +连接信息仅保存在当前应用进程中: + +- Token 不写入浏览器存储。 +- Token 不写入本地配置文件。 +- Token 只由 Electron 主进程附加到 Server 请求。 +- 本次应用会话不提供更改连接入口;需要切换 Server 或 Token 时重新启动应用。 + +支持通过环境变量提供默认值: + +```text +REINLOOP_API_URL +B_ADMIN_TOKEN +REINLOOP_DEVICE_ID +POLL_INTERVAL_MS +DOWNLOAD_DIR +``` + +## 3. 公司与产线选择 + +连接成功后,工作台顶部显示公司和产线两个选择菜单。 + +设备业务标识由 Server 生成,格式固定为: + +```text +/ +``` + +例如: + +```text +sample-co/line-1 +``` + +配置、模型、辨识数据、审核反馈和容积请求都使用同一个 `deviceId`,避免不同公司或产线的数据混用。 + +### 在线状态 + +公司菜单显示在线产线汇总,例如: + +```text +示例公司 (sample-co) · 在线 2/3 +``` + +产线菜单显示具体状态: + +```text +● 在线 · 一号产线 (line-1) +○ 离线 · 二号产线 (line-2) +◇ 状态未知 · 三号产线 (line-3) +``` + +Panel 每 10 秒调用一次 `listOrganizations` 静默刷新状态。 + +Server 已实现设备心跳: + +- ReinLoop 调用 `deviceHeartbeat` 更新产线的 `lastSeenAt`。 +- Server 以最近 30 秒是否收到心跳计算 `online`。 +- `listOrganizations` 返回每条产线的 `online` 和 `lastSeenAt`。 +- 旧数据缺少状态字段时,Panel 显示“状态未知”,不会误报在线或离线。 + +## 4. 组织管理 + +“组织管理”页面支持: + +- 添加公司 +- 为指定公司添加产线 +- 刷新公司与产线列表 + +公司编码和产线编码只允许: + +- 小写英文字母 +- 数字 +- 下划线 +- 连字符 + +编码长度为 2 到 64 位,并且必须以字母或数字开头。 + +公司编码由 Server 保证全局唯一;产线编码在同一公司内唯一。 + +相关 Server 接口: + +```text +listOrganizations +createCompany +createProductionLine +``` + +## 5. 许可证签发与管理 + +“许可证”页面根据当前选择的公司和产线签发许可证。 + +签发字段包括: + +- 公司 +- 产线 +- 组合设备 ID +- 签发时间 +- 到期时间 +- 功能范围 +- 唯一许可证 ID + +许可证载荷示例: + +```json +{ + "license_id": "UUID", + "customer": "示例公司", + "company_id": "company_UUID", + "production_line_id": "line_UUID", + "device_id": "sample-co/line-1", + "issued": "2026-07-25 12:00", + "expiry": "2027-07-25 12:00", + "features": "*" +} +``` + +### 签名格式 + +Panel 使用 RSA-PSS SHA-256 签名,与 ReinLoop 的 Python 验签逻辑兼容。 + +许可证文件格式: + +```text +base64(JSON)|base64(signature) +``` + +签发核心会拒绝: + +- 空公司或产线标识 +- 非法组合设备 ID +- 无效日期格式 +- 到期时间不晚于签发时间 +- 空功能字段 + +### 私钥安全 + +- 每次签发由操作者选择外部 PEM 私钥。 +- 私钥只在 Electron 主进程内读取。 +- 私钥不会进入渲染页面。 +- 私钥不会上传 Server。 +- 私钥路径和内容不会由 Panel 持久化。 +- 私钥不会打进安装包。 + +### 本地与 Server 一致性 + +签发流程为: + +1. 选择私钥。 +2. 选择本地许可证保存位置。 +3. 生成并写入本地许可证。 +4. 将签发结果登记到 Server。 +5. Server 登记失败时删除本次本地文件,避免出现半完成状态。 + +许可证管理支持: + +- 刷新许可证列表 +- 查看许可证详情 +- 撤销有效许可证 +- 填写撤销原因 +- 区分有效和已撤销状态 + +相关 Server 接口: + +```text +createLicense +listLicenses +getLicense +revokeLicense +``` + +## 6. 模型管理 + +“模型管理”页面按当前产线操作: + +```text +/model_config +``` + +支持: + +- 刷新模型列表 +- 查看文件名、上传时间和大小 +- 从本地选择并上传模型 +- 同名模型覆盖前要求确认并输入完整文件名 +- 下载模型到本地 `downloads` 目录 +- 按 `fileID` 精确删除模型;删除前要求两次确认并输入完整文件名 + +上传使用 Server 的两步协议: + +1. 调用 `uploadDataFile` 获取上传地址与凭证。 +2. 使用 multipart 表单上传文件内容。 + +相关 Server 接口: + +```text +listModels +uploadDataFile +downloadModel +deleteFile +``` + +## 7. 参数配置发布 + +“配置发布”页面支持两类参数: + +- 容积测量配置 +- 系统辨识配置 + +用户可以: + +- 直接编辑 JSON +- 从本地导入 JSON +- 从 Server 读取当前配置 +- 发布新配置 + +### 容积测量参数 + +严格包含 8 个字段: + +```json +{ + "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 +} +``` + +Panel 在发布前检查: + +- 配置必须是 JSON 对象 +- 字段不能缺失 +- 不能包含多余字段 +- 数值必须有限 +- `num_runs` 必须是整数 + +容积配置只会响应 ReinLoop 当前有效的一次性请求;没有待处理请求时 Server 拒绝提交。 + +### 系统辨识参数 + +严格包含 9 个字段: + +```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 +} +``` + +Panel 将其序列化为 `parameter,value` 两列 CSV,并发布到当前产线: + +```text +/identification_config/identification_config.csv +``` + +## 8. 辨识数据接收与绘图 + +Panel 按当前产线轮询 Server 收件箱,不扫描 Server 文件目录。 + +默认轮询间隔为 1 秒,可通过 `POLL_INTERVAL_MS` 修改,最小允许值为 500 毫秒。 + +支持两类数据: + +### 辨识 CSV + +收到 CSV 后: + +1. 下载到本地。 +2. 生成阀门开度与压力组合时序图。 +3. 在工作台中显示图片。 +4. 等待人工提交“通过”或“未通过”。 + +### 行程稳定压力 JSON + +收到 JSON 后: + +1. 下载到本地。 +2. 将行程与稳定压力绘制为固定 0-1000 行程范围的数值折线图,并显示各点坐标。 +3. 在工作台中显示图片。 +4. 绘图成功后确认已处理;文件保留在 Server 暂存区,可继续查看和下载。 + +绘图结果支持在系统文件管理器中定位,也支持在应用内放大、缩小、适应窗口和原始比例查看。 + +## 9. 辨识数据暂存 + +“辨识数据”页面按当前产线列出 Server 暂存的 CSV 和行程 JSON,支持: + +- 查看并生成对应图像预览 +- 将原始 CSV 或 JSON 保存到用户选择的位置 +- 管理员二次确认后删除暂存数据 +- 查看待处理和已处理状态 + +文件下载请求携带 Admin Token;Server 可使用该令牌校验下载访问,或返回短期授权下载 URL。 + +## 10. 辨识人工审核 + +绘图页面提供: + +- “通过”按钮 +- “未通过”按钮 + +### 通过 + +提交数字 `1`: + +```json +{ + "type": "setIdentificationFeedback", + "deviceId": "sample-co/line-1", + "runId": "辨识 CSV 文件名", + "result": 1 +} +``` + +ReinLoop 获取结果后结束当前辨识流程。 + +### 未通过 + +提交数字 `0` 前,Panel 强制要求: + +1. 选择一份新的系统辨识参数 JSON。 +2. 成功发布新的辨识 CSV 配置。 +3. 再提交未通过结果。 + +ReinLoop 获取数字 `0` 后重新下载配置并执行下一轮辨识。 + +### 消息可靠性 + +CSV 不会在绘图后立即从 Server 删除。 + +只有以下操作都成功后才确认消息: + +1. 人工审核结论提交成功。 +2. Server 接受 `0/1` 反馈。 +3. Panel 调用 `ackPanelFile` 成功。 + +如果应用在审核前退出,CSV 仍保留在 Server,重新启动并选择同一产线后可以再次获取。 + +Panel 同一时间只处理一个待审核 CSV,避免多个审核结果串线。 + +## 10. Electron 安全边界 + +BrowserWindow 使用: + +```text +contextIsolation: true +nodeIntegration: false +sandbox: true +``` + +渲染页面只能通过 preload 暴露的有限 IPC 调用主进程。 + +以下能力仅存在于主进程: + +- Server Token 请求 +- 私钥读取和许可证签名 +- 本地文件选择与保存 +- 模型上传与下载 +- 绘图文件读取 +- 在文件管理器中定位文件 + +页面配置了 Content Security Policy,只允许加载应用自身脚本、样式和 data URL 图片。 + +## 11. 命令行兼容工具 + +除 Electron GUI 外,仍保留原有命令行能力: + +```text +b-admin.js +poll-panel-inbox.js +``` + +支持: + +- 发布容积配置 +- 发布系统辨识配置 +- 读取配置 +- 轮询待处理数据 +- 命令行人工审核 + +Electron GUI 是主要管理入口,命令行工具用于调试和兼容既有流程。 + +## 12. 运行与打包 + +安装依赖: + +```bash +cd ControlPanel +npm install +``` + +启动 Electron: + +```bash +npm run gui +``` + +运行语法检查: + +```bash +npm run check +``` + +运行测试: + +```bash +npm test +``` + +构建 Windows 安装版和便携版: + +```bash +npm run pack:win +``` + +输出目录: + +```text +Build/ +``` + +构建内容包含: + +- Electron 主进程和 preload +- 页面文件 +- Server 客户端 +- 配置管理模块 +- 许可证签发模块 +- CSV/JSON 绘图模块 +- `canvas` 原生依赖 + +`canvas` 会从 ASAR 中解包,以便 Windows 原生模块正常加载。 + +## 13. 当前验证状态 + +已验证: + +- 所有 Panel JavaScript 文件通过 `node --check`。 +- 许可证 RSA-PSS 签名可由对应公钥验证。 +- 非法身份、设备 ID 和时间范围会被拒绝。 +- 许可证签发单元测试通过。 +- 连接门禁首屏只显示 Server URL、Token 和校验按钮。 +- 未通过连接校验时业务区域保持隐藏。 +- 页面文件没有编辑器诊断错误。 + +当前 Linux 工作区中的完整绘图测试受原生 `canvas` 环境限制:已有 `canvas.node` 不是当前 Linux 可加载格式,源码重建又缺少系统 `pangocairo` 开发库。该限制不影响 JavaScript 语法和许可证测试,但 Windows 发布前仍需在目标构建环境执行完整绘图和打包验证。 + +## 14. 主要 Server 接口依赖 + +连接与组织: + +```text +listOrganizations +createCompany +createProductionLine +``` + +许可证: + +```text +createLicense +listLicenses +getLicense +revokeLicense +``` + +模型与文件: + +```text +uploadDataFile +listModels +downloadModel +deleteFile +``` + +配置: + +```text +getPendingVolumeConfigRequest +submitVolumeConfigFile +publishIdentificationConfig +getIdentificationConfig +getFunctionConfig +``` + +辨识与收件箱: + +```text +getPendingPanelFile +ackPanelFile +setIdentificationFeedback +``` + +设备在线状态由 ReinLoop 调用: + +```text +deviceHeartbeat +``` diff --git a/ControlPanel/identification-config.example.json b/ControlPanel/identification-config.example.json new file mode 100644 index 0000000..5e90887 --- /dev/null +++ b/ControlPanel/identification-config.example.json @@ -0,0 +1,11 @@ +{ + "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 +} \ No newline at end of file diff --git a/ControlPanel/license-manager.js b/ControlPanel/license-manager.js new file mode 100644 index 0000000..b403a44 --- /dev/null +++ b/ControlPanel/license-manager.js @@ -0,0 +1,56 @@ +const crypto = require("node:crypto"); +const fs = require("node:fs"); + +function requiredText(value, fieldName) { + const text = String(value || "").trim(); + if (!text) throw new Error(`${fieldName}不能为空`); + return text; +} + +function normalizeTimestamp(value, fieldName) { + const timestamp = requiredText(value, fieldName); + const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(timestamp); + if (!match) throw new Error(`${fieldName}格式必须为 YYYY-MM-DD HH:MM`); + const [, year, month, day, hour, minute] = match.map(Number); + const parsed = new Date(year, month - 1, day, hour, minute); + if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || + parsed.getDate() !== day || parsed.getHours() !== hour || parsed.getMinutes() !== minute) { + throw new Error(`${fieldName}不是有效日期时间`); + } + return timestamp; +} + +function signLicense(payload, privateKeyPath) { + const licenseId = payload.license_id || crypto.randomUUID(); + const deviceId = requiredText(payload.device_id, "device_id"); + const deviceParts = deviceId.split("/"); + if (deviceParts.length !== 2 || deviceParts.some((part) => !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(part))) { + throw new Error("device_id 必须是 company-code/line-code 格式"); + } + const issued = normalizeTimestamp(payload.issued, "签发时间"); + const expiry = normalizeTimestamp(payload.expiry, "到期时间"); + if (expiry <= issued) throw new Error("到期时间必须晚于签发时间"); + const normalized = { + license_id: licenseId, + customer: requiredText(payload.customer, "customer"), + company_id: requiredText(payload.company_id, "company_id"), + production_line_id: requiredText(payload.production_line_id, "production_line_id"), + device_id: deviceId, + issued, + expiry, + features: requiredText(payload.features || "*", "features") + }; + const payloadBase64 = Buffer.from(JSON.stringify(normalized)).toString("base64"); + const privateKey = fs.readFileSync(privateKeyPath); + const signature = crypto.sign("sha256", Buffer.from(payloadBase64), { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX_SIGN + }); + return { + payload: normalized, + content: `${payloadBase64}|${signature.toString("base64")}` + }; +} + +module.exports = { signLicense }; \ No newline at end of file diff --git a/ControlPanel/package-lock.json b/ControlPanel/package-lock.json new file mode 100644 index 0000000..d6e9736 --- /dev/null +++ b/ControlPanel/package-lock.json @@ -0,0 +1,3998 @@ +{ + "name": "reinloop-control-panel", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reinloop-control-panel", + "version": "1.0.1", + "dependencies": { + "canvas": "^3.2.0", + "chart.js": "^4.5.1", + "chartjs-node-canvas": "^5.0.0", + "csv-parse": "^6.1.0" + }, + "devDependencies": { + "electron": "^37.2.6", + "electron-builder": "24.13.3" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/canvas": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-node-canvas": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chartjs-node-canvas/-/chartjs-node-canvas-5.0.0.tgz", + "integrity": "sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==", + "dependencies": { + "canvas": "^3.1.0", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "chart.js": "^4.4.8" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csv-parse": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz", + "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "37.10.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-37.10.3.tgz", + "integrity": "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-corefoundation/node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "optional": true + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "peer": true + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/ControlPanel/package.json b/ControlPanel/package.json new file mode 100644 index 0000000..9141832 --- /dev/null +++ b/ControlPanel/package.json @@ -0,0 +1,75 @@ +{ + "name": "reinloop-control-panel", + "version": "1.0.1", + "description": "ReinLoop B 端数据绘图与配置发布工具", + "author": "ReinLoop", + "private": true, + "main": "electron-main.js", + "scripts": { + "start": "node poll-panel-inbox.js", + "gui": "electron .", + "pack:win": "npm run pack:installer && npm run pack:portable", + "pack:installer": "electron-builder --win nsis -c.artifactName=ReinLoop-BConsole-Setup-${version}-${arch}.${ext}", + "pack:portable": "electron-builder --win portable -c.artifactName=ReinLoop-BConsole-Portable-${version}-${arch}.${ext}", + "pack:mac": "electron-builder --mac dmg zip --x64 --arm64 -c.artifactName=ReinLoop-BConsole-${version}-mac-${arch}.${ext}", + "check": "node --check poll-panel-inbox.js && node --check server-client.js && node --check b-admin.js && node --check plot-json.js && node --check license-manager.js && node --check electron-main.js && node --check electron-preload.js && node --check electron-ui/renderer.js", + "test": "node --test", + "publish:volume": "node b-admin.js publish-volume", + "publish:identification": "node b-admin.js publish-identification" + }, + "dependencies": { + "canvas": "^3.2.0", + "chart.js": "^4.5.1", + "chartjs-node-canvas": "^5.0.0", + "csv-parse": "^6.1.0" + }, + "devDependencies": { + "electron": "^37.2.6", + "electron-builder": "24.13.3" + }, + "build": { + "appId": "com.reinloop.bconsole", + "productName": "ReinLoop B端工作台", + "asar": true, + "asarUnpack": [ + "node_modules/canvas/**/*" + ], + "files": [ + "electron-main.js", + "electron-preload.js", + "electron-ui/**/*", + "b-admin.js", + "license-manager.js", + "server-client.js", + "plot-csv.js", + "plot-json.js", + "node_modules/**/*" + ], + "directories": { + "output": "../Build" + }, + "win": { + "target": [ + "nsis", + "portable" + ] + }, + "mac": { + "target": [ + "dmg", + "zip" + ], + "category": "public.app-category.productivity" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + } + }, + "allowScripts": { + "canvas@3.2.3": true, + "electron@37.10.3": true + } +} diff --git a/ControlPanel/plot-csv.js b/ControlPanel/plot-csv.js new file mode 100644 index 0000000..b703e13 --- /dev/null +++ b/ControlPanel/plot-csv.js @@ -0,0 +1,153 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { parse } = require("csv-parse/sync"); +const { ChartJSNodeCanvas } = require("chartjs-node-canvas"); +const { createCanvas, loadImage, registerFont } = require("canvas"); + +const CHART_WIDTH = 1400; +const CHART_HEIGHT = 540; +const CHINESE_FONT_PATH = "C:\\Windows\\Fonts\\msyh.ttc"; + +if (fs.existsSync(CHINESE_FONT_PATH)) { + registerFont(CHINESE_FONT_PATH, { family: "Microsoft YaHei" }); +} + +const chartCanvas = new ChartJSNodeCanvas({ + width: CHART_WIDTH, + height: CHART_HEIGHT, + backgroundColour: "white" +}); + +async function renderTimeSeries(points, options) { + return chartCanvas.renderToBuffer({ + type: "line", + data: { + datasets: [{ + data: points.map((row) => ({ x: row.t, y: row[options.column] })), + borderColor: options.color, + borderWidth: 2, + pointRadius: 0, + stepped: options.stepped, + tension: 0, + fill: false + }] + }, + options: { + responsive: false, + animation: false, + parsing: false, + layout: { padding: { top: 14, right: 34, bottom: 8, left: 24 } }, + plugins: { + legend: { display: false }, + title: { + display: true, + text: options.title, + color: "#222222", + font: { family: "Microsoft YaHei", size: 21, weight: "normal" }, + padding: { bottom: 10 } + } + }, + scales: { + x: { + type: "linear", + min: options.xMin, + max: options.xMax, + grid: { color: "#d8d8d8", lineWidth: 1 }, + border: { color: "#333333", width: 1.5 }, + ticks: { color: "#333333", font: { family: "Microsoft YaHei", size: 14 } }, + title: { + display: true, + text: "时间 (s)", + color: "#333333", + font: { family: "Microsoft YaHei", size: 17 } + } + }, + y: { + min: 0, + max: options.yMax, + grid: { color: "#d8d8d8", lineWidth: 1 }, + border: { color: "#333333", width: 1.5 }, + ticks: { color: "#333333", font: { family: "Microsoft YaHei", size: 14 } }, + title: { + display: true, + text: options.yLabel, + color: "#333333", + font: { family: "Microsoft YaHei", size: 17 } + } + } + } + } + }); +} + +async function renderCombinedPlot(points, outputPath) { + const minimumTime = Math.min(...points.map((row) => row.t)); + const maximumTime = Math.max(...points.map((row) => row.t)); + const timeSpan = Math.max(maximumTime - minimumTime, 1); + const xMin = Math.min(0, minimumTime); + const xMax = Math.ceil((maximumTime + timeSpan * 0.05) / 10) * 10; + const maximumPressure = Math.max(...points.map((row) => row.p)); + const pressureStep = maximumPressure <= 100 ? 20 : 50; + const pressureMax = Math.ceil((maximumPressure * 1.1) / pressureStep) * pressureStep; + + const upperImage = await renderTimeSeries(points, { + column: "u", + color: "#304ffe", + stepped: true, + title: "阀门开度随时间变化", + yLabel: "阀门开度 u (%)", + xMin, + xMax, + yMax: 100 + }); + const lowerImage = await renderTimeSeries(points, { + column: "p", + color: "#f5222d", + stepped: false, + title: "压力随时间变化", + yLabel: "压力 p (kPa)", + xMin, + xMax, + yMax: pressureMax + }); + + const canvas = createCanvas(CHART_WIDTH, CHART_HEIGHT * 2); + const context = canvas.getContext("2d"); + context.fillStyle = "white"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(await loadImage(upperImage), 0, 0); + context.drawImage(await loadImage(lowerImage), 0, CHART_HEIGHT); + + await fs.promises.writeFile(outputPath, canvas.toBuffer("image/png")); + console.log(`[${new Date().toISOString()}] 已生成时序图: ${outputPath}`); +} + +async function plotCsv(csvPath) { + const content = await fs.promises.readFile(csvPath, "utf8"); + const rows = parse(content, { + bom: true, + columns: true, + skip_empty_lines: true, + trim: true + }); + const requiredColumns = ["t", "u", "p"]; + const headers = rows.length > 0 ? Object.keys(rows[0]) : []; + const missingColumns = requiredColumns.filter((column) => !headers.includes(column)); + if (missingColumns.length > 0) { + throw new Error(`CSV 缺少列: ${missingColumns.join(", ")}`); + } + + const points = rows + .map((row) => ({ t: Number(row.t), u: Number(row.u), p: Number(row.p) })) + .filter((row) => Number.isFinite(row.t) && Number.isFinite(row.u) && Number.isFinite(row.p)); + if (points.length === 0) { + throw new Error("CSV 中没有可绘制的 t、u、p 数值行"); + } + + const parsedPath = path.parse(csvPath); + const outputPath = path.join(parsedPath.dir, `${parsedPath.name}-u-p-t.png`); + await renderCombinedPlot(points, outputPath); + return outputPath; +} + +module.exports = { plotCsv }; \ No newline at end of file diff --git a/ControlPanel/plot-json.js b/ControlPanel/plot-json.js new file mode 100644 index 0000000..7e4641f --- /dev/null +++ b/ControlPanel/plot-json.js @@ -0,0 +1,135 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { ChartJSNodeCanvas } = require("chartjs-node-canvas"); + +const chartCanvas = new ChartJSNodeCanvas({ + width: 1400, + height: 720, + backgroundColour: "white" +}); + +function normalizeJsonSeries(data) { + if (Array.isArray(data)) { + if (data.every(Number.isFinite)) { + return { labels: data.map((_, index) => index), series: [{ label: "value", data }] }; + } + if (data.every((item) => item && typeof item === "object" && !Array.isArray(item))) { + const numericFields = [...new Set(data.flatMap(Object.keys))] + .filter((field) => data.some((item) => Number.isFinite(item[field]))); + const xField = ["t", "time", "x", "timestamp", "index", "distance"] + .find((field) => numericFields.includes(field)); + const valueFields = numericFields.filter((field) => field !== xField); + const isTravelStability = xField === "distance" && valueFields.length === 1 && valueFields[0] === "pressure"; + return { + labels: data.map((item, index) => xField ? item[xField] : index), + series: valueFields.map((field) => ({ + label: field, + data: isTravelStability + ? data.filter((item) => Number.isFinite(item.distance) && Number.isFinite(item[field])) + .map((item) => ({ x: item.distance, y: item[field] })) + : data.map((item) => Number.isFinite(item[field]) ? item[field] : null) + })), + chartKind: isTravelStability ? "travel-stability" : "line", + xLabel: xField === "distance" ? "行程" : "采样点 / 时间", + yLabel: isTravelStability + ? "稳态压力 (kPa)" + : "数值" + }; + } + } + + if (data && typeof data === "object") { + const arrays = Object.entries(data).filter(([, value]) => Array.isArray(value)); + if (arrays.length === 1) return normalizeJsonSeries(arrays[0][1]); + + const numericArrays = arrays.filter(([, values]) => + values.length > 0 && values.every(Number.isFinite) + ); + if (numericArrays.length > 0) { + const xEntry = numericArrays.find(([field]) => + ["t", "time", "x", "timestamp", "index"].includes(field) + ); + const seriesEntries = numericArrays.filter(([field]) => !xEntry || field !== xEntry[0]); + const pointCount = Math.max(...numericArrays.map(([, values]) => values.length)); + return { + labels: xEntry ? xEntry[1] : Array.from({ length: pointCount }, (_, index) => index), + series: seriesEntries.map(([label, values]) => ({ label, data: values })) + }; + } + } + + throw new Error("JSON 必须包含数字数组、数值对象数组或多个数值数组字段"); +} + +const travelPointLabels = { + id: "travelPointLabels", + afterDatasetsDraw(chart) { + if (chart.options.plugins.travelPointLabels !== true) return; + const { ctx } = chart; + ctx.save(); + ctx.fillStyle = "#15251f"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + for (const meta of chart.getSortedVisibleDatasetMetas()) { + meta.data.forEach((element, index) => { + const point = chart.data.datasets[meta.index].data[index]; + ctx.fillText(`(${point.x}, ${Number(point.y).toFixed(2)})`, element.x + 7, element.y - 7); + }); + } + ctx.restore(); + } +}; + +function buildChartConfiguration(normalized, title) { + const isTravelStability = normalized.chartKind === "travel-stability"; + const colors = ["#d62828", "#0077b6", "#2a9d8f", "#f77f00", "#6a4c93", "#495057"]; + return { + type: isTravelStability ? "scatter" : "line", + data: { + labels: isTravelStability ? undefined : normalized.labels, + datasets: normalized.series.map((item, index) => ({ + ...item, + borderColor: colors[index % colors.length], + borderWidth: 2, + pointRadius: isTravelStability ? 4 : 0, + pointHoverRadius: isTravelStability ? 7 : 3, + showLine: isTravelStability, + tension: 0, + fill: false + })) + }, + options: { + responsive: false, + animation: false, + layout: isTravelStability ? { padding: { top: 24, right: 92 } } : undefined, + plugins: { + title: { display: true, text: title }, + legend: { display: true }, + travelPointLabels: isTravelStability + }, + scales: { + x: isTravelStability + ? { type: "linear", min: 0, max: 1000, title: { display: true, text: normalized.xLabel } } + : { title: { display: true, text: normalized.xLabel } }, + y: { title: { display: true, text: normalized.yLabel } } + } + }, + plugins: isTravelStability ? [travelPointLabels] : [] + }; +} + +async function plotJson(jsonPath) { + const content = await fs.promises.readFile(jsonPath, "utf8"); + const normalized = normalizeJsonSeries(JSON.parse(content)); + if (normalized.series.length === 0) throw new Error("JSON 数组中没有可绘制的数值字段"); + const image = await chartCanvas.renderToBuffer(buildChartConfiguration(normalized, path.basename(jsonPath))); + + const parsedPath = path.parse(jsonPath); + const outputPath = path.join(parsedPath.dir, `${parsedPath.name}-line.png`); + await fs.promises.writeFile(outputPath, image); + console.log(`[${new Date().toISOString()}] 已生成 JSON 折线图: ${outputPath}`); + return outputPath; +} + +module.exports = { buildChartConfiguration, normalizeJsonSeries, plotJson }; \ No newline at end of file diff --git a/ControlPanel/poll-panel-inbox.js b/ControlPanel/poll-panel-inbox.js new file mode 100644 index 0000000..4a70ae0 --- /dev/null +++ b/ControlPanel/poll-panel-inbox.js @@ -0,0 +1,113 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const readline = require("node:readline/promises"); +const { callServer, downloadFromUrl } = require("./server-client"); +const { publishConfig } = require("./b-admin"); +const { plotCsv } = require("./plot-csv"); +const { plotJson } = require("./plot-json"); + +const API_URL = process.env.REINLOOP_API_URL; +const B_ADMIN_TOKEN = process.env.B_ADMIN_TOKEN; +const DEVICE_ID = process.env.REINLOOP_DEVICE_ID; +const REVIEW_MODE = process.env.REVIEW_MODE || "manual"; +const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 3000); + +let polling = false; + +async function reviewIdentification(message, imagePath) { + if (REVIEW_MODE !== "manual") { + console.log(`已生成 ${imagePath};REVIEW_MODE=${REVIEW_MODE},跳过人工评审`); + return; + } + + const prompt = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + console.log(`请查看辨识图像: ${imagePath}`); + let decision; + while (decision !== 0 && decision !== 1) { + const answer = (await prompt.question("参数是否通过?输入 1=通过,0=不通过: ")).trim(); + if (answer === "0" || answer === "1") decision = Number(answer); + } + + if (decision === 0) { + const configPath = (await prompt.question("请输入新的函数2参数 JSON 路径: ")).trim(); + const config = JSON.parse(await fs.promises.readFile(path.resolve(configPath), "utf8")); + await publishConfig("identification", config.parameters || config, { + apiUrl: API_URL, + adminToken: B_ADMIN_TOKEN, + deviceId: DEVICE_ID + }); + } + + const result = await callServer({ + type: "setIdentificationFeedback", + deviceId: DEVICE_ID, + runId: message.fileName, + result: decision + }); + console.log(`辨识结果已提交: ${result.result === 1 ? "通过" : "不通过"}`); + } finally { + prompt.close(); + } +} + +async function processPendingMessage() { + const message = await callServer({ type: "getPendingPanelFile", deviceId: DEVICE_ID }); + if (!message.pending) return false; + + const sourcePath = await downloadFromUrl(message.url, message.fileName); + if (message.mediaType === "json") { + await plotJson(sourcePath); + } else { + const imagePath = await plotCsv(sourcePath); + await reviewIdentification(message, imagePath); + } + + await callServer({ + type: "ackPanelFile", + deviceId: DEVICE_ID, + fileID: message.fileID + }); + return true; +} + +async function poll() { + if (polling) return; + polling = true; + try { + while (await processPendingMessage()) { + // Drain messages already queued on the server before waiting again. + } + } catch (error) { + console.error(`[${new Date().toISOString()}] 消息处理失败: ${error.message}`); + } finally { + polling = false; + } +} + +function validateConfig() { + if (!API_URL) throw new Error("缺少环境变量 REINLOOP_API_URL"); + if (!DEVICE_ID) throw new Error("缺少环境变量 REINLOOP_DEVICE_ID"); + if (REVIEW_MODE === "manual" && !B_ADMIN_TOKEN) { + throw new Error("人工评审模式缺少环境变量 B_ADMIN_TOKEN"); + } + if (!Number.isFinite(POLL_INTERVAL_MS) || POLL_INTERVAL_MS < 1000) { + throw new Error("POLL_INTERVAL_MS 必须是大于或等于 1000 的数字"); + } +} + +function main() { + try { + validateConfig(); + console.log(`开始获取设备 ${DEVICE_ID} 的待处理消息,轮询间隔 ${POLL_INTERVAL_MS}ms`); + void poll(); + setInterval(poll, POLL_INTERVAL_MS); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +if (require.main === module) main(); + +module.exports = { processPendingMessage }; \ No newline at end of file diff --git a/ControlPanel/server-client.js b/ControlPanel/server-client.js new file mode 100644 index 0000000..67c1d15 --- /dev/null +++ b/ControlPanel/server-client.js @@ -0,0 +1,69 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { Readable } = require("node:stream"); +const { pipeline } = require("node:stream/promises"); + +const DOWNLOAD_DIR = path.resolve(process.env.DOWNLOAD_DIR || "downloads"); + +function getServerCredentials(options = {}) { + const apiUrl = options.apiUrl || process.env.REINLOOP_API_URL; + const adminToken = options.adminToken || process.env.B_ADMIN_TOKEN; + if (!apiUrl) throw new Error("缺少 server API URL"); + if (!adminToken) throw new Error("缺少 Admin Token"); + return { apiUrl, adminToken }; +} + +async function callServer(payload, options = {}) { + const { apiUrl, adminToken } = getServerCredentials(options); + const response = await fetch(apiUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...payload, adminToken }) + }); + if (!response.ok) throw new Error(`server 请求失败: HTTP ${response.status}`); + + const result = await response.json(); + if (!result.success) throw new Error(result.errMsg || "server 返回失败"); + return result; +} + +function downloadHeaders(options = {}) { + if (!options.adminToken) return {}; + return { + authorization: `Bearer ${options.adminToken}`, + "x-admin-token": options.adminToken + }; +} + +async function downloadFromUrl(url, fileName, options = {}) { + await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true }); + return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options); +} + +async function downloadToPath(url, destination, options = {}) { + const response = await fetch(url, { headers: downloadHeaders(options) }); + if (!response.ok || !response.body) { + throw new Error(`下载失败: HTTP ${response.status}`); + } + + await fs.promises.mkdir(path.dirname(destination), { recursive: true }); + const temporary = `${destination}.downloading`; + + try { + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(temporary)); + await fs.promises.rename(temporary, destination); + } catch (error) { + await fs.promises.rm(temporary, { force: true }); + throw error; + } + + console.log(`[${new Date().toISOString()}] 已下载: ${destination}`); + return destination; +} + +async function downloadFile(fileID, fileName, options = {}) { + const { url } = await callServer({ type: "downloadModel", fileID }, options); + return downloadFromUrl(url, fileName, options); +} + +module.exports = { callServer, downloadFile, downloadFromUrl, downloadToPath, getServerCredentials }; \ No newline at end of file diff --git a/ControlPanel/test/license-manager.test.js b/ControlPanel/test/license-manager.test.js new file mode 100644 index 0000000..0c32968 --- /dev/null +++ b/ControlPanel/test/license-manager.test.js @@ -0,0 +1,60 @@ +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { after, before, test } = require("node:test"); + +const { signLicense } = require("../license-manager"); + +let privateKeyPath; +let publicKey; +let temporaryDirectory; + +before(async () => { + temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-license-")); + const pair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + privateKeyPath = path.join(temporaryDirectory, "license_private.pem"); + publicKey = pair.publicKey; + await fs.promises.writeFile(privateKeyPath, pair.privateKey.export({ + type: "pkcs8", + format: "pem" + })); +}); + +after(async () => { + await fs.promises.rm(temporaryDirectory, { recursive: true, force: true }); +}); + +function validPayload(overrides = {}) { + return { + customer: "示例公司", + company_id: "company-1", + production_line_id: "line-1", + device_id: "sample-co/line-1", + issued: "2026-07-25 12:00", + expiry: "2027-07-25 12:00", + features: "*", + ...overrides + }; +} + +test("signLicense creates a Python-compatible RSA-PSS license", () => { + const signed = signLicense(validPayload(), privateKeyPath); + const [payloadBase64, signatureBase64] = signed.content.split("|"); + const verified = crypto.verify("sha256", Buffer.from(payloadBase64), { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO + }, Buffer.from(signatureBase64, "base64")); + + assert.equal(verified, true); + assert.deepEqual(JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8")), signed.payload); + assert.match(signed.payload.license_id, /^[0-9a-f-]{36}$/); +}); + +test("signLicense rejects invalid identity and date ranges", () => { + assert.throws(() => signLicense(validPayload({ device_id: "line-only" }), privateKeyPath), /device_id/); + assert.throws(() => signLicense(validPayload({ company_id: "" }), privateKeyPath), /company_id/); + assert.throws(() => signLicense(validPayload({ expiry: "2026-07-24 12:00" }), privateKeyPath), /到期时间/); +}); diff --git a/ControlPanel/test/plot-json.test.js b/ControlPanel/test/plot-json.test.js new file mode 100644 index 0000000..5c473ef --- /dev/null +++ b/ControlPanel/test/plot-json.test.js @@ -0,0 +1,35 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { buildChartConfiguration, normalizeJsonSeries } = require("../plot-json"); + +test("travel stability data plots pressure against motor distance", () => { + const normalized = normalizeJsonSeries({ + stable_pressures: [ + { distance: 1000, pressure: 12.3 }, + { distance: 900, pressure: 15.6 }, + { distance: 800, pressure: 18.1 } + ] + }); + + assert.deepEqual(normalized.labels, [1000, 900, 800]); + assert.deepEqual(normalized.series, [{ + label: "pressure", + data: [ + { x: 1000, y: 12.3 }, + { x: 900, y: 15.6 }, + { x: 800, y: 18.1 } + ] + }]); + assert.equal(normalized.chartKind, "travel-stability"); + assert.equal(normalized.xLabel, "行程"); + assert.equal(normalized.yLabel, "稳态压力 (kPa)"); + + const chart = buildChartConfiguration(normalized, "travel.json"); + assert.equal(chart.type, "scatter"); + assert.equal(chart.options.scales.x.type, "linear"); + assert.equal(chart.options.scales.x.min, 0); + assert.equal(chart.options.scales.x.max, 1000); + assert.equal(chart.options.plugins.travelPointLabels, true); + assert.equal(chart.data.datasets[0].pointRadius, 4); +}); \ No newline at end of file diff --git a/ControlPanel/volume-config.example.json b/ControlPanel/volume-config.example.json new file mode 100644 index 0000000..f1c2239 --- /dev/null +++ b/ControlPanel/volume-config.example.json @@ -0,0 +1,10 @@ +{ + "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 +} \ No newline at end of file diff --git a/ReinLoop/.gitignore b/ReinLoop/.gitignore new file mode 100644 index 0000000..afcedcc --- /dev/null +++ b/ReinLoop/.gitignore @@ -0,0 +1,80 @@ +.lic + +# ===================== +# Python +# ===================== +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +*.egg +dist/ +build/ +*.whl +*.manifest +*.spec + +# ===================== +# Virtual environments +# ===================== +venv/ +env/ +.venv/ +.env/ + +# ===================== +# C extensions / Cython +# ===================== +*.pyd +*.so +*.c +*.exp +*.lib +*.obj + +# ===================== +# IDE / Editor +# ===================== +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# ===================== +# macOS +# ===================== +.DS_Store +.AppleDouble +.LSOverride +._* + +# ===================== +# Build artifacts +# ===================== +build_libs/temp/ + +# ===================== +# Secrets & keys +# ===================== +license_private.pem +*.key +.env.local +*.local + +# ===================== +# Logs & runtime data +# ===================== +*.log +data_record/ +ind_data/ +model_config/ + +# ===================== +# Distribution / packaging +# ===================== +*.zip +*.tar.gz +*.dmg +*.app +installer/ diff --git a/ReinLoop/PcControl.py b/ReinLoop/PcControl.py new file mode 100644 index 0000000..2425528 --- /dev/null +++ b/ReinLoop/PcControl.py @@ -0,0 +1,818 @@ +import time +import os +import sys +from pymodbus.client import ModbusTcpClient, ModbusSerialClient +from pymodbus.exceptions import ModbusException +from pymodbus.payload import BinaryPayloadDecoder, BinaryPayloadBuilder +from pymodbus.constants import Endian + + +def _motor_log(msg: str): + """电机操作日志,直接写文件 + 刷盘""" + try: + log_dir = os.path.join(os.path.dirname(sys.executable), "logs") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "control_debug.log") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"[{time.strftime('%H:%M:%S.%f')[:-3]}] [MOTOR] {msg}\n") + f.flush() + os.fsync(f.fileno()) + except Exception: + pass + +volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA) +volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA) +x_max = 1000 # 最大行程 +impulse_max = 163840 # 电机脉冲最大值(对应 x_max) + + +def set_motor_limits(volthege_min_val=None, volthege_max_val=None, x_max_val=None): + """更新电机限幅参数(由 GUI 高级设置页面调用) + + Args: + volthege_min_val: 模拟量映射最小值,None 表示不更新 + volthege_max_val: 模拟量映射最大值,None 表示不更新 + x_max_val: 最大行程(对应 GUI 总限幅),None 表示不更新 + """ + global volthege_min, volthege_max, x_max + if volthege_min_val is not None: + volthege_min = volthege_min_val + if volthege_max_val is not None: + volthege_max = volthege_max_val + if x_max_val is not None: + x_max = x_max_val + +# ---------- 原有的 PLC Modbus TCP 客户端类 ---------- +class Easy521ModbusClient: + # 参数来源(GUI 页面1 Modbus TCP 区): + # host <- PLC地址 (默认 192.168.1.88) + # port <- 端口 (默认 502) + # current_p_addr <- 读取压力寄存器地址 (默认 504) + def __init__(self, host="192.168.1.88", port=502, slave_id=1, current_p_addr=504): + self.host = host + self.port = port + self.slave_id = slave_id + self.client = ModbusTcpClient( + host=host, + port=port, + timeout=3, + retries=3 + ) + self.connected = False + self.current_p_addr0 = current_p_addr + self.current_p_addr = current_p_addr + # self.current_p_addr = 18 + self.target_p_addr = 42 + self.u_addr = 514 + # self.u_addr = 40 + # self.output_postion = 514 + self.control_flag_addr = 100 + self.M901_ADDR = 901 + self.M902_ADDR = 902 + self.M903_ADDR = 903 + self.M904_ADDR = 904 + self.M905_ADDR = 905 + self.M906_ADDR = 906 + self.q_addr = 512 + + def connect(self): + try: + connection = self.client.connect() + if connection: + print(f"成功连接到 {self.host}:{self.port}") + self.connected = True + else: + print(f"无法连接到 {self.host}:{self.port}") + self.connected = False + return connection + except Exception as e: + print(f"连接错误: {e}") + self.connected = False + return False + + def disconnect(self): + self.client.close() + self.connected = False + print("连接已关闭") + + def read_float(self, address): + try: + address = int(address) + result = self.client.read_input_registers( + address=address, + count=2, + slave=self.slave_id + ) + if not result.isError(): + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, + byteorder=Endian.BIG, + wordorder=Endian.LITTLE + ) + return decoder.decode_32bit_float() + else: + print(f"读取寄存器错误: {result}") + return None + except Exception as e: + print(f"读取浮点数时发生错误: {e}") + return None + + def write_float(self, address, float_value): + try: + address = int(address) + builder = BinaryPayloadBuilder(byteorder=Endian.LITTLE, wordorder=Endian.BIG) + builder.add_32bit_float(float_value) + payload = builder.to_registers() + result = self.client.write_registers( + address=address, + values=payload, + slave=self.slave_id + ) + return not result.isError() + except Exception as e: + print(f"写入浮点数时发生错误: {e}") + return False + + def write_coil(self, address, value): + try: + address = int(address) + result = self.client.write_coil(address=address, value=value, slave=self.slave_id) + return not result.isError() + except Exception as e: + print(f"写入线圈时发生错误: {e}") + return False + + def get_current_p(self): + return self.read_float(self.current_p_addr) + + def get_current_p0(self): + return self.read_float(self.current_p_addr0) + + def get_target_p(self): + return self.read_float(self.target_p_addr) + + def get_current_q(self): + return self.read_float(self.q_addr) + + def write_u(self, float_value): + try: + address = int(self.u_addr) + builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.LITTLE) + builder.add_32bit_float(float_value) + payload = builder.to_registers() + result = self.client.write_registers( + address=address, + values=payload, + slave=self.slave_id + ) + return not result.isError() + except Exception as e: + print(f"写入浮点数时发生错误: {e}") + return False + + def start_control(self): + success = self.write_coil(self.control_flag_addr, True) + if success: + print("成功写入控制标志位True") + else: + print("写入控制标志位失败") + + def stop_control(self): + success = self.write_coil(self.control_flag_addr, False) + if success: + print("成功写入控制标志位False") + else: + print("写入控制标志位失败") + + def read_rtu_flow(self, port='COM3', slave_id=2, baudrate=9600, bytesize=8, parity='N', stopbits=1): + client = ModbusSerialClient( + port=port, + baudrate=baudrate, + bytesize=bytesize, + parity=parity, + stopbits=stopbits, + timeout=3 + ) + if not client.connect(): + print(f"无法连接到串口 {port}") + return None + + try: + result = client.read_holding_registers(address=22, count=2, slave=slave_id) + if result.isError(): + print(f"RTU 读取寄存器错误: {result}") + return None + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, + byteorder=Endian.BIG, + wordorder=Endian.BIG + ) + value = decoder.decode_32bit_uint() / 100 + return value + except Exception as e: + return None + finally: + client.close() + + def start_up(self): + if self.write_coil(self.M901_ADDR, True): + print("M901 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M901_ADDR, False): + print("M901 复位 FALSE") + else: + print("警告:M901 复位失败") + else: + print("警告:M901 置位失败") + time.sleep(1) + + if self.write_coil(self.M902_ADDR, True): + print("M902 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M902_ADDR, False): + print("M902 复位 FALSE") + else: + print("警告:M902 复位失败") + else: + print("警告:M902 置位失败") + time.sleep(1) + + if self.write_coil(self.M905_ADDR, True): + print("M905 (初始开度) 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M905_ADDR, False): + print("M905 (初始开度) 复位 FALSE") + else: + print("警告:M905 (初始开度) 复位失败") + else: + print("警告:M905 (初始开度) 置位失败") + time.sleep(1) + + if self.write_coil(self.M903_ADDR, True): + print("M903 置位 TRUE(持续)") + else: + print("警告:M903 置位失败") + + if self.write_coil(self.M904_ADDR, True): + print("M904 置位 TRUE(持续)") + else: + print("警告:M904 置位失败") + time.sleep(1) + + if self.write_coil(self.M906_ADDR, True): + print("M906 (归零) 置位 TRUE") + time.sleep(1) + if self.write_coil(self.M906_ADDR, False): + print("M906 (归零) 复位 FALSE") + else: + print("警告:M906 (归零) 复位失败") + else: + print("警告:M906 (归零) 置位失败") + + print("初始化完成,M903 和 M904 已保持为 TRUE。") + + +# ---------- 新增:独立的电机 Modbus RTU 客户端类(含报文打印) ---------- +class MotorModbusRTUClient: + """电机 Modbus RTU 通讯客户端(增强调试报文打印)""" + # 参数来源(GUI 页面1 Modbus RTU 区): + # port <- 端口号 (下拉) + # baudrate <- 波特率 (默认 115200) + # slave_id <- 站号 (默认 4) + # bytesize <- 数据位 (默认 8) + # stopbits <- 停止位 (默认 1) + # parity <- 校验位 None/Odd/Even -> 'N'/'O'/'E' (默认 'N') + def __init__(self, port='/dev/cu.usbserial-BG02B0IX', slave_id=4, baudrate=115200, + bytesize=8, parity='N', stopbits=1): + # def __init__(self, port='/dev/cu.usbserial-D30JITMY', slave_id=4, baudrate=115200): + self.port = port + self.slave_id = slave_id + self.baudrate = baudrate + self.bytesize = bytesize + self.parity = parity + self.stopbits = stopbits + self.client = None + + def connect(self): + """连接电机串口""" + self.client = ModbusSerialClient( + port=self.port, + baudrate=self.baudrate, + bytesize=self.bytesize, + parity=self.parity, + stopbits=self.stopbits, + timeout=5 # 超时时间延长,便于观察 + ) + if self.client.connect(): + print(f"电机串口 {self.port} 连接成功") + return True + else: + print(f"电机串口 {self.port} 连接失败") + return False + + def disconnect(self): + """断开电机串口""" + if self.client: + self.client.close() + self.client = None + print("电机串口已关闭") + + @staticmethod + def _compute_crc(data: bytes) -> bytes: + """计算 Modbus CRC-16""" + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc.to_bytes(2, byteorder='little') + + def _print_sent_message(self, address, function_code, data_bytes): + """构造完整报文并打印(含CRC)""" + raw = bytes([self.slave_id, function_code]) + address.to_bytes(2, byteorder='big') + data_bytes + crc = self._compute_crc(raw) + full_msg = raw + crc + hex_str = ' '.join(f'{b:02X}' for b in full_msg) + # print(f"[发送] {hex_str}") + + def _write_single_register(self, address, value): + """写单个寄存器(功能码 06)""" + data_bytes = value.to_bytes(2, byteorder='big') + self._print_sent_message(address, 0x06, data_bytes) + + try: + result = self.client.write_register(address, value, slave=self.slave_id) + if result.isError(): + print(f"[接收] 错误响应: {result}") + return False + else: + # print(f"[接收] 成功") + return True + except Exception as e: + print(f"[接收] 异常: {e}") + return False + + def _write_32bit(self, address, value): + """ + 写 32 位值到两个连续寄存器(功能码 10) + 字节序:大端(高字节在前,高字在前) + """ + builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG) + builder.add_32bit_uint(value) + payload = builder.to_registers() + # 构造数据部分:字节计数 + 各寄存器大端两字节 + data_bytes = bytes([len(payload) * 2]) + for reg in payload: + data_bytes += reg.to_bytes(2, byteorder='big') + self._print_sent_message(address, 0x10, data_bytes) + + try: + result = self.client.write_registers(address, payload, slave=self.slave_id) + if result.isError(): + print(f"[接收] 错误响应: {result}") + return False + else: + # print(f"[接收] 成功") + return True + except Exception as e: + print(f"[接收] 异常: {e}") + return False + + def init(self): + """初始化电机参数(仅需调用一次)""" + if not self.client or not self.client.connected: + print("电机未连接,请先调用 connect()") + return False + + print("开始初始化电机参数...") + success = True + + # 1. 写模式 4 → 0x6007 + print("--- 步骤1: 写模式 4 到 0x6007 ---") + if not self._write_single_register(0x6007, 4): + success = False + print("模式写入失败") + else: + print("模式写入成功") + + # 2. 写速度 64000 → 0x6072 + print("--- 步骤2: 写速度 64000 到 0x6072 ---") + if not self._write_32bit(0x6072, 64000): + success = False + print("速度写入失败") + else: + print("速度写入成功") + + # 3. 写加速度 2400000 → 0x6067 + print("--- 步骤3: 写加速度 96000 到 0x6067 ---") + if not self._write_32bit(0x6067, 96000): + success = False + print("加速度写入失败") + else: + print("加速度写入成功") + + # 4. 写减速度 240000 → 0x6069 + print("--- 步骤4: 写减速度 96000 到 0x6069 ---") + if not self._write_32bit(0x6069, 96000): + success = False + print("减速度写入失败") + else: + print("减速度写入成功") + + if success: + print("电机初始化完成。") + else: + print("电机初始化过程中出现错误。") + return success + + def _read_single_register(self, address): + """读取单个16位寄存器""" + try: + result = self.client.read_holding_registers(address, 1, slave=self.slave_id) + if not result.isError(): + return result.registers[0] + else: + print(f"读取寄存器 0x{address:X} 失败: {result}") + return None + except Exception as e: + print(f"读取寄存器 0x{address:X} 异常: {e}") + return None + + def set_position(self, position): + """设置目标位置并立即启动(位置 0~impulse_max""" + if not self.client or not self.client.connected: + print("电机未连接,请先调用 connect()") + return False + + position = int(position / x_max * impulse_max) + + if not (0 <= position <= impulse_max): + print(f"位置值 {position} 超出范围 (0~{impulse_max})") + return False + + self._write_32bit(0x6074, position) + ret2 = self._write_single_register(0x6070, 112) + if not ret2: + print("第一次写入控制字失败,1ms后重试...") + time.sleep(0.001) + ret2 = self._write_single_register(0x6070, 112) + if ret2: + print("第二次重试成功") + else: + print("第二次重试仍然失败") + + return True + + def read_current_position(self): + """ + 读取电机当前位置(INT 型,32位有符号整数) + 从寄存器 0x600E 开始,连续读取 2 个保持寄存器 + 字节序:大端(与写操作一致) + :return: 当前位置值(int),读取失败返回 None + """ + if not self.client or not self.client.connected: + print("电机未连接,无法读取位置") + return None + + try: + result = self.client.read_holding_registers( + address=0x600E, + count=2, + slave=self.slave_id + ) + if result.isError(): + print(f"读取位置寄存器失败: {result}") + return None + + # 解码为 32 位有符号整数,使用与写操作相同的大端字节序 + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, + byteorder=Endian.BIG, + wordorder=Endian.BIG + ) + position = decoder.decode_32bit_int() + print(f"当前位置position: {position}") + position_x = position / impulse_max * x_max + return position_x + except Exception as e: + print(f"读取当前位置异常: {e}") + return None + + +# ---------- PC读取压力值 ---------- +class PressureModbusRTUClient: + """压力变送器 Modbus RTU 通讯客户端(读取16位压力值)""" + def __init__(self, port='/dev/cu.usbserial-D30JITMY', slave_id=1, baudrate=9600, bytesize=8, parity='N', stopbits=1, timeout=3): + """ + 初始化压力客户端 + :param port: 串口端口,如 COM3、/dev/ttyUSB0 + :param slave_id: 从站地址(站号),默认 1 + :param baudrate: 波特率,默认 9600 + :param bytesize: 数据位,默认 8 + :param parity: 校验位,默认 'N'(无校验) + :param stopbits: 停止位,默认 1 + :param timeout: 通讯超时时间(秒),默认 3 + """ + self.port = port + self.slave_id = slave_id + self.baudrate = baudrate + self.bytesize = bytesize + self.parity = parity + self.stopbits = stopbits + self.timeout = timeout + self.client = None + self.pressure_register_addr = 4 # 压力寄存器地址(04) + + def connect(self): + """连接压力变送器串口""" + self.client = ModbusSerialClient( + port=self.port, + baudrate=self.baudrate, + bytesize=self.bytesize, + parity=self.parity, + stopbits=self.stopbits, + timeout=self.timeout + ) + if self.client.connect(): + print(f"压力串口 {self.port} 连接成功 (站号 {self.slave_id})") + return True + else: + print(f"压力串口 {self.port} 连接失败") + return False + + def disconnect(self): + """断开压力串口""" + if self.client: + self.client.close() + self.client = None + print("压力串口已关闭") + + def get_current_p(self): + """ + 读取压力值 + :return: 压力值(整数),若读取失败返回 None + """ + if not self.client or not self.client.connected: + print("压力客户端未连接,请先调用 connect()") + return None + + try: + t1 = time.perf_counter() + # 读取保持寄存器(功能码03),地址4,个数1 + result = self.client.read_holding_registers( + address=self.pressure_register_addr, + count=1, + slave=self.slave_id + ) + if result.isError(): + print(f"压力读取错误: {result}") + return None + # 返回寄存器的第一个值(16位整数) + pressure_raw = result.registers[0] + # print(f"读压力用时:{time.perf_counter() - t1:.3f}s") + return pressure_raw + except ModbusException as e: + print(f"压力读取 Modbus 异常: {e}") + return None + except Exception as e: + print(f"压力读取未知异常: {e}") + return None + + +# ---------- 新增:MT2-AM8 模块 Modbus TCP 客户端 ---------- +class MT2AM8Client: + """ + 艾莫迅 MT2-AM8 模块的 Modbus TCP 通讯类 + - 默认 IP:192.168.1.12,端口 502,模块地址(站号)默认为 1 + - 输入寄存器(AI):地址 0x00~0x03(对应 PLC 地址 30001~30004) + - 保持寄存器(AO):地址 0x00~0x03(对应 PLC 地址 40001~40004) + - 模拟量值范围:0~4095(对应 0~10V 或 0~20mA) + """ + def __init__(self, host="192.168.1.12", port=502, slave_id=1, + pressure_range=400, flow_range=300): + self.host = host + self.port = port + self.slave_id = slave_id + self.pressure_range = pressure_range # 压力表量程上限 + self.flow_range = flow_range # 流量计量程上限 + self.client = ModbusTcpClient( + host=host, + port=port, + timeout=3, + retries=3 + ) + self.connected = False + + def connect(self): + """连接模块""" + try: + conn = self.client.connect() + if conn: + print(f"成功连接到 MT2-AM8 模块 {self.host}:{self.port}") + self.connected = True + else: + print(f"无法连接到 {self.host}:{self.port}") + self.connected = False + return conn + except Exception as e: + print(f"连接错误: {e}") + self.connected = False + return False + + def disconnect(self): + """断开连接""" + self.client.close() + self.connected = False + print("连接已关闭") + + def read_analog_input(self, channel): + """ + 读取单路模拟量输入原始值(16位无符号整数) + :param channel: 通道号 0~3(对应 AI1~AI4) + :return: 0~4095 的整数值,失败返回 None + """ + try: + result = self.client.read_input_registers( + address=channel, + count=1, + slave=self.slave_id + ) + if not result.isError(): + return result.registers[0] + else: + print(f"读取输入寄存器错误: {result}") + return None + except Exception as e: + print(f"读取模拟量输入异常: {e}") + return None + + # def read_all_analog_inputs(self): + # """ + # 一次性读取全部 4 路模拟量输入 + # :return: 长度为4的列表(int),失败返回 None + # """ + # try: + # result = self.client.read_input_registers( + # address=0, + # count=4, + # slave=self.slave_id + # ) + # if not result.isError(): + # return result.registers + # else: + # print(f"读取全部输入寄存器错误: {result}") + # return None + # except Exception as e: + # print(f"读取全部模拟量输入异常: {e}") + # return None + + def write_analog_output(self, channel, value): + """ + 写入单路模拟量输出(保持寄存器) + :param channel: 通道号 0~3(对应 AO1~AO4) + :param value: {volthege_min}~{volthege_max} 的整数值 + :return: True 成功,False 失败 + """ + # if not 0 <= channel <= 3: + # print("通道号必须为 0~3") + # return False + if not 0 <= value <= volthege_max: + print(f"值 {value} 超出范围 ({volthege_min}~{volthege_max})") + return False + try: + result = self.client.write_register( + address=channel, + value=value, + slave=self.slave_id + ) + return not result.isError() + except Exception as e: + print(f"写入模拟量输出异常: {e}") + return False + + # def write_all_analog_outputs(self, values): + # """ + # 一次性写入全部 4 路模拟量输出(用于批量设置) + # :param values: 长度为4的列表或元组,每个元素为 0~4095 + # :return: True 成功,False 失败 + # """ + # if len(values) != 4: + # print("需提供 4 个输出值") + # return False + # for v in values: + # if not 0 <= v <= 4095: + # print(f"值 {v} 超出范围 (0~4095)") + # return False + # try: + # result = self.client.write_registers( + # address=0, + # values=list(values), + # slave=self.slave_id + # ) + # return not result.isError() + # except Exception as e: + # print(f"批量写入模拟量输出异常: {e}") + # return False + + def get_pressure(self, channel): + """ + 读取压力值并转换为实际物理量 + 转换公式:raw / (volthege_max - volthege_min) * pressure_range + :param channel: 压力传感器模拟量通道地址 + :return: 实际压力值(kPa),失败返回 None + """ + raw = self.read_analog_input(channel) + if raw is None: + return None + pressure = (raw - volthege_min) / (volthege_max - volthege_min) * self.pressure_range + # print(f"读取压力通道 {channel} 原始值: {raw}, 转换后压力: {pressure:.2f} kPa") + return pressure + + def get_flow(self, channel): + """ + 读取流量值并转换为实际物理量 + 转换公式:raw / (volthege_max - volthege_min) * flow_range + :param channel: 流量计模拟量通道地址 + :return: 实际流量值(L/min),失败返回 None + """ + raw = self.read_analog_input(channel) + if raw is None: + return None + flow = (raw - volthege_min) / (volthege_max - volthege_min) * self.flow_range + # print(f"读取流量通道 {channel} 原始值: {raw}, 转换后流量: {flow:.2f} L/min") + return flow + + + # def set_motor_speed(self, voltage_percent): + # """ + # 通过模拟量输出控制电机(例如 0~100% 对应 0~10V) + # :param voltage_percent: 0~100 的浮点数,表示百分比 + # """ + # if not 0 <= voltage_percent <= 100: + # print("百分比需在 0~100 之间") + # return False + # # 将百分比映射到 0~4095 + # raw_value = int(voltage_percent / 100.0 * 4095) + # return self.write_analog_output(0, raw_value) # 假设电机接在 AO1 + + def set_motor_position(self, voltage_distance, channel=0): + """ + 通过模拟量输出控制电机(例如 0~1000 对应 0~10V) + :param voltage_distance: 0~1000 的浮点数,表示行程 + :param channel: 模拟量输出通道号,默认 0(AO1) + """ + if not (0 <= voltage_distance <= x_max): + print(f"行程需在 0~{x_max} 之间") + return False + # 将行程映射到 0~4095 + raw_value = int(voltage_distance / x_max * volthege_max ) # 假设最小值对应 volthege_min + # print(f"设置电机行程为 {voltage_distance},模拟量输出值 {raw_value}") + return self.write_analog_output(channel, raw_value) + + +# ---------- 主函数:测试示例 ---------- +# if __name__ == "__main__": +# # (可选)启用 pymodbus 详细日志,可观察底层收发帧 +# # logging.basicConfig() +# # logging.getLogger('pymodbus').setLevel(logging.DEBUG) +# +# motor = MotorModbusRTUClient() +# +# +# print("连接电机 (Modbus RTU)...") +# if not motor.connect(): +# print("电机连接失败,退出。") +# exit(1) +# +# # 增加短暂延时,等待驱动器接口就绪 +# time.sleep(1) +# +# print("初始化电机参数...") +# if not motor.init(): +# print("电机初始化失败,退出。") +# motor.disconnect() +# exit(1) +# +# print("\n========== 电机位置控制测试 ==========") +# print("输入目标位置 (0~60000),输入 'q' 退出。函数已修改,只需输入开度。\n") +# +# try: +# while True: +# user_input = input("目标位置: ").strip() +# if user_input.lower() in ('q', 'quit', 'exit'): +# break +# if not user_input: +# continue +# try: +# pos = int(user_input) +# motor.set_position(pos) +# except ValueError: +# print("错误:请输入有效的整数。") +# except KeyboardInterrupt: +# print("\n用户中断测试。") +# finally: +# motor.disconnect() +# print("程序结束。") \ No newline at end of file diff --git a/ReinLoop/README.md b/ReinLoop/README.md new file mode 100644 index 0000000..572f6ff --- /dev/null +++ b/ReinLoop/README.md @@ -0,0 +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 完成,不由客户端工具执行。 diff --git a/ReinLoop/api.py b/ReinLoop/api.py new file mode 100644 index 0000000..49fb2e7 --- /dev/null +++ b/ReinLoop/api.py @@ -0,0 +1,26 @@ +"""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: + raise RuntimeError("设备 ID 不能为空") \ No newline at end of file diff --git a/ReinLoop/config/identification_config.json b/ReinLoop/config/identification_config.json new file mode 100644 index 0000000..fb36ccc --- /dev/null +++ b/ReinLoop/config/identification_config.json @@ -0,0 +1,3 @@ +{ + "notice": "Legacy notice only. The client reads identification_config.csv from cloud storage and never reads this file." +} diff --git a/ReinLoop/config/volume_measurement.json b/ReinLoop/config/volume_measurement.json new file mode 100644 index 0000000..dc9179f --- /dev/null +++ b/ReinLoop/config/volume_measurement.json @@ -0,0 +1,3 @@ +{ + "notice": "This file is not used by the client. Test creates one cloud request; the client waits for the company to upload a request-specific JSON file." +} diff --git a/ReinLoop/controllers.py b/ReinLoop/controllers.py new file mode 100644 index 0000000..b79c186 --- /dev/null +++ b/ReinLoop/controllers.py @@ -0,0 +1,145 @@ +# controllers.py +import os, sys, time + + +def _pid_log(msg: str): + """PID 内部日志,直接写文件 + 刷盘""" + try: + log_dir = os.path.join(os.path.dirname(sys.executable), "logs") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "control_debug.log") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"[{time.strftime('%H:%M:%S.%f')[:-3]}] [PID] {msg}\n") + f.flush() + os.fsync(f.fileno()) + except Exception: + pass + + +class IncrementalPID: + """增量式PID控制器""" + + def __init__(self, kp: float, ki: float, kd: float, dt: float, + out_min: float, out_max: float, xa_full: float = 1062.5): + # PID参数 + self.kp = kp + self.ki = ki + self.kd = kd + self.dt = dt # 默认50HZ执行周期防止除零 + self.motor_max = 300.0 + self.du_max = self.motor_max * self.dt + self.dead_area = 240.0 + self.xa_full = xa_full # 总限幅(最大行程) + + # self.kp = 1.392 + # self.ki = 30.2 + # self.kd = 0.000485 + # self.dt = 0.0059 + + # 限幅设置 + self.out_min = out_min + self.out_max = out_max + + # 输入输出 + self.target_pressure = 0.0 # 参考值(设定压力大小) + self.current_pressure = 0.0 # 反馈值 + self.error = 0.0 # 当前误差 + + # 计算系数 + self.a0 = 0.0 + self.a1 = 0.0 + self.a2 = 0.0 + self._calculate_coefficients() + + # 控制器状态 + self.prev_error = 0.0 # 前次误差 e(k-1) + self.prev_error2 = 0.0 # 前前次误差 e(k-2) + self.output = 0.0 # 控制器总输出 + + def _calculate_coefficients(self): + """重新计算增量式PID系数""" + if self.dt <= 0: + return + self.a0 = self.kp + (self.ki * self.dt / 2.0) + (2.0 * self.kd / self.dt) + self.a1 = -self.kp + (self.ki * self.dt / 2.0) - (4.0 * self.kd / self.dt) + self.a2 = (2.0 * self.kd) / self.dt + + def update_pressure_values(self, current_pressure, target_pressure): + """更新当前压力和目标压力值""" + self.current_pressure = current_pressure + self.target_pressure = target_pressure + + def update(self, du_max=None): + # 计算当前误差 + self.error = -(self.target_pressure - self.current_pressure) + + # if abs(self.error) < 1: + # return self.output # 误差过小,直接返回当前输出 + + # 计算控制增量 + delta = (self.a0 * self.error + self.a1 * self.prev_error + self.a2 * self.prev_error2) + + if du_max is not None: + self.du_max = du_max + else: + self.du_max = self.get_du_max(self.target_pressure) + + # 纯 Python 限幅(替代 np.clip) + if delta > self.du_max: + delta = self.du_max + elif delta < -self.du_max: + delta = -self.du_max + + # 计算新输出 + new_output = self.output + delta + # print(f"output:{self.output}, delta:{delta}, new_output:{new_output}") + + # 应用输出限幅 + new_output = max(self.out_min, min(self.out_max, new_output)) + + # 更新历史状态 + self.prev_error2 = self.prev_error + self.prev_error = self.error + self.output = new_output + return new_output + + def reset(self): + """重置PID控制器状态(保留参数)""" + self.prev_error = 0.0 + self.prev_error2 = 0.0 + self.output = 0.0 + + def update_parameters(self, kp: float, ki: float, kd: float): + self.kp = kp + self.ki = ki + self.kd = kd + self._calculate_coefficients() + + def set_du_max(self, value): + """设置 du_max(供外部模块通过方法调用设置,避免跨 .pyd 属性写入 crash)""" + self.du_max = value + + def get_du_max(self, target_pressure): + """根据目标压力计算 PID 最大增量限幅(纯 Python 线性插值)""" + x = float(target_pressure) + dt = float(self.dt) + + if x <= 0.0: + val = 500.0 * dt + + elif x >= 200.0: + val = 250.0 * dt + + elif x <= 100.0: + # 0~100:从 500 线性下降到 300 + val = (500.0 - 2.0 * x) * dt + + else: + # 100~200:从 300 线性下降到 250 + val = (350.0 - 0.5 * x) * dt + + return val + + def init_v(self, position_x): + v = (self.xa_full - position_x) / (self.xa_full - self.dead_area) * 100 + return v diff --git a/ReinLoop/core/__init__.py b/ReinLoop/core/__init__.py new file mode 100644 index 0000000..a820005 --- /dev/null +++ b/ReinLoop/core/__init__.py @@ -0,0 +1,39 @@ +# core/__init__.py +"""core 包 —— 业务逻辑层。 + +模块级许可证校验:import 此包的瞬间自动执行验签。 +两个文件均编译为 .pyd → 无法被篡改绕过。 +""" + +import sys + +# ============================================================ +# 模块级验签 —— 每次 import core.xxx 必然触发 +# 效果等同于在 main.py 中调用 check_license(), +# 但此文件编译进 .pyd,攻击者无法删除或修改。 +# ============================================================ +_LICENSE_CHECKED = False + + +def _init_license(): + """在模块加载时自动调用一次,验证许可证。""" + global _LICENSE_CHECKED + if _LICENSE_CHECKED: + return + + # 开发环境(非 PyInstaller 打包)→ 直接跳过,不打扰 + # if not getattr(sys, 'frozen', False): + # print("[core] 开发环境:跳过许可证校验") + # _LICENSE_CHECKED = True + # return + + # 生产环境(exe 打包)→ 严格执行验签 + from license_utils import check_license + + check_license() # 验签并启动唯一的后台巡检线程,失败直接退出 + + _LICENSE_CHECKED = True + + +# 导入时立即执行 +_init_license() diff --git a/ReinLoop/core/connection_manager.py b/ReinLoop/core/connection_manager.py new file mode 100644 index 0000000..fe24663 --- /dev/null +++ b/ReinLoop/core/connection_manager.py @@ -0,0 +1,132 @@ +# connection_manager.py +"""连接管理器:负责 MT2-AM8 模块 (Modbus TCP) 的连接/断开。 + +纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 +""" + +import time +from PcControl import MT2AM8Client + + +class ConnectionManager: + """管理 MT2-AM8 模块连接的生命周期""" + + def __init__(self): + self.modbus_client = None # MT2AM8Client (TCP 读压力/流量,控电机) + self._pressure_addr = 0 # 压力传感器模拟量通道地址 + self._flowmeter_addr = None # 流量计模拟量通道地址(None=使用手动输入) + self._motor_addr = 0 # 电机模拟量输出通道地址 + self._on_log = None # 日志回调 + self._on_status_change = None # 状态变化回调 + + def set_log_callback(self, callback): + """设置日志回调: callback(message: str)""" + self._on_log = callback + + def set_status_callback(self, callback): + """设置状态变化回调: callback(connected: bool, status_text: str)""" + self._on_status_change = callback + + def log(self, message): + """内部日志输出""" + if self._on_log: + self._on_log(message) + + def is_connected(self) -> bool: + """检查是否已连接""" + return self.modbus_client is not None and self.modbus_client.connected + + def connect(self, tcp_ip: str, tcp_port: int, pressure_addr: int, + motor_addr: int, flowmeter_addr: int, + pressure_range: float = 400, flow_range: float = 100) -> bool: + """连接到 MT2-AM8 模块 + + Args: + tcp_ip: 模块 IP 地址 + tcp_port: TCP 端口 + pressure_addr: 压力传感器模拟量通道地址 + motor_addr: 电机模拟量输出通道地址 + flowmeter_addr: 流量计模拟量通道地址 + pressure_range: 压力表量程上限 + flow_range: 流量计量程上限 + + Returns: + 是否连接成功 + """ + try: + self.log("正在连接设备...") + + # 保存地址配置 + self._pressure_addr = pressure_addr + self._motor_addr = motor_addr + self._flowmeter_addr = flowmeter_addr + + # 创建 MT2-AM8 客户端 + self.modbus_client = MT2AM8Client( + host=tcp_ip, + port=tcp_port, + pressure_range=pressure_range, + flow_range=flow_range, + ) + + if not self.modbus_client.connect(): + self.log(f"连接 MT2-AM8 模块失败: {tcp_ip}:{tcp_port}") + return False + + self.log(f"成功连接到 MT2-AM8 模块: {tcp_ip}:{tcp_port}") + self.log(f"压力地址: {pressure_addr}, 电机地址: {motor_addr}, 流量计地址: {flowmeter_addr}") + + if self._on_status_change: + self._on_status_change(True, "已连接") + + return True + + except Exception as e: + self.log(f"连接异常: {str(e)}") + return False + + def disconnect(self): + """断开连接""" + if self.modbus_client: + self.modbus_client.disconnect() + self.modbus_client = None + + self.log("已断开连接") + + if self._on_status_change: + self._on_status_change(False, "未连接") + + def read_pressure(self): + """读取当前压力值(转换为实际物理量) + + Returns: + 实际压力值 (kPa), 读取失败返回 None + """ + if not self.is_connected(): + return None + return self.modbus_client.get_pressure(self._pressure_addr) + + def read_flow(self): + """读取当前流量值(转换为实际物理量) + + 若未配置流量计地址,直接返回 None,由调用方使用控制栏手动输入值。 + + Returns: + 实际流量值 (L/min), 未配置地址或读取失败返回 None + """ + if not self.is_connected() or self._flowmeter_addr is None: + return None + return self.modbus_client.get_flow(self._flowmeter_addr) + + def set_motor_position(self, xa: float) -> bool: + """设置电机位置(通过模拟量输出控制阀门开度) + + Args: + xa: 目标行程 (0~x_max) + + Returns: + 是否设置成功 + """ + if not self.is_connected(): + return False + return self.modbus_client.set_motor_position(xa, channel=self._motor_addr) diff --git a/ReinLoop/core/control_engine.py b/ReinLoop/core/control_engine.py new file mode 100644 index 0000000..be2a28c --- /dev/null +++ b/ReinLoop/core/control_engine.py @@ -0,0 +1,329 @@ +# control_engine.py +"""控制引擎:管理控制主循环,支持 PID / RL / MANUAL 三种模式。 + +纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 + +设计原则:所有操作在主线程执行(QTimer 驱动),避免 Cython 编译后 +在 PyInstaller 子线程中 segfault。 +""" + +import time +import os +import traceback +import sys +import logging +import numpy as np +from controllers import IncrementalPID + + +logger = logging.getLogger("ReinLoop.ControlEngine") + + +class ControlEngine: + """控制主引擎""" + + def __init__(self, pid: IncrementalPID): + self.pid = pid + self._running = False + self._cycle_count = 0 + self._last_tick = 0.0 + + # 缓存参数(start 时从 UI 获取) + self.mode = "PID" + self.flow = 0.0 + self.volume = 0.0 + self.target_pressure = 80.0 + self.manual_valve = 0.0 + self.dz = None + self.motor_max = None + self.xa_full = 1062.5 + self.collect_data = False + + # 压力 EMA 滤波 + self.pressure_alpha = 1 # 平滑系数(0~1),越小越平滑 + self._pressure_filtered = None # 滤波后的压力值 + + # 外部依赖 + self._conn_mgr = None + self._model_mgr = None + self._data_collector = None + + # RL 模式相关 + self.last_target_rl = None + self.Kp_0 = 1.0 + self.Ki_0 = 0.4 + + # 回调 + self._on_log = None + self._on_display_update = None + self._on_pid_ui_update = None + self._on_started = None + self._on_stopped = None + + # ---- 依赖注入 ---- + def set_connection_manager(self, mgr): + self._conn_mgr = mgr + + def set_model_manager(self, mgr): + self._model_mgr = mgr + + def set_data_collector(self, collector): + self._data_collector = collector + + # ---- 回调设置 ---- + def set_log_callback(self, cb): + self._on_log = cb + + def set_display_update_callback(self, cb): + self._on_display_update = cb + + def set_pid_ui_update_callback(self, cb): + self._on_pid_ui_update = cb + + def set_started_callback(self, cb): + self._on_started = cb + + def set_stopped_callback(self, cb): + self._on_stopped = cb + + def log(self, message): + if self._on_log: + self._on_log(message) + + @property + def is_running(self) -> bool: + return self._running + + # ---- 启动/停止 ---- + def start(self): + """启动控制循环(主线程调用)""" + self.log("正在启动控制循环...") + + if not self._conn_mgr or not self._conn_mgr.is_connected(): + self.log("启动失败: 请先连接压力表") + return + + if self.mode == "RL": + if not self._model_mgr or not self._model_mgr.is_model_loaded(): + self.log("启动失败: 模型未加载,请先选择工况并点击【加载模型】按钮") + return + + # 预置初始阀位(读取当前电机位置) + # try: + # position_x = self._conn_mgr.read_motor_position() + # initial_valve = self.pid.init_v(position_x) + # self.pid.output = initial_valve + # self.log(f"预置初始阀位 {initial_valve:.1f}%") + # except Exception as e: + # self.log(f"读取初始开度失败,将使用 80% 启动: {e}") + # self.pid.output = 80.0 + + self.pid.output = 100.0 + + # 设置死区 + if self.dz is not None: + self.pid.dead_area = self.dz + + # RL 模式:在主线程预先完成模型预测 + if self.mode == "RL": + try: + current_p = self._conn_mgr.read_pressure() + if current_p is None: + current_p = 0.0 + self._rl_predict(current_p, self.target_pressure) + self.log(f"RL 初始预测: Kp={self.pid.kp:.4f}, Ki={self.pid.ki:.4f}") + except Exception as e: + self.log(f"模型调用异常,使用默认pid: {e}") + + # 重置状态 + self.last_target_rl = None + self._pressure_filtered = None # 复位滤波器 + self._cycle_count = 0 + self._last_tick = time.perf_counter() + + if self._data_collector: + self._data_collector.reset() + + self._running = True + + if self._on_started: + self._on_started() + + self.log(f"控制循环已启动 (模式: {self.mode}, 目标: {self.target_pressure} kPa)") + + def control_tick(self): + """主线程 QTimer 每次触发时调用——执行一个控制周期""" + if not self._running: + return + + cycle_start = time.perf_counter() + + try: + # 1. 读取当前压力(原始值) + raw_pressure = self._conn_mgr.read_pressure() + if raw_pressure is None: + self.log("读取当前压力失败,检查地址和连接") + return + + # EMA 低通滤波:平滑毛刺 + if self._pressure_filtered is None: + self._pressure_filtered = raw_pressure + else: + self._pressure_filtered = (self.pressure_alpha * raw_pressure + + (1 - self.pressure_alpha) * self._pressure_filtered) + current_pressure = self._pressure_filtered + + target_pressure = self.target_pressure + mode = self.mode + + # 2. 根据模式计算阀门开度 + if mode == "PID": + valve_opening = self._pid_step(current_pressure, target_pressure) + + elif mode == "RL": + valve_opening = self._rl_step(current_pressure, target_pressure) + + elif mode == "MANUAL": + valve_opening = self._manual_step() + + else: + self.log("错误!未知控制模式") + valve_opening = 0.0 + + # 3. 数据采集 + if self.collect_data and self._data_collector: + self._data_collector.record_step( + cycle_count=self._cycle_count, + current_pressure=current_pressure, + target_pressure=target_pressure, + valve_opening=valve_opening, + kp=self.pid.kp, ki=self.pid.ki, kd=self.pid.kd, + q_in=self.flow, v=self.volume + ) + + # 4. 更新 UI 显示 + if self._on_display_update: + self._on_display_update(current_pressure, target_pressure, valve_opening) + + self._cycle_count += 1 + + # 5. 周期精确计时:若本周期用时不满 dt,sleep 补足 + elapsed = time.perf_counter() - cycle_start + dt = self.pid.dt + # dt = 0.2 + if elapsed < dt: + time.sleep(dt - elapsed) + + # 6. 记录实际周期时长 + now = time.perf_counter() + tick_time = now - cycle_start + # print(f"本周期用时 {tick_time*1000:.1f}ms (目标 {dt*1000:.0f}ms)") + self._last_tick = now + + except Exception as e: + # control_tick 原本会捕获异常,因此异常不会进入 main.py 的 + # sys.excepthook。这里必须主动把完整 traceback 打到控制台。 + err_detail = traceback.format_exc() + + print("\n" + "=" * 80, file=sys.stderr, flush=True) + print("ControlEngine.control_tick 发生异常:", file=sys.stderr, flush=True) + print(err_detail, file=sys.stderr, flush=True) + print("=" * 80, file=sys.stderr, flush=True) + + # main.py 已配置控制台和文件日志;这里会同步写入 logs 目录。 + logger.error("控制周期错误:\n%s", err_detail) + + # UI 中保留一行简要信息,避免多行文本被控件截断。 + self.log( + f"控制周期错误: {type(e).__name__}: {e};" + f"完整 traceback 请看运行控制台或 logs 日志" + ) + + def stop(self): + """停止控制循环""" + self._running = False + + if self._data_collector: + self._data_collector.finalize_and_upload(self.flow, self.volume) + + self.log("控制循环已停止") + + if self._on_stopped: + self._on_stopped() + + # ---- PID 模式 ---- + def _pid_step(self, current_pressure, target_pressure): + """PID 控制单步""" + self.pid.update_pressure_values(current_pressure, target_pressure) + valve_opening = self.pid.update() + xa = self.xa_full * (100 - valve_opening) / 100 + self._conn_mgr.set_motor_position(xa) + return valve_opening + + # ---- RL 模式 ---- + def _rl_step(self, current_pressure, target_pressure): + """RL 增强控制单步""" + # 跟踪目标压力变化,触发 RL 重预测 + if self.last_target_rl is None: + self.last_target_rl = target_pressure + elif self.last_target_rl != target_pressure: + self.last_target_rl = target_pressure + try: + self._rl_predict(current_pressure, target_pressure) + except Exception as e: + self.log(f"模型调用异常,使用默认pid: {e}") + + # 检查高级设置中的单步限幅是否有填入,如果有,使用填入的值;如果没有,使用默认函数 + # if self.motor_max is not None: + # self.pid.set_du_max(self.motor_max * self.pid.dt) + # else: + # self.pid.get_du_max(target_pressure) + self.pid.update_pressure_values(current_pressure, target_pressure) + if self.motor_max is not None: + du_max = self.motor_max * self.pid.dt + else: + du_max = None + # PID 计算 + valve_opening = self.pid.update(du_max) + + # 位置换算(考虑死区) + xa = self.pid.dead_area + (100 - valve_opening) * (self.xa_full - self.pid.dead_area) / 100 + self._conn_mgr.set_motor_position(xa) + + return valve_opening + + def _rl_predict(self, current_p, target_p): + """执行 RL 模型预测并更新 PID 参数(只在主线程调用)""" + model = self._model_mgr.rl_model + if model is None: + print("[RL] 错误: rl_model 为 None,跳过预测") + return + + obs = np.array([ + self.flow / 100, + current_p / 100, + (target_p - current_p) / 100 + ], dtype=np.float32) + print(f"[RL] 预测 obs={obs}", flush=True) + action, _ = model.predict(obs, deterministic=True) + print(f"[RL] model.predict 完成, action={action}") + + action_space = model.action_space + Kp_0 = float(action_space.high[0]) + Ki_0 = float(action_space.high[1]) + kp = float(Kp_0 + action[0]) + ki = float(Ki_0 + action[1]) + + self.Kp_0 = Kp_0 + self.Ki_0 = Ki_0 + self.pid.update_parameters(kp, ki, self.pid.kd) + print(f"[RL] PID 参数已更新: Kp={kp:.4f}, Ki={ki:.4f}") + if self._on_pid_ui_update: + self._on_pid_ui_update(kp, ki, self.pid.kd) + + # ---- MANUAL 模式 ---- + def _manual_step(self): + """手动模式单步""" + xa = self.pid.dead_area + (100 - self.manual_valve) * (self.xa_full - self.pid.dead_area) / 100 + self._conn_mgr.set_motor_position(xa) + return self.manual_valve \ No newline at end of file diff --git a/ReinLoop/core/data_collector.py b/ReinLoop/core/data_collector.py new file mode 100644 index 0000000..59966cb --- /dev/null +++ b/ReinLoop/core/data_collector.py @@ -0,0 +1,199 @@ +# 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 = [] diff --git a/ReinLoop/core/device_heartbeat.py b/ReinLoop/core/device_heartbeat.py new file mode 100644 index 0000000..707c508 --- /dev/null +++ b/ReinLoop/core/device_heartbeat.py @@ -0,0 +1,20 @@ +"""Report the ReinLoop application's Server reachability for Panel status.""" + + +def heartbeat_device(timeout=5): + """Refresh the current device's Server heartbeat and return its timestamp.""" + import requests + from api import data_record_url, the_folder + + try: + response = requests.post(data_record_url, json={ + "type": "deviceHeartbeat", + "deviceId": the_folder, + }, timeout=timeout) + response.raise_for_status() + result = response.json() + except Exception as exc: + raise ValueError(f"设备心跳请求失败: {exc}") from exc + if not result.get("success"): + raise ValueError(result.get("errMsg", "设备心跳被服务端拒绝")) + return result.get("lastSeenAt") \ No newline at end of file diff --git a/ReinLoop/core/identification.py b/ReinLoop/core/identification.py new file mode 100644 index 0000000..85f724c --- /dev/null +++ b/ReinLoop/core/identification.py @@ -0,0 +1,487 @@ +# 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: + """管理系统辨识与容积测量任务""" + + def __init__(self): + self._identifying = False + self._task_thread = None + self._on_log = None + 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_volume_result_callback(self, callback): + """设置容积测量结果回调: callback(volume_L: float)""" + self._on_volume_result = callback + + 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 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"], + } + 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}") + return False + + def _run_initial_travel_scan(self, conn_mgr): + """Scan 1000..0 and upload each travel's stable pressure as JSON. + + This is an independent pre-scan. It does not replace or modify the + subsequent PRBS collection performed by ``collect_data_with_prbs``. + """ + distances = list(range(1000, -1, -100)) + settings = { + "min_wait_time": 5.0, + "sample_interval": 0.1, + "stable_window": 5.0, + "pressure_tolerance": 0.5, + "slope_tolerance": 0.05, + "stable_duration": 3.0, + "max_wait_time": 60.0, + } + stable_pressure_records = [] + stopped = False + + def slope(points): + mean_t = sum(point[0] for point in points) / len(points) + mean_p = sum(point[1] for point in points) / len(points) + denominator = sum((point[0] - mean_t) ** 2 for point in points) + if denominator == 0: + return 0.0 + return sum( + (point[0] - mean_t) * (point[1] - mean_p) + for point in points + ) / denominator + + try: + for distance in distances: + if not self._identifying: + stopped = True + break + if not conn_mgr.set_motor_position(float(distance)): + self.log(f"行程 {distance} 写入失败") + continue + + self.log(f"行程 {distance} 已写入,等待压力稳态") + stage_start = time.monotonic() + window = deque() + stable_since = None + stable_pressure = None + pressure_range = None + pressure_slope = None + + while time.monotonic() - stage_start < settings["max_wait_time"]: + if not self._identifying: + stopped = True + break + + sample_start = time.monotonic() + elapsed = sample_start - stage_start + pressure = conn_mgr.read_pressure() + if pressure is not None: + pressure = float(pressure) + if self._on_sample: + # Do not expose the confidential travel command. + self._on_sample(None, pressure) + + if elapsed >= settings["min_wait_time"]: + window.append([elapsed, pressure]) + cutoff = elapsed - settings["stable_window"] + while window and window[0][0] < cutoff: + window.popleft() + + window_span = ( + window[-1][0] - window[0][0] + if len(window) > 1 else 0 + ) + if window_span >= ( + settings["stable_window"] - + settings["sample_interval"] * 1.5): + pressures = [point[1] for point in window] + pressure_range = max(pressures) - min(pressures) + pressure_slope = slope(window) + stable_now = ( + pressure_range <= settings["pressure_tolerance"] and + abs(pressure_slope) <= settings["slope_tolerance"] + ) + if stable_now: + if stable_since is None: + stable_since = sample_start + elif sample_start - stable_since >= settings["stable_duration"]: + stable_pressure = sum(pressures) / len(pressures) + break + else: + stable_since = None + + remaining = ( + settings["sample_interval"] - + (time.monotonic() - sample_start) + ) + if remaining > 0: + time.sleep(remaining) + + if stopped: + break + if stable_pressure is None: + self.log(f"行程 {distance} 在 60 秒内未达到稳态") + continue + + stable_pressure_records.append({ + "distance": distance, + "pressure": float(stable_pressure), + }) + self.log( + f"行程 {distance} 达到稳态,压力 {stable_pressure:.3f} kPa" + ) + finally: + # The requested sequence ends at zero; also return there on stop. + conn_mgr.set_motor_position(0) + + 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( + json.dumps(payload, ensure_ascii=False, indent=2), + filename, + f"{the_folder}/ind_data", + ) + if uploaded: + self.log("行程稳态压力 JSON 上传成功,继续执行 PRBS 辨识") + 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 + """ + if running_flag_check(): + self.log("错误:请先停止控制再进行辨识") + return False + + if self.is_running: + self.log("辨识正在进行中,请等待完成") + return False + + 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) + + def collect_thread(): + try: + # Independent pre-scan. The PRBS call below is intentionally + # left unchanged and starts after the travel scan completes. + self._run_initial_travel_scan(conn_mgr) + if not self._identifying: + 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, + ) + + if result.get('success'): + csv_data = result.get("csv_data") + csv_filename = result.get("filename") + if not csv_data or not csv_filename: + error = "辨识采集结果缺少 CSV 数据或文件名" + self.log(error) + if self._on_identification_upload: + self._on_identification_upload(False, None, error) + elif self._upload_to_cos( + csv_data, csv_filename, f"{the_folder}/ind_data"): + self.log("辨识数据上传成功") + if self._on_identification_upload: + self._on_identification_upload( + True, csv_filename, None + ) + else: + self.log("辨识数据上传失败") + if self._on_identification_upload: + self._on_identification_upload( + False, csv_filename, "辨识 CSV 上传失败" + ) + else: + self.log("辨识未采集到数据") + if self._on_identification_upload: + self._on_identification_upload( + False, None, "辨识未采集到数据" + ) + + except Exception as e: + self.log(f"辨识数据采集详细错误: {traceback.format_exc()}") + self.log(f"辨识数据采集失败: {e}") + if self._on_identification_upload: + self._on_identification_upload(False, None, str(e)) + 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): + """启动容积测量(在后台线程中运行)""" + if running_flag_check(): + self.log("错误:请先停止控制再进行测试") + return False + + if self.is_running: + self.log("测试正在进行中,请等待完成") + return False + + 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("测量结束") + + thread = threading.Thread(target=volume_thread, daemon=True) + self._task_thread = thread + thread.start() + return True + + def stop(self): + """停止当前辨识/测量任务""" + self._identifying = False diff --git a/ReinLoop/core/identification_config.py b/ReinLoop/core/identification_config.py new file mode 100644 index 0000000..c89944f --- /dev/null +++ b/ReinLoop/core/identification_config.py @@ -0,0 +1,152 @@ +"""Download CSV and validate the nine PRBS identification parameters.""" + +import csv +import io +import math + + +REQUIRED_FIELDS = { + "q_in_val", "dt", "n_order", "t_c", "levels", + "dead_area", "xa_full", "V_val", "repeat", +} + + +def validate_identification_config(config) -> dict: + """Validate a parsed config mapping and normalize numeric values.""" + if not isinstance(config, dict): + raise ValueError("辨识配置必须是参数映射") + actual = set(config) + if actual != REQUIRED_FIELDS: + missing = sorted(REQUIRED_FIELDS - actual) + extra = sorted(actual - REQUIRED_FIELDS) + raise ValueError(f"辨识配置字段错误,缺少={missing},多余={extra}") + + scalar_fields = { + "q_in_val", "dt", "t_c", "dead_area", "xa_full", "V_val" + } + for field in scalar_fields: + value = config[field] + if (isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(float(value))): + raise ValueError(f"辨识参数 {field} 必须是有限数字") + + for field in ("n_order", "repeat"): + value = config[field] + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"辨识参数 {field} 必须是整数") + + levels = config["levels"] + if not isinstance(levels, list) or len(levels) < 2: + raise ValueError("levels 必须是至少包含 2 项的数组") + if len(levels) & (len(levels) - 1): + raise ValueError("levels 长度必须是 2 的整数次幂") + normalized_levels = [] + for value in levels: + if (isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) or not 0 <= value <= 100): + raise ValueError("levels 中的开度必须是 0 到 100 的有限数字") + normalized_levels.append(float(value)) + + if config["q_in_val"] < 0: + raise ValueError("q_in_val 不能小于 0") + if config["dt"] <= 0 or config["t_c"] <= 0: + raise ValueError("dt 和 t_c 必须大于 0") + if config["t_c"] < config["dt"]: + raise ValueError("t_c 必须大于等于 dt,确保每个码元至少采样一次") + if config["n_order"] < 2: + raise ValueError("n_order 必须大于等于 2") + if config["repeat"] <= 0: + raise ValueError("repeat 必须是正整数") + if config["dead_area"] < 0 or config["xa_full"] <= config["dead_area"]: + raise ValueError("必须满足 0 <= dead_area < xa_full") + if config["xa_full"] < 1000: + raise ValueError("xa_full 不能小于前置行程扫描上限 1000") + if config["V_val"] <= 0: + raise ValueError("V_val 必须大于 0") + + return { + "q_in_val": float(config["q_in_val"]), + "dt": float(config["dt"]), + "n_order": config["n_order"], + "t_c": float(config["t_c"]), + "levels": normalized_levels, + "dead_area": float(config["dead_area"]), + "xa_full": float(config["xa_full"]), + "V_val": float(config["V_val"]), + "repeat": config["repeat"], + } + + +def parse_identification_config_csv(csv_text: str) -> dict: + """Parse a two-column CSV into the validated identification config. + + The CSV must use ``parameter,value`` as its header. ``levels`` is one + quoted comma-separated value, for example ``"10,20,30,40"``. + """ + try: + reader = csv.DictReader(io.StringIO(csv_text)) + fieldnames = [name.strip() for name in (reader.fieldnames or [])] + if fieldnames != ["parameter", "value"]: + raise ValueError("CSV 表头必须为 parameter,value") + + raw = {} + for row in reader: + if None in row: + raise ValueError("CSV 每行只能包含 parameter 和 value 两列") + parameter = (row.get("parameter") or "").strip() + value = (row.get("value") or "").strip() + if not parameter: + raise ValueError("CSV 存在空参数名") + if parameter in raw: + raise ValueError(f"CSV 参数重复: {parameter}") + raw[parameter] = value + except csv.Error as exc: + raise ValueError(f"辨识配置 CSV 格式错误: {exc}") from exc + + actual = set(raw) + if actual != REQUIRED_FIELDS: + missing = sorted(REQUIRED_FIELDS - actual) + extra = sorted(actual - REQUIRED_FIELDS) + raise ValueError(f"辨识配置字段错误,缺少={missing},多余={extra}") + + try: + levels = [float(value.strip()) for value in raw["levels"].split(",")] + config = { + "q_in_val": float(raw["q_in_val"]), + "dt": float(raw["dt"]), + "n_order": int(raw["n_order"]), + "t_c": float(raw["t_c"]), + "levels": levels, + "dead_area": float(raw["dead_area"]), + "xa_full": float(raw["xa_full"]), + "V_val": float(raw["V_val"]), + "repeat": int(raw["repeat"]), + } + except (TypeError, ValueError) as exc: + raise ValueError(f"辨识配置 CSV 参数值无效: {exc}") from exc + return validate_identification_config(config) + + +def download_identification_config(timeout=20) -> dict: + """Download the current customer's CSV config through the cloud function.""" + import requests + from api import data_record_url, the_folder + + try: + response = requests.post(data_record_url, json={ + "type": "getIdentificationConfig", + "deviceId": the_folder, + }, timeout=timeout) + response.raise_for_status() + result = response.json() + except Exception as exc: + raise ValueError(f"连接云端辨识配置服务失败: {exc}") from exc + + if not result.get("success"): + raise ValueError(result.get("errMsg", "云端未返回辨识配置")) + try: + config_response = requests.get(result["url"], timeout=timeout) + config_response.raise_for_status() + return parse_identification_config_csv(config_response.text) + except (KeyError, ValueError, requests.RequestException) as exc: + raise ValueError(f"下载或解析辨识配置 CSV 失败: {exc}") from exc diff --git a/ReinLoop/core/identification_feedback.py b/ReinLoop/core/identification_feedback.py new file mode 100644 index 0000000..7763aa7 --- /dev/null +++ b/ReinLoop/core/identification_feedback.py @@ -0,0 +1,58 @@ +"""Register identification CSV results and poll the server for 0/1 review.""" + + +def _post(payload, timeout=10): + import requests + from api import data_record_url + + try: + response = requests.post(data_record_url, json=payload, timeout=timeout) + response.raise_for_status() + result = response.json() + except Exception as exc: + raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc + if not result.get("success"): + raise ValueError(result.get("errMsg", "辨识反馈服务拒绝请求")) + return result + + +def register_identification_result(run_id: str, timeout=10) -> None: + """Register one uploaded CSV as the customer's current review target.""" + from api import the_folder + + if not run_id: + raise ValueError("辨识结果缺少 run_id") + _post({ + "type": "registerIdentificationResult", + "deviceId": the_folder, + "runId": run_id, + "fileName": run_id, + }, timeout=timeout) + + +def get_identification_feedback(run_id: str, timeout=10): + """Return None while pending, otherwise return the integer 0 or 1.""" + from api import the_folder + + result = _post({ + "type": "getIdentificationFeedback", + "deviceId": the_folder, + "runId": run_id, + }, timeout=timeout) + if not result.get("ready"): + return None + feedback = result.get("result") + if isinstance(feedback, bool) or feedback not in (0, 1): + raise ValueError("云端辨识反馈必须是数字 0 或 1") + return int(feedback) + + +def acknowledge_identification_feedback(run_id: str, timeout=10) -> None: + """Delete the consumed review record so stale feedback cannot be reused.""" + from api import the_folder + + _post({ + "type": "ackIdentificationFeedback", + "deviceId": the_folder, + "runId": run_id, + }, timeout=timeout) diff --git a/ReinLoop/core/model_manager.py b/ReinLoop/core/model_manager.py new file mode 100644 index 0000000..4fc84aa --- /dev/null +++ b/ReinLoop/core/model_manager.py @@ -0,0 +1,139 @@ +# model_manager.py +"""RL 模型管理器:从云端扫描和加载强化学习模型。 + +纯业务逻辑,无 UI 依赖。通过回调与 UI 层通信。 +""" + +import threading +import io +import requests +import torch +from stable_baselines3 import SAC + +# 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault +torch.set_num_threads(1) + +from api import base_url, data_record_url, the_folder + + +class ModelManager: + """管理 RL 模型的云端扫描与加载""" + + def __init__(self): + self.model_file_map = {} # 文件名 → fileID 映射 + self.rl_model = None # 加载的 SAC 模型实例 + self._on_log = None + self._on_models_loaded = None + self._on_load_complete = None + + # ---- 回调设置 ---- + def set_log_callback(self, callback): + """设置日志回调: callback(message: str)""" + self._on_log = callback + + def set_models_loaded_callback(self, callback): + """设置模型列表加载完成回调: callback(file_names: list)""" + self._on_models_loaded = callback + + def set_load_complete_callback(self, callback): + """设置模型加载完成回调: callback(success: bool, message: str)""" + self._on_load_complete = callback + + def log(self, message): + if self._on_log: + self._on_log(message) + + # ---- 模型扫描 ---- + def scan_models(self): + """异步扫描云端模型文件夹,完成后回调通知""" + + def fetch_models(): + try: + payload = {"type": "listModels", "folder": f"{the_folder}/model_config"} + resp = requests.post(data_record_url, json=payload, timeout=10) + result = resp.json() + + if result.get("success"): + files = result.get("files", []) + file_list = result.get("fileList", []) + + self.model_file_map = { + item.get("fileName"): item.get("fileID") + for item in file_list if item.get("fileName") + } + + self.log("模型列表刷新成功") + + if self._on_models_loaded: + self._on_models_loaded(files) + else: + err = result.get('errMsg', '未知错误') + self.log(f"获取模型列表失败: {err}") + + except Exception as e: + self.log(f"扫描模型异常: {str(e)}") + + threading.Thread(target=fetch_models, daemon=True).start() + + # ---- 模型加载 ---- + def load_model(self, model_name: str): + """异步从云端加载指定的 RL 模型 + + Args: + model_name: 模型文件名 + """ + + if not model_name or model_name == "无模型文件": + self.log("错误:请先选择一个有效的模型") + return + + def download_and_load(): + try: + file_id = self.model_file_map.get(model_name) + if not file_id: + self.log("模型加载失败: 缺少 fileID,请先刷新模型列表") + return + + self.log(f"正在加载模型: {model_name}...") + + # 获取临时下载 URL + payload = {"type": "downloadModel", "fileID": file_id} + resp = requests.post(data_record_url, json=payload, timeout=15) + result = resp.json() + + if not result.get("success"): + err = result.get('errMsg', '未知错误') + self.log(f"模型加载异常: {err}") + return + + url = result['url'] + + # 下载模型文件 + model_resp = requests.get(url, timeout=30) + if model_resp.status_code != 200: + self.log(f"模型加载异常: HTTP {model_resp.status_code}") + return + + model_bytes = model_resp.content + + # 直接加载到内存 + model_stream = io.BytesIO(model_bytes) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.rl_model = SAC.load(model_stream, device=device) + + self.log(f"成功加载模型: {model_name}") + + if self._on_load_complete: + self._on_load_complete(True, f"成功加载模型: {model_name}") + + except Exception as e: + msg = str(e) + self.log(f"加载模型失败: {msg}") + if self._on_load_complete: + self._on_load_complete(False, f"加载失败: {msg}") + + threading.Thread(target=download_and_load, daemon=True).start() + + def is_model_loaded(self) -> bool: + """检查模型是否已加载""" + return self.rl_model is not None diff --git a/ReinLoop/core/volume_config.py b/ReinLoop/core/volume_config.py new file mode 100644 index 0000000..825ba21 --- /dev/null +++ b/ReinLoop/core/volume_config.py @@ -0,0 +1,154 @@ +"""Load, download, and validate the volume-measurement configuration.""" + +import json +import math +import os +from pathlib import Path +import sys + + +REQUIRED_FIELDS = { + "q_in_val", "dt", "p_max", "fit_low", "fit_high", + "T_delta", "xa_full", "num_runs", +} + + +def default_config_path() -> Path: + """Return the config path without exposing a file picker in the GUI.""" + override = os.environ.get("REINLOOP_VOLUME_CONFIG") + if override: + return Path(override).expanduser().resolve() + base_dir = (Path(sys.executable).resolve().parent + if getattr(sys, "frozen", False) + else Path(__file__).resolve().parent.parent) + return base_dir / "config" / "volume_measurement.json" + + +def load_volume_config(path=None) -> dict: + """Read exactly eight validated parameters from a JSON object.""" + config_path = Path(path).resolve() if path else default_config_path() + try: + with config_path.open("r", encoding="utf-8") as file_obj: + config = json.load(file_obj) + except FileNotFoundError as exc: + raise ValueError(f"容积测试配置文件不存在: {config_path}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"容积测试配置不是有效 JSON: {exc}") from exc + except OSError as exc: + raise ValueError(f"无法读取容积测试配置: {exc}") from exc + + return validate_volume_config(config) + + +def validate_volume_config(config) -> dict: + """Validate exactly eight parameters and normalize numeric values.""" + + if not isinstance(config, dict): + raise ValueError("容积测试配置必须是 JSON 对象") + actual_fields = set(config) + if actual_fields != REQUIRED_FIELDS: + missing = sorted(REQUIRED_FIELDS - actual_fields) + extra = sorted(actual_fields - REQUIRED_FIELDS) + raise ValueError(f"配置字段错误,缺少={missing},多余={extra}") + + for field in REQUIRED_FIELDS - {"num_runs"}: + value = config[field] + if (isinstance(value, bool) or not isinstance(value, (int, float)) + or not math.isfinite(float(value))): + raise ValueError(f"参数 {field} 必须是有限数字") + + runs = config["num_runs"] + if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0: + raise ValueError("参数 num_runs 必须是正整数") + if config["q_in_val"] <= 0: + raise ValueError("q_in_val 必须大于 0") + if config["dt"] <= 0 or config["p_max"] <= 0: + raise ValueError("dt 和 p_max 必须大于 0") + if config["fit_low"] < 0 or config["fit_high"] <= config["fit_low"]: + raise ValueError("必须满足 0 <= fit_low < fit_high") + if config["fit_high"] > config["p_max"]: + raise ValueError("fit_high 不能大于 p_max") + if config["xa_full"] <= 0: + raise ValueError("xa_full 必须大于 0") + + return { + "q_in_val": float(config["q_in_val"]), + "dt": float(config["dt"]), + "p_max": float(config["p_max"]), + "fit_low": float(config["fit_low"]), + "fit_high": float(config["fit_high"]), + "T_delta": float(config["T_delta"]), + "xa_full": float(config["xa_full"]), + "num_runs": runs, + } + + +def _post_volume_request(payload, timeout=10): + import requests + from api import data_record_url + + try: + response = requests.post(data_record_url, json=payload, timeout=timeout) + response.raise_for_status() + result = response.json() + except Exception as exc: + raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc + + if not result.get("success"): + raise ValueError(result.get("errMsg", "云端拒绝容积参数请求")) + return result + + +def create_volume_config_request(timeout=10) -> dict: + """Create exactly one cloud request after the customer clicks Test.""" + from api import the_folder + + result = _post_volume_request({ + "type": "createVolumeConfigRequest", + "deviceId": the_folder, + }, timeout=timeout) + if not result.get("requestId") or not result.get("expiresAtMs"): + raise ValueError("云端未返回有效的容积参数请求编号") + return { + "request_id": result["requestId"], + "expires_at_ms": int(result["expiresAtMs"]), + } + + +def poll_volume_config_request(request_id: str, timeout=10) -> dict: + """Poll one request; download and validate JSON only when it is ready.""" + import requests + from api import the_folder + + result = _post_volume_request({ + "type": "getVolumeConfigRequest", + "deviceId": the_folder, + "requestId": request_id, + }, timeout=timeout) + if result.get("expired"): + return {"ready": False, "expired": True} + if not result.get("ready"): + return {"ready": False, "expired": False} + + try: + response = requests.get(result["url"], timeout=timeout) + response.raise_for_status() + config = response.json() + except Exception as exc: + raise ValueError(f"下载或解析容积参数 JSON 失败: {exc}") from exc + return { + "ready": True, + "expired": False, + "config": validate_volume_config(config), + } + + +def acknowledge_volume_config_request(request_id: str, timeout=10) -> None: + """Delete the consumed/abandoned request and its temporary JSON file.""" + from api import the_folder + + _post_volume_request({ + "type": "ackVolumeConfigRequest", + "deviceId": the_folder, + "requestId": request_id, + }, timeout=timeout) diff --git a/ReinLoop/design_description.md b/ReinLoop/design_description.md new file mode 100644 index 0000000..6668d95 --- /dev/null +++ b/ReinLoop/design_description.md @@ -0,0 +1,1573 @@ +- 📋 ReinLoop V1.0 界面像素级视觉审计与重构规范(PySide / QSS 全套) + +-- 页面1 + +1. 全局色彩画布(Color Palette) +当前界面采用现代工业科技感的高级冷白调,对比度柔和,避免长时间操作视觉疲劳。 + +主背景色(App Background):#F8FAFC(柔和的微冷超浅灰蓝,作为整个窗口的基底色)。 + +卡片/区域背景(Card Background):#FFFFFF(纯白,用于 Modbus TCP 和 RTU 的分组容器)。 + +主品牌色/高亮蓝(Brand Accent):#0960D1(用于选中 Tab 的下划线、高亮图标以及分组左侧的小装饰块)。 + +按钮安全绿(Success Green):#0F955D(用于“连接设备”按钮)。 + +状态警示红(Error/Disconnect Red):#FF2424(用于右下角“未连接”状态点)。 + +文本颜色(Typography Colors): + +主标题/表单标签:#1A1A1A(高清晰度深灰)。 + +次级信息/状态栏/占位符:#757575(中灰)。 + +表单边框色(Border Color):#E2E8F0(极细的浅灰线)。 + +2. 顶部导航栏与带有图标的 Tab 栏(Top Header & Tab Bar) +整体高度与内边距:Header 整体高度约 70px,布局容器(如 QHBoxLayout)设置 setContentsMargins(24, 12, 24, 12)。 + +左侧 LOGO 与主标题: + +LOGO 使用 logo.svg,右侧有约 16px 的垂直分割线(可使用 QFrame,设置 width: 1px; background-color: #E2E8F0;)。 + +“ReinLoop V1.0” 字体:font-size: 22px; font-weight: bold; color: #1A1A1A;。 + +Tab 栏图标位置与间距控制(核心补充): + +图标位置:图标必须优雅地垂直居中,放置在标签文字的正左侧。 + +代码级注入:在 PySide 中限制图标的显示尺寸为 16x16px 或 18x18px,并在添加标签页时绑定 src文件夹中的对应svg文件: + +Python +tab_widget.setIconSize(QSize(18, 18)) +tab_widget.addTab(tab_connect, QIcon("link_icon.svg"), "连接设置") +tab_widget.addTab(tab_control, QIcon("control_icon.svg"), "控制设置") +tab_widget.addTab(tab_debug, QIcon("debug_icon.svg"), "模型调试") + * **QSS 样式与防贴边微调**: + ```css + QTabBar::tab { + background: transparent; + padding: 12px 24px 12px 16px; /* 上、右、下、左,为左侧图标留出呼吸感 */ + font-size: 14px; + color: #64748B; + } + QTabBar::tab:!selected { + opacity: 0.65; /* 未选中时,SVG 图标与文字统一半透明隐去 */ + } + QTabBar::tab:selected { + color: #0960D1; + font-weight: bold; + opacity: 1.0; + border-bottom: 3px solid #0960D1; /* 底部高亮蓝下划线 */ + } +3. 分组卡片容器(Group Cards - Modbus TCP & RTU) +基本样式: + +CSS +QFrame#GroupCard { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 12px; /* 优雅的大圆角,严禁直角 */ +} +* **阴影效果(QGraphicsDropShadowEffect)**: + 为这两个卡片容器绑定一个微弱的模糊阴影:`color = QColor(0, 0, 0, 12)`(透明度约 5%),`blurRadius = 16`,`offset = (0, 4)`。 +* **左侧装饰条标题**: + 每个卡片左上角的蓝色竖线,可以用一个宽 `4px`,高 `16px`,`background-color: #0960D1; border-radius: 2px;` 的小组件来实现。 + +--- + +## 4. 表单与输入框组件(Form Layout & QLineEdit) +* **标签(QLabel)**:字体大小 `14px`,颜色 `#333333`,加粗。 +* **输入框(QLineEdit)**: + * 高度固定:`min-height: 36px; max-height: 36px;`。 + * QSS 样式: + ```css + QLineEdit { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 6px; + padding-left: 12px; + color: #333333; + font-size: 14px; + } + QLineEdit:focus { + border: 1px solid #0960D1; /* 聚焦时激活品牌蓝 */ + } + + +5. 底部控制与状态栏(Bottom Action & Status Bar) +“刷新”按钮(QPushButton): +background-color: #FFFFFF; border: 1px solid #CCDBF0; color: #0960D1; border-radius: 6px; padding: 8px 20px; font-weight: bold; + +“连接设备”按钮(QPushButton): +background-color: #0F955D; border: none; color: #FFFFFF; border-radius: 6px; padding: 8px 24px; font-weight: bold; font-size: 14px; +QPushButton:hover { background-color: #0D8250; } + +底层状态栏(Status Bar): + +左侧日志文本:“14:27:09-模型列表刷新成功”,字体 12px,颜色 #757575。 + +右侧状态:“● 未连接”,其中圆点和文字高亮为红色(#FF2424)。 + + +“请根据这份整合了 image_fa96ff.png 界面风格与 image_049067.png 图标资产的像素级视觉规范,为我的 PySide 代码重构布局和 QSS 样式表。 +特别注意: + +Tab 栏的图标文件请严格对应 link_icon.svg、control_icon.svg 和 debug_icon.svg,且图标尺寸限定为 18x18 像素。 + +为了防止 Qt 默认的图标与文字贴得太近,请在 addTab() 传入字符串时,主动在文本前补一个半角空格(如 " 连接设置"),确保图标与文字之间有优雅的间距。 + +不要删除或修改我原本的业务逻辑和信号槽绑定。” + + + +# 任务:ReinLoop V1.0 顶部导航与 Tab 栏结构性修复(第 2 轮) +在上一轮修改后,输入框和按钮的尺寸已经好多了。但目前的顶部导航和 Tab 栏结构严重错位(参考 `image_0561c8.png`)。它与我们的设计图(`image_05629d.png`)存在巨大的方向性偏差。 + +请严格按照以下 5 点,对顶部的布局结构和 QSS 进行彻底修复: + +## 🛠️ 必须修复的视觉缺陷清单 + +1. 顶部布局结构大重构(修复 LOGO 遮挡与 Tab 移位) + - 【现状】:你错误地把 Tab 栏和 LOGO 塞进了同一行,导致整体高度严重不足,LOGO 下半部分被截断挤压。 + - 【设计图要求】:整个顶部应该是【上下两层】的垂直布局(QVBoxLayout)。 + * 上层(Layer 1):整行只有左侧的 LOGO、“ReinLoop V1.0” 标题,以及最右侧的设置图标按钮。这一行背景为纯白,不含任何 Tab 标签。 + * 下层(Layer 2):独立的一行,用来放置完整的 Tab 栏。 + - 【代码修复】:请将顶部整体调整为一个 `QVBoxLayout`,将 LOGO 整行(QHBoxLayout)作为第一个子布局,将 Tab 栏(QTabWidget/QTabBar)作为第二个子布局。 + +2. 补齐两行之间的灰色分割线与 Tab 背景色 + - 【设计图要求】:LOGO 行与 Tab 栏行之间、以及 Tab 栏行与下方主体卡片之间,都有通栏的极细灰色分割线。并且,【整个 Tab 栏这一行】的背景色应该是一体化的浅灰色。 + - 【QSS 修复】: + * 为包含 Tab 栏的这一层容器(或者 QTabWidget 的 TabBar 区域)设置背景色为 `#F8FAFC`(或与大背景融为一体的微冷浅灰)。 + * 通过 QSS 或在布局间添加 `QFrame` 分割线,确保 LOGO 行下方有一条颜色为 `#E2E8F0` 的 `1px` 水平线。 + +3. 修复 Tab 栏选中状态的颜色逻辑 + - 【现状】:目前所有的 Tab 文字和下划线都是死板的蓝色。 + - 【设计图要求】: + * 【未选中状态】:文字和图标必须是【中灰色】(颜色为 `#64748B`),且【没有】蓝色底部下划线。 + * 【选中状态】:只有当前被激活的 Tab,其文字、图标以及底部下划线才会一起变成【品牌蓝】(`#0960D1`)。 + - 【QSS 修复】:请严格分离 `QTabBar::tab`(未选中默认样式)与 `QTabBar::tab:selected`(选中高亮样式)的颜色控制。 + +4. 彻底找回丢失的 SVG 图标(Icon) + - 【现状】:目前的界面上图标完全消失了。 + - 【修复】: + * 确保在 PySide 代码中,使用 `QSize(18, 18)` 显式为 Tab 栏注入对应的图标资产:`link_icon.svg`(连接设置)、`control_icon.svg`(控制设置)、`debug_icon.svg`(模型调试)。 + * 为了让图标在未选中时呈现灰色、选中时呈现蓝色,请检查你使用的 SVG 源码或直接在 `QTabBar::tab:!selected` 中加入 `opacity: 0.6;` 降低未选中图标的明度,使其趋近于灰色。 + +5. 移除输入框在未激活时的过粗蓝色边框 + - 【现状】:目前即使没有点击输入框,部分输入框(如 PLC 地址)也自带了显眼的蓝色边框。 + - 【修复】:请确保 `QLineEdit` 的默认状态边框为极细的淡灰色(`1px solid #E2E8F0`),【只有】在聚焦状态下(`QLineEdit:focus`)才允许变蓝。 + +--- + +## 💻 交付要求 +请在完全不破坏后端逻辑、信号槽和既有变量名(如各输入框的 ObjectName)的前提下,重新梳理顶部布局结构并重写 QSS 样式表。请输出【完整重构后】的代码。 + + + + + +# 专项修复:解决 Tab 栏点击不亮、文字和图标始终为灰色的 Bug + +目前点击 Tab 栏时,文字和图标完全没有变成蓝色,依然保持灰色。 +这是因为: +1. `QTabBar` 不支持 `QIcon.Selected` 状态,它始终在使用 `QIcon.Normal` 的灰色图标。 +2. QSS 样式在 `QTabBar` 独立使用时,`:selected` 伪状态由于属性权重问题可能失效了。 + +请你针对 `main_window.py` 进行如下修改,使用显式代码逻辑实现 100% 稳定的高亮切换: + +## 1. 简化初始化图标代码 +请将 `_setup_ui` 中原本复杂的 `QIcon.addFile` 逻辑删掉,直接将灰色和蓝色的文件路径保存为类属性,方便动态调用。例如: +```python +# 在 _setup_ui 中定义好 6 个资产路径 +self._icons_gray = [ + os.path.normpath(os.path.join(_src, "link_icon_gray.svg")), + os.path.normpath(os.path.join(_src, "control_icon_gray.svg")), + os.path.normpath(os.path.join(_src, "debug_icon_gray.svg")) +] +self._icons_blue = [ + os.path.normpath(os.path.join(_src, "link_icon.svg")), + os.path.normpath(os.path.join(_src, "control_icon.svg")), + os.path.normpath(os.path.join(_src, "debug_icon.svg")) +] + +# 初始时先填入图标(默认全灰) +self.tab_bar.addTab(QIcon(self._icons_gray[0]), " 连接设置") +self.tab_bar.addTab(QIcon(self._icons_gray[1]), " 控制设置") +self.tab_bar.addTab(QIcon(self._icons_gray[2]), " 模型调试") + + + +与 Gemini 对话 +/** + +* 5. 上传用户许可证信息到 user_info 集合(按 user name 去重) + +* @param {Object} event - 云函数参数 + +* @param {string} event._id - (必填) 数据唯一标识符,这里用户名代替 + +* @param {string} event.username - (必填) 用户名,如 "XX公司" + +* @param {string} event.issued - (必填) 签发日,如 "2026-06-23" + +* @param {string} event.expiry - (必填) 到期日,如 "2027-06-23" + +* @param {string} event.license - (必填) 许可证内容(长字符串) + +* @returns {Promise} 返回操作结果 + +*/ + +async function uploadUserInfo(event) { + +const { _id, username, issued, expiry, license } = event; + +// 参数校验 + +if (!_id || !username || !issued || !expiry || !license) { + +return { success: false, errMsg: "缺少必填字段: _id, username, issued, expiry, license" }; + +} + + + +try { + +// 明确构造对象,避免任何歧义 + +const recordData = { + +username: username, + +issued: issued, + +expiry: expiry, + +license: license, + +updateTime: new Date() // 或 db.serverDate(),但用 new Date() 更稳定 + +}; + + + +// 查询是否已存在该 username + +const existed = await db.collection('user_info') + +.where({ username: username }) + +.get(); + + + +if (existed.data.length > 0) { + +// 存在则更新第一条(并删除多余同名记录) + +const keepId = existed.data[0]._id; + +await db.collection('user_info').doc(keepId).update({ + +data:{ + +issued: issued, + +expiry: expiry, + +license: license, + +updateTime: db.serverDate() + +} + +}); + +// 删除多余的重复记录(如果有) + +for (let i = 1; i < existed.data.length; i++) { + +await db.collection('user_info').doc(existed.data[i]._id).remove(); + +} + +return { success: true, action: 'updated', username }; + +} else { + +// 不存在则新增 + +await db.collection('user_info').add({ data: recordData }); + +return { success: true, action: 'inserted', username }; + +} + +} catch (err) { + +console.error("上传用户信息失败:", err); + +return { success: false, errMsg: err.message }; + +} + +} + + + +为什么数据库中的_id没有和用户名相同 + +你发现的这个现象,原因在于云函数的代码逻辑并没有把传入的 _id 赋值给新增的文档。 + +具体原因可以通过分析你提供的云函数代码和微信小程序云数据库的默认行为来解开: + +1. 核心原因:代码未在 add() 时指定 _id +在你的 uploadUserInfo 云函数中,虽然你在最开始从 event 里解构出了 _id: + +JavaScript +const { _id, username, issued, expiry, license } = event; +但是在执行新增数据(else 分支)时,你传入 add 方法的 recordData 结构如下: + +JavaScript +const recordData = { + username: username, + issued: issued, + expiry: expiry, + license: license, + updateTime: new Date() +}; + +// ... + +// 这里直接使用了 recordData,里面并不包含 _id 字段 +await db.collection('user_info').add({ data: recordData }); +2. 数据库的默认行为 +在微信小程序云开发(或 UniCloud / 腾讯云开发)中,如果你使用 db.collection().add() 方法向数据库插入一条新记录: + +如果传入的 data 对象中没有 _id 字段,数据库系统会自动为你生成一个 28位/32位的随机唯一字符串(即你在图片中看到的 "57fc8e266a3b85600ea88e53d6ff6bc")作为该记录的 _id。 + +如果你希望它的 _id 就是用户名(比如 "程序测试1"),你必须在 data 对象中显式地把 _id 放进去。 + +🛠️ 如何修改代码? +如果你希望数据库中的 _id 和用户名(username)完全保持一致,你需要修改云函数中 recordData 的定义,将 _id 显式写入。修改如下: + +JavaScript +// 明确构造对象,避免任何歧义 +const recordData = { + _id: username, // 👈 在这里显式指定 _id 为用户名(或者用传入的 _id 变量) + username: username, + issued: issued, + expiry: expiry, + license: license, + updateTime: new Date() +}; +⚠️ 温馨提示:将 _id 设为自定义字符串(如用户名)后,该集合的 _id 就具备了唯一索引的特性。如果未来有同名用户直接触发 add() 操作,数据库会直接抛出 dup key(键重复)错误,这本身也是一种非常好的防重手段。 + +为什么我上传含有相同用户名的数据时没有二次确认 + +这也是由你当前的云函数代码逻辑决定的。 + +简单来说:因为你的云函数在发现用户名重复时,直接在后台静默执行了“更新旧数据、删除多余数据”的操作,并没有返回任何可以让客户端拦截并进行二次确认的信号。 + +我们可以对比一下你的 Python 脚本(license_gen.py)和云函数(JavaScript)的逻辑差异: + +1. 根本原因:云函数直接把“防呆”给做完了 +在你的云函数代码中,处理流程是这样的: + +JavaScript +// 1. 查询是否已存在该 username +const existed = await db.collection('user_info') + .where({ username: username }) + .get(); + +if (existed.data.length > 0) { + // 2. ❗如果存在,直接无条件更新第一条,并删掉其余同名数据 + const keepId = existed.data[0]._id; + await db.collection('user_info').doc(keepId).update({ ... }); + + // ... 删除多余记录 ... + + // 3. 直接返回成功,action 标记为 'updated' + return { success: true, action: 'updated', username }; +} + +运行 +因为云函数一旦匹配到 existed.data.length > 0,就直接调用了 update 和 remove 写入数据库,所以不管你的 Python 客户端怎么发请求,云数据库都已经瞬间被覆盖了。 + +2. Python 脚本里设计的“两阶段确认”失效了 +你在 Python 脚本中其实写了非常完美的防呆机制: + +第一次请求不带 force_update。 + +如果云端返回 result.get("conflict"),就会在本地终端弹出 是否覆盖更新?[y/N]: 的二次确认。 + +用户输入 y 后,才会带上 force_update: true 重新发起请求。 + +但是,你的云函数完全没有配合这个机制。 云函数既没有检查 event.force_update 这个参数,也没有在发生冲突时返回 { conflict: true, existingData: ... }。 + +🛠️ 如何修改云函数以支持二次确认? +为了让 Python 脚本的二次确认功能生效,你需要让云函数在发现重复时“先不更新,而是举手报警”。 + +请将云函数中的 if (existed.data.length > 0) 部分修改为如下逻辑: + +JavaScript + // 查询是否已存在该 username + const existed = await db.collection('user_info') + .where({ username: username }) + .get(); + + if (existed.data.length > 0) { + // 💡 核心修改:检查客户端是否传了 force_update 标记 + // 如果没有传,说明是第一次尝试,直接拦截并返回冲突信息给 Python 客户端 + if (!event.force_update) { + return { + success: false, + conflict: true, + existingData: { + issued: existed.data[0].issued, + expiry: existed.data[0].expiry + } + }; + } + + // 如果传了 force_update: true,说明用户已经在客户端敲了 "y" 确认覆盖,此时再执行更新 + const keepId = existed.data[0]._id; + await db.collection('user_info').doc(keepId).update({ + data:{ + issued: issued, + expiry: expiry, + license: license, + updateTime: db.serverDate() + } + }); + + // 删除多余的重复记录(如果有) + for (let i = 1; i < existed.data.length; i++) { + await db.collection('user_info').doc(existed.data[i]._id).remove(); + } + return { success: true, action: 'updated', username }; + } + +运行 +💡 修改后的效果 +这样改完后,当你再次上传同名数据时: + +云函数查到重复 → 发现没有 force_update → 返回 conflict: true 终止。 + +Python 脚本收到响应,触发 input() 并在终端拦截,打印出旧证书的签发和到期时间,询问你是否覆盖。 + +你输入 y 后,Python 携带 force_update: true 二次请求,云函数才会真正修改数据库。 + +https://github.com/Leonxlnx/taste-skill + + + +查看项目,解释这个项目有什么功能,如何使用 + +Leonxlnx/taste-skill(也称为 Taste Skill)是一个专门为 AI 编程智能体(AI Coding Agents) 打造的开源前端设计规则与指令集框架。 +Taste Skill + +它的核心口号是:“Give your AI good taste, stop generating boring, generic slop”(让你的 AI 具备良好的审美,停止生成无聊、千篇一律的工业垃圾/AI水货代码)。 +GitHub + +以下是该项目的具体功能介绍以及使用方法: + +一、 核心功能与痛点解决 +虽然像 Cursor, Claude Code, Devin, Lovable 等 AI 编程工具能以极快的速度写出代码,但默认生成的 UI/UX 界面往往极具“AI味”——布局单调、字体缩放死板、间距不讲究、动画生硬。 +Taste Skill + +taste-skill 通过提供高度优化的指令文件(包含一套严格的视觉心理学、排版、间距和动效规范),让 AI 像资深设计师一样去思考。其主要功能特点包括: +GitHub + +摆脱模版化设计:包含独特的反重复规则和严格的预检(Pre-flight check)机制,禁止 AI 生成“大厂样板房”式的 UI。 +GitHub + +多款参数微调(Dials):每个技能文件顶部有 1-10 的数字“旋钮”,可以通过直接改数字控制 AI 生成的代码: +GitHub + +DESIGN_VARIANCE:布局实验度(低:传统整洁;高:现代不对称)。 +GitHub + +MOTION_INTENSITY:动效深度(低:简单悬停;高:复杂的滚动和磁性动画)。 +GitHub + +VISUAL_DENSITY:视觉密度(低:宽敞留白;高:紧凑的数据看板)。 +GitHub + +丰富的技能变体(Variants): + +design-taste-frontend (v2):默认的主力技能。它能自动推断你项目的行业背景、受众和情绪,匹配最适合的暗黑模式、设计系统或动效框架(如 GSAP)。 +GitHub + +redesign-skill:用于已有项目的重构和视觉审计。 +Taste Skill + +image-to-code-skill:配合图片生成能力,实现“生成参考图 → 分析风格 → 编写高还原度前端代码”的组合工作流。 +GitHub + +minimalist-skill / brutalist-skill:直接锁定极简风或新野兽主义视觉风格。 +Taste Skill + +output-skill:强迫 AI 必须完整输出代码,拒绝生成中断或使用 // 你的其余代码... 等敷衍占位符。 +Taste Skill + +二、 如何使用 +该项目不挑前端框架(无论你用 React, Vue, Svelte 还是原生 HTML+Tailwind 均可),它作为一种“外挂技能指令(SKILL.md)”插入到你现有的 AI 编程流中。 +GitHub + +使用方法通常有以下几种: + +方法 1:通过 CLI 命令行工具自动安装(推荐) +如果你使用的是支持特定技能扩展的现代 AI 客户端(如 Claude Code 或其他原生集成环境),可以在项目根目录下直接运行 npx 脚本: + +安装默认的前端审美技能: + +Bash +npx skills add https://github.com/Leonxlnx/taste-skill --skill "design-taste-frontend" +* **如果你需要指定其他子技能(例如极简风):** + ```bash + npx skills add https://github.com/Leonxlnx/taste-skill --skill "minimalist-skill" +方法 2:手动复制到项目中(适用于 Cursor / VS Code / Claude Code) +打开该 GitHub 仓库,进入 skills/ 文件夹。 + +找到你需要赋予 AI 的能力文件(如 skills/taste-skill/SKILL.md)。 + +将该 SKILL.md 文件(或者里面的文本内容)直接下载或复制到你本地的项目根目录下。 + +触发 AI:现在的编程 Agent(如 Cursor 或 Claude)在感知到目录下的新指令文件后,在后续的代码生成中就会自动加载、遵循其中的“高级审美规范”。 + +方法 3:直接粘贴进提示词对话(适用于 Web 版 ChatGPT / Claude / Gemini) +如果你在网页端使用大模型写前端,直接把对应的 SKILL.md 里的文本全选复制,然后作为前置条件发送给 AI: + +“以下是我的项目的审美和代码规范,请严格遵循。在这个规范下,帮我用 React+Tailwind 写一个……” + +三、 总结建议 +如果你想通过 AI 开发一个看起来高级、有大牌质感、拥有丝滑动画的产品界面(而非粗制滥造的 demo),建议首先通过 CLI 安装它的主技能: + +Bash +npx skills add https://github.com/Leonxlnx/taste-skill +装好后,你甚至可以通过修改文件顶部的 MOTION_INTENSITY: 8 这一行字,来直接指挥 AI 给你的页面加上炫酷的滚动动效。 + +redesign-skill:用于已有项目的重构和视觉审计。 + +image-to-code-skill:配合图片生成能力,实现“生成参考图 $\rightarrow$ 分析风格 $\rightarrow$ 编写高还原度前端代码”的组合工作流。 + + + +详细解释这两个用法 + +在 Leonxlnx/taste-skill 框架中,redesign-skill 和 image-to-code-skill 是两个极具实战价值的高级变体(Variants)。它们将普通的 AI 重构和生图转换,升级为了具备设计师视角和严苛审美标准的半自动化工作流。 + +以下是这两个用法的详细运作机制、深度解析以及具体的使用步骤: + +1. redesign-skill(老旧项目的重构与视觉审计) +普通的 AI 在面对“重构”请求时,通常只是帮你重写一下过时的组件语法(例如把 Class 组件换成 Hooks),或者单纯做代码瘦身。而 redesign-skill 的核心在于“视觉审计(Visual Audit)”——它像一个挑剔的前端设计总监,先给你的既有页面“挑刺”,再动手重构。 + +⚙️ 运作机制与功能特点 +深度视觉破产审查(Visual Bankruptcy Review):AI 会扫描你原有的前端 HTML/CSS/Tailwind 代码,无情地指出其中不合理的设计。例如:间距不一致(一会儿用 p-4 一会儿用 p-5)、缺乏排版层级、颜色搭配死板(如直接使用纯黑 #000000 或高饱和度纯蓝)、缺乏微动效导致页面死气沉沉。 + +无破坏性重构:它在重构时,会严格保证你原有的业务逻辑、API 数据绑定、状态管理(如 useState/Redux)不被破坏,仅对 DOM 结构、CSS 类名、布局容器和交互动画进行局部手術式改造。 + +注入高级感(The "Taste" Injection):根据你设置的旋钮参数(如布局自由度、动效强度),它会自动引入诸如“毛玻璃效果(Backdrop-blur)”、“非对称优雅布局”、“微调排版比例”等现代前端设计语言。 + +🛠️ 怎么使用? +启用该技能: +在你的项目根目录下使用 CLI 加载,或者把对应的 SKILL.md 指令喂给你的 AI Agent(如 Cursor / Claude Code)。 + +Bash +npx skills add https://github.com/Leonxlnx/taste-skill --skill "redesign-skill" +设定旋钮参数(在技能文件顶部微调,或直接在 Prompt 中指定): + +“保持 VISUAL_DENSITY: 5(保持舒适留白),将 MOTION_INTENSITY 设为 6(加入优雅的过渡动效)。” + +发送重构指令: +将你需要重构的老旧组件代码选中,发送给 AI: + +Prompt 示例: > “这是我们现有的用户看板组件代码。它目前看起来非常具有‘老旧管理后台’的呆板感。请启动 redesign-skill 对其进行视觉审计,找出至少 3 个视觉痛点,然后在不破坏现有数据绑定(userData)和点击事件的前提下,将其重构成符合 Vercel/Linear 风格的现代暗黑模式界面。” + +2. image-to-code-skill(图片驱动的高还原度开发流) +在多模态时代,我们经常把一张好看的网页截图发给 AI 说:“帮我实现这个界面”。但普通 AI 往往只能模仿一个大概的轮廓,细节(如边框渐变、阴影深度、字体粗细、响应式间距)通常是一塌糊涂。 + +image-to-code-skill 解决的就是“如何让 AI 像素级还原一张设计图/参考图”。 + +⚙️ 运作机制(三阶段组合工作流) +该技能强制 AI 遵循一个三步走的思考链(Chain of Thought),严禁直接盲目写代码: + +第一阶段:分析风格(Deconstruction & Moodboarding) AI 接收到图片后,首先将其转化为一套具体的“前端设计资产清单”。它会输出一段设计分析,明确指出:图片的色彩系统(主色、辅助色、渐变色)、排版规则(字体权重、行高)、图形特征(如 border-radius: 24px 的大圆角、多层柔和阴影)、以及潜在的交互预期。 + +第二阶段:对齐规范(Framework Alignment) AI 会将分析出的设计风格,完美映射到你指定的前端技术栈(例如 Tailwind CSS、Framer Motion、GSAP)。它会规划好哪些地方该用 Flex 布局,哪些地方该用 Grid,如何用 Tailwind 组合出图片里的高质感滤镜。 + +第三阶段:编写高还原度代码(High-Fidelity Code Gen) 最后一步,AI 才会开始编写代码。此时它会启动 output-skill 的防敷衍机制,为你输出结构清晰、带有详尽注释(说明如何用 CSS 还原了图片某处细节)的完整代码。 + +🛠️ 怎么使用? +准备参考图:可以是你在 Dribbble、Linear 官网看到的优秀设计截图,甚至是利用 Midjourney/DALL-E 3 生成的前端 UI 概念图。 + +加载技能并上传图片: +在支持多模态的 AI 聊天界面(如 Cursor Chat、Claude 3.5 Sonnet 或网页端)中,上传你的截图,并附带以下 Prompt: + +Prompt 示例: > “我想用 React + Tailwind CSS 实现截图中的这个卡片悬停效果。请启动 image-to-code-skill: + +先帮我深度拆解这张图的视觉密码(阴影层级、渐变色值、字体排版)。 + +将 DESIGN_VARIANCE 设为 8,允许你在实现时加入一些符合整体高档风格的现代交互细节。 + +请完整输出代码,不要省略任何 Tailwind 类名。” + +💡 总结 +如果你手里有一个已经能跑、但长得很丑的系统,用 redesign-skill 可以帮你一键洗稿,让它焕然一新且不破坏功能。 + +如果你手里有好看的视觉稿或竞品截图,用 image-to-code-skill 能逼迫 AI 像一个像素眼(Pixel-perfect)前端工程师一样,把图片高还原度地变成能用的代码。 + +针对你的需求——“拿着现有的 Figma 设计图,让 AI 按图优化已写好的界面代码”——这个 image-to-code 技能可能无法直接满足你的期望。它的工作方式和你的需求之间存在一些关键的错位。 + +🎯 image-to-code 技能的实际工作方式 + +这个技能的核心是 “AI 自生成,自消化” 的闭环流程。它的标准步骤是: + +AI 自己生成设计图:它会先根据你的文字描述,利用图像生成能力“凭空”画出几张它认为符合“好品味”的网页设计参考图。 + +AI 分析自己的图:然后,它会深入分析自己生成的这些图片,提取其中的布局、配色、排版等信息。 + +AI 编写代码:最后,基于它对自产图片的分析,来编写最终的前端代码。 + +这个流程和你“拿着现成的 Figma 设计图去优化代码”的场景,完全是两个方向 + + + +为什么她说的和你说的不一样 + +你提到的这位朋友对项目的理解非常深入且敏锐,她指出了一个很多人在使用 AI 编程时最容易踩到的“预期差”坑。 + +其实我们两个人说的都没错,只是观察这个技能的维度和所处的“工程流阶段”不同。之所以会产生这种“不一样”的感觉,原因在于 taste-skill 框架本身是面向“全自动 AI Agent(如 Claude Code/Devin)”设计的,它的理想闭学流确实如她所说;但在日常开发中,我们可以把它降维当成“普通的提示词外挂”来用。 + +为了让你彻底搞懂,我们来把这两种视角的本质拆开来看: + +1. 她说的“AI自生成、自消化”:是针对“全自动 Agent 工作流”的本质视角 +她所描述的,是 taste-skill 仓库里 image-to-code-skill 在完全自动化运行(Autonomous Mode)下的标准闭环。 + +在这种模式下,你给 AI 一个需求(比如:“帮我做一个极简风的音乐播放器”),AI 并不是直接去写代码,而是: + +AI 自己生图:调用多模态模型(或生图插件)先画一张 UI 概念图。 + +AI 视觉审计:AI 自己用“像素眼”去审查这张图好不好看、间距对不对、有没有工业垃圾的感觉。 + +AI 把图变代码:审查通过后,AI 再把这张图据为己有,翻译成高质量的前端代码。 + +在这个全自动闭环里,确实没有给“人类提供 Figma 设计图”留下现成的输入接口。所以她非常精准地提醒你:如果你想直接把 Figma 链接丢给它,让它完美重构你写了一半的烂代码,这个技能的原生设计跟你想的完全是两个方向。 + +2. 我说的“像素级还原截图”:是我们在“日常对话/Cursor 开发”中的实用主义视角 +而我前面提到的用法,是在我们把这个技能“降维”应用在日常聊天(如 Cursor Chat、Claude 网页端)中的实战变通。 + +当我们不再让 AI “全自动瞎跑”,而是由人类来主导控制流时,这个技能的思考框架依然可以被我们“白嫖”: + +人类提供图和代码:你把你现有的代码,以及 Figma 的高保真设计稿截图一起喂给 AI。 + +借用它的思考链(Mindset):虽然这个技能原本是让 AI 审计自己的图,但当它加载了 image-to-code-skill 后,它会被强行注入一套“高级设计师的像素眼规范”。此时,它会用这套规范去对比你的 Figma 截图和你现有的烂代码。 + +高还原度优化:它会指出:“你的 Figma 图里阴影是柔和的 shadow-xl 感觉,但你的代码里写死了一个很丑的粗边框,我帮你优化成……” + +所以,我说的用法是把这个技能当成了 AI 的“审美补丁”。 + +📋 总结:你该听谁的? +你们两个人的观点结合起来,才是这个技能最完整、最避坑的真相: + +大方向上,听她的:千万不要以为装了这个技能,AI 就能像个真正的 UI 实习生一样,完全读懂你们复杂的 Figma 组件树和既有业务逻辑,然后天衣无缝地帮你改好代码。直接丢复杂项目进去,一定会因为两者的错位而翻车。 + +具体操作上,用我说的:如果你真的想让它帮你按 Figma 优化,你需要人工把任务切碎。不要给它一整个大项目,而是把“某一个卡片”或“某一个导航栏”的代码和 Figma 局部截图发给它,利用该技能的审美规范,让它帮你做局部洗稿。 + +她帮你踩了“高估 AI Agent 自动化流程”的坑,而我给了你一个“把大炮拆成手枪用”的压榨 AI 偏方。 + +https://github.com/addyosmani/agent-skills + + + +解释这个项目有什么功能 + +addyosmani/agent-skills 是由 Google Chrome 团队工程主管 Addy Osmani 主导并开源的一个专门为 AI 编程智能体(AI Coding Agents)打造的高标准工程化技能/工作流框架。 +knightli.com + +它不是一个新的编程框架,也不是简单的提示词(Prompt)片段,而是一套用纯 Markdown 编写的、将资深软件工程师的“工作纪律和最佳实践”代码化的指令集。它的核心功能是给容易“偷懒”的 AI 盖上质量闸门,逼迫 AI 像大型科技公司的资深开发者一样去规范地写代码、测试和交付。 +knightli.com ++ 1 + +该项目的核心功能和机制可以归纳为以下几点: + +一、 核心功能与技术亮点 +1. 覆盖全生命周期的 7 个斜杠命令(Slash Commands) +该项目将整个软件开发生命周期(SDLC)拆解为 7 个明确的阶段命令,让 AI 在每个阶段只专心做一件事,避免因上下文过长而逻辑混乱: +knightli.com + +/spec(定义):在写任何代码前,必须先理清需求,输出涵盖目标、范围边界和技术栈的 PRD/设计文档。 + +/plan(规划):严禁横向大面积铺开写代码,强迫 AI 将任务拆解为极小的、垂直切片的原子任务。 +GitHub + +/build(构建):增量式开发,一次只解决一个垂直切片,写完即提交。 +GitHub + +/test(测试):推行测试驱动开发(TDD),测试通过才是功能完成的唯一铁证。 +GitHub + +/review(评审):从代码健康度、可读性等维度进行类似 Google 内部标准的严格审计(如控制单次 PR 在 100 行左右)。 +GitHub + +/code-simplify(代码简化):在不改变核心业务行为的前提下,精简和重构代码(遵循切斯特顿栅栏原则)。 +knightli.com + +/ship(交付):完成最后的发布前检查(包括功能标帜开关、CI/CD 管道验证等)。 +knightli.com + +2. 独特的“反借口/反狡辩表”(Anti-Rationalization Tables) +AI 编程智能体(如 Claude 或 Cursor)有一个通病:极度注重效率,因此经常走捷径(例如偷懒不写测试,或者用 // 你的其余代码... 来敷衍)。 +该项目的每个技能文件(SKILL.md)都内置了“反向防呆表”。如果 AI 试图找借口说 “这只是个小改动,不需要写单元测试”,系统会有一套严厉的对立论据直接把它“拍回去”,强制它遵循测试金字塔。 +Agensi ++ 2 + +3. 24 个工业级核心技能(Skills) +除了上述命令,该仓库还包含 24 个细分工程技能,把大厂沉淀的软件工程经验固化成了 AI 的工作步骤,例如: +knightli.com + +Doubt-driven Development(怀疑驱动开发):逼迫 AI 对自己的假设保持警惕。 + +Source-driven Development(源码/官方文档驱动):强制 AI 每一段技术决策都必须引用官方最新文档,拒绝幻觉。 +GitHub + +Security & Hardening(安全加固):对 OWASP Top 10 漏洞、鉴权隔离和敏感密钥管理进行强制审查。 +GitHub + +Web Performance & Browser Debugging:利用 Chrome DevTools 协议(MCP)深入 runtime 进行 DOM、内存和网络加载(LCP、INP、CLS)的真实审计。 +GitHub + +4. 渐进式呈现(Progressive Disclosure) +为了防止一次性喂给 AI 太多规则导致其“脑容量”(上下文窗口)爆炸、Token 消耗过大,该项目采用路由机制。AI 默认只加载总入口技能(using-agent-skills),只有在被触发特定指令或特定场景时,才会动态按需加载对应的子技能文档。 +GitHub ++ 1 + +二、 它适合怎么使用? + +knightli.com ++ 2 +这个项目是语言无关、框架无关的(不管是写 Python, Go 还是 React 都能用),它可以无缝外挂到当前主流的 AI 编程工具中: +GitHub + +搭配全自动 Agent(如 Claude Code、Gemini CLI): +这类工具原生支持 MCP 或外部技能包加载,可以直接通过项目提供的脚本一键安装到智能体的技能树中。 +knightli.com + +搭配 IDE 插件(如 Cursor、Windsurf、VS Code Copilot): +你可以把该项目中的 SKILL.md 文件或核心工作流,直接放入项目根目录的 .cursor/rules/ 文件夹下,作为 AI 的全局行为准则(System Prompts)。 +AI Native Landscape - Jimmy Song + +人类开发团队的 Wiki 规范: +由于其将大厂(如 Google 内部)的工程规范提炼得非常精简骨感,很多团队甚至直接把它打印出来,作为人类工程师与 AI 协同开发时的项目军规。 +AI Native Landscape - Jimmy Song + +一句话总结:addyosmani/agent-skills 就像是给你的 AI 程序员外接了一个严厉、刻板、极具强迫症的“高级技术总监监工”,把 AI 辅助编程从“靠运气写玩具 Demo”提升到了“工业级生产线”的标准。 +AI Native Landscape - Jimmy Song + +对于taste- skill,我把设计图给ai,让它改界面代码,该怎么用skill,告诉我prompt + +要让 AI 拿着你的 Figma 设计图(截图) 去优化已有的界面代码,你不能直接把整个大项目丢给它。正如前面所说,我们需要“把大炮拆成手枪用”——采用局部喂食、强制审计的策略。 + +这里为你准备了一套完整的操作步骤和开箱即用的 Prompt(提示词)模板。 + +🛠️ 第一步:准备工作(切碎任务) +截取局部图:不要截整个大网页,只截你想修改的那一个组件(例如:一个登录卡片、一个导航栏、或一个数据图表网格)。 + +复制局部代码:把这个组件对应的现有前端代码(HTML/Tailwind/React/Vue 等)复制下来。 + +📝 第二步:调教 AI 的 Prompt 模板 +在 Cursor Chat、Claude 3.5 Sonnet 或 ChatGPT 中,先上传你的设计图截图,然后复制并修改以下 Prompt 发送给 AI: + +Markdown +# 角色与技能启动 +你现在是一位拥有像素级强迫症(Pixel-perfect)的高级前端专家。请启动 `taste-skill` 中的 `image-to-code-skill` 和 `redesign-skill` 核心思维。 + +# 任务目标 +我上传了一张 Figma 的设计稿截图,并在下方提供了我目前已经写好的组件代码。由于“AI味”太浓、细节不到位,目前的代码和设计稿有明显的视觉差距。请帮我“洗稿”重构这段代码,使其高还原度地匹配设计图。 + +# 旋钮参数设定(Taste Dials) +- DESIGN_VARIANCE: 2 (严格对齐设计图的排版,不要自己瞎编乱造布局) +- MOTION_INTENSITY: 5 (如果设计图有明显的交互暗示,加入优雅微弱的 Tailwind/CSS 过渡动效) +- VISUAL_DENSITY: 5 (严格保持设计图中的微观留白与间距) + +# 你的工作流(请分步执行) +1. 视觉审计(Visual Audit):对比设计图和我的代码,指出至少 3 个目前代码里最丑、最不还原的细节(如阴影、边框、颜色饱和度、字体粗细、内边距等)。 +2. 代码重构(Refactoring):在【绝对不破坏】现有业务逻辑、数据绑定和点击事件的前提下,对界面代码进行视觉升级。 +3. 拒绝敷衍:必须输出【完整】的代码,严禁使用 `// 你的其余代码...` 等占位符。 + +--- + +## 💾 我现有的组件代码如下: +```json +// 👈 在这里粘贴你现有的烂代码 +请先输出你的视觉审计分析,然后给出重构后的完整代码。 + + +--- + +### 💡 为什么这个 Prompt 能发挥作用? + +1. **限定了 `DESIGN_VARIANCE: 2`**:这是关键。默认的 taste-skill 会让 AI 放飞自我去搞“艺术创作”。我们把它调低到 2,就是死命令:**“老老实实照着我给的 Figma 图抄,别搞非对称和艺术抽象”**。 +2. **设定了安全边界**:明确要求“绝对不破坏现有业务逻辑和数据绑定”,防止 AI 把你的按钮点击事件、组件传参(Props)给改丢了。 +3. **给 AI 装上像素眼**:迫使它先做“视觉审计”,AI 会主动去吸取设计图里的阴影深度(如 `shadow-[0_8px_30px_rgb(0,0,0,0.12)]`)、渐变色值、毛玻璃滤镜,吐出来的代码质量会比直接问“帮我改改”高出好几个档次。 +能否给你界面设计图片,你来转化成文字描述形式的文件,我再把文件喂给ai来编辑代码? + +当然可以,这其实是一个非常聪明的“曲线救国”方案! + +现在的很多 AI 编程工具(比如部分版本的 Cursor 或低配的开源 Agent)在对话时不能直接接收图片,或者对图片的视觉解析力经常“抓大放小”。如果你让我先把图片翻译成纯文本的高清设计规范(Design Spec),你再把这个文本喂给代码 AI,反而能实现极高的还原度。 + +你可以直接把设计图(截图)发给我,我会为你输出一份“像素级前端转译描述文件”。 + +为了确保吐出来的文本能完美对接 taste-skill 等 AI 框架,我会严格按照以下结构为你描述: + +📝 我将为你输出的描述文件结构: +全局调性(Design System Overview) + +色彩画布:精确到 Hex 色值(如背景色、主色调、次级文本、渐变色、暗黑模式映射)。 + +字体排版:标题、正文的字重(Font Weight)、行高(Line Height)和视觉层级。 + +微观布局与间距(Layout & Spacing) + +采用的布局模式(Flex 还是 Grid),各元素的对齐方式。 + +外部留白(Margin)与内部填充(Padding)的体感描述(换算成 Tailwind 或标准 CSS 比例)。 + +视觉细节与质感(Visual Fidelity) + +圆角大小(如大卡片圆角、按钮圆角)。 + +阴影深度(是扁平风、微弱阴影,还是高档的扩散多层软阴影)。 + +滤镜与边框(是否有毛玻璃 backdrop-blur、极细的半透明边框渐变线等)。 + +交互动效暗示(Interactive Clues) + +悬停(Hover)、点击(Active)时应该发生怎样的平滑过渡。 + +🛠️ 怎么操作? +发图给我:现在就把你需要还原或参考的 Figma 界面截图 贴到对话框里发给我。 + +补充说明(可选):顺便告诉我你打算让代码 AI 用什么技术栈(比如:React + Tailwind CSS 或者是 Vue + Element Plus),这样我写描述文件时,会直接用该技术栈的术语(如 Tailwind 类名)来描述,让代码 AI 更好消化。 + +现在,请把你的设计图发给我吧! + +我用的是pyside + +收到!既然你使用的是 PySide(PySide2 或 PySide6) 来开发这个工业控制系统软件(ReinLoop V1.0),那么我会完全基于 QSS(Qt Style Sheets)、QWidget 布局逻辑以及 Qt 的视觉层级为你转译这张 image_fa96ff.png 的设计规范。 + +这份文本不仅分析了当前界面的“设计质感缺陷”,还将设计参数直接映射成了 PySide 开发者能听懂的代码术语(如 margin, padding, border-radius 以及颜色代码)。你待会把这段文本喂给负责写 PySide 的 AI 代码工具(例如 Cursor)时,它能秒懂并写出像素级还原的高质量 QSS。 + +📋 ReinLoop V1.0 界面像素级视觉审计与重构规范(PySide / QSS) +1. 全局色彩画布(Color Palette) +当前界面采用现代工业科技感的高级冷白调,对比度柔和,避免长时间操作视觉疲劳。 + +主背景色(App Background):#F8FAFC(非常柔和的微冷超浅灰蓝,作为整个窗口的基底色)。 + +卡片/区域背景(Card Background):#FFFFFF(纯白,用于 Modbus TCP 和 RTU 的分组容器)。 + +主品牌色/高亮蓝(Brand Accent):#0056C1 或 #0960D1(用于左上角 LOGO、顶部选中 Tab 的下划线、以及分组左侧的小装饰块)。 + +按钮安全绿(Success Green):#0F955D(用于“连接设备”按钮)。 + +状态警示红(Error/Disconnect Red):#FF2424(用于右下角“未连接”状态点)。 + +文本颜色(Typography Colors): + +主标题/表单标签:#1A1A1A 或 #333333(高清晰度深灰)。 + +次级信息/状态栏/占位符:#757575(中灰)。 + +表单边框色(Border Color):#E2E8F0(极细的浅灰线)。 + +2. 顶部导航栏与标题区(Top Header & Tab Bar) +整体高度与内边距:Header 整体高度约 70px,QVBoxLayout 或 QHBoxLayout 的 setContentsMargins(24, 12, 24, 12)。 + +左侧 LOGO 与主标题: + +LOGO 图片与右侧标题中间有约 16px 的垂直分割线(可以使用 QFrame,设置 width: 1px; background-color: #E2E8F0;)。 + +“ReinLoop V1.0” 字体:font-size: 22px; font-weight: bold; color: #1A1A1A;。 + +顶部 Tab 栏(QTabBar / QTabWidget 样式): + +背景透明,去除自带的边框:QTabBar::tab { background: transparent; padding: 12px 20px; font-size: 14px; color: #64748B; } + +选中状态(连接设置):字体变蓝 color: #0960D1; font-weight: bold;。下方有粗细约 3px 的蓝色下划线。可以通过 QTabBar::tab:selected 配合下边框实现:border-bottom: 3px solid #0960D1;。 + +3. 分组卡片容器(Group Cards - Modbus TCP & RTU) +这是目前界面最容易产生“呆板粗糙感”的地方。要消除 AI 味,必须严格控制圆角和极其微弱的阴影。 + +基本样式: + +CSS +QFrame#GroupCard { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 12px; /* 💡 必须是优雅的大圆角,严禁直角 */ +} +* **阴影效果(QGraphicsDropShadowEffect)**: + 卡片千万不能用死黑阴影。必须为这两个卡片容器绑定一个非常淡的阴影: + `color = QColor(0, 0, 0, 15)`(透明度仅 5% 左右),`blurRadius = 16`,`offset = (0, 4)`。 +* **左侧装饰条标题**: + 每个卡片左上角的蓝色竖线,可以用一个极窄的 `QWidget`(宽 `4px`,高 `16px`,`background-color: #0960D1; border-radius: 2px;`)和标签文本放入一个 `QHBoxLayout`。 + +--- + +## 4. 表单与输入框组件(Form Layout & QLineEdit) +当前界面的输入框显得过长、过空,重构时必须优化其尺寸与间距。 +* **标签(QLabel)**:字体大小 `14px`,颜色 `#333333`,加粗。 +* **输入框(QLineEdit)**: + * 高度固定:`min-height: 36px; max-height: 36px;`。 + * QSS 样式: + ```css + QLineEdit { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 6px; /* 柔和的小圆角 */ + padding-left: 12px; + color: #333333; + font-size: 14px; + } + QLineEdit:focus { + border: 1px solid #0960D1; /* 聚焦时变蓝 */ + } + +运行 +多列并排布局(站号/数据位/停止位): +这一行应该使用嵌套的 QHBoxLayout。每个标签和输入框组合后,设置合理的伸缩因子(Stretch Factor),防止输入框在窗口放大时被拉得过宽变形。 + +5. 底部控制与状态栏(Bottom Action & Status Bar) +按钮区布局:左下角的“刷新”和“连接设备”按钮需要靠左对齐,放入 QHBoxLayout 并在最右侧加一个 addStretch()。 + +“刷新”按钮(QPushButton): + +样式为浅色白底蓝字。background-color: #FFFFFF; border: 1px solid #CCDBF0; color: #0960D1; border-radius: 6px; padding: 8px 20px; font-weight: bold; + +“连接设备”按钮(QPushButton): + +样式为高亮绿底白字。background-color: #0F955D; border: none; color: #FFFFFF; border-radius: 6px; padding: 8px 24px; font-weight: bold; font-size: 14px; + +QPushButton:hover { background-color: #0D8250; }(添加平滑的悬停深色反馈)。 + +底层状态栏(Status Bar): + +左侧日志文本:“14:27:09-模型列表刷新成功”,字体 12px,颜色 #757575。 + +右侧状态:“● 未连接”,其中圆点和文字为红色(颜色 #FF2424),靠最右侧对齐。 + +💡 你接下来的操作: +你可以把上面这段长文本直接作为需求,连同你已有的 PySide 代码一起喂给你的 AI 工具(例如 Cursor),并附带这句话: + +给 AI 的指令: +“请阅读上面这份针对 image_fa96ff.png 设计图转译的 PySide 视觉规范。请在不修改我原有业务逻辑、信号与槽(Signal & Slot)连接、变量命名的情况下,重构我的 QWidget 布局布局参数(margins/spacing),并编写一套高质感、无‘AI水货味’的全局 QSS 样式表来洗稿我的界面。” + +补充tab栏 icon的位置 + +在 PySide 中,为顶部 Tab 栏(QTabBar / QTabWidget)添加图标并精细控制其样式和位置,主要通过 QIcon 传参和 QSS(Qt 样式表)的伪状态与子控件选择器来实现。 + +根据 image_fa96ff.png 的视觉调性,图标应该优雅地居中放置在文字的左侧,并保持适当的间距。以下是为转译文件补充的 Tab 栏 Icon 像素级技术规范: + +5. Tab 栏图标视觉与位置规范(Icon Placement) +5.1 代码级图标注入(PySide 代码) +在构建 QTabWidget 时,图标需要显式传入,并建议通过 QSize 强制限制其图标分辨率,避免大图导致的模糊或布局撑开: + +Python +from PySide6.QtGui import QIcon +from PySide6.QtCore import QSize + +# 设置图标尺寸(建议 16x16px 或 18x18px,配合 14px 字体最优雅) +tab_widget.setIconSize(QSize(18, 18)) + +# 注入对应的矢量或免版权高质感彩色/单色图标 +tab_widget.addTab(tab_connect, QIcon(":/icons/link.png"), "连接设置") +tab_widget.addTab(tab_control, QIcon(":/icons/settings.png"), "控制设置") +tab_widget.addTab(tab_debug, QIcon(":/icons/chart.png"), "模型调试") +5.2 QSS 像素级位置与间距控制 +为了完美契合 image_fa96ff.png 中“连接设置”等标签的精致感,必须消除 Qt 默认图标和文字粘连的问题,调整高亮状态: + +CSS +/* 1. 全局定义 Tab 标签的内边距,为左侧图标和文字留出呼吸感 */ +QTabBar::tab { + background: transparent; + padding: 12px 24px 12px 20px; /* 上、右、下、左 */ + font-size: 14px; + color: #64748B; + font-family: "Segoe UI", "Microsoft YaHei"; +} + +/* 2. 控制 Tab 内部 Icon 的位置与右侧文字的间距 */ +QTabBar::tab i { + /* 注:在标准 QSS 中,QTabBar 内部子控件选择器对 icon 的微调较为有限, + 标准做法是通过 padding-left 间接控制,或直接依赖 Qt 默认的左侧居中排列。 + 若需强制拉开图标与文字的距离,可以通过控制 subcontrol-position(如果适用) + 或者在 PySide 实例化时在文本前手动加一个半角空格(如 " 连接设置")作为最稳定的 Native 变通方案。*/ +} + +/* 3. 未选中状态下的图标样式(可选用半透明度让界面更有层次) */ +QTabBar::tab:!selected { + opacity: 0.7; /* 让未选中的图标和文字一起变淡 */ +} + +/* 4. 选中状态下的图标与整体样式 */ +QTabBar::tab:selected { + color: #0960D1; + font-weight: bold; + opacity: 1.0; /* 恢复完全不透明 */ + border-bottom: 3px solid #0960D1; /* 底部高亮蓝下划线 */ +} +💡 补充给 AI 的微调补丁: +当你把这个补充喂给 AI 时,可以加上这句大白话: + +“请确保在 PySide 代码中,QTabWidget 调用的 setIconSize 严格限制在 18x18 像素。如果发现图标和文字贴得太近,请直接在 addTab 传递字符串参数时,在文字前面手动补一个空格(例如:" 连接设置"),这是规避 QSS 无法完美控制 Qt 标签内 icon margin 缺陷的最优雅、最轻量化的工业级偏方。” + +重新回答上面的问题,补充tab栏 icon的位置 + +太棒了,你提供了具体的 SVG 图标资产文件名(image_049067.png),这让代码的衔接度可以直接拉满! + +现在我将重新整合并输出一份最完整的 PySide / QSS 转译描述文件。这次会把 image_049067.png 中具体的 link_icon.svg、control_icon.svg 和 debug_icon.svg 完美融合进顶部 Tab 栏的设计规范中,并精细定义其位置。 + +📋 ReinLoop V1.0 界面像素级视觉审计与重构规范(PySide / QSS 全套) +1. 全局色彩画布(Color Palette) +当前界面采用现代工业科技感的高级冷白调,对比度柔和,避免长时间操作视觉疲劳。 + +主背景色(App Background):#F8FAFC(柔和的微冷超浅灰蓝,作为整个窗口的基底色)。 + +卡片/区域背景(Card Background):#FFFFFF(纯白,用于 Modbus TCP 和 RTU 的分组容器)。 + +主品牌色/高亮蓝(Brand Accent):#0960D1(用于选中 Tab 的下划线、高亮图标以及分组左侧的小装饰块)。 + +按钮安全绿(Success Green):#0F955D(用于“连接设备”按钮)。 + +状态警示红(Error/Disconnect Red):#FF2424(用于右下角“未连接”状态点)。 + +文本颜色(Typography Colors): + +主标题/表单标签:#1A1A1A(高清晰度深灰)。 + +次级信息/状态栏/占位符:#757575(中灰)。 + +表单边框色(Border Color):#E2E8F0(极细的浅灰线)。 + +2. 顶部导航栏与带有图标的 Tab 栏(Top Header & Tab Bar) +整体高度与内边距:Header 整体高度约 70px,布局容器(如 QHBoxLayout)设置 setContentsMargins(24, 12, 24, 12)。 + +左侧 LOGO 与主标题: + +LOGO 使用 logo.svg,右侧有约 16px 的垂直分割线(可使用 QFrame,设置 width: 1px; background-color: #E2E8F0;)。 + +“ReinLoop V1.0” 字体:font-size: 22px; font-weight: bold; color: #1A1A1A;。 + +Tab 栏图标位置与间距控制(核心补充): + +图标位置:图标必须优雅地垂直居中,放置在标签文字的正左侧。 + +代码级注入:在 PySide 中限制图标的显示尺寸为 16x16px 或 18x18px,并在添加标签页时绑定 image_049067.png 中的对应文件: + +Python +tab_widget.setIconSize(QSize(18, 18)) +tab_widget.addTab(tab_connect, QIcon("link_icon.svg"), "连接设置") +tab_widget.addTab(tab_control, QIcon("control_icon.svg"), "控制设置") +tab_widget.addTab(tab_debug, QIcon("debug_icon.svg"), "模型调试") + * **QSS 样式与防贴边微调**: + ```css + QTabBar::tab { + background: transparent; + padding: 12px 24px 12px 16px; /* 上、右、下、左,为左侧图标留出呼吸感 */ + font-size: 14px; + color: #64748B; + } + QTabBar::tab:!selected { + opacity: 0.65; /* 未选中时,SVG 图标与文字统一半透明隐去 */ + } + QTabBar::tab:selected { + color: #0960D1; + font-weight: bold; + opacity: 1.0; + border-bottom: 3px solid #0960D1; /* 底部高亮蓝下划线 */ + } +3. 分组卡片容器(Group Cards - Modbus TCP & RTU) +基本样式: + +CSS +QFrame#GroupCard { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 12px; /* 优雅的大圆角,严禁直角 */ +} +* **阴影效果(QGraphicsDropShadowEffect)**: + 为这两个卡片容器绑定一个微弱的模糊阴影:`color = QColor(0, 0, 0, 12)`(透明度约 5%),`blurRadius = 16`,`offset = (0, 4)`。 +* **左侧装饰条标题**: + 每个卡片左上角的蓝色竖线,可以用一个宽 `4px`,高 `16px`,`background-color: #0960D1; border-radius: 2px;` 的小组件来实现。 + +--- + +## 4. 表单与输入框组件(Form Layout & QLineEdit) +* **标签(QLabel)**:字体大小 `14px`,颜色 `#333333`,加粗。 +* **输入框(QLineEdit)**: + * 高度固定:`min-height: 36px; max-height: 36px;`。 + * QSS 样式: + ```css + QLineEdit { + background-color: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 6px; + padding-left: 12px; + color: #333333; + font-size: 14px; + } + QLineEdit:focus { + border: 1px solid #0960D1; /* 聚焦时激活品牌蓝 */ + } + +运行 +5. 底部控制与状态栏(Bottom Action & Status Bar) +“刷新”按钮(QPushButton): +background-color: #FFFFFF; border: 1px solid #CCDBF0; color: #0960D1; border-radius: 6px; padding: 8px 20px; font-weight: bold; + +“连接设备”按钮(QPushButton): +background-color: #0F955D; border: none; color: #FFFFFF; border-radius: 6px; padding: 8px 24px; font-weight: bold; font-size: 14px; +QPushButton:hover { background-color: #0D8250; } + +底层状态栏(Status Bar): + +左侧日志文本:“14:27:09-模型列表刷新成功”,字体 12px,颜色 #757575。 + +右侧状态:“● 未连接”,其中圆点和文字高亮为红色(#FF2424)。 + +💡 怎么把它喂给代码 AI? +直接复制上面整篇规范丢给你的 Cursor 或其他编程 AI,并附上这一段提示: + +“请根据这份整合了 image_fa96ff.png 界面风格与 image_049067.png 图标资产的像素级视觉规范,为我的 PySide 代码重构布局和 QSS 样式表。 +特别注意: + +Tab 栏的图标文件请严格对应 link_icon.svg、control_icon.svg 和 debug_icon.svg,且图标尺寸限定为 18x18 像素。 + +为了防止 Qt 默认的图标与文字贴得太近,请在 addTab() 传入字符串时,主动在文本前补一个半角空格(如 " 连接设置"),确保图标与文字之间有优雅的间距。 + +不要删除或修改我原本的业务逻辑和信号槽绑定。” + +这是ai根据规范改的,下一步该如何告诉它继续修改这些不一样的地方 + +从 image_04fcab.png 的截图来看,AI 已经把基本的调性、圆角和背景框架搭起来了,整体质感提升了很多。但它在细节处理上依然犯了 AI 编程的通病——组件拼凑生硬、间距失控、输入框错位。 + +下一步我们应该启动 redesign-skill 的“精准找茬(Visual Defect Review)”机制,把大任务拆解为具体的局部对齐指令。 + +以下是为你整理好的第二轮重构 Prompt,你可以直接复制丢给 AI: + +📝 第二轮微调优化 Prompt +Markdown +# 任务:ReinLoop V1.0 界面细节打磨(第 2 轮) +你刚才调整的 PySide 基础框架和 QSS 样式已经初具质感,但在小细节上和我们要求的工业级品质还有较大差距(参考当前截图 `image_04fcab.png`)。请针对以下 5 个暴露出来的视觉缺陷,进行专项代码调整和 QSS 打磨。 + +## 🛠️ 当前需要修复的缺陷清单 + +1. 顶部 Tab 栏选中项下划线缺陷 + - 【现状】:选中“连接设置”时,蓝色下划线直接顶满了整个 Tab 标签的底部,显得非常笨重。 + - 【优化】:下划线应该是有内边距的。请通过调整 QSS 中 `QTabBar::tab:selected` 的 `margin-left` 和 `margin-right`(例如各设 `8px`),使蓝色下划线只缩在文字和图标的下方,不要顶满边缘。 + +2. 表单输入框(QLineEdit)宽度失控与错位 + - 【现状】:Modbus TCP 和 RTU 下的大部分输入框被无限制地拉得太长(特别是“波特率”和“校验位”),且右侧莫名其妙多出了空白边缘或嵌套边框。 + - 【优化】: + * 请为输入框统一设置最大宽度限制 `max-width: 450px;`(或将它们放入一个固定宽度的局部布局中),防止其随窗口放大而无限横向拉伸。 + * 检查布局嵌套,确保输入框右侧没有多余的隐藏空白小控件。 + * 【重要】“站号/数据位/停止位”这一行,输入框和标签贴得太死、大小不一,且没有和上方的输入框左对齐。请重新用 `QHBoxLayout` 梳理,设置合理的 `spacing(10)`,并确保“站号”输入框的起始位置与上方的输入框完全左对齐。 + +3. 底部“连接设备”按钮文字被遮挡 + - 【现状】:“连接设备”按钮由于内边距或高度没有自适应,导致文字上半部分被截断,只露出了下半部分,且“刷新”按钮和它大小不统一。 + - 【优化】:请为底部的两个按钮统一设定固定高度:`min-height: 32px; max-height: 32px;`,并确保 QSS 中没有写死导致文字移位的 `padding-top`。同时把按钮里的文本垂直对齐方式设置为居中。 + +4. 底部状态栏(QStatusBar)元素堆叠错位 + - 【现状】:底部的日志文本、绿色的麦克风图标(或圆形按钮)以及“● 未连接”状态在垂直方向上没有对齐,高矮不一,且绿色圆圈图标压到了最底部的灰色线条。 + - 【优化】:将底部的状态栏改用一个干净的 `QHBoxLayout` 进行托管,设置 `setAlignment(Qt.AlignVCenter)` 确保所有文本和图标在【垂直方向上绝对居中对齐】。 + +5. 消除多余的边框嵌套 + - 【现状】:在“读取压力存储器地址”输入框被选中时,由于你叠加了 focus 样式,导致原本外层包裹的未知容器也露出了隐约的蓝色双重边框。 + - 【优化】:检查底层布局结构,确保 `QLineEdit` 外层没有包裹多余的带边框 `QFrame`。如果包裹了,请设置外层容器 QSS:`border: none; background: transparent;`。 + +--- + +## 💻 请开始重构 +请根据上述 5 点,精准修改相关的 `QWidget` 布局参数和 QSS 样式表。必须保证不修改任何信号与槽、变量名称等业务逻辑,并输出【完整修改后】的代码。 + +运行 +💡 为什么这么提问? +AI 对于“输入框错位”、“文字截断”这种空间几何问题是非常迟钝的。这个 Prompt 强制它从 “最大宽度(max-width)”、“垂直居中(AlignVCenter)” 以及 “边框重叠” 等具体的 Qt 开发术语去思考,它就能迅速定位到是自己把 Layout 嵌套错了,改出来的效果就会非常精准。 + +前两张图片是ai根据规范改后的界面,第三张图片是设计图,下一步该如何告诉它继续修改和设计图不一样的地方 + +1. 导航栏的logo下面被遮挡了 + +2. tab栏和logo行有灰色分割线,并且tab栏是灰色背景 + +3. tab栏的icon和标签文字应在选中后才变为蓝色 + +4. icon缺失 + +从你提供的新一轮运行截图来看,AI 已经对输入框宽度和按钮截断做出了很好的修正,但顶部的 Header 和 Tab 栏完全变形了。特别是它错误地把整个 Tab 栏上移到了 LOGO 那一行,而且把 QTabBar 的原生样式给彻底搞崩了。 + +为了像素级逼近第三张设计图(image_05629d.png),这次我们需要强制它做“结构性大手术”。以下是为你整理好的第三轮精确调教 Prompt: + +📝 第三轮结构修正 Prompt +Markdown +# 任务:ReinLoop V1.0 顶部导航与 Tab 栏结构性修复(第 3 轮) +在上一轮修改后,输入框和按钮的尺寸已经好多了。但目前的顶部导航和 Tab 栏结构严重错位(参考 `image_0561c8.png`)。它与我们的设计图(`image_05629d.png`)存在巨大的方向性偏差。 + +请严格按照以下 5 点,对顶部的布局结构和 QSS 进行彻底修复: + +## 🛠️ 必须修复的视觉缺陷清单 + +1. 顶部布局结构大重构(修复 LOGO 遮挡与 Tab 移位) + - 【现状】:你错误地把 Tab 栏和 LOGO 塞进了同一行,导致整体高度严重不足,LOGO 下半部分被截断挤压。 + - 【设计图要求】:整个顶部应该是【上下两层】的垂直布局(QVBoxLayout)。 + * 上层(Layer 1):整行只有左侧的 LOGO、“ReinLoop V1.0” 标题,以及最右侧的设置图标按钮。这一行背景为纯白,不含任何 Tab 标签。 + * 下层(Layer 2):独立的一行,用来放置完整的 Tab 栏。 + - 【代码修复】:请将顶部整体调整为一个 `QVBoxLayout`,将 LOGO 整行(QHBoxLayout)作为第一个子布局,将 Tab 栏(QTabWidget/QTabBar)作为第二个子布局。 + +2. 补齐两行之间的灰色分割线与 Tab 背景色 + - 【设计图要求】:LOGO 行与 Tab 栏行之间、以及 Tab 栏行与下方主体卡片之间,都有通栏的极细灰色分割线。并且,【整个 Tab 栏这一行】的背景色应该是一体化的浅灰色。 + - 【QSS 修复】: + * 为包含 Tab 栏的这一层容器(或者 QTabWidget 的 TabBar 区域)设置背景色为 `#F8FAFC`(或与大背景融为一体的微冷浅灰)。 + * 通过 QSS 或在布局间添加 `QFrame` 分割线,确保 LOGO 行下方有一条颜色为 `#E2E8F0` 的 `1px` 水平线。 + +3. 修复 Tab 栏选中状态的颜色逻辑 + - 【现状】:目前所有的 Tab 文字和下划线都是死板的蓝色。 + - 【设计图要求】: + * 【未选中状态】:文字和图标必须是【中灰色】(颜色为 `#64748B`),且【没有】蓝色底部下划线。 + * 【选中状态】:只有当前被激活的 Tab,其文字、图标以及底部下划线才会一起变成【品牌蓝】(`#0960D1`)。 + - 【QSS 修复】:请严格分离 `QTabBar::tab`(未选中默认样式)与 `QTabBar::tab:selected`(选中高亮样式)的颜色控制。 + +4. 彻底找回丢失的 SVG 图标(Icon) + - 【现状】:目前的界面上图标完全消失了。 + - 【修复】: + * 确保在 PySide 代码中,使用 `QSize(18, 18)` 显式为 Tab 栏注入对应的图标资产:`link_icon.svg`(连接设置)、`control_icon.svg`(控制设置)、`debug_icon.svg`(模型调试)。 + * 为了让图标在未选中时呈现灰色、选中时呈现蓝色,请检查你使用的 SVG 源码或直接在 `QTabBar::tab:!selected` 中加入 `opacity: 0.6;` 降低未选中图标的明度,使其趋近于灰色。 + +5. 移除输入框在未激活时的过粗蓝色边框 + - 【现状】:目前即使没有点击输入框,部分输入框(如 PLC 地址)也自带了显眼的蓝色边框。 + - 【修复】:请确保 `QLineEdit` 的默认状态边框为极细的淡灰色(`1px solid #E2E8F0`),【只有】在聚焦状态下(`QLineEdit:focus`)才允许变蓝。 + +--- + +## 💻 交付要求 +请在完全不破坏后端逻辑、信号槽和既有变量名(如各输入框的 ObjectName)的前提下,重新梳理顶部布局结构并重写 QSS 样式表。请输出【完整重构后】的代码。 + +运行 +💡 为什么这样能纠正它? +AI 在写 Qt 布局时,很容易因为贪图省事把多个小容器直接 addWidget 到同一个行布局里,从而导致大面积挤压变形。 +这个 Prompt 的核心是帮它理清“两层空间结构”(上层 LOGO,下层 Tab 栏),并利用 QTabBar::tab:!selected 和 QTabBar::tab:selected 这对绝对对立的伪状态强迫它交替颜色。这样改完,顶部的精细度就会瞬间恢复。 + +tab栏选中状态不仅文字要变色,icon也要变色的, + +收到!这是一个非常关键的细节。在 PySide (Qt) 中,如果要实现 “选中 Tab 时,不仅文字变蓝,Icon 图标也同步由灰变蓝”,普通的 QSS 对 QTabBar::tab:selected 的 color 设置只能影响文字,无法直接改变图片文件的颜色。 + +要完美达到设计图(image_05629d.png)的效果,有以下两种工业级的标准解决方案。你可以根据你目前图标资产的形式(是纯 SVG 源码字符串,还是两个独立的图片文件),让 AI 选择其中一种去实现: + +💡 方案一:为 AI 提供“双态图标”机制(最稳妥、最容易看懂) +既然选中和未选中状态颜色不同,最标准的做法是准备两套图标(或者让 AI 用代码逻辑在切换 Tab 时动态更换图标)。 + +你可以让 AI 编写一个信号槽监听函数,当 Tab 切换时,动态更换图标: + +给 AI 的专项补充提示词(方案一): + +Markdown +# 6. 联动修改:Tab 选中时 Icon 变色逻辑 +在 QSS 中,`border-bottom` 和 `color` 只能改变下划线和文字,无法直接修改 `QIcon` 图片的颜色。为了让图标(Icon)在选中时也变成蓝色,请在 PySide 代码中加入 `currentChanged` 信号槽监听: + +1. 准备两套状态(如果只有一套,请让 AI 用逻辑或准备蓝色的 `link_icon_active.svg` 等)。 +2. 在初始化时,编写一个动态更新图标的函数: +```python +def update_tab_icons(index): + # 默认全设为灰色图标 + tab_widget.setTabIcon(0, QIcon("link_icon.svg")) + tab_widget.setTabIcon(1, QIcon("control_icon.svg")) + tab_widget.setTabIcon(2, QIcon("debug_icon.svg")) + + # 将当前选中的项换成蓝色高亮图标(或者通过代码动态染色) + if index == 0: + tab_widget.setTabIcon(0, QIcon("link_icon_blue.svg")) # 或者是高亮资产 + elif index == 1: + tab_widget.setTabIcon(1, QIcon("control_icon_blue.svg")) + elif index == 2: + tab_widget.setTabIcon(2, QIcon("debug_icon_blue.svg")) + +# 绑定信号 +tab_widget.currentChanged.connect(update_tab_icons) +# 首次运行初始化 +update_tab_icons(0) +💡 方案二:利用 QSS 伪状态 + 属性改变(全 QSS 纯净流) +如果你不想在 Python 代码里写一堆 if-else 来换图片,Qt 还允许我们在 QSS 中利用 qproperty-icon 属性直接在“选中伪状态”下强行替换图标路径。 + +给 AI 的专项补充提示词(方案二): + +CSS +/* 默认状态(未选中):使用原图,并降低透明度使其呈灰色调 */ +QTabBar::tab { + background: transparent; + color: #64748B; + padding: 12px 24px; +} + +/* 核心技巧:利用 Qt 样式表属性在选中状态下直接覆盖图标文件 */ +/* 假设你让 AI 或者是你自己生成了对应的蓝色版本图标,直接在 QSS 里换掉它 */ +QTabBar::tab:selected { + color: #0960D1; + font-weight: bold; + border-bottom: 3px solid #0960D1; +} + +/* 如果你的 AI 能够将 SVG 渲染为 QIcon,可以在具体的 tab 识别器或伪状态中强制指定属性 */ +/* 注:原生 QTabBar 对单个 tab 的 qproperty-icon 支持有限, + 如果直接支持,可让 AI 尝试以下 QSS 扩展(需要指定 objectName 或位置):*/ +QTabBar::tab:selected[text="连接设置"] { + /* 如果有对应的蓝色SVG,可以直接在这里切换 */ + qproperty-icon: url(link_icon_blue.svg); +} +📋 针对你的这个需求,你可以直接对 AI 这样说: +“补充一条刚需:当 Tab 栏切换时,选中的 Tab 连同文字和 Icon 图标必须同时变蓝;未选中的则图标和文字同时变灰。 + +请你在 Python 代码中为 QTabWidget (或 QTabBar) 绑定 currentChanged 信号。当用户点击不同的 Tab 时,动态地为当前选中的 Tab 切换成蓝色的高亮版 SVG 图标(例如 link_icon_blue.svg),而让未选中的 Tab 保持默认的灰色线条图标。请把这个变色逻辑完整实现在重构后的代码中。” + +为什么tab栏还是不能点击从灰色变深色 + +你的 Tab 栏之所以点击后文字和图标依然“死”在灰色、没有任何变化,是因为 AI 在编写 main_window.py 时犯了两个 Qt 机制上的常识性错误: + +核心原因分析 +图标(Icon)永远是灰色的原因: +代码里 AI 写的是: + +Python +link_icon.addFile(_link_gray, QSize(18, 18), QIcon.Normal, QIcon.Off) +link_icon.addFile(_link_blue, QSize(18, 18), QIcon.Selected, QIcon.On) +这是完全错误的。 在 Qt 中,QIcon.Selected 状态只对 QListView 或 QTreeView 这种列表视图的选中项有效。QTabBar 在绘制选中的标签时,永远只会去读取 QIcon.Normal(或 QIcon.Active)状态的图片。因此,不论你点哪个 Tab,它渲染出来的永远是第一行指定的灰色图标。 + +文字依然是灰色的原因: +因为你的 Tab 栏设置了动态自定义属性:self.tab_bar.setProperty("cssClass", "mainTab")。AI 在后台写的 QSS 样式表可能只简单写了 QTabBar::tab:selected,导致样式选择器的优先级(权重)不够,被默认的置灰样式覆盖了;或者是因为点击切换时,QTabBar 没能自动触发样式的重绘刷新(Polish)。 + +💡 怎么告诉 AI 一步到位修复? +不用在 QSS 里面去猜复杂的伪状态优先级了,既然我们在 main_window.py 里已经有了统一的切换函数 _switch_tab(self, index),最工业级、100% 稳定的解法就是在切换事件里用代码动态更新图标和样式。 + +你可以把下面这段话原封不动复制给 AI: + +Markdown +# 专项修复:解决 Tab 栏点击不亮、文字和图标始终为灰色的 Bug + +目前点击 Tab 栏时,文字和图标完全没有变成蓝色,依然保持灰色。 +这是因为: +1. `QTabBar` 不支持 `QIcon.Selected` 状态,它始终在使用 `QIcon.Normal` 的灰色图标。 +2. QSS 样式在 `QTabBar` 独立使用时,`:selected` 伪状态由于属性权重问题可能失效了。 + +请你针对 `main_window.py` 进行如下修改,使用显式代码逻辑实现 100% 稳定的高亮切换: + +## 1. 简化初始化图标代码 +请将 `_setup_ui` 中原本复杂的 `QIcon.addFile` 逻辑删掉,直接将灰色和蓝色的文件路径保存为类属性,方便动态调用。例如: +```python +# 在 _setup_ui 中定义好 6 个资产路径 +self._icons_gray = [ + os.path.normpath(os.path.join(_src, "link_icon_gray.svg")), + os.path.normpath(os.path.join(_src, "control_icon_gray.svg")), + os.path.normpath(os.path.join(_src, "debug_icon_gray.svg")) +] +self._icons_blue = [ + os.path.normpath(os.path.join(_src, "link_icon.svg")), + os.path.normpath(os.path.join(_src, "control_icon.svg")), + os.path.normpath(os.path.join(_src, "debug_icon.svg")) +] + +# 初始时先填入图标(默认全灰) +self.tab_bar.addTab(QIcon(self._icons_gray[0]), " 连接设置") +self.tab_bar.addTab(QIcon(self._icons_gray[1]), " 控制设置") +self.tab_bar.addTab(QIcon(self._icons_gray[2]), " 模型调试") + + +2. 升级 _switch_tab 切换函数 +请将 _switch_tab 函数重写,当 Tab 切换时,动态把当前选中的项换成蓝色图标,其余换回灰色图标,并强制触发 style().polish() 刷新 QSS 样式: + +def _switch_tab(self, index: int): + """切换标签页,并动态刷新图标与 QSS 状态""" + self.stack.setCurrentIndex(index) + + # 阻断信号,防止改变时死循环 + self.tab_bar.blockSignals(True) + self.tab_bar.setCurrentIndex(index) + self.tab_bar.blockSignals(False) + + # 动态轮询,更新图标 + for i in range(self.tab_bar.count()): + if i == index: + # 选中的 tab 换成蓝色高亮图标 + self.tab_bar.setTabIcon(i, QIcon(self._icons_blue[i])) + else: + # 未选中的 tab 换成灰色图标 + self.tab_bar.setTabIcon(i, QIcon(self._icons_gray[i])) + + # 强制让控件重新polish样式,确保 QSS 中的 :selected 样式立即生效 + self.tab_bar.style().unpolish(self.tab_bar) + self.tab_bar.style().polish(self.tab_bar) + +3. 检查你的全局 QSS 样式表 +请确保你写给 mainTab 的 QSS 选择器严格区分了状态,并具有足够的权重(带上属性名): + +CSS +/* 默认未选中状态:文字灰色 */ +QTabBar[cssClass="mainTab"]::tab { + background: transparent; + color: #64748B; + font-weight: normal; + border-bottom: 3px solid transparent; +} + +/* 选中状态:文字变成品牌蓝,并展示底部深蓝/高亮蓝下划线 */ +QTabBar[cssClass="mainTab"]::tab:selected { + color: #0960D1; + font-weight: bold; + border-bottom: 3px solid #0960D1; +} +请在不破坏我原本任何业务逻辑和变量名的前提下,完成对 main_window.py 这一部分的重构。 + + + +# 任务:修复底部“刷新”与“连接设备”按钮的视觉错位与多余嵌套 + +目前界面的底部按钮(参考 `image_164e00.png`)存在严重的视觉 Bug: +1. 两个按钮被一个错误带蓝色激活边框的容器包裹了,属于多余的视觉嵌套。 +2. 按钮的高度不足,导致“连接设备”的文字上下被无情截断。 +3. 两个按钮的布局没有和上方的卡片对齐。 + +请针对 `connection_tab.py` 和你的全局 QSS 样式表进行以下精准重构: + +## 🛠️ 核心修改要求 + +1. 剥离按钮外层多余的 QFrame + - 【现状】:你可能在按钮外层包裹了一个设置了 `cssClass` 或者是默认带有输入框聚焦样式的 `QFrame`。 + - 【修改】:请在 `connection_tab.py` 的底部布局中,【删掉】这个包裹按钮的容器。让这两个按钮直接并排暴露在主布局的最下方,它们的背景应该是透明或跟随大背景的,严禁有外层边框。 + +2. 统一按钮的固定高度与内边距(彻底修复文字截断) + - 【修改】:请在代码或 QSS 中,为这两个按钮统一指定高度和内边距,确保文字上下绝对垂直居中。 + ```css + /* 刷新按钮样式 */ + QPushButton#refresh_btn { + background-color: #FFFFFF; + border: 1px solid #CCDBF0; + color: #0960D1; + border-radius: 6px; + min-height: 34px; + max-height: 34px; + padding: 0px 20px; + font-weight: bold; + font-size: 14px; + } + QPushButton#refresh_btn:hover { + background-color: #F0F4FA; + } + + /* 连接设备按钮样式 */ + QPushButton#connect_btn { + background-color: #0F955D; + border: none; + color: #FFFFFF; + border-radius: 6px; + min-height: 34px; + max-height: 34px; + padding: 0px 24px; + font-weight: bold; + font-size: 14px; + } + QPushButton#connect_btn:hover { + background-color: #0D8250; + } + + + +# 任务:根据设计图重构“模型调试(DebugTab)”页面组件与布局 +参考connection_tab的页面设计,请对 `DebugTab` 页面(即第三个标签页的内容区)进行完整的 UI 梳理与 QSS 样式编写。保持一贯的工业科技感、12px卡片圆角和浅灰色柔和基调。 + +页面主体由【上下两个 Section 卡片】组成,均采用带有蓝色左侧竖线装饰的纯白卡片样式: +- **卡片 1(上)**:系统辨识(包含多行密集表单项与控制按钮) +- **卡片 2(下)**:高级设置(包含两行双列输入框) + +### 1. 卡片一:系统辨识(System Identification Card) +* **标题**:`蓝竖线装饰条` + `系统辨识` 文本。 +* **布局逻辑**:建议使用 `QGridLayout` 处理前 4 行的参数项。最后一行“序列”独立作为一个布局或整合进网格中。 +* **表单项明细**: + * **第 1 行**: + * 左列:标签“压力上限:”,右接带单位内嵌文本的输入框,默认值 `200`,右侧灰色内嵌文字 `kPa`。 + * 右列:标签“过程升温:”,右接带单位内嵌文本的输入框,默认值 `30`,右侧灰色内嵌文字 `°C`。 + * **第 2 行**: + * 左列:标签“约束上界”,右接标准输入框,默认值 `200`。 + * 右列:标签“下界:”,右接标准输入框,默认值 `50`。 + * **第 3 行**: + * 左列:标签“容积”,右接带单位内嵌文本的输入框,空初始值,右侧灰色内嵌文字 `L`。 + * 右列:放置一个**“测试”按钮**,样式为:`background-color: #0960D1;` (品牌蓝),白色文字。 + * **第 4 行**: + * 左列:标签“周期:”,右接带单位内嵌文本的输入框,默认值 `2.5`,右侧灰色内嵌文字 `s`。 + * 右列:标签“阶数:”,右接标准输入框,默认值 `6`。 + * **第 5 行(通栏/长输入框行)**: + * 标签“序列:”,右接一个超长输入框,带浅灰色占位符文本(Placeholder): `如10,20,30,40,50,60,70,80`。 + * **核心动作按钮**:在这行输入框的右侧,放置一个 **“开始辨识” 按钮**。样式为:`background-color: #0960D1;` (品牌蓝),白色文字,左侧带一个白色的播放 ▶ 图标。 + +--- + +### 2. 卡片二:高级设置(Advanced Settings Card) +* **标题**:`蓝竖线装饰条` + `高级设置` 文本。 +* **布局逻辑**:双列并排,共 2 行。 +* **表单项明细**: + * **第 1 行**: + * 左列:标签“死区:”,右接输入框(带浅灰占位符 `默认2...`)。 + * 右列:标签“单步限幅:”,右接标准空输入框。 + * **第 2 行**: + * 左列:标签“总限幅:”,右接标准空输入框。 + * 右列:标签“最大脉冲数:”,右接标准输入框,默认值 `85000`。 + + + +系统状态栏 +🧱 布局结构(Layout) +容器样式:横向并排的三个独立卡片(QFrame),嵌入在 QHBoxLayout 布局中。 + +卡片基础特质:纯白背景(#FFFFFF),带有 12px 的柔和圆角(border-radius: 12px;),四周有极浅的微弱模糊阴影(QGraphicsDropShadowEffect)以提供悬浮层级感。 + +内部对齐:每个卡片内部采用横向布局,左侧为圆形背景图标,右侧为上下堆叠的指标文本与数值。 + +🎨 三栏卡片像素级规范 +1. 左侧卡片:当前系统压力(安全绿主题) +视觉定位:代表实时反馈的监控状态。 + +左侧图标:一个正圆形、极浅绿色背景(#E2F5ED)的容器,中心高亮展示绿色仪表盘线条图标图标均在src中,请选择对应的svg文件(#0F955D)。 + +右侧内容: + +上方为小字标签:“当前系统压力”,颜色为中灰(#555555)。 + +下方为巨大的实时数值:0.0,字体加粗(font-weight: bold; font-size: 32px;),颜色为安全绿(#0F955D),右侧紧跟同色小字单位 kPa。 + +右上角点缀:卡片右上角带有一个小的安全绿静态状态圆点。 + +2. 中间卡片:设置目标压力(品牌蓝主题) +视觉定位:代表用户输入的控制靶点。 + +左侧图标:一个正圆形、极浅蓝色背景(#EBF3FE)的容器,中心高亮展示蓝色准心/目标线条图标(#0960D1)。 + +右侧内容: + +上方为小字标签:“设置目标压力”,颜色为中灰(#555555)。 + +下方为巨大的设定数值:0.0,字体加粗,颜色为品牌蓝(#0960D1),右侧紧跟同色小字单位 kPa。 + +右上角点缀:卡片右上角带有一个小的品牌蓝静态状态圆点。 + +3. 右侧卡片:控制阀门开度(警示橙主题) +视觉定位:代表执行机构的输出状态。 + +左侧图标:一个正圆形、极浅橙色背景(#FFF2E8)的容器,中心高亮展示橙色波纹/阀门象征线条图标(#E67E22)。 + +右侧内容: + +上方为小字标签:“控制阀门开度”,颜色为中灰(#555555)。 + +下方为巨大的输出数值:0.0,字体加粗,颜色为执行橙(#E67E22),右侧紧跟同色小字单位 %。 + +右上角点缀:卡片右上角带有一个小的执行橙静态状态圆点。 \ No newline at end of file diff --git a/ReinLoop/environment.yml b/ReinLoop/environment.yml new file mode 100644 index 0000000..866f971 --- /dev/null +++ b/ReinLoop/environment.yml @@ -0,0 +1,9 @@ +name: RL +channels: + - conda-forge + - defaults +dependencies: + - python=3.10 # 建议固定版本 + - pip + - pip: + - -r requirements.txt # 自动引用刚才生成的文件 \ No newline at end of file diff --git a/ReinLoop/features.md b/ReinLoop/features.md new file mode 100644 index 0000000..650906d --- /dev/null +++ b/ReinLoop/features.md @@ -0,0 +1,157 @@ +# ReinLoop 功能与接口 + +本文档描述 `ReinLoop/` Python 客户端当前提供的运行时功能与接口。 + +## 约定 + +- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用 + `REINLOOP_SERVER_URL + /api`。 +- 新许可证的设备标识为 `/`,在代码中通过 + `api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`。 +- 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。 +- 公司、产线、许可证签发/撤销、模型删除、审核反馈及配置提交均为 + ControlPanel 管理端能力,客户端不提供对应的管理接口。旧微信云函数和其管理脚本 + 已移除。 + +## 应用与设备连接 + +| 功能 | 接口 | 返回或行为 | +| --- | --- | --- | +| 启动桌面程序 | `main.main()` | 初始化 PySide6 主窗口、全局日志及异常处理。 | +| 连接 MT2-AM8 | `ConnectionManager.connect(tcp_ip, tcp_port, pressure_addr, motor_addr, flowmeter_addr, pressure_range=400, flow_range=100)` | 返回 `bool`。建立 Modbus TCP 连接并保存模拟量通道配置。 | +| 断开设备 | `ConnectionManager.disconnect()` | 关闭连接并通知状态回调。 | +| 查询连接状态 | `ConnectionManager.is_connected()` | 返回 `bool`。 | +| 读取压力 | `ConnectionManager.read_pressure()` | 返回压力值 `kPa`,失败时为 `None`。 | +| 读取流量 | `ConnectionManager.read_flow()` | 返回流量 `L/min`;未配置流量计或失败时为 `None`。 | +| 设置电机位置 | `ConnectionManager.set_motor_position(xa)` | 将目标行程写入模拟量输出,返回 `bool`。 | +| 日志和连接回调 | `set_log_callback(callback)`、`set_status_callback(callback)` | 回调签名分别为 `callback(message)`、`callback(connected, status_text)`。 | + +主运行路径使用 `ConnectionManager`。底层调试或独立脚本还可使用 +`PcControl.py` 中的 `MT2AM8Client`、`Easy521ModbusClient`、 +`MotorModbusRTUClient` 和 `PressureModbusRTUClient`。 + +## 压力控制 + +| 功能 | 接口 | 返回或行为 | +| --- | --- | --- | +| 创建控制器 | `ControlEngine(pid)` | `pid` 为 `IncrementalPID` 实例。 | +| 注入依赖 | `set_connection_manager(mgr)`、`set_model_manager(mgr)`、`set_data_collector(collector)` | 配置设备、RL 模型和数据采集服务。 | +| 启动控制 | `ControlEngine.start()` | 要求设备已连接;RL 模式还要求模型已加载。 | +| 执行一个周期 | `ControlEngine.control_tick()` | 读取压力、执行 PID/RL/手动控制、写入电机并更新显示。由 GUI 的 QTimer 调用。 | +| 停止控制 | `ControlEngine.stop()` | 停止循环,并触发 `DataCollector.finalize_and_upload()`。 | +| 查询运行状态 | `ControlEngine.is_running` | 只读属性,返回 `bool`。 | +| 切换模式 | `engine.mode = "PID" / "RL" / "MANUAL"` | PID 闭环、RL 调参增强闭环或直接设置阀门开度。 | +| 更新 PID 参数 | `IncrementalPID.update_parameters(kp, ki, kd)` | 重算增量 PID 系数。 | +| 执行 PID 单步 | `IncrementalPID.update_pressure_values(current, target)`、`update(du_max=None)` | `update()` 返回受限后的阀门开度百分比。 | +| 重置 PID 状态 | `IncrementalPID.reset()` | 清除误差历史与输出状态。 | +| 设置单步限幅 | `IncrementalPID.set_du_max(value)` | 设置 PID 输出增量上限。 | + +`ControlEngine` 的常用配置字段包括 `target_pressure`、`flow`、`volume`、 +`manual_valve`、`collect_data`、`dz`、`motor_max`、`xa_full` 和 +`pressure_alpha`。 + +## RL 模型管理 + +| 功能 | 接口 | 服务端请求 | +| --- | --- | --- | +| 刷新模型列表 | `ModelManager.scan_models()` | `listModels`,目录为 `/model_config`。异步执行。 | +| 加载 SAC 模型 | `ModelManager.load_model(model_name)` | `downloadModel` 获取临时 URL,再下载并以 `SAC.load()` 加载。 | +| 检查模型状态 | `ModelManager.is_model_loaded()` | 返回 `bool`。 | +| 回调注册 | `set_models_loaded_callback(callback)`、`set_load_complete_callback(callback)` | 回调签名分别为 `callback(file_names)`、`callback(success, message)`。 | + +RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力误差预测 `Kp/Ki`,随后继续使用 PID 计算阀门开度。 + +## 控制过程数据采集与上传 + +| 功能 | 接口 | 返回或行为 | +| --- | --- | --- | +| 初始化一轮采集 | `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)` | 内部接口;先请求上传凭证,再将对象直传。 | + +上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。 + +## 系统辨识 + +| 功能 | 接口 | 返回或行为 | +| --- | --- | --- | +| 下载辨识配置 | `download_identification_config(timeout=20)` | 下载并返回已校验的九参数配置字典。 | +| 校验配置对象 | `validate_identification_config(config)` | 规范化后返回字典,非法参数抛出 `ValueError`。 | +| 解析配置 CSV | `parse_identification_config_csv(csv_text)` | 解析 `parameter,value` 两列 CSV 并完成校验。 | +| 启动辨识 | `IdentificationManager.start_identification(conn_mgr=..., running_flag_check=..., q_in_val=..., dt=..., n_order=..., t_c=..., levels=..., dead_area=..., xa_full=..., V_val=..., repeat=2)` | 返回 `bool`;后台依次执行行程预扫描、PRBS 采集并上传 CSV。 | +| 停止辨识 | `IdentificationManager.stop()` | 请求正在运行的辨识/容积任务停止。 | +| 查询任务状态 | `IdentificationManager.is_running` | 返回 `bool`。 | +| 生成 PRBS | `generate_prbs(n_order=7, low_val=40, high_val=60, samples_per_bit=20, levels=None)` | 返回 NumPy 激励序列。 | +| 生成复合激励 | `generate_composite_sequence(dt, n_order, t_c, levels)` | 返回闭阀、全开和多电平 PRBS 组合序列。 | +| 执行 PRBS 采集 | `collect_data_with_prbs(conn_mgr, q_in_val, dt=0.05, n_order=7, t_c=1.0, levels=None, dead_area=240, xa_full=1062.5, V_val=None, should_stop=None, log=print, on_sample=None, repeat=2)` | 返回含 `t`、`u`、`p`、`csv_data`、`filename`、`samples` 和 `success` 的字典。 | + +辨识流程会先按 `1000` 到 `0` 的行程档位扫描稳定压力,将结果上传到 +`/ind_data`;随后上传 PRBS CSV 到同一目录。 + +### 辨识审核反馈 + +| 功能 | 接口 | 服务端请求 | 返回 | +| --- | --- | --- | --- | +| 登记结果 | `register_identification_result(run_id, timeout=10)` | `registerIdentificationResult` | 成功时返回 `None`。 | +| 查询审核 | `get_identification_feedback(run_id, timeout=10)` | `getIdentificationFeedback` | 未就绪返回 `None`;就绪返回 `0` 或 `1`。 | +| 确认清理 | `acknowledge_identification_feedback(run_id, timeout=10)` | `ackIdentificationFeedback` | 成功时返回 `None`。 | + +## 容积测量 + +| 功能 | 接口 | 返回或行为 | +| --- | --- | --- | +| 读取本地配置 | `load_volume_config(path=None)` | 读取 JSON 并返回八参数配置。 | +| 校验配置 | `validate_volume_config(config)` | 返回规范化配置;错误时抛出 `ValueError`。 | +| 执行单次测量 | `measure_volume(conn_mgr, q_in_slm=50.0, dt=0.1, xa=1000, p_max=200.0, fit_low=50.0, fit_high=150.0, T_delta=30.0, should_stop=None, log=print, on_sample=None)` | 返回拟合斜率、截距、`c1`、`volume_L`、原始压力曲线和 `success`。 | +| 启动多次测量 | `IdentificationManager.start_volume_measurement(conn_mgr=..., running_flag_check=..., q_in_val=..., dt=..., p_max=..., fit_low=..., fit_high=..., T_delta=..., xa_full=1000, num_runs=3)` | 返回 `bool`;后台多次测量、计算平均值并上传 JSON。 | +| 创建配置请求 | `create_volume_config_request(timeout=10)` | 返回 `{"request_id", "expires_at_ms"}`。 | +| 查询配置请求 | `poll_volume_config_request(request_id, timeout=10)` | 返回 `{"ready", "expired"}`;就绪时额外包含 `config`。 | +| 确认配置请求 | `acknowledge_volume_config_request(request_id, timeout=10)` | 成功时返回 `None`。 | + +容积测量汇总结果上传到 `/V_config`。容积参数请求与确认是客户端和 ControlPanel 的一次性协作流程。 + +## 许可证与设备标识 + +| 功能 | 接口 | 返回或行为 | +| --- | --- | --- | +| 本地验签 | `verify_license(lic_path=None)` | 验证 RSA-PSS/SHA-256 签名、载荷和有效期,返回许可证载荷。 | +| 启动许可证检查 | `check_license(lic_path=None)` | 本地校验、在线校验并启动唯一的后台巡检线程;失败时退出程序。 | +| 获取已验证载荷 | `get_verified_license()` | 返回缓存载荷副本,未验证时返回 `None`。 | +| 在线校验 | `validate_license_online(payload)` | 调用 `validateLicense`;明确无效时抛出 `ExpiredError`。 | +| 启动后台巡检 | `start_license_watchdog(interval_minutes=5)` | 幂等启动,最短巡检间隔为 5 分钟。 | +| 注册生命周期回调 | `set_on_expired(callback)`、`set_on_grace(callback)`、`set_on_log(callback)` | 接收失效信息、宽限期小时数或许可证日志。 | + +新许可证必须包含 `license_id`、`company_id`、`production_line_id`、 +`device_id`。`device_id` 必须是两个安全路径段组成的 +`/`。旧许可证仍可本地验签,但不支持在线撤销。 + +网络故障不会立即中断控制;离线时限由 `REINLOOP_LICENSE_OFFLINE_HOURS` 配置,默认 72 小时。 + +## 客户端服务端协议 + +所有业务请求都发送至 `POST /api`。业务成功响应应至少包含 `success: true`。 + +| `type` | 请求关键字段 | 用途 | +| --- | --- | --- | +| `validateLicense` | `licenseId`, `deviceId` | 校验许可证是否为 `active` 状态。 | +| `listModels` | `folder` | 列出 `/model_config` 中的模型。 | +| `downloadModel` | `fileID` | 获取模型临时下载 URL。 | +| `uploadDataFile` | `fileName`, `folder` | 获取对象存储直传凭证。 | +| `getIdentificationConfig` | `deviceId` | 获取九项辨识参数 CSV 的下载 URL。 | +| `registerIdentificationResult` | `deviceId`, `runId`, `fileName` | 登记待审核的辨识 CSV。 | +| `getIdentificationFeedback` | `deviceId`, `runId` | 查询辨识审核结果。 | +| `ackIdentificationFeedback` | `deviceId`, `runId` | 确认并清理已消费的审核结果。 | +| `createVolumeConfigRequest` | `deviceId` | 创建一次性容积配置请求。 | +| `getVolumeConfigRequest` | `deviceId`, `requestId` | 查询请求状态;就绪时取得配置下载 URL。 | +| `ackVolumeConfigRequest` | `deviceId`, `requestId` | 确认或清理容积配置请求。 | + +## 配置环境变量 + +| 变量 | 用途 | +| --- | --- | +| `REINLOOP_SERVER_URL` | 服务端根地址。 | +| `REINLOOP_API_URL` | 完整 API 地址,优先级高于根地址。 | +| `REINLOOP_DEVICE_ID` | 旧许可证或开发测试设备标识;新许可证中必须与 `device_id` 一致。 | +| `REINLOOP_LICENSE_OFFLINE_HOURS` | 许可证在线校验的最大离线时长,默认 `72`。 | +| `REINLOOP_VOLUME_CONFIG` | 本地容积配置 JSON 的覆盖路径。 | \ No newline at end of file diff --git a/ReinLoop/get_V.py b/ReinLoop/get_V.py new file mode 100644 index 0000000..74ae184 --- /dev/null +++ b/ReinLoop/get_V.py @@ -0,0 +1,193 @@ +import numpy as np +import time + + +def measure_volume(conn_mgr, + q_in_slm=50.0, dt=0.1, + xa=1000, p_max=200.0, + fit_low=50.0, fit_high=150.0, + T_delta=30.0, + should_stop=None, log=print, on_sample=None): + """充气升压测试,辨识系统等效体积 V(可直接被 GUI 导入调用)。 + + 连接由调用方负责:传入已连接的 ConnectionManager。 + 本函数不创建客户端、不调用 exit()、不画图、不阻塞,只跑测试并返回结果。 + + 参数: + conn_mgr : 已连接的 ConnectionManager(需有 read_pressure() / set_motor_position()) + q_in_slm : 进气流量设定 (SLM) + dt : 控制/采样周期 (秒) + xa : 阀门全开对应的电机位置指令 + p_max : 升压上限,超过即停止并关阀 (kPa) + fit_low/high : 用于线性拟合的压力区间 (kPa) + t_std : 流量计标况温度 (K) + t_tank : 充气时估计气体温度 (K) + should_stop : 可选回调,返回 True 时提前中止(供 GUI 停止按钮用) + log : 日志回调,默认 print(GUI 可传入 self.log_message) + on_sample : 可选回调 on_sample(t, pressure),每个采样点调用一次(供 GUI 刷新界面) + + 返回: + dict: { + 'record_time': [...], 'p_actual': [...], + 'slope': float | None, 'intercept': float | None, + 'c1': float | None, 'volume_L': float | None, + 'valid_points': int, 'payload_data': dict | None, + 'success': bool + } + """ + p_actual = [] + record_time = [] + + conn_mgr.set_motor_position(xa) + # time.sleep(5) # 等待压力稳定 + + begin_time = time.perf_counter() + current_pressure = conn_mgr.read_pressure() + while True: + if should_stop is not None and should_stop(): + log("测试被手动中止") + conn_mgr.set_motor_position(0) + break + + start_time = time.perf_counter() + if current_pressure is not None and current_pressure > p_max: + conn_mgr.set_motor_position(0) + break + + current_pressure = conn_mgr.read_pressure() + t = time.perf_counter() - begin_time + if current_pressure is not None: + p_actual.append(current_pressure) + record_time.append(t) + print(f"time:{t:.2f}, current_pressure:{current_pressure}") + if on_sample is not None: + on_sample(t, current_pressure) + + i += 1 + + cycle_time = time.perf_counter() - start_time + time.sleep(max(dt - cycle_time, 0.001)) + + # ========================================== + # 自动计算 升压速率(dP/dt) 与 c1、等效体积 V + # ========================================== + valid_times = [] + valid_pressures = [] + for tt, p in zip(record_time, p_actual): + if fit_low <= p <= fit_high: + valid_times.append(tt) + valid_pressures.append(p) + + result = { + 'record_time': record_time, + 'p_actual': p_actual, + 'slope': None, + 'intercept': None, + 'c1': None, + 'volume_L': None, + 'valid_points': len(valid_times), + 'payload_data': None, + 'success': False, + } + + log("=" * 40) + log("物理参数辨识结果") + log("=" * 40) + + t_std = 293.15 + t_tank = t_std + T_delta + + if len(valid_times) > 1: + slope, intercept = np.polyfit(valid_times, valid_pressures, 1) + c1 = slope / q_in_slm + P_atm = 101.325 + volume_L = P_atm / (60 * c1) * (t_tank / t_std) + + # 打包json上传到云端 + payload_data = { + "slope": slope, + "intercept": intercept, + "c1": c1, + "volume_L": volume_L, + "valid_points": len(valid_times), + "q_in_slm": q_in_slm, + } + + result.update({ + 'slope': float(slope), + 'intercept': float(intercept), + 'c1': float(c1), + 'volume_L': float(volume_L), + 'payload_data': payload_data, + 'success': True, + }) + + print(f"有效数据点数量: {len(valid_times)}") + print(f"实测升压速率 (dP/dt) : {slope:.4f} kPa/s") + print(f"进气流量设定 (q_in) : {q_in_slm} SLM") + print(f"最终进气增益 (c1) : {c1:.4f}") + print(f"系统真实等效体积 (V) : {volume_L:.4f} L") + + + + else: + log(f"警告:{fit_low:.0f}~{fit_high:.0f}kPa 区间内的数据点太少,无法计算斜率!") + log("=" * 40) + + return result + + +def main(): + """独立运行入口:自建连接、跑测试、画图验证。""" + import matplotlib.pyplot as plt + from PcControl import Easy521ModbusClient, MotorModbusRTUClient + + q_in_slm = 50.0 + + modbus_client = Easy521ModbusClient() + if modbus_client.connect(): + print("成功连接到PLC") + modbus_client.start_control() + + motor = MotorModbusRTUClient() + if not motor.connect(): + print("电机连接失败,退出。") + return + time.sleep(1) # 增加短暂延时,等待驱动器接口就绪 + if not motor.init(): + print("电机初始化失败,退出。") + motor.disconnect() + return + + result = measure_volume(modbus_client, motor, q_in_slm=q_in_slm) + + modbus_client.stop_control() + motor.disconnect() + + record_time = result['record_time'] + p_actual = result['p_actual'] + + # ========================================== + # 画图验证 + # ========================================== + plt.figure(figsize=(10, 6)) + plt.plot(record_time, p_actual, 'b.-', label='Actual P (kPa)') + + if result['success']: + slope = result['slope'] + intercept = result['intercept'] + valid_times = [t for t, p in zip(record_time, p_actual) if 50 <= p <= 150] + ideal_p = [slope * t + intercept for t in valid_times] + plt.plot(valid_times, ideal_p, 'r--', linewidth=2, label=f'Linear Fit (slope={slope:.1f})') + + plt.title('Pressure Rise Test') + plt.xlabel('Time (s)') + plt.ylabel('Pressure (kPa)') + plt.grid(True) + plt.legend() + plt.tight_layout() + plt.show() + + +if __name__ == "__main__": + main() diff --git a/ReinLoop/ind_collector.py b/ReinLoop/ind_collector.py new file mode 100644 index 0000000..24c4b3d --- /dev/null +++ b/ReinLoop/ind_collector.py @@ -0,0 +1,250 @@ +import numpy as np +import time +import pandas as pd +import datetime +import os +import warnings +# 忽略所有的 DeprecationWarning +warnings.filterwarnings("ignore", category=DeprecationWarning) +import logging +# 将 pymodbus 的日志级别提高到 ERROR,屏蔽 WARNING 及以下的信息 +logging.getLogger("pymodbus").setLevel(logging.ERROR) + + +# ==================== 1.5 生成复合辨识序列 (三级火箭) ==================== +def generate_composite_sequence(dt, n_order, t_c, levels): + """ + 生成包含 闭阀、全开、多电平M序列 的终极复合辨识序列 + """ + # 1. 第一段:绝对闭阀段 (占位 8 秒) + # 目的:憋气升压,暴露纯进气增益 c1 + part1_duration = 4.0 + part1_samples = int(part1_duration / dt) + part1_signal = np.zeros(part1_samples) + + # 2. 第二段:绝对全开段 (占位 6 秒) + # 目的:极限泄压,暴露纯排气增益 c2 和机械延迟 tau + part2_duration = 5.0 + part2_samples = int(part2_duration / dt) + part2_signal = np.full(part2_samples, 100.0) # 假设 100 为全开 + + # 3. 第三段:多电平 M 序列段 + # 目的:中频动态跳变,暴露出 S 曲线非线性特征 + samples_per_bit = int(t_c / dt) + part3_signal = generate_prbs(n_order=n_order, samples_per_bit=samples_per_bit, levels=levels) + + # 拼接并返回完整序列 + composite_signal = np.concatenate([part1_signal, part2_signal, part3_signal]) + return composite_signal + + +# ==================== 1. 生成 M 序列信号 ==================== +def generate_prbs(n_order=7, low_val=40, high_val=60, samples_per_bit=20, levels=None): + """ + 生成线性反馈移位寄存器的 PRBS 信号。 + n_order: 阶数 (2^n-1 长度) + low_val: 低位输出(两电平时使用) + high_val: 高位输出(两电平时使用) + samples_per_bit: 每个码元持续的控制周期数 + levels: 可选的多电平列表(长度必须为 2^k),若提供则忽略 low_val/high_val + """ + length = 2 ** n_order - 1 + reg = np.ones(n_order, dtype=int) + bit_seq = [] + + # 反馈多项式:取最高位和次高位(可根据需要修改) + for _ in range(length): + feedback = reg[-1] ^ reg[-2] # 使用最后两位,适用于任意阶数 + bit_seq.append(reg[-1]) + reg = np.roll(reg, 1) + reg[0] = feedback + + # 若未指定 levels,则使用两电平映射 + if levels is None: + raw = np.array(bit_seq) + scaled = np.where(raw == 1, high_val, low_val) + signal = np.repeat(scaled, samples_per_bit) + return signal + + # 多电平模式:将二进制序列按组转换为索引 + n_levels = len(levels) + group_bits = int(np.log2(n_levels)) + if 2 ** group_bits != n_levels: + raise ValueError("levels 长度必须是 2 的整数次幂") + num_groups = len(bit_seq) // group_bits + bit_seq = bit_seq[:num_groups * group_bits] + indices = [] + for i in range(0, len(bit_seq), group_bits): + idx = 0 + for j in range(group_bits): + idx = (idx << 1) | bit_seq[i + j] + indices.append(idx) + scaled = [levels[idx] for idx in indices] + signal = np.repeat(scaled, samples_per_bit) + return signal + +# ==================== 5. 实时数据采集(通过 PLC) ==================== +def collect_data_with_prbs(conn_mgr, + q_in_val, dt=0.05, n_order=7, t_c=1.0, levels=None, + dead_area=240, xa_full=1062.5, + save_dir=None, V_val=None, + should_stop=None, log=print, on_sample=None, + repeat=2): + """使用复合 M 序列激励,通过 MT2-AM8 模块采集压力响应数据。 + + 连接由调用方负责:传入已连接的 ConnectionManager。 + 本函数不创建客户端、不调用 exit()/input()、不画图,只跑采集、存盘并返回结果。 + + 参数: + conn_mgr : 已连接的 ConnectionManager(需有 read_pressure() / set_motor_position()) + q_in_val : 实验流量 (SLM),写入数据列并用于文件名 + dt : 控制/采样周期 (秒) + n_order : M 序列阶数 + t_c : 码元周期 (秒) + levels : 多电平列表(长度需为 2 的整数次幂) + dead_area : 电机死区补偿 + xa_full : 阀门全开对应的电机位置上限 + save_dir : CSV 保存目录,None 时存到当前目录的 ind_data/ + V_val : 可选容积 (L),提供时写入文件名 + should_stop : 可选回调,返回 True 时提前中止(供 GUI 停止按钮用) + log : 日志回调,默认 print(GUI 可传入 self.log_message) + on_sample : 可选回调 on_sample(t, u_cmd, pressure),每采样点调用(供 GUI 刷新界面) + repeat : 整段复合序列重复次数,默认 2 + + 返回: + dict: { + 't': [...], 'u': [...], 'p': [...], + 'filename': str | None, 'samples': int, 'success': bool + } + """ + # 生成复合序列(闭阀 + 全开 + 多电平 M 序列) + signal = generate_composite_sequence(dt, n_order, t_c, levels) + if repeat > 1: + signal = np.tile(signal, repeat) + log(f"序列已重复 {repeat} 次") + total_samples = len(signal) + duration = total_samples * dt + log(f"复合序列总长度: {total_samples} 步, 预计耗时: {duration:.1f} 秒 ({duration/60:.1f} 分钟)") + + # 数据记录 + t_record = [] + u_record = [] + p_record = [] + + # 初始化压力滤波 + p_filter = conn_mgr.read_pressure() + + log("开始采集...") + start_time = time.perf_counter() + for step, u_cmd in enumerate(signal): + if should_stop is not None and should_stop(): + log("辨识采集被手动中止") + conn_mgr.set_motor_position(0) + break + + # 记录绝对时间 + current_t = time.perf_counter() - start_time + # 写入阀门开度(含死区补偿) + xa = dead_area + (100 - u_cmd) * (xa_full - dead_area) / 100 + conn_mgr.set_motor_position(xa) + + # 读取压力 + p_raw = conn_mgr.read_pressure() + alpha = 1 + if p_raw is not None: + p_filter = alpha * p_raw + (1 - alpha) * p_filter + + # 记录数据 + t_record.append(current_t) + u_record.append(u_cmd) + p_record.append(p_filter) + if on_sample is not None: + on_sample(current_t, u_cmd, p_filter) + + # 控制周期延时 + elapsed = time.perf_counter() - start_time + expected = step * dt + if elapsed < expected: + time.sleep(expected - elapsed) + + # 保存为 CSV + df = pd.DataFrame({'t': t_record, 'u': u_record, 'p': p_record}) + df['q_in'] = q_in_val + if V_val is not None: + df['V'] = V_val + + # if save_dir is None: + # save_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ind_data") + # os.makedirs(save_dir, exist_ok=True) + + # 生成 CSV 字节数据(不写入磁盘) + csv_buffer = df.to_csv(index=False).encode('utf-8') + + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + if V_val is not None: + filename = f'identification_data_{q_in_val}SLM_{V_val}L_{timestamp}.csv' + else: + filename = f'identification_data_{q_in_val}SLM_{timestamp}.csv' + + # if V_val is not None: + # filename = os.path.join(save_dir, f'identification_data_{q_in_val}SLM_{V_val}L_{timestamp}.csv') + # else: + # filename = os.path.join(save_dir, f'identification_data_{q_in_val}SLM_{timestamp}.csv') + # df.to_csv(filename, index=False) + # log(f"数据已保存至 {filename}") + + return { + 't': t_record, + 'u': u_record, + 'p': p_record, + 'filename': filename, + 'csv_data': csv_buffer, + 'samples': len(t_record), + 'success': len(t_record) > 0, + } + +# ==================== 6. 主程序 ==================== +def main(): + """独立运行入口:自建连接、采集、存盘。""" + from PcControl import Easy521ModbusClient, MotorModbusRTUClient + + dt = 0.1 + n_order = 7 # 码元数 127 + t_c = 5 # 码元周期 5 秒 + levels = [10, 20, 30, 40, 50, 60, 70, 80] # 8 个电平,对应 group_bits=3 + + # 连接 PLC + modbus_client = Easy521ModbusClient() + if not modbus_client.connect(): + print("无法连接 PLC,退出") + return + modbus_client.start_control() + + motor = MotorModbusRTUClient() + if not motor.connect(): + print("电机连接失败,退出。") + return + time.sleep(1) # 增加短暂延时,等待驱动器接口就绪 + if not motor.init(): + print("电机初始化失败,退出。") + motor.disconnect() + return + + q_in_val = float(input("请输入实验时的流量 (SLM): ")) + try: + collect_data_with_prbs(modbus_client, motor, + q_in_val=q_in_val, dt=dt, n_order=n_order, t_c=t_c, + levels=levels, save_dir="test_data", repeat=2) + finally: + modbus_client.stop_control() + modbus_client.disconnect() + motor.disconnect() + + +if __name__ == "__main__": + main() + + + + + diff --git a/ReinLoop/license_utils.py b/ReinLoop/license_utils.py new file mode 100644 index 0000000..5b4abdf --- /dev/null +++ b/ReinLoop/license_utils.py @@ -0,0 +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 diff --git a/ReinLoop/main.py b/ReinLoop/main.py new file mode 100644 index 0000000..24310b7 --- /dev/null +++ b/ReinLoop/main.py @@ -0,0 +1,99 @@ +# main.py +"""主程序入口 — PySide6 版本""" + +import sys +import os +import traceback +import warnings +import logging +from datetime import datetime + +# 必须在导入任何 matplotlib 之前设置后端 +import matplotlib +matplotlib.use('QtAgg') + +from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtCore import Qt + +from ui.main_window import MainWindow +from styles import apply_app_style + +# 屏蔽多余警告 +warnings.filterwarnings('ignore') +logging.getLogger("pymodbus").setLevel(logging.ERROR) + +# 优化 matplotlib 设置 +matplotlib.rcParams['figure.max_open_warning'] = 20 +matplotlib.rcParams['axes.linewidth'] = 0.5 +matplotlib.rcParams['lines.linewidth'] = 1.0 + +# 中文字体 +import matplotlib.pyplot as plt +plt.rcParams['font.sans-serif'] = [ + 'Microsoft YaHei', 'SimHei', 'PingFang SC', 'Heiti TC', 'sans-serif' +] +plt.rcParams['axes.unicode_minus'] = False + +# ---- 全局异常日志(打包为 exe 后排查问题用) ---- +_LOG_DIR = os.path.join(os.path.dirname(sys.executable), "logs") +os.makedirs(_LOG_DIR, exist_ok=True) +_LOG_FILE = os.path.join(_LOG_DIR, f"reinloop_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log") + + +def _write_log(msg: str): + try: + with open(_LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}\n") + except Exception: + pass + + +def _global_excepthook(exc_type, exc_value, exc_tb): + """未捕获异常 → 写日志 + 弹窗""" + err = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) + _write_log(f"未捕获异常:\n{err}") + try: + QMessageBox.critical(None, "程序错误", + f"发生未捕获异常:\n{exc_value}\n\n详情见: {_LOG_FILE}") + except Exception: + pass + sys.__excepthook__(exc_type, exc_value, exc_tb) + + +def main(): + """主程序入口""" + sys.excepthook = _global_excepthook + _write_log(f"启动 | Python={sys.version} | exe={sys.executable}") + print("正在启动控制系统...") + + # 0. 防止 Windows 深色模式干扰 Qt 样式(打包后常见黑底问题) + if sys.platform == "win32": + os.environ.setdefault("QT_QPA_PLATFORM", "windows:darkmode=0") + + # 1. 初始化 QApplication + app = QApplication(sys.argv) + app.setApplicationName("ReinLoop") + + # 1.1 强制浅色模式(防止系统深色模式或 style 插件缺失导致黑底) + app.setStyle("Fusion") # Fusion 内置于 QtCore,不依赖外部 style 插件 + try: + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + except (AttributeError, TypeError): + pass # Qt < 6.5 无此方法,忽略 + + # 2. 应用全局样式 + colors = apply_app_style(app) + _write_log("样式加载完成") + + # 3. 创建主窗口 + window = MainWindow(colors) + _write_log("主窗口创建完成") + window.show() + + # 4. 进入事件循环 + _write_log("进入事件循环") + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/ReinLoop/project.config.json b/ReinLoop/project.config.json new file mode 100644 index 0000000..85acdd6 --- /dev/null +++ b/ReinLoop/project.config.json @@ -0,0 +1,25 @@ +{ + "setting": { + "es6": true, + "postcss": true, + "minified": true, + "uglifyFileName": false, + "enhance": true, + "packNpmRelationList": [], + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "useCompilerPlugins": false, + "minifyWXML": true + }, + "compileType": "miniprogram", + "simulatorPluginLibVersion": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "appid": "wx156896aa598edf68", + "editorSetting": {} +} \ No newline at end of file diff --git a/ReinLoop/requirements.txt b/ReinLoop/requirements.txt new file mode 100644 index 0000000..3f23c5c --- /dev/null +++ b/ReinLoop/requirements.txt @@ -0,0 +1,15 @@ +cython==3.2.4 +cryptography>=42.0,<47 +matplotlib==3.11.0 +numpy==2.4.6 +pandas==3.0.3 +prompt_toolkit==3.0.52 +pyautogui==0.9.54 +pygetwindow==0.0.9 +pymodbus==3.6.9 +PySide6>=6.8,<7 +pyserial==3.5 +Requests==2.34.2 +setuptools==81.0.0 +stable_baselines3==2.8.0 +torch==2.11.0 diff --git a/ReinLoop/setup.py b/ReinLoop/setup.py new file mode 100644 index 0000000..0ba7404 --- /dev/null +++ b/ReinLoop/setup.py @@ -0,0 +1,69 @@ +# setup.py +"""Cython 编译脚本 — 将核心业务 .py 文件编译为 .pyd/.so 防止反编译。 + +用法: + python setup.py build_ext --inplace # 原地编译(开发测试) + python setup.py build_ext # 输出到 build_libs/ +""" +from setuptools import setup, find_packages +from Cython.Build import cythonize +import os + +# ============================================================ +# 1. 明确指定要加密保护的核心业务文件(千万不要把 main.py 放进去) +# ============================================================ +py_modules = [ + # 根目录业务文件 + "api.py", + "controllers.py", + "PcControl.py", + "ind_collector.py", + "get_V.py", + "styles.py", + # 许可证模块(含公钥 + 验签逻辑,编译后不可篡改) + "license_utils.py", + # core/ 业务逻辑层 + "core/__init__.py", # 模块级验签入口,import 时自动触发 + "core/connection_manager.py", + "core/control_engine.py", + "core/model_manager.py", + "core/data_collector.py", + "core/identification.py", + "core/identification_config.py", + "core/identification_feedback.py", + "core/volume_config.py", +] + +# 过滤掉本地不存在的文件,防止报错 +py_modules = [f for f in py_modules if os.path.exists(f)] + +# ============================================================ +# 2. 编译器优化指令 +# ============================================================ +compiler_directives = { + 'language_level': "3", # Python 3 语义 + 'boundscheck': False, # 关闭数组越界检查(提升性能) + 'wraparound': False, # 关闭负索引检查 + 'cdivision': True, # C 除法语义(更快) + 'always_allow_keywords': False, # 不生成 **kwargs(减小体积) +} + +setup( + name="PressureControlCore", + version="1.0.0", + python_requires=">=3.8", + packages=find_packages(include=["core", "core.*"]), + ext_modules=cythonize( + py_modules, + compiler_directives=compiler_directives, + annotate=False, # 不生成 html 报告,减少垃圾文件 + build_dir="build_libs/temp", # .c 文件的临时目录 + force=True, # 强制重新生成 .c 文件(防止用旧缓存) + ), + options={ + "build_ext": { + "build_lib": "build_libs", # 最终 .pyd/.so 输出目录 + "build_temp": "build_libs/temp" # 中间 .c 和 .o 输出目录 + } + }, +) diff --git a/ReinLoop/skills-lock.json b/ReinLoop/skills-lock.json new file mode 100644 index 0000000..d09818a --- /dev/null +++ b/ReinLoop/skills-lock.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "skills": { + "brandkit": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/brandkit/SKILL.md", + "computedHash": "b63012f3c3d21197e0185d3e9cc7ec40c589fb10e0b5a32a561739de31aa3f20" + }, + "design-taste-frontend": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/taste-skill/SKILL.md", + "computedHash": "6d838b246d0e35d0b53f4f23f98ba7a1dd561937e64f7d0c7553b0928e376c3e" + }, + "design-taste-frontend-v1": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/taste-skill-v1/SKILL.md", + "computedHash": "d704ab912c4d0ca954ffa858983da755ae4cd5cad9ba22554db5557382f5bd34" + }, + "full-output-enforcement": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/output-skill/SKILL.md", + "computedHash": "26bd29ce4c5e02c7666b2d503609bf466bd32290822e91f0e984147048dbb924" + }, + "high-end-visual-design": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/soft-skill/SKILL.md", + "computedHash": "7db385e4c5370e5a7fca9704a1361b056e4504ea6a03924bb86f33a4f00b5c73" + }, + "image-to-code": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/image-to-code-skill/SKILL.md", + "computedHash": "58517b03b2a01f4c9ba65861559d03df931400871bbc200978c975b24bb92c73" + }, + "industrial-brutalist-ui": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/brutalist-skill/SKILL.md", + "computedHash": "8fc355c4aadb7d29c53ca28bc41be3cd6eea765d121e3737c4dc2d0f90a8effa" + }, + "redesign-existing-projects": { + "source": "Leonxlnx/taste-skill", + "sourceType": "github", + "skillPath": "skills/redesign-skill/SKILL.md", + "computedHash": "b405eee0e0e80fc243f731d9aa368bca307e356db7e6157d27101d369dac6726" + } + } +} diff --git a/ReinLoop/src/Setting_line_light.svg b/ReinLoop/src/Setting_line_light.svg new file mode 100644 index 0000000..de4ec6d --- /dev/null +++ b/ReinLoop/src/Setting_line_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/ReinLoop/src/connect_device.svg b/ReinLoop/src/connect_device.svg new file mode 100644 index 0000000..88e385c --- /dev/null +++ b/ReinLoop/src/connect_device.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ReinLoop/src/control_icon.svg b/ReinLoop/src/control_icon.svg new file mode 100644 index 0000000..d678229 --- /dev/null +++ b/ReinLoop/src/control_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ReinLoop/src/control_icon_gray.svg b/ReinLoop/src/control_icon_gray.svg new file mode 100644 index 0000000..1c7b8f1 --- /dev/null +++ b/ReinLoop/src/control_icon_gray.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ReinLoop/src/debug_icon.svg b/ReinLoop/src/debug_icon.svg new file mode 100644 index 0000000..234149e --- /dev/null +++ b/ReinLoop/src/debug_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ReinLoop/src/debug_icon_gray.svg b/ReinLoop/src/debug_icon_gray.svg new file mode 100644 index 0000000..a655edb --- /dev/null +++ b/ReinLoop/src/debug_icon_gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ReinLoop/src/link_icon.svg b/ReinLoop/src/link_icon.svg new file mode 100644 index 0000000..d443d34 --- /dev/null +++ b/ReinLoop/src/link_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ReinLoop/src/link_icon_gray.svg b/ReinLoop/src/link_icon_gray.svg new file mode 100644 index 0000000..452cd3d --- /dev/null +++ b/ReinLoop/src/link_icon_gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ReinLoop/src/load_model.svg b/ReinLoop/src/load_model.svg new file mode 100644 index 0000000..5bf48c5 --- /dev/null +++ b/ReinLoop/src/load_model.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ReinLoop/src/logo.svg b/ReinLoop/src/logo.svg new file mode 100644 index 0000000..48e606b --- /dev/null +++ b/ReinLoop/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ReinLoop/src/plot.svg b/ReinLoop/src/plot.svg new file mode 100644 index 0000000..a62bfdb --- /dev/null +++ b/ReinLoop/src/plot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ReinLoop/src/pressure.svg b/ReinLoop/src/pressure.svg new file mode 100644 index 0000000..2e6f985 --- /dev/null +++ b/ReinLoop/src/pressure.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ReinLoop/src/refresh.svg b/ReinLoop/src/refresh.svg new file mode 100644 index 0000000..564fb96 --- /dev/null +++ b/ReinLoop/src/refresh.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ReinLoop/src/start_control.svg b/ReinLoop/src/start_control.svg new file mode 100644 index 0000000..a017837 --- /dev/null +++ b/ReinLoop/src/start_control.svg @@ -0,0 +1,3 @@ + + + diff --git a/ReinLoop/src/target.svg b/ReinLoop/src/target.svg new file mode 100644 index 0000000..6aa0acb --- /dev/null +++ b/ReinLoop/src/target.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ReinLoop/src/valve.svg b/ReinLoop/src/valve.svg new file mode 100644 index 0000000..a938bd6 --- /dev/null +++ b/ReinLoop/src/valve.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ReinLoop/styles.py b/ReinLoop/styles.py new file mode 100644 index 0000000..62df125 --- /dev/null +++ b/ReinLoop/styles.py @@ -0,0 +1,569 @@ +# styles.py +"""界面样式集中管理模块:PySide6 QSS 样式表。 + +颜色主题:品牌蓝 #0960D1,主背景 #F8FAFC,成功绿 #0F955D。 +通过 apply_app_style(app) 应用全局 QSS 样式,并拿到配色字典。 +""" + +from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QPalette, QColor + +# ========================================== +# 统一配色架构(与 Tkinter 版本保持一致) +# ========================================== +COLORS = { + "BG_COLOR": "#F8FAFC", # 浅色大背景(微冷超浅灰蓝) + "CARD_BG": "#FFFFFF", # 容器卡片背景 + "BORDER_COLOR": "#E2E8F0", # 扁平化细边框 + "TEXT_MAIN": "#1A1A1A", # 主文字颜色(高清晰度深灰) + "TEXT_MUTED": "#757575", # 辅助文字颜色(中灰) + "ACCENT_LIGHT": "#0960D1", # 主品牌色/高亮蓝 + "ACCENT_DARK": "#0960D1", # 品牌色(统一) + "HOVER_BLUE": "#0856B8", # 悬停过渡色(品牌色加深) + "SUCCESS_GREEN": "#0F955D", # 连接成功绿 + "SUCCESS_ACTIVE": "#0D8250", # 成功按钮按下态 + "DISABLED": "#CBD5E1", # 禁用态灰 + "ERROR_RED": "#FF2424", # 状态警示红 + "NAV_BG": "#FFFFFF", # 导航栏背景(白色) + "NAV_BORDER": "#E2E8F0", # 导航栏底部边框 + "NAV_TAB_ACTIVE_TEXT": "#0960D1", # Tab激活态文字色 + "NAV_TAB_HOVER": "#F1F5F9", # Tab悬停背景 + "FORM_LABEL": "#333333", # 表单标签色 + "REFRESH_BORDER": "#CCDBF0", # 刷新按钮边框 +} +# 科技蓝核心高亮统一为浅色主题色 +COLORS["ACCENT_BLUE"] = COLORS["ACCENT_LIGHT"] + +# 字体配置 +BASE_FONT_FAMILY = "Microsoft YaHei, PingFang SC, SimHei, sans-serif" +BASE_FONT_SIZE = "14px" +BASE_FONT_SIZE_SM = "12px" +BASE_FONT_SIZE_LG = "16px" +BASE_FONT_SIZE_XL = "28px" +BASE_FONT_SIZE_NAV_TITLE = "22px" + +QSS_STYLESHEET = f""" +/* ===== 全局默认 ===== */ +QMainWindow, QWidget {{ + background-color: {COLORS["BG_COLOR"]}; + color: {COLORS["TEXT_MAIN"]}; + font-family: "{BASE_FONT_FAMILY}"; + font-size: {BASE_FONT_SIZE}; +}} + +/* ===== 标题行(Layer 1:纯白背景) ===== */ +QWidget[cssClass="titleRow"] {{ + background-color: #FFFFFF; +}} + +/* ===== Tab 栏容器(Layer 2:一体化浅灰背景 #F8FAFC) ===== */ +QWidget[cssClass="tabRow"] {{ + background-color: #F8FAFC; +}} + +/* ===== QTabBar 导航标签 ===== */ +QTabBar[cssClass="mainTab"]::tab {{ + background: transparent; + padding: 10px 24px 10px 16px; + font-size: 15px; + color: #64748B; + border: none; + border-bottom: 3px solid transparent; + font-weight: normal; +}} + +QTabBar[cssClass="mainTab"]::tab:selected {{ + color: #0960D1; + font-weight: bold; + border-bottom: 3px solid #0960D1; +}} + +QTabBar[cssClass="mainTab"]::tab:hover:!selected {{ + color: #0960D1; +}} + +/* ===== QGroupBox ===== */ +QGroupBox {{ + background-color: {COLORS["CARD_BG"]}; + border: 1px solid {COLORS["BORDER_COLOR"]}; + border-radius: 12px; + margin-top: 14px; + padding: 16px 12px 12px 12px; + font-weight: bold; + color: {COLORS["TEXT_MAIN"]}; + font-size: {BASE_FONT_SIZE_LG}; +}} + +QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 0 10px; + color: {COLORS["ACCENT_DARK"]}; + font-weight: bold; + font-size: {BASE_FONT_SIZE_LG}; + background-color: transparent; +}} + +/* ===== Section 卡片(替代 QGroupBox 的轻量方案) ===== */ +QFrame[cssClass="sectionCard"] {{ + background-color: {COLORS["CARD_BG"]}; + border: 1px solid {COLORS["BORDER_COLOR"]}; + border-radius: 12px; +}} + +/* ===== Section 标题 ===== */ +QLabel[cssClass="sectionTitle"] {{ + color: {COLORS["TEXT_MAIN"]}; + font-weight: bold; + font-size: {BASE_FONT_SIZE_LG}; + background-color: transparent; +}} + +/* Section 标题左侧蓝色竖线(4px x 16px) */ +QWidget[cssClass="sectionAccent"] {{ + background-color: {COLORS["ACCENT_LIGHT"]}; + border-radius: 2px; +}} + +/* ===== 表单标签 ===== */ +QLabel[cssClass="formLabel"] {{ + color: {COLORS["FORM_LABEL"]}; + font-size: 14px; + font-weight: bold; + background-color: transparent; + min-width: 140px; +}} + +/* ===== QPushButton 基础 ===== */ +QPushButton {{ + background-color: {COLORS["ACCENT_BLUE"]}; + color: white; + border: none; + border-radius: 6px; + padding: 9px 20px; + font-weight: bold; + font-size: {BASE_FONT_SIZE}; +}} + +QPushButton:hover {{ + background-color: {COLORS["HOVER_BLUE"]}; +}} + +QPushButton:pressed {{ + background-color: {COLORS["ACCENT_DARK"]}; +}} + +QPushButton:disabled {{ + background-color: {COLORS["DISABLED"]}; + color: #94A3B8; +}} + +/* 主操作按钮(绿色 - 连接设备) */ +QPushButton[cssClass="action"] {{ + background-color: {COLORS["SUCCESS_GREEN"]}; + border: none; + color: #FFFFFF; + padding: 8px 24px; + font-size: 14px; + font-weight: bold; + border-radius: 6px; +}} + +QPushButton[cssClass="action"]:hover {{ + background-color: #0D8250; +}} + +QPushButton[cssClass="action"]:pressed {{ + background-color: {COLORS["SUCCESS_ACTIVE"]}; +}} + +/* 次要按钮 / 刷新按钮 */ +QPushButton[cssClass="refresh"] {{ + background-color: #FFFFFF; + border: 1px solid {COLORS["REFRESH_BORDER"]}; + color: {COLORS["ACCENT_LIGHT"]}; + padding: 8px 20px; + font-size: 14px; + font-weight: bold; + border-radius: 6px; +}} + +QPushButton[cssClass="refresh"]:hover {{ + background-color: #F1F5F9; + border-color: {COLORS["ACCENT_LIGHT"]}; +}} + +QPushButton[cssClass="refresh"]:pressed {{ + background-color: #E2E8F0; +}} + +/* ===== 底部操作按钮(ID 选择器,精确控制高度与内边距,修复文字截断) ===== */ +QPushButton#refresh_btn {{ + background-color: #FFFFFF; + border: 1px solid #CCDBF0; + color: #0960D1; + border-radius: 6px; + min-height: 34px; + max-height: 34px; + padding: 0px 20px; + font-weight: bold; + font-size: 14px; + outline: none; +}} + +QPushButton#refresh_btn:hover {{ + background-color: #F0F4FA; +}} + +QPushButton#refresh_btn:focus {{ + outline: none; +}} + +QPushButton#connect_btn {{ + background-color: #0F955D; + border: none; + color: #FFFFFF; + border-radius: 6px; + min-height: 34px; + max-height: 34px; + padding: 0px 24px; + font-weight: bold; + font-size: 14px; + outline: none; +}} + +QPushButton#connect_btn:hover {{ + background-color: #0D8250; +}} + +QPushButton#connect_btn:focus {{ + outline: none; +}} + +/* 危险按钮(红色,用于停止) */ +QPushButton[cssClass="danger"] {{ + background-color: #EF4444; + border: none; + color: #FFFFFF; + padding: 8px 24px; + font-size: 14px; + font-weight: bold; + border-radius: 6px; +}} + +QPushButton[cssClass="danger"]:hover {{ + background-color: #DC2626; +}} + +QPushButton[cssClass="danger"]:pressed {{ + background-color: #B91C1C; +}} + +/* ===== QLineEdit 输入框 ===== */ +QLineEdit {{ + background-color: #FFFFFF; + border: 1px solid {COLORS["BORDER_COLOR"]}; + border-radius: 6px; + padding-left: 12px; + color: #333333; + font-size: 14px; + min-height: 36px; + max-height: 36px; +}} + +QLineEdit:focus {{ + border: 1px solid {COLORS["ACCENT_LIGHT"]}; +}} + +QLineEdit:hover:!focus {{ + border-color: #94A3B8; +}} + +QLineEdit:disabled {{ + background-color: #F1F5F9; + color: {COLORS["TEXT_MUTED"]}; + border-color: #E2E8F0; +}} + +/* ===== QComboBox 下拉框(增强鲁棒性,防止打包后黑底) ===== */ +QComboBox {{ + background-color: #FFFFFF; + border: 1px solid {COLORS["BORDER_COLOR"]}; + border-radius: 6px; + padding: 7px 10px; + color: {COLORS["TEXT_MAIN"]}; + font-size: 14px; + min-width: 100px; + min-height: 36px; + max-height: 36px; + outline: none; +}} + +QComboBox:hover {{ + border-color: #94A3B8; +}} + +QComboBox:focus {{ + border-color: {COLORS["ACCENT_LIGHT"]}; + border-width: 1px; +}} + +QComboBox:disabled {{ + background-color: #F1F5F9; + color: {COLORS["TEXT_MUTED"]}; + border-color: #E2E8F0; +}} + +QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 28px; + border: none; + border-left: 1px solid {COLORS["BORDER_COLOR"]}; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + background-color: #F8FAFC; +}} + +QComboBox::down-arrow {{ + width: 12px; + height: 12px; +}} + +/* 下拉弹出视图(最关键的修复点——保证白色背景) */ +QComboBox QAbstractItemView {{ + background-color: #FFFFFF; + border: 1px solid {COLORS["BORDER_COLOR"]}; + border-radius: 4px; + selection-background-color: {COLORS["ACCENT_LIGHT"]}; + selection-color: #FFFFFF; + color: {COLORS["TEXT_MAIN"]}; + font-size: {BASE_FONT_SIZE}; + padding: 4px; + outline: none; +}} + +QComboBox QAbstractItemView::item {{ + padding: 6px 10px; + border-radius: 3px; + color: {COLORS["TEXT_MAIN"]}; + background-color: #FFFFFF; +}} + +QComboBox QAbstractItemView::item:selected {{ + background-color: {COLORS["ACCENT_LIGHT"]}; + color: #FFFFFF; +}} + +QComboBox QAbstractItemView::item:hover {{ + background-color: #EFF6FF; +}} + +/* 防止下拉滚动条区域也变黑 */ +QComboBox QAbstractScrollArea {{ + background-color: #FFFFFF; + color: {COLORS["TEXT_MAIN"]}; +}} + +/* ===== QRadioButton / QCheckBox ===== */ +QRadioButton, QCheckBox {{ + background-color: transparent; + color: {COLORS["TEXT_MAIN"]}; + font-size: {BASE_FONT_SIZE}; + spacing: 8px; +}} + +QRadioButton::indicator {{ + width: 18px; + height: 18px; + border-radius: 9px; + border: 2px solid {COLORS["BORDER_COLOR"]}; + background-color: #FFFFFF; +}} + +QRadioButton::indicator:checked {{ + background-color: {COLORS["ACCENT_BLUE"]}; + border-color: {COLORS["ACCENT_BLUE"]}; +}} + +QRadioButton::indicator:hover {{ + border-color: {COLORS["ACCENT_BLUE"]}; +}} + +QRadioButton::indicator:checked:hover {{ + background-color: {COLORS["ACCENT_DARK"]}; + border-color: {COLORS["ACCENT_DARK"]}; +}} + +QRadioButton:disabled {{ + color: {COLORS["DISABLED"]}; +}} + +QRadioButton::indicator:disabled {{ + background-color: #F1F5F9; + border-color: {COLORS["DISABLED"]}; +}} + +QCheckBox::indicator {{ + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid {COLORS["BORDER_COLOR"]}; + background-color: #FFFFFF; +}} + +QCheckBox::indicator:checked {{ + background-color: {COLORS["ACCENT_BLUE"]}; + border-color: {COLORS["ACCENT_BLUE"]}; +}} + +QCheckBox::indicator:hover {{ + border-color: {COLORS["ACCENT_BLUE"]}; +}} + +QCheckBox::indicator:checked:hover {{ + background-color: {COLORS["ACCENT_DARK"]}; + border-color: {COLORS["ACCENT_DARK"]}; +}} + +QCheckBox:disabled {{ + color: {COLORS["DISABLED"]}; +}} + +QCheckBox::indicator:disabled {{ + background-color: #F1F5F9; + border-color: {COLORS["DISABLED"]}; +}} + +/* ===== 仪表板卡片 ===== */ +QFrame[cssClass="dashboardCard"] {{ + background-color: {COLORS["CARD_BG"]}; + border: 1px solid {COLORS["BORDER_COLOR"]}; + border-radius: 12px; + padding: 12px; +}} + +QFrame[cssClass="dashboardCard"] QLabel {{ + background-color: transparent; +}} + +QLabel[cssClass="dashboardLabel"] {{ + color: {COLORS["TEXT_MUTED"]}; + font-size: {BASE_FONT_SIZE_SM}; + font-weight: normal; +}} + +QLabel[cssClass="dashboardValue"] {{ + font-family: "SF Mono, Menlo, Consolas, monospace"; + font-size: {BASE_FONT_SIZE_XL}; + font-weight: bold; +}} + +/* ===== 底部状态栏 ===== */ +QWidget[cssClass="bottomBar"] {{ + background-color: #FFFFFF; + border-top: 1px solid {COLORS["BORDER_COLOR"]}; +}} + +QLabel[cssClass="logLabel"] {{ + color: #757575; + font-family: "SF Mono, Consolas, Menlo, monospace"; + font-size: 12px; + background-color: transparent; +}} + +QLabel[cssClass="statusLabel"] {{ + font-weight: bold; + font-size: 14px; + background-color: transparent; +}} + +/* ===== 透明背景容器(避免 inline stylesheet 覆盖子控件 QSS) ===== */ +QWidget[cssClass="transparentBg"] {{ + background-color: transparent; +}} + +/* ===== QStackedWidget 页面 ===== */ +QWidget[cssClass="tabPage"] {{ + background-color: #FFFFFF; +}} + +/* ===== 分组辅助标签 ===== */ +QLabel[cssClass="section"] {{ + color: {COLORS["ACCENT_DARK"]}; + font-weight: bold; + font-size: {BASE_FONT_SIZE_LG}; + background-color: transparent; +}} + +/* ===== 导航栏标题 ===== */ +QLabel[cssClass="navTitle"] {{ + color: {COLORS["TEXT_MAIN"]}; + font-size: 22px; + font-weight: bold; + background-color: transparent; + letter-spacing: 0px; +}} + +QLabel[cssClass="navSubtitle"] {{ + color: {COLORS["ACCENT_DARK"]}; + font-size: 11px; + font-weight: normal; + background-color: transparent; +}} + +QLabel[cssClass="navDivider"] {{ + color: {COLORS["BORDER_COLOR"]}; + background-color: transparent; +}} + +/* 设置按钮(圆形,右上角) */ +QPushButton[cssClass="navSettings"] {{ + background-color: transparent; + border: 1.5px solid {COLORS["BORDER_COLOR"]}; + border-radius: 18px; + padding: 4px; + min-width: 36px; + max-width: 36px; + min-height: 36px; + max-height: 36px; +}} + +QPushButton[cssClass="navSettings"]:hover {{ + background-color: {COLORS["BG_COLOR"]}; + border-color: #CBD5E1; +}} + +QPushButton[cssClass="navSettings"]:pressed {{ + background-color: #E2E8F0; +}} +""" + + +def apply_app_style(app: QApplication): + """配置全局 QSS 样式表与窗口默认调色板,返回配色字典供布局复用。""" + # ---- 强制使用 Fusion 风格(跨平台一致,避免 Windows 原生风格/深色模式干扰 QSS) ---- + app.setStyle("Fusion") + + # 应用 QSS 样式表 + app.setStyleSheet(QSS_STYLESHEET) + + # 设置默认字体 + font = app.font() + font.setFamily(BASE_FONT_FAMILY.split(",")[0].strip().strip('"')) + font.setPointSize(10) + app.setFont(font) + + # 配置默认调色板(仅设置 Window/Base 等基础角色,不污染 Button/ComboBox) + palette = QPalette() + palette.setColor(QPalette.Window, QColor(COLORS["BG_COLOR"])) + palette.setColor(QPalette.WindowText, QColor(COLORS["TEXT_MAIN"])) + palette.setColor(QPalette.Base, QColor("#FFFFFF")) + palette.setColor(QPalette.Text, QColor(COLORS["TEXT_MAIN"])) + palette.setColor(QPalette.Button, QColor("#FFFFFF")) # 白色底,避免黑色 + palette.setColor(QPalette.ButtonText, QColor(COLORS["TEXT_MAIN"])) + palette.setColor(QPalette.Highlight, QColor(COLORS["ACCENT_LIGHT"])) + palette.setColor(QPalette.HighlightedText, QColor("#FFFFFF")) + app.setPalette(palette) + + return COLORS diff --git a/ReinLoop/tests/test_device_heartbeat.py b/ReinLoop/tests/test_device_heartbeat.py new file mode 100644 index 0000000..ed8687d --- /dev/null +++ b/ReinLoop/tests/test_device_heartbeat.py @@ -0,0 +1,41 @@ +import importlib.util +from pathlib import Path +import sys +import types +import unittest +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "device_heartbeat.py" +SPEC = importlib.util.spec_from_file_location("device_heartbeat_under_test", MODULE_PATH) +HEARTBEAT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HEARTBEAT) + + +class DeviceHeartbeatTests(unittest.TestCase): + def test_sends_current_device_id_to_server(self): + calls = [] + requests_module = types.ModuleType("requests") + + class Response: + def raise_for_status(self): + return None + + def json(self): + return {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"} + + def post(url, json, timeout): + calls.append((url, json, timeout)) + return Response() + + requests_module.post = post + api_module = types.ModuleType("api") + api_module.data_record_url = "https://server.example/api" + api_module.the_folder = "company/line" + with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}): + timestamp = HEARTBEAT.heartbeat_device(timeout=7) + + self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z") + self.assertEqual(calls, [("https://server.example/api", { + "type": "deviceHeartbeat", "deviceId": "company/line" + }, 7)]) \ No newline at end of file diff --git a/ReinLoop/tests/test_identification_config.py b/ReinLoop/tests/test_identification_config.py new file mode 100644 index 0000000..502fbb1 --- /dev/null +++ b/ReinLoop/tests/test_identification_config.py @@ -0,0 +1,157 @@ +import importlib.util +import csv +import io +from pathlib import Path +import sys +import types +import unittest +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "identification_config.py" +SPEC = importlib.util.spec_from_file_location( + "identification_config_under_test", MODULE_PATH +) +IDENTIFICATION_CONFIG = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(IDENTIFICATION_CONFIG) +validate_identification_config = IDENTIFICATION_CONFIG.validate_identification_config +parse_identification_config_csv = IDENTIFICATION_CONFIG.parse_identification_config_csv +download_identification_config = IDENTIFICATION_CONFIG.download_identification_config + + +VALID_CONFIG = { + "q_in_val": 50.0, + "dt": 0.1, + "n_order": 6, + "t_c": 2.5, + "levels": [10, 20, 30, 40, 50, 60, 70, 80], + "dead_area": 240.0, + "xa_full": 1000.0, + "V_val": 5.0, + "repeat": 2, +} + + +def config_csv(config): + output = io.StringIO(newline="") + writer = csv.writer(output) + writer.writerow(("parameter", "value")) + for key in ( + "q_in_val", "dt", "n_order", "t_c", "levels", "dead_area", + "xa_full", "V_val", "repeat"): + value = config[key] + if key == "levels": + value = ",".join(str(item) for item in value) + writer.writerow((key, value)) + return output.getvalue() + + +class IdentificationConfigTests(unittest.TestCase): + def test_accepts_and_normalizes_valid_config(self): + result = validate_identification_config(VALID_CONFIG) + self.assertEqual(result["repeat"], 2) + self.assertEqual(result["levels"], [ + 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0 + ]) + + def test_rejects_missing_field(self): + config = dict(VALID_CONFIG) + config.pop("repeat") + with self.assertRaisesRegex(ValueError, "缺少"): + validate_identification_config(config) + + def test_parses_parameter_value_csv(self): + result = parse_identification_config_csv(config_csv(VALID_CONFIG)) + self.assertEqual(result, VALID_CONFIG) + + def test_rejects_non_power_of_two_levels(self): + config = dict(VALID_CONFIG, levels=[10, 20, 30]) + with self.assertRaisesRegex(ValueError, "2 的整数次幂"): + validate_identification_config(config) + + def test_rejects_symbol_period_shorter_than_sample_period(self): + config = dict(VALID_CONFIG, dt=0.1, t_c=0.05) + with self.assertRaisesRegex(ValueError, "t_c 必须大于等于 dt"): + validate_identification_config(config) + + def test_rejects_travel_scan_above_xa_full(self): + config = dict(VALID_CONFIG, xa_full=999.0) + with self.assertRaisesRegex(ValueError, "1000"): + validate_identification_config(config) + + def test_rejects_dead_area_at_or_above_xa_full(self): + config = dict(VALID_CONFIG, dead_area=1000.0) + with self.assertRaisesRegex(ValueError, "dead_area"): + validate_identification_config(config) + + def test_download_requests_customer_config_and_validates_it(self): + calls = [] + + class FakeResponse: + def __init__(self, body=None, text=None): + self.body = body + self.text = text + + def raise_for_status(self): + return None + + def json(self): + return self.body + + requests_module = types.ModuleType("requests") + requests_module.RequestException = Exception + + def post(url, json, timeout): + calls.append(("post", url, json, timeout)) + return FakeResponse({"success": True, "url": "https://temp/config"}) + + def get(url, timeout): + calls.append(("get", url, timeout)) + return FakeResponse(text=config_csv(VALID_CONFIG)) + + requests_module.post = post + requests_module.get = get + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + result = download_identification_config(timeout=7) + + self.assertEqual(result["repeat"], 2) + self.assertEqual(calls[0], ( + "post", + "https://cloud/data_record", + {"type": "getIdentificationConfig", "deviceId": "客户A"}, + 7, + )) + self.assertEqual(calls[1], ("get", "https://temp/config", 7)) + + def test_download_reports_cloud_rejection(self): + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"success": False, "errMsg": "配置不存在"} + + requests_module = types.ModuleType("requests") + requests_module.RequestException = Exception + requests_module.post = lambda *args, **kwargs: FakeResponse() + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + with self.assertRaisesRegex(ValueError, "配置不存在"): + download_identification_config() + + +if __name__ == "__main__": + unittest.main() diff --git a/ReinLoop/tests/test_identification_feedback.py b/ReinLoop/tests/test_identification_feedback.py new file mode 100644 index 0000000..be0b6ac --- /dev/null +++ b/ReinLoop/tests/test_identification_feedback.py @@ -0,0 +1,85 @@ +import importlib.util +from pathlib import Path +import sys +import types +import unittest +from unittest.mock import patch + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] / "core" / "identification_feedback.py" +) +SPEC = importlib.util.spec_from_file_location( + "identification_feedback_under_test", MODULE_PATH +) +FEEDBACK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FEEDBACK) + + +class FakeResponse: + def __init__(self, body): + self.body = body + + def raise_for_status(self): + return None + + def json(self): + return self.body + + +class IdentificationFeedbackTests(unittest.TestCase): + def call_with_response(self, response_body, callback): + calls = [] + requests_module = types.ModuleType("requests") + + def post(url, json, timeout): + calls.append((url, json, timeout)) + return FakeResponse(response_body) + + requests_module.post = post + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + value = callback() + return value, calls + + def test_registers_uploaded_csv(self): + _, calls = self.call_with_response( + {"success": True}, + lambda: FEEDBACK.register_identification_result("result.csv", 7), + ) + self.assertEqual(calls[0][1], { + "type": "registerIdentificationResult", + "deviceId": "客户A", + "runId": "result.csv", + "fileName": "result.csv", + }) + + def test_pending_feedback_returns_none(self): + value, _ = self.call_with_response( + {"success": True, "ready": False}, + lambda: FEEDBACK.get_identification_feedback("result.csv"), + ) + self.assertIsNone(value) + + def test_feedback_returns_only_zero_or_one(self): + for result in (0, 1): + value, _ = self.call_with_response( + {"success": True, "ready": True, "result": result}, + lambda: FEEDBACK.get_identification_feedback("result.csv"), + ) + self.assertEqual(value, result) + + with self.assertRaisesRegex(ValueError, "0 或 1"): + self.call_with_response( + {"success": True, "ready": True, "result": 2}, + lambda: FEEDBACK.get_identification_feedback("result.csv"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ReinLoop/tests/test_initial_travel_scan.py b/ReinLoop/tests/test_initial_travel_scan.py new file mode 100644 index 0000000..5f5e007 --- /dev/null +++ b/ReinLoop/tests/test_initial_travel_scan.py @@ -0,0 +1,153 @@ +import importlib.util +import json +from pathlib import Path +import sys +import types +import unittest +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "identification.py" + + +def load_identification_module(): + get_v = types.ModuleType("get_V") + get_v.measure_volume = lambda *args, **kwargs: None + ind_collector = types.ModuleType("ind_collector") + ind_collector.collect_data_with_prbs = lambda *args, **kwargs: {} + api = types.ModuleType("api") + api.base_url = "https://cloud.example" + api.data_record_url = "https://cloud.example/data_record" + api.the_folder = "customer-a" + requests = types.ModuleType("requests") + + spec = importlib.util.spec_from_file_location( + "identification_under_test", MODULE_PATH + ) + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, { + "get_V": get_v, + "ind_collector": ind_collector, + "api": api, + "requests": requests, + }): + spec.loader.exec_module(module) + return module + + +IDENTIFICATION = load_identification_module() + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def monotonic(self): + self.now += 0.001 + return self.now + + def sleep(self, duration): + self.now += max(0.0, duration) + + +class FakeConnectionManager: + def __init__(self): + self.distance = 0 + + def set_motor_position(self, distance): + self.distance = int(distance) + return True + + def read_pressure(self): + return self.distance / 100.0 + + +class InitialTravelScanTests(unittest.TestCase): + def test_uploads_distance_and_pressure_json_without_time_fields(self): + manager = IDENTIFICATION.IdentificationManager() + manager._identifying = True + captured = {} + + def capture_upload(body, filename, folder): + captured.update(body=body, filename=filename, folder=folder) + return True + + manager._upload_to_cos = capture_upload + clock = FakeClock() + with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \ + patch.object(IDENTIFICATION.time, "sleep", clock.sleep): + payload = manager._run_initial_travel_scan( + FakeConnectionManager() + ) + + records = payload["stable_pressures"] + expected_distances = list(range(1000, -1, -100)) + self.assertEqual( + [record["distance"] for record in records], expected_distances + ) + self.assertEqual( + [record["pressure"] for record in records], + [distance / 100.0 for distance in expected_distances], + ) + self.assertTrue(captured["filename"].endswith(".json")) + self.assertEqual(captured["folder"], "customer-a/ind_data") + self.assertEqual(json.loads(captured["body"]), payload) + self.assertTrue(all( + set(record) == {"distance", "pressure"} for record in records + )) + + def test_identification_uploads_collector_csv_and_notifies_filename(self): + manager = IDENTIFICATION.IdentificationManager() + manager._run_initial_travel_scan = lambda conn_mgr: {} + uploaded = {} + callbacks = [] + 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: ( + uploaded.update( + content=content, filename=filename, folder=folder + ) or True + ) + manager.set_identification_upload_callback( + lambda success, filename, error: + callbacks.append((success, filename, error)) + ) + + class ConnectedManager: + def is_connected(self): + return True + + collector_result = { + "success": True, + "csv_data": csv_data, + "filename": csv_filename, + } + with patch.object( + IDENTIFICATION, "collect_data_with_prbs", + return_value=collector_result): + started = manager.start_identification( + conn_mgr=ConnectedManager(), + running_flag_check=lambda: False, + q_in_val=50.0, + dt=0.1, + n_order=6, + t_c=2.5, + levels=[10, 20, 30, 40, 50, 60, 70, 80], + dead_area=240.0, + xa_full=1000.0, + V_val=5.0, + repeat=2, + ) + manager._task_thread.join(timeout=2) + + self.assertTrue(started) + self.assertFalse(manager._task_thread.is_alive()) + self.assertEqual(uploaded["content"], csv_data) + self.assertEqual(uploaded["filename"], csv_filename) + self.assertEqual(uploaded["folder"], "customer-a/ind_data") + self.assertEqual(callbacks, [(True, csv_filename, None)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ReinLoop/tests/test_license_protocol.py b/ReinLoop/tests/test_license_protocol.py new file mode 100644 index 0000000..e2b9784 --- /dev/null +++ b/ReinLoop/tests/test_license_protocol.py @@ -0,0 +1,122 @@ +"""Tests for the company/production-line license protocol.""" + +import base64 +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +LICENSE_SPEC = importlib.util.spec_from_file_location( + "license_utils_under_test", ROOT / "license_utils.py" +) +LICENSE = importlib.util.module_from_spec(LICENSE_SPEC) +LICENSE_SPEC.loader.exec_module(LICENSE) + + +class FakePublicKey: + def verify(self, *args, **kwargs): + return None + + +def license_file(payload): + payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode() + handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False) + handle.write(f"{payload_b64}|{base64.b64encode(b'signature').decode()}") + handle.close() + return handle.name + + +NEW_LICENSE = { + "license_id": "license-123", + "customer": "Sample Co", + "company_id": "company-123", + "production_line_id": "line-123", + "device_id": "sample-co/line-1", + "issued": "2026-01-01 00:00", + "expiry": "2099-01-01 00:00", + "features": "*", +} + + +class LicenseProtocolTests(unittest.TestCase): + def verify_payload(self, payload): + path = license_file(payload) + self.addCleanup(os.unlink, path) + with patch.object(LICENSE, "_load_public_key", return_value=FakePublicKey()): + return LICENSE.verify_license(path) + + def test_new_license_returns_company_and_line_identifiers(self): + self.assertEqual(self.verify_payload(NEW_LICENSE), NEW_LICENSE) + + def test_old_license_remains_valid(self): + legacy = { + "customer": "Legacy Customer", + "issued": "2026-01-01", + "expiry": "2099-01-01", + "features": "*", + } + self.assertEqual(self.verify_payload(legacy), legacy) + + def test_new_license_rejects_missing_organization_identifier(self): + invalid = dict(NEW_LICENSE) + invalid.pop("production_line_id") + with self.assertRaisesRegex(ValueError, "production_line_id"): + self.verify_payload(invalid) + + def test_new_license_rejects_unsafe_device_id(self): + invalid = dict(NEW_LICENSE, device_id="sample-co/../line-1") + with self.assertRaisesRegex(ValueError, "device_id"): + self.verify_payload(invalid) + + def test_online_active_status_is_accepted(self): + class Response: + def raise_for_status(self): + return None + + def json(self): + return {"success": True, "valid": True, "status": "active", + "licenseId": NEW_LICENSE["license_id"]} + + with patch.object(LICENSE.requests, "post", return_value=Response()): + LICENSE.validate_license_online(NEW_LICENSE) + + def test_online_invalid_statuses_are_rejected(self): + for status in ("revoked", "expired", "device_mismatch"): + with self.subTest(status=status): + class Response: + def raise_for_status(self): + return None + + def json(self): + return {"success": True, "valid": False, "status": status} + + with patch.object(LICENSE.requests, "post", return_value=Response()): + with self.assertRaisesRegex(LICENSE.ExpiredError, status): + LICENSE.validate_license_online(NEW_LICENSE) + + def test_online_network_failure_is_allowed_within_offline_grace(self): + LICENSE._last_online_success_monotonic = LICENSE._time_module.monotonic() + with patch.object(LICENSE.requests, "post", + side_effect=LICENSE.requests.ConnectionError("offline")): + LICENSE.validate_license_online(NEW_LICENSE) + + def test_api_rejects_environment_device_id_mismatch(self): + fake_license_utils = types.ModuleType("license_utils") + fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE) + api_spec = importlib.util.spec_from_file_location("api_under_test", ROOT / "api.py") + api_module = importlib.util.module_from_spec(api_spec) + with patch.dict(os.environ, {"REINLOOP_DEVICE_ID": "other/line"}, clear=False), \ + patch.dict(sys.modules, {"license_utils": fake_license_utils}): + with self.assertRaisesRegex(RuntimeError, "不一致"): + api_spec.loader.exec_module(api_module) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/ReinLoop/tests/test_volume_config.py b/ReinLoop/tests/test_volume_config.py new file mode 100644 index 0000000..6a9da41 --- /dev/null +++ b/ReinLoop/tests/test_volume_config.py @@ -0,0 +1,218 @@ +import json +import importlib.util +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "volume_config.py" +SPEC = importlib.util.spec_from_file_location("volume_config_under_test", MODULE_PATH) +VOLUME_CONFIG = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VOLUME_CONFIG) +load_volume_config = VOLUME_CONFIG.load_volume_config +validate_volume_config = VOLUME_CONFIG.validate_volume_config +create_volume_config_request = VOLUME_CONFIG.create_volume_config_request +poll_volume_config_request = VOLUME_CONFIG.poll_volume_config_request +acknowledge_volume_config_request = VOLUME_CONFIG.acknowledge_volume_config_request + + +VALID_CONFIG = { + "q_in_val": 50.0, "dt": 0.05, "p_max": 200.0, + "fit_low": 50.0, "fit_high": 150.0, "T_delta": 30.0, + "xa_full": 1000.0, "num_runs": 3, +} + + +class VolumeConfigTests(unittest.TestCase): + def write_config(self, directory, config): + path = Path(directory) / "volume.json" + path.write_text(json.dumps(config), encoding="utf-8") + return path + + def test_load_valid_config(self): + with tempfile.TemporaryDirectory() as directory: + result = load_volume_config(self.write_config(directory, VALID_CONFIG)) + self.assertEqual(result["num_runs"], 3) + self.assertEqual(result["xa_full"], 1000.0) + + def test_rejects_missing_field(self): + with tempfile.TemporaryDirectory() as directory: + config = dict(VALID_CONFIG) + config.pop("dt") + with self.assertRaisesRegex(ValueError, "缺少"): + load_volume_config(self.write_config(directory, config)) + + def test_rejects_invalid_range(self): + with tempfile.TemporaryDirectory() as directory: + config = dict(VALID_CONFIG, fit_high=40.0) + with self.assertRaisesRegex(ValueError, "fit_low"): + load_volume_config(self.write_config(directory, config)) + + def test_rejects_zero_flow(self): + with self.assertRaisesRegex(ValueError, "q_in_val 必须大于 0"): + validate_volume_config(dict(VALID_CONFIG, q_in_val=0)) + + def test_customer_creates_exactly_one_request_instruction(self): + calls = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return { + "success": True, + "requestId": "request-1", + "expiresAtMs": 123456, + } + + requests_module = types.ModuleType("requests") + + def post(url, json, timeout): + calls.append((url, json, timeout)) + return FakeResponse() + + requests_module.post = post + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + result = create_volume_config_request(timeout=7) + + self.assertEqual(result, { + "request_id": "request-1", + "expires_at_ms": 123456, + }) + self.assertEqual(calls, [( + "https://cloud/data_record", + {"type": "createVolumeConfigRequest", "deviceId": "客户A"}, + 7, + )]) + + def test_pending_request_does_not_download_a_file(self): + calls = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"success": True, "ready": False, "expired": False} + + requests_module = types.ModuleType("requests") + requests_module.post = lambda *args, **kwargs: ( + calls.append(("post", kwargs["json"])) or FakeResponse() + ) + requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0])) + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + result = poll_volume_config_request("request-1") + + self.assertEqual(result, {"ready": False, "expired": False}) + self.assertEqual(calls, [("post", { + "type": "getVolumeConfigRequest", + "deviceId": "客户A", + "requestId": "request-1", + })]) + + def test_ready_request_downloads_and_validates_json(self): + class FakeResponse: + def __init__(self, body): + self.body = body + + def raise_for_status(self): + return None + + def json(self): + return self.body + + requests_module = types.ModuleType("requests") + requests_module.post = lambda *args, **kwargs: FakeResponse({ + "success": True, + "ready": True, + "expired": False, + "url": "https://temp/volume.json", + }) + requests_module.get = lambda *args, **kwargs: FakeResponse( + dict(VALID_CONFIG) + ) + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + result = poll_volume_config_request("request-1") + + self.assertTrue(result["ready"]) + self.assertEqual(result["config"], VALID_CONFIG) + + def test_create_request_reports_server_rejection(self): + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"success": False, "errMsg": "尚未配置"} + + requests_module = types.ModuleType("requests") + requests_module.post = lambda *args, **kwargs: FakeResponse() + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + with self.assertRaisesRegex(ValueError, "尚未配置"): + create_volume_config_request() + + def test_acknowledges_the_same_request_for_cleanup(self): + calls = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return {"success": True, "deleted": 1} + + requests_module = types.ModuleType("requests") + requests_module.post = lambda *args, **kwargs: ( + calls.append(kwargs["json"]) or FakeResponse() + ) + api_module = types.ModuleType("api") + api_module.data_record_url = "https://cloud/data_record" + api_module.the_folder = "客户A" + + with patch.dict(sys.modules, { + "requests": requests_module, + "api": api_module, + }): + acknowledge_volume_config_request("request-1") + + self.assertEqual(calls, [{ + "type": "ackVolumeConfigRequest", + "deviceId": "客户A", + "requestId": "request-1", + }]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ReinLoop/tool/auto_test.py b/ReinLoop/tool/auto_test.py new file mode 100644 index 0000000..18b5fb7 --- /dev/null +++ b/ReinLoop/tool/auto_test.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +自动控制 GUI 界面脚本 - 通过模拟用户操作设置目标压力 + +升级功能: + 1. 加入坐标校准功能,摆脱写死的硬编码坐标 + 2. 自动寻找并置顶 GUI 窗口 + 3. 加入 PyAutoGUI 故障保护 (防失控) + +使用方法: + 1. 首次使用建议进行校准: python auto_test.py --calibrate --targets 50 80 100 + 2. 后续固定窗口位置后直接运行: python auto_test.py --targets 50 80 100 + python tool/auto_test.py --calibrate --targets 50 80 100 180 170 130 200 210 270 290 280 250 175 165 100 45 +""" + +import argparse +import time +import platform +import pyautogui + +try: + import pygetwindow as gw +except ImportError: + gw = None + +# 配置 PyAutoGUI +pyautogui.FAILSAFE = True # 将鼠标移动到屏幕四个角落可紧急停止脚本 +pyautogui.PAUSE = 0.3 # 每个动作后默认停顿 0.3 秒,让 UI 有时间反应 + +# 平台相关的全选快捷键:macOS 用 command,Windows/Linux 用 ctrl +_MODIFIER_KEY = 'command' if platform.system() == 'Darwin' else 'ctrl' + + +class GUIController: + def __init__(self): + # 默认坐标 (如果不使用 calibrate 模式,将使用这些备用坐标) + # 注意:这些默认值是错误的,请务必使用 --calibrate 参数校准 + self.input_x, self.input_y = 200, 150 + self.btn_x, self.btn_y = 320, 150 + + def activate_window(self, title_keyword="ReinLoop"): + """尝试寻找并激活目标窗口(支持部分标题匹配)""" + if gw is None: + print("⚠️ 未安装 pygetwindow,请手动确保 GUI 窗口在前台。") + print(" 安装命令: pip install pygetwindow") + return + + print(f"正在寻找包含 '{title_keyword}' 的窗口...") + try: + windows = gw.getWindowsWithTitle(title_keyword) + if windows: + win = windows[0] + if win.isMinimized: + win.restore() + win.activate() + print(f"✅ 成功激活窗口: {win.title}") + time.sleep(1) # 等待窗口彻底弹出 + else: + print(f"⚠️ 未找到包含 '{title_keyword}' 的窗口。") + print(f" 当前所有窗口列表:") + all_wins = gw.getAllWindows() + for w in all_wins: + if w.title.strip(): + print(f" - {w.title}") + print(" 请确保 ReinLoop GUI 已打开,或使用 --calibrate 后手动置顶窗口。") + except Exception as e: + print(f"⚠️ 窗口激活失败: {e},请手动将窗口切换到前台。") + + def calibrate(self): + """交互式坐标校准,动态获取按钮位置""" + print("\n" + "=" * 40) + print("🔧 进入坐标校准模式 (请不要切走窗口)") + print("=" * 40) + + print("\n👉 请在 5 秒内将鼠标光标移动到【目标压力输入框】中心...") + for i in range(5, 0, -1): + print(f"\r倒计时: {i} 秒", end='') + time.sleep(1) + self.input_x, self.input_y = pyautogui.position() + print(f"\n✅ 输入框坐标已记录: ({self.input_x}, {self.input_y})") + + print("\n👉 请在 5 秒内将鼠标光标移动到【设置目标】按钮中心...") + for i in range(5, 0, -1): + print(f"\r倒计时: {i} 秒", end='') + time.sleep(1) + self.btn_x, self.btn_y = pyautogui.position() + print(f"\n✅ 按钮坐标已记录: ({self.btn_x}, {self.btn_y})") + print("=" * 40 + "\n") + + def set_target_pressure(self, target): + """模拟用户操作设置目标压力""" + print(f"▶ 正在设置目标压力: {target} kPa") + try: + # 点击输入框 + pyautogui.click(x=self.input_x, y=self.input_y) + + # 全选并删除现有内容(macOS: command+a, Windows/Linux: ctrl+a) + pyautogui.hotkey(_MODIFIER_KEY, 'a') + pyautogui.press('backspace') + + # 输入新的目标压力值 + pyautogui.typewrite(str(target)) + + # 点击"设置目标"按钮 + pyautogui.click(x=self.btn_x, y=self.btn_y) + + print(f"✅ 成功设置目标压力: {target} kPa") + return True + + except Exception as e: + print(f"❌ 设置目标压力失败: {e}") + return False + + +def auto_control(targets, interval, do_calibrate): + print("=" * 60) + print("🤖 GUI 自动控制脚本启动") + print("提示: 运行过程中将鼠标移动到屏幕四个角落即可紧急停止") + print("=" * 60) + + controller = GUIController() + controller.activate_window() + + if do_calibrate: + controller.calibrate() + else: + print( + f"ℹ️ 使用默认坐标 (输入框: {controller.input_x},{controller.input_y} | " + f"按钮: {controller.btn_x},{controller.btn_y})") + print("⚠️ 如果点击位置不准确,请使用 --calibrate 参数运行脚本。") + + print("\n3秒后开始自动控制序列...") + time.sleep(3) + + for i, target in enumerate(targets): + print(f"\n--- 步骤 {i + 1}/{len(targets)} ---") + + if not controller.set_target_pressure(target): + print(f"❌ 步骤 {i + 1} 出现异常,提前终止自动控制") + break + + if i < len(targets) - 1: + print(f"等待 {interval} 秒...") + for j in range(interval, 0, -1): + print(f"\r剩余时间: {j} 秒 ", end='') + time.sleep(1) + print() + + print("\n🎉 自动控制序列全部完成!") + + +def main(): + parser = argparse.ArgumentParser(description='GUI 自动控制脚本') + parser.add_argument('--targets', type=float, nargs='+', default=[50, 80, 100, 120], + help='目标压力值列表,用空格隔开,单位 kPa') + parser.add_argument('--interval', type=int, default=10, + help='每个目标压力持续时间,单位秒') + parser.add_argument('--calibrate', action='store_true', + help='启动坐标校准模式,动态获取输入框和按钮的屏幕坐标') + + args = parser.parse_args() + auto_control(args.targets, args.interval, args.calibrate) + + +if __name__ == "__main__": + main() diff --git a/ReinLoop/tool/data_analyze b/ReinLoop/tool/data_analyze new file mode 100644 index 0000000..cc6f161 --- /dev/null +++ b/ReinLoop/tool/data_analyze @@ -0,0 +1,285 @@ +import os +import pickle +import glob + +def load_and_merge_pickle_chunks(folder_path, file_pattern="*.pkl"): + """ + 从指定文件夹中读取所有匹配的分片文件,解包并合并成一个总的数据列表。 + + Args: + folder_path: 存放 .pkl 分片文件的文件夹路径 + file_pattern: 文件匹配模式,默认匹配所有 .pkl 文件 + """ + all_episodes = [] + + # 获取所有匹配的 pkl 文件路径,并按名称排序(确保 part1, part2 顺序或逻辑清晰) + search_path = os.path.join(folder_path, file_pattern) + file_list = sorted(glob.glob(search_path)) + + if not file_list: + print(f"❌ 未在路径 【{folder_path}】 下找到任何匹配 【{file_pattern}】 的文件!") + return [] + + print(f"📂 找到 {len(file_list)} 个数据分片文件,开始加载...") + + for file_path in file_list: + try: + with open(file_path, 'rb') as f: + # 每个分片解包出来都是一个 list [ep1, ep2, ...] + chunk_data = pickle.load(f) + + if isinstance(chunk_data, list): + all_episodes.extend(chunk_data) + print(f" ✅ 成功加载: {os.path.basename(file_path)} (包含 {len(chunk_data)} 个 Episode)") + else: + print(f" ⚠️ 警告: {os.path.basename(file_path)} 解析出的数据格式不是列表,跳过。") + except Exception as e: + print(f" ❌ 读取文件 {os.path.basename(file_path)} 失败: {e}") + + print(f"整个序列加载完成,共合并了 {len(all_episodes)} 个 Episode。") + return all_episodes + + +def analyze_episodes_data(episode_data_raw): + """ + 分析 Episode 数据,统计超调情况。 + """ + total_episodes = len(episode_data_raw) + if total_episodes == 0: + print("没有数据可供分析。") + return + + invalid_count = 0 # 最后一步误差绝对值 > 2 kPa 的无效 episode + invalid_high_flow = 0 # 无效 episode 中流量 > 200 + invalid_low_flow = 0 # 无效 episode 中流量 < 100 + all_steady_abs_errors = [] # 所有有效 episode 的稳态误差(绝对值) + no_overshoot_count = 0 + no_overshoot_abs_errors = [] # 绝对值稳态误差 + no_overshoot_raw_errors = [] # 带符号稳态误差(+ = 高于目标, - = 低于目标) + overshoot_lt_1_count = 0 + overshoot_1_to_2_count = 0 + overshoot_2_to_3_count = 0 + overshoot_3_to_4_count = 0 + overshoot_4_to_5_count = 0 + overshoot_5_to_10_count = 0 + overshoot_gt_10_count = 0 + overshoots_5_to_10 = [] + overshoots_gt_10 = [] + + for idx, ep in enumerate(episode_data_raw): + pressures = ep.get('pressures', []) + target_p = ep.get('target_pressure', 0.0) + + if not pressures: + continue + + # 最后一步误差绝对值 > 2 kPa → 无效 episode,跳过 + errors = ep.get('errors', []) + if errors and abs(errors[-1]) > 2: + invalid_count += 1 + q = ep.get('Q_in', 0) + if q > 200: + invalid_high_flow += 1 + elif q < 100: + invalid_low_flow += 1 + continue + + initial_p = pressures[0] + + # 所有有效 episode 的稳态误差(最后 30 步绝对值均值) + if errors: + last_n = errors[-30:] if len(errors) >= 30 else errors + all_steady_abs_errors.append(sum(abs(e) for e in last_n) / len(last_n)) + + is_step_up = target_p >= initial_p # 升压为 True,降压为 False + overshoot = 0.0 + + if is_step_up: + # 升压:最大值大于目标压力为超调 + max_p = max(pressures) + if max_p > target_p: + overshoot = max_p - target_p + else: + # 降压:最小值小于目标压力为超调 + min_p = min(pressures) + if min_p < target_p: + overshoot = target_p - min_p + + # 统计区间 + if overshoot == 0: + no_overshoot_count += 1 + elif overshoot < 1.0: + overshoot_lt_1_count += 1 + # 最后 30 步的平均误差作为稳态误差(分别记录绝对值和带符号值) + if len(errors) >= 30: + last_30 = errors[-30:] + elif errors: + last_30 = errors + else: + last_30 = [] + if last_30: + no_overshoot_abs_errors.append(sum(abs(e) for e in last_30) / len(last_30)) + no_overshoot_raw_errors.append(sum(last_30) / len(last_30)) + elif 1.0 <= overshoot < 2.0: + overshoot_1_to_2_count += 1 + elif 2.0 <= overshoot < 3.0: + overshoot_2_to_3_count += 1 + elif 3.0 <= overshoot < 4.0: + overshoot_3_to_4_count += 1 + elif 4.0 <= overshoot <= 5.0: + overshoot_4_to_5_count += 1 + else: + item = { + "index": idx, + "direction": "升压" if is_step_up else "降压", + "initial_p": initial_p, + "target_p": target_p, + "overshoot_value": round(overshoot, 3), + "Q_in": ep.get("Q_in", 0), + } + if overshoot <= 10.0: + overshoot_5_to_10_count += 1 + overshoots_5_to_10.append(item) + else: + overshoot_gt_10_count += 1 + overshoots_gt_10.append(item) + + # 打印报告 + def _pct(n): return f"{n / total_episodes * 100:.1f}%" + + print("\n" + "="*25 + " 离线数据分析 " + "="*25) + valid_episodes = total_episodes - invalid_count + print(f"合并后的总 Episode 数 : {total_episodes}") + print(f" - 无效 Episode(末步误差>2): {invalid_count} ({_pct(invalid_count)})") + if invalid_count > 0: + print(f" ├ 流量 > 200 L/min : {invalid_high_flow}") + print(f" └ 流量 < 100 L/min : {invalid_low_flow}") + print(f" - 有效 Episode 数 : {valid_episodes}") + print(f" - 未超调的 Episode 数 : {no_overshoot_count} ({_pct(no_overshoot_count)})") + print(f" - 超调 < 1 kPa : {overshoot_lt_1_count} ({_pct(overshoot_lt_1_count)})") + print(f" - 超调在 1 ~ 2 kPa 之间 : {overshoot_1_to_2_count} ({_pct(overshoot_1_to_2_count)})") + print(f" - 超调在 2 ~ 3 kPa 之间 : {overshoot_2_to_3_count} ({_pct(overshoot_2_to_3_count)})") + print(f" - 超调在 3 ~ 4 kPa 之间 : {overshoot_3_to_4_count} ({_pct(overshoot_3_to_4_count)})") + print(f" - 超调在 4 ~ 5 kPa 之间 : {overshoot_4_to_5_count} ({_pct(overshoot_4_to_5_count)})") + print(f" - 超调在 5 ~ 10 kPa 之间 : {overshoot_5_to_10_count} ({_pct(overshoot_5_to_10_count)})") + print(f" - 超调 > 10 kPa : {overshoot_gt_10_count} ({_pct(overshoot_gt_10_count)})") + print("=" * 68) + + def _print_detail(title, items): + if items: + print(f"\n[⚠️ {title}]:") + for item in items: + print(f" * Episode [{item['index']}] ({item['direction']}): " + f"初始 {item['initial_p']:.2f} -> 目标 {item['target_p']:.2f} | " + f"超调量: {item['overshoot_value']:.2f} kPa | " + f"流量: {item['Q_in']:.1f} L/min") + + _print_detail("超调在 5 ~ 10 kPa", overshoots_5_to_10) + _print_detail("超调大于 10 kPa", overshoots_gt_10) + + if not overshoots_5_to_10 and not overshoots_gt_10: + print("\n🎉 极好!没有发现超调大于 5 kPa 的数据。") + + # ---- 流量分布统计 ---- + flow_bins = [ + (0, 10), (10, 50), (50, 100), (100, 150), + (150, 200), (200, 250), (250, 300), + ] + flow_counts = {f"{lo}~{hi}": 0 for lo, hi in flow_bins} + flow_counts["300+"] = 0 + + for ep in episode_data_raw: + q = ep.get('Q_in', 0) + placed = False + for lo, hi in flow_bins: + if lo <= q < hi: + flow_counts[f"{lo}~{hi}"] += 1 + placed = True + break + if not placed: + flow_counts["300+"] += 1 + + print(f"\n📊 流量分布统计 (共 {total_episodes} 个 Episode):") + for lo, hi in flow_bins: + label = f"{lo}~{hi}" + print(f" {label:>10} L/min : {flow_counts[label]:>5} ({flow_counts[label]/total_episodes*100:5.1f}%)") + print(f" {'300+':>10} L/min : {flow_counts['300+']:>5} ({flow_counts['300+']/total_episodes*100:5.1f}%)") + + if all_steady_abs_errors: + avg_all = sum(all_steady_abs_errors) / len(all_steady_abs_errors) + print(f"\n📊 所有有效 Episode 平均稳态误差(最后 30 步绝对值均值): {avg_all:.3f} kPa" + f" ({len(all_steady_abs_errors)} 个 Episode)") + + if no_overshoot_abs_errors: + avg_abs = sum(no_overshoot_abs_errors) / len(no_overshoot_abs_errors) + avg_raw = sum(no_overshoot_raw_errors) / len(no_overshoot_raw_errors) + print(f"\n📊 超调0~1kpa Episode 平均稳态误差(最后 30 步):") + print(f" 绝对值均值 : {avg_abs:.3f} kPa") + print(f" 带符号均值 : {avg_raw:.3f} kPa ({'偏高于目标' if avg_raw > 0 else '偏低' if avg_raw < 0 else '无偏'})" + f" ({no_overshoot_count} 个 Episode)") + + +def print_episode_detail(episode_data_raw, index): + """打印指定 episode 的完整数据""" + if index < 0 or index >= len(episode_data_raw): + print(f"❌ Episode 索引 {index} 超出范围 (0~{len(episode_data_raw)-1})") + return + + ep = episode_data_raw[index] + print(f"\n{'='*60}") + print(f" Episode [{index}] 完整数据") + print(f"{'='*60}") + + for key in ['Q_in', 'volume', 'target_pressure', 'mode']: + if key in ep: + print(f" {key}: {ep[key]}") + + pressures = ep.get('pressures', []) + errors = ep.get('errors', []) + valve_openings = ep.get('valves', []) + + print(f"\n 步数: {len(pressures)}") + if pressures: + print(f" 初始压力: {pressures[0]:.2f} kPa") + print(f" 最终压力: {pressures[-1]:.2f} kPa") + print(f" 目标压力: {ep.get('target_pressure', 'N/A')} kPa") + if errors: + print(f" 最终误差: {errors[-1]:.3f} kPa") + + print(f"\n {'步':>4s} {'压力(kPa)':>10s} {'误差(kPa)':>10s} {'开度(%)':>8s}") + print(f" {'-'*36}") + n = len(pressures) + for i in range(n): + p = pressures[i] + e = errors[i] if i < len(errors) else float('nan') + vo = valve_openings[i] if i < len(valve_openings) else float('nan') + print(f" {i:4d} {p:10.2f} {e:10.3f} {vo:8.2f}") + print(f"{'='*60}\n") + + +# --- 执行离线分析 --- +if __name__ == "__main__": + # 💡 数据存放文件夹路径 + DATA_FOLDER = "/Users/menglingrui/Documents/DominatedConvergence/cloud_down_file/永久/data_8L" + + # 1. 读取并合并分片 + merged_data = load_and_merge_pickle_chunks(DATA_FOLDER, file_pattern="*part*.pkl") + + # 2. 执行分析 + if merged_data: + analyze_episodes_data(merged_data) + # 3. 找出无效 episode(末步误差绝对值 > 2 kPa),打印前 3 个的完整数据 + # invalid_indices = [] + # for idx, ep in enumerate(merged_data): + # errors = ep.get('errors', []) + # if errors and abs(errors[-1]) > 2: + # invalid_indices.append(idx) + # if len(invalid_indices) >= 3: + # break + # if invalid_indices: + # print(f"\n找到 {len(invalid_indices)} 个无效 Episode,索引: {invalid_indices}") + # for idx in invalid_indices: + # print_episode_detail(merged_data, idx) + # else: + # print("\n未找到无效 Episode") + print_episode_detail(merged_data, 2500) \ No newline at end of file diff --git a/ReinLoop/tool/gui.py b/ReinLoop/tool/gui.py new file mode 100644 index 0000000..ba05fec --- /dev/null +++ b/ReinLoop/tool/gui.py @@ -0,0 +1,2024 @@ +# gui.py +import base64 + +import matplotlib +# from prompt_toolkit.key_binding.bindings.named_commands import self_insert +import requests + +matplotlib.use('TkAgg') +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import warnings +import tkinter as tk +from tkinter import ttk +# from tkinter import scrolledtext +import threading +import time +import os +import pickle +import datetime +import json +import threading +import sys +import numpy as np +# import pandas as pd +import serial.tools.list_ports +from stable_baselines3 import SAC +import torch +# from PressureEnv import CustomPressureEnv +from PcControl import Easy521ModbusClient, MotorModbusRTUClient +# from zzp import SECRET_KEY +# from PcControl import PressureModbusRTUClient, MotorModbusRTUClient +from controllers import IncrementalPID +from styles import apply_app_style +from get_V import measure_volume +from ind_collector import collect_data_with_prbs +import logging + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from api import base_url, data_record_url, the_folder + +warnings.filterwarnings('ignore') +logging.getLogger("pymodbus").setLevel(logging.ERROR) + +# 优化matplotlib设置 +matplotlib.rcParams['figure.max_open_warning'] = 20 +matplotlib.rcParams['axes.linewidth'] = 0.5 +matplotlib.rcParams['lines.linewidth'] = 1.0 +plt.rcParams['font.sans-serif'] = [ + 'Microsoft YaHei', # Windows 优先 (微软雅黑) + 'SimHei', # Windows 备选 (黑体) + 'PingFang SC', # macOS 优先 (苹方) + 'Heiti TC', # macOS 备选 (黑体) + 'sans-serif' # 最终兜底 +] +plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题 + +def get_base_path(): + """获取程序运行时的当前真实根目录""" + if getattr(sys, 'frozen', False): + return os.path.dirname(sys.executable) # exe所在目录 + else: + return os.path.dirname(os.path.abspath(__file__)) # py脚本所在目录 + +class ControlGUI: + def __init__(self, root): + self.root = root + self.root.title("ReinLoop-V1.0 - 收敛有界") + self.root.geometry("900x700") + + # 初始化Modbus客户端和NMPC控制器 + self.modbus_client = None + self.IncrementalPID = IncrementalPID(kp=1.0, ki=0.4, kd=0, dt=0.1, out_min=0, out_max=100) + + # 定义 (容积, 流量) 组合与模型的映射关系 + # self.update_selectors_from_config() # 更新下拉菜单 + self.condition_to_model_map = {} # 先初始化为空 + # self.load_config_from_json() # 调用外部读取方法 + + # 控制标志 + self.running = False + self.control_thread = None + + # 数据记录 + self.pressure_data = [] + self.target_data = [] + self.valve_data = [] + self.time_data = [] + self.cycle_count = 0 + self.start_time = None + + # --- 数据记录与状态显示 --- + + # --- 数据收集与系统辨识相关 --- + self.collect_data_var = tk.BooleanVar(value=False) # 数据收集开关 + self.episode_data_raw = [] # 存放所有 Episode 的列表 + self.current_episode = None # 当前正在记录的 Episode + self.steady_count = 0 # 稳态计数器 + self.last_target_rl = None # 记录上一个目标值,用于RL模型 + self.last_target_record = None # 记录上一个目标值,用于切分 Episode + + # 绘图相关 + self.current_fig = None + self.current_ax1 = None + self.current_ax2 = None + self.current_canvas = None + self.x_min_var = None + self.x_max_var = None + self.is_plotting = False + + self.setup_gui() + self.scan_models_folder() + + self.confirmed_target_pressure = float(self.target_entry.get()) + + def safe_log(self, message): + if hasattr(self, "log_text"): + self.log_message(message) + else: + print(message) + + def update_selectors_from_config(self): + """根据加载到的配置更新界面下拉框内容""" + if not self.condition_to_model_map: + return + vols = sorted(list(set([k[0] for k in self.condition_to_model_map.keys()])), key=float) + flows = sorted(list(set([k[1] for k in self.condition_to_model_map.keys()])), key=float) + if hasattr(self, 'volume_selector'): + self.volume_selector['values'] = vols + self.flow_selector['values'] = flows + + def _update_pid_ui(self, kp, ki, kd=None): + """实时更新界面上的 PID 参数显示""" + # 必须先解除禁用状态才能修改文字 + current_state = self.Kp_entry['state'] + self.Kp_entry.config(state=tk.NORMAL) + self.Ki_entry.config(state=tk.NORMAL) + self.Kd_entry.config(state=tk.NORMAL) + + self.Kp_entry.delete(0, tk.END) + self.Kp_entry.insert(0, f"{kp:.3f}") + self.Ki_entry.delete(0, tk.END) + self.Ki_entry.insert(0, f"{ki:.3f}") + if kd is not None: + self.Kd_entry.delete(0, tk.END) + self.Kd_entry.insert(0, f"{kd:.3f}") + + # 恢复之前的状态 (如果是在RL模式下,它应该变回灰色的 DISABLED) + self.Kp_entry.config(state=current_state) + self.Ki_entry.config(state=current_state) + self.Kd_entry.config(state=current_state) + + def setup_gui(self): + """严格划分双标签页(页1:连接设置,页2:控制设置)""" + # ========================================== + # 1. 应用统一配色与全局 ttk 样式(定义见 styles.py) + # ========================================== + colors = apply_app_style(self.root) + BG_COLOR = colors["BG_COLOR"] + CARD_BG = colors["CARD_BG"] + BORDER_COLOR = colors["BORDER_COLOR"] + TEXT_MAIN = colors["TEXT_MAIN"] + TEXT_MUTED = colors["TEXT_MUTED"] + ACCENT_LIGHT = colors["ACCENT_LIGHT"] + ACCENT_DARK = colors["ACCENT_DARK"] + HOVER_BLUE = colors["HOVER_BLUE"] + ACCENT_BLUE = colors["ACCENT_BLUE"] + SUCCESS_GREEN = colors["SUCCESS_GREEN"] + + # ========================================== + # 2. 创建顶部导航栏(深色横贯条:第一行系统名,第二行标签页) + # 系统名与标签页同处一个深色容器内,浑然一体,无分界线 + # ========================================== + nav_frame = tk.Frame(self.root, bg=ACCENT_DARK) + nav_frame.pack(fill=tk.X, side=tk.TOP) + + # --- 第一行:系统名 --- + title_row = tk.Frame(nav_frame, bg=ACCENT_DARK) + title_row.pack(fill=tk.X) + title_label = tk.Label( + title_row, + text="ReinLoop", + bg=ACCENT_DARK, + fg="white", + font=("Microsoft YaHei", 20, "bold") + ) + title_label.pack(side=tk.LEFT, padx=20, pady=(10, 4)) + + # --- 第二行:标签页(自定义按钮,仅颜色变化,尺寸恒定)--- + tab_row = tk.Frame(nav_frame, bg=ACCENT_DARK) + tab_row.pack(fill=tk.X) + + # ========================================== + # 3. 创建核心双标签页容器(隐藏自带标签栏,由上方导航栏切换) + # ========================================== + self.notebook = ttk.Notebook(self.root) + self.notebook.pack(expand=True, fill=tk.BOTH, padx=10, pady=10) + + # 构建三个独立的标签页 Frame + self.tab1 = ttk.Frame(self.notebook, padding="15") + self.tab2 = ttk.Frame(self.notebook, padding="15") + self.tab3 = ttk.Frame(self.notebook, padding="15") + + self.notebook.add(self.tab1) + self.notebook.add(self.tab2) + self.notebook.add(self.tab3) + + # --- 自定义导航标签按钮 --- + # 颜色:未选中=深色主题色(与导航栏融为一体),选中=浅色主题色,悬停=过渡色 + self._nav_tab_buttons = [] + nav_tabs = [("连接设置", self.tab1), ("控制设置", self.tab2), ("模型调试", self.tab3)] + + def _select_nav_tab(index): + self.notebook.select(index) + for i, btn in enumerate(self._nav_tab_buttons): + if i == index: + btn.config(bg=ACCENT_LIGHT, fg="white") + else: + btn.config(bg=ACCENT_DARK, fg="white") + # 立即刷新空闲任务队列,强制新页面马上重绘 + # (否则单击时事件队列为空,页面重绘会被延迟到下一个事件到来时才显示) + self.notebook.update_idletasks() + + for idx, (label_text, _tab) in enumerate(nav_tabs): + btn = tk.Label( + tab_row, + text=label_text, + bg=ACCENT_DARK, + fg="white", + font=("Microsoft YaHei", 16, "bold"), + padx=24, + pady=8, + cursor="hand2" + ) + btn.pack(side=tk.LEFT, padx=(20 if idx == 0 else 4, 0), pady=(0, 4)) + btn.bind("", lambda e, i=idx: _select_nav_tab(i)) + + def _on_enter(e, b=btn, i=idx): + if self.notebook.index(self.notebook.select()) != i: + b.config(bg=HOVER_BLUE) + + def _on_leave(e, b=btn, i=idx): + if self.notebook.index(self.notebook.select()) != i: + b.config(bg=ACCENT_DARK) + + btn.bind("", _on_enter) + btn.bind("", _on_leave) + self._nav_tab_buttons.append(btn) + + # 默认选中第一个标签页 + _select_nav_tab(0) + + # ========================================== + # 3. 布局【页面 1:连接设置】 + # ========================================== + # 列配置:标签列固定宽度,控件列自适应 + self.tab1.columnconfigure(0, minsize=140) + self.tab1.columnconfigure(1, weight=1) + + _r = 0 # 行计数器 + + # ---------------- Modbus TCP 区 ---------------- + ttk.Label(self.tab1, text="Modbus TCP", style="Section.TLabel").grid( + row=_r, column=0, columnspan=2, sticky=tk.W, padx=12, pady=(8, 2)) + _r += 1 + + # PLC 地址 + ttk.Label(self.tab1, text="PLC地址:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + self.tcp_ip_entry = ttk.Entry(self.tab1, width=20) + self.tcp_ip_entry.insert(0, "192.168.1.88") + self.tcp_ip_entry.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + _r += 1 + + # 端口 + ttk.Label(self.tab1, text="端口:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + self.tcp_port_entry = ttk.Entry(self.tab1, width=20) + self.tcp_port_entry.insert(0, "502") + self.tcp_port_entry.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + _r += 1 + + # 读取压力寄存器地址 + ttk.Label(self.tab1, text="读取压力寄存器地址:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + self.pressure_addr_entry = ttk.Entry(self.tab1, width=20) + self.pressure_addr_entry.insert(0, "504") + self.pressure_addr_entry.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + _r += 1 + + # ---------------- Modbus RTU 区 ---------------- + ttk.Label(self.tab1, text="Modbus RTU", style="Section.TLabel").grid( + row=_r, column=0, columnspan=2, sticky=tk.W, padx=12, pady=(18, 2)) + _r += 1 + + # 端口号(下拉) + ttk.Label(self.tab1, text="端口号:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + self.serial_port_var = tk.StringVar() + self.serial_port_cb = ttk.Combobox(self.tab1, textvariable=self.serial_port_var, width=18, state="readonly") + self.serial_port_cb.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + _r += 1 + + # 波特率(下拉) + ttk.Label(self.tab1, text="波特率:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + self.baudrate_var = tk.StringVar(value="115200") + self.baudrate_cb = ttk.Combobox(self.tab1, textvariable=self.baudrate_var, width=18, state="readonly", + values=["9600", "19200", "38400", "57600", "115200"]) + self.baudrate_cb.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + _r += 1 + + # 站号 / 数据位 / 停止位(同一行) + ttk.Label(self.tab1, text="站号:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + rtu_line = tk.Frame(self.tab1, bg=CARD_BG) + rtu_line.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + self.rtu_slave_entry = ttk.Entry(rtu_line, width=6) + self.rtu_slave_entry.insert(0, "4") + self.rtu_slave_entry.pack(side=tk.LEFT) + ttk.Label(rtu_line, text="数据位:").pack(side=tk.LEFT, padx=(20, 5)) + self.databits_entry = ttk.Entry(rtu_line, width=6) + self.databits_entry.insert(0, "8") + self.databits_entry.pack(side=tk.LEFT) + ttk.Label(rtu_line, text="停止位:").pack(side=tk.LEFT, padx=(20, 5)) + self.stopbits_entry = ttk.Entry(rtu_line, width=6) + self.stopbits_entry.insert(0, "1") + self.stopbits_entry.pack(side=tk.LEFT) + _r += 1 + + # 校验位(下拉) + ttk.Label(self.tab1, text="校验位:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) + self.parity_var = tk.StringVar(value="None") + self.parity_cb = ttk.Combobox(self.tab1, textvariable=self.parity_var, width=18, state="readonly", + values=["None", "Odd", "Even"]) + self.parity_cb.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) + _r += 1 + + # 操作动作按钮组 + btn_group = tk.Frame(self.tab1, bg=CARD_BG) + btn_group.grid(row=_r, column=0, columnspan=2, sticky=tk.W, padx=10, pady=20) + + self.refresh_port_btn = ttk.Button(btn_group, text="🔄 刷新", command=self.refresh_serial_ports) + self.refresh_port_btn.pack(side=tk.LEFT, padx=(0, 15)) + + self.connect_btn = ttk.Button(btn_group, text="连接设备", style="Action.TButton", command=self.toggle_connection) + self.connect_btn.pack(side=tk.LEFT, padx=5) + + # 初始化调用一次串口刷新 + self.refresh_serial_ports() + + # ========================================== + # 4. 布局【页面 2:控制设置】 + # ========================================== + self.tab2.columnconfigure(0, weight=1) + self.tab2.rowconfigure(3, weight=1) # 允许底部的数据图表与日志终端拉伸 + + # --- [A. 实时监控大字号仪表看板] --- + status_frame = ttk.LabelFrame(self.tab2, text=" 系统状态 ", padding="10") + status_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + status_frame.columnconfigure((0, 1, 2), weight=1, uniform="status_cards") + + # 当前压力 + p_card = tk.Frame(status_frame, bg=CARD_BG, bd=1, relief="solid") + p_card.grid(row=0, column=0, padx=6, pady=6, sticky="nsew") + tk.Label(p_card, text="当前系统压力", bg=CARD_BG, fg=TEXT_MUTED, font=("Microsoft YaHei", 18)).pack(anchor="w", padx=10, pady=(8, 2)) + self.current_pressure_var = tk.StringVar(value="0.0 kPa") + tk.Label(p_card, textvariable=self.current_pressure_var, bg=CARD_BG, fg="#059669", font=("Century Gothic", 28, "bold")).pack(anchor="w", padx=10, pady=(0, 8)) + + # 目标压力 + t_card = tk.Frame(status_frame, bg=CARD_BG, bd=1, relief="solid") + t_card.grid(row=0, column=1, padx=6, pady=6, sticky="nsew") + tk.Label(t_card, text="设定目标压力", bg=CARD_BG, fg=TEXT_MUTED, font=("Microsoft YaHei", 18)).pack(anchor="w", padx=10, pady=(8, 2)) + self.target_pressure_var = tk.StringVar(value="0.0 kPa") + tk.Label(t_card, textvariable=self.target_pressure_var, bg=CARD_BG, fg=ACCENT_BLUE, font=("Century Gothic", 28, "bold")).pack(anchor="w", padx=10, pady=(0, 8)) + + # 阀门开度 + v_card = tk.Frame(status_frame, bg=CARD_BG, bd=1, relief="solid") + v_card.grid(row=0, column=2, padx=6, pady=6, sticky="nsew") + tk.Label(v_card, text="控制阀门开度", bg=CARD_BG, fg=TEXT_MUTED, font=("Microsoft YaHei", 18)).pack(anchor="w", padx=10, pady=(8, 2)) + self.valve_opening_var = tk.StringVar(value="0.0 %") + tk.Label(v_card, textvariable=self.valve_opening_var, bg=CARD_BG, fg="#D97706", font=("Century Gothic", 28, "bold")).pack(anchor="w", padx=10, pady=(0, 8)) + + # --- [B. 参数运行模态配置区] --- + control_frame = ttk.LabelFrame(self.tab2, text=" 控制设置 ", padding="10") + control_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) + + # 物理工况:环境容积 + 稳态流量 + row0 = tk.Frame(control_frame, bg=CARD_BG) + row0.pack(fill=tk.X, pady=5) + ttk.Label(row0, text="物理工况:").pack(side=tk.LEFT, padx=(0, 15)) + ttk.Label(row0, text="容积:").pack(side=tk.LEFT) + self.volume_var = tk.StringVar(value="2") + self.volume_entry = ttk.Entry(row0, textvariable=self.volume_var, width=6) + self.volume_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(row0, text="L").pack(side=tk.LEFT, padx=(2, 20)) + ttk.Label(row0, text="流量:").pack(side=tk.LEFT) + self.flow_var = tk.StringVar(value="100") + self.flow_entry = ttk.Entry(row0, textvariable=self.flow_var, width=6) + self.flow_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(row0, text="L/min").pack(side=tk.LEFT, padx=2) + + # 目标压力设定 + 控制启停 + row1 = tk.Frame(control_frame, bg=CARD_BG) + row1.pack(fill=tk.X, pady=5) + ttk.Label(row1, text="目标压力:").pack(side=tk.LEFT, padx=(0, 15)) + self.target_entry = ttk.Entry(row1, width=10) + self.target_entry.insert(0, "80.0") + self.target_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(row1, text="kPa").pack(side=tk.LEFT, padx=(2, 10)) + self.set_target_btn = ttk.Button(row1, text="设置目标", command=self.set_target_pressure) + self.set_target_btn.pack(side=tk.LEFT, padx=5) + + # 控制模式单选组 + row2 = tk.Frame(control_frame, bg=CARD_BG) + row2.pack(fill=tk.X, pady=5) + ttk.Label(row2, text="控制方式:").pack(side=tk.LEFT, padx=(0, 15)) + self.control_mode_var = tk.StringVar(value="RL") + self.radio_rl = ttk.Radiobutton(row2, text="智能自动", variable=self.control_mode_var, value="RL", command=self._on_mode_change) + self.radio_rl.pack(side=tk.LEFT, padx=10) + self.radio_pid = ttk.Radiobutton(row2, text="手动PID", variable=self.control_mode_var, value="PID", command=self._on_mode_change) + self.radio_pid.pack(side=tk.LEFT, padx=10) + self.radio_manual = ttk.Radiobutton(row2, text="设置开度", variable=self.control_mode_var, value="MANUAL", command=self._on_mode_change) + self.radio_manual.pack(side=tk.LEFT, padx=10) + + # 强化学习决策模型加载组 + self.rl_frame = tk.Frame(control_frame, bg=CARD_BG) + self.rl_frame.pack(fill=tk.X, pady=5) + ttk.Label(self.rl_frame, text="决策模型:").pack(side=tk.LEFT, padx=(0, 15)) + self.model_combobox = ttk.Combobox(self.rl_frame, width=25, state="readonly") + self.model_combobox.pack(side=tk.LEFT, padx=5) + self.load_model_btn = ttk.Button(self.rl_frame, text="加载模型", command=self.load_rl_model) + self.load_model_btn.pack(side=tk.LEFT, padx=5) + self.refresh_models_btn = ttk.Button(self.rl_frame, text="🔄 刷新", width=6, command=self.scan_models_folder) + self.refresh_models_btn.pack(side=tk.LEFT, padx=5) + + # 经典PID调节面板 + self.pid_frame = tk.Frame(control_frame, bg=CARD_BG) + self.pid_frame.pack(fill=tk.X, pady=5) + ttk.Label(self.pid_frame, text="PID 调节:").pack(side=tk.LEFT, padx=(0, 15)) + ttk.Label(self.pid_frame, text="Kp:").pack(side=tk.LEFT) + self.Kp_entry = ttk.Entry(self.pid_frame, width=6) + self.Kp_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(self.pid_frame, text="Ki:").pack(side=tk.LEFT) + self.Ki_entry = ttk.Entry(self.pid_frame, width=6) + self.Ki_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(self.pid_frame, text="Kd:").pack(side=tk.LEFT) + self.Kd_entry = ttk.Entry(self.pid_frame, width=6) + self.Kd_entry.pack(side=tk.LEFT, padx=5) + self.update_pid_btn = ttk.Button(self.pid_frame, text="更新PID参数", command=self.update_pid_parameters) + self.update_pid_btn.pack(side=tk.LEFT, padx=(10, 0)) + + # 设置开度 + self.manual_frame = tk.Frame(control_frame, bg=CARD_BG) + self.manual_frame.pack(fill=tk.X, pady=5) + ttk.Label(self.manual_frame, text="设置开度:").pack(side=tk.LEFT, padx=(0, 15)) + self.valve_entry = ttk.Entry(self.manual_frame, width=10) + self.valve_entry.insert(0, " ") + self.valve_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(self.manual_frame, text="%").pack(side=tk.LEFT, padx=(2, 10)) + self.set_valve_btn = ttk.Button(self.manual_frame, text="设置", command=self.set_valve) + self.set_valve_btn.pack(side=tk.LEFT, padx=5) + + # 控制启停行(最后一行,始终在底部) + control_row = tk.Frame(control_frame, bg=CARD_BG) + control_row.pack(fill=tk.X, pady=5, side=tk.BOTTOM) + self.start_btn = ttk.Button(control_row, text="开始控制", style="Action.TButton", command=self.toggle_control) + self.start_btn.pack(side=tk.LEFT, padx=5) + self.plot_btn = ttk.Button(control_row, text="绘制曲线", command=self.plot_control_data) + self.plot_btn.pack(side=tk.LEFT, padx=5) + # self.chk_collect_data = tk.Checkbutton(control_row, text="同步收集数据集", variable=self.collect_data_var, bg=CARD_BG) + self.chk_collect_data = tk.Checkbutton( + control_row, + text="同步收集数据集", + variable=self.collect_data_var, + bg=CARD_BG, + activebackground=CARD_BG, + selectcolor="#0354AE", # 勾选时背景色为蓝色 + fg="#1F2937", # 文字颜色 + activeforeground="#1F2937" + ) + self.chk_collect_data.pack(side=tk.LEFT, padx=(15, 0)) + + # --- [D. 预留空间] --- + # 日志栏已移至底部导航栏 + + # ========================================== + # 5. 布局【页面 3:模型调试】—— 与页面1一致:grid 排列、无外框 + # ========================================== + self.tab3.columnconfigure(0, weight=1) + + # --- [A. 系统辨识] --- + identify_frame = ttk.LabelFrame(self.tab3, text=" 系统辨识 ", padding="10") + identify_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + + # 压力上限(对应 measure_volume 的 p_max) + pmax_row = tk.Frame(identify_frame, bg=CARD_BG) + pmax_row.pack(fill=tk.X, pady=5) + ttk.Label(pmax_row, text="压力上限:").pack(side=tk.LEFT) + self.p_max_var = tk.StringVar(value="200") + self.p_max_entry = ttk.Entry(pmax_row, textvariable=self.p_max_var, width=6) + self.p_max_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(pmax_row, text="kPa").pack(side=tk.LEFT) + + # 过程升温(对应 measure_volume 的 t_delta,单位 °C) + tdelta_row = tk.Frame(identify_frame, bg=CARD_BG) + tdelta_row.pack(fill=tk.X, pady=5) + ttk.Label(tdelta_row, text="过程升温:").pack(side=tk.LEFT) + self.t_delta_var = tk.StringVar(value="30") + self.t_delta_entry = ttk.Entry(tdelta_row, textvariable=self.t_delta_var, width=6) + self.t_delta_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(tdelta_row, text="°C").pack(side=tk.LEFT) + + # 约束上界 / 下界(对应 measure_volume 的 fit_high / fit_low) + constraint_row = tk.Frame(identify_frame, bg=CARD_BG) + constraint_row.pack(fill=tk.X, pady=5) + ttk.Label(constraint_row, text="约束上界:").pack(side=tk.LEFT) + self.fit_high_var = tk.StringVar(value="150") + self.fit_high_entry = ttk.Entry(constraint_row, textvariable=self.fit_high_var, width=6) + self.fit_high_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(constraint_row, text="下界:").pack(side=tk.LEFT, padx=(20, 0)) + self.fit_low_var = tk.StringVar(value="50") + self.fit_low_entry = ttk.Entry(constraint_row, textvariable=self.fit_low_var, width=6) + self.fit_low_entry.pack(side=tk.LEFT, padx=5) + + # 容积 + 测试按钮 + volumn_row = tk.Frame(identify_frame, bg=CARD_BG) + volumn_row.pack(fill=tk.X, pady=5) + ttk.Label(volumn_row, text="容积:").pack(side=tk.LEFT) + self.volume_var = tk.StringVar(value="") + self.volume_entry = ttk.Entry(volumn_row, textvariable=self.volume_var, width=6) + self.volume_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(volumn_row, text="L").pack(side=tk.LEFT, padx=(0, 20)) + self.test_btn = ttk.Button(volumn_row, text="测试", command=self.get_V) + self.test_btn.pack(side=tk.LEFT, padx=5) + + # 周期(对应 collect_data_with_prbs 的 t_c) + period_row = tk.Frame(identify_frame, bg=CARD_BG) + period_row.pack(fill=tk.X, pady=5) + ttk.Label(period_row, text="周期:").pack(side=tk.LEFT) + self.period_var = tk.StringVar(value="2.5") + self.period_entry = ttk.Entry(period_row, textvariable=self.period_var, width=6) + self.period_entry.pack(side=tk.LEFT, padx=5) + ttk.Label(period_row, text="s").pack(side=tk.LEFT) + + # 阶数(对应 n_order) + order_row = tk.Frame(identify_frame, bg=CARD_BG) + order_row.pack(fill=tk.X, pady=5) + ttk.Label(order_row, text="阶数:").pack(side=tk.LEFT) + self.order_var = tk.StringVar(value="6") + self.order_entry = ttk.Entry(order_row, textvariable=self.order_var, width=6) + self.order_entry.pack(side=tk.LEFT, padx=5) + + # 序列(对应 levels)+ 开始辨识按钮 + ident_row = tk.Frame(identify_frame, bg=CARD_BG) + ident_row.pack(fill=tk.X, pady=5) + ttk.Label(ident_row, text="序列:").pack(side=tk.LEFT) + self.levels_var = tk.StringVar() + self.levels_entry = ttk.Entry(ident_row, textvariable=self.levels_var, width=15) + self.levels_entry.pack(side=tk.LEFT, padx=5) + self.identify_btn = ttk.Button(ident_row, text="开始辨识", command=self.start_identification) + self.identify_btn.pack(side=tk.LEFT, padx=5) + + # --- [B. 高级设置] --- + advanced_frame = ttk.LabelFrame(self.tab3, text=" 高级设置 ", padding="10") + advanced_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) + + # 死区(控制用 dead_area + collect_data_with_prbs 的 dead_area) + dz_row = tk.Frame(advanced_frame, bg=CARD_BG) + dz_row.pack(fill=tk.X, pady=5) + ttk.Label(dz_row, text="死区:").pack(side=tk.LEFT) + self.dz_var = tk.StringVar(value="") + self.dz_entry = ttk.Entry(dz_row, textvariable=self.dz_var, width=6) + self.dz_entry.pack(side=tk.LEFT, padx=5) + + # 单步限幅(原 motor_max -> du_max) + bound_row = tk.Frame(advanced_frame, bg=CARD_BG) + bound_row.pack(fill=tk.X, pady=5) + ttk.Label(bound_row, text="单步限幅:").pack(side=tk.LEFT) + self.motor_max_var = tk.StringVar(value="") + self.motor_max_entry = ttk.Entry(bound_row, textvariable=self.motor_max_var, width=6) + self.motor_max_entry.pack(side=tk.LEFT, padx=5) + + # 总限幅(对应 collect_data_with_prbs 的 xa_full) + xa_full_row = tk.Frame(advanced_frame, bg=CARD_BG) + xa_full_row.pack(fill=tk.X, pady=5) + ttk.Label(xa_full_row, text="总限幅:").pack(side=tk.LEFT) + self.xa_full_var = tk.StringVar(value="749") + self.xa_full_entry = ttk.Entry(xa_full_row, textvariable=self.xa_full_var, width=6) + self.xa_full_entry.pack(side=tk.LEFT, padx=5) + + # ========================================== + # 6. 底部导航栏(包含日志和连接状态) + # ========================================== + self.bottom_frame = tk.Frame(self.root, bg=BG_COLOR) + self.bottom_frame.pack(fill=tk.X, side=tk.BOTTOM, padx=5, pady=1) + + # 设置两列权重:第一列(日志)占 3,第二列(状态)占 1,即比例 3:1 + self.bottom_frame.columnconfigure(0, weight=3) # 日志区域 + self.bottom_frame.columnconfigure(1, weight=1, minsize=150) # 状态区域 + + # 左侧容器:日志 + log_container = tk.Frame(self.bottom_frame, bg=BG_COLOR) + log_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=5) + + # self.log_text = scrolledtext.ScrolledText( + # log_container, height=2, + # bg=BG_COLOR, fg="#059669", + # insertbackground="#1F2937", selectbackground="#93C5FD", + # highlightthickness=0, + # relief="flat", borderwidth=0, font=("Consolas", 15) + # ) + self.log_text = tk.Text( + log_container, height=1, + fg=TEXT_MUTED, + insertbackground="#1F2937", selectbackground="#93C5FD", + highlightthickness=0, relief="flat", borderwidth=0, + font=("Consolas", 15) + ) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # 右侧容器:连接状态 + status_container = tk.Frame(self.bottom_frame, bg=BG_COLOR) + status_container.grid(row=0, column=1, sticky="nsew", padx=0, pady=5) + + self.connection_status_var = tk.StringVar(value="未连接") + self.status_lbl = tk.Label( + status_container, + textvariable=self.connection_status_var, + bg=BG_COLOR, + fg="#EF4444", + font=("Microsoft YaHei", 20, "bold") + ) + self.status_lbl.pack(expand=True, fill=tk.BOTH) + + # 联动更新初始的 PID/RL 输入框置灰状态 + self._on_mode_change() + + def refresh_serial_ports(self): + """扫描当前电脑可用的所有物理/虚拟串口并更新两个下拉框(压力表和电机)""" + ports = [port.device for port in serial.tools.list_ports.comports()] + + # 更新压力表串口下拉框 + # self.pressure_serial_cb['values'] = ports + # if ports: + # self.pressure_serial_cb.current(0) # 默认选中第一个可用串口 + # else: + # self.pressure_serial_cb.set("无可用串口") + # self.safe_log("警告: 未检测到任何可用串口,请检查压力表线缆连接!") + + # 更新电机串口下拉框 + self.serial_port_cb['values'] = ports + if ports: + self.serial_port_cb.current(0) # 默认选中第一个可用串口 + # self.log_message(f"已扫描到 {len(ports)} 个串口") # 可选:为了避免启动时日志太啰嗦,这行可以注释掉 + else: + self.serial_port_cb.set("无可用串口") + self.safe_log("警告: 未检测到任何可用串口,请检查电机线缆连接!") + + def scan_models_folder(self): + """从云端 model_config 文件夹扫描模型文件(不再扫描本地)""" + def fetch_models(): + try: + payload = {"type": "listModels", "folder": f"{the_folder}/model_config"} + resp = requests.post(data_record_url, json=payload, timeout=10) + result = resp.json() + if result.get("success"): + files = result.get("files", []) + file_list = result.get("fileList", []) + # 建立 文件名 -> fileID 的映射(downloadModel 云函数需要 fileID) + self.model_file_map = { + item.get("fileName"): item.get("fileID") + for item in file_list if item.get("fileName") + } + + def _update_combobox(): + if files: + self.model_combobox['values'] = files + self.model_combobox.current(0) # 默认选中第一个 + self.root.after(0, lambda: self.log_message(f"成功刷新")) + else: + self.model_combobox['values'] = [] + self.model_combobox.set("无模型文件") + self.root.after(0, _update_combobox) + else: + err = result.get('errMsg') + self.root.after(0, lambda err=err: self.log_message(f"获取模型列表失败: {err}")) + except Exception as e: + msg = str(e) + self.root.after(0, lambda msg=msg: self.log_message(f"扫描模型异常: {msg}")) + + threading.Thread(target=fetch_models, daemon=True).start() + + # """扫描 model_config 文件夹下的所有模型文件(支持 .onnx, .pt, .pth)""" + # base_path = get_base_path() + # models_dir = os.path.join(base_path, "model_config") + # if not os.path.exists(models_dir): + # os.makedirs(models_dir, exist_ok=True) + # self.model_combobox['values'] = [] + # self.model_combobox.set("") + # return + + # # 支持的模型文件扩展名 + # extensions = ('.enc', '.sys', '.zip', '.mlr') + # # extensions = ('.pt', '.pth', '.zip', 'rar', '.onnx') + # model_files = [] + # for f in os.listdir(models_dir): + # if f.lower().endswith(extensions): + # model_files.append(f) # 只保存文件名,完整路径在加载时拼接 + # model_files.sort() + # self.model_combobox['values'] = model_files + # if model_files: + # self.model_combobox.current(0) # 默认选中第一个 + # else: + # self.model_combobox.set("无模型文件") + + def _on_mode_change(self): + mode = self.control_mode_var.get() + if mode == "PID": + # 显示PID栏,隐藏其他 + self.rl_frame.pack_forget() + self.pid_frame.pack(fill=tk.X, pady=5) + self.manual_frame.pack_forget() + + self.Kp_entry.config(state=tk.NORMAL) + self.Ki_entry.config(state=tk.NORMAL) + self.Kd_entry.config(state=tk.NORMAL) + self.update_pid_btn.config(state=tk.NORMAL) + self.volume_entry.config(state=tk.NORMAL) + self.flow_entry.config(state=tk.NORMAL) + self.model_combobox.config(state=tk.DISABLED) + self.load_model_btn.config(state=tk.DISABLED) + self.refresh_models_btn.config(state=tk.DISABLED) + # self.collect_data_var.set(False) + self.chk_collect_data.config(state=tk.NORMAL) + elif mode == "RL": # RL + # 显示决策模型栏,隐藏其他 + self.rl_frame.pack(fill=tk.X, pady=5) + self.pid_frame.pack_forget() + self.manual_frame.pack_forget() + + self.Kp_entry.config(state=tk.DISABLED) + self.Ki_entry.config(state=tk.DISABLED) + self.Kd_entry.config(state=tk.DISABLED) + self.update_pid_btn.config(state=tk.DISABLED) + self.volume_entry.config(state=tk.NORMAL) + self.flow_entry.config(state=tk.NORMAL) + self.model_combobox.config(state="readonly") + self.load_model_btn.config(state=tk.NORMAL) + self.refresh_models_btn.config(state=tk.NORMAL) + self.chk_collect_data.config(state=tk.NORMAL) + elif mode == "MANUAL": # 手动设置开度 + # 显示设置开度栏,隐藏其他 + self.rl_frame.pack_forget() + self.pid_frame.pack_forget() + self.manual_frame.pack(fill=tk.X, pady=5) + + self.Kp_entry.config(state=tk.DISABLED) + self.Ki_entry.config(state=tk.DISABLED) + self.Kd_entry.config(state=tk.DISABLED) + self.update_pid_btn.config(state=tk.DISABLED) + self.volume_entry.config(state=tk.NORMAL) + self.flow_entry.config(state=tk.NORMAL) + self.model_combobox.config(state=tk.DISABLED) + self.load_model_btn.config(state=tk.DISABLED) + self.refresh_models_btn.config(state=tk.DISABLED) + self.collect_data_var.set(False) + self.chk_collect_data.config(state=tk.DISABLED) + + def load_rl_model(self): + """从下拉框选择的文件名加载 RL 模型""" + selected = self.model_combobox.get() + if not selected or selected == "无模型文件": + self.log_message("错误:请先选择一个有效的模型") + return + + def download_and_load(): + try: + # 1. 取出该模型对应的 fileID(downloadModel 云函数只认 fileID) + file_id = getattr(self, "model_file_map", {}).get(selected) + if not file_id: + self.root.after(0, lambda: self.log_message("获取模型下载链接失败: 缺少 fileID,请先刷新模型列表")) + return + else: + self.root.after(0, lambda: self.log_message(f"正在加载模型: {selected}...")) + # print(f"Debug: 选中的模型文件 {selected} 对应的 fileID 是 {file_id}") + + # 2. 请求云函数获取模型文件的临时下载 URL + payload = { + "type": "downloadModel", + "fileID": file_id + } + resp = requests.post(data_record_url, json=payload, timeout=15) + result = resp.json() + if not result.get("success"): + err = result.get('errMsg') + self.root.after(0, lambda err=err: self.log_message(f"获取模型下载链接失败: {err}")) + return + url = result['url'] + + # 3. 下载模型文件(二进制内容) + model_resp = requests.get(url, timeout=30) + if model_resp.status_code != 200: + code = model_resp.status_code + self.root.after(0, lambda code=code: self.log_message(f"下载模型文件失败: HTTP {code}")) + return + model_bytes = model_resp.content + + # 4. 直接加载到内存(无需解密) + import io + model_stream = io.BytesIO(model_bytes) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model = SAC.load(model_stream, device=device) + + # 5. 保存模型实例 + self.rl_model = model + self.root.after(0, lambda: self.log_message(f"成功加载模型: {selected}")) + + except Exception as e: + msg = str(e) + self.root.after(0, lambda msg=msg: self.log_message(f"加载模型失败: {msg}")) + + threading.Thread(target=download_and_load, daemon=True).start() + + # base_path = get_base_path() + # model_path = os.path.join(base_path, "model_config", selected) + # if not os.path.exists(model_path): + # self.log_message(f"错误:模型文件不存在 -> {model_path}") + # return + + # # if not selected.lower().endswith('.sys'): + # # self.log_message(f"错误:仅支持 .sys 格式的文件,当前文件为 {selected}") + # # return + # try: + # # 获取当前界面输入的容积和流量(用于构建临时环境) + # # vol_str = self.volume_var.get().strip() + # # flow_str = self.flow_var.get().strip() + # # if not vol_str or not flow_str: + # # self.log_message("错误:请先填写容积(L)和流量(L/min)") + # # return + # # V = float(vol_str) + # # Q_in = float(flow_str) + # # temp_env = CustomPressureEnv(Q_in=Q_in, V=V, dt=self.IncrementalPID.dt) + # # 加载 SAC 模型 + # import io + # from cryptography.fernet import Fernet + # # 1. 把你刚才生成的密钥硬编码写在这里 + # cipher = Fernet(SECRET_KEY) + # # 2. 读取硬盘上的加密乱码文件 + # with open(model_path, 'rb') as f: + # encrypted_data = f.read() + # # 3. 在内存中瞬间解密 + # decrypted_data = cipher.decrypt(encrypted_data) + # # 4. 🌟 核心技巧:将内存中的字节数组伪装成一个“文件对象” + # model_stream = io.BytesIO(decrypted_data) + # # 5. 直接让 SAC 从内存流中加载模型,不接触硬盘! + # device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + # model = SAC.load(model_stream, device=device) + # # model = SAC.load(model_path, env=temp_env, device=device) + # # 将加载的模型和临时环境保存到实例变量中 + # self.rl_model = model + # # self.rl_env = temp_env # 保留环境引用,以便后续获取归一化参数 + # self.log_message(f"成功加载模型: {selected}") + # except Exception as e: + # self.log_message(f"加载模型失败: {e}") + + def log_message(self, message): + """添加消息到日志(线程安全优化版)- 一次只显示一条""" + def _append_log(): + # 清除之前的日志,只显示最新一条 + self.log_text.delete('1.0', tk.END) + self.log_text.insert(tk.END, f"{time.strftime('%H:%M:%S')} - {message}") + # 🚨 绝对不要在这里使用 self.root.update() 🚨 + # 将打印任务打包,丢给主线程的事件队列去安全执行,绝对不阻塞当前控制线程 + self.root.after(0, _append_log) + + def start_identification(self): + """启动辨识数据采集""" + if self.running: + self.log_message("错误:请先停止控制再进行辨识") + return + + # 新增:检查是否正在辨识中 + if hasattr(self, 'identifying') and self.identifying: + self.log_message("辨识正在进行中,请等待完成") + return + + if not self.modbus_client or not self.modbus_client.connect: + self.log_message("错误:请先连接压力表") + return + + if not hasattr(self, 'motor') or not self.motor.connect: + self.log_message("错误:请先连接电机") + return + + q_input = self.flow_var.get().strip() + V_val = float(self.volume_var.get()) if self.volume_var.get() else 0 + + if not q_input: + self.log_message("错误:请先在控制设置中输入流量") + return + try: + q_in_val = float(q_input) + except ValueError: + self.log_message("错误:流量输入必须是有效数字") + return + + self.identifying = True # 设置辨识中标志 + self.log_message("开始辨识数据采集...") + + def collect_thread(): + try: + dt = 0.1 + + # 阶数 n_order(页面3 阶数输入框) + try: + n_order = int(self.order_var.get()) + except (ValueError, AttributeError): + self.log_message("警告: 阶数输入无效,使用默认值 6") + n_order = 6 + + # 周期 t_c(页面3 周期输入框,单位 s) + try: + t_c = float(self.period_var.get()) + except (ValueError, AttributeError): + self.log_message("警告: 周期输入无效,使用默认值 2.5") + t_c = 2.5 + + # 死区 dead_area(页面3 高级设置-死区) + dz_str = self.dz_var.get().strip() + try: + dead_area = float(dz_str) if dz_str else 240 + except ValueError: + self.log_message("警告: 死区输入无效,使用默认值 240") + dead_area = 240 + + # 总限幅 xa_full(页面3 高级设置-总限幅) + try: + xa_full = float(self.xa_full_var.get()) + except (ValueError, AttributeError): + self.log_message("警告: 总限幅输入无效,使用默认值 749") + xa_full = 749 + + levels_str = self.levels_var.get().strip() + try: + levels = [int(x.strip()) for x in levels_str.split(',')] + if len(levels) < 2: + self.log_message("警告: 序列至少需要2个值,使用默认值") + levels = [10, 20, 30, 40, 50, 60, 70, 80] + except ValueError: + self.log_message("警告: 序列输入格式错误,使用默认值") + levels = [10, 20, 30, 40, 50, 60, 70, 80] + + def _on_sample(t, u_cmd, p): + self.root.after(0, lambda u=u_cmd, p=p: [ + self.valve_opening_var.set(f"{u:.1f} %"), + self.current_pressure_var.set(f"{p:.1f} kPa") + ]) + + result = collect_data_with_prbs( + self.modbus_client, + self.motor, + 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, + # save_dir=os.path.join(get_base_path(), "ind_data"), + V_val=V_val, + should_stop=lambda: self.identifying is False, + log=self.log_message, + on_sample=_on_sample, + ) + + if result['success']: + # ========== 直接上传内存中的 CSV 数据 ========== + csv_data = result.get('csv_data') + filename = result.get('filename') + if csv_data and filename: + # 编码为 base64 + file_base64 = base64.b64encode(csv_data).decode('utf-8') + payload = { + "type": "uploadDataFile", + "fileName": filename, + "fileBase64": file_base64, + "folder": f"{the_folder}/ind_data" + } + try: + resp = requests.post(data_record_url, json=payload, timeout=30) + resp_json = resp.json() + if resp_json.get("success"): + self.root.after(0, lambda: self.log_message(f"辨识数据上传成功")) + else: + self.root.after(0, lambda: self.log_message(f"辨识数据上传失败: {resp_json.get('errMsg')}")) + except Exception as e: + err_msg = f"上传辨识数据异常: {e}" + self.root.after(0, lambda msg=err_msg: self.log_message(msg)) + self.root.after(0, lambda: self.log_message("辨识数据采集完成")) + else: + self.root.after(0, lambda: self.log_message("辨识未采集到数据")) + + except Exception as e: + # 🚀 顺手加上打印完整的崩溃调用栈,以后如果再错就能一眼看出是哪行代码的问题 + import traceback + self.log_message(f"辨识数据采集详细错误: {traceback.format_exc()}") + self.log_message(f"辨识数据采集失败: {e}") + + finally: + self.identifying = False # 清除辨识中标志 + self.log_message("辨识结束") + + thread = threading.Thread(target=collect_thread) + thread.daemon = True + thread.start() + + def get_V(self): + """获取体积""" + if self.running: + self.log_message("错误:请先停止控制再进行测试") + return + + # 新增:检查是否正在辨识中 + if hasattr(self, 'identifying') and self.identifying: + self.log_message("测试正在进行中,请等待完成") + return + + if not self.modbus_client or not self.modbus_client.connect: + self.log_message("错误:请先连接压力表") + return + + if not hasattr(self, 'motor') or not self.motor.connect: + self.log_message("错误:请先连接电机") + return + + q_input = self.flow_var.get().strip() + + if not q_input: + self.log_message("错误:请先在控制设置中输入流量") + return + try: + q_in_val = float(q_input) + except ValueError: + self.log_message("错误:流量输入必须是有效数字") + return + + # 约束上界 / 下界(页面3 约束上界/下界 -> measure_volume 的 fit_high / fit_low) + try: + fit_high = float(self.fit_high_var.get()) + fit_low = float(self.fit_low_var.get()) + except ValueError: + self.log_message("错误:约束上界/下界必须是有效数字") + return + + # 压力上限 p_max、过程升温 t_delta(页面3 系统辨识) + try: + p_max = float(self.p_max_var.get()) + except ValueError: + self.log_message("错误:压力上限必须是有效数字") + return + try: + t_delta = float(self.t_delta_var.get()) + except ValueError: + self.log_message("错误:过程升温必须是有效数字") + return + + self.identifying = True # 设置辨识中标志 + self.log_message("开始测量容积...") + + def volume_thread(): + try: + result = measure_volume( + self.modbus_client, + self.motor, + q_in_slm=q_in_val, + dt=self.IncrementalPID.dt, + p_max=p_max, + fit_low=fit_low, + fit_high=fit_high, + t_delta=t_delta, + should_stop=lambda: self.identifying is False, + log=self.log_message, + on_sample=lambda t, p: self.root.after( + 0, lambda p=p: self.current_pressure_var.set(f"{p:.1f} kPa")), + ) + if result['success']: + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + vol = result['volume_L'] + payload_data = result['payload_data'] + json_str = json.dumps(payload_data, indent=2, ensure_ascii=False) + json_bytes = json_str.encode('utf-8') + file_base64 = base64.b64encode(json_bytes).decode('utf-8') + filename = f"volume_test_{vol:.2f}L_{timestamp}.json" + # 上传到云存储的 ind_data 文件夹(与辨识数据同一位置) + upload_payload = { + "type": "uploadDataFile", + "fileName": filename, + "fileBase64": file_base64, + "folder": f"{the_folder}/V_config" + } + try: + resp = requests.post(data_record_url, json=upload_payload, timeout=30) + resp_json = resp.json() + if resp_json.get("success"): + self.root.after(0, lambda: self.log_message(f"体积测量成功")) + else: + self.root.after(0, lambda: self.log_message(f"体积测量失败")) + except Exception as e: + err_msg = f"体积测量异常: {e}" + self.root.after(0, lambda msg=err_msg: self.log_message(msg)) + + # 把测得的等效体积自动填回容积输入框 + self.root.after(0, lambda: self.volume_var.set(f"{vol:.2f}")) + self.root.after(0, lambda: self.log_message(f"测量完成,系统等效体积 V = {vol:.4f} L")) + else: + self.root.after(0, lambda: self.log_message("测量失败:有效数据点不足,无法计算体积")) + except Exception as e: + import traceback + self.log_message(f"容积测量详细错误: {traceback.format_exc()}") + self.log_message(f"容积测量失败: {e}") + finally: + self.identifying = False # 清除辨识中标志 + self.log_message("测量结束") + + thread = threading.Thread(target=volume_thread) + thread.daemon = True + thread.start() + + def toggle_connection(self): + """切换Modbus连接状态""" + if self.modbus_client and self.modbus_client.connect: + self.disconnect_plc() + else: + self.connect_plc() + + def connect_plc(self): + """连接到PLC及电机""" + # ---------------- 读取 Modbus TCP 参数 ---------------- + ip_address = self.tcp_ip_entry.get().strip() + try: + tcp_port = int(self.tcp_port_entry.get()) + except ValueError: + self.log_message("错误: TCP端口必须是整数") + return + try: + pressure_addr = int(self.pressure_addr_entry.get()) + except ValueError: + self.log_message("错误: 压力寄存器地址必须是整数") + return + + # ---------------- 读取 Modbus RTU 参数 ---------------- + motor_port = self.serial_port_var.get() + if not motor_port or motor_port == "无可用串口": + self.log_message("错误: 请先在下拉框选择有效的端口号!") + return + try: + baudrate = int(self.baudrate_var.get()) + rtu_slave = int(self.rtu_slave_entry.get()) + databits = int(self.databits_entry.get()) + stopbits = int(self.stopbits_entry.get()) + except ValueError: + self.log_message("错误: 波特率/站号/数据位/停止位必须是整数") + return + # 校验位 None/Odd/Even -> pymodbus 的 N/O/E + parity = {"None": "N", "Odd": "O", "Even": "E"}.get(self.parity_var.get(), "N") + + try: + # Modbus TCP:读压力 + self.modbus_client = Easy521ModbusClient(ip_address, port=tcp_port, current_p_addr=pressure_addr) + # Modbus RTU:控电机 + self.motor = MotorModbusRTUClient( + port=motor_port, + slave_id=rtu_slave, + baudrate=baudrate, + bytesize=databits, + parity=parity, + stopbits=stopbits + ) + + if self.modbus_client.connect(): + self.connection_status_var.set("已连接") + self.connect_btn.config(text="断开连接") + self.log_message(f"成功连接到PLC: {ip_address}:{tcp_port}") + else: + self.log_message(f"连接PLC失败: {ip_address}:{tcp_port}") + return # PLC连不上直接退出,不连电机了 + except Exception as e: + self.log_message(f"连接错误: {str(e)}") + return + + # ==================== 优化:去除 exit(1) 防闪退 ==================== + if not self.motor.connect(): + self.log_message(f"电机串口 ({motor_port}) 连接失败,请检查线缆或占用情况!") + self.disconnect_plc() # 回滚状态 + return + time.sleep(1) # 增加短暂延时,等待驱动器接口就绪 + if not self.motor.init(): + self.log_message("电机初始化失败!") + self.motor.disconnect() + self.disconnect_plc() # 回滚状态 + return + self.log_message(f"电机串口 ({motor_port}) 连接并初始化成功!") + + def disconnect_plc(self): + """断开PLC连接""" + if self.modbus_client: + self.modbus_client.disconnect() + self.modbus_client = None + self.connection_status_var.set("未连接") + self.connect_btn.config(text="连接设备") + self.log_message("已断开连接") + + # 电机可能尚未创建(如 TCP 阶段就失败),加保护避免 AttributeError + if getattr(self, "motor", None): + self.motor.disconnect() + self.motor = None + + def set_target_pressure(self): + """设置目标压力并同步到PLC""" + try: + target = float(self.target_entry.get()) + if 0 <= target <= 300: + self.confirmed_target_pressure = target + self.log_message(f"目标压力设置为: {target} kPa") + # # 更新本地控制器 + # self.IncrementalPID.target_pressure = target + # # 如果PLC已连接,将目标压力同步写入D42寄存器 + # if self.modbus_client and self.modbus_client.connected: + # try: + # # 安全获取目标地址 + # target_addr = self.safe_int_convert(self.target_addr_entry.get(), 42) + + # # 写入目标压力到PLC + # success = self.modbus_client.write_float(target_addr, float(target)) + + # if success: + # self.log_message(f"目标压力设置为: {target} kPa (已同步到PLC)") + # # 更新显示 + # self.target_pressure_var.set(f"{target:.1f} kPa") + # else: + # self.log_message(f"目标压力设置为: {target} kPa (但PLC写入失败)") + # except Exception as e: + # self.log_message(f"目标压力设置为: {target} kPa (但PLC写入错误: {str(e)})") + # else: + # self.log_message(f"目标压力设置为: {target} kPa (未连接PLC)") + # # 更新显示 + # self.target_pressure_var.set(f"{target:.1f} kPa") + else: + self.log_message("错误: 目标压力必须在0-300 kPa范围内") + except ValueError: + self.log_message("错误: 请输入有效的数字") + + def set_valve(self): + """设置开度给阀门(仅手动模式)""" + try: + valve = float(self.valve_entry.get()) + if 0 <= valve <= 120: + self.confirmed_valve = valve + self.log_message(f"阀门开度设置为: {valve}%") + else: + self.log_message("错误: 目标阀开度超出范围") + except ValueError: + self.log_message("错误: 请输入有效的数字") + + def toggle_control(self): + """开始/停止控制""" + if not self.running: + self.start_control() + else: + self.stop_control() + + def update_pid_parameters(self): + """更新PID参数""" + try: + self.IncrementalPID.kp = float(self.Kp_entry.get()) + self.IncrementalPID.ki = float(self.Ki_entry.get()) + self.IncrementalPID.kd = float(self.Kd_entry.get()) + self.IncrementalPID._calculate_coefficients() + self.log_message( + f"PID参数更新为: Kp={self.IncrementalPID.kp}, Ki={self.IncrementalPID.ki}, Kd={self.IncrementalPID.kd}") + except ValueError as e: + self.log_message(f"PID参数输入错误: {e}") + + def start_control(self): + """开始控制循环""" + if not self.modbus_client or not self.modbus_client.connect: + self.log_message("错误: 请先连接压力表") + # self.log_message("错误: 请先连接PLC") + return + + # ======================================================== + # 🚀 新增:强制前置校验 + # 如果处于 RL 模式,必须确认模型已成功加载,否则绝对不允许启动 + # ======================================================== + if self.control_mode_var.get() == "RL": + if not hasattr(self, 'rl_model') or self.rl_model is None: + self.log_message("❌ 启动失败: 强化学习模型未加载!") + self.log_message("请先选择工况并点击【加载模型】按钮,然后再点击开始控制。") + return + # ======================================================== + + # ===== 消除魔法数字:根据当前压力预置 PID 初始阀位 ===== + try: + # pressure_addr = self.safe_int_convert(self.pressure_addr_entry.get(), 18) + # p_init = self.modbus_client.read_float(pressure_addr) + # if p_init is not None: + # 这里的公式假设 300kPa 对应 100% 开度 (线性前馈) + # 如果你在 env.calculate_feedforward_valve 里有更精确的公式,请替换这里 + # initial_valve = max(0.0, min(100.0, (p_init / 300.0) * 100.0)) + position_x = self.motor.read_current_position() + initial_valve = self.IncrementalPID.init_v(position_x) + self.IncrementalPID.output = initial_valve + self.log_message(f"预置初始阀位 {initial_valve:.1f}%") + # self.log_message(f"初始化:当前压力 {p_init:.1f}kPa,预置初始阀位 {initial_valve:.1f}%") + except Exception as e: + self.log_message(f"读取初始开度失败,将使用 80% 启动: {e}") + self.IncrementalPID.output = 80.0 + # ======================================================== + + self.cached_mode = self.control_mode_var.get() + self.cached_collect_data = self.collect_data_var.get() + + # 提前把字符串转成浮点数存好 + flow_str = self.flow_var.get().strip() + if not flow_str: + self.log_message("错误:请先在控制设置中输入流量") + return + try: + self.cached_flow = float(flow_str) + except ValueError: + self.log_message("错误:流量输入必须是有效数字") + return + + vol_str = self.volume_var.get() + self.cached_volume = float(vol_str) if vol_str else 0.0 + + dz_str = self.dz_var.get().strip() + self.cached_dz = float(dz_str) if dz_str else None + # 如果输入了 dz 值,则使用输入的值,否则使用默认值 240 + if self.cached_dz is not None: + self.IncrementalPID.dead_area = self.cached_dz + + motor_max_str = self.motor_max_var.get().strip() + self.cached_motor_max = float(motor_max_str) if motor_max_str else None + + + self.running = True + self.start_btn.config(text="停止控制") + self.cycle_count = 0 + self.start_time = time.time() + self.pressure_data = [] + self.target_data = [] + self.valve_data = [] + self.time_data = [] + + # --- 新增:初始化数据收集 --- + self.episode_data_raw = [] + self.current_episode = None + self.last_target_rl = None # 记录上一个目标值,用于RL模型 + self.last_target_record = None # 记录上一个目标值,用于切分 Episode + + # 写入M100为True + # try: + # control_flag_addr = self.safe_int_convert(self.control_flag_addr_entry.get(), 100) + # success = self.modbus_client.write_coil(control_flag_addr, True) + # if success: + # self.log_message(f"已写入控制标志位 M{control_flag_addr} = True") + # else: + # self.log_message(f"写入控制标志位 M{control_flag_addr} 失败") + # except Exception as e: + # self.log_message(f"写入控制标志位错误: {str(e)}") + + # 在单独线程中运行控制循环 + # 🌟 1. 挂一块小黑板(共享元组),初始化为 0 + self.latest_display_data = (0.0, 0.0, 0.0) + # 🌟 2. 告诉一号员工(主线程):每 100 毫秒去小黑板看一眼数据 + self.root.after(50, self._ui_refresh_timer) + + self.control_thread = threading.Thread(target=self.control_loop, daemon=True) + self.control_thread.start() + + def _ui_refresh_timer(self): + """一号员工(主线程)专属:只负责看黑板、画界面""" + if not self.running: + return # 如果停止了,就不看了 + + # 从小黑板上读取最新数据 + current_pressure, target_pressure, valve_opening = self.latest_display_data + + # 刷新界面显示 + self.current_pressure_var.set(f"{current_pressure:.1f} kPa") + self.target_pressure_var.set(f"{target_pressure:.1f} kPa") + self.valve_opening_var.set(f"{valve_opening:.1f} %") + + # 设个闹钟,100毫秒后再次执行自己 + self.root.after(50, self._ui_refresh_timer) + + def stop_control(self): + """停止控制循环""" + self.running = False + self.start_btn.config(text="开始控制") + self.log_message("停止控制") + + # --- 修复:只要有收集到数据就保存,不受复选框当前状态限制 --- + # 把最后一个还没闭合的 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 self.episode_data_raw: + self._save_and_upload_data() + + # 写入M100为False + # if self.modbus_client and self.modbus_client.connect: + # try: + # control_flag_addr = self.safe_int_convert(self.control_flag_addr_entry.get(), 100) + # success = self.modbus_client.write_coil(control_flag_addr, False) + # if success: + # self.log_message(f"已写入控制标志位 M{control_flag_addr} = False") + # else: + # self.log_message(f"写入控制标志位 M{control_flag_addr} 失败") + # except Exception as e: + # self.log_message(f"写入控制标志位错误: {str(e)}") + + def _save_and_upload_data(self): + """本地保存数据,并(可选)异步回传到服务器""" + try: + # 1. 本地落盘 + vol = self.cached_flow + flow = self.cached_flow + + # base_dir = get_base_path() + # save_dir = os.path.join(base_dir, f'data_record/data_{flow}SLM_{vol}L') + # os.makedirs(save_dir, exist_ok=True) + + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f'episode_raw_data_{timestamp}.pkl' + # filepath = os.path.join(save_dir, filename) + + # with open(filepath, 'wb') as f: + # pickle.dump(self.episode_data_raw, f) + + # self.log_message(f"已成功收集并保存 {len(self.episode_data_raw)} 段数据至 {filepath}") + + # 上传到微信云存储(新建线程防止阻塞 GUI) + # 将内存数据序列化为 bytes,再转 base64 + data_bytes = pickle.dumps(self.episode_data_raw) + file_base64 = base64.b64encode(data_bytes).decode('utf-8') + + def upload_to_wechat(): + try: + payload = { + "type": "uploadDataFile", + "fileName": filename, + "fileBase64": file_base64, + "folder": f"{the_folder}/data_record/data_{flow}SLM_{vol}L" # 指定存储在云存储的 data_record 文件夹下 + } + resp = requests.post(data_record_url, json=payload, timeout=30) + result = resp.json() + if result.get("success"): + self.root.after(0, lambda: self.log_message(f"成功收集数据")) + else: + self.root.after(0, lambda: self.log_message(f"收集数据失败: {result.get('errMsg')}")) + except Exception as e: + err_msg = f"收集数据异常: {e}" + self.root.after(0, lambda msg=err_msg: self.log_message(msg)) + + threading.Thread(target=upload_to_wechat, daemon=True).start() + except Exception as e: + self.log_message(f"保存收集数据时发生错误: {e}") + finally: + self.episode_data_raw = [] # 清空内存 + + def reset_controller(self): + """重置控制器""" + if self.running: + self.log_message("请先停止控制再重置控制器") + return + + self.IncrementalPID.reset() + self.log_message("控制器已重置") + + def safe_int_convert(self, value, default=0): + """安全地将值转换为整数""" + try: + return int(value) + except (ValueError, TypeError): + return default + + def control_loop(self): + """控制主循环""" + initial_loop = True + while self.running: + cycle_start = time.perf_counter() # 记录周期开始时间 + try: + # 安全地获取地址值 + # pressure_addr = self.safe_int_convert(self.pressure_addr_entry.get(), 18) + # target_addr = self.safe_int_convert(self.target_addr_entry.get(), 42) + # valve_addr = self.safe_int_convert(self.valve_addr_entry.get(), 40) + + current_time = time.time() + elapsed_time = current_time - self.start_time if self.start_time else 0 + self.time_data.append(elapsed_time) + # 读取当前压力值 + current_pressure = self.modbus_client.get_current_p() + # print(f"t1-读压力用时:{time.perf_counter()-t1}") + # current_pressure = self.modbus_client.read_float(pressure_addr) + + t2 = time.perf_counter() + # 建议在高速循环中把这行打印注释掉,否则日志和控制台会刷屏导致软件卡顿 + # self.log_message(f"当前压力:{current_pressure}") + if initial_loop: + target_pressure = float(self.target_entry.get()) + set_valve = float(self.valve_entry.get()) + initial_loop = False + + if current_pressure is not None: + # plc_target = self.modbus_client.read_float(target_addr) + # target_pressure = plc_target if plc_target is not None else float(self.target_entry.get()) + # 使用确认后的目标压力(只有点击"设置目标"按钮才会更新) + target_pressure = self.confirmed_target_pressure + + # ======================================================== + current_mode = self.cached_mode + # current_mode = self.control_mode_var.get() + # print(f"t2={time.perf_counter()-t1}") + + if current_mode == "PID": + # 1. 纯 PID 控制模式 + self.IncrementalPID.update_pressure_values(current_pressure, target_pressure) + valve_opening = self.IncrementalPID.update() + xa = 749 * (100 - valve_opening) / 100 + success = self.motor.set_position(xa) + # success = self.motor.set_position(valve_opening) + + elif current_mode == "RL": + if hasattr(self, 'rl_model') and self.rl_model is not None: + t3 = time.perf_counter() + volume_val = self.cached_volume + flow_rate = self.cached_flow + # # --- A. 获取容积 (框里只有数字,直接转 float) --- + # volume_str = self.volume_var.get() + # volume_val = float(volume_str) if volume_str else 0.0 + # # --- B. 获取流量 (框里只有数字,直接转 float) --- + # flow_str = self.flow_var.get() + # flow_rate = float(flow_str) if flow_str else 0.0 + + # dz_str = self.dz_var.get().strip() + # if dz_str: + # self.IncrementalPID.dead_area = float(dz_str) + + # print(f"t3={time.perf_counter() - t1}") + + # --- C. 调用 RL 模型预测参数增量 --- + if self.last_target_rl is None: + self.last_target_rl = target_pressure + + position_x = self.motor.read_current_position() + + self.IncrementalPID.output = self.IncrementalPID.init_v(position_x) + # self.IncrementalPID.output = self.rl_env.calculate_feedforward_valve(current_pressure) + + obs = np.array([flow_rate / 100, current_pressure / 100, + (target_pressure - current_pressure) / 100], dtype=np.float32) + self.log_message(f"obs:{obs}") + action, _ = self.rl_model.predict(obs, deterministic=True) + # --- D. 更新 PID 参数 --- + action_space = self.rl_model.action_space + self.Kp_0 = action_space.high[0] + self.Ki_0 = action_space.high[1] + self.IncrementalPID.kp = self.Kp_0 + action[0] + self.IncrementalPID.ki = self.Ki_0 + action[1] + + # self.IncrementalPID.kp = self.rl_env.Kp_0 + action[0] + # self.IncrementalPID.ki = self.rl_env.Ki_0 + action[1] + self.log_message( + f"Kp={self.IncrementalPID.kp:.4f}, Ki={self.IncrementalPID.ki:.4f}, Kd={self.IncrementalPID.kd:.4f}") + self.IncrementalPID._calculate_coefficients() + self.root.after(0, self._update_pid_ui, self.IncrementalPID.kp, self.IncrementalPID.ki, self.IncrementalPID.kd) + + # error = -(target_pressure - current_pressure) + # dkp, dki = self.rl_controller.predict(current_pressure, error) + # new_kp = max(0.0, min(10.0, 1.0 + dkp)) + # new_ki = max(0.0, min(20.0, 0.4 + dki)) + # self.IncrementalPID.kp = new_kp + # self.IncrementalPID.ki = new_ki + # self.IncrementalPID._calculate_coefficients() + # self.root.after(0, self._update_pid_ui, new_kp, new_ki) + elif self.last_target_rl != target_pressure: + # 更新 last_target,确保只在目标压力真正变化时调用一次 RL 模型 + self.last_target_rl = target_pressure + obs = np.array([flow_rate/100, current_pressure/100, (target_pressure-current_pressure)/100], dtype=np.float32) + self.log_message(f"obs: {obs}") + + action, _ = self.rl_model.predict(obs, deterministic=True) + + t4 = time.perf_counter() + # --- D. 更新 PID 参数 --- + # Kp_0 = action_space.high[0] + # Ki_0 = action_space.high[1] + self.IncrementalPID.kp = self.Kp_0 + action[0] + self.IncrementalPID.ki = self.Ki_0 + action[1] + # self.IncrementalPID.kp = self.rl_env.Kp_0 + action[0] + # self.IncrementalPID.ki = self.rl_env.Ki_0 + action[1] + self.log_message(f"Kp={self.IncrementalPID.kp:.4f}, Ki={self.IncrementalPID.ki:.4f}, Kd={self.IncrementalPID.kd:.4f}") + self.IncrementalPID._calculate_coefficients() + self.root.after(0, self._update_pid_ui, self.IncrementalPID.kp, self.IncrementalPID.ki, self.IncrementalPID.kd) + + # print(f"t4={time.perf_counter() - t1:.3f}") + + # 降低 UI 刷新频率:每 10 个控制周期 (0.05秒) 更新一次界面,防止 Tkinter 卡死 + # if self.cycle_count % 10 == 0: + # self.root.after(0, self._update_pid_ui, new_kp, new_ki) + + t5 = time.perf_counter() + # --- E. 算新开度 --- + # 如果输入了电机限幅值,则更新 IncrementalPID.motor_max + # motor_max_str = self.motor_max_var.get().strip() + # if motor_max_str: + # self.IncrementalPID.du_max = float(motor_max_str) * self.IncrementalPID.dt + if self.cached_motor_max is not None: + self.IncrementalPID.du_max = self.cached_motor_max * self.IncrementalPID.dt + else: + self.IncrementalPID.get_du_max(target_pressure) + self.IncrementalPID.update_pressure_values(current_pressure, target_pressure) + valve_opening = self.IncrementalPID.update() + + xa = self.IncrementalPID.dead_area + (100 - valve_opening) * (750 - self.IncrementalPID.dead_area) / 100 + # va = (750 - xa) / 750 * 100 + # self.log_message(f"xa: {xa:.4f}") + success = self.motor.set_position(xa) + # print(f"t5-写开度用时:{time.perf_counter() - t1:.3f}") + + else: + # 极端异常兜底:按理说有前置拦截不会走到这里 + self.log_message("⚠️ 致命错误:控制线程中丢失模型实例!正在紧急停机。") + self.root.after(0, self.stop_control) + # valve_opening = 0.0 # 输出安全阀位 + + elif current_mode == "MANUAL": + # valve_opening = float(self.valve_entry.get()) + xa = self.IncrementalPID.dead_area + (100 - set_valve) * (750 - self.IncrementalPID.dead_area) / 100 + success = self.motor.set_position(xa) + + else: + # valve_opening = 0.0 + self.log_message("错误!") + # ======================================================== + + # ... [此处是原有的获取 valve_opening 并写入 PLC 的代码] ... + # 写入阀门开度到PLC + # success = self.modbus_client.write_float(valve_addr, float(valve_opening)) + # success = self.motor.set_position(valve_opening) + + # ======================================================== + # 新增:训练数据收集逻辑 + # ======================================================== + t6 = time.perf_counter() + # if self.collect_data_var.get(): + # vol_str = self.volume_var.get() + # flow_str = self.flow_var.get() + if self.cached_collect_data: + Q_in = self.cached_flow + V = self.cached_volume + # Q_in = float(flow_str) if flow_str else 0.0 # 根据你的设定转换为标准单位 + # # Q_in = float(flow_str) * 1000 if flow_str else 0.0 # 根据你的设定转换为标准单位 + # V = float(vol_str) if vol_str else 0.0 + + # 检查是否需要开启新的 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_message( + f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点") + + self.current_episode = { + 'pid': [float(self.IncrementalPID.kp), float(self.IncrementalPID.ki), float(self.IncrementalPID.kd)], + 'target_pressure': target_pressure, + 'Q_in': Q_in, + 'V': V, + 'steps': [], + 'pressures': [], + 'errors': [], + 'valves': [] + } + self.last_target_record = target_pressure + self.steady_count = 0 + + # 记录当前步的数据 + error = -(target_pressure - current_pressure) + self.current_episode['steps'].append(self.cycle_count) + self.current_episode['pressures'].append(current_pressure) + self.current_episode['errors'].append(error) + self.current_episode['valves'].append(float(valve_opening)) + + # print(f"t6-记录数据用时:{time.perf_counter() - t1:.3f}") + + # 可选:判断稳态提前结束 Episode(类似 DataCollector.py 的逻辑) + # if 0 <= target_pressure - current_pressure <= 1: + # self.steady_count += 1 + # else: + # self.steady_count = 0 + # if self.steady_count > 40: # 假设 40 个 step 为稳态 + # ... + # ======================================================== + + # 更新UI并记录数据 + self.pressure_data.append(current_pressure) + self.target_data.append(target_pressure) + self.valve_data.append(valve_opening) + + # 🌟 2. 把算出来的最新数据写到小黑板上,然后就可以拍拍屁股走人了 + self.latest_display_data = (current_pressure, target_pressure, valve_opening) + + # self.root.after(0, self.update_display, current_pressure, target_pressure, valve_opening) + self.cycle_count += 1 + # print(f"t6-记录数据用时:{time.perf_counter() - t1:.3f}") + + else: + self.log_message("读取当前压力失败,检查地址和连接") + time.sleep(self.IncrementalPID.dt) # 读取失败时短暂等待 + + except Exception as e: + self.log_message(f"控制循环错误: {str(e)}") + import traceback + self.log_message(f"详细错误: {traceback.format_exc()}") + time.sleep(self.IncrementalPID.dt) + + # 控制周期时间补偿 (保持控制频率恒定) + # print(f"cycle time:{time.perf_counter() - cycle_start}") + elapsed_time = time.perf_counter() - cycle_start + sleep_time = max(0.001, self.IncrementalPID.dt - elapsed_time) # 确保最小等待1ms + time.sleep(sleep_time) + total_time = time.perf_counter() - cycle_start + print(f"total time:{total_time}") + if total_time > 0.11: + print(f"=================================超时!本循环用时{total_time}") + # self.log_message(f"超时!本循环用时{total_time}") + print("\n") + + def update_display(self, current_pressure, target_pressure, valve_opening): + """更新显示并记录数据 (带滚动窗口限制)""" + self.current_pressure_var.set(f"{current_pressure:.1f} kPa") + self.target_pressure_var.set(f"{target_pressure:.1f} kPa") + self.valve_opening_var.set(f"{valve_opening:.1f} %") + + # 1. 记录数据用于绘图 + current_time = time.time() + if self.start_time is not None: + elapsed_time = current_time - self.start_time + else: + self.start_time = current_time + elapsed_time = 0 + + self.time_data.append(elapsed_time) + self.pressure_data.append(current_pressure) + self.target_data.append(target_pressure) + self.valve_data.append(valve_opening) + + # ======================================================== + # 2. 新增:限制绘图数据的最大长度(只保留最近10分钟) + # 控制周期 0.05s,10分钟 = 600秒 = 12000个控制周期 + # ======================================================== + # max_points = 12000 + # if len(self.time_data) > max_points: + # print(1111) + # # 列表切片,丢弃最前面的老点,只保留最后 12000 个新点 + # self.time_data = self.time_data[-max_points:] + # self.pressure_data = self.pressure_data[-max_points:] + # self.target_data = self.target_data[-max_points:] + # self.valve_data = self.valve_data[-max_points:] + + def plot_control_data(self): + """绘制控制数据曲线 - 修复白屏问题""" + if not self.pressure_data: + self.log_message("没有可绘制的数据") + return + + # 防止重复点击 + if self.is_plotting: + return + + self.is_plotting = True + self.plot_btn.config(state=tk.DISABLED) + self.log_message("正在生成图表...") + + # 在新线程中创建绘图窗口 + plot_thread = threading.Thread(target=self._create_plot_window, daemon=True) + plot_thread.start() + + def _create_plot_window(self): + """在新线程中创建绘图窗口""" + try: + # 确保matplotlib使用正确的设置 + matplotlib.use('TkAgg') + # plt.rcParams['font.sans-serif'] = ['SimHei'] + # plt.rcParams['axes.unicode_minus'] = False + + # 在主线程中创建窗口 + self.root.after(0, self._safe_create_plot_window) + except Exception as e: + self.root.after(0, self._plot_error, str(e)) + + def _safe_create_plot_window(self): + """安全创建绘图窗口(在主线程中执行)""" + try: + # 创建新的Toplevel窗口 + plot_window = tk.Toplevel(self.root) + plot_window.title("控制数据曲线图") + plot_window.geometry("1100x800") + + # 添加加载提示 + loading_label = ttk.Label(plot_window, text="正在加载图表...", font=("Arial", 12)) + loading_label.pack(pady=20) + plot_window.update() + + # 创建主框架 + main_frame = ttk.Frame(plot_window) + main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # 创建控制面板 + control_frame = ttk.Frame(main_frame) + control_frame.pack(fill=tk.X, pady=(0, 10)) + + # X轴范围控制 + ttk.Label(control_frame, text="时间轴范围 (秒):").pack(side=tk.LEFT, padx=(0, 5)) + + self.x_min_var = tk.StringVar(value="0") + x_min_entry = ttk.Entry(control_frame, textvariable=self.x_min_var, width=10) + x_min_entry.pack(side=tk.LEFT, padx=5) + + ttk.Label(control_frame, text="到").pack(side=tk.LEFT, padx=5) + + if self.time_data: + self.x_max_var = tk.StringVar(value=f"{max(self.time_data):.1f}") + else: + self.x_max_var = tk.StringVar(value="10") + + x_max_entry = ttk.Entry(control_frame, textvariable=self.x_max_var, width=10) + x_max_entry.pack(side=tk.LEFT, padx=5) + + ttk.Button(control_frame, text="应用", + command=lambda: self._apply_x_limits()).pack(side=tk.LEFT, padx=10) + + ttk.Button(control_frame, text="重置", + command=lambda: self._reset_view()).pack(side=tk.LEFT, padx=5) + + ttk.Button(control_frame, text="全部", + command=lambda: self._show_all_data()).pack(side=tk.LEFT, padx=5) + + ttk.Button(control_frame, text="最后30秒", + command=lambda: self._zoom_last_n_seconds(30)).pack(side=tk.LEFT, padx=5) + + # 创建图形 + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), dpi=100) + + # 确保有足够的数据 + if not self.time_data or len(self.time_data) < 2: + loading_label.config(text="数据不足,无法绘制图表") + self._reenable_plot_button() + return + + # 压力曲线 + ax1.plot(self.time_data, self.pressure_data, 'b-o', + linewidth=1.5, markersize=3, alpha=0.8, label='实际压力') + + ax1.plot(self.time_data, self.target_data, 'r--', + linewidth=1.5, alpha=0.8, label='目标压力') + + ax1.set_ylabel('压力 (kPa)', fontsize=12) + ax1.set_title('压力控制性能', fontsize=14, fontweight='bold') + ax1.legend(loc='upper right', fontsize=10) + ax1.grid(True, alpha=0.3) + + # 阀门开度曲线 + ax2.plot(self.time_data, self.valve_data, 'm-o', + linewidth=1.5, markersize=3, alpha=0.8, label='实际阀门指令') + + ax2.set_xlabel('时间 (秒)', fontsize=12) + ax2.set_ylabel('阀门开度 (%)', fontsize=12) + ax2.legend(loc='upper right', fontsize=10) + ax2.set_ylim([0, 105]) + ax2.grid(True, alpha=0.3) + + # 共享X轴 + ax2.sharex(ax1) + + plt.tight_layout() + + # 移除加载提示 + loading_label.destroy() + + # 创建画布 + canvas_frame = ttk.Frame(main_frame) + canvas_frame.pack(fill=tk.BOTH, expand=True) + + canvas = FigureCanvasTkAgg(fig, master=canvas_frame) + canvas.draw() + + # 添加导航工具栏 + toolbar_frame = ttk.Frame(canvas_frame) + toolbar_frame.pack(fill=tk.X, pady=(0, 5)) + + toolbar = NavigationToolbar2Tk(canvas, toolbar_frame) + toolbar.update() + + # 将画布放置到窗口中 + canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + + # 添加提示标签 + hint_label = ttk.Label(canvas_frame, + text="提示: 使用工具栏缩放/平移 | 拖动矩形区域可局部放大", + font=("Arial", 9), foreground="gray") + hint_label.pack(side=tk.BOTTOM, pady=(5, 0)) + + # 存储图表对象 + self.current_fig = fig + self.current_ax1 = ax1 + self.current_ax2 = ax2 + self.current_canvas = canvas + + # 配置窗口关闭事件 + def on_closing(): + try: + plt.close(fig) + plot_window.destroy() + self.current_fig = None + self.current_ax1 = None + self.current_ax2 = None + self.current_canvas = None + except: + pass + finally: + self.is_plotting = False + self._reenable_plot_button() + + plot_window.protocol("WM_DELETE_WINDOW", on_closing) + + # 确保窗口正确显示 + plot_window.update() + plot_window.deiconify() + + self.log_message("图表已生成") + + except Exception as e: + self.log_message(f"创建图表时出错: {str(e)}") + import traceback + traceback.print_exc() + finally: + self.is_plotting = False + self._reenable_plot_button() + + def _apply_x_limits(self): + """应用X轴范围限制""" + if not self.current_canvas or not self.current_ax1: + return + + try: + x_min = float(self.x_min_var.get()) + x_max = float(self.x_max_var.get()) + + if x_min >= x_max: + return + + self.current_ax1.set_xlim([x_min, x_max]) + self.current_ax2.set_xlim([x_min, x_max]) + self.current_canvas.draw() + except ValueError: + pass + + def _reset_view(self): + """重置视图""" + if not self.current_canvas or not self.current_ax1 or not self.time_data: + return + + x_min = min(self.time_data) + x_max = max(self.time_data) + x_range = x_max - x_min + margin = x_range * 0.05 if x_range > 0 else 0.1 + + self.current_ax1.set_xlim([x_min - margin, x_max + margin]) + self.current_ax2.set_xlim([x_min - margin, x_max + margin]) + + self.x_min_var.set(f"{x_min - margin:.1f}") + self.x_max_var.set(f"{x_max + margin:.1f}") + + self.current_canvas.draw() + + def _show_all_data(self): + """显示所有数据""" + if not self.current_canvas or not self.current_ax1 or not self.time_data: + return + + x_min = min(self.time_data) + x_max = max(self.time_data) + + self.current_ax1.set_xlim([x_min, x_max]) + self.current_ax2.set_xlim([x_min, x_max]) + + self.x_min_var.set(f"{x_min:.1f}") + self.x_max_var.set(f"{x_max:.1f}") + + self.current_canvas.draw() + + def _zoom_last_n_seconds(self, n_seconds): + """缩放到最后N秒的数据""" + if not self.current_canvas or not self.current_ax1 or not self.time_data: + return + + x_max = max(self.time_data) + x_min = max(0, x_max - n_seconds) + + self.current_ax1.set_xlim([x_min, x_max]) + self.current_ax2.set_xlim([x_min, x_max]) + + self.x_min_var.set(f"{x_min:.1f}") + self.x_max_var.set(f"{x_max:.1f}") + + self.current_canvas.draw() + + def _plot_error(self, error_msg): + """处理绘图错误""" + self.log_message(f"绘图错误: {error_msg}") + self._reenable_plot_button() + + def _reenable_plot_button(self): + """重新启用绘制按钮""" + if self.plot_btn and self.plot_btn.winfo_exists(): + self.plot_btn.config(state=tk.NORMAL) + diff --git a/ReinLoop/tool/identification_config.example.csv b/ReinLoop/tool/identification_config.example.csv new file mode 100644 index 0000000..c93aecc --- /dev/null +++ b/ReinLoop/tool/identification_config.example.csv @@ -0,0 +1,10 @@ +parameter,value +q_in_val,50.0 +dt,0.1 +n_order,6 +t_c,2.5 +levels,"10,20,30,40,50,60,70,80" +dead_area,240.0 +xa_full,1000.0 +V_val,5.0 +repeat,2 diff --git a/ReinLoop/tool/plt_font.py b/ReinLoop/tool/plt_font.py new file mode 100644 index 0000000..c5f03ae --- /dev/null +++ b/ReinLoop/tool/plt_font.py @@ -0,0 +1,61 @@ +import matplotlib +import shutil +import os + +# 获取 Matplotlib 缓存目录 +cache_dir = matplotlib.get_cachedir() +print(f"正在清理缓存目录: {cache_dir}") + +# 删除缓存 +if os.path.exists(cache_dir): + shutil.rmtree(cache_dir) + print("字体缓存已清除!请重新运行你的主程序。") +else: + print("未找到缓存目录。") + +import os +import matplotlib + +matplotlib.use('TkAgg') +import matplotlib.pyplot as plt +import matplotlib.font_manager as fm + + +# ----------------- 强制解决中文乱码 (Mac版) ----------------- +def force_chinese_font_mac(): + """强制加载 macOS 系统自带的苹方或黑体""" + # macOS 常见中文字体路径 + font_paths = [ + "/System/Library/Fonts/PingFang.ttc", # 苹方 (现代 macOS 默认中文字体) + "/System/Library/Fonts/STHeiti Light.ttc", # 华文黑体 + "/System/Library/Fonts/STHeiti Medium.ttc", # 华文黑体 (中等粗细) + "/System/Library/Fonts/Supplemental/Songti.ttc", # 宋体 (部分较新 macOS 系统的路径) + "/Library/Fonts/Arial Unicode.ttf" # 包含中文的通用字体 + ] + + font_loaded = False + for path in font_paths: + if os.path.exists(path): + try: + # 强制将字体加入 Matplotlib 的内存库 + fm.fontManager.addfont(path) + # 获取该字体在 matplotlib 内部的真实名称 + prop = fm.FontProperties(fname=path) + plt.rcParams['font.family'] = prop.get_name() + font_loaded = True + print(f"已成功加载 Mac 系统字体: {path}") + break # 加载成功一个就跳出 + except Exception as e: + print(f"尝试加载字体 {path} 失败: {e}") + continue + + if not font_loaded: + print("警告: 未在 macOS 默认路径找到中文字体文件。") + + # 解决负号 '-' 显示为方块的问题 + plt.rcParams['axes.unicode_minus'] = False + + +# 立即执行字体加载 +force_chinese_font_mac() +# ---------------------------------------------------- \ No newline at end of file diff --git a/ReinLoop/tool/submit_identification_feedback.py b/ReinLoop/tool/submit_identification_feedback.py new file mode 100644 index 0000000..b0c551d --- /dev/null +++ b/ReinLoop/tool/submit_identification_feedback.py @@ -0,0 +1,26 @@ +"""已迁移至 ControlPanel 的辨识反馈管理能力。""" + +import argparse +def submit_feedback(customer: str, result: int, run_id=None, timeout=20): + raise RuntimeError("辨识反馈已迁移至 ControlPanel,客户端不提供管理接口") + + +def main(): + parser = argparse.ArgumentParser(description="提交辨识结果 0/1") + parser.add_argument("customer", help="许可证中的客户名称") + parser.add_argument("result", type=int, choices=(0, 1), help="1=通过,0=未通过") + parser.add_argument("--run-id", help="可选:限定当前辨识 CSV 文件名") + args = parser.parse_args() + + try: + data = submit_feedback(args.customer, args.result, args.run_id) + except Exception as exc: + print(f"提交失败: {exc}") + return 1 + state = "已通过" if data["result"] == 1 else "未通过" + print(f"提交成功:{state},runId={data.get('runId', '')}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ReinLoop/tool/volume_measurement.example.json b/ReinLoop/tool/volume_measurement.example.json new file mode 100644 index 0000000..adfbc5b --- /dev/null +++ b/ReinLoop/tool/volume_measurement.example.json @@ -0,0 +1,10 @@ +{ + "q_in_val": 50.0, + "dt": 0.05, + "p_max": 200.0, + "fit_low": 50.0, + "fit_high": 150.0, + "T_delta": 30.0, + "xa_full": 1000.0, + "num_runs": 3 +} diff --git a/ReinLoop/ui/__init__.py b/ReinLoop/ui/__init__.py new file mode 100644 index 0000000..aea6472 --- /dev/null +++ b/ReinLoop/ui/__init__.py @@ -0,0 +1 @@ +# ui package - Pure PySide6 UI layer diff --git a/ReinLoop/ui/connection_tab.py b/ReinLoop/ui/connection_tab.py new file mode 100644 index 0000000..6e875fe --- /dev/null +++ b/ReinLoop/ui/connection_tab.py @@ -0,0 +1,177 @@ +# connection_tab.py +"""页面1:Modbus TCP 连接参数设置""" + +import os +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QLineEdit, QPushButton, QFrame, + QSizePolicy, QGraphicsDropShadowEffect +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QColor, QIcon + + +_SRC_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "src")) + + +# ========================================== +# 工具函数:创建带左侧蓝色竖线的 Section 卡片 +# ========================================== +def _make_section_card(parent, title_text: str, colors: dict): + card = QFrame(parent) + card.setProperty("cssClass", "sectionCard") + card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + # 添加微弱的模糊阴影效果 + shadow = QGraphicsDropShadowEffect(card) + shadow.setColor(QColor(0, 0, 0, 12)) + shadow.setBlurRadius(16) + shadow.setOffset(0, 4) + card.setGraphicsEffect(shadow) + + outer = QVBoxLayout(card) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # ---- 标题行(蓝色左竖线 + 标题文字) ---- + title_row = QHBoxLayout() + title_row.setContentsMargins(20, 16, 20, 0) + title_row.setSpacing(10) + + accent = QWidget() + accent.setProperty("cssClass", "sectionAccent") + accent.setFixedSize(4, 16) + title_row.addWidget(accent) + + title_lbl = QLabel(title_text) + title_lbl.setProperty("cssClass", "sectionTitle") + title_row.addWidget(title_lbl) + title_row.addStretch() + outer.addLayout(title_row) + + # ---- 内容区 ---- + content_widget = QWidget() + content_widget.setStyleSheet("background-color: transparent;") + content_layout = QGridLayout(content_widget) + content_layout.setContentsMargins(20, 14, 20, 18) + content_layout.setHorizontalSpacing(0) + content_layout.setVerticalSpacing(10) + # 列0(标签)固定宽度,列1(输入框)拉伸 + content_layout.setColumnMinimumWidth(0, 148) + content_layout.setColumnStretch(1, 1) + outer.addWidget(content_widget) + + return card, content_layout + + +# ========================================== +# 工具函数:创建表单标签(左对齐) +# ========================================== +def _form_label(text: str, parent=None): + lbl = QLabel(text, parent) + lbl.setProperty("cssClass", "formLabel") + lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + return lbl + + +class ConnectionTab(QWidget): + """连接设置页面""" + + def __init__(self, colors: dict, parent=None): + super().__init__(parent) + self.setProperty("cssClass", "tabPage") + self.colors = colors + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 16, 20, 16) + main_layout.setSpacing(14) + + # ========================================== + # Section: Modbus TCP + # ========================================== + tcp_card, tcp_layout = _make_section_card(self, "Modbus TCP", colors) + self._build_tcp_section(tcp_layout) + main_layout.addWidget(tcp_card) + + # ========================================== + # 按钮组 + # ========================================== + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 4, 0, 0) + btn_row.setSpacing(12) + + # 连接设备按钮 + self.connect_btn = QPushButton(" 连接设备") + self.connect_btn.setObjectName("connect_btn") + self.connect_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "connect_device.svg"))) + self.connect_btn.setIconSize(QSize(18, 18)) + self.connect_btn.setCursor(Qt.PointingHandCursor) + + # 保存按钮引用(connect/disconnect 切换文字时使用) + self._connect_text_lbl = self.connect_btn + + btn_row.addWidget(self.connect_btn) + btn_row.addStretch() + + main_layout.addLayout(btn_row) + main_layout.addStretch() + + # ========================================== + # Modbus TCP 表单 + # ========================================== + def _build_tcp_section(self, grid: QGridLayout): + row = 0 + + grid.addWidget(_form_label("模块地址:", self), row, 0) + self.tcp_ip_entry = QLineEdit("192.168.1.12") + self.tcp_ip_entry.setPlaceholderText("输入模块 IP地址") + grid.addWidget(self.tcp_ip_entry, row, 1) + row += 1 + + grid.addWidget(_form_label("端口:", self), row, 0) + self.tcp_port_entry = QLineEdit("502") + self.tcp_port_entry.setPlaceholderText("默认502") + grid.addWidget(self.tcp_port_entry, row, 1) + row += 1 + + grid.addWidget(_form_label("读取压力寄存器地址:", self), row, 0) + self.pressure_addr_entry = QLineEdit("0") + self.pressure_addr_entry.setPlaceholderText("寄存器地址") + grid.addWidget(self.pressure_addr_entry, row, 1) + row += 1 + + grid.addWidget(_form_label("电机地址:", self), row, 0) + self.motor_addr_entry = QLineEdit("0") + self.motor_addr_entry.setPlaceholderText("电机模拟量通道地址") + grid.addWidget(self.motor_addr_entry, row, 1) + row += 1 + + grid.addWidget(_form_label("流量计地址:", self), row, 0) + self.flowmeter_addr_entry = QLineEdit("1") + self.flowmeter_addr_entry.setPlaceholderText("留空则使用手动输入流量") + grid.addWidget(self.flowmeter_addr_entry, row, 1) + row += 1 + + grid.addWidget(_form_label("压力表量程:", self), row, 0) + self.pressure_range_entry = QLineEdit("400") + self.pressure_range_entry.setPlaceholderText("压力传感器量程上限") + grid.addWidget(self.pressure_range_entry, row, 1) + row += 1 + + grid.addWidget(_form_label("流量计量程:", self), row, 0) + self.flow_range_entry = QLineEdit("300") + self.flow_range_entry.setPlaceholderText("流量计量程上限") + grid.addWidget(self.flow_range_entry, row, 1) + + # ---- 公开方法 ---- + def get_connection_params(self) -> dict: + flow_str = self.flowmeter_addr_entry.text().strip() + return { + "tcp_ip": self.tcp_ip_entry.text().strip(), + "tcp_port": int(self.tcp_port_entry.text() or "502"), + "pressure_addr": int(self.pressure_addr_entry.text() or "504"), + "motor_addr": int(self.motor_addr_entry.text() or "0"), + "flowmeter_addr": int(flow_str) if flow_str else None, + "pressure_range": float(self.pressure_range_entry.text() or "400"), + "flow_range": float(self.flow_range_entry.text() or "300"), + } diff --git a/ReinLoop/ui/control_tab.py b/ReinLoop/ui/control_tab.py new file mode 100644 index 0000000..e898288 --- /dev/null +++ b/ReinLoop/ui/control_tab.py @@ -0,0 +1,593 @@ +# control_tab.py +"""页面2:系统状态栏(三卡片) + 控制参数(Section 卡片) + +重构要点: +- 状态栏:三张横向并排卡片,每张含圆形图标 + 大字数值 + 右上角色点 +- 控制参数区:Section 卡片(蓝竖线装饰),QGridLayout 双列布局,输入列拉伸占满约 2/3 页宽 +""" + +import os +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QLineEdit, QComboBox, QPushButton, + QRadioButton, QCheckBox, QFrame, QButtonGroup, QSizePolicy, + QGraphicsDropShadowEffect, +) +from PySide6.QtCore import Qt, Signal, QSize +from PySide6.QtGui import QColor, QIcon +from PySide6.QtSvgWidgets import QSvgWidget + +from ui.connection_tab import _make_section_card + +_SRC_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "src")) + +# ========================================== +# 工具函数:透明容器 +# ========================================== +def _transparent_widget() -> QWidget: + """创建一个透明的空容器(用于包裹多个控件)。""" + w = QWidget() + w.setProperty("cssClass", "transparentBg") + w.style().unpolish(w) + w.style().polish(w) + return w + + +# ========================================== +# 工具函数:三点状态栏卡片 +# ========================================== +def _make_status_card(parent, title: str, value: str, unit: str, + value_color: str, circle_bg: str, + icon_path: str, dot_color: str): + """创建单张状态卡片(圆形图标 + 大字数值 + 右上角圆点)。 + + 返回 (card, value_label)。 + """ + card = QFrame(parent) + card.setProperty("cssClass", "sectionCard") + card.style().unpolish(card) + card.style().polish(card) + card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + shadow = QGraphicsDropShadowEffect(card) + shadow.setColor(QColor(0, 0, 0, 10)) + shadow.setBlurRadius(14) + shadow.setOffset(0, 2) + card.setGraphicsEffect(shadow) + + inner = QVBoxLayout(card) + inner.setContentsMargins(16, 12, 16, 14) + inner.setSpacing(0) + + # ---- 右上角圆点 ---- + dot_row = QHBoxLayout() + dot_row.setContentsMargins(0, 0, 0, 6) + dot_row.addStretch() + dot = QWidget() + dot.setFixedSize(8, 8) + dot.setStyleSheet(f"background: {dot_color}; border-radius: 4px;") + dot_row.addWidget(dot) + inner.addLayout(dot_row) + + # ---- 主体:圆形图标 + 文本 ---- + body = QHBoxLayout() + body.setSpacing(30) + + # 圆形图标容器 + icon_circle = QWidget() + icon_circle.setFixedSize(82, 82) + icon_circle.setStyleSheet( + f"background: {circle_bg}; border-radius: 41px;" + ) + icon_inner = QVBoxLayout(icon_circle) + icon_inner.setContentsMargins(0, 0, 0, 0) + icon_inner.setAlignment(Qt.AlignCenter) + + svg = QSvgWidget(icon_path) + svg.setFixedSize(48, 48) + icon_inner.addWidget(svg, alignment=Qt.AlignCenter) + + body.addWidget(icon_circle) + + # 文本列 + text_col = QVBoxLayout() + text_col.setSpacing(4) + + title_lbl = QLabel(title) + title_lbl.setStyleSheet( + "color: #555555; font-size: 15px; background: transparent; border: none;" + ) + text_col.addWidget(title_lbl) + + value_row = QHBoxLayout() + value_row.setSpacing(4) + + val_lbl = QLabel(value) + val_lbl.setStyleSheet( + f"color: {value_color}; font-size: 56px; font-weight: bold;" + "background: transparent; border: none;" + ) + value_row.addWidget(val_lbl) + + unit_lbl = QLabel(unit) + unit_lbl.setStyleSheet( + f"color: {value_color}; font-size: 24px; background: transparent;" + "border: none; padding-top: 14px;" + ) + value_row.addWidget(unit_lbl) + value_row.addStretch() + + text_col.addLayout(value_row) + body.addLayout(text_col, 1) + inner.addLayout(body, 1) + + return card, val_lbl + + +# ========================================== +# 工具函数:行级标签 +# ========================================== +def _label(text: str, parent=None) -> QLabel: + """紧凑表单标签。""" + lbl = QLabel(text, parent) + lbl.setStyleSheet( + "color: #333333; font-size: 14px; font-weight: bold; background: transparent;" + ) + lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + return lbl + + +def _unit_label(unit: str, parent=None) -> QLabel: + """单位标签(灰色小字)。""" + lbl = QLabel(unit, parent) + lbl.setStyleSheet( + "color: #94A3B8; font-size: 12px; background: transparent;" + ) + return lbl + + +# ========================================== +# 主类 +# ========================================== +class ControlTab(QWidget): + """控制设置页面""" + + # ---- 信号 ---- + target_set_requested = Signal(float) + mode_changed = Signal(str) + pid_update_requested = Signal(float, float, float) + model_load_requested = Signal(str) + models_refresh_requested = Signal() + control_toggle_requested = Signal() + plot_requested = Signal() + manual_valve_set_requested = Signal(float) + log_message_requested = Signal(str) + + def __init__(self, colors: dict, parent=None): + super().__init__(parent) + self.setProperty("cssClass", "tabPage") + self.colors = colors + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 16, 20, 16) + main_layout.setSpacing(14) + + # ========================================== + # A. 系统状态栏(三卡片) + # ========================================== + status_bar = QHBoxLayout() + status_bar.setSpacing(14) + + self._pressure_card, self.current_pressure_lbl = _make_status_card( + self, + title="当前系统压力", + value="0.0", + unit="kPa", + value_color="#0F955D", + circle_bg="#E2F5ED", + icon_path=os.path.join(_SRC_DIR, "pressure.svg"), + dot_color="#0F955D", + ) + + self._target_card, self.target_pressure_lbl = _make_status_card( + self, + title="设置目标压力", + value="0.0", + unit="kPa", + value_color="#0960D1", + circle_bg="#EBF3FE", + icon_path=os.path.join(_SRC_DIR, "target.svg"), + dot_color="#0960D1", + ) + + self._valve_card, self.valve_opening_lbl = _make_status_card( + self, + title="控制阀门开度", + value="0.0", + unit="%", + value_color="#E67E22", + circle_bg="#FFF2E8", + icon_path=os.path.join(_SRC_DIR, "valve.svg"), + dot_color="#E67E22", + ) + + status_bar.addWidget(self._pressure_card) + status_bar.addWidget(self._target_card) + status_bar.addWidget(self._valve_card) + main_layout.addLayout(status_bar) + + # ========================================== + # B. 控制参数设置区(Section 卡片) + # ========================================== + ctrl_card, ctrl_grid = _make_section_card(self, "控制设置", colors) + self._build_control_section(ctrl_grid) + main_layout.addWidget(ctrl_card) + + main_layout.addStretch() + + # 信号连接 + self.mode_group.buttonClicked.connect(self._on_mode_changed_internal) + + # ========================================== + # 控制设置 — QGridLayout 双列布局,输入列拉伸占满 2/3 页宽 + # ========================================== + def _build_control_section(self, grid: QGridLayout): + # 沿用 _make_section_card 的列配置:col 0 标签固定 148px,col 1 输入区拉伸 + grid.setVerticalSpacing(16) + + # --- B1: 物理工况 (容积 + 流量) --- + row = 0 + grid.addWidget(_label("物理工况:"), row, 0) + b1 = _transparent_widget() + b1h = QHBoxLayout(b1) + b1h.setContentsMargins(0, 0, 0, 0) + b1h.setSpacing(6) + b1h.addWidget(_label("容积")) + self.volume_entry = QLineEdit() + self.volume_entry.setFixedWidth(120) + b1h.addWidget(self.volume_entry) + b1h.addWidget(_unit_label("L")) + b1h.addSpacing(32) + b1h.addWidget(_label("流量")) + self.flow_entry = QLineEdit("100") + self.flow_entry.setFixedWidth(120) + b1h.addWidget(self.flow_entry) + b1h.addWidget(_unit_label("L/min")) + b1h.addStretch() + grid.addWidget(b1, row, 1) + + # --- B2: 目标压力 + 按钮 --- + row = 1 + grid.addWidget(_label("目标压力:"), row, 0) + b2 = _transparent_widget() + b2h = QHBoxLayout(b2) + b2h.setContentsMargins(0, 0, 0, 0) + b2h.setSpacing(6) + self.target_entry = QLineEdit("80.0") + self.target_entry.setFixedWidth(200) + b2h.addWidget(self.target_entry) + b2h.addWidget(_unit_label("kPa")) + b2h.addSpacing(10) + self.set_target_btn = QPushButton("设置目标") + self.set_target_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "target.svg"))) + self.set_target_btn.setIconSize(QSize(18, 18)) + self.set_target_btn.setStyleSheet( + "QPushButton { background: white; color: #0960D1; border: 1.5px solid #0960D1;" + "border-radius: 6px; padding: 9px 20px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background: #EBF3FE; }" + ) + self.set_target_btn.setCursor(Qt.PointingHandCursor) + self.set_target_btn.clicked.connect(self._on_set_target) + b2h.addWidget(self.set_target_btn) + b2h.addStretch() + grid.addWidget(b2, row, 1) + + # --- B3: 控制模式单选 --- + row = 2 + grid.addWidget(_label("控制方式:"), row, 0) + b3 = _transparent_widget() + b3h = QHBoxLayout(b3) + b3h.setContentsMargins(0, 0, 0, 0) + b3h.setSpacing(24) + self.mode_group = QButtonGroup(self) + self.radio_rl = QRadioButton("智能自动") + self.radio_pid = QRadioButton("手动PID") + self.radio_manual = QRadioButton("设置开度") + self.mode_group.addButton(self.radio_rl, 0) + self.mode_group.addButton(self.radio_pid, 1) + self.mode_group.addButton(self.radio_manual, 2) + self.radio_rl.setChecked(True) + b3h.addWidget(self.radio_rl) + b3h.addWidget(self.radio_pid) + b3h.addWidget(self.radio_manual) + b3h.addStretch() + grid.addWidget(b3, row, 1) + + # --- B4: 模型面板(跨两列,内部标签固定148px与外层col0对齐) --- + row = 3 + self.rl_panel = QWidget() + self.rl_panel.setStyleSheet("background: transparent;") + rl_layout = QHBoxLayout(self.rl_panel) + rl_layout.setContentsMargins(0, 0, 0, 0) + rl_layout.setSpacing(8) + rl_lbl = _label("决策模型:") + rl_lbl.setFixedWidth(148) + rl_layout.addWidget(rl_lbl) + self.model_combobox = QComboBox() + self.model_combobox.setFixedWidth(280) + self.model_combobox.setFixedHeight(36) + rl_layout.addWidget(self.model_combobox) + self.load_model_btn = QPushButton("加载模型") + self.load_model_btn.setStyleSheet( + "QPushButton { background: #0960D1; color: white; border: none;" + "border-radius: 6px; padding: 7px 16px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background: #0856B8; }" + ) + self.load_model_btn.setCursor(Qt.PointingHandCursor) + self.load_model_btn.clicked.connect(self._on_load_model) + rl_layout.addWidget(self.load_model_btn) + self.refresh_models_btn = QPushButton("🔄 刷新") + self.refresh_models_btn.setProperty("cssClass", "refresh") + self.refresh_models_btn.setCursor(Qt.PointingHandCursor) + self.refresh_models_btn.clicked.connect(self._on_refresh_models) + rl_layout.addWidget(self.refresh_models_btn) + rl_layout.addStretch() + grid.addWidget(self.rl_panel, row, 0, 1, 2) + + # --- B5: PID 面板(跨两列,内部标签固定148px) --- + row = 4 + self.pid_panel = QWidget() + self.pid_panel.setStyleSheet("background: transparent;") + self.pid_panel.hide() + pid_layout = QHBoxLayout(self.pid_panel) + pid_layout.setContentsMargins(0, 0, 0, 0) + pid_layout.setSpacing(6) + pid_lbl = _label("PID 调节:") + pid_lbl.setFixedWidth(148) + pid_layout.addWidget(pid_lbl) + pid_layout.addWidget(_label("Kp:")) + self.Kp_entry = QLineEdit("1.0") + self.Kp_entry.setFixedWidth(80) + pid_layout.addWidget(self.Kp_entry) + pid_layout.addWidget(_label("Ki:")) + self.Ki_entry = QLineEdit("0.4") + self.Ki_entry.setFixedWidth(80) + pid_layout.addWidget(self.Ki_entry) + pid_layout.addWidget(_label("Kd:")) + self.Kd_entry = QLineEdit("0") + self.Kd_entry.setFixedWidth(80) + pid_layout.addWidget(self.Kd_entry) + self.update_pid_btn = QPushButton("更新PID参数") + self.update_pid_btn.setStyleSheet( + "QPushButton { background: #0960D1; color: white; border: none;" + "border-radius: 6px; padding: 7px 16px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background: #0856B8; }" + ) + self.update_pid_btn.setCursor(Qt.PointingHandCursor) + self.update_pid_btn.clicked.connect(self._on_update_pid) + pid_layout.addWidget(self.update_pid_btn) + pid_layout.addStretch() + grid.addWidget(self.pid_panel, row, 0, 1, 2) + + # --- B6: 手动开度面板(跨两列,内部标签固定148px) --- + row = 5 + self.manual_panel = QWidget() + self.manual_panel.setStyleSheet("background: transparent;") + self.manual_panel.hide() + man_layout = QHBoxLayout(self.manual_panel) + man_layout.setContentsMargins(0, 0, 0, 0) + man_layout.setSpacing(6) + man_lbl = _label("设置开度:") + man_lbl.setFixedWidth(148) + man_layout.addWidget(man_lbl) + self.valve_entry = QLineEdit() + self.valve_entry.setFixedWidth(160) + man_layout.addWidget(self.valve_entry) + man_layout.addWidget(_unit_label("%")) + self.set_valve_btn = QPushButton("设置") + self.set_valve_btn.setStyleSheet( + "QPushButton { background: #0960D1; color: white; border: none;" + "border-radius: 6px; padding: 7px 16px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background: #0856B8; }" + ) + self.set_valve_btn.setCursor(Qt.PointingHandCursor) + self.set_valve_btn.clicked.connect(self._on_set_valve) + man_layout.addWidget(self.set_valve_btn) + man_layout.addStretch() + grid.addWidget(self.manual_panel, row, 0, 1, 2) + + # --- B7: 控制启停行(跨两列) --- + row = 6 + b7 = _transparent_widget() + b7h = QHBoxLayout(b7) + b7h.setContentsMargins(0, 0, 0, 0) + b7h.setSpacing(12) + self.start_btn = QPushButton("开始控制") + self.start_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "start_control.svg"))) + self.start_btn.setIconSize(QSize(18, 18)) + self.start_btn.setProperty("cssClass", "action") + self.start_btn.setStyleSheet( + "QPushButton { background-color: #0F955D; color: white; border: none;" + "border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background-color: #0D8250; }" + ) + self.start_btn.setCursor(Qt.PointingHandCursor) + self.start_btn.clicked.connect(self._on_toggle_control) + self.plot_btn = QPushButton("绘制图线") + self.plot_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "plot.svg"))) + self.plot_btn.setIconSize(QSize(18, 18)) + self.plot_btn.setStyleSheet( + "QPushButton { background: white; color: #0960D1; border: 1.5px solid #0960D1;" + "border-radius: 6px; padding: 9px 20px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background: #EBF3FE; }" + ) + self.plot_btn.setCursor(Qt.PointingHandCursor) + self.plot_btn.clicked.connect(self._on_plot) + self.collect_data_cb = QCheckBox("同步收集数据集") + b7h.addWidget(self.start_btn) + b7h.addWidget(self.plot_btn) + b7h.addWidget(self.collect_data_cb) + grid.addWidget(b7, row, 0, 1, 2) + + # ============================================================ + # 以下方法完全兼容旧版 API,main_window.py 无需变动 + # ============================================================ + + # ---- 模式切换 ---- + def _on_mode_changed_internal(self, btn): + if btn == self.radio_pid: + mode = "PID" + self.rl_panel.hide() + self.manual_panel.hide() + self.pid_panel.show() + self.collect_data_cb.setEnabled(True) + self.model_combobox.setEnabled(False) + self.load_model_btn.setEnabled(False) + self.refresh_models_btn.setEnabled(False) + elif btn == self.radio_rl: + mode = "RL" + self.pid_panel.hide() + self.manual_panel.hide() + self.rl_panel.show() + self.collect_data_cb.setEnabled(True) + self.model_combobox.setEnabled(True) + self.load_model_btn.setEnabled(True) + self.refresh_models_btn.setEnabled(True) + elif btn == self.radio_manual: + mode = "MANUAL" + self.rl_panel.hide() + self.pid_panel.hide() + self.manual_panel.show() + self.collect_data_cb.setChecked(False) + self.collect_data_cb.setEnabled(False) + else: + mode = "RL" + self.mode_changed.emit(mode) + + def init_mode_ui(self): + self.rl_panel.show() + self.pid_panel.hide() + self.manual_panel.hide() + + def set_mode_switch_enabled(self, enabled: bool): + self.radio_pid.setEnabled(enabled) + self.radio_rl.setEnabled(enabled) + self.radio_manual.setEnabled(enabled) + + # ---- 信号处理 ---- + def _on_set_target(self): + try: + target = float(self.target_entry.text()) + if 0 <= target <= 3000: + self.target_set_requested.emit(target) + else: + self.target_set_requested.emit(-1) + except ValueError: + self.target_set_requested.emit(-1) + + def _on_load_model(self): + selected = self.model_combobox.currentText() + self.model_load_requested.emit(selected) + + def _on_refresh_models(self): + self.models_refresh_requested.emit() + + def _on_update_pid(self): + try: + kp = float(self.Kp_entry.text()) + ki = float(self.Ki_entry.text()) + kd = float(self.Kd_entry.text()) + self.pid_update_requested.emit(kp, ki, kd) + except ValueError: + self.log_message_requested.emit("错误: PID参数输入无效,请输入有效数字") + + def _on_set_valve(self): + try: + valve = float(self.valve_entry.text()) + if 0 <= valve <= 120: + self.manual_valve_set_requested.emit(valve) + else: + self.manual_valve_set_requested.emit(-1) + except ValueError: + self.manual_valve_set_requested.emit(-2) + + def _on_toggle_control(self): + self.control_toggle_requested.emit() + + def _on_plot(self): + self.plot_requested.emit() + + # ---- 公开方法 (由 main_window 调用) ---- + def set_control_running(self, running: bool): + if running: + self.start_btn.setText("停止控制") + self.start_btn.setIcon(QIcon()) + self.start_btn.setProperty("cssClass", "danger") + self.start_btn.setStyleSheet( + "QPushButton { background-color: #EF4444; color: white; border: none;" + "border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background-color: #DC2626; }" + ) + else: + self.start_btn.setText("开始控制") + self.start_btn.setIcon(QIcon(os.path.join(_SRC_DIR, "start_control.svg"))) + self.start_btn.setIconSize(QSize(18, 18)) + self.start_btn.setProperty("cssClass", "action") + self.start_btn.setStyleSheet( + "QPushButton { background-color: #0F955D; color: white; border: none;" + "border-radius: 6px; padding: 9px 24px; font-weight: bold; font-size: 14px; }" + "QPushButton:hover { background-color: #0D8250; }" + ) + self.start_btn.style().unpolish(self.start_btn) + self.start_btn.style().polish(self.start_btn) + + def update_display(self, pressure: float, target: float, valve: float): + self.current_pressure_lbl.setText(f"{pressure:.1f}") + self.target_pressure_lbl.setText(f"{target:.1f}") + self.valve_opening_lbl.setText(f"{valve:.1f}") + + def update_pid_entries(self, kp: float, ki: float, kd: float): + self.Kp_entry.setText(f"{kp:.3f}") + self.Ki_entry.setText(f"{ki:.3f}") + self.Kd_entry.setText(f"{kd:.3f}") + + def update_model_list(self, files: list): + self.model_combobox.clear() + if files: + self.model_combobox.addItems(files) + else: + self.model_combobox.addItem("无模型文件") + + def get_mode(self) -> str: + if self.radio_pid.isChecked(): + return "PID" + elif self.radio_manual.isChecked(): + return "MANUAL" + return "RL" + + def get_collect_data(self) -> bool: + return self.collect_data_cb.isChecked() + + def get_control_params(self) -> dict: + return { + "volume": float(self.volume_entry.text() or "0"), + "flow": float(self.flow_entry.text() or "100"), + } + + def get_pid_params(self) -> tuple: + return ( + float(self.Kp_entry.text() or "1.0"), + float(self.Ki_entry.text() or "0.4"), + float(self.Kd_entry.text() or "0"), + ) + + def get_manual_valve(self) -> float: + return float(self.valve_entry.text() or "0") + + def enable_plot_button(self, enable: bool): + self.plot_btn.setEnabled(enable) + + def set_pid_entries_text(self, kp, ki, kd): + self.Kp_entry.setText(str(kp)) + self.Ki_entry.setText(str(ki)) + self.Kd_entry.setText(str(kd)) diff --git a/ReinLoop/ui/debug_tab.py b/ReinLoop/ui/debug_tab.py new file mode 100644 index 0000000..a666969 --- /dev/null +++ b/ReinLoop/ui/debug_tab.py @@ -0,0 +1,427 @@ +# debug_tab.py +"""页面3:系统辨识 + 高级设置(Section 卡片 + 蓝竖线装饰) + +参考 connection_tab 的页面设计,使用 _make_section_card 创建带蓝色左侧竖线的 +纯白卡片,内部以 QGridLayout 双列排列表单项。 +""" + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QLineEdit, QPushButton, +) +from PySide6.QtCore import Qt, QSettings, Signal + +from ui.connection_tab import _make_section_card + +# ---- 按钮默认样式(品牌蓝底白字,保证不被父级 inline stylesheet 覆盖) ---- +_BTN_STYLE = """ + QPushButton { + background-color: #0960D1; + color: white; + border: none; + border-radius: 6px; + padding: 9px 20px; + font-weight: bold; + font-size: 14px; + } + QPushButton:hover { + background-color: #0856B8; + } + QPushButton:pressed { + background-color: #0960D1; + } +""" + +_BTN_STYLE_DANGER = """ + QPushButton { + background-color: #EF4444; + color: white; + border: none; + border-radius: 6px; + padding: 9px 20px; + font-weight: bold; + font-size: 14px; + } + QPushButton:hover { + background-color: #DC2626; + } + QPushButton:pressed { + background-color: #B91C1C; + } +""" + + +def _compact_label(text: str, parent=None) -> QLabel: + """紧凑表单标签(无 140px min-width,自然适应文字宽度)。""" + lbl = QLabel(text, parent) + lbl.setStyleSheet( + "color: #333333; font-size: 14px; font-weight: bold;" + "background: transparent;" + ) + lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + return lbl + + +def _wrap_widget(child: QWidget) -> QWidget: + """将子控件放入透明容器(使用 cssClass 而非 inline stylesheet, + 避免覆盖子控件的 QSS 样式)。""" + w = QWidget() + w.setProperty("cssClass", "transparentBg") + w.style().unpolish(w) + w.style().polish(w) + lay = QHBoxLayout(w) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + lay.addWidget(child, 1) + return w + + +def _transparent_widget() -> QWidget: + """创建一个透明的空容器(用于包裹多个控件)。""" + w = QWidget() + w.setProperty("cssClass", "transparentBg") + w.style().unpolish(w) + w.style().polish(w) + return w + + +class DebugTab(QWidget): + """模型调试页面""" + + # ---- 信号 ---- + identify_start_requested = Signal() + identify_stop_requested = Signal() + volume_measure_requested = Signal() + volume_stop_requested = Signal() + + def __init__(self, colors: dict, parent=None): + super().__init__(parent) + self.setProperty("cssClass", "tabPage") + self.colors = colors + + # 记录按钮当前是否处于"运行中"状态 + self._identifying_running = False + self._volume_running = False + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(20, 16, 20, 16) + main_layout.setSpacing(14) + + # ========================================== + # Card 1: 系统辨识 + # ========================================== + ident_card, ident_grid = _make_section_card(self, "系统辨识", colors) + self._build_ident_section(ident_grid) + main_layout.addWidget(ident_card) + + # ========================================== + # Card 2: 高级设置 + # ========================================== + adv_card, adv_grid = _make_section_card(self, "高级设置", colors) + self._build_advanced_section(adv_grid) + main_layout.addWidget(adv_card) + adv_card.hide() + + main_layout.addStretch() + + # 恢复上次保存的设置 + self._load_settings() + + # ========================================== + # Card 1: 系统辨识 — 4 行双列 + 1 行通栏 + # ========================================== + def _build_ident_section(self, grid: QGridLayout): + # 重置 _make_section_card 预设的单列表单列宽配置 + for c in range(10): + grid.setColumnMinimumWidth(c, 0) + grid.setColumnStretch(c, 0) + + # 紧凑双列布局: 左标签 | 左输入区 | 间距 | 右标签 | 右输入区 + grid.setColumnMinimumWidth(0, 60) + grid.setColumnStretch(1, 1) + grid.setColumnMinimumWidth(2, 100) + grid.setColumnMinimumWidth(3, 60) + grid.setColumnStretch(4, 1) + grid.setVerticalSpacing(8) + + # ---- 第 1 行:压力上限(kPa) | 过程升温(°C) ---- + row = 0 + grid.addWidget(_compact_label("压力上限:", self), row, 0) + self.p_max_entry = QLineEdit("200") + grid.addWidget(self._with_unit(self.p_max_entry, "kPa"), row, 1) + + grid.addWidget(_compact_label("过程升温:", self), row, 3) + self.T_delta_entry = QLineEdit("30") + grid.addWidget(self._with_unit(self.T_delta_entry, "°C"), row, 4) + + # ---- 第 2 行:约束上界 | 下界 ---- + row = 1 + grid.addWidget(_compact_label("约束上界:", self), row, 0) + self.fit_high_entry = QLineEdit("200") + grid.addWidget(_wrap_widget(self.fit_high_entry), row, 1) + + grid.addWidget(_compact_label("下界:", self), row, 3) + self.fit_low_entry = QLineEdit("50") + grid.addWidget(_wrap_widget(self.fit_low_entry), row, 4) + + # ---- 第 3 行:容积(L) | 测试按钮 ---- + row = 2 + grid.addWidget(_compact_label("容积:", self), row, 0) + self.volume_entry = QLineEdit() + grid.addWidget(self._with_unit(self.volume_entry, "L"), row, 1) + + self.test_btn = QPushButton("测试") + self.test_btn.setStyleSheet(_BTN_STYLE) + self.test_btn.setCursor(Qt.PointingHandCursor) + self.test_btn.clicked.connect(self._on_measure_volume) + + btn_wrap = _transparent_widget() + btn_h = QHBoxLayout(btn_wrap) + btn_h.setContentsMargins(0, 0, 0, 0) + btn_h.addWidget(self.test_btn) + btn_h.addStretch() + grid.addWidget(btn_wrap, row, 4) + + # ---- 第 4 行:周期(s) | 阶数 ---- + row = 3 + grid.addWidget(_compact_label("周期:", self), row, 0) + self.period_entry = QLineEdit("2.5") + grid.addWidget(self._with_unit(self.period_entry, "s"), row, 1) + + grid.addWidget(_compact_label("阶数:", self), row, 3) + self.order_entry = QLineEdit("6") + grid.addWidget(_wrap_widget(self.order_entry), row, 4) + + # ---- 第 5 行(通栏):序列 + 开始辨识按钮 ---- + row = 4 + grid.addWidget(_compact_label("序列:", self), row, 0) + + seq_wrap = _transparent_widget() + seq_h = QHBoxLayout(seq_wrap) + seq_h.setContentsMargins(0, 0, 0, 0) + seq_h.setSpacing(8) + + self.levels_entry = QLineEdit() + seq_h.addWidget(self.levels_entry, 1) + + self.ident_result_label = QLabel("等待开始") + self.ident_result_label.setStyleSheet( + "color: #64748B; font-size: 13px; font-weight: 600;" + ) + seq_h.addWidget(self.ident_result_label) + + self.identify_btn = QPushButton(" ▶ 开始辨识") + self.identify_btn.setStyleSheet(_BTN_STYLE) + self.identify_btn.setCursor(Qt.PointingHandCursor) + self.identify_btn.clicked.connect(self._on_start_identify) + seq_h.addWidget(self.identify_btn) + + grid.addWidget(seq_wrap, row, 1, 1, 4) # 跨越列 1-4 + + # Keep the legacy widgets for internal compatibility, but do not + # expose confidential measurement parameters in the customer UI. + # Volume-test values come only from volume_measurement.json. + for index in range(grid.count()): + widget = grid.itemAt(index).widget() + if widget is not None and widget not in (btn_wrap, seq_wrap): + widget.hide() + self.levels_entry.hide() + + # ========================================== + # Card 2: 高级设置 — 2 行双列 + # ========================================== + def _build_advanced_section(self, grid: QGridLayout): + # 重置 _make_section_card 预设的单列表单列宽配置 + for c in range(10): + grid.setColumnMinimumWidth(c, 0) + grid.setColumnStretch(c, 0) + + # 同样采用紧凑双列布局 + grid.setColumnMinimumWidth(0, 60) + grid.setColumnStretch(1, 1) + grid.setColumnMinimumWidth(2, 100) + grid.setColumnMinimumWidth(3, 60) + grid.setColumnStretch(4, 1) + grid.setVerticalSpacing(8) + + # ---- 第 1 行:死区 ---- + row = 0 + grid.addWidget(_compact_label("死区:", self), row, 0) + self.dz_entry = QLineEdit() + self.dz_entry.setPlaceholderText("默认2...") + grid.addWidget(_wrap_widget(self.dz_entry), row, 1) + + # ---- 第 2 行:单步限幅 | 总限幅 ---- + row = 1 + grid.addWidget(_compact_label("单步限幅:", self), row, 0) + self.motor_max_entry = QLineEdit() + grid.addWidget(_wrap_widget(self.motor_max_entry), row, 1) + + grid.addWidget(_compact_label("总限幅:", self), row, 3) + self.xa_full_entry = QLineEdit() + grid.addWidget(_wrap_widget(self.xa_full_entry), row, 4) + + # ---- 第 3 行:模拟量映射最小值 | 最大值 ---- + row = 2 + grid.addWidget(_compact_label("模拟量映射最小值:", self), row, 0) + self.volthege_min_entry = QLineEdit("819") + grid.addWidget(_wrap_widget(self.volthege_min_entry), row, 1) + + grid.addWidget(_compact_label("最大值:", self), row, 3) + self.volthege_max_entry = QLineEdit("4095") + grid.addWidget(_wrap_widget(self.volthege_max_entry), row, 4) + + # ---- 第 4 行:确认设置按钮 ---- + row = 3 + self.confirm_settings_btn = QPushButton("确认设置") + self.confirm_settings_btn.setStyleSheet(_BTN_STYLE) + self.confirm_settings_btn.setCursor(Qt.PointingHandCursor) + self.confirm_settings_btn.clicked.connect(self._save_settings) + + btn_wrap = _transparent_widget() + btn_h = QHBoxLayout(btn_wrap) + btn_h.setContentsMargins(0, 0, 0, 0) + btn_h.addWidget(self.confirm_settings_btn) + btn_h.addStretch() + grid.addWidget(btn_wrap, row, 0, 1, 5) + + # ========================================== + # 辅助方法 + # ========================================== + def _with_unit(self, line_edit: QLineEdit, unit: str) -> QWidget: + """将输入框与单位标签组合为一个 widget,单位以灰色显示在输入框右侧。""" + w = _transparent_widget() + h = QHBoxLayout(w) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(0) + h.addWidget(line_edit, 1) + + unit_lbl = QLabel(unit) + unit_lbl.setStyleSheet( + "color: #94A3B8; font-size: 12px; background: transparent;" + "padding: 0 10px 0 6px;" + ) + h.addWidget(unit_lbl) + return w + + # ---- 信号处理 ---- + def _on_start_identify(self): + if self._identifying_running: + self.identify_stop_requested.emit() + else: + self._identifying_running = True + self.identify_btn.setText(" ■ 结束辨识") + self.identify_btn.setStyleSheet(_BTN_STYLE_DANGER) + self.identify_start_requested.emit() + + def _on_measure_volume(self): + if self._volume_running: + self.volume_stop_requested.emit() + else: + self._volume_running = True + self.test_btn.setText("停止") + self.test_btn.setStyleSheet(_BTN_STYLE_DANGER) + self.volume_measure_requested.emit() + + # ---- 公开方法:任务完成后由 main_window 调用恢复按钮 ---- + def set_identify_finished(self): + self._identifying_running = False + self.identify_btn.setText(" ▶ 开始辨识") + self.identify_btn.setStyleSheet(_BTN_STYLE) + + def set_identification_feedback(self, text: str, state="neutral"): + """显示当前辨识审核状态。""" + colors = { + "neutral": "#64748B", + "pending": "#2563EB", + "passed": "#15803D", + "failed": "#B91C1C", + } + color = colors.get(state, colors["neutral"]) + self.ident_result_label.setText(text) + self.ident_result_label.setStyleSheet( + f"color: {color}; font-size: 13px; font-weight: 600;" + ) + + def set_volume_finished(self): + self._volume_running = False + self.test_btn.setText("测试") + self.test_btn.setStyleSheet(_BTN_STYLE) + + # ---- 公开数据获取方法(接口与旧版完全兼容) ---- + def get_identify_params(self) -> dict: + """获取辨识参数""" + return { + "p_max": float(self.p_max_entry.text() or "200"), + "T_delta": float(self.T_delta_entry.text() or "30"), + "fit_high": float(self.fit_high_entry.text() or "200"), + "fit_low": float(self.fit_low_entry.text() or "50"), + "volume": float(self.volume_entry.text() or "0"), + "period": float(self.period_entry.text() or "2.5"), + "order": int(self.order_entry.text() or "6"), + "levels": self._parse_levels(), + } + + def get_advanced_params(self) -> dict: + """获取高级设置参数""" + dz = self.dz_entry.text().strip() + mm = self.motor_max_entry.text().strip() + xa = self.xa_full_entry.text().strip() + return { + "dz": float(dz) if dz else None, + "motor_max": float(mm) if mm else None, + "xa_full": float(xa) if xa else None, + "volthege_min": int(self.volthege_min_entry.text() or "0"), + "volthege_max": int(self.volthege_max_entry.text() or "4095"), + } + + def _parse_levels(self) -> list: + """解析序列输入""" + levels_str = self.levels_entry.text().strip() + if not levels_str: + print("未输入序列,使用默认值: 10,20,30,40,50,60,70,80") + return [10, 20, 30, 40, 50, 60, 70, 80] + try: + levels = [int(x.strip()) for x in levels_str.split(',')] + if len(levels) < 2: + print("序列至少需要两个值,使用默认值: 10,20,30,40,50,60,70,80") + return [10, 20, 30, 40, 50, 60, 70, 80] + print(f"使用自定义序列: {levels}") + return levels + except ValueError: + print("序列格式错误,使用默认值: 10,20,30,40,50,60,70,80") + return [10, 20, 30, 40, 50, 60, 70, 80] + + def set_volume_text(self, vol: float): + """设置容积输入框(测量完成后回填)""" + self.volume_entry.setText(f"{vol:.2f}") + + # ---- 设置持久化 ---- + def _save_settings(self): + """将高级设置和容积保存到 QSettings,下次启动自动恢复""" + settings = QSettings("ReinLoop", "ReinLoop") + settings.setValue("advanced/dz", self.dz_entry.text()) + settings.setValue("advanced/xa_full", self.xa_full_entry.text()) + settings.setValue("advanced/volthege_min", self.volthege_min_entry.text()) + settings.setValue("advanced/volthege_max", self.volthege_max_entry.text()) + settings.setValue("identify/volume", self.volume_entry.text()) + print("设置已保存") + + def _load_settings(self): + """从 QSettings 恢复上次保存的设置(contains 确保空值也能覆盖默认值)""" + settings = QSettings("ReinLoop", "ReinLoop") + + if settings.contains("advanced/dz"): + self.dz_entry.setText(settings.value("advanced/dz")) + + if settings.contains("advanced/xa_full"): + self.xa_full_entry.setText(settings.value("advanced/xa_full")) + + if settings.contains("advanced/volthege_min"): + self.volthege_min_entry.setText(settings.value("advanced/volthege_min")) + + if settings.contains("advanced/volthege_max"): + self.volthege_max_entry.setText(settings.value("advanced/volthege_max")) + + if settings.contains("identify/volume"): + self.volume_entry.setText(settings.value("identify/volume")) diff --git a/ReinLoop/ui/main_window.py b/ReinLoop/ui/main_window.py new file mode 100644 index 0000000..9055be4 --- /dev/null +++ b/ReinLoop/ui/main_window.py @@ -0,0 +1,1109 @@ +# main_window.py +"""主窗口:组装所有 UI 组件,连接 Core 层业务逻辑""" + +import time +import os +import threading +from collections import deque +from PySide6.QtWidgets import ( + QApplication, + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QStackedWidget, QLabel, QPushButton, QTabBar, QFrame +) +from PySide6.QtCore import Qt, QTimer, Signal, QObject, QSize +from PySide6.QtGui import QFont, QIcon +from PySide6.QtSvgWidgets import QSvgWidget + +from ui.status_bar import StatusBar +from ui.connection_tab import ConnectionTab +from ui.control_tab import ControlTab +from ui.debug_tab import DebugTab +from ui.plot_window import PlotWindow + +from core.connection_manager import ConnectionManager +from core.control_engine import ControlEngine +from core.model_manager import ModelManager +from core.data_collector import DataCollector +from core.identification import IdentificationManager +from core.identification_config import download_identification_config +from core.identification_feedback import ( + acknowledge_identification_feedback, + get_identification_feedback, + register_identification_result, +) +from core.volume_config import ( + acknowledge_volume_config_request, + create_volume_config_request, + poll_volume_config_request, +) +from core.device_heartbeat import heartbeat_device + +from controllers import IncrementalPID + +import PcControl + + +IDENTIFICATION_FEEDBACK_POLL_INTERVAL_MS = 2000 +IDENTIFICATION_CONFIG_RETRY_INTERVAL_MS = 2000 +VOLUME_REQUEST_STATUS_POLL_INTERVAL_MS = 2000 +DEVICE_HEARTBEAT_INTERVAL_MS = 10_000 + + +class _Bridge(QObject): + """线程安全的信号桥:后台线程通过 signal.emit() → 主线程 slot 更新 UI""" + log_signal = Signal(str) + status_signal = Signal(bool, str) + display_signal = Signal(float, float, float) + pid_ui_signal = Signal(float, float, float) + models_loaded_signal = Signal(object) + model_load_complete = Signal(bool, str) + sample_signal = Signal(object, object) # (valve_cmd, pressure) + volume_result_signal = Signal(float) + identification_config_loaded = Signal(int, object, object) + identification_upload_complete = Signal(bool, object, object) + identification_feedback_loaded = Signal(int, object, object) + volume_request_created = Signal(int, object, object) + volume_config_loaded = Signal(int, object, object) + heartbeat_completed = Signal(object) + + +class MainWindow(QMainWindow): + """主窗口:ReinLoop-V1.0""" + + def __init__(self, colors: dict): + super().__init__() + self.colors = colors + self.setWindowTitle("ReinLoop-V1.0 - 收敛有界") + self.resize(900, 700) + + # ---- 业务层初始化 ---- + self.pid = IncrementalPID(kp=1.0, ki=0.4, kd=0, dt=0.1, out_min=0, out_max=100, xa_full=1062.5) + + self.conn_mgr = ConnectionManager() + self.model_mgr = ModelManager() + self.data_collector = DataCollector() + self.ident_mgr = IdentificationManager() + self.engine = ControlEngine(self.pid) + + # 注入依赖到控制引擎 + self.engine.set_connection_manager(self.conn_mgr) + self.engine.set_model_manager(self.model_mgr) + self.engine.set_data_collector(self.data_collector) + + # ---- 信号桥 (线程安全) ---- + self._bridge = _Bridge() + self._ident_config_request_id = 0 + self._ident_config_request_inflight = False + self._ident_workflow_active = False + self._ident_waiting_for_updated_config = False + self._ident_current_config = None + self._ident_feedback_run_id = None + self._ident_feedback_request_id = 0 + self._ident_feedback_request_inflight = False + self._ident_feedback_registered = False + self._ident_feedback_last_error = None + self._volume_workflow_active = False + self._volume_measurement_running = False + self._volume_config_request_id = 0 + self._volume_config_request_inflight = False + self._volume_cloud_request_id = None + self._volume_request_expires_at_ms = None + self._heartbeat_inflight = False + self._heartbeat_last_error = None + + # 确认的目标压力 + self.confirmed_target_pressure = 80.0 + + # 数据历史 (用于绘图,最多保留 10 小时 / 720000 点) + _MAX = 720_000 + self.pressure_data = deque(maxlen=_MAX) + self.target_data = deque(maxlen=_MAX) + self.valve_data = deque(maxlen=_MAX) + self.time_data = deque(maxlen=_MAX) + self.start_time = None + + # 绘图窗口引用 + self._plot_window = None + + # ---- 搭建 UI ---- + self._setup_ui() + self._connect_signals() + + # ---- 初始化回调 ---- + self._setup_callbacks() + + # 初始扫描模型列表 + self.model_mgr.scan_models() + + def _setup_ui(self): + """构建主窗口布局:顶部两层结构 (Layer1: Logo标题行, Layer2: Tab栏)""" + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + _src = os.path.join(os.path.dirname(__file__), "..", "src") + + # ========================================== + # 1. 顶部区域 — Layer 1: 标题行(纯白背景) + # ========================================== + title_row = QWidget() + title_row.setProperty("cssClass", "titleRow") + title_layout = QHBoxLayout(title_row) + title_layout.setContentsMargins(24, 12, 24, 12) + title_layout.setSpacing(0) + + # Logo SVG + logo_path = os.path.normpath(os.path.join(_src, "logo.svg")) + logo_widget = QSvgWidget(logo_path) + logo_widget.setFixedSize(100, 33) + title_layout.addWidget(logo_widget) + + # 竖线分隔符 + vdivider = QFrame() + vdivider.setFrameShape(QFrame.VLine) + vdivider.setFrameShadow(QFrame.Plain) + vdivider.setFixedWidth(1) + vdivider.setFixedHeight(28) + vdivider.setStyleSheet("background-color: #E2E8F0; border: none;") + title_layout.addSpacing(16) + title_layout.addWidget(vdivider) + title_layout.addSpacing(16) + + # 标题 "ReinLoop V1.0" + title_lbl = QLabel("ReinLoop V1.0") + title_lbl.setProperty("cssClass", "navTitle") + title_layout.addWidget(title_lbl) + + title_layout.addStretch() + + main_layout.addWidget(title_row) + + # -- 分割线 1:标题行与 Tab 行之间的灰色细线 -- + div1 = QFrame() + div1.setFrameShape(QFrame.HLine) + div1.setFixedHeight(1) + div1.setStyleSheet("background-color: #E2E8F0; border: none;") + main_layout.addWidget(div1) + + # ========================================== + # 1. 顶部区域 — Layer 2: Tab 栏行(浅灰背景 #F8FAFC) + # ========================================== + tab_container = QWidget() + tab_container.setProperty("cssClass", "tabRow") + tab_container_layout = QHBoxLayout(tab_container) + tab_container_layout.setContentsMargins(24, 0, 24, 0) + tab_container_layout.setSpacing(0) + + # 使用 QTabBar(仅导航栏)+ 独立 QStackedWidget(内容区) + self.tab_bar = QTabBar() + self.tab_bar.setProperty("cssClass", "mainTab") + self.tab_bar.setIconSize(QSize(22, 22)) + self.tab_bar.setCursor(Qt.PointingHandCursor) + self.tab_bar.setExpanding(False) + self.tab_bar.setDrawBase(False) + + # Tab 图标路径(灰色/蓝色两套),保存为类属性方便动态切换 + self._icons_gray = [ + os.path.normpath(os.path.join(_src, "link_icon_gray.svg")), + os.path.normpath(os.path.join(_src, "control_icon_gray.svg")), + os.path.normpath(os.path.join(_src, "debug_icon_gray.svg")) + ] + self._icons_blue = [ + os.path.normpath(os.path.join(_src, "link_icon.svg")), + os.path.normpath(os.path.join(_src, "control_icon.svg")), + os.path.normpath(os.path.join(_src, "debug_icon.svg")) + ] + + # 初始默认全灰图标,文字前加半角空格确保图标与文字间距 + self.tab_bar.addTab(QIcon(self._icons_gray[0]), " 连接设置") + self.tab_bar.addTab(QIcon(self._icons_gray[1]), " 控制设置") + self.tab_bar.addTab(QIcon(self._icons_gray[2]), " 模型调试") + + tab_container_layout.addWidget(self.tab_bar) + tab_container_layout.addStretch() + main_layout.addWidget(tab_container) + + # -- 分割线 2:Tab 行与主体内容之间的灰色细线 -- + div2 = QFrame() + div2.setFrameShape(QFrame.HLine) + div2.setFixedHeight(1) + div2.setStyleSheet("background-color: #E2E8F0; border: none;") + main_layout.addWidget(div2) + + # ========================================== + # 2. 页面堆栈 + # ========================================== + self.stack = QStackedWidget() + + self.tab_connection = ConnectionTab(self.colors) + self.tab_control = ControlTab(self.colors) + self.tab_debug = DebugTab(self.colors) + + self.stack.addWidget(self.tab_connection) + self.stack.addWidget(self.tab_control) + self.stack.addWidget(self.tab_debug) + + main_layout.addWidget(self.stack, stretch=1) + + # ========================================== + # 3. 底部状态栏 + # ========================================== + self.status_bar = StatusBar(self.colors) + main_layout.addWidget(self.status_bar) + + # 默认选中第一个标签 + self.tab_bar.setCurrentIndex(0) + self.stack.setCurrentIndex(0) + + def _switch_tab(self, index: int): + """切换标签页,并动态刷新图标与 QSS 状态""" + self.stack.setCurrentIndex(index) + + # 阻断信号,防止改变时死循环 + self.tab_bar.blockSignals(True) + self.tab_bar.setCurrentIndex(index) + self.tab_bar.blockSignals(False) + + # 动态轮询,更新图标:选中项换蓝色高亮图标,其余换回灰色 + for i in range(self.tab_bar.count()): + if i == index: + self.tab_bar.setTabIcon(i, QIcon(self._icons_blue[i])) + else: + self.tab_bar.setTabIcon(i, QIcon(self._icons_gray[i])) + + # 强制让控件重新 polish 样式,确保 QSS 中的 :selected 样式立即生效 + self.tab_bar.style().unpolish(self.tab_bar) + self.tab_bar.style().polish(self.tab_bar) + + # ========================================== + # 回调设置(Core → UI 通过信号桥) + # ========================================== + def _setup_callbacks(self): + """将 Core 层的回调全部桥接到主线程信号""" + bridge = self._bridge + + # 日志 → status_bar + def on_log(msg): + bridge.log_signal.emit(msg) + + self.conn_mgr.set_log_callback(on_log) + self.model_mgr.set_log_callback(on_log) + self.data_collector.set_log_callback(on_log) + self.ident_mgr.set_log_callback(on_log) + self.engine.set_log_callback(on_log) + + # 连接状态 → status_bar + self.conn_mgr.set_status_callback( + lambda connected, text: bridge.status_signal.emit(connected, text) + ) + + # 控制显示更新 → control_tab + self.engine.set_display_update_callback( + lambda p, t, v: bridge.display_signal.emit(p, t, v) + ) + + # PID UI 更新 → control_tab + self.engine.set_pid_ui_update_callback( + lambda kp, ki, kd: bridge.pid_ui_signal.emit(kp, ki, kd) + ) + + # 控制启停 → control_tab 按钮状态 + 模式锁定 + self.engine.set_started_callback( + self._on_engine_started + ) + self.engine.set_stopped_callback( + self._on_engine_stopped + ) + + # 模型列表加载完成 + self.model_mgr.set_models_loaded_callback( + lambda files: bridge.models_loaded_signal.emit(files) + ) + + # 模型加载完成 + self.model_mgr.set_load_complete_callback( + lambda success, msg: bridge.model_load_complete.emit(success, msg) + ) + + # 辨识/容积采样 + self.ident_mgr.set_sample_callback( + lambda u, p: bridge.sample_signal.emit(u, p) + ) + + # 容积结果 + self.ident_mgr.set_volume_result_callback( + lambda vol: bridge.volume_result_signal.emit(vol) + ) + + self.ident_mgr.set_identification_upload_callback( + lambda success, filename, error: + bridge.identification_upload_complete.emit( + success, filename, error + ) + ) + + def _connect_signals(self): + """连接信号桥到各 UI 组件槽函数""" + bridge = self._bridge + + bridge.log_signal.connect(self.status_bar.set_log) + bridge.status_signal.connect(self.status_bar.set_connection_status) + bridge.display_signal.connect(self._on_display_update) + bridge.pid_ui_signal.connect(self.tab_control.update_pid_entries) + bridge.models_loaded_signal.connect(self.tab_control.update_model_list) + bridge.model_load_complete.connect(self._on_model_load_complete) + bridge.sample_signal.connect(self._on_sample_update) + bridge.volume_result_signal.connect(self.tab_debug.set_volume_text) + bridge.identification_config_loaded.connect( + self._on_identification_config_loaded + ) + bridge.identification_upload_complete.connect( + self._on_identification_upload_complete + ) + bridge.identification_feedback_loaded.connect( + self._on_identification_feedback_loaded + ) + bridge.volume_request_created.connect(self._on_volume_request_created) + bridge.volume_config_loaded.connect(self._on_volume_config_loaded) + bridge.heartbeat_completed.connect(self._on_heartbeat_completed) + + # ---- UI 按钮 → Core 方法 ---- + # 连接设置页 + self.tab_connection.connect_btn.clicked.connect(self._on_connect_toggle) + + # 控制设置页 + self.tab_control.target_set_requested.connect(self._on_set_target) + self.tab_control.mode_changed.connect(self._on_mode_changed) + self.tab_control.pid_update_requested.connect(self._on_update_pid) + self.tab_control.model_load_requested.connect(self.model_mgr.load_model) + self.tab_control.models_refresh_requested.connect(self.model_mgr.scan_models) + self.tab_control.control_toggle_requested.connect(self._on_control_toggle) + self.tab_control.plot_requested.connect(self._on_plot) + self.tab_control.manual_valve_set_requested.connect(self._on_set_manual_valve) + self.tab_control.log_message_requested.connect(self.status_bar.set_log) + + # 模型调试页 + self.tab_debug.identify_start_requested.connect(self._on_identify_start) + self.tab_debug.identify_stop_requested.connect(self._on_identify_stop) + self.tab_debug.volume_measure_requested.connect(self._on_volume_measure) + self.tab_debug.volume_stop_requested.connect(self._on_volume_stop) + + # 轮询辨识任务完成状态(100ms),任务结束后恢复按钮 + self._ident_poll_timer = QTimer(self) + self._ident_poll_timer.setInterval(100) + self._ident_poll_timer.timeout.connect(self._poll_ident_done) + + self._ident_feedback_timer = QTimer(self) + self._ident_feedback_timer.setInterval( + IDENTIFICATION_FEEDBACK_POLL_INTERVAL_MS + ) + self._ident_feedback_timer.timeout.connect( + self._request_identification_feedback + ) + + self._ident_config_retry_timer = QTimer(self) + self._ident_config_retry_timer.setInterval( + IDENTIFICATION_CONFIG_RETRY_INTERVAL_MS + ) + self._ident_config_retry_timer.timeout.connect( + self._request_identification_config + ) + + self._volume_request_status_timer = QTimer(self) + self._volume_request_status_timer.setInterval( + VOLUME_REQUEST_STATUS_POLL_INTERVAL_MS + ) + self._volume_request_status_timer.timeout.connect( + self._request_volume_config + ) + + self._heartbeat_timer = QTimer(self) + self._heartbeat_timer.setInterval(DEVICE_HEARTBEAT_INTERVAL_MS) + self._heartbeat_timer.timeout.connect(self._request_device_heartbeat) + self._heartbeat_timer.start() + self._request_device_heartbeat() + + # ---- 导航栏 Tab 切换 → 页面切换 ---- + self.tab_bar.currentChanged.connect(self._switch_tab) + + def _request_device_heartbeat(self): + if self._heartbeat_inflight: + return + self._heartbeat_inflight = True + + def heartbeat_thread(): + try: + heartbeat_device() + except Exception as exc: + self._bridge.heartbeat_completed.emit(str(exc)) + else: + self._bridge.heartbeat_completed.emit(None) + + threading.Thread( + target=heartbeat_thread, + name="device-heartbeat", + daemon=True, + ).start() + + def _on_heartbeat_completed(self, error): + self._heartbeat_inflight = False + if error and error != self._heartbeat_last_error: + self.status_bar.set_log(f"设备在线状态未上报: {error}") + self._heartbeat_last_error = error + + # ========================================== + # 槽函数 + # ========================================== + def _on_connect_toggle(self): + if self.conn_mgr.is_connected(): + self.status_bar.set_log("正在断开连接...") + self.conn_mgr.disconnect() + self.tab_connection._connect_text_lbl.setText(" 连接设备") + self.status_bar.set_log("已断开设备连接") + else: + self.status_bar.set_log("正在连接设备,请稍候...") + QApplication.processEvents() # 强制刷新 UI,让日志立即可见 + params = self.tab_connection.get_connection_params() + success = self.conn_mgr.connect(**params) + if success: + self.tab_connection._connect_text_lbl.setText(" 断开连接") + self.status_bar.set_log("设备连接成功") + else: + self.status_bar.set_log("设备连接失败,请检查参数和硬件连接") + + def _on_set_target(self, target: float): + if target < 0 or target > 3000: + self.status_bar.set_log("错误: 目标压力必须在0-3000 kPa范围内") + return + self.confirmed_target_pressure = target + self.engine.target_pressure = target # 运行中实时同步到控制引擎 + + # 智能自动模式(RL):每次设置目标时自动从 PLC 读取流量 + if self.engine.mode == "RL": + if self.conn_mgr.is_connected(): + q = self.conn_mgr.read_flow() + if q is not None: + self.engine.flow = q + self.tab_control.flow_entry.setText(f"{q:.1f}") + self.status_bar.set_log( + f"目标压力已设置为: {target} kPa | 已从模块读取流量: {q:.1f} L/min") + return + + self.status_bar.set_log(f"目标压力已设置为: {target} kPa") + + def _on_mode_changed(self, mode: str): + self.engine.mode = mode + + def _on_update_pid(self, kp: float, ki: float, kd: float): + self.pid.kp = kp + self.pid.ki = ki + self.pid.kd = kd + self.pid._calculate_coefficients() + self.status_bar.set_log(f"PID参数已更新: Kp={kp:.3f}, Ki={ki:.3f}, Kd={kd:.3f}") + + def _on_engine_started(self): + self.tab_control.set_control_running(True) + self.tab_control.set_mode_switch_enabled(False) + + def _on_engine_stopped(self): + self.tab_control.set_control_running(False) + self.tab_control.set_mode_switch_enabled(True) + + def _on_control_toggle(self): + if self.engine.is_running: + self.engine.stop() + self.status_bar.set_log("控制已停止") + else: + try: + self._do_start_control() + except Exception as e: + import traceback + err = traceback.format_exc() + self.status_bar.set_log(f"启动控制失败: {e}") + # 同时写入日志文件(打包后无控制台) + try: + import os, sys + from datetime import datetime + log_dir = os.path.join(os.path.dirname(sys.executable), "logs") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "reinloop_error.log") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%Y%m%d_%H%M%S')}] _on_control_toggle 异常:\n{err}\n") + except Exception: + pass + + def _do_start_control(self): + """实际启动控制的逻辑(从 _on_control_toggle 拆出,便于异常隔离)""" + # 自动从 PLC 读取流量值并填入输入框 + if self.conn_mgr.is_connected(): + q = self.conn_mgr.read_flow() + if q is not None: + self.tab_control.flow_entry.setText(f"{q:.1f}") + self.status_bar.set_log(f"已从模块读取流量: {q:.1f} L/min") + + # 收集参数 + params = self.tab_control.get_control_params() + pid_params = self.tab_control.get_pid_params() + adv = self.tab_debug.get_advanced_params() + + # 同步电机限幅参数到 PcControl(总限幅→x_max, 模拟量映射→volthege_min/max) + PcControl.set_motor_limits( + volthege_min_val=adv.get("volthege_min"), + volthege_max_val=adv.get("volthege_max"), + x_max_val=adv.get("xa_full") + ) + + self.engine.mode = self.tab_control.get_mode() + self.engine.flow = params["flow"] + self.engine.volume = params["volume"] + self.engine.target_pressure = self.confirmed_target_pressure + self.engine.dz = adv["dz"] + self.engine.motor_max = adv["motor_max"] + xa_full_val = adv.get("xa_full") + self.engine.xa_full = float(xa_full_val) if xa_full_val is not None else 1062.5 + self.pid.xa_full = float(xa_full_val) if xa_full_val is not None else 1062.5 + self.engine.collect_data = self.tab_control.get_collect_data() + + if self.engine.mode == "MANUAL": + self.engine.manual_valve = self.tab_control.get_manual_valve() + + # 重置数据 + _MAX = 720_000 + self.pressure_data = deque(maxlen=_MAX) + self.target_data = deque(maxlen=_MAX) + self.valve_data = deque(maxlen=_MAX) + self.time_data = deque(maxlen=_MAX) + self.start_time = time.time() + + # 启动定时器(周期 = PID dt,与 control_tick 内的 sleep 配合保证精确周期) + # 先停止旧 timer(防止重复启动累积多个 timer) + if hasattr(self, '_ui_timer') and self._ui_timer is not None: + self._ui_timer.stop() + self._ui_timer = QTimer(self) + self._ui_timer.timeout.connect(self._poll_display) + timer_ms = max(1, int(self.pid.dt * 1000)) + self._ui_timer.start(timer_ms) + + self.engine.start() + + def _poll_display(self): + """定时轮询:在主线程执行一个控制周期(读压力→计算→写电机→更新UI) + + 全部在主线程运行,避免 Cython 编译后在 PyInstaller 子线程中 segfault。 + """ + self.engine.control_tick() + + def _on_display_update(self, pressure: float, target: float, valve: float): + """更新 UI 显示 + 记录数据(deque 自动滚动,最多保留 10 小时)""" + self.tab_control.update_display(pressure, target, valve) + + if self.start_time is not None: + elapsed = time.time() - self.start_time + self.time_data.append(elapsed) + self.pressure_data.append(pressure) + self.target_data.append(target) + self.valve_data.append(valve) + + def _on_model_load_complete(self, success: bool, message: str): + self.status_bar.set_log(message) + + def _on_sample_update(self, valve_cmd, pressure): + if valve_cmd is not None: + self.tab_control.valve_opening_lbl.setText(f"{valve_cmd:.1f}") + if pressure is not None: + self.tab_control.current_pressure_lbl.setText(f"{pressure:.1f}") + + def _on_set_manual_valve(self, valve: float): + if valve == -1: + self.status_bar.set_log("错误: 目标阀开度超出范围") + elif valve == -2: + self.status_bar.set_log("错误: 请输入有效的数字") + else: + self.engine.manual_valve = valve + self.status_bar.set_log(f"阀门开度已设置为: {valve}%") + + def _on_plot(self): + if not self.pressure_data: + self.status_bar.set_log("没有可绘制的数据") + return + + # 关闭已有窗口(先断开 finished 信号,防止回调中 enable_plot_button(True) + # 与下面紧跟的 enable_plot_button(False) 产生竞争,导致按钮状态闪烁) + if self._plot_window is not None: + try: + self._plot_window.finished.disconnect(self._on_plot_closed) + except RuntimeError: + pass # 信号可能已被断开 + self._plot_window.close() + self._plot_window = None + + self.tab_control.enable_plot_button(False) + try: + self._plot_window = PlotWindow( + self.time_data, self.pressure_data, + self.target_data, self.valve_data, + parent=self + ) + except Exception as e: + import traceback + self.status_bar.set_log(f"绘图失败: {e}") + traceback.print_exc() + self.tab_control.enable_plot_button(True) + return + + self._plot_window.finished.connect(self._on_plot_closed) + self._plot_window.show() + self.status_bar.set_log("绘图窗口已打开") + + def _on_plot_closed(self): + self._plot_window = None + self.tab_control.enable_plot_button(True) + + def _on_identify_start(self): + if self._volume_workflow_active: + self.status_bar.set_log("请先停止容积测试") + self.tab_debug.set_identify_finished() + return + + self._ident_workflow_active = True + self._ident_waiting_for_updated_config = False + self._ident_current_config = None + self._ident_config_request_id += 1 + self._ident_config_request_inflight = False + self._ident_feedback_request_id += 1 + self._ident_feedback_request_inflight = False + self._ident_feedback_registered = False + self._ident_feedback_run_id = None + self._ident_feedback_last_error = None + self._ident_feedback_timer.stop() + self._ident_config_retry_timer.stop() + self.tab_debug.set_identification_feedback("正在获取参数", "pending") + self.status_bar.set_log("正在从云端获取辨识参数...") + self._request_identification_config() + + def _request_identification_config(self): + """异步获取 9 参数 CSV;失败重试时禁止并发请求。""" + if (not self._ident_workflow_active + or self._ident_config_request_inflight): + return + + request_id = self._ident_config_request_id + self._ident_config_request_inflight = True + + def download_thread(): + try: + config = download_identification_config() + except Exception as exc: + self._bridge.identification_config_loaded.emit( + request_id, None, str(exc) + ) + else: + self._bridge.identification_config_loaded.emit( + request_id, config, None + ) + + threading.Thread( + target=download_thread, + name="identification-config-download", + daemon=True, + ).start() + + def _on_identification_config_loaded(self, request_id, config, error): + """在主线程使用云端参数启动辨识,忽略已被停止的旧请求。""" + if (request_id != self._ident_config_request_id + or not self._ident_workflow_active): + return + self._ident_config_request_inflight = False + + if error: + if self._ident_waiting_for_updated_config: + self.status_bar.set_log( + f"等待新辨识参数,将继续重试: {error}" + ) + else: + self.status_bar.set_log(f"无法启动辨识: {error}") + self.tab_debug.set_identification_feedback( + "参数获取失败", "failed" + ) + self._ident_workflow_active = False + self.tab_debug.set_identify_finished() + return + + if (self._ident_waiting_for_updated_config + and config == self._ident_current_config): + self.status_bar.set_log( + "辨识未通过,正在等待云端更新 9 参数 CSV..." + ) + return + + self._ident_waiting_for_updated_config = False + self._ident_config_retry_timer.stop() + self._ident_current_config = dict(config) + + # xa_full 同时参与 PRBS 行程换算和 PcControl 电机限幅。 + PcControl.set_motor_limits(x_max_val=config["xa_full"]) + self.status_bar.set_log("云端参数已加载,正在启动辨识数据采集...") + self.tab_debug.set_identification_feedback("正在辨识", "pending") + started = self.ident_mgr.start_identification( + conn_mgr=self.conn_mgr, + running_flag_check=lambda: self.engine.is_running, + **config, + ) + if not started: + self._ident_workflow_active = False + self.tab_debug.set_identification_feedback("启动失败", "failed") + self.tab_debug.set_identify_finished() + return + self._ident_poll_timer.start() + + def _on_identification_upload_complete(self, success, filename, error): + """辨识 CSV 上传后登记本轮审核任务。""" + if not self._ident_workflow_active: + return + if not success or not filename: + self._ident_workflow_active = False + self.tab_debug.set_identification_feedback("上传失败", "failed") + self.status_bar.set_log(f"辨识结果上传失败: {error or '未知错误'}") + self.tab_debug.set_identify_finished() + return + + self._ident_feedback_run_id = filename + self._ident_feedback_request_id += 1 + self._ident_feedback_request_inflight = False + self._ident_feedback_registered = False + self._ident_feedback_last_error = None + self.tab_debug.set_identification_feedback("等待反馈", "pending") + self.status_bar.set_log("辨识 CSV 已上传,正在等待云端返回 0/1...") + self._request_identification_feedback() + self._ident_feedback_timer.start() + + def _request_identification_feedback(self): + """登记本轮 CSV,并轮询服务器返回的数字 0/1。""" + if (not self._ident_workflow_active + or not self._ident_feedback_run_id + or self._ident_feedback_request_inflight): + return + + request_id = self._ident_feedback_request_id + run_id = self._ident_feedback_run_id + registered = self._ident_feedback_registered + self._ident_feedback_request_inflight = True + + def feedback_thread(): + try: + if not registered: + register_identification_result(run_id) + payload = {"registered": True} + else: + payload = { + "registered": True, + "feedback": get_identification_feedback(run_id), + } + except Exception as exc: + self._bridge.identification_feedback_loaded.emit( + request_id, None, str(exc) + ) + else: + self._bridge.identification_feedback_loaded.emit( + request_id, payload, None + ) + + threading.Thread( + target=feedback_thread, + name="identification-feedback-poll", + daemon=True, + ).start() + + def _on_identification_feedback_loaded(self, request_id, payload, error): + if (request_id != self._ident_feedback_request_id + or not self._ident_workflow_active): + return + self._ident_feedback_request_inflight = False + + if error: + if error != self._ident_feedback_last_error: + self.status_bar.set_log( + f"辨识反馈查询失败,将继续重试: {error}" + ) + self._ident_feedback_last_error = error + return + + self._ident_feedback_last_error = None + if payload.get("registered") and not self._ident_feedback_registered: + self._ident_feedback_registered = True + self._request_identification_feedback() + return + + feedback = payload.get("feedback") + if feedback is None: + return + + self._ident_feedback_timer.stop() + run_id = self._ident_feedback_run_id + self._ident_feedback_request_id += 1 + self._ident_feedback_request_inflight = False + self._ident_feedback_registered = False + self._ident_feedback_run_id = None + self._ack_identification_feedback(run_id) + + if feedback == 1: + self._ident_workflow_active = False + self.tab_debug.set_identification_feedback("已通过", "passed") + self.status_bar.set_log("辨识结果:已通过") + self.tab_debug.set_identify_finished() + return + + self.tab_debug.set_identification_feedback("未通过", "failed") + self.status_bar.set_log( + "辨识结果:未通过,等待重新制定并上传 9 参数 CSV" + ) + self._ident_waiting_for_updated_config = True + self._ident_config_request_inflight = False + self._request_identification_config() + self._ident_config_retry_timer.start() + + def _ack_identification_feedback(self, run_id): + def ack_thread(): + try: + acknowledge_identification_feedback(run_id) + except Exception as exc: + self._bridge.log_signal.emit(f"确认辨识反馈失败: {exc}") + + threading.Thread( + target=ack_thread, + name="identification-feedback-ack", + daemon=True, + ).start() + + def _on_identify_stop(self): + self._ident_workflow_active = False + self._ident_waiting_for_updated_config = False + self._ident_config_request_id += 1 + self._ident_config_request_inflight = False + self._ident_feedback_request_id += 1 + self._ident_feedback_request_inflight = False + self._ident_feedback_run_id = None + self._ident_feedback_timer.stop() + self._ident_config_retry_timer.stop() + self.ident_mgr.stop() + self.tab_debug.set_identification_feedback("已停止", "neutral") + self.tab_debug.set_identify_finished() + self.status_bar.set_log("已停止辨识及反馈监听") + + def _on_volume_measure(self): + if self._ident_workflow_active: + self.status_bar.set_log("请先结束辨识及反馈流程") + self.tab_debug.set_volume_finished() + return + if self._volume_workflow_active: + return + + self._volume_workflow_active = True + self._volume_config_request_id += 1 + self._volume_config_request_inflight = False + self._volume_cloud_request_id = None + self._volume_request_expires_at_ms = None + self.status_bar.set_log("正在向云端发送容积参数请求指令...") + self._create_volume_config_request() + + def _create_volume_config_request(self): + """点击测试后只创建一次请求指令。""" + generation = self._volume_config_request_id + self._volume_config_request_inflight = True + + def create_thread(): + try: + request_info = create_volume_config_request() + except Exception as exc: + self._bridge.volume_request_created.emit( + generation, None, str(exc) + ) + else: + self._bridge.volume_request_created.emit( + generation, request_info, None + ) + + threading.Thread( + target=create_thread, + name="volume-request-create", + daemon=True, + ).start() + + def _on_volume_request_created(self, generation, request_info, error): + if (generation != self._volume_config_request_id + or not self._volume_workflow_active): + if request_info and request_info.get("request_id"): + self._ack_volume_config_request(request_info["request_id"]) + return + self._volume_config_request_inflight = False + if error: + self._volume_workflow_active = False + self.status_bar.set_log(f"发送容积参数请求失败: {error}") + self.tab_debug.set_volume_finished() + return + + self._volume_cloud_request_id = request_info["request_id"] + self._volume_request_expires_at_ms = request_info["expires_at_ms"] + self.status_bar.set_log( + "容积参数请求已发送,等待公司端上传本次 JSON..." + ) + self._volume_request_status_timer.start() + self._request_volume_config() + + def _request_volume_config(self): + """只查询已创建 requestId 的状态,不重复创建请求指令。""" + if (not self._volume_workflow_active + or self._volume_config_request_inflight + or not self._volume_cloud_request_id): + return + + generation = self._volume_config_request_id + cloud_request_id = self._volume_cloud_request_id + self._volume_config_request_inflight = True + + def download_thread(): + try: + result = poll_volume_config_request(cloud_request_id) + except Exception as exc: + self._bridge.volume_config_loaded.emit( + generation, None, str(exc) + ) + else: + self._bridge.volume_config_loaded.emit( + generation, result, None + ) + + threading.Thread( + target=download_thread, + name="volume-request-status", + daemon=True, + ).start() + + def _on_volume_config_loaded(self, generation, result, error): + """检测本次请求关联的几分钟内 JSON,成功后加载一次。""" + if (generation != self._volume_config_request_id + or not self._volume_workflow_active): + return + self._volume_config_request_inflight = False + + if error: + if (self._volume_request_expires_at_ms is not None + and time.time() * 1000 >= self._volume_request_expires_at_ms): + self._finish_volume_request("等待公司端参数超时") + else: + self.status_bar.set_log( + f"查询本次容积参数请求失败,将继续等待: {error}" + ) + return + + if result.get("expired"): + self._finish_volume_request("等待公司端参数超时") + return + if not result.get("ready"): + return + + self._volume_request_status_timer.stop() + cloud_request_id = self._volume_cloud_request_id + self._volume_cloud_request_id = None + self._volume_request_expires_at_ms = None + self._ack_volume_config_request(cloud_request_id) + config = result["config"] + + PcControl.set_motor_limits(x_max_val=config["xa_full"]) + started = self.ident_mgr.start_volume_measurement( + conn_mgr=self.conn_mgr, + running_flag_check=lambda: self.engine.is_running, + **config, + ) + if not started: + self._volume_workflow_active = False + self.tab_debug.set_volume_finished() + return + + self._volume_measurement_running = True + self.status_bar.set_log("已获取云端 8 参数,开始容积测量") + self._ident_poll_timer.start() + + def _finish_volume_request(self, message): + cloud_request_id = self._volume_cloud_request_id + self._volume_workflow_active = False + self._volume_config_request_inflight = False + self._volume_cloud_request_id = None + self._volume_request_expires_at_ms = None + self._volume_request_status_timer.stop() + if cloud_request_id: + self._ack_volume_config_request(cloud_request_id) + self.status_bar.set_log(message) + self.tab_debug.set_volume_finished() + + def _ack_volume_config_request(self, request_id): + def ack_thread(): + try: + acknowledge_volume_config_request(request_id) + except Exception as exc: + self._bridge.log_signal.emit(f"清理容积参数请求失败: {exc}") + + threading.Thread( + target=ack_thread, + name="volume-request-ack", + daemon=True, + ).start() + + def _on_volume_stop(self): + cloud_request_id = self._volume_cloud_request_id + self._volume_workflow_active = False + self._volume_config_request_id += 1 + self._volume_config_request_inflight = False + self._volume_cloud_request_id = None + self._volume_request_expires_at_ms = None + self._volume_request_status_timer.stop() + if cloud_request_id: + self._ack_volume_config_request(cloud_request_id) + if self._volume_measurement_running: + self.ident_mgr.stop() + self._volume_measurement_running = False + self.tab_debug.set_volume_finished() + self.status_bar.set_log("已停止容积测试") + + def _poll_ident_done(self): + """100ms 轮询:检测辨识/测量任务是否自然结束,恢复按钮状态""" + if self.ident_mgr.is_running: + return + + self._ident_poll_timer.stop() + if not self._ident_workflow_active: + self.tab_debug.set_identify_finished() + if self._volume_measurement_running: + self._volume_measurement_running = False + self._volume_workflow_active = False + self.status_bar.set_log("容积测量完成") + self.tab_debug.set_volume_finished() + elif not self._volume_workflow_active: + self.tab_debug.set_volume_finished() + + def closeEvent(self, event): + """窗口关闭时清理资源""" + self._ident_config_request_id += 1 + self._ident_config_request_inflight = False + self._ident_workflow_active = False + self._ident_feedback_request_id += 1 + self._ident_feedback_request_inflight = False + self._ident_feedback_timer.stop() + self._ident_config_retry_timer.stop() + self._volume_workflow_active = False + self._volume_config_request_id += 1 + self._volume_request_status_timer.stop() + self._heartbeat_timer.stop() + if self._volume_cloud_request_id: + self._ack_volume_config_request(self._volume_cloud_request_id) + self._volume_cloud_request_id = None + self.ident_mgr.stop() + if self.engine.is_running: + self.engine.stop() + if self.conn_mgr.is_connected(): + self.conn_mgr.disconnect() + if self._plot_window is not None: + self._plot_window.close() + event.accept() diff --git a/ReinLoop/ui/plot_window.py b/ReinLoop/ui/plot_window.py new file mode 100644 index 0000000..746b8f2 --- /dev/null +++ b/ReinLoop/ui/plot_window.py @@ -0,0 +1,189 @@ +# plot_window.py +"""独立绘图窗口:嵌入 matplotlib (QtAgg 后端) 显示控制数据曲线。 + +注意:matplotlib backend 由 main.py 在最早期统一设置,此处不再重复调用。 +使用 Figure() 直接创建图形,避免 plt.subplots() 污染 pyplot 全局状态导致闪退。 +""" + +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT +from matplotlib.figure import Figure + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QWidget +) +from PySide6.QtCore import Qt + + +class PlotWindow(QDialog): + """压力控制数据曲线窗口""" + + def __init__(self, time_data, pressure_data, target_data, valve_data, parent=None): + super().__init__(parent) + self.setWindowTitle("控制数据曲线图") + self.resize(1100, 800) + self.setAttribute(Qt.WA_DeleteOnClose) + + self.time_data = list(time_data) + self.pressure_data = list(pressure_data) + self.target_data = list(target_data) + self.valve_data = list(valve_data) + + self._fig = None + self._ax1 = None + self._ax2 = None + self._canvas = None + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # ---- 控制面板 ---- + ctrl_widget = QWidget() + ctrl_layout = QHBoxLayout(ctrl_widget) + ctrl_layout.setContentsMargins(0, 0, 0, 0) + ctrl_layout.setSpacing(8) + + ctrl_layout.addWidget(QLabel("时间轴范围 (秒):")) + + self.x_min_entry = QLineEdit("0") + self.x_min_entry.setMaximumWidth(80) + ctrl_layout.addWidget(self.x_min_entry) + + ctrl_layout.addWidget(QLabel("到")) + + x_max_default = f"{max(self.time_data):.1f}" if self.time_data else "10" + self.x_max_entry = QLineEdit(x_max_default) + self.x_max_entry.setMaximumWidth(80) + ctrl_layout.addWidget(self.x_max_entry) + + apply_btn = QPushButton("应用") + apply_btn.clicked.connect(self._apply_x_limits) + ctrl_layout.addWidget(apply_btn) + + reset_btn = QPushButton("重置") + reset_btn.clicked.connect(self._reset_view) + ctrl_layout.addWidget(reset_btn) + + all_btn = QPushButton("全部") + all_btn.clicked.connect(self._show_all) + ctrl_layout.addWidget(all_btn) + + last30_btn = QPushButton("最后30秒") + last30_btn.clicked.connect(lambda: self._zoom_last_n(30)) + ctrl_layout.addWidget(last30_btn) + + ctrl_layout.addStretch() + layout.addWidget(ctrl_widget) + + # ---- matplotlib 画布 ---- + if not self.time_data or len(self.time_data) < 2: + layout.addWidget(QLabel("数据不足,无法绘制图表")) + return + + try: + # 使用 Figure() 直接创建,避免 plt.subplots() 将图形注册到 pyplot 全局状态 + self._fig = Figure(figsize=(10, 7), dpi=100) + self._ax1 = self._fig.add_subplot(2, 1, 1) + self._ax2 = self._fig.add_subplot(2, 1, 2) + + # 压力曲线 + self._ax1.plot(self.time_data, self.pressure_data, 'b-o', + linewidth=1, markersize=1, alpha=0.8, label='实际压力') + self._ax1.plot(self.time_data, self.target_data, 'r--', + linewidth=1.5, alpha=0.8, label='目标压力') + self._ax1.set_ylabel('压力 (kPa)', fontsize=12) + self._ax1.set_title('压力控制性能', fontsize=14, fontweight='bold') + self._ax1.legend(loc='upper right', fontsize=10) + self._ax1.grid(True, alpha=0.3) + + # 阀门开度曲线 + self._ax2.plot(self.time_data, self.valve_data, 'm-o', + linewidth=1, markersize=1, alpha=0.8, label='实际阀门指令') + self._ax2.set_xlabel('时间 (秒)', fontsize=12) + self._ax2.set_ylabel('阀门开度 (%)', fontsize=12) + self._ax2.legend(loc='upper right', fontsize=10) + self._ax2.set_ylim([0, 105]) + self._ax2.grid(True, alpha=0.3) + + self._fig.tight_layout() + + # 创建 canvas + self._canvas = FigureCanvasQTAgg(self._fig) + layout.addWidget(self._canvas, stretch=1) + + # 导航工具栏(macOS 上某些版本可能崩溃,加容错) + try: + toolbar = NavigationToolbar2QT(self._canvas, self) + layout.addWidget(toolbar) + except Exception as e: + print(f"[PlotWindow] 工具栏创建失败: {e}") + + # 提示标签 + hint = QLabel("提示: 使用工具栏缩放/平移 | 拖动矩形区域可局部放大") + hint.setStyleSheet("color: gray; font-size: 12px;") + layout.addWidget(hint) + + except Exception as e: + import traceback + traceback.print_exc() + layout.addWidget(QLabel(f"绘图创建失败: {e}")) + + def _apply_x_limits(self): + try: + x_min = float(self.x_min_entry.text()) + x_max = float(self.x_max_entry.text()) + if x_min >= x_max or self._ax1 is None: + return + self._ax1.set_xlim([x_min, x_max]) + self._ax2.set_xlim([x_min, x_max]) + self._canvas.draw() + except ValueError: + pass + + def _reset_view(self): + if not self._ax1 or not self.time_data: + return + x_min = min(self.time_data) + x_max = max(self.time_data) + self._ax1.set_xlim([x_min, x_max]) + self._ax2.set_xlim([x_min, x_max]) + self.x_min_entry.setText(f"{x_min:.1f}") + self.x_max_entry.setText(f"{x_max:.1f}") + self._canvas.draw() + + def _show_all(self): + if not self._ax1 or not self.time_data: + return + x_min = min(self.time_data) + x_max = max(self.time_data) + self._ax1.set_xlim([x_min, x_max]) + self._ax2.set_xlim([x_min, x_max]) + self.x_min_entry.setText(f"{x_min:.1f}") + self.x_max_entry.setText(f"{x_max:.1f}") + self._canvas.draw() + + def _zoom_last_n(self, n_seconds): + if not self._ax1 or not self.time_data: + return + x_max = max(self.time_data) + x_min = max(0, x_max - n_seconds) + self._ax1.set_xlim([x_min, x_max]) + self._ax2.set_xlim([x_min, x_max]) + self.x_min_entry.setText(f"{x_min:.1f}") + self.x_max_entry.setText(f"{x_max:.1f}") + self._canvas.draw() + + def closeEvent(self, event): + """Qt 会按控件树父子关系自动销毁所有子控件(canvas + toolbar)。 + 此处只需清空 Python 侧引用,让 Figure 能被 GC 正常回收。 + + 严禁 plt.close(self._fig)!plt.close() 内部绕过 Qt 直接销毁 canvas + widget,与 WA_DeleteOnClose 冲突导致 double-free → SIGSEGV 闪退。 + """ + self._canvas = None + self._ax1 = None + self._ax2 = None + self._fig = None + super().closeEvent(event) diff --git a/ReinLoop/ui/status_bar.py b/ReinLoop/ui/status_bar.py new file mode 100644 index 0000000..ebc90ad --- /dev/null +++ b/ReinLoop/ui/status_bar.py @@ -0,0 +1,47 @@ +# status_bar.py +"""底部状态栏组件:日志 + 连接状态""" + +import time +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel +from PySide6.QtCore import Qt + + +class StatusBar(QWidget): + """底部状态栏 —— 左侧日志,右侧连接状态""" + + def __init__(self, colors: dict, parent=None): + super().__init__(parent) + self.colors = colors + self.setProperty("cssClass", "bottomBar") + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 6, 12, 6) + + # ---- 左侧:日志 ---- + self.log_label = QLabel("就绪") + self.log_label.setProperty("cssClass", "logLabel") + self.log_label.setMinimumHeight(24) + layout.addWidget(self.log_label, stretch=3) + + # ---- 右侧:连接状态(● 未连接 / ● 已连接) ---- + self.status_label = QLabel("● 未连接") + self.status_label.setProperty("cssClass", "statusLabel") + self.status_label.setStyleSheet( + f"color: {colors.get('ERROR_RED', '#FF2424')}; font-weight: bold; font-size: 14px;" + ) + layout.addWidget(self.status_label, stretch=1, alignment=Qt.AlignRight | Qt.AlignVCenter) + + # ---- 公开接口 ---- + def set_log(self, message: str): + """设置日志消息(仅显示最新一条)""" + self.log_label.setText(f"{time.strftime('%H:%M:%S')} - {message}") + + def set_connection_status(self, connected: bool, status_text: str = None): + """设置连接状态显示""" + if status_text is None: + status_text = "● 已连接" if connected else "● 未连接" + color = self.colors.get("SUCCESS_GREEN", "#0F955D") if connected else self.colors.get("ERROR_RED", "#FF2424") + self.status_label.setText(status_text) + self.status_label.setStyleSheet( + f"color: {color}; font-weight: bold; font-size: 14px;" + ) diff --git a/ReinLoop/修改记录.md b/ReinLoop/修改记录.md new file mode 100644 index 0000000..7fd5818 --- /dev/null +++ b/ReinLoop/修改记录.md @@ -0,0 +1,716 @@ +# 修改记录 + +> 当前状态说明:本节以 Git 基线提交 `5841f6d` 为参照,记录 2026-07-23 工作区中的最终代码差异。后面的“历史过程记录”仅用于追溯,若与本节冲突,以本节和当前代码为准。 + +## 当前修改总览 + +| 项目 | 当前值 | +| --- | --- | +| 仓库 | `https://github.com/azuki-m/pressure_control_gui.git` | +| 本地目录 | `C:\Users\31765\.codex\pressure_control_gui_source` | +| 分支 | `MT2-AM8` | +| 基线提交 | `5841f6d 修改默认值,增加压力滤波(暂未启用)` | +| 工作区状态 | 本文所列修改均尚未提交 | + +相对基线,当前增加了三条主要业务链路: + +1. 辨识前执行 `1000 -> 0` 的绝对行程稳态压力预扫描,上传不含时间字段的 JSON。 +2. 辨识 9 参数改为从云端 CSV 获取;PRBS 结果保持 CSV 上传,并根据云端数字 `0/1` 显示审核结果。未通过时等待公司更新参数,再重新执行完整辨识。 +3. 容积测试 8 参数改为按请求传递:客户点击“测试”只创建一次请求指令,公司端检测到后上传本次 JSON,客户端持续查询同一个请求,加载参数后删除临时文件和请求记录。 + +客户调试界面不再读取或显示这些参数输入框。旧控件对象仍保留以兼容现有代码,但不是新流程的数据来源。 + +## 当前文件差异 + +### 修改的原文件 + +| 文件 | 当前修改 | +| --- | --- | +| `core/identification.py` | 增加行程稳态预扫描;上传函数支持文本和字节;PRBS 原始结果改为 CSV 直传;增加上传回调;加强任务线程存活判断和启动返回值。 | +| `ui/main_window.py` | 增加辨识参数下载、反馈轮询、未通过后等待新参数、容积请求握手、超时/停止清理及 Qt 线程信号桥。 | +| `ui/debug_tab.py` | 隐藏客户不应输入的辨识/容积参数和高级设置;增加辨识审核状态显示。 | +| `setup.py` | 将 3 个新增核心模块加入 Cython 编译列表。 | + +### 新增业务文件 + +| 文件 | 用途 | +| --- | --- | +| `core/identification_config.py` | 下载、解析、校验 9 参数 CSV。 | +| `core/identification_feedback.py` | 登记辨识 CSV、查询数字 `0/1`、确认并清理反馈。 | +| `core/volume_config.py` | 校验 8 参数 JSON,创建、查询、清理一次容积参数请求。 | +| `index.js` | 云函数入口,增加辨识参数、辨识反馈、容积请求接口。 | +| `config/identification_config.json` | 旧本地格式迁移提示;客户端不读取。 | +| `config/volume_measurement.json` | 旧本地格式迁移提示;客户端不读取。 | + +### 新增公司端工具和示例 + +| 文件 | 用途 | +| --- | --- | +| `tool/identification_config.example.csv` | 9 参数 CSV 示例。 | +| `tool/upload_identification_config.py` | 校验并上传客户的固定辨识参数 CSV。 | +| `tool/submit_identification_feedback.py` | 提交辨识审核数字 `1` 或 `0`。 | +| `tool/volume_measurement.example.json` | 8 参数 JSON 示例。 | +| `tool/upload_volume_config.py` | 等待客户请求,检测到后校验、上传并关联本次 JSON。 | + +### 新增测试 + +- `tests/test_initial_travel_scan.py` +- `tests/test_identification_config.py` +- `tests/test_identification_feedback.py` +- `tests/test_volume_config.py` + +## 当前辨识流程 + +### 云端 9 参数 CSV + +客户点击“开始辨识”后,客户端按许可证中的客户名称读取: + +```text +ReinLoop_GUI/{客户名称}/identification_config/identification_config.csv +``` + +CSV 固定使用 `parameter,value` 两列: + +```csv +parameter,value +q_in_val,50.0 +dt,0.1 +n_order,6 +t_c,2.5 +levels,"10,20,30,40,50,60,70,80" +dead_area,240.0 +xa_full,1000.0 +V_val,5.0 +repeat,2 +``` + +客户端要求且只允许这 9 个字段。主要约束: + +| 参数 | 约束 | +| --- | --- | +| `q_in_val` | 有限数字且 `>= 0` | +| `dt` | 有限数字且 `> 0` | +| `n_order` | 整数且 `>= 2` | +| `t_c` | 有限数字且 `>= dt` | +| `levels` | 至少 2 项,长度为 2 的整数次幂,每项在 `0..100` | +| `dead_area` | `0 <= dead_area < xa_full` | +| `xa_full` | `>= 1000` | +| `V_val` | 有限数字且 `> 0` | +| `repeat` | 正整数 | + +校验成功后,9 个参数通过 `**config` 传给 `start_identification()`;`conn_mgr` 和 `running_flag_check` 仍由客户端本地创建,不属于 CSV。 + +公司端上传命令: + +```powershell +python tool/upload_identification_config.py "客户名称" "公司内部路径\identification_config.csv" +``` + +同一路径再次上传会覆盖固定 CSV。客户端只在开始一轮辨识或收到未通过结果后重新读取,不会在本轮运行中途替换参数。 + +### `1000 -> 0` 行程稳态预扫描 + +`start_identification()` 先扫描以下绝对行程,再调用原有 `collect_data_with_prbs()`: + +```text +1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 0 +``` + +每个行程至少等待 5 秒,以 0.1 秒周期采样;使用最近 5 秒窗口,在压力极差 `<= 0.5 kPa`、压力斜率绝对值 `<= 0.05 kPa/s` 且连续稳定 3 秒后记录平均压力。每个行程最长等待 60 秒,超时跳过。停止、异常或结束时尝试把行程写回 `0`。 + +结果上传到 `ReinLoop_GUI/{客户名称}/ind_data/`,文件名为 `travel_stability_pressures_时间戳.json`。内容只包含行程和稳定压力,不包含相对时间,也不保存压力变化过程数组: + +```json +{ + "stable_pressures": [ + {"distance": 1000, "pressure": 12.3}, + {"distance": 900, "pressure": 15.6} + ] +} +``` + +预扫描 JSON 上传失败只记录日志,不阻止后续 PRBS。 + +### PRBS CSV 和 `0/1` 反馈 + +基线会把 PRBS 结果重新包装为 JSON;当前直接上传 `collect_data_with_prbs()` 返回的 `csv_data` 和 `.csv` 文件名,不改变采集器的原始 CSV 格式。 + +```text +上传 PRBS CSV + -> registerIdentificationResult 登记本轮 CSV 文件名为 runId + -> 客户端每 2 秒查询 getIdentificationFeedback + -> 数字 1:显示“已通过”,清理反馈记录,结束 + -> 数字 0:显示“未通过”,清理反馈记录,等待云端 CSV 更新 + -> 每 2 秒重新获取 identification_config.csv + -> 9 参数内容与本轮不同后,才重新执行完整辨识 +``` + +反馈只接受数字 `0` 或 `1`,布尔值和其他数字均拒绝。公司端命令: + +```powershell +python tool/submit_identification_feedback.py "客户名称" 1 +python tool/submit_identification_feedback.py "客户名称" 0 +``` + +云端集合 `identification_reviews` 对每个客户只保留当前待审核记录,客户端消费后调用 `ackIdentificationFeedback` 删除,防止下一轮误用旧结果。 + +## 当前容积测试流程 + +### 8 参数 JSON + +```json +{ + "q_in_val": 50.0, + "dt": 0.05, + "p_max": 200.0, + "fit_low": 50.0, + "fit_high": 150.0, + "T_delta": 30.0, + "xa_full": 1000.0, + "num_runs": 3 +} +``` + +客户端要求且只允许这 8 个字段。主要约束:`q_in_val > 0`、`dt > 0`、`p_max > 0`、`0 <= fit_low < fit_high <= p_max`、`xa_full > 0`、`num_runs` 为正整数,其他数值必须有限。 + +### 最终请求握手 + +服务器不能主动向客户端或公司端推送,因此采用“一次创建请求 + 两端查询同一请求状态”: + +```text +客户点击“测试” + -> 客户端只调用一次 createVolumeConfigRequest + -> 云端生成 requestId,写入 volume_config_requests,有效期 5 分钟 + +公司端工具 + -> 每 2 秒查询 getPendingVolumeConfigRequest + -> 检测到 requestId 后才上传 8 参数 JSON + -> submitVolumeConfigFile 把文件与 requestId 关联 + +客户端等待期间 + -> 每 2 秒查询 getVolumeConfigRequest,始终使用同一个 requestId + -> 状态查询不会重复创建请求,也不会重复要求公司端上传 + -> 检测到本次新文件后下载并校验 JSON + -> ackVolumeConfigRequest 删除临时文件和请求记录 + -> 执行一次 start_volume_measurement(..., **config) +``` + +公司端命令: + +```powershell +python tool/upload_volume_config.py "客户名称" "公司内部路径\volume.json" --wait-seconds 300 +``` + +云端文件固定为: + +```text +ReinLoop_GUI/{客户名称}/volume_config_requests/{requestId}/volume_measurement.json +``` + +`submitVolumeConfigFile` 会核对 `file_records` 中的客户目录、`requestId`、文件名和上传时间。只有请求创建后、5 分钟内上传且属于该请求的 JSON 才能加载;旧目录或其他请求的文件不能关联。 + +### 临时文件处理 + +- 创建新请求时,云端清理该客户遗留的旧容积请求及临时 JSON。 +- 客户端加载成功、用户停止或请求超时后,删除当前请求、云存储 JSON 和对应 `file_records` 记录。 +- 公司端上传或关联失败时,工具尝试删除刚上传的文件。 +- 测量开始后不再监听参数变化,也不会因云端更新而自动重测;下一次必须由客户再次点击“测试”。 +- 辨识 CSV 是公司维护的固定文件,后续上传会覆盖;容积 JSON 是一次请求的临时文件,消费后删除。 + +上一版“公司预先写入最新 8 参数、客户端直接获取”的方案已移除。当前代码不存在 `pushVolumeConfig`、`getVolumeConfig` 或 `volume_measurement_configs` 的有效调用路径。 + +## 当前客户端和构建修改 + +- 调试页隐藏辨识、容积参数和高级设置,保留开始/停止按钮及辨识审核状态。 +- 网络请求在后台线程中执行,通过 Qt `Signal` 回到主线程更新界面。 +- 请求代数编号和 `inflight` 标志用于忽略停止后迟到的结果,并阻止同类请求并发。 +- 辨识与容积测试互斥;停止或关闭窗口时停止定时器、使旧请求失效并尝试清理云端状态。 +- `IdentificationManager.is_running` 同时检查运行标志和任务线程是否存活。 +- `start_identification()` 与 `start_volume_measurement()` 返回布尔值,调用方可判断任务是否启动。 +- `setup.py` 新增 `core/identification_config.py`、`core/identification_feedback.py`、`core/volume_config.py` 三个 Cython 编译目标。 + +## 当前新增云函数接口 + +| 接口 | 调用方 | 作用 | +| --- | --- | --- | +| `getIdentificationConfig` | 客户端 | 获取当前客户固定辨识 CSV 的临时地址。 | +| `registerIdentificationResult` | 客户端 | 登记刚上传的 PRBS CSV。 | +| `getIdentificationFeedback` | 客户端 | 查询本轮数字 `0/1`。 | +| `setIdentificationFeedback` | 公司端 | 提交本轮数字 `0/1`。 | +| `ackIdentificationFeedback` | 客户端 | 删除已消费反馈。 | +| `createVolumeConfigRequest` | 客户端 | 点击“测试”时创建一次 5 分钟请求。 | +| `getPendingVolumeConfigRequest` | 公司端 | 查询客户的待上传请求。 | +| `submitVolumeConfigFile` | 公司端 | 把 JSON 与本次请求关联。 | +| `getVolumeConfigRequest` | 客户端 | 查询同一请求是否已有有效 JSON。 | +| `ackVolumeConfigRequest` | 客户端 | 删除已消费、取消或超时的请求和文件。 | + +## 当前验证结果 + +已执行: + +```powershell +python -m unittest discover -s tests -p 'test_*.py' -v +``` + +- 23 项单元测试全部通过。 +- 38 个 Python 文件通过 AST 语法解析。 +- `git diff --check` 通过,仅有 Windows 的 LF/CRLF 转换提示。 +- 测试覆盖 9 参数 CSV、预扫描 JSON 无时间字段、PRBS CSV 直传、数字 `0/1`、8 参数 JSON、一次请求创建、等待/就绪状态和请求清理。 + +## 尚未完成和发布风险 + +1. 尚未连接真实 MT2-AM8、真实云环境和公司端工具完成端到端联调。 +2. 新 `index.js` 尚未部署;部署前客户端无法使用新增接口。 +3. 本机没有独立 Node.js,`index.js` 尚未完成语法检查;上一次借用 VS Code 运行时的检查被中止,不计为通过。 +4. 云数据库需要允许云函数读写 `identification_reviews`、`volume_config_requests` 和现有 `file_records`。 +5. 当前 HTTP 接口主要依赖 `deviceId` 区分客户,没有请求签名或设备令牌;正式发布前需要服务端身份认证。 +6. 当前 `index.js` 含明文小程序 `SECRET`。不得直接提交或分发,应立即轮换,并改为从云函数环境变量或密钥服务读取。 +7. `requirements.txt` 未声明程序实际使用的 `PySide6`,新机器仅按该文件安装仍不能启动。 +8. 所有改动仍在工作区,尚未形成 Git 提交。 + +## 发布顺序建议 + +1. 轮换并移除 `index.js` 中的明文 `SECRET`。 +2. 在测试云环境部署 `index.js`,建立并授权新增集合。 +3. 公司端先上传一份辨识参数 CSV。 +4. 联调一次容积请求的创建、发现、上传、下载和删除。 +5. 联调预扫描、PRBS CSV 上传和 `0/1` 反馈重测。 +6. 补齐运行依赖和打包配置,再生成客户安装包。 + +
+历史过程记录(仅供追溯,当前行为以上述整理为准) + +## 项目基线 + +- 仓库:`https://github.com/azuki-m/pressure_control_gui.git` +- 分支:`MT2-AM8` +- 基线提交:`5841f6d 修改默认值,增加压力滤波(暂未启用)` +- 本地目录:`C:\Users\31765\.codex\pressure_control_gui_source` +- 开始日期:2026-07-22 + +## 记录规则 + +每次修改应记录以下内容: + +1. 修改目标和需求来源。 +2. 涉及的文件、类和函数。 +3. 修改前后的行为差异。 +4. 参数、接口或数据格式变化。 +5. 验证方法和验证结果。 +6. 尚未完成的事项与风险。 + +## 修改历史 + +### 0. 基线建立 + +- 从 GitHub 重新克隆 `MT2-AM8` 分支。 +- 保留原始代码,不继承此前测试版 1.0 的工作区修改。 +- 对 28 个 Python 文件执行 AST 语法解析,全部通过。 + +### 1. 云函数恢复 + +- 将此前测试版云函数备份到 `pressure_control_gui_test_v1.0/cloud_index.latest-test.js`。 +- 恢复 `index.js` 的原始接口分发,仅保留: + `uploadDataFile`、`listModels`、`downloadModel`、`deleteFile`、`uploadUserInfo`。 +- 测试版新增的参数传输和多轮调试接口不再从云函数入口暴露。 +- 已从恢复版 `index.js` 中完整移除测试版新增的参数传输和多轮会话函数。 +- 恢复后的 `index.js` 已同步至 `C:\Users\31765\Desktop\index.js`。 + +### 2. 容积测试的 8 个参数改为 JSON 输入(历史阶段,已由第 4 节替代) + +#### 2.1 修改目标 + +- 客户端不再通过 UI 输入容积测试参数。 +- 参数从固定 JSON 文件读取并校验后,传给 `start_volume_measurement()`。 +- 参数无效或文件读取失败时禁止启动设备,并在状态栏显示错误。 +- 旧 UI 控件对象继续保留,避免影响仍依赖这些属性的历史代码。 + +#### 2.2 JSON 文件和字段 + +- 默认文件:`config/volume_measurement.json` +- 打包后默认位置:可执行文件同级的 `config/volume_measurement.json` +- 可使用环境变量 `REINLOOP_VOLUME_CONFIG` 覆盖默认路径。 +- JSON 必须且只能包含下面 8 个字段: + +```json +{ + "q_in_val": 50.0, + "dt": 0.05, + "p_max": 200.0, + "fit_low": 50.0, + "fit_high": 150.0, + "T_delta": 30.0, + "xa_full": 1000.0, + "num_runs": 3 +} +``` + +字段与 `start_volume_measurement()` 参数的对应关系: + +| JSON 字段 | 类型 | 作用 | +| --- | --- | --- | +| `q_in_val` | float | 进气流量 | +| `dt` | float | 控制与采样周期 | +| `p_max` | float | 测量压力上限 | +| `fit_low` | float | 压力拟合区间下限 | +| `fit_high` | float | 压力拟合区间上限 | +| `T_delta` | float | 测量过程温升参数 | +| `xa_full` | float | 电机总行程/全开行程参数 | +| `num_runs` | int | 重复测量次数 | + +#### 2.3 代码位置和改动 + +1. `core/volume_config.py` + + - `REQUIRED_FIELDS`(约第 10 行):定义必须存在的 8 个字段。 + - `default_config_path()`(约第 16 行):确定默认路径,并支持环境变量覆盖。 + - `load_volume_config()`(约第 27 行):读取 JSON、拒绝缺失或多余字段、 + 校验数据类型及范围,最后返回可直接展开传参的字典。 + - 范围约束包括:`dt > 0`、`p_max > 0`、 + `0 <= fit_low < fit_high <= p_max`、`xa_full > 0`、 + `num_runs` 为正整数。 + +2. `config/volume_measurement.json` + + - 新增默认配置模板。 + - 该文件中的值是当前测试默认值,部署前应由项目负责人确认。 + +3. `ui/main_window.py` + + - 第 27 行附近:导入 `load_volume_config`。 + - `_on_volume_measure()`(约第 589 行):删除以下 UI 参数读取逻辑: + `get_identify_params()`、`get_advanced_params()`、控制页流量输入和 PID 周期。 + - 新流程为: + +```text +点击测试 + -> load_volume_config() + -> 校验成功 + -> start_volume_measurement(conn_mgr, running_flag_check, **config) +``` + + - 配置失败时调用 `set_volume_finished()`,恢复测试按钮状态,不启动测量线程。 + +4. `ui/debug_tab.py` + + - 高级设置卡片创建完成后调用 `adv_card.hide()`(约第 123 行)。 + - `_build_ident_section()` 末尾(约第 215 行)遍历布局并隐藏参数控件。 + - 保留 `btn_wrap` 和 `seq_wrap`,因此测试和辨识操作按钮仍可见。 + - `levels_entry` 单独隐藏。 + - `get_identify_params()`、`get_advanced_params()` 和 QSettings 逻辑没有删除, + 仅不再作为容积测试的数据来源。 + +5. `core/identification.py` + + - `start_volume_measurement()`(约第 196 行)接口本身未改名。 + - 仍接收上述 8 个业务参数,内部继续调用 `measure_volume()` 并上传测量结果。 + +6. `tests/test_volume_config.py` + + - `test_load_valid_config()`:验证合法配置可以读取。 + - `test_rejects_missing_field()`:验证缺少字段时拒绝启动。 + - `test_rejects_invalid_range()`:验证非法拟合区间被拒绝。 + +#### 2.4 修改前后行为 + +修改前: + +```text +UI 流量输入 + PID 周期 + 调试页参数 + 高级设置 + -> main_window.py 组合参数 + -> start_volume_measurement() +``` + +修改后: + +```text +config/volume_measurement.json + -> load_volume_config() 严格校验 + -> main_window.py 使用 **config + -> start_volume_measurement() +``` + +#### 2.5 验证结果 + +- `tests/test_volume_config.py`:3 个测试全部通过。 +- 30 个 Python 文件通过 AST 语法解析。 +- `git diff --check` 通过,仅提示 Windows 的 LF/CRLF 转换警告。 +- 未连接真实 MT2-AM8,因此尚未执行设备端容积测量联调。 + +#### 2.6 当前限制和安全说明 + +- 该阶段读取本地 JSON;当前实现已由第 4 节的云端单次请求替代。 +- “隐藏”仅指参数不在客户 UI 中显示;如果 JSON 明文部署在客户电脑上, + 有文件系统访问权限的用户仍可读取它。 +- 若参数属于公司机密,后续应增加云端临时下载、身份校验、加密或用后销毁流程。 +- 控制页面原有流量输入仍服务于其他控制功能,但容积测试不会读取该输入。 + +## 待修改事项 + +### 已完成:PRBS 前增加绝对行程稳态预扫描 + +- 修改文件:`core/identification.py`。 +- 新增函数:`IdentificationManager._run_initial_travel_scan()`。 +- `start_identification()` 的后台线程先调用新函数,完成后继续执行原有 + `collect_data_with_prbs()`;PRBS 的生成、参数和上传逻辑未替换。 +- 固定行程序列:`1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 0`。 +- 稳态判据:最短等待 5 s、采样周期 0.1 s、滑动窗口 5 s、压力极差 + `<= 0.5 kPa`、斜率绝对值 `<= 0.05 kPa/s`、连续稳定 3 s、单行程 + 最大等待 60 s。 +- 每个达到稳态的行程只记录行程和平均稳定压力,不保存相对时间或响应过程。 +- 输出为 JSON 对象,格式如下: + +```json +{ + "stable_pressures": [ + {"distance": 1000, "pressure": 12.3}, + {"distance": 900, "pressure": 15.6} + ] +} +``` + +- 压力单位为 kPa;未达到稳态或写入失败的行程不会写入结果,但其余记录仍能 + 通过 `distance` 明确对应行程。 +- 预扫描结果单独上传至 + `{客户名称}/ind_data/travel_stability_pressures_时间戳.json`。 +- 行程指令不通过实时 UI 回调显示;UI 只接收压力。 +- 用户停止或发生异常时尝试将行程写回 `0`。 +- 预扫描上传失败不会改变后续 PRBS 行为,程序记录日志后继续 PRBS。 +- 新增 `tests/test_initial_travel_scan.py`,模拟全部 11 个行程达到稳态,验证 + 上传文件为 JSON,且每条记录只包含 `distance` 和 `pressure`。 +- 验证结果:34 个 Python 文件通过 AST 语法解析;原 + `collect_data_with_prbs()` 调用及其 9 个传参保持不变;已有 3 个 JSON + 配置单元测试继续通过;`git diff --check` 通过。 +- 尚未连接真实 MT2-AM8,稳态等待、行程方向和 `0` 是否为安全位置需要硬件联调。 + +### 3. 辨识的 9 个参数改为从云端 CSV 获取 + +#### 3.1 修改目标 + +- 客户端点击“开始辨识”后,不再读取调试页、控制页或高级设置中的本地参数。 +- 客户端根据许可证中的客户名称,从云端读取固定 CSV 文件。 +- CSV 解析和严格校验成功后,将 9 个业务参数一次性传给 + `IdentificationManager.start_identification()`。 +- `conn_mgr` 和 `running_flag_check` 是客户端运行时对象,仍由本地创建, + 不属于云端 CSV。 +- 参数不在客户界面显示;之前隐藏的参数控件继续保留用于兼容旧代码, + 但辨识流程不会读取这些控件。 +- 既有的绝对行程稳态预扫描和原始 PRBS 采集顺序不变。 + +#### 3.2 云端文件和接口 + +- 固定云端目录:`{客户名称}/identification_config` +- 固定文件名:`identification_config.csv` +- 完整对象存储路径: + `ReinLoop_GUI/{客户名称}/identification_config/identification_config.csv` +- 客户名称来自 `api.py` 的 `the_folder`,生产环境中对应许可证的 + `customer` 字段。 +- `index.js` 第 318 行附近新增 `getIdentificationConfig(event)`: + 校验 `deviceId`,查询 `file_records` 中的固定记录,并返回腾讯云临时下载 URL。 +- `index.js` 第 380 行附近新增同名分发入口。 +- 仓库中的新版 `index.js` 已将固定查询文件改为 CSV;部署时应以仓库版本为准。 +- 公司端仍通过既有 `uploadDataFile` 接口获取直传凭证;同一路径再次上传时, + 云函数执行 upsert,客户端下一次辨识将读取覆盖后的版本。 + +#### 3.3 CSV 格式 + +`tool/identification_config.example.csv` 是公司端示例模板。实际客户配置应另存为 +公司内部文件,不要放进客户安装包;CSV 固定使用 `parameter,value` 两列: + +```csv +parameter,value +q_in_val,50.0 +dt,0.1 +n_order,6 +t_c,2.5 +levels,"10,20,30,40,50,60,70,80" +dead_area,240.0 +xa_full,1000.0 +V_val,5.0 +repeat,2 +``` + +| CSV 参数 | `start_identification()` 参数 | 校验要求 | +| --- | --- | --- | +| `q_in_val` | `q_in_val` | 有限数字,`>= 0` | +| `dt` | `dt` | 有限数字,`> 0` | +| `n_order` | `n_order` | 整数,`>= 2` | +| `t_c` | `t_c` | 有限数字,`>= dt` | +| `levels` | `levels` | 至少 2 项,长度为 2 的整数次幂,每项在 0~100 | +| `dead_area` | `dead_area` | 有限数字,`0 <= dead_area < xa_full` | +| `xa_full` | `xa_full` | 有限数字,`>= 1000` | +| `V_val` | `V_val` | 有限数字,`> 0` | +| `repeat` | `repeat` | 正整数 | + +`xa_full >= 1000` 是因为辨识开始前的固定行程预扫描包含 1000; +`levels` 的长度要求来自原始 `generate_prbs()` 多电平映射算法。 + +#### 3.4 客户端代码位置和执行流程 + +1. `core/identification_config.py` + + - `REQUIRED_FIELDS`(第 7 行附近):定义 9 个必需字段。 + - `validate_identification_config()`(第 13 行附近):拒绝缺失字段、 + 多余字段、布尔值、非有限数值和不安全的范围。 + - `parse_identification_config_csv()`:解析 `parameter,value` 两列,并将 + `levels` 的逗号分隔值恢复为 Python 列表。 + - `download_identification_config()`(第 79 行附近):调用云函数, + 获取临时 URL,下载 CSV,并在客户端再次校验。 + +2. `ui/main_window.py` + + - `_Bridge.identification_config_loaded`(第 46 行附近):后台下载完成后, + 将结果安全地送回 Qt 主线程。 + - `_on_identify_start()`(第 555 行附近):点击辨识后启动后台下载线程, + 不阻塞界面,也不读取原有 UI 参数。 + - `_on_identification_config_loaded()`(第 578 行附近):同步 + `PcControl` 的 `xa_full`,再执行: + +```python +self.ident_mgr.start_identification( + conn_mgr=self.conn_mgr, + running_flag_check=lambda: self.engine.is_running, + **config, +) +``` + + - `_on_identify_stop()`(第 597 行附近):停止辨识并使尚未完成的云端请求失效; + 即使旧请求稍后返回,也不会再启动设备。 + - `closeEvent()`(第 632 行附近):关闭软件时同样取消待处理请求并停止辨识。 + +3. `core/identification.py` + + - `start_identification()`(第 274 行附近)的接口和 9 个业务参数保持不变。 + - 第 320 行附近仍先运行 `_run_initial_travel_scan()`,随后第 324 行附近 + 调用原始 `collect_data_with_prbs()`;PRBS 调节方式没有替换。 + +4. `setup.py` + + - 将 `core/identification_config.py`、`core/identification_feedback.py` 和 + `core/volume_config.py` 加入 Cython 核心模块清单,正式构建时不需要向 + 客户交付这些模块的 Python 源码。 + +客户端完整流程: + +```text +点击开始辨识 + -> 后台调用 getIdentificationConfig(deviceId=许可证客户名称) + -> 获取临时 URL 并下载 identification_config.csv + -> 解析 parameter,value 两列 + -> 严格校验 9 个参数 + -> start_identification(conn_mgr, running_flag_check, **config) + -> 1000 到 0 的稳态预扫描 + -> 原始 PRBS 动态辨识 +``` + +#### 3.5 公司端上传工具 + +- 新增 `tool/upload_identification_config.py`。 +- 第 41 行附近的 `upload_identification_config()` 在公司电脑上先使用与客户端 + 相同的规则解析和校验 CSV,再规范化为 UTF-8 CSV,并调用既有 COS 直传流程。 +- 文件名和云端子目录由脚本固定,不能误传到模型目录。 +- 使用方式: + +```powershell +python tool/upload_identification_config.py "客户名称" "公司内部路径\identification_config.csv" +``` + +- 同一客户再次执行会覆盖固定云端文件,用于多轮调整;已经运行中的一轮辨识 + 不会被中途改参,客户下一次点击辨识才获取新版本。 + +#### 3.6 验证结果 + +- 新增 `tests/test_identification_config.py`,覆盖:合法配置归一化、缺少字段、 + CSV 解析、非 2 的整数次幂序列、`t_c < dt`、`xa_full < 1000`、死区越界。 +- 容积配置、辨识配置和预扫描输出共 16 个单元测试全部通过;其中包含云函数请求参数、 + 临时 URL 下载和云端拒绝响应的模拟测试,不会访问真实网络。 +- 35 个 Python 文件通过 AST 语法解析。 +- `git diff --check` 通过,仅有 Git 的 LF/CRLF 转换提示。 +- 本机没有 Node.js,因此未运行 `node --check index.js`。 +- 当前 Python 环境未安装 `requests`(项目 `requirements.txt` 已声明该依赖), + 因此未向真实云环境上传配置;云端流程仅使用模拟响应完成单元测试。 +- 未连接 MT2-AM8 做完整硬件联调。 + +#### 3.7 安全边界和部署注意事项 + +- 客户 UI 不显示这 9 个参数,客户端本地也不需要保存配置文件;但 Python + 客户端解析 CSV 后,参数会在进程内存中存在,不能等同于绝对防提取。 +- `tool/identification_config.example.csv` 仅是字段模板;构建客户安装包时不要 + 打包 `tool` 目录,也不要把填写了真实参数的公司内部 CSV 放进项目分发目录。 +- `config/identification_config.json` 仅是旧格式迁移提示,客户端不会读取; + 辨识配置只使用云端固定 CSV 文件。 +- 云函数返回的是有有效期的临时下载 URL,但源 CSV 会持续保存在云存储中; + 当前实现是“同路径覆盖”,不是“客户端下载后销毁”。 +- 当前 HTTP 云函数仅按 `deviceId` 查找文件,没有请求签名或设备身份认证。 + 知道接口和其他客户名称的人理论上可能越权请求,因此正式发布前必须增加 + 服务端许可证签名/设备令牌校验,不能只依赖 UI 隐藏。 +- 修改后的 `index.js` 必须重新部署到当前腾讯云环境,否则客户端会收到 + “无效的 type 字段”。 + +### 4. 容积测试通过请求指令获取本次云端 8 参数 JSON + +- 客户点击“测试”后,客户端只调用一次 `createVolumeConfigRequest`,在云端创建 + 一条带 `requestId` 的请求指令;请求有效期为 5 分钟。 +- 客户端随后每 2 秒调用 `getVolumeConfigRequest` 查询同一个 `requestId` 的状态。 + 这些调用只是监听该请求是否已有文件,不会重复创建请求,也不会重复要求公司端上传。 +- 云端使用 `volume_config_requests` 集合保存等待上传、文件就绪和过期状态;创建新请求时 + 会清理该客户遗留的旧请求及其临时 JSON,避免客户端读取旧参数。 +- 公司端工具调用 `getPendingVolumeConfigRequest` 等待客户请求,检测到请求后才校验并上传 + 8 参数 JSON,再调用 `submitVolumeConfigFile` 把文件与本次 `requestId` 关联: + +```powershell +python tool/upload_volume_config.py "客户名称" "公司内部路径\volume.json" --wait-seconds 300 +``` + +- 云端只接受位于 + `{客户名称}/volume_config_requests/{requestId}/volume_measurement.json` 的上传记录, + 并检查上传时间处于本次请求的创建时间和过期时间之间;其他请求或旧目录中的文件不能关联。 +- 客户端检测到本次文件就绪后下载 JSON,严格校验 8 个字段,再执行一次 + `start_volume_measurement(conn_mgr, running_flag_check, **config)`。 +- 客户端加载完成、用户停止或请求超时后调用 `ackVolumeConfigRequest`,及时删除云端临时 JSON、 + `file_records` 记录和请求记录。测量期间不再监听参数变化,也不会自动开始新一轮测量。 +- 本地 `config/volume_measurement.json` 不提供业务参数,只保留迁移提示;公司端示例位于 + `tool/volume_measurement.example.json`。 +- 客户端要求 `q_in_val > 0`,避免容积计算除零;其他 7 个参数继续按原有范围严格校验。 +- 当前共 23 个单元测试,38 个 Python 文件通过 AST 语法解析;未连接真实云端和 MT2-AM8 + 完成端到端联调。 +- 更新后的 `index.js` 必须重新部署,新的请求指令接口才会生效。 + +### 5. 辨识 CSV 的 0/1 审核与自动重测闭环 + +- `collect_data_with_prbs()` 生成的 `csv_data` 和 `.csv` 文件名现在直接上传到 + `{客户名称}/ind_data`,不再重新包装为辨识结果 JSON。 +- CSV 上传成功后,客户端调用 `registerIdentificationResult` 登记本轮文件名 + 作为 `runId`,然后每 2 秒调用 `getIdentificationFeedback` 查询审核结果。 +- 云端使用 `identification_reviews` 集合;每个客户只保留当前一条待审核记录, + 新一轮登记会覆盖旧记录并删除重复项。 +- 审核结果严格使用数字:`1` 表示通过,`0` 表示未通过。其他值会被服务器和 + 客户端拒绝,布尔值也不会被当作数字接受。 +- 客户端调试页新增持久状态显示:`正在辨识`、`等待反馈`、`已通过`、`未通过`、 + `上传失败` 等。 +- 收到 `1` 后显示“已通过”,停止反馈轮询并结束辨识流程。 +- 收到 `0` 后显示“未通过”,每 2 秒重新下载云端 + `identification_config.csv`;如果仍是本轮旧参数则继续等待,检测到 9 参数 + 内容变化后才重新调用 `start_identification()`,防止旧参数重复执行。 +- 客户端消费 `0/1` 后调用 `ackIdentificationFeedback` 删除当前审核记录,避免 + 旧反馈被下一轮误用。 +- 公司端或审核算法可调用 `setIdentificationFeedback`;人工测试命令为: + +```powershell +python tool/submit_identification_feedback.py "客户名称" 1 +python tool/submit_identification_feedback.py "客户名称" 0 +``` + +- 新增 `tests/test_identification_feedback.py`,并补充辨识管理器 CSV 直传测试; + 当前 20 个单元测试全部通过,38 个 Python 文件通过 AST 语法解析。 +- 尚未对真实云函数、审核程序和 MT2-AM8 进行端到端联调;更新后的 `index.js` + 必须重新部署。 + +- [x] 明确容积测试的云端 JSON 参数格式和传输流程。 +- [x] 明确辨识功能的云端 CSV 参数格式和传输流程。 +- [x] 明确绝对行程扫描与 PRBS 辨识的当前执行顺序。 +- [x] 隐藏客户调试页中的容积和辨识参数控件。 +- [x] 明确测试结果上传、公司端审核和多轮反馈流程。 +- [ ] 完成真实 MT2-AM8 硬件联调。 + +
diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..e4da5bb --- /dev/null +++ b/server/.env.example @@ -0,0 +1,8 @@ +HOST=0.0.0.0 +PORT=3000 +B_ADMIN_TOKEN=replace-with-a-long-random-token +# 对外部署时填写可被客户端访问的 HTTPS 根地址,例如 https://api.example.com +# PUBLIC_BASE_URL=https://api.example.com +# DATA_DIR=D:\ReinLoopData +# VOLUME_CONFIG_FOLDER=volume_config +# VOLUME_REQUEST_TTL_MS=300000 \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..da7f3d3 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +coverage/ \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..73faf90 --- /dev/null +++ b/server/README.md @@ -0,0 +1,125 @@ +# ReinLoop Express Server + +该服务将原微信云函数中的文件中转、配置发布、辨识反馈和容积配置请求迁移到服务器。 +请求体继续使用原来的 `type` 字段,因此 ReinLoop 和 ControlPanel 只需更换服务 URL。 + +## 本地运行 + +要求 Node.js 20 或更高版本。 + +```powershell +cd server +npm install +$env:B_ADMIN_TOKEN="your-admin-token" +npm start +``` + +默认监听: + +- 业务接口:`http://127.0.0.1:3000`(同时兼容原有 `/api` 路径) +- 健康检查:`http://127.0.0.1:3000/health` + +ControlPanel 本地联调: + +```powershell +$env:REINLOOP_API_URL="http://127.0.0.1:3000" +$env:B_ADMIN_TOKEN="your-admin-token" +$env:REINLOOP_DEVICE_ID="local-test-device" +cd ControlPanel +npm run gui +``` + +ReinLoop 无 GUI 核心联调: + +```powershell +$env:REINLOOP_SERVER_URL="http://127.0.0.1:3000" +$env:REINLOOP_API_URL="http://127.0.0.1:3000" +$env:REINLOOP_DEVICE_ID="local-test-device" +``` + +如果 ReinLoop 或 ControlPanel 运行在其他设备上,不可使用 `127.0.0.1`, +应改为服务器的局域网 IP 或 HTTPS 域名。 + +业务请求可直接发送到域名根路径,也继续兼容 `/api`。反向代理需要将根路径完整转发到 +Node 服务,例如 Nginx: + +```nginx +location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} +``` + +外部访问返回 `502 Bad Gateway` 表示请求尚未到达 Express,通常是 Node 服务未运行、 +代理的端口不一致或代理无法连接上游。先在服务器执行 +`curl http://127.0.0.1:3000/health`,确认返回 `success: true`,再检查代理配置和服务日志。 + +## 数据与上传 + +- 开发和单元测试时,元数据保存在 `data/database.json`。全新生产部署必须设置 + `NODE_ENV=production` 和 `DATABASE_URL`;已有 `database.json` 的旧生产实例可继续启动, + 但会输出迁移警告。PostgreSQL 启动时会执行可重复的规范化表迁移。 +- 模型文件保存在 `data/models/<公司编码>/<产线编码>/`。 +- 模型上传可通过 `modelName` 重命名;数据库同时保存 `originalFileName`,供 Panel + 显示和识别本地来源名称。未传 `modelName` 时保持原名。 +- 其他上传文件保存在 `data/files/ReinLoop_GUI/`。 +- ReinLoop 上传到 `<设备 ID>/ind_data` 的 CSV/JSON 会进入 Panel 消息队列; + Panel 处理并确认后,server 将其标记为已处理并保留,默认 30 天后自动清理。 +- B 端可通过 `listIdentificationFiles` 查看暂存历史,通过 + `getIdentificationFileDownload` 获取短期签名 URL 下载原始文件,也可通过 + `deleteIdentificationFile` 显式删除。 +- 除模型外,`/files/:fileID` 必须携带服务端签发且绑定文件与过期时间的下载 token; + 直接拼接文件地址会返回 `403`。 +- `uploadDataFile` 仍返回 `uploadMetadata`,现有 Python 与 ControlPanel 的 multipart + 两步上传代码可以继续使用。 +- 可通过 `DATA_DIR` 将数据目录放到独立磁盘。 +- 单文件默认上限为 100 MB。 + +## API 参考 + +完整的业务功能、接口字段、权限边界、上传协议和流程说明见 +[features.md](features.md)。 + +## PostgreSQL 与密钥 + +生产环境需要以下变量: + +- `DATABASE_URL`:PostgreSQL 连接串。 +- `B_ADMIN_TOKEN`:高熵管理令牌。 +- `LICENSE_PUBLIC_KEY_PATH`:只读 RSA 公钥 PEM 路径,用于验证 Panel 已签名许可证。 +- `PUBLIC_BASE_URL`:外部 HTTPS 根地址。 +- `DATA_DIR`:文件存储目录;文件二进制仍保存在该目录的 `files/` 下。 +- `IDENTIFICATION_RETENTION_MS`:已处理辨识 CSV/JSON 的保留时长,默认 30 天。 +- `IDENTIFICATION_PURGE_INTERVAL_MS`:过期清理周期,默认 1 小时。 +- `DOWNLOAD_TOKEN_TTL_MS`:非模型文件短期下载 URL 有效期,默认 5 分钟。 +- `HOST`、`PORT`:监听地址和端口。 + +迁移可单独执行,且可重复运行: + +```sh +DATABASE_URL=postgres://... npm run migrate +``` + +数据库保存文件元数据,文件本体目前需要共享卷或单实例部署;多实例部署前应改为对象存储。 +不得上传、保存或提交 RSA 私钥。通过 HTTPS 部署,定期备份 PostgreSQL 与 `DATA_DIR`, +密钥轮换时先部署新公钥并验证新许可证,再废止旧签发私钥;恢复时先恢复数据库,再恢复同一 +时间点的文件卷。 + +## 生产部署注意事项 + +1. 设置强随机 `B_ADMIN_TOKEN`,不要使用默认开发令牌。 +2. 设置 `HOST=0.0.0.0` 并通过 Nginx/Caddy 提供 HTTPS,或由容器平台映射端口。 +3. 设置 `PUBLIC_BASE_URL` 为外部 HTTPS 根地址,否则下载和上传 URL 会按请求 Host 生成。 +4. 微信小程序后台需要把 HTTPS 域名加入 request、uploadFile 和 downloadFile 合法域名。 +5. 定期备份 PostgreSQL 与整个 `DATA_DIR`;生产环境不可回退到 JSON 存储。 + +## 验证 + +```powershell +npm run check +npm test +``` + +测试会在随机本地端口验证健康检查、multipart 上传、文件列表、下载、辨识反馈和容积请求流程。 \ No newline at end of file diff --git a/server/features.md b/server/features.md new file mode 100644 index 0000000..7f75393 --- /dev/null +++ b/server/features.md @@ -0,0 +1,103 @@ +# ReinLoop Server 功能与接口 + +## 通用约定 + +业务接口为 `POST /api`。请求与响应均为 JSON,响应包含 `success`。 + +标注为 Admin 的接口需要附加: + +```json +{"adminToken":""} +``` + +`deviceId` 统一为两段格式:`/`。 + +## 服务入口 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `POST` | `/api` | 主业务 API | +| `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 | +| `GET` | `/files/:fileID` | 下载已存储文件 | +| `GET` | `/health` | 服务存活检查 | + +## 设备心跳与组织 + +| type | 鉴权 | 请求字段 | 功能与响应要点 | +| --- | --- | --- | --- | +| `deviceHeartbeat` | 无 | `deviceId` | 已登记设备每 10 秒上报。返回 `deviceId`、服务器记录的 `lastSeenAt`;未登记设备失败。 | +| `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id`、`companyId`、`name`、`code`、`deviceId`、`lastSeenAt`、`online`。最近 30 秒有心跳时 `online` 为 `true`。 | +| `createCompany` | Admin | `name`、`code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_`、`-`。 | +| `createProductionLine` | Admin | `companyId`、`name`、`code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `/`。 | + +Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。 + +## 许可证 + +| type | 鉴权 | 请求字段 | 功能与响应要点 | +| --- | --- | --- | --- | +| `createLicense` | Admin | `licenseId`、`companyId`、`productionLineId`、`customer`、`issued`、`expiry`、`features`、`license` | 创建已签名许可证。服务端以 RSA-PSS 公钥验签,校验签名载荷、组织关系和设备 ID。`issued`/`expiry` 使用 `YYYY-MM-DD HH:MM`,按 `Asia/Shanghai` 解析并存为 UTC。相同 ID 和内容幂等成功,不同内容冲突。 | +| `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 | +| `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `license`。 | +| `revokeLicense` | Admin | `licenseId`、`reason` | 撤销许可证,保留历史、撤销时间和原因。 | +| `validateLicense` | 无 | `licenseId`、`deviceId` | 返回 `valid`、`status`、`licenseId`。状态为 `active`、`revoked`、`expired`、`not_found` 或 `device_mismatch`;不泄露客户信息和许可证原文。 | + +许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。 + +## 文件与模型 + +| type | 鉴权 | 请求字段 | 功能与响应要点 | +| --- | --- | --- | --- | +| `uploadDataFile` | 视目录而定 | `fileName`、`folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 | +| `issueModelUpload` | Admin | `deviceId`、`fileName`、可选 `modelName`、`overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 | +| `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName` 和 `originalFileName`。 | +| `downloadModel` | 视文件而定 | `fileID` | 模型保持兼容;非模型文件要求 Admin 并返回短期签名下载 URL。 | +| `deleteFile` | Admin | `fileID`,或 `folder` 与 `fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 | +| `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 | + +上传分两步:先调用 `uploadDataFile` 或 `issueModelUpload`,再将文件作为 `multipart/form-data` 的 `file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。 +模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。 + +上传到 `/ind_data` 的 `.csv`、`.json` 会自动进入 Panel inbox。 + +## 配置发布与读取 + +| type | 鉴权 | 请求字段 | 功能与响应要点 | +| --- | --- | --- | --- | +| `publishIdentificationConfig` | Admin | `deviceId`、`parameters` | 校验辨识参数并为 `/identification_config/identification_config.csv` 签发上传凭证。 | +| `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID` 和 `url`。 | +| `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 | +| `getVolumeConfigFile` | 无 | 无 | 返回已发布容积 JSON 的 `fileID`、`cloudPath`、`url`。 | +| `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters`、`version`、`updateTime`。 | + +发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。 + +## 辨识结果与 Panel 收件箱 + +| type | 鉴权 | 请求字段 | 功能与响应要点 | +| --- | --- | --- | --- | +| `registerIdentificationResult` | 无 | `deviceId`、`runId`、可选 `fileName` | ReinLoop 登记待审核辨识结果。 | +| `getIdentificationFeedback` | 无 | `deviceId`、`runId` | 未就绪时 `ready: false`;就绪时返回 `ready: true` 和 `result`(`0` 或 `1`)。 | +| `setIdentificationFeedback` | Admin | `deviceId`、可选 `runId`、`result` | Panel 提交辨识审核结果,`result` 必须为数字 `0` 或 `1`。 | +| `ackIdentificationFeedback` | 无 | `deviceId`、`runId` | ReinLoop 消费后清理反馈。 | +| `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 | +| `ackPanelFile` | Admin | `deviceId`、`fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 | +| `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType`、`status`、`page`、`pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 | +| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的短期签名下载 URL。 | +| `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 | + +CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。 +已处理文件默认保留 30 天,从 `processedAt` 开始计算;待处理文件不会被 TTL 清理。 +保留时长和清理周期分别由 `IDENTIFICATION_RETENTION_MS`、`IDENTIFICATION_PURGE_INTERVAL_MS` 配置。 + +## 容积配置请求 + +| type | 鉴权 | 请求字段 | 功能与响应要点 | +| --- | --- | --- | --- | +| `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId`、`createdAtMs`、`expiresAtMs`。同设备旧请求会被替换。 | +| `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 | +| `submitVolumeConfigFile` | 无 | `deviceId`、`requestId`、`fileID`、可选 `fileName` | 将已上传到 `/volume_config_requests//` 的文件绑定至请求。 | +| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含下载 `url`。 | +| `ackVolumeConfigRequest` | 无 | `deviceId`、`requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 | + +请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。 diff --git a/server/migrations/001_normalized_schema.sql b/server/migrations/001_normalized_schema.sql new file mode 100644 index 0000000..e095336 --- /dev/null +++ b/server/migrations/001_normalized_schema.sql @@ -0,0 +1,105 @@ +CREATE TABLE IF NOT EXISTS companies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + code TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS production_lines ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL REFERENCES companies(id), + name TEXT NOT NULL, + code TEXT NOT NULL, + device_id TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ, + UNIQUE (company_id, code) +); + +ALTER TABLE production_lines ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS production_lines_last_seen_at_idx ON production_lines (last_seen_at); + +CREATE TABLE IF NOT EXISTS licenses ( + license_id UUID PRIMARY KEY, + company_id TEXT NOT NULL REFERENCES companies(id), + production_line_id TEXT NOT NULL REFERENCES production_lines(id), + device_id TEXT NOT NULL, + customer TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expiry_at TIMESTAMPTZ NOT NULL, + features TEXT NOT NULL, + license TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + revocation_reason TEXT +); + +CREATE TABLE IF NOT EXISTS file_records ( + id TEXT PRIMARY KEY, + file_name TEXT NOT NULL, + original_file_name TEXT, + folder TEXT NOT NULL, + cloud_path TEXT NOT NULL UNIQUE, + file_id TEXT NOT NULL UNIQUE, + upload_time TIMESTAMPTZ NOT NULL, + size_bytes BIGINT NOT NULL +); + +ALTER TABLE file_records ADD COLUMN IF NOT EXISTS original_file_name TEXT; + +CREATE TABLE IF NOT EXISTS function_configs ( + config_type TEXT PRIMARY KEY, + parameters JSONB NOT NULL, + version BIGINT NOT NULL, + update_time TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS panel_inbox ( + file_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + upload_time TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS identification_files ( + file_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + upload_time TIMESTAMPTZ NOT NULL, + size_bytes BIGINT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'processed')), + processed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS identification_feedback ( + device_id TEXT NOT NULL, + run_id TEXT NOT NULL, + file_name TEXT NOT NULL, + status TEXT NOT NULL, + result SMALLINT, + update_time TIMESTAMPTZ NOT NULL, + PRIMARY KEY (device_id, run_id) +); + +CREATE TABLE IF NOT EXISTS volume_config_requests ( + device_id TEXT NOT NULL, + request_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + expires_at_ms BIGINT NOT NULL, + config_file_id TEXT, + config_file_name TEXT, + uploaded_at_ms BIGINT, + update_time TIMESTAMPTZ NOT NULL, + PRIMARY KEY (device_id, request_id) +); + +CREATE INDEX IF NOT EXISTS licenses_status_device_expiry_idx ON licenses (status, device_id, expiry_at); +CREATE INDEX IF NOT EXISTS file_records_folder_idx ON file_records (folder); +CREATE INDEX IF NOT EXISTS identification_files_device_upload_idx ON identification_files (device_id, upload_time DESC); +CREATE INDEX IF NOT EXISTS identification_files_expires_idx ON identification_files (expires_at) WHERE expires_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS identification_feedback_device_idx ON identification_feedback (device_id); \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..9e477fa --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1175 @@ +{ + "name": "reinloop-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reinloop-server", + "version": "1.0.0", + "dependencies": { + "express": "^5.1.0", + "multer": "^2.0.2", + "pg": "^8.22.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5dcacf5 --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "reinloop-server", + "version": "1.0.0", + "description": "ReinLoop 本地业务与文件服务", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "check": "node --check src/server.js && node --check src/app.js && node --check src/store.js && node --check src/postgres-store.js", + "migrate": "node -e \"const fs=require('node:fs'); const {Pool}=require('pg'); if(!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required'); (async()=>{const p=new Pool({connectionString:process.env.DATABASE_URL}); await p.query(fs.readFileSync('migrations/001_normalized_schema.sql','utf8')); await p.end();})().catch(error=>{console.error(error.message);process.exitCode=1})\"", + "test": "node --test" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "express": "^5.1.0", + "multer": "^2.0.2", + "pg": "^8.22.0" + } +} diff --git a/server/reinloop-server.service b/server/reinloop-server.service new file mode 100644 index 0000000..48a7d9d --- /dev/null +++ b/server/reinloop-server.service @@ -0,0 +1,22 @@ +[Unit] +Description=ReinLoop Node.js server +After=network.target + +[Service] +Type=simple +User=Epifnne +Group=Epifnne +WorkingDirectory=/ReinLoop/server +Environment=NODE_ENV=production +Environment=HOST=127.0.0.1 +Environment=PORT=3000 +Environment=PUBLIC_BASE_URL=https://ReinLoop.dominatedconvergence.com +EnvironmentFile=/ReinLoop/server/.env.production +ExecStart=/usr/bin/node /ReinLoop/server/src/server.js +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..053f6d1 --- /dev/null +++ b/server/src/app.js @@ -0,0 +1,997 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { randomUUID, constants, createHmac, timingSafeEqual, verify } = require("node:crypto"); +const express = require("express"); +const multer = require("multer"); + +const BASE_FOLDER = "ReinLoop_GUI"; +const VOLUME_REQUEST_TTL_MS = Number(process.env.VOLUME_REQUEST_TTL_MS || 300000); +const DOWNLOAD_TOKEN_TTL_MS = Number(process.env.DOWNLOAD_TOKEN_TTL_MS || 300000); +const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000); +const DEVICE_HEARTBEAT_TTL_MS = 30_000; +const CONFIG_SCHEMAS = { + volume: { + q_in_val: "number", dt: "number", xa_full: "number", p_max: "number", + fit_low: "number", fit_high: "number", T_delta: "number", num_runs: "integer" + }, + identification: { + q_in_val: "number", dt: "number", n_order: "integer", t_c: "number", + levels: "array", dead_area: "number", xa_full: "number", V_val: "number", + repeat: "integer" + } +}; + +function normalizeRelativePath(value, fallback = "") { + const normalized = String(value || fallback).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); + if (!normalized || normalized.split("/").some((part) => !part || part === "." || part === "..")) { + throw new Error("目录格式无效"); + } + return normalized; +} + +function normalizeFileName(value) { + const fileName = path.basename(String(value || "").trim()); + if (!fileName || fileName === "." || fileName === "..") throw new Error("缺少有效的 fileName"); + return fileName; +} + +function normalizeDeviceId(value) { + if (typeof value !== "string") throw new Error("缺少有效的 deviceId"); + const deviceId = value.trim().replace(/\\/g, "/"); + const parts = deviceId.split("/"); + if (!deviceId || parts.length !== 2 || parts.some((part) => !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(part))) { + throw new Error("deviceId 格式无效"); + } + return deviceId; +} + +function controlDataDeviceId(folder) { + const parts = String(folder || "").split("/"); + if (parts.length < 3 || parts[2] !== "data_record") return null; + return normalizeDeviceId(parts.slice(0, 2).join("/")); +} + +function isControlDataRecord(record, deviceId) { + if (!record || typeof record.folder !== "string") return false; + const prefix = `${deviceId}/data_record`; + return record.folder === prefix || record.folder.startsWith(`${prefix}/`); +} + +function parseLicenseTimestamp(value, fieldName) { + const text = String(value || "").trim(); + const matched = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(text); + if (!matched) throw new Error(`${fieldName} 必须是 YYYY-MM-DD HH:MM 格式`); + const [, year, month, day, hour, minute] = matched.map(Number); + const wallClock = new Date(Date.UTC(year, month - 1, day, hour, minute)); + if ( + wallClock.getUTCFullYear() !== year || wallClock.getUTCMonth() !== month - 1 || + wallClock.getUTCDate() !== day || wallClock.getUTCHours() !== hour || + wallClock.getUTCMinutes() !== minute + ) { + throw new Error(`${fieldName} 无效`); + } + const utcMs = Date.UTC(year, month - 1, day, hour - 8, minute); + return new Date(utcMs); +} + +function parseSignedLicense(content, publicKeyPath) { + if (!publicKeyPath) throw new Error("服务器未配置 LICENSE_PUBLIC_KEY_PATH"); + const parts = String(content || "").split("|"); + if (parts.length !== 2 || !parts.every(Boolean)) throw new Error("许可证格式无效"); + const [payloadBase64, signatureBase64] = parts; + let payload; + let signature; + try { + payload = JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8")); + signature = Buffer.from(signatureBase64, "base64"); + } catch { + throw new Error("许可证内容解析失败"); + } + const publicKey = fs.readFileSync(publicKeyPath); + const valid = verify("sha256", Buffer.from(payloadBase64), { + key: publicKey, + padding: constants.RSA_PKCS1_PSS_PADDING, + saltLength: constants.RSA_PSS_SALTLEN_AUTO + }, signature); + if (!valid) throw new Error("许可证签名验证失败"); + return payload; +} + +function licenseContentsMatch(record, event) { + return ["companyId", "productionLineId", "customer", "issued", "expiry", "features", "license"] + .every((field) => record[field] === event[field]); +} + +function normalizeCode(value, fieldName) { + const code = String(value || "").trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_-]{1,63}$/.test(code)) { + throw new Error(`${fieldName}只能包含 2-64 位小写字母、数字、下划线或连字符`); + } + return code; +} + +function publicLicense(record, includeContent = false) { + const result = { ...record }; + if (!includeContent) delete result.license; + return result; +} + +function validateConfigParameters(configType, parameters) { + const schema = CONFIG_SCHEMAS[configType]; + if (!schema) return `未知 configType: ${configType}`; + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return "parameters 必须是对象"; + const expected = Object.keys(schema); + const actual = Object.keys(parameters); + const missing = expected.filter((field) => !actual.includes(field)); + const extra = actual.filter((field) => !expected.includes(field)); + if (missing.length) return `缺少字段: ${missing.join(", ")}`; + if (extra.length) return `包含不允许的字段: ${extra.join(", ")}`; + for (const [field, type] of Object.entries(schema)) { + const value = parameters[field]; + if (type === "array" && (!Array.isArray(value) || !value.length)) return `${field} 必须是非空数组`; + if (type === "number" && !Number.isFinite(value)) return `${field} 必须是数字`; + if (type === "integer" && !Number.isInteger(value)) return `${field} 必须是整数`; + } + if (configType === "identification") { + const { levels } = parameters; + if (levels.length < 2 || (levels.length & (levels.length - 1)) !== 0) { + return "levels 长度必须是大于等于 2 的 2 的整数次幂"; + } + if (levels.some((value) => !Number.isFinite(value) || value < 0 || value > 100)) { + return "levels 中的开度必须是 0 到 100 的有限数字"; + } + if (parameters.q_in_val < 0) return "q_in_val 不能小于 0"; + if (parameters.dt <= 0 || parameters.t_c < parameters.dt) return "必须满足 0 < dt <= t_c"; + if (parameters.n_order < 2) return "n_order 必须大于等于 2"; + if (parameters.repeat <= 0) return "repeat 必须是正整数"; + if (parameters.dead_area < 0 || parameters.xa_full <= parameters.dead_area) { + return "必须满足 0 <= dead_area < xa_full"; + } + if (parameters.xa_full < 1000) return "xa_full 不能小于 1000"; + if (parameters.V_val <= 0) return "V_val 必须大于 0"; + } + return null; +} + +function createApp({ + store, + adminToken = process.env.B_ADMIN_TOKEN || "dev-admin-token", + licensePublicKeyPath = process.env.LICENSE_PUBLIC_KEY_PATH +}) { + const app = express(); + const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } }); + const pendingUploads = new Map(); + + app.disable("x-powered-by"); + app.use(express.json({ limit: "2mb" })); + + function publicBaseUrl(req) { + return (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get("host")}`).replace(/\/$/, ""); + } + + function requireAdmin(event) { + if (!adminToken) return "服务器未配置 B_ADMIN_TOKEN"; + return event.adminToken === adminToken ? null : "B端管理令牌无效"; + } + + function signDownload(fileID, expiresAtMs) { + return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex"); + } + + function downloadUrl(req, fileID) { + const baseUrl = `${publicBaseUrl(req)}/files/${encodeURIComponent(fileID)}`; + if (fileID.startsWith("model://")) return baseUrl; + const expires = Date.now() + DOWNLOAD_TOKEN_TTL_MS; + return `${baseUrl}?expires=${expires}&token=${signDownload(fileID, expires)}`; + } + + function hasValidDownloadToken(req, fileID) { + const expires = Number(req.query.expires); + const token = String(req.query.token || ""); + if (!Number.isSafeInteger(expires) || expires < Date.now() || !/^[0-9a-f]{64}$/.test(token)) return false; + const expected = signDownload(fileID, expires); + return timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex")); + } + +//config test----------------------------------- + function logReceivedConfig({ configType, deviceId, requestId, record, config }) { + console.info("[config] received", { + configType, + deviceId, + requestId: requestId || null, + fileID: record.fileID, + cloudPath: record.cloudPath, + storagePath: store.resolveStoredFile(record.fileID), + config + }); + } + + function logConfigRead({ configType, deviceId, requestId, record }) { + console.info("[config] read", { + configType, + deviceId, + requestId: requestId || null, + fileID: record.fileID, + cloudPath: record.cloudPath, + storagePath: store.resolveStoredFile(record.fileID) + }); + } +//end config test------------------------------------ + + async function issueUpload(event, req) { + const originalFileName = normalizeFileName(event.fileName); + const folder = normalizeRelativePath(event.folder, "data_record"); + const folderParts = folder.split("/"); + if (folderParts.includes("data_record")) { + const deviceId = controlDataDeviceId(folder); + if (!deviceId) { + return { success: false, errMsg: "控制数据目录必须为 /data_record/ 的子目录" }; + } + } + const isModel = folderParts.at(-1) === "model_config"; + const fileName = isModel && event.modelName + ? normalizeFileName(event.modelName) + : originalFileName; + if (isModel && path.extname(fileName).toLowerCase() !== path.extname(originalFileName).toLowerCase()) { + return { success: false, errMsg: "重命名后的模型扩展名必须与原文件一致" }; + } + const modelDeviceId = isModel + ? normalizeDeviceId(folderParts.slice(0, -1).join("/")) + : null; + const cloudPath = isModel + ? `${modelDeviceId}/${fileName}` + : `${BASE_FOLDER}/${folder}/${fileName}`; + const fileID = `${isModel ? "model" : "local"}://${cloudPath}`; + if (isModel && event.overwrite !== true) { + const database = await store.read(); + const existing = database.fileRecords.find((record) => record.cloudPath === cloudPath); + if (existing) { + return { + success: false, + conflict: true, + errMsg: "同名模型已存在", + existing: { + ...existing, + originalFileName: existing.originalFileName || existing.fileName + } + }; + } + } + const token = randomUUID(); + pendingUploads.set(token, { + fileName, + originalFileName: isModel ? originalFileName : undefined, + folder, + cloudPath, + fileID, + configType: event.configType, + parameters: event.parameters, + expiresAt: Date.now() + 10 * 60 * 1000 + }); + return { + code: 200, + success: true, + uploadMetadata: { + url: `${publicBaseUrl(req)}/upload/${token}`, + token, + authorization: token, + cosFileId: cloudPath, + fileId: fileID + }, + fileID, + cloudPath, + fileName, + originalFileName: isModel ? originalFileName : undefined + }; + } + + async function removeFile(database, fileID) { + const filePath = store.resolveStoredFile(fileID); + if (filePath) await fs.promises.rm(filePath, { force: true }); + const before = database.fileRecords.length; + database.fileRecords = database.fileRecords.filter((record) => record.fileID !== fileID); + database.panelInbox = database.panelInbox.filter((record) => record.fileID !== fileID); + database.identificationFiles = database.identificationFiles.filter((record) => record.fileID !== fileID); + return before - database.fileRecords.length; + } + + function backfillIdentificationFiles(database) { + for (const record of database.fileRecords) { + if (!record.folder.endsWith("/ind_data") || ![".csv", ".json"].includes(path.extname(record.fileName).toLowerCase())) continue; + if (database.identificationFiles.some((item) => item.fileID === record.fileID)) continue; + const deviceId = normalizeDeviceId(record.folder.slice(0, -"/ind_data".length)); + const pending = database.panelInbox.some((item) => item.fileID === record.fileID); + database.identificationFiles.push({ + fileID: record.fileID, + deviceId, + fileName: record.fileName, + mediaType: path.extname(record.fileName).slice(1).toLowerCase(), + uploadTime: record.uploadTime, + size: record.size, + status: pending ? "pending" : "processed", + processedAt: pending ? null : record.uploadTime, + expiresAt: pending ? null : new Date(Date.parse(record.uploadTime) + IDENTIFICATION_RETENTION_MS).toISOString() + }); + } + } + + async function purgeExpiredIdentificationFiles() { + return store.update(async (database) => { + backfillIdentificationFiles(database); + const now = Date.now(); + const expired = database.identificationFiles.filter((record) => + record.status === "processed" && record.expiresAt && Date.parse(record.expiresAt) <= now + ); + for (const record of expired) await removeFile(database, record.fileID); + return expired.length; + }); + } + + app.locals.purgeExpiredIdentificationFiles = purgeExpiredIdentificationFiles; + + async function dispatch(event, req) { + switch (event.type) { + case "listOrganizations": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const database = await store.read(); + const now = Date.now(); + const companies = database.companies.map((company) => ({ + ...company, + productionLines: database.productionLines + .filter((line) => line.companyId === company.id) + .map((line) => ({ + ...line, + online: Boolean(line.lastSeenAt && now - Date.parse(line.lastSeenAt) <= DEVICE_HEARTBEAT_TTL_MS), + lastSeenAt: line.lastSeenAt || null + })) + })); + return { success: true, companies }; + } + case "deviceHeartbeat": { + const deviceId = normalizeDeviceId(event.deviceId); + return store.update((database) => { + const line = database.productionLines.find((item) => item.deviceId === deviceId); + if (!line) return { success: false, errMsg: "设备未注册" }; + line.lastSeenAt = new Date().toISOString(); + return { success: true, deviceId, lastSeenAt: line.lastSeenAt }; + }); + } + case "createCompany": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const name = String(event.name || "").trim(); + if (!name) return { success: false, errMsg: "公司名称不能为空" }; + const code = normalizeCode(event.code, "公司编码"); + return store.update((database) => { + if (database.companies.some((item) => item.code === code)) { + return { success: false, errMsg: `公司编码 ${code} 已存在` }; + } + const company = { + id: store.createId("company"), name, code, + createdAt: new Date().toISOString() + }; + database.companies.push(company); + return { success: true, company }; + }); + } + case "createProductionLine": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const name = String(event.name || "").trim(); + if (!name) return { success: false, errMsg: "产线名称不能为空" }; + const code = normalizeCode(event.code, "产线编码"); + return store.update((database) => { + const company = database.companies.find((item) => item.id === event.companyId); + if (!company) return { success: false, errMsg: "公司不存在" }; + if (database.productionLines.some((item) => item.companyId === company.id && item.code === code)) { + return { success: false, errMsg: `该公司下产线编码 ${code} 已存在` }; + } + const productionLine = { + id: store.createId("line"), companyId: company.id, name, code, + deviceId: `${company.code}/${code}`, + createdAt: new Date().toISOString(), lastSeenAt: null + }; + database.productionLines.push(productionLine); + return { success: true, productionLine }; + }); + } + case "createLicense": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const required = ["licenseId", "companyId", "productionLineId", "customer", "issued", "expiry", "license"]; + const missing = required.filter((field) => !String(event[field] || "").trim()); + if (missing.length) return { success: false, errMsg: `缺少必填字段: ${missing.join(", ")}` }; + const licenseId = String(event.licenseId).trim(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(licenseId)) { + return { success: false, errMsg: "licenseId 必须是 UUID" }; + } + const issuedAt = parseLicenseTimestamp(event.issued, "issued"); + const expiryAt = parseLicenseTimestamp(event.expiry, "expiry"); + if (expiryAt <= issuedAt) return { success: false, errMsg: "expiry 必须晚于 issued" }; + const payload = parseSignedLicense(event.license, licensePublicKeyPath); + const signedFields = { + license_id: licenseId, + company_id: String(event.companyId), + production_line_id: String(event.productionLineId), + customer: String(event.customer), + issued: String(event.issued), + expiry: String(event.expiry), + features: String(event.features || "*") + }; + if (Object.entries(signedFields).some(([field, value]) => payload[field] !== value)) { + return { success: false, errMsg: "许可证载荷与请求字段不一致" }; + } + return store.update((database) => { + const company = database.companies.find((item) => item.id === event.companyId); + const line = database.productionLines.find((item) => item.id === event.productionLineId && item.companyId === event.companyId); + if (!company || !line) return { success: false, errMsg: "公司或产线不存在" }; + if (payload.device_id !== line.deviceId) return { success: false, errMsg: "许可证 device_id 与产线不匹配" }; + const existing = database.licenses.find((item) => item.licenseId === licenseId); + if (existing) { + return licenseContentsMatch(existing, { ...event, features: event.features || "*" }) + ? { success: true, license: publicLicense(existing), idempotent: true } + : { success: false, conflict: true, errMsg: "licenseId 已存在且内容不同" }; + } + const record = { + licenseId, companyId: company.id, + productionLineId: line.id, companyName: company.name, + productionLineName: line.name, deviceId: line.deviceId, + customer: String(event.customer), issued: String(event.issued), + expiry: String(event.expiry), issuedAt: issuedAt.toISOString(), + expiryAt: expiryAt.toISOString(), features: event.features || "*", + license: String(event.license), status: "active", + createdAt: new Date().toISOString(), revokedAt: null, + revocationReason: null + }; + database.licenses.push(record); + return { success: true, license: publicLicense(record) }; + }); + } + case "listLicenses": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const database = await store.read(); + return { + success: true, + licenses: database.licenses.map((record) => publicLicense(record)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + }; + } + case "getLicense": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const database = await store.read(); + const record = database.licenses.find((item) => item.licenseId === event.licenseId); + return record + ? { success: true, license: publicLicense(record, true) } + : { success: false, errMsg: "许可证不存在" }; + } + case "revokeLicense": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + return store.update((database) => { + const record = database.licenses.find((item) => item.licenseId === event.licenseId); + if (!record) return { success: false, errMsg: "许可证不存在" }; + record.status = "revoked"; + record.revokedAt = new Date().toISOString(); + record.revocationReason = String(event.reason || "管理员撤销").trim(); + return { success: true, license: publicLicense(record) }; + }); + } + case "validateLicense": { + const database = await store.read(); + const record = database.licenses.find((item) => item.licenseId === event.licenseId); + if (!record) return { success: true, valid: false, status: "not_found" }; + if (event.deviceId && normalizeDeviceId(event.deviceId) !== record.deviceId) { + return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId }; + } + if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) { + return { success: true, valid: false, status: "expired", licenseId: record.licenseId }; + } + return { + success: true, valid: record.status === "active", + status: record.status, licenseId: record.licenseId + }; + } + case "uploadDataFile": + if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + } + return issueUpload(event, req); + case "issueModelUpload": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + return issueUpload({ ...event, folder: `${deviceId}/model_config` }, req); + } + case "listModels": { + const folder = normalizeRelativePath(event.folder, "model_config"); + const database = await store.read(); + const fileList = database.fileRecords + .filter((record) => record.folder === folder) + .sort((left, right) => right.uploadTime.localeCompare(left.uploadTime)) + .slice(0, 100); + return { + success: true, + files: fileList.map((record) => record.fileName), + fileList: fileList.map((record) => ({ + ...record, + originalFileName: record.originalFileName || record.fileName + })) + }; + } + case "downloadModel": { + const database = await store.read(); + const record = database.fileRecords.find((item) => item.fileID === event.fileID); + if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" }; + if (!record.fileID.startsWith("model://")) { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + } + return { + success: true, + url: downloadUrl(req, record.fileID), + fileName: record.fileName, + originalFileName: record.originalFileName || record.fileName + }; + } + case "deleteFile": + case "deleteModel": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + return store.update(async (database) => { + let records = event.fileID + ? database.fileRecords.filter((item) => item.fileID === event.fileID) + : database.fileRecords.filter((item) => + item.folder === normalizeRelativePath(event.folder, "model_config") && + item.fileName === normalizeFileName(event.fileName) + ); + if (!event.fileID && records.length > 1) { + return { success: false, ambiguous: true, errMsg: `发现 ${records.length} 个同名文件,请用 fileID 精确指定`, candidates: records }; + } + if (!records.length) return { success: false, errMsg: "数据库中未找到对应记录" }; + let deletedCount = 0; + for (const record of records) deletedCount += await removeFile(database, record.fileID); + return { success: true, deletedFileID: records.map((record) => record.fileID).join(", "), deletedCount }; + }); + } + case "publishIdentificationConfig": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + const validationError = validateConfigParameters("identification", event.parameters); + if (validationError) return { success: false, errMsg: validationError }; + return issueUpload({ + fileName: "identification_config.csv", + folder: `${deviceId}/identification_config` + }, req); + } + case "publishVolumeConfig": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const validationError = validateConfigParameters("volume", event.parameters); + if (validationError) return { success: false, errMsg: validationError }; + const result = await issueUpload({ + fileName: "volume_config.json", + folder: process.env.VOLUME_CONFIG_FOLDER || "volume_config", + configType: "volume", + parameters: event.parameters + }, req); + return { ...result, parameters: event.parameters }; + } + case "getVolumeConfigFile": { + const folder = process.env.VOLUME_CONFIG_FOLDER || "volume_config"; + const fileID = `local://${BASE_FOLDER}/${folder}/volume_config.json`; + const database = await store.read(); + if (!database.fileRecords.some((record) => record.fileID === fileID)) { + return { success: false, notFound: true, errMsg: "容积配置文件尚未发布" }; + } + return { success: true, fileID, cloudPath: `${BASE_FOLDER}/${folder}/volume_config.json`, url: downloadUrl(req, fileID) }; + } + case "getFunctionConfig": { + if (event.configType !== "volume") return { success: false, errMsg: `未知 configType: ${event.configType}` }; + const database = await store.read(); + const config = database.functionConfigs.find((item) => item.configType === event.configType); + return config ? { success: true, ...config } : { success: false, notFound: true, errMsg: "参数配置尚未发布" }; + } + case "getIdentificationConfig": { + const deviceId = normalizeDeviceId(event.deviceId); + const database = await store.read(); + const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv"); + if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" }; + logConfigRead({ configType: "identification", deviceId, record }); + return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: downloadUrl(req, record.fileID) }; + } + case "getPendingPanelFile": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + const database = await store.read(); + const message = database.panelInbox.find((item) => { + if (item.deviceId !== deviceId) return false; + if (item.mediaType !== "csv") return true; + return database.identificationFeedback.some((feedback) => + feedback.deviceId === deviceId && feedback.runId === item.fileName + ); + }); + if (!message) return { success: true, pending: false }; + return { + success: true, + pending: true, + fileID: message.fileID, + fileName: message.fileName, + mediaType: message.mediaType, + uploadTime: message.uploadTime, + url: downloadUrl(req, message.fileID) + }; + } + case "ackPanelFile": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.fileID) return { success: false, errMsg: "缺少 fileID" }; + return store.update((database) => { + backfillIdentificationFiles(database); + const messages = database.panelInbox.filter((item) => + item.deviceId === deviceId && item.fileID === String(event.fileID) + ); + const historyRecord = database.identificationFiles.find((item) => + item.deviceId === deviceId && item.fileID === String(event.fileID) + ); + if (!messages.length && !historyRecord) return { success: false, errMsg: "辨识文件不存在" }; + const processedAt = new Date().toISOString(); + if (historyRecord) { + historyRecord.status = "processed"; + historyRecord.processedAt = processedAt; + historyRecord.expiresAt = new Date(Date.now() + IDENTIFICATION_RETENTION_MS).toISOString(); + } + const before = database.panelInbox.length; + database.panelInbox = database.panelInbox.filter((item) => + !(item.deviceId === deviceId && item.fileID === String(event.fileID)) + ); + return { success: true, processed: true, deleted: before - database.panelInbox.length, processedAt }; + }); + } + case "listIdentificationFiles": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + await purgeExpiredIdentificationFiles(); + const database = await store.read(); + const page = Math.max(1, Number.parseInt(event.page, 10) || 1); + const pageSize = Math.min(100, Math.max(1, Number.parseInt(event.pageSize, 10) || 20)); + const files = database.identificationFiles + .filter((record) => record.deviceId === deviceId) + .filter((record) => !event.mediaType || record.mediaType === event.mediaType) + .filter((record) => !event.status || record.status === event.status) + .sort((left, right) => right.uploadTime.localeCompare(left.uploadTime)); + const offset = (page - 1) * pageSize; + return { + success: true, + files: files.slice(offset, offset + pageSize), + total: files.length, + page, + pageSize + }; + } + case "listControlFiles": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + const page = Math.max(1, Number.parseInt(event.page, 10) || 1); + const pageSize = Math.min(100, Math.max(1, Number.parseInt(event.pageSize, 10) || 20)); + const database = await store.read(); + const files = database.fileRecords + .filter((record) => isControlDataRecord(record, deviceId)) + .sort((left, right) => right.uploadTime.localeCompare(left.uploadTime)); + const offset = (page - 1) * pageSize; + return { + success: true, + files: files.slice(offset, offset + pageSize).map((record) => ({ + fileID: record.fileID, + fileName: record.fileName, + uploadTime: record.uploadTime, + size: record.size + })), + total: files.length, + page, + pageSize + }; + } + case "getControlFileDownload": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + if (!event.fileID) return { success: false, errMsg: "缺少 fileID" }; + const database = await store.read(); + const record = database.fileRecords.find((item) => item.fileID === String(event.fileID)); + if (!record || !controlDataDeviceId(record.folder) || !store.resolveStoredFile(record.fileID)) { + return { success: false, errMsg: "控制数据文件不存在或不属于控制数据目录" }; + } + try { + await fs.promises.access(store.resolveStoredFile(record.fileID)); + } catch { + return { success: false, errMsg: "控制数据文件不存在" }; + } + return { + success: true, + fileID: record.fileID, + fileName: record.fileName, + uploadTime: record.uploadTime, + size: record.size, + url: downloadUrl(req, record.fileID) + }; + } + case "deleteControlFile": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + if (!event.fileID) return { success: false, errMsg: "缺少 fileID" }; + return store.update(async (database) => { + const record = database.fileRecords.find((item) => item.fileID === String(event.fileID)); + if (!record || !controlDataDeviceId(record.folder)) { + return { success: false, errMsg: "控制数据文件不存在或不属于控制数据目录" }; + } + const deletedCount = await removeFile(database, record.fileID); + return deletedCount + ? { success: true, deletedCount } + : { success: false, errMsg: "控制数据文件不存在" }; + }); + } + case "getIdentificationFileDownload": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const database = await store.read(); + const historyRecord = database.identificationFiles.find((item) => item.fileID === event.fileID); + const fileRecord = database.fileRecords.find((item) => item.fileID === event.fileID); + if (!historyRecord || !fileRecord || !store.resolveStoredFile(fileRecord.fileID)) { + return { success: false, errMsg: "辨识文件不存在" }; + } + return { + success: true, + fileID: fileRecord.fileID, + fileName: fileRecord.fileName, + mediaType: historyRecord.mediaType, + url: downloadUrl(req, fileRecord.fileID) + }; + } + case "deleteIdentificationFile": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + if (!event.fileID) return { success: false, errMsg: "缺少 fileID" }; + return store.update(async (database) => { + if (!database.identificationFiles.some((item) => item.fileID === event.fileID)) { + return { success: false, errMsg: "辨识文件不存在" }; + } + const deletedCount = await removeFile(database, event.fileID); + return { success: true, deletedCount }; + }); + } + case "registerIdentificationResult": { + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" }; + return store.update((database) => { + const record = { deviceId, runId: String(event.runId), fileName: event.fileName || String(event.runId), status: "waiting_feedback", result: null, updateTime: new Date().toISOString() }; + database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId); + database.identificationFeedback.push(record); + return { success: true, runId: record.runId, status: record.status }; + }); + } + case "getIdentificationFeedback": { + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" }; + const database = await store.read(); + const record = database.identificationFeedback.find((item) => item.deviceId === deviceId && item.runId === String(event.runId)); + if (!record || record.status !== "feedback_ready") return { success: true, ready: false }; + return { success: true, ready: true, result: record.result, runId: record.runId }; + } + case "setIdentificationFeedback": { + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; + const deviceId = normalizeDeviceId(event.deviceId); + if (event.result !== 0 && event.result !== 1) return { success: false, errMsg: "result 必须是数字 0/1" }; + return store.update((database) => { + const record = database.identificationFeedback.find((item) => item.deviceId === deviceId); + if (!record) return { success: false, errMsg: "当前没有待审核的辨识结果" }; + if (event.runId && String(event.runId) !== record.runId) return { success: false, errMsg: "runId 与当前待审核结果不一致" }; + record.status = "feedback_ready"; + record.result = event.result; + record.updateTime = new Date().toISOString(); + return { success: true, runId: record.runId, fileName: record.fileName, result: record.result }; + }); + } + case "ackIdentificationFeedback": { + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" }; + return store.update((database) => { + const before = database.identificationFeedback.length; + database.identificationFeedback = database.identificationFeedback.filter((item) => !(item.deviceId === deviceId && item.runId === String(event.runId))); + return { success: true, deleted: before - database.identificationFeedback.length }; + }); + } + case "createVolumeConfigRequest": { + const deviceId = normalizeDeviceId(event.deviceId); + return store.update(async (database) => { + const previous = database.volumeConfigRequests.filter((item) => item.deviceId === deviceId); + for (const record of previous) if (record.configFileID) await removeFile(database, record.configFileID); + database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId); + const createdAtMs = Date.now(); + const record = { deviceId, requestId: `${createdAtMs}-${randomUUID().slice(0, 10)}`, status: "waiting_upload", createdAtMs, expiresAtMs: createdAtMs + VOLUME_REQUEST_TTL_MS, configFileID: null, configFileName: null, uploadedAtMs: null, updateTime: new Date().toISOString() }; + database.volumeConfigRequests.push(record); + return { success: true, requestId: record.requestId, createdAtMs, expiresAtMs: record.expiresAtMs }; + }); + } + case "getPendingVolumeConfigRequest": { + const deviceId = normalizeDeviceId(event.deviceId); + return store.update(async (database) => { + const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.status === "waiting_upload"); + if (!record) return { success: true, pending: false }; + if (Date.now() > record.expiresAtMs) { + database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item !== record); + return { success: true, pending: false, expired: true }; + } + return { success: true, pending: true, requestId: record.requestId, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs }; + }); + } + case "submitVolumeConfigFile": { + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.requestId || !event.fileID) return { success: false, errMsg: "缺少 deviceId、requestId 或 fileID" }; + return store.update((database) => { + const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.requestId === String(event.requestId)); + if (!record) return { success: false, errMsg: "容积参数请求不存在" }; + if (record.status !== "waiting_upload" || Date.now() > record.expiresAtMs) return { success: false, errMsg: "容积参数请求已失效" }; + const fileName = event.fileName || "volume_measurement.json"; + const expectedFolder = `${deviceId}/volume_config_requests/${record.requestId}`; + const fileRecord = database.fileRecords.find((item) => item.fileID === String(event.fileID) && item.folder === expectedFolder && item.fileName === fileName); + if (!fileRecord) return { success: false, errMsg: "上传文件不属于本次容积参数请求" }; + record.status = "ready"; + record.configFileID = fileRecord.fileID; + record.configFileName = fileName; + record.uploadedAtMs = Date.now(); + record.updateTime = new Date().toISOString(); + return { success: true, requestId: record.requestId, uploadedAtMs: record.uploadedAtMs }; + }); + } + case "getVolumeConfigRequest": { + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.requestId) return { success: false, errMsg: "缺少 deviceId 或 requestId" }; + const database = await store.read(); + const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.requestId === String(event.requestId)); + if (!record || Date.now() > record.expiresAtMs) return { success: true, ready: false, expired: true }; + if (record.status !== "ready") return { success: true, ready: false, expired: false }; + const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID); + if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" }; + logConfigRead({ configType: "volume", deviceId, requestId: record.requestId, record: fileRecord }); + return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: downloadUrl(req, record.configFileID) }; + } + case "ackVolumeConfigRequest": { + const deviceId = normalizeDeviceId(event.deviceId); + if (!event.requestId) return { success: false, errMsg: "缺少 deviceId 或 requestId" }; + return store.update(async (database) => { + const records = database.volumeConfigRequests.filter((item) => item.deviceId === deviceId && item.requestId === String(event.requestId)); + for (const record of records) if (record.configFileID) await removeFile(database, record.configFileID); + database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => !records.includes(item)); + return { success: true, deleted: records.length }; + }); + } + default: + return { success: false, errMsg: "无效的 type 字段" }; + } + } + + app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" })); + + app.post("/upload/:token", upload.single("file"), async (req, res, next) => { + try { + const pending = pendingUploads.get(req.params.token); + if (!pending || pending.expiresAt < Date.now()) return res.status(403).json({ success: false, errMsg: "上传凭证无效或已过期" }); + if (!req.file) return res.status(400).json({ success: false, errMsg: "缺少 file 表单字段" }); + const destination = store.resolveStoredFile(pending.fileID); + await fs.promises.mkdir(path.dirname(destination), { recursive: true }); + await fs.promises.writeFile(destination, req.file.buffer); + await store.update((database) => { + const record = { _id: store.createId("file"), fileName: pending.fileName, originalFileName: pending.originalFileName, folder: pending.folder, cloudPath: pending.cloudPath, fileID: pending.fileID, uploadTime: new Date().toISOString(), size: req.file.size }; + database.fileRecords = database.fileRecords.filter((item) => item.cloudPath !== pending.cloudPath); + database.fileRecords.push(record); + const folderParts = pending.folder.split("/"); + const extension = path.extname(pending.fileName).toLowerCase(); + if (folderParts.at(-1) === "ind_data" && [".csv", ".json"].includes(extension)) { + const deviceId = normalizeDeviceId(folderParts.slice(0, -1).join("/")); + const message = { + deviceId, + fileID: pending.fileID, + fileName: pending.fileName, + mediaType: extension.slice(1), + uploadTime: record.uploadTime + }; + database.panelInbox = database.panelInbox.filter((item) => item.fileID !== pending.fileID); + database.panelInbox.push(message); + database.identificationFiles = database.identificationFiles.filter((item) => item.fileID !== pending.fileID); + database.identificationFiles.push({ + ...message, + size: record.size, + status: "pending", + processedAt: null, + expiresAt: null + }); + } + if (pending.configType && pending.parameters) { + const config = { + configType: pending.configType, + parameters: pending.parameters, + version: Date.now(), + updateTime: new Date().toISOString() + }; + database.functionConfigs = database.functionConfigs.filter( + (item) => item.configType !== pending.configType + ); + database.functionConfigs.push(config); + } + }); + const folderParts = pending.folder.split("/"); + if (folderParts.at(-1) === "identification_config") { + logReceivedConfig({ + configType: "identification", + deviceId: folderParts.slice(0, -1).join("/"), + record: { fileID: pending.fileID, cloudPath: pending.cloudPath }, + config: req.file.buffer.toString("utf8").trim() + }); + } else if (folderParts.at(-2) === "volume_config_requests") { + let config; + try { + config = JSON.parse(req.file.buffer.toString("utf8")); + } catch { + config = ""; + } + logReceivedConfig({ + configType: "volume", + deviceId: folderParts.slice(0, -2).join("/"), + requestId: folderParts.at(-1), + record: { fileID: pending.fileID, cloudPath: pending.cloudPath }, + config + }); + } + pendingUploads.delete(req.params.token); + res.status(204).end(); + } catch (error) { + next(error); + } + }); + + app.get("/files/:fileID", async (req, res, next) => { + try { + const fileID = decodeURIComponent(req.params.fileID); + if (!fileID.startsWith("model://") && !hasValidDownloadToken(req, fileID)) { + return res.status(403).json({ success: false, errMsg: "下载凭证无效或已过期" }); + } + const database = await store.read(); + const record = database.fileRecords.find((item) => item.fileID === fileID); + const filePath = record && store.resolveStoredFile(fileID); + if (!record || !filePath) return res.status(404).json({ success: false, errMsg: "文件不存在" }); + await fs.promises.access(filePath); + res.download(filePath, record.fileName); + } catch (error) { + if (error.code === "ENOENT") return res.status(404).json({ success: false, errMsg: "文件不存在" }); + next(error); + } + }); + + const apiHandler = async (req, res) => { + try { + res.json(await dispatch(req.body || {}, req)); + } catch (error) { + res.status(400).json({ success: false, errMsg: error.message }); + } + }; + app.post("/", apiHandler); + app.post("/api", apiHandler); + + app.use((error, req, res, next) => { + console.error(error); + res.status(500).json({ success: false, errMsg: "服务器内部错误" }); + }); + + return app; +} + +module.exports = { createApp, validateConfigParameters }; \ No newline at end of file diff --git a/server/src/postgres-store.js b/server/src/postgres-store.js new file mode 100644 index 0000000..c2fdbbf --- /dev/null +++ b/server/src/postgres-store.js @@ -0,0 +1,110 @@ +const path = require("node:path"); +const fs = require("node:fs"); +const { randomUUID } = require("node:crypto"); +const { Pool } = require("pg"); +const { EMPTY_DATABASE } = require("./store"); + +function asIso(value) { + return value instanceof Date ? value.toISOString() : value; +} + +function formatShanghaiTimestamp(value) { + return new Intl.DateTimeFormat("sv-SE", { + timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", hourCycle: "h23" + }).format(new Date(value)); +} + +class PostgresStore { + constructor(connectionString, filesDirectory) { + this.pool = new Pool({ connectionString }); + this.filesDirectory = filesDirectory; + this.modelsDirectory = path.join(path.dirname(filesDirectory), "models"); + } + + async initialize() { + const migrationPath = path.join(__dirname, "..", "migrations", "001_normalized_schema.sql"); + await this.pool.query(await fs.promises.readFile(migrationPath, "utf8")); + } + + async read() { + return this.readDatabase(this.pool); + } + + async readDatabase(queryable) { + const companies = await queryable.query("SELECT id, name, code, created_at FROM companies"); + const productionLines = await queryable.query("SELECT id, company_id, name, code, device_id, created_at, last_seen_at FROM production_lines"); + const licenses = await queryable.query("SELECT licenses.license_id, licenses.company_id, licenses.production_line_id, licenses.device_id, licenses.customer, licenses.issued_at, licenses.expiry_at, licenses.features, licenses.license, licenses.status, licenses.created_at, licenses.revoked_at, licenses.revocation_reason, companies.name AS company_name, production_lines.name AS production_line_name FROM licenses JOIN companies ON companies.id = licenses.company_id JOIN production_lines ON production_lines.id = licenses.production_line_id"); + const fileRecords = await queryable.query("SELECT id, file_name, original_file_name, folder, cloud_path, file_id, upload_time, size_bytes FROM file_records"); + const functionConfigs = await queryable.query("SELECT config_type, parameters, version, update_time FROM function_configs"); + const panelInbox = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time FROM panel_inbox"); + const identificationFiles = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at FROM identification_files"); + const feedback = await queryable.query("SELECT device_id, run_id, file_name, status, result, update_time FROM identification_feedback"); + const requests = await queryable.query("SELECT device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time FROM volume_config_requests"); + return { + ...structuredClone(EMPTY_DATABASE), + companies: companies.rows.map((row) => ({ id: row.id, name: row.name, code: row.code, createdAt: asIso(row.created_at) })), + productionLines: productionLines.rows.map((row) => ({ id: row.id, companyId: row.company_id, name: row.name, code: row.code, deviceId: row.device_id, createdAt: asIso(row.created_at), lastSeenAt: row.last_seen_at && asIso(row.last_seen_at) })), + licenses: licenses.rows.map((row) => ({ licenseId: row.license_id, companyId: row.company_id, productionLineId: row.production_line_id, companyName: row.company_name, productionLineName: row.production_line_name, deviceId: row.device_id, customer: row.customer, issued: formatShanghaiTimestamp(row.issued_at), expiry: formatShanghaiTimestamp(row.expiry_at), issuedAt: asIso(row.issued_at), expiryAt: asIso(row.expiry_at), features: row.features, license: row.license, status: row.status, createdAt: asIso(row.created_at), revokedAt: row.revoked_at && asIso(row.revoked_at), revocationReason: row.revocation_reason })), + fileRecords: fileRecords.rows.map((row) => ({ _id: row.id, fileName: row.file_name, originalFileName: row.original_file_name || undefined, folder: row.folder, cloudPath: row.cloud_path, fileID: row.file_id, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes) })), + functionConfigs: functionConfigs.rows.map((row) => ({ configType: row.config_type, parameters: row.parameters, version: Number(row.version), updateTime: asIso(row.update_time) })), + panelInbox: panelInbox.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time) })), + identificationFiles: identificationFiles.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes), status: row.status, processedAt: row.processed_at && asIso(row.processed_at), expiresAt: row.expires_at && asIso(row.expires_at) })), + identificationFeedback: feedback.rows.map((row) => ({ deviceId: row.device_id, runId: row.run_id, fileName: row.file_name, status: row.status, result: row.result, updateTime: asIso(row.update_time) })), + volumeConfigRequests: requests.rows.map((row) => ({ deviceId: row.device_id, requestId: row.request_id, status: row.status, createdAtMs: Number(row.created_at_ms), expiresAtMs: Number(row.expires_at_ms), configFileID: row.config_file_id, configFileName: row.config_file_name, uploadedAtMs: row.uploaded_at_ms && Number(row.uploaded_at_ms), updateTime: asIso(row.update_time) })) + }; + } + + async update(mutator) { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock(81720260725)"); + const database = await this.readDatabase(client); + const response = await mutator(database); + await this.writeDatabase(client, database); + await client.query("COMMIT"); + return response; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async writeDatabase(client, database) { + await client.query("DELETE FROM volume_config_requests; DELETE FROM identification_feedback; DELETE FROM panel_inbox; DELETE FROM identification_files; DELETE FROM function_configs; DELETE FROM file_records; DELETE FROM licenses; DELETE FROM production_lines; DELETE FROM companies;"); + for (const item of database.companies) await client.query("INSERT INTO companies (id, name, code, created_at) VALUES ($1, $2, $3, $4)", [item.id, item.name, item.code, item.createdAt]); + for (const item of database.productionLines) await client.query("INSERT INTO production_lines (id, company_id, name, code, device_id, created_at, last_seen_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", [item.id, item.companyId, item.name, item.code, item.deviceId, item.createdAt, item.lastSeenAt]); + for (const item of database.licenses) await client.query("INSERT INTO licenses (license_id, company_id, production_line_id, device_id, customer, issued_at, expiry_at, features, license, status, created_at, revoked_at, revocation_reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", [item.licenseId, item.companyId, item.productionLineId, item.deviceId, item.customer, item.issuedAt, item.expiryAt, item.features, item.license, item.status, item.createdAt, item.revokedAt, item.revocationReason]); + for (const item of database.fileRecords) await client.query("INSERT INTO file_records (id, file_name, original_file_name, folder, cloud_path, file_id, upload_time, size_bytes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", [item._id, item.fileName, item.originalFileName || null, item.folder, item.cloudPath, item.fileID, item.uploadTime, item.size]); + for (const item of database.functionConfigs) await client.query("INSERT INTO function_configs (config_type, parameters, version, update_time) VALUES ($1,$2,$3,$4)", [item.configType, item.parameters, item.version, item.updateTime]); + for (const item of database.panelInbox) await client.query("INSERT INTO panel_inbox (file_id, device_id, file_name, media_type, upload_time) VALUES ($1,$2,$3,$4,$5)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime]); + for (const item of database.identificationFiles) await client.query("INSERT INTO identification_files (file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime, item.size, item.status, item.processedAt, item.expiresAt]); + for (const item of database.identificationFeedback) await client.query("INSERT INTO identification_feedback (device_id, run_id, file_name, status, result, update_time) VALUES ($1,$2,$3,$4,$5,$6)", [item.deviceId, item.runId, item.fileName, item.status, item.result, item.updateTime]); + for (const item of database.volumeConfigRequests) await client.query("INSERT INTO volume_config_requests (device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.deviceId, item.requestId, item.status, item.createdAtMs, item.expiresAtMs, item.configFileID, item.configFileName, item.uploadedAtMs, item.updateTime]); + } + + createId(prefix) { + return `${prefix}_${randomUUID()}`; + } + + resolveStoredFile(fileID) { + if (typeof fileID !== "string") return null; + const isModel = fileID.startsWith("model://"); + if (!isModel && !fileID.startsWith("local://")) return null; + const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory; + const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/"); + const absolutePath = path.resolve(rootDirectory, relativePath); + const relativeToRoot = path.relative(rootDirectory, absolutePath); + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null; + return absolutePath; + } + + async close() { + await this.pool.end(); + } +} + +module.exports = { PostgresStore }; \ No newline at end of file diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..fd35523 --- /dev/null +++ b/server/src/server.js @@ -0,0 +1,51 @@ +const path = require("node:path"); +const fs = require("node:fs"); +const { createApp } = require("./app"); +const { JsonStore } = require("./store"); +const { PostgresStore } = require("./postgres-store"); + +const host = process.env.HOST || "127.0.0.1"; +const port = Number(process.env.PORT || 3000); +const identificationPurgeIntervalMs = Number(process.env.IDENTIFICATION_PURGE_INTERVAL_MS || 60 * 60 * 1000); +const dataDirectory = path.resolve(process.env.DATA_DIR || path.join(__dirname, "..", "data")); + +async function main() { + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT 必须是有效端口号"); + const jsonDatabasePath = path.join(dataDirectory, "database.json"); + const hasExistingJsonStore = await fs.promises.access(jsonDatabasePath).then(() => true, () => false); + if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL && !hasExistingJsonStore) { + throw new Error("生产环境必须配置 DATABASE_URL"); + } + if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL) { + console.warn("警告: 正在使用既有 JSON 数据库;请尽快迁移到 PostgreSQL"); + } + const filesDirectory = path.join(dataDirectory, "files"); + await fs.promises.mkdir(filesDirectory, { recursive: true }); + const store = process.env.DATABASE_URL + ? new PostgresStore(process.env.DATABASE_URL, filesDirectory) + : new JsonStore(dataDirectory); + await store.initialize(); + const app = createApp({ store }); + const purgeIdentificationFiles = async () => { + try { + const purged = await app.locals.purgeExpiredIdentificationFiles(); + if (purged) console.info(`[retention] purged ${purged} expired identification file(s)`); + } catch (error) { + console.error("[retention] identification file purge failed", error); + } + }; + await purgeIdentificationFiles(); + const purgeTimer = setInterval(purgeIdentificationFiles, identificationPurgeIntervalMs); + purgeTimer.unref(); + app.listen(port, host, () => { + console.log(`ReinLoop server listening on http://${host}:${port}`); + console.log(`API endpoints: http://${host}:${port} and http://${host}:${port}/api`); + console.log(`Data directory: ${dataDirectory}`); + console.log(`Metadata store: ${process.env.DATABASE_URL ? "PostgreSQL" : "local JSON"}`); + }); +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/server/src/store.js b/server/src/store.js new file mode 100644 index 0000000..3f1cdfd --- /dev/null +++ b/server/src/store.js @@ -0,0 +1,80 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { randomUUID } = require("node:crypto"); + +const EMPTY_DATABASE = { + fileRecords: [], + functionConfigs: [], + panelInbox: [], + identificationFiles: [], + identificationFeedback: [], + volumeConfigRequests: [], + userInfo: [], + companies: [], + productionLines: [], + licenses: [] +}; + +class JsonStore { + constructor(dataDirectory) { + this.dataDirectory = dataDirectory; + this.filesDirectory = path.join(dataDirectory, "files"); + this.modelsDirectory = path.join(dataDirectory, "models"); + this.databasePath = path.join(dataDirectory, "database.json"); + this.writeQueue = Promise.resolve(); + } + + async initialize() { + await fs.promises.mkdir(this.filesDirectory, { recursive: true }); + try { + await fs.promises.access(this.databasePath); + } catch { + await this.writeDatabase(structuredClone(EMPTY_DATABASE)); + } + } + + async read() { + const content = await fs.promises.readFile(this.databasePath, "utf8"); + return { ...structuredClone(EMPTY_DATABASE), ...JSON.parse(content) }; + } + + async update(mutator) { + const operation = this.writeQueue.then(async () => { + const database = await this.read(); + const result = await mutator(database); + await this.writeDatabase(database); + return result; + }); + this.writeQueue = operation.catch(() => undefined); + return operation; + } + + async writeDatabase(database) { + await fs.promises.mkdir(this.dataDirectory, { recursive: true }); + const temporaryPath = `${this.databasePath}.${process.pid}.tmp`; + await fs.promises.writeFile( + temporaryPath, + `${JSON.stringify(database, null, 2)}\n`, + "utf8" + ); + await fs.promises.rename(temporaryPath, this.databasePath); + } + + createId(prefix) { + return `${prefix}_${randomUUID()}`; + } + + resolveStoredFile(fileID) { + if (typeof fileID !== "string") return null; + const isModel = fileID.startsWith("model://"); + if (!isModel && !fileID.startsWith("local://")) return null; + const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory; + const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/"); + const absolutePath = path.resolve(rootDirectory, relativePath); + const relativeToRoot = path.relative(rootDirectory, absolutePath); + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null; + return absolutePath; + } +} + +module.exports = { EMPTY_DATABASE, JsonStore }; \ No newline at end of file diff --git a/server/test/server.test.js b/server/test/server.test.js new file mode 100644 index 0000000..c8d4075 --- /dev/null +++ b/server/test/server.test.js @@ -0,0 +1,665 @@ +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { after, before, test } = require("node:test"); + +const { createApp } = require("../src/app"); +const { JsonStore } = require("../src/store"); + +let baseUrl; +let dataDirectory; +let server; +let licensePrivateKey; +let licensePublicKeyPath; + +async function post(payload) { + const response = await fetch(`${baseUrl}/api`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload) + }); + assert.equal(response.status, 200); + return response.json(); +} + +function signLicense(payload) { + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64"); + const signature = crypto.sign("sha256", Buffer.from(payloadBase64), { + key: licensePrivateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX + }); + return `${payloadBase64}|${signature.toString("base64")}`; +} + +before(async () => { + dataDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-server-")); + const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + licensePrivateKey = keyPair.privateKey; + licensePublicKeyPath = path.join(dataDirectory, "license-public.pem"); + await fs.promises.writeFile(licensePublicKeyPath, keyPair.publicKey.export({ type: "spki", format: "pem" })); + const store = new JsonStore(dataDirectory); + await store.initialize(); + const app = createApp({ store, adminToken: "test-token", licensePublicKeyPath }); + await new Promise((resolve) => { + server = app.listen(0, "127.0.0.1", resolve); + }); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await fs.promises.rm(dataDirectory, { recursive: true, force: true }); +}); + +test("health endpoint reports ready", async () => { + const response = await fetch(`${baseUrl}/health`); + assert.deepEqual(await response.json(), { success: true, service: "reinloop-server" }); +}); + +test("root endpoint accepts API requests without the /api suffix", async () => { + const response = await fetch(baseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "listOrganizations", adminToken: "test-token" }) + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { success: true, companies: [] }); +}); + +test("legacy data_record endpoint and uploadUserInfo type are unavailable", async () => { + const legacyRoute = await fetch(`${baseUrl}/data_record`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "listOrganizations", adminToken: "test-token" }) + }); + assert.equal(legacyRoute.status, 404); + + const legacyType = await post({ + type: "uploadUserInfo", _id: "legacy", username: "legacy", + issued: "2026-07-25 12:00", expiry: "2027-07-25 12:00", license: "legacy" + }); + assert.equal(legacyType.success, false); + assert.match(legacyType.errMsg, /无效/); +}); + +test("existing two-step client flow uploads, lists, and downloads a file", async () => { + const issued = await post({ type: "uploadDataFile", fileName: "result.csv", folder: "customer/line-1/ind_data" }); + assert.equal(issued.success, true); + assert.ok(issued.uploadMetadata.authorization); + + const form = new FormData(); + form.append("key", issued.uploadMetadata.cosFileId); + form.append("Signature", issued.uploadMetadata.authorization); + form.append("x-cos-security-token", issued.uploadMetadata.token); + form.append("x-cos-meta-fileid", issued.uploadMetadata.fileId); + form.append("file", new Blob(["time,pressure\n0,10\n"], { type: "text/csv" }), "result.csv"); + const uploaded = await fetch(issued.uploadMetadata.url, { method: "POST", body: form }); + assert.equal(uploaded.status, 204); + + const listed = await post({ type: "listModels", folder: "customer/line-1/ind_data" }); + assert.deepEqual(listed.files, ["result.csv"]); + assert.equal(listed.fileList[0].fileID, issued.fileID); + + const directDownload = await fetch(`${baseUrl}/files/${encodeURIComponent(issued.fileID)}`); + assert.equal(directDownload.status, 403); + const rejectedDownload = await post({ type: "downloadModel", fileID: issued.fileID }); + assert.equal(rejectedDownload.success, false); + const download = await post({ + type: "downloadModel", fileID: issued.fileID, adminToken: "test-token" + }); + const downloaded = await fetch(download.url); + assert.equal(await downloaded.text(), "time,pressure\n0,10\n"); +}); + +test("admin lists, downloads, and deletes only the selected device control data", async () => { + async function uploadControlData(deviceId, fileName, content) { + const issued = await post({ + type: "uploadDataFile", fileName, folder: `${deviceId}/data_record/run-1` + }); + assert.equal(issued.success, true); + const form = new FormData(); + form.append("file", new Blob([content]), fileName); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204); + return issued; + } + + const deviceId = "control-co/line-1"; + const first = await uploadControlData(deviceId, "episode_part1.pkl", "part-1"); + const second = await uploadControlData(deviceId, "episode_manifest.json", '{"parts":1}'); + await uploadControlData("other-co/line-2", "other.pkl", "other"); + + const rejectedList = await post({ type: "listControlFiles", deviceId }); + assert.equal(rejectedList.success, false); + const listed = await post({ + type: "listControlFiles", deviceId, page: 1, pageSize: 500, adminToken: "test-token" + }); + assert.equal(listed.total, 2); + assert.equal(listed.pageSize, 100); + assert.deepEqual(new Set(listed.files.map((item) => item.fileID)), new Set([first.fileID, second.fileID])); + + const forbiddenDownload = await post({ + type: "getControlFileDownload", fileID: "local://ReinLoop_GUI/other-co/line-2/ind_data/result.csv", + adminToken: "test-token" + }); + assert.equal(forbiddenDownload.success, false); + const download = await post({ + type: "getControlFileDownload", fileID: second.fileID, adminToken: "test-token" + }); + assert.equal(download.success, true); + assert.equal(download.size, Buffer.byteLength('{"parts":1}')); + assert.match(download.url, /expires=.*token=/); + assert.equal(await (await fetch(download.url)).text(), '{"parts":1}'); + + const forbiddenDelete = await post({ + type: "deleteControlFile", fileID: "model://control-co/line-1/controller.bin", adminToken: "test-token" + }); + assert.equal(forbiddenDelete.success, false); + const deleted = await post({ type: "deleteControlFile", fileID: first.fileID, adminToken: "test-token" }); + assert.deepEqual(deleted, { success: true, deletedCount: 1 }); + assert.equal((await post({ + type: "deleteControlFile", fileID: first.fileID, adminToken: "test-token" + })).success, false); +}); + +test("model uploads are stored under data/models/company/line", async () => { + const issued = await post({ + type: "issueModelUpload", deviceId: "company-a/line-1", + fileName: "controller.bin", adminToken: "test-token" + }); + assert.equal(issued.fileID, "model://company-a/line-1/controller.bin"); + + const form = new FormData(); + form.append("file", new Blob(["model-content"]), "controller.bin"); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204); + assert.equal( + await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "line-1", "controller.bin"), "utf8"), + "model-content" + ); + + const listed = await post({ type: "listModels", folder: "company-a/line-1/model_config" }); + assert.equal(listed.fileList[0].fileID, issued.fileID); +}); + +test("model upload supports renaming while preserving the original file name", async () => { + const issued = await post({ + type: "issueModelUpload", deviceId: "company-a/rename-line", + fileName: "controller-original.bin", modelName: "pressure-controller-v2.bin", + adminToken: "test-token" + }); + assert.equal(issued.success, true); + assert.equal(issued.fileName, "pressure-controller-v2.bin"); + assert.equal(issued.originalFileName, "controller-original.bin"); + assert.equal(issued.fileID, "model://company-a/rename-line/pressure-controller-v2.bin"); + + const form = new FormData(); + form.append("file", new Blob(["renamed-model"]), "controller-original.bin"); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204); + + const listed = await post({ + type: "listModels", folder: "company-a/rename-line/model_config" + }); + assert.deepEqual(listed.files, ["pressure-controller-v2.bin"]); + assert.equal(listed.fileList[0].fileName, "pressure-controller-v2.bin"); + assert.equal(listed.fileList[0].originalFileName, "controller-original.bin"); + assert.equal( + await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "rename-line", "pressure-controller-v2.bin"), "utf8"), + "renamed-model" + ); + + const invalid = await post({ + type: "issueModelUpload", deviceId: "company-a/rename-line", + fileName: "controller.bin", modelName: "controller.zip", + adminToken: "test-token" + }); + assert.equal(invalid.success, false); + assert.match(invalid.errMsg, /扩展名/); +}); + +test("model upload requires explicit overwrite for an existing file", async () => { + const request = { + type: "issueModelUpload", deviceId: "company-a/overwrite-line", + fileName: "controller.bin", adminToken: "test-token" + }; + const issued = await post(request); + const firstForm = new FormData(); + firstForm.append("file", new Blob(["first-version"]), "controller.bin"); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: firstForm })).status, 204); + + const conflict = await post(request); + assert.equal(conflict.success, false); + assert.equal(conflict.conflict, true); + assert.equal(conflict.existing.fileID, issued.fileID); + + const replacement = await post({ ...request, overwrite: true }); + assert.equal(replacement.success, true); + const replacementForm = new FormData(); + replacementForm.append("file", new Blob(["second-version"]), "controller.bin"); + assert.equal((await fetch(replacement.uploadMetadata.url, { + method: "POST", body: replacementForm + })).status, 204); + assert.equal( + await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "overwrite-line", "controller.bin"), "utf8"), + "second-version" + ); +}); + +test("panel consumes uploaded device files from an inbox without scanning folders", async () => { + const deviceId = "panel-company/panel-line"; + const issued = await post({ + type: "uploadDataFile", fileName: "result_20260724_120000.csv", + folder: `${deviceId}/ind_data` + }); + const form = new FormData(); + form.append("file", new Blob(["t,u,p\n0,10,20\n"], { type: "text/csv" }), issued.fileID); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204); + + const beforeRegistration = await post({ + type: "getPendingPanelFile", deviceId, adminToken: "test-token" + }); + assert.equal(beforeRegistration.pending, false); + + await post({ + type: "registerIdentificationResult", deviceId, + runId: "result_20260724_120000.csv" + }); + const pending = await post({ + type: "getPendingPanelFile", deviceId, adminToken: "test-token" + }); + assert.equal(pending.pending, true); + assert.equal(pending.fileName, "result_20260724_120000.csv"); + assert.equal(await (await fetch(pending.url)).text(), "t,u,p\n0,10,20\n"); + + assert.equal((await post({ + type: "ackPanelFile", deviceId, fileID: pending.fileID, + adminToken: "test-token" + })).deleted, 1); + assert.equal((await post({ + type: "getPendingPanelFile", deviceId, adminToken: "test-token" + })).pending, false); + + const jsonIssued = await post({ + type: "uploadDataFile", fileName: "travel_stability_pressures_20260724_120000.json", + folder: `${deviceId}/ind_data` + }); + const jsonForm = new FormData(); + jsonForm.append("file", new Blob(['{"stable_pressures":[]}'], { + type: "application/json" + }), jsonIssued.fileID); + assert.equal((await fetch(jsonIssued.uploadMetadata.url, { + method: "POST", body: jsonForm + })).status, 204); + const jsonPending = await post({ + type: "getPendingPanelFile", deviceId, adminToken: "test-token" + }); + assert.equal(jsonPending.pending, true); + assert.equal(jsonPending.mediaType, "json"); + + const rejectedHistory = await post({ type: "listIdentificationFiles", deviceId }); + assert.equal(rejectedHistory.success, false); + const history = await post({ + type: "listIdentificationFiles", deviceId, adminToken: "test-token" + }); + assert.equal(history.total, 2); + const csvHistory = history.files.find((file) => file.fileID === pending.fileID); + assert.equal(csvHistory.status, "processed"); + assert.ok(csvHistory.processedAt); + assert.ok(csvHistory.expiresAt); + + const rejectedHistoryDownload = await post({ + type: "getIdentificationFileDownload", fileID: pending.fileID + }); + assert.equal(rejectedHistoryDownload.success, false); + const historyDownload = await post({ + type: "getIdentificationFileDownload", fileID: pending.fileID, + adminToken: "test-token" + }); + assert.equal(await (await fetch(historyDownload.url)).text(), "t,u,p\n0,10,20\n"); + + const rejectedDelete = await post({ + type: "deleteIdentificationFile", fileID: jsonPending.fileID + }); + assert.equal(rejectedDelete.success, false); + const deleted = await post({ + type: "deleteIdentificationFile", fileID: jsonPending.fileID, + adminToken: "test-token" + }); + assert.equal(deleted.deletedCount, 1); + assert.equal((await fetch(jsonPending.url)).status, 404); +}); + +test("expired processed identification files are purged without affecting pending files", async () => { + const deviceId = "retention-company/retention-line"; + const processedIssued = await post({ + type: "uploadDataFile", fileName: "processed.csv", folder: `${deviceId}/ind_data` + }); + const processedForm = new FormData(); + processedForm.append("file", new Blob(["processed"]), "processed.csv"); + assert.equal((await fetch(processedIssued.uploadMetadata.url, { + method: "POST", body: processedForm + })).status, 204); + await post({ + type: "ackPanelFile", deviceId, fileID: processedIssued.fileID, + adminToken: "test-token" + }); + + const pendingIssued = await post({ + type: "uploadDataFile", fileName: "pending.json", folder: `${deviceId}/ind_data` + }); + const pendingForm = new FormData(); + pendingForm.append("file", new Blob(["{}"]), "pending.json"); + assert.equal((await fetch(pendingIssued.uploadMetadata.url, { + method: "POST", body: pendingForm + })).status, 204); + + const store = new JsonStore(dataDirectory); + await store.update((database) => { + const record = database.identificationFiles.find((item) => item.fileID === processedIssued.fileID); + record.expiresAt = new Date(Date.now() - 1000).toISOString(); + }); + const history = await post({ + type: "listIdentificationFiles", deviceId, adminToken: "test-token" + }); + assert.deepEqual(history.files.map((file) => file.fileID), [pendingIssued.fileID]); + assert.equal(history.files[0].status, "pending"); + assert.equal(await fs.promises.access( + path.join(dataDirectory, "files", "ReinLoop_GUI", deviceId, "ind_data", "processed.csv") + ).then(() => true, () => false), false); +}); + +test("identification feedback supports register, publish, poll, and acknowledge", async () => { + assert.equal((await post({ + type: "registerIdentificationResult", deviceId: "customer-a/line-a", runId: "run-1" + })).success, true); + assert.deepEqual(await post({ + type: "getIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1" + }), { success: true, ready: false }); + + const published = await post({ + type: "setIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1", + result: 1, adminToken: "test-token" + }); + assert.equal(published.result, 1); + assert.equal((await post({ + type: "getIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1" + })).result, 1); + assert.equal((await post({ + type: "ackIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1" + })).deleted, 1); +}); + +test("control panel publishes the device identification CSV consumed by ReinLoop", async () => { + const parameters = { + 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 issued = await post({ + type: "publishIdentificationConfig", deviceId: "customer-a/line-a", + parameters, adminToken: "test-token" + }); + assert.equal(issued.success, true); + + const invalid = await post({ + type: "publishIdentificationConfig", deviceId: "customer-a/line-a", + parameters: { ...parameters, levels: [1000, 900, 800] }, + adminToken: "test-token" + }); + assert.equal(invalid.success, false); + assert.match(invalid.errMsg, /levels/); + + const csv = [ + "parameter,value", + "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", + "" + ].join("\n"); + const form = new FormData(); + form.append("file", new Blob([csv], { type: "text/csv" }), "identification_config.csv"); + assert.equal((await fetch(issued.uploadMetadata.url, { + method: "POST", body: form + })).status, 204); + + const available = await post({ + type: "getIdentificationConfig", deviceId: "customer-a/line-a" + }); + assert.equal(available.success, true); + assert.equal( + available.cloudPath, + "ReinLoop_GUI/customer-a/line-a/identification_config/identification_config.csv" + ); + assert.equal(await (await fetch(available.url)).text(), csv); +}); + +test("volume publishing becomes readable only after the file upload completes", async () => { + const parameters = { + 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 + }; + const issued = await post({ + type: "publishVolumeConfig", parameters, adminToken: "test-token" + }); + const beforeUpload = await post({ type: "getFunctionConfig", configType: "volume" }); + assert.equal(beforeUpload.notFound, true); + + const form = new FormData(); + form.append("file", new Blob([JSON.stringify(parameters)], { + type: "application/json" + }), "volume_config.json"); + assert.equal((await fetch(issued.uploadMetadata.url, { + method: "POST", body: form + })).status, 204); + + const published = await post({ type: "getFunctionConfig", configType: "volume" }); + assert.equal(published.success, true); + assert.deepEqual(published.parameters, parameters); +}); + +test("volume request accepts only the file uploaded for that request", async () => { + const request = await post({ type: "createVolumeConfigRequest", deviceId: "customer-a/line-a" }); + const folder = `customer-a/line-a/volume_config_requests/${request.requestId}`; + const issued = await post({ type: "uploadDataFile", fileName: "volume_measurement.json", folder }); + const form = new FormData(); + form.append("file", new Blob(["{\"num_runs\":2}"], { type: "application/json" }), "volume_measurement.json"); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204); + + const submitted = await post({ + type: "submitVolumeConfigFile", deviceId: "customer-a/line-a", requestId: request.requestId, + fileID: issued.fileID, fileName: "volume_measurement.json" + }); + assert.equal(submitted.success, true); + const ready = await post({ + type: "getVolumeConfigRequest", deviceId: "customer-a/line-a", requestId: request.requestId + }); + assert.equal(ready.ready, true); + assert.equal( + ready.cloudPath, + `ReinLoop_GUI/customer-a/line-a/volume_config_requests/${request.requestId}/volume_measurement.json` + ); + assert.deepEqual(await (await fetch(ready.url)).json(), { num_runs: 2 }); +}); + +test("admin manages companies and production lines with a stable device id", async () => { + const unauthorized = await post({ + type: "createCompany", name: "未授权公司", code: "blocked" + }); + assert.equal(unauthorized.success, false); + + const company = await post({ + type: "createCompany", name: "示例公司", code: "sample-co", + adminToken: "test-token" + }); + assert.equal(company.success, true); + assert.equal(company.company.code, "sample-co"); + + const line = await post({ + type: "createProductionLine", companyId: company.company.id, + name: "一号产线", code: "line-1", adminToken: "test-token" + }); + assert.equal(line.success, true); + assert.equal(line.productionLine.deviceId, "sample-co/line-1"); + + const organizations = await post({ + type: "listOrganizations", adminToken: "test-token" + }); + assert.equal(organizations.companies.length, 1); + const listedLine = organizations.companies[0].productionLines[0]; + assert.equal(listedLine.id, line.productionLine.id); + assert.equal(listedLine.online, false); + assert.equal(listedLine.lastSeenAt, null); + + const heartbeat = await post({ type: "deviceHeartbeat", deviceId: line.productionLine.deviceId }); + assert.equal(heartbeat.success, true); + assert.equal(heartbeat.deviceId, line.productionLine.deviceId); + assert.ok(heartbeat.lastSeenAt); + + const onlineOrganizations = await post({ type: "listOrganizations", adminToken: "test-token" }); + const onlineLine = onlineOrganizations.companies[0].productionLines[0]; + assert.equal(onlineLine.online, true); + assert.equal(onlineLine.lastSeenAt, heartbeat.lastSeenAt); + + const store = new JsonStore(dataDirectory); + await store.update((database) => { + const storedLine = database.productionLines.find((item) => item.id === line.productionLine.id); + storedLine.lastSeenAt = new Date(Date.now() - 30_001).toISOString(); + }); + const offlineOrganizations = await post({ type: "listOrganizations", adminToken: "test-token" }); + assert.equal(offlineOrganizations.companies[0].productionLines[0].online, false); + + const unknownDevice = await post({ type: "deviceHeartbeat", deviceId: "unknown-co/unknown-line" }); + assert.equal(unknownDevice.success, false); +}); + +test("model write operations require an admin token", async () => { + const rejectedUpload = await post({ + type: "uploadDataFile", fileName: "model.bin", folder: "company-a/line-a/model_config" + }); + assert.equal(rejectedUpload.success, false); + + const issued = await post({ + type: "issueModelUpload", deviceId: "company-a/line-a", fileName: "model.bin", + adminToken: "test-token" + }); + assert.equal(issued.success, true); + const form = new FormData(); + form.append("file", new Blob(["model"], { type: "application/octet-stream" }), "model.bin"); + assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204); + + const rejectedDelete = await post({ type: "deleteFile", fileID: issued.fileID }); + assert.equal(rejectedDelete.success, false); + const deleted = await post({ type: "deleteModel", fileID: issued.fileID, adminToken: "test-token" }); + assert.equal(deleted.deletedCount, 1); +}); + +test("issued licenses can be listed, validated, and revoked", async () => { + const company = await post({ + type: "createCompany", name: "许可证公司", code: "licensed-co", + adminToken: "test-token" + }); + const line = await post({ + type: "createProductionLine", companyId: company.company.id, + name: "测试线", code: "test-line", adminToken: "test-token" + }); + const licenseId = crypto.randomUUID(); + const licensePayload = { + license_id: licenseId, + company_id: company.company.id, + production_line_id: line.productionLine.id, + customer: "许可证公司", + device_id: "licensed-co/test-line", + issued: "2026-07-25 12:00", + expiry: "2027-07-25 12:00", + features: "*" + }; + const issued = await post({ + type: "createLicense", licenseId, + companyId: company.company.id, productionLineId: line.productionLine.id, + customer: "许可证公司", issued: "2026-07-25 12:00", + expiry: "2027-07-25 12:00", features: "*", + license: signLicense(licensePayload), adminToken: "test-token" + }); + assert.equal(issued.success, true); + assert.equal(issued.license.status, "active"); + assert.equal(issued.license.deviceId, "licensed-co/test-line"); + assert.equal("license" in issued.license, false); + + const validation = await post({ + type: "validateLicense", licenseId, + deviceId: "licensed-co/test-line" + }); + assert.deepEqual(validation, { + success: true, valid: true, status: "active", licenseId + }); + + const listed = await post({ type: "listLicenses", adminToken: "test-token" }); + assert.equal(listed.licenses.length, 1); + assert.equal(listed.licenses[0].customer, "许可证公司"); + + const revoked = await post({ + type: "revokeLicense", licenseId, + reason: "合同终止", adminToken: "test-token" + }); + assert.equal(revoked.license.status, "revoked"); + assert.equal(revoked.license.revocationReason, "合同终止"); + + const rejected = await post({ + type: "validateLicense", licenseId, + deviceId: "licensed-co/test-line" + }); + assert.equal(rejected.valid, false); + assert.equal(rejected.status, "revoked"); +}); + +test("license creation verifies the signed payload and validates expiry in real time", async () => { + const company = await post({ + type: "createCompany", name: "安全校验公司", code: "security-co", adminToken: "test-token" + }); + const line = await post({ + type: "createProductionLine", companyId: company.company.id, name: "安全线", code: "secure-line", + adminToken: "test-token" + }); + const licenseId = crypto.randomUUID(); + const payload = { + license_id: licenseId, company_id: company.company.id, production_line_id: line.productionLine.id, + customer: "安全校验公司", device_id: line.productionLine.deviceId, + issued: "2026-07-25 01:00", expiry: "2027-07-25 01:00", features: "*" + }; + const request = { + type: "createLicense", licenseId, companyId: company.company.id, productionLineId: line.productionLine.id, + customer: payload.customer, issued: payload.issued, expiry: payload.expiry, features: "*", + license: signLicense(payload), adminToken: "test-token" + }; + assert.equal((await post(request)).success, true); + assert.equal((await post(request)).idempotent, true); + + const changed = await post({ ...request, customer: "已篡改客户" }); + assert.equal(changed.success, false); + assert.match(changed.errMsg, /不一致/); + + const invalidSignature = await post({ + ...request, licenseId: crypto.randomUUID(), license: `${request.license}x` + }); + assert.equal(invalidSignature.success, false); + + const expiredId = crypto.randomUUID(); + const expiredPayload = { + ...payload, license_id: expiredId, issued: "2020-01-01 00:00", expiry: "2020-01-02 00:00" + }; + const expired = await post({ + ...request, licenseId: expiredId, issued: expiredPayload.issued, expiry: expiredPayload.expiry, + license: signLicense(expiredPayload) + }); + assert.equal(expired.success, true); + assert.deepEqual(await post({ + type: "validateLicense", licenseId: expiredId, deviceId: line.productionLine.deviceId + }), { success: true, valid: false, status: "expired", licenseId: expiredId }); +}); \ No newline at end of file