Files
Cognitive_Agent/apps/web-console/energy-monitor.js
T

909 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const SAMPLE_INTERVAL_MS = 1000;
const MEASURED_STALE_MS = 15000;
const HISTORY_LIMIT = 60;
const MAX_DEVICE_POWER_W = 100000;
const MAX_REPORTED_ENERGY_WH = 1000000000;
const ENERGY_MONITORED_DEVICES = Object.freeze([
{ id: "passenger", telemetryKeys: ["passenger", "passenger_seat"], name: "副驾座椅", icon: "armchair", phase: 1 },
{ id: "driver", telemetryKeys: ["driver", "driver_seat"], name: "主驾座椅", icon: "armchair", phase: 2 },
{ id: "climate", telemetryKeys: ["climate", "hvac"], name: "空调", icon: "snowflake", phase: 3 },
{ id: "shade", telemetryKeys: ["shade", "roof", "sunshade"], name: "遮阳帘", icon: "panels-top-left", phase: 4 },
{ id: "windows", telemetryKeys: ["windows", "window"], name: "车窗", icon: "gallery-vertical-end", phase: 5 },
{ id: "reading", telemetryKeys: ["reading", "reading_light", "interior_light"], name: "阅读灯", icon: "book-open", phase: 6 },
{ id: "ambient", telemetryKeys: ["ambient", "ambient_light"], name: "氛围灯", icon: "sparkles", phase: 7 },
]);
const SERIES = Object.freeze([
{ id: "total", name: "总功率", devices: ENERGY_MONITORED_DEVICES.map(({ id }) => id) },
{ id: "climate", name: "空调", devices: ["climate"] },
{ id: "seats", name: "座椅", devices: ["passenger", "driver"] },
{ id: "lights", name: "灯光", devices: ["reading", "ambient"] },
]);
const aliasToDevice = new Map();
ENERGY_MONITORED_DEVICES.forEach((device) => {
device.telemetryKeys.forEach((key) => aliasToDevice.set(key, device.id));
});
const monitor = {
mounted: false,
open: false,
activeSeries: "total",
timer: null,
startedAt: Date.now(),
lastSampleAt: null,
frames: [],
latest: null,
measured: new Map(),
reportedEnergy: new Map(),
energyWh: Object.fromEntries(ENERGY_MONITORED_DEVICES.map(({ id }) => [id, 0])),
motionUntil: new Map(),
controlRequests: new Map(),
returnFocus: null,
backgroundInert: [],
};
function finiteNonNegative(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : null;
}
function finitePower(value) {
const number = finiteNonNegative(value);
return number !== null && number <= MAX_DEVICE_POWER_W ? number : null;
}
function finiteReportedEnergy(value) {
const number = finiteNonNegative(value);
return number !== null && number <= MAX_REPORTED_ENERGY_WH ? number : null;
}
function normalizeSource(value) {
return String(value || "").trim().toLowerCase().replace(/[\s-]+/g, "_");
}
function telemetrySource(telemetry, payload) {
if (telemetry?.simulated === true || payload?.simulated === true) return "simulated";
if (telemetry?.estimated === true || payload?.estimated === true) return "estimated";
const declared = normalizeSource(
telemetry?.source
|| telemetry?.data_source
|| payload?.source
|| payload?.data_source,
);
if (/(^|_)(simulated|simulation|demo|mock|synthetic|virtual)(_|$)/.test(declared)) {
return "simulated";
}
if (/(^|_)(estimated|estimate|derived|inferred|calculated|projected|unknown)(_|$)/.test(declared)) {
return "estimated";
}
if (telemetry?.measured === true || payload?.measured === true) return "measured";
if (/(^|_)(measured|meter|sensor|hardware|physical|vehicle|can|mqtt)(_|$)/.test(declared)) {
return "measured";
}
// An unlabelled caller is not enough evidence to claim hardware measurement.
return "estimated";
}
function text(selector, fallback = "") {
return $(selector)?.textContent?.trim() || fallback;
}
function stateFlag(value) {
const normalized = String(value || "").trim();
if (/OFF|关闭|停止|未运行/i.test(normalized)) return false;
if (/ON|ACTIVE|AMBIENT|INTERIOR|开启|运行|L[1-9]|高|中|低/i.test(normalized)) return true;
return null;
}
function numericText(selector, fallback) {
const match = text(selector).match(/-?\d+(?:\.\d+)?/);
const value = match ? Number(match[0]) : NaN;
return Number.isFinite(value) ? value : fallback;
}
function deterministicWave(now, phase, amplitude = 0.04) {
const seconds = now / 1000;
return 1
+ Math.sin(seconds / 2.4 + phase * 1.37) * amplitude
+ Math.sin(seconds / 5.7 + phase * 0.71) * amplitude * 0.45;
}
function controlIsActive(deviceId, now) {
if ((monitor.motionUntil.get(deviceId) || 0) > now) return true;
const card = $(`.v2-synced-device[data-device="${deviceId}"]`);
return /等待车机同步/.test(card?.querySelector("[data-device-control-apply]")?.textContent || "");
}
function estimateDevicePower(device, now) {
let watts = 0;
let activity = "待机";
const moving = controlIsActive(device.id, now);
if (device.id === "climate") {
const power = stateFlag(text("#climatePower"));
const fan = Math.max(0, Math.min(7, numericText("#fanLevel", 3)));
const target = (numericText("#driverTemperature", 24) + numericText("#passengerTemperature", 24)) / 2;
if (power === false) {
watts = 3.2;
activity = "待机";
} else {
watts = 390 + fan * 48 + Math.abs(24 - target) * 32;
activity = power === true ? `${fan} 档运行` : "状态估算";
}
if (moving) {
watts += 95;
activity = "调节执行中";
}
} else if (device.id === "passenger") {
const card = $('.seat-card[data-seat="passenger"]');
const ventilation = stateFlag(card?.dataset.ventilationState);
const heating = stateFlag(card?.dataset.heatingState);
const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1));
const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1));
watts = 5.6 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (moving ? 130 : 0);
activity = moving ? "执行器动作" : ventilation || heating ? "舒适功能运行" : "待机";
} else if (device.id === "driver") {
const card = $('.seat-card[data-seat="driver"]');
const ventilation = stateFlag(card?.dataset.ventilationState || text('.seat-card[data-seat="driver"] [data-function="ventilation"] b'));
const heating = stateFlag(card?.dataset.heatingState || text('.seat-card[data-seat="driver"] [data-function="heating"] b'));
const massage = stateFlag(card?.dataset.massageState || text('.seat-card[data-seat="driver"] [data-function="massage"] b'));
const ventilationLevel = Math.max(1, Math.min(3, Number(card?.dataset.ventilationLevel) || 1));
const heatingLevel = Math.max(1, Math.min(3, Number(card?.dataset.heatingLevel) || 1));
watts = 6.4 + (ventilation ? 7 + ventilationLevel * 7 : 0) + (heating ? 18 + heatingLevel * 16 : 0) + (massage ? 31 : 0) + (moving ? 92 : 0);
activity = moving ? "执行器动作" : ventilation || heating || massage ? "舒适功能运行" : "待机";
} else if (device.id === "windows") {
watts = moving ? 118 : 2.8;
activity = moving ? "车窗动作" : "待机";
} else if (device.id === "shade") {
watts = moving ? 86 : 1.9;
activity = moving ? "遮阳帘动作" : "待机";
} else if (device.id === "reading") {
const power = stateFlag(text("#interiorLightPower"));
watts = power === false ? 0.8 : power === true ? 18 : 7.5;
activity = power === true ? "已开启" : power === false ? "已关闭" : "状态估算";
if (moving) {
watts += 6;
activity = "切换执行中";
}
} else if (device.id === "ambient") {
const power = stateFlag(text("#ambientPower"));
const brightness = Math.max(1, Math.min(5, numericText("#ambientBrightness", 3)));
watts = power === false ? 1.1 : power === true ? 8 + brightness * 6.2 : 18;
activity = power === true ? `${brightness} 级亮度` : power === false ? "已关闭" : "状态估算";
if (moving) {
watts += 5;
activity = "调光执行中";
}
}
const amplitude = watts < 10 ? 0.025 : device.id === "climate" ? 0.055 : 0.04;
return {
watts: Math.max(0, watts * deterministicWave(now, device.phase, amplitude)),
source: "estimated",
activity,
updatedAt: now,
};
}
function readingForDevice(device, now) {
const measured = monitor.measured.get(device.id);
const reportedEnergy = monitor.reportedEnergy.get(device.id);
if (measured && now - measured.receivedAt <= MEASURED_STALE_MS) {
const activity = measured.source === "measured"
? "车端功率通道"
: measured.source === "simulated" ? "模拟功率通道" : "外部估算通道";
return {
watts: measured.watts,
source: measured.source,
activity,
updatedAt: measured.updatedAt || measured.receivedAt,
reportedEnergyWh: reportedEnergy?.wattsHours ?? null,
reportedEnergyLabel: reportedEnergy?.label || "",
reportedEnergySource: reportedEnergy?.source || "",
};
}
return {
...estimateDevicePower(device, now),
reportedEnergyWh: reportedEnergy?.wattsHours ?? null,
reportedEnergyLabel: reportedEnergy?.label || "",
reportedEnergySource: reportedEnergy?.source || "",
};
}
function frameSeriesValue(frame, seriesId) {
const exactDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === seriesId);
const deviceIds = exactDevice
? [exactDevice.id]
: SERIES.find(({ id }) => id === seriesId)?.devices || SERIES[0].devices;
return deviceIds.reduce((sum, id) => sum + (frame.values[id] || 0), 0);
}
function publicSnapshot() {
if (!monitor.latest) return null;
return {
timestamp: monitor.latest.timestamp,
total_power_w: monitor.latest.total,
total_energy_wh: Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0),
source: monitor.latest.source,
measured_count: monitor.latest.measuredCount,
simulated_count: monitor.latest.simulatedCount,
estimated_count: monitor.latest.estimatedCount,
total_energy_scope: "monitor_power_integral_estimate",
devices: Object.fromEntries(ENERGY_MONITORED_DEVICES.map((device) => {
const reading = monitor.latest.readings[device.id];
return [device.id, {
name: device.name,
power_w: reading.watts,
energy_wh: monitor.energyWh[device.id],
reported_energy_wh: reading.reportedEnergyWh,
reported_energy_label: reading.reportedEnergyLabel,
reported_energy_source: reading.reportedEnergySource,
source: reading.source,
activity: reading.activity,
}];
})),
sample_count: monitor.frames.length,
};
}
function pruneExpiredControlRequests(now) {
monitor.controlRequests.forEach((request, requestId) => {
if (request.until <= now) monitor.controlRequests.delete(requestId);
});
}
function sampleNow({ now = Date.now(), integrate = true, render = true } = {}) {
const timestamp = Number(now);
if (!Number.isFinite(timestamp)) return publicSnapshot();
pruneExpiredControlRequests(timestamp);
const readings = {};
const values = {};
let total = 0;
let measuredCount = 0;
let simulatedCount = 0;
ENERGY_MONITORED_DEVICES.forEach((device) => {
const reading = readingForDevice(device, timestamp);
readings[device.id] = reading;
values[device.id] = reading.watts;
total += finitePower(reading.watts) ?? 0;
if (reading.source === "measured") measuredCount += 1;
if (reading.source === "simulated") simulatedCount += 1;
});
const elapsedMs = monitor.lastSampleAt === null
? 0
: Math.max(0, timestamp - monitor.lastSampleAt);
if (integrate && elapsedMs > 0) {
ENERGY_MONITORED_DEVICES.forEach(({ id }) => {
const previous = monitor.frames.at(-1)?.values?.[id] ?? values[id];
monitor.energyWh[id] += ((previous + values[id]) / 2) * elapsedMs / 3600000;
});
}
monitor.lastSampleAt = timestamp;
monitor.frames.push({ timestamp, values, total });
if (monitor.frames.length > HISTORY_LIMIT) monitor.frames.splice(0, monitor.frames.length - HISTORY_LIMIT);
const estimatedCount = ENERGY_MONITORED_DEVICES.length - measuredCount - simulatedCount;
const source = measuredCount === ENERGY_MONITORED_DEVICES.length
? "measured"
: simulatedCount === ENERGY_MONITORED_DEVICES.length
? "simulated"
: estimatedCount === ENERGY_MONITORED_DEVICES.length
? "estimated"
: measuredCount && simulatedCount && estimatedCount
? "measured_simulated_estimated"
: measuredCount && simulatedCount
? "measured_simulated"
: measuredCount ? "mixed" : "simulated_estimated";
monitor.latest = {
timestamp,
readings,
total,
measuredCount,
simulatedCount,
estimatedCount,
source,
};
if (render && monitor.mounted) renderMonitor();
window.dispatchEvent(new CustomEvent("cognitive-agent:energy-snapshot", { detail: publicSnapshot() }));
return publicSnapshot();
}
function ingestPowerTelemetry(payload, { sample = true } = {}) {
const receivedAt = Date.now();
const updates = payload?.devices && typeof payload.devices === "object"
? Object.entries(payload.devices)
: payload?.device ? [[payload.device, payload]] : [];
updates.forEach(([rawKey, rawTelemetry]) => {
const deviceId = aliasToDevice.get(String(rawKey || "").trim().toLowerCase());
if (!deviceId) return;
const telemetry = rawTelemetry && typeof rawTelemetry === "object"
? rawTelemetry
: { power_w: rawTelemetry };
const directEnergyWh = finiteReportedEnergy(telemetry.energy_wh ?? telemetry.total_energy_wh);
const tripEnergyKwh = finiteNonNegative(telemetry.trip_energy_kwh);
const todayEnergyKwh = finiteNonNegative(telemetry.today_energy_kwh);
const tripEnergyWh = tripEnergyKwh === null ? null : finiteReportedEnergy(tripEnergyKwh * 1000);
const todayEnergyWh = todayEnergyKwh === null ? null : finiteReportedEnergy(todayEnergyKwh * 1000);
const reportedEnergyWh = directEnergyWh
?? tripEnergyWh
?? todayEnergyWh
?? null;
const reportedEnergyLabel = directEnergyWh !== null
? String(telemetry.energy_label || "车端累计")
: tripEnergyWh !== null ? "本次行程"
: todayEnergyWh !== null ? "今日累计"
: "";
const source = telemetrySource(telemetry, payload);
if (reportedEnergyWh !== null) {
monitor.reportedEnergy.set(deviceId, {
wattsHours: reportedEnergyWh,
label: reportedEnergyLabel,
source,
receivedAt,
});
}
const watts = finitePower(
telemetry.power_w ?? telemetry.total_power_w ?? telemetry.current_power_w,
);
if (watts === null) return;
const parsedUpdatedAt = telemetry.updated_at ? new Date(telemetry.updated_at).getTime() : NaN;
monitor.measured.set(deviceId, {
watts,
receivedAt,
updatedAt: Number.isFinite(parsedUpdatedAt) ? parsedUpdatedAt : receivedAt,
source,
});
});
return sample ? sampleNow() : publicSnapshot();
}
function formatPowerParts(watts) {
const value = finiteNonNegative(watts) || 0;
if (value >= 1000) return { value: (value / 1000).toFixed(value >= 10000 ? 1 : 2), unit: "kW" };
return { value: String(Math.round(value)), unit: "W" };
}
function formatPower(watts, estimated = false) {
const parts = formatPowerParts(watts);
return `${estimated ? "≈ " : ""}${parts.value} ${parts.unit}`;
}
function formatEnergy(wh, estimated = false) {
const value = finiteNonNegative(wh) || 0;
const prefix = estimated ? "≈ " : "";
if (value >= 1000) return `${prefix}${(value / 1000).toFixed(3)} kWh`;
return `${prefix}${value < 10 ? value.toFixed(2) : value.toFixed(1)} Wh`;
}
function sourceLabel(source) {
return {
measured: "车端实测",
simulated: "模拟数据",
simulated_estimated: "模拟 + 估算",
measured_simulated: "实测 + 模拟",
measured_simulated_estimated: "实测 + 模拟 + 估算",
mixed: "实测 + 估算",
estimated: "状态估算",
}[source] || "状态估算";
}
function sourceShortLabel(source) {
return source === "measured" ? "实测" : source === "simulated" ? "模拟" : "估算";
}
function totalPowerQualifier(source) {
if (source === "measured") return "实测总功率";
if (source === "simulated") return "模拟总功率";
if (source === "estimated") return "估算总功率";
return "混合来源总功率";
}
function niceMaximum(value) {
const safe = Math.max(1, value);
const magnitude = 10 ** Math.floor(Math.log10(safe));
const normalized = safe / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
function chartPoints(values, maxValue, width = 700, height = 238, padding = { left: 42, right: 14, top: 16, bottom: 28 }) {
if (values.length < 2) return [];
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
return values.map((value, index) => ({
x: padding.left + innerWidth * index / (values.length - 1),
y: padding.top + innerHeight * (1 - Math.max(0, value) / maxValue),
}));
}
function polylinePoints(values, maxValue, width, height, padding) {
return chartPoints(values, maxValue, width, height, padding)
.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`)
.join(" ");
}
function renderMainChart() {
const values = monitor.frames.map((frame) => frameSeriesValue(frame, monitor.activeSeries));
const maximum = niceMaximum(Math.max(...values, 1));
const points = chartPoints(values, maximum);
const line = $("#energyMonitorChartLine");
const area = $("#energyMonitorChartArea");
const svg = $("#energyMonitorChart");
const baseline = 210;
line?.setAttribute("points", points.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "));
area?.setAttribute("d", points.length
? `M ${points[0].x.toFixed(1)} ${baseline} L ${points.map(({ x, y }) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1).x.toFixed(1)} ${baseline} Z`
: "");
const selectedDevice = ENERGY_MONITORED_DEVICES.find(({ id }) => id === monitor.activeSeries);
const selectedSeries = SERIES.find(({ id }) => id === monitor.activeSeries);
const label = selectedDevice?.name || selectedSeries?.name || "总功率";
const current = values.at(-1) || 0;
const currentReading = selectedDevice ? monitor.latest?.readings?.[selectedDevice.id] : null;
const estimated = currentReading ? currentReading.source !== "measured" : monitor.latest?.source !== "measured";
$("#energyMonitorTrendName").textContent = label;
$("#energyMonitorTrendCurrent").textContent = formatPower(current, estimated);
$("#energyMonitorChartMax").textContent = formatPower(maximum);
$("#energyMonitorChartMid").textContent = formatPower(maximum / 2);
const spanSeconds = Math.max(1, Math.round(((monitor.frames.at(-1)?.timestamp || 0) - (monitor.frames[0]?.timestamp || 0)) / 1000));
const windowSeconds = monitor.frames.length >= HISTORY_LIMIT ? 60 : Math.min(60, spanSeconds);
$("#energyMonitorTrendWindow").textContent = `最近 ${windowSeconds} 秒`;
$("#energyMonitorChartStart").textContent = `${windowSeconds} 秒前`;
if (svg) svg.setAttribute("aria-label", `${label}最近 ${windowSeconds} 秒功率趋势,当前${formatPower(current, estimated)}`);
$$('[data-energy-series]').forEach((button) => {
const selected = button.dataset.energySeries === monitor.activeSeries;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
$$('[data-energy-device]').forEach((button) => {
const selected = button.dataset.energyDevice === monitor.activeSeries;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
});
}
function renderDeviceRows() {
if (!monitor.latest) return;
const total = Math.max(monitor.latest.total, 0.0001);
ENERGY_MONITORED_DEVICES.forEach((device) => {
const row = $(`[data-energy-device="${device.id}"]`);
const reading = monitor.latest.readings[device.id];
if (!row || !reading) return;
const estimated = reading.source !== "measured";
$("[data-energy-device-power]", row).textContent = formatPower(reading.watts, estimated);
const hasReportedEnergy = reading.reportedEnergyWh !== null && reading.reportedEnergyWh !== undefined;
const displayedEnergyWh = hasReportedEnergy ? reading.reportedEnergyWh : monitor.energyWh[device.id];
const reportedEnergyEstimated = !hasReportedEnergy || reading.reportedEnergySource !== "measured";
$("[data-energy-device-energy]", row).textContent = formatEnergy(displayedEnergyWh, reportedEnergyEstimated);
$("[data-energy-device-energy]", row).title = hasReportedEnergy
? `${reading.reportedEnergyLabel || "车端累计"}能耗`
: "本次监测功率积分估算";
$("[data-energy-device-source]", row).textContent = sourceShortLabel(reading.source);
$("[data-energy-device-source]", row).dataset.source = reading.source;
$("[data-energy-device-activity]", row).textContent = reading.activity;
const share = Math.max(0, Math.min(100, reading.watts / total * 100));
$("[data-energy-device-share]", row).textContent = `${share.toFixed(1)}%`;
row.style.setProperty("--energy-share", `${share}%`);
const values = monitor.frames.map((frame) => frame.values[device.id] || 0);
const maximum = Math.max(...values, 1);
$("[data-energy-device-sparkline]", row)?.setAttribute(
"points",
polylinePoints(values, maximum, 92, 28, { left: 1, right: 1, top: 3, bottom: 3 }),
);
const energyScope = hasReportedEnergy ? reading.reportedEnergyLabel || "车端累计" : "本次监测估算";
row.setAttribute("aria-label", `${device.name},当前${formatPower(reading.watts, estimated)},${energyScope}${formatEnergy(displayedEnergyWh, reportedEnergyEstimated)},点击查看趋势`);
});
}
function renderMonitor() {
if (!monitor.latest) return;
const totalParts = formatPowerParts(monitor.latest.total);
const estimated = monitor.latest.source !== "measured";
$("#energyMonitorTotalPower").textContent = totalParts.value;
$("#energyMonitorTotalUnit").textContent = totalParts.unit;
$("#energyMonitorTotalQualifier").textContent = totalPowerQualifier(monitor.latest.source);
$("#energyMonitorAccumulated").textContent = formatEnergy(
Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0),
true,
);
const elapsedHours = Math.max(1 / 3600, (monitor.latest.timestamp - monitor.startedAt) / 3600000);
$("#energyMonitorAverage").textContent = formatPower(
Object.values(monitor.energyWh).reduce((sum, value) => sum + value, 0) / elapsedHours,
true,
);
$("#energyMonitorCoverage").textContent = `${ENERGY_MONITORED_DEVICES.length} / ${ENERGY_MONITORED_DEVICES.length}`;
$("#energyMonitorSource").textContent = sourceLabel(monitor.latest.source);
$("#energyMonitorSource").dataset.source = monitor.latest.source;
const sourceBreakdown = [
monitor.latest.measuredCount ? `${monitor.latest.measuredCount} 路实测` : "",
monitor.latest.simulatedCount ? `${monitor.latest.simulatedCount} 路模拟` : "",
monitor.latest.estimatedCount ? `${monitor.latest.estimatedCount} 路估算` : "",
].filter(Boolean).join(" · ");
$("#energyMonitorCoverageNote").textContent = sourceBreakdown || "等待设备采样";
$("#energyMonitorLiveLabelText").textContent = monitor.latest.measuredCount
? "实时监测"
: monitor.latest.simulatedCount ? "动态模拟" : "状态估算";
$("#energyMonitorFreshness").textContent = `采样更新 ${new Date(monitor.latest.timestamp).toLocaleTimeString("zh-CN", { hour12: false })}`;
$("#energyMonitorPage").dataset.revision = String(Number($("#energyMonitorPage").dataset.revision || 0) + 1);
$("#energyMonitorPage").dataset.sampleCount = String(monitor.frames.length);
renderMainChart();
renderDeviceRows();
}
function deviceRowsMarkup() {
return ENERGY_MONITORED_DEVICES.map((device) => `
<button class="energy-device-row" type="button" data-energy-device="${device.id}" aria-pressed="false">
<span class="energy-device-icon"><i data-lucide="${device.icon}"></i></span>
<span class="energy-device-identity"><strong>${device.name}</strong><small><b data-energy-device-source data-source="estimated">估算</b><i></i><span data-energy-device-activity>等待采样</span></small></span>
<span class="energy-device-metrics"><strong data-energy-device-power>-- W</strong><small data-energy-device-energy>-- Wh</small></span>
<svg class="energy-device-sparkline" viewBox="0 0 92 28" preserveAspectRatio="none" aria-hidden="true"><polyline data-energy-device-sparkline points=""></polyline></svg>
<span class="energy-device-share"><b data-energy-device-share>0%</b><i><em></em></i></span>
</button>`).join("");
}
function createLayer() {
const trigger = document.createElement("button");
trigger.id = "energyMonitorToggle";
trigger.className = "energy-monitor-toggle v2-panel-control-button";
trigger.type = "button";
trigger.setAttribute("aria-controls", "energyMonitorLayer");
trigger.setAttribute("aria-pressed", "false");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-label", "打开整车能耗监测");
trigger.title = "打开整车能耗监测";
trigger.innerHTML = '<i data-lucide="activity"></i><span class="visually-hidden">整车能耗监测</span><b class="energy-monitor-toggle-live" aria-hidden="true"></b>';
const layer = document.createElement("div");
layer.id = "energyMonitorLayer";
layer.className = "energy-monitor-layer";
layer.hidden = true;
layer.inert = true;
layer.setAttribute("role", "dialog");
layer.setAttribute("aria-modal", "true");
layer.setAttribute("aria-labelledby", "energyMonitorTitle");
layer.innerHTML = `
<section class="energy-monitor-page" id="energyMonitorPage" data-revision="0" data-sample-count="0">
<header class="energy-monitor-header">
<div class="energy-monitor-heading">
<span><i data-lucide="gauge"></i></span>
<div><h2 id="energyMonitorTitle">整车能耗监测</h2><p>实时追踪各可控车端设备功率与累计能耗</p></div>
</div>
<div class="energy-monitor-live-state">
<span class="energy-monitor-live-label"><i></i><b id="energyMonitorLiveLabelText">状态估算</b></span>
<span id="energyMonitorFreshness">最后更新 --:--:--</span>
<span class="energy-monitor-source" id="energyMonitorSource" data-source="estimated">状态估算</span>
<button id="energyMonitorClose" type="button" aria-label="关闭整车能耗监测" title="关闭"><i data-lucide="x"></i></button>
</div>
</header>
<div class="energy-monitor-content">
<section class="energy-monitor-kpis" aria-label="整车能耗摘要">
<article class="energy-kpi energy-kpi-primary"><small>实时总功率</small><strong><b id="energyMonitorTotalPower">--</b><span id="energyMonitorTotalUnit">W</span></strong><em id="energyMonitorTotalQualifier">等待采样</em><i data-lucide="zap"></i></article>
<article class="energy-kpi"><small>本次监测累计能耗</small><strong id="energyMonitorAccumulated">-- Wh</strong><em>按实时功率积分</em></article>
<article class="energy-kpi"><small>本次监测平均功率</small><strong id="energyMonitorAverage">-- W</strong><em>自页面加载开始</em></article>
<article class="energy-kpi"><small>监测设备</small><strong id="energyMonitorCoverage">7 / 7</strong><em id="energyMonitorCoverageNote">等待设备采样</em></article>
</section>
<div class="energy-monitor-dashboard">
<section class="energy-trend-card" aria-labelledby="energyTrendTitle">
<header>
<div><h3 id="energyTrendTitle">功率趋势</h3><p><span id="energyMonitorTrendName">总功率</span> · <span id="energyMonitorTrendWindow">最近 24 秒</span></p></div>
<strong id="energyMonitorTrendCurrent">-- W</strong>
</header>
<div class="energy-trend-tabs" role="group" aria-label="选择功率趋势">
${SERIES.map((series) => `<button type="button" data-energy-series="${series.id}" aria-pressed="${String(series.id === "total")}" class="${series.id === "total" ? "active" : ""}">${series.name}</button>`).join("")}
</div>
<div class="energy-chart-wrap">
<span class="energy-chart-y energy-chart-y-top" id="energyMonitorChartMax">-- W</span>
<span class="energy-chart-y energy-chart-y-mid" id="energyMonitorChartMid">-- W</span>
<span class="energy-chart-y energy-chart-y-bottom">0</span>
<svg id="energyMonitorChart" class="energy-monitor-chart" viewBox="0 0 700 238" preserveAspectRatio="none" role="img" aria-label="功率趋势">
<defs><linearGradient id="energyMonitorAreaGradient" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".22"></stop><stop offset="1" stop-color="currentColor" stop-opacity=".015"></stop></linearGradient></defs>
<g class="energy-chart-grid"><line x1="42" y1="16" x2="686" y2="16"></line><line x1="42" y1="113" x2="686" y2="113"></line><line x1="42" y1="210" x2="686" y2="210"></line></g>
<path id="energyMonitorChartArea" class="energy-chart-area" d=""></path>
<polyline id="energyMonitorChartLine" class="energy-chart-line" points=""></polyline>
</svg>
<div class="energy-chart-x"><span id="energyMonitorChartStart">60 秒前</span><span>现在</span></div>
</div>
<footer><span><i></i>当前选中曲线</span><p>点击右侧设备可查看其独立趋势</p></footer>
</section>
<section class="energy-devices-card" aria-labelledby="energyDevicesTitle">
<header><div><h3 id="energyDevicesTitle">设备实时功率</h3><p>功率 · 累计能耗 · 占比</p></div><span>${ENERGY_MONITORED_DEVICES.length} 个可控设备</span></header>
<div class="energy-device-list" id="energyMonitorDeviceList">${deviceRowsMarkup()}</div>
<footer><i data-lucide="info"></i><span>优先读取车端功率通道;未接入独立电表的设备显示明确标注的状态估算值。</span></footer>
</section>
</div>
</div>
</section>`;
$("#v2PanelControlDock").append(trigger);
document.body.append(layer);
trigger.addEventListener("click", () => setOpen(!monitor.open));
$("#energyMonitorClose", layer).addEventListener("click", () => setOpen(false));
layer.addEventListener("click", (event) => {
if (event.target === layer) setOpen(false);
});
layer.addEventListener("click", (event) => {
const seriesButton = event.target.closest("[data-energy-series]");
const deviceButton = event.target.closest("[data-energy-device]");
if (seriesButton) selectSeries(seriesButton.dataset.energySeries);
if (deviceButton) selectSeries(deviceButton.dataset.energyDevice);
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function focusableElements() {
return $$('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', $("#energyMonitorLayer"))
.filter((element) => !element.hidden && element.offsetParent !== null);
}
function setBackgroundInert(inert) {
const layer = $("#energyMonitorLayer");
if (!layer || !document.body) return;
if (inert) {
monitor.backgroundInert = [...document.body.children]
.filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName))
.map((element) => ({ element, wasInert: element.hasAttribute("inert") }));
monitor.backgroundInert.forEach(({ element }) => { element.toggleAttribute("inert", true); });
return;
}
monitor.backgroundInert.forEach(({ element, wasInert }) => { element.toggleAttribute("inert", wasInert); });
monitor.backgroundInert = [];
}
function captureModalKeys(event) {
if (!monitor.open) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopImmediatePropagation();
setOpen(false);
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements();
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1);
const layer = $("#energyMonitorLayer");
if (!layer?.contains(document.activeElement)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function setOpen(open) {
const next = Boolean(open);
const layer = $("#energyMonitorLayer");
const trigger = $("#energyMonitorToggle");
if (!layer || !trigger || monitor.open === next) return;
monitor.open = next;
trigger.classList.toggle("active", next);
trigger.setAttribute("aria-pressed", String(next));
trigger.setAttribute("aria-expanded", String(next));
trigger.setAttribute("aria-label", next ? "关闭整车能耗监测" : "打开整车能耗监测");
trigger.title = next ? "关闭整车能耗监测" : "打开整车能耗监测";
if (next) {
monitor.returnFocus = document.activeElement;
layer.hidden = false;
layer.inert = false;
layer.dataset.open = "true";
setBackgroundInert(true);
renderMonitor();
requestAnimationFrame(() => $("#energyMonitorClose", layer)?.focus());
} else {
layer.dataset.open = "false";
layer.inert = true;
layer.hidden = true;
setBackgroundInert(false);
const target = monitor.returnFocus instanceof HTMLElement && monitor.returnFocus.isConnected
? monitor.returnFocus
: trigger;
monitor.returnFocus = null;
target.focus();
}
}
function selectSeries(seriesId) {
const valid = SERIES.some(({ id }) => id === seriesId)
|| ENERGY_MONITORED_DEVICES.some(({ id }) => id === seriesId);
if (!valid) return;
monitor.activeSeries = seriesId;
renderMainChart();
}
function deviceIdsForControlDomain(domain, context = "") {
const normalizedDomain = normalizeSource(domain).replace(/^native_/, "");
const normalizedContext = normalizeSource(context).replace(/[^a-z0-9\u4e00-\u9fff]+/g, "_");
if (normalizedDomain === "seat" || /(^|_)seat(_|$)/.test(normalizedContext)) {
if (/(^|_)(driver|front_left|left_front)(_|$)|主驾/.test(normalizedContext)) return ["driver"];
if (/(^|_)(passenger|front_right|right_front)(_|$)|副驾/.test(normalizedContext)) return ["passenger"];
return ["passenger"];
}
if (["window", "windows"].includes(normalizedDomain) || /(^|_)window(s)?(_|$)/.test(normalizedContext)) return ["windows"];
if (["roof", "shade", "sunshade"].includes(normalizedDomain) || /(^|_)(roof|shade|sunshade)(_|$)/.test(normalizedContext)) return ["shade"];
if (["climate", "hvac", "outlet"].includes(normalizedDomain) || /(^|_)(climate|hvac|outlet|ac)(_|$)/.test(normalizedContext)) return ["climate"];
if (["interior_light", "reading", "reading_light"].includes(normalizedDomain) || /(^|_)(interior|reading)_?light(_|$)/.test(normalizedContext)) return ["reading"];
if (["ambient", "ambient_light"].includes(normalizedDomain) || /(^|_)ambient_?light(_|$)/.test(normalizedContext)) return ["ambient"];
return [];
}
function controlActivityDevices(payload = {}) {
const wrappedResult = payload?.result && typeof payload.result === "object";
const eventPayload = wrappedResult ? payload.result : payload;
const status = String(eventPayload?.status || "");
if (payload?.error) return [];
if (wrappedResult && !["succeeded", "partially_succeeded"].includes(status)) return [];
if (!wrappedResult && status && !["processing", "succeeded", "partially_succeeded", "submitted"].includes(status)) return [];
if (eventPayload?.preview_only === true || /_preview$/.test(String(eventPayload?.orchestration_route || ""))) {
return [];
}
const deviceIds = new Set();
const directId = aliasToDevice.get(normalizeSource(eventPayload?.device_id))
|| normalizeSource(eventPayload?.device_id);
if (ENERGY_MONITORED_DEVICES.some(({ id }) => id === directId)) deviceIds.add(directId);
const actions = [
...(Array.isArray(eventPayload?.actions) ? eventPayload.actions : []),
...(Array.isArray(eventPayload?.validated_actions) ? eventPayload.validated_actions : []),
...(Array.isArray(eventPayload?.intent_ir?.spec?.actions) ? eventPayload.intent_ir.spec.actions : []),
];
actions.forEach((action) => {
const capabilityDomain = String(action?.capability || "").split(".")[0];
const context = JSON.stringify({
capability: action?.capability,
target: action?.target,
});
deviceIdsForControlDomain(action?.domain || capabilityDomain, context).forEach((id) => deviceIds.add(id));
});
const functionConfigs = Object.entries(eventPayload?.function_configs || {});
if (eventPayload?.function_config && typeof eventPayload.function_config === "object") {
functionConfigs.push([eventPayload.function_config?.spec?.domain || "function", eventPayload.function_config]);
}
functionConfigs.forEach(([configKey, config]) => {
const domain = config?.spec?.domain || config?.domain || configKey;
const operations = config?.spec?.operations || config?.operations || [];
if (!operations.length) {
deviceIdsForControlDomain(domain, configKey).forEach((id) => deviceIds.add(id));
return;
}
operations.forEach((operation) => {
const context = JSON.stringify({
capability: operation?.capability,
target: operation?.target,
});
deviceIdsForControlDomain(operation?.domain || domain, context).forEach((id) => deviceIds.add(id));
});
});
Object.entries(eventPayload?.command_plans || {}).forEach(([planKey, plan]) => {
const domain = plan?.spec?.domain || plan?.domain || planKey;
const commands = plan?.spec?.commands || plan?.commands || [];
const context = commands.length ? JSON.stringify(commands) : planKey;
deviceIdsForControlDomain(domain, context).forEach((id) => deviceIds.add(id));
});
return [...deviceIds];
}
function markControlActivity(payload) {
const until = Date.now() + 4000;
const requestId = controlRequestId(payload);
const currentDeviceIds = controlActivityDevices(payload);
const existing = requestId ? monitor.controlRequests.get(requestId) : null;
const deviceIds = [...new Set([...(existing?.deviceIds || []), ...currentDeviceIds])];
deviceIds.forEach((deviceId) => {
monitor.motionUntil.set(deviceId, Math.max(monitor.motionUntil.get(deviceId) || 0, until));
});
if (requestId && deviceIds.length) {
monitor.controlRequests.set(requestId, { deviceIds, until });
}
return currentDeviceIds;
}
function controlRequestId(payload = {}) {
const eventPayload = payload?.result && typeof payload.result === "object"
? payload.result
: payload;
return String(eventPayload?.request_id || payload?.request_id || "").trim();
}
function completeControlActivity(payload = {}) {
const eventPayload = payload?.result && typeof payload.result === "object"
? payload.result
: payload;
const requestId = controlRequestId(payload);
const tracked = requestId ? monitor.controlRequests.get(requestId) : null;
const succeeded = !payload?.error
&& ["succeeded", "partially_succeeded"].includes(String(eventPayload?.status || ""));
if (tracked) {
monitor.controlRequests.delete(requestId);
if (!succeeded) {
const now = Date.now();
tracked.deviceIds.forEach((deviceId) => {
if ((monitor.motionUntil.get(deviceId) || 0) > tracked.until) return;
const remainingUntil = [...monitor.controlRequests.values()]
.filter((request) => request.deviceIds.includes(deviceId))
.reduce((maximum, request) => Math.max(maximum, request.until), now);
monitor.motionUntil.set(deviceId, remainingUntil);
});
}
return tracked.deviceIds;
}
return succeeded ? markControlActivity(payload) : [];
}
function seedHistory() {
const now = Date.now();
for (let offset = 23; offset >= 1; offset -= 1) {
sampleNow({ now: now - offset * SAMPLE_INTERVAL_MS, integrate: false, render: false });
}
monitor.lastSampleAt = now - SAMPLE_INTERVAL_MS;
sampleNow({ now, integrate: false, render: false });
monitor.startedAt = now;
}
function mount(attempt = 0) {
if (monitor.mounted) return;
if (!$("#v2PanelControlDock")) {
if (attempt < 20) window.setTimeout(() => mount(attempt + 1), 50);
return;
}
monitor.mounted = true;
createLayer();
seedHistory();
renderMonitor();
monitor.timer = window.setInterval(
() => sampleNow({ render: monitor.open }),
SAMPLE_INTERVAL_MS,
);
document.addEventListener("keydown", captureModalKeys, true);
window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false }));
const existingUpdater = window.updateDevicePowerTelemetry;
if (typeof existingUpdater === "function" && !existingUpdater.energyMonitorLinked) {
const linkedUpdater = (payload) => {
const result = existingUpdater(payload);
ingestPowerTelemetry(payload, { sample: false });
return result;
};
linkedUpdater.energyMonitorLinked = true;
window.updateDevicePowerTelemetry = linkedUpdater;
}
window.addEventListener("cognitive-agent:vehicle-can-control-request", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:validated-can-commands", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:vehicle-control-activity", (event) => markControlActivity(event.detail || {}));
window.addEventListener("cognitive-agent:request-complete", (event) => completeControlActivity(event.detail || {}));
}
window.CognitiveAgentEnergyMonitor = {
close: () => setOpen(false),
getDevices: () => ENERGY_MONITORED_DEVICES.map(({ id, name, telemetryKeys }) => ({ id, name, telemetryKeys: [...telemetryKeys] })),
getSnapshot: publicSnapshot,
ingestTelemetry: ingestPowerTelemetry,
isOpen: () => monitor.open,
completeControlActivity,
notifyControlActivity: markControlActivity,
open: () => setOpen(true),
sampleNow,
selectSeries,
};
document.addEventListener("DOMContentLoaded", () => mount());
}());