feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -0,0 +1,905 @@
|
||||
(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(), 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());
|
||||
}());
|
||||
Reference in New Issue
Block a user