feat(n7web): sync N7 scene cards and optimize runtime
This commit is contained in:
+324
-28
@@ -60,6 +60,8 @@ const state = {
|
|||||||
vehicleStateTurn: -1,
|
vehicleStateTurn: -1,
|
||||||
seatTwin: null,
|
seatTwin: null,
|
||||||
seatActuatorState: {},
|
seatActuatorState: {},
|
||||||
|
seatRenderFingerprint: "",
|
||||||
|
domainRenderFingerprint: "",
|
||||||
seatFeedbackSource: "simulated",
|
seatFeedbackSource: "simulated",
|
||||||
seatFeedbackSources: { passenger: "simulated", driver: "unavailable" },
|
seatFeedbackSources: { passenger: "simulated", driver: "unavailable" },
|
||||||
availableSeatAxes: null,
|
availableSeatAxes: null,
|
||||||
@@ -76,6 +78,14 @@ const state = {
|
|||||||
modeRunTimer: null,
|
modeRunTimer: null,
|
||||||
modeHighlightTimer: null,
|
modeHighlightTimer: null,
|
||||||
latestModeRefreshKey: "",
|
latestModeRefreshKey: "",
|
||||||
|
modeProfileSync: {
|
||||||
|
initialized: false,
|
||||||
|
inFlight: false,
|
||||||
|
poller: null,
|
||||||
|
catalogRevision: "",
|
||||||
|
importedCount: 0,
|
||||||
|
rejectedCount: 0,
|
||||||
|
},
|
||||||
modeEditor: null,
|
modeEditor: null,
|
||||||
directCanCommandPending: false,
|
directCanCommandPending: false,
|
||||||
};
|
};
|
||||||
@@ -90,6 +100,7 @@ const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(inpu
|
|||||||
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
const REQUEST_TIMEOUT_MS = 45 * 1000;
|
const REQUEST_TIMEOUT_MS = 45 * 1000;
|
||||||
const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000;
|
const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000;
|
||||||
|
const MODE_PROFILE_SYNC_POLL_INTERVAL_MS = 1000;
|
||||||
const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]);
|
const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]);
|
||||||
|
|
||||||
async function apiResponseError(response) {
|
async function apiResponseError(response) {
|
||||||
@@ -2025,6 +2036,7 @@ function applyTwinSceneTheme(twin) {
|
|||||||
canvas.dataset.themeRole = role;
|
canvas.dataset.themeRole = role;
|
||||||
canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`;
|
canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`;
|
||||||
}
|
}
|
||||||
|
twin.requestRender?.({ hotspots: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTwinVehicleVisual(systems, timestamp) {
|
function updateTwinVehicleVisual(systems, timestamp) {
|
||||||
@@ -2574,6 +2586,10 @@ function configureFixedTwinSeat(THREE, model, position, role) {
|
|||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestSeatTwinRender(options = {}) {
|
||||||
|
state.seatTwin?.requestRender?.(options);
|
||||||
|
}
|
||||||
|
|
||||||
function setTwinSelection(selected) {
|
function setTwinSelection(selected) {
|
||||||
const twin = state.seatTwin;
|
const twin = state.seatTwin;
|
||||||
if (!twin) return;
|
if (!twin) return;
|
||||||
@@ -2609,6 +2625,7 @@ function setTwinSelection(selected) {
|
|||||||
twin.view.targetX = 0;
|
twin.view.targetX = 0;
|
||||||
twin.view.yaw = -0.62;
|
twin.view.yaw = -0.62;
|
||||||
renderTwinReadout();
|
renderTwinReadout();
|
||||||
|
requestSeatTwinRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) {
|
function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) {
|
||||||
@@ -2734,6 +2751,7 @@ function updateSeatTwin(actuatorState) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
renderTwinReadout();
|
renderTwinReadout();
|
||||||
|
requestSeatTwinRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
const REFERENCE_VEHICLE_ROOT = "/assets/models/byd-tang/";
|
const REFERENCE_VEHICLE_ROOT = "/assets/models/byd-tang/";
|
||||||
@@ -4080,7 +4098,7 @@ function initSeatTwin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderer.autoClear = false;
|
renderer.autoClear = false;
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
|
||||||
if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
|
if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
else renderer.outputEncoding = THREE.sRGBEncoding;
|
else renderer.outputEncoding = THREE.sRGBEncoding;
|
||||||
renderer.shadowMap.enabled = true;
|
renderer.shadowMap.enabled = true;
|
||||||
@@ -4218,6 +4236,15 @@ function initSeatTwin() {
|
|||||||
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
|
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
|
||||||
dragging: false,
|
dragging: false,
|
||||||
pointer: { x: 0, y: 0 },
|
pointer: { x: 0, y: 0 },
|
||||||
|
animationFrame: null,
|
||||||
|
renderRequested: false,
|
||||||
|
settleFrames: 0,
|
||||||
|
inViewport: true,
|
||||||
|
hotspotsDirty: true,
|
||||||
|
hotspotLastLayoutAt: Number.NEGATIVE_INFINITY,
|
||||||
|
hotspotMetrics: new Map(),
|
||||||
|
hotspotElements: new Map(),
|
||||||
|
requestRender,
|
||||||
};
|
};
|
||||||
applyTwinSceneTheme(state.seatTwin);
|
applyTwinSceneTheme(state.seatTwin);
|
||||||
window.addEventListener("cognitive-agent:module-change", () => {
|
window.addEventListener("cognitive-agent:module-change", () => {
|
||||||
@@ -4284,6 +4311,7 @@ function initSeatTwin() {
|
|||||||
distance: 10.2,
|
distance: 10.2,
|
||||||
targetX: 0,
|
targetX: 0,
|
||||||
});
|
});
|
||||||
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
let stageWasVisible = false;
|
let stageWasVisible = false;
|
||||||
@@ -4294,6 +4322,8 @@ function initSeatTwin() {
|
|||||||
camera.aspect = width / height;
|
camera.aspect = width / height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
state.seatTwin.narrowViewport = width < 600;
|
state.seatTwin.narrowViewport = width < 600;
|
||||||
|
state.seatTwin.hotspotMetrics.clear();
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function synchronizeTwinViewport({ reset = false } = {}) {
|
function synchronizeTwinViewport({ reset = false } = {}) {
|
||||||
@@ -4317,6 +4347,31 @@ function initSeatTwin() {
|
|||||||
stageObserver.observe(stage);
|
stageObserver.observe(stage);
|
||||||
state.seatTwin.resizeObserver = stageObserver;
|
state.seatTwin.resizeObserver = stageObserver;
|
||||||
}
|
}
|
||||||
|
if (window.IntersectionObserver) {
|
||||||
|
const visibilityObserver = new IntersectionObserver(([entry]) => {
|
||||||
|
const twin = state.seatTwin;
|
||||||
|
if (!twin) return;
|
||||||
|
twin.inViewport = Boolean(entry?.isIntersecting);
|
||||||
|
if (!twin.inViewport && twin.animationFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(twin.animationFrame);
|
||||||
|
twin.animationFrame = null;
|
||||||
|
} else if (twin.inViewport) {
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
|
}
|
||||||
|
}, { root: null, rootMargin: "100px" });
|
||||||
|
visibilityObserver.observe(stage);
|
||||||
|
state.seatTwin.visibilityObserver = visibilityObserver;
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
const twin = state.seatTwin;
|
||||||
|
if (!twin) return;
|
||||||
|
if (document.visibilityState !== "visible" && twin.animationFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(twin.animationFrame);
|
||||||
|
twin.animationFrame = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
|
});
|
||||||
window.addEventListener("resize", scheduleViewportSync);
|
window.addEventListener("resize", scheduleViewportSync);
|
||||||
window.addEventListener("cognitive-agent:module-change", scheduleViewportSync);
|
window.addEventListener("cognitive-agent:module-change", scheduleViewportSync);
|
||||||
canvas.addEventListener("webglcontextlost", (event) => {
|
canvas.addEventListener("webglcontextlost", (event) => {
|
||||||
@@ -4342,6 +4397,7 @@ function initSeatTwin() {
|
|||||||
refreshIcons();
|
refreshIcons();
|
||||||
window.setTimeout(() => { fallback.hidden = true; }, 3200);
|
window.setTimeout(() => { fallback.hidden = true; }, 3200);
|
||||||
}
|
}
|
||||||
|
requestRender({ settleFrames: 8 });
|
||||||
});
|
});
|
||||||
synchronizeTwinViewport({ reset: true });
|
synchronizeTwinViewport({ reset: true });
|
||||||
stage.addEventListener("pointerdown", (event) => {
|
stage.addEventListener("pointerdown", (event) => {
|
||||||
@@ -4349,6 +4405,7 @@ function initSeatTwin() {
|
|||||||
state.seatTwin.pointer = { x: event.clientX, y: event.clientY };
|
state.seatTwin.pointer = { x: event.clientX, y: event.clientY };
|
||||||
stage.setPointerCapture(event.pointerId);
|
stage.setPointerCapture(event.pointerId);
|
||||||
stage.classList.add("dragging");
|
stage.classList.add("dragging");
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
});
|
});
|
||||||
stage.addEventListener("pointermove", (event) => {
|
stage.addEventListener("pointermove", (event) => {
|
||||||
const twin = state.seatTwin;
|
const twin = state.seatTwin;
|
||||||
@@ -4358,11 +4415,13 @@ function initSeatTwin() {
|
|||||||
twin.pointer = { x: event.clientX, y: event.clientY };
|
twin.pointer = { x: event.clientX, y: event.clientY };
|
||||||
twin.view.yaw -= dx * 0.006;
|
twin.view.yaw -= dx * 0.006;
|
||||||
twin.view.pitch = Math.max(0.12, Math.min(0.68, twin.view.pitch + dy * 0.004));
|
twin.view.pitch = Math.max(0.12, Math.min(0.68, twin.view.pitch + dy * 0.004));
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
});
|
});
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
if (!state.seatTwin) return;
|
if (!state.seatTwin) return;
|
||||||
state.seatTwin.dragging = false;
|
state.seatTwin.dragging = false;
|
||||||
stage.classList.remove("dragging");
|
stage.classList.remove("dragging");
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
};
|
};
|
||||||
stage.addEventListener("pointerup", endDrag);
|
stage.addEventListener("pointerup", endDrag);
|
||||||
stage.addEventListener("pointercancel", endDrag);
|
stage.addEventListener("pointercancel", endDrag);
|
||||||
@@ -4370,6 +4429,7 @@ function initSeatTwin() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const twin = state.seatTwin;
|
const twin = state.seatTwin;
|
||||||
twin.view.distance = Math.max(9.4, Math.min(15.2, twin.view.distance + event.deltaY * 0.006));
|
twin.view.distance = Math.max(9.4, Math.min(15.2, twin.view.distance + event.deltaY * 0.006));
|
||||||
|
requestRender({ settleFrames: 2 });
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
$("#twinReset").addEventListener("click", resetView);
|
$("#twinReset").addEventListener("click", resetView);
|
||||||
$$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat)));
|
$$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat)));
|
||||||
@@ -4392,7 +4452,11 @@ function initSeatTwin() {
|
|||||||
scene.updateMatrixWorld(true);
|
scene.updateMatrixWorld(true);
|
||||||
camera.updateMatrixWorld();
|
camera.updateMatrixWorld();
|
||||||
Object.entries(TWIN_DEVICE_HOTSPOT_LAYOUT).forEach(([deviceId, layout]) => {
|
Object.entries(TWIN_DEVICE_HOTSPOT_LAYOUT).forEach(([deviceId, layout]) => {
|
||||||
const button = $(`[data-twin-device="${deviceId}"]`, hotspotLayer);
|
let button = twin.hotspotElements.get(deviceId);
|
||||||
|
if (!button?.isConnected) {
|
||||||
|
button = $(`[data-twin-device="${deviceId}"]`, hotspotLayer);
|
||||||
|
if (button) twin.hotspotElements.set(deviceId, button);
|
||||||
|
}
|
||||||
if (!button || !twinDeviceWorldAnchor(THREE, twin, deviceId, hotspotWorldAnchor)) return;
|
if (!button || !twinDeviceWorldAnchor(THREE, twin, deviceId, hotspotWorldAnchor)) return;
|
||||||
projectedHotspot.copy(hotspotWorldAnchor).project(camera);
|
projectedHotspot.copy(hotspotWorldAnchor).project(camera);
|
||||||
const anchorX = (projectedHotspot.x * 0.5 + 0.5) * width;
|
const anchorX = (projectedHotspot.x * 0.5 + 0.5) * width;
|
||||||
@@ -4412,8 +4476,18 @@ function initSeatTwin() {
|
|||||||
button.dataset.anchorSource = "projected-3d";
|
button.dataset.anchorSource = "projected-3d";
|
||||||
|
|
||||||
const label = $(".v2-twin-hotspot-label", button);
|
const label = $(".v2-twin-hotspot-label", button);
|
||||||
const halfWidth = Math.max(44, (label?.offsetWidth || 94) / 2);
|
let metrics = twin.hotspotMetrics.get(deviceId);
|
||||||
const halfHeight = Math.max(20, (label?.offsetHeight || 42) / 2);
|
if (!metrics || metrics.label !== label) {
|
||||||
|
const measuredWidth = label?.offsetWidth || 94;
|
||||||
|
const measuredHeight = label?.offsetHeight || 42;
|
||||||
|
metrics = {
|
||||||
|
label,
|
||||||
|
halfWidth: Math.max(44, measuredWidth / 2),
|
||||||
|
halfHeight: Math.max(20, measuredHeight / 2),
|
||||||
|
};
|
||||||
|
twin.hotspotMetrics.set(deviceId, metrics);
|
||||||
|
}
|
||||||
|
const { halfWidth, halfHeight } = metrics;
|
||||||
const preferredX = anchorX + layout.labelOffset[0] * offsetScale;
|
const preferredX = anchorX + layout.labelOffset[0] * offsetScale;
|
||||||
const preferredY = anchorY + layout.labelOffset[1] * offsetScale;
|
const preferredY = anchorY + layout.labelOffset[1] * offsetScale;
|
||||||
const labelX = clampLabelCoordinate(
|
const labelX = clampLabelCoordinate(
|
||||||
@@ -4448,22 +4522,84 @@ function initSeatTwin() {
|
|||||||
window.CognitiveAgentConsoleV2?.positionTwinDevicePopover?.();
|
window.CognitiveAgentConsoleV2?.positionTwinDevicePopover?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueTwinFrame() {
|
||||||
|
const twin = state.seatTwin;
|
||||||
|
if (!twin || twin.animationFrame !== null) return;
|
||||||
|
if (document.visibilityState !== "visible" || twin.inViewport === false) return;
|
||||||
|
twin.animationFrame = window.requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRender({ hotspots = true, settleFrames = 36 } = {}) {
|
||||||
|
const twin = state.seatTwin;
|
||||||
|
if (!twin) return;
|
||||||
|
twin.renderRequested = true;
|
||||||
|
twin.settleFrames = Math.max(twin.settleFrames || 0, Math.max(0, Number(settleFrames) || 0));
|
||||||
|
if (hotspots) twin.hotspotsDirty = true;
|
||||||
|
queueTwinFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function twinNeedsAnimation(twin) {
|
||||||
|
if (twin.dragging || twin.renderRequested || twin.hotspotsDirty || twin.settleFrames > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const movingSeat = Object.values(twin.models).some((model) => (
|
||||||
|
Object.keys(model.positions).some(
|
||||||
|
(axis) => Math.abs(model.targets[axis] - model.positions[axis]) >= 0.002,
|
||||||
|
)
|
||||||
|
|| Object.keys(model.angles).some(
|
||||||
|
(axis) => Math.abs(model.angleTargets[axis] - model.angles[axis]) >= 0.002,
|
||||||
|
)
|
||||||
|
|| ["ventilation", "heating"].some((feature) => (
|
||||||
|
Math.abs(model.thermalTargets[feature] - model.thermalLevels[feature]) >= 0.002
|
||||||
|
|| model.thermalTargets[feature] > 0.015
|
||||||
|
|| model.thermalLevels[feature] > 0.015
|
||||||
|
))
|
||||||
|
));
|
||||||
|
if (movingSeat) return true;
|
||||||
|
const systems = twin.vehicleSystems;
|
||||||
|
if (!systems) return false;
|
||||||
|
const desiredAmbient = systems.targets.ambientPower ? systems.targets.ambientBrightness : 0;
|
||||||
|
const movingSystem = [
|
||||||
|
[systems.current.ambientBrightness, desiredAmbient],
|
||||||
|
[systems.current.climateLevel, systems.targets.climateLevel],
|
||||||
|
[systems.current.roofTransmittance, systems.targets.roofTransmittance],
|
||||||
|
[systems.current.roofShadePosition, systems.targets.roofShadePosition],
|
||||||
|
[systems.current.lowBeam, systems.targets.lowBeam],
|
||||||
|
[systems.current.highBeam, systems.targets.highBeam],
|
||||||
|
[systems.current.fogLight, systems.targets.fogLight],
|
||||||
|
[systems.current.turnSignal, systems.targets.turnSignal],
|
||||||
|
...Object.keys(systems.current.windowPositions).map((key) => [
|
||||||
|
systems.current.windowPositions[key],
|
||||||
|
systems.targets.windowPositions[key],
|
||||||
|
]),
|
||||||
|
].some(([current, target]) => Math.abs(Number(target) - Number(current)) >= 0.002);
|
||||||
|
return movingSystem
|
||||||
|
|| systems.targets.turnSignal > 0.015
|
||||||
|
|| systems.targets.climateLevel > 0.015
|
||||||
|
|| systems.airflow.level > 0.015;
|
||||||
|
}
|
||||||
|
|
||||||
function animate(timestamp = 0) {
|
function animate(timestamp = 0) {
|
||||||
const twin = state.seatTwin;
|
const twin = state.seatTwin;
|
||||||
if (!twin) return;
|
if (!twin) return;
|
||||||
|
twin.animationFrame = null;
|
||||||
|
if (document.visibilityState !== "visible" || twin.inViewport === false) return;
|
||||||
if (!stageHasLayout() || canvas.dataset.contextLost === "true") {
|
if (!stageHasLayout() || canvas.dataset.contextLost === "true") {
|
||||||
stageWasVisible = false;
|
stageWasVisible = false;
|
||||||
window.requestAnimationFrame(animate);
|
twin.renderRequested = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!stageWasVisible) synchronizeTwinViewport({ reset: true });
|
if (!stageWasVisible) synchronizeTwinViewport({ reset: true });
|
||||||
|
let geometryChanged = false;
|
||||||
Object.values(twin.models).forEach((model) => {
|
Object.values(twin.models).forEach((model) => {
|
||||||
Object.keys(model.positions).forEach((axis) => {
|
Object.keys(model.positions).forEach((axis) => {
|
||||||
const delta = model.targets[axis] - model.positions[axis];
|
const delta = model.targets[axis] - model.positions[axis];
|
||||||
|
if (Math.abs(delta) >= 0.002) geometryChanged = true;
|
||||||
model.positions[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
|
model.positions[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
|
||||||
});
|
});
|
||||||
Object.keys(model.angles).forEach((axis) => {
|
Object.keys(model.angles).forEach((axis) => {
|
||||||
const delta = model.angleTargets[axis] - model.angles[axis];
|
const delta = model.angleTargets[axis] - model.angles[axis];
|
||||||
|
if (Math.abs(delta) >= 0.002) geometryChanged = true;
|
||||||
model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
|
model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
|
||||||
});
|
});
|
||||||
model.slide.position.z = (model.positions.longitudinal - 50) * 0.012;
|
model.slide.position.z = (model.positions.longitudinal - 50) * 0.012;
|
||||||
@@ -4499,6 +4635,7 @@ function initSeatTwin() {
|
|||||||
);
|
);
|
||||||
updateTwinThermalVisual(model, timestamp);
|
updateTwinThermalVisual(model, timestamp);
|
||||||
});
|
});
|
||||||
|
if (geometryChanged) twin.hotspotsDirty = true;
|
||||||
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
|
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
|
||||||
const { view } = twin;
|
const { view } = twin;
|
||||||
// Fit the whole reference vehicle against the limiting horizontal field
|
// Fit the whole reference vehicle against the limiting horizontal field
|
||||||
@@ -4519,7 +4656,11 @@ function initSeatTwin() {
|
|||||||
);
|
);
|
||||||
camera.lookAt(cameraTargetX, 1.14, -0.08);
|
camera.lookAt(cameraTargetX, 1.14, -0.08);
|
||||||
updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera);
|
updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera);
|
||||||
positionTwinDeviceHotspots();
|
if (twin.hotspotsDirty && timestamp - twin.hotspotLastLayoutAt >= 100) {
|
||||||
|
positionTwinDeviceHotspots();
|
||||||
|
twin.hotspotsDirty = false;
|
||||||
|
twin.hotspotLastLayoutAt = timestamp;
|
||||||
|
}
|
||||||
canvas.dataset.framing = "whole-vehicle-centered";
|
canvas.dataset.framing = "whole-vehicle-centered";
|
||||||
renderer.clear();
|
renderer.clear();
|
||||||
camera.layers.set(0);
|
camera.layers.set(0);
|
||||||
@@ -4532,10 +4673,12 @@ function initSeatTwin() {
|
|||||||
scene.background = sceneBackground;
|
scene.background = sceneBackground;
|
||||||
camera.layers.set(0);
|
camera.layers.set(0);
|
||||||
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
|
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
|
||||||
window.requestAnimationFrame(animate);
|
twin.renderRequested = false;
|
||||||
|
twin.settleFrames = Math.max(0, twin.settleFrames - 1);
|
||||||
|
if (twinNeedsAnimation(twin)) queueTwinFrame();
|
||||||
}
|
}
|
||||||
setTwinSelection("passenger");
|
setTwinSelection("passenger");
|
||||||
animate();
|
requestRender({ settleFrames: 48 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
@@ -5114,6 +5257,10 @@ function isUserModeProfile(mode) {
|
|||||||
return mode?.origin === "user" || mode?.deletable === true;
|
return mode?.origin === "user" || mode?.deletable === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isN7SyncedModeProfile(mode) {
|
||||||
|
return mode?.origin === "n7_mqtt" || mode?.source_topic === "from_n7/scenes";
|
||||||
|
}
|
||||||
|
|
||||||
function isDynamicModeProfile(mode) {
|
function isDynamicModeProfile(mode) {
|
||||||
const behavior = String(mode?.behavior || "").trim().toLowerCase();
|
const behavior = String(mode?.behavior || "").trim().toLowerCase();
|
||||||
if (["cyclic", "dynamic"].includes(behavior)) return true;
|
if (["cyclic", "dynamic"].includes(behavior)) return true;
|
||||||
@@ -5179,10 +5326,14 @@ function createModeGalleryCard(mode) {
|
|||||||
heading.className = "mode-gallery-card-heading";
|
heading.className = "mode-gallery-card-heading";
|
||||||
const name = document.createElement("strong");
|
const name = document.createElement("strong");
|
||||||
name.textContent = mode.name || mode.id;
|
name.textContent = mode.name || mode.id;
|
||||||
|
const sourceBadge = document.createElement("span");
|
||||||
|
sourceBadge.className = "mode-gallery-source-badge";
|
||||||
|
sourceBadge.textContent = "N7 同步";
|
||||||
|
sourceBadge.hidden = !isN7SyncedModeProfile(mode);
|
||||||
const stateLabel = document.createElement("small");
|
const stateLabel = document.createElement("small");
|
||||||
stateLabel.className = "mode-gallery-state";
|
stateLabel.className = "mode-gallery-state";
|
||||||
stateLabel.textContent = "点击执行";
|
stateLabel.textContent = "点击执行";
|
||||||
heading.append(name, stateLabel);
|
heading.append(name, sourceBadge, stateLabel);
|
||||||
const description = document.createElement("span");
|
const description = document.createElement("span");
|
||||||
description.className = "mode-gallery-card-description";
|
description.className = "mode-gallery-card-description";
|
||||||
description.textContent = mode.description || "整车场景联动配置";
|
description.textContent = mode.description || "整车场景联动配置";
|
||||||
@@ -5382,6 +5533,93 @@ function refreshModeProfileFromResult(result) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modeProfileSyncCount(value) {
|
||||||
|
const count = Number(value);
|
||||||
|
return Number.isFinite(count) && count >= 0 ? Math.trunc(count) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primeModeProfileSyncFromRuntime(runtime) {
|
||||||
|
const sync = state.modeProfileSync;
|
||||||
|
const catalogRevision = String(
|
||||||
|
runtime?.mode_scene_catalog?.catalog_revision || "",
|
||||||
|
);
|
||||||
|
if (catalogRevision) sync.catalogRevision = catalogRevision;
|
||||||
|
if (sync.initialized) return;
|
||||||
|
const ingress = runtime?.vehicle_mqtt?.mode_scene_import;
|
||||||
|
if (!ingress || typeof ingress !== "object") return;
|
||||||
|
sync.importedCount = modeProfileSyncCount(ingress.imported_count);
|
||||||
|
sync.rejectedCount = modeProfileSyncCount(ingress.rejected_count);
|
||||||
|
sync.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkModeProfileSync() {
|
||||||
|
const sync = state.modeProfileSync;
|
||||||
|
if (document.visibilityState === "hidden" || sync.inFlight) return;
|
||||||
|
sync.inFlight = true;
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("/api/mode-profiles/status", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw await apiResponseError(response);
|
||||||
|
const payload = await response.json();
|
||||||
|
const ingress = payload?.mode_scene_ingress || {};
|
||||||
|
const catalogRevision = String(payload?.catalog_revision || "");
|
||||||
|
const importedCount = modeProfileSyncCount(ingress.imported_count);
|
||||||
|
const rejectedCount = modeProfileSyncCount(ingress.rejected_count);
|
||||||
|
if (!sync.initialized) {
|
||||||
|
sync.catalogRevision = catalogRevision;
|
||||||
|
sync.importedCount = importedCount;
|
||||||
|
sync.rejectedCount = rejectedCount;
|
||||||
|
sync.initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogChanged = Boolean(
|
||||||
|
catalogRevision && catalogRevision !== sync.catalogRevision,
|
||||||
|
);
|
||||||
|
const imported = importedCount > sync.importedCount;
|
||||||
|
const rejected = rejectedCount > sync.rejectedCount;
|
||||||
|
sync.catalogRevision = catalogRevision || sync.catalogRevision;
|
||||||
|
sync.importedCount = importedCount;
|
||||||
|
sync.rejectedCount = rejectedCount;
|
||||||
|
|
||||||
|
const sceneId = String(ingress.last_scene_id || "").trim();
|
||||||
|
const sceneAlreadyVisible = sceneId
|
||||||
|
? modeProfiles().some((mode) => String(mode?.id || "") === sceneId)
|
||||||
|
: false;
|
||||||
|
if (catalogChanged || (imported && !sceneAlreadyVisible)) {
|
||||||
|
await loadRuntime();
|
||||||
|
}
|
||||||
|
if (imported) {
|
||||||
|
if (sceneId) revealModeProfile(sceneId);
|
||||||
|
const sceneName = String(
|
||||||
|
ingress.last_result?.scene_name || sceneId || "新模式",
|
||||||
|
);
|
||||||
|
showToast(`已从 N7 同步模式卡片:${sceneName}`);
|
||||||
|
} else if (rejected) {
|
||||||
|
const reason = String(ingress.last_error || "消息格式不符合要求");
|
||||||
|
showToast(`N7 模式卡片导入失败:${reason}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("N7 mode profile synchronization check failed", error);
|
||||||
|
} finally {
|
||||||
|
sync.inFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModeProfileSyncPolling() {
|
||||||
|
const sync = state.modeProfileSync;
|
||||||
|
window.clearInterval(sync.poller);
|
||||||
|
sync.poller = window.setInterval(
|
||||||
|
() => void checkModeProfileSync(),
|
||||||
|
MODE_PROFILE_SYNC_POLL_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openModeGallery() {
|
function openModeGallery() {
|
||||||
const backdrop = $("#modeGalleryBackdrop");
|
const backdrop = $("#modeGalleryBackdrop");
|
||||||
if (!backdrop) return;
|
if (!backdrop) return;
|
||||||
@@ -6639,8 +6877,16 @@ function setModeChipsDisabled(disabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExecutableModeCommandFlow(mode) {
|
||||||
|
const flow = mode?.command_flow;
|
||||||
|
if (!mode?.id || !flow || typeof flow !== "object") return false;
|
||||||
|
const plans = flow.plans;
|
||||||
|
return Boolean(flow.source_request_id)
|
||||||
|
|| (plans && typeof plans === "object" && Object.keys(plans).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
||||||
if (!mode?.id || !mode?.command_flow?.source_request_id) return false;
|
if (!hasExecutableModeCommandFlow(mode)) return false;
|
||||||
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
|
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
|
||||||
const assistant = window.CognitiveAgentAssistant;
|
const assistant = window.CognitiveAgentAssistant;
|
||||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||||
@@ -6747,7 +6993,7 @@ function applyModeCommand(command, modeProfileId = null) {
|
|||||||
setModeRailSelection(mode);
|
setModeRailSelection(mode);
|
||||||
closeModeGallery({ restoreFocus: false });
|
closeModeGallery({ restoreFocus: false });
|
||||||
assistant?.open?.();
|
assistant?.open?.();
|
||||||
if (mode?.command_flow?.source_request_id) {
|
if (hasExecutableModeCommandFlow(mode)) {
|
||||||
void executeSavedModeProfile(mode, { command: modeCommand });
|
void executeSavedModeProfile(mode, { command: modeCommand });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -6809,6 +7055,7 @@ async function loadRuntime() {
|
|||||||
const response = await apiRequest("/api/runtime", { cache: "no-store" });
|
const response = await apiRequest("/api/runtime", { cache: "no-store" });
|
||||||
if (!response.ok) throw await apiResponseError(response);
|
if (!response.ok) throw await apiResponseError(response);
|
||||||
state.runtime = await response.json();
|
state.runtime = await response.json();
|
||||||
|
primeModeProfileSyncFromRuntime(state.runtime);
|
||||||
renderControlTransport();
|
renderControlTransport();
|
||||||
applyRuntimeTargetPolicy(state.runtime);
|
applyRuntimeTargetPolicy(state.runtime);
|
||||||
populateVehicleSelector();
|
populateVehicleSelector();
|
||||||
@@ -7157,6 +7404,8 @@ function resetSeatState() {
|
|||||||
|
|
||||||
function resetVehicleStatusDisplay() {
|
function resetVehicleStatusDisplay() {
|
||||||
state.seatActuatorState = {};
|
state.seatActuatorState = {};
|
||||||
|
state.seatRenderFingerprint = "";
|
||||||
|
state.domainRenderFingerprint = "";
|
||||||
resetSeatState();
|
resetSeatState();
|
||||||
const setText = (selector, value) => {
|
const setText = (selector, value) => {
|
||||||
const element = $(selector);
|
const element = $(selector);
|
||||||
@@ -7227,6 +7476,28 @@ function resetVehicleStatusDisplay() {
|
|||||||
summarySystem.textContent = "等待状态";
|
summarySystem.textContent = "等待状态";
|
||||||
summarySystem.dataset.state = "waiting";
|
summarySystem.dataset.state = "waiting";
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-state-rendered", {
|
||||||
|
detail: { scope: "reset", fingerprint: "" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const VEHICLE_RENDER_VOLATILE_KEYS = new Set([
|
||||||
|
"age_ms",
|
||||||
|
"capture_sequence",
|
||||||
|
"captured_at",
|
||||||
|
"frame_timestamp",
|
||||||
|
"last_seen_at",
|
||||||
|
"observed_at",
|
||||||
|
"received_at",
|
||||||
|
"request_id",
|
||||||
|
"timestamp",
|
||||||
|
"updated_at",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function vehicleRenderFingerprint(value) {
|
||||||
|
return JSON.stringify(value, (key, item) => (
|
||||||
|
VEHICLE_RENDER_VOLATILE_KEYS.has(key) ? undefined : item
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSeatActuatorState(previousState, patchState) {
|
function mergeSeatActuatorState(previousState, patchState) {
|
||||||
@@ -7246,20 +7517,6 @@ function mergeSeatActuatorState(previousState, patchState) {
|
|||||||
mergedSeat.position = mergedPosition;
|
mergedSeat.position = mergedPosition;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (feature === "dynamic_mode") {
|
|
||||||
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
|
|
||||||
if (!item) return;
|
|
||||||
const running = value.state === "running";
|
|
||||||
item.classList.toggle("running", running);
|
|
||||||
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
|
|
||||||
const runtimeLabel = Number.isInteger(runtimeMinutes)
|
|
||||||
? String(runtimeMinutes)
|
|
||||||
: runtimeMinutes.toFixed(1);
|
|
||||||
$("b", item).textContent = running
|
|
||||||
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
|
|
||||||
: "未运行";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
|
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
|
||||||
? { ...(previousSeat[feature] || {}), ...value }
|
? { ...(previousSeat[feature] || {}), ...value }
|
||||||
: value;
|
: value;
|
||||||
@@ -7270,13 +7527,15 @@ function mergeSeatActuatorState(previousState, patchState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSeatState(actuatorState) {
|
function renderSeatState(actuatorState) {
|
||||||
if (!actuatorState) return;
|
if (!actuatorState) return false;
|
||||||
const mergedActuatorState = mergeSeatActuatorState(
|
const mergedActuatorState = mergeSeatActuatorState(
|
||||||
state.seatActuatorState,
|
state.seatActuatorState,
|
||||||
actuatorState,
|
actuatorState,
|
||||||
);
|
);
|
||||||
|
const renderFingerprint = vehicleRenderFingerprint(mergedActuatorState);
|
||||||
state.seatActuatorState = mergedActuatorState;
|
state.seatActuatorState = mergedActuatorState;
|
||||||
resetSeatState();
|
if (renderFingerprint === state.seatRenderFingerprint) return false;
|
||||||
|
state.seatRenderFingerprint = renderFingerprint;
|
||||||
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
|
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
|
||||||
const displayFunctions = seat === "driver"
|
const displayFunctions = seat === "driver"
|
||||||
? twinDeviceForSeat("driver", { driver: functions })
|
? twinDeviceForSeat("driver", { driver: functions })
|
||||||
@@ -7302,6 +7561,20 @@ function renderSeatState(actuatorState) {
|
|||||||
: "CH1 状态已同步";
|
: "CH1 状态已同步";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (feature === "dynamic_mode") {
|
||||||
|
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
|
||||||
|
if (!item) return;
|
||||||
|
const running = value.state === "running";
|
||||||
|
item.classList.toggle("running", running);
|
||||||
|
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
|
||||||
|
const runtimeLabel = Number.isInteger(runtimeMinutes)
|
||||||
|
? String(runtimeMinutes)
|
||||||
|
: runtimeMinutes.toFixed(1);
|
||||||
|
$("b", item).textContent = running
|
||||||
|
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
|
||||||
|
: "未运行";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (feature === "position") {
|
if (feature === "position") {
|
||||||
Object.entries(value).forEach(([axis, motion]) => {
|
Object.entries(value).forEach(([axis, motion]) => {
|
||||||
const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`);
|
const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`);
|
||||||
@@ -7335,10 +7608,17 @@ function renderSeatState(actuatorState) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
updateSeatTwin(mergedActuatorState);
|
updateSeatTwin(mergedActuatorState);
|
||||||
|
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-state-rendered", {
|
||||||
|
detail: { scope: "seat", fingerprint: renderFingerprint },
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDomainState(domainState) {
|
function renderDomainState(domainState) {
|
||||||
if (!domainState) return;
|
if (!domainState) return false;
|
||||||
|
const renderFingerprint = vehicleRenderFingerprint(domainState);
|
||||||
|
if (renderFingerprint === state.domainRenderFingerprint) return false;
|
||||||
|
state.domainRenderFingerprint = renderFingerprint;
|
||||||
const outletModeLabel = (value) => {
|
const outletModeLabel = (value) => {
|
||||||
const normalized = String(value || "").toLowerCase().replaceAll("-", "_");
|
const normalized = String(value || "").toLowerCase().replaceAll("-", "_");
|
||||||
const matrixLabels = {
|
const matrixLabels = {
|
||||||
@@ -7723,6 +8003,11 @@ function renderDomainState(domainState) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
requestSeatTwinRender();
|
||||||
|
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-state-rendered", {
|
||||||
|
detail: { scope: "domain", fingerprint: renderFingerprint },
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderedConfig(result) {
|
function renderedConfig(result) {
|
||||||
@@ -9041,6 +9326,15 @@ window.addEventListener("cognitive-agent:request-complete", (event) => {
|
|||||||
setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed");
|
setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") void checkModeProfileSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
window.clearInterval(state.modeProfileSync.poller);
|
||||||
|
state.modeProfileSync.poller = null;
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
initGlassThemeToggle();
|
initGlassThemeToggle();
|
||||||
initJointTwinLayout();
|
initJointTwinLayout();
|
||||||
@@ -9147,6 +9441,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
||||||
await loadAuthStatus();
|
await loadAuthStatus();
|
||||||
await loadRuntime();
|
await loadRuntime();
|
||||||
|
startModeProfileSyncPolling();
|
||||||
|
void checkModeProfileSync();
|
||||||
if (!isDriverOnlyCh1Runtime()) {
|
if (!isDriverOnlyCh1Runtime()) {
|
||||||
await loadHardwareAccessStatus({ silent: true });
|
await loadHardwareAccessStatus({ silent: true });
|
||||||
state.hardwareAccessPoller = window.setInterval(
|
state.hardwareAccessPoller = window.setInterval(
|
||||||
|
|||||||
@@ -466,6 +466,9 @@ body.console-v2 .v2-twin-device-popover {
|
|||||||
background: oklch(0.16 0.045 285 / 0.74) !important;
|
background: oklch(0.16 0.045 285 / 0.74) !important;
|
||||||
border-color: var(--glass-line-strong) !important;
|
border-color: var(--glass-line-strong) !important;
|
||||||
box-shadow: 0 10px 28px oklch(0.04 0.035 286 / 0.32), inset 0 1px 0 var(--glass-highlight) !important;
|
box-shadow: 0 10px 28px oklch(0.04 0.035 286 / 0.32), inset 0 1px 0 var(--glass-highlight) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.console-v2 .v2-twin-device-popover {
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(1.18);
|
-webkit-backdrop-filter: blur(14px) saturate(1.18);
|
||||||
backdrop-filter: blur(14px) saturate(1.18);
|
backdrop-filter: blur(14px) saturate(1.18);
|
||||||
}
|
}
|
||||||
@@ -526,8 +529,6 @@ body.console-v2 .v2-pet-nfc-field output {
|
|||||||
background: oklch(0.11 0.035 286 / 0.42) !important;
|
background: oklch(0.11 0.035 286 / 0.42) !important;
|
||||||
border-color: var(--glass-line-strong) !important;
|
border-color: var(--glass-line-strong) !important;
|
||||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.08) !important;
|
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.08) !important;
|
||||||
-webkit-backdrop-filter: blur(10px) saturate(1.14);
|
|
||||||
backdrop-filter: blur(10px) saturate(1.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.console-v2 :is(input, textarea, select):focus {
|
body.console-v2 :is(input, textarea, select):focus {
|
||||||
|
|||||||
@@ -1265,6 +1265,7 @@
|
|||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
deviceFrame: null,
|
deviceFrame: null,
|
||||||
deviceFingerprint: "",
|
deviceFingerprint: "",
|
||||||
|
deviceRenderDirty: true,
|
||||||
activeDevice: null,
|
activeDevice: null,
|
||||||
pinnedDevice: null,
|
pinnedDevice: null,
|
||||||
previewClearTimer: null,
|
previewClearTimer: null,
|
||||||
@@ -1523,9 +1524,13 @@
|
|||||||
</label>`;
|
</label>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSyncedDevices() {
|
function renderSyncedDevices({ force = false } = {}) {
|
||||||
const grid = $("#v2DeviceSyncGrid");
|
const grid = $("#v2DeviceSyncGrid");
|
||||||
if (!grid) return;
|
if (!grid) return false;
|
||||||
|
if (!force && !panelVisible("devices")) {
|
||||||
|
displaySync.deviceRenderDirty = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
grid.innerHTML = syncedDevices.map((device) => {
|
grid.innerHTML = syncedDevices.map((device) => {
|
||||||
const rows = deviceReadoutRows(device);
|
const rows = deviceReadoutRows(device);
|
||||||
const waiting = rows.some(([, value]) => value === "-");
|
const waiting = rows.some(([, value]) => value === "-");
|
||||||
@@ -1553,7 +1558,9 @@
|
|||||||
$(`.v2-synced-device[data-device="${displaySync.activeDevice}"]`)?.classList.add("active");
|
$(`.v2-synced-device[data-device="${displaySync.activeDevice}"]`)?.classList.add("active");
|
||||||
}
|
}
|
||||||
displaySync.deviceFingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
|
displaySync.deviceFingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
|
||||||
|
displaySync.deviceRenderDirty = false;
|
||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeDeviceControlDraft(input) {
|
function storeDeviceControlDraft(input) {
|
||||||
@@ -1641,11 +1648,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSyncedDeviceRender() {
|
function scheduleSyncedDeviceRender() {
|
||||||
|
if (!panelVisible("devices")) {
|
||||||
|
displaySync.deviceRenderDirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (displaySync.deviceFrame) return;
|
if (displaySync.deviceFrame) return;
|
||||||
displaySync.deviceFrame = requestAnimationFrame(() => {
|
displaySync.deviceFrame = requestAnimationFrame(() => {
|
||||||
displaySync.deviceFrame = null;
|
displaySync.deviceFrame = null;
|
||||||
const fingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
|
const fingerprint = JSON.stringify(syncedDevices.map((device) => [device.id, deviceReadoutRows(device)]));
|
||||||
if (fingerprint === displaySync.deviceFingerprint && $("#v2DeviceSyncGrid")?.childElementCount) return;
|
if (!displaySync.deviceRenderDirty
|
||||||
|
&& fingerprint === displaySync.deviceFingerprint
|
||||||
|
&& $("#v2DeviceSyncGrid")?.childElementCount) return;
|
||||||
renderSyncedDevices();
|
renderSyncedDevices();
|
||||||
if (displaySync.activeDevice) presentTwinDevice(displaySync.activeDevice, {
|
if (displaySync.activeDevice) presentTwinDevice(displaySync.activeDevice, {
|
||||||
pinned: Boolean(displaySync.pinnedDevice),
|
pinned: Boolean(displaySync.pinnedDevice),
|
||||||
@@ -1748,7 +1761,11 @@
|
|||||||
rightRail.toggleAttribute("inert", collapsed);
|
rightRail.toggleAttribute("inert", collapsed);
|
||||||
rightRail.dataset.openPanels = openPanels.join(" ") || "none";
|
rightRail.dataset.openPanels = openPanels.join(" ") || "none";
|
||||||
}
|
}
|
||||||
|
if (key === "devices" && visible) renderSyncedDevices({ force: true });
|
||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
window.dispatchEvent(new CustomEvent("cognitive-agent:side-panel-visibility", {
|
||||||
|
detail: { panel: key, visible: Boolean(visible) },
|
||||||
|
}));
|
||||||
window.dispatchEvent(new Event("resize"));
|
window.dispatchEvent(new Event("resize"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2550,19 +2567,10 @@
|
|||||||
}
|
}
|
||||||
buildTwinDeviceInteractions();
|
buildTwinDeviceInteractions();
|
||||||
cabinBand.classList.add("v2-source-state-only");
|
cabinBand.classList.add("v2-source-state-only");
|
||||||
new MutationObserver(scheduleSyncedDeviceRender).observe(cabinBand, {
|
window.addEventListener(
|
||||||
attributes: true,
|
"cognitive-agent:vehicle-state-rendered",
|
||||||
childList: true,
|
scheduleSyncedDeviceRender,
|
||||||
characterData: true,
|
);
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
const stateBand = $(".state-band");
|
|
||||||
if (stateBand) new MutationObserver(scheduleSyncedDeviceRender).observe(stateBand, {
|
|
||||||
attributes: true,
|
|
||||||
childList: true,
|
|
||||||
characterData: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setSidePanelVisibility("devices", false);
|
setSidePanelVisibility("devices", false);
|
||||||
setSidePanelVisibility("extensions", false);
|
setSidePanelVisibility("extensions", false);
|
||||||
@@ -2983,8 +2991,8 @@
|
|||||||
});
|
});
|
||||||
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
|
window.addEventListener("cognitive-agent:vehicle-feedback-state", () => {
|
||||||
syncRuntimePresentation();
|
syncRuntimePresentation();
|
||||||
renderSyncedDevices();
|
scheduleSyncedDeviceRender();
|
||||||
if (displaySync.activeDevice) {
|
if (panelVisible("devices") && displaySync.activeDevice) {
|
||||||
presentTwinDevice(displaySync.activeDevice, {
|
presentTwinDevice(displaySync.activeDevice, {
|
||||||
pinned: Boolean(displaySync.pinnedDevice),
|
pinned: Boolean(displaySync.pinnedDevice),
|
||||||
selectSeat: false,
|
selectSeat: false,
|
||||||
|
|||||||
@@ -869,7 +869,10 @@
|
|||||||
createLayer();
|
createLayer();
|
||||||
seedHistory();
|
seedHistory();
|
||||||
renderMonitor();
|
renderMonitor();
|
||||||
monitor.timer = window.setInterval(() => sampleNow(), SAMPLE_INTERVAL_MS);
|
monitor.timer = window.setInterval(
|
||||||
|
() => sampleNow({ render: monitor.open }),
|
||||||
|
SAMPLE_INTERVAL_MS,
|
||||||
|
);
|
||||||
document.addEventListener("keydown", captureModalKeys, true);
|
document.addEventListener("keydown", captureModalKeys, true);
|
||||||
window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false }));
|
window.addEventListener("cognitive-agent:power-telemetry", (event) => ingestPowerTelemetry(event.detail || {}, { sample: false }));
|
||||||
const existingUpdater = window.updateDevicePowerTelemetry;
|
const existingUpdater = window.updateDevicePowerTelemetry;
|
||||||
|
|||||||
@@ -38,14 +38,14 @@
|
|||||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||||
<script src="/network.js?v=android-pad-2" defer></script>
|
<script src="/network.js?v=android-pad-2" defer></script>
|
||||||
<script src="/app.js?v=dual-seat-141" defer></script>
|
<script src="/app.js?v=dual-seat-145-n7-dispatch" defer></script>
|
||||||
<script src="/speech-input.js?v=sensevoice-13" defer></script>
|
<script src="/speech-input.js?v=sensevoice-13" defer></script>
|
||||||
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
||||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||||
<script src="/console-v2.js?v=local-first-76" defer></script>
|
<script src="/console-v2.js?v=local-first-77-performance" defer></script>
|
||||||
<script src="/self-extension.js?v=pet-settings-52" defer></script>
|
<script src="/self-extension.js?v=pet-settings-53-performance" defer></script>
|
||||||
<script src="/assistant-chat.js?v=codex-assistant-34" defer></script>
|
<script src="/assistant-chat.js?v=codex-assistant-34" defer></script>
|
||||||
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
|
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="console-v2">
|
<body class="console-v2">
|
||||||
<div id="app" aria-busy="true"></div>
|
<div id="app" aria-busy="true"></div>
|
||||||
|
|||||||
@@ -389,6 +389,11 @@
|
|||||||
let selectedPetCardId = null;
|
let selectedPetCardId = null;
|
||||||
const petCardDrafts = new Map();
|
const petCardDrafts = new Map();
|
||||||
let nfcPollTimer = null;
|
let nfcPollTimer = null;
|
||||||
|
let nfcDialogFingerprint = "";
|
||||||
|
let nfcDialogDirty = true;
|
||||||
|
let unifiedExtensionFingerprint = "";
|
||||||
|
let unifiedExtensionDirty = true;
|
||||||
|
let gscuHealthFingerprint = "";
|
||||||
let legacySameOriginChecked = false;
|
let legacySameOriginChecked = false;
|
||||||
let legacyMigrationCompleted = false;
|
let legacyMigrationCompleted = false;
|
||||||
const legacyBridgeAttempts = new Set();
|
const legacyBridgeAttempts = new Set();
|
||||||
@@ -3673,12 +3678,50 @@
|
|||||||
return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用";
|
return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNfcDialog() {
|
function currentNfcDialogFingerprint() {
|
||||||
|
return JSON.stringify({
|
||||||
|
revision: nfcSnapshot.revision,
|
||||||
|
reader: nfcSnapshot.reader,
|
||||||
|
card: nfcSnapshot.card,
|
||||||
|
petRevision: petCardSnapshot.revision,
|
||||||
|
pets: petCardSnapshot.cards,
|
||||||
|
publisher: petCardSnapshot.publisher,
|
||||||
|
devices: state.devices.map((device) => ({
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
type: device.type,
|
||||||
|
cardUid: device.cardUid || null,
|
||||||
|
})),
|
||||||
|
capabilities: state.capabilities.map((capability) => ({
|
||||||
|
id: capability.id,
|
||||||
|
deviceId: capability.deviceId,
|
||||||
|
name: capability.name,
|
||||||
|
aliases: capability.aliases,
|
||||||
|
slot: capability.slot,
|
||||||
|
status: capability.status,
|
||||||
|
verified: capability.verified,
|
||||||
|
enabled: capability.enabled !== false,
|
||||||
|
})),
|
||||||
|
infraredCommands: state.infraredCommands,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNfcDialog({ force = false } = {}) {
|
||||||
|
const dialog = $("#extensionNfcDialog");
|
||||||
|
if (!dialog) return false;
|
||||||
|
if (!force && !dialog.open) {
|
||||||
|
nfcDialogDirty = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const renderFingerprint = currentNfcDialogFingerprint();
|
||||||
|
if (!force && !nfcDialogDirty && renderFingerprint === nfcDialogFingerprint) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const stage = $("#extensionNfcStage");
|
const stage = $("#extensionNfcStage");
|
||||||
const body = $("#extensionNfcBody");
|
const body = $("#extensionNfcBody");
|
||||||
const status = $("#extensionNfcReaderStatus");
|
const status = $("#extensionNfcReaderStatus");
|
||||||
const badge = $("#extensionNfcReaderBadge");
|
const badge = $("#extensionNfcReaderBadge");
|
||||||
if (!stage || !body || !status || !badge) return;
|
if (!stage || !body || !status || !badge) return false;
|
||||||
|
|
||||||
const reader = nfcSnapshot.reader || {};
|
const reader = nfcSnapshot.reader || {};
|
||||||
const card = nfcSnapshot.card;
|
const card = nfcSnapshot.card;
|
||||||
@@ -3725,7 +3768,10 @@
|
|||||||
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
|
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
|
||||||
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
|
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
|
||||||
}
|
}
|
||||||
|
nfcDialogFingerprint = renderFingerprint;
|
||||||
|
nfcDialogDirty = false;
|
||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUnifiedNfcDialog() {
|
function buildUnifiedNfcDialog() {
|
||||||
@@ -3891,8 +3937,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNfcDialog() {
|
function openNfcDialog() {
|
||||||
renderNfcDialog();
|
|
||||||
openDialog("extensionNfcDialog");
|
openDialog("extensionNfcDialog");
|
||||||
|
renderNfcDialog({ force: true });
|
||||||
void refreshNfcStatus();
|
void refreshNfcStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3969,10 +4015,73 @@
|
|||||||
if (!mount) return false;
|
if (!mount) return false;
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
|
|
||||||
const render = () => {
|
const extensionViewFingerprint = (health) => JSON.stringify({
|
||||||
|
transport: health.transport,
|
||||||
|
connected: health.connected,
|
||||||
|
peerConnected: health.peer_connected,
|
||||||
|
peer: health.peer,
|
||||||
|
listening: health.listening,
|
||||||
|
message: health.message,
|
||||||
|
extensionCatalog: (snapshot?.extension?.catalog || []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
deviceId: item.device_id,
|
||||||
|
deviceName: item.device_name,
|
||||||
|
deviceType: item.device_type,
|
||||||
|
typeLabel: item.type_label,
|
||||||
|
action: item.action,
|
||||||
|
name: item.name,
|
||||||
|
cardUid: item.card_uid,
|
||||||
|
status: item.status,
|
||||||
|
enabled: item.enabled,
|
||||||
|
})),
|
||||||
|
lastAction: snapshot?.extension?.last_action ? {
|
||||||
|
command: snapshot.extension.last_action.command,
|
||||||
|
type: snapshot.extension.last_action.type,
|
||||||
|
} : null,
|
||||||
|
lastResult: snapshot?.extension?.result ? {
|
||||||
|
status: snapshot.extension.result.status,
|
||||||
|
message: snapshot.extension.result.message,
|
||||||
|
} : null,
|
||||||
|
nfc: {
|
||||||
|
revision: nfcSnapshot.revision,
|
||||||
|
reader: nfcSnapshot.reader,
|
||||||
|
card: nfcSnapshot.card,
|
||||||
|
},
|
||||||
|
pets: petCardSnapshot,
|
||||||
|
localDevices: state.devices.map((device) => ({
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
type: device.type,
|
||||||
|
cardUid: device.cardUid || null,
|
||||||
|
})),
|
||||||
|
localCapabilities: state.capabilities.map((capability) => ({
|
||||||
|
id: capability.id,
|
||||||
|
deviceId: capability.deviceId,
|
||||||
|
name: capability.name,
|
||||||
|
slot: capability.slot,
|
||||||
|
status: capability.status,
|
||||||
|
verified: capability.verified,
|
||||||
|
enabled: capability.enabled !== false,
|
||||||
|
})),
|
||||||
|
infraredCommands: state.infraredCommands,
|
||||||
|
});
|
||||||
|
|
||||||
|
const render = ({ force = false } = {}) => {
|
||||||
const health = snapshot?.extension_health || {};
|
const health = snapshot?.extension_health || {};
|
||||||
healthState = health;
|
healthState = health;
|
||||||
if ($("#extensionGscuTcpDialog")?.open) renderGscuTcpDialog(health, false);
|
const nextGscuHealthFingerprint = JSON.stringify({
|
||||||
|
transport: health.transport,
|
||||||
|
connected: health.connected,
|
||||||
|
peerConnected: health.peer_connected,
|
||||||
|
peer: health.peer,
|
||||||
|
listening: health.listening,
|
||||||
|
message: health.message,
|
||||||
|
});
|
||||||
|
if ($("#extensionGscuTcpDialog")?.open
|
||||||
|
&& (force || nextGscuHealthFingerprint !== gscuHealthFingerprint)) {
|
||||||
|
renderGscuTcpDialog(health, false);
|
||||||
|
}
|
||||||
|
gscuHealthFingerprint = nextGscuHealthFingerprint;
|
||||||
const embeddedNfc = health.nfc;
|
const embeddedNfc = health.nfc;
|
||||||
if (nfcSnapshot.revision < 0 && embeddedNfc) {
|
if (nfcSnapshot.revision < 0 && embeddedNfc) {
|
||||||
nfcSnapshot = {
|
nfcSnapshot = {
|
||||||
@@ -3989,6 +4098,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false });
|
if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false });
|
||||||
|
const panel = mount.closest("#v2ExtensionPanel");
|
||||||
|
if (!force && (panel?.hidden || panel?.classList.contains("is-collapsed"))) {
|
||||||
|
unifiedExtensionDirty = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const renderFingerprint = extensionViewFingerprint(health);
|
||||||
|
if (!force
|
||||||
|
&& !unifiedExtensionDirty
|
||||||
|
&& renderFingerprint === unifiedExtensionFingerprint
|
||||||
|
&& mount.childElementCount) return false;
|
||||||
const physicalConnected = health.transport !== "simulation"
|
const physicalConnected = health.transport !== "simulation"
|
||||||
&& Boolean(health.connected || health.peer_connected);
|
&& Boolean(health.connected || health.peer_connected);
|
||||||
const listening = Boolean(health.listening);
|
const listening = Boolean(health.listening);
|
||||||
@@ -4114,7 +4233,10 @@
|
|||||||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "等待 NFC 外设感应")}</strong></div>
|
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "等待 NFC 外设感应")}</strong></div>
|
||||||
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
|
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
|
||||||
</footer>`;
|
</footer>`;
|
||||||
|
unifiedExtensionFingerprint = renderFingerprint;
|
||||||
|
unifiedExtensionDirty = false;
|
||||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
renderUnifiedExtensionView = render;
|
renderUnifiedExtensionView = render;
|
||||||
|
|
||||||
@@ -4125,6 +4247,11 @@
|
|||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
window.addEventListener("cognitive-agent:side-panel-visibility", (event) => {
|
||||||
|
if (event.detail?.panel === "extensions" && event.detail?.visible) {
|
||||||
|
render({ force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
buildDialogs();
|
buildDialogs();
|
||||||
buildUnifiedNfcDialog();
|
buildUnifiedNfcDialog();
|
||||||
bindUnifiedNfcEvents(mount);
|
bindUnifiedNfcEvents(mount);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing";
|
const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
@@ -6,22 +6,22 @@ const APP_SHELL = [
|
|||||||
"/index.html",
|
"/index.html",
|
||||||
"/styles.css?v=dual-seat-23",
|
"/styles.css?v=dual-seat-23",
|
||||||
"/network.js?v=android-pad-2",
|
"/network.js?v=android-pad-2",
|
||||||
"/app.js?v=dual-seat-141",
|
"/app.js?v=dual-seat-145-n7-dispatch",
|
||||||
"/gesture-control.js?v=android-pad-9",
|
"/gesture-control.js?v=android-pad-9",
|
||||||
"/pwa.js?v=android-pad-3",
|
"/pwa.js?v=android-pad-3",
|
||||||
"/console-v2.css?v=delivery-state-69",
|
"/console-v2.css?v=delivery-state-69",
|
||||||
"/console-v2.js?v=local-first-76",
|
"/console-v2.js?v=local-first-77-performance",
|
||||||
"/self-extension.css?v=pet-settings-52",
|
"/self-extension.css?v=pet-settings-52",
|
||||||
"/self-extension.js?v=pet-settings-52",
|
"/self-extension.js?v=pet-settings-53-performance",
|
||||||
"/speech-input.css?v=sensevoice-7",
|
"/speech-input.css?v=sensevoice-7",
|
||||||
"/speech-input.js?v=sensevoice-13",
|
"/speech-input.js?v=sensevoice-13",
|
||||||
"/speech-audio-worklet.js?v=sensevoice-5",
|
"/speech-audio-worklet.js?v=sensevoice-5",
|
||||||
"/assistant-chat.css?v=codex-assistant-29",
|
"/assistant-chat.css?v=codex-assistant-29",
|
||||||
"/assistant-chat.js?v=codex-assistant-34",
|
"/assistant-chat.js?v=codex-assistant-34",
|
||||||
"/energy-monitor.css?v=energy-monitor-4",
|
"/energy-monitor.css?v=energy-monitor-4",
|
||||||
"/energy-monitor.js?v=energy-monitor-4",
|
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||||
"/ui-consistency.css?v=spatial-ui-8",
|
"/ui-consistency.css?v=spatial-ui-8",
|
||||||
"/codepen-glass-theme.css?v=creative-cloud-5",
|
"/codepen-glass-theme.css?v=creative-cloud-6-performance",
|
||||||
"/vue-ui.js?v=vue-presentation-3",
|
"/vue-ui.js?v=vue-presentation-3",
|
||||||
"/assets/pets/self-extension-cat.png?v=3",
|
"/assets/pets/self-extension-cat.png?v=3",
|
||||||
"/assets/pets/self-extension-cat-entrance.png?v=3",
|
"/assets/pets/self-extension-cat-entrance.png?v=3",
|
||||||
|
|||||||
@@ -724,6 +724,22 @@ body.mode-editor-open {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-gallery-source-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: #315eb8;
|
||||||
|
background: rgba(91, 132, 222, 0.14);
|
||||||
|
border: 1px solid rgba(91, 132, 222, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-gallery-source-badge[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-gallery-card-description,
|
.mode-gallery-card-description,
|
||||||
.mode-gallery-card-meta {
|
.mode-gallery-card-meta {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -40,7 +40,20 @@ def validate_semantics(
|
|||||||
for index, deferred in enumerate(parsed.get("deferred_requests", [])):
|
for index, deferred in enumerate(parsed.get("deferred_requests", [])):
|
||||||
domain = str(deferred.get("domain"))
|
domain = str(deferred.get("domain"))
|
||||||
capability = str(deferred.get("capability"))
|
capability = str(deferred.get("capability"))
|
||||||
if domain not in detected_domains or not capability.startswith(f"{domain}."):
|
# An N7-synchronised scene may use a vehicle-owned capability namespace
|
||||||
|
# that intentionally differs from its routing domain (for example
|
||||||
|
# ``domain=local_vehicle`` with ``capability=local_scene.seat_preset``).
|
||||||
|
# It is not executed by this validator's local backend: the original
|
||||||
|
# action is forwarded unchanged to ``transto_n7/ctl``. Keep the strict
|
||||||
|
# namespace check for every ordinary deferred request, while allowing
|
||||||
|
# this explicitly marked transfer backend to retain the source contract.
|
||||||
|
n7_transfer = (
|
||||||
|
deferred.get("execution_backend") == "n7_vehicle_transfer"
|
||||||
|
and deferred.get("rejection_code") == "PC_CAPABILITY_UNAVAILABLE"
|
||||||
|
)
|
||||||
|
if domain not in detected_domains or (
|
||||||
|
not n7_transfer and not capability.startswith(f"{domain}.")
|
||||||
|
):
|
||||||
issues.append(
|
issues.append(
|
||||||
{
|
{
|
||||||
"severity": "error",
|
"severity": "error",
|
||||||
|
|||||||
@@ -2225,8 +2225,24 @@ class AssistantCapabilityCatalog:
|
|||||||
operations = _safe_mode_operations(
|
operations = _safe_mode_operations(
|
||||||
[item for item in requests if isinstance(item, Mapping)]
|
[item for item in requests if isinstance(item, Mapping)]
|
||||||
)
|
)
|
||||||
callable_mode = bool(operation_ids) and all(
|
n7_transfer_operation_ids = [
|
||||||
capability_id in callable_capabilities for capability_id in operation_ids
|
capability_id
|
||||||
|
for capability_id in operation_ids
|
||||||
|
if capability_id not in callable_capabilities
|
||||||
|
]
|
||||||
|
command_flow = raw.get("command_flow")
|
||||||
|
command_plans = (
|
||||||
|
command_flow.get("plans")
|
||||||
|
if isinstance(command_flow, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
n7_transfer_ready = (
|
||||||
|
raw.get("source_topic") == "from_n7/scenes"
|
||||||
|
and isinstance(command_plans, Mapping)
|
||||||
|
and bool(command_plans)
|
||||||
|
)
|
||||||
|
callable_mode = bool(operation_ids) and (
|
||||||
|
not n7_transfer_operation_ids or n7_transfer_ready
|
||||||
)
|
)
|
||||||
modes.append(
|
modes.append(
|
||||||
{
|
{
|
||||||
@@ -2240,6 +2256,9 @@ class AssistantCapabilityCatalog:
|
|||||||
"operation_ids": operation_ids,
|
"operation_ids": operation_ids,
|
||||||
"operations": operations,
|
"operations": operations,
|
||||||
"assistant_callable": callable_mode,
|
"assistant_callable": callable_mode,
|
||||||
|
"n7_transfer_operation_ids": (
|
||||||
|
n7_transfer_operation_ids if n7_transfer_ready else []
|
||||||
|
),
|
||||||
"all_operations_have_native_binding": bool(operation_ids)
|
"all_operations_have_native_binding": bool(operation_ids)
|
||||||
and all(
|
and all(
|
||||||
capability_id in native_capabilities for capability_id in operation_ids
|
capability_id in native_capabilities for capability_id in operation_ids
|
||||||
|
|||||||
@@ -14,8 +14,31 @@ from typing import Any
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
_MATCH_FORMATTING = re.compile(r"[\s()()]+")
|
_MATCH_FORMATTING = re.compile(r"[\s()()]+")
|
||||||
|
_MODE_INVOCATION_FORMATTING = re.compile(r"[\s,。!?,.!?::;;()()]+")
|
||||||
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
|
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
|
||||||
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||||
|
_EXACT_MODE_INVOCATION_PREFIXES = (
|
||||||
|
"",
|
||||||
|
"进入",
|
||||||
|
"执行",
|
||||||
|
"执行一下",
|
||||||
|
"开始",
|
||||||
|
"开始执行",
|
||||||
|
"启动",
|
||||||
|
"打开",
|
||||||
|
"切换到",
|
||||||
|
"切换进入",
|
||||||
|
"请进入",
|
||||||
|
"请执行",
|
||||||
|
"请执行一下",
|
||||||
|
"请启动",
|
||||||
|
"请打开",
|
||||||
|
"帮我进入",
|
||||||
|
"帮我执行",
|
||||||
|
"请帮我进入",
|
||||||
|
"请帮我执行",
|
||||||
|
)
|
||||||
|
_EXACT_MODE_INVOCATION_SUFFIXES = ("", "吧", "一下", "试试")
|
||||||
_MODE_REFERENCE_VERBS = (
|
_MODE_REFERENCE_VERBS = (
|
||||||
"切换进入",
|
"切换进入",
|
||||||
"切换到",
|
"切换到",
|
||||||
@@ -79,6 +102,34 @@ def _normalize_match_text(value: str) -> str:
|
|||||||
return _MATCH_FORMATTING.sub("", value.casefold())
|
return _MATCH_FORMATTING.sub("", value.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_mode_invocation_matches(
|
||||||
|
utterance: str,
|
||||||
|
candidates: list[object],
|
||||||
|
) -> bool:
|
||||||
|
"""Accept a synchronized card only as the complete control intent.
|
||||||
|
|
||||||
|
N7 can legitimately name a card ``影院模式`` while the same words are a
|
||||||
|
parameter of another capability (for example an ambient-light mode). A
|
||||||
|
synchronized card therefore uses an exact, bounded invocation grammar
|
||||||
|
rather than ordinary substring matching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
compact = _MODE_INVOCATION_FORMATTING.sub("", str(utterance)).casefold()
|
||||||
|
if not compact:
|
||||||
|
return False
|
||||||
|
invocations = {
|
||||||
|
_MODE_INVOCATION_FORMATTING.sub(
|
||||||
|
"",
|
||||||
|
f"{prefix}{candidate}{suffix}",
|
||||||
|
).casefold()
|
||||||
|
for candidate in candidates
|
||||||
|
if str(candidate).strip()
|
||||||
|
for prefix in _EXACT_MODE_INVOCATION_PREFIXES
|
||||||
|
for suffix in _EXACT_MODE_INVOCATION_SUFFIXES
|
||||||
|
}
|
||||||
|
return compact in invocations
|
||||||
|
|
||||||
|
|
||||||
def _mode_candidate_matches(utterance: str, candidate: object) -> bool:
|
def _mode_candidate_matches(utterance: str, candidate: object) -> bool:
|
||||||
"""Match a mode reference without treating device nouns as mode aliases.
|
"""Match a mode reference without treating device nouns as mode aliases.
|
||||||
|
|
||||||
@@ -156,6 +207,20 @@ def _clean_command_flow(value: Any) -> dict[str, Any] | None:
|
|||||||
return json.loads(encoded)
|
return json.loads(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_sync_origin(value: Any) -> str:
|
||||||
|
cleaned = str(value or "").strip()
|
||||||
|
if cleaned not in {"", "from_n7/scenes"}:
|
||||||
|
raise ValueError("mode sync_origin is not supported")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_upstream_origin(value: Any) -> str:
|
||||||
|
cleaned = str(value or "").strip()
|
||||||
|
if len(cleaned) > 64:
|
||||||
|
raise ValueError("mode upstream_origin must be at most 64 characters")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
|
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
|
||||||
values = [name, *(aliases or [])]
|
values = [name, *(aliases or [])]
|
||||||
if name.endswith("模式") and len(name) > 2:
|
if name.endswith("模式") and len(name) > 2:
|
||||||
@@ -451,6 +516,8 @@ class ModeProfile:
|
|||||||
user_defined: bool = False
|
user_defined: bool = False
|
||||||
instruction: str = ""
|
instruction: str = ""
|
||||||
command_flow: dict[str, Any] | None = None
|
command_flow: dict[str, Any] | None = None
|
||||||
|
sync_origin: str = ""
|
||||||
|
upstream_origin: str = ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
|
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
|
||||||
@@ -487,6 +554,10 @@ class ModeProfile:
|
|||||||
user_defined=_is_user_profile_path(path, profile_root),
|
user_defined=_is_user_profile_path(path, profile_root),
|
||||||
instruction=_clean_instruction(document.get("instruction", "")),
|
instruction=_clean_instruction(document.get("instruction", "")),
|
||||||
command_flow=_clean_command_flow(document.get("command_flow")),
|
command_flow=_clean_command_flow(document.get("command_flow")),
|
||||||
|
sync_origin=_clean_sync_origin(document.get("sync_origin")),
|
||||||
|
upstream_origin=_clean_upstream_origin(
|
||||||
|
document.get("upstream_origin")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def public_dict(self) -> dict[str, Any]:
|
def public_dict(self) -> dict[str, Any]:
|
||||||
@@ -508,11 +579,20 @@ class ModeProfile:
|
|||||||
"vehicle_ids": list(self.vehicle_ids),
|
"vehicle_ids": list(self.vehicle_ids),
|
||||||
},
|
},
|
||||||
"behavior": "cyclic" if is_cyclic else "static",
|
"behavior": "cyclic" if is_cyclic else "static",
|
||||||
"origin": "user" if self.user_defined else "preset",
|
"origin": (
|
||||||
|
"n7_mqtt"
|
||||||
|
if self.sync_origin == "from_n7/scenes"
|
||||||
|
else "user"
|
||||||
|
if self.user_defined
|
||||||
|
else "preset"
|
||||||
|
),
|
||||||
"deletable": self.user_defined,
|
"deletable": self.user_defined,
|
||||||
"instruction": self.instruction,
|
"instruction": self.instruction,
|
||||||
"command_flow": self.command_flow,
|
"command_flow": self.command_flow,
|
||||||
}
|
}
|
||||||
|
if self.sync_origin:
|
||||||
|
result["source_topic"] = self.sync_origin
|
||||||
|
result["upstream_origin"] = self.upstream_origin or None
|
||||||
if self.timeline is not None:
|
if self.timeline is not None:
|
||||||
result["timeline"] = self.timeline.public_dict()
|
result["timeline"] = self.timeline.public_dict()
|
||||||
return result
|
return result
|
||||||
@@ -588,7 +668,11 @@ class ModeProfileRegistry:
|
|||||||
public = []
|
public = []
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
item = profile.public_dict()
|
item = profile.public_dict()
|
||||||
if profile.user_defined and profile.id in preset_ids:
|
if (
|
||||||
|
profile.user_defined
|
||||||
|
and profile.id in preset_ids
|
||||||
|
and not profile.sync_origin
|
||||||
|
):
|
||||||
item["origin"] = "customized"
|
item["origin"] = "customized"
|
||||||
item["preset_override"] = True
|
item["preset_override"] = True
|
||||||
public.append(item)
|
public.append(item)
|
||||||
@@ -628,6 +712,19 @@ class ModeProfileRegistry:
|
|||||||
matched_ids: set[str] = set()
|
matched_ids: set[str] = set()
|
||||||
for profile in self._visible_candidates_for_vehicle(vehicle_id):
|
for profile in self._visible_candidates_for_vehicle(vehicle_id):
|
||||||
candidates = [profile.id, profile.name, *profile.aliases]
|
candidates = [profile.id, profile.name, *profile.aliases]
|
||||||
|
effective_profile = selected[profile.id]
|
||||||
|
if (
|
||||||
|
effective_profile.sync_origin == "from_n7/scenes"
|
||||||
|
and not _exact_mode_invocation_matches(
|
||||||
|
utterance,
|
||||||
|
[
|
||||||
|
effective_profile.id,
|
||||||
|
effective_profile.name,
|
||||||
|
*effective_profile.aliases,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
if any(
|
if any(
|
||||||
candidate and _mode_candidate_matches(haystack, candidate)
|
candidate and _mode_candidate_matches(haystack, candidate)
|
||||||
for candidate in candidates
|
for candidate in candidates
|
||||||
@@ -635,7 +732,7 @@ class ModeProfileRegistry:
|
|||||||
if profile.id in matched_ids:
|
if profile.id in matched_ids:
|
||||||
continue
|
continue
|
||||||
matched_ids.add(profile.id)
|
matched_ids.add(profile.id)
|
||||||
matches.append(selected[profile.id])
|
matches.append(effective_profile)
|
||||||
return matches
|
return matches
|
||||||
|
|
||||||
def resolve_exact_name(
|
def resolve_exact_name(
|
||||||
@@ -684,6 +781,8 @@ class ModeProfileRegistry:
|
|||||||
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
|
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
|
||||||
"instruction": profile.instruction,
|
"instruction": profile.instruction,
|
||||||
"command_flow": profile.command_flow,
|
"command_flow": profile.command_flow,
|
||||||
|
"sync_origin": profile.sync_origin,
|
||||||
|
"upstream_origin": profile.upstream_origin,
|
||||||
}
|
}
|
||||||
canonical = json.dumps(
|
canonical = json.dumps(
|
||||||
document,
|
document,
|
||||||
@@ -714,6 +813,8 @@ class ModeProfileRegistry:
|
|||||||
instruction: str = "",
|
instruction: str = "",
|
||||||
command_flow: dict[str, Any] | None = None,
|
command_flow: dict[str, Any] | None = None,
|
||||||
timeline: ModeTimeline | None = None,
|
timeline: ModeTimeline | None = None,
|
||||||
|
sync_origin: str = "",
|
||||||
|
upstream_origin: str = "",
|
||||||
expected_revision: str | None = None,
|
expected_revision: str | None = None,
|
||||||
create_only: bool = False,
|
create_only: bool = False,
|
||||||
) -> ModeProfile:
|
) -> ModeProfile:
|
||||||
@@ -729,6 +830,8 @@ class ModeProfileRegistry:
|
|||||||
profile_id = _clean_mode_id(mode_id or _slugify_mode_id(clean_name))
|
profile_id = _clean_mode_id(mode_id or _slugify_mode_id(clean_name))
|
||||||
clean_instruction = _clean_instruction(instruction)
|
clean_instruction = _clean_instruction(instruction)
|
||||||
clean_command_flow = _clean_command_flow(command_flow)
|
clean_command_flow = _clean_command_flow(command_flow)
|
||||||
|
clean_sync_origin = _clean_sync_origin(sync_origin)
|
||||||
|
clean_upstream_origin = _clean_upstream_origin(upstream_origin)
|
||||||
scope = (
|
scope = (
|
||||||
{"vehicle_ids": [vehicle_id]}
|
{"vehicle_ids": [vehicle_id]}
|
||||||
if vehicle_id
|
if vehicle_id
|
||||||
@@ -751,6 +854,9 @@ class ModeProfileRegistry:
|
|||||||
document["command_flow"] = clean_command_flow
|
document["command_flow"] = clean_command_flow
|
||||||
if timeline is not None:
|
if timeline is not None:
|
||||||
document["timeline"] = timeline.to_yaml()
|
document["timeline"] = timeline.to_yaml()
|
||||||
|
if clean_sync_origin:
|
||||||
|
document["sync_origin"] = clean_sync_origin
|
||||||
|
document["upstream_origin"] = clean_upstream_origin
|
||||||
path = (
|
path = (
|
||||||
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
|
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
|
||||||
if vehicle_id
|
if vehicle_id
|
||||||
@@ -891,6 +997,8 @@ class ModeProfileRegistry:
|
|||||||
instruction=profile.instruction,
|
instruction=profile.instruction,
|
||||||
command_flow=profile.command_flow,
|
command_flow=profile.command_flow,
|
||||||
timeline=profile.timeline,
|
timeline=profile.timeline,
|
||||||
|
sync_origin=profile.sync_origin,
|
||||||
|
upstream_origin=profile.upstream_origin,
|
||||||
)
|
)
|
||||||
backup_path = _initial_backup_path(Path(updated.source))
|
backup_path = _initial_backup_path(Path(updated.source))
|
||||||
if not backup_path.exists():
|
if not backup_path.exists():
|
||||||
@@ -1024,6 +1132,9 @@ def _mode_profile_document(
|
|||||||
document["instruction"] = profile.instruction
|
document["instruction"] = profile.instruction
|
||||||
if profile.command_flow is not None:
|
if profile.command_flow is not None:
|
||||||
document["command_flow"] = profile.command_flow
|
document["command_flow"] = profile.command_flow
|
||||||
|
if profile.sync_origin:
|
||||||
|
document["sync_origin"] = profile.sync_origin
|
||||||
|
document["upstream_origin"] = profile.upstream_origin
|
||||||
return document
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -242,11 +242,41 @@ def _scene_from_profile(
|
|||||||
public_profile: dict[str, Any],
|
public_profile: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
effective_operations = _effective_scene_operations(profile)
|
effective_operations = _effective_scene_operations(profile)
|
||||||
|
source_scene = (
|
||||||
|
profile.command_flow.get("source_scene")
|
||||||
|
if profile.sync_origin == "from_n7/scenes"
|
||||||
|
and isinstance(profile.command_flow, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
source_actions = (
|
||||||
|
source_scene.get("actions")
|
||||||
|
if isinstance(source_scene, dict)
|
||||||
|
and isinstance(source_scene.get("actions"), list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
actions = (
|
||||||
|
deepcopy(source_actions)
|
||||||
|
if source_actions is not None
|
||||||
|
and len(source_actions) == len(effective_operations)
|
||||||
|
else [
|
||||||
|
_scene_action(
|
||||||
|
operation,
|
||||||
|
target=target,
|
||||||
|
operation_ref=operation_ref,
|
||||||
|
)
|
||||||
|
for operation, target, operation_ref in effective_operations
|
||||||
|
]
|
||||||
|
)
|
||||||
scene = {
|
scene = {
|
||||||
"id": profile.id,
|
"id": profile.id,
|
||||||
"label": profile.name,
|
"label": profile.name,
|
||||||
"name": profile.name,
|
"name": profile.name,
|
||||||
"background_time": _background_time(profile),
|
"background_time": (
|
||||||
|
str(source_scene.get("background_time") or "").strip()
|
||||||
|
if isinstance(source_scene, dict)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
or _background_time(profile),
|
||||||
"description": profile.description,
|
"description": profile.description,
|
||||||
"icon": profile.icon,
|
"icon": profile.icon,
|
||||||
"aliases": list(profile.aliases),
|
"aliases": list(profile.aliases),
|
||||||
@@ -257,19 +287,15 @@ def _scene_from_profile(
|
|||||||
"operation_count": len(effective_operations),
|
"operation_count": len(effective_operations),
|
||||||
"scope": deepcopy(public_profile.get("scope") or {}),
|
"scope": deepcopy(public_profile.get("scope") or {}),
|
||||||
"profile_revision": registry.profile_revision(profile),
|
"profile_revision": registry.profile_revision(profile),
|
||||||
"actions": [
|
"actions": actions,
|
||||||
_scene_action(
|
|
||||||
operation,
|
|
||||||
target=target,
|
|
||||||
operation_ref=operation_ref,
|
|
||||||
)
|
|
||||||
for operation, target, operation_ref in effective_operations
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
if public_profile.get("preset_override") is True:
|
if public_profile.get("preset_override") is True:
|
||||||
scene["preset_override"] = True
|
scene["preset_override"] = True
|
||||||
if profile.timeline is not None:
|
if profile.timeline is not None:
|
||||||
scene["timeline"] = profile.timeline.public_dict()
|
scene["timeline"] = profile.timeline.public_dict()
|
||||||
|
if profile.sync_origin:
|
||||||
|
scene["source_topic"] = profile.sync_origin
|
||||||
|
scene["upstream_origin"] = profile.upstream_origin or None
|
||||||
return scene
|
return scene
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""Validate and persist N7-originated mode cards outside the MQTT callback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
|
||||||
|
from cognitive_agent.infrastructure.can.mqtt_transport import (
|
||||||
|
MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
|
MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES,
|
||||||
|
mqtt_mode_scene_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MODE_SCENE_INGRESS_QUEUE_SIZE = 32
|
||||||
|
DEFAULT_MODE_SCENE_DEDUPLICATION_WINDOW_S = 5.0
|
||||||
|
MAX_MODE_SCENE_ACTIONS = 256
|
||||||
|
MAX_MODE_SCENE_AT_MS = 24 * 60 * 60 * 1000
|
||||||
|
_MODE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ModeSceneIngressError(ValueError):
|
||||||
|
"""Raised when an inbound ``from_n7/scenes`` card is not safe to import."""
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp() -> str:
|
||||||
|
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object_without_duplicate_keys(
|
||||||
|
pairs: list[tuple[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes contains duplicate key: {key}"
|
||||||
|
)
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
maximum: int,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ModeSceneIngressError(f"from_n7/scenes {field} must be text")
|
||||||
|
cleaned = value.strip()
|
||||||
|
if not cleaned or len(cleaned) > maximum:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes {field} must contain between 1 and {maximum} characters"
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mode_scene_ingress_payload(raw_payload: bytes | str) -> dict[str, Any]:
|
||||||
|
"""Parse one card using the exact outbound scene contract plus hard limits."""
|
||||||
|
|
||||||
|
if isinstance(raw_payload, bytes):
|
||||||
|
encoded = raw_payload
|
||||||
|
try:
|
||||||
|
text = raw_payload.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes payload must be UTF-8 JSON"
|
||||||
|
) from exc
|
||||||
|
elif isinstance(raw_payload, str):
|
||||||
|
text = raw_payload
|
||||||
|
encoded = raw_payload.encode("utf-8")
|
||||||
|
else:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes payload must be UTF-8 JSON"
|
||||||
|
)
|
||||||
|
if not encoded or len(encoded) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes payload must contain 1-512 KiB"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scene = json.loads(
|
||||||
|
text,
|
||||||
|
object_pairs_hook=_json_object_without_duplicate_keys,
|
||||||
|
)
|
||||||
|
except ModeSceneIngressError:
|
||||||
|
raise
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes payload must be valid JSON"
|
||||||
|
) from exc
|
||||||
|
if not isinstance(scene, Mapping):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes payload must contain one scene object"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
mqtt_mode_scene_payload(scene)
|
||||||
|
except CanGatewayError as exc:
|
||||||
|
raise ModeSceneIngressError(str(exc)) from exc
|
||||||
|
|
||||||
|
normalized = deepcopy(dict(scene))
|
||||||
|
scene_id = _required_text(
|
||||||
|
normalized.get("id"), field="id", maximum=64
|
||||||
|
)
|
||||||
|
if not _MODE_ID_PATTERN.fullmatch(scene_id):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes id must use lowercase letters, numbers, underscores, or hyphens"
|
||||||
|
)
|
||||||
|
normalized["id"] = scene_id
|
||||||
|
normalized["name"] = _required_text(
|
||||||
|
normalized.get("name"), field="name", maximum=64
|
||||||
|
)
|
||||||
|
normalized["label"] = _required_text(
|
||||||
|
normalized.get("label"), field="label", maximum=64
|
||||||
|
)
|
||||||
|
normalized["icon"] = _required_text(
|
||||||
|
normalized.get("icon"), field="icon", maximum=64
|
||||||
|
)
|
||||||
|
normalized["background_time"] = _required_text(
|
||||||
|
normalized.get("background_time"),
|
||||||
|
field="background_time",
|
||||||
|
maximum=32,
|
||||||
|
)
|
||||||
|
normalized["origin"] = _required_text(
|
||||||
|
normalized.get("origin"), field="origin", maximum=64
|
||||||
|
)
|
||||||
|
normalized["behavior"] = _required_text(
|
||||||
|
normalized.get("behavior"), field="behavior", maximum=32
|
||||||
|
)
|
||||||
|
normalized["profile_revision"] = _required_text(
|
||||||
|
normalized.get("profile_revision"),
|
||||||
|
field="profile_revision",
|
||||||
|
maximum=128,
|
||||||
|
)
|
||||||
|
if not isinstance(normalized.get("deletable"), bool):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes deletable must be boolean"
|
||||||
|
)
|
||||||
|
description = normalized.get("description")
|
||||||
|
if not isinstance(description, str) or len(description) > 500:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes description must be text with at most 500 characters"
|
||||||
|
)
|
||||||
|
aliases = normalized.get("aliases")
|
||||||
|
if (
|
||||||
|
not isinstance(aliases, list)
|
||||||
|
or len(aliases) > 64
|
||||||
|
or any(
|
||||||
|
not isinstance(alias, str)
|
||||||
|
or not alias.strip()
|
||||||
|
or len(alias.strip()) > 64
|
||||||
|
for alias in aliases
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes aliases must contain at most 64 non-empty text values"
|
||||||
|
)
|
||||||
|
normalized["aliases"] = [alias.strip() for alias in aliases]
|
||||||
|
domains = normalized.get("domains")
|
||||||
|
if (
|
||||||
|
not isinstance(domains, list)
|
||||||
|
or not 1 <= len(domains) <= 64
|
||||||
|
or any(
|
||||||
|
not isinstance(domain, str)
|
||||||
|
or not domain.strip()
|
||||||
|
or len(domain.strip()) > 64
|
||||||
|
for domain in domains
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes domains must contain 1-64 non-empty text values"
|
||||||
|
)
|
||||||
|
normalized["domains"] = [domain.strip() for domain in domains]
|
||||||
|
|
||||||
|
scope = normalized.get("scope")
|
||||||
|
if not isinstance(scope, Mapping):
|
||||||
|
raise ModeSceneIngressError("from_n7/scenes scope must be an object")
|
||||||
|
vehicle_ids = scope.get("vehicle_ids", [])
|
||||||
|
if "common" in scope and not isinstance(scope.get("common"), bool):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes scope.common must be boolean"
|
||||||
|
)
|
||||||
|
if not isinstance(vehicle_ids, list) or len(vehicle_ids) > 64 or any(
|
||||||
|
not isinstance(vehicle_id, str)
|
||||||
|
or not vehicle_id.strip()
|
||||||
|
or len(vehicle_id.strip()) > 64
|
||||||
|
for vehicle_id in vehicle_ids
|
||||||
|
):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes scope.vehicle_ids must be an array of vehicle ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = normalized.get("actions")
|
||||||
|
if not isinstance(actions, list) or not 1 <= len(actions) <= MAX_MODE_SCENE_ACTIONS:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes actions must contain 1-{MAX_MODE_SCENE_ACTIONS} items"
|
||||||
|
)
|
||||||
|
for index, action in enumerate(actions):
|
||||||
|
if not isinstance(action, Mapping):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes action {index} must be an object"
|
||||||
|
)
|
||||||
|
at_ms = action.get("at_ms")
|
||||||
|
if (
|
||||||
|
isinstance(at_ms, bool)
|
||||||
|
or not isinstance(at_ms, int)
|
||||||
|
or not 0 <= at_ms <= MAX_MODE_SCENE_AT_MS
|
||||||
|
):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes action {index} at_ms must be in range 0-{MAX_MODE_SCENE_AT_MS}"
|
||||||
|
)
|
||||||
|
for field, maximum in (
|
||||||
|
("type", 64),
|
||||||
|
("domain", 64),
|
||||||
|
("capability", 128),
|
||||||
|
("target_scope", 128),
|
||||||
|
("operation", 64),
|
||||||
|
):
|
||||||
|
action[field] = _required_text(
|
||||||
|
action.get(field),
|
||||||
|
field=f"actions[{index}].{field}",
|
||||||
|
maximum=maximum,
|
||||||
|
)
|
||||||
|
if not isinstance(action.get("parameters"), Mapping):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes action {index} parameters must be an object"
|
||||||
|
)
|
||||||
|
target = action.get("target")
|
||||||
|
if target is not None and not isinstance(target, Mapping):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes action {index} target must be an object"
|
||||||
|
)
|
||||||
|
operation_ref = action.get("operation_ref")
|
||||||
|
if operation_ref is not None and (
|
||||||
|
not isinstance(operation_ref, str)
|
||||||
|
or not operation_ref.strip()
|
||||||
|
or len(operation_ref.strip()) > 128
|
||||||
|
):
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
f"from_n7/scenes action {index} operation_ref must be non-empty text"
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ModeSceneIngressConsumer:
|
||||||
|
"""Deduplicate and persist inbound cards on one bounded worker thread."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
importer: Callable[[Mapping[str, Any]], Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
queue_size: int = DEFAULT_MODE_SCENE_INGRESS_QUEUE_SIZE,
|
||||||
|
deduplication_window_s: float = DEFAULT_MODE_SCENE_DEDUPLICATION_WINDOW_S,
|
||||||
|
) -> None:
|
||||||
|
if queue_size <= 0:
|
||||||
|
raise ValueError("queue_size must be greater than zero")
|
||||||
|
if deduplication_window_s <= 0:
|
||||||
|
raise ValueError("deduplication_window_s must be greater than zero")
|
||||||
|
self._importer = importer
|
||||||
|
self._queue: queue.Queue[tuple[bytes, float, str]] = queue.Queue(queue_size)
|
||||||
|
self._deduplication_window_s = float(deduplication_window_s)
|
||||||
|
self._recent_digests: dict[str, float] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._status: dict[str, Any] = {
|
||||||
|
"state": "stopped",
|
||||||
|
"topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
|
"received_count": 0,
|
||||||
|
"processed_count": 0,
|
||||||
|
"imported_count": 0,
|
||||||
|
"duplicate_count": 0,
|
||||||
|
"rejected_count": 0,
|
||||||
|
"last_scene_id": None,
|
||||||
|
"last_received_at": None,
|
||||||
|
"last_completed_at": None,
|
||||||
|
"last_latency_ms": None,
|
||||||
|
"last_result": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(state="waiting", last_error=None)
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="n7-mode-scene-ingress",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
thread = self._thread
|
||||||
|
if thread and thread.is_alive():
|
||||||
|
thread.join(timeout=2.0)
|
||||||
|
self._thread = None
|
||||||
|
with self._lock:
|
||||||
|
self._status["state"] = "stopped"
|
||||||
|
|
||||||
|
def submit(self, raw_payload: bytes) -> None:
|
||||||
|
"""Queue broker bytes immediately; parsing and disk I/O happen later."""
|
||||||
|
|
||||||
|
if self._stop.is_set():
|
||||||
|
raise RuntimeError("mode scene ingress consumer is stopped")
|
||||||
|
payload = bytes(raw_payload)
|
||||||
|
if not payload or len(payload) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
|
||||||
|
raise ModeSceneIngressError(
|
||||||
|
"from_n7/scenes payload must contain 1-512 KiB"
|
||||||
|
)
|
||||||
|
received_at = _timestamp()
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait((payload, time.monotonic(), received_at))
|
||||||
|
except queue.Full as exc:
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(
|
||||||
|
state="queue_full",
|
||||||
|
last_error="from_n7/scenes queue is full",
|
||||||
|
)
|
||||||
|
raise RuntimeError("from_n7/scenes queue is full") from exc
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(
|
||||||
|
state="queued",
|
||||||
|
received_count=int(self._status["received_count"]) + 1,
|
||||||
|
last_received_at=received_at,
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
**deepcopy(self._status),
|
||||||
|
"queue_depth": self._queue.qsize(),
|
||||||
|
"queue_capacity": self._queue.maxsize,
|
||||||
|
"deduplication_window_ms": round(
|
||||||
|
self._deduplication_window_s * 1000
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _is_duplicate(self, digest: str, now: float) -> bool:
|
||||||
|
cutoff = now - self._deduplication_window_s
|
||||||
|
self._recent_digests = {
|
||||||
|
key: observed_at
|
||||||
|
for key, observed_at in self._recent_digests.items()
|
||||||
|
if observed_at >= cutoff
|
||||||
|
}
|
||||||
|
previous = self._recent_digests.get(digest)
|
||||||
|
return previous is not None and previous >= cutoff
|
||||||
|
|
||||||
|
def _process(self, item: tuple[bytes, float, str]) -> None:
|
||||||
|
raw_payload, received_monotonic, received_at = item
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(
|
||||||
|
state="validating",
|
||||||
|
last_received_at=received_at,
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
scene = parse_mode_scene_ingress_payload(raw_payload)
|
||||||
|
canonical = json.dumps(
|
||||||
|
scene,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
duplicate = self._is_duplicate(digest, now)
|
||||||
|
if duplicate:
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(
|
||||||
|
state="duplicate_ignored",
|
||||||
|
processed_count=int(self._status["processed_count"]) + 1,
|
||||||
|
duplicate_count=int(self._status["duplicate_count"]) + 1,
|
||||||
|
last_scene_id=scene["id"],
|
||||||
|
last_completed_at=_timestamp(),
|
||||||
|
last_latency_ms=round(
|
||||||
|
max(0.0, now - received_monotonic) * 1000
|
||||||
|
),
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
result = self._importer(deepcopy(scene))
|
||||||
|
if not isinstance(result, Mapping):
|
||||||
|
raise TypeError("mode scene importer returned an invalid result")
|
||||||
|
with self._lock:
|
||||||
|
self._recent_digests[digest] = time.monotonic()
|
||||||
|
except Exception as exc: # noqa: BLE001 - reject one card, keep worker alive
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(
|
||||||
|
state="rejected",
|
||||||
|
processed_count=int(self._status["processed_count"]) + 1,
|
||||||
|
rejected_count=int(self._status["rejected_count"]) + 1,
|
||||||
|
last_completed_at=_timestamp(),
|
||||||
|
last_latency_ms=round(
|
||||||
|
max(0.0, time.monotonic() - received_monotonic) * 1000
|
||||||
|
),
|
||||||
|
last_result=None,
|
||||||
|
last_error=str(exc)[:240],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._status.update(
|
||||||
|
state="imported",
|
||||||
|
processed_count=int(self._status["processed_count"]) + 1,
|
||||||
|
imported_count=int(self._status["imported_count"]) + 1,
|
||||||
|
last_scene_id=scene["id"],
|
||||||
|
last_completed_at=_timestamp(),
|
||||||
|
last_latency_ms=round(
|
||||||
|
max(0.0, time.monotonic() - received_monotonic) * 1000
|
||||||
|
),
|
||||||
|
last_result=deepcopy(dict(result)),
|
||||||
|
last_error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
item = self._queue.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self._process(item)
|
||||||
|
finally:
|
||||||
|
self._queue.task_done()
|
||||||
|
with self._lock:
|
||||||
|
if self._queue.empty() and self._status["state"] in {
|
||||||
|
"imported",
|
||||||
|
"duplicate_ignored",
|
||||||
|
}:
|
||||||
|
# Keep the outcome visible while accurately reporting that
|
||||||
|
# the worker is ready for the next broker message.
|
||||||
|
self._status["worker_state"] = "waiting"
|
||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Mapping
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -50,6 +52,10 @@ from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime
|
|||||||
from cognitive_agent.application.mode_profile import ( # noqa: E402
|
from cognitive_agent.application.mode_profile import ( # noqa: E402
|
||||||
ModeProfile,
|
ModeProfile,
|
||||||
ModeProfileRegistry,
|
ModeProfileRegistry,
|
||||||
|
ModeTimeline,
|
||||||
|
)
|
||||||
|
from cognitive_agent.application.mode_scene_ingress import ( # noqa: E402
|
||||||
|
ModeSceneIngressConsumer,
|
||||||
)
|
)
|
||||||
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
|
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
|
||||||
DEFAULT_N7_SCENE_SERVER_ID,
|
DEFAULT_N7_SCENE_SERVER_ID,
|
||||||
@@ -130,7 +136,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
|||||||
VehicleMqttSettings,
|
VehicleMqttSettings,
|
||||||
)
|
)
|
||||||
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
|
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
|
||||||
|
MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
MQTT_MODE_SCENE_TOPIC,
|
MQTT_MODE_SCENE_TOPIC,
|
||||||
|
MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||||
MqttCanTransport,
|
MqttCanTransport,
|
||||||
)
|
)
|
||||||
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
|
from cognitive_agent.infrastructure.llm.openai_responses import ( # noqa: E402
|
||||||
@@ -446,6 +454,14 @@ class SeatDemoService:
|
|||||||
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
|
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
|
||||||
topic=MQTT_MODE_SCENE_TOPIC,
|
topic=MQTT_MODE_SCENE_TOPIC,
|
||||||
)
|
)
|
||||||
|
self._mode_scene_ingress = ModeSceneIngressConsumer(
|
||||||
|
self.import_n7_mode_scene,
|
||||||
|
)
|
||||||
|
self._vehicle_mqtt_transport.set_mode_scene_ingress_handler(
|
||||||
|
self._mode_scene_ingress.submit
|
||||||
|
)
|
||||||
|
self._n7_transfer_lock = threading.RLock()
|
||||||
|
self._n7_transfer_publications: dict[str, float] = {}
|
||||||
self.scene_templates = SceneTemplateRegistry(
|
self.scene_templates = SceneTemplateRegistry(
|
||||||
repository_root / "config" / "scene_templates"
|
repository_root / "config" / "scene_templates"
|
||||||
)
|
)
|
||||||
@@ -546,6 +562,18 @@ class SeatDemoService:
|
|||||||
def mode_scene_catalog_status(self) -> dict[str, Any]:
|
def mode_scene_catalog_status(self) -> dict[str, Any]:
|
||||||
return deepcopy(self._mode_scene_catalog_status)
|
return deepcopy(self._mode_scene_catalog_status)
|
||||||
|
|
||||||
|
def mode_profile_sync_status(self) -> dict[str, Any]:
|
||||||
|
"""Return the lightweight state used by the browser's scene watcher."""
|
||||||
|
|
||||||
|
catalog = self.mode_scene_catalog_status()
|
||||||
|
return {
|
||||||
|
"catalog_revision": catalog.get("catalog_revision"),
|
||||||
|
"scene_count": int(catalog.get("scene_count") or 0),
|
||||||
|
"catalog_state": str(catalog.get("state") or "unknown"),
|
||||||
|
"catalog_error": catalog.get("last_error"),
|
||||||
|
"mode_scene_ingress": self._mode_scene_ingress.snapshot(),
|
||||||
|
}
|
||||||
|
|
||||||
def publish_saved_mode_scene(
|
def publish_saved_mode_scene(
|
||||||
self,
|
self,
|
||||||
mode_id: str,
|
mode_id: str,
|
||||||
@@ -591,6 +619,142 @@ class SeatDemoService:
|
|||||||
|
|
||||||
return self.publish_saved_mode_scene(mode_id, vehicle_id)
|
return self.publish_saved_mode_scene(mode_id, vehicle_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _imported_scene_function_configs(
|
||||||
|
scene: dict[str, Any],
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Retain only local peripheral metadata needed for later authorization."""
|
||||||
|
|
||||||
|
extension_operations: list[dict[str, Any]] = []
|
||||||
|
for index, action in enumerate(scene.get("actions", [])):
|
||||||
|
if (
|
||||||
|
not isinstance(action, dict)
|
||||||
|
or action.get("domain") != "self_extension"
|
||||||
|
or action.get("capability") != "infrared.emit"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
target = action.get("target")
|
||||||
|
target_document = (
|
||||||
|
deepcopy(target)
|
||||||
|
if isinstance(target, dict)
|
||||||
|
else {"device": str(action.get("target_scope") or "")}
|
||||||
|
)
|
||||||
|
extension_operations.append(
|
||||||
|
{
|
||||||
|
"id": str(action.get("operation_ref") or f"n7-ir-{index + 1}"),
|
||||||
|
"domain": "self_extension",
|
||||||
|
"capability": "infrared.emit",
|
||||||
|
"target_scope": str(action.get("target_scope") or ""),
|
||||||
|
"target": target_document,
|
||||||
|
"operation": str(action.get("operation") or "emit"),
|
||||||
|
"parameters": deepcopy(dict(action.get("parameters") or {})),
|
||||||
|
"parse_source": "from_n7_scene",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not extension_operations:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"self_extension": {
|
||||||
|
"apiVersion": "cognitive-agent.io/v1alpha1",
|
||||||
|
"kind": "FunctionConfig",
|
||||||
|
"metadata": {
|
||||||
|
"id": f"from-n7-{scene['id']}-self-extension",
|
||||||
|
"name": f"{scene['name']} imported infrared actions",
|
||||||
|
"labels": {"source_topic": MQTT_MODE_SCENE_INGRESS_TOPIC},
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"domain": "self_extension",
|
||||||
|
"operations": extension_operations,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def import_n7_mode_scene(
|
||||||
|
self,
|
||||||
|
scene: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Upsert one validated N7 card without publishing it back to the broker."""
|
||||||
|
|
||||||
|
document = deepcopy(dict(scene))
|
||||||
|
actions = document.get("actions")
|
||||||
|
if not isinstance(actions, list) or not actions:
|
||||||
|
raise ValueError("from_n7/scenes card must contain executable actions")
|
||||||
|
operations = [
|
||||||
|
{
|
||||||
|
"domain": str(action["domain"]),
|
||||||
|
"capability": str(action["capability"]),
|
||||||
|
"target_scope": str(action["target_scope"]),
|
||||||
|
"operation": str(action["operation"]),
|
||||||
|
"parameters": deepcopy(dict(action["parameters"])),
|
||||||
|
"reason": (
|
||||||
|
f"由 {MQTT_MODE_SCENE_INGRESS_TOPIC} 导入的模式动作。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for action in actions
|
||||||
|
]
|
||||||
|
timeline = None
|
||||||
|
raw_timeline = document.get("timeline")
|
||||||
|
if raw_timeline is not None:
|
||||||
|
if not isinstance(raw_timeline, dict):
|
||||||
|
raise ValueError("from_n7/scenes timeline must be an object")
|
||||||
|
timeline = ModeTimeline.from_dict(raw_timeline)
|
||||||
|
command_flow = {
|
||||||
|
"schema": "cognitive-agent-mode-command-flow/v1",
|
||||||
|
"source_topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
|
"imported_at": _now(),
|
||||||
|
"source_scene": document,
|
||||||
|
"function_configs": self._imported_scene_function_configs(document),
|
||||||
|
# The execution path recompiles the canonical ModeOperation list
|
||||||
|
# against this PC's capability registry. This marker makes the
|
||||||
|
# card directly executable while avoiding trust in foreign CAN bytes.
|
||||||
|
"plans": {
|
||||||
|
"from_n7_scene": {
|
||||||
|
"spec": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"operation_ref": str(
|
||||||
|
action.get("operation_ref") or f"action-{index + 1}"
|
||||||
|
),
|
||||||
|
"at_ms": int(action.get("at_ms", 0)),
|
||||||
|
}
|
||||||
|
for index, action in enumerate(actions)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"execution_steps": [],
|
||||||
|
}
|
||||||
|
scope = document.get("scope") or {}
|
||||||
|
vehicle_ids = scope.get("vehicle_ids", []) if isinstance(scope, dict) else []
|
||||||
|
common = bool(scope.get("common", not vehicle_ids)) if isinstance(scope, dict) else False
|
||||||
|
resolved_vehicle_id = (
|
||||||
|
None
|
||||||
|
if common and not vehicle_ids
|
||||||
|
else self.vehicle_profiles.default().id
|
||||||
|
)
|
||||||
|
saved = self.mode_profiles.save_mode(
|
||||||
|
str(document["name"]),
|
||||||
|
operations,
|
||||||
|
mode_id=str(document["id"]),
|
||||||
|
aliases=[str(alias) for alias in document.get("aliases", [])],
|
||||||
|
vehicle_id=resolved_vehicle_id,
|
||||||
|
description=str(document.get("description") or "由 N7 同步的模式卡片。"),
|
||||||
|
icon=str(document.get("icon") or "radio-tower"),
|
||||||
|
instruction=f"进入{document['name']}",
|
||||||
|
command_flow=command_flow,
|
||||||
|
timeline=timeline,
|
||||||
|
sync_origin=MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
|
upstream_origin=str(document.get("origin") or ""),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "imported",
|
||||||
|
"scene_id": saved.id,
|
||||||
|
"scene_name": saved.name,
|
||||||
|
"source_topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
|
"profile_revision": self.mode_profiles.profile_revision(saved),
|
||||||
|
"storage": "persistent_yaml",
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def driver_only_ch1_execution_active(self) -> bool:
|
def driver_only_ch1_execution_active(self) -> bool:
|
||||||
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
|
"""Return whether deployment policy authorizes only the CH1 vehicle path."""
|
||||||
@@ -646,6 +810,7 @@ class SeatDemoService:
|
|||||||
"last_error": None,
|
"last_error": None,
|
||||||
}
|
}
|
||||||
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
|
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
|
||||||
|
vehicle_mqtt_info["mode_scene_import"] = self._mode_scene_ingress.snapshot()
|
||||||
return {
|
return {
|
||||||
"service": "cabin-cognitive-orchestrator",
|
"service": "cabin-cognitive-orchestrator",
|
||||||
"domain": "cabin",
|
"domain": "cabin",
|
||||||
@@ -807,6 +972,207 @@ class SeatDemoService:
|
|||||||
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
|
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
|
||||||
return self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
|
return self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fallback_scene_action(
|
||||||
|
operation: Any,
|
||||||
|
*,
|
||||||
|
index: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "control",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": operation.domain,
|
||||||
|
"capability": operation.capability,
|
||||||
|
"target_scope": operation.target_scope,
|
||||||
|
"operation": operation.operation,
|
||||||
|
"parameters": deepcopy(operation.parameters),
|
||||||
|
"operation_ref": f"action-{index + 1}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _mode_requests_and_n7_transfers(
|
||||||
|
self,
|
||||||
|
profile: ModeProfile,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Separate locally understood operations from N7-only capabilities."""
|
||||||
|
|
||||||
|
requests = self.mode_profiles.requests_for_profiles([profile])
|
||||||
|
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
|
||||||
|
return requests, []
|
||||||
|
source_scene = (
|
||||||
|
profile.command_flow.get("source_scene")
|
||||||
|
if isinstance(profile.command_flow, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
source_actions = (
|
||||||
|
source_scene.get("actions")
|
||||||
|
if isinstance(source_scene, dict)
|
||||||
|
and isinstance(source_scene.get("actions"), list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
local_requests: list[dict[str, Any]] = []
|
||||||
|
transfer_actions: list[dict[str, Any]] = []
|
||||||
|
for index, (operation, request) in enumerate(
|
||||||
|
zip(profile.operations, requests, strict=True)
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
operation.domain == "self_extension"
|
||||||
|
and operation.capability == "infrared.emit"
|
||||||
|
):
|
||||||
|
# The server's durable NFC allowlist owns this local TCP path.
|
||||||
|
# Authorization failures must not be converted into N7 retries.
|
||||||
|
continue
|
||||||
|
registered = self.model_plan_adapter.capabilities.get(
|
||||||
|
operation.capability
|
||||||
|
)
|
||||||
|
native_candidate = {
|
||||||
|
"domain": operation.domain,
|
||||||
|
"capability": operation.capability,
|
||||||
|
"target": operation.target_scope,
|
||||||
|
"parameters": deepcopy(operation.parameters),
|
||||||
|
"operation": operation.operation,
|
||||||
|
}
|
||||||
|
if registered is not None or self.native_can_runtime.supports(
|
||||||
|
native_candidate
|
||||||
|
):
|
||||||
|
local_requests.append(request)
|
||||||
|
continue
|
||||||
|
raw_action = (
|
||||||
|
source_actions[index]
|
||||||
|
if index < len(source_actions)
|
||||||
|
and isinstance(source_actions[index], dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
action = (
|
||||||
|
deepcopy(raw_action)
|
||||||
|
if raw_action is not None
|
||||||
|
else self._fallback_scene_action(operation, index=index)
|
||||||
|
)
|
||||||
|
transfer_actions.append(action)
|
||||||
|
return local_requests, transfer_actions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_local_mode_peripheral(profile: ModeProfile) -> bool:
|
||||||
|
return any(
|
||||||
|
operation.domain == "self_extension"
|
||||||
|
and operation.capability == "infrared.emit"
|
||||||
|
for operation in profile.operations
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _n7_transfer_deferred_requests(
|
||||||
|
actions: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"domain": str(action.get("domain") or ""),
|
||||||
|
"capability": str(action.get("capability") or ""),
|
||||||
|
"target_scope": str(action.get("target_scope") or ""),
|
||||||
|
"desired_change": str(action.get("operation") or "set"),
|
||||||
|
"parameters": deepcopy(dict(action.get("parameters") or {})),
|
||||||
|
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
|
||||||
|
"execution_backend": "n7_vehicle_transfer",
|
||||||
|
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
|
||||||
|
"transport_submitted": False,
|
||||||
|
}
|
||||||
|
for action in actions
|
||||||
|
]
|
||||||
|
|
||||||
|
def _finalize_n7_control_transfer(
|
||||||
|
self,
|
||||||
|
result: dict[str, Any],
|
||||||
|
*,
|
||||||
|
execution_mode: str,
|
||||||
|
local_preview: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Forward only N7-originated actions never submitted by this PC."""
|
||||||
|
|
||||||
|
actions = result.get("n7_transfer_candidates")
|
||||||
|
scene = result.get("n7_source_scene")
|
||||||
|
if (
|
||||||
|
execution_mode != "hardware"
|
||||||
|
or local_preview
|
||||||
|
or not isinstance(actions, list)
|
||||||
|
or not actions
|
||||||
|
or not isinstance(scene, dict)
|
||||||
|
):
|
||||||
|
return result
|
||||||
|
if str(result.get("status") or "") not in {
|
||||||
|
"succeeded",
|
||||||
|
"partially_succeeded",
|
||||||
|
"capability_unavailable",
|
||||||
|
}:
|
||||||
|
return result
|
||||||
|
if result.get("execution_outcome") == "failed_or_blocked":
|
||||||
|
return result
|
||||||
|
request_id = str(result.get("request_id") or "").strip()
|
||||||
|
scene_id = str(scene.get("id") or "").strip()
|
||||||
|
scene_name = str(scene.get("name") or "").strip()
|
||||||
|
if not request_id or not scene_id or not scene_name:
|
||||||
|
return result
|
||||||
|
transfer_actions = [deepcopy(action) for action in actions if isinstance(action, dict)]
|
||||||
|
if not transfer_actions:
|
||||||
|
return result
|
||||||
|
payload = {
|
||||||
|
"scene_id": scene_id,
|
||||||
|
"scene_name": scene_name,
|
||||||
|
"request_id": request_id,
|
||||||
|
"operation_count": len(transfer_actions),
|
||||||
|
"actions": transfer_actions,
|
||||||
|
}
|
||||||
|
digest = json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
publication_key = f"{request_id}:{hashlib.sha256(digest.encode('utf-8')).hexdigest()}"
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._n7_transfer_lock:
|
||||||
|
cutoff = now - 300.0
|
||||||
|
self._n7_transfer_publications = {
|
||||||
|
key: published_at
|
||||||
|
for key, published_at in self._n7_transfer_publications.items()
|
||||||
|
if published_at >= cutoff
|
||||||
|
}
|
||||||
|
if publication_key in self._n7_transfer_publications:
|
||||||
|
result["n7_control_transfer"] = {
|
||||||
|
"state": "duplicate_ignored",
|
||||||
|
"topic": MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||||
|
"scene_id": scene_id,
|
||||||
|
"operation_count": len(transfer_actions),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
publication = self._vehicle_mqtt_transport.publish_n7_control_transfer(
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - local execution result remains valid
|
||||||
|
result["n7_control_transfer"] = {
|
||||||
|
"state": "publish_failed",
|
||||||
|
"topic": MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||||
|
"scene_id": scene_id,
|
||||||
|
"operation_count": len(transfer_actions),
|
||||||
|
"last_error": str(exc)[:240],
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
with self._n7_transfer_lock:
|
||||||
|
self._n7_transfer_publications[publication_key] = now
|
||||||
|
result["n7_control_transfer"] = {
|
||||||
|
"state": "published",
|
||||||
|
"scene_id": scene_id,
|
||||||
|
"operation_count": len(transfer_actions),
|
||||||
|
"payload": payload,
|
||||||
|
**dict(publication),
|
||||||
|
}
|
||||||
|
result["decision_summary"] = [
|
||||||
|
*list(result.get("decision_summary") or []),
|
||||||
|
(
|
||||||
|
f"当前 PC 未注册的 {len(transfer_actions)} 条 N7 来源动作已发送至 "
|
||||||
|
f"{MQTT_N7_CONTROL_TRANSFER_TOPIC};PC 已提交或被安全门禁拦截的动作未转发。"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
def execute_mode_profile_card(
|
def execute_mode_profile_card(
|
||||||
self,
|
self,
|
||||||
mode_id: str,
|
mode_id: str,
|
||||||
@@ -1183,6 +1549,10 @@ class SeatDemoService:
|
|||||||
instruction=clean_instruction,
|
instruction=clean_instruction,
|
||||||
command_flow=command_flow,
|
command_flow=command_flow,
|
||||||
timeline=profile.timeline if profile is not None else None,
|
timeline=profile.timeline if profile is not None else None,
|
||||||
|
sync_origin=profile.sync_origin if profile is not None else "",
|
||||||
|
upstream_origin=(
|
||||||
|
profile.upstream_origin if profile is not None else ""
|
||||||
|
),
|
||||||
expected_revision=draft_existing_mode_revision or None,
|
expected_revision=draft_existing_mode_revision or None,
|
||||||
create_only=not bool(clean_mode_id),
|
create_only=not bool(clean_mode_id),
|
||||||
)
|
)
|
||||||
@@ -1379,6 +1749,7 @@ class SeatDemoService:
|
|||||||
if self._vehicle_state_monitor is not None:
|
if self._vehicle_state_monitor is not None:
|
||||||
self._vehicle_state_monitor.start()
|
self._vehicle_state_monitor.start()
|
||||||
self._mode_scene_mqtt_publisher.start()
|
self._mode_scene_mqtt_publisher.start()
|
||||||
|
self._mode_scene_ingress.start()
|
||||||
self._vehicle_mqtt_transport.start()
|
self._vehicle_mqtt_transport.start()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
@@ -1386,6 +1757,7 @@ class SeatDemoService:
|
|||||||
self._vehicle_state_monitor.close()
|
self._vehicle_state_monitor.close()
|
||||||
self._mode_scene_mqtt_publisher.close()
|
self._mode_scene_mqtt_publisher.close()
|
||||||
self._vehicle_mqtt_transport.close()
|
self._vehicle_mqtt_transport.close()
|
||||||
|
self._mode_scene_ingress.close()
|
||||||
|
|
||||||
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
|
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
|
||||||
"""Publish one NFC-authorized pet card through the shared vehicle broker."""
|
"""Publish one NFC-authorized pet card through the shared vehicle broker."""
|
||||||
@@ -2416,7 +2788,7 @@ class SeatDemoService:
|
|||||||
# The head unit owns both physical CAN channels in wireless mode.
|
# The head unit owns both physical CAN channels in wireless mode.
|
||||||
# Local T2G/ZLG hardware access, firmware profiles and the local
|
# Local T2G/ZLG hardware access, firmware profiles and the local
|
||||||
# physical-bench lease are unrelated to MQTT relay execution.
|
# physical-bench lease are unrelated to MQTT relay execution.
|
||||||
return self._process_once(
|
result = self._process_once(
|
||||||
utterance,
|
utterance,
|
||||||
provider,
|
provider,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -2438,11 +2810,16 @@ class SeatDemoService:
|
|||||||
mode_profile_draft_context=_mode_profile_draft_context,
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
cloud_instruction_context=_cloud_instruction_context,
|
cloud_instruction_context=_cloud_instruction_context,
|
||||||
)
|
)
|
||||||
|
return self._finalize_n7_control_transfer(
|
||||||
|
result,
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
local_preview=_local_preview,
|
||||||
|
)
|
||||||
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
|
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
|
||||||
control_transport
|
control_transport
|
||||||
):
|
):
|
||||||
with self.hardware_execution.reserve():
|
with self.hardware_execution.reserve():
|
||||||
return self._process_once(
|
result = self._process_once(
|
||||||
utterance,
|
utterance,
|
||||||
provider,
|
provider,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -2464,10 +2841,15 @@ class SeatDemoService:
|
|||||||
mode_profile_draft_context=_mode_profile_draft_context,
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
cloud_instruction_context=_cloud_instruction_context,
|
cloud_instruction_context=_cloud_instruction_context,
|
||||||
)
|
)
|
||||||
|
return self._finalize_n7_control_transfer(
|
||||||
|
result,
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
local_preview=_local_preview,
|
||||||
|
)
|
||||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||||
with self.hardware_execution.reserve():
|
with self.hardware_execution.reserve():
|
||||||
with self.hardware_access.use(authorized_client_id):
|
with self.hardware_access.use(authorized_client_id):
|
||||||
return self._process_once(
|
result = self._process_once(
|
||||||
utterance,
|
utterance,
|
||||||
provider,
|
provider,
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -2489,6 +2871,11 @@ class SeatDemoService:
|
|||||||
mode_profile_draft_context=_mode_profile_draft_context,
|
mode_profile_draft_context=_mode_profile_draft_context,
|
||||||
cloud_instruction_context=_cloud_instruction_context,
|
cloud_instruction_context=_cloud_instruction_context,
|
||||||
)
|
)
|
||||||
|
return self._finalize_n7_control_transfer(
|
||||||
|
result,
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
local_preview=_local_preview,
|
||||||
|
)
|
||||||
|
|
||||||
def _process_once(
|
def _process_once(
|
||||||
self,
|
self,
|
||||||
@@ -2768,8 +3155,8 @@ class SeatDemoService:
|
|||||||
traces,
|
traces,
|
||||||
)
|
)
|
||||||
if direct_mode_profile is not None:
|
if direct_mode_profile is not None:
|
||||||
mode_requests = self.mode_profiles.requests_for_profiles(
|
mode_requests, n7_transfer_actions = (
|
||||||
[direct_mode_profile]
|
self._mode_requests_and_n7_transfers(direct_mode_profile)
|
||||||
)
|
)
|
||||||
detected_domains = list(
|
detected_domains = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
@@ -2814,6 +3201,28 @@ class SeatDemoService:
|
|||||||
*list(parsed.get("native_can_actions", [])),
|
*list(parsed.get("native_can_actions", [])),
|
||||||
*saved_native_actions,
|
*saved_native_actions,
|
||||||
]
|
]
|
||||||
|
if n7_transfer_actions:
|
||||||
|
parsed["deferred_requests"] = [
|
||||||
|
*list(parsed.get("deferred_requests", [])),
|
||||||
|
*self._n7_transfer_deferred_requests(
|
||||||
|
n7_transfer_actions
|
||||||
|
),
|
||||||
|
]
|
||||||
|
parsed["_n7_transfer_actions"] = n7_transfer_actions
|
||||||
|
parsed["_n7_scene_profile"] = {
|
||||||
|
"id": direct_mode_profile.id,
|
||||||
|
"name": direct_mode_profile.name,
|
||||||
|
}
|
||||||
|
parsed["detected_domains"] = self._merge_domains(
|
||||||
|
list(parsed.get("detected_domains", [])),
|
||||||
|
[
|
||||||
|
str(action.get("domain") or "")
|
||||||
|
for action in n7_transfer_actions
|
||||||
|
if str(action.get("domain") or "")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if self._has_local_mode_peripheral(direct_mode_profile):
|
||||||
|
parsed["_saved_mode_local_peripheral"] = True
|
||||||
provider_used = "saved_mode_command_flow"
|
provider_used = "saved_mode_command_flow"
|
||||||
provider_notice = None
|
provider_notice = None
|
||||||
elif direct_can_batch is not None:
|
elif direct_can_batch is not None:
|
||||||
@@ -2951,6 +3360,12 @@ class SeatDemoService:
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
n7_transfer_actions = parsed.get("_n7_transfer_actions", [])
|
||||||
|
n7_scene_profile = parsed.get("_n7_scene_profile")
|
||||||
|
if isinstance(n7_transfer_actions, list) and n7_transfer_actions:
|
||||||
|
result["n7_transfer_candidates"] = deepcopy(n7_transfer_actions)
|
||||||
|
if isinstance(n7_scene_profile, dict):
|
||||||
|
result["n7_source_scene"] = deepcopy(n7_scene_profile)
|
||||||
result["parsed_control_signatures"] = [
|
result["parsed_control_signatures"] = [
|
||||||
list(self._control_signature(action))
|
list(self._control_signature(action))
|
||||||
for action in [
|
for action in [
|
||||||
@@ -2969,7 +3384,10 @@ class SeatDemoService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
allow_empty_control_result
|
(
|
||||||
|
allow_empty_control_result
|
||||||
|
or parsed.get("_saved_mode_local_peripheral") is True
|
||||||
|
)
|
||||||
and not has_control
|
and not has_control
|
||||||
and not parsed.get("ambiguities")
|
and not parsed.get("ambiguities")
|
||||||
):
|
):
|
||||||
@@ -3019,7 +3437,11 @@ class SeatDemoService:
|
|||||||
"hardware mode refuses virtual or unverified vehicle actions: "
|
"hardware mode refuses virtual or unverified vehicle actions: "
|
||||||
+ ", ".join(unsupported)
|
+ ", ".join(unsupported)
|
||||||
)
|
)
|
||||||
if not parsed.get("actions") and not parsed.get("native_can_actions"):
|
if (
|
||||||
|
not parsed.get("actions")
|
||||||
|
and not parsed.get("native_can_actions")
|
||||||
|
and not parsed.get("_n7_transfer_actions")
|
||||||
|
):
|
||||||
raise HardwareSafetyError(
|
raise HardwareSafetyError(
|
||||||
"hardware mode requires at least one verified seat or vehicle CAN operation"
|
"hardware mode requires at least one verified seat or vehicle CAN operation"
|
||||||
)
|
)
|
||||||
@@ -4070,7 +4492,10 @@ class SeatDemoService:
|
|||||||
if safety_ambiguities:
|
if safety_ambiguities:
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
|
matched_profile = matched_modes[0]
|
||||||
|
mode_requests, n7_transfer_actions = (
|
||||||
|
self._mode_requests_and_n7_transfers(matched_profile)
|
||||||
|
)
|
||||||
detected_domains = []
|
detected_domains = []
|
||||||
for request in mode_requests:
|
for request in mode_requests:
|
||||||
if request["domain"] not in detected_domains:
|
if request["domain"] not in detected_domains:
|
||||||
@@ -4136,6 +4561,26 @@ class SeatDemoService:
|
|||||||
float(mode_parsed.get("confidence", 0)),
|
float(mode_parsed.get("confidence", 0)),
|
||||||
float(parsed.get("confidence", 0)),
|
float(parsed.get("confidence", 0)),
|
||||||
)
|
)
|
||||||
|
if n7_transfer_actions:
|
||||||
|
merged["deferred_requests"] = self._merge_unique(
|
||||||
|
list(merged.get("deferred_requests", [])),
|
||||||
|
self._n7_transfer_deferred_requests(n7_transfer_actions),
|
||||||
|
)
|
||||||
|
merged["_n7_transfer_actions"] = deepcopy(n7_transfer_actions)
|
||||||
|
merged["_n7_scene_profile"] = {
|
||||||
|
"id": matched_profile.id,
|
||||||
|
"name": matched_profile.name,
|
||||||
|
}
|
||||||
|
merged["detected_domains"] = self._merge_domains(
|
||||||
|
list(merged.get("detected_domains", [])),
|
||||||
|
[
|
||||||
|
str(action.get("domain") or "")
|
||||||
|
for action in n7_transfer_actions
|
||||||
|
if str(action.get("domain") or "")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if self._has_local_mode_peripheral(matched_profile):
|
||||||
|
merged["_saved_mode_local_peripheral"] = True
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ MQTT_EXTENSION_CONTROL_TOPIC = "extension/ctl"
|
|||||||
MQTT_EXTENSION_CONTROL_QOS = 1
|
MQTT_EXTENSION_CONTROL_QOS = 1
|
||||||
MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024
|
MQTT_EXTENSION_CONTROL_MAX_PAYLOAD_BYTES = 256 * 1024
|
||||||
MQTT_MODE_SCENE_TOPIC = "from_pc/scenes"
|
MQTT_MODE_SCENE_TOPIC = "from_pc/scenes"
|
||||||
|
MQTT_MODE_SCENE_INGRESS_TOPIC = "from_n7/scenes"
|
||||||
|
MQTT_MODE_SCENE_INGRESS_QOS = 1
|
||||||
|
MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES = 512 * 1024
|
||||||
|
MQTT_N7_CONTROL_TRANSFER_TOPIC = "transto_n7/ctl"
|
||||||
|
MQTT_N7_CONTROL_TRANSFER_QOS = 1
|
||||||
MQTT_POLL_INTERVAL_MS = 500
|
MQTT_POLL_INTERVAL_MS = 500
|
||||||
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
|
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
|
||||||
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
|
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
|
||||||
@@ -727,11 +732,86 @@ def mqtt_mode_scene_payload(payload: Mapping[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise CanGatewayError("mode scene MQTT payload is not valid JSON") from exc
|
raise CanGatewayError("mode scene MQTT payload is not valid JSON") from exc
|
||||||
if len(raw_payload.encode("utf-8")) > 512 * 1024:
|
if len(raw_payload.encode("utf-8")) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
|
||||||
raise CanGatewayError("mode scene MQTT payload exceeds 512 KiB")
|
raise CanGatewayError("mode scene MQTT payload exceeds 512 KiB")
|
||||||
return raw_payload
|
return raw_payload
|
||||||
|
|
||||||
|
|
||||||
|
_N7_CONTROL_TRANSFER_REQUIRED_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"scene_id",
|
||||||
|
"scene_name",
|
||||||
|
"request_id",
|
||||||
|
"operation_count",
|
||||||
|
"actions",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_n7_control_transfer_payload(payload: Mapping[str, Any]) -> str:
|
||||||
|
"""Serialize PC-unavailable actions for the originating N7 scene."""
|
||||||
|
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise CanGatewayError("N7 control transfer payload must be a JSON object")
|
||||||
|
missing = sorted(_N7_CONTROL_TRANSFER_REQUIRED_KEYS - set(payload))
|
||||||
|
if missing:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer payload is missing required fields: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
for key, maximum in (("scene_id", 64), ("scene_name", 64), ("request_id", 128)):
|
||||||
|
value = payload.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
||||||
|
raise CanGatewayError(
|
||||||
|
f"N7 control transfer {key} must contain between 1 and {maximum} characters"
|
||||||
|
)
|
||||||
|
operation_count = payload.get("operation_count")
|
||||||
|
actions = payload.get("actions")
|
||||||
|
if (
|
||||||
|
isinstance(operation_count, bool)
|
||||||
|
or not isinstance(operation_count, int)
|
||||||
|
or operation_count < 1
|
||||||
|
):
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer operation_count must be a positive integer"
|
||||||
|
)
|
||||||
|
if not isinstance(actions, list) or len(actions) != operation_count:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer actions must match operation_count"
|
||||||
|
)
|
||||||
|
for action in actions:
|
||||||
|
if not isinstance(action, Mapping):
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer actions must contain JSON objects"
|
||||||
|
)
|
||||||
|
missing_action_keys = sorted(
|
||||||
|
_MODE_SCENE_ACTION_REQUIRED_KEYS - set(action)
|
||||||
|
)
|
||||||
|
if missing_action_keys:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer action is missing required fields: "
|
||||||
|
+ ", ".join(missing_action_keys)
|
||||||
|
)
|
||||||
|
if not isinstance(action.get("parameters"), Mapping):
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer action parameters must be an object"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw_payload = json.dumps(
|
||||||
|
deepcopy(dict(payload)),
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"N7 control transfer payload is not valid JSON"
|
||||||
|
) from exc
|
||||||
|
if len(raw_payload.encode("utf-8")) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
|
||||||
|
raise CanGatewayError("N7 control transfer payload exceeds 512 KiB")
|
||||||
|
return raw_payload
|
||||||
|
|
||||||
|
|
||||||
def _validate_native_seat_motion_control(data: bytes) -> None:
|
def _validate_native_seat_motion_control(data: bytes) -> None:
|
||||||
"""Validate the firmware-native 0x300 semantic runtime subset.
|
"""Validate the firmware-native 0x300 semantic runtime subset.
|
||||||
|
|
||||||
@@ -863,6 +943,13 @@ class MqttCanTransport:
|
|||||||
self._extension_control_retained_count = 0
|
self._extension_control_retained_count = 0
|
||||||
self._extension_control_last_received_at: str | None = None
|
self._extension_control_last_received_at: str | None = None
|
||||||
self._extension_control_last_error: str | None = None
|
self._extension_control_last_error: str | None = None
|
||||||
|
self._mode_scene_ingress_handler: Callable[[bytes], None] | None = None
|
||||||
|
self._mode_scene_ingress_received_count = 0
|
||||||
|
self._mode_scene_ingress_dispatched_count = 0
|
||||||
|
self._mode_scene_ingress_rejected_count = 0
|
||||||
|
self._mode_scene_ingress_retained_count = 0
|
||||||
|
self._mode_scene_ingress_last_received_at: str | None = None
|
||||||
|
self._mode_scene_ingress_last_error: str | None = None
|
||||||
|
|
||||||
def _build_client(self) -> Any:
|
def _build_client(self) -> Any:
|
||||||
if self.client_factory is not None:
|
if self.client_factory is not None:
|
||||||
@@ -968,6 +1055,18 @@ class MqttCanTransport:
|
|||||||
self._extension_control_rejection_handler = handler
|
self._extension_control_rejection_handler = handler
|
||||||
self._condition.notify_all()
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
def set_mode_scene_ingress_handler(
|
||||||
|
self,
|
||||||
|
handler: Callable[[bytes], None] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Register the non-blocking application queue for ``from_n7/scenes``."""
|
||||||
|
|
||||||
|
if handler is not None and not callable(handler):
|
||||||
|
raise TypeError("mode scene ingress handler must be callable")
|
||||||
|
with self._condition:
|
||||||
|
self._mode_scene_ingress_handler = handler
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
def _notify_extension_control_rejection(self, error: str, kind: str) -> None:
|
def _notify_extension_control_rejection(self, error: str, kind: str) -> None:
|
||||||
with self._condition:
|
with self._condition:
|
||||||
handler = self._extension_control_rejection_handler
|
handler = self._extension_control_rejection_handler
|
||||||
@@ -1003,6 +1102,7 @@ class MqttCanTransport:
|
|||||||
subscriptions = (
|
subscriptions = (
|
||||||
(self.settings.condition_topic, self.settings.qos),
|
(self.settings.condition_topic, self.settings.qos),
|
||||||
(MQTT_EXTENSION_CONTROL_TOPIC, MQTT_EXTENSION_CONTROL_QOS),
|
(MQTT_EXTENSION_CONTROL_TOPIC, MQTT_EXTENSION_CONTROL_QOS),
|
||||||
|
(MQTT_MODE_SCENE_INGRESS_TOPIC, MQTT_MODE_SCENE_INGRESS_QOS),
|
||||||
)
|
)
|
||||||
failed_subscription: tuple[str, int] | None = None
|
failed_subscription: tuple[str, int] | None = None
|
||||||
try:
|
try:
|
||||||
@@ -1054,6 +1154,9 @@ class MqttCanTransport:
|
|||||||
if topic == MQTT_EXTENSION_CONTROL_TOPIC:
|
if topic == MQTT_EXTENSION_CONTROL_TOPIC:
|
||||||
self._dispatch_extension_control_message(message)
|
self._dispatch_extension_control_message(message)
|
||||||
return
|
return
|
||||||
|
if topic == MQTT_MODE_SCENE_INGRESS_TOPIC:
|
||||||
|
self._dispatch_mode_scene_ingress_message(message)
|
||||||
|
return
|
||||||
if topic != self.settings.condition_topic:
|
if topic != self.settings.condition_topic:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -1134,6 +1237,45 @@ class MqttCanTransport:
|
|||||||
self._condition.notify_all()
|
self._condition.notify_all()
|
||||||
self._notify_extension_control_rejection(error, rejection_kind)
|
self._notify_extension_control_rejection(error, rejection_kind)
|
||||||
|
|
||||||
|
def _dispatch_mode_scene_ingress_message(self, message: Any) -> None:
|
||||||
|
"""Forward a fresh scene payload without blocking the MQTT network loop."""
|
||||||
|
|
||||||
|
received_at = _now()
|
||||||
|
with self._condition:
|
||||||
|
self._mode_scene_ingress_received_count += 1
|
||||||
|
self._mode_scene_ingress_last_received_at = received_at
|
||||||
|
handler = self._mode_scene_ingress_handler
|
||||||
|
retained = bool(getattr(message, "retain", False))
|
||||||
|
if retained:
|
||||||
|
self._mode_scene_ingress_retained_count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if retained:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"retained from_n7/scenes messages are not imported"
|
||||||
|
)
|
||||||
|
payload = bytes(message.payload)
|
||||||
|
if not payload or len(payload) > MQTT_MODE_SCENE_MAX_PAYLOAD_BYTES:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"from_n7/scenes payload must contain 1-512 KiB"
|
||||||
|
)
|
||||||
|
if handler is None:
|
||||||
|
raise CanGatewayError(
|
||||||
|
"from_n7/scenes handler is not configured"
|
||||||
|
)
|
||||||
|
handler(payload)
|
||||||
|
except Exception as exc: # noqa: BLE001 - isolate MQTT network loop
|
||||||
|
with self._condition:
|
||||||
|
self._mode_scene_ingress_rejected_count += 1
|
||||||
|
self._mode_scene_ingress_last_error = str(exc)[:240]
|
||||||
|
self._condition.notify_all()
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._condition:
|
||||||
|
self._mode_scene_ingress_dispatched_count += 1
|
||||||
|
self._mode_scene_ingress_last_error = None
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
def _record_frame(self, frame: CanFrame) -> None:
|
def _record_frame(self, frame: CanFrame) -> None:
|
||||||
if (
|
if (
|
||||||
frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS
|
frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS
|
||||||
@@ -1431,6 +1573,38 @@ class MqttCanTransport:
|
|||||||
"message_id": getattr(result, "mid", None),
|
"message_id": getattr(result, "mid", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def publish_n7_control_transfer(
|
||||||
|
self,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Queue PC-unavailable scene actions for execution by the N7 side."""
|
||||||
|
|
||||||
|
raw_payload = mqtt_n7_control_transfer_payload(payload)
|
||||||
|
with self._condition:
|
||||||
|
client = self._client
|
||||||
|
state = self._state
|
||||||
|
if client is None or state != "online":
|
||||||
|
raise CanGatewayError("N7 control transfer MQTT publisher is not connected")
|
||||||
|
result = client.publish(
|
||||||
|
MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||||
|
raw_payload,
|
||||||
|
qos=MQTT_N7_CONTROL_TRANSFER_QOS,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
if int(getattr(result, "rc", 0)) != 0:
|
||||||
|
raise CanGatewayError(
|
||||||
|
f"N7 control transfer MQTT publish failed with code {result.rc}"
|
||||||
|
)
|
||||||
|
is_published = getattr(result, "is_published", None)
|
||||||
|
broker_confirmed = bool(is_published()) if callable(is_published) else False
|
||||||
|
return {
|
||||||
|
"topic": MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||||
|
"qos": MQTT_N7_CONTROL_TRANSFER_QOS,
|
||||||
|
"retain": False,
|
||||||
|
"delivery_status": "published" if broker_confirmed else "queued",
|
||||||
|
"message_id": getattr(result, "mid", None),
|
||||||
|
}
|
||||||
|
|
||||||
def _receive_after(
|
def _receive_after(
|
||||||
self,
|
self,
|
||||||
cursor: int,
|
cursor: int,
|
||||||
@@ -1500,6 +1674,18 @@ class MqttCanTransport:
|
|||||||
"last_received_at": self._extension_control_last_received_at,
|
"last_received_at": self._extension_control_last_received_at,
|
||||||
"last_error": self._extension_control_last_error,
|
"last_error": self._extension_control_last_error,
|
||||||
}
|
}
|
||||||
|
info["mode_scene_ingress"] = {
|
||||||
|
"mode": "persistent_mqtt_subscription",
|
||||||
|
"topic": MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
|
"qos": MQTT_MODE_SCENE_INGRESS_QOS,
|
||||||
|
"handler_configured": self._mode_scene_ingress_handler is not None,
|
||||||
|
"received_count": self._mode_scene_ingress_received_count,
|
||||||
|
"dispatched_count": self._mode_scene_ingress_dispatched_count,
|
||||||
|
"rejected_count": self._mode_scene_ingress_rejected_count,
|
||||||
|
"retained_count": self._mode_scene_ingress_retained_count,
|
||||||
|
"last_received_at": self._mode_scene_ingress_last_received_at,
|
||||||
|
"last_error": self._mode_scene_ingress_last_error,
|
||||||
|
}
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -64,7 +65,16 @@ class SceneCatalogSource:
|
|||||||
|
|
||||||
class N7SceneHttpServer(ThreadingHTTPServer):
|
class N7SceneHttpServer(ThreadingHTTPServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
allow_reuse_address = True
|
allow_reuse_address = os.name != "nt"
|
||||||
|
|
||||||
|
def server_bind(self) -> None:
|
||||||
|
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||||
|
self.socket.setsockopt(
|
||||||
|
socket.SOL_SOCKET,
|
||||||
|
socket.SO_EXCLUSIVEADDRUSE,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
super().server_bind()
|
||||||
|
|
||||||
def __init__(self, address: tuple[str, int], source: SceneCatalogSource) -> None:
|
def __init__(self, address: tuple[str, int], source: SceneCatalogSource) -> None:
|
||||||
self.catalog_source = source
|
self.catalog_source = source
|
||||||
@@ -145,7 +155,16 @@ class N7SceneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
class N7SceneUdpServer(ThreadingUDPServer):
|
class N7SceneUdpServer(ThreadingUDPServer):
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
allow_reuse_address = True
|
allow_reuse_address = os.name != "nt"
|
||||||
|
|
||||||
|
def server_bind(self) -> None:
|
||||||
|
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||||
|
self.socket.setsockopt(
|
||||||
|
socket.SOL_SOCKET,
|
||||||
|
socket.SO_EXCLUSIVEADDRUSE,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
super().server_bind()
|
||||||
|
|
||||||
|
|
||||||
class N7SceneDiscoveryHandler(BaseRequestHandler):
|
class N7SceneDiscoveryHandler(BaseRequestHandler):
|
||||||
|
|||||||
@@ -358,6 +358,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
if path == "/api/assistant/capabilities":
|
if path == "/api/assistant/capabilities":
|
||||||
self._json(self._assistant_capability_catalog().snapshot())
|
self._json(self._assistant_capability_catalog().snapshot())
|
||||||
return
|
return
|
||||||
|
if path == "/api/mode-profiles/status":
|
||||||
|
self._json(self.service.mode_profile_sync_status())
|
||||||
|
return
|
||||||
if path == "/api/runtime":
|
if path == "/api/runtime":
|
||||||
self._json(self.service.runtime_info())
|
self._json(self.service.runtime_info())
|
||||||
return
|
return
|
||||||
@@ -1592,6 +1595,107 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
spec["status"] = str(result.get("status") or "blocked")
|
spec["status"] = str(result.get("status") or "blocked")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _exact_n7_mode_invocation(
|
||||||
|
self,
|
||||||
|
utterance: str,
|
||||||
|
vehicle_id: str | None,
|
||||||
|
) -> object | None:
|
||||||
|
"""Resolve a standalone spoken/text invocation of one N7-synced card."""
|
||||||
|
|
||||||
|
if not str(utterance).strip():
|
||||||
|
return None
|
||||||
|
registry = getattr(self.service, "mode_profiles", None)
|
||||||
|
match_text = getattr(registry, "match_text", None)
|
||||||
|
if not callable(match_text):
|
||||||
|
# Lightweight service doubles used by peripheral-only routes do
|
||||||
|
# not own a mode registry. They must keep their existing routing
|
||||||
|
# behaviour instead of failing before the peripheral parser runs.
|
||||||
|
return None
|
||||||
|
matches = [
|
||||||
|
profile
|
||||||
|
for profile in match_text(
|
||||||
|
utterance,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
)
|
||||||
|
if getattr(profile, "sync_origin", "") == "from_n7/scenes"
|
||||||
|
]
|
||||||
|
# ``ModeProfileRegistry.match_text`` applies the bounded, exact
|
||||||
|
# invocation grammar to N7-synchronised cards. Keeping that rule in
|
||||||
|
# one place prevents text, speech and card-click routes from drifting.
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
def _execute_n7_named_mode_request(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
common_arguments: dict[str, object],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
utterance = str(common_arguments.get("utterance") or "").strip()
|
||||||
|
vehicle_id = (
|
||||||
|
str(common_arguments["vehicle_id"])
|
||||||
|
if common_arguments.get("vehicle_id")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
profile = self._exact_n7_mode_invocation(utterance, vehicle_id)
|
||||||
|
if profile is None:
|
||||||
|
return None
|
||||||
|
execution_mode = (
|
||||||
|
"dry_run"
|
||||||
|
if path == "/api/requests/preview"
|
||||||
|
else str(common_arguments.get("execution_mode") or "dry_run")
|
||||||
|
)
|
||||||
|
conversation_id = (
|
||||||
|
str(common_arguments["conversation_id"])
|
||||||
|
if common_arguments.get("conversation_id")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
entries = self._saved_mode_infrared_entries(profile)
|
||||||
|
can_result = self.service.execute_mode_profile_card(
|
||||||
|
str(getattr(profile, "id")),
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
approval_token=(
|
||||||
|
str(common_arguments["approval_token"])
|
||||||
|
if common_arguments.get("approval_token")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
simulation_fault=str(
|
||||||
|
common_arguments.get("simulation_fault") or "normal"
|
||||||
|
),
|
||||||
|
hardware_client_id=(
|
||||||
|
str(common_arguments["hardware_client_id"])
|
||||||
|
if common_arguments.get("hardware_client_id")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
hardware_access_bypass=bool(
|
||||||
|
common_arguments.get("hardware_access_bypass", True)
|
||||||
|
),
|
||||||
|
control_transport=str(
|
||||||
|
common_arguments.get("control_transport") or "wired_can"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not entries:
|
||||||
|
can_result["utterance"] = utterance
|
||||||
|
return can_result
|
||||||
|
infrared_result = self._execute_local_peripheral_actions(
|
||||||
|
utterance,
|
||||||
|
entries,
|
||||||
|
provider="saved_mode_command_flow",
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
)
|
||||||
|
result = self._merge_can_and_peripheral_results(
|
||||||
|
utterance,
|
||||||
|
can_result,
|
||||||
|
infrared_result,
|
||||||
|
entries,
|
||||||
|
)
|
||||||
|
result["provider_used"] = "saved_mode_command_flow"
|
||||||
|
result["orchestration_route"] = "saved_mode_command_flow"
|
||||||
|
result["mode_profile"] = profile.public_dict()
|
||||||
|
self.service.persist_composed_result(result)
|
||||||
|
return result
|
||||||
|
|
||||||
def _execute_request_with_peripherals(
|
def _execute_request_with_peripherals(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -1626,6 +1730,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
**mode_arguments,
|
**mode_arguments,
|
||||||
_on_commands_validated=callback,
|
_on_commands_validated=callback,
|
||||||
)
|
)
|
||||||
|
n7_mode_result = self._execute_n7_named_mode_request(
|
||||||
|
path,
|
||||||
|
common_arguments,
|
||||||
|
)
|
||||||
|
if n7_mode_result is not None:
|
||||||
|
return n7_mode_result
|
||||||
catalog = self._peripheral_routing_catalog()
|
catalog = self._peripheral_routing_catalog()
|
||||||
nfc_snapshot = self.nfc.snapshot()
|
nfc_snapshot = self.nfc.snapshot()
|
||||||
partition = partition_active_peripheral_intent(
|
partition = partition_active_peripheral_intent(
|
||||||
@@ -4198,6 +4308,22 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class N7WebHttpServer(ThreadingHTTPServer):
|
||||||
|
"""Prevent multiple Windows processes from silently sharing one port."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = os.name != "nt"
|
||||||
|
|
||||||
|
def server_bind(self) -> None:
|
||||||
|
if os.name == "nt" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||||
|
self.socket.setsockopt(
|
||||||
|
socket.SOL_SOCKET,
|
||||||
|
socket.SO_EXCLUSIVEADDRUSE,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
super().server_bind()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Run the cabin orchestration demo")
|
parser = argparse.ArgumentParser(description="Run the cabin orchestration demo")
|
||||||
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
|
parser.add_argument("--host", default=os.getenv("HOST", "127.0.0.1"))
|
||||||
@@ -4221,7 +4347,7 @@ def main() -> None:
|
|||||||
args.scene_catalog,
|
args.scene_catalog,
|
||||||
server_id=args.scene_server_id,
|
server_id=args.scene_server_id,
|
||||||
)
|
)
|
||||||
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
|
server = N7WebHttpServer((args.host, args.port), SeatDemoHandler)
|
||||||
SeatDemoHandler.speech.start()
|
SeatDemoHandler.speech.start()
|
||||||
SeatDemoHandler.assistant.start(
|
SeatDemoHandler.assistant.start(
|
||||||
f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control"
|
f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control"
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||||
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
|
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
|
||||||
self.assertEqual(caught.exception.code, 401)
|
self.assertEqual(caught.exception.code, 401)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as sync_status_denied:
|
||||||
|
unauthenticated.open(
|
||||||
|
f"{self.base_url}/api/mode-profiles/status",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
self.assertEqual(sync_status_denied.exception.code, 401)
|
||||||
|
|
||||||
with self.assertRaises(urllib.error.HTTPError) as state_denied:
|
with self.assertRaises(urllib.error.HTTPError) as state_denied:
|
||||||
unauthenticated.open(
|
unauthenticated.open(
|
||||||
@@ -292,7 +298,9 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
self.assertGreaterEqual(catalog["scene_count"], 13)
|
self.assertGreaterEqual(catalog["scene_count"], 13)
|
||||||
self.assertEqual(catalog["scene_count"], len(catalog["scenes"]))
|
self.assertEqual(catalog["scene_count"], len(catalog["scenes"]))
|
||||||
office = next(scene for scene in catalog["scenes"] if scene["id"] == "office")
|
office = next(scene for scene in catalog["scenes"] if scene["id"] == "office")
|
||||||
self.assertIn(office["origin"], {"preset", "customized"})
|
self.assertIn(office["origin"], {"preset", "customized", "n7_mqtt"})
|
||||||
|
if office["origin"] == "n7_mqtt":
|
||||||
|
self.assertEqual(office["source_topic"], "from_n7/scenes")
|
||||||
self.assertTrue(office["actions"])
|
self.assertTrue(office["actions"])
|
||||||
self.assertIn("capability", office["actions"][0])
|
self.assertIn("capability", office["actions"][0])
|
||||||
self.assertNotIn("command_flow", json.dumps(catalog, ensure_ascii=False))
|
self.assertNotIn("command_flow", json.dumps(catalog, ensure_ascii=False))
|
||||||
@@ -306,6 +314,20 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
|
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
|
||||||
self.assertIn("GET", response.headers["Access-Control-Allow-Methods"])
|
self.assertIn("GET", response.headers["Access-Control-Allow-Methods"])
|
||||||
|
|
||||||
|
def test_mode_profile_sync_status_is_lightweight_and_authenticated(self) -> None:
|
||||||
|
with self.opener.open(
|
||||||
|
f"{self.base_url}/api/mode-profiles/status",
|
||||||
|
timeout=3,
|
||||||
|
) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertIn("catalog_revision", payload)
|
||||||
|
self.assertIn("scene_count", payload)
|
||||||
|
self.assertIn("mode_scene_ingress", payload)
|
||||||
|
self.assertIn("imported_count", payload["mode_scene_ingress"])
|
||||||
|
self.assertNotIn("modes", payload)
|
||||||
|
|
||||||
def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None:
|
def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None:
|
||||||
original_registry = self.service.mode_profiles
|
original_registry = self.service.mode_profiles
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
@@ -932,14 +954,14 @@ operations:
|
|||||||
vite_entry = (web_console_root / "src" / "main.js").read_text(
|
vite_entry = (web_console_root / "src" / "main.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-141", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-145-n7-dispatch", timeout=3) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
gesture_script = response.read().decode("utf-8")
|
gesture_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/console-v2.js?v=local-first-76", timeout=3
|
f"{self.base_url}/console-v2.js?v=local-first-77-performance", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
console_script = response.read().decode("utf-8")
|
console_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
@@ -959,7 +981,7 @@ operations:
|
|||||||
) as response:
|
) as response:
|
||||||
speech_worklet = response.read().decode("utf-8")
|
speech_worklet = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/self-extension.js?v=pet-settings-52", timeout=3
|
f"{self.base_url}/self-extension.js?v=pet-settings-53-performance", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
extension_script = response.read().decode("utf-8")
|
extension_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
@@ -971,7 +993,7 @@ operations:
|
|||||||
) as response:
|
) as response:
|
||||||
assistant_script = response.read().decode("utf-8")
|
assistant_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/energy-monitor.js?v=energy-monitor-4", timeout=3
|
f"{self.base_url}/energy-monitor.js?v=energy-monitor-5-performance", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
energy_script = response.read().decode("utf-8")
|
energy_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
@@ -1018,10 +1040,10 @@ operations:
|
|||||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", template)
|
self.assertIn("CMD 0x431 · FB 0x41A · CH1", template)
|
||||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
|
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
|
||||||
self.assertNotIn("透明车体 · 实体设备", template)
|
self.assertNotIn("透明车体 · 实体设备", template)
|
||||||
self.assertIn('src="/app.js?v=dual-seat-141"', html)
|
self.assertIn('src="/app.js?v=dual-seat-145-n7-dispatch"', html)
|
||||||
self.assertIn('import "../styles.css";', vite_entry)
|
self.assertIn('import "../styles.css";', vite_entry)
|
||||||
self.assertNotIn('class="vehicle-system-hud"', template)
|
self.assertNotIn('class="vehicle-system-hud"', template)
|
||||||
self.assertIn('src="/console-v2.js?v=local-first-76"', html)
|
self.assertIn('src="/console-v2.js?v=local-first-77-performance"', html)
|
||||||
self.assertIn('import "../console-v2.css";', vite_entry)
|
self.assertIn('import "../console-v2.css";', vite_entry)
|
||||||
self.assertIn('src="/speech-input.js?v=sensevoice-13"', html)
|
self.assertIn('src="/speech-input.js?v=sensevoice-13"', html)
|
||||||
self.assertIn('import "../speech-input.css";', vite_entry)
|
self.assertIn('import "../speech-input.css";', vite_entry)
|
||||||
@@ -1030,7 +1052,7 @@ operations:
|
|||||||
self.assertIn('<body class="console-v2">', html)
|
self.assertIn('<body class="console-v2">', html)
|
||||||
self.assertIn('id="v2BootScreen"', template)
|
self.assertIn('id="v2BootScreen"', template)
|
||||||
self.assertIn('正在加载车机控制与实时状态', template)
|
self.assertIn('正在加载车机控制与实时状态', template)
|
||||||
self.assertIn('src="/self-extension.js?v=pet-settings-52"', html)
|
self.assertIn('src="/self-extension.js?v=pet-settings-53-performance"', html)
|
||||||
self.assertIn('import "../self-extension.css";', vite_entry)
|
self.assertIn('import "../self-extension.css";', vite_entry)
|
||||||
self.assertNotIn("self-calibration", template)
|
self.assertNotIn("self-calibration", template)
|
||||||
self.assertNotIn("self-repair", template)
|
self.assertNotIn("self-repair", template)
|
||||||
@@ -1041,7 +1063,7 @@ operations:
|
|||||||
self.assertIn('import "../assistant-chat.css";', vite_entry)
|
self.assertIn('import "../assistant-chat.css";', vite_entry)
|
||||||
self.assertIn('src="/assistant-chat.js?v=codex-assistant-34"', html)
|
self.assertIn('src="/assistant-chat.js?v=codex-assistant-34"', html)
|
||||||
self.assertIn('import "../energy-monitor.css";', vite_entry)
|
self.assertIn('import "../energy-monitor.css";', vite_entry)
|
||||||
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
|
self.assertIn('src="/energy-monitor.js?v=energy-monitor-5-performance"', html)
|
||||||
self.assertIn('import "../codepen-glass-theme.css";', vite_entry)
|
self.assertIn('import "../codepen-glass-theme.css";', vite_entry)
|
||||||
self.assertEqual(extension_control_status["source_label"], "车端 MQTT")
|
self.assertEqual(extension_control_status["source_label"], "车端 MQTT")
|
||||||
self.assertIn("action_statuses", extension_control_status)
|
self.assertIn("action_statuses", extension_control_status)
|
||||||
@@ -1331,16 +1353,16 @@ operations:
|
|||||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"',
|
'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"',
|
||||||
service_worker,
|
service_worker,
|
||||||
)
|
)
|
||||||
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
|
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
|
||||||
self.assertIn('/console-v2.js?v=local-first-76', service_worker)
|
self.assertIn('/console-v2.js?v=local-first-77-performance', service_worker)
|
||||||
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
|
self.assertIn('/assistant-chat.css?v=codex-assistant-29', service_worker)
|
||||||
self.assertIn('/assistant-chat.js?v=codex-assistant-34', service_worker)
|
self.assertIn('/assistant-chat.js?v=codex-assistant-34', service_worker)
|
||||||
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
|
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
|
||||||
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
|
self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker)
|
||||||
self.assertIn('"/app.js?v=dual-seat-141"', service_worker)
|
self.assertIn('"/app.js?v=dual-seat-145-n7-dispatch"', service_worker)
|
||||||
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
|
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
|
||||||
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
|
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
|
||||||
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
|
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
|
||||||
@@ -1800,7 +1822,7 @@ operations:
|
|||||||
) as response:
|
) as response:
|
||||||
extension_script = response.read().decode("utf-8")
|
extension_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/app.js?v=dual-seat-141", timeout=3
|
f"{self.base_url}/app.js?v=dual-seat-145-n7-dispatch", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ from cognitive_agent.generated.can_semantic_protocol import (
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def vehicle_service() -> SeatDemoService:
|
def vehicle_service(tmp_path_factory: pytest.TempPathFactory) -> SeatDemoService:
|
||||||
return SeatDemoService()
|
service = SeatDemoService()
|
||||||
|
service.mode_profiles = type(service.mode_profiles)(
|
||||||
|
service.root / "config" / "modes",
|
||||||
|
user_root=tmp_path_factory.mktemp("assistant-capability-modes"),
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
def _catalog(
|
def _catalog(
|
||||||
@@ -269,7 +274,16 @@ def test_all_modes_are_discoverable_and_reference_callable_capabilities(
|
|||||||
"zero_gravity",
|
"zero_gravity",
|
||||||
}
|
}
|
||||||
assert all(item["assistant_callable"] is True for item in modes)
|
assert all(item["assistant_callable"] is True for item in modes)
|
||||||
assert all(set(item["operation_ids"]) <= callable_ids for item in modes)
|
assert all(
|
||||||
|
set(item["operation_ids"])
|
||||||
|
<= callable_ids | set(item.get("n7_transfer_operation_ids", []))
|
||||||
|
for item in modes
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
not item.get("n7_transfer_operation_ids")
|
||||||
|
or item.get("origin") == "n7_mqtt"
|
||||||
|
for item in modes
|
||||||
|
)
|
||||||
assert (
|
assert (
|
||||||
next(item for item in modes if item["id"] == "baby_nap")[
|
next(item for item in modes if item["id"] == "baby_nap")[
|
||||||
"all_operations_have_native_binding"
|
"all_operations_have_native_binding"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface():
|
|||||||
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
||||||
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
||||||
assert (
|
assert (
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"'
|
'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"'
|
||||||
in service_worker
|
in service_worker
|
||||||
)
|
)
|
||||||
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
|
|||||||
|
|
||||||
for asset in (
|
for asset in (
|
||||||
"/energy-monitor.css?v=energy-monitor-4",
|
"/energy-monitor.css?v=energy-monitor-4",
|
||||||
"/energy-monitor.js?v=energy-monitor-4",
|
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||||
):
|
):
|
||||||
assert f'"{asset}"' in service_worker
|
assert f'"{asset}"' in service_worker
|
||||||
assert 'import "../energy-monitor.css";' in vite_entry
|
assert 'import "../energy-monitor.css";' in vite_entry
|
||||||
assert "/energy-monitor.js?v=energy-monitor-4" in html
|
assert "/energy-monitor.js?v=energy-monitor-5-performance" in html
|
||||||
assert html.index("/assistant-chat.js?v=codex-assistant-34") < html.index(
|
assert html.index("/assistant-chat.js?v=codex-assistant-34") < html.index(
|
||||||
"/energy-monitor.js?v=energy-monitor-4"
|
"/energy-monitor.js?v=energy-monitor-5-performance"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"'
|
'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"'
|
||||||
in service_worker
|
in service_worker
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ def test_energy_monitor_samples_integrates_and_bounds_its_curves() -> None:
|
|||||||
assert 'id="energyMonitorChartLine"' in script
|
assert 'id="energyMonitorChartLine"' in script
|
||||||
assert 'id="energyMonitorChartArea"' in script
|
assert 'id="energyMonitorChartArea"' in script
|
||||||
assert "polylinePoints(values, maximum" in script
|
assert "polylinePoints(values, maximum" in script
|
||||||
assert 'window.setInterval(() => sampleNow(), SAMPLE_INTERVAL_MS)' in script
|
assert '() => sampleNow({ render: monitor.open })' in script
|
||||||
assert 'ingestPowerTelemetry(event.detail || {}, { sample: false })' in script
|
assert 'ingestPowerTelemetry(event.detail || {}, { sample: false })' in script
|
||||||
assert 'ingestPowerTelemetry(payload, { sample: false })' in script
|
assert 'ingestPowerTelemetry(payload, { sample: false })' in script
|
||||||
assert 'new CustomEvent("cognitive-agent:energy-snapshot"' in script
|
assert 'new CustomEvent("cognitive-agent:energy-snapshot"' in script
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WEB = ROOT / "apps" / "web-console"
|
||||||
|
|
||||||
|
|
||||||
|
def source(name: str) -> str:
|
||||||
|
return (WEB / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def section(content: str, start: str, end: str) -> str:
|
||||||
|
start_index = content.index(start)
|
||||||
|
end_index = content.index(end, start_index)
|
||||||
|
return content[start_index:end_index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehicle_twin_renders_on_demand_and_throttles_hotspot_layout() -> None:
|
||||||
|
app = source("app.js")
|
||||||
|
animation = section(app, " function animate(timestamp = 0) {", " setTwinSelection(")
|
||||||
|
|
||||||
|
assert "function requestRender({ hotspots = true, settleFrames = 36 } = {})" in app
|
||||||
|
assert "if (twinNeedsAnimation(twin)) queueTwinFrame();" in animation
|
||||||
|
assert "window.requestAnimationFrame(animate)" not in animation
|
||||||
|
assert "document.visibilityState !== \"visible\"" in app
|
||||||
|
assert "new IntersectionObserver" in app
|
||||||
|
assert "timestamp - twin.hotspotLastLayoutAt >= 100" in animation
|
||||||
|
assert "hotspotMetrics: new Map()" in app
|
||||||
|
assert "label?.offsetWidth" in app
|
||||||
|
assert "renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5))" in app
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_vehicle_state_skips_dom_and_device_panel_rebuilds() -> None:
|
||||||
|
app = source("app.js")
|
||||||
|
console = source("console-v2.js")
|
||||||
|
seat_render = section(app, "function renderSeatState(actuatorState) {", "\nfunction renderDomainState")
|
||||||
|
|
||||||
|
assert "vehicleRenderFingerprint(mergedActuatorState)" in seat_render
|
||||||
|
assert "renderFingerprint === state.seatRenderFingerprint" in seat_render
|
||||||
|
assert "resetSeatState();" not in seat_render
|
||||||
|
assert 'new CustomEvent("cognitive-agent:vehicle-state-rendered"' in app
|
||||||
|
assert '"cognitive-agent:vehicle-state-rendered"' in console
|
||||||
|
assert "observe(cabinBand" not in console
|
||||||
|
assert "observe(stateBand" not in console
|
||||||
|
assert 'if (!panelVisible("devices"))' in console
|
||||||
|
assert "renderSyncedDevices({ force: true })" in console
|
||||||
|
|
||||||
|
|
||||||
|
def test_nfc_polling_keeps_its_frequency_without_rendering_hidden_surfaces() -> None:
|
||||||
|
extension = source("self-extension.js")
|
||||||
|
|
||||||
|
assert "const NFC_POLL_INTERVAL_MS = 700" in extension
|
||||||
|
assert "currentNfcDialogFingerprint()" in extension
|
||||||
|
assert "if (!force && !dialog.open)" in extension
|
||||||
|
assert "renderFingerprint === nfcDialogFingerprint" in extension
|
||||||
|
assert 'panel?.classList.contains("is-collapsed")' in extension
|
||||||
|
assert "renderFingerprint === unifiedExtensionFingerprint" in extension
|
||||||
|
assert '"cognitive-agent:side-panel-visibility"' in extension
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_energy_monitor_samples_without_repainting() -> None:
|
||||||
|
monitor = source("energy-monitor.js")
|
||||||
|
|
||||||
|
assert "SAMPLE_INTERVAL_MS = 1000" in monitor
|
||||||
|
assert "() => sampleNow({ render: monitor.open })" in monitor
|
||||||
|
assert "if (next)" in monitor
|
||||||
|
assert "renderMonitor();" in section(monitor, " function setOpen(open) {", "\n function selectSeries")
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_frequency_fields_and_hotspots_do_not_add_compositor_blur() -> None:
|
||||||
|
theme = source("codepen-glass-theme.css")
|
||||||
|
field_rule = section(
|
||||||
|
theme,
|
||||||
|
"body.console-v2 :is(input, textarea, select),",
|
||||||
|
"body.console-v2 :is(input, textarea, select):focus",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "backdrop-filter" not in field_rule
|
||||||
|
assert "body.console-v2 .v2-twin-device-popover {" in theme
|
||||||
|
shared_hotspot_rule = section(
|
||||||
|
theme,
|
||||||
|
"body.console-v2 .v2-twin-hotspot-label,",
|
||||||
|
"body.console-v2 .v2-twin-device-popover {\n -webkit-backdrop-filter",
|
||||||
|
)
|
||||||
|
assert "backdrop-filter" not in shared_hotspot_rule
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_assets_have_a_new_cache_generation() -> None:
|
||||||
|
html = source("index.html")
|
||||||
|
worker = source("service-worker.js")
|
||||||
|
|
||||||
|
for asset in (
|
||||||
|
"/app.js?v=dual-seat-145-n7-dispatch",
|
||||||
|
"/console-v2.js?v=local-first-77-performance",
|
||||||
|
"/self-extension.js?v=pet-settings-53-performance",
|
||||||
|
"/energy-monitor.js?v=energy-monitor-5-performance",
|
||||||
|
):
|
||||||
|
assert asset in html
|
||||||
|
assert f'"{asset}"' in worker
|
||||||
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' in worker
|
||||||
|
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
|
||||||
@@ -47,12 +47,12 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
|
|||||||
('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"),
|
('import "../console-v2.css"', "/console-v2.css?v=delivery-state-69"),
|
||||||
('import "../self-extension.css"', "/self-extension.css?v=pet-settings-52"),
|
('import "../self-extension.css"', "/self-extension.css?v=pet-settings-52"),
|
||||||
('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"),
|
('import "../speech-input.css"', "/speech-input.css?v=sensevoice-7"),
|
||||||
('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-5"),
|
('import "../codepen-glass-theme.css"', "/codepen-glass-theme.css?v=creative-cloud-6-performance"),
|
||||||
):
|
):
|
||||||
assert source_asset in main
|
assert source_asset in main
|
||||||
assert f'"{cached_asset}"' in service_worker
|
assert f'"{cached_asset}"' in service_worker
|
||||||
assert 'import "./styles/design-system.css"' in main
|
assert 'import "./styles/design-system.css"' in main
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||||
|
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_repository_mode_cards_preserve_every_operation_in_dry_run(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Every visible preset must reach a validated executable configuration."""
|
||||||
|
|
||||||
|
service = SeatDemoService(REPOSITORY_ROOT)
|
||||||
|
service.mode_profiles = ModeProfileRegistry(
|
||||||
|
REPOSITORY_ROOT / "config" / "modes",
|
||||||
|
user_root=tmp_path / "user-modes",
|
||||||
|
)
|
||||||
|
service.artifact_root = tmp_path / "runs"
|
||||||
|
vehicle_id = "denza_n7"
|
||||||
|
failures: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
for card in service.mode_profiles.public_modes(vehicle_id):
|
||||||
|
profile = service.mode_profiles.get_profile(str(card["id"]), vehicle_id)
|
||||||
|
result = service.process(
|
||||||
|
profile.instruction or f"进入{profile.name}",
|
||||||
|
provider="local",
|
||||||
|
conversation_id=f"conv-mode-integrity-{profile.id.replace('_', '-')}",
|
||||||
|
execution_mode="dry_run",
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
control_transport="wireless_mqtt",
|
||||||
|
)
|
||||||
|
compiled_operations = [
|
||||||
|
operation
|
||||||
|
for config in result.get("function_configs", {}).values()
|
||||||
|
for operation in config.get("spec", {}).get("operations", [])
|
||||||
|
]
|
||||||
|
validation_errors = [
|
||||||
|
issue
|
||||||
|
for issue in result.get("validation", [])
|
||||||
|
if issue.get("severity") == "error"
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
result.get("status") != "succeeded"
|
||||||
|
or validation_errors
|
||||||
|
or result.get("deferred_requests")
|
||||||
|
or len(compiled_operations) != len(profile.operations)
|
||||||
|
):
|
||||||
|
failures.append(
|
||||||
|
{
|
||||||
|
"id": profile.id,
|
||||||
|
"expected_operations": len(profile.operations),
|
||||||
|
"compiled_operations": len(compiled_operations),
|
||||||
|
"status": result.get("status"),
|
||||||
|
"deferred_requests": result.get("deferred_requests"),
|
||||||
|
"validation_errors": validation_errors,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not failures, json.dumps(failures, ensure_ascii=False, indent=2)
|
||||||
@@ -310,6 +310,11 @@ const context = {
|
|||||||
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
|
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
|
||||||
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
||||||
showToast(message) { calls.push(["toast", message]); },
|
showToast(message) { calls.push(["toast", message]); },
|
||||||
|
hasExecutableModeCommandFlow(mode) {
|
||||||
|
const flow = mode?.command_flow;
|
||||||
|
return Boolean(flow?.source_request_id)
|
||||||
|
|| Boolean(flow?.plans && Object.keys(flow.plans).length);
|
||||||
|
},
|
||||||
executeSavedModeProfile(mode, options) {
|
executeSavedModeProfile(mode, options) {
|
||||||
calls.push(["execute", mode.id, options.command]);
|
calls.push(["execute", mode.id, options.command]);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
@@ -346,6 +351,42 @@ console.log(JSON.stringify({
|
|||||||
assert "executeSavedModeProfile" in snippet
|
assert "executeSavedModeProfile" in snippet
|
||||||
|
|
||||||
|
|
||||||
|
def test_n7_mode_command_flow_is_directly_executable_without_source_request_id() -> None:
|
||||||
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
|
start = app.index("function hasExecutableModeCommandFlow")
|
||||||
|
end = app.index("\n\nasync function executeSavedModeProfile", start)
|
||||||
|
helper = app[start:end]
|
||||||
|
script = helper + r"""
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
imported: hasExecutableModeCommandFlow({
|
||||||
|
id: "cinema",
|
||||||
|
command_flow: {
|
||||||
|
source_topic: "from_n7/scenes",
|
||||||
|
plans: { from_n7_scene: { spec: { commands: [] } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
generated: hasExecutableModeCommandFlow({
|
||||||
|
id: "reading-user",
|
||||||
|
command_flow: { source_request_id: "req-reading" },
|
||||||
|
}),
|
||||||
|
missing: hasExecutableModeCommandFlow({ id: "office" }),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(
|
||||||
|
[node_runtime(), "-e", script],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"imported": True,
|
||||||
|
"generated": True,
|
||||||
|
"missing": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mode_card_without_saved_flow_falls_back_to_assistant_submission() -> None:
|
def test_mode_card_without_saved_flow_falls_back_to_assistant_submission() -> None:
|
||||||
app = (WEB / "app.js").read_text(encoding="utf-8")
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
||||||
start = app.index("function applyModeCommand")
|
start = app.index("function applyModeCommand")
|
||||||
@@ -378,6 +419,11 @@ const context = {
|
|||||||
setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); },
|
setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); },
|
||||||
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
||||||
showToast(message) { calls.push(["toast", message]); },
|
showToast(message) { calls.push(["toast", message]); },
|
||||||
|
hasExecutableModeCommandFlow(mode) {
|
||||||
|
const flow = mode?.command_flow;
|
||||||
|
return Boolean(flow?.source_request_id)
|
||||||
|
|| Boolean(flow?.plans && Object.keys(flow.plans).length);
|
||||||
|
},
|
||||||
$(selector) {
|
$(selector) {
|
||||||
return { "#utterance": input, "#requestForm": form, "#runButton": runButton }[selector];
|
return { "#utterance": input, "#requestForm": form, "#runButton": runButton }[selector];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cognitive_agent.application.assistant_capabilities import (
|
||||||
|
AssistantCapabilityCatalog,
|
||||||
|
)
|
||||||
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
||||||
|
from cognitive_agent.application.mode_scene_ingress import (
|
||||||
|
ModeSceneIngressConsumer,
|
||||||
|
ModeSceneIngressError,
|
||||||
|
parse_mode_scene_ingress_payload,
|
||||||
|
)
|
||||||
|
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
||||||
|
|
||||||
|
|
||||||
|
def scene_card(*, temperature: int = 24) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": "n7-reading",
|
||||||
|
"label": "N7 阅读模式",
|
||||||
|
"name": "N7 阅读模式",
|
||||||
|
"background_time": "evening",
|
||||||
|
"description": "由车端同步的阅读场景。",
|
||||||
|
"icon": "book-open",
|
||||||
|
"aliases": ["N7 阅读模式", "车端阅读"],
|
||||||
|
"origin": "user",
|
||||||
|
"deletable": True,
|
||||||
|
"behavior": "static",
|
||||||
|
"domains": ["climate"],
|
||||||
|
"operation_count": 1,
|
||||||
|
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||||
|
"profile_revision": f"remote-{temperature}",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "climate_temperature",
|
||||||
|
"at_ms": 250,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_temperature",
|
||||||
|
"target_scope": "passenger",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"temperature_c": temperature},
|
||||||
|
"zone": "passenger",
|
||||||
|
"celsius": temperature,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cinema_scene_card() -> dict[str, object]:
|
||||||
|
"""Representative N7 card containing local and head-unit-only actions."""
|
||||||
|
|
||||||
|
actions: list[dict[str, object]] = [
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"type": "window_position",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "window",
|
||||||
|
"capability": "window.set_position",
|
||||||
|
"target_scope": target,
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"position_pct": 0},
|
||||||
|
}
|
||||||
|
for target in ("front_left", "front_right", "rear_left", "rear_right")
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"type": "roof_shade_position",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "roof",
|
||||||
|
"capability": "roof.set_shade_position",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"position_pct": 0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "seat_preset",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "local_vehicle",
|
||||||
|
"capability": "local_scene.seat_preset",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"targets_pulse": [246, 112, 405, 342]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "seat_ventilation",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "seat",
|
||||||
|
"capability": "seat.set_ventilation",
|
||||||
|
"target_scope": "passenger",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"state": "off"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "climate_power",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_power",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"state": "on"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "climate_fan",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_fan_level",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"level": 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "climate_outlet_mode",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_outlet_mode",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"mode": "face"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "climate_cycle_mode",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_cycle_mode",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"mode": "internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "climate_control_mode",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_control_mode",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"mode": "manual"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "climate_temperature",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "climate",
|
||||||
|
"capability": "climate.set_temperature",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"temperature_c": 25},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "interior_light_reading_power",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "interior_light",
|
||||||
|
"capability": "interior_light.set_reading_power",
|
||||||
|
"target_scope": "all",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"state": "off"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ambient_light_power",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "ambient_light",
|
||||||
|
"capability": "ambient_light.set_power",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"state": "on"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ambient_light_color",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "ambient_light",
|
||||||
|
"capability": "ambient_light.set_color_index",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"color_index": 36},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ambient_light_brightness",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "ambient_light",
|
||||||
|
"capability": "ambient_light.set_brightness_level",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"brightness_level": 4},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": "cinema",
|
||||||
|
"label": "Cinema mode",
|
||||||
|
"name": "Cinema mode",
|
||||||
|
"background_time": "night",
|
||||||
|
"description": "Representative N7 mixed-domain scene.",
|
||||||
|
"icon": "bookmark-plus",
|
||||||
|
"aliases": ["Cinema mode"],
|
||||||
|
"origin": "headunit",
|
||||||
|
"deletable": True,
|
||||||
|
"behavior": "static",
|
||||||
|
"domains": list(dict.fromkeys(str(item["domain"]) for item in actions)),
|
||||||
|
"operation_count": len(actions),
|
||||||
|
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||||
|
"actions": actions,
|
||||||
|
"profile_revision": "cinema-test-revision",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until(predicate, timeout: float = 1.5) -> bool:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return bool(predicate())
|
||||||
|
|
||||||
|
|
||||||
|
def configured_service(tmp_path: Path) -> SeatDemoService:
|
||||||
|
service = SeatDemoService(REPOSITORY_ROOT)
|
||||||
|
service.mode_profiles = ModeProfileRegistry(
|
||||||
|
tmp_path / "presets",
|
||||||
|
user_root=tmp_path / "profiles",
|
||||||
|
)
|
||||||
|
service.artifact_root = tmp_path / "runs"
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_uses_exact_single_card_contract_and_rejects_duplicate_keys() -> None:
|
||||||
|
scene = scene_card()
|
||||||
|
parsed = parse_mode_scene_ingress_payload(
|
||||||
|
json.dumps(scene, ensure_ascii=False).encode("utf-8")
|
||||||
|
)
|
||||||
|
assert parsed == scene
|
||||||
|
|
||||||
|
duplicate = (
|
||||||
|
'{"id":"one","id":"two","label":"x","name":"x",'
|
||||||
|
'"background_time":"noon","description":"",'
|
||||||
|
'"icon":"x","aliases":[],"origin":"user",'
|
||||||
|
'"deletable":true,"behavior":"static","domains":["x"],'
|
||||||
|
'"operation_count":1,"scope":{},"profile_revision":"x",'
|
||||||
|
'"actions":[]}'
|
||||||
|
)
|
||||||
|
with pytest.raises(ModeSceneIngressError, match="duplicate key"):
|
||||||
|
parse_mode_scene_ingress_payload(duplicate)
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_identical_frames_are_imported_once() -> None:
|
||||||
|
imports: list[dict[str, object]] = []
|
||||||
|
consumer = ModeSceneIngressConsumer(
|
||||||
|
lambda scene: imports.append(dict(scene)) or {"status": "imported"},
|
||||||
|
deduplication_window_s=1.0,
|
||||||
|
)
|
||||||
|
payload = json.dumps(scene_card(), ensure_ascii=False).encode("utf-8")
|
||||||
|
consumer.start()
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
consumer.submit(payload)
|
||||||
|
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 3)
|
||||||
|
assert imports == [scene_card()]
|
||||||
|
status = consumer.snapshot()
|
||||||
|
assert status["imported_count"] == 1
|
||||||
|
assert status["duplicate_count"] == 2
|
||||||
|
assert status["rejected_count"] == 0
|
||||||
|
finally:
|
||||||
|
consumer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_import_does_not_suppress_identical_retry() -> None:
|
||||||
|
importer = MagicMock(
|
||||||
|
side_effect=[
|
||||||
|
RuntimeError("temporary persistence failure"),
|
||||||
|
{"status": "imported"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
consumer = ModeSceneIngressConsumer(
|
||||||
|
importer,
|
||||||
|
deduplication_window_s=1.0,
|
||||||
|
)
|
||||||
|
payload = json.dumps(scene_card(), ensure_ascii=False).encode("utf-8")
|
||||||
|
consumer.start()
|
||||||
|
try:
|
||||||
|
consumer.submit(payload)
|
||||||
|
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 1)
|
||||||
|
first_status = consumer.snapshot()
|
||||||
|
assert first_status["rejected_count"] == 1
|
||||||
|
assert first_status["imported_count"] == 0
|
||||||
|
|
||||||
|
consumer.submit(payload)
|
||||||
|
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 2)
|
||||||
|
retry_status = consumer.snapshot()
|
||||||
|
assert retry_status["rejected_count"] == 1
|
||||||
|
assert retry_status["imported_count"] == 1
|
||||||
|
assert retry_status["duplicate_count"] == 0
|
||||||
|
assert importer.call_count == 2
|
||||||
|
|
||||||
|
consumer.submit(payload)
|
||||||
|
assert wait_until(lambda: consumer.snapshot()["processed_count"] == 3)
|
||||||
|
final_status = consumer.snapshot()
|
||||||
|
assert final_status["duplicate_count"] == 1
|
||||||
|
assert importer.call_count == 2
|
||||||
|
finally:
|
||||||
|
consumer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_upserts_yaml_refreshes_catalog_and_does_not_echo(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
service.configure_mode_scene_catalog(tmp_path / "scenes.json")
|
||||||
|
outbound = MagicMock()
|
||||||
|
service._vehicle_mqtt_transport.publish_mode_scene = outbound
|
||||||
|
|
||||||
|
first = service.import_n7_mode_scene(scene_card(temperature=24))
|
||||||
|
updated = service.import_n7_mode_scene(scene_card(temperature=26))
|
||||||
|
|
||||||
|
assert first["scene_id"] == "n7-reading"
|
||||||
|
assert updated["scene_id"] == "n7-reading"
|
||||||
|
profiles = service.mode_profiles.public_modes("denza_n7")
|
||||||
|
assert len(profiles) == 1
|
||||||
|
profile = profiles[0]
|
||||||
|
assert profile["origin"] == "n7_mqtt"
|
||||||
|
assert profile["source_topic"] == "from_n7/scenes"
|
||||||
|
assert profile["upstream_origin"] == "user"
|
||||||
|
assert profile["operations"][0]["parameters"] == {"temperature_c": 26}
|
||||||
|
|
||||||
|
catalog = service.mode_scene_catalog("denza_n7")
|
||||||
|
assert catalog["scene_count"] == 1
|
||||||
|
card = catalog["scenes"][0]
|
||||||
|
assert card["origin"] == "n7_mqtt"
|
||||||
|
assert card["source_topic"] == "from_n7/scenes"
|
||||||
|
assert card["actions"] == scene_card(temperature=26)["actions"]
|
||||||
|
assert card["actions"][0]["at_ms"] == 250
|
||||||
|
assert outbound.call_count == 0
|
||||||
|
|
||||||
|
yaml_path = Path(service.mode_profiles.get_profile("n7-reading", "denza_n7").source)
|
||||||
|
yaml_text = yaml_path.read_text(encoding="utf-8")
|
||||||
|
assert "sync_origin: from_n7/scenes" in yaml_text
|
||||||
|
assert "upstream_origin: user" in yaml_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_n7_action_is_forwarded_once_only_during_hardware_execution(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
unknown = scene_card()
|
||||||
|
unknown.update(
|
||||||
|
domains=["media"],
|
||||||
|
operation_count=1,
|
||||||
|
actions=[
|
||||||
|
{
|
||||||
|
"type": "media_play_favorite",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "media",
|
||||||
|
"capability": "media.play_favorite",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "play",
|
||||||
|
"parameters": {"playlist": "focus"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
service.import_n7_mode_scene(unknown)
|
||||||
|
publisher = MagicMock(
|
||||||
|
return_value={
|
||||||
|
"topic": "transto_n7/ctl",
|
||||||
|
"qos": 1,
|
||||||
|
"retain": False,
|
||||||
|
"delivery_status": "published",
|
||||||
|
"message_id": 7,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
|
||||||
|
|
||||||
|
dry_run = service.execute_mode_profile_card(
|
||||||
|
"n7-reading",
|
||||||
|
execution_mode="dry_run",
|
||||||
|
control_transport="wireless_mqtt",
|
||||||
|
)
|
||||||
|
assert dry_run["status"] == "capability_unavailable"
|
||||||
|
publisher.assert_not_called()
|
||||||
|
|
||||||
|
result = service.execute_mode_profile_card(
|
||||||
|
"n7-reading",
|
||||||
|
execution_mode="hardware",
|
||||||
|
control_transport="wireless_mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "capability_unavailable"
|
||||||
|
assert result["n7_control_transfer"]["state"] == "published"
|
||||||
|
publisher.assert_called_once()
|
||||||
|
payload = publisher.call_args.args[0]
|
||||||
|
assert payload == {
|
||||||
|
"scene_id": "n7-reading",
|
||||||
|
"scene_name": "N7 阅读模式",
|
||||||
|
"request_id": result["request_id"],
|
||||||
|
"operation_count": 1,
|
||||||
|
"actions": deepcopy(unknown["actions"]),
|
||||||
|
}
|
||||||
|
assert result["deferred_requests"][0]["transport_submitted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_n7_scene_compiles_every_local_action_and_preserves_transfer(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
scene = cinema_scene_card()
|
||||||
|
service.import_n7_mode_scene(scene)
|
||||||
|
|
||||||
|
result = service.execute_mode_profile_card(
|
||||||
|
"cinema",
|
||||||
|
execution_mode="dry_run",
|
||||||
|
control_transport="wireless_mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "partially_succeeded"
|
||||||
|
assert not [
|
||||||
|
issue
|
||||||
|
for issue in result["validation"]
|
||||||
|
if issue.get("severity") == "error"
|
||||||
|
]
|
||||||
|
compiled_operations = [
|
||||||
|
operation
|
||||||
|
for config in result["function_configs"].values()
|
||||||
|
for operation in config["spec"]["operations"]
|
||||||
|
]
|
||||||
|
assert len(compiled_operations) == 16
|
||||||
|
assert all(
|
||||||
|
operation.get("parse_source") == "saved_mode_command_flow"
|
||||||
|
for operation in compiled_operations
|
||||||
|
)
|
||||||
|
assert {
|
||||||
|
operation["target"]["zone"]
|
||||||
|
for operation in compiled_operations
|
||||||
|
if operation["capability"] == "window.set_position"
|
||||||
|
} == {"front_left", "front_right", "rear_left", "rear_right"}
|
||||||
|
transferred = scene["actions"][5]
|
||||||
|
assert result["n7_transfer_candidates"] == [transferred]
|
||||||
|
assert result["deferred_requests"] == [
|
||||||
|
{
|
||||||
|
"domain": "local_vehicle",
|
||||||
|
"capability": "local_scene.seat_preset",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"desired_change": "set",
|
||||||
|
"parameters": {"targets_pulse": [246, 112, 405, 342]},
|
||||||
|
"reason": "当前 PC 未注册该能力,将由 N7 端继续处理。",
|
||||||
|
"execution_backend": "n7_vehicle_transfer",
|
||||||
|
"rejection_code": "PC_CAPABILITY_UNAVAILABLE",
|
||||||
|
"transport_submitted": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert len(compiled_operations) + len(result["n7_transfer_candidates"]) == len(
|
||||||
|
scene["actions"]
|
||||||
|
)
|
||||||
|
|
||||||
|
text_result = service.process(
|
||||||
|
"进入Cinema mode模式",
|
||||||
|
provider="local",
|
||||||
|
conversation_id="conv-n7-cinema-text",
|
||||||
|
execution_mode="dry_run",
|
||||||
|
vehicle_id="denza_n7",
|
||||||
|
control_transport="wireless_mqtt",
|
||||||
|
)
|
||||||
|
assert text_result["status"] == "partially_succeeded"
|
||||||
|
assert text_result["mode_profile"] is None
|
||||||
|
assert [item["id"] for item in text_result["matched_modes"]] == ["cinema"]
|
||||||
|
assert len(text_result["n7_transfer_candidates"]) == 1
|
||||||
|
assert service.mode_profiles.match_text(
|
||||||
|
"氛围灯设置为Cinema mode模式",
|
||||||
|
vehicle_id="denza_n7",
|
||||||
|
) == []
|
||||||
|
|
||||||
|
catalog_mode = next(
|
||||||
|
mode
|
||||||
|
for mode in AssistantCapabilityCatalog(service).snapshot("denza_n7")["modes"]
|
||||||
|
if mode["id"] == "cinema"
|
||||||
|
)
|
||||||
|
assert catalog_mode["assistant_callable"] is True
|
||||||
|
assert catalog_mode["n7_transfer_operation_ids"] == [
|
||||||
|
"local_scene.seat_preset"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("domain", "target_scope", "parameters"),
|
||||||
|
[
|
||||||
|
("media", "passenger", {"temperature_c": 24}),
|
||||||
|
("climate", "unsupported_zone", {"temperature_c": 24}),
|
||||||
|
("climate", "passenger", {"unsupported_parameter": 24}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_registered_but_invalid_action_is_not_forwarded_to_n7(
|
||||||
|
tmp_path: Path,
|
||||||
|
domain: str,
|
||||||
|
target_scope: str,
|
||||||
|
parameters: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
invalid = scene_card()
|
||||||
|
invalid["actions"] = [
|
||||||
|
{
|
||||||
|
"type": "climate_temperature",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": domain,
|
||||||
|
"capability": "climate.set_temperature",
|
||||||
|
"target_scope": target_scope,
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
service.import_n7_mode_scene(invalid)
|
||||||
|
profile = service.get_mode_profile_card("n7-reading", "denza_n7")
|
||||||
|
|
||||||
|
local_requests, transfer_actions = service._mode_requests_and_n7_transfers(profile)
|
||||||
|
|
||||||
|
assert len(local_requests) == 1
|
||||||
|
assert transfer_actions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_marks_n7_synchronized_cards() -> None:
|
||||||
|
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
styles = (
|
||||||
|
REPOSITORY_ROOT / "apps" / "web-console" / "styles.css"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert 'mode?.origin === "n7_mqtt"' in script
|
||||||
|
assert 'sourceBadge.textContent = "N7 同步"' in script
|
||||||
|
assert ".mode-gallery-source-badge" in styles
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_profile_sync_status_exposes_catalog_and_ingress_counters(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service = configured_service(tmp_path)
|
||||||
|
service.configure_mode_scene_catalog(tmp_path / "scenes.json")
|
||||||
|
|
||||||
|
status = service.mode_profile_sync_status()
|
||||||
|
|
||||||
|
assert len(status["catalog_revision"]) == 16
|
||||||
|
assert status["scene_count"] == 0
|
||||||
|
assert status["catalog_state"] == "ready"
|
||||||
|
assert status["mode_scene_ingress"]["imported_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_polls_lightweight_status_and_refreshes_on_n7_import() -> None:
|
||||||
|
script = (REPOSITORY_ROOT / "apps" / "web-console" / "app.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'apiRequest("/api/mode-profiles/status"' in script
|
||||||
|
assert "MODE_PROFILE_SYNC_POLL_INTERVAL_MS = 1000" in script
|
||||||
|
assert 'document.visibilityState === "hidden"' in script
|
||||||
|
assert "if (catalogChanged || (imported && !sceneAlreadyVisible))" in script
|
||||||
|
assert "await loadRuntime()" in script
|
||||||
|
assert "revealModeProfile(sceneId)" in script
|
||||||
|
assert "已从 N7 同步模式卡片" in script
|
||||||
|
assert "N7 模式卡片导入失败" in script
|
||||||
@@ -19,11 +19,14 @@ from cognitive_agent.infrastructure.can.mqtt_transport import (
|
|||||||
MQTT_CAN_SCHEMA,
|
MQTT_CAN_SCHEMA,
|
||||||
MQTT_EXTENSION_CONTROL_TOPIC,
|
MQTT_EXTENSION_CONTROL_TOPIC,
|
||||||
MQTT_EXTENSION_TOY_TOPIC,
|
MQTT_EXTENSION_TOY_TOPIC,
|
||||||
|
MQTT_MODE_SCENE_INGRESS_TOPIC,
|
||||||
MQTT_MODE_SCENE_TOPIC,
|
MQTT_MODE_SCENE_TOPIC,
|
||||||
|
MQTT_N7_CONTROL_TRANSFER_TOPIC,
|
||||||
MQTT_PET_TOPIC,
|
MQTT_PET_TOPIC,
|
||||||
MqttCanTransport,
|
MqttCanTransport,
|
||||||
mqtt_extension_toy_payload,
|
mqtt_extension_toy_payload,
|
||||||
mqtt_mode_scene_payload,
|
mqtt_mode_scene_payload,
|
||||||
|
mqtt_n7_control_transfer_payload,
|
||||||
mqtt_pet_payload,
|
mqtt_pet_payload,
|
||||||
parse_mqtt_can_payload,
|
parse_mqtt_can_payload,
|
||||||
)
|
)
|
||||||
@@ -739,6 +742,82 @@ class MqttCanTransportTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_n7_mode_scene_ingress_dispatches_raw_bytes_and_rejects_retained(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
received: list[bytes] = []
|
||||||
|
self.transport.set_mode_scene_ingress_handler(received.append)
|
||||||
|
scene = {
|
||||||
|
"id": "n7-reading",
|
||||||
|
"label": "N7 阅读模式",
|
||||||
|
"name": "N7 阅读模式",
|
||||||
|
"background_time": "noon",
|
||||||
|
"description": "车端同步模式。",
|
||||||
|
"icon": "book-open",
|
||||||
|
"aliases": ["N7 阅读模式"],
|
||||||
|
"origin": "user",
|
||||||
|
"deletable": True,
|
||||||
|
"behavior": "static",
|
||||||
|
"domains": ["interior_light"],
|
||||||
|
"operation_count": 1,
|
||||||
|
"scope": {"common": False, "vehicle_ids": ["denza_n7"]},
|
||||||
|
"profile_revision": "remote-revision",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "interior_light_reading_power",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "interior_light",
|
||||||
|
"capability": "interior_light.set_reading_power",
|
||||||
|
"target_scope": "all",
|
||||||
|
"operation": "set",
|
||||||
|
"parameters": {"state": "on"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client.emit(MQTT_MODE_SCENE_INGRESS_TOPIC, scene)
|
||||||
|
self.client.emit(MQTT_MODE_SCENE_INGRESS_TOPIC, scene, retain=True)
|
||||||
|
|
||||||
|
self.assertEqual(len(received), 1)
|
||||||
|
self.assertEqual(json.loads(received[0]), scene)
|
||||||
|
status = self.transport.public_info()["mode_scene_ingress"]
|
||||||
|
self.assertTrue(status["handler_configured"])
|
||||||
|
self.assertEqual(status["received_count"], 2)
|
||||||
|
self.assertEqual(status["dispatched_count"], 1)
|
||||||
|
self.assertEqual(status["rejected_count"], 1)
|
||||||
|
self.assertEqual(status["retained_count"], 1)
|
||||||
|
|
||||||
|
def test_n7_control_transfer_uses_fixed_qos_one_topic_and_never_retains(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"scene_id": "n7-reading",
|
||||||
|
"scene_name": "N7 阅读模式",
|
||||||
|
"request_id": "req-20260916000000-1234567890abcdef",
|
||||||
|
"operation_count": 1,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "n7_media",
|
||||||
|
"at_ms": 0,
|
||||||
|
"domain": "media",
|
||||||
|
"capability": "media.play_favorite",
|
||||||
|
"target_scope": "vehicle",
|
||||||
|
"operation": "play",
|
||||||
|
"parameters": {"playlist": "focus"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
publication = self.transport.publish_n7_control_transfer(payload)
|
||||||
|
|
||||||
|
topic, raw_payload, qos, retain = self.client.published[-1]
|
||||||
|
self.assertEqual(topic, MQTT_N7_CONTROL_TRANSFER_TOPIC)
|
||||||
|
self.assertEqual(raw_payload, mqtt_n7_control_transfer_payload(payload))
|
||||||
|
self.assertEqual(json.loads(raw_payload), payload)
|
||||||
|
self.assertEqual(qos, 1)
|
||||||
|
self.assertFalse(retain)
|
||||||
|
self.assertEqual(publication["delivery_status"], "published")
|
||||||
|
|
||||||
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
|
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
|
||||||
self.client.emit(
|
self.client.emit(
|
||||||
"n7/n7-headunit/can/state",
|
"n7/n7-headunit/can/state",
|
||||||
@@ -775,6 +854,7 @@ class MqttCanTransportTests(unittest.TestCase):
|
|||||||
[
|
[
|
||||||
("n7/n7-headunit/can/state", 1),
|
("n7/n7-headunit/can/state", 1),
|
||||||
(MQTT_EXTENSION_CONTROL_TOPIC, 1),
|
(MQTT_EXTENSION_CONTROL_TOPIC, 1),
|
||||||
|
(MQTT_MODE_SCENE_INGRESS_TOPIC, 1),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
|
|||||||
assert '.v2-nfc-command-list' in styles
|
assert '.v2-nfc-command-list' in styles
|
||||||
assert '.v2-nfc-command-option' in styles
|
assert '.v2-nfc-command-option' in styles
|
||||||
assert '.v2-nfc-select-all' in styles
|
assert '.v2-nfc-select-all' in styles
|
||||||
assert 'self-extension.js?v=pet-settings-52' in html
|
assert 'self-extension.js?v=pet-settings-53-performance' in html
|
||||||
assert 'import "../self-extension.css";' in vite_entry
|
assert 'import "../self-extension.css";' in vite_entry
|
||||||
|
|
||||||
|
|
||||||
@@ -209,5 +209,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
|
|||||||
assert "下发控制指令" in console
|
assert "下发控制指令" in console
|
||||||
assert "已解析控制指令" not in console
|
assert "已解析控制指令" not in console
|
||||||
assert 'li[data-can-channel="IR"]' in styles
|
assert 'li[data-can-channel="IR"]' in styles
|
||||||
assert "console-v2.js?v=local-first-76" in html
|
assert "console-v2.js?v=local-first-77-performance" in html
|
||||||
assert 'speech_semantic: "语音语义"' in console
|
assert 'speech_semantic: "语音语义"' in console
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools import start_n7web
|
||||||
|
|
||||||
|
|
||||||
|
def test_tcp_port_probe_detects_an_existing_listener() -> None:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
||||||
|
listener.bind(("127.0.0.1", 0))
|
||||||
|
listener.listen(1)
|
||||||
|
|
||||||
|
assert start_n7web._tcp_port_is_listening( # noqa: SLF001
|
||||||
|
"0.0.0.0",
|
||||||
|
listener.getsockname()[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_rejects_a_duplicate_service_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
args = SimpleNamespace(
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=8000,
|
||||||
|
scene_bind="0.0.0.0",
|
||||||
|
scene_port=8090,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
start_n7web,
|
||||||
|
"_tcp_port_is_listening",
|
||||||
|
lambda _host, port: port == 8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="stop the previous N7Web instance"):
|
||||||
|
start_n7web._ensure_service_ports_available(args) # noqa: SLF001
|
||||||
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
|
|||||||
assert package["scripts"]["build"] == "vite build"
|
assert package["scripts"]["build"] == "vite build"
|
||||||
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
assert 'import vue from "@vitejs/plugin-vue"' in vite
|
||||||
assert 'outDir: outputRoot' in vite
|
assert 'outDir: outputRoot' in vite
|
||||||
assert 'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"' in service_worker
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' in service_worker
|
||||||
assert "unpkg.com" not in html
|
assert "unpkg.com" not in html
|
||||||
assert "cdn.jsdelivr.net" not in html
|
assert "cdn.jsdelivr.net" not in html
|
||||||
|
|
||||||
|
|||||||
+42
-6
@@ -8,6 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -183,6 +184,41 @@ def _display_command(command: Sequence[str]) -> str:
|
|||||||
return shlex.join(command)
|
return shlex.join(command)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_host(bind_address: str) -> str:
|
||||||
|
address = str(bind_address).strip()
|
||||||
|
if address in {"", "0.0.0.0"}:
|
||||||
|
return "127.0.0.1"
|
||||||
|
if address == "::":
|
||||||
|
return "::1"
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def _tcp_port_is_listening(bind_address: str, port: int) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((_probe_host(bind_address), port), timeout=0.25):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_service_ports_available(args: argparse.Namespace) -> None:
|
||||||
|
endpoints = (
|
||||||
|
("main N7Web service", args.host, args.port),
|
||||||
|
("LAN mode-card service", args.scene_bind, args.scene_port),
|
||||||
|
)
|
||||||
|
occupied = [
|
||||||
|
f"{name} ({_probe_host(host)}:{port})"
|
||||||
|
for name, host, port in endpoints
|
||||||
|
if _tcp_port_is_listening(host, port)
|
||||||
|
]
|
||||||
|
if occupied:
|
||||||
|
joined = ", ".join(occupied)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"service port already in use: {joined}; stop the previous N7Web "
|
||||||
|
"instance before starting another"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_npm() -> Path:
|
def _resolve_npm() -> Path:
|
||||||
executable = shutil.which("npm.cmd" if os.name == "nt" else "npm")
|
executable = shutil.which("npm.cmd" if os.name == "nt" else "npm")
|
||||||
if not executable:
|
if not executable:
|
||||||
@@ -314,13 +350,13 @@ def run(argv: Sequence[str] | None = None) -> int:
|
|||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
if npm is not None:
|
_ensure_service_ports_available(args)
|
||||||
try:
|
if npm is not None:
|
||||||
_build_web_console(npm)
|
_build_web_console(npm)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
print(f"startup failed: {exc}", file=sys.stderr, flush=True)
|
print(f"startup failed: {exc}", file=sys.stderr, flush=True)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
children: list[ChildProcess] = []
|
children: list[ChildProcess] = []
|
||||||
interrupted = False
|
interrupted = False
|
||||||
|
|||||||
Reference in New Issue
Block a user