添加无线模块

This commit is contained in:
minghanchen
2026-08-19 09:59:36 +08:00
parent 344f662970
commit 52c4dd08f9
33 changed files with 4740 additions and 246 deletions
+7
View File
@@ -113,6 +113,13 @@ config/gesture/default.yaml
同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。
Linux 上使用 USBCANFD-200U 被动同步 CH1 车辆状态时,使用
`zlgcan-linux` 后端。USB 权限、60 MHz/500k/2M 位时序、只读配置和状态接口
验证见 [Linux ZLG 车辆状态接入](docs/workflows/linux-zlg-vehicle-state.md)。
同一页面也可选择本机 NanoMQ 无线链路,其中 CAN0 对应副驾座椅、CAN1 对应
其余车辆状态和控制;MQTT 主题、原始 CAN 帧 JSON 合约和有线/无线共用安全
门禁见同一文档。
## 认证与公网体验
公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。
+194 -42
View File
@@ -39,10 +39,16 @@ function loadHardwareClientId() {
return clientId;
}
function loadControlTransport() {
const stored = localStorage.getItem("cognitive-agent.control-transport");
return ["wired_can", "wireless_mqtt"].includes(stored) ? stored : "wired_can";
}
const state = {
provider: "openai",
reasoningEffort: localStorage.getItem("cognitive-agent.reasoning-effort") || "medium",
executionMode: "dry_run",
controlTransport: loadControlTransport(),
conversationId: loadConversationId(),
vehicleId: null,
runtime: null,
@@ -61,6 +67,7 @@ const state = {
hardwareAccessPoller: null,
physicalStatePoller: null,
physicalStateSyncPromise: null,
passengerStateSyncPromise: null,
pendingModeProfileId: null,
modeRunTimer: null,
modeDraftCommand: "",
@@ -76,11 +83,18 @@ const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(inpu
// workflow so the UI does not report failure while the seat is still moving.
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 45 * 1000;
const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000;
function requestTimeoutForExecutionMode(mode) {
return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
}
function physicalStatePollIntervalMs() {
const configured = Number(selectedVehicleTransport()?.state_sync?.poll_interval_ms);
if (!Number.isFinite(configured)) return DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS;
return Math.max(250, Math.min(DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS, configured));
}
function refreshIcons() {
if (window.lucide) {
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
@@ -4265,12 +4279,80 @@ function isLocalDecisionHost() {
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
}
function vehicleTransportOptions() {
return Array.isArray(state.runtime?.vehicle_transports?.options)
? state.runtime.vehicle_transports.options
: [];
}
function selectedVehicleTransport() {
return vehicleTransportOptions().find((item) => item.id === state.controlTransport)
|| (state.controlTransport === "wireless_mqtt"
? state.runtime?.vehicle_mqtt
: state.runtime?.vehicle_can)
|| null;
}
function isDriverOnlyCh1Runtime() {
return state.runtime?.vehicle_can?.configured === true
&& state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
const transport = selectedVehicleTransport();
return transport?.configured === true
&& transport?.target_policy === "ch1_body_can_driver_only"
&& state.runtime?.hardware?.configured !== true;
}
function renderControlTransport() {
const options = vehicleTransportOptions();
$$('[data-control-transport]').forEach((button) => {
const id = button.dataset.controlTransport;
const runtime = options.find((item) => item.id === id)
|| (id === "wireless_mqtt" ? state.runtime?.vehicle_mqtt : state.runtime?.vehicle_can);
const selected = id === state.controlTransport;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
// Transport choice is intentionally manual. An unavailable connection is
// shown as offline, but it must never disable or silently change the
// operator's wired/wireless selection.
button.disabled = false;
button.dataset.connectionState = runtime?.state_sync?.state === "online" ? "online" : "offline";
button.title = runtime?.state_configured === true
? runtime?.state_sync?.last_error || ""
: `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`;
});
const selected = selectedVehicleTransport();
const note = $("#vehicleTransportNote");
if (!note) return;
if (selected?.state_configured !== true) {
note.textContent = state.controlTransport === "wireless_mqtt"
? "已手动选择无线控制 · MQTT 当前未配置或不可用"
: "已手动选择有线控制 · ZLG CAN 当前未配置或不可用";
return;
}
if (state.controlTransport === "wireless_mqtt") {
const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接";
note.textContent = `MQTT ${connection} · 状态 ${selected?.condition_topic || "n7/n7-headunit/can/state"} · 控制 ${selected?.control_topic || "n7/can_control"}`;
} else {
const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接";
note.textContent = `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`;
}
}
function selectControlTransport(transport) {
if (!["wired_can", "wireless_mqtt"].includes(transport)) return;
const changed = state.controlTransport !== transport;
state.controlTransport = transport;
localStorage.setItem("cognitive-agent.control-transport", transport);
if (changed) {
state.conversationId = createConversationId();
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
state.seatFeedbackSources.passenger = "unavailable";
state.seatFeedbackSources.driver = "unavailable";
}
state.vehicleStateSnapshot = null;
state.vehicleStateTurn = -1;
renderControlTransport();
applyRuntimeTargetPolicy(state.runtime);
}
function renderHardwareAccess(status) {
state.hardwareAccess = status;
}
@@ -4301,6 +4383,8 @@ function updateSeatFeedbackModeLabel() {
physical_can: "实车已同步",
physical_partial: "物理反馈不完整",
physical_can_ch1: "实车已同步",
wireless_mqtt_ch0: "MQTT 已同步",
wireless_mqtt_ch1: "MQTT 已同步",
simulated_hardware: "模拟 HIL",
simulated: "虚拟反馈",
unavailable: "不可用",
@@ -4309,6 +4393,8 @@ function updateSeatFeedbackModeLabel() {
physical_can: "实车",
physical_partial: "部分",
physical_can_ch1: "实车",
wireless_mqtt_ch0: "MQTT",
wireless_mqtt_ch1: "MQTT",
simulated_hardware: "模拟",
simulated: "虚拟",
unavailable: "未连接",
@@ -4326,7 +4412,11 @@ function updateSeatFeedbackModeLabel() {
}
function selectExecutionMode(mode) {
if (mode === "hardware" && !state.runtime?.hardware_execution_allowed) return;
const selectedTransport = selectedVehicleTransport();
const hardwareExecutionReady = Boolean(
state.runtime?.hardware?.configured || selectedTransport?.configured,
);
if (mode === "hardware" && !hardwareExecutionReady) return;
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return;
state.executionMode = mode;
$$('[data-execution-mode]').forEach((button) => {
@@ -4334,7 +4424,8 @@ function selectExecutionMode(mode) {
});
const hardware = mode === "hardware";
const simulatedHardware = mode === "simulated_hardware";
const vehicleCanReady = Boolean(state.runtime?.vehicle_can?.configured);
const vehicleCanReady = Boolean(selectedTransport?.configured);
const mqttSelected = state.controlTransport === "wireless_mqtt";
const ch1DriverOnly = isDriverOnlyCh1Runtime();
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
@@ -4348,7 +4439,7 @@ function selectExecutionMode(mode) {
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
$("#safetyText").textContent = hardware
? ch1DriverOnly && vehicleCanReady
? "仅使用 ZLG CH1 控制主驾座椅与已验证车身设备;每次动作前必须确认 P 挡和 0 km/h,并以 CH1 状态帧回读为准。CH0、副驾 T2G 与 IAR 路径均不参与。"
? `仅使用${mqttSelected ? "本地 MQTT 无线链路" : "ZLG 有线 CH1"}控制主驾座椅与已验证车身设备;每次动作前必须确认 P 挡和 0 km/h,并以 CH1 状态帧回读为准。CH0、副驾 T2G 与 IAR 路径均不参与。`
: vehicleCanReady
? "同一 CAN 盒由单进程持有:CH0 控制副驾四电机并校验板端反馈;CH1 控制主驾四电机与车身设备,每次动作前校验 P 挡、零车速和对应状态帧。"
: "当前仅通道 0 连接副驾座椅台架并校验板端反馈;车端 CAN 未启用,模式中的其他车舱领域保持虚拟执行。"
@@ -4358,7 +4449,9 @@ function selectExecutionMode(mode) {
$("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行";
$("#feedbackModeLabel").textContent = hardware
? (ch1DriverOnly
? "CH1 BODY CAN · AUTHORITATIVE PHYSICAL FEEDBACK"
? (mqttSelected
? "MQTT CH1 · AUTHORITATIVE CAN FEEDBACK"
: "CH1 BODY CAN · AUTHORITATIVE PHYSICAL FEEDBACK")
: "T2G BENCH · PHYSICAL CAN FEEDBACK")
: simulatedHardware
? "SIMULATED HIL · FAULT INJECTION"
@@ -5062,13 +5155,18 @@ async function saveCurrentModeProfile() {
}
function applyRuntimeTargetPolicy(runtime) {
const passengerReady = runtime?.hardware?.configured === true;
const driverReady = runtime?.vehicle_can?.configured === true;
const selectedTransport = selectedVehicleTransport();
const wirelessDualChannel = state.controlTransport === "wireless_mqtt"
&& selectedTransport?.target_policy === "dual_channel_mqtt_can0_passenger_can1_vehicle";
const passengerReady = wirelessDualChannel
? selectedTransport?.configured === true
: runtime?.hardware?.configured === true;
const driverReady = selectedTransport?.configured === true;
const driverOnly = driverReady && !passengerReady;
const stage = $("#seatTwin");
if (stage) {
stage.dataset.passengerChannel = "CH0";
stage.dataset.driverChannel = "CH1";
stage.dataset.driverChannel = state.controlTransport === "wireless_mqtt" ? "MQTT" : "CH1";
stage.dataset.passengerConfigured = String(passengerReady);
stage.dataset.driverConfigured = String(driverReady);
}
@@ -5115,6 +5213,7 @@ async function loadRuntime() {
const response = await apiRequest("/api/runtime", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.runtime = await response.json();
renderControlTransport();
applyRuntimeTargetPolicy(state.runtime);
populateVehicleSelector();
populateModeChips();
@@ -5123,12 +5222,17 @@ async function loadRuntime() {
state.reasoningEffort = configuredEffort;
}
const hardware = state.runtime.hardware || {};
const vehicleCan = state.runtime.vehicle_can || {};
const linkedHardwareReady = Boolean(hardware.configured && vehicleCan.configured);
const seatHardwareReady = Boolean(hardware.configured);
const vehicleCan = selectedVehicleTransport() || {};
const mqttSelected = state.controlTransport === "wireless_mqtt";
const mqttDualChannel = mqttSelected
&& vehicleCan.target_policy === "dual_channel_mqtt_can0_passenger_can1_vehicle";
const seatHardwareReady = Boolean(
mqttDualChannel ? vehicleCan.configured : hardware.configured,
);
const vehicleHardwareReady = Boolean(vehicleCan.configured);
const linkedHardwareReady = seatHardwareReady && vehicleHardwareReady;
const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
const hardwareExecutionReady = Boolean(state.runtime.hardware_execution_allowed);
const hardwareExecutionReady = Boolean(hardware.configured || vehicleCan.configured);
const badge = $("#modelBadge");
const gptButton = $('[data-provider="openai"]');
if (state.runtime.openai_available) {
@@ -5163,32 +5267,35 @@ async function loadRuntime() {
&& hardware.access_policy === "local_approval_idle_lease"
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
: "";
const vehicleRouteText = mqttSelected
? `MQTT ${vehicleCan.host || "127.0.0.1"}:${vehicleCan.port || 1883} · ${vehicleCan.condition_topic || "n7/n7-headunit/can/state"} / ${vehicleCan.control_topic || "n7/can_control"}`
: `${vehicleCan.backend || "zlgcan"} · CH${vehicleCan.channel ?? 1} 车身 CAN FD ${Number(vehicleCan.arbitration?.bitrate || 500000) / 1000}k/${Number(vehicleCan.data?.bitrate || 2000000) / 1000000}M`;
$("#hardwareNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `${vehicleCan.backend} · CH${vehicleCan.channel} 车身 CAN FD ${vehicleCan.arbitration.bitrate / 1000}k/${vehicleCan.data.bitrate / 1000000}M · 仅主驾/车端白名单 · CH0/T2G/IAR 已禁用${accessText}${concurrencyText}`
? `${vehicleRouteText} · 仅主驾/车端白名单 · CH0/T2G/IAR 已禁用${accessText}${concurrencyText}`
: linkedHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · CH${vehicleCan.channel} 车身 CAN FD ${vehicleCan.arbitration.bitrate / 1000}k/${vehicleCan.data.bitrate / 1000000}M · ${profileText} · ${flashText}${accessText}${concurrencyText}`
? `${mqttDualChannel ? "MQTT CAN0 副驾座椅 · MQTT CAN1 其余车辆" : `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s`} · ${vehicleRouteText} · ${profileText}${mqttDualChannel ? " · 远端自动烧录禁用" : ` · ${flashText}`}${accessText}${concurrencyText}`
: seatHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
: `联动真实执行已锁定:${profileText};请完成座椅固件、双通道 ZLG 适配器和车身 CAN FD 配置`;
: `真实执行已锁定:${profileText};当前${mqttSelected ? "无线 MQTT" : "有线 CAN"}控制未通过后端审批配置`;
$("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? "CH1 → 车身 CAN FD / 主驾座椅 · CH0 与副驾 T2G 路径禁止"
? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止`
: linkedHardwareReady
? "CH0 → 副驾座椅控制器 · CH1 → 车身 CAN FD"
? `${mqttDualChannel ? "MQTT CAN0" : "CH0"} → 副驾座椅控制器 · ${mqttDualChannel ? "MQTT CAN1" : "CH1"} → 其余车辆 CAN`
: seatHardwareReady
? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
: "尚未配置可执行的物理 CAN 通道";
$("#hardwareBadge").className = `status-badge ${hardwareExecutionReady ? "" : "danger"}`.trim();
$("#hardwareBadge").innerHTML = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? '<i data-lucide="shield-check"></i>CH1 车端已配置'
? `<i data-lucide="shield-check"></i>${mqttSelected ? "MQTT" : "CH1"} 车端已配置`
: linkedHardwareReady
? '<i data-lucide="shield-check"></i>双通道已配置'
: seatHardwareReady
? '<i data-lucide="shield-check"></i>座椅台架已配置'
: '<i data-lucide="shield-off"></i>硬件锁定';
$("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `CH1 主驾 + 车端设备 · 物理字段回读${accessText}${concurrencyText}`
? `${mqttSelected ? "MQTT" : "CH1"} 主驾 + 车端设备 · CAN 字段回读${accessText}${concurrencyText}`
: linkedHardwareReady
? `CH0 座椅 + CH${vehicleCan.channel} 车身${accessText}${concurrencyText}`
? `CH0 座椅 + ${mqttSelected ? "MQTT" : `CH${vehicleCan.channel}`} 车身${accessText}${concurrencyText}`
: seatHardwareReady
? `CH${hardware.channel} 座椅 · 物理反馈${accessText}${concurrencyText}`
: "已阻断";
@@ -5209,10 +5316,16 @@ async function loadRuntime() {
}
async function synchronizePassengerSeatState() {
if (!state.runtime?.hardware?.configured) return false;
const requestedTransport = state.controlTransport;
const mqttSelected = requestedTransport === "wireless_mqtt";
const stateReady = mqttSelected
? selectedVehicleTransport()?.state_configured
: state.runtime?.hardware?.configured;
if (!stateReady) return false;
const query = new URLSearchParams({
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
control_transport: requestedTransport,
hardware_client_id: state.hardwareClientId,
});
try {
@@ -5225,15 +5338,16 @@ async function synchronizePassengerSeatState() {
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (requestedTransport !== state.controlTransport) return false;
renderSeatState({ passenger: payload.actuator_state?.passenger || {} });
state.seatFeedbackSources.passenger = payload.complete
? "physical_can"
? payload.source === "wireless_mqtt_ch0" ? "wireless_mqtt_ch0" : "physical_can"
: "physical_partial";
state.seatFeedbackSource = state.seatFeedbackSources.passenger;
updateSeatFeedbackModeLabel();
return true;
} catch (_error) {
if (!state.seatFeedbackSources.passenger.startsWith("physical")) {
if (!["physical_can", "wireless_mqtt_ch0"].includes(state.seatFeedbackSources.passenger)) {
state.seatFeedbackSources.passenger = "unavailable";
updateSeatFeedbackModeLabel();
}
@@ -5242,10 +5356,12 @@ async function synchronizePassengerSeatState() {
}
async function synchronizeDriverSeatState() {
if (!state.runtime?.vehicle_can?.configured) return false;
if (!selectedVehicleTransport()?.state_configured) return false;
const requestedTransport = state.controlTransport;
const query = new URLSearchParams({
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
control_transport: requestedTransport,
hardware_client_id: state.hardwareClientId,
});
try {
@@ -5256,13 +5372,16 @@ async function synchronizeDriverSeatState() {
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (requestedTransport !== state.controlTransport) return false;
applyAuthoritativeVehicleState(payload);
state.seatFeedbackSources.driver = payload.complete ? "physical_can_ch1" : "physical_partial";
state.seatFeedbackSources.driver = payload.complete
? payload.source === "wireless_mqtt_ch1" ? "wireless_mqtt_ch1" : "physical_can_ch1"
: "physical_partial";
state.seatFeedbackSource = state.seatFeedbackSources.driver;
updateSeatFeedbackModeLabel();
return true;
} catch (_error) {
if (!state.seatFeedbackSources.driver.startsWith("physical")) {
if (!["physical_can_ch1", "wireless_mqtt_ch1"].includes(state.seatFeedbackSources.driver)) {
state.seatFeedbackSources.driver = "unavailable";
updateSeatFeedbackModeLabel();
}
@@ -5273,7 +5392,22 @@ async function synchronizeDriverSeatState() {
async function synchronizePhysicalSeatState() {
if (state.physicalStateSyncPromise) return state.physicalStateSyncPromise;
state.physicalStateSyncPromise = (async () => {
// Both channels share one adapter owner; keep passive reads serialized.
if (state.controlTransport === "wireless_mqtt") {
// CAN0 may legitimately be absent and wait for its bounded seat-status
// timeout. Keep that wait single-flight in the background so cached
// CAN1 vehicle state can still refresh at the MQTT polling cadence.
if (!state.passengerStateSyncPromise) {
let passengerPromise;
passengerPromise = synchronizePassengerSeatState().finally(() => {
if (state.passengerStateSyncPromise === passengerPromise) {
state.passengerStateSyncPromise = null;
}
});
state.passengerStateSyncPromise = passengerPromise;
}
return synchronizeDriverSeatState();
}
// The wired channels share one adapter owner; keep passive reads serialized.
const passenger = await synchronizePassengerSeatState();
const driver = await synchronizeDriverSeatState();
return passenger || driver;
@@ -5285,6 +5419,16 @@ async function synchronizePhysicalSeatState() {
}
}
function restartPhysicalStatePolling() {
if (state.physicalStatePoller) window.clearInterval(state.physicalStatePoller);
state.physicalStatePoller = window.setInterval(() => {
const activeModule = document.body.dataset.module;
if (document.visibilityState !== "visible") return;
if (!["self-orchestration", "self-extension"].includes(activeModule)) return;
synchronizePhysicalSeatState();
}, physicalStatePollIntervalMs());
}
async function loadAuthStatus() {
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
if (!response.ok) return;
@@ -5585,7 +5729,7 @@ function renderDomainState(domainState) {
if (vehicleSystems) {
vehicleSystems.targets.climateLevel = Math.max(0, Math.min(7, Number(climate.fan_level) || 0));
vehicleSystems.targets.climateTemperature = Number(
state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
? driverTemperature
: passengerTemperature,
) || 24;
@@ -6213,8 +6357,8 @@ function renderExecution(result) {
: simulatedHil
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
? selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
? `${executedDomains.join(" / ")} 已通过${result.control_transport === "wireless_mqtt" ? " MQTT" : " CH1"}下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
if (partiallySucceeded) {
@@ -6349,8 +6493,12 @@ function applyAuthoritativeVehicleState(payload) {
domainState: domainPatch,
};
if (actuatorPatch?.driver) {
state.seatFeedbackSource = "physical_can_ch1";
state.seatFeedbackSources.driver = "physical_can_ch1";
const source = payload.control_transport === "wireless_mqtt"
|| payload.source === "wireless_mqtt_ch1"
? "wireless_mqtt_ch1"
: "physical_can_ch1";
state.seatFeedbackSource = source;
state.seatFeedbackSources.driver = source;
}
renderSeatState(actuatorPatch);
renderDomainState(domainPatch);
@@ -6389,8 +6537,10 @@ function renderResult(result) {
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
} else if (feedbackSource === "physical_can_ch1"
&& isAuthoritativeVehicleStatePayload(result)) {
state.seatFeedbackSource = "physical_can_ch1";
state.seatFeedbackSources.driver = "physical_can_ch1";
state.seatFeedbackSource = result.control_transport === "wireless_mqtt"
? "wireless_mqtt_ch1"
: "physical_can_ch1";
state.seatFeedbackSources.driver = state.seatFeedbackSource;
} else if (reportMode === "hil") {
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
? "physical_can"
@@ -6436,7 +6586,7 @@ function renderResult(result) {
renderTrace(result.trace_events || []);
renderValidation(result.validation || []);
const ch1HardwareResult = reportMode === "hil"
&& state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only";
&& selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only";
if (ch1HardwareResult) {
applyAuthoritativeVehicleState(result);
} else {
@@ -6499,6 +6649,7 @@ async function submitRequest(event) {
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
control_transport: state.controlTransport,
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
...(state.executionMode === "hardware" && !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
@@ -6747,6 +6898,12 @@ document.addEventListener("DOMContentLoaded", async () => {
$$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider)));
$$('[data-reasoning-effort]').forEach((button) => button.addEventListener("click", () => selectReasoningEffort(button.dataset.reasoningEffort)));
$$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode)));
$$('[data-control-transport]').forEach((button) => button.addEventListener("click", async () => {
selectControlTransport(button.dataset.controlTransport);
await loadRuntime();
restartPhysicalStatePolling();
await synchronizePhysicalSeatState();
}));
$$(".example-command").forEach((button) => button.addEventListener("click", () => {
$("#utterance").value = button.dataset.example;
}));
@@ -6763,12 +6920,7 @@ document.addEventListener("DOMContentLoaded", async () => {
);
}
await synchronizePhysicalSeatState();
state.physicalStatePoller = window.setInterval(() => {
const activeModule = document.body.dataset.module;
if (document.visibilityState !== "visible") return;
if (!["self-orchestration", "self-extension"].includes(activeModule)) return;
synchronizePhysicalSeatState();
}, 5000);
restartPhysicalStatePolling();
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
}
+57
View File
@@ -366,6 +366,63 @@
display: none;
}
.console-v2 .v2-transport-field {
padding: 8px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 10px;
}
.console-v2 .v2-transport-field > .field-label {
margin-bottom: 6px;
}
.console-v2 .v2-transport-field .vehicle-transport-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
.console-v2 .v2-transport-field .mode-option {
min-width: 0;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 7px;
color: #657879;
background: rgba(246, 249, 248, 0.92);
border: 1px solid rgba(105, 137, 137, 0.16);
border-radius: 8px;
font-size: 10px;
font-weight: 850;
cursor: pointer;
}
.console-v2 .v2-transport-field .mode-option svg {
width: 14px;
height: 14px;
}
.console-v2 .v2-transport-field .mode-option.active {
color: #fff;
background: linear-gradient(145deg, var(--v2-accent), var(--v2-accent-strong));
border-color: transparent;
box-shadow: 0 5px 13px color-mix(in srgb, var(--v2-accent) 20%, transparent);
}
.console-v2 .v2-transport-field > #vehicleTransportNote {
display: block;
margin: 6px 1px 0;
overflow: hidden;
color: #809091;
font-size: 8px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2 .v2-config-card {
min-width: 0;
min-height: var(--v2-sidebar-card-height);
+6
View File
@@ -302,6 +302,7 @@
function buildConfigFields(configStack, originalForm) {
const vehicleField = $(".vehicle-field", originalForm);
const transportField = $("#vehicleTransportField", originalForm);
const providerField = $(".field-group:has(.segmented [data-provider])", originalForm);
const reasoningField = $("#reasoningField", originalForm);
const executionField = $(".field-group:has(.execution-mode-options)", originalForm);
@@ -328,6 +329,11 @@
configStack.append(vehicleField);
}
if (transportField) {
transportField.classList.add("v2-config-field", "v2-transport-field");
configStack.append(transportField);
}
const modelField = make("div", "v2-config-field v2-config-card v2-model-field", `
<i data-lucide="sparkles"></i>
<div><small>推理模型</small><strong id="v2ModelValue">模型检测中</strong></div>
+17 -4
View File
@@ -9,8 +9,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>智舱随行 · 整车智能控制台</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-107" />
<link rel="stylesheet" href="/styles.css?v=dual-seat-10" />
<link rel="stylesheet" href="/console-v2.css?v=desktop-refactor-108" />
<link rel="stylesheet" href="/self-evolution.css?v=seat-weighing-5" />
<link rel="stylesheet" href="/self-extension.css?v=extension-19" />
<link rel="stylesheet" href="/self-calibration.css?v=calibration-12" />
@@ -20,10 +20,10 @@
<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="/network.js?v=android-pad-1" defer></script>
<script src="/app.js?v=dual-seat-82" defer></script>
<script src="/app.js?v=dual-seat-89" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-7"></script>
<script src="/pwa.js?v=android-pad-1" defer></script>
<script src="/console-v2.js?v=desktop-refactor-36" defer></script>
<script src="/console-v2.js?v=desktop-refactor-37" defer></script>
<script src="/self-evolution.js?v=seat-weighing-2" defer></script>
<script src="/self-extension.js?v=extension-21" defer></script>
<script src="/self-calibration.js?v=calibration-8" defer></script>
@@ -147,6 +147,19 @@
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
</div>
<div class="field-group" id="vehicleTransportField">
<span class="field-label">车辆连接方式</span>
<div class="segmented vehicle-transport-options" role="group" aria-label="车辆连接方式">
<button type="button" class="mode-option active" data-control-transport="wired_can">
<i data-lucide="cable"></i>有线控制
</button>
<button type="button" class="mode-option" data-control-transport="wireless_mqtt">
<i data-lucide="wifi"></i>无线控制
</button>
</div>
<p class="provider-note" id="vehicleTransportNote">正在检查车辆连接</p>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
+5 -5
View File
@@ -1,16 +1,16 @@
const CACHE_VERSION = "cognitive-agent-pad-v120";
const CACHE_VERSION = "cognitive-agent-pad-v128";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
"/",
"/index.html",
"/styles.css?v=dual-seat-9",
"/styles.css?v=dual-seat-10",
"/network.js?v=android-pad-1",
"/app.js?v=dual-seat-82",
"/app.js?v=dual-seat-89",
"/gesture-control.js?v=android-pad-7",
"/pwa.js?v=android-pad-1",
"/console-v2.css?v=desktop-refactor-107",
"/console-v2.js?v=desktop-refactor-36",
"/console-v2.css?v=desktop-refactor-108",
"/console-v2.js?v=desktop-refactor-37",
"/self-evolution.css?v=seat-weighing-5",
"/self-evolution.js?v=seat-weighing-2",
"/self-extension.css?v=extension-19",
+18
View File
@@ -5071,6 +5071,24 @@ pre {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vehicle-transport-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vehicle-transport-options .mode-option[data-connection-state="offline"] {
color: var(--muted);
}
.vehicle-transport-options .mode-option[data-connection-state="online"]::after {
content: "";
width: 7px;
height: 7px;
margin-left: 4px;
border-radius: 50%;
background: #2ccf83;
box-shadow: 0 0 8px rgba(44, 207, 131, 0.65);
}
.simulation-fault-field select {
width: 100%;
min-height: 44px;
@@ -0,0 +1,23 @@
# Read-only vehicle-state synchronization through ZLG's Linux VCI SDK.
# Keep the approval token unset: hardware transmit remains locked.
COGNITIVE_AGENT_VEHICLE_CAN_ENABLED=true
COGNITIVE_AGENT_VEHICLE_CAN_BACKEND=zlgcan-linux
COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL=1
COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL=1
COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE=500000
COGNITIVE_AGENT_VEHICLE_CAN_DATA_BITRATE=2000000
COGNITIVE_AGENT_VEHICLE_CAN_TERMINATION_ENABLED=false
COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE=33
COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX=0
COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY=/home/orin/project511/project-code/canfd/libusbcanfd.so
COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED=false
# Local NanoMQ wireless dual-CAN relay: CAN0 is the passenger seat; CAN1 is the
# rest of the vehicle. State subscription remains available while physical
# control stays locked unless the existing hardware approval policy is configured.
COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED=true
COGNITIVE_AGENT_VEHICLE_MQTT_HOST=127.0.0.1
COGNITIVE_AGENT_VEHICLE_MQTT_PORT=1883
COGNITIVE_AGENT_VEHICLE_MQTT_CONDITION_TOPIC=n7/n7-headunit/can/state
COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC=n7/can_control
COGNITIVE_AGENT_VEHICLE_MQTT_QOS=1
+174
View File
@@ -0,0 +1,174 @@
# Linux ZLG 车辆状态接入
当前工程可以通过 ZLG Linux VCI SDK 被动读取 USBCANFD-200U 的 CH1,并把
车辆状态送入 `/api/hardware/vehicle-state` 和前端整车孪生。状态轮询使用独立
`monitor-only` 桥,桥进程会拒绝所有发送请求。服务启动后由一个常驻监听线程
持续缓存最新的已验证状态,HTTP 接口不会为每次前端请求反复开关 USB 设备。
Linux 只读状态模式的前端刷新周期为 500 ms;缓存只保留 3 秒内的新鲜反馈,
断线后会自动关闭旧桥并重试连接。监听通道只接受本文列出的 11 个标准帧 ID,
同时在 SDK 桥内保留同样的软件过滤,避免把无关总线流量转发给服务进程。
## 硬件和总线基线
- USB:`3068:0009 ZLG USBCANFD-200U`
- Linux SDK 设备类型:`33`
- 通道:CH1
- 帧格式:11-bit ISO CAN FD,按报文保留 BRS 属性
- 控制器时钟:60 MHz
- 仲裁段:500 kbit/s,`tseg1=14, tseg2=3, sjw=2, brp=5`
- 数据段:2 Mbit/s,`tseg1=10, tseg2=2, sjw=2, brp=1`
- 终端:默认不启用适配器内部 120 欧;只能按实际拓扑调整
前端车辆概况依赖 `0x342`(档位)、`0x4EB/0x07`(车速)和 `0x407`
(门锁)。主驾座椅依赖 `0x4FA`、`0x494`。收到其他帧不能替代这些已验证
反馈。
## 前端有线 / 无线选择
前端“车辆连接方式”提供两个相互独立的传输选项:
- 有线控制:状态和控制都走后端 ZLG CH1。
- 无线控制:状态订阅 `n7/n7-headunit/can/state`,控制发布到
`n7/can_control`;默认 Broker 为 `127.0.0.1:1883`。MQTT CAN0 专用于
副驾座椅,MQTT CAN1 专用于除副驾座椅外的车辆状态和控制。
选择保存在当前浏览器,并随车辆状态请求和控制请求一起传给后端。切换传输时
会建立新的会话上下文,上一条链路缓存的状态不会冒充当前链路状态。两种方式
共用同一套编译范围检查、P 挡/零车速预检、反馈目标匹配和硬件批准令牌;MQTT
发布成功只证明 Broker 接收了消息,不能替代 ECU 状态反馈。
MQTT 主题只承载原始 CAN 帧,不接受已经解析的车辆状态 JSON。规范帧格式为:
```json
{
"schema": "n7.can.frame.v1",
"arbitration_id": 834,
"frame_id": "0x342",
"channel": 1,
"data_hex": "00 00 00 01 00 00 00 00",
"data": [0, 0, 0, 1, 0, 0, 0, 0],
"payload_size": 8,
"is_fd": true,
"bitrate_switch": true,
"timestamp": 1787029200.0
}
```
`n7/n7-headunit/can/state` 可以发送单帧对象,也可以发送
`{"frames":[...]}` 批次。接收侧兼容 `can_id`、`canId`、`frame_id` 和
`dataHex` 等常见字段名,但仍强制 CAN0/CAN1、11 位 ID、1–64 字节长度和
逐通道反馈 ID 白名单。`n7/can_control` 始终由后端输出上述规范单帧格式,
其中 `channel` 保留编译计划选择的通道。
无线通道边界为:
- CAN0:经典 CAN 副驾座椅;直接状态接收 `0x301–0x307`、`0x4FA`。当前车端
还会以只读 `0x6A0` `SEAT_INFO(0x60)` 查询和 `0x6A2` 响应上报四轴 Hall
位置、范围和标志。后端只配对 `C7/7C`、相同序号、`status=0` 的查询响应,
再转换成统一的四轴 `0x302` 状态;查询帧不会被当作普通动作执行。
无线副驾控制只允许 `0x6A0` 中固件明确提供的运行时 opcode:
`STOP(0x63)`、`MOVE(0x64)`、`HEARTBEAT(0x65)` 和
`DYNAMIC_START(0x66)`,并等待匹配序号/opcode 的 `0x6A2` 成功响应及新鲜
四轴位置反馈。刷写、进入 Bootloader、测试、校准和回零 opcode 全部拒绝;
旧 `0x29E/0x300/0x3CE` 不会在该无线 CAN0 路径发布。
- CAN1:除副驾座椅以外的整车 CAN;状态和控制继续使用项目中已有的 CH1
车辆白名单。CAN0 控制 ID 不能经 CAN1 发送,CAN1 控制 ID 也不能经 CAN0
发送。
两个通道的协议来源严格分离:
- CAN0 只以 `/home/orin/project511/seat-code-file/seat-ota` 副驾固件为准;
入站状态必须是 11 位标准帧、经典 CAN、无 BRS、DLC 8。
- CAN1 只以
`/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx` 为准。
例如 `0x42E` 颜色值只有 `0x01–0x1F` 有效,保留值不会显示为
真实颜色。
MQTT 消息自带的 `can0Seat.parsed` 和 `can1Vehicle.parsed` 只可用于车端
诊断,后端不使用它们更新状态或通过安全门禁。
副驾位置换算严格使用固件 `OTA_SEAT_INFO_AXIS0..3_POSITION_RANGE`:响应值低
16 位为当前位置 Hall 计数,高 16 位为该轴 Hall 范围;
`OTA_SEAT_INFO_AXIS0..3_FLAGS` 提供 `valid/moving/driver_fault/direction`。四轴
分别映射为水平、坐垫、靠背和腿托。只读缓存最多保留 3 秒,新建状态请求可
立即复用新鲜四轴快照,不需要等待下一轮车端查询。
每个 MQTT 总线视图只会收到本通道反馈。发布完成仅代表 Broker 接收消息;
座椅和整车执行器仍必须等待对应状态 ID 与目标字段匹配,才会报告动作成功。
当前 seat-ota 固件只提供四轴位置和动态模式控制,没有副驾加热/通风运行时
opcode;无线模式收到这两类副驾请求会在发布前明确失败,不会伪造反馈。
车端现有 `n7-can-signals/v1` 状态封装也可直接发布到
`n7/n7-headunit/can/state`。后端优先从顶层 `signals` 读取,避免与嵌套副本重复;
顶层缺失时会合并 `can0Seat.signals`(默认 CAN0)和
`can1Vehicle.signals`(默认 CAN1)。接收兼容 `channel: "can0"/"can1"`、
`channelIndex`、`frameType`、`brs`、`data`/`bytes` 和毫秒时间戳。若同一帧的
`channel` 与 `channelIndex` 冲突,整批消息会被拒绝。封装中的
`can0Seat.parsed` 和 `can1Vehicle.parsed` 都不作为权威状态输入;安全预检和
前端状态仍由原始 CAN 帧通过逐通道白名单解码器生成。带 Unix 时间戳的过期
CAN1 非安全帧可短暂作为 `display_only` 缓存显示,但不会进入控制执行器的
反馈队列。过期的 `0x342` 挡位和 `0x4EB` 车速帧会直接拒绝,绝不能用
旧的 P 挡或零车速结果通过当前控制预检。
无线 CAN0 和 CAN1 状态读取互不持有真实执行独占锁。CAN0 状态缺失或等待时
不会再让 CAN1 接口返回 `hardware_busy`。前端按通道分别保持 single-flight,
CAN1 以 500 ms 周期刷新;手动切换有线/无线后会立即按所选后端重新建立轮询
周期,上一传输方式的迟到响应会被丢弃。
无线副驾动作在向 CAN0 发布前,也必须通过同一条 MQTT 链路上的 CAN1 新鲜
P 挡与零车速预检。远端链路不会触发本机 IAR 自动烧录;副驾固件升级仍需走
独立、明确授权的维护流程。
## USB 权限
设备节点必须允许运行服务的用户读写。推荐使用仅授予当前桌面登录用户权限的
udev 规则:
```bash
sudo sh -c 'printf "%s\n" '\''SUBSYSTEM=="usb", ATTR{idVendor}=="3068", ATTR{idProduct}=="0009", TAG+="uaccess", MODE="0660"'\'' > /etc/udev/rules.d/50-zlg-usbcanfd.rules'
sudo udevadm control --reload-rules
sudo udevadm trigger --attr-match=idVendor=3068 --attr-match=idProduct=0009
```
若 ACL 没有立即出现,重新插拔适配器。使用以下命令核对,当前服务用户应具有
`rw`:
```bash
getfacl -p /dev/bus/usb/001/006
```
设备编号每次插拔可能变化,应以 `lsusb` 的 Bus/Device 为准。
## 服务配置
将 `config/environments/linux_vehicle_state.example.env` 中的配置复制到仓库
根目录 `.env`,并按实际 SDK 位置修改
`COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY`。不要设置硬件批准令牌;此时
`vehicle_can.state_configured=true`,但 `vehicle_can.configured=false`,所以只
允许状态同步,前端真实执行保持锁定。
启动:
```bash
make dev
```
核对运行时:
```bash
curl -s http://127.0.0.1:8000/api/runtime
```
车辆状态接口应返回 `source=physical_can_ch1`、实际收到的
`observed_frame_ids`,以及缺失反馈列表。发送成功、CAN ACK 和状态反馈是不同
证据层级;只有接口实际收到并解码目标反馈才会更新前端。
## SDK 自检
自检只加载动态库和核对 ABI,不打开 USB:
```bash
.venv/bin/python src/cognitive_agent/infrastructure/can/zlgcan_linux_bridge.py \
--dll /home/orin/project511/project-code/canfd/libusbcanfd.so \
--self-test
```
@@ -113,6 +113,7 @@ class HardwareSeatExecutor:
firmware_guard: T2GFirmwareGuard | None = None,
synchronize_position_before_first_motion: bool = False,
position_sync_state: dict[str, bool] | None = None,
require_thermal_feedback: bool = True,
):
self.matrix = matrix
self.bus_factory = bus_factory
@@ -134,6 +135,7 @@ class HardwareSeatExecutor:
if position_sync_state is not None
else {"synchronized": False}
)
self.require_thermal_feedback = bool(require_thermal_feedback)
self._current_samples: dict[int, list[dict[str, Any]]] = {}
@classmethod
@@ -469,21 +471,25 @@ class HardwareSeatExecutor:
"feedback_value": raw,
}
@staticmethod
def _physical_state_complete(state: dict[str, Any]) -> bool:
def _physical_state_complete(self, state: dict[str, Any]) -> bool:
positions_complete = set(state.get("position", {})) == set(
PHYSICAL_MOTOR_AXES.values()
)
if not self.require_thermal_feedback:
return positions_complete
return (
set(state.get("position", {})) == set(PHYSICAL_MOTOR_AXES.values())
positions_complete
and isinstance(state.get("ventilation"), dict)
and isinstance(state.get("heating"), dict)
)
@staticmethod
def _physical_state_missing(state: dict[str, Any]) -> list[str]:
def _physical_state_missing(self, state: dict[str, Any]) -> list[str]:
missing = [
axis
for axis in PHYSICAL_MOTOR_AXES.values()
if axis not in state.get("position", {})
]
if self.require_thermal_feedback:
missing.extend(
feature
for feature in ("ventilation", "heating")
+2 -2
View File
@@ -11,13 +11,14 @@ dependencies = [
"jsonschema>=4.21",
"pydantic>=2.6",
"PyYAML>=6.0",
"paho-mqtt>=2,<3",
]
[project.optional-dependencies]
api = ["fastapi>=0.110", "uvicorn>=0.27"]
can = ["cantools>=39", "python-can>=4.3"]
llm = ["openai>=1.30"]
ir = ["paho-mqtt>=2,<3"]
ir = []
test = ["pytest>=8", "pytest-cov>=5"]
dev = ["mypy>=1.9", "ruff>=0.4"]
@@ -36,4 +37,3 @@ markers = [
[tool.ruff]
line-length = 100
target-version = "py311"
+2
View File
@@ -14,6 +14,8 @@ Requires-Dist: cantools>=39; extra == "can"
Requires-Dist: python-can>=4.3; extra == "can"
Provides-Extra: llm
Requires-Dist: openai>=1.30; extra == "llm"
Provides-Extra: ir
Requires-Dist: paho-mqtt<3,>=2; extra == "ir"
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Requires-Dist: pytest-cov>=5; extra == "test"
+25
View File
@@ -13,15 +13,40 @@ src/cognitive_agent/application/capability_registry.py
src/cognitive_agent/application/conversation_state.py
src/cognitive_agent/application/domain_runtime.py
src/cognitive_agent/application/gesture_bridge.py
src/cognitive_agent/application/mode_profile.py
src/cognitive_agent/application/model_plan_adapter.py
src/cognitive_agent/application/native_can_feedback.py
src/cognitive_agent/application/native_can_intent.py
src/cognitive_agent/application/native_can_relative.py
src/cognitive_agent/application/native_can_runtime.py
src/cognitive_agent/application/seat_calibration.py
src/cognitive_agent/application/seat_service.py
src/cognitive_agent/application/self_extension.py
src/cognitive_agent/application/semantic_ir.py
src/cognitive_agent/application/vehicle_profile.py
src/cognitive_agent/application/zcan_seat_executor.py
src/cognitive_agent/application/calibration/__init__.py
src/cognitive_agent/application/calibration/mqtt_client.py
src/cognitive_agent/application/calibration/protocol.py
src/cognitive_agent/application/calibration/service.py
src/cognitive_agent/application/calibration/transports.py
src/cognitive_agent/application/calibration/video.py
src/cognitive_agent/application/self_repair/__init__.py
src/cognitive_agent/application/self_repair/can_runtime.py
src/cognitive_agent/application/self_repair/healing_model.py
src/cognitive_agent/application/self_repair/service.py
src/cognitive_agent/application/self_repair/signal_diagnostic.py
src/cognitive_agent/application/self_repair/zcan_bridge.py
src/cognitive_agent/infrastructure/can/__init__.py
src/cognitive_agent/infrastructure/can/access.py
src/cognitive_agent/infrastructure/can/gateway.py
src/cognitive_agent/infrastructure/can/hardware.py
src/cognitive_agent/infrastructure/can/matrix.py
src/cognitive_agent/infrastructure/can/zlgcan_bridge.py
src/cognitive_agent/infrastructure/can/zlgcan_linux_bridge.py
src/cognitive_agent/infrastructure/firmware/__init__.py
src/cognitive_agent/infrastructure/firmware/t2g_seat.py
src/cognitive_agent/infrastructure/llm/openai_responses.py
src/cognitive_agent/runtime/contracts.py
src/cognitive_agent/testing/__init__.py
src/cognitive_agent/testing/ch1_hil.py
@@ -14,6 +14,9 @@ python-can>=4.3
mypy>=1.9
ruff>=0.4
[ir]
paho-mqtt<3,>=2
[llm]
openai>=1.30
@@ -0,0 +1,443 @@
"""Wireless passenger-seat executor for the seat-ota runtime protocol."""
from __future__ import annotations
import threading
import time
from datetime import UTC, datetime
from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
from seat_domain import HardwareExecutionError, HardwareSeatExecutor
OTA_CONTROL_ID = 0x6A0
OTA_RESPONSE_ID = 0x6A2
OTA_CONTROL_MAGIC = 0xC7
OTA_RESPONSE_MAGIC = 0x7C
OTA_OPCODE_SEAT_STOP = 0x63
OTA_OPCODE_SEAT_MOVE = 0x64
OTA_OPCODE_SEAT_HEARTBEAT = 0x65
OTA_OPCODE_SEAT_DYNAMIC_START = 0x66
OTA_SEAT_AXIS_ALL = 0xFF
OTA_RESPONSE_TIMEOUT_MS = 1_500
OTA_HEARTBEAT_INTERVAL_MS = 500
OTA_DYNAMIC_DURATION_SECONDS = 300
OTA_STATUS_NAMES = {
0x00: "OK",
0x01: "BAD_MAGIC",
0x02: "BAD_OPCODE",
0x03: "BAD_STATE",
0x04: "BAD_ARGUMENT",
0x0C: "RX_OVERFLOW",
0x0D: "BUS_OFF",
}
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
class MqttOtaSeatExecutor:
"""Map verified seat plans to the board's bounded OTA runtime opcodes.
Flash, bootloader-entry, calibration, homing and test opcodes are not
represented here. The MQTT bus performs a second payload-level check
before it will publish any 0x6A0 frame.
"""
_sequence_lock = threading.Lock()
_sequence = 0
def __init__(
self,
status_executor: HardwareSeatExecutor,
bus_factory: Callable[[], CanBus],
) -> None:
self.status_executor = status_executor
self.bus_factory = bus_factory
@classmethod
def _next_sequence(cls) -> int:
with cls._sequence_lock:
cls._sequence = (cls._sequence + 1) & 0xFF
if cls._sequence == 0:
cls._sequence = 1
return cls._sequence
def read_physical_state(self) -> dict[str, Any]:
return self.status_executor.read_physical_state()
@staticmethod
def _axis_state(frame: CanFrame) -> dict[str, Any] | None:
if frame.arbitration_id != 0x302 or len(frame.data) != 8:
return None
flags = frame.data[2]
return {
"axis": frame.data[0],
"moving": bool(flags & (1 << 2)),
"position_valid": bool(flags & (1 << 3)),
"fault": bool(flags & (1 << 7)) or (frame.data[1] >> 4) == 7,
"value_x100": int.from_bytes(frame.data[5:7], "little"),
"frame": frame,
}
def _receive_axis(
self,
bus: CanBus,
axis: int,
timeout_ms: int,
) -> dict[str, Any]:
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
while True:
remaining_ms = round((deadline - time.monotonic()) * 1_000)
if remaining_ms <= 0:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for wireless seat axis {axis}"
)
frame = bus.receive(remaining_ms)
if frame is None:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for wireless seat axis {axis}"
)
state = self._axis_state(frame)
if state is not None and state["axis"] == axis:
return state
def _wait_response(
self,
bus: CanBus,
sequence: int,
opcode: int,
timeout_ms: int = OTA_RESPONSE_TIMEOUT_MS,
) -> tuple[CanFrame, int]:
deadline = time.monotonic() + max(1, timeout_ms) / 1_000
while True:
remaining_ms = round((deadline - time.monotonic()) * 1_000)
if remaining_ms <= 0:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for CAN 0x6A2 "
f"sequence={sequence} opcode=0x{opcode:02X}"
)
frame = bus.receive(remaining_ms)
if frame is None:
raise HardwareExecutionError(
f"timed out after {timeout_ms} ms waiting for CAN 0x6A2 "
f"sequence={sequence} opcode=0x{opcode:02X}"
)
if (
frame.arbitration_id != OTA_RESPONSE_ID
or len(frame.data) != 8
or frame.data[0] != OTA_RESPONSE_MAGIC
or frame.data[1] != sequence
or frame.data[2] != opcode
):
continue
status = frame.data[3]
value = int.from_bytes(frame.data[4:8], "little")
if status != 0:
raise HardwareExecutionError(
"seat-ota runtime rejected CAN0 command: "
f"status={OTA_STATUS_NAMES.get(status, f'0x{status:02X}')}, "
f"opcode=0x{opcode:02X}, value=0x{value:08X}",
telemetry={
"response_frame": frame.public(),
"ota_status": status,
"ota_value": value,
},
)
return frame, value
def _send_control(
self,
bus: CanBus,
opcode: int,
selector: int,
argument: int,
) -> tuple[CanFrame, CanFrame, int]:
sequence = self._next_sequence()
payload = bytes(
(OTA_CONTROL_MAGIC, sequence, opcode, selector)
) + int(argument).to_bytes(4, "little")
command_frame = bus.send(OTA_CONTROL_ID, payload)
response_frame, value = self._wait_response(bus, sequence, opcode)
return command_frame, response_frame, value
def _best_effort_stop(self, bus: CanBus) -> None:
try:
self._send_control(
bus,
OTA_OPCODE_SEAT_STOP,
OTA_SEAT_AXIS_ALL,
0,
)
except Exception: # noqa: BLE001 - preserve the primary failure
pass
def _execute_move(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
signals = command["signals"]
legacy_opcode = int(signals["Opcode"])
if legacy_opcode not in {0x01, 0x0B}:
raise HardwareExecutionError(
"wireless seat-ota supports absolute four-axis targets only; "
f"legacy opcode 0x{legacy_opcode:02X} was not sent"
)
axis = int(signals["Motor_Selector"])
if axis not in range(4):
raise HardwareExecutionError("wireless seat axis must be between 0 and 3")
target_x100 = int(command["completion"]["value"])
tolerance_x100 = int(command["completion"].get("tolerance", 100))
target_permille = max(0, min(1_000, round(target_x100 / 10)))
preflight = self._receive_axis(bus, axis, OTA_RESPONSE_TIMEOUT_MS)
if preflight["fault"] or not preflight["position_valid"]:
raise HardwareExecutionError(
f"wireless seat axis {axis} is faulted or position-invalid before send",
telemetry={"preflight_frame": preflight["frame"].public()},
)
command_frame, response_frame, job_state = self._send_control(
bus,
OTA_OPCODE_SEAT_MOVE,
axis,
target_permille,
)
deadline = time.monotonic() + int(command["completion"]["timeout_ms"]) / 1_000
next_heartbeat = time.monotonic() + OTA_HEARTBEAT_INTERVAL_MS / 1_000
feedback = preflight
while True:
now = time.monotonic()
if now >= deadline:
raise HardwareExecutionError(
f"wireless seat axis {axis} did not reach target "
f"{target_x100}±{tolerance_x100}",
telemetry={"last_feedback_frame": feedback["frame"].public()},
)
wait_until = min(deadline, next_heartbeat)
frame = bus.receive(max(1, round((wait_until - now) * 1_000)))
state = self._axis_state(frame) if frame is not None else None
if state is not None and state["axis"] == axis:
feedback = state
if state["fault"]:
raise HardwareExecutionError(
f"wireless seat axis {axis} reported a driver fault",
telemetry={"feedback_frame": state["frame"].public()},
)
if (
state["position_valid"]
and not state["moving"]
and abs(state["value_x100"] - target_x100) <= tolerance_x100
):
break
if time.monotonic() >= next_heartbeat:
self._send_control(
bus,
OTA_OPCODE_SEAT_HEARTBEAT,
axis,
0,
)
next_heartbeat = (
time.monotonic() + OTA_HEARTBEAT_INTERVAL_MS / 1_000
)
output = []
for operation in operations:
output.append(
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
"seat-ota 0x6A2 accepted the MOVE lease and fresh CAN0 "
"position telemetry confirmed the target"
),
"telemetry": {
"motion_axis": operation["capability"].removeprefix(
"seat.set_"
),
"effective_position_pct": round(
feedback["value_x100"] / 100
),
"position_valid": True,
"ota_target_permille": target_permille,
"ota_job_state": job_state,
"command_frame": command_frame.public(),
"response_frame": response_frame.public(),
"feedback_frame": feedback["frame"].public(),
"hardware_feedback": True,
"simulated": False,
},
}
)
return output
def _execute_dynamic(
self,
bus: CanBus,
command: dict[str, Any],
operations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if len(operations) != 1:
raise HardwareExecutionError(
"one wireless seat dynamic command must reference one operation"
)
operation = operations[0]
stopping = operation["capability"] == "seat.stop_dynamic_mode"
mode_id = 0 if stopping else int(operation["parameters"]["mode_id"])
opcode = OTA_OPCODE_SEAT_STOP if stopping else OTA_OPCODE_SEAT_DYNAMIC_START
selector = OTA_SEAT_AXIS_ALL if stopping else mode_id
duration = 0 if stopping else OTA_DYNAMIC_DURATION_SECONDS
command_frame, response_frame, job_state = self._send_control(
bus,
opcode,
selector,
duration,
)
active = bool(job_state & 1)
job_type = (job_state >> 4) & 0x0F
if (stopping and active) or (not stopping and (not active or job_type != 4)):
raise HardwareExecutionError(
"seat-ota response did not confirm the requested dynamic state",
telemetry={
"response_frame": response_frame.public(),
"ota_job_state": job_state,
},
)
return [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded",
"message": (
"seat-ota 0x6A2 confirmed dynamic mode stopped"
if stopping
else "seat-ota 0x6A2 confirmed the bounded dynamic mode is active"
),
"telemetry": {
"dynamic_mode_id": mode_id,
"dynamic_mode_name": (
"" if stopping else "木马模式" if mode_id == 1 else "动态摇篮模式"
),
"dynamic_state": 0 if stopping else 1,
"repeat": "none" if stopping else "bounded_runtime",
"max_runtime_ms": 0 if stopping else duration * 1_000,
"ota_job_state": job_state,
"command_frame": command_frame.public(),
"response_frame": response_frame.public(),
"hardware_feedback": True,
"simulated": False,
},
}
]
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
del actuator_state
started_at = _now()
operations = {
item["id"]: item for item in function_config["spec"]["operations"]
}
results: list[dict[str, Any]] = []
bus: CanBus | None = None
motion_armed = False
physical_state: dict[str, Any] | None = None
with HardwareSeatExecutor._adapter_lock: # noqa: SLF001 - shared CAN0 owner
try:
bus = self.bus_factory()
previous_offset = 0
for command in command_plan["spec"]["commands"]:
offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset:
time.sleep((offset - previous_offset) / 1_000)
previous_offset = offset
referenced = [
operations[item]
for item in command["operation_ref"].split(",")
]
if command["message"] != "T2G_Seat_Move_Command_300":
raise HardwareExecutionError(
"seat-ota firmware does not provide wireless thermal control; "
f"{command['message']} was not sent"
)
opcode = int(command["signals"]["Opcode"])
if opcode in {0x0E, 0x0F}:
motion_armed = opcode == 0x0E
results.extend(
self._execute_dynamic(bus, command, referenced)
)
motion_armed = False
else:
motion_armed = True
results.extend(self._execute_move(bus, command, referenced))
motion_armed = False
except Exception as exc: # noqa: BLE001 - normalize protocol failures
if bus is not None and motion_armed:
self._best_effort_stop(bus)
reported = {item["ref"] for item in results}
operation_ref = next(
(item for item in operations if item not in reported),
next(iter(operations)),
)
results.append(
{
"ref": operation_ref,
"domain": "seat",
"status": "failed",
"message": str(exc),
"telemetry": {
**getattr(exc, "telemetry", {}),
"error_type": type(exc).__name__,
"stop_all_attempted": bool(bus is not None and motion_armed),
"simulated": False,
},
}
)
finally:
if bus is not None:
try:
physical_state = self.status_executor._capture_physical_state( # noqa: SLF001
bus,
results,
)
physical_state["source"] = "wireless_mqtt_ch0"
except Exception: # noqa: BLE001 - keep command outcome primary
physical_state = None
bus.close()
succeeded = bool(results) and all(
item["status"] == "succeeded" for item in results
)
request_id = function_config["metadata"]["request_id"]
spec: dict[str, Any] = {
"mode": "hil",
"status": "succeeded" if succeeded else "failed",
"started_at": started_at,
"finished_at": _now(),
"results": results,
}
if physical_state is not None:
spec["physical_state"] = physical_state
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "seat-ota MQTT hardware execution report",
"version": "1.0.0",
"request_id": request_id,
"labels": {
"simulation": "false",
"feedback": "wireless_mqtt_ch0",
},
},
"spec": spec,
}
@@ -2,7 +2,9 @@
The ZLG SDK accepting a transmit request is not evidence that an ECU acted.
This module contains the small, byte-exact subset of CAN_DEV feedback that the
web console is allowed to present as physical state.
web console is allowed to present as physical state. CH1 signal definitions
come from ``project-code/canfd/CAN矩阵表_filled.xlsx`` Sheet1; decoded
summaries embedded in an MQTT envelope are never used here.
"""
from __future__ import annotations
@@ -294,8 +296,43 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
"rear_brightness_level": (
rear_brightness_raw - 1 if 1 <= rear_brightness_raw <= 6 else None
),
"front_color_index": front_color,
"rear_color_index": rear_color,
# Sheet1 rows 1896-1897 define 0x01-0x1F as colors and
# 0x20-0x7F as reserved. Do not expose a reserved raw value
# as though it were a real cabin color.
"front_color_index": (
front_color if 1 <= front_color <= 0x1F else None
),
"rear_color_index": (
rear_color if 1 <= rear_color <= 0x1F else None
),
},
}
return decoded
if frame.arbitration_id == 0x3FF and len(data) >= 8:
# CAN矩阵表_filled.xlsx Sheet1 rows 169, 172 and 173. The
# illuminance signal reserves 0x3FE and uses 0x3FF as its initial
# value, so only the explicitly valid 0x000-0x3FD range is physical.
illuminance_raw = _bits(data, 46, 10)
decoded["domain_state"] = {
"interior_light": {
"power": _state(_bits(data, 28, 2)),
"footwell_power": _state(_bits(data, 35, 2)),
"ambient_illuminance_lux": (
illuminance_raw * 30 if illuminance_raw <= 0x3FD else None
),
}
}
return decoded
if frame.arbitration_id == 0x3F3 and len(data) >= 8:
# CAN矩阵表_filled.xlsx Sheet1 rows 2336 and 2338.
decoded["domain_state"] = {
"interior_light": {
"wall_light_power": _state(_bits(data, 58, 2)),
},
"ambient_light": {
"power": _state(_bits(data, 62, 2), on=2, off=1),
},
}
return decoded
+334 -47
View File
@@ -53,6 +53,9 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
SEAT_CAPABILITIES,
SEAT_MOTION_CAPABILITIES,
)
from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttOtaSeatExecutor,
)
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
from cognitive_agent.application.native_can_relative import ( # noqa: E402
NativeCanRelativeResolver,
@@ -76,6 +79,12 @@ from cognitive_agent.application.vehicle_profile import ( # noqa: E402
VehicleProfile,
VehicleProfileRegistry,
)
from cognitive_agent.application.vehicle_state_monitor import ( # noqa: E402
OBSERVABLE_IDS,
REQUIRED_BODY_IDS,
REQUIRED_SEAT_IDS,
VehicleStateMonitor,
)
from cognitive_agent.application.zcan_seat_executor import ( # noqa: E402
ZcanBridgeSeatExecutor,
)
@@ -89,8 +98,12 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
PHYSICAL_BENCH_COORDINATOR,
SeatHardwareSettings,
VehicleCanSettings,
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig # noqa: E402
from cognitive_agent.infrastructure.can.mqtt_transport import ( # noqa: E402
MqttCanTransport,
)
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
PHYSICAL_BENCH_ACCESS,
)
@@ -117,6 +130,11 @@ PASSENGER_SEAT_SCOPE_PATTERN = re.compile(
r"\bpassenger\b|\bfront[-_ ]?right\b|\bright[-_ ]?front\b|副驾|乘客|右前",
re.IGNORECASE,
)
WIRED_CAN_TRANSPORT = "wired_can"
WIRELESS_MQTT_TRANSPORT = "wireless_mqtt"
VEHICLE_CONTROL_TRANSPORTS = frozenset(
{WIRED_CAN_TRANSPORT, WIRELESS_MQTT_TRANSPORT}
)
def _has_mixed_driver_passenger_seat_scope(utterance: str) -> bool:
@@ -209,6 +227,17 @@ class SeatDemoService:
)
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
self.vehicle_can_settings = VehicleCanSettings.from_env(repository_root)
self.vehicle_mqtt_settings = VehicleMqttSettings.from_env()
self._vehicle_state_monitor = (
VehicleStateMonitor(self.vehicle_can_settings)
if self.vehicle_can_settings.backend == "zlgcan-linux"
and self.vehicle_can_settings.state_configured
and not self.vehicle_can_settings.configured
else None
)
self._vehicle_mqtt_transport = MqttCanTransport(
self.vehicle_mqtt_settings
)
self.native_can_runtime = NativeCanRuntime(repository_root)
self.native_can_relative_resolver = NativeCanRelativeResolver(repository_root)
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
@@ -272,6 +301,22 @@ class SeatDemoService:
and self.vehicle_can_settings.channel == 1
)
def _driver_only_vehicle_execution_active(self, transport: str) -> bool:
if self.hardware_settings.enabled:
return False
if transport == WIRELESS_MQTT_TRANSPORT:
return self.vehicle_mqtt_settings.configured
return self.driver_only_ch1_execution_active
@staticmethod
def _validate_control_transport(transport: str) -> str:
normalized = str(transport).strip().lower()
if normalized not in VEHICLE_CONTROL_TRANSPORTS:
raise ValueError(
"control_transport must be wired_can or wireless_mqtt"
)
return normalized
def runtime_info(self) -> dict[str, Any]:
catalog = yaml.safe_load(
(self.domain_root / "config" / "capabilities.yaml").read_text(encoding="utf-8")
@@ -283,6 +328,15 @@ class SeatDemoService:
hardware_info = self.hardware_settings.public_info()
hardware_info.update(self.hardware_execution.public_info())
hardware_info.update(self.hardware_access.public_info())
vehicle_can_info = self.vehicle_can_settings.public_info()
if self._vehicle_state_monitor is not None:
state_sync_info = self._vehicle_state_monitor.public_info()
if self.hardware_settings.configured:
# Keep the slower legacy cadence when the same page also polls
# the physical passenger-seat path through the shared adapter.
state_sync_info["poll_interval_ms"] = 5_000
vehicle_can_info["state_sync"] = state_sync_info
vehicle_mqtt_info = self._vehicle_mqtt_transport.public_info()
return {
"service": "cabin-cognitive-orchestrator",
"domain": "cabin",
@@ -294,9 +348,27 @@ class SeatDemoService:
"hardware_execution_allowed": (
self.hardware_settings.configured
or self.vehicle_can_settings.configured
or self.vehicle_mqtt_settings.configured
),
"hardware": hardware_info,
"vehicle_can": self.vehicle_can_settings.public_info(),
"vehicle_can": vehicle_can_info,
"vehicle_mqtt": vehicle_mqtt_info,
"vehicle_transports": {
"default": WIRED_CAN_TRANSPORT,
"selection_scope": "browser_request",
"options": [
{
"id": WIRED_CAN_TRANSPORT,
"label": "有线控制",
**vehicle_can_info,
},
{
"id": WIRELESS_MQTT_TRANSPORT,
"label": "无线控制",
**vehicle_mqtt_info,
},
],
},
"vehicle_safety_policy": {
"required_channel": 1,
"required_gear": "P",
@@ -322,6 +394,8 @@ class SeatDemoService:
and self.vehicle_can_settings.configured
else "ch1_body_can_driver_only"
if self.vehicle_can_settings.configured
else "ch1_body_can_read_only"
if self.vehicle_can_settings.state_configured
else "ch0_passenger_only"
if self.hardware_settings.configured
else "virtual_only"
@@ -407,23 +481,54 @@ class SeatDemoService:
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
) -> dict[str, Any]:
"""Read the bench only when no physical execution currently owns it."""
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if hardware_access_bypass:
# Wireless CAN0 is a thread-safe view over the persistent MQTT
# subscription. HardwareSeatExecutor still serializes this seat
# read with seat actuation through its adapter lock, while an
# absent CAN0 stream must not block the independent cached CAN1
# dashboard or make control requests fail with hardware_busy.
if mqtt_selected:
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
if mqtt_selected:
with self.hardware_access.use(authorized_client_id):
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
with self.hardware_access.use(authorized_client_id):
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
return self._synchronize_hardware_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
def _synchronize_hardware_state_once(
self,
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
"""Read the bench and project it into the current passenger conversation."""
@@ -431,10 +536,26 @@ class SeatDemoService:
raise ValueError(
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if mqtt_selected:
if not self.vehicle_mqtt_settings.state_configured:
raise HardwareSafetyError("the MQTT CAN0 seat relay is not configured")
bus_factory = lambda: self._vehicle_mqtt_transport.bus(
channel=0,
replay_latest=True,
)
else:
if not self.hardware_settings.configured:
raise HardwareSafetyError("the T2G seat hardware runtime is not configured")
bus_factory = None
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
physical_state = self._new_hardware_executor().read_physical_state()
physical_state = self._new_hardware_executor(
bus_factory,
remote_transport=mqtt_selected,
).read_physical_state()
if mqtt_selected:
physical_state["source"] = "wireless_mqtt_ch0"
context = self.conversations.synchronize_physical_passenger(
conversation_id,
physical_state,
@@ -444,7 +565,8 @@ class SeatDemoService:
"status": "succeeded",
"conversation_id": conversation_id,
"conversation_turn": context["turn_count"],
"source": "physical_can",
"source": "wireless_mqtt_ch0" if mqtt_selected else "physical_can",
"control_transport": control_transport,
"captured_at": physical_state.get("captured_at"),
"complete": bool(physical_state.get("complete")),
"missing": list(physical_state.get("missing", [])),
@@ -457,38 +579,103 @@ class SeatDemoService:
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
) -> dict[str, Any]:
"""Passively receive CH1 driver-seat feedback without transmitting CAN."""
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if hardware_access_bypass:
# The MQTT snapshot is already decoded and protected by the
# transport condition lock. It is independent from physical
# execution ownership and should remain readable during CAN0
# waits or an in-flight command.
if mqtt_selected:
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
return self._synchronize_vehicle_state_once(conversation_id, vehicle_id)
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
if mqtt_selected:
with self.hardware_access.use(authorized_client_id):
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
with self.hardware_execution.reserve():
with self.hardware_access.use(authorized_client_id):
return self._synchronize_vehicle_state_once(conversation_id, vehicle_id)
return self._synchronize_vehicle_state_once(
conversation_id,
vehicle_id,
control_transport=control_transport,
)
def start(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.start()
self._vehicle_mqtt_transport.start()
def close(self) -> None:
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._vehicle_mqtt_transport.close()
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
vehicle_id: str | None = None,
*,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
raise ValueError(
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
if not self.vehicle_can_settings.configured:
control_transport = self._validate_control_transport(control_transport)
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if mqtt_selected:
if not self.vehicle_mqtt_settings.state_configured:
raise HardwareSafetyError("the MQTT vehicle CAN relay is not configured")
elif not self.vehicle_can_settings.state_configured:
raise HardwareSafetyError("the CH1 vehicle CAN runtime is not configured")
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
shared_runtime_enabled = os.getenv(
shared_runtime_enabled = (
self.vehicle_can_settings.backend != "zlgcan-linux"
and self.vehicle_can_settings.configured
and os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv("COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"),
os.getenv(
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
),
).strip().lower() in {"1", "true", "yes", "on"}
)
bridge = None
if shared_runtime_enabled:
capture_sequence: int | None = None
if mqtt_selected:
snapshot = self._vehicle_mqtt_transport.snapshot()
feedback_states = list(snapshot.feedback_states)
observed_ids = set(snapshot.observed_ids)
captured_at = snapshot.captured_at
capture_sequence = snapshot.capture_sequence
elif self._vehicle_state_monitor is not None:
snapshot = self._vehicle_state_monitor.snapshot()
feedback_states = list(snapshot.feedback_states)
observed_ids = set(snapshot.observed_ids)
captured_at = snapshot.captured_at
capture_sequence = snapshot.capture_sequence
elif shared_runtime_enabled:
from cognitive_agent.application.self_repair.service import BRIDGE_MANAGER
if not hasattr(BRIDGE_MANAGER, "channel_bus_factory"):
@@ -500,35 +687,31 @@ class SeatDemoService:
)()
else:
bridge = self.vehicle_can_settings.bridge(
[self.vehicle_can_settings.channel_config()]
[self.vehicle_can_settings.channel_config()],
monitor_only=True,
)
bus = bridge.channel_bus(self.vehicle_can_settings.channel)
feedback_states: list[dict[str, Any]] = []
observed_ids: set[int] = set()
if not mqtt_selected and self._vehicle_state_monitor is None:
feedback_states = []
observed_ids = set()
captured_at = None
# Keep driver-seat completeness compatible with the existing endpoint,
# while passively collecting every recognized CH1 body-state frame for
# the whole-vehicle dashboard during the same bounded receive window.
required_seat_ids = {0x4FA, 0x494}
required_body_ids = {0x342, 0x4EB, 0x407}
observable_ids = {
*required_seat_ids,
*required_body_ids,
0x388,
0x404,
0x41A,
0x42E,
}
deadline = time.monotonic() + self.vehicle_can_settings.feedback_timeout_ms / 1000
deadline = (
time.monotonic()
+ self.vehicle_can_settings.feedback_timeout_ms / 1000
)
try:
while time.monotonic() < deadline and observed_ids != observable_ids:
while time.monotonic() < deadline and observed_ids != OBSERVABLE_IDS:
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
frame = bus.receive(remaining_ms, include_fd=True)
if frame is None:
break
if frame.channel != self.vehicle_can_settings.channel:
continue
if frame.arbitration_id not in observable_ids or len(frame.data) < 8:
if frame.arbitration_id not in OBSERVABLE_IDS or len(frame.data) < 8:
continue
decoded = decode_feedback(frame)
if not any(
@@ -557,6 +740,7 @@ class SeatDemoService:
)
feedback_states.append(decoded)
observed_ids.add(frame.arbitration_id)
captured_at = str(decoded["captured_at"])
finally:
if bridge is not None:
bridge.close()
@@ -570,19 +754,23 @@ class SeatDemoService:
context = self._context_for_vehicle(context, vehicle_profile)
missing = [
f"0x{frame_id:03X}"
for frame_id in sorted(required_seat_ids - observed_ids)
for frame_id in sorted(REQUIRED_SEAT_IDS - observed_ids)
]
body_state_missing = [
f"0x{frame_id:03X}"
for frame_id in sorted(required_body_ids - observed_ids)
for frame_id in sorted(REQUIRED_BODY_IDS - observed_ids)
]
return {
"status": "succeeded",
"conversation_id": conversation_id,
"conversation_turn": context["turn_count"],
"source": "physical_can_ch1",
"source": (
"wireless_mqtt_ch1" if mqtt_selected else "physical_can_ch1"
),
"control_transport": control_transport,
"read_only": True,
"captured_at": feedback_states[-1].get("captured_at") if feedback_states else _now(),
"captured_at": captured_at or _now(),
"capture_sequence": capture_sequence,
"complete": not missing,
"missing": missing,
"body_state_complete": not body_state_missing,
@@ -593,8 +781,11 @@ class SeatDemoService:
}
def _new_hardware_executor(
self, bus_factory: Any | None = None
) -> HardwareSeatExecutor:
self,
bus_factory: Any | None = None,
*,
remote_transport: bool = False,
) -> HardwareSeatExecutor | MqttOtaSeatExecutor | ZcanBridgeSeatExecutor:
shared_bridge_enabled = os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv("COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"),
@@ -624,7 +815,7 @@ class SeatDemoService:
auto_home=self.hardware_settings.auto_home_enabled,
)
resolved_bus_factory = bus_factory or self.hardware_settings.bus_factory()
return HardwareSeatExecutor(
executor = HardwareSeatExecutor(
self.hardware_matrix,
resolved_bus_factory,
thermal_timeout_ms=self.hardware_settings.thermal_timeout_ms,
@@ -644,12 +835,19 @@ class SeatDemoService:
),
auto_home=self.hardware_settings.auto_home_enabled,
home_timeout_s=self.hardware_settings.home_timeout_s,
firmware_guard=self.hardware_settings.firmware_guard(resolved_bus_factory),
firmware_guard=self.hardware_settings.firmware_guard(
resolved_bus_factory,
allow_programming=not remote_transport,
),
synchronize_position_before_first_motion=(
self.hardware_settings.position_sync_enabled
),
position_sync_state=self._seat_position_memory_state,
require_thermal_feedback=not remote_transport,
)
if remote_transport:
return MqttOtaSeatExecutor(executor, resolved_bus_factory)
return executor
def process(
self,
@@ -664,7 +862,9 @@ class SeatDemoService:
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
# and validate it in dry-run even when the console currently targets
# the real bench, otherwise virtual domains such as climate or ambient
@@ -681,8 +881,11 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
)
if hardware_access_bypass or self.driver_only_ch1_execution_active:
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
):
with self.hardware_execution.reserve():
return self._process_once(
utterance,
@@ -693,6 +896,7 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -706,6 +910,7 @@ class SeatDemoService:
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
)
def _process_once(
@@ -718,7 +923,9 @@ class SeatDemoService:
vehicle_id: str | None = None,
simulation_fault: str = "normal",
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
if not utterance:
raise ValueError("utterance must not be empty")
@@ -776,6 +983,7 @@ class SeatDemoService:
vehicle_profile,
simulation_fault,
mode_create_name,
control_transport,
)
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
@@ -798,6 +1006,7 @@ class SeatDemoService:
"reasoning_effort_requested": effective_reasoning_effort,
"reasoning_effort_used": None,
"execution_mode": execution_mode,
"control_transport": control_transport,
"hardware_execution_allowed": (
hardware_request and not control_plane_request
),
@@ -899,7 +1108,14 @@ class SeatDemoService:
parsed = apply_language_safety_guards(parsed, utterance)
parsed = self._partition_native_actions(
parsed,
promote_supported=(hardware_request and self.vehicle_can_settings.enabled),
promote_supported=(
hardware_request
and (
self.vehicle_mqtt_settings.enabled
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.enabled
)
),
)
if hardware_request and not control_plane_request:
if parsed.get("domain_actions"):
@@ -914,18 +1130,30 @@ class SeatDemoService:
+ ", ".join(unsupported)
)
if parsed.get("actions"):
self.hardware_settings.authorize(approval_token)
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.hardware_settings
).authorize(approval_token)
if parsed.get("native_can_actions"):
self.vehicle_can_settings.authorize(approval_token)
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings
).authorize(approval_token)
if not parsed.get("actions") and not parsed.get("native_can_actions"):
raise HardwareSafetyError(
"hardware mode requires at least one verified seat or vehicle CAN operation"
)
if parsed.get("actions") and parsed.get("native_can_actions"):
if self.hardware_settings.backend != "zlgcan":
if (
control_transport == WIRED_CAN_TRANSPORT
and self.hardware_settings.backend != "zlgcan"
):
raise HardwareSafetyError(
"linked seat/vehicle execution requires the shared zlgcan backend"
)
if control_transport == WIRED_CAN_TRANSPORT:
try:
seat_channel = int(self.hardware_settings.channel, 0)
except ValueError as exc:
@@ -1052,6 +1280,7 @@ class SeatDemoService:
simulated_hardware_request=simulated_hardware_request,
simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
)
intent_ir = build_intent_ir(
@@ -1366,10 +1595,41 @@ class SeatDemoService:
execute_started = time.perf_counter()
if hardware_request:
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
vehicle_safety_preflight = None
if mqtt_selected:
try:
vehicle_safety_preflight = observe_vehicle_safety(
self._vehicle_mqtt_transport.bus(channel=1),
timeout_ms=min(
5_000,
max(1_000, self.vehicle_mqtt_settings.feedback_timeout_ms),
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
raise HardwareSafetyError(
"CAN1 vehicle safety preflight refused wireless passenger-seat execution: "
f"{exc}"
) from exc
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
)
else:
hardware_executor = self._new_hardware_executor()
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
if vehicle_safety_preflight is not None:
execution_report["spec"]["vehicle_safety_preflight"] = (
vehicle_safety_preflight
)
for item in execution_report["spec"].get("results", []):
item.setdefault("telemetry", {})[
"vehicle_safety_preflight"
] = vehicle_safety_preflight
elif simulated_hardware_request:
execution_report = self.simulated_hardware_executor.execute(
command_plan,
@@ -2481,6 +2741,7 @@ class SeatDemoService:
vehicle_profile: VehicleProfile,
simulation_fault: str,
mode_name: str,
control_transport: str,
) -> dict[str, Any]:
"""Build, verify, and persist a new named mode in one user turn.
@@ -2502,6 +2763,7 @@ class SeatDemoService:
approval_token,
vehicle_profile.id,
simulation_fault,
control_transport=control_transport,
)
if source_result.get("status") not in {"succeeded", "partially_succeeded"}:
source_result["decision_summary"] = [
@@ -2519,6 +2781,7 @@ class SeatDemoService:
None,
vehicle_profile.id,
"normal",
control_transport=control_transport,
)
if saved_result.get("status") != "succeeded" or not saved_result.get(
"mode_profile"
@@ -2887,6 +3150,7 @@ class SeatDemoService:
simulated_hardware_request: bool = False,
simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
"""Run isolated adapters under the selected physical-bus policy."""
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
@@ -3194,6 +3458,7 @@ class SeatDemoService:
hardware_request=hardware_request,
simulated_hardware_request=simulated_hardware_request,
simulation_fault=simulation_fault,
control_transport=control_transport,
)
report_results = [
item
@@ -3470,21 +3735,33 @@ class SeatDemoService:
hardware_request: bool,
simulated_hardware_request: bool,
simulation_fault: str,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, dict[str, Any]]:
reports: dict[str, dict[str, Any]] = {}
bridge = None
seat_bus_factory = None
native_bus_factory = None
vehicle_safety_preflight: dict[str, Any] | None = None
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
if hardware_request and native_config_keys:
shared_runtime_enabled = os.getenv(
if hardware_request and (
native_config_keys or (mqtt_selected and "seat" in configs)
):
shared_runtime_enabled = (
not mqtt_selected
and self.vehicle_can_settings.backend != "zlgcan-linux"
and os.getenv(
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
os.getenv(
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
),
).strip().lower() in {"1", "true", "yes", "on"}
if shared_runtime_enabled:
)
if mqtt_selected:
native_bus_factory = lambda: self._vehicle_mqtt_transport.bus(channel=1)
if "seat" in configs:
seat_bus_factory = lambda: self._vehicle_mqtt_transport.bus(channel=0)
elif shared_runtime_enabled:
from cognitive_agent.application.self_repair.service import (
BRIDGE_MANAGER,
)
@@ -3531,9 +3808,14 @@ class SeatDemoService:
native_bus_factory(),
timeout_ms=min(
5_000,
max(1_000, self.vehicle_can_settings.feedback_timeout_ms),
max(
1_000,
self.vehicle_mqtt_settings.feedback_timeout_ms
if mqtt_selected
else self.vehicle_can_settings.feedback_timeout_ms,
),
required_channel=self.vehicle_can_settings.channel,
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
@@ -3560,7 +3842,9 @@ class SeatDemoService:
executor = NativeCanExecutor(
native_bus_factory,
feedback_timeout_ms=(
self.vehicle_can_settings.feedback_timeout_ms
self.vehicle_mqtt_settings.feedback_timeout_ms
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.feedback_timeout_ms
),
)
native_reports[key] = executor.execute(
@@ -3581,7 +3865,10 @@ class SeatDemoService:
config = configs["seat"]
if hardware_request:
seat_executor = (
self._new_hardware_executor(seat_bus_factory)
self._new_hardware_executor(
seat_bus_factory,
remote_transport=mqtt_selected,
)
if seat_bus_factory is not None
else self._new_hardware_executor()
)
@@ -176,7 +176,7 @@ def _build_bridge_manager():
termination_enabled=seat.termination_enabled,
)
]
if vehicle.enabled:
if vehicle.configured and vehicle.backend == "zlgcan":
if vehicle.channel == seat_channel:
raise ValueError("seat CH0 and vehicle CH1 must use different channels")
if (
@@ -0,0 +1,244 @@
"""Persistent read-only CH1 vehicle-state receiver for the Linux ZLG backend."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.native_can_feedback import decode_feedback
from cognitive_agent.infrastructure.can.gateway import CanFrame, CanGatewayError
from cognitive_agent.infrastructure.can.hardware import VehicleCanSettings
REQUIRED_SEAT_IDS = frozenset({0x4FA, 0x494})
REQUIRED_BODY_IDS = frozenset({0x342, 0x4EB, 0x407})
REQUIRED_UI_IDS = REQUIRED_SEAT_IDS | REQUIRED_BODY_IDS
OBSERVABLE_IDS = REQUIRED_UI_IDS | frozenset(
{0x388, 0x3F3, 0x3FF, 0x404, 0x41A, 0x42E}
)
PERSISTENT_MONITOR_POLL_INTERVAL_MS = 500
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
@dataclass(frozen=True)
class VehicleStateSnapshot:
feedback_states: tuple[dict[str, Any], ...]
observed_ids: frozenset[int]
captured_at: str | None
capture_sequence: int
class VehicleStateMonitor:
"""Keep one monitor-only adapter owner online and cache fresh decoded state."""
def __init__(
self,
settings: VehicleCanSettings,
*,
decoder: Callable[[CanFrame], dict[str, Any]] = decode_feedback,
reconnect_delay_s: float = 1.0,
) -> None:
self.settings = settings
self.decoder = decoder
self.reconnect_delay_s = max(0.01, float(reconnect_delay_s))
self.initial_snapshot_timeout_s = max(0, settings.feedback_timeout_ms) / 1000
self.freshness_s = max(3.0, self.initial_snapshot_timeout_s * 2)
self._condition = threading.Condition(threading.RLock())
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._bridge: Any | None = None
self._latest: dict[int, tuple[float, dict[str, Any]]] = {}
self._baseline_ready = False
self._capture_sequence = 0
self._state = "idle"
self._last_error: str | None = None
def start(self) -> None:
with self._condition:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._state = "connecting"
self._thread = threading.Thread(
target=self._run,
name="zlg-linux-vehicle-state-monitor",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop_event.set()
with self._condition:
self._condition.notify_all()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
with self._condition:
bridge = self._bridge
if thread is not None and thread.is_alive() and bridge is not None:
bridge.close()
thread.join(timeout=1.0)
with self._condition:
if self._thread is None or not self._thread.is_alive():
self._state = "stopped"
self._thread = None
self._condition.notify_all()
def snapshot(self) -> VehicleStateSnapshot:
"""Return cached fresh state, waiting only for the first required baseline."""
self.start()
deadline = time.monotonic() + self.initial_snapshot_timeout_s
with self._condition:
while not self._baseline_ready:
fresh_ids = self._fresh_ids(time.monotonic())
if REQUIRED_UI_IDS <= fresh_ids:
self._baseline_ready = True
break
if self._state == "error" and not fresh_ids:
raise CanGatewayError(
self._last_error or "Linux vehicle-state monitor is unavailable"
)
remaining_s = deadline - time.monotonic()
if remaining_s <= 0 or self._stop_event.is_set():
break
self._condition.wait(timeout=remaining_s)
now = time.monotonic()
observed_ids = self._fresh_ids(now)
if self._state == "error" and not observed_ids:
raise CanGatewayError(
self._last_error or "Linux vehicle-state monitor is unavailable"
)
feedback_states = tuple(
deepcopy(self._latest[frame_id][1])
for frame_id in sorted(observed_ids)
)
captured_at = max(
(
str(state.get("captured_at"))
for state in feedback_states
if state.get("captured_at")
),
default=None,
)
return VehicleStateSnapshot(
feedback_states=feedback_states,
observed_ids=frozenset(observed_ids),
captured_at=captured_at,
capture_sequence=self._capture_sequence,
)
def public_info(self) -> dict[str, Any]:
with self._condition:
return {
"mode": "persistent_monitor",
"state": self._state,
"read_only": True,
"poll_interval_ms": PERSISTENT_MONITOR_POLL_INTERVAL_MS,
"snapshot_freshness_ms": round(self.freshness_s * 1000),
"capture_sequence": self._capture_sequence,
"observed_frame_ids": [
f"0x{frame_id:03X}"
for frame_id in sorted(self._fresh_ids(time.monotonic()))
],
"last_error": self._last_error,
}
def _fresh_ids(self, now: float) -> set[int]:
return {
frame_id
for frame_id, (captured_monotonic, _) in self._latest.items()
if now - captured_monotonic <= self.freshness_s
}
def _record(self, frame: CanFrame) -> None:
if (
frame.channel != self.settings.channel
or frame.arbitration_id not in OBSERVABLE_IDS
or len(frame.data) < 8
):
return
decoded = self.decoder(frame)
if not any(
key in decoded
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
# 0x4EB is multiplexed; only subtype 0x07 contains verified speed.
return
frame_id = str(decoded.get("frame_id", ""))
decoded.update(
{
"valid": True,
"feedback_frame_id": frame_id,
"captured_at": _now(),
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"frame_id": frame_id,
"payload_size": len(frame.data),
},
}
)
with self._condition:
self._capture_sequence += 1
self._latest[frame.arbitration_id] = (time.monotonic(), decoded)
if REQUIRED_UI_IDS <= self._fresh_ids(time.monotonic()):
self._baseline_ready = True
self._condition.notify_all()
def _run(self) -> None:
while not self._stop_event.is_set():
bridge = None
try:
with self._condition:
self._state = "connecting"
self._condition.notify_all()
channel_config = replace(
self.settings.channel_config(),
receive_ids=tuple(sorted(OBSERVABLE_IDS)),
)
bridge = self.settings.bridge(
[channel_config],
monitor_only=True,
)
bus = bridge.channel_bus(self.settings.channel)
with self._condition:
self._bridge = bridge
self._latest.clear()
self._baseline_ready = False
self._last_error = None
self._state = "online"
self._condition.notify_all()
while not self._stop_event.is_set():
frame = bus.receive(100, include_fd=True)
if frame is not None:
self._record(frame)
except Exception as exc: # noqa: BLE001 - normalize vendor/runtime failures
if not self._stop_event.is_set():
with self._condition:
self._state = "error"
self._last_error = str(exc)
self._condition.notify_all()
finally:
with self._condition:
if self._bridge is bridge:
self._bridge = None
if bridge is not None:
bridge.close()
if not self._stop_event.wait(self.reconnect_delay_s):
continue
with self._condition:
self._state = "stopped"
self._condition.notify_all()
@@ -129,13 +129,26 @@ class ZlgChannelConfig:
data_bitrate: int = 2_000_000
can_fd_iso: bool = True
termination_enabled: bool = True
receive_ids: tuple[int, ...] | None = None
def __post_init__(self) -> None:
if self.frame_format not in {"classic_can", "can_fd"}:
raise ValueError("frame_format must be classic_can or can_fd")
if self.receive_ids is not None:
if not 1 <= len(self.receive_ids) <= 64:
raise ValueError("receive_ids must contain between 1 and 64 identifiers")
if len(self.receive_ids) != len(set(self.receive_ids)):
raise ValueError("receive_ids must not contain duplicates")
if any(
isinstance(frame_id, bool)
or not isinstance(frame_id, int)
or not 0 <= frame_id <= 0x7FF
for frame_id in self.receive_ids
):
raise ValueError("receive_ids must contain only 11-bit identifiers")
def public(self) -> dict[str, Any]:
return {
value = {
"channel": self.channel,
"frame_format": self.frame_format,
"arbitration_bitrate": self.arbitration_bitrate,
@@ -143,6 +156,9 @@ class ZlgChannelConfig:
"can_fd_iso": self.can_fd_iso,
"termination_enabled": self.termination_enabled,
}
if self.receive_ids is not None:
value["receive_ids"] = list(self.receive_ids)
return value
class ZlgCanBridgeProcess:
@@ -157,6 +173,8 @@ class ZlgCanBridgeProcess:
device_index: int,
channels: list[ZlgChannelConfig],
transmit_type: int = 0,
monitor_only: bool = False,
trace_all: bool = False,
):
if not channels:
raise CanGatewayError("at least one ZLG channel must be configured")
@@ -179,6 +197,10 @@ class ZlgCanBridgeProcess:
"--transmit-type",
str(transmit_type),
]
if monitor_only or trace_all:
command.append("--monitor-only")
if trace_all:
command.append("--trace-all")
try:
self._process = subprocess.Popen( # noqa: S603 - paths are trusted server config
command,
@@ -4,7 +4,9 @@ from __future__ import annotations
import hashlib
import hmac
import importlib.util
import os
import sys
import threading
import time
from contextlib import contextmanager
@@ -114,6 +116,13 @@ def _env_int(name: str, default: int) -> int:
raise HardwareSafetyError(f"{name} must be an integer") from exc
def _module_available(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except ModuleNotFoundError:
return False
def _profiles_from_env() -> frozenset[str]:
raw = os.getenv(
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES",
@@ -405,12 +414,19 @@ class SeatHardwareSettings:
)
def firmware_guard(
self, bus_factory: Callable[[], CanBus] | None = None
self,
bus_factory: Callable[[], CanBus] | None = None,
*,
allow_programming: bool = True,
) -> T2GFirmwareGuard:
return T2GFirmwareGuard(
bus_factory or self.bus_factory(),
self._programmer() if self.auto_flash_enabled else None,
auto_flash=self.auto_flash_enabled,
(
self._programmer()
if self.auto_flash_enabled and allow_programming
else None
),
auto_flash=self.auto_flash_enabled and allow_programming,
probe_timeout_ms=self.firmware_probe_timeout_ms,
boot_wait_ms=self.firmware_boot_wait_ms,
)
@@ -500,6 +516,52 @@ class VehicleCanSettings:
@classmethod
def from_env(cls, repository_root: Path) -> "VehicleCanSettings":
backend = os.getenv(
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
).strip().lower()
linux_vci = backend == "zlgcan-linux"
default_linux_library = (
repository_root.parent / "project-code" / "canfd" / "libusbcanfd.so"
)
if linux_vci:
zlg_python = Path(
os.getenv("COGNITIVE_AGENT_ZLGCAN_PYTHON", sys.executable)
)
zlg_library = Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY",
str(default_linux_library),
)
)
bridge_script = (
repository_root
/ "src"
/ "cognitive_agent"
/ "infrastructure"
/ "can"
/ "zlgcan_linux_bridge.py"
)
else:
zlg_python = Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
)
)
zlg_library = Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_DLL",
r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
)
)
bridge_script = (
repository_root
/ "src"
/ "cognitive_agent"
/ "infrastructure"
/ "can"
/ "zlgcan_bridge.py"
)
return cls(
repository_root=repository_root,
enabled=_env_bool(
@@ -514,9 +576,7 @@ class VehicleCanSettings:
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
),
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
backend=os.getenv(
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
).strip().lower(),
backend=backend,
channel=_env_int("COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL", 1),
arbitration_bitrate=_env_int(
"COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE", 500_000
@@ -539,47 +599,42 @@ class VehicleCanSettings:
feedback_timeout_ms=_env_int(
"COGNITIVE_AGENT_VEHICLE_CAN_FEEDBACK_TIMEOUT_MS", 1_500
),
device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41),
device_type=_env_int(
"COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 33 if linux_vci else 41
),
device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0),
zlg_python=Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
)
),
zlg_dll=Path(
os.getenv(
"COGNITIVE_AGENT_ZLGCAN_DLL",
r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
)
),
bridge_script=repository_root
/ "src"
/ "cognitive_agent"
/ "infrastructure"
/ "can"
/ "zlgcan_bridge.py",
zlg_python=zlg_python,
zlg_dll=zlg_library,
bridge_script=bridge_script,
)
@property
def adapter_configured(self) -> bool:
return bool(
self.backend == "zlgcan"
self.backend in {"zlgcan", "zlgcan-linux"}
and self.zlg_python.is_file()
and self.zlg_dll.is_file()
and self.bridge_script.is_file()
)
@property
def configured(self) -> bool:
def state_configured(self) -> bool:
"""Whether authoritative read-only CH1 synchronization can run."""
return bool(
self.enabled
and (not self.approval_required or self.approval_token)
and self.adapter_configured
and self.timing_is_verified
and self.channel_is_allowed
)
@property
def configured(self) -> bool:
return bool(
self.state_configured
and (not self.approval_required or self.approval_token)
)
@property
def required_channel(self) -> int:
"""Physical body-CAN channel selected by the fail-closed bench policy."""
@@ -617,9 +672,9 @@ class VehicleCanSettings:
supplied_token, self.approval_token
):
raise HardwareSafetyError("hardware approval token is invalid")
if self.backend != "zlgcan":
if self.backend not in {"zlgcan", "zlgcan-linux"}:
raise HardwareSafetyError(
"vehicle CAN FD currently requires the zlgcan multi-channel backend"
"vehicle CAN FD requires the zlgcan or zlgcan-linux backend"
)
if not self.channel_is_allowed:
raise HardwareSafetyError(
@@ -648,7 +703,13 @@ class VehicleCanSettings:
termination_enabled=self.termination_enabled,
)
def bridge(self, channels: list[ZlgChannelConfig]) -> ZlgCanBridgeProcess:
def bridge(
self,
channels: list[ZlgChannelConfig],
*,
monitor_only: bool = False,
trace_all: bool = False,
) -> ZlgCanBridgeProcess:
return ZlgCanBridgeProcess(
python_executable=self.zlg_python,
bridge_script=self.bridge_script,
@@ -656,12 +717,15 @@ class VehicleCanSettings:
device_type=self.device_type,
device_index=self.device_index,
channels=channels,
monitor_only=monitor_only,
trace_all=trace_all,
)
def public_info(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"configured": self.configured,
"state_configured": self.state_configured,
"backend": self.backend,
"platform": "t2g_cabin_bench",
"channel": self.channel,
@@ -699,3 +763,125 @@ class VehicleCanSettings:
"source_branch": "project-code/CAN_DEV",
"source_commit": "6e7ed04a468dfcde7efda9e30bc15144bf02faf9",
}
@dataclass(frozen=True)
class VehicleMqttSettings:
"""Wireless CAN0/CAN1 relay settings with the same physical approval gate."""
enabled: bool
approval_required: bool
approval_token: str
host: str
port: int
condition_topic: str
control_topic: str
qos: int
keepalive_s: int
username: str | None
password: str | None
tls_enabled: bool
feedback_timeout_ms: int
@classmethod
def from_env(cls) -> "VehicleMqttSettings":
username = os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_USERNAME")
return cls(
enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", default=True),
approval_required=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
),
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
host=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_HOST", "127.0.0.1").strip(),
port=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_PORT", 1883),
condition_topic=os.getenv(
"COGNITIVE_AGENT_VEHICLE_MQTT_CONDITION_TOPIC",
"n7/n7-headunit/can/state",
).strip(),
control_topic=os.getenv(
"COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC",
"n7/can_control",
).strip(),
qos=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_QOS", 1),
keepalive_s=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_KEEPALIVE_S", 30),
username=username.strip() if username and username.strip() else None,
password=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_PASSWORD"),
tls_enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_TLS"),
feedback_timeout_ms=_env_int(
"COGNITIVE_AGENT_VEHICLE_MQTT_FEEDBACK_TIMEOUT_MS", 1_500
),
)
@property
def adapter_configured(self) -> bool:
return bool(
self.host
and 1 <= self.port <= 65_535
and self.condition_topic
and self.control_topic
and self.condition_topic != self.control_topic
and self.qos in {0, 1, 2}
and 5 <= self.keepalive_s <= 3_600
and 100 <= self.feedback_timeout_ms <= 10_000
and _module_available("paho.mqtt.client")
)
@property
def state_configured(self) -> bool:
return self.enabled and self.adapter_configured
@property
def configured(self) -> bool:
return bool(
self.state_configured
and (not self.approval_required or self.approval_token)
)
def authorize(self, supplied_token: str | None) -> None:
if not self.enabled:
raise HardwareSafetyError(
"vehicle MQTT execution is disabled; set "
"COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED=true"
)
if self.approval_required:
if not self.approval_token:
raise HardwareSafetyError(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN is not configured"
)
if not supplied_token or not hmac.compare_digest(
supplied_token, self.approval_token
):
raise HardwareSafetyError("hardware approval token is invalid")
if not self.adapter_configured:
raise HardwareSafetyError("the configured MQTT CAN relay is not ready")
def public_info(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"configured": self.configured,
"state_configured": self.state_configured,
"backend": "mqtt",
"host": self.host,
"port": self.port,
"condition_topic": self.condition_topic,
"control_topic": self.control_topic,
"qos": self.qos,
"tls_enabled": self.tls_enabled,
"authentication_configured": bool(self.username),
"channels": {"seat": 0, "vehicle": 1},
"target_policy": "dual_channel_mqtt_can0_passenger_can1_vehicle",
"frame_contract": "n7.can.frame.v1",
"can0_seat_protocol": "seat-ota-runtime",
"can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302",
"can0_control_id": "0x6A0",
"can0_control_opcodes": ["0x63", "0x64", "0x65", "0x66"],
"can0_forbidden_workflows": [
"flash",
"bootloader_entry",
"test",
"calibration",
"homing",
],
"approval_required": self.approval_required,
"approval_configured": bool(self.approval_token),
}
@@ -0,0 +1,836 @@
"""MQTT transport for relaying validated raw CAN0/CAN1 frames.
The MQTT topics carry CAN frames, never decoded vehicle-state patches. Every
incoming frame is therefore subjected to the same allow-list and byte decoder
as a frame received from the wired ZLG adapter.
CAN0 is the classic-CAN passenger-seat protocol defined by
``/home/orin/project511/seat-code-file/seat-ota`` and remains raw for the seat
domain decoder. CAN1 is the rest-of-vehicle protocol defined by
``/home/orin/project511/project-code/canfd/CAN矩阵表_filled.xlsx`` and is
decoded by :mod:`cognitive_agent.application.native_can_feedback`.
"""
from __future__ import annotations
import json
import math
import threading
import time
import uuid
from collections import deque
from collections.abc import Callable
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.native_can_feedback import decode_feedback
from cognitive_agent.application.vehicle_state_monitor import (
OBSERVABLE_IDS,
REQUIRED_UI_IDS,
VehicleStateSnapshot,
)
from cognitive_agent.infrastructure.can.gateway import (
CanFrame,
CanGatewayError,
)
from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_POLL_INTERVAL_MS = 500
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
MQTT_SEAT_FEEDBACK_IDS = frozenset({*range(0x301, 0x308), 0x4FA, 0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x6A0}),
1: frozenset({0x1DE, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
}
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
_ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id")
_DATA_KEYS = ("data_hex", "dataHex", "data", "payload", "bytes")
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _parse_frame_id(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("CAN frame ID must not be boolean") # noqa: TRY004
if isinstance(value, int):
frame_id = value
elif isinstance(value, str):
raw = value.strip()
if not raw:
raise ValueError("CAN frame ID is empty")
if raw.lower().startswith("0x") or any(character in "abcdefABCDEF" for character in raw):
frame_id = int(raw, 16)
else:
frame_id = int(raw, 10)
else:
raise ValueError("CAN frame ID must be an integer or string") # noqa: TRY004
if not 0 <= frame_id <= 0x7FF:
raise ValueError("only 11-bit CAN frame IDs are accepted")
return frame_id
def _parse_data(value: Any) -> bytes:
if isinstance(value, list):
if not value or len(value) > 64:
raise ValueError("CAN data must contain between 1 and 64 bytes")
if any(
isinstance(item, bool) or not isinstance(item, int) or not 0 <= item <= 255
for item in value
):
raise ValueError("CAN data array must contain only byte integers")
return bytes(value)
if not isinstance(value, str):
raise ValueError("CAN data must be a hex string or byte array") # noqa: TRY004
compact = value.strip().replace("0x", "").replace("0X", "")
for separator in (" ", ":", "-", ",", "_"):
compact = compact.replace(separator, "")
if not compact or len(compact) % 2 or len(compact) > 128:
raise ValueError("CAN data hex must encode between 1 and 64 bytes")
try:
return bytes.fromhex(compact)
except ValueError as exc:
raise ValueError("CAN data contains invalid hexadecimal bytes") from exc
def _parse_channel(value: Any) -> int:
if isinstance(value, bool):
raise ValueError("CAN channel must not be boolean") # noqa: TRY004
if isinstance(value, str):
raw = value.strip().lower().removeprefix("can")
value = raw
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError("CAN channel must be an integer or can1-style name") from exc
def _parse_one_frame(value: Any, *, default_channel: int) -> CanFrame:
if not isinstance(value, dict):
raise ValueError("each MQTT CAN frame must be a JSON object") # noqa: TRY004
frame_id_value = next((value[key] for key in _ID_KEYS if key in value), None)
data_value = next((value[key] for key in _DATA_KEYS if key in value), None)
if frame_id_value is None or data_value is None:
raise ValueError("MQTT CAN frame requires an ID and data")
frame_id = _parse_frame_id(frame_id_value)
data = _parse_data(data_value)
channel_values = [
value[key]
for key in ("channelIndex", "channel_index", "channel")
if key in value
] or [default_channel]
channels = {_parse_channel(channel_value) for channel_value in channel_values}
if len(channels) != 1:
raise ValueError("MQTT CAN frame channel declarations conflict")
channel = channels.pop()
if channel not in MQTT_ACCEPTED_FEEDBACK_IDS:
raise ValueError("wireless CAN state accepts only CAN0 or CAN1 frames")
frame_type = str(value.get("frameType", value.get("frame_type", ""))).strip().lower()
is_fd = bool(
value.get(
"is_fd",
value.get(
"isFd",
frame_type in {"fd", "canfd", "can_fd"} if frame_type else len(data) > 8,
),
)
)
bitrate_switch = bool(
value.get("bitrate_switch", value.get("bitrateSwitch", value.get("brs", False)))
)
if not is_fd and len(data) > 8:
raise ValueError("classic CAN data must not exceed 8 bytes")
if channel == 0 and (is_fd or bitrate_switch or len(data) != 8):
raise ValueError(
"CAN0 passenger-seat state requires classic CAN, no BRS, and DLC 8"
)
timestamp_value = value.get("timestamp", value.get("timestamp_s"))
timestamp: float | None = None
if timestamp_value is not None:
if isinstance(timestamp_value, bool):
raise ValueError("CAN timestamp must not be boolean")
try:
timestamp = float(timestamp_value)
except (TypeError, ValueError) as exc:
raise ValueError("CAN timestamp must be numeric") from exc
if not math.isfinite(timestamp) or timestamp < 0:
raise ValueError("CAN timestamp must be a finite non-negative number")
# n7-can-signals/v1 uses Unix milliseconds while the canonical frame
# contract uses Unix seconds.
if timestamp >= 100_000_000_000:
timestamp /= 1_000
return CanFrame(
arbitration_id=frame_id,
data=data,
timestamp=timestamp,
channel=channel,
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
"""Translate read-only 0x6A0/0x6A2 seat telemetry into 0x302 states.
The seat-ota runtime returns a selector value in a generic response frame:
0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is
``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO
opcode is paired here. Neither OTA identifier is added to an outbound
allow-list or exposed to the ordinary seat executor.
"""
selectors_by_request: dict[tuple[int, int], int] = {}
for frame in frames:
if (
frame.channel == 0
and frame.arbitration_id == 0x6A0
and len(frame.data) == 8
and frame.data[0] == 0xC7
and frame.data[2] == 0x60
and frame.data[3] <= 14
):
selectors_by_request[(frame.data[1], frame.data[2])] = frame.data[3]
values: dict[int, tuple[int, CanFrame]] = {}
for frame in frames:
if (
frame.channel != 0
or frame.arbitration_id != 0x6A2
or len(frame.data) != 8
or frame.data[0] != 0x7C
or frame.data[2] != 0x60
or frame.data[3] != 0x00
):
continue
selector = selectors_by_request.get((frame.data[1], frame.data[2]))
if selector is None:
continue
values[selector] = (int.from_bytes(frame.data[4:8], "little"), frame)
translated: list[CanFrame] = []
for axis in range(4):
position_entry = values.get(1 + axis)
flags_entry = values.get(5 + axis)
if position_entry is None or flags_entry is None:
continue
packed_position, position_frame = position_entry
flags, flags_frame = flags_entry
position = packed_position & 0xFFFF
hall_range = (packed_position >> 16) & 0xFFFF
if hall_range <= 0 or position > hall_range:
continue
value_x100 = min(10_000, round(position * 10_000 / hall_range))
moving = bool(flags & (1 << 1))
position_valid = bool(flags & (1 << 0))
driver_fault = bool(flags & (1 << 3))
direction = (flags >> 8) & 0x03
task_state = 1 if moving else 0
result = 7 if driver_fault else 0
state_flags = direction
if moving:
state_flags |= 1 << 2
if position_valid:
state_flags |= 1 << 3
if position == 0:
state_flags |= 1 << 4
if position == hall_range:
state_flags |= 1 << 5
if driver_fault:
state_flags |= 1 << 7
sequence = flags_frame.data[1]
payload = bytes(
(
axis,
task_state | (result << 4),
state_flags,
sequence,
0,
value_x100 & 0xFF,
(value_x100 >> 8) & 0xFF,
0,
)
)
translated.append(
CanFrame(
arbitration_id=0x302,
data=payload,
timestamp=max(
value
for value in (position_frame.timestamp, flags_frame.timestamp)
if value is not None
)
if position_frame.timestamp is not None
or flags_frame.timestamp is not None
else None,
channel=0,
is_fd=False,
bitrate_switch=False,
)
)
return translated
def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
"""Parse one frame or a bounded frame batch from the condition topic."""
if isinstance(payload, bytes):
try:
text = payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("MQTT CAN payload is not UTF-8") from exc
else:
text = payload
if len(text.encode("utf-8")) > 256 * 1024:
raise ValueError("MQTT CAN payload exceeds 256 KiB")
try:
decoded = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError("MQTT CAN payload must be JSON") from exc
batches: list[tuple[Any, int]]
if isinstance(decoded, dict) and "frames" in decoded:
batches = [(decoded["frames"], 1)]
elif isinstance(decoded, dict) and "signals" in decoded:
# Vehicle-side n7-can-signals/v1 envelope. Its decoded ``parsed``
# summary is intentionally ignored: raw frames still pass through the
# same per-channel allow-list and decoders as wired traffic. When the
# top-level batch is present, nested copies are intentionally ignored.
batches = [(decoded["signals"], 1)]
elif isinstance(decoded, dict) and any(
isinstance(decoded.get(key), dict) and "signals" in decoded[key]
for key in ("can0Seat", "can1Vehicle")
):
batches = []
for key, default_channel in (("can0Seat", 0), ("can1Vehicle", 1)):
block = decoded.get(key)
if isinstance(block, dict) and "signals" in block:
batches.append((block["signals"], default_channel))
elif isinstance(decoded, list):
batches = [(decoded, 1)]
else:
batches = [([decoded], 1)]
if any(not isinstance(items, list) for items, _ in batches):
raise ValueError("MQTT CAN frame batch must be an array")
item_count = sum(len(items) for items, _ in batches)
if not 1 <= item_count <= 256:
raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames")
frames = [
_parse_one_frame(item, default_channel=default_channel)
for items, default_channel in batches
for item in items
]
return [*frames, *_ota_seat_status_frames(frames)]
def mqtt_control_payload(frame: CanFrame) -> str:
"""Serialize one compiled outbound frame using the documented wire contract."""
return json.dumps(
{
"schema": MQTT_CAN_SCHEMA,
"arbitration_id": frame.arbitration_id,
"frame_id": f"0x{frame.arbitration_id:03X}",
"channel": frame.channel,
"data_hex": frame.data.hex(" ").upper(),
"data": list(frame.data),
"payload_size": len(frame.data),
"is_fd": frame.is_fd,
"bitrate_switch": frame.bitrate_switch,
"timestamp": time.time(),
},
ensure_ascii=False,
separators=(",", ":"),
)
def _validate_ota_seat_runtime_control(data: bytes) -> None:
"""Reject every CAN0 OTA opcode except normal bounded seat runtime control."""
if len(data) != 8 or data[0] != 0xC7:
raise CanGatewayError("CAN0 seat-ota control requires C7 and DLC 8")
opcode = data[2]
selector = data[3]
argument = int.from_bytes(data[4:8], "little")
if opcode not in MQTT_OTA_SEAT_RUNTIME_OPCODES:
raise CanGatewayError(
"CAN0 seat-ota control allows only STOP, MOVE, HEARTBEAT, or DYNAMIC"
)
if opcode == 0x63 and (selector not in {*range(4), 0xFF} or argument != 0):
raise CanGatewayError("CAN0 seat STOP selector or argument is invalid")
if opcode == 0x64 and (selector not in range(4) or argument > 1_000):
raise CanGatewayError("CAN0 seat MOVE requires axis 0..3 and target 0..1000")
if opcode == 0x65 and (selector not in {*range(4), 0xFF} or argument != 0):
raise CanGatewayError("CAN0 seat HEARTBEAT selector or argument is invalid")
if opcode == 0x66 and (
selector not in {1, 2}
or (argument != 0 and not 5 <= argument <= 300)
):
raise CanGatewayError(
"CAN0 seat DYNAMIC requires mode 1/2 and duration 5..300 seconds"
)
class MqttCanTransport:
"""One process-wide MQTT connection serving snapshots and executor buses."""
def __init__(
self,
settings: VehicleMqttSettings,
*,
client_factory: Callable[[], Any] | None = None,
decoder: Callable[[CanFrame], dict[str, Any]] = decode_feedback,
) -> None:
self.settings = settings
self.client_factory = client_factory
self.decoder = decoder
self.initial_snapshot_timeout_s = settings.feedback_timeout_ms / 1_000
self.freshness_s = max(3.0, self.initial_snapshot_timeout_s * 2)
self._condition = threading.Condition(threading.RLock())
self._client: Any | None = None
self._started = False
self._state = "disabled" if not settings.enabled else "idle"
self._last_error: str | None = None
self._parse_errors = 0
self._stale_frames = 0
self._capture_sequence = 0
self._initial_snapshot_attempted = False
self._frames: deque[tuple[int, CanFrame]] = deque(maxlen=4_096)
self._latest: dict[int, tuple[float, dict[str, Any]]] = {}
self._latest_seen: dict[tuple[int, int], float] = {}
self._latest_bus_frames: dict[
tuple[int, int, int], tuple[float, CanFrame]
] = {}
def _build_client(self) -> Any:
if self.client_factory is not None:
return self.client_factory()
try:
import paho.mqtt.client as mqtt # type: ignore[import-not-found]
except ImportError as exc:
raise CanGatewayError("paho-mqtt is not installed") from exc
client_id = f"n7-cognitive-agent-{uuid.uuid4().hex[:12]}"
try:
return mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
protocol=mqtt.MQTTv311,
)
except (AttributeError, TypeError):
return mqtt.Client(client_id=client_id, protocol=mqtt.MQTTv311)
def start(self) -> None:
with self._condition:
if self._started or not self.settings.state_configured:
return
self._started = True
self._state = "connecting"
try:
client = self._build_client()
client.on_connect = self._on_connect
client.on_disconnect = self._on_disconnect
client.on_message = self._on_message
if self.settings.username:
client.username_pw_set(self.settings.username, self.settings.password)
if self.settings.tls_enabled:
client.tls_set()
if hasattr(client, "reconnect_delay_set"):
client.reconnect_delay_set(min_delay=1, max_delay=10)
with self._condition:
self._client = client
client.connect_async(
self.settings.host,
self.settings.port,
self.settings.keepalive_s,
)
client.loop_start()
except Exception as exc: # noqa: BLE001 - normalize broker/client failures
with self._condition:
self._state = "error"
self._last_error = str(exc)
self._condition.notify_all()
def close(self) -> None:
with self._condition:
client = self._client
self._client = None
self._started = False
if client is not None:
try:
client.disconnect()
except Exception: # noqa: BLE001, S110 - best-effort service shutdown
pass
try:
client.loop_stop()
except Exception: # noqa: BLE001, S110 - best-effort service shutdown
pass
with self._condition:
self._state = "stopped"
self._condition.notify_all()
@staticmethod
def _reason_code_value(reason_code: Any) -> int:
try:
return int(reason_code)
except (TypeError, ValueError):
return int(getattr(reason_code, "value", 1))
def _on_connect(
self,
client: Any,
_userdata: Any,
_flags: Any,
reason_code: Any,
_properties: Any = None,
) -> None:
code = self._reason_code_value(reason_code)
if code != 0:
with self._condition:
self._state = "error"
self._last_error = f"MQTT connection refused with reason {code}"
self._condition.notify_all()
return
result = client.subscribe(self.settings.condition_topic, qos=self.settings.qos)
result_code = result[0] if isinstance(result, tuple) else getattr(result, "rc", 0)
with self._condition:
if int(result_code) == 0:
self._state = "online"
self._last_error = None
else:
self._state = "error"
self._last_error = f"MQTT subscribe failed with code {result_code}"
self._condition.notify_all()
def _on_disconnect(
self,
_client: Any,
_userdata: Any,
*args: Any,
) -> None:
reason_code = args[-2] if len(args) >= 2 else args[-1] if args else 0
code = self._reason_code_value(reason_code)
with self._condition:
if self._started:
self._state = "connecting"
self._last_error = None if code == 0 else f"MQTT disconnected: {code}"
else:
self._state = "stopped"
self._condition.notify_all()
def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None:
if str(getattr(message, "topic", "")) != self.settings.condition_topic:
return
try:
frames = parse_mqtt_can_payload(bytes(message.payload))
for frame in frames:
self._record_frame(frame)
except (TypeError, ValueError) as exc:
with self._condition:
self._parse_errors += 1
self._last_error = str(exc)
self._condition.notify_all()
def _record_frame(self, frame: CanFrame) -> None:
if (
frame.channel not in MQTT_ACCEPTED_FEEDBACK_IDS
or frame.arbitration_id not in MQTT_ACCEPTED_FEEDBACK_IDS[frame.channel]
or len(frame.data) < 8
):
return
cache_age_s = (
max(0.0, time.time() - frame.timestamp)
if frame.timestamp is not None and frame.timestamp >= 1_000_000_000
else 0.0
)
stale = cache_age_s > self.freshness_s
with self._condition:
self._capture_sequence += 1
sequence = self._capture_sequence
self._latest_seen[(frame.channel, frame.arbitration_id)] = time.monotonic()
if stale:
self._stale_frames += 1
else:
self._frames.append((sequence, frame))
discriminator = (
frame.data[0]
if frame.channel == 0
and frame.arbitration_id == 0x302
and frame.data
else 0
)
self._latest_bus_frames[
(frame.channel, frame.arbitration_id, discriminator)
] = (time.monotonic(), frame)
self._condition.notify_all()
if stale and (
frame.channel != 1
or frame.arbitration_id in MQTT_SAFETY_FEEDBACK_IDS
or frame.arbitration_id not in OBSERVABLE_IDS
):
# Cached frames never enter an executor bus. In particular, old
# gear/speed evidence cannot satisfy the stationary-vehicle gate.
return
if frame.channel != 1 or frame.arbitration_id not in OBSERVABLE_IDS:
return
decoded = self.decoder(frame)
if not any(
key in decoded
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
return
frame_id = str(decoded.get("frame_id", ""))
captured_at = (
datetime.fromtimestamp(frame.timestamp, UTC)
.isoformat()
.replace("+00:00", "Z")
if frame.timestamp is not None and frame.timestamp >= 1_000_000_000
else _now()
)
decoded.update(
{
"valid": True,
"feedback_frame_id": frame_id,
"captured_at": captured_at,
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"transport": "wireless_mqtt",
"topic": self.settings.condition_topic,
"frame_id": frame_id,
"payload_size": len(frame.data),
"fresh": not stale,
"display_only": stale,
"control_eligible": not stale,
"cache_age_ms": round(cache_age_s * 1_000),
},
}
)
with self._condition:
self._latest[frame.arbitration_id] = (time.monotonic(), decoded)
self._condition.notify_all()
def _fresh_ids(self, now: float) -> set[int]:
return {
frame_id
for frame_id, (captured_monotonic, _) in self._latest.items()
if now - captured_monotonic <= self.freshness_s
}
def snapshot(self) -> VehicleStateSnapshot:
self.start()
deadline = time.monotonic() + self.initial_snapshot_timeout_s
with self._condition:
while not self._initial_snapshot_attempted:
fresh_ids = self._fresh_ids(time.monotonic())
# A partial live body snapshot is immediately useful to the
# dashboard. Completeness remains explicit in the API; only a
# completely empty first subscription waits for its bounded
# initial frame window.
if fresh_ids:
break
if self._state == "error":
raise CanGatewayError(self._last_error or "MQTT CAN relay is unavailable")
remaining_s = deadline - time.monotonic()
if remaining_s <= 0:
break
self._condition.wait(timeout=remaining_s)
self._initial_snapshot_attempted = True
observed_ids = self._fresh_ids(time.monotonic())
feedback_states = tuple(
deepcopy(self._latest[frame_id][1])
for frame_id in sorted(observed_ids)
)
captured_at = max(
(
str(state.get("captured_at"))
for state in feedback_states
if state.get("captured_at")
),
default=None,
)
return VehicleStateSnapshot(
feedback_states=feedback_states,
observed_ids=frozenset(observed_ids),
captured_at=captured_at,
capture_sequence=self._capture_sequence,
)
def bus(self, channel: int = 1, *, replay_latest: bool = False) -> MqttCanBus:
if channel not in MQTT_ACCEPTED_FEEDBACK_IDS:
raise CanGatewayError("MQTT CAN bus channel must be 0 or 1")
self.start()
with self._condition:
cursor = self._capture_sequence
now = time.monotonic()
initial_frames = tuple(
frame
for (seen_channel, _frame_id, _discriminator),
(captured_monotonic, frame) in sorted(self._latest_bus_frames.items())
if replay_latest
and seen_channel == channel
and now - captured_monotonic <= self.freshness_s
)
return MqttCanBus(self, cursor, channel, initial_frames=initial_frames)
def publish(self, frame: CanFrame) -> None:
if (
frame.channel not in MQTT_APPROVED_CONTROL_IDS
or frame.arbitration_id not in MQTT_APPROVED_CONTROL_IDS[frame.channel]
):
raise CanGatewayError("MQTT CAN frame is outside the approved channel policy")
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
if frame.channel == 0:
_validate_ota_seat_runtime_control(frame.data)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("MQTT CAN relay is not connected")
result = client.publish(
self.settings.control_topic,
mqtt_control_payload(frame),
qos=self.settings.qos,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(f"MQTT CAN publish failed with code {result.rc}")
if hasattr(result, "wait_for_publish"):
published = result.wait_for_publish(timeout=2.0)
if published is False:
raise CanGatewayError("MQTT CAN publish confirmation timed out")
def _receive_after(
self,
cursor: int,
timeout_ms: int,
*,
channel: int,
include_fd: bool,
) -> tuple[int, CanFrame | None]:
deadline = time.monotonic() + max(0, timeout_ms) / 1_000
with self._condition:
while True:
for sequence, frame in self._frames:
if sequence <= cursor:
continue
cursor = sequence
if frame.channel == channel and (include_fd or not frame.is_fd):
return cursor, frame
remaining_s = deadline - time.monotonic()
if timeout_ms <= 0 or remaining_s <= 0:
return cursor, None
self._condition.wait(timeout=remaining_s)
def public_info(self) -> dict[str, Any]:
with self._condition:
info = self.settings.public_info()
now = time.monotonic()
observed_by_channel = {
str(channel): [
f"0x{frame_id:03X}"
for (seen_channel, frame_id), captured_monotonic in sorted(
self._latest_seen.items()
)
if seen_channel == channel
and now - captured_monotonic <= self.freshness_s
]
for channel in MQTT_ACCEPTED_FEEDBACK_IDS
}
info["state_sync"] = {
"mode": "persistent_mqtt_subscription",
"state": self._state,
"read_only": True,
"poll_interval_ms": MQTT_POLL_INTERVAL_MS,
"snapshot_freshness_ms": round(self.freshness_s * 1_000),
"capture_sequence": self._capture_sequence,
"observed_frame_ids": [
f"0x{frame_id:03X}"
for frame_id in sorted(self._fresh_ids(now))
],
"observed_frame_ids_by_channel": observed_by_channel,
"parse_errors": self._parse_errors,
"stale_frames": self._stale_frames,
"last_error": self._last_error,
}
return info
class MqttCanBus:
"""Cursor-based bus view over the shared MQTT subscription."""
def __init__(
self,
transport: MqttCanTransport,
cursor: int,
channel: int,
*,
initial_frames: tuple[CanFrame, ...] = (),
) -> None:
self._transport = transport
self._cursor = cursor
self.channel = channel
self._initial_frames = deque(initial_frames)
self._closed = False
def send(
self,
arbitration_id: int,
data: bytes,
*,
is_fd: bool = False,
bitrate_switch: bool = False,
) -> CanFrame:
if self._closed:
raise CanGatewayError("MQTT CAN bus is closed")
if arbitration_id not in MQTT_APPROVED_CONTROL_IDS[self.channel]:
raise CanGatewayError(
f"CAN frame ID is not in the approved CAN{self.channel} control set"
)
if not 1 <= len(data) <= 64 or (not is_fd and len(data) > 8):
raise CanGatewayError("invalid CAN payload length")
if self.channel == 0 and (is_fd or bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
frame = CanFrame(
arbitration_id=arbitration_id,
data=bytes(data),
channel=self.channel,
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
self._transport.publish(frame)
return frame
def receive(
self,
timeout_ms: int,
*,
include_fd: bool = False,
) -> CanFrame | None:
if self._closed:
raise CanGatewayError("MQTT CAN bus is closed")
while self._initial_frames:
frame = self._initial_frames.popleft()
if include_fd or not frame.is_fd:
return frame
self._cursor, frame = self._transport._receive_after(
self._cursor,
timeout_ms,
channel=self.channel,
include_fd=include_fd,
)
return frame
def close(self) -> None:
self._closed = True
@@ -108,6 +108,7 @@ def emit(value):
class Driver:
def __init__(self, args):
self.transmit_type = args.transmit_type
self.monitor_only = bool(args.monitor_only or args.trace_all)
self.dll = ctypes.WinDLL(args.dll)
self._declare_functions()
self.device = self.dll.ZCAN_OpenDevice(args.device_type, args.device_index, 0)
@@ -270,6 +271,8 @@ class Driver:
raise RuntimeError(operation + " did not accept the frame" + detail)
def send(self, channel, arbitration_id, data, is_fd=False, bitrate_switch=False):
if self.monitor_only:
raise PermissionError("ZLG bridge is monitor-only; transmit rejected")
if not 0 <= arbitration_id <= 0x7FF:
raise ValueError("only 11-bit standard CAN identifiers are allowed")
handle = self._handle(channel)
@@ -389,6 +392,8 @@ def parse_args():
help="0 for normal transmission; 2 is diagnostic self-reception",
)
parser.add_argument("--self-test", action="store_true")
parser.add_argument("--monitor-only", action="store_true")
parser.add_argument("--trace-all", action="store_true")
return parser.parse_args()
@@ -430,7 +435,14 @@ def main():
driver = None
try:
driver = Driver(args)
emit({"ok": True, "event": "ready", "channels": sorted(driver.channels)})
emit(
{
"ok": True,
"event": "ready",
"channels": sorted(driver.channels),
"monitor_only": driver.monitor_only,
}
)
for line in sys.stdin:
try:
request = json.loads(line)
@@ -0,0 +1,625 @@
"""JSON-line bridge for ZLG's Linux ``libusbcanfd`` VCI SDK.
The process owns one USBCANFD-200U and exposes the same small protocol as the
Windows ``zlgcan_bridge.py``. Passive vehicle-state synchronization launches
this bridge with ``--monitor-only`` so every send request is rejected inside
the hardware-owning process, not merely hidden by the browser.
The ABI below follows the vendor ``zcan.h`` delivered with the verified
``libusbcanfd.so.1.0.12``. Both classic CAN and CAN FD queues are polled: the
SDK does not merge them unless a separate receive mode is explicitly enabled.
"""
from __future__ import annotations
import argparse
import ctypes
import json
import os
import sys
import time
from collections import deque
from pathlib import Path
from typing import Any
CMD_CAN_FILTER = 0x14
CMD_CAN_TRES = 0x18
ZCAN_TX_NORM = 0
INFO_FMT_SHIFT = 4
INFO_REMOTE = 1 << 8
INFO_EXTENDED = 1 << 9
INFO_ERROR = 1 << 10
INFO_BRS = 1 << 11
MAX_BATCH = 64
CAN_FD_LENGTHS = frozenset((*range(9), 12, 16, 20, 24, 32, 48, 64))
class ZcanTiming(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("tseg1", ctypes.c_uint8),
("tseg2", ctypes.c_uint8),
("sjw", ctypes.c_uint8),
("smp", ctypes.c_uint8),
("brp", ctypes.c_uint16),
]
class ZcanInit(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("clk", ctypes.c_uint32),
("mode", ctypes.c_uint32),
("aset", ZcanTiming),
("dset", ZcanTiming),
]
class ZcanFilter(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("type", ctypes.c_uint8),
("pad", ctypes.c_uint8 * 3),
("sid", ctypes.c_uint32),
("eid", ctypes.c_uint32),
]
class ZcanFilterTable(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("size", ctypes.c_uint32),
("table", ZcanFilter * 64),
]
class ZcanMessageHeader(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("ts", ctypes.c_uint32),
("id", ctypes.c_uint32),
("inf", ctypes.c_uint32),
("pad", ctypes.c_uint16),
("chn", ctypes.c_uint8),
("len", ctypes.c_uint8),
]
class ZcanClassicMessage(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("hdr", ZcanMessageHeader),
("dat", ctypes.c_uint8 * 8),
]
class ZcanFdMessage(ctypes.LittleEndianStructure):
_pack_ = 1
_fields_ = [ # noqa: RUF012 - ctypes declares ABI fields as a class list
("hdr", ZcanMessageHeader),
("dat", ctypes.c_uint8 * 64),
]
EXPECTED_ABI_SIZES = {
"timing": 6,
"init": 20,
"filter": 12,
"header": 16,
"classic": 24,
"fd": 80,
}
_PROTOCOL_STDOUT: Any | None = None
def _separate_protocol_stdout() -> None:
"""Keep vendor SDK prints out of the JSON-line protocol stream."""
global _PROTOCOL_STDOUT
if _PROTOCOL_STDOUT is not None:
return
_PROTOCOL_STDOUT = os.fdopen(os.dup(sys.stdout.fileno()), "w", buffering=1)
# libusbcanfd writes diagnostics directly to fd 1. Route those messages to
# stderr while retaining the duplicated original stdout for JSON replies.
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
def emit(value: dict[str, Any]) -> None:
stream = _PROTOCOL_STDOUT or sys.stdout
print(
json.dumps(value, ensure_ascii=False, separators=(",", ":")),
file=stream,
flush=True,
)
def log(value: str) -> None:
print(value, file=sys.stderr, flush=True)
def abi_sizes() -> dict[str, int]:
return {
"timing": ctypes.sizeof(ZcanTiming),
"init": ctypes.sizeof(ZcanInit),
"filter": ctypes.sizeof(ZcanFilter),
"header": ctypes.sizeof(ZcanMessageHeader),
"classic": ctypes.sizeof(ZcanClassicMessage),
"fd": ctypes.sizeof(ZcanFdMessage),
}
def validate_abi() -> None:
actual = abi_sizes()
if actual != EXPECTED_ABI_SIZES:
raise RuntimeError(
f"Linux ZLG SDK ABI size mismatch: expected {EXPECTED_ABI_SIZES}, got {actual}"
)
def _load_sdk(path: str) -> ctypes.CDLL:
validate_abi()
sdk = ctypes.CDLL(path)
required = (
"VCI_OpenDevice",
"VCI_CloseDevice",
"VCI_InitCAN",
"VCI_SetReference",
"VCI_ClearBuffer",
"VCI_StartCAN",
"VCI_ResetCAN",
"VCI_Transmit",
"VCI_TransmitFD",
"VCI_Receive",
"VCI_ReceiveFD",
)
missing = [name for name in required if not hasattr(sdk, name)]
if missing:
raise RuntimeError("Linux ZLG SDK is missing symbols: " + ", ".join(missing))
return sdk
def _declare_functions(sdk: ctypes.CDLL) -> None:
u32 = ctypes.c_uint32
sdk.VCI_OpenDevice.argtypes = [u32, u32, u32]
sdk.VCI_OpenDevice.restype = u32
sdk.VCI_CloseDevice.argtypes = [u32, u32]
sdk.VCI_CloseDevice.restype = u32
sdk.VCI_InitCAN.argtypes = [u32, u32, u32, ctypes.POINTER(ZcanInit)]
sdk.VCI_InitCAN.restype = u32
sdk.VCI_SetReference.argtypes = [u32, u32, u32, u32, ctypes.c_void_p]
sdk.VCI_SetReference.restype = u32
sdk.VCI_ClearBuffer.argtypes = [u32, u32, u32]
sdk.VCI_ClearBuffer.restype = u32
sdk.VCI_StartCAN.argtypes = [u32, u32, u32]
sdk.VCI_StartCAN.restype = u32
sdk.VCI_ResetCAN.argtypes = [u32, u32, u32]
sdk.VCI_ResetCAN.restype = u32
sdk.VCI_Transmit.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanClassicMessage),
u32,
]
sdk.VCI_Transmit.restype = u32
sdk.VCI_TransmitFD.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanFdMessage),
u32,
]
sdk.VCI_TransmitFD.restype = u32
sdk.VCI_Receive.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanClassicMessage),
u32,
u32,
]
sdk.VCI_Receive.restype = u32
sdk.VCI_ReceiveFD.argtypes = [
u32,
u32,
u32,
ctypes.POINTER(ZcanFdMessage),
u32,
u32,
]
sdk.VCI_ReceiveFD.restype = u32
def _channel_configs(raw: str) -> list[dict[str, Any]]:
value = json.loads(raw)
if not isinstance(value, list) or not value:
raise ValueError("channels-json must be a non-empty JSON array")
configs: list[dict[str, Any]] = []
seen: set[int] = set()
for candidate in value:
if not isinstance(candidate, dict):
raise TypeError("each channel configuration must be an object")
config = dict(candidate)
channel = int(config["channel"])
if channel not in {0, 1}:
raise ValueError("USBCANFD-200U channel must be 0 or 1")
if channel in seen:
raise ValueError("channel numbers must be unique")
seen.add(channel)
frame_format = str(config.get("frame_format", "can_fd"))
if frame_format not in {"classic_can", "can_fd"}:
raise ValueError("frame_format must be classic_can or can_fd")
raw_receive_ids = config.get("receive_ids")
if raw_receive_ids is not None:
if not isinstance(raw_receive_ids, list) or not 1 <= len(raw_receive_ids) <= 64:
raise ValueError("receive_ids must be an array of 1 to 64 identifiers")
if len(raw_receive_ids) != len(set(raw_receive_ids)) or any(
isinstance(frame_id, bool)
or not isinstance(frame_id, int)
or not 0 <= frame_id <= 0x7FF
for frame_id in raw_receive_ids
):
raise ValueError("receive_ids must contain unique 11-bit identifiers")
config["receive_ids"] = frozenset(raw_receive_ids)
config["channel"] = channel
config["frame_format"] = frame_format
configs.append(config)
return configs
def _verified_init(config: dict[str, Any]) -> ZcanInit:
arbitration_bitrate = int(config.get("arbitration_bitrate", 500_000))
data_bitrate = int(config.get("data_bitrate", 2_000_000))
if arbitration_bitrate != 500_000 or data_bitrate != 2_000_000:
raise ValueError(
"Linux vehicle CAN timing is locked to 500000/2000000 bit/s"
)
init = ZcanInit()
init.clk = 60_000_000
# Vendor ABI: bit0 normal/listen-only, bit1 ISO/BOSCH. Zero is normal ISO.
init.mode = 0 if bool(config.get("can_fd_iso", True)) else 2
init.aset = ZcanTiming(14, 3, 2, 0, 5)
init.dset = ZcanTiming(10, 2, 2, 0, 1)
return init
def _acceptance_filters(config: dict[str, Any]) -> ZcanFilterTable:
"""Build exact standard-ID filters when a passive consumer declares its IDs."""
filters = ZcanFilterTable()
receive_ids = config.get("receive_ids")
ranges = (
[(int(frame_id), int(frame_id)) for frame_id in sorted(receive_ids)]
if receive_ids is not None
else [(0x000, 0x7FF)]
)
# This SDK interprets size as valid bytes after the size field.
filters.size = len(ranges) * ctypes.sizeof(ZcanFilter)
for index, (start_id, end_id) in enumerate(ranges):
filters.table[index].type = 0
filters.table[index].sid = start_id
filters.table[index].eid = end_id
return filters
class Driver:
def __init__(self, args: argparse.Namespace):
self.device_type = int(args.device_type)
self.device_index = int(args.device_index)
self.monitor_only = bool(args.monitor_only or args.trace_all)
self.trace_all = bool(args.trace_all)
self.sdk = _load_sdk(args.dll)
_declare_functions(self.sdk)
self.configs = {
int(item["channel"]): item for item in _channel_configs(args.channels_json)
}
self.queues: dict[int, deque[dict[str, Any]]] = {
channel: deque() for channel in self.configs
}
self.started_channels: list[int] = []
self.opened = False
if self.sdk.VCI_OpenDevice(self.device_type, self.device_index, 0) == 0:
raise RuntimeError(
"VCI_OpenDevice failed; check USB 3068:0009 permissions and close other owners"
)
self.opened = True
try:
for channel, config in self.configs.items():
self._initialize_channel(channel, config)
except Exception:
self.close()
raise
def _initialize_channel(self, channel: int, config: dict[str, Any]) -> None:
init = _verified_init(config)
if self.sdk.VCI_InitCAN(
self.device_type, self.device_index, channel, ctypes.byref(init)
) == 0:
raise RuntimeError(f"VCI_InitCAN failed for channel {channel}")
filters = _acceptance_filters(config)
if self.sdk.VCI_SetReference(
self.device_type,
self.device_index,
channel,
CMD_CAN_FILTER,
ctypes.byref(filters),
) == 0:
log(f"[warning] channel {channel}: acceptance filter was not applied")
termination = ctypes.c_uint32(
1 if bool(config.get("termination_enabled", False)) else 0
)
if self.sdk.VCI_SetReference(
self.device_type,
self.device_index,
channel,
CMD_CAN_TRES,
ctypes.byref(termination),
) == 0:
raise RuntimeError(f"failed to configure termination for channel {channel}")
if self.sdk.VCI_StartCAN(self.device_type, self.device_index, channel) == 0:
raise RuntimeError(f"VCI_StartCAN failed for channel {channel}")
self.started_channels.append(channel)
self.sdk.VCI_ClearBuffer(self.device_type, self.device_index, channel)
def _validate_channel(self, channel: int) -> dict[str, Any]:
try:
return self.configs[channel]
except KeyError as exc:
raise ValueError(f"channel {channel} was not initialized") from exc
@staticmethod
def _frame(message: Any, *, is_fd: bool) -> dict[str, Any] | None:
info = int(message.hdr.inf)
if info & INFO_ERROR:
log(
f"[CAN error] ch={int(message.hdr.chn)} "
f"id=0x{int(message.hdr.id):X} fmt={'FD' if is_fd else 'CAN2.0'}"
)
return None
# CanFrame has no representation for extended or remote frames. Do not
# let them masquerade as verified standard data frames upstream.
if info & (INFO_EXTENDED | INFO_REMOTE):
return None
maximum = 64 if is_fd else 8
length = min(int(message.hdr.len), maximum)
return {
"arbitration_id": int(message.hdr.id),
"data_hex": bytes(message.dat[:length]).hex(),
"timestamp_us": int(message.hdr.ts),
"is_fd": is_fd,
"bitrate_switch": bool(info & INFO_BRS),
}
def _drain(self, channel: int, include_fd: bool) -> None:
receive_ids = self.configs[channel].get("receive_ids")
classic = (ZcanClassicMessage * MAX_BATCH)()
classic_count = int(
self.sdk.VCI_Receive(
self.device_type,
self.device_index,
channel,
classic,
MAX_BATCH,
0,
)
)
if classic_count > MAX_BATCH:
raise RuntimeError(f"VCI_Receive returned invalid count {classic_count}")
for index in range(classic_count):
frame = self._frame(classic[index], is_fd=False)
if frame is not None and (
receive_ids is None or frame["arbitration_id"] in receive_ids
):
self.queues[channel].append(frame)
self._trace(channel, frame)
if not include_fd:
return
fd = (ZcanFdMessage * MAX_BATCH)()
fd_count = int(
self.sdk.VCI_ReceiveFD(
self.device_type,
self.device_index,
channel,
fd,
MAX_BATCH,
0,
)
)
if fd_count > MAX_BATCH:
raise RuntimeError(f"VCI_ReceiveFD returned invalid count {fd_count}")
for index in range(fd_count):
frame = self._frame(fd[index], is_fd=True)
if frame is not None and (
receive_ids is None or frame["arbitration_id"] in receive_ids
):
self.queues[channel].append(frame)
self._trace(channel, frame)
def _trace(self, channel: int, frame: dict[str, Any]) -> None:
if not self.trace_all:
return
log(
"[RX] "
f"ID=0x{int(frame['arbitration_id']):03X} ch={channel} "
f"len={len(bytes.fromhex(str(frame['data_hex'])))} "
f"fmt={'CANFD' if frame['is_fd'] else 'CAN2.0'} "
f"brs={int(bool(frame['bitrate_switch']))} id=STD kind=DATA "
f"data={frame['data_hex']}"
)
def receive(self, channel: int, timeout_ms: int, include_fd: bool) -> dict[str, Any] | None:
config = self._validate_channel(channel)
if include_fd and config["frame_format"] != "can_fd":
raise ValueError(f"channel {channel} is configured for classic CAN")
deadline = time.monotonic() + max(0, timeout_ms) / 1000
while True:
if self.queues[channel]:
return self.queues[channel].popleft()
self._drain(channel, include_fd)
if self.queues[channel]:
return self.queues[channel].popleft()
if timeout_ms <= 0 or time.monotonic() >= deadline:
return None
time.sleep(min(0.001, max(0.0, deadline - time.monotonic())))
def send(
self,
channel: int,
arbitration_id: int,
data: bytes,
*,
is_fd: bool,
bitrate_switch: bool,
) -> dict[str, Any]:
config = self._validate_channel(channel)
if self.monitor_only:
raise PermissionError("Linux ZLG bridge is monitor-only; transmit rejected")
if not 0 <= arbitration_id <= 0x7FF:
raise ValueError("only 11-bit standard CAN identifiers are allowed")
if is_fd and config["frame_format"] != "can_fd":
raise ValueError(f"channel {channel} is configured for classic CAN")
if is_fd and len(data) not in CAN_FD_LENGTHS:
raise ValueError("invalid CAN FD payload length")
if not is_fd and len(data) > 8:
raise ValueError("classic CAN payload cannot exceed 8 bytes")
info = ZCAN_TX_NORM
if is_fd:
info |= 1 << INFO_FMT_SHIFT
if bitrate_switch:
if not is_fd:
raise ValueError("bitrate switch requires a CAN FD frame")
info |= INFO_BRS
message_type = ZcanFdMessage if is_fd else ZcanClassicMessage
message = message_type()
message.hdr.id = arbitration_id
message.hdr.inf = info
message.hdr.chn = channel
message.hdr.len = len(data)
for index, byte in enumerate(data):
message.dat[index] = byte
log(
"[TX] "
f"ID=0x{arbitration_id:03X} ch={channel} len={len(data)} "
f"fmt={'CANFD' if is_fd else 'CAN2.0'} brs={int(bitrate_switch)} "
f"id=STD kind=DATA err=0 data={data.hex()}"
)
transmit = self.sdk.VCI_TransmitFD if is_fd else self.sdk.VCI_Transmit
if transmit(
self.device_type,
self.device_index,
channel,
ctypes.byref(message),
1,
) != 1:
raise RuntimeError(f"{'VCI_TransmitFD' if is_fd else 'VCI_Transmit'} returned 0")
return {
"arbitration_id": arbitration_id,
"data_hex": data.hex(),
"is_fd": is_fd,
"bitrate_switch": bitrate_switch,
}
def close(self) -> None:
if not self.opened:
return
for channel in reversed(self.started_channels):
self.sdk.VCI_ResetCAN(self.device_type, self.device_index, channel)
self.sdk.VCI_CloseDevice(self.device_type, self.device_index)
self.opened = False
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the Linux ZLG VCI JSON bridge")
parser.add_argument("--dll", required=True, help="Path to libusbcanfd.so")
parser.add_argument("--device-type", type=int, default=33)
parser.add_argument("--device-index", type=int, default=0)
parser.add_argument("--channels-json", default="[]")
# Accepted for protocol compatibility with the Windows bridge. Linux VCI
# transmit mode is encoded directly in each message.
parser.add_argument("--transmit-type", type=int, default=0)
parser.add_argument("--monitor-only", action="store_true")
parser.add_argument("--trace-all", action="store_true")
parser.add_argument("--self-test", action="store_true")
return parser
def _self_test(args: argparse.Namespace) -> None:
sdk = _load_sdk(args.dll)
_declare_functions(sdk)
emit(
{
"ok": True,
"event": "self_test",
"library": str(Path(args.dll).resolve()),
"device_opened": False,
"monitor_only": True,
"abi_sizes": abi_sizes(),
}
)
def main() -> None:
args = _parser().parse_args()
_separate_protocol_stdout()
if args.self_test:
_self_test(args)
return
driver: Driver | None = None
try:
driver = Driver(args)
emit(
{
"ok": True,
"event": "ready",
"backend": "linux_vci",
"device_type": args.device_type,
"device_index": args.device_index,
"channels": sorted(driver.configs),
"monitor_only": driver.monitor_only,
}
)
for raw in sys.stdin:
try:
request = json.loads(raw)
operation = str(request.get("operation", ""))
if operation == "close":
emit({"ok": True, "event": "closed"})
break
if operation == "receive":
frame = driver.receive(
int(request["channel"]),
max(0, int(request.get("timeout_ms", 0))),
bool(request.get("include_fd", False)),
)
emit({"ok": True, "frame": frame})
continue
if operation == "send":
response = driver.send(
int(request["channel"]),
int(request["arbitration_id"]),
bytes.fromhex(str(request.get("data_hex", ""))),
is_fd=bool(request.get("is_fd", False)),
bitrate_switch=bool(request.get("bitrate_switch", False)),
)
emit({"ok": True, **response})
continue
raise ValueError(f"unsupported operation: {operation!r}")
except Exception as exc: # noqa: BLE001 - JSON process boundary
emit({"ok": False, "message": str(exc)})
except Exception as exc:
emit({"ok": False, "message": str(exc)})
raise SystemExit(1) from exc
finally:
if driver is not None:
driver.close()
if __name__ == "__main__":
main()
+9
View File
@@ -155,6 +155,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
query = parse_qs(parsed_url.query)
conversation_id = str(query.get("conversation_id", [""])[0])
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
control_transport = str(
query.get("control_transport", ["wired_can"])[0]
)
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
try:
self._json(
@@ -165,6 +168,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)(
conversation_id,
vehicle_id,
control_transport=control_transport,
hardware_client_id=hardware_client_id,
hardware_access_bypass=self._is_unproxied_loopback_request(),
)
@@ -318,6 +322,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if payload.get("reasoning_effort") is not None
else None
),
control_transport=str(
payload.get("control_transport", "wired_can")
),
)
self._json(result, HTTPStatus.CREATED)
except HardwareAccessRequiredError as exc:
@@ -1066,6 +1073,7 @@ def main() -> None:
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
SeatDemoHandler.service.start()
SeatDemoHandler.extension.start()
SeatDemoHandler.calibration.start()
SeatDemoHandler.repair.start()
@@ -1097,6 +1105,7 @@ def main() -> None:
pass
finally:
server.server_close()
SeatDemoHandler.service.close()
SeatDemoHandler.extension.close()
SeatDemoHandler.calibration.close()
SeatDemoHandler.repair.close()
+116 -10
View File
@@ -10,6 +10,7 @@ import urllib.request
from http.cookiejar import CookieJar
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
@@ -177,18 +178,18 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-82", timeout=3) as response:
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-89", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3
) as response:
gesture_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.js?v=desktop-refactor-36", timeout=3
f"{self.base_url}/console-v2.js?v=desktop-refactor-37", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/console-v2.css?v=desktop-refactor-107", timeout=3
f"{self.base_url}/console-v2.css?v=desktop-refactor-108", timeout=3
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
@@ -231,12 +232,12 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-82"', html)
self.assertIn('href="/styles.css?v=dual-seat-9"', html)
self.assertIn('src="/app.js?v=dual-seat-89"', html)
self.assertIn('href="/styles.css?v=dual-seat-10"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-7"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=desktop-refactor-36"', html)
self.assertIn('href="/console-v2.css?v=desktop-refactor-107"', html)
self.assertIn('src="/console-v2.js?v=desktop-refactor-37"', html)
self.assertIn('href="/console-v2.css?v=desktop-refactor-108"', html)
self.assertIn('src="/self-evolution.js?v=seat-weighing-2"', html)
self.assertIn('href="/self-evolution.css?v=seat-weighing-5"', html)
self.assertIn('src="/self-extension.js?v=extension-21"', html)
@@ -399,7 +400,19 @@ operations:
self.assertIn('const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]', app_script)
self.assertIn('divider.className = "mode-group-divider"', app_script)
self.assertIn(
'mode === "hardware" && !state.runtime?.hardware_execution_allowed',
'mode === "hardware" && !hardwareExecutionReady',
app_script,
)
self.assertIn('data-control-transport="wired_can"', html)
self.assertIn('data-control-transport="wireless_mqtt"', html)
self.assertIn('control_transport: state.controlTransport', app_script)
self.assertIn('const transportField = $("#vehicleTransportField", originalForm)', console_script)
self.assertIn('configStack.append(transportField)', console_script)
self.assertIn('.console-v2 .v2-transport-field .vehicle-transport-options', console_styles)
self.assertIn('button.disabled = false', app_script)
self.assertIn('button.setAttribute("aria-pressed", String(selected))', app_script)
self.assertNotIn(
'availableTransports.find((item) => item.state_configured)?.id',
app_script,
)
self.assertIn("hardwareButton.disabled = !hardwareExecutionReady", app_script)
@@ -484,7 +497,7 @@ operations:
self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v120"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v128"', service_worker)
self.assertIn('"/self-evolution.js?v=seat-weighing-2"', service_worker)
self.assertIn('"/self-evolution.css?v=seat-weighing-5"', service_worker)
self.assertIn(
@@ -699,7 +712,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-82", timeout=3
f"{self.base_url}/app.js?v=dual-seat-89", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -1032,10 +1045,103 @@ operations:
synchronize.assert_called_once_with(
"conv-e2e-physical-state",
"denza_n7",
control_transport="wired_can",
hardware_client_id=None,
hardware_access_bypass=True,
)
def test_wireless_passenger_snapshot_uses_mqtt_can0(self) -> None:
physical_state = {
"position": {
"longitudinal": {
"position_pct": 42,
"position_valid": True,
"motion_state": "stopped",
"fault": False,
}
},
"captured_at": "2026-08-18T00:00:00Z",
"complete": True,
"missing": [],
}
executor = SimpleNamespace(read_physical_state=lambda: dict(physical_state))
with (
patch.object(
self.service,
"vehicle_mqtt_settings",
SimpleNamespace(state_configured=True),
),
patch.object(
self.service._vehicle_mqtt_transport,
"bus",
return_value=object(),
) as mqtt_bus,
patch.object(
self.service,
"_new_hardware_executor",
return_value=executor,
) as new_executor,
patch.object(
self.service.hardware_execution,
"reserve",
side_effect=AssertionError(
"wireless cached state must not reserve the physical bench"
),
),
):
payload = self.service.synchronize_hardware_state(
"conv-e2e-wireless-seat-state",
"denza_n7",
control_transport="wireless_mqtt",
hardware_access_bypass=True,
)
bus_factory = new_executor.call_args.args[0]
bus_factory()
mqtt_bus.assert_called_once_with(channel=0, replay_latest=True)
self.assertTrue(new_executor.call_args.kwargs["remote_transport"])
self.assertEqual(payload["source"], "wireless_mqtt_ch0")
self.assertEqual(payload["control_transport"], "wireless_mqtt")
self.assertEqual(
payload["actuator_state"]["passenger"]["position"]["longitudinal"]["position_pct"],
42,
)
def test_wireless_vehicle_snapshot_does_not_reserve_physical_bench(self) -> None:
snapshot = SimpleNamespace(
feedback_states=(),
observed_ids=frozenset(),
captured_at="2026-08-18T00:00:00Z",
capture_sequence=17,
)
with (
patch.object(
self.service,
"vehicle_mqtt_settings",
SimpleNamespace(state_configured=True),
),
patch.object(
self.service._vehicle_mqtt_transport,
"snapshot",
return_value=snapshot,
),
patch.object(
self.service.hardware_execution,
"reserve",
side_effect=AssertionError(
"wireless cached state must not reserve the physical bench"
),
),
):
payload = self.service.synchronize_vehicle_state(
"conv-e2e-wireless-vehicle-state",
"denza_n7",
control_transport="wireless_mqtt",
hardware_access_bypass=True,
)
self.assertEqual(payload["source"], "wireless_mqtt_ch1")
self.assertEqual(payload["capture_sequence"], 17)
def test_gpt_http_failure_falls_back_without_returning_502(self) -> None:
original_api_key = self.service.openai.api_key
self.service.openai.api_key = "test-key"
+6 -2
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from contextlib import nullcontext
import os
from types import SimpleNamespace
import sys
import unittest
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
@@ -21,6 +23,8 @@ class Ch1VehicleSafetyTests(unittest.TestCase):
"COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL": "1",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true",
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "approved",
"COGNITIVE_AGENT_ZLGCAN_PYTHON": sys.executable,
"COGNITIVE_AGENT_ZLGCAN_DLL": str(Path(__file__).resolve()),
}
with patch.dict(os.environ, environment, clear=False):
return VehicleCanSettings.from_env(REPOSITORY_ROOT)
@@ -225,9 +225,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
"async function synchronizePhysicalSeatState() {",
"\nasync function loadAuthStatus()",
)
self.assertIn("state.runtime?.hardware?.configured", passenger_source)
self.assertIn('requestedTransport === "wireless_mqtt"', passenger_source)
self.assertIn("selectedVehicleTransport()?.state_configured", passenger_source)
self.assertIn("control_transport: requestedTransport", passenger_source)
self.assertIn("requestedTransport !== state.controlTransport", passenger_source)
self.assertIn('payload.source === "wireless_mqtt_ch0"', passenger_source)
self.assertIn("/api/hardware/seat-state", passenger_source)
self.assertIn("selectedVehicleTransport()?.state_configured", driver_source)
self.assertIn("control_transport: requestedTransport", driver_source)
self.assertIn("requestedTransport !== state.controlTransport", driver_source)
self.assertIn("/api/hardware/vehicle-state", driver_source)
self.assertIn('state.controlTransport === "wireless_mqtt"', combined_source)
self.assertIn("state.passengerStateSyncPromise", combined_source)
self.assertLess(
combined_source.index("synchronizePassengerSeatState()"),
combined_source.index("synchronizeDriverSeatState()"),
@@ -239,6 +248,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
)
self.assertNotIn("passenger: mergedActuatorState.driver", render_source)
def test_persistent_linux_monitor_uses_backend_selected_fast_polling(self) -> None:
interval_source = function_source(
self.app_source,
"function physicalStatePollIntervalMs() {",
"\nfunction refreshIcons",
)
self.assertIn("selectedVehicleTransport()?.state_sync?.poll_interval_ms", interval_source)
self.assertIn("Math.max(250", interval_source)
self.assertIn("DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000", self.app_source)
self.assertIn("}, physicalStatePollIntervalMs());", self.app_source)
self.assertIn("restartPhysicalStatePolling();", self.app_source)
def test_vehicle_selection_cannot_replay_unfiltered_state_in_ch1_mode(self) -> None:
select_source = function_source(
self.app_source,
+581
View File
@@ -0,0 +1,581 @@
from __future__ import annotations
import json
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.infrastructure.can.gateway import CanFrame
from cognitive_agent.infrastructure.can.hardware import (
HardwareSafetyError,
VehicleMqttSettings,
)
from cognitive_agent.infrastructure.can.mqtt_transport import (
MQTT_CAN_SCHEMA,
MqttCanTransport,
parse_mqtt_can_payload,
)
class _PublishResult:
rc = 0
def wait_for_publish(self, timeout: float) -> bool:
return timeout > 0
class _FakeMqttClient:
def __init__(self) -> None:
self.on_connect = None
self.on_disconnect = None
self.on_message = None
self.subscriptions: list[tuple[str, int]] = []
self.published: list[tuple[str, str, int, bool]] = []
def reconnect_delay_set(self, **_kwargs) -> None:
return None
def connect_async(self, host: str, port: int, keepalive: int) -> None:
self.connection = (host, port, keepalive)
def loop_start(self) -> None:
self.on_connect(self, None, {}, 0, None)
def loop_stop(self) -> None:
return None
def disconnect(self) -> None:
return None
def subscribe(self, topic: str, qos: int):
self.subscriptions.append((topic, qos))
return (0, 1)
def publish(self, topic: str, payload: str, qos: int, retain: bool):
self.published.append((topic, payload, qos, retain))
return _PublishResult()
def emit(self, topic: str, payload: object) -> None:
message = SimpleNamespace(
topic=topic,
payload=json.dumps(payload).encode("utf-8"),
)
self.on_message(self, None, message)
def _settings():
values = {
"enabled": True,
"state_configured": True,
"configured": True,
"host": "127.0.0.1",
"port": 1883,
"condition_topic": "n7/n7-headunit/can/state",
"control_topic": "n7/can_control",
"qos": 1,
"keepalive_s": 30,
"username": None,
"password": None,
"tls_enabled": False,
"feedback_timeout_ms": 100,
}
values["public_info"] = lambda: {
"enabled": True,
"configured": True,
"state_configured": True,
"backend": "mqtt",
"condition_topic": "n7/n7-headunit/can/state",
"control_topic": "n7/can_control",
}
return SimpleNamespace(**values)
class MqttCanPayloadTests(unittest.TestCase):
def test_accepts_one_canonical_frame_and_common_receive_aliases(self) -> None:
canonical = parse_mqtt_can_payload(
json.dumps(
{
"schema": MQTT_CAN_SCHEMA,
"arbitration_id": 0x342,
"channel": 1,
"data_hex": "00 00 00 01 00 00 00 00",
"is_fd": True,
"bitrate_switch": True,
}
)
)[0]
alias = parse_mqtt_can_payload(
json.dumps({"canId": "0x4FA", "data": [0] * 8})
)[0]
self.assertEqual(canonical.arbitration_id, 0x342)
self.assertEqual(canonical.channel, 1)
self.assertTrue(canonical.is_fd)
self.assertEqual(alias.arbitration_id, 0x4FA)
def test_accepts_vehicle_n7_can_signals_envelope(self) -> None:
frames = parse_mqtt_can_payload(
json.dumps(
{
"schema": "n7-can-signals/v1",
"sequence": 335,
"timestamp": 1787037724090,
"online": True,
"signals": [
{
"channel": "can1",
"channelIndex": 1,
"canId": "0x388",
"canIdDecimal": 904,
"frameType": "fd",
"brs": True,
"dlc": 8,
"data": "00 00 00 02 01 00 59 00",
"bytes": [0, 0, 0, 2, 1, 0, 89, 0],
"timestamp": 1787037667665,
"direction": "rx",
}
],
"can1Vehicle": {
"parsed": {"speedKph": 0, "gear": 1}
},
}
)
)
self.assertEqual(len(frames), 1)
self.assertEqual(frames[0].arbitration_id, 0x388)
self.assertEqual(frames[0].channel, 1)
self.assertEqual(frames[0].data, bytes.fromhex("00 00 00 02 01 00 59 00"))
self.assertTrue(frames[0].is_fd)
self.assertTrue(frames[0].bitrate_switch)
self.assertEqual(frames[0].timestamp, 1787037667.665)
def test_accepts_nested_can1_vehicle_signals_when_top_level_is_absent(self) -> None:
frame = parse_mqtt_can_payload(
json.dumps(
{
"schema": "n7-can-signals/v1",
"can1Vehicle": {
"signals": [
{
"channel": "can1",
"canId": "0x42E",
"frameType": "fd",
"brs": True,
"bytes": [17, 100, 50, 3, 144, 2, 0, 198],
}
]
},
}
)
)[0]
self.assertEqual(frame.arbitration_id, 0x42E)
self.assertTrue(frame.is_fd)
def test_accepts_nested_can0_seat_signals_with_can0_default(self) -> None:
frame = parse_mqtt_can_payload(
json.dumps(
{
"schema": "n7-can-signals/v1",
"can0Seat": {
"signals": [
{
"canId": "0x302",
"frameType": "classic",
"bytes": [0] * 8,
}
]
},
}
)
)[0]
self.assertEqual(frame.arbitration_id, 0x302)
self.assertEqual(frame.channel, 0)
self.assertFalse(frame.is_fd)
def test_translates_seat_ota_info_responses_into_four_position_states(self) -> None:
values = {
1: (480 << 16) | 407,
2: (373 << 16) | 266,
3: (917 << 16) | 451,
4: (647 << 16) | 575,
5: 0x11,
6: 0x11,
7: 0x11,
8: 0x11,
}
signals = []
for selector, value in values.items():
sequence = 0x20 + selector
signals.extend(
(
{
"channel": "can0",
"canId": "0x6A0",
"frameType": "classic",
"direction": "tx",
"bytes": [0xC7, sequence, 0x60, selector, 0, 0, 0, 0],
},
{
"channel": "can0",
"canId": "0x6A2",
"frameType": "classic",
"direction": "rx",
"bytes": [0x7C, sequence, 0x60, 0, *value.to_bytes(4, "little")],
},
)
)
frames = parse_mqtt_can_payload(json.dumps({"signals": signals}))
positions = [frame for frame in frames if frame.arbitration_id == 0x302]
self.assertEqual([frame.data[0] for frame in positions], [0, 1, 2, 3])
self.assertEqual(
[int.from_bytes(frame.data[5:7], "little") for frame in positions],
[round(407 * 10_000 / 480), round(266 * 10_000 / 373),
round(451 * 10_000 / 917), round(575 * 10_000 / 647)],
)
self.assertTrue(all(frame.data[2] & (1 << 3) for frame in positions))
def test_rejects_can0_frames_outside_seat_firmware_format(self) -> None:
for label, frame in (
(
"fd",
{
"channel": "can0",
"canId": "0x302",
"frameType": "fd",
"bytes": [0] * 8,
},
),
(
"brs",
{
"channel": "can0",
"canId": "0x302",
"frameType": "classic",
"brs": True,
"bytes": [0] * 8,
},
),
(
"dlc",
{
"channel": "can0",
"canId": "0x302",
"frameType": "classic",
"bytes": [0] * 7,
},
),
):
with self.subTest(label=label), self.assertRaisesRegex(
ValueError, "classic CAN, no BRS, and DLC 8"
):
parse_mqtt_can_payload(json.dumps(frame))
def test_top_level_signals_can_mix_can0_and_can1_without_nested_duplicates(self) -> None:
frames = parse_mqtt_can_payload(
json.dumps(
{
"schema": "n7-can-signals/v1",
"signals": [
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8},
{"channel": "can1", "canId": "0x342", "bytes": [0] * 8},
],
"can0Seat": {
"signals": [
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8}
]
},
}
)
)
self.assertEqual(
[(frame.channel, frame.arbitration_id) for frame in frames],
[(0, 0x302), (1, 0x342)],
)
def test_rejects_decoded_state_invalid_channels_and_channel_conflicts(self) -> None:
with self.assertRaisesRegex(ValueError, "requires an ID and data"):
parse_mqtt_can_payload(json.dumps({"gear": "P", "speed_kmh": 0}))
with self.assertRaisesRegex(ValueError, "only CAN0 or CAN1"):
parse_mqtt_can_payload(
json.dumps({"id": 0x342, "channel": 2, "data": [0] * 8})
)
with self.assertRaisesRegex(ValueError, "declarations conflict"):
parse_mqtt_can_payload(
json.dumps(
{
"id": 0x342,
"channel": "can1",
"channelIndex": 0,
"data": [0] * 8,
}
)
)
def test_control_remains_locked_without_existing_hardware_approval(self) -> None:
settings = VehicleMqttSettings(
enabled=True,
approval_required=True,
approval_token="",
host="127.0.0.1",
port=1883,
condition_topic="n7/n7-headunit/can/state",
control_topic="n7/can_control",
qos=1,
keepalive_s=30,
username=None,
password=None,
tls_enabled=False,
feedback_timeout_ms=1_500,
)
with patch(
"cognitive_agent.infrastructure.can.hardware._module_available",
return_value=True,
):
self.assertTrue(settings.state_configured)
self.assertFalse(settings.configured)
with self.assertRaisesRegex(HardwareSafetyError, "not configured"):
settings.authorize(None)
class MqttCanTransportTests(unittest.TestCase):
def setUp(self) -> None:
self.client = _FakeMqttClient()
self.transport = MqttCanTransport(
_settings(),
client_factory=lambda: self.client,
)
self.transport.start()
def tearDown(self) -> None:
self.transport.close()
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
{
"frames": [
{"id": 0x342, "data": [0, 0, 0, 1, 0, 0, 0, 0]},
{"id": 0x4EB, "data": [7, 0, 0, 0, 0, 0, 0, 0]},
{"id": 0x407, "data": [0] * 8},
{"id": 0x494, "data": [0] * 8},
{"id": 0x4FA, "data": [0] * 8},
{"id": 0x555, "data": [255] * 8},
]
},
)
snapshot = self.transport.snapshot()
self.assertEqual(
snapshot.observed_ids,
frozenset({0x342, 0x4EB, 0x407, 0x494, 0x4FA}),
)
self.assertTrue(
all(
state["verification"]["transport"] == "wireless_mqtt"
for state in snapshot.feedback_states
)
)
self.assertEqual(
self.client.subscriptions,
[("n7/n7-headunit/can/state", 1)],
)
def test_first_partial_live_vehicle_snapshot_does_not_wait_for_missing_ids(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
{
"signals": [
{
"channel": "can1",
"canId": "0x388",
"frameType": "fd",
"brs": True,
"bytes": [0, 0, 0, 2, 1, 0, 89, 0],
}
]
},
)
started = time.perf_counter()
snapshot = self.transport.snapshot()
self.assertLess(time.perf_counter() - started, 0.05)
self.assertEqual(snapshot.observed_ids, frozenset({0x388}))
def test_stale_safety_signal_cannot_enter_snapshot_or_executor_bus(self) -> None:
bus = self.transport.bus(channel=1)
self.client.emit(
"n7/n7-headunit/can/state",
{
"schema": "n7-can-signals/v1",
"signals": [
{
"channel": "can1",
"channelIndex": 1,
"canId": "0x342",
"frameType": "fd",
"brs": True,
"data": "00 00 00 01 00 00 00 00",
"timestamp": round((time.time() - 60) * 1_000),
}
],
},
)
snapshot = self.transport.snapshot()
info = self.transport.public_info()["state_sync"]
self.assertEqual(info["capture_sequence"], 1)
self.assertEqual(info["stale_frames"], 1)
self.assertEqual(info["observed_frame_ids"], [])
self.assertEqual(snapshot.observed_ids, frozenset())
self.assertIsNone(bus.receive(0, include_fd=True))
def test_stale_non_safety_can1_frames_are_display_only(self) -> None:
bus = self.transport.bus(channel=1)
cached_timestamp = round((time.time() - 60) * 1_000)
self.client.emit(
"n7/n7-headunit/can/state",
{
"schema": "n7-can-signals/v1",
"signals": [
{
"channel": "can1",
"canId": frame_id,
"frameType": "fd",
"brs": True,
"data": data,
"timestamp": cached_timestamp,
}
for frame_id, data in (
("0x388", "00 00 00 02 01 00 59 00"),
("0x41A", "00 5C 85 08 20 42 C1 FD"),
("0x42E", "11 64 32 03 90 02 00 C6"),
("0x3FF", "5B 09 FF 0F 32 61 04 12"),
)
],
},
)
snapshot = self.transport.snapshot()
self.assertEqual(
snapshot.observed_ids,
frozenset({0x388, 0x3FF, 0x41A, 0x42E}),
)
for state in snapshot.feedback_states:
verification = state["verification"]
self.assertFalse(verification["fresh"])
self.assertTrue(verification["display_only"])
self.assertFalse(verification["control_eligible"])
self.assertGreater(verification["cache_age_ms"], 50_000)
self.assertIsNone(bus.receive(0, include_fd=True))
def test_bus_publishes_only_canonical_frame_contract(self) -> None:
bus = self.transport.bus()
sent = bus.send(
0x431,
bytes.fromhex("10 00 00 00 00 00 00 00"),
is_fd=True,
bitrate_switch=True,
)
self.assertEqual(sent.channel, 1)
self.assertEqual(len(self.client.published), 1)
topic, raw_payload, qos, retain = self.client.published[0]
payload = json.loads(raw_payload)
self.assertEqual(topic, "n7/can_control")
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(payload["schema"], MQTT_CAN_SCHEMA)
self.assertEqual(payload["arbitration_id"], 0x431)
self.assertEqual(payload["data_hex"], "10 00 00 00 00 00 00 00")
self.assertTrue(payload["is_fd"])
self.assertTrue(payload["bitrate_switch"])
with self.assertRaisesRegex(Exception, "approved CAN1 control set"):
bus.send(0x555, bytes(8), is_fd=True)
self.assertEqual(len(self.client.published), 1)
def test_can0_bus_publishes_only_classic_passenger_seat_control(self) -> None:
bus = self.transport.bus(channel=0)
sent = bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"))
payload = json.loads(self.client.published[-1][1])
self.assertEqual(sent.channel, 0)
self.assertEqual(payload["channel"], 0)
self.assertFalse(payload["is_fd"])
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
bus.send(0x431, bytes(8))
with self.assertRaisesRegex(Exception, "requires classic CAN"):
bus.send(0x6A0, bytes.fromhex("C7 01 64 00 F4 01 00 00"),
is_fd=True, bitrate_switch=True)
with self.assertRaisesRegex(Exception, "approved CAN0 control set"):
bus.send(0x300, bytes(8))
with self.assertRaisesRegex(Exception, "only STOP, MOVE, HEARTBEAT, or DYNAMIC"):
bus.send(0x6A0, bytes.fromhex("C7 02 61 00 00 00 00 00"))
with self.assertRaisesRegex(Exception, "target 0..1000"):
bus.send(0x6A0, bytes.fromhex("C7 03 64 00 E9 03 00 00"))
with self.assertRaisesRegex(Exception, "approved channel policy"):
self.transport.publish(
CanFrame(arbitration_id=0x431, data=bytes(8), channel=0)
)
def test_bus_receive_views_are_isolated_by_channel(self) -> None:
can0 = self.transport.bus(channel=0)
can1 = self.transport.bus(channel=1)
self.client.emit(
"n7/n7-headunit/can/state",
{
"signals": [
{"channel": "can0", "canId": "0x302", "bytes": [0] * 8},
{
"channel": "can1",
"canId": "0x342",
"frameType": "fd",
"bytes": [0] * 8,
},
]
},
)
self.assertEqual(can0.receive(0).arbitration_id, 0x302)
self.assertEqual(can1.receive(0, include_fd=True).arbitration_id, 0x342)
self.assertIsNone(can0.receive(0))
self.assertIsNone(can1.receive(0, include_fd=True))
def test_can0_status_bus_can_replay_latest_translated_ota_positions(self) -> None:
signals = []
for selector, value in {
1: (480 << 16) | 407,
2: (373 << 16) | 266,
3: (917 << 16) | 451,
4: (647 << 16) | 575,
5: 0x11,
6: 0x11,
7: 0x11,
8: 0x11,
}.items():
sequence = 0x40 + selector
signals.extend(
(
{"channel": "can0", "canId": "0x6A0", "bytes":
[0xC7, sequence, 0x60, selector, 0, 0, 0, 0]},
{"channel": "can0", "canId": "0x6A2", "bytes":
[0x7C, sequence, 0x60, 0, *value.to_bytes(4, "little")]},
)
)
self.client.emit("n7/n7-headunit/can/state", {"signals": signals})
bus = self.transport.bus(channel=0, replay_latest=True)
replayed = [bus.receive(0) for _ in range(4)]
self.assertEqual(
[frame.data[0] for frame in replayed if frame is not None],
[0, 1, 2, 3],
)
self.assertEqual(bus.receive(0).arbitration_id, 0x6A2)
self.assertIsNone(bus.receive(0))
self.assertEqual(
self.transport.public_info()["state_sync"]["observed_frame_ids_by_channel"]["0"],
["0x302", "0x6A2"],
)
if __name__ == "__main__":
unittest.main()
+218
View File
@@ -0,0 +1,218 @@
from __future__ import annotations
import unittest
from collections import deque
from types import SimpleNamespace
from cognitive_agent.application.mqtt_ota_seat_executor import (
MqttOtaSeatExecutor,
)
from cognitive_agent.infrastructure.can.gateway import CanFrame
def _axis_frame(axis: int, value_x100: int, *, valid: bool = True) -> CanFrame:
flags = (1 << 3) if valid else 0
return CanFrame(
0x302,
bytes(
(
axis,
0,
flags,
0,
0,
value_x100 & 0xFF,
(value_x100 >> 8) & 0xFF,
0,
)
),
channel=0,
)
class _FakeOtaBus:
def __init__(self, initial_frames: list[CanFrame]) -> None:
self.frames = deque(initial_frames)
self.sent: list[CanFrame] = []
self.closed = False
def send(
self,
arbitration_id: int,
data: bytes,
*,
is_fd: bool = False,
bitrate_switch: bool = False,
) -> CanFrame:
frame = CanFrame(
arbitration_id,
bytes(data),
channel=0,
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
self.sent.append(frame)
opcode = data[2]
job_state = (
1 | (4 << 4) | (9 << 8)
if opcode == 0x66
else (1 | (3 << 4) | (7 << 8))
if opcode in {0x64, 0x65}
else (1 << 1) | (1 << 2)
)
self.frames.append(
CanFrame(
0x6A2,
bytes((0x7C, data[1], opcode, 0))
+ job_state.to_bytes(4, "little"),
channel=0,
)
)
if opcode == 0x64:
target_x100 = int.from_bytes(data[4:8], "little") * 10
self.frames.append(_axis_frame(data[3], target_x100))
return frame
def receive(self, _timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
del include_fd
return self.frames.popleft() if self.frames else None
def close(self) -> None:
self.closed = True
class _StatusExecutor:
def read_physical_state(self) -> dict[str, object]:
return {"position": {}, "complete": False}
def _capture_physical_state(
self,
_bus: _FakeOtaBus,
_results: list[dict[str, object]],
) -> dict[str, object]:
return {"position": {}, "complete": True, "missing": []}
def _function_config(capability: str, parameters: dict[str, object]) -> dict[str, object]:
return {
"metadata": {"request_id": "req-mqtt-ota-seat"},
"spec": {
"operations": [
{
"id": "operation-1",
"capability": capability,
"target": {"zone": "front_right", "instance": "passenger"},
"parameters": parameters,
}
]
},
}
class MqttOtaSeatExecutorTests(unittest.TestCase):
def test_absolute_move_uses_runtime_move_and_fresh_position_confirmation(self) -> None:
bus = _FakeOtaBus([_axis_frame(0, 4_000)])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
lambda: bus,
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Move_Command_300",
"signals": {"Opcode": 1, "Motor_Selector": 0},
"completion": {
"value": 5_000,
"tolerance": 100,
"timeout_ms": 1_000,
},
}
]
}
}
report = executor.execute(
plan,
_function_config(
"seat.set_longitudinal_position",
{"position_pct": 50},
),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(bus.sent[0].arbitration_id, 0x6A0)
self.assertEqual(bus.sent[0].data[2], 0x64)
self.assertEqual(bus.sent[0].data[3], 0)
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 500)
self.assertEqual(
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
50,
)
self.assertTrue(bus.closed)
def test_legacy_zero_target_maps_to_move_not_homing(self) -> None:
bus = _FakeOtaBus([_axis_frame(3, 2_000)])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
lambda: bus,
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Move_Command_300",
"signals": {"Opcode": 0x0B, "Motor_Selector": 3},
"completion": {
"value": 0,
"tolerance": 100,
"timeout_ms": 1_000,
},
}
]
}
}
report = executor.execute(
plan,
_function_config("seat.set_legrest_height", {"position_pct": 0}),
)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual(bus.sent[0].data[2], 0x64)
self.assertEqual(int.from_bytes(bus.sent[0].data[4:8], "little"), 0)
self.assertNotIn(0x61, [frame.data[2] for frame in bus.sent])
self.assertNotIn(0x62, [frame.data[2] for frame in bus.sent])
def test_wireless_thermal_command_fails_before_publish(self) -> None:
bus = _FakeOtaBus([])
executor = MqttOtaSeatExecutor( # type: ignore[arg-type]
_StatusExecutor(),
lambda: bus,
)
plan = {
"spec": {
"commands": [
{
"operation_ref": "operation-1",
"message": "T2G_Seat_Thermal_Control_29E",
"signals": {},
}
]
}
}
report = executor.execute(
plan,
_function_config("seat.set_heating", {"state": "on", "level": 1}),
)
self.assertEqual(report["spec"]["status"], "failed")
self.assertIn("does not provide wireless thermal control", report["spec"]["results"][0]["message"])
self.assertEqual(bus.sent, [])
if __name__ == "__main__":
unittest.main()
+25
View File
@@ -54,6 +54,31 @@ class NativeCanFeedbackTests(unittest.TestCase):
self.assertFalse(feedback_matches("indoor_light:on", feedback))
self.assertTrue(feedback_matches("ambient_power:off", feedback))
def test_can1_matrix_rejects_reserved_ambient_color_values(self) -> None:
decoded = decode_feedback(
self.frame(0x42E, "11 64 32 03 90 02 00 C6")
)["domain_state"]["ambient_light"]
self.assertIsNone(decoded["front_color_index"])
self.assertIsNone(decoded["rear_color_index"])
def test_3ff_matrix_decodes_lighting_and_illuminance(self) -> None:
light = decode_feedback(
self.frame(0x3FF, "5B 09 FF 0F 32 61 04 12")
)["domain_state"]["interior_light"]
self.assertEqual(light["power"], "unknown")
self.assertEqual(light["footwell_power"], "off")
self.assertEqual(light["ambient_illuminance_lux"], 510)
def test_3f3_matrix_decodes_wall_and_ambient_switches(self) -> None:
decoded = decode_feedback(
self.frame(0x3F3, "00 00 00 00 00 00 00 84")
)["domain_state"]
self.assertEqual(decoded["interior_light"]["wall_light_power"], "on")
self.assertEqual(decoded["ambient_light"]["power"], "on")
def test_driver_seat_feedback_uses_0x4fa_not_0x42a(self) -> None:
passive_feedback = self.frame(0x4FA, "0980aa0000810000")
passive_driver = decode_feedback(passive_feedback)["actuator_state"]["driver"]
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import threading
import time
import unittest
from collections import deque
from pathlib import Path
from types import SimpleNamespace
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.application.vehicle_state_monitor import (
OBSERVABLE_IDS,
REQUIRED_UI_IDS,
VehicleStateMonitor,
VehicleStateSnapshot,
)
from cognitive_agent.infrastructure.can.gateway import (
CanFrame,
CanGatewayError,
ZlgChannelConfig,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class _FakeBus:
def __init__(self, frames: list[CanFrame]) -> None:
self._frames = deque(frames)
self._condition = threading.Condition()
def push(self, frame: CanFrame) -> None:
with self._condition:
self._frames.append(frame)
self._condition.notify_all()
def receive(self, timeout_ms: int, *, include_fd: bool = False) -> CanFrame | None:
if not include_fd:
raise AssertionError("vehicle monitor must receive classic CAN and CAN FD")
with self._condition:
if not self._frames:
self._condition.wait(max(0, timeout_ms) / 1000)
return self._frames.popleft() if self._frames else None
class _FakeBridge:
def __init__(self, bus: _FakeBus) -> None:
self.bus = bus
self.closed = False
def channel_bus(self, channel: int) -> _FakeBus:
if channel != 1:
raise AssertionError("vehicle monitor must stay on CH1")
return self.bus
def close(self) -> None:
self.closed = True
class _FakeSettings:
channel = 1
feedback_timeout_ms = 100
def __init__(self, bus: _FakeBus) -> None:
self.bus = bus
self.bridge_instance = _FakeBridge(bus)
self.monitor_only: bool | None = None
def channel_config(self) -> ZlgChannelConfig:
return ZlgChannelConfig(channel=1, termination_enabled=False)
def bridge(
self,
channels: list[ZlgChannelConfig],
*,
monitor_only: bool = False,
) -> _FakeBridge:
if len(channels) != 1:
raise AssertionError("vehicle monitor must own exactly one channel")
self.monitor_only = monitor_only
if channels[0].receive_ids != tuple(sorted(OBSERVABLE_IDS)):
raise AssertionError("vehicle monitor must filter to observable identifiers")
return self.bridge_instance
def _frame(frame_id: int, data: bytes | None = None) -> CanFrame:
return CanFrame(
frame_id,
data or bytes(8),
channel=1,
is_fd=True,
bitrate_switch=True,
)
def _baseline_frames() -> list[CanFrame]:
gear_p = bytearray(8)
gear_p[3] = 1
return [
_frame(0x342, bytes(gear_p)),
_frame(0x4EB, bytes.fromhex("0700000000000000")),
_frame(0x407),
_frame(0x4FA),
_frame(0x494),
]
class VehicleStateMonitorTests(unittest.TestCase):
def test_persistent_monitor_returns_cached_state_without_reopening_adapter(self) -> None:
bus = _FakeBus(_baseline_frames())
settings = _FakeSettings(bus)
monitor = VehicleStateMonitor(settings, reconnect_delay_s=0.01) # type: ignore[arg-type]
self.addCleanup(monitor.close)
first = monitor.snapshot()
started = time.perf_counter()
second = monitor.snapshot()
cached_latency_s = time.perf_counter() - started
self.assertEqual(first.observed_ids, REQUIRED_UI_IDS)
self.assertEqual(second.observed_ids, REQUIRED_UI_IDS)
self.assertLess(cached_latency_s, 0.05)
self.assertTrue(settings.monitor_only)
self.assertEqual(monitor.public_info()["mode"], "persistent_monitor")
def test_monitor_updates_sequence_for_each_new_feedback_frame(self) -> None:
bus = _FakeBus(_baseline_frames())
monitor = VehicleStateMonitor(
_FakeSettings(bus), # type: ignore[arg-type]
reconnect_delay_s=0.01,
)
self.addCleanup(monitor.close)
baseline = monitor.snapshot()
gear_d = bytearray(8)
gear_d[3] = 4
bus.push(_frame(0x342, bytes(gear_d)))
deadline = time.monotonic() + 0.5
updated = baseline
while updated.capture_sequence == baseline.capture_sequence:
if time.monotonic() >= deadline:
self.fail("monitor did not consume the new gear frame")
time.sleep(0.005)
updated = monitor.snapshot()
gear = next(
state["vehicle_safety"]["gear"]
for state in updated.feedback_states
if state["frame_id"] == "0x342"
)
self.assertEqual(gear, "D")
def test_monitor_surfaces_adapter_open_failure(self) -> None:
settings = _FakeSettings(_FakeBus([]))
def fail_bridge(*_args: object, **_kwargs: object) -> _FakeBridge:
raise RuntimeError("adapter open failed")
settings.bridge = fail_bridge # type: ignore[method-assign]
monitor = VehicleStateMonitor(settings, reconnect_delay_s=0.01) # type: ignore[arg-type]
self.addCleanup(monitor.close)
with self.assertRaisesRegex(CanGatewayError, "adapter open failed"):
monitor.snapshot()
def test_service_merges_a_cached_monitor_snapshot(self) -> None:
service = SeatDemoService(REPOSITORY_ROOT)
gear_state = {
"source": "physical_can_ch1",
"channel": 1,
"frame_id": "0x342",
"feedback_frame_id": "0x342",
"valid": True,
"captured_at": "2026-08-18T00:00:00Z",
"vehicle_safety": {"gear": "P"},
"verification": {
"authoritative": True,
"channel": 1,
"simulated": False,
"read_only": True,
"frame_id": "0x342",
"payload_size": 8,
},
}
service.vehicle_can_settings = SimpleNamespace(
state_configured=True,
backend="zlgcan-linux",
)
service._vehicle_state_monitor = SimpleNamespace(
snapshot=lambda: VehicleStateSnapshot(
feedback_states=(gear_state,),
observed_ids=REQUIRED_UI_IDS,
captured_at="2026-08-18T00:00:00Z",
capture_sequence=42,
)
)
result = service._synchronize_vehicle_state_once(
"conv-monitor-test",
"denza_n7",
)
self.assertEqual(result["capture_sequence"], 42)
self.assertEqual(result["domain_state"]["vehicle_status"]["gear"], "P")
self.assertTrue(result["complete"])
self.assertTrue(result["body_state_complete"])
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import io
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from cognitive_agent.infrastructure.can.gateway import (
ZlgCanBridgeProcess,
ZlgChannelConfig,
)
from cognitive_agent.infrastructure.can.hardware import VehicleCanSettings
from cognitive_agent.infrastructure.can.zlgcan_linux_bridge import (
EXPECTED_ABI_SIZES,
Driver,
_acceptance_filters,
_verified_init,
abi_sizes,
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class LinuxZlgBridgeTests(unittest.TestCase):
def test_ctypes_layout_matches_vendor_packed_abi(self) -> None:
self.assertEqual(abi_sizes(), EXPECTED_ABI_SIZES)
def test_verified_60mhz_timing_matches_can_dev_baseline(self) -> None:
init = _verified_init(
{
"arbitration_bitrate": 500_000,
"data_bitrate": 2_000_000,
"can_fd_iso": True,
}
)
self.assertEqual(init.clk, 60_000_000)
self.assertEqual(init.mode, 0)
self.assertEqual(
(init.aset.tseg1, init.aset.tseg2, init.aset.sjw, init.aset.brp),
(14, 3, 2, 5),
)
self.assertEqual(
(init.dset.tseg1, init.dset.tseg2, init.dset.sjw, init.dset.brp),
(10, 2, 2, 1),
)
with self.assertRaisesRegex(ValueError, "locked"):
_verified_init(
{"arbitration_bitrate": 1_000_000, "data_bitrate": 4_000_000}
)
def test_passive_state_filter_uses_exact_standard_identifiers(self) -> None:
frame_ids = frozenset({0x342, 0x407, 0x4EB})
filters = _acceptance_filters({"receive_ids": frame_ids})
self.assertEqual(filters.size, 3 * 12)
self.assertEqual(
[
(
filters.table[index].type,
filters.table[index].sid,
filters.table[index].eid,
)
for index in range(3)
],
[(0, 0x342, 0x342), (0, 0x407, 0x407), (0, 0x4EB, 0x4EB)],
)
def test_channel_config_serializes_receive_filter_only_when_requested(self) -> None:
filtered = ZlgChannelConfig(channel=1, receive_ids=(0x342, 0x4EB))
unfiltered = ZlgChannelConfig(channel=1)
self.assertEqual(filtered.public()["receive_ids"], [0x342, 0x4EB])
self.assertNotIn("receive_ids", unfiltered.public())
def test_monitor_only_rejects_send_before_touching_sdk(self) -> None:
driver = object.__new__(Driver)
driver.monitor_only = True
driver.configs = {1: {"channel": 1, "frame_format": "can_fd"}}
driver.sdk = Mock()
with self.assertRaisesRegex(PermissionError, "monitor-only"):
driver.send(
1,
0x123,
bytes.fromhex("0001020304050607"),
is_fd=True,
bitrate_switch=True,
)
driver.sdk.VCI_TransmitFD.assert_not_called()
driver.sdk.VCI_Transmit.assert_not_called()
def test_linux_state_can_be_ready_while_transmit_stays_locked(self) -> None:
with tempfile.TemporaryDirectory() as directory:
library = Path(directory) / "libusbcanfd.so"
library.write_bytes(b"test")
environment = {
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "true",
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND": "zlgcan-linux",
"COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL": "1",
"COGNITIVE_AGENT_VEHICLE_CAN_REQUIRED_CHANNEL": "1",
"COGNITIVE_AGENT_ZLGCAN_PYTHON": sys.executable,
"COGNITIVE_AGENT_ZLGCAN_LINUX_LIBRARY": str(library),
}
with patch.dict(os.environ, environment, clear=True):
settings = VehicleCanSettings.from_env(REPOSITORY_ROOT)
self.assertEqual(settings.device_type, 33)
self.assertTrue(settings.state_configured)
self.assertFalse(settings.configured)
self.assertTrue(settings.bridge_script.name.endswith("linux_bridge.py"))
self.assertTrue(settings.public_info()["state_configured"])
def test_process_passes_monitor_and_trace_flags_to_bridge(self) -> None:
process = Mock()
process.stdout = io.StringIO('{"ok":true,"event":"ready"}\n')
process.stderr = io.StringIO()
process.stdin = io.StringIO()
process.poll.return_value = 0
with patch(
"cognitive_agent.infrastructure.can.gateway.subprocess.Popen",
return_value=process,
) as popen:
bridge = ZlgCanBridgeProcess(
python_executable=Path(sys.executable),
bridge_script=Path("bridge.py"),
dll_path=Path("libusbcanfd.so"),
device_type=33,
device_index=0,
channels=[ZlgChannelConfig(channel=1, termination_enabled=False)],
monitor_only=True,
trace_all=True,
)
command = popen.call_args.args[0]
self.assertIn("--monitor-only", command)
self.assertIn("--trace-all", command)
bridge.close()
if __name__ == "__main__":
unittest.main()