merge: integrate wired and wireless CAN into N7Web
This commit is contained in:
@@ -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 模式只应在可信网络内临时演示。
|
同一局域网的其他电脑需要体验时,使用 `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`。
|
公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。
|
||||||
|
|||||||
+194
-42
@@ -39,10 +39,16 @@ function loadHardwareClientId() {
|
|||||||
return clientId;
|
return clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadControlTransport() {
|
||||||
|
const stored = localStorage.getItem("cognitive-agent.control-transport");
|
||||||
|
return ["wired_can", "wireless_mqtt"].includes(stored) ? stored : "wired_can";
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
reasoningEffort: localStorage.getItem("cognitive-agent.reasoning-effort") || "medium",
|
reasoningEffort: localStorage.getItem("cognitive-agent.reasoning-effort") || "medium",
|
||||||
executionMode: "dry_run",
|
executionMode: "dry_run",
|
||||||
|
controlTransport: loadControlTransport(),
|
||||||
conversationId: loadConversationId(),
|
conversationId: loadConversationId(),
|
||||||
vehicleId: null,
|
vehicleId: null,
|
||||||
runtime: null,
|
runtime: null,
|
||||||
@@ -61,6 +67,7 @@ const state = {
|
|||||||
hardwareAccessPoller: null,
|
hardwareAccessPoller: null,
|
||||||
physicalStatePoller: null,
|
physicalStatePoller: null,
|
||||||
physicalStateSyncPromise: null,
|
physicalStateSyncPromise: null,
|
||||||
|
passengerStateSyncPromise: null,
|
||||||
pendingModeProfileId: null,
|
pendingModeProfileId: null,
|
||||||
modeRunTimer: null,
|
modeRunTimer: null,
|
||||||
modeDraftCommand: "",
|
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.
|
// workflow so the UI does not report failure while the seat is still moving.
|
||||||
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
const REQUEST_TIMEOUT_MS = 45 * 1000;
|
const REQUEST_TIMEOUT_MS = 45 * 1000;
|
||||||
|
const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
function requestTimeoutForExecutionMode(mode) {
|
function requestTimeoutForExecutionMode(mode) {
|
||||||
return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
|
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() {
|
function refreshIcons() {
|
||||||
if (window.lucide) {
|
if (window.lucide) {
|
||||||
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||||
@@ -4222,12 +4236,80 @@ function isLocalDecisionHost() {
|
|||||||
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
|
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() {
|
function isDriverOnlyCh1Runtime() {
|
||||||
return state.runtime?.vehicle_can?.configured === true
|
const transport = selectedVehicleTransport();
|
||||||
&& state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
|
return transport?.configured === true
|
||||||
|
&& transport?.target_policy === "ch1_body_can_driver_only"
|
||||||
&& state.runtime?.hardware?.configured !== true;
|
&& 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) {
|
function renderHardwareAccess(status) {
|
||||||
state.hardwareAccess = status;
|
state.hardwareAccess = status;
|
||||||
}
|
}
|
||||||
@@ -4258,6 +4340,8 @@ function updateSeatFeedbackModeLabel() {
|
|||||||
physical_can: "实车已同步",
|
physical_can: "实车已同步",
|
||||||
physical_partial: "物理反馈不完整",
|
physical_partial: "物理反馈不完整",
|
||||||
physical_can_ch1: "实车已同步",
|
physical_can_ch1: "实车已同步",
|
||||||
|
wireless_mqtt_ch0: "MQTT 已同步",
|
||||||
|
wireless_mqtt_ch1: "MQTT 已同步",
|
||||||
simulated_hardware: "模拟 HIL",
|
simulated_hardware: "模拟 HIL",
|
||||||
simulated: "虚拟反馈",
|
simulated: "虚拟反馈",
|
||||||
unavailable: "不可用",
|
unavailable: "不可用",
|
||||||
@@ -4266,6 +4350,8 @@ function updateSeatFeedbackModeLabel() {
|
|||||||
physical_can: "实车",
|
physical_can: "实车",
|
||||||
physical_partial: "部分",
|
physical_partial: "部分",
|
||||||
physical_can_ch1: "实车",
|
physical_can_ch1: "实车",
|
||||||
|
wireless_mqtt_ch0: "MQTT",
|
||||||
|
wireless_mqtt_ch1: "MQTT",
|
||||||
simulated_hardware: "模拟",
|
simulated_hardware: "模拟",
|
||||||
simulated: "虚拟",
|
simulated: "虚拟",
|
||||||
unavailable: "未连接",
|
unavailable: "未连接",
|
||||||
@@ -4283,7 +4369,11 @@ function updateSeatFeedbackModeLabel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectExecutionMode(mode) {
|
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;
|
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return;
|
||||||
state.executionMode = mode;
|
state.executionMode = mode;
|
||||||
$$('[data-execution-mode]').forEach((button) => {
|
$$('[data-execution-mode]').forEach((button) => {
|
||||||
@@ -4291,7 +4381,8 @@ function selectExecutionMode(mode) {
|
|||||||
});
|
});
|
||||||
const hardware = mode === "hardware";
|
const hardware = mode === "hardware";
|
||||||
const simulatedHardware = mode === "simulated_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();
|
const ch1DriverOnly = isDriverOnlyCh1Runtime();
|
||||||
$("#simulationFaultField").hidden = !simulatedHardware;
|
$("#simulationFaultField").hidden = !simulatedHardware;
|
||||||
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
|
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
|
||||||
@@ -4305,7 +4396,7 @@ function selectExecutionMode(mode) {
|
|||||||
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
|
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
|
||||||
$("#safetyText").textContent = hardware
|
$("#safetyText").textContent = hardware
|
||||||
? ch1DriverOnly && vehicleCanReady
|
? 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
|
: vehicleCanReady
|
||||||
? "同一 CAN 盒由单进程持有:CH0 控制副驾四电机并校验板端反馈;CH1 控制主驾四电机与车身设备,每次动作前校验 P 挡、零车速和对应状态帧。"
|
? "同一 CAN 盒由单进程持有:CH0 控制副驾四电机并校验板端反馈;CH1 控制主驾四电机与车身设备,每次动作前校验 P 挡、零车速和对应状态帧。"
|
||||||
: "当前仅通道 0 连接副驾座椅台架并校验板端反馈;车端 CAN 未启用,模式中的其他车舱领域保持虚拟执行。"
|
: "当前仅通道 0 连接副驾座椅台架并校验板端反馈;车端 CAN 未启用,模式中的其他车舱领域保持虚拟执行。"
|
||||||
@@ -4315,7 +4406,9 @@ function selectExecutionMode(mode) {
|
|||||||
$("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行";
|
$("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行";
|
||||||
$("#feedbackModeLabel").textContent = hardware
|
$("#feedbackModeLabel").textContent = hardware
|
||||||
? (ch1DriverOnly
|
? (ch1DriverOnly
|
||||||
? "CH1 BODY CAN · AUTHORITATIVE PHYSICAL FEEDBACK"
|
? (mqttSelected
|
||||||
|
? "MQTT CH1 · AUTHORITATIVE CAN FEEDBACK"
|
||||||
|
: "CH1 BODY CAN · AUTHORITATIVE PHYSICAL FEEDBACK")
|
||||||
: "T2G BENCH · PHYSICAL CAN FEEDBACK")
|
: "T2G BENCH · PHYSICAL CAN FEEDBACK")
|
||||||
: simulatedHardware
|
: simulatedHardware
|
||||||
? "SIMULATED HIL · FAULT INJECTION"
|
? "SIMULATED HIL · FAULT INJECTION"
|
||||||
@@ -5019,13 +5112,18 @@ async function saveCurrentModeProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyRuntimeTargetPolicy(runtime) {
|
function applyRuntimeTargetPolicy(runtime) {
|
||||||
const passengerReady = runtime?.hardware?.configured === true;
|
const selectedTransport = selectedVehicleTransport();
|
||||||
const driverReady = runtime?.vehicle_can?.configured === true;
|
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 driverOnly = driverReady && !passengerReady;
|
||||||
const stage = $("#seatTwin");
|
const stage = $("#seatTwin");
|
||||||
if (stage) {
|
if (stage) {
|
||||||
stage.dataset.passengerChannel = "CH0";
|
stage.dataset.passengerChannel = "CH0";
|
||||||
stage.dataset.driverChannel = "CH1";
|
stage.dataset.driverChannel = state.controlTransport === "wireless_mqtt" ? "MQTT" : "CH1";
|
||||||
stage.dataset.passengerConfigured = String(passengerReady);
|
stage.dataset.passengerConfigured = String(passengerReady);
|
||||||
stage.dataset.driverConfigured = String(driverReady);
|
stage.dataset.driverConfigured = String(driverReady);
|
||||||
}
|
}
|
||||||
@@ -5072,6 +5170,7 @@ async function loadRuntime() {
|
|||||||
const response = await apiRequest("/api/runtime", { cache: "no-store" });
|
const response = await apiRequest("/api/runtime", { cache: "no-store" });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
state.runtime = await response.json();
|
state.runtime = await response.json();
|
||||||
|
renderControlTransport();
|
||||||
applyRuntimeTargetPolicy(state.runtime);
|
applyRuntimeTargetPolicy(state.runtime);
|
||||||
populateVehicleSelector();
|
populateVehicleSelector();
|
||||||
populateModeChips();
|
populateModeChips();
|
||||||
@@ -5080,12 +5179,17 @@ async function loadRuntime() {
|
|||||||
state.reasoningEffort = configuredEffort;
|
state.reasoningEffort = configuredEffort;
|
||||||
}
|
}
|
||||||
const hardware = state.runtime.hardware || {};
|
const hardware = state.runtime.hardware || {};
|
||||||
const vehicleCan = state.runtime.vehicle_can || {};
|
const vehicleCan = selectedVehicleTransport() || {};
|
||||||
const linkedHardwareReady = Boolean(hardware.configured && vehicleCan.configured);
|
const mqttSelected = state.controlTransport === "wireless_mqtt";
|
||||||
const seatHardwareReady = Boolean(hardware.configured);
|
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 vehicleHardwareReady = Boolean(vehicleCan.configured);
|
||||||
|
const linkedHardwareReady = seatHardwareReady && vehicleHardwareReady;
|
||||||
const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
|
const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
|
||||||
const hardwareExecutionReady = Boolean(state.runtime.hardware_execution_allowed);
|
const hardwareExecutionReady = Boolean(hardware.configured || vehicleCan.configured);
|
||||||
const badge = $("#modelBadge");
|
const badge = $("#modelBadge");
|
||||||
const gptButton = $('[data-provider="openai"]');
|
const gptButton = $('[data-provider="openai"]');
|
||||||
if (state.runtime.openai_available) {
|
if (state.runtime.openai_available) {
|
||||||
@@ -5120,32 +5224,35 @@ async function loadRuntime() {
|
|||||||
&& hardware.access_policy === "local_approval_idle_lease"
|
&& hardware.access_policy === "local_approval_idle_lease"
|
||||||
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
|
? ` · 本地审批 · 空闲 ${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
|
$("#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
|
: 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
|
: seatHardwareReady
|
||||||
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
|
? `${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
|
$("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
||||||
? "CH1 → 车身 CAN FD / 主驾座椅 · CH0 与副驾 T2G 路径禁止"
|
? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止`
|
||||||
: linkedHardwareReady
|
: linkedHardwareReady
|
||||||
? "CH0 → 副驾座椅控制器 · CH1 → 车身 CAN FD"
|
? `${mqttDualChannel ? "MQTT CAN0" : "CH0"} → 副驾座椅控制器 · ${mqttDualChannel ? "MQTT CAN1" : "CH1"} → 其余车辆 CAN`
|
||||||
: seatHardwareReady
|
: seatHardwareReady
|
||||||
? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
|
? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
|
||||||
: "尚未配置可执行的物理 CAN 通道";
|
: "尚未配置可执行的物理 CAN 通道";
|
||||||
$("#hardwareBadge").className = `status-badge ${hardwareExecutionReady ? "" : "danger"}`.trim();
|
$("#hardwareBadge").className = `status-badge ${hardwareExecutionReady ? "" : "danger"}`.trim();
|
||||||
$("#hardwareBadge").innerHTML = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
$("#hardwareBadge").innerHTML = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
||||||
? '<i data-lucide="shield-check"></i>CH1 车端已配置'
|
? `<i data-lucide="shield-check"></i>${mqttSelected ? "MQTT" : "CH1"} 车端已配置`
|
||||||
: linkedHardwareReady
|
: linkedHardwareReady
|
||||||
? '<i data-lucide="shield-check"></i>双通道已配置'
|
? '<i data-lucide="shield-check"></i>双通道已配置'
|
||||||
: seatHardwareReady
|
: seatHardwareReady
|
||||||
? '<i data-lucide="shield-check"></i>座椅台架已配置'
|
? '<i data-lucide="shield-check"></i>座椅台架已配置'
|
||||||
: '<i data-lucide="shield-off"></i>硬件锁定';
|
: '<i data-lucide="shield-off"></i>硬件锁定';
|
||||||
$("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
$("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
||||||
? `CH1 主驾 + 车端设备 · 物理字段回读${accessText}${concurrencyText}`
|
? `${mqttSelected ? "MQTT" : "CH1"} 主驾 + 车端设备 · CAN 字段回读${accessText}${concurrencyText}`
|
||||||
: linkedHardwareReady
|
: linkedHardwareReady
|
||||||
? `CH0 座椅 + CH${vehicleCan.channel} 车身${accessText}${concurrencyText}`
|
? `CH0 座椅 + ${mqttSelected ? "MQTT" : `CH${vehicleCan.channel}`} 车身${accessText}${concurrencyText}`
|
||||||
: seatHardwareReady
|
: seatHardwareReady
|
||||||
? `CH${hardware.channel} 座椅 · 物理反馈${accessText}${concurrencyText}`
|
? `CH${hardware.channel} 座椅 · 物理反馈${accessText}${concurrencyText}`
|
||||||
: "已阻断";
|
: "已阻断";
|
||||||
@@ -5166,10 +5273,16 @@ async function loadRuntime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function synchronizePassengerSeatState() {
|
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({
|
const query = new URLSearchParams({
|
||||||
conversation_id: state.conversationId,
|
conversation_id: state.conversationId,
|
||||||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||||||
|
control_transport: requestedTransport,
|
||||||
hardware_client_id: state.hardwareClientId,
|
hardware_client_id: state.hardwareClientId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -5182,15 +5295,16 @@ async function synchronizePassengerSeatState() {
|
|||||||
}
|
}
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
if (requestedTransport !== state.controlTransport) return false;
|
||||||
renderSeatState({ passenger: payload.actuator_state?.passenger || {} });
|
renderSeatState({ passenger: payload.actuator_state?.passenger || {} });
|
||||||
state.seatFeedbackSources.passenger = payload.complete
|
state.seatFeedbackSources.passenger = payload.complete
|
||||||
? "physical_can"
|
? payload.source === "wireless_mqtt_ch0" ? "wireless_mqtt_ch0" : "physical_can"
|
||||||
: "physical_partial";
|
: "physical_partial";
|
||||||
state.seatFeedbackSource = state.seatFeedbackSources.passenger;
|
state.seatFeedbackSource = state.seatFeedbackSources.passenger;
|
||||||
updateSeatFeedbackModeLabel();
|
updateSeatFeedbackModeLabel();
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (!state.seatFeedbackSources.passenger.startsWith("physical")) {
|
if (!["physical_can", "wireless_mqtt_ch0"].includes(state.seatFeedbackSources.passenger)) {
|
||||||
state.seatFeedbackSources.passenger = "unavailable";
|
state.seatFeedbackSources.passenger = "unavailable";
|
||||||
updateSeatFeedbackModeLabel();
|
updateSeatFeedbackModeLabel();
|
||||||
}
|
}
|
||||||
@@ -5199,10 +5313,12 @@ async function synchronizePassengerSeatState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function synchronizeDriverSeatState() {
|
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({
|
const query = new URLSearchParams({
|
||||||
conversation_id: state.conversationId,
|
conversation_id: state.conversationId,
|
||||||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||||||
|
control_transport: requestedTransport,
|
||||||
hardware_client_id: state.hardwareClientId,
|
hardware_client_id: state.hardwareClientId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -5213,13 +5329,16 @@ async function synchronizeDriverSeatState() {
|
|||||||
}
|
}
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
if (requestedTransport !== state.controlTransport) return false;
|
||||||
applyAuthoritativeVehicleState(payload);
|
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;
|
state.seatFeedbackSource = state.seatFeedbackSources.driver;
|
||||||
updateSeatFeedbackModeLabel();
|
updateSeatFeedbackModeLabel();
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (!state.seatFeedbackSources.driver.startsWith("physical")) {
|
if (!["physical_can_ch1", "wireless_mqtt_ch1"].includes(state.seatFeedbackSources.driver)) {
|
||||||
state.seatFeedbackSources.driver = "unavailable";
|
state.seatFeedbackSources.driver = "unavailable";
|
||||||
updateSeatFeedbackModeLabel();
|
updateSeatFeedbackModeLabel();
|
||||||
}
|
}
|
||||||
@@ -5230,7 +5349,22 @@ async function synchronizeDriverSeatState() {
|
|||||||
async function synchronizePhysicalSeatState() {
|
async function synchronizePhysicalSeatState() {
|
||||||
if (state.physicalStateSyncPromise) return state.physicalStateSyncPromise;
|
if (state.physicalStateSyncPromise) return state.physicalStateSyncPromise;
|
||||||
state.physicalStateSyncPromise = (async () => {
|
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 passenger = await synchronizePassengerSeatState();
|
||||||
const driver = await synchronizeDriverSeatState();
|
const driver = await synchronizeDriverSeatState();
|
||||||
return passenger || driver;
|
return passenger || driver;
|
||||||
@@ -5242,6 +5376,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() {
|
async function loadAuthStatus() {
|
||||||
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
|
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
@@ -5542,7 +5686,7 @@ function renderDomainState(domainState) {
|
|||||||
if (vehicleSystems) {
|
if (vehicleSystems) {
|
||||||
vehicleSystems.targets.climateLevel = Math.max(0, Math.min(7, Number(climate.fan_level) || 0));
|
vehicleSystems.targets.climateLevel = Math.max(0, Math.min(7, Number(climate.fan_level) || 0));
|
||||||
vehicleSystems.targets.climateTemperature = Number(
|
vehicleSystems.targets.climateTemperature = Number(
|
||||||
state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
|
selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
|
||||||
? driverTemperature
|
? driverTemperature
|
||||||
: passengerTemperature,
|
: passengerTemperature,
|
||||||
) || 24;
|
) || 24;
|
||||||
@@ -6170,8 +6314,8 @@ function renderExecution(result) {
|
|||||||
: simulatedHil
|
: simulatedHil
|
||||||
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
|
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
|
||||||
: physical
|
: physical
|
||||||
? state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
|
? selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
|
||||||
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
? `${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(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
||||||
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
|
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
|
||||||
if (partiallySucceeded) {
|
if (partiallySucceeded) {
|
||||||
@@ -6306,8 +6450,12 @@ function applyAuthoritativeVehicleState(payload) {
|
|||||||
domainState: domainPatch,
|
domainState: domainPatch,
|
||||||
};
|
};
|
||||||
if (actuatorPatch?.driver) {
|
if (actuatorPatch?.driver) {
|
||||||
state.seatFeedbackSource = "physical_can_ch1";
|
const source = payload.control_transport === "wireless_mqtt"
|
||||||
state.seatFeedbackSources.driver = "physical_can_ch1";
|
|| payload.source === "wireless_mqtt_ch1"
|
||||||
|
? "wireless_mqtt_ch1"
|
||||||
|
: "physical_can_ch1";
|
||||||
|
state.seatFeedbackSource = source;
|
||||||
|
state.seatFeedbackSources.driver = source;
|
||||||
}
|
}
|
||||||
renderSeatState(actuatorPatch);
|
renderSeatState(actuatorPatch);
|
||||||
renderDomainState(domainPatch);
|
renderDomainState(domainPatch);
|
||||||
@@ -6346,8 +6494,10 @@ function renderResult(result) {
|
|||||||
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
|
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
|
||||||
} else if (feedbackSource === "physical_can_ch1"
|
} else if (feedbackSource === "physical_can_ch1"
|
||||||
&& isAuthoritativeVehicleStatePayload(result)) {
|
&& isAuthoritativeVehicleStatePayload(result)) {
|
||||||
state.seatFeedbackSource = "physical_can_ch1";
|
state.seatFeedbackSource = result.control_transport === "wireless_mqtt"
|
||||||
state.seatFeedbackSources.driver = "physical_can_ch1";
|
? "wireless_mqtt_ch1"
|
||||||
|
: "physical_can_ch1";
|
||||||
|
state.seatFeedbackSources.driver = state.seatFeedbackSource;
|
||||||
} else if (reportMode === "hil") {
|
} else if (reportMode === "hil") {
|
||||||
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
|
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
|
||||||
? "physical_can"
|
? "physical_can"
|
||||||
@@ -6393,7 +6543,7 @@ function renderResult(result) {
|
|||||||
renderTrace(result.trace_events || []);
|
renderTrace(result.trace_events || []);
|
||||||
renderValidation(result.validation || []);
|
renderValidation(result.validation || []);
|
||||||
const ch1HardwareResult = reportMode === "hil"
|
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) {
|
if (ch1HardwareResult) {
|
||||||
applyAuthoritativeVehicleState(result);
|
applyAuthoritativeVehicleState(result);
|
||||||
} else {
|
} else {
|
||||||
@@ -6457,6 +6607,7 @@ async function submitRequest(event) {
|
|||||||
conversation_id: state.conversationId,
|
conversation_id: state.conversationId,
|
||||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||||
execution_mode: state.executionMode,
|
execution_mode: state.executionMode,
|
||||||
|
control_transport: state.controlTransport,
|
||||||
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||||||
...(state.executionMode === "hardware" && !driverOnlyCh1
|
...(state.executionMode === "hardware" && !driverOnlyCh1
|
||||||
? { hardware_client_id: state.hardwareClientId }
|
? { hardware_client_id: state.hardwareClientId }
|
||||||
@@ -6716,6 +6867,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
$$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider)));
|
$$(".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-reasoning-effort]').forEach((button) => button.addEventListener("click", () => selectReasoningEffort(button.dataset.reasoningEffort)));
|
||||||
$$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode)));
|
$$('[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", () => {
|
$$(".example-command").forEach((button) => button.addEventListener("click", () => {
|
||||||
$("#utterance").value = button.dataset.example;
|
$("#utterance").value = button.dataset.example;
|
||||||
}));
|
}));
|
||||||
@@ -6732,12 +6889,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await synchronizePhysicalSeatState();
|
await synchronizePhysicalSeatState();
|
||||||
state.physicalStatePoller = window.setInterval(() => {
|
restartPhysicalStatePolling();
|
||||||
const activeModule = document.body.dataset.module;
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
if (!["self-orchestration", "self-extension"].includes(activeModule)) return;
|
|
||||||
synchronizePhysicalSeatState();
|
|
||||||
}, 5000);
|
|
||||||
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
|
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
|
||||||
$("#requestForm").requestSubmit();
|
$("#requestForm").requestSubmit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,63 @@
|
|||||||
display: none;
|
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 {
|
.console-v2 .v2-config-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: var(--v2-sidebar-card-height);
|
min-height: var(--v2-sidebar-card-height);
|
||||||
|
|||||||
@@ -293,6 +293,7 @@
|
|||||||
|
|
||||||
function buildConfigFields(configStack, originalForm) {
|
function buildConfigFields(configStack, originalForm) {
|
||||||
const vehicleField = $(".vehicle-field", originalForm);
|
const vehicleField = $(".vehicle-field", originalForm);
|
||||||
|
const transportField = $("#vehicleTransportField", originalForm);
|
||||||
const providerField = $(".field-group:has(.segmented [data-provider])", originalForm);
|
const providerField = $(".field-group:has(.segmented [data-provider])", originalForm);
|
||||||
const reasoningField = $("#reasoningField", originalForm);
|
const reasoningField = $("#reasoningField", originalForm);
|
||||||
const executionField = $(".field-group:has(.execution-mode-options)", originalForm);
|
const executionField = $(".field-group:has(.execution-mode-options)", originalForm);
|
||||||
@@ -319,6 +320,11 @@
|
|||||||
configStack.append(vehicleField);
|
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", `
|
const modelField = make("div", "v2-config-field v2-config-card v2-model-field", `
|
||||||
<i data-lucide="sparkles"></i>
|
<i data-lucide="sparkles"></i>
|
||||||
<div><small>推理模型</small><strong id="v2ModelValue">模型检测中</strong></div>
|
<div><small>推理模型</small><strong id="v2ModelValue">模型检测中</strong></div>
|
||||||
|
|||||||
@@ -9,18 +9,18 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" href="/icons/app-icon-192.png" />
|
<link rel="icon" href="/icons/app-icon-192.png" />
|
||||||
<title>511工程-智能座舱</title>
|
<title>511工程-智能座舱</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=dual-seat-9" />
|
<link rel="stylesheet" href="/styles.css?v=dual-seat-10" />
|
||||||
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-26" />
|
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-27" />
|
||||||
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
|
<link rel="stylesheet" href="/self-extension.css?v=sync-layout-26" />
|
||||||
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||||
<script src="/network.js?v=android-pad-1" defer></script>
|
<script src="/network.js?v=android-pad-1" defer></script>
|
||||||
<script src="/app.js?v=dual-seat-85" defer></script>
|
<script src="/app.js?v=dual-seat-90" defer></script>
|
||||||
<script type="module" src="/gesture-control.js?v=android-pad-8"></script>
|
<script type="module" src="/gesture-control.js?v=android-pad-8"></script>
|
||||||
<script src="/pwa.js?v=android-pad-2" defer></script>
|
<script src="/pwa.js?v=android-pad-2" defer></script>
|
||||||
<script src="/console-v2.js?v=sync-layout-26" defer></script>
|
<script src="/console-v2.js?v=sync-layout-27" defer></script>
|
||||||
<script src="/self-extension.js?v=sync-layout-26" defer></script>
|
<script src="/self-extension.js?v=sync-layout-26" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -140,6 +140,19 @@
|
|||||||
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
||||||
</div>
|
</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>
|
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
|
||||||
<label class="field-label" for="simulationFault">故障注入场景</label>
|
<label class="field-label" for="simulationFault">故障注入场景</label>
|
||||||
<select id="simulationFault" aria-label="选择模拟硬件故障">
|
<select id="simulationFault" aria-label="选择模拟硬件故障">
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
const CACHE_VERSION = "cognitive-agent-pad-v178";
|
const CACHE_VERSION = "cognitive-agent-pad-v179";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
"/",
|
"/",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/styles.css?v=dual-seat-9",
|
"/styles.css?v=dual-seat-10",
|
||||||
"/network.js?v=android-pad-1",
|
"/network.js?v=android-pad-1",
|
||||||
"/app.js?v=dual-seat-85",
|
"/app.js?v=dual-seat-90",
|
||||||
"/gesture-control.js?v=android-pad-8",
|
"/gesture-control.js?v=android-pad-8",
|
||||||
"/pwa.js?v=android-pad-2",
|
"/pwa.js?v=android-pad-2",
|
||||||
"/console-v2.css?v=sync-layout-26",
|
"/console-v2.css?v=sync-layout-27",
|
||||||
"/console-v2.js?v=sync-layout-26",
|
"/console-v2.js?v=sync-layout-27",
|
||||||
"/self-extension.css?v=sync-layout-26",
|
"/self-extension.css?v=sync-layout-26",
|
||||||
"/self-extension.js?v=sync-layout-26",
|
"/self-extension.js?v=sync-layout-26",
|
||||||
"/assets/pets/self-extension-cat.png?v=3",
|
"/assets/pets/self-extension-cat.png?v=3",
|
||||||
|
|||||||
@@ -5071,6 +5071,24 @@ pre {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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 {
|
.simulation-fault-field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 44px;
|
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
|
||||||
@@ -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,
|
firmware_guard: T2GFirmwareGuard | None = None,
|
||||||
synchronize_position_before_first_motion: bool = False,
|
synchronize_position_before_first_motion: bool = False,
|
||||||
position_sync_state: dict[str, bool] | None = None,
|
position_sync_state: dict[str, bool] | None = None,
|
||||||
|
require_thermal_feedback: bool = True,
|
||||||
):
|
):
|
||||||
self.matrix = matrix
|
self.matrix = matrix
|
||||||
self.bus_factory = bus_factory
|
self.bus_factory = bus_factory
|
||||||
@@ -134,6 +135,7 @@ class HardwareSeatExecutor:
|
|||||||
if position_sync_state is not None
|
if position_sync_state is not None
|
||||||
else {"synchronized": False}
|
else {"synchronized": False}
|
||||||
)
|
)
|
||||||
|
self.require_thermal_feedback = bool(require_thermal_feedback)
|
||||||
self._current_samples: dict[int, list[dict[str, Any]]] = {}
|
self._current_samples: dict[int, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -469,26 +471,30 @@ class HardwareSeatExecutor:
|
|||||||
"feedback_value": raw,
|
"feedback_value": raw,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
def _physical_state_complete(self, state: dict[str, Any]) -> bool:
|
||||||
def _physical_state_complete(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 (
|
return (
|
||||||
set(state.get("position", {})) == set(PHYSICAL_MOTOR_AXES.values())
|
positions_complete
|
||||||
and isinstance(state.get("ventilation"), dict)
|
and isinstance(state.get("ventilation"), dict)
|
||||||
and isinstance(state.get("heating"), dict)
|
and isinstance(state.get("heating"), dict)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def _physical_state_missing(self, state: dict[str, Any]) -> list[str]:
|
||||||
def _physical_state_missing(state: dict[str, Any]) -> list[str]:
|
|
||||||
missing = [
|
missing = [
|
||||||
axis
|
axis
|
||||||
for axis in PHYSICAL_MOTOR_AXES.values()
|
for axis in PHYSICAL_MOTOR_AXES.values()
|
||||||
if axis not in state.get("position", {})
|
if axis not in state.get("position", {})
|
||||||
]
|
]
|
||||||
missing.extend(
|
if self.require_thermal_feedback:
|
||||||
feature
|
missing.extend(
|
||||||
for feature in ("ventilation", "heating")
|
feature
|
||||||
if not isinstance(state.get(feature), dict)
|
for feature in ("ventilation", "heating")
|
||||||
)
|
if not isinstance(state.get(feature), dict)
|
||||||
|
)
|
||||||
return missing
|
return missing
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+2
-2
@@ -11,13 +11,14 @@ dependencies = [
|
|||||||
"jsonschema>=4.21",
|
"jsonschema>=4.21",
|
||||||
"pydantic>=2.6",
|
"pydantic>=2.6",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
|
"paho-mqtt>=2,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
api = ["fastapi>=0.110", "uvicorn>=0.27"]
|
api = ["fastapi>=0.110", "uvicorn>=0.27"]
|
||||||
can = ["cantools>=39", "python-can>=4.3"]
|
can = ["cantools>=39", "python-can>=4.3"]
|
||||||
llm = ["openai>=1.30"]
|
llm = ["openai>=1.30"]
|
||||||
ir = ["paho-mqtt>=2,<3"]
|
ir = []
|
||||||
test = ["pytest>=8", "pytest-cov>=5"]
|
test = ["pytest>=8", "pytest-cov>=5"]
|
||||||
dev = ["mypy>=1.9", "ruff>=0.4"]
|
dev = ["mypy>=1.9", "ruff>=0.4"]
|
||||||
|
|
||||||
@@ -36,4 +37,3 @@ markers = [
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
+24
-22
@@ -1,22 +1,24 @@
|
|||||||
Metadata-Version: 2.4
|
Metadata-Version: 2.4
|
||||||
Name: cognitive-agent
|
Name: cognitive-agent
|
||||||
Version: 0.1.0
|
Version: 0.1.0
|
||||||
Summary: Configuration-driven cognitive orchestration agent for in-vehicle actuators
|
Summary: Configuration-driven cognitive orchestration agent for in-vehicle actuators
|
||||||
Requires-Python: >=3.11
|
Requires-Python: >=3.11
|
||||||
Requires-Dist: jsonschema>=4.21
|
Requires-Dist: jsonschema>=4.21
|
||||||
Requires-Dist: pydantic>=2.6
|
Requires-Dist: pydantic>=2.6
|
||||||
Requires-Dist: PyYAML>=6.0
|
Requires-Dist: PyYAML>=6.0
|
||||||
Provides-Extra: api
|
Provides-Extra: api
|
||||||
Requires-Dist: fastapi>=0.110; extra == "api"
|
Requires-Dist: fastapi>=0.110; extra == "api"
|
||||||
Requires-Dist: uvicorn>=0.27; extra == "api"
|
Requires-Dist: uvicorn>=0.27; extra == "api"
|
||||||
Provides-Extra: can
|
Provides-Extra: can
|
||||||
Requires-Dist: cantools>=39; extra == "can"
|
Requires-Dist: cantools>=39; extra == "can"
|
||||||
Requires-Dist: python-can>=4.3; extra == "can"
|
Requires-Dist: python-can>=4.3; extra == "can"
|
||||||
Provides-Extra: llm
|
Provides-Extra: llm
|
||||||
Requires-Dist: openai>=1.30; extra == "llm"
|
Requires-Dist: openai>=1.30; extra == "llm"
|
||||||
Provides-Extra: test
|
Provides-Extra: ir
|
||||||
Requires-Dist: pytest>=8; extra == "test"
|
Requires-Dist: paho-mqtt<3,>=2; extra == "ir"
|
||||||
Requires-Dist: pytest-cov>=5; extra == "test"
|
Provides-Extra: test
|
||||||
Provides-Extra: dev
|
Requires-Dist: pytest>=8; extra == "test"
|
||||||
Requires-Dist: mypy>=1.9; extra == "dev"
|
Requires-Dist: pytest-cov>=5; extra == "test"
|
||||||
Requires-Dist: ruff>=0.4; extra == "dev"
|
Provides-Extra: dev
|
||||||
|
Requires-Dist: mypy>=1.9; extra == "dev"
|
||||||
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
||||||
|
|||||||
@@ -13,15 +13,40 @@ src/cognitive_agent/application/capability_registry.py
|
|||||||
src/cognitive_agent/application/conversation_state.py
|
src/cognitive_agent/application/conversation_state.py
|
||||||
src/cognitive_agent/application/domain_runtime.py
|
src/cognitive_agent/application/domain_runtime.py
|
||||||
src/cognitive_agent/application/gesture_bridge.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/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_calibration.py
|
||||||
src/cognitive_agent/application/seat_service.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/__init__.py
|
||||||
|
src/cognitive_agent/infrastructure/can/access.py
|
||||||
src/cognitive_agent/infrastructure/can/gateway.py
|
src/cognitive_agent/infrastructure/can/gateway.py
|
||||||
src/cognitive_agent/infrastructure/can/hardware.py
|
src/cognitive_agent/infrastructure/can/hardware.py
|
||||||
src/cognitive_agent/infrastructure/can/matrix.py
|
src/cognitive_agent/infrastructure/can/matrix.py
|
||||||
src/cognitive_agent/infrastructure/can/zlgcan_bridge.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/__init__.py
|
||||||
src/cognitive_agent/infrastructure/firmware/t2g_seat.py
|
src/cognitive_agent/infrastructure/firmware/t2g_seat.py
|
||||||
src/cognitive_agent/infrastructure/llm/openai_responses.py
|
src/cognitive_agent/infrastructure/llm/openai_responses.py
|
||||||
src/cognitive_agent/runtime/contracts.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
|
mypy>=1.9
|
||||||
ruff>=0.4
|
ruff>=0.4
|
||||||
|
|
||||||
|
[ir]
|
||||||
|
paho-mqtt<3,>=2
|
||||||
|
|
||||||
[llm]
|
[llm]
|
||||||
openai>=1.30
|
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.
|
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
|
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
|
from __future__ import annotations
|
||||||
@@ -294,8 +296,43 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
|
|||||||
"rear_brightness_level": (
|
"rear_brightness_level": (
|
||||||
rear_brightness_raw - 1 if 1 <= rear_brightness_raw <= 6 else None
|
rear_brightness_raw - 1 if 1 <= rear_brightness_raw <= 6 else None
|
||||||
),
|
),
|
||||||
"front_color_index": front_color,
|
# Sheet1 rows 1896-1897 define 0x01-0x1F as colors and
|
||||||
"rear_color_index": rear_color,
|
# 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
|
return decoded
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
|
|||||||
SEAT_CAPABILITIES,
|
SEAT_CAPABILITIES,
|
||||||
SEAT_MOTION_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_intent import merge_native_can_intent # noqa: E402
|
||||||
from cognitive_agent.application.native_can_relative import ( # noqa: E402
|
from cognitive_agent.application.native_can_relative import ( # noqa: E402
|
||||||
NativeCanRelativeResolver,
|
NativeCanRelativeResolver,
|
||||||
@@ -76,6 +79,12 @@ from cognitive_agent.application.vehicle_profile import ( # noqa: E402
|
|||||||
VehicleProfile,
|
VehicleProfile,
|
||||||
VehicleProfileRegistry,
|
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
|
from cognitive_agent.application.zcan_seat_executor import ( # noqa: E402
|
||||||
ZcanBridgeSeatExecutor,
|
ZcanBridgeSeatExecutor,
|
||||||
)
|
)
|
||||||
@@ -89,8 +98,12 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
|||||||
PHYSICAL_BENCH_COORDINATOR,
|
PHYSICAL_BENCH_COORDINATOR,
|
||||||
SeatHardwareSettings,
|
SeatHardwareSettings,
|
||||||
VehicleCanSettings,
|
VehicleCanSettings,
|
||||||
|
VehicleMqttSettings,
|
||||||
)
|
)
|
||||||
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig # noqa: E402
|
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
|
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||||
PHYSICAL_BENCH_ACCESS,
|
PHYSICAL_BENCH_ACCESS,
|
||||||
)
|
)
|
||||||
@@ -117,6 +130,11 @@ PASSENGER_SEAT_SCOPE_PATTERN = re.compile(
|
|||||||
r"\bpassenger\b|\bfront[-_ ]?right\b|\bright[-_ ]?front\b|副驾|乘客|右前",
|
r"\bpassenger\b|\bfront[-_ ]?right\b|\bright[-_ ]?front\b|副驾|乘客|右前",
|
||||||
re.IGNORECASE,
|
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:
|
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.hardware_settings = SeatHardwareSettings.from_env(repository_root)
|
||||||
self.vehicle_can_settings = VehicleCanSettings.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_runtime = NativeCanRuntime(repository_root)
|
||||||
self.native_can_relative_resolver = NativeCanRelativeResolver(repository_root)
|
self.native_can_relative_resolver = NativeCanRelativeResolver(repository_root)
|
||||||
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
|
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
|
||||||
@@ -272,6 +301,22 @@ class SeatDemoService:
|
|||||||
and self.vehicle_can_settings.channel == 1
|
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]:
|
def runtime_info(self) -> dict[str, Any]:
|
||||||
catalog = yaml.safe_load(
|
catalog = yaml.safe_load(
|
||||||
(self.domain_root / "config" / "capabilities.yaml").read_text(encoding="utf-8")
|
(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 = self.hardware_settings.public_info()
|
||||||
hardware_info.update(self.hardware_execution.public_info())
|
hardware_info.update(self.hardware_execution.public_info())
|
||||||
hardware_info.update(self.hardware_access.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 {
|
return {
|
||||||
"service": "cabin-cognitive-orchestrator",
|
"service": "cabin-cognitive-orchestrator",
|
||||||
"domain": "cabin",
|
"domain": "cabin",
|
||||||
@@ -294,9 +348,27 @@ class SeatDemoService:
|
|||||||
"hardware_execution_allowed": (
|
"hardware_execution_allowed": (
|
||||||
self.hardware_settings.configured
|
self.hardware_settings.configured
|
||||||
or self.vehicle_can_settings.configured
|
or self.vehicle_can_settings.configured
|
||||||
|
or self.vehicle_mqtt_settings.configured
|
||||||
),
|
),
|
||||||
"hardware": hardware_info,
|
"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": {
|
"vehicle_safety_policy": {
|
||||||
"required_channel": 1,
|
"required_channel": 1,
|
||||||
"required_gear": "P",
|
"required_gear": "P",
|
||||||
@@ -322,6 +394,8 @@ class SeatDemoService:
|
|||||||
and self.vehicle_can_settings.configured
|
and self.vehicle_can_settings.configured
|
||||||
else "ch1_body_can_driver_only"
|
else "ch1_body_can_driver_only"
|
||||||
if self.vehicle_can_settings.configured
|
if self.vehicle_can_settings.configured
|
||||||
|
else "ch1_body_can_read_only"
|
||||||
|
if self.vehicle_can_settings.state_configured
|
||||||
else "ch0_passenger_only"
|
else "ch0_passenger_only"
|
||||||
if self.hardware_settings.configured
|
if self.hardware_settings.configured
|
||||||
else "virtual_only"
|
else "virtual_only"
|
||||||
@@ -407,23 +481,54 @@ class SeatDemoService:
|
|||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
*,
|
*,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
hardware_client_id: str | None = None,
|
hardware_client_id: str | None = None,
|
||||||
hardware_access_bypass: bool = True,
|
hardware_access_bypass: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Read the bench only when no physical execution currently owns it."""
|
"""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:
|
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():
|
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)
|
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_execution.reserve():
|
||||||
with self.hardware_access.use(authorized_client_id):
|
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(
|
def _synchronize_hardware_state_once(
|
||||||
self,
|
self,
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
|
*,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Read the bench and project it into the current passenger conversation."""
|
"""Read the bench and project it into the current passenger conversation."""
|
||||||
|
|
||||||
@@ -431,10 +536,26 @@ class SeatDemoService:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
|
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
|
||||||
)
|
)
|
||||||
if not self.hardware_settings.configured:
|
control_transport = self._validate_control_transport(control_transport)
|
||||||
raise HardwareSafetyError("the T2G seat hardware runtime is not configured")
|
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)
|
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(
|
context = self.conversations.synchronize_physical_passenger(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
physical_state,
|
physical_state,
|
||||||
@@ -444,7 +565,8 @@ class SeatDemoService:
|
|||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"conversation_turn": context["turn_count"],
|
"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"),
|
"captured_at": physical_state.get("captured_at"),
|
||||||
"complete": bool(physical_state.get("complete")),
|
"complete": bool(physical_state.get("complete")),
|
||||||
"missing": list(physical_state.get("missing", [])),
|
"missing": list(physical_state.get("missing", [])),
|
||||||
@@ -457,38 +579,103 @@ class SeatDemoService:
|
|||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
*,
|
*,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
hardware_client_id: str | None = None,
|
hardware_client_id: str | None = None,
|
||||||
hardware_access_bypass: bool = True,
|
hardware_access_bypass: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Passively receive CH1 driver-seat feedback without transmitting CAN."""
|
"""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:
|
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():
|
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)
|
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_execution.reserve():
|
||||||
with self.hardware_access.use(authorized_client_id):
|
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(
|
def _synchronize_vehicle_state_once(
|
||||||
self,
|
self,
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
|
*,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
|
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
|
"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")
|
raise HardwareSafetyError("the CH1 vehicle CAN runtime is not configured")
|
||||||
|
|
||||||
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
|
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
|
||||||
shared_runtime_enabled = os.getenv(
|
shared_runtime_enabled = (
|
||||||
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
|
self.vehicle_can_settings.backend != "zlgcan-linux"
|
||||||
os.getenv("COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"),
|
and self.vehicle_can_settings.configured
|
||||||
).strip().lower() in {"1", "true", "yes", "on"}
|
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"}
|
||||||
|
)
|
||||||
bridge = None
|
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
|
from cognitive_agent.application.self_repair.service import BRIDGE_MANAGER
|
||||||
|
|
||||||
if not hasattr(BRIDGE_MANAGER, "channel_bus_factory"):
|
if not hasattr(BRIDGE_MANAGER, "channel_bus_factory"):
|
||||||
@@ -500,68 +687,65 @@ class SeatDemoService:
|
|||||||
)()
|
)()
|
||||||
else:
|
else:
|
||||||
bridge = self.vehicle_can_settings.bridge(
|
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)
|
bus = bridge.channel_bus(self.vehicle_can_settings.channel)
|
||||||
|
|
||||||
feedback_states: list[dict[str, Any]] = []
|
if not mqtt_selected and self._vehicle_state_monitor is None:
|
||||||
observed_ids: set[int] = set()
|
feedback_states = []
|
||||||
# Keep driver-seat completeness compatible with the existing endpoint,
|
observed_ids = set()
|
||||||
# while passively collecting every recognized CH1 body-state frame for
|
captured_at = None
|
||||||
# the whole-vehicle dashboard during the same bounded receive window.
|
# Keep driver-seat completeness compatible with the existing endpoint,
|
||||||
required_seat_ids = {0x4FA, 0x494}
|
# while passively collecting every recognized CH1 body-state frame for
|
||||||
required_body_ids = {0x342, 0x4EB, 0x407}
|
# the whole-vehicle dashboard during the same bounded receive window.
|
||||||
observable_ids = {
|
deadline = (
|
||||||
*required_seat_ids,
|
time.monotonic()
|
||||||
*required_body_ids,
|
+ self.vehicle_can_settings.feedback_timeout_ms / 1000
|
||||||
0x388,
|
)
|
||||||
0x404,
|
try:
|
||||||
0x41A,
|
while time.monotonic() < deadline and observed_ids != OBSERVABLE_IDS:
|
||||||
0x42E,
|
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
|
||||||
}
|
frame = bus.receive(remaining_ms, include_fd=True)
|
||||||
deadline = time.monotonic() + self.vehicle_can_settings.feedback_timeout_ms / 1000
|
if frame is None:
|
||||||
try:
|
break
|
||||||
while time.monotonic() < deadline and observed_ids != observable_ids:
|
if frame.channel != self.vehicle_can_settings.channel:
|
||||||
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
|
continue
|
||||||
frame = bus.receive(remaining_ms, include_fd=True)
|
if frame.arbitration_id not in OBSERVABLE_IDS or len(frame.data) < 8:
|
||||||
if frame is None:
|
continue
|
||||||
break
|
decoded = decode_feedback(frame)
|
||||||
if frame.channel != self.vehicle_can_settings.channel:
|
if not any(
|
||||||
continue
|
key in decoded
|
||||||
if frame.arbitration_id not in observable_ids or len(frame.data) < 8:
|
for key in ("actuator_state", "domain_state", "vehicle_safety")
|
||||||
continue
|
):
|
||||||
decoded = decode_feedback(frame)
|
# 0x4EB is multiplexed. Only subtype 0x07 carries the
|
||||||
if not any(
|
# verified speed signal, so unrelated subframes must not
|
||||||
key in decoded
|
# make the body-state snapshot look complete.
|
||||||
for key in ("actuator_state", "domain_state", "vehicle_safety")
|
continue
|
||||||
):
|
frame_id = str(decoded.get("frame_id", ""))
|
||||||
# 0x4EB is multiplexed. Only subtype 0x07 carries the
|
decoded.update(
|
||||||
# verified speed signal, so unrelated subframes must not
|
{
|
||||||
# make the body-state snapshot look complete.
|
"valid": True,
|
||||||
continue
|
"feedback_frame_id": frame_id,
|
||||||
frame_id = str(decoded.get("frame_id", ""))
|
"captured_at": _now(),
|
||||||
decoded.update(
|
"verification": {
|
||||||
{
|
"authoritative": True,
|
||||||
"valid": True,
|
"channel": 1,
|
||||||
"feedback_frame_id": frame_id,
|
"simulated": False,
|
||||||
"captured_at": _now(),
|
"read_only": True,
|
||||||
"verification": {
|
"frame_id": frame_id,
|
||||||
"authoritative": True,
|
"payload_size": len(frame.data),
|
||||||
"channel": 1,
|
},
|
||||||
"simulated": False,
|
}
|
||||||
"read_only": True,
|
)
|
||||||
"frame_id": frame_id,
|
feedback_states.append(decoded)
|
||||||
"payload_size": len(frame.data),
|
observed_ids.add(frame.arbitration_id)
|
||||||
},
|
captured_at = str(decoded["captured_at"])
|
||||||
}
|
finally:
|
||||||
)
|
if bridge is not None:
|
||||||
feedback_states.append(decoded)
|
bridge.close()
|
||||||
observed_ids.add(frame.arbitration_id)
|
else:
|
||||||
finally:
|
bus.close()
|
||||||
if bridge is not None:
|
|
||||||
bridge.close()
|
|
||||||
else:
|
|
||||||
bus.close()
|
|
||||||
|
|
||||||
context = self.conversations.synchronize_physical_ch1_feedback(
|
context = self.conversations.synchronize_physical_ch1_feedback(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
@@ -570,19 +754,23 @@ class SeatDemoService:
|
|||||||
context = self._context_for_vehicle(context, vehicle_profile)
|
context = self._context_for_vehicle(context, vehicle_profile)
|
||||||
missing = [
|
missing = [
|
||||||
f"0x{frame_id:03X}"
|
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 = [
|
body_state_missing = [
|
||||||
f"0x{frame_id:03X}"
|
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 {
|
return {
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"conversation_turn": context["turn_count"],
|
"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,
|
"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,
|
"complete": not missing,
|
||||||
"missing": missing,
|
"missing": missing,
|
||||||
"body_state_complete": not body_state_missing,
|
"body_state_complete": not body_state_missing,
|
||||||
@@ -593,8 +781,11 @@ class SeatDemoService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _new_hardware_executor(
|
def _new_hardware_executor(
|
||||||
self, bus_factory: Any | None = None
|
self,
|
||||||
) -> HardwareSeatExecutor:
|
bus_factory: Any | None = None,
|
||||||
|
*,
|
||||||
|
remote_transport: bool = False,
|
||||||
|
) -> HardwareSeatExecutor | MqttOtaSeatExecutor | ZcanBridgeSeatExecutor:
|
||||||
shared_bridge_enabled = os.getenv(
|
shared_bridge_enabled = os.getenv(
|
||||||
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
|
"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"),
|
||||||
@@ -624,7 +815,7 @@ class SeatDemoService:
|
|||||||
auto_home=self.hardware_settings.auto_home_enabled,
|
auto_home=self.hardware_settings.auto_home_enabled,
|
||||||
)
|
)
|
||||||
resolved_bus_factory = bus_factory or self.hardware_settings.bus_factory()
|
resolved_bus_factory = bus_factory or self.hardware_settings.bus_factory()
|
||||||
return HardwareSeatExecutor(
|
executor = HardwareSeatExecutor(
|
||||||
self.hardware_matrix,
|
self.hardware_matrix,
|
||||||
resolved_bus_factory,
|
resolved_bus_factory,
|
||||||
thermal_timeout_ms=self.hardware_settings.thermal_timeout_ms,
|
thermal_timeout_ms=self.hardware_settings.thermal_timeout_ms,
|
||||||
@@ -644,12 +835,19 @@ class SeatDemoService:
|
|||||||
),
|
),
|
||||||
auto_home=self.hardware_settings.auto_home_enabled,
|
auto_home=self.hardware_settings.auto_home_enabled,
|
||||||
home_timeout_s=self.hardware_settings.home_timeout_s,
|
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=(
|
synchronize_position_before_first_motion=(
|
||||||
self.hardware_settings.position_sync_enabled
|
self.hardware_settings.position_sync_enabled
|
||||||
),
|
),
|
||||||
position_sync_state=self._seat_position_memory_state,
|
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(
|
def process(
|
||||||
self,
|
self,
|
||||||
@@ -664,7 +862,9 @@ class SeatDemoService:
|
|||||||
hardware_client_id: str | None = None,
|
hardware_client_id: str | None = None,
|
||||||
hardware_access_bypass: bool = True,
|
hardware_access_bypass: bool = True,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
control_transport = self._validate_control_transport(control_transport)
|
||||||
# Creating a reusable scene is a configuration workflow. Synthesize
|
# Creating a reusable scene is a configuration workflow. Synthesize
|
||||||
# and validate it in dry-run even when the console currently targets
|
# and validate it in dry-run even when the console currently targets
|
||||||
# the real bench, otherwise virtual domains such as climate or ambient
|
# the real bench, otherwise virtual domains such as climate or ambient
|
||||||
@@ -681,8 +881,11 @@ class SeatDemoService:
|
|||||||
vehicle_id,
|
vehicle_id,
|
||||||
simulation_fault,
|
simulation_fault,
|
||||||
reasoning_effort,
|
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():
|
with self.hardware_execution.reserve():
|
||||||
return self._process_once(
|
return self._process_once(
|
||||||
utterance,
|
utterance,
|
||||||
@@ -693,6 +896,7 @@ class SeatDemoService:
|
|||||||
vehicle_id,
|
vehicle_id,
|
||||||
simulation_fault,
|
simulation_fault,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
|
control_transport,
|
||||||
)
|
)
|
||||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||||
with self.hardware_execution.reserve():
|
with self.hardware_execution.reserve():
|
||||||
@@ -706,6 +910,7 @@ class SeatDemoService:
|
|||||||
vehicle_id,
|
vehicle_id,
|
||||||
simulation_fault,
|
simulation_fault,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
|
control_transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _process_once(
|
def _process_once(
|
||||||
@@ -718,7 +923,9 @@ class SeatDemoService:
|
|||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
simulation_fault: str = "normal",
|
simulation_fault: str = "normal",
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
control_transport = self._validate_control_transport(control_transport)
|
||||||
utterance = utterance.strip()
|
utterance = utterance.strip()
|
||||||
if not utterance:
|
if not utterance:
|
||||||
raise ValueError("utterance must not be empty")
|
raise ValueError("utterance must not be empty")
|
||||||
@@ -776,6 +983,7 @@ class SeatDemoService:
|
|||||||
vehicle_profile,
|
vehicle_profile,
|
||||||
simulation_fault,
|
simulation_fault,
|
||||||
mode_create_name,
|
mode_create_name,
|
||||||
|
control_transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
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_requested": effective_reasoning_effort,
|
||||||
"reasoning_effort_used": None,
|
"reasoning_effort_used": None,
|
||||||
"execution_mode": execution_mode,
|
"execution_mode": execution_mode,
|
||||||
|
"control_transport": control_transport,
|
||||||
"hardware_execution_allowed": (
|
"hardware_execution_allowed": (
|
||||||
hardware_request and not control_plane_request
|
hardware_request and not control_plane_request
|
||||||
),
|
),
|
||||||
@@ -899,7 +1108,14 @@ class SeatDemoService:
|
|||||||
parsed = apply_language_safety_guards(parsed, utterance)
|
parsed = apply_language_safety_guards(parsed, utterance)
|
||||||
parsed = self._partition_native_actions(
|
parsed = self._partition_native_actions(
|
||||||
parsed,
|
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 hardware_request and not control_plane_request:
|
||||||
if parsed.get("domain_actions"):
|
if parsed.get("domain_actions"):
|
||||||
@@ -914,28 +1130,40 @@ class SeatDemoService:
|
|||||||
+ ", ".join(unsupported)
|
+ ", ".join(unsupported)
|
||||||
)
|
)
|
||||||
if parsed.get("actions"):
|
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"):
|
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"):
|
if not parsed.get("actions") and not parsed.get("native_can_actions"):
|
||||||
raise HardwareSafetyError(
|
raise HardwareSafetyError(
|
||||||
"hardware mode requires at least one verified seat or vehicle CAN operation"
|
"hardware mode requires at least one verified seat or vehicle CAN operation"
|
||||||
)
|
)
|
||||||
if parsed.get("actions") and parsed.get("native_can_actions"):
|
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(
|
raise HardwareSafetyError(
|
||||||
"linked seat/vehicle execution requires the shared zlgcan backend"
|
"linked seat/vehicle execution requires the shared zlgcan backend"
|
||||||
)
|
)
|
||||||
try:
|
if control_transport == WIRED_CAN_TRANSPORT:
|
||||||
seat_channel = int(self.hardware_settings.channel, 0)
|
try:
|
||||||
except ValueError as exc:
|
seat_channel = int(self.hardware_settings.channel, 0)
|
||||||
raise HardwareSafetyError(
|
except ValueError as exc:
|
||||||
"ZLG seat CAN channel must be an integer"
|
raise HardwareSafetyError(
|
||||||
) from exc
|
"ZLG seat CAN channel must be an integer"
|
||||||
if seat_channel == self.vehicle_can_settings.channel:
|
) from exc
|
||||||
raise HardwareSafetyError(
|
if seat_channel == self.vehicle_can_settings.channel:
|
||||||
"seat and vehicle CAN must use different adapter channels"
|
raise HardwareSafetyError(
|
||||||
)
|
"seat and vehicle CAN must use different adapter channels"
|
||||||
|
)
|
||||||
self._trace(
|
self._trace(
|
||||||
traces,
|
traces,
|
||||||
request_id,
|
request_id,
|
||||||
@@ -1052,6 +1280,7 @@ class SeatDemoService:
|
|||||||
simulated_hardware_request=simulated_hardware_request,
|
simulated_hardware_request=simulated_hardware_request,
|
||||||
simulation_fault=simulation_fault,
|
simulation_fault=simulation_fault,
|
||||||
vehicle_profile=vehicle_profile,
|
vehicle_profile=vehicle_profile,
|
||||||
|
control_transport=control_transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
intent_ir = build_intent_ir(
|
intent_ir = build_intent_ir(
|
||||||
@@ -1366,10 +1595,41 @@ class SeatDemoService:
|
|||||||
|
|
||||||
execute_started = time.perf_counter()
|
execute_started = time.perf_counter()
|
||||||
if hardware_request:
|
if hardware_request:
|
||||||
hardware_executor = self._new_hardware_executor()
|
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(
|
execution_report = hardware_executor.execute(
|
||||||
command_plan, function_config, context["devices"]
|
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:
|
elif simulated_hardware_request:
|
||||||
execution_report = self.simulated_hardware_executor.execute(
|
execution_report = self.simulated_hardware_executor.execute(
|
||||||
command_plan,
|
command_plan,
|
||||||
@@ -2481,6 +2741,7 @@ class SeatDemoService:
|
|||||||
vehicle_profile: VehicleProfile,
|
vehicle_profile: VehicleProfile,
|
||||||
simulation_fault: str,
|
simulation_fault: str,
|
||||||
mode_name: str,
|
mode_name: str,
|
||||||
|
control_transport: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build, verify, and persist a new named mode in one user turn.
|
"""Build, verify, and persist a new named mode in one user turn.
|
||||||
|
|
||||||
@@ -2502,6 +2763,7 @@ class SeatDemoService:
|
|||||||
approval_token,
|
approval_token,
|
||||||
vehicle_profile.id,
|
vehicle_profile.id,
|
||||||
simulation_fault,
|
simulation_fault,
|
||||||
|
control_transport=control_transport,
|
||||||
)
|
)
|
||||||
if source_result.get("status") not in {"succeeded", "partially_succeeded"}:
|
if source_result.get("status") not in {"succeeded", "partially_succeeded"}:
|
||||||
source_result["decision_summary"] = [
|
source_result["decision_summary"] = [
|
||||||
@@ -2519,6 +2781,7 @@ class SeatDemoService:
|
|||||||
None,
|
None,
|
||||||
vehicle_profile.id,
|
vehicle_profile.id,
|
||||||
"normal",
|
"normal",
|
||||||
|
control_transport=control_transport,
|
||||||
)
|
)
|
||||||
if saved_result.get("status") != "succeeded" or not saved_result.get(
|
if saved_result.get("status") != "succeeded" or not saved_result.get(
|
||||||
"mode_profile"
|
"mode_profile"
|
||||||
@@ -2887,6 +3150,7 @@ class SeatDemoService:
|
|||||||
simulated_hardware_request: bool = False,
|
simulated_hardware_request: bool = False,
|
||||||
simulation_fault: str = "normal",
|
simulation_fault: str = "normal",
|
||||||
vehicle_profile: VehicleProfile | None = None,
|
vehicle_profile: VehicleProfile | None = None,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run isolated adapters under the selected physical-bus policy."""
|
"""Run isolated adapters under the selected physical-bus policy."""
|
||||||
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
|
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
|
||||||
@@ -3194,6 +3458,7 @@ class SeatDemoService:
|
|||||||
hardware_request=hardware_request,
|
hardware_request=hardware_request,
|
||||||
simulated_hardware_request=simulated_hardware_request,
|
simulated_hardware_request=simulated_hardware_request,
|
||||||
simulation_fault=simulation_fault,
|
simulation_fault=simulation_fault,
|
||||||
|
control_transport=control_transport,
|
||||||
)
|
)
|
||||||
report_results = [
|
report_results = [
|
||||||
item
|
item
|
||||||
@@ -3470,21 +3735,33 @@ class SeatDemoService:
|
|||||||
hardware_request: bool,
|
hardware_request: bool,
|
||||||
simulated_hardware_request: bool,
|
simulated_hardware_request: bool,
|
||||||
simulation_fault: str,
|
simulation_fault: str,
|
||||||
|
control_transport: str = WIRED_CAN_TRANSPORT,
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
reports: dict[str, dict[str, Any]] = {}
|
reports: dict[str, dict[str, Any]] = {}
|
||||||
bridge = None
|
bridge = None
|
||||||
seat_bus_factory = None
|
seat_bus_factory = None
|
||||||
native_bus_factory = None
|
native_bus_factory = None
|
||||||
vehicle_safety_preflight: dict[str, Any] | None = None
|
vehicle_safety_preflight: dict[str, Any] | None = None
|
||||||
|
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
|
||||||
|
|
||||||
if hardware_request and native_config_keys:
|
if hardware_request and (
|
||||||
shared_runtime_enabled = os.getenv(
|
native_config_keys or (mqtt_selected and "seat" in configs)
|
||||||
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
|
):
|
||||||
os.getenv(
|
shared_runtime_enabled = (
|
||||||
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
|
not mqtt_selected
|
||||||
),
|
and self.vehicle_can_settings.backend != "zlgcan-linux"
|
||||||
).strip().lower() in {"1", "true", "yes", "on"}
|
and os.getenv(
|
||||||
if shared_runtime_enabled:
|
"COGNITIVE_AGENT_SHARED_CAN_RUNTIME_ENABLED",
|
||||||
|
os.getenv(
|
||||||
|
"COGNITIVE_AGENT_SEAT_SHARED_ZCANPRO_BRIDGE_ENABLED", "true"
|
||||||
|
),
|
||||||
|
).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
)
|
||||||
|
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 (
|
from cognitive_agent.application.self_repair.service import (
|
||||||
BRIDGE_MANAGER,
|
BRIDGE_MANAGER,
|
||||||
)
|
)
|
||||||
@@ -3531,9 +3808,14 @@ class SeatDemoService:
|
|||||||
native_bus_factory(),
|
native_bus_factory(),
|
||||||
timeout_ms=min(
|
timeout_ms=min(
|
||||||
5_000,
|
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)
|
require_stationary_vehicle(vehicle_safety_preflight)
|
||||||
except VehicleSafetyPreflightError as exc:
|
except VehicleSafetyPreflightError as exc:
|
||||||
@@ -3560,7 +3842,9 @@ class SeatDemoService:
|
|||||||
executor = NativeCanExecutor(
|
executor = NativeCanExecutor(
|
||||||
native_bus_factory,
|
native_bus_factory,
|
||||||
feedback_timeout_ms=(
|
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(
|
native_reports[key] = executor.execute(
|
||||||
@@ -3581,7 +3865,10 @@ class SeatDemoService:
|
|||||||
config = configs["seat"]
|
config = configs["seat"]
|
||||||
if hardware_request:
|
if hardware_request:
|
||||||
seat_executor = (
|
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
|
if seat_bus_factory is not None
|
||||||
else self._new_hardware_executor()
|
else self._new_hardware_executor()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def _build_bridge_manager():
|
|||||||
termination_enabled=seat.termination_enabled,
|
termination_enabled=seat.termination_enabled,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if vehicle.enabled:
|
if vehicle.configured and vehicle.backend == "zlgcan":
|
||||||
if vehicle.channel == seat_channel:
|
if vehicle.channel == seat_channel:
|
||||||
raise ValueError("seat CH0 and vehicle CH1 must use different channels")
|
raise ValueError("seat CH0 and vehicle CH1 must use different channels")
|
||||||
if (
|
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
|
data_bitrate: int = 2_000_000
|
||||||
can_fd_iso: bool = True
|
can_fd_iso: bool = True
|
||||||
termination_enabled: bool = True
|
termination_enabled: bool = True
|
||||||
|
receive_ids: tuple[int, ...] | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.frame_format not in {"classic_can", "can_fd"}:
|
if self.frame_format not in {"classic_can", "can_fd"}:
|
||||||
raise ValueError("frame_format must be classic_can or 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]:
|
def public(self) -> dict[str, Any]:
|
||||||
return {
|
value = {
|
||||||
"channel": self.channel,
|
"channel": self.channel,
|
||||||
"frame_format": self.frame_format,
|
"frame_format": self.frame_format,
|
||||||
"arbitration_bitrate": self.arbitration_bitrate,
|
"arbitration_bitrate": self.arbitration_bitrate,
|
||||||
@@ -143,6 +156,9 @@ class ZlgChannelConfig:
|
|||||||
"can_fd_iso": self.can_fd_iso,
|
"can_fd_iso": self.can_fd_iso,
|
||||||
"termination_enabled": self.termination_enabled,
|
"termination_enabled": self.termination_enabled,
|
||||||
}
|
}
|
||||||
|
if self.receive_ids is not None:
|
||||||
|
value["receive_ids"] = list(self.receive_ids)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ZlgCanBridgeProcess:
|
class ZlgCanBridgeProcess:
|
||||||
@@ -157,6 +173,8 @@ class ZlgCanBridgeProcess:
|
|||||||
device_index: int,
|
device_index: int,
|
||||||
channels: list[ZlgChannelConfig],
|
channels: list[ZlgChannelConfig],
|
||||||
transmit_type: int = 0,
|
transmit_type: int = 0,
|
||||||
|
monitor_only: bool = False,
|
||||||
|
trace_all: bool = False,
|
||||||
):
|
):
|
||||||
if not channels:
|
if not channels:
|
||||||
raise CanGatewayError("at least one ZLG channel must be configured")
|
raise CanGatewayError("at least one ZLG channel must be configured")
|
||||||
@@ -179,6 +197,10 @@ class ZlgCanBridgeProcess:
|
|||||||
"--transmit-type",
|
"--transmit-type",
|
||||||
str(transmit_type),
|
str(transmit_type),
|
||||||
]
|
]
|
||||||
|
if monitor_only or trace_all:
|
||||||
|
command.append("--monitor-only")
|
||||||
|
if trace_all:
|
||||||
|
command.append("--trace-all")
|
||||||
try:
|
try:
|
||||||
self._process = subprocess.Popen( # noqa: S603 - paths are trusted server config
|
self._process = subprocess.Popen( # noqa: S603 - paths are trusted server config
|
||||||
command,
|
command,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
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
|
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]:
|
def _profiles_from_env() -> frozenset[str]:
|
||||||
raw = os.getenv(
|
raw = os.getenv(
|
||||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES",
|
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES",
|
||||||
@@ -405,12 +414,19 @@ class SeatHardwareSettings:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def firmware_guard(
|
def firmware_guard(
|
||||||
self, bus_factory: Callable[[], CanBus] | None = None
|
self,
|
||||||
|
bus_factory: Callable[[], CanBus] | None = None,
|
||||||
|
*,
|
||||||
|
allow_programming: bool = True,
|
||||||
) -> T2GFirmwareGuard:
|
) -> T2GFirmwareGuard:
|
||||||
return T2GFirmwareGuard(
|
return T2GFirmwareGuard(
|
||||||
bus_factory or self.bus_factory(),
|
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,
|
probe_timeout_ms=self.firmware_probe_timeout_ms,
|
||||||
boot_wait_ms=self.firmware_boot_wait_ms,
|
boot_wait_ms=self.firmware_boot_wait_ms,
|
||||||
)
|
)
|
||||||
@@ -500,6 +516,52 @@ class VehicleCanSettings:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, repository_root: Path) -> "VehicleCanSettings":
|
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(
|
return cls(
|
||||||
repository_root=repository_root,
|
repository_root=repository_root,
|
||||||
enabled=_env_bool(
|
enabled=_env_bool(
|
||||||
@@ -514,9 +576,7 @@ class VehicleCanSettings:
|
|||||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
|
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
|
||||||
),
|
),
|
||||||
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
|
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
|
||||||
backend=os.getenv(
|
backend=backend,
|
||||||
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
|
|
||||||
).strip().lower(),
|
|
||||||
channel=_env_int("COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL", 1),
|
channel=_env_int("COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL", 1),
|
||||||
arbitration_bitrate=_env_int(
|
arbitration_bitrate=_env_int(
|
||||||
"COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE", 500_000
|
"COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE", 500_000
|
||||||
@@ -539,47 +599,42 @@ class VehicleCanSettings:
|
|||||||
feedback_timeout_ms=_env_int(
|
feedback_timeout_ms=_env_int(
|
||||||
"COGNITIVE_AGENT_VEHICLE_CAN_FEEDBACK_TIMEOUT_MS", 1_500
|
"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),
|
device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0),
|
||||||
zlg_python=Path(
|
zlg_python=zlg_python,
|
||||||
os.getenv(
|
zlg_dll=zlg_library,
|
||||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
|
bridge_script=bridge_script,
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def adapter_configured(self) -> bool:
|
def adapter_configured(self) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
self.backend == "zlgcan"
|
self.backend in {"zlgcan", "zlgcan-linux"}
|
||||||
and self.zlg_python.is_file()
|
and self.zlg_python.is_file()
|
||||||
and self.zlg_dll.is_file()
|
and self.zlg_dll.is_file()
|
||||||
and self.bridge_script.is_file()
|
and self.bridge_script.is_file()
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def state_configured(self) -> bool:
|
||||||
|
"""Whether authoritative read-only CH1 synchronization can run."""
|
||||||
|
|
||||||
return bool(
|
return bool(
|
||||||
self.enabled
|
self.enabled
|
||||||
and (not self.approval_required or self.approval_token)
|
|
||||||
and self.adapter_configured
|
and self.adapter_configured
|
||||||
and self.timing_is_verified
|
and self.timing_is_verified
|
||||||
and self.channel_is_allowed
|
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
|
@property
|
||||||
def required_channel(self) -> int:
|
def required_channel(self) -> int:
|
||||||
"""Physical body-CAN channel selected by the fail-closed bench policy."""
|
"""Physical body-CAN channel selected by the fail-closed bench policy."""
|
||||||
@@ -617,9 +672,9 @@ class VehicleCanSettings:
|
|||||||
supplied_token, self.approval_token
|
supplied_token, self.approval_token
|
||||||
):
|
):
|
||||||
raise HardwareSafetyError("hardware approval token is invalid")
|
raise HardwareSafetyError("hardware approval token is invalid")
|
||||||
if self.backend != "zlgcan":
|
if self.backend not in {"zlgcan", "zlgcan-linux"}:
|
||||||
raise HardwareSafetyError(
|
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:
|
if not self.channel_is_allowed:
|
||||||
raise HardwareSafetyError(
|
raise HardwareSafetyError(
|
||||||
@@ -648,7 +703,13 @@ class VehicleCanSettings:
|
|||||||
termination_enabled=self.termination_enabled,
|
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(
|
return ZlgCanBridgeProcess(
|
||||||
python_executable=self.zlg_python,
|
python_executable=self.zlg_python,
|
||||||
bridge_script=self.bridge_script,
|
bridge_script=self.bridge_script,
|
||||||
@@ -656,12 +717,15 @@ class VehicleCanSettings:
|
|||||||
device_type=self.device_type,
|
device_type=self.device_type,
|
||||||
device_index=self.device_index,
|
device_index=self.device_index,
|
||||||
channels=channels,
|
channels=channels,
|
||||||
|
monitor_only=monitor_only,
|
||||||
|
trace_all=trace_all,
|
||||||
)
|
)
|
||||||
|
|
||||||
def public_info(self) -> dict[str, Any]:
|
def public_info(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"configured": self.configured,
|
"configured": self.configured,
|
||||||
|
"state_configured": self.state_configured,
|
||||||
"backend": self.backend,
|
"backend": self.backend,
|
||||||
"platform": "t2g_cabin_bench",
|
"platform": "t2g_cabin_bench",
|
||||||
"channel": self.channel,
|
"channel": self.channel,
|
||||||
@@ -699,3 +763,125 @@ class VehicleCanSettings:
|
|||||||
"source_branch": "project-code/CAN_DEV",
|
"source_branch": "project-code/CAN_DEV",
|
||||||
"source_commit": "6e7ed04a468dfcde7efda9e30bc15144bf02faf9",
|
"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:
|
class Driver:
|
||||||
def __init__(self, args):
|
def __init__(self, args):
|
||||||
self.transmit_type = args.transmit_type
|
self.transmit_type = args.transmit_type
|
||||||
|
self.monitor_only = bool(args.monitor_only or args.trace_all)
|
||||||
self.dll = ctypes.WinDLL(args.dll)
|
self.dll = ctypes.WinDLL(args.dll)
|
||||||
self._declare_functions()
|
self._declare_functions()
|
||||||
self.device = self.dll.ZCAN_OpenDevice(args.device_type, args.device_index, 0)
|
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)
|
raise RuntimeError(operation + " did not accept the frame" + detail)
|
||||||
|
|
||||||
def send(self, channel, arbitration_id, data, is_fd=False, bitrate_switch=False):
|
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:
|
if not 0 <= arbitration_id <= 0x7FF:
|
||||||
raise ValueError("only 11-bit standard CAN identifiers are allowed")
|
raise ValueError("only 11-bit standard CAN identifiers are allowed")
|
||||||
handle = self._handle(channel)
|
handle = self._handle(channel)
|
||||||
@@ -389,6 +392,8 @@ def parse_args():
|
|||||||
help="0 for normal transmission; 2 is diagnostic self-reception",
|
help="0 for normal transmission; 2 is diagnostic self-reception",
|
||||||
)
|
)
|
||||||
parser.add_argument("--self-test", action="store_true")
|
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -430,7 +435,14 @@ def main():
|
|||||||
driver = None
|
driver = None
|
||||||
try:
|
try:
|
||||||
driver = Driver(args)
|
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:
|
for line in sys.stdin:
|
||||||
try:
|
try:
|
||||||
request = json.loads(line)
|
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()
|
||||||
@@ -237,6 +237,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
query = parse_qs(parsed_url.query)
|
query = parse_qs(parsed_url.query)
|
||||||
conversation_id = str(query.get("conversation_id", [""])[0])
|
conversation_id = str(query.get("conversation_id", [""])[0])
|
||||||
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
|
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
|
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
|
||||||
try:
|
try:
|
||||||
self._json(
|
self._json(
|
||||||
@@ -247,6 +250,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
)(
|
)(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
vehicle_id,
|
vehicle_id,
|
||||||
|
control_transport=control_transport,
|
||||||
hardware_client_id=hardware_client_id,
|
hardware_client_id=hardware_client_id,
|
||||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||||
)
|
)
|
||||||
@@ -420,6 +424,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
if payload.get("reasoning_effort") is not None
|
if payload.get("reasoning_effort") is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
control_transport=str(
|
||||||
|
payload.get("control_transport", "wired_can")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.vehicle_display_sync.update_task(
|
self.vehicle_display_sync.update_task(
|
||||||
status=str(result.get("status", "succeeded")),
|
status=str(result.get("status", "succeeded")),
|
||||||
@@ -1209,6 +1216,7 @@ def main() -> None:
|
|||||||
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
|
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
|
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
|
||||||
|
SeatDemoHandler.service.start()
|
||||||
SeatDemoHandler.extension.start()
|
SeatDemoHandler.extension.start()
|
||||||
SeatDemoHandler.calibration.start()
|
SeatDemoHandler.calibration.start()
|
||||||
SeatDemoHandler.repair.start()
|
SeatDemoHandler.repair.start()
|
||||||
@@ -1240,6 +1248,7 @@ def main() -> None:
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
SeatDemoHandler.service.close()
|
||||||
SeatDemoHandler.extension.close()
|
SeatDemoHandler.extension.close()
|
||||||
SeatDemoHandler.calibration.close()
|
SeatDemoHandler.calibration.close()
|
||||||
SeatDemoHandler.repair.close()
|
SeatDemoHandler.repair.close()
|
||||||
|
|||||||
+116
-10
@@ -10,6 +10,7 @@ import urllib.request
|
|||||||
from http.cookiejar import CookieJar
|
from http.cookiejar import CookieJar
|
||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
|
from cognitive_agent.auth import AuthConfig, AuthRuntime, hash_password
|
||||||
@@ -229,18 +230,18 @@ operations:
|
|||||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||||
html = response.read().decode("utf-8")
|
html = response.read().decode("utf-8")
|
||||||
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-85", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-90", timeout=3) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/gesture-control.js?v=android-pad-8", timeout=3
|
f"{self.base_url}/gesture-control.js?v=android-pad-8", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
gesture_script = response.read().decode("utf-8")
|
gesture_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/console-v2.js?v=sync-layout-26", timeout=3
|
f"{self.base_url}/console-v2.js?v=sync-layout-27", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
console_script = response.read().decode("utf-8")
|
console_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/console-v2.css?v=sync-layout-26", timeout=3
|
f"{self.base_url}/console-v2.css?v=sync-layout-27", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
console_styles = response.read().decode("utf-8")
|
console_styles = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
@@ -275,12 +276,12 @@ operations:
|
|||||||
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
|
||||||
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
|
||||||
self.assertNotIn("透明车体 · 实体设备", html)
|
self.assertNotIn("透明车体 · 实体设备", html)
|
||||||
self.assertIn('src="/app.js?v=dual-seat-85"', html)
|
self.assertIn('src="/app.js?v=dual-seat-90"', html)
|
||||||
self.assertIn('href="/styles.css?v=dual-seat-9"', html)
|
self.assertIn('href="/styles.css?v=dual-seat-10"', html)
|
||||||
self.assertIn('src="/gesture-control.js?v=android-pad-8"', html)
|
self.assertIn('src="/gesture-control.js?v=android-pad-8"', html)
|
||||||
self.assertNotIn('class="vehicle-system-hud"', html)
|
self.assertNotIn('class="vehicle-system-hud"', html)
|
||||||
self.assertIn('src="/console-v2.js?v=sync-layout-26"', html)
|
self.assertIn('src="/console-v2.js?v=sync-layout-27"', html)
|
||||||
self.assertIn('href="/console-v2.css?v=sync-layout-26"', html)
|
self.assertIn('href="/console-v2.css?v=sync-layout-27"', html)
|
||||||
self.assertIn('src="/self-extension.js?v=sync-layout-26"', html)
|
self.assertIn('src="/self-extension.js?v=sync-layout-26"', html)
|
||||||
self.assertIn('href="/self-extension.css?v=sync-layout-26"', html)
|
self.assertIn('href="/self-extension.css?v=sync-layout-26"', html)
|
||||||
self.assertNotIn("self-calibration", html)
|
self.assertNotIn("self-calibration", html)
|
||||||
@@ -398,7 +399,19 @@ operations:
|
|||||||
self.assertIn('const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]', app_script)
|
self.assertIn('const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]', app_script)
|
||||||
self.assertIn('divider.className = "mode-group-divider"', app_script)
|
self.assertIn('divider.className = "mode-group-divider"', app_script)
|
||||||
self.assertIn(
|
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,
|
app_script,
|
||||||
)
|
)
|
||||||
self.assertIn("hardwareButton.disabled = !hardwareExecutionReady", app_script)
|
self.assertIn("hardwareButton.disabled = !hardwareExecutionReady", app_script)
|
||||||
@@ -484,7 +497,7 @@ operations:
|
|||||||
self.assertIn('client_type: "web_console"', app_script)
|
self.assertIn('client_type: "web_console"', app_script)
|
||||||
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
|
||||||
self.assertIn('"cognitive-agent:module-change"', app_script)
|
self.assertIn('"cognitive-agent:module-change"', app_script)
|
||||||
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v178"', service_worker)
|
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v179"', service_worker)
|
||||||
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
|
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
|
||||||
self.assertIn(".then(() => self.skipWaiting())", service_worker)
|
self.assertIn(".then(() => self.skipWaiting())", service_worker)
|
||||||
self.assertNotIn("const cached = await caches.match(request)", service_worker)
|
self.assertNotIn("const cached = await caches.match(request)", service_worker)
|
||||||
@@ -700,7 +713,7 @@ operations:
|
|||||||
) as response:
|
) as response:
|
||||||
extension_script = response.read().decode("utf-8")
|
extension_script = response.read().decode("utf-8")
|
||||||
with self.opener.open(
|
with self.opener.open(
|
||||||
f"{self.base_url}/app.js?v=dual-seat-83", timeout=3
|
f"{self.base_url}/app.js?v=dual-seat-90", timeout=3
|
||||||
) as response:
|
) as response:
|
||||||
app_script = response.read().decode("utf-8")
|
app_script = response.read().decode("utf-8")
|
||||||
|
|
||||||
@@ -1033,10 +1046,103 @@ operations:
|
|||||||
synchronize.assert_called_once_with(
|
synchronize.assert_called_once_with(
|
||||||
"conv-e2e-physical-state",
|
"conv-e2e-physical-state",
|
||||||
"denza_n7",
|
"denza_n7",
|
||||||
|
control_transport="wired_can",
|
||||||
hardware_client_id=None,
|
hardware_client_id=None,
|
||||||
hardware_access_bypass=True,
|
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:
|
def test_gpt_http_failure_falls_back_without_returning_502(self) -> None:
|
||||||
original_api_key = self.service.openai.api_key
|
original_api_key = self.service.openai.api_key
|
||||||
self.service.openai.api_key = "test-key"
|
self.service.openai.api_key = "test-key"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import nullcontext
|
|
||||||
import os
|
import os
|
||||||
from types import SimpleNamespace
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
|
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_VEHICLE_CAN_REQUIRED_CHANNEL": "1",
|
||||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true",
|
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED": "true",
|
||||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN": "approved",
|
"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):
|
with patch.dict(os.environ, environment, clear=False):
|
||||||
return VehicleCanSettings.from_env(REPOSITORY_ROOT)
|
return VehicleCanSettings.from_env(REPOSITORY_ROOT)
|
||||||
|
|||||||
@@ -214,9 +214,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
|||||||
"async function synchronizePhysicalSeatState() {",
|
"async function synchronizePhysicalSeatState() {",
|
||||||
"\nasync function loadAuthStatus()",
|
"\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("/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("/api/hardware/vehicle-state", driver_source)
|
||||||
|
self.assertIn('state.controlTransport === "wireless_mqtt"', combined_source)
|
||||||
|
self.assertIn("state.passengerStateSyncPromise", combined_source)
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
combined_source.index("synchronizePassengerSeatState()"),
|
combined_source.index("synchronizePassengerSeatState()"),
|
||||||
combined_source.index("synchronizeDriverSeatState()"),
|
combined_source.index("synchronizeDriverSeatState()"),
|
||||||
@@ -228,6 +237,18 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertNotIn("passenger: mergedActuatorState.driver", render_source)
|
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:
|
def test_vehicle_selection_cannot_replay_unfiltered_state_in_ch1_mode(self) -> None:
|
||||||
select_source = function_source(
|
select_source = function_source(
|
||||||
self.app_source,
|
self.app_source,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -54,6 +54,31 @@ class NativeCanFeedbackTests(unittest.TestCase):
|
|||||||
self.assertFalse(feedback_matches("indoor_light:on", feedback))
|
self.assertFalse(feedback_matches("indoor_light:on", feedback))
|
||||||
self.assertTrue(feedback_matches("ambient_power:off", 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:
|
def test_driver_seat_feedback_uses_0x4fa_not_0x42a(self) -> None:
|
||||||
passive_feedback = self.frame(0x4FA, "0980aa0000810000")
|
passive_feedback = self.frame(0x4FA, "0980aa0000810000")
|
||||||
passive_driver = decode_feedback(passive_feedback)["actuator_state"]["driver"]
|
passive_driver = decode_feedback(passive_feedback)["actuator_state"]["driver"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user