update server
@@ -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
|
||||||
@@ -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` 固定为 `<company_code>/<production_line_code>`,配置发布、模型目录、
|
||||||
|
绘图收件箱和辨识反馈均使用同一个值。
|
||||||
|
|
||||||
|
应用启动时首先显示连接门禁页,只提供 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;登记失败会删除本次本地文件,避免半完成状态。
|
||||||
|
- 查看已签发许可证详情和撤销许可证。
|
||||||
|
|
||||||
|
模型管理页按当前产线列出 `<deviceId>/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 兼容性单独评估。
|
||||||
@@ -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 <publish-volume|publish-identification|get-volume|get-identification> [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
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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))
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'">
|
||||||
|
<title>ReinLoop B 端工作台</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">REINLOOP / B CONSOLE</p>
|
||||||
|
<h1>数据评审工作台</h1>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" data-tone="idle">未连接</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="connection-screen" class="connection-screen" aria-labelledby="connection-title">
|
||||||
|
<form id="connection-form" class="connection-form">
|
||||||
|
<p class="section-kicker">SERVER ACCESS</p>
|
||||||
|
<h2 id="connection-title">连接管理服务</h2>
|
||||||
|
<label><span>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com/api" required></label>
|
||||||
|
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
|
||||||
|
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
|
||||||
|
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main id="workspace" hidden>
|
||||||
|
<section class="connection-band" aria-label="当前业务目标">
|
||||||
|
<label>
|
||||||
|
<span>公司</span>
|
||||||
|
<select id="company-select" aria-label="选择公司"><option value="">请先刷新组织</option></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>产线</span>
|
||||||
|
<select id="device-id" aria-label="选择产线"><option value="">请选择产线</option></select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="功能切换">
|
||||||
|
<button class="tab active" data-target="plot-panel">绘图预览</button>
|
||||||
|
<button class="tab" data-target="identification-data-panel">辨识数据</button>
|
||||||
|
<button class="tab" data-target="config-panel">配置发布</button>
|
||||||
|
<button class="tab" data-target="model-panel">模型管理</button>
|
||||||
|
<button class="tab" data-target="license-panel">许可证</button>
|
||||||
|
<button class="tab" data-target="organization-panel">组织管理</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="plot-panel" class="panel active">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">IDENTIFICATION REVIEW</p>
|
||||||
|
<h2>数据曲线</h2>
|
||||||
|
</div>
|
||||||
|
<div class="review-actions">
|
||||||
|
<span id="review-target">等待辨识结果</span>
|
||||||
|
<button id="reject-review" class="button danger" disabled>未通过</button>
|
||||||
|
<button id="approve-review" class="button primary" disabled>通过</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plot-grid">
|
||||||
|
<article class="plot-item">
|
||||||
|
<div class="plot-title">
|
||||||
|
<div>
|
||||||
|
<span>辨识 CSV</span>
|
||||||
|
<strong>阀门开度与压力</strong>
|
||||||
|
</div>
|
||||||
|
<div class="plot-actions"><button class="button icon" data-open-plot="csv" title="放大图像" disabled aria-label="放大辨识图">+</button><button id="show-csv-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示辨识图">↗</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="plot-stage">
|
||||||
|
<div id="empty-csv-plot" class="empty-state">
|
||||||
|
<strong>尚未载入辨识曲线</strong>
|
||||||
|
<span>等待 ReinLoop 上传 CSV</span>
|
||||||
|
</div>
|
||||||
|
<img id="csv-plot-image" alt="辨识 CSV 绘图预览" hidden>
|
||||||
|
</div>
|
||||||
|
<div class="plot-meta">
|
||||||
|
<p id="csv-image-path" class="file-path">未接收 CSV</p>
|
||||||
|
<p id="csv-upload-time" class="upload-time">等待数据</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="plot-item">
|
||||||
|
<div class="plot-title">
|
||||||
|
<div>
|
||||||
|
<span>行程 JSON</span>
|
||||||
|
<strong>行程与稳态压力</strong>
|
||||||
|
</div>
|
||||||
|
<div class="plot-actions"><button class="button icon" data-open-plot="json" title="放大图像" disabled aria-label="放大行程图">+</button><button id="show-json-image" class="button icon" title="在文件夹中显示" disabled aria-label="在文件夹中显示行程图">↗</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="plot-stage">
|
||||||
|
<div id="empty-json-plot" class="empty-state">
|
||||||
|
<strong>尚未载入行程曲线</strong>
|
||||||
|
<span>等待 ReinLoop 上传行程 JSON</span>
|
||||||
|
</div>
|
||||||
|
<img id="json-plot-image" alt="行程稳态压力绘图预览" hidden>
|
||||||
|
</div>
|
||||||
|
<div class="plot-meta">
|
||||||
|
<p id="json-image-path" class="file-path">未接收行程 JSON</p>
|
||||||
|
<p id="json-upload-time" class="upload-time">等待数据</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="identification-data-panel" class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><p class="section-kicker">IDENTIFICATION ARCHIVE</p><h2>辨识数据暂存</h2></div>
|
||||||
|
<button id="refresh-identification-files" class="button secondary">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="data-surface">
|
||||||
|
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th>操作</th></tr></thead><tbody id="identification-file-list"></tbody></table>
|
||||||
|
<p id="identification-file-empty" class="empty-row">选择公司和产线后刷新辨识数据</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="config-panel" class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">FUNCTION PARAMETERS</p>
|
||||||
|
<h2>配置数据</h2>
|
||||||
|
</div>
|
||||||
|
<div class="segmented" aria-label="配置类型">
|
||||||
|
<button class="segment active" data-type="volume">容积测量</button>
|
||||||
|
<button class="segment" data-type="identification">系统辨识</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-layout">
|
||||||
|
<div class="editor-column">
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<span id="config-label">容积测量配置</span>
|
||||||
|
<button id="import-config" class="text-button">导入 JSON</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="config-editor" spellcheck="false" aria-label="JSON 配置编辑器"></textarea>
|
||||||
|
<p id="config-path" class="file-path">可直接编辑,或从本地 JSON 导入</p>
|
||||||
|
</div>
|
||||||
|
<aside class="publish-aside">
|
||||||
|
<h3>发布检查</h3>
|
||||||
|
<dl>
|
||||||
|
<div><dt>目标</dt><dd id="publish-target">Server 配置文件</dd></div>
|
||||||
|
<div><dt>格式</dt><dd>JSON</dd></div>
|
||||||
|
<div><dt>鉴权</dt><dd>Admin Token</dd></div>
|
||||||
|
</dl>
|
||||||
|
<button id="load-server" class="button secondary full">读取 Server 配置</button>
|
||||||
|
<button id="publish-config" class="button primary full">上传容积配置</button>
|
||||||
|
<p id="publish-result" class="result">等待操作</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="model-panel" class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><p class="section-kicker">MODEL CONTROL</p><h2>产线模型</h2></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="refresh-models" class="button secondary">刷新</button>
|
||||||
|
<button id="upload-model" class="button primary">上传模型</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-surface">
|
||||||
|
<table><thead><tr><th>文件名</th><th>上传时间</th><th>大小</th><th>操作</th></tr></thead><tbody id="model-list"></tbody></table>
|
||||||
|
<p id="model-empty" class="empty-row">选择公司和产线后刷新模型列表</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="license-panel" class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><p class="section-kicker">LICENSE REGISTRY</p><h2>许可证签发与管理</h2></div>
|
||||||
|
<button id="refresh-licenses" class="button secondary">刷新列表</button>
|
||||||
|
</div>
|
||||||
|
<div class="management-layout">
|
||||||
|
<form id="license-form" class="form-surface">
|
||||||
|
<h3>签发许可证</h3>
|
||||||
|
<label><span>当前目标</span><input id="license-target" readonly placeholder="请先选择公司和产线"></label>
|
||||||
|
<label><span>签发时间</span><input id="license-issued" type="datetime-local" required></label>
|
||||||
|
<label><span>到期时间</span><input id="license-expiry" type="datetime-local" required></label>
|
||||||
|
<label><span>功能</span><input id="license-features" value="*" required></label>
|
||||||
|
<button class="button primary" type="submit">选择私钥并签发</button>
|
||||||
|
<p class="form-note">许可证保存到本地后同步登记到 Server;私钥不会上传或保存。</p>
|
||||||
|
</form>
|
||||||
|
<div class="data-surface">
|
||||||
|
<table><thead><tr><th>公司 / 产线</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="license-list"></tbody></table>
|
||||||
|
<p id="license-empty" class="empty-row">尚未读取许可证</p>
|
||||||
|
<pre id="license-detail" class="detail-view" hidden></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="organization-panel" class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><p class="section-kicker">ORGANIZATION</p><h2>公司与产线</h2></div>
|
||||||
|
<button id="refresh-organizations" class="button secondary">刷新组织</button>
|
||||||
|
</div>
|
||||||
|
<div class="management-layout equal">
|
||||||
|
<form id="company-form" class="form-surface">
|
||||||
|
<h3>添加公司</h3>
|
||||||
|
<label><span>公司名称</span><input id="company-name" required></label>
|
||||||
|
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||||
|
<button class="button primary" type="submit">添加公司</button>
|
||||||
|
</form>
|
||||||
|
<form id="line-form" class="form-surface">
|
||||||
|
<h3>添加产线</h3>
|
||||||
|
<label><span>所属公司</span><select id="line-company" required><option value="">请选择公司</option></select></label>
|
||||||
|
<label><span>产线名称</span><input id="line-name" required></label>
|
||||||
|
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
|
||||||
|
<button class="button primary" type="submit">添加产线</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div id="image-lightbox" class="lightbox" hidden role="dialog" aria-modal="true" aria-labelledby="lightbox-title">
|
||||||
|
<div class="lightbox-shell">
|
||||||
|
<div class="lightbox-toolbar"><strong id="lightbox-title">图像预览</strong><div class="lightbox-actions"><button id="zoom-out" class="button icon" title="缩小" aria-label="缩小图像">-</button><button id="zoom-in" class="button icon" title="放大" aria-label="放大图像">+</button><button id="zoom-fit" class="button secondary" type="button">适应窗口</button><button id="zoom-reset" class="button secondary" type="button">原始比例</button><button id="close-lightbox" class="button icon" title="关闭" aria-label="关闭图像预览">x</button></div></div>
|
||||||
|
<div id="lightbox-canvas" class="lightbox-canvas"><img id="lightbox-image" alt="放大的图像预览"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 `<option value="${escapeHtml(company.id)}">${escapeHtml(company.name)} (${escapeHtml(company.code)})${escapeHtml(statusSummary)}</option>`;
|
||||||
|
}).join("");
|
||||||
|
elements.companySelect.innerHTML = `<option value="">请选择公司</option>${companyOptions}`;
|
||||||
|
elements.lineCompany.innerHTML = `<option value="">请选择公司</option>${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 = `<option value="">请选择产线</option>${lines.map((line) =>
|
||||||
|
`<option value="${escapeHtml(line.deviceId)}">${escapeHtml(line.online === true ? "● 在线" : line.online === false ? "○ 离线" : "◇ 状态未知")} · ${escapeHtml(line.name)} (${escapeHtml(line.code)})</option>`
|
||||||
|
).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) => `
|
||||||
|
<tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</td>
|
||||||
|
<td>${escapeHtml(license.expiry)}</td><td>${license.status === "active" ? "有效" : "已撤销"}</td>
|
||||||
|
<td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button>${license.status === "active" ? `<button class="table-action danger" data-license-revoke="${escapeHtml(license.licenseId)}">撤销</button>` : ""}</td></tr>
|
||||||
|
`).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) => `
|
||||||
|
<tr><td>${escapeHtml(model.fileName)}</td><td>${escapeHtml(model.uploadTime || "-")}</td>
|
||||||
|
<td>${formatSize(model.size)}</td><td><button class="table-action" data-model-download="${escapeHtml(model.fileID)}">下载</button><button class="table-action danger" data-model-delete="${escapeHtml(model.fileID)}">删除</button></td></tr>
|
||||||
|
`).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) => `
|
||||||
|
<tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td>
|
||||||
|
<td>${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}</td><td>${formatSize(file.size)}</td>
|
||||||
|
<td>${file.status === "processed" ? "已处理" : "待处理"}</td>
|
||||||
|
<td><button class="table-action" data-identification-preview="${escapeHtml(file.fileID)}">查看</button><button class="table-action" data-identification-download="${escapeHtml(file.fileID)}">下载</button><button class="table-action danger" data-identification-delete="${escapeHtml(file.fileID)}">删除</button></td></tr>
|
||||||
|
`).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);
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
<company_code>/<production_line_code>
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```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
|
||||||
|
<deviceId>/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
|
||||||
|
<deviceId>/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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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), /到期时间/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
@@ -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("程序结束。")
|
||||||
@@ -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 完成,不由客户端工具执行。
|
||||||
@@ -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 不能为空")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"notice": "Legacy notice only. The client reads identification_config.csv from cloud storage and never reads this file."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 = []
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
name: RL
|
||||||
|
channels:
|
||||||
|
- conda-forge
|
||||||
|
- defaults
|
||||||
|
dependencies:
|
||||||
|
- python=3.10 # 建议固定版本
|
||||||
|
- pip
|
||||||
|
- pip:
|
||||||
|
- -r requirements.txt # 自动引用刚才生成的文件
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# ReinLoop 功能与接口
|
||||||
|
|
||||||
|
本文档描述 `ReinLoop/` Python 客户端当前提供的运行时功能与接口。
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
|
||||||
|
`REINLOOP_SERVER_URL + /api`。
|
||||||
|
- 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过
|
||||||
|
`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`,目录为 `<deviceId>/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 到 `<deviceId>/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` 的行程档位扫描稳定压力,将结果上传到
|
||||||
|
`<deviceId>/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`。 |
|
||||||
|
|
||||||
|
容积测量汇总结果上传到 `<deviceId>/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` 必须是两个安全路径段组成的
|
||||||
|
`<company>/<production-line>`。旧许可证仍可本地验签,但不支持在线撤销。
|
||||||
|
|
||||||
|
网络故障不会立即中断控制;离线时限由 `REINLOOP_LICENSE_OFFLINE_HOURS` 配置,默认 72 小时。
|
||||||
|
|
||||||
|
## 客户端服务端协议
|
||||||
|
|
||||||
|
所有业务请求都发送至 `POST /api`。业务成功响应应至少包含 `success: true`。
|
||||||
|
|
||||||
|
| `type` | 请求关键字段 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `validateLicense` | `licenseId`, `deviceId` | 校验许可证是否为 `active` 状态。 |
|
||||||
|
| `listModels` | `folder` | 列出 `<deviceId>/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 的覆盖路径。 |
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 输出目录
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 6.1401C6 6.0627 6.0627 6 6.1401 6H17.8599C17.9373 6 18 6.0627 18 6.1401V12C18 15.3137 15.3137 18 12 18C8.6863 18 6 15.3137 6 12V6.1401Z" stroke="#FEFEFE" stroke-width="2"/>
|
||||||
|
<path d="M10 6V2" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 6V2" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11 13.5H13" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 18V20.5C12 21.3285 12.6715 22 13.5 22H19" stroke="#FEFEFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 740 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.75 5H17.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 3V7" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 5H2.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6.75 12H2.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.75 10V14" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.75 12H10.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.75 19H17.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 19H2.75" stroke="#0960D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 971 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.75 5H17.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 3V7" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 5H2.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6.75 12H2.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.75 10V14" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.75 12H10.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.75 19H17.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.75 19H2.75" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 971 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 6L15.7071 11.2929C15.3166 11.6834 14.6834 11.6834 14.2929 11.2929L12.7071 9.70711C12.3166 9.31658 11.6834 9.31658 11.2929 9.70711L7 14" stroke="#0A50A1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 3V17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.07989 21 6.2 21H21" stroke="#0A50A1" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 6L15.7071 11.2929C15.3166 11.6834 14.6834 11.6834 14.2929 11.2929L12.7071 9.70711C12.3166 9.31658 11.6834 9.31658 11.2929 9.70711L7 14" stroke="#64748B" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 3V17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.07989 21 6.2 21H21" stroke="#64748B" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.6667 13.3333L13.3333 18.6666" stroke="#0060AD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.3333 17.3333L24 14.6666C25.841 12.8257 25.841 9.84091 24 7.99996C22.1591 6.15901 19.1743 6.15901 17.3333 7.99996L14.6667 10.6666M10.6667 14.6666L8 17.3333C6.15905 19.1742 6.15905 22.159 8 24C9.84095 25.8409 12.8257 25.8409 14.6667 24L17.3333 21.3333" stroke="#0060AD" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.6667 13.3333L13.3333 18.6667" stroke="#64748B" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21.3333 17.3333L24 14.6667C25.841 12.8257 25.841 9.84095 24 8C22.1591 6.15905 19.1743 6.15905 17.3333 8L14.6667 10.6667M10.6667 14.6667L8 17.3333C6.15905 19.1743 6.15905 22.159 8 24C9.84095 25.8409 12.8257 25.8409 14.6667 24L17.3333 21.3333" stroke="#64748B" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_155_334)">
|
||||||
|
<mask id="mask0_155_334" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="19" height="19">
|
||||||
|
<path d="M19 0H0V19H19V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_155_334)">
|
||||||
|
<path d="M2.375 9.50329V16.625H16.625V9.5" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.0625 5.9375L9.5 2.375L5.9375 5.9375" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.4967 12.6667V2.375" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_155_334">
|
||||||
|
<rect width="19" height="19" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21.0771 6.02206L15.7622 11.337C15.3717 11.7275 14.7385 11.7275 14.348 11.337L12.7512 9.7402C12.3606 9.34968 11.7275 9.34968 11.337 9.7402L7.02568 14.0515" stroke="#0B64DD" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3.01099 3.01102V17.8772C3.01099 18.9973 3.01099 19.5574 3.22897 19.9852C3.42072 20.3615 3.72668 20.6675 4.10301 20.8592C4.53083 21.0772 5.09088 21.0772 6.21099 21.0772H21.0772" stroke="#0B64DD" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M30.2972 18.7786C30.2972 18.7786 27.0679 27.8809 25.5334 29.47C23.9988 31.0591 21.4665 31.1033 19.8774 29.5687C18.2882 28.0341 18.244 25.5019 19.7786 23.9127C21.3132 22.3236 30.2972 18.7786 30.2972 18.7786Z" fill="#1C9B5D" stroke="#1C9B5D" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M38.8492 38.8492C42.6495 35.049 45 29.799 45 24C45 12.402 35.598 3 24 3C12.402 3 3 12.402 3 24C3 29.799 5.35051 35.049 9.15076 38.8492" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 4V8" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M38.8454 11.1421L35.7368 13.6593" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M42.5223 27.2328L38.6248 26.333" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M5.47742 27.2328L9.3749 26.333" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.15466 11.142L12.2632 13.6593" stroke="#1C9B5D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.125 3.83337V11.5" stroke="#0960D1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2.875 11.5V19.1667" stroke="#0960D1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.125 11.5C20.125 6.73656 16.2634 2.875 11.5 2.875C9.0632 2.875 6.86243 3.88554 5.29388 5.51042M2.875 11.5C2.875 16.2634 6.73656 20.125 11.5 20.125C13.8266 20.125 15.9381 19.2038 17.4896 17.7061" stroke="#0960D1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 574 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3ZM10.7832 7.99023C9.98347 7.54594 9.00025 8.12429 9 9.03906V14.9609C9.00025 15.8757 9.98347 16.4541 10.7832 16.0098L16.4268 12.874C17.1122 12.493 17.1122 11.507 16.4268 11.126L10.7832 7.99023Z" fill="#FEFEFE"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="53" height="53" viewBox="0 0 53 53" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="26.4999" cy="26.5" r="18.7909" stroke="#0B64DD" stroke-width="4"/>
|
||||||
|
<circle cx="26.5" cy="26.5" r="7.22727" stroke="#0B64DD" stroke-width="4"/>
|
||||||
|
<path d="M26.9819 7.7091V2.89091" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M45.2909 26.9818L50.1091 26.9818" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M26.9819 50.1091L26.9819 45.2909" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M2.8908 26.9818H7.70898" stroke="#0B64DD" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 656 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="49" height="39" viewBox="0 0 49 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M47.25 14.2084L47.25 11.6667C47.25 9.30305 47.25 8.12121 46.9902 7.15157C46.2851 4.52024 44.2298 2.46494 41.5985 1.75988C40.6289 1.50006 39.447 1.50006 37.0833 1.50006" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1.5 14.2084L1.5 11.6667C1.5 9.30305 1.5 8.12121 1.75982 7.15157C2.46488 4.52024 4.52018 2.46494 7.1515 1.75988C8.12115 1.50006 9.30299 1.50006 11.6667 1.50006" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M47.25 24.3751L47.25 26.9167C47.25 29.2804 47.25 30.4622 46.9902 31.4319C46.2851 34.0632 44.2298 36.1185 41.5985 36.8236C40.6288 37.0834 39.447 37.0834 37.0833 37.0834" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1.5 24.3751L1.5 26.9167C1.5 29.2804 1.5 30.4622 1.75982 31.4319C2.46488 34.0632 4.52018 36.1185 7.15151 36.8236C8.12115 37.0834 9.30299 37.0834 11.6667 37.0834" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24.375 26.9167L24.375 11.6667" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.2085 24.3751L14.2085 14.2084" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M34.5415 24.3751L34.5415 14.2084" stroke="#EE7F1D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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
|
||||||
@@ -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)])
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
@@ -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()
|
||||||
|
# ----------------------------------------------------
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# ui package - Pure PySide6 UI layer
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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"))
|
||||||
@@ -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)
|
||||||
@@ -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;"
|
||||||
|
)
|
||||||
@@ -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. 补齐运行依赖和打包配置,再生成客户安装包。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>历史过程记录(仅供追溯,当前行为以上述整理为准)</summary>
|
||||||
|
|
||||||
|
## 项目基线
|
||||||
|
|
||||||
|
- 仓库:`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 硬件联调。
|
||||||
|
|
||||||
|
</details>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
coverage/
|
||||||
@@ -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 上传、文件列表、下载、辨识反馈和容积请求流程。
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# ReinLoop Server 功能与接口
|
||||||
|
|
||||||
|
## 通用约定
|
||||||
|
|
||||||
|
业务接口为 `POST /api`。请求与响应均为 JSON,响应包含 `success`。
|
||||||
|
|
||||||
|
标注为 Admin 的接口需要附加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"adminToken":"<B_ADMIN_TOKEN>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`deviceId` 统一为两段格式:`<company-code>/<line-code>`。
|
||||||
|
|
||||||
|
## 服务入口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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` | 创建产线。产线编码在公司内唯一;服务端固定生成 `<company.code>/<line.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` 时两者相同,旧客户端行为不变。
|
||||||
|
|
||||||
|
上传到 `<deviceId>/ind_data` 的 `.csv`、`.json` 会自动进入 Panel inbox。
|
||||||
|
|
||||||
|
## 配置发布与读取
|
||||||
|
|
||||||
|
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `publishIdentificationConfig` | Admin | `deviceId`、`parameters` | 校验辨识参数并为 `<deviceId>/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` | 将已上传到 `<deviceId>/volume_config_requests/<requestId>/` 的文件绑定至请求。 |
|
||||||
|
| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含下载 `url`。 |
|
||||||
|
| `ackVolumeConfigRequest` | 无 | `deviceId`、`requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 |
|
||||||
|
|
||||||
|
请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。
|
||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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: "控制数据目录必须为 <deviceId>/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 = "<invalid JSON>";
|
||||||
|
}
|
||||||
|
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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||