update server

This commit is contained in:
2026-07-30 11:12:31 +08:00
commit 4312cb878c
99 changed files with 24034 additions and 0 deletions
+66
View File
@@ -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
+250
View File
@@ -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 兼容性单独评估。
+191
View File
@@ -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
};
+332
View File
@@ -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();
});
+30
View File
@@ -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))
});
+219
View File
@@ -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>
+716
View File
@@ -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);
});
+258
View File
@@ -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; }
}
+533
View File
@@ -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
}
+56
View File
@@ -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 };
+3998
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -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
}
}
+153
View File
@@ -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 };
+135
View File
@@ -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 };
+113
View File
@@ -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 };
+69
View File
@@ -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 };
+60
View File
@@ -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), /到期时间/);
});
+35
View File
@@ -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);
});
+10
View File
@@ -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
}
+80
View File
@@ -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/
+818
View File
@@ -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 通讯类
- 默认 IP192.168.1.12,端口 502,模块地址(站号)默认为 1
- 输入寄存器(AI):地址 0x00~0x03(对应 PLC 地址 30001~30004
- 保持寄存器(AO):地址 0x00~0x03(对应 PLC 地址 40001~40004
- 模拟量值范围:0~4095(对应 0~10V 或 020mA
"""
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("程序结束。")
+85
View File
@@ -0,0 +1,85 @@
# ReinLoop V1.0 — 收敛有界
基于 Modbus 通讯的压力控制 GUI,支持 PID / 强化学习 / 手动三种控制模式。
## 项目结构
```
pressure_control_gui/
├── main.py # 应用入口
├── PcControl.py # Modbus 通讯类
│ # MT2AM8Client - MT2-AM8 模块 TCPAI 读压力/流量,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 压力寄存器 50432-bit float)、写线圈控制 |
| `MotorModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-BG02B0IX`,站号 4115200 | 通过 RS-485 直接读写电机驱动器寄存器 |
| `PressureModbusRTUClient` | Modbus RTU | `/dev/cu.usbserial-D30JITMY`,站号 1,9600 | 读压力变送器保持寄存器(备用) |
## 数据上传
控制数据(Episode)、辨识数据和容积测量结果通过 Express Server 的上传接口保存,
服务地址与设备目录配置在 `api.py`。许可证、模型和公司/产线等管理操作由
ControlPanel 完成,不由客户端工具执行。
+26
View File
@@ -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."
}
+3
View 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."
}
+145
View 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
+39
View File
@@ -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()
+132
View File
@@ -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)
+329
View File
@@ -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
+199
View File
@@ -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 = []
+20
View File
@@ -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")
+487
View File
@@ -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
+152
View File
@@ -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
+58
View File
@@ -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)
+139
View File
@@ -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
+154
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
name: RL
channels:
- conda-forge
- defaults
dependencies:
- python=3.10 # 建议固定版本
- pip
- pip:
- -r requirements.txt # 自动引用刚才生成的文件
+157
View File
@@ -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 的覆盖路径。 |
+193
View File
@@ -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 : 日志回调默认 printGUI 可传入 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()
+250
View File
@@ -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 : 日志回调默认 printGUI 可传入 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()
+656
View File
@@ -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 作为 contextreceiver否则 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: 许可证 payloadcustomer, 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: 检查间隔分钟默认 144024小时
"""
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
+99
View File
@@ -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()
+25
View File
@@ -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": {}
}
+15
View File
@@ -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
+69
View File
@@ -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 输出目录
}
},
)
+53
View File
@@ -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"
}
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

+7
View File
@@ -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

+10
View File
@@ -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

+10
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+17
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

+4
View File
@@ -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

+9
View File
@@ -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

+5
View File
@@ -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

+3
View File
@@ -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

+8
View File
@@ -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

+9
View File
@@ -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

+569
View File
@@ -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
+41
View File
@@ -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()
+153
View File
@@ -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()
+122
View File
@@ -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()
+218
View File
@@ -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()
+167
View File
@@ -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 用 commandWindows/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()
+285
View File
@@ -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📊 超调01kpa 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)
+2024
View File
File diff suppressed because it is too large Load Diff
@@ -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
1 parameter value
2 q_in_val 50.0
3 dt 0.1
4 n_order 6
5 t_c 2.5
6 levels 10,20,30,40,50,60,70,80
7 dead_area 240.0
8 xa_full 1000.0
9 V_val 5.0
10 repeat 2
+61
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
# ui package - Pure PySide6 UI layer
+177
View File
@@ -0,0 +1,177 @@
# connection_tab.py
"""页面1Modbus 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"),
}
+593
View File
@@ -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)
# ============================================================
# 以下方法完全兼容旧版 APImain_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))
+427
View File
@@ -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"))
File diff suppressed because it is too large Load Diff
+189
View File
@@ -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)
+47
View File
@@ -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;"
)
+716
View File
@@ -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>
+8
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
.env
coverage/
+125
View File
@@ -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 上传、文件列表、下载、辨识反馈和容积请求流程。
+103
View File
@@ -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 分钟)。
+105
View File
@@ -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);
+1175
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+22
View File
@@ -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
+997
View File
@@ -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 };
+110
View File
@@ -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 };
+51
View File
@@ -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;
});
+80
View File
@@ -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 };
+665
View File
@@ -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 });
});