update panel

This commit is contained in:
rangang
2026-07-30 11:40:00 +08:00
parent 4312cb878c
commit e12134e40d
16 changed files with 3327 additions and 2775 deletions
+7 -1
View File
@@ -242,7 +242,13 @@ Electron 工作台现已使用“公司 + 产线”选择代替手工设备 ID
Panel 当前依赖以下新增 Server type
`listOrganizations``createCompany``createProductionLine``createLicense`
`listLicenses``getLicense``revokeLicense`。既有模型和反馈接口继续使用。
`listLicenses``getLicense``revokeLicense``listControlFiles`
`getControlFileDownload``deleteControlFile`。既有模型和反馈接口继续使用。
控制数据接口约定:`listControlFiles``deviceId` 分页返回控制结束后上传到
`<deviceId>/data_record/` 的文件元数据;`getControlFileDownload``fileID` 返回带时效的
下载 URL`deleteControlFile``fileID` 删除文件和元数据。三者均应要求 B 端管理令牌,且
服务端必须验证文件归属控制数据目录,避免使用该接口操作其他业务文件。
## 9. 已知依赖风险
+60 -1
View File
@@ -10,7 +10,7 @@ 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 DEFAULT_API_URL = "https://ReinLoop.dominatedconvergence.com";
const API_URL = process.env.REINLOOP_API_URL || DEFAULT_API_URL;
const INBOX_POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS || 1000);
const connectionState = {
@@ -204,6 +204,20 @@ function registerHandlers() {
callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials));
ipcMain.handle("license:revoke", (_event, request) =>
callServer({ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, request.credentials));
ipcMain.handle("license:download", async (_event, request) => {
const result = await callServer({ type: "getLicense", licenseId: request.licenseId }, request.credentials);
if (typeof result.license?.license !== "string" || !result.license.license) {
throw new Error("Server 未返回许可证原文,无法下载");
}
const selection = await dialog.showSaveDialog({
title: "保存许可证",
defaultPath: `${request.licenseId}.lic`,
filters: [{ name: "ReinLoop 许可证", extensions: ["lic"] }]
});
if (selection.canceled) return null;
await fs.promises.writeFile(selection.filePath, result.license.license, { encoding: "utf8", mode: 0o600 });
return { filePath: selection.filePath };
});
ipcMain.handle("review:submit", async (_event, request) => {
if (!pendingReview || pendingReview.deviceId !== request.deviceId || pendingReview.runId !== request.runId) {
@@ -292,6 +306,51 @@ function registerHandlers() {
ipcMain.handle("identification:delete", (_event, request) =>
callServer({ type: "deleteIdentificationFile", fileID: request.fileID }, request.credentials));
ipcMain.handle("control-data:list", (_event, request) =>
callServer({
type: "listControlFiles",
deviceId: request.deviceId,
page: request.page,
pageSize: request.pageSize
}, request.credentials));
ipcMain.handle("control-data:preview", async (_event, request) => {
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
const fileName = download.fileName || request.fileName;
const sourcePath = await downloadFromUrl(download.url, fileName, request.credentials);
const extension = path.extname(fileName).toLowerCase();
let content = null;
if (extension === ".json") {
const text = await fs.promises.readFile(sourcePath, "utf8");
try {
content = JSON.stringify(JSON.parse(text), null, 2);
} catch (_error) {
content = text;
}
}
return {
filePath: sourcePath,
fileName,
uploadTime: download.uploadTime || request.uploadTime,
size: download.size ?? request.size,
content
};
});
ipcMain.handle("control-data:download", async (_event, request) => {
const download = await callServer({ type: "getControlFileDownload", fileID: request.fileID }, request.credentials);
const fileName = download.fileName || request.fileName;
const extension = path.extname(fileName).replace(/^\./, "") || "bin";
const selection = await dialog.showSaveDialog({
title: "保存控制原始数据",
defaultPath: fileName,
filters: [{ name: `${extension.toUpperCase()} 文件`, extensions: [extension] }]
});
if (selection.canceled) return null;
await downloadToPath(download.url, selection.filePath, request.credentials);
return { filePath: selection.filePath };
});
ipcMain.handle("control-data:delete", (_event, request) =>
callServer({ type: "deleteControlFile", fileID: request.fileID }, request.credentials));
ipcMain.handle("config:choose", async () => {
const result = await dialog.showOpenDialog({
title: "导入配置 JSON",
+5
View File
@@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld("reinloop", {
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
getLicense: (request) => ipcRenderer.invoke("license:get", request),
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
downloadLicense: (request) => ipcRenderer.invoke("license:download", request),
submitReview: (request) => ipcRenderer.invoke("review:submit", request),
listModels: (request) => ipcRenderer.invoke("model:list", request),
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
@@ -25,6 +26,10 @@ contextBridge.exposeInMainWorld("reinloop", {
previewIdentificationFile: (request) => ipcRenderer.invoke("identification:preview", request),
downloadIdentificationFile: (request) => ipcRenderer.invoke("identification:download", request),
deleteIdentificationFile: (request) => ipcRenderer.invoke("identification:delete", request),
listControlFiles: (request) => ipcRenderer.invoke("control-data:list", request),
previewControlFile: (request) => ipcRenderer.invoke("control-data:preview", request),
downloadControlFile: (request) => ipcRenderer.invoke("control-data:download", request),
deleteControlFile: (request) => ipcRenderer.invoke("control-data:delete", request),
onCsvUpdated: (callback) => ipcRenderer.on("csv:updated", (_event, result) => callback(result)),
onCsvWatchError: (callback) => ipcRenderer.on("csv:watch-error", (_event, message) => callback(message))
});
+14 -1
View File
@@ -20,7 +20,7 @@
<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>Server API URL</span><input id="api-url" type="url" placeholder="https://server.example.com" required></label>
<label><span>Admin Token</span><input id="admin-token" type="password" autocomplete="current-password" placeholder="请输入管理令牌"></label>
<button id="connect-button" class="button primary" type="submit">连接并校验</button>
<p id="connection-message" class="connection-message">校验通过后开放业务工作台</p>
@@ -42,6 +42,7 @@
<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="control-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>
@@ -115,6 +116,18 @@
</div>
</section>
<section id="control-data-panel" class="panel">
<div class="panel-head">
<div><p class="section-kicker">CONTROL DATA ARCHIVE</p><h2>控制数据暂存</h2></div>
<button id="refresh-control-files" class="button secondary">刷新</button>
</div>
<div class="data-surface">
<table><thead><tr><th>上传时间</th><th>文件名</th><th>类型</th><th>大小</th><th>操作</th></tr></thead><tbody id="control-file-list"></tbody></table>
<p id="control-file-empty" class="empty-row">选择公司和产线后刷新控制数据</p>
<pre id="control-file-detail" class="detail-view" hidden></pre>
</div>
</section>
<section id="config-panel" class="panel">
<div class="panel-head">
<div>
+122 -5
View File
@@ -26,6 +26,7 @@ const state = {
imagePaths: { csv: null, json: null },
imageDataUrls: { csv: null, json: null },
identificationFiles: [],
controlFiles: [],
configs: { volume: volumeExample, identification: identificationExample },
companies: [],
models: [],
@@ -80,6 +81,9 @@ const elements = {
modelEmpty: document.querySelector("#model-empty"),
identificationFileList: document.querySelector("#identification-file-list"),
identificationFileEmpty: document.querySelector("#identification-file-empty"),
controlFileList: document.querySelector("#control-file-list"),
controlFileEmpty: document.querySelector("#control-file-empty"),
controlFileDetail: document.querySelector("#control-file-detail"),
reviewTarget: document.querySelector("#review-target"),
approveReview: document.querySelector("#approve-review"),
rejectReview: document.querySelector("#reject-review"),
@@ -127,6 +131,13 @@ function formatSize(value) {
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function controlFileType(fileName) {
const extension = String(fileName || "").split(".").pop().toLowerCase();
if (extension === "pkl") return "控制 Episode 分片";
if (extension === "json") return "控制数据清单";
return extension ? `${extension.toUpperCase()} 文件` : "控制数据";
}
function escapeHtml(value) {
const node = document.createElement("span");
node.textContent = String(value ?? "");
@@ -242,7 +253,7 @@ async function refreshLicenses() {
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>
<td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button><button class="table-action" data-license-download="${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");
@@ -280,6 +291,38 @@ async function refreshIdentificationFiles() {
setStatus("辨识数据已刷新", "success");
}
async function refreshControlFiles() {
if (!elements.deviceId.value) return showError(new Error("请先选择公司和产线"));
const result = await runBusy("正在读取控制暂存数据", () => window.reinloop.listControlFiles({
deviceId: elements.deviceId.value, page: 1, pageSize: 100, credentials: credentials()
}));
if (!result) return;
state.controlFiles = result.files || result.fileList || [];
elements.controlFileList.innerHTML = state.controlFiles.map((file) => `
<tr><td>${escapeHtml(file.uploadTime || "-")}</td><td>${escapeHtml(file.fileName)}</td>
<td>${escapeHtml(controlFileType(file.fileName))}</td><td>${formatSize(file.size)}</td>
<td><button class="table-action" data-control-preview="${escapeHtml(file.fileID)}">查看</button><button class="table-action" data-control-download="${escapeHtml(file.fileID)}">下载</button><button class="table-action danger" data-control-delete="${escapeHtml(file.fileID)}">删除</button></td></tr>
`).join("");
elements.controlFileEmpty.hidden = state.controlFiles.length > 0;
elements.controlFileDetail.hidden = true;
elements.controlFileDetail.textContent = "";
setStatus("控制数据已刷新", "success");
}
function showControlFilePreview(result) {
const metadata = [
`文件名:${result.fileName}`,
`上传时间:${result.uploadTime || "-"}`,
`大小:${formatSize(result.size)}`,
`本地缓存:${result.filePath}`
];
const detail = result.content === null
? `${metadata.join("\n")}\n\n此文件为二进制控制 Episode 分片(.pkl),请下载后使用 ReinLoop/Python 分析。`
: `${metadata.join("\n")}\n\n${result.content}`;
elements.controlFileDetail.textContent = detail;
elements.controlFileDetail.hidden = false;
}
function showError(error) {
const message = (error?.message || String(error))
.replace(/^Error invoking remote method '[^']+': Error: /, "");
@@ -503,20 +546,48 @@ document.querySelector("#license-form").addEventListener("submit", async (event)
});
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;
const button = event.target.closest("button");
if (!button) return;
const detailId = button.dataset.licenseDetail;
const downloadId = button.dataset.licenseDownload;
const revokeId = button.dataset.licenseRevoke;
if (detailId) {
const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() }));
if (result) {
elements.licenseDetail.textContent = JSON.stringify(result.license, null, 2);
elements.licenseDetail.hidden = false;
}
return;
}
if (downloadId) {
button.disabled = true;
try {
const result = await runBusy("正在下载许可证", () => window.reinloop.downloadLicense({
licenseId: downloadId, credentials: credentials()
}));
if (result) setStatus(`许可证已保存: ${result.filePath}`, "success");
} finally {
button.disabled = false;
}
return;
}
if (revokeId) {
const reason = window.prompt("请输入撤销原因", "管理员撤销");
if (reason === null) return;
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ licenseId: revokeId, reason, credentials: credentials() }));
if (result) await refreshLicenses();
const trimmedReason = reason.trim() || "管理员撤销";
if (!window.confirm(`确认撤销此许可证?\n原因:${trimmedReason}`)) return;
button.disabled = true;
try {
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({
licenseId: revokeId, reason: trimmedReason, credentials: credentials()
}));
if (result) {
await refreshLicenses();
setStatus("许可证已撤销", "success");
}
} finally {
button.disabled = false;
}
}
});
@@ -714,3 +785,49 @@ document.addEventListener("keydown", (event) => {
if (!elements.lightbox.hidden && event.key === "+") zoomLightbox(1.25);
if (!elements.lightbox.hidden && event.key === "-") zoomLightbox(0.8);
});
document.querySelector("#refresh-control-files").addEventListener("click", refreshControlFiles);
elements.controlFileList.addEventListener("click", async (event) => {
const button = event.target.closest("button");
if (!button) return;
const fileID = button.dataset.controlPreview || button.dataset.controlDownload || button.dataset.controlDelete;
if (!fileID) return;
const file = state.controlFiles.find((item) => item.fileID === fileID);
if (!file) return;
if (button.dataset.controlPreview) {
button.disabled = true;
try {
const result = await runBusy("正在下载控制数据预览", () => window.reinloop.previewControlFile({
...file, credentials: credentials()
}));
if (result) {
showControlFilePreview(result);
setStatus("控制数据预览已载入", "success");
}
} finally {
button.disabled = false;
}
} else if (button.dataset.controlDownload) {
button.disabled = true;
try {
const result = await runBusy("正在保存控制原始数据", () => window.reinloop.downloadControlFile({
...file, credentials: credentials()
}));
if (result) setStatus(`已保存到: ${result.filePath}`, "success");
} finally {
button.disabled = false;
}
} else {
if (!window.confirm(`确认永久删除 ${file.fileName}`)) return;
if (!window.confirm("删除后无法恢复,确认继续?")) return;
button.disabled = true;
try {
const result = await runBusy("正在删除控制数据", () => window.reinloop.deleteControlFile({
fileID, credentials: credentials()
}));
if (result) await refreshControlFiles();
} finally {
button.disabled = false;
}
}
});
+28
View File
@@ -179,6 +179,7 @@ base64(JSON)|base64(signature)
- 刷新许可证列表
- 查看许可证详情
- 下载已签发许可证
- 撤销有效许可证
- 填写撤销原因
- 区分有效和已撤销状态
@@ -192,6 +193,33 @@ getLicense
revokeLicense
```
下载已签发许可证复用 `getLicense` 返回的许可证原文;Panel 在本地选择保存位置后写入 `.lic` 文件。
## 5.1 控制数据暂存
“控制数据”页面按当前产线显示 ReinLoop 在控制结束后上传到
`<deviceId>/data_record/` 的 Episode 分片和清单文件。页面支持:
- 刷新控制数据列表
- 查看 JSON 清单内容;`.pkl` 分片显示文件元数据和本地缓存位置
- 下载任意控制原始文件
- 删除指定控制数据文件
控制 Episode 分片采用 Python pickle 格式,Panel 不在渲染进程反序列化该二进制数据;
需要详细分析时,应下载后使用 ReinLoop/Python 读取。JSON manifest 可直接在页面中查看。
Panel 需要 Server 提供以下 Admin 接口:
```text
listControlFiles deviceId, page, pageSize -> files, total, page, pageSize
getControlFileDownload fileID -> fileID, fileName, uploadTime, size, url
deleteControlFile fileID -> deletedCount
```
每条 `files` 记录至少包含 `fileID``fileName``uploadTime``size`。下载接口必须返回
可下载原始文件的短期签名 URL;删除接口必须同时删除文件本体和元数据,并仅允许删除该设备的
控制数据目录中的文件。
## 6. 模型管理
“模型管理”页面按当前产线操作:
+1 -1
View File
@@ -22,7 +22,7 @@ pressure_control_gui/
│ ├── control_engine.py # 控制主循环(PID / RL / 手动),含 EMA 压力滤波
│ ├── connection_manager.py# 连接管理器(封装 MT2AM8Client
│ ├── model_manager.py # RL 模型管理
│ ├── data_collector.py # 数据采集与云上传
│ ├── data_collector.py # 数据采集与云服务器上传
│ └── identification.py # 系统辨识与容积测量管理
├── ui/
│ ├── main_window.py # 主窗口(布局与信号槽绑定)
+6 -3
View File
@@ -1,4 +1,4 @@
"""ReinLoop server endpoint configuration shared by core modules."""
"""ReinLoop cloud-server endpoint configuration shared by core modules."""
import os
@@ -7,12 +7,15 @@ from license_utils import get_verified_license
base_url = os.environ.get(
"REINLOOP_SERVER_URL",
"http://ReinLoop.dominatedconvergence.com",
"https://ReinLoop.dominatedconvergence.com",
).rstrip("/")
data_record_url = os.environ.get(
server_api_url = os.environ.get(
"REINLOOP_API_URL",
f"{base_url}/api",
)
# Compatibility alias used by existing modules. It points to the ReinLoop
# Express server API, not a cloud-function endpoint.
data_record_url = server_api_url
_license = get_verified_license()
_license_device_id = (_license or {}).get("device_id", "").strip()
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
+59 -24
View File
@@ -22,17 +22,30 @@ class DataCollector:
self.current_episode = None # 当前正在记录的 Episode
self.last_target_record = None
self._on_log = None
self._on_upload_complete = None
def set_log_callback(self, callback):
"""设置日志回调"""
self._on_log = callback
def set_upload_complete_callback(self, callback):
"""设置控制数据上传完成回调。
callback(success, manifest, error) 会在后台上传线程中调用成功时
manifest 是已上传的清单字典失败时 error 为可展示的错误信息
"""
self._on_upload_complete = callback
def log(self, message):
if self._on_log:
self._on_log(message)
def _upload_to_cos(self, data_bytes: bytes, filename: str, folder: str) -> bool:
"""通过云函数获取直传凭证,再将数据直传到腾讯云 COS。"""
def _notify_upload_complete(self, success, manifest=None, error=None):
if self._on_upload_complete:
self._on_upload_complete(success, manifest, error)
def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool:
"""向 ReinLoop 云服务器申请上传地址并上传控制数据。"""
try:
resp = requests.post(data_record_url, json={
"type": "uploadDataFile",
@@ -41,35 +54,29 @@ class DataCollector:
}, timeout=30)
result = resp.json()
except Exception as e:
self.log(f"向云函数申请凭证异常: {e}")
self.log(f"向云服务器申请上传地址异常: {e}")
return False
if not result.get("success"):
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
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("未返回有效的上传元数据")
if not meta or "url" 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)
upload_resp = requests.post(meta["url"], files=files, timeout=60)
if cos_resp.status_code in [200, 204]:
if upload_resp.status_code in [200, 204]:
return True
else:
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}")
return False
except Exception as e:
self.log(f"COS 直传异常: {e}")
self.log(f"云服务器上传异常: {e}")
return False
def reset(self):
@@ -136,8 +143,12 @@ class DataCollector:
if not self.episode_data_raw:
return
# 上传在线程中继续执行,因此必须持有本轮数据快照。否则 finally
# 清空缓存后,异步线程生成的 manifest 会错误地显示 0 个 Episode。
episodes = list(self.episode_data_raw)
try:
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
base_folder = f"{the_folder}/data_record/data_{flow}SLM_{vol}L"
# 拆成每片尽量不超过 5MB 的 episode 分组
@@ -145,55 +156,79 @@ class DataCollector:
chunks = [] # [(chunk_index, episodes_subset)]
current_chunk = []
for ep in self.episode_data_raw:
for ep in episodes:
current_chunk.append(ep)
if pickle.dumps(current_chunk).__len__() >= MAX_CHUNK_BYTES:
# 当前片已满,回退一个 episode 后保存
current_chunk.pop()
# 单个 Episode 也可能超过 5 MB;此时仍上传该 Episode
# 而不是产生一个无内容的空分片。
if current_chunk:
chunks.append(current_chunk)
current_chunk = [ep]
if current_chunk:
chunks.append(current_chunk)
total_chunks = len(chunks)
self.log(f"控制数据共 {len(self.episode_data_raw)} 个 Episode"
self.log(f"控制数据共 {len(episodes)} 个 Episode"
f"拆为 {total_chunks} 个分片上传")
def upload_all():
part_files = []
part_metadata = []
for idx, chunk_eps in enumerate(chunks):
data_bytes = pickle.dumps(chunk_eps)
size_kb = len(data_bytes) / 1024
part_filename = f'episode_raw_data_{timestamp}_part{idx + 1}of{total_chunks}.pkl'
self.log(f" 上传分片 {idx + 1}/{total_chunks} ({size_kb:.0f} KB)...")
if self._upload_to_cos(data_bytes, part_filename, base_folder):
if self._upload_to_server(data_bytes, part_filename, base_folder):
part_files.append(part_filename)
part_metadata.append({
"file_name": part_filename,
"episode_count": len(chunk_eps),
"size_bytes": len(data_bytes),
})
else:
self.log(f" 分片 {idx + 1} 上传失败")
# 上传 manifest
manifest = {
"schema_version": 1,
"data_type": "control_episode",
"run_id": timestamp,
"timestamp": timestamp,
"folder": base_folder,
"total_chunks": total_chunks,
"uploaded_chunks": len(part_files),
"part_files": part_files,
"total_episodes": len(self.episode_data_raw),
"parts": part_metadata,
"total_episodes": len(episodes),
"flow": flow,
"volume": vol,
}
manifest_str = json.dumps(manifest, indent=2, ensure_ascii=False)
manifest_bytes = manifest_str.encode('utf-8')
manifest_filename = f'episode_raw_data_{timestamp}_manifest.json'
self._upload_to_cos(manifest_bytes, manifest_filename, base_folder)
manifest_uploaded = self._upload_to_server(
manifest_bytes, manifest_filename, base_folder
)
if len(part_files) == total_chunks:
if len(part_files) == total_chunks and manifest_uploaded:
self.log(f"控制数据上传成功 ({total_chunks} 个分片)")
self._notify_upload_complete(True, manifest, None)
else:
self.log(f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})")
error = (
"控制数据清单上传失败"
if not manifest_uploaded
else f"控制数据部分上传失败 ({len(part_files)}/{total_chunks})"
)
self.log(error)
self._notify_upload_complete(False, manifest, error)
threading.Thread(target=upload_all, daemon=True).start()
except Exception as e:
self.log(f"保存收集数据时发生错误: {e}")
self._notify_upload_complete(False, None, str(e))
finally:
self.episode_data_raw = []
+15 -21
View File
@@ -59,12 +59,12 @@ class IdentificationManager:
)
return self._identifying or thread_alive
def _upload_to_cos(self, content, filename: str, folder: str) -> bool:
"""通过云函数获取直传凭证,再将文本或字节数据直传到 COS
def _upload_to_server(self, content, filename: str, folder: str) -> bool:
"""向 ReinLoop 云服务器申请上传地址并上传文本或字节数据
返回 True 表示上传成功False 表示失败已内部记 log
"""
# Step 1: 向云函数申请直传凭证(不传文件内容)
# Step 1: 向业务服务器申请一次性上传地址(不传文件内容)
try:
resp = requests.post(data_record_url, json={
"type": "uploadDataFile",
@@ -73,40 +73,34 @@ class IdentificationManager:
}, timeout=30)
result = resp.json()
except Exception as e:
self.log(f"向云函数申请凭证异常: {e}")
self.log(f"向云服务器申请上传地址异常: {e}")
return False
if not result.get("success"):
self.log(f"申请上传凭证失败: {result.get('errMsg', result)}")
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("未返回有效的上传元数据")
if not meta or "url" not in meta:
self.log("服务器未返回有效的上传地址")
return False
# Step 2: 直传到 COS
# Step 2: multipart 上传到云服务器提供的一次性地址
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)
upload_resp = requests.post(meta["url"], files=files, timeout=60)
if cos_resp.status_code in [200, 204]:
if upload_resp.status_code in [200, 204]:
return True
else:
self.log(f"COS 直传失败,状态码: {cos_resp.status_code}")
self.log(f"云服务器上传失败,状态码: {upload_resp.status_code}")
return False
except Exception as e:
self.log(f"COS 直传异常: {e}")
self.log(f"云服务器上传异常: {e}")
return False
def _run_initial_travel_scan(self, conn_mgr):
@@ -226,7 +220,7 @@ class IdentificationManager:
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"travel_stability_pressures_{timestamp}.json"
payload = {"stable_pressures": stable_pressure_records}
uploaded = self._upload_to_cos(
uploaded = self._upload_to_server(
json.dumps(payload, ensure_ascii=False, indent=2),
filename,
f"{the_folder}/ind_data",
@@ -309,7 +303,7 @@ class IdentificationManager:
self.log(error)
if self._on_identification_upload:
self._on_identification_upload(False, None, error)
elif self._upload_to_cos(
elif self._upload_to_server(
csv_data, csv_filename, f"{the_folder}/ind_data"):
self.log("辨识数据上传成功")
if self._on_identification_upload:
@@ -458,7 +452,7 @@ class IdentificationManager:
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"):
if self._upload_to_server(json_str, filename, f"{the_folder}/V_config"):
self.log("体积测量数据上传成功")
self.log(f"成功:{n}/{num_runs},平均等效体积 V = {avg_vol:.4f} L")
+3 -3
View File
@@ -128,7 +128,7 @@ def parse_identification_config_csv(csv_text: str) -> dict:
def download_identification_config(timeout=20) -> dict:
"""Download the current customer's CSV config through the cloud function."""
"""Download the current customer's CSV config through the cloud server."""
import requests
from api import data_record_url, the_folder
@@ -140,10 +140,10 @@ def download_identification_config(timeout=20) -> dict:
response.raise_for_status()
result = response.json()
except Exception as exc:
raise ValueError(f"连接云辨识配置服务失败: {exc}") from exc
raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc
if not result.get("success"):
raise ValueError(result.get("errMsg", "未返回辨识配置"))
raise ValueError(result.get("errMsg", "服务器未返回辨识配置"))
try:
config_response = requests.get(result["url"], timeout=timeout)
config_response.raise_for_status()
+21 -3
View File
@@ -5,7 +5,8 @@
## 约定
- 业务服务端地址由 `REINLOOP_API_URL` 指定,未设置时使用
`REINLOOP_SERVER_URL + /api`
`REINLOOP_SERVER_URL + /api`;默认地址为
`https://ReinLoop.dominatedconvergence.com/api`
- 新许可证的设备标识为 `<company_code>/<production_line_code>`,在代码中通过
`api.the_folder` 使用。旧许可证才回退到 `REINLOOP_DEVICE_ID`
- 所有服务端业务请求均使用 `POST /api`,通过请求体的 `type` 字段分发。
@@ -67,11 +68,28 @@ RL 模式下,`ControlEngine` 使用模型根据流量、当前压力和压力
| --- | --- | --- |
| 初始化一轮采集 | `DataCollector.reset()` | 清空 Episode 缓存。 |
| 记录控制点 | `DataCollector.record_step(cycle_count, current_pressure, target_pressure, valve_opening, kp, ki, kd, q_in, v)` | 目标压力变化时自动切分 Episode。 |
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/...`。 |
| 上传凭证与直传 | `DataCollector._upload_to_cos(data_bytes, filename, folder)` | 内部接口;先请求上传凭证,再将对象直传。 |
| 停止后异步上传 | `DataCollector.finalize_and_upload(flow, vol)` | 分片上传 Pickle 数据和 manifest 到 `<deviceId>/data_record/data_<flow>SLM_<volume>L`。清单含 `data_type: "control_episode"``run_id`、分片元数据和总 Episode 数。 |
| 申请上传地址并上传 | `DataCollector._upload_to_server(data_bytes, filename, folder)` | 内部接口;先向 ReinLoop 云服务器申请一次性上传地址,再以 multipart 上传文件。 |
| 上传结果通知 | `DataCollector.set_upload_complete_callback(callback)` | 注册 `callback(success, manifest, error)`;在后台上传线程完成时调用。 |
上传地址通过服务端 `uploadDataFile` 获取。单个 Episode 数据文件超过约 5 MB 时会自动拆分。
### 控制数据服务端约定
控制数据上传目录固定以 `<deviceId>/data_record/` 为前缀;每次停止控制会上传
若干 `.pkl` 分片和一个同名时间戳的 `_manifest.json`。服务端在接收二步上传的文件后,
应保留文件元数据,并向管理端提供以下仅管理员可调用的接口:
| `type` | 请求字段 | 成功响应 | 服务端行为 |
| --- | --- | --- | --- |
| `listControlFiles` | `deviceId`、可选 `page``pageSize` | `files``total``page``pageSize` | 仅返回 `folder``<deviceId>/data_record/` 开头的记录;每条至少有 `fileID``fileName``folder``uploadTime``size`。 |
| `getControlFileDownload` | `fileID` | `fileID``fileName``url` | 仅允许下载控制数据目录内的文件,并返回短期签名下载 URL。 |
| `deleteControlFile` | `fileID` | `deletedCount` | 仅允许删除控制数据目录内的文件;同时删除文件本体及对应元数据。 |
上述三个接口必须校验管理端令牌,并根据 `fileID` 对应记录的目录验证设备边界,不能仅信任
调用方传入的设备标识。Panel 可直接展示 JSON manifest`.pkl` 为 Python pickle 二进制,
应仅供下载,不应在管理端进程中反序列化。
## 系统辨识
| 功能 | 接口 | 返回或行为 |
+1 -1
View File
@@ -274,7 +274,7 @@ def _is_new_license(payload):
def _api_url():
base_url = os.environ.get(
"REINLOOP_SERVER_URL", "http://ReinLoop.dominatedconvergence.com"
"REINLOOP_SERVER_URL", "https://ReinLoop.dominatedconvergence.com"
).rstrip("/")
return os.environ.get("REINLOOP_API_URL", f"{base_url}/api")
+96
View File
@@ -0,0 +1,96 @@
import importlib.util
import json
import pickle
from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "data_collector.py"
def load_data_collector_module():
api = types.ModuleType("api")
api.base_url = "https://cloud.example"
api.data_record_url = "https://cloud.example/api"
api.the_folder = "customer-a/line-1"
requests = types.ModuleType("requests")
spec = importlib.util.spec_from_file_location(
"data_collector_under_test", MODULE_PATH
)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"api": api, "requests": requests}):
spec.loader.exec_module(module)
return module
DATA_COLLECTOR = load_data_collector_module()
class ImmediateThread:
def __init__(self, target, daemon):
self.target = target
def start(self):
self.target()
class DataCollectorUploadTests(unittest.TestCase):
def test_uploads_control_manifest_with_complete_metadata(self):
collector = DATA_COLLECTOR.DataCollector()
collector.record_step(0, 10.0, 20.0, 30.0, 1.0, 0.2, 0.0, 50.0, 5.0)
collector.record_step(1, 11.0, 20.0, 31.0, 1.0, 0.2, 0.0, 50.0, 5.0)
uploads = []
completions = []
collector._upload_to_server = lambda data, name, folder: (
uploads.append((data, name, folder)) or True
)
collector.set_upload_complete_callback(
lambda success, manifest, error: completions.append(
(success, manifest, error)
)
)
with patch.object(DATA_COLLECTOR.threading, "Thread", ImmediateThread):
collector.finalize_and_upload(50.0, 5.0)
self.assertEqual(len(uploads), 2)
part_data, part_name, folder = uploads[0]
manifest_data, manifest_name, manifest_folder = uploads[1]
self.assertTrue(part_name.endswith(".pkl"))
self.assertTrue(manifest_name.endswith("_manifest.json"))
self.assertEqual(folder, "customer-a/line-1/data_record/data_50.0SLM_5.0L")
self.assertEqual(manifest_folder, folder)
self.assertEqual(len(pickle.loads(part_data)), 1)
manifest = json.loads(manifest_data)
self.assertEqual(manifest["schema_version"], 1)
self.assertEqual(manifest["data_type"], "control_episode")
self.assertEqual(manifest["total_episodes"], 1)
self.assertEqual(manifest["uploaded_chunks"], 1)
self.assertEqual(manifest["part_files"], [part_name])
self.assertEqual(manifest["parts"][0]["file_name"], part_name)
self.assertEqual(completions, [(True, manifest, None)])
self.assertEqual(collector.episode_data_raw, [])
def test_does_not_create_an_empty_chunk_for_oversized_episode(self):
collector = DATA_COLLECTOR.DataCollector()
collector.episode_data_raw = [{"payload": "x" * (5 * 1024 * 1024)}]
uploads = []
collector._upload_to_server = lambda data, name, folder: (
uploads.append((data, name, folder)) or True
)
with patch.object(DATA_COLLECTOR.threading, "Thread", ImmediateThread):
collector.finalize_and_upload(1.0, 1.0)
part_data = uploads[0][0]
self.assertEqual(len(pickle.loads(part_data)), 1)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -72,7 +72,7 @@ class InitialTravelScanTests(unittest.TestCase):
captured.update(body=body, filename=filename, folder=folder)
return True
manager._upload_to_cos = capture_upload
manager._upload_to_server = capture_upload
clock = FakeClock()
with patch.object(IDENTIFICATION.time, "monotonic", clock.monotonic), \
patch.object(IDENTIFICATION.time, "sleep", clock.sleep):
@@ -104,7 +104,7 @@ class InitialTravelScanTests(unittest.TestCase):
csv_data = b"t,u,p,q_in,V\n0.0,10.0,20.0,50.0,5.0\n"
csv_filename = "identification_data_test.csv"
manager._upload_to_cos = lambda content, filename, folder: (
manager._upload_to_server = lambda content, filename, folder: (
uploaded.update(
content=content, filename=filename, folder=folder
) or True
+178
View File
@@ -0,0 +1,178 @@
1. 控制数据(ReinLoop 里的 core/data_collecter)和辨识数据一样,上传服务器后,需要服务器向panel提供删除下载查看功能。
2. 控制台许可证撤销界面点了没有反应,需要实现撤销和下载功能。
修改reinloop和panel,并给出服务器端需要实现的接口及功能。
## 服务端访问约定
不再调用微信云函数或云存储接口。ReinLoop 与 ControlPanel 统一访问云服务器:
```text
https://ReinLoop.dominatedconvergence.com/api
```
- 所有业务接口均使用 `POST /api`,请求与响应均为 JSON。
- 通过请求体中的 `type` 字段区分业务功能。
- 管理端接口必须携带 `adminToken`,由服务端校验 `B_ADMIN_TOKEN`;ReinLoop 客户端上传控制数据时不携带管理令牌。
- 所有响应必须包含 `success: true|false`;失败时必须提供可展示的 `errMsg`
- 设备标识 `deviceId` 固定为 `<company-code>/<line-code>`,服务端必须校验其格式,禁止路径遍历。
- 服务端需要设置 `PUBLIC_BASE_URL=https://ReinLoop.dominatedconvergence.com`,确保上传地址和下载地址均为可从客户端访问的 HTTPS URL。
### 通用文件上传接口:`uploadDataFile`
ReinLoop 的控制数据、辨识数据及配置文件均通过此两步协议上传:
1. 客户端调用业务接口申请一次性上传地址:
```json
{
"type": "uploadDataFile",
"fileName": "episode_raw_data_20260730_120000_part1of2.pkl",
"folder": "<deviceId>/data_record/data_50SLM_5L"
}
```
2. 服务端返回 `uploadMetadata.url` 后,客户端以 `multipart/form-data` 向该 URL 提交 `file` 字段。上传成功应返回 HTTP `204``200`
服务端需要在上传完成时保存文件本体和 `fileRecords` 元数据(包括 `fileID``fileName``folder``uploadTime``size`)。控制数据目录必须以 `<deviceId>/data_record/` 为前缀。
## 服务器端接口需求(控制数据)
控制数据由 `ReinLoop/core/data_collector.py` 上传到
`<deviceId>/data_record/`,包括控制 Episode 的 `.pkl` 分片和对应的 JSON manifest。
以下接口均为管理端接口,要求请求体携带有效的 `adminToken`;响应统一包含
`success: true|false`,失败时返回 `errMsg`
### `listControlFiles`
按设备分页查询控制数据文件,供 Panel 的“控制数据”列表使用。
请求:
```json
{
"type": "listControlFiles",
"adminToken": "<B_ADMIN_TOKEN>",
"deviceId": "<company-code>/<line-code>",
"page": 1,
"pageSize": 100
}
```
成功响应:
```json
{
"success": true,
"files": [
{
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/...",
"fileName": "episode_raw_data_20260730_120000_part1of2.pkl",
"uploadTime": "2026-07-30T04:00:00.000Z",
"size": 123456
}
],
"total": 1,
"page": 1,
"pageSize": 100
}
```
服务端只可返回指定 `deviceId``data_record` 目录及其子目录中的文件;按上传时间倒序排列,
`pageSize` 建议限制在 $1\dots100$。
### `getControlFileDownload`
`fileID` 获取控制数据原始文件的短期下载地址,供 Panel 查看 JSON manifest 或保存 `.pkl` / `.json` 文件。
请求:
```json
{
"type": "getControlFileDownload",
"adminToken": "<B_ADMIN_TOKEN>",
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/..."
}
```
成功响应:
```json
{
"success": true,
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/...",
"fileName": "episode_raw_data_20260730_120000_manifest.json",
"uploadTime": "2026-07-30T04:00:00.000Z",
"size": 1024,
"url": "https://server.example/files/...?..."
}
```
`url` 必须是绑定该文件且会过期的签名 URL,不能根据任意路径直接下载。服务端须校验文件存在,
且该文件必须属于控制数据目录。
### `deleteControlFile`
永久删除指定控制数据文件,供 Panel 的二次确认删除操作使用。
请求:
```json
{
"type": "deleteControlFile",
"adminToken": "<B_ADMIN_TOKEN>",
"fileID": "local://ReinLoop_GUI/<deviceId>/data_record/..."
}
```
成功响应:
```json
{
"success": true,
"deletedCount": 1
}
```
服务端须同时删除文件本体及 `fileRecords` 中的元数据;必须校验 `fileID` 属于控制数据目录,
禁止借此接口删除模型、辨识数据、配置或许可证相关文件。文件不存在时返回明确错误,不应将删除操作视为成功。
## 许可证接口补充
### `getLicense`
Panel 的许可证“下载”复用既有 `getLicense` 接口。请求:
```json
{
"type": "getLicense",
"adminToken": "<B_ADMIN_TOKEN>",
"licenseId": "<license-uuid>"
}
```
成功响应中的 `license` 对象必须包含原始许可证文本字段 `license`Panel 将该字段保存为 `.lic` 文件。
服务端不得将私钥或其他许可证的内容一并返回。
### `revokeLicense`
Panel 的许可证撤销使用既有 `revokeLicense` 接口:
```json
{
"type": "revokeLicense",
"adminToken": "<B_ADMIN_TOKEN>",
"licenseId": "<license-uuid>",
"reason": "管理员撤销原因"
}
```
成功响应应返回 `success: true` 及更新后的许可证对象,其中 `status``revoked`。服务端必须保留
`revokedAt``revocationReason` 审计信息;许可证在线校验接口 `validateLicense` 随后应返回
`valid: false``status: "revoked"`,使 ReinLoop 客户端在下一次许可证巡检时生效。
### Panel 对应功能
- “控制数据”页面:调用 `listControlFiles` 刷新列表;JSON manifest 可请求下载后直接预览,`.pkl` 仅提供下载;删除前需二次确认。
- “许可证”页面:调用 `getLicense` 下载 `.lic`;调用 `revokeLicense` 撤销,并在成功后刷新许可证列表。
- Panel 不得自行拼接服务器文件路径、下载 URL 或绕过上述 Admin 接口访问文件。