699 lines
33 KiB
JavaScript
699 lines
33 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const API_ROOT = "/api/calibration";
|
|
const TARGET_KEY = "cognitive-agent.calibration-target";
|
|
const pageTitles = {
|
|
hall: ["N7 SEAT CALIBRATION", "座椅 Hall 标定", "四轴位置、端点与自动标定"],
|
|
stereo: ["STEREO CALIBRATION", "双目映射标定", "MQTT 位姿、RTSP 画面与 Hall 映射"],
|
|
system: ["CONNECTION SETTINGS", "连接与运行设置", "CAN、MQTT 与 RTSP 接入参数"],
|
|
};
|
|
const phaseLabels = {
|
|
0: "待命",
|
|
1: "寻找最近点",
|
|
2: "寻找最远点",
|
|
3: "返回最近点",
|
|
4: "完成",
|
|
5: "失败",
|
|
6: "已停止",
|
|
};
|
|
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
|
|
let currentState = null;
|
|
let eventSource = null;
|
|
let reconnectTimer = 0;
|
|
let toastTimer = 0;
|
|
let settingsHydrated = false;
|
|
let modelRequested = false;
|
|
let activeView = "hall";
|
|
|
|
function buildShell() {
|
|
const sidebar = $(".v2-sidebar");
|
|
const mainPanel = $(".main-panel");
|
|
const moduleSwitcher = $("#v2ModuleSwitcher");
|
|
if (!sidebar || !mainPanel || !moduleSwitcher || $("#v2SelfCalibration")) return false;
|
|
|
|
const calibrationSidebar = document.createElement("section");
|
|
calibrationSidebar.className = "v2-sidebar-section v2-calibration-sidebar";
|
|
calibrationSidebar.innerHTML = `
|
|
<div class="v2-section-heading">
|
|
<span>CALIBRATION DESK</span>
|
|
<h2>控制台</h2>
|
|
</div>
|
|
<span class="v2-field-caption">标定方式</span>
|
|
<div class="v2-calibration-mode-switch" role="group" aria-label="标定方式">
|
|
<button type="button" class="active" data-calibration-view="hall"><i data-lucide="armchair"></i><span>座椅标定</span></button>
|
|
<button type="button" data-calibration-view="stereo"><i data-lucide="scan"></i><span>双目标定</span></button>
|
|
</div>
|
|
<div class="v2-divider"></div>
|
|
<span class="v2-field-caption">配置信息</span>
|
|
<div class="v2-calibration-config-stack">
|
|
<div class="v2-calibration-config-card"><i data-lucide="car-front"></i><div><small>当前车型</small><strong>腾势 N7</strong></div><span>N7</span></div>
|
|
<div class="v2-calibration-config-card"><i data-lucide="sparkles"></i><div><small>推理模型</small><strong>GPT-5.6-sol</strong></div><span>READY</span></div>
|
|
<div class="v2-calibration-config-card v2-config-select-card" id="calibrationTargetCard">
|
|
<i data-lucide="workflow"></i>
|
|
<div class="v2-config-card-copy">
|
|
<small>执行目标</small>
|
|
<div class="v2-config-card-select">
|
|
<select id="calibrationExecutionSelect" aria-label="选择自标定执行目标">
|
|
<option value="hardware">真实台架</option>
|
|
<option value="simulation">标定仿真台架</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<span id="calibrationTargetState">IDLE</span>
|
|
</div>
|
|
</div>
|
|
<p class="v2-calibration-target-note" id="calibrationTargetNote">选择执行目标后连接台架</p>
|
|
<button class="v2-calibration-connect" id="calibrationConnectTarget" type="button"><i data-lucide="plug-zap"></i><span>连接真实台架</span></button>
|
|
<button class="v2-calibration-settings-link" id="calibrationSettingsLink" type="button"><i data-lucide="settings-2"></i><span>连接设置</span><i data-lucide="chevron-right"></i></button>`;
|
|
sidebar.insertBefore(calibrationSidebar, $(".v2-sidebar-footer"));
|
|
|
|
const calibrationFooter = document.createElement("footer");
|
|
calibrationFooter.className = "v2-sidebar-footer v2-calibration-footer";
|
|
calibrationFooter.innerHTML = `
|
|
<span class="v2-live-dot"></span>
|
|
<div><strong>自标定安全链路</strong><small id="calibrationConnectionText">尚未连接台架</small></div>
|
|
<button type="button" id="calibrationGlobalStop" title="全轴安全停止" aria-label="全轴安全停止"><i data-lucide="octagon"></i></button>`;
|
|
sidebar.append(calibrationFooter);
|
|
|
|
const root = document.createElement("section");
|
|
root.className = "v2-self-calibration";
|
|
root.id = "v2SelfCalibration";
|
|
root.setAttribute("aria-label", "N7 自标定工作台");
|
|
root.innerHTML = `
|
|
<header class="v2-calibration-heading">
|
|
<div>
|
|
<span class="section-kicker" id="calibrationPageKicker">N7 SEAT CALIBRATION</span>
|
|
<h1 id="calibrationPageTitle">座椅 Hall 标定</h1>
|
|
<p id="calibrationPageDescription">四轴位置、端点与自动标定</p>
|
|
</div>
|
|
<div class="v2-calibration-statuses">
|
|
<div><i id="calibrationCanDot"></i><span><small>CAN</small><strong id="calibrationCanState">未连接</strong></span></div>
|
|
<div><i id="calibrationMotorDot"></i><span><small>MOTOR</small><strong id="calibrationMotorState">等待状态</strong></span></div>
|
|
<div><i id="calibrationMqttDot"></i><span><small>MQTT</small><strong id="calibrationMqttState">等待数据</strong></span></div>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="v2-calibration-view active" data-calibration-page="hall">
|
|
<div class="v2-calibration-hero-grid">
|
|
<section class="v2-calibration-panel v2-calibration-stage">
|
|
<header><div><span class="section-kicker">DIGITAL TWIN</span><h2>实时座椅姿态</h2></div><span class="v2-calibration-badge wait" id="calibrationPhaseBadge">待命</span></header>
|
|
<div class="v2-calibration-seat-scene">
|
|
<div class="v2-calibration-floor-grid"></div>
|
|
<div id="seatModelHost" class="seat-model-host">
|
|
<canvas id="seatCanvas" aria-label="N7 原始三维座椅模型,可拖动旋转并滚轮缩放"></canvas>
|
|
<div id="seatModelLoading" class="model-loading">
|
|
<span class="model-spinner"></span>
|
|
<strong>正在加载原始 N7 座椅</strong>
|
|
<small>ANDROID GLB · LOCAL</small>
|
|
</div>
|
|
<div class="orbit-hint">拖动旋转 · 滚轮缩放 · 双击复位</div>
|
|
</div>
|
|
<div class="v2-axis-tag tag-back">靠背 <b id="calibrationTagBack">0.0°</b></div>
|
|
<div class="v2-axis-tag tag-base">前后 <b id="calibrationTagLong">0.00 cm</b></div>
|
|
<div class="v2-axis-tag tag-leg">腿托 <b id="calibrationTagLeg">0.0°</b></div>
|
|
<div class="v2-axis-tag tag-height">高度 <b id="calibrationTagHeight">0.00 cm</b></div>
|
|
</div>
|
|
<div class="v2-calibration-operation">
|
|
<i></i><div><small>当前任务</small><strong id="calibrationOperationLabel">等待设备检测</strong></div><span>JOB <b id="calibrationJobId">—</b></span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="v2-calibration-panel v2-calibration-workflow">
|
|
<header><div><span class="section-kicker">CALIBRATION FLOW</span><h2>自动标定</h2></div></header>
|
|
<p>依次寻找近端、远端并返回近端,完成后保存真实 Hall 量程。</p>
|
|
<div class="v2-calibration-flow">
|
|
<div data-phase="1"><span>01</span><div><strong>寻找最近点</strong><small>建立机械零位</small></div></div>
|
|
<i></i>
|
|
<div data-phase="2"><span>02</span><div><strong>寻找最远点</strong><small>测量完整量程</small></div></div>
|
|
<i></i>
|
|
<div data-phase="3"><span>03</span><div><strong>返回最近点</strong><small>验证端点一致性</small></div></div>
|
|
</div>
|
|
<button class="v2-calibration-primary" id="calibrationStartAll" type="button" disabled><span>开始四轴自动标定</span><i data-lucide="arrow-right"></i></button>
|
|
<div class="v2-calibration-safety"><i data-lucide="shield-check"></i><p><strong>安全租约已启用</strong><br>运动期间每 500 ms 发送心跳,超时由固件自动 STOP。</p></div>
|
|
</section>
|
|
</div>
|
|
<section class="v2-calibration-axis-section">
|
|
<header><div><span class="section-kicker">LIVE AXES</span><h2>四轴实时控制</h2></div><p>松开滑块发送目标位置;端点按钮为一次性命令。</p></header>
|
|
<div class="v2-calibration-axis-grid" id="calibrationAxisCards"></div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="v2-calibration-view" data-calibration-page="stereo" hidden>
|
|
<div class="v2-calibration-stereo-grid">
|
|
<section class="v2-calibration-panel v2-calibration-mqtt-panel">
|
|
<header><div><span class="section-kicker">ZED / SEATPOSE</span><h2>双目位姿数据</h2></div><span class="v2-calibration-badge wait" id="calibrationMqttBadge">等待数据</span></header>
|
|
<div class="v2-calibration-metrics"><div><small>消息总数</small><strong id="calibrationMqttCount">0</strong></div><div><small>最新帧</small><strong id="calibrationMqttFrame">—</strong></div><div><small>解析错误</small><strong id="calibrationMqttErrors">0</strong></div></div>
|
|
<p class="v2-calibration-detail" id="calibrationMqttDetail">MQTT 尚未连接</p>
|
|
<div class="v2-calibration-pose-areas" id="calibrationPoseAreas"></div>
|
|
<div class="v2-calibration-actions"><button id="calibrationMqttConnect" type="button">连接 MQTT</button><button id="calibrationMqttDisconnect" type="button">断开</button><button id="calibrationMqttTest" type="button">发送回环测试</button></div>
|
|
</section>
|
|
<section class="v2-calibration-panel v2-calibration-video-panel">
|
|
<header><div><span class="section-kicker">RTSP MONITOR</span><h2>ZED 实时画面</h2></div><span class="v2-calibration-badge wait" id="calibrationVideoBadge">未启动</span></header>
|
|
<div class="v2-calibration-video-frame"><img id="calibrationVideoImage" alt="RTSP 视频画面"><div id="calibrationVideoPlaceholder"><i data-lucide="scan-line"></i><strong>RTSP → MJPEG</strong><small>由现有 Python 服务通过 FFmpeg 转发</small></div></div>
|
|
<p class="v2-calibration-detail" id="calibrationVideoDetail">视频接收尚未启动</p>
|
|
<div class="v2-calibration-actions"><button id="calibrationVideoStart" type="button">开始接收</button><button id="calibrationVideoStop" type="button">停止</button></div>
|
|
</section>
|
|
</div>
|
|
<section class="v2-calibration-panel v2-calibration-mapping-panel">
|
|
<header><div><span class="section-kicker">HALL ↔ 3D</span><h2>实时映射概览</h2></div></header>
|
|
<div class="v2-calibration-mapping-rows" id="calibrationMappingRows"></div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="v2-calibration-view" data-calibration-page="system" hidden>
|
|
<div class="v2-calibration-settings-grid">
|
|
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationCanForm">
|
|
<header><span>CAN</span><div><small>TRANSPORT</small><h2>CAN 适配器</h2></div></header>
|
|
<p class="v2-calibration-form-note">执行目标由左侧下拉菜单选择。</p>
|
|
<div class="v2-calibration-two-col"><label>接口<input id="calibrationCanInterface" value="zlgcan"></label><label>通道<input id="calibrationCanChannel" value="0"></label></div>
|
|
<label>位速率<input id="calibrationCanBitrate" value="500000" readonly></label>
|
|
<label>ZCANPRO Python<input id="calibrationCanPython" value="C:\\Program Files (x86)\\ZCANPRO\\python38\\python.exe"></label>
|
|
<label>ZLG DLL<input id="calibrationCanDll" value="C:\\Program Files (x86)\\ZCANPRO\\zlgcan.dll"></label>
|
|
<div class="v2-calibration-two-col"><label>设备类型<input id="calibrationCanDeviceType" type="number" value="41"></label><label>设备序号<input id="calibrationCanDeviceIndex" type="number" value="0"></label></div>
|
|
<button class="v2-calibration-primary" type="submit">保存并连接台架</button>
|
|
<p class="v2-calibration-detail" id="calibrationTransportDetail"></p>
|
|
</form>
|
|
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationMqttForm">
|
|
<header><span>MQ</span><div><small>STEREO DATA</small><h2>MQTT Broker</h2></div></header>
|
|
<div class="v2-calibration-two-col"><label>主机<input id="calibrationMqttHost"></label><label>端口<input id="calibrationMqttPort" type="number"></label></div>
|
|
<label>主题<input id="calibrationMqttTopic"></label>
|
|
<button type="submit">保存 MQTT 设置</button>
|
|
<p class="v2-calibration-form-note">正式数据主题默认:zed/seatpose</p>
|
|
</form>
|
|
<form class="v2-calibration-panel v2-calibration-settings-card" id="calibrationVideoForm">
|
|
<header><span>RT</span><div><small>VIDEO</small><h2>RTSP 视频</h2></div></header>
|
|
<div class="v2-calibration-two-col"><label>主机<input id="calibrationVideoHost"></label><label>端口<input id="calibrationVideoPort" type="number"></label></div>
|
|
<label>流路径<input id="calibrationVideoPath"></label>
|
|
<button type="submit">保存 RTSP 设置</button>
|
|
<p class="v2-calibration-form-note" id="calibrationFfmpegHint"></p>
|
|
</form>
|
|
</div>
|
|
</section>`;
|
|
moduleSwitcher.insertAdjacentElement("afterend", root);
|
|
|
|
const target = $("#calibrationExecutionSelect");
|
|
target.value = sessionStorage.getItem(TARGET_KEY) === "simulation"
|
|
? "simulation"
|
|
: "hardware";
|
|
window.CognitiveAgentConsoleV2?.enhanceSelect(target);
|
|
return true;
|
|
}
|
|
|
|
function isAdmin() {
|
|
return window.CognitiveAgentConsoleV2?.getRole() === "admin"
|
|
|| document.body.dataset.demoRole === "admin";
|
|
}
|
|
|
|
function isActive() {
|
|
return isAdmin() && document.body.dataset.module === "self-calibration";
|
|
}
|
|
|
|
function toast(message, error = false) {
|
|
const node = $("#toast");
|
|
if (!node) return;
|
|
node.textContent = message;
|
|
node.className = error ? "toast show error" : "toast show";
|
|
window.clearTimeout(toastTimer);
|
|
toastTimer = window.setTimeout(() => {
|
|
node.className = "toast";
|
|
}, 3600);
|
|
}
|
|
|
|
async function api(path, body = {}) {
|
|
const response = await fetch(`${API_ROOT}${path}`, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"X-Cognitive-Agent-Role": "admin",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await response.json().catch(() => ({
|
|
message: `HTTP ${response.status}`,
|
|
}));
|
|
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
|
|
if (data.state) render(data.state);
|
|
return data;
|
|
}
|
|
|
|
async function fetchState() {
|
|
const response = await fetch(`${API_ROOT}/state`, {
|
|
credentials: "same-origin",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"X-Cognitive-Agent-Role": "admin",
|
|
},
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
|
|
render(data);
|
|
}
|
|
|
|
async function run(action, success = "") {
|
|
try {
|
|
await action();
|
|
if (success) toast(success);
|
|
} catch (error) {
|
|
toast(error.message || String(error), true);
|
|
}
|
|
}
|
|
|
|
function statusClass(state) {
|
|
if (["online", "subscribed", "simulated", "playing"].includes(state)) return "online";
|
|
if (["connecting", "idle"].includes(state)) return "wait";
|
|
if (state === "error") return "error";
|
|
return "";
|
|
}
|
|
|
|
function setStatusDot(selector, state) {
|
|
const node = $(selector);
|
|
if (node) node.className = statusClass(state);
|
|
}
|
|
|
|
function render(state) {
|
|
if (!state?.transport || !state?.controller) return;
|
|
currentState = state;
|
|
const { transport, controller, mqtt } = state;
|
|
setStatusDot("#calibrationCanDot", transport.state);
|
|
setStatusDot(
|
|
"#calibrationMotorDot",
|
|
controller.motor_ready ? "online" : transport.state === "online" ? "connecting" : transport.state,
|
|
);
|
|
setStatusDot("#calibrationMqttDot", mqtt.state);
|
|
|
|
$("#calibrationCanState").textContent = transport.state === "online"
|
|
? transport.mode === "simulation" ? "仿真在线" : "实机在线"
|
|
: transport.state === "connecting" ? "连接中"
|
|
: transport.state === "error" ? "连接异常" : "未连接";
|
|
$("#calibrationMotorState").textContent = controller.motor_ready
|
|
? controller.armed ? "已解锁" : "已就绪"
|
|
: "等待状态";
|
|
$("#calibrationMqttState").textContent = mqtt.state === "simulated"
|
|
? "仿真数据"
|
|
: mqtt.state === "subscribed" ? "已订阅"
|
|
: mqtt.state === "connecting" ? "连接中" : "未连接";
|
|
$("#calibrationPhaseBadge").textContent =
|
|
state.operation.phase_name || phaseLabels[state.operation.phase] || "待命";
|
|
$("#calibrationPhaseBadge").className = `v2-calibration-badge ${
|
|
[5, 6].includes(state.operation.phase)
|
|
? "error"
|
|
: state.operation.phase === 0 ? "wait" : ""
|
|
}`;
|
|
$("#calibrationOperationLabel").textContent = state.operation.label;
|
|
$("#calibrationJobId").textContent = controller.job_id || "—";
|
|
$("#calibrationTransportDetail").textContent = transport.detail;
|
|
$("#calibrationConnectionText").textContent = transport.detail;
|
|
|
|
const targetState = $("#calibrationTargetState");
|
|
targetState.textContent = transport.state === "online" ? "READY"
|
|
: transport.state === "connecting" ? "LINK" : transport.state === "error" ? "ERROR" : "IDLE";
|
|
$("#calibrationTargetCard").dataset.state = transport.state;
|
|
$("#calibrationTargetNote").textContent = transport.detail;
|
|
|
|
renderWorkflow(state.operation.phase);
|
|
renderAxes(state.axes, controller.job_active, transport.state === "online");
|
|
renderSeat(state.axes);
|
|
renderMqtt(mqtt);
|
|
renderVideo(state.video);
|
|
renderMappings(state.axes, mqtt.areas);
|
|
$("#calibrationStartAll").disabled =
|
|
transport.state !== "online" || controller.job_active;
|
|
if (!settingsHydrated) hydrateSettings(state.settings, state.video.ffmpeg_available);
|
|
}
|
|
|
|
function renderWorkflow(phase) {
|
|
$$(".v2-calibration-flow > div").forEach((step) => {
|
|
const value = Number(step.dataset.phase);
|
|
step.classList.toggle("active", phase === value);
|
|
step.classList.toggle("done", phase === 4 || (phase > value && phase < 5));
|
|
});
|
|
}
|
|
|
|
function createAxisCards(axes) {
|
|
const container = $("#calibrationAxisCards");
|
|
if (!container) return;
|
|
axes.forEach((axis) => {
|
|
const card = document.createElement("article");
|
|
card.className = "v2-calibration-axis-card";
|
|
card.dataset.axis = axis.id;
|
|
card.innerHTML = `
|
|
<header><span>AXIS 0${axis.id + 1}</span><strong></strong><b>就绪</b></header>
|
|
<div class="v2-calibration-axis-reading"><strong>0</strong><span>HALL 0 / 0</span></div>
|
|
<input type="range" min="0" max="1000" value="0" aria-label="${axis.name}位置">
|
|
<div class="v2-calibration-range-labels"><span>近端 0%</span><span>远端 100%</span></div>
|
|
<div class="v2-calibration-axis-actions"><button data-seek="near" type="button">近端</button><button data-axis-calibrate type="button">标定</button><button data-seek="far" type="button">远端</button></div>`;
|
|
$("header strong", card).textContent = axis.title;
|
|
const slider = $('input[type="range"]', card);
|
|
slider.addEventListener("input", () => {
|
|
$(".v2-calibration-axis-reading strong", card).textContent =
|
|
formatPhysical(axis, Number(slider.value));
|
|
});
|
|
slider.addEventListener("change", () => run(
|
|
() => api("/move", { axis: axis.id, permille: Number(slider.value) }),
|
|
));
|
|
$('[data-seek="near"]', card).addEventListener("click", () => run(
|
|
() => api("/seek", { axis: axis.id, far: false }),
|
|
));
|
|
$('[data-seek="far"]', card).addEventListener("click", () => run(
|
|
() => api("/seek", { axis: axis.id, far: true }),
|
|
));
|
|
$("[data-axis-calibrate]", card).addEventListener("click", () => run(
|
|
() => api("/start", { axis: axis.id }),
|
|
`${axis.name}自动标定已启动`,
|
|
));
|
|
container.append(card);
|
|
});
|
|
}
|
|
|
|
function renderAxes(axes, busy, online) {
|
|
if (!$("#calibrationAxisCards")?.children.length) createAxisCards(axes);
|
|
axes.forEach((axis) => {
|
|
const card = $(`.v2-calibration-axis-card[data-axis="${axis.id}"]`);
|
|
if (!card) return;
|
|
const slider = $('input[type="range"]', card);
|
|
if (document.activeElement !== slider) slider.value = axis.permille;
|
|
$(".v2-calibration-axis-reading strong", card).textContent =
|
|
formatPhysical(axis, Number(slider.value));
|
|
$(".v2-calibration-axis-reading span", card).textContent =
|
|
`HALL ${axis.hall} / ${axis.range || "—"}`;
|
|
const state = $("header b", card);
|
|
state.textContent = axis.fault ? "故障"
|
|
: axis.calibrating ? "标定中"
|
|
: axis.moving ? axis.direction
|
|
: axis.position_valid ? "就绪" : "位置未知";
|
|
state.className = axis.moving || axis.calibrating ? "moving" : "";
|
|
slider.disabled = busy || !online;
|
|
$$(".v2-calibration-axis-actions button", card).forEach((button) => {
|
|
button.disabled = busy || !online;
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatPhysical(axis, permille) {
|
|
const value = axis.physical_range * permille / 1000;
|
|
return axis.unit === "cm" ? `${value.toFixed(2)} ${axis.unit}` : `${value.toFixed(1)}${axis.unit}`;
|
|
}
|
|
|
|
function renderSeat(axes) {
|
|
if (!axes?.length) return;
|
|
const longitudinal = axes.find((axis) => axis.id === 0) || axes[0];
|
|
const legrest = axes.find((axis) => axis.id === 1) || axes[1];
|
|
const backrest = axes.find((axis) => axis.id === 2) || axes[2];
|
|
const height = axes.find((axis) => axis.id === 3) || axes[3];
|
|
const pose = {
|
|
longitudinalM:
|
|
(longitudinal.permille / 1000 - 0.5) * longitudinal.physical_range * 0.01,
|
|
verticalM: (0.5 - height.permille / 1000) * height.physical_range * 0.01,
|
|
backrestDeg: backrest.physical_value,
|
|
legrestDeg: legrest.physical_value,
|
|
};
|
|
window.n7SeatPose = pose;
|
|
window.dispatchEvent(new CustomEvent("n7-seat-pose", { detail: pose }));
|
|
$("#calibrationTagLong").textContent = `${longitudinal.physical_value.toFixed(2)} cm`;
|
|
$("#calibrationTagLeg").textContent = `${legrest.physical_value.toFixed(1)}°`;
|
|
$("#calibrationTagBack").textContent = `${backrest.physical_value.toFixed(1)}°`;
|
|
$("#calibrationTagHeight").textContent = `${height.physical_value.toFixed(2)} cm`;
|
|
}
|
|
|
|
function renderMqtt(mqtt) {
|
|
const friendly = {
|
|
simulated: "仿真数据",
|
|
subscribed: "已订阅",
|
|
connecting: "连接中",
|
|
disconnected: "未连接",
|
|
error: "异常",
|
|
};
|
|
$("#calibrationMqttBadge").textContent = friendly[mqtt.state] || mqtt.state;
|
|
$("#calibrationMqttBadge").className = `v2-calibration-badge ${
|
|
mqtt.state === "error"
|
|
? "error"
|
|
: ["disconnected", "connecting"].includes(mqtt.state) ? "wait" : ""
|
|
}`;
|
|
$("#calibrationMqttCount").textContent = mqtt.count;
|
|
$("#calibrationMqttFrame").textContent = mqtt.last_frame >= 0 ? mqtt.last_frame : "—";
|
|
$("#calibrationMqttErrors").textContent = mqtt.parse_errors;
|
|
$("#calibrationMqttDetail").textContent = mqtt.detail;
|
|
const container = $("#calibrationPoseAreas");
|
|
container.replaceChildren();
|
|
const entries = Object.entries(mqtt.areas || {});
|
|
if (!entries.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "v2-calibration-empty";
|
|
empty.textContent = "等待 Seat_cushion / Seat_backrest / Seat_leg_rest 位姿…";
|
|
container.append(empty);
|
|
}
|
|
entries.forEach(([name, pose]) => {
|
|
const row = document.createElement("div");
|
|
row.className = "v2-calibration-pose-row";
|
|
const title = document.createElement("strong");
|
|
title.textContent = name;
|
|
const angle = document.createElement("span");
|
|
angle.textContent = pose.angle == null ? "ANGLE —" : `${Number(pose.angle).toFixed(2)}°`;
|
|
const translation = document.createElement("small");
|
|
translation.textContent = pose.translation
|
|
? `T [${pose.translation.slice(0, 3).map((value) => Number(value).toFixed(3)).join(", ")}]`
|
|
: "T —";
|
|
row.append(title, angle, translation);
|
|
container.append(row);
|
|
});
|
|
}
|
|
|
|
function renderVideo(video) {
|
|
const friendly = { idle: "未启动", connecting: "连接中", playing: "播放中", error: "异常" };
|
|
$("#calibrationVideoBadge").textContent = friendly[video.state] || video.state;
|
|
$("#calibrationVideoBadge").className = `v2-calibration-badge ${
|
|
video.state === "error" ? "error" : video.state === "idle" ? "wait" : ""
|
|
}`;
|
|
$("#calibrationVideoDetail").textContent = video.detail;
|
|
const image = $("#calibrationVideoImage");
|
|
if (video.state === "playing" && !image.classList.contains("playing")) {
|
|
image.src = `${API_ROOT}/video/stream?role=admin&t=${Date.now()}`;
|
|
image.classList.add("playing");
|
|
}
|
|
if (!["playing", "connecting"].includes(video.state)) {
|
|
image.classList.remove("playing");
|
|
image.removeAttribute("src");
|
|
}
|
|
}
|
|
|
|
function renderMappings(axes, areas = {}) {
|
|
const container = $("#calibrationMappingRows");
|
|
container.replaceChildren();
|
|
const areaFor = ["Seat_cushion", "Seat_leg_rest", "Seat_backrest", "Seat_cushion"];
|
|
axes.forEach((axis) => {
|
|
const pose = areas[areaFor[axis.id]] || {};
|
|
const observed = axis.id === 0 || axis.id === 3
|
|
? pose.translation?.[axis.id === 0 ? 0 : 1]
|
|
: pose.angle;
|
|
const row = document.createElement("div");
|
|
row.className = "v2-calibration-mapping-row";
|
|
row.innerHTML = "<strong></strong><span></span><div><i></i></div><b></b>";
|
|
$("strong", row).textContent = axis.name;
|
|
$("span", row).textContent = `${axis.hall} Hall`;
|
|
$("i", row).style.width = `${axis.permille / 10}%`;
|
|
$("b", row).textContent = observed == null
|
|
? "3D —"
|
|
: `${Number(observed).toFixed(3)}${axis.id === 1 || axis.id === 2 ? "°" : " T"}`;
|
|
container.append(row);
|
|
});
|
|
}
|
|
|
|
function hydrateSettings(settings, ffmpegAvailable) {
|
|
settingsHydrated = true;
|
|
$("#calibrationCanInterface").value = settings.can.interface;
|
|
$("#calibrationCanChannel").value = settings.can.channel;
|
|
$("#calibrationCanBitrate").value = settings.can.bitrate;
|
|
$("#calibrationCanPython").value = settings.can.python_executable || "";
|
|
$("#calibrationCanDll").value = settings.can.dll_path || "";
|
|
$("#calibrationCanDeviceType").value = settings.can.device_type ?? 41;
|
|
$("#calibrationCanDeviceIndex").value = settings.can.device_index ?? 0;
|
|
$("#calibrationMqttHost").value = settings.mqtt.host;
|
|
$("#calibrationMqttPort").value = settings.mqtt.port;
|
|
$("#calibrationMqttTopic").value = settings.mqtt.topic;
|
|
$("#calibrationVideoHost").value = settings.video.host;
|
|
$("#calibrationVideoPort").value = settings.video.port;
|
|
$("#calibrationVideoPath").value = settings.video.path;
|
|
$("#calibrationFfmpegHint").textContent = ffmpegAvailable
|
|
? "已检测到 FFmpeg,可转发 RTSP。"
|
|
: "未检测到 FFmpeg;其余功能不受影响。";
|
|
}
|
|
|
|
function collectSettings() {
|
|
return {
|
|
can: {
|
|
mode: $("#calibrationExecutionSelect").value,
|
|
interface: $("#calibrationCanInterface").value.trim(),
|
|
channel: $("#calibrationCanChannel").value.trim(),
|
|
bitrate: Number($("#calibrationCanBitrate").value),
|
|
python_executable: $("#calibrationCanPython").value.trim(),
|
|
dll_path: $("#calibrationCanDll").value.trim(),
|
|
device_type: Number($("#calibrationCanDeviceType").value),
|
|
device_index: Number($("#calibrationCanDeviceIndex").value),
|
|
resistance: 1,
|
|
},
|
|
mqtt: {
|
|
host: $("#calibrationMqttHost").value.trim(),
|
|
port: Number($("#calibrationMqttPort").value),
|
|
topic: $("#calibrationMqttTopic").value.trim(),
|
|
},
|
|
video: {
|
|
host: $("#calibrationVideoHost").value.trim(),
|
|
port: Number($("#calibrationVideoPort").value),
|
|
path: $("#calibrationVideoPath").value.trim(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function showView(view) {
|
|
activeView = pageTitles[view] ? view : "hall";
|
|
$$(".v2-calibration-view").forEach((page) => {
|
|
const active = page.dataset.calibrationPage === activeView;
|
|
page.hidden = !active;
|
|
page.classList.toggle("active", active);
|
|
});
|
|
$$("[data-calibration-view]").forEach((button) => {
|
|
button.classList.toggle("active", button.dataset.calibrationView === activeView);
|
|
});
|
|
$("#calibrationSettingsLink").classList.toggle("active", activeView === "system");
|
|
const [kicker, title, description] = pageTitles[activeView];
|
|
$("#calibrationPageKicker").textContent = kicker;
|
|
$("#calibrationPageTitle").textContent = title;
|
|
$("#calibrationPageDescription").textContent = description;
|
|
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
|
|
}
|
|
|
|
function updateTargetPresentation() {
|
|
const simulation = $("#calibrationExecutionSelect").value === "simulation";
|
|
sessionStorage.setItem(TARGET_KEY, simulation ? "simulation" : "hardware");
|
|
$("#calibrationConnectTarget span").textContent =
|
|
simulation ? "连接标定仿真台架" : "连接真实台架";
|
|
if (!currentState || currentState.transport.state === "disconnected") {
|
|
$("#calibrationTargetNote").textContent =
|
|
simulation ? "完整 CAN 协议仿真,不驱动实体设备" : "本机 admin 可连接真实 CAN 台架";
|
|
}
|
|
}
|
|
|
|
function connectTarget() {
|
|
const settings = collectSettings();
|
|
return run(async () => {
|
|
await api("/settings", settings);
|
|
await api("/can/connect", { can: settings.can });
|
|
}, settings.can.mode === "simulation" ? "标定仿真台架已连接" : "真实台架连接完成");
|
|
}
|
|
|
|
function loadSeatModel() {
|
|
if (modelRequested || !$("#seatModelHost")) return;
|
|
modelRequested = true;
|
|
const script = document.createElement("script");
|
|
script.src = "/assets/self-calibration-model.bundle.js";
|
|
script.async = true;
|
|
script.onerror = () => {
|
|
modelRequested = false;
|
|
$("#seatModelLoading")?.classList.add("error");
|
|
};
|
|
document.head.append(script);
|
|
}
|
|
|
|
function closeEvents() {
|
|
window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = 0;
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
function connectEvents() {
|
|
if (!isActive() || eventSource) return;
|
|
eventSource = new EventSource(`${API_ROOT}/events?role=admin`);
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
render(JSON.parse(event.data));
|
|
} catch (error) {
|
|
console.error("自标定状态解析失败", error);
|
|
}
|
|
};
|
|
eventSource.onerror = () => {
|
|
closeEvents();
|
|
if (isActive()) reconnectTimer = window.setTimeout(connectEvents, 1600);
|
|
};
|
|
}
|
|
|
|
function activate() {
|
|
if (!isActive()) {
|
|
closeEvents();
|
|
return;
|
|
}
|
|
loadSeatModel();
|
|
fetchState().catch((error) => toast(`自标定服务连接失败:${error.message}`, true));
|
|
connectEvents();
|
|
}
|
|
|
|
function bindEvents() {
|
|
$$("[data-calibration-view]").forEach((button) => {
|
|
button.addEventListener("click", () => showView(button.dataset.calibrationView));
|
|
});
|
|
$("#calibrationSettingsLink").addEventListener("click", () => showView("system"));
|
|
$("#calibrationExecutionSelect").addEventListener("change", updateTargetPresentation);
|
|
$("#calibrationConnectTarget").addEventListener("click", connectTarget);
|
|
$("#calibrationGlobalStop").addEventListener("click", () => run(
|
|
() => api("/stop"),
|
|
"全轴 STOP 已确认",
|
|
));
|
|
$("#calibrationStartAll").addEventListener("click", () => run(
|
|
() => api("/start", { axis: "all" }),
|
|
"四轴自动标定已启动",
|
|
));
|
|
$("#calibrationMqttConnect").addEventListener("click", () => run(
|
|
() => api("/mqtt/connect"),
|
|
"正在连接 MQTT",
|
|
));
|
|
$("#calibrationMqttDisconnect").addEventListener("click", () => run(
|
|
() => api("/mqtt/disconnect"),
|
|
"MQTT 已断开",
|
|
));
|
|
$("#calibrationMqttTest").addEventListener("click", () => run(
|
|
() => api("/mqtt/test"),
|
|
"MQTT 测试帧已发送",
|
|
));
|
|
$("#calibrationVideoStart").addEventListener("click", () => run(
|
|
() => api("/video/start"),
|
|
"正在启动 RTSP 接收",
|
|
));
|
|
$("#calibrationVideoStop").addEventListener("click", () => run(
|
|
() => api("/video/stop"),
|
|
"视频接收已停止",
|
|
));
|
|
$("#calibrationCanForm").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
connectTarget();
|
|
});
|
|
$("#calibrationMqttForm").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
run(() => api("/settings", collectSettings()), "MQTT 设置已保存");
|
|
});
|
|
$("#calibrationVideoForm").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
run(() => api("/settings", collectSettings()), "RTSP 设置已保存");
|
|
});
|
|
window.addEventListener("cognitive-agent:module-change", activate);
|
|
window.addEventListener("cognitive-agent:demo-role-change", activate);
|
|
window.addEventListener("beforeunload", closeEvents);
|
|
}
|
|
|
|
function init() {
|
|
if (!buildShell()) return;
|
|
bindEvents();
|
|
updateTargetPresentation();
|
|
showView(activeView);
|
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
|
activate();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
}());
|