diff --git a/.gitignore b/.gitignore index 02c63cc..8898421 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,13 @@ firmware/t2g/Seat_Controller_demo/IAR/rev_d/.ninja_log firmware/t2g/Seat_Controller_demo/IAR/rev_d/BrowseInfo/ firmware/t2g/Seat_Controller_demo/IAR/rev_d/List/ firmware/t2g/Seat_Controller_demo/IAR/rev_d/Obj/ +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/settings/ +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.eww +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/.Passenger_Seat_Timed_Controller_demo_build_cache +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/.ninja_deps +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/.ninja_log +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/BrowseInfo/ +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/List/ +firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Obj/ firmware/t2g/tviibh8m/src/examples/crypto/ClientRunsOnCM7_0/RSA/key_data/private_key*.pem +artifacts/logs/ diff --git a/README.md b/README.md index 5283485..503be41 100755 --- a/README.md +++ b/README.md @@ -199,13 +199,15 @@ config/gesture/default.yaml 服务正常启动时,会把当前车型可见的工程预设模式和用户自定义模式原子同步到相邻目录 `../scenses/demo-scenes.json`,并提供无需登录、只读的 `GET /n7/scenes`。YAML 模式库仍是唯一真源;创建、更新、删除、恢复模式都会刷新 JSON,原有模式执行和安全校验链路不变。JSON 保留 `n7-scenes` v1 的 `id/label/background_time/description/actions` 字段,同时在动作中保留完整的 `domain/capability/target_scope/operation/parameters`,因此座椅、空调、天幕和氛围灯配置不会在导出时丢失。可用 `N7_SCENE_CATALOG_PATH` 或启动参数 `--scene-catalog` 覆盖输出路径。 +分阶段动态模式额外保留完整 `schedule` 和用于展示单轮阶段起点的 `preview_actions`,并标记 `execution.kind=server_schedule`。此类模式的旧版 `actions` 列表为空,避免不认识时间轴的 v1 客户端把多个阶段同时下发;必须通过主工程已认证、受控的模式执行链运行。静态模式和已有 `timeline` 格式不变。 + 主项目和客户端只读目录服务可以用一个监督脚本同时启动: ```powershell .\.venv\Scripts\python.exe tools\start_n7web.py ``` -脚本自动设置 `PYTHONPATH`,主页面默认仅监听 `127.0.0.1:8000`,模式卡片 HTTP/UDP 服务监听 `0.0.0.0:8090`。需要让可信局域网内的设备同时访问主页面时,使用 `--host 0.0.0.0`;可通过 `--port`、`--scene-port`、`--catalog` 和 `--server-id` 覆盖其他参数。任一子服务异常退出或按一次 `Ctrl+C` 时,脚本会停止另一个服务。 +脚本自动设置 `PYTHONPATH`:管理页面默认仅监听 `127.0.0.1:8000`,只读固件 OTA 服务监听 `127.0.0.1:8012`,模式卡片 HTTP/UDP 服务监听 `0.0.0.0:8090`。OTA 服务只提供健康检查、全部历史发布列表、最新版本清单和固件下载,设计为通过固定公网 HTTPS 隧道供车机访问,不暴露工作台和车辆控制接口。车机通过 `GET /api/firmware-workbench/releases` 枚举所有可下载固件,通过 `GET /api/firmware-workbench/releases/latest` 查询最新固件。如果 `8012` 端口已经运行健康的 N7 OTA 服务,启动器会直接复用它。可通过 `--host`、`--port`、`--ota-host`、`--ota-port`、`--scene-port`、`--catalog` 和 `--server-id` 覆盖参数。任一由启动器管理的子服务异常退出或按一次 `Ctrl+C` 时,脚本会停止其余受管服务。 同步由模式注册表的创建、更新、删除、恢复和显式重载事件触发,不会持续扫描目录。每次变更只重新生成并原子替换一份小型 JSON;独立目录服务在收到 HTTP 请求时才读取它。因此空闲时的额外开销主要是一个等待请求的 Python 进程和一个 UDP 发现线程,而不是持续的 CPU 或磁盘轮询。 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 5c67c1a..0f6fc58 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -5166,7 +5166,18 @@ function modeOperationParameters(operation) { return Object.entries(parameters); } -function formatModeOperationValue(name, value) { +function formatModeOperationValue(name, value, capability = "") { + if (name === "level" && ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) { + return { 1: "高档", 2: "低档" }[value] || formatConfigValue(name, value); + } + if (capability === "seat.set_driver_massage_native") { + const labels = { + action: { mode: "切换按摩模式", strength: "调节按摩力度", off: "关闭" }, + mode: { wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松" }, + strength: { 1: "轻柔", 2: "中等", 3: "较强" }, + }; + if (labels[name]?.[value]) return labels[name][value]; + } const valueLabels = { on: "开启", off: "关闭", @@ -5193,7 +5204,7 @@ function modeOperationView(operation) { const parameters = modeOperationParameters(operation) .map(([name, value]) => { const label = CONFIG_PARAMETER_LABELS[name] || name; - return `${label}:${formatModeOperationValue(name, value)}`; + return `${label}:${formatModeOperationValue(name, value, capability)}`; }) .join(","); return { @@ -5232,7 +5243,50 @@ function modeTimelineStepView(step, action) { }; } +function modeScheduleDurationLabel(durationMs) { + const minutes = Math.floor(durationMs / 60000); + const seconds = Number(((durationMs % 60000) / 1000).toFixed(3)); + return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`; +} + +function modeScheduleView(mode) { + const schedule = mode?.schedule; + if ( + schedule?.schema !== "cognitive-agent-mode-schedule/v1" + || !Array.isArray(schedule.phases) || !schedule.phases.length + || !Number.isInteger(schedule.repeat_count) + || schedule.repeat_count < 1 || schedule.repeat_count > 20 + || schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms) + || phase.duration_ms <= 0 || !Array.isArray(phase.operations)) + ) return null; + let offsetMs = 0; + const phases = schedule.phases.map((phase, index) => { + const view = { + name: String(phase.name || `阶段 ${index + 1}`), + timing: `+${modeScheduleDurationLabel(offsetMs)} 开始 · 持续 ${modeScheduleDurationLabel(phase.duration_ms)}`, + actions: phase.operations.map(modeOperationView), + }; + offsetMs += phase.duration_ms; + return view; + }); + return { + phases, + summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${modeScheduleDurationLabel(offsetMs * schedule.repeat_count)}`, + stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚;时间为每轮的相对时间。", + }; +} + function modeExecutionActionViews(mode) { + const schedule = modeScheduleView(mode); + if (schedule) { + // Runtime operations may contain a flattened projection. Prefer phases so + // repeated device targets remain distinct and are not listed twice. + return schedule.phases.flatMap((phase, index) => phase.actions.map((action) => ({ + ...action, + phase: `阶段 ${index + 1}:${phase.name}`, + timing: phase.timing, + }))); + } const operations = Array.isArray(mode?.operations) ? mode.operations.map(modeOperationView) : []; @@ -5253,6 +5307,57 @@ function modeExecutionActionViews(mode) { return [...operations, ...timelineSteps]; } +function modeScheduleTrialMatches(draft, payload) { + if (!draft?.schedule) return true; + if (!modeScheduleView(draft) || !modeScheduleView(payload)) return false; + const signature = (schedule) => JSON.stringify([ + schedule.schema, schedule.repeat_count, + schedule.phases.map((phase) => [ + phase.id, phase.name, phase.duration_ms, + phase.operations.map((operation) => [ + operation.domain, operation.capability, + operation.target_scope || operation.target?.instance || operation.target?.zone, + modeOperationParameters(operation).sort(([left], [right]) => left.localeCompare(right)), + ]), + ]), + ]); + const execution = payload.schedule_execution; + const totalDurationMs = draft.schedule.phases.reduce((total, phase) => total + phase.duration_ms, 0) + * draft.schedule.repeat_count; + return signature(draft.schedule) === signature(payload.schedule) + && execution?.status === "completed" && execution.completed === true + && execution.virtual_elapsed_ms === totalDurationMs; +} + +function modeScheduleExecutionView(result) { + const execution = result?.schedule_execution; + if (!execution || typeof execution !== "object") return null; + const status = String(execution.status || ""); + if (status === "running" && result.status === "running") { + return { + state: "processing", badge: "已启动", title: "动态时间轴已启动", + detail: "后续动作将按阶段执行;可输入“停止动态模式”提前结束。已执行的设备状态不会自动回滚。", + }; + } + if (["stopped", "cancelled"].includes(status)) { + return { + state: "succeeded", badge: "已停止", title: "动态时间轴已停止", + detail: "后续阶段不再执行;已经执行的设备状态不会自动回滚。", + }; + } + if (status === "completed" && execution.completed === true) { + const simulated = execution.virtual_elapsed_ms != null; + return { + state: "succeeded", badge: simulated ? "模拟通过" : "已结束", + title: simulated ? "动态时间轴模拟试运行通过" : "动态时间轴已结束", + detail: simulated + ? "已使用虚拟时钟校验全部阶段;未按真实时间等待,也未向车辆下发控制。" + : "已处理全部计划阶段,请以车辆反馈确认各设备的实际状态。", + }; + } + return null; +} + function isUserModeProfile(mode) { return mode?.origin === "user" || mode?.deletable === true; } @@ -5264,6 +5369,7 @@ function isN7SyncedModeProfile(mode) { function isDynamicModeProfile(mode) { const behavior = String(mode?.behavior || "").trim().toLowerCase(); if (["cyclic", "dynamic"].includes(behavior)) return true; + if (modeScheduleView(mode)) return true; if (behavior === "static") return false; if (mode?.timeline && typeof mode.timeline === "object") return true; return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || "")); @@ -5348,6 +5454,34 @@ function createModeGalleryCard(mode) { button.append(iconBox, copy); shell.append(button); + const schedule = modeScheduleView(mode); + if (schedule) { + meta.textContent = schedule.summary; + const details = document.createElement("details"); + details.className = "mode-gallery-schedule-details"; + const summary = document.createElement("summary"); + summary.textContent = "查看分阶段执行动作"; + const list = document.createElement("ol"); + schedule.phases.forEach((phase) => { + const item = document.createElement("li"); + const title = document.createElement("strong"); + title.textContent = phase.name; + const timing = document.createElement("small"); + timing.textContent = phase.timing; + item.append(title, timing); + phase.actions.forEach((action) => { + const row = document.createElement("p"); + row.textContent = `${action.target} · ${action.action}:${action.parameters}`; + item.append(row); + }); + list.append(item); + }); + const hint = document.createElement("p"); + hint.textContent = schedule.stopHint; + details.append(summary, list, hint); + shell.append(details); + } + const editButton = document.createElement("button"); editButton.className = "mode-gallery-edit"; editButton.type = "button"; @@ -6313,25 +6447,37 @@ function installModeChipScroller() { pointerStartScroll = group.scrollLeft; dragged = false; pointerCaptured = false; + suppressClick = false; }); group.addEventListener("pointermove", (event) => { if (pointerId !== event.pointerId) return; + if (event.buttons !== 1) { + finishPointerDrag(event); + return; + } const distance = event.clientX - pointerStartX; if (!dragged && Math.abs(distance) < 5) return; - dragged = true; - if (!pointerCaptured && typeof group.setPointerCapture === "function") { - group.setPointerCapture(pointerId); - pointerCaptured = true; + if (!dragged) { + // Capturing on pointerdown retargets a normal button click to the rail. + // Capture only after movement establishes that this gesture is a drag. + dragged = true; + if (typeof group.setPointerCapture === "function") { + group.setPointerCapture(pointerId); + pointerCaptured = true; + } + group.classList.add("is-dragging"); } - group.classList.add("is-dragging"); group.scrollLeft = Math.max(0, Math.min(maximumScroll(), pointerStartScroll - distance)); updateScrollState(); event.preventDefault(); }); const finishPointerDrag = (event) => { if (pointerId !== event.pointerId) return; - if (pointerCaptured) group.releasePointerCapture?.(pointerId); + const finishedPointerId = pointerId; pointerId = null; + if (pointerCaptured && group.hasPointerCapture?.(finishedPointerId)) { + group.releasePointerCapture(finishedPointerId); + } pointerCaptured = false; group.classList.remove("is-dragging"); if (dragged) { @@ -6342,6 +6488,7 @@ function installModeChipScroller() { }; group.addEventListener("pointerup", finishPointerDrag); group.addEventListener("pointercancel", finishPointerDrag); + group.addEventListener("lostpointercapture", finishPointerDrag); group.addEventListener("click", (event) => { if (!suppressClick) return; event.preventDefault(); @@ -6392,10 +6539,13 @@ function ensureModeActionPopover() { function renderModeActionPopover(mode) { const popover = ensureModeActionPopover(); + const schedule = modeScheduleView(mode); const commandFlow = modeEditorReadableCommands(mode?.command_flow); - const usesLatestCommandFlow = commandFlow.length > 0; + const usesLatestCommandFlow = !schedule && commandFlow.length > 0; const actions = usesLatestCommandFlow ? commandFlow : modeExecutionActionViews(mode); - $(`[data-mode-action-eyebrow]`, popover).textContent = usesLatestCommandFlow + $(`[data-mode-action-eyebrow]`, popover).textContent = schedule + ? "动态时间轴" + : usesLatestCommandFlow ? "最新控制指令流" : "执行动作预览"; $("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式"; @@ -6404,6 +6554,12 @@ function renderModeActionPopover(mode) { : `${Number(mode?.operation_count || 0)} 项`; const list = $("[data-mode-action-list]", popover); list.replaceChildren(); + if (schedule) { + const summary = document.createElement("li"); + summary.className = "mode-schedule-popover-summary"; + summary.textContent = `${schedule.summary}。${schedule.stopHint}`; + list.append(summary); + } if (!actions.length) { const empty = document.createElement("li"); empty.className = "mode-chip-action-empty"; @@ -6422,6 +6578,12 @@ function renderModeActionPopover(mode) { actionName.textContent = usesLatestCommandFlow ? String(action.category || "控制指令") : action.action; + if (action.phase) { + const phase = document.createElement("small"); + phase.className = "mode-schedule-popover-phase"; + phase.textContent = `${action.phase} · ${action.timing}`; + copy.append(phase); + } const metadata = document.createElement("small"); const appendMetadata = (label, value) => { const text = String(value || "").trim(); @@ -6703,8 +6865,17 @@ function populateModeChips() { runIcon.dataset.modeRun = "true"; runIcon.title = `执行${mode.name || mode.id}`; const timeline = mode.timeline; + const schedule = modeScheduleView(mode); let cycleBadge = $(".mode-cycle-badge", button); - if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) { + if (schedule) { + if (!cycleBadge) { + cycleBadge = document.createElement("small"); + cycleBadge.className = "mode-cycle-badge"; + button.append(cycleBadge); + } + cycleBadge.textContent = schedule.summary; + button.dataset.modeBehavior = "dynamic"; + } else if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) { if (!cycleBadge) { cycleBadge = document.createElement("small"); cycleBadge.className = "mode-cycle-badge"; @@ -6736,7 +6907,7 @@ function populateModeChips() { : `${phase.name} 目标到位`; }).join(" → ") : ""; - button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"] + button.title = [mode.description || mode.name, schedule?.summary || phaseSummary, "悬浮或聚焦查看全部执行动作"] .filter(Boolean) .join("\n"); }); @@ -6850,7 +7021,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") { $$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => { const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle"; button.classList.toggle("running", active && modeState === "running"); - button.classList.toggle("succeeded", active && modeState === "succeeded"); + button.classList.toggle("succeeded", active && ["succeeded", "started", "ended", "stopped"].includes(modeState)); button.classList.toggle("failed", active && modeState === "failed"); button.dataset.modeState = active ? modeState : "idle"; button.setAttribute("aria-pressed", active ? "true" : "false"); @@ -6859,6 +7030,12 @@ function setModeExecutionState(modeProfileId, modeState = "idle") { if (stateLabel) { stateLabel.textContent = active && modeState === "running" ? "执行中" + : active && modeState === "started" + ? "已启动" + : active && modeState === "ended" + ? "已结束" + : active && modeState === "stopped" + ? "已停止" : active && modeState === "succeeded" ? "已完成" : active && modeState === "failed" @@ -6866,7 +7043,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") { : "点击执行"; } }); - if (modeProfileId && ["succeeded", "failed"].includes(modeState)) { + if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) { state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800); } } @@ -6937,11 +7114,14 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) { ); if (!payload) throw new Error("模式卡片未返回执行结果"); renderResult(payload); + const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing"; const completed = ["succeeded", "partially_succeeded"].includes(payload.status); - $("#resultTitle").textContent = completed + $("#resultTitle").textContent = scheduleRunning + ? `${mode.name}时间轴已启动` + : completed ? `${mode.name}执行完成` : `${mode.name}执行未完成`; - setModeExecutionState(mode.id, completed ? "succeeded" : "failed"); + setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed"); if (assistantModeHandle && assistant?.completeModeExecution) { assistant.completeModeExecution(assistantModeHandle, mode, payload, { command: modeCommand, @@ -6950,7 +7130,9 @@ async function executeSavedModeProfile(mode, { command = "" } = {}) { assistant?.announceModeExecution?.(mode, payload, { command: modeCommand }); } if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); - showToast(completed + showToast(scheduleRunning + ? "时间轴已启动,可输入“停止动态模式”提前结束" + : completed ? `${mode.name}已直接执行保存的控制指令流` : `${mode.name}未完成,请查看执行结果`); } catch (error) { @@ -6985,10 +7167,17 @@ function applyModeCommand(command, modeProfileId = null) { showToast("AI助手正在处理上一条指令,请稍候"); return false; } + const speech = window.CognitiveAgentSpeech; + if (["capturing", "finalizing"].includes(speech?.phase) + || (speech?.phase === "arming" && speech?.inputMode === "manual")) { + showToast("请等待语音整理完成后再发送"); + return false; + } if ($("#runButton")?.disabled) { showToast("上一条指令仍在执行,请稍候"); return false; } + hideModeActionPopover({ immediate: true }); const mode = modeProfiles().find((item) => item.id === modeProfileId); setModeRailSelection(mode); closeModeGallery({ restoreFocus: false }); @@ -8057,6 +8246,9 @@ const CONFIG_DOMAIN_META = { const CONFIG_CAPABILITY_LABELS = { "seat.set_ventilation": "设置座椅通风", "seat.set_heating": "设置座椅加热", + "seat.set_driver_ventilation_native": "设置主驾座椅通风", + "seat.set_driver_heating_native": "设置主驾座椅加热", + "seat.set_driver_massage_native": "设置主驾座椅按摩", "seat.set_longitudinal": "调节座椅前后位置", "seat.set_longitudinal_position": "调节座椅前后位置", "seat.set_cushion_tilt": "调节座椅高度", @@ -8087,6 +8279,8 @@ const CONFIG_CAPABILITY_LABELS = { "ambient_light.set_illumination": "设置氛围灯颜色与亮度", "ambient_light.set_color": "设置氛围灯颜色", "ambient_light.set_brightness": "设置氛围灯亮度", + "ambient_light.set_brightness_level": "设置氛围灯亮度", + "ambient_light.set_color_index": "设置氛围灯色号", "ambient_light.set_mode": "设置氛围灯模式", "roof.set_transmittance": "设置天幕透光率", "roof.set_shade_position": "设置遮阳帘位置", @@ -8095,6 +8289,10 @@ const CONFIG_CAPABILITY_LABELS = { }; const CONFIG_PARAMETER_LABELS = { + action: "动作", + strength: "力度", + color_index: "色号", + brightness_level: "亮度档位", power: "电源", state: "状态", level: "档位", @@ -8381,6 +8579,24 @@ function renderExecution(result) { const summary = $("#executionSummary"); const list = $("#resultList"); list.replaceChildren(); + const scheduleExecution = modeScheduleExecutionView(result); + if (scheduleExecution) { + summary.className = `execution-summary ${scheduleExecution.state === "processing" ? "partial" : scheduleExecution.state}`; + const icon = document.createElement("i"); + icon.dataset.lucide = scheduleExecution.state === "processing" ? "timer" : "circle-check"; + const copy = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = scheduleExecution.title; + const detail = document.createElement("span"); + detail.textContent = scheduleExecution.detail; + copy.append(title, detail); + summary.replaceChildren(icon, copy); + $("#resultNavMeta").textContent = scheduleExecution.badge; + $("#resultPanelState").textContent = scheduleExecution.badge; + $("#resultPanelState").className = `workspace-panel-state ${scheduleExecution.state}`; + refreshIcons(); + return; + } const report = result.execution_report; if (!report) { summary.className = "execution-summary failed"; @@ -8656,6 +8872,7 @@ window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => { function renderResult(result) { state.result = result; + const scheduleExecution = modeScheduleExecutionView(result); const reportMode = result.execution_report?.spec?.mode; const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback; const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified"; @@ -8690,7 +8907,9 @@ function renderResult(result) { }; const peripheralRoute = String(result.orchestration_route || "") .startsWith("nfc_peripheral"); - $("#resultTitle").textContent = peripheralRoute && executionSucceeded + $("#resultTitle").textContent = scheduleExecution + ? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}` + : peripheralRoute && executionSucceeded ? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}` : partiallySucceeded ? `${domains.length} 域场景部分完成` @@ -8714,7 +8933,7 @@ function renderResult(result) { $("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`; } setRunStatus( - partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed", + scheduleExecution?.state || (partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed"), submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(), ); renderDecision(result); @@ -9293,12 +9512,17 @@ window.CognitiveAgentConsole = { && expectedSignatures.every((signature, index) => signature === actualSignatures[index])); if (payload.status !== "succeeded" || (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount) - || !sameActions) { + || !sameActions || !modeScheduleTrialMatches(draft, payload)) { throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。"); } return payload; }, revealModeProfile, + updateScheduleExecutionStatus(modeProfileId, status) { + if (!modeProfileId || (state.pendingModeProfileId && state.pendingModeProfileId !== modeProfileId)) return; + const terminal = { completed: "ended", failed: "failed", cancelled: "stopped", stopped: "stopped", idle: "idle" }[status]; + if (terminal) setModeExecutionState(modeProfileId, terminal); + }, notify(message) { showToast(String(message)); }, @@ -9321,9 +9545,11 @@ window.addEventListener("cognitive-agent:request-complete", (event) => { if (!modeProfileId) return; state.pendingModeProfileId = null; const result = event.detail?.result; + const scheduleRunning = !event.detail?.error + && modeScheduleExecutionView(result)?.state === "processing"; const succeeded = !event.detail?.error && ["succeeded", "partially_succeeded"].includes(String(result?.status || "")); - setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed"); + setModeExecutionState(modeProfileId, scheduleRunning ? "started" : succeeded ? "succeeded" : "failed"); }); document.addEventListener("visibilitychange", () => { diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css index df6ed08..47f56d1 100644 --- a/apps/web-console/assistant-chat.css +++ b/apps/web-console/assistant-chat.css @@ -551,6 +551,111 @@ padding: 9px 10px 8px; } +.assistant-mode-schedule-summary, +.assistant-mode-schedule-hint { + margin: 0; + padding: 9px 12px; + color: var(--assistant-muted, #64748b); + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.assistant-mode-schedule-summary { + color: var(--status-info-foreground, #3764d9); + background: var(--status-info-soft, #eef3ff); + font-weight: 750; +} + +.assistant-mode-schedule-phase + .assistant-mode-schedule-phase, +.assistant-mode-schedule-hint { + border-top: 1px solid var(--assistant-border, #e5eaf1); +} + +.assistant-mode-schedule-phase > header { + display: grid; + gap: 3px; + padding: 10px 12px 5px; + color: var(--assistant-heading, #334155); + font-size: 11px; + overflow-wrap: anywhere; +} + +.assistant-mode-schedule-phase > header small { + color: var(--assistant-muted, #64748b); + font-size: 9px; + font-weight: 500; + line-height: 1.5; +} + +.assistant-mode-draft-scheduled .assistant-mode-draft-row { + grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr); + gap: 5px; +} + +.assistant-mode-draft-scheduled .assistant-mode-draft-row > strong { + white-space: normal; + overflow-wrap: anywhere; + text-align: right; +} + +.mode-gallery-card-shell:has(> .mode-gallery-schedule-details) > .mode-gallery-card { + height: auto; +} + +.mode-gallery-schedule-details { + padding: 9px 12px; + color: var(--foreground, #334155); + background: var(--card, #fff); + border: 1px solid var(--border, #e5eaf1); + border-radius: 10px; + font-size: 11px; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.mode-gallery-schedule-details > summary { + cursor: pointer; + color: var(--primary, #3764d9); + font-weight: 700; +} + +.mode-gallery-schedule-details ol { + margin: 10px 0; + padding-left: 19px; +} + +.mode-gallery-schedule-details li + li { + margin-top: 10px; +} + +.mode-gallery-schedule-details li > strong, +.mode-gallery-schedule-details li > small { + display: block; +} + +.mode-gallery-schedule-details small, +.mode-gallery-schedule-details > p { + color: var(--muted-foreground, #64748b); +} + +.mode-gallery-schedule-details p { + margin: 4px 0; +} + +.mode-chip-action-popover li.mode-schedule-popover-summary { + display: block; + color: var(--status-info-foreground, #3764d9); + background: var(--status-info-soft, #eef3ff); + font-size: 11px; + line-height: 1.6; +} + +.mode-schedule-popover-phase { + color: var(--primary, #3764d9); + line-height: 1.5; +} + .assistant-mode-draft-actions[hidden] { display: none; } @@ -1478,7 +1583,14 @@ } .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell { - top: 60px; + /* Share the content row on compact screens, never cover the mode rail. */ + position: relative; + grid-column: 1; + grid-row: 3; + width: 100%; + top: auto; + right: auto; + bottom: auto; } .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-module-switcher { diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index f2faf1a..b818e9c 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -11,6 +11,7 @@ let pendingMessage = null; let latestControlResult = null; let activeModeDraftContext = null; + let activeSchedulePoll = null; let initialized = false; const $ = (selector, root = document) => root.querySelector(selector); @@ -176,11 +177,13 @@ if (!compact || !context?.name) return false; const politePrefix = "(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|帮我|帮忙|给我)?"; const prefixUpdateVerb = "(?:修改一下|修改|调整|更改|更新|改一下|变更)"; - const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)"; + const addPredicate = "(?:加入|添加|增加|补充|加上|追加|新增)"; + const changePredicate = `(?:${addPredicate}|修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)`; const modeName = "([^,。!?!?、;;::]{1,64}?模式)"; const modePossessiveBoundary = "(?:中的|里的|内的|之中的|的|中|里|内)"; const modeMemberBoundary = `(?:${modePossessiveBoundary}|[,,::])`; const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|(?:吗|么|呢)[。.!!]?$/; + const declinedAddition = new RegExp(`^(?:先|暂时)?(?:不要|别|不用|不必|无需|取消|撤销)(?:再|继续)?${addPredicate}`); const candidates = [ { pattern: new RegExp(`^${politePrefix}${prefixUpdateVerb}${modeName}${modeMemberBoundary}[,,::]?(.*)$`), @@ -194,14 +197,29 @@ pattern: new RegExp(`^${politePrefix}${modeName}${modeMemberBoundary}[,,::]?(.+)$`), predicateRequired: true, }, + { + pattern: new RegExp(`^${politePrefix}(?:在|给|为|往|向)${modeName}(?:${modePossessiveBoundary})?[,,::]?(.+)$`), + predicateRequired: true, + }, + { + pattern: new RegExp(`^${politePrefix}${modeName}((?:再|也|还)?${addPredicate}.+)$`), + predicateRequired: true, + }, + { + pattern: new RegExp(`^${politePrefix}(?:把|将)(.+?)${addPredicate}(?:到|进)?${modeName}(?:中|里|内)?[。.!!]?$`), + predicateRequired: false, + nameIndex: 2, + changeIndex: 1, + }, ]; - return candidates.some(({ pattern, predicateRequired }) => { + return candidates.some(({ pattern, predicateRequired, nameIndex = 1, changeIndex = 2 }) => { const matched = compact.match(pattern); - const change = String(matched?.[2] || ""); + const change = String(matched?.[changeIndex] || ""); return Boolean( matched - && normalizeModeName(matched[1]) === normalizeModeName(context.name) + && normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name) && !question.test(change) + && !declinedAddition.test(change) && (!predicateRequired || new RegExp(changePredicate).test(change)) ); }); @@ -894,7 +912,7 @@ const consumed = new Set(); const rows = []; const targetZone = (action) => String( - action?.target?.instance || action?.target?.zone || action?.target || "", + action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "", ); const parameters = (action) => ( action?.parameters && typeof action.parameters === "object" ? action.parameters : {} @@ -965,6 +983,10 @@ const capability = String(action.capability || ""); const domain = String(action.domain || ""); const zone = targetZone(action); + const zoneLabel = { + driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾", + front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后", + }[zone] || ""; const params = parameters(action); let label = capability || domain || "座舱设备"; let value = "已配置"; @@ -999,35 +1021,86 @@ "seat.set_cushion_tilt": "坐垫角度", "seat.set_backrest_recline": "靠背角度", "seat.set_legrest_height": "腿托高度", + "seat.set_ventilation": "座椅通风", + "seat.set_heating": "座椅加热", + "seat.set_driver_ventilation_native": "座椅通风", + "seat.set_driver_heating_native": "座椅加热", + "seat.set_driver_massage_native": "座椅按摩", }; label = `${seatLabel}${seatCapabilityLabels[capability] || "座椅"}`; value = params.position_pct != null ? `${params.position_pct}%${params.angle_deg != null ? `(${params.angle_deg}°)` : ""}` : motionLabel(params.state || params.mode_id); + if (["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) { + // CH1 driver controls encode high as 1 and low as 2; passenger + // comfort levels use a different scale and must not reuse this map. + const level = { 1: "高档", 2: "低档" }[params.level ?? 2] || "档位待确认"; + value = params.state === "off" ? "关闭" + : params.state === "on" ? `开启 · ${level}` : "开关状态待确认"; + } else if (["seat.set_ventilation", "seat.set_heating"].includes(capability)) { + value = params.state === "off" ? "关闭" + : params.state === "on" + ? `开启${params.level != null ? ` · ${params.level}档` : ""}` + : "开关状态待确认"; + } else if (capability === "seat.set_driver_massage_native") { + const massageModes = { + wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松", + }; + const massageStrengths = { 1: "轻柔", 2: "中等", 3: "较强" }; + value = params.action === "off" ? "关闭" + : params.action === "mode" + ? (massageModes[params.mode] ? `${massageModes[params.mode]}模式` : "按摩模式待确认") + : params.action === "strength" + ? (massageStrengths[params.strength] ? `${massageStrengths[params.strength]}力度` : "按摩力度待确认") + : "按摩动作待确认"; + } icon = "armchair"; order = instructionOrder("座椅", "副驾", "主驾"); } else if (capability === "climate.set_temperature") { - label = "空调温度"; + label = `${zoneLabel}空调温度`; value = `${params.temperature_c ?? "--"}℃`; icon = "thermometer"; order = instructionOrder("空调", "温度"); } else if (domain === "climate") { - const cycleLabels = { internal: "内循环", external: "外循环", auto: "自动循环" }; + const climateModeLabels = { + "climate.set_cycle_mode": { internal: "内循环", external: "外循环" }, + "climate.set_control_mode": { manual: "手动控制", auto: "自动控制" }, + "climate.set_outlet_mode": { + face: "吹面", face_feet: "吹面 / 吹脚", feet: "吹脚", + feet_defrost: "吹脚 / 除霜", defrost: "除霜", + face_feet_defrost: "吹面 / 吹脚 / 除霜", face_defrost: "吹面 / 除霜", + }, + }; label = capability === "climate.set_fan_level" ? "空调风量" - : capability === "climate.set_cycle_mode" ? "空调循环" : "空调"; + : capability === "climate.set_cycle_mode" ? "空调循环" + : capability === "climate.set_control_mode" ? "空调控制模式" + : capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调"; + label = `${zoneLabel}${label}`; value = params.level != null ? `${params.level}档` - : params.mode != null ? (cycleLabels[String(params.mode)] || String(params.mode)) + : params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode)) : motionLabel(params.state); icon = "fan"; order = instructionOrder("空调", "风量"); } else if (capability === "interior_light.set_reading_power") { - label = "阅读灯"; + label = `${zoneLabel}阅读灯`; value = motionLabel(params.state); icon = "book-open"; order = instructionOrder("阅读灯", "灯光"); } else if (["interior_light", "ambient_light"].includes(domain)) { label = domain === "ambient_light" ? "氛围灯" : "室内灯"; value = motionLabel(params.state); + if (capability === "ambient_light.set_brightness_level") { + label = "氛围灯亮度"; + value = params.brightness_level != null ? `${params.brightness_level}档` : "亮度档位待确认"; + } else if (capability === "ambient_light.set_color_index") { + label = "氛围灯颜色"; + value = params.color_index != null ? `色号 ${params.color_index}` : "颜色待确认"; + } else if (capability === "ambient_light.set_illumination") { + value = [ + params.color_hex != null ? `颜色 ${params.color_hex}` : "颜色待确认", + params.brightness_pct != null ? `亮度 ${params.brightness_pct}%` : "亮度待确认", + ].join(" · "); + } icon = "lightbulb"; order = instructionOrder(label, "灯光"); } @@ -1036,6 +1109,63 @@ return rows.sort((left, right) => left.order - right.order); } + function modeDraftScheduleView(draft) { + const schedule = draft?.schedule; + if ( + schedule?.schema !== "cognitive-agent-mode-schedule/v1" + || !Array.isArray(schedule.phases) || !schedule.phases.length + || !Number.isInteger(schedule.repeat_count) + || schedule.repeat_count < 1 || schedule.repeat_count > 20 + || schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms) + || phase.duration_ms <= 0 || !Array.isArray(phase.operations)) + ) return null; + const durationLabel = (durationMs) => { + const minutes = Math.floor(durationMs / 60000); + const seconds = Number(((durationMs % 60000) / 1000).toFixed(3)); + return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`; + }; + let offsetMs = 0; + const phases = schedule.phases.map((phase, index) => { + // Each phase is rendered separately, without instruction-based grouping: + // the same seat or temperature can have a different target later on. + const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({ + ...operation, + target: operation.target || (operation.domain === "seat" + ? { instance: operation.target_scope } + : { zone: operation.target_scope }), + }))); + const view = { + id: String(phase.id || `phase-${index + 1}`), + name: String(phase.name || `阶段 ${index + 1}`), + timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`, + rows, + }; + offsetMs += phase.duration_ms; + return view; + }); + return { + phases, + operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0), + summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`, + stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。", + }; + } + + function appendModeDraftRows(container, rows) { + rows.forEach((item) => { + const row = document.createElement("div"); + row.className = "assistant-mode-draft-row"; + const icon = document.createElement("i"); + icon.dataset.lucide = item.icon; + const label = document.createElement("span"); + label.textContent = item.label; + const value = document.createElement("strong"); + value.textContent = item.value; + row.append(icon, label, value); + container.append(row); + }); + } + async function saveModeProfileDraft(message, card, draft, buttons, status) { buttons.forEach((button) => { button.disabled = true; }); card.classList.add("is-busy"); @@ -1151,23 +1281,39 @@ headingCopy.append(eyebrow, title); const badge = document.createElement("span"); badge.className = "assistant-mode-draft-badge"; - badge.textContent = `${Number(draft.operation_count) || 0} 项动作`; + const scheduleView = modeDraftScheduleView(draft); + badge.textContent = `${scheduleView?.operationCount ?? (Number(draft.operation_count) || 0)} 项动作`; heading.append(headingCopy, badge); const list = document.createElement("div"); list.className = "assistant-mode-draft-list"; - modeDraftRows(draft, actions).forEach((item) => { - const row = document.createElement("div"); - row.className = "assistant-mode-draft-row"; - const icon = document.createElement("i"); - icon.dataset.lucide = item.icon; - const label = document.createElement("span"); - label.textContent = item.label; - const value = document.createElement("strong"); - value.textContent = item.value; - row.append(icon, label, value); - list.append(row); - }); + if (scheduleView) { + card.classList.add("assistant-mode-draft-scheduled"); + const summary = document.createElement("p"); + summary.className = "assistant-mode-schedule-summary"; + summary.textContent = scheduleView.summary; + list.append(summary); + scheduleView.phases.forEach((phase, index) => { + const section = document.createElement("section"); + section.className = "assistant-mode-schedule-phase"; + section.dataset.modeSchedulePhase = phase.id; + const phaseHeading = document.createElement("header"); + const name = document.createElement("strong"); + name.textContent = `${index + 1}. ${phase.name}`; + const timing = document.createElement("small"); + timing.textContent = phase.timing; + phaseHeading.append(name, timing); + section.append(phaseHeading); + appendModeDraftRows(section, phase.rows); + list.append(section); + }); + const hint = document.createElement("p"); + hint.className = "assistant-mode-schedule-hint"; + hint.textContent = scheduleView.stopHint; + list.append(hint); + } else { + appendModeDraftRows(list, modeDraftRows(draft, actions)); + } const controls = document.createElement("div"); controls.className = "assistant-mode-draft-actions"; @@ -1267,6 +1413,90 @@ } } + function stopScheduleStatusPoll() { + if (!activeSchedulePoll) return; + window.clearTimeout(activeSchedulePoll.timer); + activeSchedulePoll.controller?.abort(); + activeSchedulePoll = null; + } + + function startScheduleStatusPoll(payload, message) { + stopScheduleStatusPoll(); + const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id + || payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || ""); + if (!vehicleId) return; + const poll = { + vehicleId, conversationId: conversationId(), message, + sessionId: String(payload?.schedule_execution?.session_id || ""), + runId: String(payload?.schedule_execution?.run_id || ""), + modeProfileId: String(payload?.mode_profile?.id || ""), + timer: null, controller: null, + }; + activeSchedulePoll = poll; + const isCurrent = () => activeSchedulePoll === poll + && conversationId() === poll.conversationId + && (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId); + const update = async () => { + if (!isCurrent()) { + if (activeSchedulePoll === poll) stopScheduleStatusPoll(); + return; + } + poll.controller = new AbortController(); + try { + const response = await window.CognitiveAgentNetwork.request( + `/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`, + { method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 }, + ); + if (!isCurrent()) return; + if (response.status === 401) { + setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed"); + stopScheduleStatusPoll(); + return; + } + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const status = await response.json(); + if (!isCurrent()) return; + if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) { + throw new Error("时间轴状态车型不匹配"); + } + if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId) + || (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) { + setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working"); + stopScheduleStatusPoll(); + return; + } + const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status); + if (status.status === "running") { + const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0 + ? ` · 阶段 ${status.phase_index + 1}` : ""; + const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0 + ? ` · 第 ${status.repeat_index + 1} 轮` : ""; + setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working"); + } else if (terminal) { + const stopped = ["cancelled", "stopped"].includes(status.status); + const failed = status.status === "failed"; + const copy = failed + ? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。` + : stopped + ? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。" + : status.status === "completed" + ? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。" + : "当前没有正在运行的动态时间轴。"; + setActionStatus(message, copy, failed ? "failed" : "success"); + window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status); + stopScheduleStatusPoll(); + return; + } + } catch (error) { + if (!isCurrent() || poll.controller?.signal.aborted) return; + setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working"); + } finally { + if (isCurrent()) poll.timer = window.setTimeout(update, 2000); + } + }; + poll.timer = window.setTimeout(update, 2000); + } + function applyAssistantEvent(event, message) { if (event.type === "validated_control_commands" && event.payload) { window.dispatchEvent(new CustomEvent( @@ -1295,14 +1525,20 @@ if (event.type === "action.completed" || event.type === "work.completed") { latestControlResult = event.payload || latestControlResult; const completed = event.success !== false; + const scheduleRunning = completed + && event.payload?.orchestration_route === "mode_schedule_execute" + && (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running"); const defaultText = event.type === "work.completed" ? (completed ? "AI助手已完成工作" : "AI助手未能完成工作") : (completed ? "座舱控制已完成" : "座舱控制未完成"); setActionStatus( message, - event.display_text || defaultText, - completed ? "success" : "failed", + scheduleRunning + ? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。" + : event.display_text || defaultText, + scheduleRunning ? "working" : completed ? "success" : "failed", ); + if (scheduleRunning) startScheduleStatusPoll(event.payload, message); if (event.payload) { window.CognitiveAgentConsole?.applySyncedResult?.(event.payload); appendDispatchedControls(message, event.payload); @@ -1574,6 +1810,8 @@ : beginModeExecution(profile, { command: commandText }); const message = activeHandle.message; const status = String(result?.status || ""); + const scheduleRunning = status === "running" + && result?.schedule_execution?.status === "running"; const dryRun = result?.execution_mode === "dry_run"; const waitingForFeedback = result?.execution_outcome === "submitted_unverified"; @@ -1583,6 +1821,10 @@ if (error) { content = `${modeName}执行失败:${error.message || "请稍后重试。"}`; statusText = "功能模式执行失败"; + } else if (scheduleRunning) { + content = `已启动动态时间轴:${commandText}。后续动作将分阶段执行。`; + statusText = "动态时间轴执行中,可输入“停止动态模式”提前结束"; + statusState = "working"; } else if (status === "succeeded") { content = dryRun ? `已完成模拟编排:${commandText}。` @@ -1603,6 +1845,7 @@ setMessageContent(message, content, { final: true }); setActionStatus(message, statusText, statusState); if (result) appendDispatchedControls(message, result); + if (scheduleRunning) startScheduleStatusPoll(result, message); persistMessages(); return activeHandle; } @@ -1689,9 +1932,15 @@ const target = event.target instanceof Element ? event.target : null; if (!target?.closest("#newConversation")) return; event.preventDefault?.(); + stopScheduleStatusPoll(); resetConversation(); }, true); + document.addEventListener("change", (event) => { + if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll(); + }); + window.addEventListener("pagehide", stopScheduleStatusPoll); + document.addEventListener("DOMContentLoaded", initialize); window.CognitiveAgentAssistant = { submit: submitAssistant, diff --git a/apps/web-console/firmware-workbench-entry.css b/apps/web-console/firmware-workbench-entry.css new file mode 100644 index 0000000..7c766a2 --- /dev/null +++ b/apps/web-console/firmware-workbench-entry.css @@ -0,0 +1,75 @@ +.console-v2 .firmware-workbench-layer { + position: fixed; + z-index: 5000; + inset: 0; + display: grid; + place-items: center; + padding: 68px 20px 20px; + background: color-mix(in oklch, var(--background) 58%, transparent); + backdrop-filter: blur(6px) saturate(.9); + -webkit-backdrop-filter: blur(6px) saturate(.9); + overscroll-behavior: contain; +} +.console-v2 .firmware-workbench-layer[hidden] { display: none !important; } +.console-v2 .firmware-workbench-page { + width: min(1180px, 100%); + height: min(664px, 100%); + min-height: 0; + display: grid; + grid-template-rows: 76px minmax(0, 1fr); + overflow: hidden; + color: var(--foreground); + background: color-mix(in oklch, var(--card) 97%, var(--background)); + border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border)); + border-radius: 22px; + box-shadow: 0 28px 70px color-mix(in oklch, var(--foreground) 22%, transparent), 0 4px 18px color-mix(in oklch, var(--primary) 10%, transparent), inset 0 1px 0 color-mix(in oklch, var(--primary-foreground) 85%, transparent); + animation: firmware-workbench-enter 180ms ease-out both; +} +.console-v2 .firmware-workbench-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 18px 13px 20px; + border-bottom: 1px solid color-mix(in oklch, var(--primary) 10%, var(--border)); + background: color-mix(in oklch, var(--card) 85%, transparent); +} +.console-v2 .firmware-workbench-heading { min-width: 0; display: flex; align-items: center; gap: 12px; } +.console-v2 .firmware-workbench-heading > span { + width: 46px; + height: 46px; + flex: 0 0 46px; + display: grid; + place-items: center; + color: var(--primary); + background: color-mix(in oklch, var(--primary) 10%, var(--card)); + border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border)); + border-radius: 15px; +} +.console-v2 .firmware-workbench-heading svg { width: 24px; height: 24px; stroke-width: 1.8; } +.console-v2 .firmware-workbench-heading h2 { margin: 0; color: var(--heading-foreground, var(--foreground)); font-size: 20px; line-height: 1.2; letter-spacing: -.025em; } +.console-v2 #firmwareWorkbenchClose { + width: 38px; + height: 38px; + display: grid; + flex: 0 0 38px; + place-items: center; + padding: 0; + color: var(--foreground); + background: color-mix(in oklch, var(--card) 88%, var(--secondary)); + border: 1px solid color-mix(in oklch, var(--primary) 13%, var(--border)); + border-radius: 12px; + cursor: pointer; +} +.console-v2 #firmwareWorkbenchClose:hover, +.console-v2 #firmwareWorkbenchClose:focus-visible { color: var(--primary); background: var(--accent); outline: 3px solid color-mix(in oklch, var(--ring) 16%, transparent); } +.console-v2 #firmwareWorkbenchClose svg { width: 18px; height: 18px; } +.console-v2 .firmware-workbench-frame { width: 100%; height: 100%; display: block; border: 0; background: transparent; } +@keyframes firmware-workbench-enter { from { opacity: 0; transform: translateY(8px) scale(.992); } to { opacity: 1; transform: none; } } +@media (max-width: 760px) { + .console-v2 .firmware-workbench-layer { padding: 54px 8px 8px; } + .console-v2 .firmware-workbench-page { height: 100%; border-radius: 17px; grid-template-rows: 64px minmax(0, 1fr); } + .console-v2 .firmware-workbench-header { padding: 9px 11px; } + .console-v2 .firmware-workbench-heading > span { width: 40px; height: 40px; flex-basis: 40px; border-radius: 12px; } + .console-v2 .firmware-workbench-heading h2 { font-size: 17px; } +} diff --git a/apps/web-console/firmware-workbench-entry.js b/apps/web-console/firmware-workbench-entry.js new file mode 100644 index 0000000..cd652b2 --- /dev/null +++ b/apps/web-console/firmware-workbench-entry.js @@ -0,0 +1,108 @@ +(() => { + "use strict"; + + const state = { open: false, returnFocus: null, backgroundInert: [] }; + + function setBackgroundInert(inert) { + const layer = document.querySelector("#firmwareWorkbenchLayer"); + if (!layer) return; + if (inert) { + state.backgroundInert = [...document.body.children] + .filter((element) => element !== layer && !["SCRIPT", "STYLE", "LINK"].includes(element.tagName)) + .map((element) => ({ element, wasInert: element.hasAttribute("inert") })); + state.backgroundInert.forEach(({ element }) => element.toggleAttribute("inert", true)); + return; + } + state.backgroundInert.forEach(({ element, wasInert }) => element.toggleAttribute("inert", wasInert)); + state.backgroundInert = []; + } + + function setOpen(open) { + const next = Boolean(open); + const layer = document.querySelector("#firmwareWorkbenchLayer"); + const trigger = document.querySelector("#firmwareWorkbenchToggle"); + if (!layer || !trigger || state.open === next) return; + state.open = next; + trigger.classList.toggle("active", next); + trigger.setAttribute("aria-pressed", String(next)); + trigger.setAttribute("aria-expanded", String(next)); + trigger.setAttribute("aria-label", next ? "关闭座椅固件开发工作台" : "打开座椅固件开发工作台"); + trigger.title = next ? "关闭座椅固件开发工作台" : "打开座椅固件开发工作台"; + if (next) { + state.returnFocus = document.activeElement; + layer.hidden = false; + layer.inert = false; + layer.dataset.open = "true"; + setBackgroundInert(true); + requestAnimationFrame(() => document.querySelector("#firmwareWorkbenchClose")?.focus()); + } else { + layer.dataset.open = "false"; + layer.inert = true; + layer.hidden = true; + setBackgroundInert(false); + const target = state.returnFocus instanceof HTMLElement && state.returnFocus.isConnected + ? state.returnFocus + : trigger; + state.returnFocus = null; + target.focus(); + } + } + + function mount() { + const dock = document.querySelector("#v2PanelControlDock"); + if (!dock || document.querySelector("#firmwareWorkbenchToggle")) return false; + + const trigger = document.createElement("button"); + trigger.id = "firmwareWorkbenchToggle"; + trigger.className = "firmware-workbench-toggle v2-panel-control-button"; + trigger.type = "button"; + trigger.setAttribute("aria-controls", "firmwareWorkbenchLayer"); + trigger.setAttribute("aria-pressed", "false"); + trigger.setAttribute("aria-expanded", "false"); + trigger.setAttribute("aria-label", "打开座椅固件开发工作台"); + trigger.title = "打开座椅固件开发工作台"; + trigger.innerHTML = '座椅固件开发工作台'; + + const layer = document.createElement("div"); + layer.id = "firmwareWorkbenchLayer"; + layer.className = "firmware-workbench-layer"; + layer.hidden = true; + layer.inert = true; + layer.setAttribute("role", "dialog"); + layer.setAttribute("aria-modal", "true"); + layer.setAttribute("aria-labelledby", "firmwareWorkbenchTitle"); + layer.innerHTML = ` +
+
+
+ +

座椅固件开发工作台

+
+ +
+ +
`; + + dock.append(trigger); + document.body.append(layer); + trigger.addEventListener("click", () => setOpen(!state.open)); + document.querySelector("#firmwareWorkbenchClose", layer).addEventListener("click", () => setOpen(false)); + layer.addEventListener("click", (event) => { if (event.target === layer) setOpen(false); }); + document.addEventListener("keydown", (event) => { + if (state.open && event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } + }); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + return true; + } + + if (!mount()) { + const observer = new MutationObserver(() => { + if (mount()) observer.disconnect(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + window.setTimeout(() => observer.disconnect(), 15000); + } +})(); diff --git a/apps/web-console/firmware-workbench.css b/apps/web-console/firmware-workbench.css new file mode 100644 index 0000000..3f0a587 --- /dev/null +++ b/apps/web-console/firmware-workbench.css @@ -0,0 +1,456 @@ +:root { + color-scheme: light; + font-family: "Microsoft YaHei UI", "PingFang SC", Inter, system-ui, sans-serif; + --page: #eef3f9; + --surface: #ffffff; + --surface-soft: #f7f9fc; + --line: #dce4ee; + --line-strong: #cbd7e5; + --text: #172033; + --muted: #68758b; + --primary: #2869e8; + --primary-soft: #eaf1ff; + --success: #16845b; + --success-soft: #e7f7f0; + --warning: #ba7314; + --danger: #c23f4a; + --shadow: 0 22px 60px rgba(43, 67, 101, .14), 0 4px 14px rgba(43, 67, 101, .08); +} + +* { box-sizing: border-box; } + +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } + +body { + display: grid; + place-items: center; + padding: 16px; + color: var(--text); + font-size: 14px; + line-height: 1.45; + background: + radial-gradient(circle at 15% 8%, rgba(74, 130, 235, .11), transparent 31%), + linear-gradient(145deg, #f7f9fc 0%, var(--page) 100%); +} + +button, textarea, select { font: inherit; } +button, a, select { -webkit-tap-highlight-color: transparent; } +button, select { cursor: pointer; } + +.shell { + width: min(1280px, 100%); + height: min(780px, calc(100dvh - 32px)); + min-height: 620px; + overflow: hidden; + background: #f7f9fc; + border: 1px solid rgba(205, 216, 230, .9); + border-radius: 22px; + box-shadow: var(--shadow); +} + +.topbar { + height: 68px; + padding: 0 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + background: rgba(255, 255, 255, .94); + border-bottom: 1px solid var(--line); +} + +.topbar-main, .topbar-status, .card-heading, .button-row, .log-actions, +.server-target, .command-footer, .capability-brief { display: flex; align-items: center; } +.topbar-main { gap: 11px; min-width: 0; } +.topbar-status { gap: 9px; flex-wrap: wrap; justify-content: flex-end; } + +.back-link, .product-mark, .icon-button, .result-icon { + display: inline-grid; + place-items: center; +} +.back-link, .icon-button { + width: 34px; + height: 34px; + border: 1px solid var(--line); + border-radius: 10px; + color: #4d5b71; + background: var(--surface); + text-decoration: none; +} +.back-link:hover, .icon-button:hover { color: var(--primary); border-color: #a9c1ef; background: var(--primary-soft); } +.back-link svg, .icon-button svg { width: 17px; height: 17px; } +.product-mark { width: 36px; height: 36px; border-radius: 11px; color: white; background: linear-gradient(145deg, #3a7cf0, #1d59cd); box-shadow: 0 7px 16px rgba(40, 105, 232, .22); } +.product-mark svg { width: 19px; } +.eyebrow, .section-kicker { margin: 0; color: #7a88a0; font-size: 12px; font-weight: 700; letter-spacing: .08em; } +h1 { margin: 1px 0 0; font-size: 20px; line-height: 1.15; letter-spacing: -.02em; } +h2 { margin: 1px 0 0; font-size: 17px; line-height: 1.2; } + +.environment-chip, .server-state, .status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 29px; + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: 999px; + color: #58677d; + background: #f8fafc; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} +.environment-chip svg { width: 14px; } +.server-state span { width: 7px; height: 7px; border-radius: 50%; background: #9aa7b8; } +.server-state.online { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); } +.server-state.online span { background: var(--success); box-shadow: 0 0 0 4px rgba(22, 132, 91, .1); } + +main { + height: calc(100% - 68px); + min-height: 0; + padding: 11px; + display: grid; + grid-template-columns: 380px minmax(0, 1fr); + grid-template-rows: 116px minmax(0, 1fr) 172px; + grid-template-areas: + "pipeline pipeline" + "command execution" + "software software"; + gap: 10px; +} + +.card { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + padding: 12px 14px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 15px; + box-shadow: 0 2px 8px rgba(46, 66, 94, .045); +} +.card-heading { justify-content: space-between; gap: 12px; min-height: 32px; } +.hero-grid { grid-area: command; min-width: 0; min-height: 0; } +.hero-grid .command-card { height: 100%; } +.pipeline-card { grid-area: pipeline; } +.execution-grid { grid-area: execution; min-width: 0; min-height: 0; } +.execution-grid .log-card { width: 100%; height: 100%; } +.software-card { grid-area: software; } + +.baseline-picker { + display: grid; + grid-template-columns: auto auto; + align-items: center; + column-gap: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.baseline-picker svg { grid-row: 1 / 3; width: 16px; color: var(--primary); } +.baseline-picker span { line-height: 1; } +.baseline-picker select { + max-width: 148px; + border: 0; + padding: 1px 18px 1px 0; + color: var(--text); + background: transparent; + font-size: 13px; + font-weight: 700; + outline: none; +} + +.command-card { display: flex; flex-direction: column; gap: 7px; } +.command-card > label { color: #5d6b80; font-size: 13px; font-weight: 650; } +textarea { + width: 100%; + min-height: 64px; + flex: 1 1 70px; + resize: none; + padding: 9px 11px; + color: var(--text); + background: var(--surface-soft); + border: 1px solid var(--line-strong); + border-radius: 10px; + font-size: 13px; + line-height: 1.55; + outline: none; +} +textarea:focus { border-color: #79a4f4; box-shadow: 0 0 0 3px rgba(40, 105, 232, .1); background: #fff; } +.prompt-templates { display: flex; gap: 6px; overflow: hidden; } +.prompt-templates button { + min-width: 0; + padding: 5px 8px; + border: 1px solid #cbd9ef; + border-radius: 8px; + color: #3563a9; + background: #f3f7ff; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} +.capability-brief { + min-height: 50px; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + border: 1px solid #dbe6f6; + border-radius: 10px; + background: #f6f9fe; +} +.capability-brief > div { min-width: 0; display: grid; gap: 1px; } +.capability-brief span { color: var(--muted); font-size: 12px; } +.capability-brief strong { overflow: hidden; text-overflow: ellipsis; color: #344157; font-size: 13px; white-space: nowrap; } +.capability-brief > svg { width: 17px; flex: 0 0 auto; color: #8fa7ca; } +.capability-target { flex: 1; } +.capability-target strong { color: var(--primary); } +.command-footer { justify-content: flex-end; gap: 8px; } +.button-row { gap: 6px; } +.button { + min-height: 33px; + padding: 6px 11px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 9px; + border: 1px solid transparent; + font-size: 13px; + font-weight: 750; + white-space: nowrap; +} +.button svg { width: 15px; } +.button-primary { color: white; background: var(--primary); box-shadow: 0 5px 12px rgba(40, 105, 232, .18); } +.button-secondary { color: #526178; background: #fff; border-color: var(--line-strong); } +.button:disabled, .icon-button:disabled { cursor: not-allowed; opacity: .48; } + +.pipeline { + width: calc(100% - 230px); + height: 58px; + margin: 7px 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + list-style: none; +} +.pipeline-stage { position: relative; min-width: 0; padding-top: 2px; text-align: center; } +.pipeline-stage::after { content: ""; position: absolute; top: 14px; left: calc(50% + 15px); width: calc(100% - 30px); height: 2px; background: #dce4ee; } +.pipeline-stage:last-child::after { display: none; } +.stage-node { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto 1px; display: grid; place-items: center; border: 2px solid #d4deea; border-radius: 50%; color: #7b8aa0; background: #fff; } +.stage-node svg { width: 14px; } +.stage-name { display: block; overflow: hidden; text-overflow: ellipsis; font-size: 13px; line-height: 1.15; white-space: nowrap; } +.stage-state { display: block; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: 12px; line-height: 1.1; white-space: nowrap; } +.pipeline-stage[data-status="running"] .stage-node { color: var(--primary); border-color: var(--primary); background: var(--primary-soft); animation: pulse 1.4s infinite; } +.pipeline-stage[data-status="success"] .stage-node { color: var(--success); border-color: #65bd9e; background: var(--success-soft); } +.pipeline-stage[data-status="failed"] .stage-node, .pipeline-stage[data-status="blocked"] .stage-node { color: var(--danger); border-color: #e39aa1; background: #fff0f1; } +.pipeline-stage[data-status="canceled"] .stage-node { color: #7b8798; background: #eef1f5; } +.pipeline-stage[data-status="pending_confirmation"] .stage-node { color: var(--warning); border-color: #ecc080; background: #fff7e9; } +.pipeline-stage[data-status="success"]::after { background: #8bcdb5; } +.release-result { + position: absolute; + top: 43px; + right: 14px; + width: 210px; + min-height: 52px; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-soft); +} +.result-icon { width: 32px; height: 32px; flex: 0 0 auto; color: #748399; border-radius: 9px; background: #e9eef5; } +.result-icon svg { width: 17px; } +.release-result div { min-width: 0; display: grid; } +.release-result small { color: var(--muted); font-size: 12px; } +.release-result strong { overflow: hidden; text-overflow: ellipsis; font-size: 13px; white-space: nowrap; } +.release-result[data-state="success"] { border-color: #b9e5d3; background: var(--success-soft); } +.release-result[data-state="success"] .result-icon { color: var(--success); background: #d2f0e3; } +.release-result[data-state="failed"] { border-color: #efc5c9; background: #fff2f3; } +.status-legend { display: none; } + +.log-card { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 7px; } +.log-actions { gap: 6px; } +.log-actions .icon-button { width: 31px; height: 31px; } +.status-badge { min-height: 25px; padding: 3px 9px; } +.status-badge[data-status="running"] { color: var(--primary); border-color: #b9cff7; background: var(--primary-soft); } +.status-badge[data-status="success"], .status-badge[data-status="published"] { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); } +.status-badge[data-status="failed"], .status-badge[data-status="blocked"] { color: var(--danger); border-color: #efc5c9; background: #fff2f3; } +.status-badge[data-status="ready"], .status-badge[data-status="pending_confirmation"] { color: var(--warning); border-color: #efd3a8; background: #fff7e9; } +.metrics { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; } +.metrics div { min-width: 0; padding: 5px 7px; border-radius: 8px; background: var(--surface-soft); border: 1px solid #edf1f6; } +.metrics dt { color: var(--muted); font-size: 12px; } +.metrics dd { margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; font-size: 13px; font-weight: 750; white-space: nowrap; } +.log-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(220px, .9fr); gap: 8px; } +.log-stream, .log-details { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: #f9fbfd; } +.log-stream { padding: 7px 9px; scrollbar-width: thin; } +.empty-state { height: 100%; min-height: 110px; display: grid; place-content: center; justify-items: center; gap: 4px; color: var(--muted); text-align: center; } +.empty-state svg { width: 24px; color: #93a3ba; } +.empty-state strong { color: #4a596f; font-size: 13px; } +.empty-state span { font-size: 12px; } +.log-entry { display: grid; grid-template-columns: 58px 9px minmax(0, 1fr); gap: 7px; padding: 5px 0; border-bottom: 1px solid #e9eef5; } +.log-entry:last-child { border-bottom: 0; } +.log-time { color: #7b889b; font: 12px/1.4 ui-monospace, Consolas, monospace; } +.log-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: #91a0b4; } +.log-entry[data-level="success"] .log-dot { background: var(--success); } +.log-entry[data-level="error"] .log-dot { background: var(--danger); } +.log-entry[data-level="warning"] .log-dot { background: var(--warning); } +.log-copy { min-width: 0; } +.log-copy strong { font-size: 13px; } +.log-copy p { margin: 1px 0 0; color: #5b687b; font-size: 13px; overflow-wrap: anywhere; } +.log-details { display: flex; flex-direction: column; } +.inspector-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); background: #f2f5f9; } +.inspector-tabs button { padding: 7px 4px; border: 0; border-bottom: 2px solid transparent; color: #68768b; background: transparent; font-size: 12px; font-weight: 700; } +.inspector-tabs button.active { color: var(--primary); border-bottom-color: var(--primary); background: #fff; } +.inspector-content { min-height: 0; flex: 1; overflow: auto; padding: 7px; } +.empty-inline, .empty-table { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; } +.change-item, .test-item { display: flex; gap: 7px; padding: 6px; border-bottom: 1px solid #e8edf4; } +.change-type, .test-mark { flex: 0 0 auto; height: 21px; padding: 2px 5px; border-radius: 5px; color: var(--primary); background: var(--primary-soft); font-size: 12px; } +.test-mark { width: 21px; display: grid; place-items: center; color: var(--success); background: var(--success-soft); } +.test-mark svg { width: 12px; } +.change-copy, .test-copy { min-width: 0; display: grid; } +.change-copy strong, .test-copy strong { font-size: 12px; overflow-wrap: anywhere; } +.change-copy span, .test-copy span { color: var(--muted); font-size: 12px; } +.build-output, .code-block { margin: 0; color: #26364d; font: 12px/1.55 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } + +.software-card { display: grid; grid-template-rows: 33px minmax(0, 1fr); gap: 5px; } +.server-target { gap: 6px; color: var(--muted); font-size: 12px; } +.server-target svg { width: 15px; color: var(--primary); } +.server-target strong { color: #3b4a61; } +.table-wrap { min-height: 0; overflow: auto; border: 1px solid var(--line); border-radius: 9px; } +table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; } +th { position: sticky; top: 0; z-index: 1; padding: 6px 7px; color: #617086; background: #f2f5f9; text-align: left; font-weight: 750; white-space: nowrap; } +td { padding: 6px 7px; border-top: 1px solid #e8edf4; color: #4d5a6e; vertical-align: middle; } +th:nth-child(1) { width: 14%; } th:nth-child(2) { width: 13%; } th:nth-child(3) { width: 11%; } +th:nth-child(4) { width: 28%; } th:nth-child(5) { width: 8%; } th:nth-child(6) { width: 26%; } +.software-name, .file-cell { min-width: 0; display: grid; } +.software-name strong, .file-cell strong { overflow: hidden; text-overflow: ellipsis; color: #26354b; white-space: nowrap; } +.software-name span, .file-cell span { color: var(--muted); } +.file-cell > span { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cap-tags { display: flex; gap: 3px; flex-wrap: wrap; } +.cap-tags span { padding: 2px 5px; color: #3563a9; background: #eef4ff; border-radius: 5px; white-space: nowrap; } +.hash-value { display: inline; font-family: ui-monospace, Consolas, monospace; white-space: nowrap; } +.table-actions { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; } +.table-actions button, .table-actions a { min-width: 27px; height: 26px; padding: 3px 5px; display: inline-grid; place-items: center; border: 0; border-radius: 5px; color: #3563a9; background: #edf3ff; font-size: 12px; text-decoration: none; white-space: nowrap; } +.table-actions svg { width: 13px; height: 13px; } +.table-actions .publish-action { color: white; background: var(--primary); } +.table-actions .delete-action { color: var(--danger); background: #fff0f1; } +.baseline-protection { height: 26px; padding: 3px 6px; display: inline-flex; align-items: center; gap: 3px; color: var(--success); background: var(--success-soft); border-radius: 5px; font-size: 12px; white-space: nowrap; } +.baseline-protection svg { width: 13px; } +.status-badge[data-status="baseline"] { color: var(--success); border-color: #b9e5d3; background: var(--success-soft); } + +.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 24px; background: rgba(31, 43, 61, .34); backdrop-filter: blur(4px); } +.modal-backdrop[hidden] { display: none; } +.modal { width: min(780px, 100%); max-height: min(680px, calc(100dvh - 48px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; background: #fff; border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); } +.modal header, .modal footer { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); } +.modal footer { justify-content: flex-end; border-top: 1px solid var(--line); border-bottom: 0; } +.modal-body { overflow: auto; padding: 16px; } +.detail-grid, .diff-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.detail-field, .diff-panel { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); } +.detail-field span { display: block; color: var(--muted); font-size: 12px; } +.detail-field strong { display: block; margin-top: 3px; overflow-wrap: anywhere; } +.diff-panel h3 { margin: 0 0 8px; font-size: 14px; } +.diff-panel p { margin: 5px 0; color: #58677c; } +.diff-panel.added { border-color: #b9e5d3; background: var(--success-soft); } +.toast-region { position: fixed; z-index: 80; right: 22px; bottom: 22px; display: grid; gap: 8px; } +.toast { max-width: 390px; padding: 10px 13px; color: #fff; background: #344258; border-radius: 9px; box-shadow: 0 10px 28px rgba(31, 43, 61, .22); font-size: 13px; } +.toast.success { background: var(--success); } +.toast.error { background: var(--danger); } + +@keyframes pulse { 50% { box-shadow: 0 0 0 5px rgba(40, 105, 232, .09); } } + +@media (max-width: 980px) { + body { padding: 8px; } + .shell { width: 100%; height: calc(100dvh - 16px); min-height: 560px; border-radius: 16px; } + .topbar { height: 62px; padding: 0 14px; } + .topbar-status .dashboard-chip, .environment-chip:last-child { display: none; } + main { height: calc(100% - 62px); grid-template-columns: 330px minmax(0, 1fr); grid-template-rows: 108px minmax(0, 1fr) 164px; padding: 8px; gap: 8px; } + .card { padding: 10px; } + .pipeline { width: calc(100% - 190px); } + .release-result { width: 176px; right: 10px; } + .log-workspace { grid-template-columns: 1fr; } + .log-details { display: none; } + .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 720px) { + body { display: block; padding: 0; } + .shell { height: 100dvh; min-height: 0; border: 0; border-radius: 0; } + .topbar { height: 58px; } + h1 { font-size: 17px; } + .eyebrow, .topbar-status { display: none; } + main { height: calc(100% - 58px); overflow: auto; grid-template-columns: 1fr; grid-template-rows: 104px 360px 330px 190px; grid-template-areas: "pipeline" "command" "execution" "software"; } + .execution-grid { display: block; } + .execution-grid .log-card { min-height: 330px; } + .pipeline { width: 100%; } + .pipeline-card { overflow-x: auto; } + .pipeline-card .pipeline { width: 680px; } + .release-result { display: none; } + .stage-name { font-size: 11px; } + .stage-state { display: none; } + .command-card { gap: 5px; } + .command-footer p { display: none; } + .command-footer { justify-content: flex-end; } + .prompt-templates button { flex: 1; } + th:nth-child(2), td:nth-child(2), th:nth-child(3), td:nth-child(3) { display: none; } + th:nth-child(1), th:nth-child(4), th:nth-child(5), th:nth-child(6) { width: auto; } +} + +body.embedded { + display: block; + padding: 0; + background: transparent; +} +body.embedded .shell { + width: 100%; + height: 100dvh; + min-height: 0; + border: 0; + border-radius: 0; + box-shadow: none; + background: #f7f9fc; +} +body.embedded .topbar { display: none; } +body.embedded main { + height: 100%; + padding: 8px; + grid-template-columns: 350px minmax(0, 1fr); + grid-template-rows: 82px minmax(0, 1fr) 204px; + gap: 8px; +} +body.embedded .card { padding: 8px 10px; border-radius: 12px; } +body.embedded .card-heading { min-height: 27px; } +body.embedded .command-card { gap: 5px; } +body.embedded textarea { min-height: 48px; padding: 7px 9px; } +body.embedded .prompt-templates button { padding: 4px 7px; } +body.embedded .capability-brief { min-height: 44px; padding: 5px 8px; } +body.embedded .pipeline { height: 48px; margin: 4px 0 0; } +body.embedded .stage-node { width: 24px; height: 24px; } +body.embedded .pipeline-stage::after { top: 12px; } +body.embedded .stage-node svg { width: 12px; } +body.embedded .stage-name { font-size: 12px; } +body.embedded .stage-state { font-size: 11px; } +body.embedded .release-result { top: 18px; min-height: 44px; padding: 5px 8px; } +body.embedded .result-icon { width: 28px; height: 28px; } +body.embedded .log-card { gap: 5px; } +body.embedded .metrics div { padding: 3px 6px; } +body.embedded .software-card { grid-template-rows: 27px minmax(0, 1fr); gap: 3px; } +body.embedded th { padding: 4px 6px; } +body.embedded table { font-size: 12.5px; } +body.embedded td { padding: 3px 6px; } +body.embedded .software-name strong, +body.embedded .file-cell strong { line-height: 1.2; } +body.embedded .software-name span, +body.embedded .file-cell span { line-height: 1.15; } + +@media (max-width: 720px) { + body.embedded main { + height: 100%; + overflow: auto; + grid-template-columns: 1fr; + grid-template-rows: 96px 350px 320px 210px; + grid-template-areas: "pipeline" "command" "execution" "software"; + } + body.embedded .pipeline-card { overflow-x: auto; } + body.embedded .pipeline-card .pipeline { width: 680px; } +} diff --git a/apps/web-console/firmware-workbench.html b/apps/web-console/firmware-workbench.html new file mode 100644 index 0000000..60ce069 --- /dev/null +++ b/apps/web-console/firmware-workbench.html @@ -0,0 +1,143 @@ + + + + + + + + 座椅固件开发工作台 · 511 工程 + + + + + + +
+
+
+ + + + +

座椅固件开发工作台

+
+
+ 正在连接发布服务 +
+
+ +
+
+
+
+
+

开发指令

+
+ +
+ + +
+ + + +
+
+
当前基线时间控制
+ +
目标固件能力时间控制+位置控制+座椅通风+座椅加热
+
+ +
+
+ +
+
    +
    + +
    当前交付状态尚未开始
    +
    +
    + 等待运行中 + 待确认成功 + 失败取消 + 阻塞 +
    +
    + +
    +
    +
    +
    +

    任务执行日志

    +
    +
    + 等待 + + +
    +
    +
    +
    开始时间
    —
    结束时间
    —
    +
    实际耗时
    —
    文件变更
    —
    +
    +
    +
    +
    暂无执行记录创建开发任务后,这里将实时显示各阶段输出。
    +
    + +
    +
    +
    + +
    +
    +
    +

    固件版本与软件产物

    +
    +
    发布目标车机升级服务器
    +
    +
    + + + +
    软件 / 版本能力构建时间固件文件 / 校验状态操作
    尚未生成固件产物
    +
    +
    +
    +
    + + +
    + + diff --git a/apps/web-console/firmware-workbench.js b/apps/web-console/firmware-workbench.js new file mode 100644 index 0000000..0e9638a --- /dev/null +++ b/apps/web-console/firmware-workbench.js @@ -0,0 +1,449 @@ +(() => { + "use strict"; + + if (window.self !== window.top || new URLSearchParams(window.location.search).has("embedded")) { + document.body.classList.add("embedded"); + } + + const STATUS_LABELS = { + waiting: "等待", + running: "运行中", + pending_confirmation: "待确认", + success: "成功", + failed: "失败", + canceled: "取消", + blocked: "阻塞", + published: "已发布", + ready: "待发布", + baseline: "基线固件", + }; + const STAGE_ICONS = { + instruction: "file-input", + requirements: "list-checks", + baseline: "git-compare-arrows", + codegen: "code-2", + build: "hammer", + test: "shield-check", + package: "package-check", + publish: "cloud-upload", + }; + const ACTIVE_STATES = new Set(["waiting", "running", "pending_confirmation"]); + const CAPABILITY_ORDER = ["时间控制", "位置控制", "座椅通风", "座椅加热"]; + const FALLBACK_BASELINES = { + time_only: ["时间控制"], + }; + const FALLBACK_PIPELINE = [ + ["instruction", "指令输入"], ["requirements", "需求解析"], + ["baseline", "基线检查"], ["codegen", "代码生成"], + ["build", "编译构建"], ["test", "测试验证"], + ["package", "固件打包"], ["publish", "发布服务器"], + ].map(([id, name]) => ({ id, name, status: "waiting", summary: "等待执行" })); + + const ui = { + instruction: document.querySelector("#instructionInput"), + baseline: document.querySelector("#baselineSelect"), + baselineCapabilities: document.querySelector("#baselineCapabilities"), + targetCapabilities: document.querySelector("#targetCapabilities"), + start: document.querySelector("#startTaskButton"), + cancel: document.querySelector("#cancelTaskButton"), + retry: document.querySelector("#retryTaskButton"), + server: document.querySelector("#serverState"), + serverName: document.querySelector("#otaServerName"), + release: document.querySelector("#releaseResult"), + pipeline: document.querySelector("#pipeline"), + logs: document.querySelector("#logStream"), + scrollLogs: document.querySelector("#scrollLogButton"), + status: document.querySelector("#taskStatusBadge"), + metrics: document.querySelector("#taskMetrics"), + inspector: document.querySelector("#inspectorContent"), + artifacts: document.querySelector("#artifactTableBody"), + modal: document.querySelector("#detailModal"), + modalTitle: document.querySelector("#modalTitle"), + modalBody: document.querySelector("#modalBody"), + modalClose: document.querySelector("#closeModalButton"), + modalCloseFooter: document.querySelector("#modalCloseButton"), + toasts: document.querySelector("#toastRegion"), + }; + const model = { + snapshot: { tasks: [], artifacts: [], releases: [], pipeline: FALLBACK_PIPELINE }, + selectedTaskId: null, + inspectorTab: "changes", + pending: false, + pollTimer: null, + lastLogCount: 0, + }; + + function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + async function api(path, options = {}) { + const response = await fetch(path, { + credentials: "same-origin", + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") ? await response.json() : null; + if (!response.ok) throw new Error(payload?.message || `请求失败(${response.status})`); + return payload; + } + + function selectedTask() { + return model.snapshot.tasks.find((task) => task.id === model.selectedTaskId) + || model.snapshot.tasks[0] + || null; + } + + function baselineCapabilities() { + const baseline = model.snapshot.baselines?.find((item) => item.id === ui.baseline.value); + return baseline?.capabilities || FALLBACK_BASELINES[ui.baseline.value] || FALLBACK_BASELINES.time_only; + } + + function parseInstructionCapabilities(instruction) { + const lowered = String(instruction || "").toLowerCase(); + const aliases = { + "时间控制": ["时间", "定时", "timed"], + "位置控制": ["位置", "position"], + "座椅通风": ["通风", "vent"], + "座椅加热": ["加热", "heater", "heat"], + }; + return CAPABILITY_ORDER.filter((capability) => aliases[capability].some((alias) => lowered.includes(alias))); + } + + function updateCapabilityPreview(task = null) { + const baseline = baselineCapabilities(); + const taskMatchesDraft = task + && task.instruction === ui.instruction.value.trim() + && (task.baseline_id || "time_only") === ui.baseline.value; + const target = taskMatchesDraft + ? task.target_capabilities + : CAPABILITY_ORDER.filter((item) => baseline.includes(item) || parseInstructionCapabilities(ui.instruction.value).includes(item)); + ui.baselineCapabilities.textContent = baseline.join("+"); + ui.targetCapabilities.textContent = (target?.length ? target : baseline).join("+"); + } + + function formatDate(value) { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", + hour12: false, + }).format(date).replaceAll("/", "-"); + } + + function formatClock(value) { + if (!value) return "--:--:--"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "--:--:--"; + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }).format(date); + } + + function formatDuration(milliseconds) { + if (milliseconds === null || milliseconds === undefined) return "—"; + if (milliseconds < 1000) return `${milliseconds} 毫秒`; + const seconds = milliseconds / 1000; + return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`; + } + + function formatSize(bytes) { + const value = Number(bytes || 0); + if (value < 1024) return `${value} 字节`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} 千字节`; + return `${(value / 1024 / 1024).toFixed(2)} 兆字节`; + } + + function toast(message, type = "info") { + const element = document.createElement("div"); + element.className = `toast ${type}`; + element.textContent = message; + ui.toasts.append(element); + window.setTimeout(() => element.remove(), 4200); + } + + function refreshIcons() { + if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.8 } }); + } + + function renderPipeline(task) { + const stages = task?.stages || model.snapshot.pipeline?.map((stage) => ({ ...stage, status: "waiting" })) || FALLBACK_PIPELINE; + ui.pipeline.innerHTML = stages.map((stage) => ` +
  1. + + ${escapeHtml(stage.name)} + ${escapeHtml(STATUS_LABELS[stage.status] || stage.status || "等待")}${stage.duration_ms !== null && stage.duration_ms !== undefined ? ` · ${escapeHtml(formatDuration(stage.duration_ms))}` : ""} +
  2. `).join(""); + } + + function renderRelease(task) { + let icon = "cloud-upload"; + let label = "尚未开始"; + let state = "idle"; + if (task) { + label = task.message || STATUS_LABELS[task.status] || task.status; + state = task.status; + if (task.status === "success") { icon = "circle-check-big"; state = "success"; } + else if (task.status === "running") icon = "loader-circle"; + else if (task.status === "failed") icon = "circle-x"; + else if (task.status === "canceled") icon = "ban"; + } + ui.release.dataset.state = state; + ui.release.innerHTML = `
    当前交付状态${escapeHtml(label)}
    `; + } + + function renderLogs(task) { + const logs = task?.logs || []; + if (!logs.length) { + ui.logs.innerHTML = `
    暂无执行记录创建开发任务后,这里将实时显示各阶段输出。
    `; + model.lastLogCount = 0; + return; + } + const nearBottom = ui.logs.scrollHeight - ui.logs.scrollTop - ui.logs.clientHeight < 80; + ui.logs.innerHTML = logs.map((entry) => ` +
    + + +
    ${escapeHtml(entry.title)}

    ${escapeHtml(entry.message)}

    +
    `).join(""); + if (nearBottom || logs.length > model.lastLogCount) ui.logs.scrollTop = ui.logs.scrollHeight; + model.lastLogCount = logs.length; + } + + function renderInspector(task) { + const status = task?.status || "waiting"; + ui.status.dataset.status = status; + ui.status.textContent = STATUS_LABELS[status] || status; + const fileCount = task?.file_changes?.length || 0; + ui.metrics.innerHTML = ` +
    开始时间
    ${escapeHtml(formatDate(task?.started_at))}
    +
    结束时间
    ${escapeHtml(formatDate(task?.finished_at))}
    +
    实际耗时
    ${escapeHtml(formatDuration(task?.duration_ms))}
    +
    文件变更
    ${fileCount ? `${fileCount} 个文件` : "—"}
    `; + document.querySelectorAll("[data-inspector-tab]").forEach((button) => { + button.classList.toggle("active", button.dataset.inspectorTab === model.inspectorTab); + }); + if (!task) { + ui.inspector.innerHTML = `
    暂无任务数据
    `; + return; + } + if (model.inspectorTab === "build") { + ui.inspector.innerHTML = task.build_output?.length + ? `
    ${escapeHtml(task.build_output.join("\n"))}
    ` + : `
    尚无编译输出
    `; + return; + } + if (model.inspectorTab === "tests") { + ui.inspector.innerHTML = task.test_results?.length + ? task.test_results.map((test) => `
    ${escapeHtml(test.name)}${escapeHtml(test.summary)}
    `).join("") + : `
    测试尚未执行
    `; + return; + } + ui.inspector.innerHTML = task.file_changes?.length + ? task.file_changes.map((change) => `
    ${change.type === "added" ? "新增" : "修改"}
    ${escapeHtml(change.path)}${escapeHtml(change.summary)}
    `).join("") + : `
    尚无文件变更
    `; + } + + function renderArtifacts() { + const artifacts = model.snapshot.artifacts || []; + if (!artifacts.length) { + ui.artifacts.innerHTML = `
    尚未生成固件产物
    `; + return; + } + ui.artifacts.innerHTML = artifacts.map((artifact) => ` + +
    ${escapeHtml(artifact.software_name)}${escapeHtml(artifact.version)}
    +
    ${(artifact.capabilities || []).map((capability) => `${escapeHtml(capability)}`).join("")}
    + ${escapeHtml(formatDate(artifact.build_time))} +
    ${escapeHtml(artifact.firmware_filename)}${escapeHtml(formatSize(artifact.size_bytes))} · ${escapeHtml(artifact.sha256)}
    + ${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)} +
    + + + + + + + ${artifact.is_baseline ? `受保护` : ``} +
    + `).join(""); + } + + function render() { + const task = selectedTask(); + if (task) model.selectedTaskId = task.id; + renderPipeline(task); + renderRelease(task); + renderLogs(task); + renderInspector(task); + renderArtifacts(); + updateCapabilityPreview(task); + const active = task && ACTIVE_STATES.has(task.status); + ui.start.disabled = model.pending || Boolean(active); + ui.cancel.disabled = model.pending || !active; + ui.retry.disabled = model.pending || !task || !["failed", "canceled", "blocked"].includes(task.status); + ui.start.innerHTML = model.pending ? `正在提交` : `开始生成`; + refreshIcons(); + } + + async function refreshState({ quiet = false } = {}) { + try { + model.snapshot = await api("/api/firmware-workbench/state"); + if (!model.selectedTaskId && model.snapshot.tasks?.length) model.selectedTaskId = model.snapshot.tasks[0].id; + ui.server.classList.add("online"); + ui.server.innerHTML = `发布服务已连接`; + ui.serverName.textContent = model.snapshot.ota_server?.name || "车机升级服务器"; + render(); + } catch (error) { + ui.server.classList.remove("online"); + ui.server.innerHTML = `发布服务连接失败`; + if (!quiet) toast(error.message, "error"); + } finally { + schedulePoll(); + } + } + + function schedulePoll() { + window.clearTimeout(model.pollTimer); + const active = model.snapshot.tasks?.some((task) => ACTIVE_STATES.has(task.status)); + model.pollTimer = window.setTimeout(() => refreshState({ quiet: true }), active ? 700 : 5000); + } + + async function startTask() { + const instruction = ui.instruction.value.trim(); + if (!instruction) { toast("请输入开发指令。", "error"); ui.instruction.focus(); return; } + model.pending = true; + render(); + try { + const result = await api("/api/firmware-workbench/tasks", { + method: "POST", body: JSON.stringify({ instruction, baseline_id: ui.baseline.value }), + }); + model.selectedTaskId = result.task.id; + toast("开发任务已创建,流水线开始执行。", "success"); + await refreshState({ quiet: true }); + } catch (error) { + toast(error.message, "error"); + } finally { + model.pending = false; + render(); + } + } + + async function taskAction(action) { + const task = selectedTask(); + if (!task) return; + model.pending = true; + render(); + try { + await api(`/api/firmware-workbench/tasks/${encodeURIComponent(task.id)}/${action}`, { method: "POST", body: "{}" }); + toast(action === "cancel" ? "取消请求已提交。" : "任务已重新进入流水线。", "success"); + await refreshState({ quiet: true }); + } catch (error) { + toast(error.message, "error"); + } finally { + model.pending = false; + render(); + } + } + + function appendPrompt(text) { + const current = ui.instruction.value.trim(); + if (current.includes(text)) return; + ui.instruction.value = current ? `${current.replace(/[。;;]?$/, "")};${text}。` : `${text}。`; + ui.instruction.focus(); + updateCapabilityPreview(); + } + + function showModal(title, content) { + ui.modalTitle.textContent = title; + ui.modalBody.innerHTML = content; + ui.modal.hidden = false; + document.body.style.overflow = "hidden"; + refreshIcons(); + ui.modalClose.focus(); + } + + function closeModal() { + ui.modal.hidden = true; + document.body.style.overflow = ""; + } + + async function artifactAction(button) { + const artifact = model.snapshot.artifacts.find((item) => item.id === button.dataset.artifactId); + if (!artifact) return; + const action = button.dataset.artifactAction; + if (action === "delete") { + if (!window.confirm(`确认删除“${artifact.firmware_filename}”及其服务器发布记录吗?此操作不可恢复。`)) return; + button.disabled = true; + try { + const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}`, { method: "DELETE" }); + toast(result.message, "success"); + await refreshState({ quiet: true }); + } catch (error) { toast(error.message, "error"); } + finally { button.disabled = false; } + return; + } + if (action === "publish") { + button.disabled = true; + try { + const release = await api(`/api/firmware-workbench/artifacts/${artifact.id}/publish`, { method: "POST", body: "{}" }); + toast(`${release.message}(${release.id})`, "success"); + await refreshState({ quiet: true }); + } catch (error) { toast(error.message, "error"); } + finally { button.disabled = false; } + return; + } + if (action === "detail") { + showModal("固件产物详情", `
    +
    软件名称${escapeHtml(artifact.software_name)}
    +
    版本${escapeHtml(artifact.version)}
    +
    固件包${escapeHtml(artifact.firmware_filename)}
    +
    构建时间${escapeHtml(formatDate(artifact.build_time))}
    +
    固件包大小${escapeHtml(formatSize(artifact.size_bytes))}
    +
    发布状态${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}
    +
    SHA-256(固件包)${escapeHtml(artifact.sha256)}
    +
    SHA-256(HEX 镜像)${escapeHtml(artifact.image_sha256)}
    +
    `); + } else if (action === "capabilities") { + const diff = artifact.capability_diff || {}; + showModal("能力差异", `
    +

    固件基线

    ${(artifact.baseline_capabilities || []).map((item) => `

    • ${escapeHtml(item)}

    `).join("")}
    +

    目标固件变化

    ${(diff.added || []).map((item) => `

    + ${escapeHtml(item)}

    `).join("")}${(diff.retained || []).map((item) => `

    ✓ 保留${escapeHtml(item)}

    `).join("") || "

    无新增能力

    "}
    +
    `); + } else if (action === "code") { + showModal("代码差异", `
    ${escapeHtml(artifact.code_diff || "暂无代码差异记录")}
    `); + } else if (action === "validation") { + showModal("验证记录", (artifact.validation_records || []).map((test) => `
    ${escapeHtml(test.name)}${escapeHtml(test.summary)} · ${escapeHtml(formatDate(test.finished_at))}
    `).join("") || `
    暂无验证记录
    `); + } + } + + ui.start.addEventListener("click", startTask); + ui.instruction.addEventListener("input", () => updateCapabilityPreview()); + ui.baseline.addEventListener("change", () => updateCapabilityPreview()); + ui.cancel.addEventListener("click", () => taskAction("cancel")); + ui.retry.addEventListener("click", () => taskAction("retry")); + ui.scrollLogs.addEventListener("click", () => { ui.logs.scrollTop = ui.logs.scrollHeight; }); + document.querySelectorAll("[data-prompt]").forEach((button) => button.addEventListener("click", () => appendPrompt(button.dataset.prompt))); + document.querySelectorAll("[data-inspector-tab]").forEach((button) => button.addEventListener("click", () => { model.inspectorTab = button.dataset.inspectorTab; renderInspector(selectedTask()); refreshIcons(); })); + ui.artifacts.addEventListener("click", (event) => { + const button = event.target.closest("[data-artifact-action]"); + if (button) artifactAction(button); + }); + ui.modalClose.addEventListener("click", closeModal); + ui.modalCloseFooter.addEventListener("click", closeModal); + ui.modal.addEventListener("click", (event) => { if (event.target === ui.modal) closeModal(); }); + document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !ui.modal.hidden) closeModal(); }); + + renderPipeline(null); + renderRelease(null); + updateCapabilityPreview(); + refreshIcons(); + refreshState(); +})(); diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 1d9d711..38c2787 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -33,6 +33,7 @@ document.documentElement.style.colorScheme = theme; })(); + @@ -46,6 +47,7 @@ +
    diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index e864fb1..741c7d4 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"; +const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -23,6 +23,11 @@ const APP_SHELL = [ "/ui-consistency.css?v=spatial-ui-8", "/codepen-glass-theme.css?v=creative-cloud-6-performance", "/vue-ui.js?v=vue-presentation-3", + "/firmware-workbench-entry.css?v=1", + "/firmware-workbench-entry.js?v=2", + "/firmware-workbench.html", + "/firmware-workbench.css?v=5", + "/firmware-workbench.js?v=3", "/assets/pets/self-extension-cat.png?v=3", "/assets/pets/self-extension-cat-entrance.png?v=3", "/manifest.webmanifest", diff --git a/apps/web-console/vite.config.js b/apps/web-console/vite.config.js index 2eaf938..5f3a5bc 100644 --- a/apps/web-console/vite.config.js +++ b/apps/web-console/vite.config.js @@ -29,12 +29,17 @@ const runtimeFiles = [ "speech-input.js", "assistant-chat.js", "energy-monitor.js", + "firmware-workbench-entry.js", + "firmware-workbench.js", + "firmware-workbench.html", "styles.css", "console-v2.css", "self-extension.css", "speech-input.css", "assistant-chat.css", "energy-monitor.css", + "firmware-workbench-entry.css", + "firmware-workbench.css", "ui-consistency.css", "codepen-glass-theme.css", "vue-ui.js" diff --git a/config/modes/vehicles/denza_n7/leisure_lounger.yaml b/config/modes/vehicles/denza_n7/leisure_lounger.yaml index a4079e2..5e45c2e 100644 --- a/config/modes/vehicles/denza_n7/leisure_lounger.yaml +++ b/config/modes/vehicles/denza_n7/leisure_lounger.yaml @@ -2,12 +2,13 @@ id: leisure_lounger name: 休闲躺椅模式 aliases: - 休闲躺椅 -description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。 +description: 腾势 N7 主驾放松按摩与副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力;驻车使用,不改变主驾驾驶姿态。 icon: sofa scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已接入的主驾放松按摩,让主副驾按各自能力联动休闲;主驾没有绝对位置反馈,不自动移动其座椅。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/modes/vehicles/denza_n7/meditation_space.yaml b/config/modes/vehicles/denza_n7/meditation_space.yaml index b1d4eb5..1bbc62c 100644 --- a/config/modes/vehicles/denza_n7/meditation_space.yaml +++ b/config/modes/vehicles/denza_n7/meditation_space.yaml @@ -3,12 +3,13 @@ name: 冥想空间模式 aliases: - 冥想空间 - 冥想 -description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。 +description: 腾势 N7 主驾放松按摩与副驾冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力;驻车使用,不改变主驾驾驶姿态。 icon: leaf scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用当前工程已接入的主驾放松按摩辅助驻车冥想;不假定副驾具有按摩能力,不移动主驾座椅。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/modes/vehicles/denza_n7/nap.yaml b/config/modes/vehicles/denza_n7/nap.yaml index f21b476..4e4de9f 100644 --- a/config/modes/vehicles/denza_n7/nap.yaml +++ b/config/modes/vehicles/denza_n7/nap.yaml @@ -2,12 +2,13 @@ id: nap name: 小憩模式 aliases: - 小憩 -description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。 +description: 腾势 N7 主驾低档座椅通风与副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力;驻车使用,不改变主驾驾驶姿态。 icon: moon scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾使用低档座椅通风保持驻车静坐舒适,不自动移动主驾或开启按摩。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/modes/vehicles/denza_n7/office.yaml b/config/modes/vehicles/denza_n7/office.yaml index b492850..422be86 100644 --- a/config/modes/vehicles/denza_n7/office.yaml +++ b/config/modes/vehicles/denza_n7/office.yaml @@ -2,12 +2,14 @@ id: office name: 办公模式 aliases: - 办公 -description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。 +description: 腾势 N7 主副驾低档座椅通风与副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力;不改变主驾驾驶姿态。 icon: briefcase scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 低档通风,改善驻车办公时久坐闷热;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口低档通风,与办公支撑姿态联动。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/scene_templates/boarding_warmup.yaml b/config/scene_templates/boarding_warmup.yaml new file mode 100644 index 0000000..a30fcc6 --- /dev/null +++ b/config/scene_templates/boarding_warmup.yaml @@ -0,0 +1,26 @@ +id: boarding-warmup +name: 上车预热模式 +aliases: [上车预热的功能模式, 上车预热功能模式, 上车暖舱模式, 分阶段预热模式, 动态预热模式, 空调预热模式] +description: 分阶段上车预热;前2分钟以28℃和4档风量快速暖舱,再用24℃和1档风量舒适保持5分钟,并分别编排主副驾加热。总计7分钟,不移动座椅位置;时间轴结束后保持最后设置,不承诺无人值守预约启动。 +icon: thermometer +schedule: + schema: cognitive-agent-mode-schedule/v1 + repeat_count: 1 + phases: + - id: warmup + name: 快速暖舱 + duration_ms: 120000 + operations: + - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: 'on'}, reason: 开启空调。} + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 28}, reason: 初始预热目标。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 4}, reason: 初始阶段较高风量。} + - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾低档加热接口2为低档。} + - {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 2}, reason: 副驾使用独立接口2档加热。} + - id: comfort + name: 舒适恒温 + duration_ms: 300000 + operations: + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 预热结束后降低目标温度。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪。} + - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾维持低档。} + - {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 1}, reason: 副驾降低为1档。} diff --git a/config/scene_templates/camping.yaml b/config/scene_templates/camping.yaml index fcbcdd3..a11d09f 100644 --- a/config/scene_templates/camping.yaml +++ b/config/scene_templates/camping.yaml @@ -1,9 +1,13 @@ id: camping name: 露营模式 aliases: [户外露营模式, 营地模式] -description: 使用低风外循环、打开遮阳帘并启用露营氛围;不自动开窗。 +description: 为驻车露营组合主副驾低档通风、副驾靠背和腿托支撑、低风外循环、开阔天幕与露营氛围;不移动主驾座椅,不自动开窗。 icon: tent-tree operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风,减少驻车久坐闷热。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车露营提供适度舒展的副驾靠背支撑。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 使用适度副驾腿托支撑,不采用极限躺平姿态。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持座舱温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。} diff --git a/config/scene_templates/cinema.yaml b/config/scene_templates/cinema.yaml index 565f709..6c2e74c 100644 --- a/config/scene_templates/cinema.yaml +++ b/config/scene_templates/cinema.yaml @@ -1,9 +1,13 @@ id: cinema name: 观影模式 aliases: [影院模式, 家庭影院模式, 电影模式, 看电影模式] -description: 关闭直射光和车内主照明,使用低风恒温与影院氛围灯;不包含屏幕或音响控制。 +description: 为驻车观影组合主驾放松按摩、副驾舒展靠背和腿托、低档通风、遮光与低风恒温;不改变主驾驾驶姿态,不包含屏幕或音响控制。 icon: film operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已接入的放松按摩,缓解驻车观影时的久坐不适;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风减少久坐闷热。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 为驻车观影提供适度后仰的副驾靠背支撑。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车观影提供副驾腿部支撑。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 维持观影期间的座舱恒温。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用低风量降低噪声。} diff --git a/config/scene_templates/lay_flat.yaml b/config/scene_templates/lay_flat.yaml new file mode 100644 index 0000000..d761866 --- /dev/null +++ b/config/scene_templates/lay_flat.yaml @@ -0,0 +1,19 @@ +id: lay_flat +name: 躺平模式 +aliases: + - 躺平休息模式 + - 平躺休息模式 +description: 驻车休息预览:默认仅联动副驾靠背与腿托,配合恒温、柔和氛围光、关闭阅读灯与四扇车窗;指定主驾时仅使用其已接入的按摩能力,不调整驾驶姿态。所有动作需确认后保存或试运行。 +icon: armchair +operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾只使用已接入的舒适按摩,不改变驾驶姿态。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 66}, reason: 为驻车躺靠休息提供副驾靠背支撑,不使用机械极限位置。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 76}, reason: 副驾腿托支撑腿部,具体姿态可在预览后调整。} + - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 休息期间保持座舱恒温。} + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 提供可调整的中性舒适温度。} + - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFB347', brightness_pct: 20}, reason: 使用低亮暖色氛围光。} + - {domain: interior_light, capability: interior_light.set_reading_power, target_scope: all, operation: set, parameters: {state: "off"}, reason: 关闭阅读灯,减少躺靠休息时的直射光。} + - {domain: window, capability: window.set_position, target_scope: front_left, operation: set, parameters: {position_pct: 0}, reason: 关闭左前车窗,降低外部噪声。} + - {domain: window, capability: window.set_position, target_scope: front_right, operation: set, parameters: {position_pct: 0}, reason: 关闭右前车窗,降低外部噪声。} + - {domain: window, capability: window.set_position, target_scope: rear_left, operation: set, parameters: {position_pct: 0}, reason: 关闭左后车窗,降低外部噪声。} + - {domain: window, capability: window.set_position, target_scope: rear_right, operation: set, parameters: {position_pct: 0}, reason: 关闭右后车窗,降低外部噪声。} diff --git a/config/scene_templates/long_trip_comfort.yaml b/config/scene_templates/long_trip_comfort.yaml index b5457d5..2908fde 100644 --- a/config/scene_templates/long_trip_comfort.yaml +++ b/config/scene_templates/long_trip_comfort.yaml @@ -1,9 +1,11 @@ id: long-trip-comfort name: 长途舒适模式 aliases: [长途乘车模式, 高速舒适模式] -description: 为长途乘坐提供副驾恒温、柔和风量、外循环与适度遮光。 +description: 为长途乘坐提供主副驾低档座椅通风、副驾恒温、柔和风量、外循环与适度遮光,不改变座椅驾驶姿态。 icon: route operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风;该接口 1 为低档。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_temperature, target_scope: passenger, operation: set, parameters: {temperature_c: 24}, reason: 保持副驾舒适温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。} diff --git a/config/scene_templates/quiet_night.yaml b/config/scene_templates/quiet_night.yaml index bfb3af1..e88b24c 100644 --- a/config/scene_templates/quiet_night.yaml +++ b/config/scene_templates/quiet_night.yaml @@ -3,9 +3,11 @@ name: 夜间静享模式 aliases: - 夜间模式 - 静夜模式 -description: 使用低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静。 +description: 使用主副驾低档座椅通风、低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静;不改变座椅姿态。 icon: moon-star operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风,降低夜间久坐闷热且不启动姿态调整或按摩。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,保持座椅当前姿态。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围灯。} - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#102040', brightness_pct: 10}, reason: 使用低亮深蓝氛围。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。} diff --git a/config/scene_templates/reading.yaml b/config/scene_templates/reading.yaml index 50b10a5..c511090 100644 --- a/config/scene_templates/reading.yaml +++ b/config/scene_templates/reading.yaml @@ -4,9 +4,13 @@ aliases: - 阅读空间模式 - 看书模式 - 读书模式 -description: 为座舱阅读提供暖色照明、低风恒温和适度遮光。 +description: 为驻车阅读提供主副驾低档座椅通风、副驾靠背与腿托支撑、暖色照明、低风恒温和适度遮光;不改变主驾驾驶姿态。 icon: book-open operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾采用已接入原生 CAN 的低档通风,减少久坐闷热;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用独立接口的低档通风。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 22}, reason: 使用偏直立的副驾阅读靠背支撑,避免大幅躺倒。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 21}, reason: 副驾腿托适度支撑腿部,供驻车阅读预览使用。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持阅读期间的座舱恒温。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。} diff --git a/config/scene_templates/reception.yaml b/config/scene_templates/reception.yaml index e322a1c..74aaeaa 100644 --- a/config/scene_templates/reception.yaml +++ b/config/scene_templates/reception.yaml @@ -1,9 +1,11 @@ id: reception name: 接待模式 aliases: [贵宾模式, 迎宾接待模式] -description: 使用柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境。 +description: 使用主副驾低档座椅通风、柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境;不自动移动有人入座的座椅。 icon: handshake operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风营造舒适接待环境,保持当前座椅位置。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,不在迎宾入座时自动调整姿态。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。} - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFE2B8', brightness_pct: 50}, reason: 使用柔和暖色氛围光。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启室内主照明。} diff --git a/config/scene_templates/relaxation.yaml b/config/scene_templates/relaxation.yaml index a9d8914..286edbd 100644 --- a/config/scene_templates/relaxation.yaml +++ b/config/scene_templates/relaxation.yaml @@ -1,9 +1,10 @@ id: relaxation name: 放松模式 -aliases: [休闲模式, 舒缓模式, 轻松模式, 放空模式] -description: 使用舒展的副驾姿态、安静恒温、适度遮光和低亮暖光,营造适合放松休息的座舱环境。 +aliases: [休闲模式, 舒缓模式, 轻松模式, 放空模式, 坐着放松模式, 乘坐放松模式, 座椅放松模式, 座位放松模式] +description: 使用主驾放松按摩、舒展的副驾姿态、安静恒温、适度遮光和低亮暖光,营造适合驻车放松休息的座舱环境;不改变主驾驾驶姿态。 icon: sofa operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已验证的主驾放松按摩模式;主驾没有绝对位置反馈,不自动移动主驾座椅。} - {domain: seat, capability: seat.set_longitudinal_position, target_scope: passenger, operation: set, parameters: {position_pct: 51}, reason: 将副驾移动到兼顾腿部空间与安全的休闲位置。} - {domain: seat, capability: seat.set_cushion_tilt, target_scope: passenger, operation: set, parameters: {position_pct: 30}, reason: 调整坐盆角度以提供稳定支撑。} - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 66}, reason: 将副驾靠背调整到舒展的休闲角度。} diff --git a/config/scene_templates/romantic.yaml b/config/scene_templates/romantic.yaml index f9f56df..5a5658c 100644 --- a/config/scene_templates/romantic.yaml +++ b/config/scene_templates/romantic.yaml @@ -1,9 +1,11 @@ id: romantic name: 浪漫模式 aliases: [约会模式, 浪漫氛围模式] -description: 使用浪漫氛围灯并关闭车内主照明与阅读灯,同时关闭遮阳帘。 +description: 使用主副驾低档座椅通风与浪漫氛围灯,关闭车内主照明、阅读灯和遮阳帘;保持当前座椅姿态。 icon: heart operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风减少久坐闷热,不调整驾驶姿态。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风,保持用户当前的座椅姿态。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。} - {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: romantic}, reason: 使用工程已接入的浪漫氛围。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。} diff --git a/config/scene_templates/seat_comfort.yaml b/config/scene_templates/seat_comfort.yaml new file mode 100644 index 0000000..a2e45e0 --- /dev/null +++ b/config/scene_templates/seat_comfort.yaml @@ -0,0 +1,14 @@ +id: seat-comfort +name: 座椅舒适模式 +aliases: [座位舒适模式, 乘坐舒适模式, 坐着舒服模式, 坐着舒适模式, 座椅舒服模式, 座位舒服模式] +description: 为主副驾组合低档通风、主驾放松按摩和副驾适度靠背及腿托支撑;按两座实际能力分别编排,主驾没有绝对位置反馈,不自动移动主驾座椅。适合驻车调整并预览后使用。 +icon: armchair +operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾通风使用独立 CAN 接口的低档 2,减少背部闷热。} + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已验证的放松按摩模式,不假定副驾具有按摩能力。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾通风使用独立接口的低档 1。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 副驾靠背使用可反馈的适度舒展目标。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 副驾腿托提供适度腿部支撑。} + - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启座舱恒温。} + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 保持中性舒适温度。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪和直吹打扰。} diff --git a/config/scene_templates/seat_dynamic.yaml b/config/scene_templates/seat_dynamic.yaml new file mode 100644 index 0000000..0be451c --- /dev/null +++ b/config/scene_templates/seat_dynamic.yaml @@ -0,0 +1,23 @@ +id: seat-dynamic +name: 座椅动态舒缓模式 +aliases: [座椅动态模式, 座椅的动态模式, 动态座椅模式, 座椅循环模式, 座椅交替放松模式, 动态放松模式] +description: 驻车使用的有界双座舒缓时间轴;主驾在放松和舒缓按摩间切换,副驾靠背与腿托小幅交替,30秒一个阶段、重复5次,总计5分钟。不自动移动主驾位置;停止后不再进入后续阶段。 +icon: activity +schedule: + schema: cognitive-agent-mode-schedule/v1 + repeat_count: 5 + phases: + - id: relax + name: 舒展支撑 + duration_ms: 30000 + operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾使用原生放松按摩,不改变驾驶姿态。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 驻车时副驾适度舒展。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 25}, reason: 适度支撑腿部。} + - id: support + name: 轻柔回落 + duration_ms: 30000 + operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: slack}, reason: 主驾切换舒缓按摩。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 40}, reason: 副驾小幅回到支撑姿态。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 20}, reason: 腿托小幅回落,不使用无界轴运动。} diff --git a/config/scene_templates/summer_cooling.yaml b/config/scene_templates/summer_cooling.yaml index ca1f60f..5324658 100644 --- a/config/scene_templates/summer_cooling.yaml +++ b/config/scene_templates/summer_cooling.yaml @@ -1,9 +1,10 @@ id: summer-cooling name: 夏季清凉模式 aliases: [清凉模式, 酷夏模式] -description: 使用内循环、适中冷风、关闭遮阳帘并开启当前已接入的副驾座椅通风。 +description: 使用内循环、适中冷风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅通风;不改变座椅姿态。 icon: snowflake operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档,不与副驾档位混用。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 23}, reason: 设置清凉但不过低的温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 3}, reason: 使用适中风量快速降温。} diff --git a/config/scene_templates/winter_warmth.yaml b/config/scene_templates/winter_warmth.yaml index f7e0472..df9dfef 100644 --- a/config/scene_templates/winter_warmth.yaml +++ b/config/scene_templates/winter_warmth.yaml @@ -1,9 +1,10 @@ id: winter-warmth name: 冬季温暖模式 aliases: [暖冬模式, 冬日模式] -description: 使用内循环、暖风、关闭遮阳帘并开启当前已接入的副驾座椅加热。 +description: 使用内循环、暖风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅加热;不改变座椅姿态。 icon: flame operations: + - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用已接入的主驾原生 CAN 低档加热;该接口 2 为低档,不与副驾档位混用。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 26}, reason: 设置温暖舒适温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。} diff --git a/docs/mode-schedules.md b/docs/mode-schedules.md new file mode 100644 index 0000000..2fad154 --- /dev/null +++ b/docs/mode-schedules.md @@ -0,0 +1,46 @@ +# 功能模式的座椅联动与时间轴 + +## 行为 + +新建或重新构建功能模式时,未指定座位默认只编排副驾座椅;明确“主驾”时只编排主驾座椅;只有明确要求主驾和副驾时才组合双座。该规则同时覆盖已登记模板、工程预设复制、未登记模型组合,以及时间轴每一个阶段。其他设备的独立区域不改变座椅选择,例如“主驾空调”并不代表选择主驾座椅。 + +模板保留主副驾能力作为可选来源。构建时服务端先按所选座位投影,再逐动作校验,不能把副驾靠背、腿托的位置能力重标为主驾。模型规划的能力目录也按所选座位收窄,越界方案整体拒绝。已经保存的用户模式及其后续参数编辑仍以原有配置为准,不因本次默认规则变更静默删除座椅动作。 + +常用阅读、观影、露营、夜间静享、接待、浪漫、冬季温暖、夏季清凉模板,以及办公、小憩、冥想、休闲躺椅预设,包含本工程支持的主驾和副驾舒适动作。主驾优先使用加热、通风、按摩,不凭空增加没有协议支持的位置动作。除明确座椅场景外,不随意改变主驾坐姿。 + +已有用户保存的配置优先,不被模板升级静默覆盖。要采用新版组合,可删除不再需要的旧自定义副本后重新构建,或明确修改已有模式。除霜、新风等专项模式不强行增加无关的座椅动作。 + +“构建一个座椅的动态模式”和“构建一个上车预热的功能模式”命中本地模板,无需模型推理。其他带时间变化的需求由结构化场景编排产生分阶段计划,每个阶段再次经过能力、参数、语义、安全和 CAN 编译校验;任意阶段失败则整份草稿拒绝,不生成静态替代品。 + +## 模板默认值 + +- 座椅动态舒缓:30 秒舒缓、30 秒支撑,重复 5 次,总计 5 分钟。默认副驾靠背与腿托小幅改变;明确主驾时改用主驾支持的按摩切换,不移动主驾姿态。仅应在车辆安全停稳后使用。 +- 上车预热:升温阶段 2 分钟,空调 28℃、风量 4;舒适保持阶段 5 分钟,空调 24℃、风量 1,同时编排所选座位的加热(默认副驾)。总计 7 分钟。 + +预览卡片展示每阶段的目标与时长,默认值属于可审核的建议。生成和试运行都不下发实际车辆控制;保存后进入模式才开始计时。“上车预热”目前是模式名称,不表示已接入自动开门触发。 + +## 存储与执行 + +`cognitive-agent-mode-schedule/v1` 保存 `repeat_count` 与有序 `phases`。每阶段有唯一 ID、名称、`duration_ms` 和绝对目标 `operations`。同一能力在不同阶段的不同值不能合并或去重。 + +约束:2–8 阶段;每阶段 1–12 项动作,1 秒–15 分钟;1–20 轮,含重复的总时长不超过 1 小时。禁止无限循环、相对累加动作和未经校验的底层指令。 + +真实执行由服务端计时。每阶段重新编译并检查权限/硬件租约,任意阶段失败即停止后续阶段。同车只运行一条时间轴,新时间轴替换旧时间轴;同车阶段回调互斥。不同车辆独立。 + +输入“停止动态模式”可取消后续阶段。已下发的目标不自动回滚,当前已进入执行器的操作可能继续完成。模拟停止不会改变真实任务。服务关闭时取消时间轴,重启不会自动续跑;网页关闭不会隐式修改服务端已启动任务。 + +`GET /api/modes/schedule/status?vehicle_id=denza_n7` 只读返回运行 ID、会话、阶段和终态,不暴露底层指令或 CAN 数据。助手区分已启动、模拟完成、真实完成、失败和取消,不把刚启动显示为全部完成。 + +## 验证 + +核心时钟/并发/取消:`tests/unit/test_mode_schedule.py`。 + +真实能力链创建、试运行、保存、重载、执行与防篡改:`tests/integration/test_mode_schedule_flow.py`。 + +座椅模板覆盖:`tests/integration/test_registered_scene_seat_coverage.py`。 + +新建座位选择、模型目录约束、非座椅分区隔离、保存绑定及越界拒绝:`tests/integration/test_mode_creation_seat_policy.py`。词汇与路由:`tests/unit/test_mode_creation_seat_policy.py`、`tests/unit/test_mode_creation_seat_routing.py`。 + +时间轴卡片与状态轮询:`tests/unit/mode_schedule_frontend.test.cjs`;隔离服务的真实浏览器:`tests/e2e/test_mode_schedule_browser.py`。 + +自动化测试使用模拟执行、临时用户目录并阻断硬件访问,不代表完成实车 CAN 验收。 diff --git a/domains/seat/tests/integration/test_seat_service.py b/domains/seat/tests/integration/test_seat_service.py index 80bab6c..d4c0b17 100755 --- a/domains/seat/tests/integration/test_seat_service.py +++ b/domains/seat/tests/integration/test_seat_service.py @@ -305,8 +305,20 @@ class SeatServiceIntegrationTests(unittest.TestCase): ) interpret.assert_called_once_with( - "播放音乐", context, self.service.capability_context + "播放音乐", + { + **context, + "natural_language_routing": { + "original_utterance": "空调温度设置为25度,播放音乐", + "parsed_clauses": ["空调温度设置为25度"], + "resolved_clauses": ["空调温度设置为25度"], + "unresolved_clauses": ["播放音乐"], + "cloud_input_utterance": "播放音乐", + }, + }, + self.service.capability_context, ) + self.assertNotIn("natural_language_routing", context) self.assertEqual( provider, f"local_rules+openai:{self.service.openai.model}" ) diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/main_cm0plus.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/main_cm0plus.c new file mode 100644 index 0000000..f79c233 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/main_cm0plus.c @@ -0,0 +1,79 @@ +/***************************************************************************//** +* \file main_cm0plus.c +* +* CYT4BFBCJE rev_d CM0+ entry point for the passenger-seat timed-only ECU. +*******************************************************************************/ + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "bsp_seat_backrest_heater.h" +#include "bsp_seat_fan.h" +#include "motor_control.h" +#include "safety_manager.h" +#include "seat_move_config.h" +#include "seat_timed_application.h" + +volatile uint32_t g_seatTimedResetReason = 0u; +volatile uint32_t g_seatTimedInitResult = MOTOR_API_FAILURE; + +static void SeatTimed_HoldSafeOff(void) +{ + for(;;) + { + Motor_ForceSafeOff(); + (void)BspSeatBackrestHeater_ForceOff(); + (void)BspSeatFan_ForceOff(); + SafetyManager_KickWatchdog(); + Cy_SysTick_DelayInUs(SEAT_MOVE_TASK_PERIOD_US); + } +} + +int main(void) +{ + (void)BspSeatBackrestHeater_ForceOff(); + (void)BspSeatFan_ForceOff(); + SystemInit(); + + Motor_ForceSafeOff(); + if(!BspSeatBackrestHeater_ForceOff() || !BspSeatFan_ForceOff()) + { + SeatTimed_HoldSafeOff(); + } + + g_seatTimedResetReason = Cy_SysReset_GetResetReason(); + Cy_SysReset_ClearAllResetReasons(); + SafetyManager_Init(g_seatTimedResetReason); + if(SafetyManager_IsInhibited()) + { + SeatTimed_HoldSafeOff(); + } + + __enable_irq(); + g_seatTimedInitResult = Motor_Init(); + if(g_seatTimedInitResult != MOTOR_API_SUCCESS) + { + SafetyManager_LatchFault(SAFETY_FAULT_DRIVER); + SeatTimed_HoldSafeOff(); + } + + Motor_SetWatchdogServiceEnabled(true); + if(!SeatTimedApplication_Init()) + { + SeatTimed_HoldSafeOff(); + } + + for(;;) + { + SeatTimedApplication_Service(); + if(SafetyManager_IsInhibited()) + { + Motor_ForceSafeOff(); + (void)BspSeatBackrestHeater_ForceOff(); + (void)BspSeatFan_ForceOff(); + } + SafetyManager_KickWatchdog(); + Cy_SysTick_DelayInUs(SEAT_MOVE_TASK_PERIOD_US); + } +} + +/* [] END OF FILE */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/safety_manager.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/safety_manager.c new file mode 100644 index 0000000..52a14d4 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/safety_manager.c @@ -0,0 +1,332 @@ +#include "safety_manager.h" + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "motor_control.h" +#include "seat_motor_manager.h" +#include "seat_move_config.h" + +#if defined(CoreDebug) && defined(CoreDebug_DHCSR_C_DEBUGEN_Msk) +#define SAFETY_DEBUG_DETACH_SUPPORTED (1u) +#else +#define SAFETY_DEBUG_DETACH_SUPPORTED (0u) +#endif + +static volatile safety_fault_t latchedFault = SAFETY_FAULT_NONE; +static bool watchdogReset = false; +static bool watchdogInitialized = false; +static bool hilWatchdogResetArmed = false; +static bool hilWatchdogRecovered = false; +#if (SAFETY_DEBUG_DETACH_SUPPORTED == 1u) +static bool debuggerWasAttached = false; +#endif + +static bool SafetyManager_DebuggerAttached(void) +{ +#if (SAFETY_DEBUG_DETACH_SUPPORTED == 1u) + return ((CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk) != 0ul); +#else + return false; +#endif +} + +static uint32_t SafetyManager_ReadDebugMarker(void) +{ + return BACKUP->unBREG[SEAT_MOVE_DEBUG_SESSION_BREG_INDEX].u32Register; +} + +static bool SafetyManager_WriteDebugMarker(uint32_t marker) +{ + BACKUP->unBREG[SEAT_MOVE_DEBUG_SESSION_BREG_INDEX].u32Register = marker; + __DSB(); + return (SafetyManager_ReadDebugMarker() == marker); +} + +static bool SafetyManager_WaitForWatchdogState(bool enabled) +{ + uint32_t remaining = 200ul; + uint32_t expected = enabled ? 1ul : 0ul; + + /* WDT ENABLE is an asynchronous request bit; ENABLED is the synchronized + * LF-clock state. Configuration writes made before disable completes are + * ignored by the hardware. */ + while((WDT->unCTL.stcField.u1ENABLED != expected) && (remaining > 0ul)) + { + Cy_SysTick_DelayInUs(1u); + remaining--; + } + return (WDT->unCTL.stcField.u1ENABLED == expected); +} + +static void SafetyManager_ClearHilWatchdogCredential(void) +{ + BACKUP->unBREG[SEAT_MOVE_HIL_WDT_MAGIC_BREG_INDEX].u32Register = 0u; + BACKUP->unBREG[SEAT_MOVE_HIL_WDT_TOKEN_BREG_INDEX].u32Register = 0u; + BACKUP->unBREG[SEAT_MOVE_HIL_WDT_CHECK_BREG_INDEX].u32Register = 0u; + __DSB(); +} + +void SafetyManager_Init(uint32_t resetReason) +{ + const uint32_t watchdogMask = CY_SYSRESET_WDT | + CY_SYSRESET_MCWDT0 | + CY_SYSRESET_MCWDT1 | + CY_SYSRESET_MCWDT2 | + CY_SYSRESET_MCWDT3; + bool watchdogStopped; + bool debuggerAttached; + uint32_t marker; + uint32_t hilMagic; + uint32_t hilToken; + uint32_t hilCheck; + bool authorizedHilReset; + bool markerOk = true; + + /* A watchdog instance is retained across its own reset on this device. + * Stop it before reading credentials or initializing peripherals; the + * 100 ms bench timeout otherwise permits a reset loop. */ + Cy_WDT_Disable(); + watchdogStopped = SafetyManager_WaitForWatchdogState(false); + + debuggerAttached = SafetyManager_DebuggerAttached(); + marker = SafetyManager_ReadDebugMarker(); + hilMagic = BACKUP->unBREG[ + SEAT_MOVE_HIL_WDT_MAGIC_BREG_INDEX].u32Register; + hilToken = BACKUP->unBREG[ + SEAT_MOVE_HIL_WDT_TOKEN_BREG_INDEX].u32Register; + hilCheck = BACKUP->unBREG[ + SEAT_MOVE_HIL_WDT_CHECK_BREG_INDEX].u32Register; + authorizedHilReset = + (hilMagic == SEAT_MOVE_HIL_WDT_MARKER) && + (hilCheck == (hilMagic ^ hilToken ^ 0xFFFFFFFFu)) && + (((resetReason & watchdogMask) != 0ul) || + ((resetReason & CY_SYSRESET_SOFT) != 0ul)); + + latchedFault = SAFETY_FAULT_NONE; + watchdogInitialized = false; + hilWatchdogResetArmed = false; + watchdogReset = ((resetReason & watchdogMask) != 0ul) || + authorizedHilReset; + hilWatchdogRecovered = authorizedHilReset; + if((resetReason & CY_SYSRESET_PORVDDD) != 0ul) + { + markerOk = SafetyManager_WriteDebugMarker(0u); + marker = SafetyManager_ReadDebugMarker(); + SafetyManager_ClearHilWatchdogCredential(); + hilWatchdogRecovered = false; + } + else if(hilWatchdogRecovered) + { + /* Consume before CAN comes online: transport retries cannot turn one + * authenticated reset into a second physical reset. */ + SafetyManager_ClearHilWatchdogCredential(); + } + else if(hilMagic == SEAT_MOVE_HIL_WDT_MARKER) + { + /* XRES/programming without a valid reset reason is not completion. */ + SafetyManager_ClearHilWatchdogCredential(); + } +#if (SAFETY_DEBUG_DETACH_SUPPORTED == 1u) + debuggerWasAttached = debuggerAttached; +#endif + + if(!markerOk || (marker == SEAT_MOVE_DEBUG_EXIT_MAGIC) || + ((marker == SEAT_MOVE_DEBUG_ACTIVE_MAGIC) && !debuggerAttached)) + { + latchedFault = SAFETY_FAULT_DEBUG_DETACH; + } + else if(watchdogReset && !hilWatchdogRecovered) + { + latchedFault = SAFETY_FAULT_BOOT_RECOVERY; + } + else if(debuggerAttached && + !SafetyManager_WriteDebugMarker(SEAT_MOVE_DEBUG_ACTIVE_MAGIC)) + { + latchedFault = SAFETY_FAULT_DEBUG_DETACH; + } + + /* The system-WDT configuration register is retained across some reset + * paths and AUTO_SERVICE is not reset by Cy_WDT_Init(). If that bit is + * left enabled, stopping the two software service paths can never prove a + * real watchdog reset because hardware services the counter at WARN_LIMIT. + * Configure only while stopped, and make software the sole WDT owner. */ + if(!watchdogStopped) + { + latchedFault = SAFETY_FAULT_DRIVER; + return; + } + Cy_WDT_Init(); + Cy_WDT_Unlock(); + Cy_WDT_SetUpperLimit(SEAT_MOVE_WDT_TIMEOUT_TICKS); + Cy_WDT_SetUpperAction(CY_WDT_LOW_UPP_ACTION_RESET); + Cy_WDT_SetWarnAction(CY_WDT_WARN_ACTION_NONE); + Cy_WDT_SetAutoService(CY_WDT_DISABLE); + Cy_WDT_SetDebugRun(CY_WDT_ENABLE); + Cy_WDT_Lock(); + if((WDT->unUPPER_LIMIT.stcField.u32UPPER_LIMIT != + SEAT_MOVE_WDT_TIMEOUT_TICKS) || + (WDT->unCONFIG.stcField.u1UPPER_ACTION != + (uint32_t)CY_WDT_LOW_UPP_ACTION_RESET) || + (WDT->unCONFIG.stcField.u1AUTO_SERVICE != + (uint32_t)CY_WDT_DISABLE)) + { + latchedFault = SAFETY_FAULT_DRIVER; + return; + } + Cy_WDT_Enable(); + if(!SafetyManager_WaitForWatchdogState(true)) + { + latchedFault = SAFETY_FAULT_DRIVER; + return; + } + Cy_WDT_ClearWatchdog(); + Cy_SysTick_DelayInUs(1000u); + if(Cy_WDT_GetCount() == 0ul) + { + latchedFault = SAFETY_FAULT_DRIVER; + return; + } + watchdogInitialized = true; + SafetyManager_KickWatchdog(); +} + +void SafetyManager_KickWatchdog(void) +{ + if((false != watchdogInitialized) && !hilWatchdogResetArmed) + { + Cy_WDT_ClearWatchdog(); + } +} + +bool SafetyManager_IsWatchdogInitialized(void) +{ + return watchdogInitialized; +} + +void SafetyManager_LatchFault(safety_fault_t fault) +{ + if((fault != SAFETY_FAULT_NONE) && + (latchedFault == SAFETY_FAULT_NONE)) + { + latchedFault = fault; + Motor_LatchSafetyInhibitFromIsr(0u); + Motor_StopAll(); + SeatMotorManager_StopAll(0ul); + } +} + +void SafetyManager_Service(void) +{ + bool debuggerAttached = SafetyManager_DebuggerAttached(); + uint32_t marker = SafetyManager_ReadDebugMarker(); + + if(marker == SEAT_MOVE_DEBUG_EXIT_MAGIC) + { + SafetyManager_LatchFault(SAFETY_FAULT_DEBUG_DETACH); + } + + if(Motor_IsDriverFaultLatched()) + { + SafetyManager_LatchFault(SAFETY_FAULT_DRIVER); + } + else + { + /* No newly observed driver fault. */ + } + +#if (SAFETY_DEBUG_DETACH_SUPPORTED == 1u) + if(debuggerAttached) + { + if((marker != SEAT_MOVE_DEBUG_ACTIVE_MAGIC) && + !SafetyManager_WriteDebugMarker(SEAT_MOVE_DEBUG_ACTIVE_MAGIC)) + { + SafetyManager_LatchFault(SAFETY_FAULT_DEBUG_DETACH); + } + debuggerWasAttached = true; + } + else if(debuggerWasAttached) + { + SafetyManager_LatchFault(SAFETY_FAULT_DEBUG_DETACH); + debuggerWasAttached = false; + } +#else + (void)debuggerAttached; +#endif + + SafetyManager_KickWatchdog(); +} + +bool SafetyManager_IsInhibited(void) +{ + return (latchedFault != SAFETY_FAULT_NONE); +} + +safety_fault_t SafetyManager_GetFault(void) +{ + return latchedFault; +} + +bool SafetyManager_WasWatchdogReset(void) +{ + return watchdogReset; +} + +bool SafetyManager_TriggerHilWatchdogReset(uint16_t token) +{ + uint32_t check; + + if(!watchdogInitialized || hilWatchdogResetArmed) + { + return false; + } + BACKUP->unBREG[SEAT_MOVE_HIL_WDT_MAGIC_BREG_INDEX].u32Register = + SEAT_MOVE_HIL_WDT_MARKER; + BACKUP->unBREG[SEAT_MOVE_HIL_WDT_TOKEN_BREG_INDEX].u32Register = token; + check = SEAT_MOVE_HIL_WDT_MARKER ^ (uint32_t)token ^ 0xFFFFFFFFu; + BACKUP->unBREG[SEAT_MOVE_HIL_WDT_CHECK_BREG_INDEX].u32Register = check; + __DSB(); + if((BACKUP->unBREG[ + SEAT_MOVE_HIL_WDT_MAGIC_BREG_INDEX].u32Register != + SEAT_MOVE_HIL_WDT_MARKER) || + (BACKUP->unBREG[ + SEAT_MOVE_HIL_WDT_CHECK_BREG_INDEX].u32Register != check)) + { + SafetyManager_ClearHilWatchdogCredential(); + return false; + } + /* Long-running motor calibration/coast loops normally service WDT + * directly so they remain safe even while the application loop is busy. + * The armed HIL case must suspend both service paths or a real watchdog + * reset can never occur. Startup re-enables this after the reset. */ + Motor_SetWatchdogServiceEnabled(false); + Cy_WDT_ClearWatchdog(); + hilWatchdogResetArmed = true; + return true; +} + +void SafetyManager_CommitHilWatchdogReset(void) +{ + if(!hilWatchdogResetArmed) + { + return; + } + + /* This bench's connected CMSIS-DAP can suppress the WDT reset output even + * after C-SPY detaches. First prove physical WDT expiry, then use the + * reference project's authenticated processor-reset fallback. */ + while(Cy_WDT_GetCount() < SEAT_MOVE_WDT_TIMEOUT_TICKS) + { + __NOP(); + } + __DSB(); + NVIC_SystemReset(); + for(;;) + { + __NOP(); + } +} + +bool SafetyManager_WasHilWatchdogRecovered(void) +{ + return hilWatchdogRecovered; +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/safety_manager.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/safety_manager.h new file mode 100644 index 0000000..e4e67a3 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/safety_manager.h @@ -0,0 +1,30 @@ +#ifndef SAFETY_MANAGER_H +#define SAFETY_MANAGER_H + +#include +#include + +typedef enum +{ + SAFETY_FAULT_NONE = 0, + SAFETY_FAULT_DRIVER = 1, + SAFETY_FAULT_CAN_BUS_OFF = 2, + SAFETY_FAULT_CONFIG_STORE = 3, + SAFETY_FAULT_BOOT_RECOVERY = 4, + SAFETY_FAULT_DEBUG_DETACH = 5, + SAFETY_FAULT_CAN_INIT = 6 +} safety_fault_t; + +void SafetyManager_Init(uint32_t resetReason); +void SafetyManager_Service(void); +void SafetyManager_LatchFault(safety_fault_t fault); +void SafetyManager_KickWatchdog(void); +bool SafetyManager_IsWatchdogInitialized(void); +bool SafetyManager_IsInhibited(void); +safety_fault_t SafetyManager_GetFault(void); +bool SafetyManager_WasWatchdogReset(void); +bool SafetyManager_TriggerHilWatchdogReset(uint16_t token); +void SafetyManager_CommitHilWatchdogReset(void); +bool SafetyManager_WasHilWatchdogRecovered(void); + +#endif /* SAFETY_MANAGER_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_motor_manager.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_motor_manager.c new file mode 100644 index 0000000..fa5f195 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_motor_manager.c @@ -0,0 +1,1269 @@ +#include "seat_motor_manager.h" + +#include "seat_move_config.h" + +#include +#include + +#define SEAT_MOTOR_RETRY_DELAY_MS (2000ul) +#define SEAT_MOTOR_SPEED_CAL_WARMUP_MS (500ul) +#define SEAT_MOTOR_SPEED_CAL_MEASURE_MS (1000ul) +#define SEAT_MOTOR_SPEED_CAL_DUTY_PERCENT (95u) +#define SEAT_MOTOR_DEFAULT_CAL_LEG_TIMEOUT_MS (70000ul) +#define SEAT_MOTOR_TIMED_ARRIVAL_SETTLE_MS (500ul) +#define SEAT_MOTOR_TIMED_M0_NEAR_EXTENSION_MS (900ul) +#define SEAT_MOTOR_TIMED_M0_FAR_EXTENSION_MS (1000ul) +#define SEAT_MOTOR_TIMED_M1_NEAR_EXTENSION_MS (1200ul) +#define SEAT_MOTOR_TIMED_M1_FAR_EXTENSION_MS (700ul) +#define SEAT_MOTOR_TIMED_M3_NEAR_EXTENSION_MS (1900ul) +#define SEAT_MOTOR_TIMED_M3_FAR_EXTENSION_MS (1600ul) + +typedef char seat_motor_manager_requires_four_motors[ + (MOTOR_MODE_COUNT == 4) ? 1 : -1]; + +typedef struct +{ + seat_motor_task_state_t state; + seat_motor_task_state_t stateAfterStart; + seat_motor_result_t result; + uint32_t commandSequence; + uint32_t targetValueX100; + uint32_t durationMs; + uint32_t speedPlanDurationMs; + uint32_t deadlineGraceMs; + uint32_t deadlineMs; + uint32_t retryAtMs; + uint32_t speedValuePerSecondX100; + uint32_t timedRawStartEdges; + uint32_t calibrationRawStart; + uint32_t calibrationStartMs; + uint32_t measuredMaxSpeedPpsX100; + motor_direction_t direction; + uint8_t dutyPercent; + uint8_t retryCount; + bool dynamicSpeed; + bool active; +} seat_motor_task_t; + +static seat_motor_task_t tasks[MOTOR_MODE_COUNT]; + +static bool SeatMotor_ModeValid(motor_mode_t mode) +{ + return ((uint32_t)mode < (uint32_t)MOTOR_MODE_COUNT); +} + +static uint32_t SeatMotor_MaxValueX100(motor_mode_t mode) +{ + if(mode == MOTOR_MODE_0_LINEAR) + { + return MOTOR_MODE0_MAX_POSITION_MM_X100; + } + if(mode == MOTOR_MODE_1_ANGLE) + { + return MOTOR_MODE1_MAX_VERTICAL_MM_X100; + } + if(mode == MOTOR_MODE_2_ANGLE) + { + return MOTOR_MODE2_MAX_ANGLE_DEG_X100; + } + if(mode == MOTOR_MODE_3_LINEAR_TRACK) + { + return MOTOR_MODE3_MAX_ANGLE_DEG_X100; + } + return 0ul; +} + +static uint8_t SeatMotor_DefaultDutyPercent(motor_mode_t mode) +{ + if(mode == MOTOR_MODE_0_LINEAR) + { + return SEAT_MOVE_M0_DEFAULT_DUTY_PERCENT; + } + if(mode == MOTOR_MODE_1_ANGLE) + { + return SEAT_MOVE_M1_DEFAULT_DUTY_PERCENT; + } + if(mode == MOTOR_MODE_2_ANGLE) + { + return SEAT_MOVE_M2_DEFAULT_DUTY_PERCENT; + } + if(mode == MOTOR_MODE_3_LINEAR_TRACK) + { + return SEAT_MOVE_M3_DEFAULT_DUTY_PERCENT; + } + return 0u; +} + +static bool SeatMotor_TimeReached(uint32_t now, uint32_t deadline) +{ + return ((int32_t)(now - deadline) >= 0); +} + +static uint32_t SeatMotor_AbsDelta(uint32_t lhs, uint32_t rhs) +{ + return (lhs >= rhs) ? (lhs - rhs) : (rhs - lhs); +} + +static uint32_t SeatMotor_TimedPlanExtensionMs( + motor_mode_t mode, + motor_direction_t direction) +{ + /* The four loaded mechanisms do not have identical low-speed breakaway + * and coast behavior. Bench traces show repeatable early arrival against + * M2 when every axis follows the same ideal time-position line. Extend + * only the speed plan (never the phase deadline) by the measured lead so + * all four axes converge at the requested phase boundary. */ + if(mode == MOTOR_MODE_0_LINEAR) + { + return (direction == MOTOR_DIRECTION_TOWARD_NEAR) ? + SEAT_MOTOR_TIMED_M0_NEAR_EXTENSION_MS : + SEAT_MOTOR_TIMED_M0_FAR_EXTENSION_MS; + } + if(mode == MOTOR_MODE_1_ANGLE) + { + return (direction == MOTOR_DIRECTION_TOWARD_NEAR) ? + SEAT_MOTOR_TIMED_M1_NEAR_EXTENSION_MS : + SEAT_MOTOR_TIMED_M1_FAR_EXTENSION_MS; + } + if(mode == MOTOR_MODE_3_LINEAR_TRACK) + { + return (direction == MOTOR_DIRECTION_TOWARD_NEAR) ? + SEAT_MOTOR_TIMED_M3_NEAR_EXTENSION_MS : + SEAT_MOTOR_TIMED_M3_FAR_EXTENSION_MS; + } + return 0ul; +} + +static bool SeatMotor_TargetWithinHallTolerance( + const seat_motor_task_t *task, + const motor_position_info_t *info) +{ + uint32_t targetHallCount; + + if((NULL == task) || (NULL == info) || + (false == info->positionValid) || (0ul == info->maxValueX100) || + (0ul == info->maxHallCount) || + (task->targetValueX100 > info->maxValueX100)) + { + return false; + } + targetHallCount = (uint32_t) + (((uint64_t)task->targetValueX100 * info->maxHallCount + + (info->maxValueX100 / 2ul)) / info->maxValueX100); + return SeatMotor_AbsDelta(targetHallCount, info->hallCount) <= + SEAT_MOVE_POSITION_TARGET_HALL_TOLERANCE; +} + +static bool SeatMotor_DutyValid(uint8_t dutyPercent) +{ + return (dutyPercent >= MOTOR_SPEED_MIN_DUTY_PERCENT) && + (dutyPercent <= MOTOR_SPEED_MAX_DUTY_PERCENT); +} + +static bool SeatMotor_TaskIsPosition(const seat_motor_task_t *task) +{ + return (task->state == SEAT_MOTOR_TASK_POSITION) || + ((task->state == SEAT_MOTOR_TASK_WAIT_START) && + (task->stateAfterStart == SEAT_MOTOR_TASK_POSITION)); +} + +static bool SeatMotor_TaskIsTimed(const seat_motor_task_t *task) +{ + return (task->state == SEAT_MOTOR_TASK_TIMED) || + ((task->state == SEAT_MOTOR_TASK_WAIT_START) && + (task->stateAfterStart == SEAT_MOTOR_TASK_TIMED)); +} + +static void SeatMotor_SetFailed(motor_mode_t mode, + seat_motor_result_t result) +{ + seat_motor_task_t *task = &tasks[(uint32_t)mode]; + + (void)Motor_StopMode(mode); + task->state = SEAT_MOTOR_TASK_FAILED; + task->result = result; + task->active = false; +} + +static void SeatMotor_SetComplete(motor_mode_t mode) +{ + seat_motor_task_t *task = &tasks[(uint32_t)mode]; + + task->state = SEAT_MOTOR_TASK_COMPLETE; + task->result = SEAT_MOTOR_RESULT_OK; + task->active = false; +} + +static void SeatMotor_SetStopped(motor_mode_t mode) +{ + seat_motor_task_t *task = &tasks[(uint32_t)mode]; + + task->state = SEAT_MOTOR_TASK_IDLE; + task->result = SEAT_MOTOR_RESULT_STOPPED; + task->active = false; +} + +static bool SeatMotor_PrepareNewTask(motor_mode_t mode, + uint32_t commandSequence) +{ + seat_motor_task_t *task; + + if((false == SeatMotor_ModeValid(mode)) || + Motor_IsDriverFaultLatched() || + (commandSequence > UINT16_MAX)) + { + return false; + } + if((MOTOR_API_SUCCESS != Motor_StopMode(mode)) || + (MOTOR_API_SUCCESS != Motor_AcknowledgeCommand( + mode, (uint16_t)commandSequence))) + { + return false; + } + task = &tasks[(uint32_t)mode]; + (void)memset(task, 0, sizeof(*task)); + task->commandSequence = commandSequence; + task->result = SEAT_MOTOR_RESULT_RUNNING; + task->active = true; + return true; +} + +static bool SeatMotor_StartPositionTask(motor_mode_t mode, + uint32_t targetValueX100, + uint8_t dutyPercent, + uint32_t speedValuePerSecondX100, + uint32_t durationLimitMs, + uint32_t speedPlanDurationMs, + uint32_t commandSequence, + bool dynamicSpeed, + bool preserveRetry, + seat_motor_task_state_t runningState) +{ + seat_motor_task_t savedTask; + seat_motor_task_t *task; + motor_position_info_t info; + uint32_t travelValueX100; + uint32_t planDurationMs = 0ul; + uint8_t retryCount; + + if((false == SeatMotor_ModeValid(mode)) || + ((runningState != SEAT_MOTOR_TASK_POSITION) && + (runningState != SEAT_MOTOR_TASK_TIMED)) || + (targetValueX100 > SeatMotor_MaxValueX100(mode)) || + (MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid)) + { + return false; + } + task = &tasks[(uint32_t)mode]; + retryCount = task->retryCount; + if((false == dynamicSpeed) && (false == SeatMotor_DutyValid(dutyPercent))) + { + return false; + } + travelValueX100 = SeatMotor_AbsDelta(targetValueX100, info.valueX100); + if((false != dynamicSpeed) && + ((0ul == speedValuePerSecondX100) || (0ul == travelValueX100))) + { + if(0ul == travelValueX100) + { + if(false == preserveRetry) + { + if(false == SeatMotor_PrepareNewTask(mode, commandSequence)) + { + return false; + } + } + SeatMotor_SetComplete(mode); + return true; + } + return false; + } + + savedTask = *task; + if((false == preserveRetry) && + (false == SeatMotor_PrepareNewTask(mode, commandSequence))) + { + return false; + } + task = &tasks[(uint32_t)mode]; + if(false != preserveRetry) + { + *task = savedTask; + task->result = SEAT_MOTOR_RESULT_RUNNING; + task->active = true; + retryCount = 1u; + } + + if(false != dynamicSpeed) + { + planDurationMs = (speedPlanDurationMs != 0ul) ? + speedPlanDurationMs : (durationLimitMs != 0ul) ? durationLimitMs : + (uint32_t)((((uint64_t)travelValueX100 * 1000ull) + + speedValuePerSecondX100 - 1ull) / + speedValuePerSecondX100); + if((0ul == planDurationMs) || + (MOTOR_API_SUCCESS != Motor_SetSpeedPlan(mode, + planDurationMs, + travelValueX100))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + } + else if((MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent(mode, + dutyPercent))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + + task->stateAfterStart = runningState; + task->state = (durationLimitMs != 0ul) ? + SEAT_MOTOR_TASK_WAIT_START : runningState; + task->targetValueX100 = targetValueX100; + task->durationMs = durationLimitMs; + task->speedPlanDurationMs = planDurationMs; + task->deadlineMs = 0ul; + task->speedValuePerSecondX100 = speedValuePerSecondX100; + task->dutyPercent = dutyPercent; + task->dynamicSpeed = dynamicSpeed; + task->retryCount = retryCount; + task->direction = (targetValueX100 >= info.valueX100) ? + MOTOR_DIRECTION_TOWARD_FAR : + MOTOR_DIRECTION_TOWARD_NEAR; + + if(MOTOR_API_SUCCESS != Motor_StartMoveToValue(mode, targetValueX100)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + return true; +} + +void SeatMotorManager_Init(void) +{ + (void)memset(tasks, 0, sizeof(tasks)); +} + +void SeatMotorManager_Stop(motor_mode_t mode, uint32_t commandSequence) +{ + seat_motor_task_t *task; + + if(false == SeatMotor_ModeValid(mode)) + { + return; + } + (void)Motor_StopMode(mode); + task = &tasks[(uint32_t)mode]; + (void)memset(task, 0, sizeof(*task)); + task->commandSequence = commandSequence; + task->state = SEAT_MOTOR_TASK_IDLE; + task->result = SEAT_MOTOR_RESULT_STOPPED; +} + +void SeatMotorManager_StopAll(uint32_t commandSequence) +{ + uint32_t index; + + for(index = 0ul; index < (uint32_t)MOTOR_MODE_COUNT; index++) + { + SeatMotorManager_Stop((motor_mode_t)index, commandSequence); + } +} + +bool SeatMotorManager_IsAnyActive(void) +{ + uint32_t index; + + for(index = 0ul; index < (uint32_t)MOTOR_MODE_COUNT; index++) + { + if(false != tasks[index].active) + { + return true; + } + } + return false; +} + +static void SeatMotor_ServiceSpeedCalibration(motor_mode_t mode, + const motor_position_info_t *info, + uint32_t now) +{ + seat_motor_task_t *task = &tasks[(uint32_t)mode]; + motor_persistent_state_t rawState; + uint32_t elapsedMs; + uint32_t measuredPpsX100; + + if(task->state == SEAT_MOTOR_TASK_SPEED_CAL_SETTLE) + { + if(false == Motor_IsCommandCurrent( + mode, (uint16_t)task->commandSequence)) + { + SeatMotor_SetStopped(mode); + return; + } + if(Motor_IsMoving(mode)) + { + return; + } + if((0ul == task->measuredMaxSpeedPpsX100) || + (MOTOR_API_SUCCESS != Motor_SetMaximumSpeedPpsX100( + mode, + task->measuredMaxSpeedPpsX100))) + { + if(false == Motor_IsCommandCurrent( + mode, (uint16_t)task->commandSequence)) + { + SeatMotor_SetStopped(mode); + } + else + { + SeatMotor_SetFailed( + mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + } + return; + } + SeatMotor_SetComplete(mode); + return; + } + + if((info->lastEvent == MOTOR_EVENT_STALL) || + (info->lastEvent == MOTOR_EVENT_NEAR_ENDPOINT) || + (info->lastEvent == MOTOR_EVENT_FAR_ENDPOINT) || + (info->lastEvent == MOTOR_EVENT_TIMEOUT) || + (info->lastEvent == MOTOR_EVENT_TARGET_REACHED)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return; + } + if(task->state == SEAT_MOTOR_TASK_WAIT_START) + { + return; + } + if(task->state == SEAT_MOTOR_TASK_SPEED_CAL_WARMUP) + { + if(SeatMotor_TimeReached(now, + task->calibrationStartMs + + SEAT_MOTOR_SPEED_CAL_WARMUP_MS)) + { + if(MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &rawState)) + { + SeatMotor_SetFailed(mode, + SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return; + } + task->calibrationRawStart = rawState.rawRisingEdges; + task->calibrationStartMs = now; + task->state = SEAT_MOTOR_TASK_SPEED_CAL_MEASURE; + } + return; + } + + if(SeatMotor_TimeReached(now, + task->calibrationStartMs + + SEAT_MOTOR_SPEED_CAL_MEASURE_MS)) + { + if(MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &rawState)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return; + } + elapsedMs = now - task->calibrationStartMs; + measuredPpsX100 = (uint32_t) + (((uint64_t)(rawState.rawRisingEdges - + task->calibrationRawStart) * 100000ull) / + elapsedMs); + if((0ul == measuredPpsX100) || + (MOTOR_API_SUCCESS != Motor_StopMode(mode))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return; + } + task->measuredMaxSpeedPpsX100 = measuredPpsX100; + task->state = SEAT_MOTOR_TASK_SPEED_CAL_SETTLE; + } +} + +void SeatMotorManager_Service(void) +{ + uint32_t index; + uint32_t now; + + Motor_ServiceAll(); + now = Motor_GetTimeMs(); + for(index = 0ul; index < (uint32_t)MOTOR_MODE_COUNT; index++) + { + motor_mode_t mode = (motor_mode_t)index; + seat_motor_task_t *task = &tasks[index]; + motor_position_info_t info; + bool endpointEvent; + + if((false == task->active) || + (MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info))) + { + continue; + } + if(Motor_IsDriverFaultLatched() || + (info.lastEvent == MOTOR_EVENT_DRIVER_FAULT)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_DRIVER_FAULT); + continue; + } + if(false == Motor_IsCommandCurrent( + mode, (uint16_t)task->commandSequence)) + { + SeatMotor_SetStopped(mode); + continue; + } + if((info.lastEvent == MOTOR_EVENT_ABORTED) && + (task->state != SEAT_MOTOR_TASK_SPEED_CAL_SETTLE)) + { + SeatMotor_SetStopped(mode); + continue; + } + if((task->state == SEAT_MOTOR_TASK_WAIT_START) && + (info.direction != MOTOR_DIRECTION_STOPPED)) + { + task->state = task->stateAfterStart; + if(task->state == SEAT_MOTOR_TASK_SPEED_CAL_WARMUP) + { + task->calibrationStartMs = now; + } + else if(0ul != task->durationMs) + { + task->deadlineMs = now + task->durationMs + + task->deadlineGraceMs; + } + else + { + task->deadlineMs = 0ul; + } + } + if((task->state == SEAT_MOTOR_TASK_SPEED_CAL_WARMUP) || + (task->state == SEAT_MOTOR_TASK_SPEED_CAL_MEASURE) || + (task->state == SEAT_MOTOR_TASK_SPEED_CAL_SETTLE) || + ((task->state == SEAT_MOTOR_TASK_WAIT_START) && + (task->stateAfterStart == + SEAT_MOTOR_TASK_SPEED_CAL_WARMUP))) + { + SeatMotor_ServiceSpeedCalibration(mode, &info, now); + continue; + } + if(task->state == SEAT_MOTOR_TASK_RETRY_DELAY) + { + if(SeatMotor_TimeReached(now, task->retryAtMs)) + { + if(false == SeatMotor_StartPositionTask( + mode, + task->targetValueX100, + task->dutyPercent, + task->speedValuePerSecondX100, + task->durationMs, + task->speedPlanDurationMs, + task->commandSequence, + task->dynamicSpeed, + true, + task->stateAfterStart)) + { + if(false == Motor_IsCommandCurrent( + mode, + (uint16_t)task->commandSequence)) + { + SeatMotor_SetStopped(mode); + } + else + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_STALL); + } + } + } + continue; + } + endpointEvent = (info.lastEvent == MOTOR_EVENT_NEAR_ENDPOINT) || + (info.lastEvent == MOTOR_EVENT_FAR_ENDPOINT); + if((info.lastEvent == MOTOR_EVENT_STALL) || endpointEvent) + { + /* The low-level safety event has already stopped the hardware. + * At a discrete Hall endpoint, the mechanism may stop up to two + * pulses short of the mathematically rounded target. This is a + * successful position arrival, not a fault/retry condition. */ + if(SeatMotor_TaskIsPosition(task) && + SeatMotor_TargetWithinHallTolerance(task, &info)) + { + SeatMotor_SetComplete(mode); + continue; + } + if(SeatMotor_TaskIsPosition(task) && + (0u == task->retryCount)) + { + if(MOTOR_API_SUCCESS != Motor_StopMode(mode)) + { + SeatMotor_SetFailed( + mode, + Motor_IsDriverFaultLatched() ? + SEAT_MOTOR_RESULT_DRIVER_FAULT : + SEAT_MOTOR_RESULT_INVALID); + continue; + } + task->state = SEAT_MOTOR_TASK_RETRY_DELAY; + task->deadlineMs = 0ul; + task->retryAtMs = now + SEAT_MOTOR_RETRY_DELAY_MS; + task->retryCount = 1u; + } + else if(endpointEvent && SeatMotor_TaskIsTimed(task)) + { + SeatMotor_SetComplete(mode); + } + else + { + SeatMotor_SetFailed( + mode, + endpointEvent ? SEAT_MOTOR_RESULT_ENDPOINT : + SEAT_MOTOR_RESULT_STALL); + } + continue; + } + if(info.lastEvent == MOTOR_EVENT_TARGET_REACHED) + { + SeatMotor_SetComplete(mode); + continue; + } + if(info.lastEvent == MOTOR_EVENT_TIMEOUT) + { + /* Some seat actuators open their internal limit contact instead + * of producing a high-current endpoint event. The low-level + * no-motion timer then reports TIMEOUT even when discrete Hall + * feedback stopped only a few pulses from the requested pose. + * Accept only the same tightly bounded Hall-domain arrival proof + * used for endpoint/stall events; every other timeout remains a + * failure. */ + if(SeatMotor_TaskIsPosition(task) && + SeatMotor_TargetWithinHallTolerance(task, &info)) + { + SeatMotor_SetComplete(mode); + continue; + } + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_TIMEOUT); + continue; + } + if((0ul != task->deadlineMs) && + SeatMotor_TimeReached(now, task->deadlineMs)) + { + if(MOTOR_API_SUCCESS != Motor_StopMode(mode)) + { + SeatMotor_SetFailed( + mode, + Motor_IsDriverFaultLatched() ? + SEAT_MOTOR_RESULT_DRIVER_FAULT : + SEAT_MOTOR_RESULT_INVALID); + continue; + } + if(task->state == SEAT_MOTOR_TASK_TIMED) + { + motor_persistent_state_t rawState; + + /* A duration expiring is not proof that the requested axis + * actually moved. Require at least one Hall edge so an open + * circuit, unpowered bridge or disconnected motor is reported + * as STALL instead of a misleading OK completion. */ + if(MOTOR_API_SUCCESS != + Motor_GetPersistentState(mode, &rawState)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + } + else if(rawState.rawRisingEdges == + task->timedRawStartEdges) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_STALL); + } + else + { + SeatMotor_SetComplete(mode); + } + } + else + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_TIMEOUT); + } + } + } +} + +bool SeatMotor_MoveAbsoluteDuty(motor_mode_t mode, + uint32_t targetValueX100, + uint8_t dutyPercent, + uint32_t commandSequence) +{ + return SeatMotor_StartPositionTask(mode, + targetValueX100, + dutyPercent, + 0ul, + 0ul, + 0ul, + commandSequence, + false, + false, + SEAT_MOTOR_TASK_POSITION); +} + +bool SeatMotor_MoveRelativeDuty(motor_mode_t mode, + int32_t deltaValueX100, + uint8_t dutyPercent, + uint32_t commandSequence) +{ + motor_position_info_t info; + int64_t target; + + if((MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid)) + { + return false; + } + target = (int64_t)info.valueX100 + deltaValueX100; + if((target < 0ll) || (target > (int64_t)SeatMotor_MaxValueX100(mode))) + { + return false; + } + return SeatMotor_MoveAbsoluteDuty(mode, + (uint32_t)target, + dutyPercent, + commandSequence); +} + +bool SeatMotor_MoveAbsoluteNormalized(motor_mode_t mode, + uint32_t targetPercentX100, + uint32_t timeoutMs, + uint8_t dutyPercent, + uint32_t commandSequence) +{ + motor_position_info_t info; + uint32_t targetValueX100; + + if((targetPercentX100 > 10000ul) || (0ul == timeoutMs) || + (MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid) || (0ul == info.maxValueX100)) + { + return false; + } + targetValueX100 = (uint32_t) + ((((uint64_t)targetPercentX100 * info.maxValueX100) + 5000ull) / + 10000ull); + return SeatMotor_StartPositionTask(mode, + targetValueX100, + dutyPercent, + 0ul, + timeoutMs, + 0ul, + commandSequence, + false, + false, + SEAT_MOTOR_TASK_POSITION); +} + +bool SeatMotor_MoveRelativeNormalized(motor_mode_t mode, + int32_t deltaPercentX100, + uint32_t timeoutMs, + uint8_t dutyPercent, + uint32_t commandSequence) +{ + motor_position_info_t info; + int32_t targetPercentX100; + + if((MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid)) + { + return false; + } + targetPercentX100 = (int32_t)info.percentX100 + deltaPercentX100; + if(targetPercentX100 < 0) + { + targetPercentX100 = 0; + } + else if(targetPercentX100 > 10000) + { + targetPercentX100 = 10000; + } + else + { + /* Target already lies in the normalized range. */ + } + return SeatMotor_MoveAbsoluteNormalized(mode, + (uint32_t)targetPercentX100, + timeoutMs, + dutyPercent, + commandSequence); +} + +bool SeatMotor_MoveTimedDuty(motor_mode_t mode, + motor_direction_t direction, + uint32_t durationMs, + uint8_t dutyPercent, + uint32_t commandSequence) +{ + seat_motor_task_t *task; + + if((0ul == durationMs) || (false == SeatMotor_DutyValid(dutyPercent)) || + ((direction != MOTOR_DIRECTION_TOWARD_FAR) && + (direction != MOTOR_DIRECTION_TOWARD_NEAR)) || + (false == SeatMotor_PrepareNewTask(mode, commandSequence))) + { + return false; + } + if(MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent(mode, dutyPercent)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + task = &tasks[(uint32_t)mode]; + { + motor_persistent_state_t rawState; + + if(MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &rawState)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + task->timedRawStartEdges = rawState.rawRisingEdges; + } + /* A timed manual movement is valid even before an absolute endpoint has + * been calibrated. It therefore uses the open-loop direction primitive + * and is bounded by duration, Hall stall/endpoint detection and the + * low-level absolute operation timeout. */ + if(MOTOR_API_SUCCESS != Motor_StartOpenLoop(mode, direction)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + task->state = SEAT_MOTOR_TASK_WAIT_START; + task->stateAfterStart = SEAT_MOTOR_TASK_TIMED; + task->direction = direction; + task->durationMs = durationMs; + task->deadlineMs = 0ul; + task->dutyPercent = dutyPercent; + return true; +} + +bool SeatMotor_MoveAbsoluteSpeed(motor_mode_t mode, + uint32_t targetValueX100, + uint32_t speedValuePerSecondX100, + uint32_t commandSequence) +{ + return SeatMotor_StartPositionTask(mode, + targetValueX100, + 0u, + speedValuePerSecondX100, + 0ul, + 0ul, + commandSequence, + true, + false, + SEAT_MOTOR_TASK_POSITION); +} + +bool SeatMotor_MoveRelativeSpeed(motor_mode_t mode, + int32_t deltaValueX100, + uint32_t speedValuePerSecondX100, + uint32_t commandSequence) +{ + motor_position_info_t info; + int64_t target; + + if((MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid)) + { + return false; + } + target = (int64_t)info.valueX100 + deltaValueX100; + if((target < 0ll) || (target > (int64_t)SeatMotor_MaxValueX100(mode))) + { + return false; + } + return SeatMotor_MoveAbsoluteSpeed(mode, + (uint32_t)target, + speedValuePerSecondX100, + commandSequence); +} + +bool SeatMotor_MoveTimedSpeed(motor_mode_t mode, + motor_direction_t direction, + uint32_t durationMs, + uint32_t speedValuePerSecondX100, + uint32_t commandSequence) +{ + seat_motor_task_t *task; + uint32_t travel; + + if((0ul == durationMs) || (0ul == speedValuePerSecondX100) || + ((direction != MOTOR_DIRECTION_TOWARD_FAR) && + (direction != MOTOR_DIRECTION_TOWARD_NEAR)) || + (false == SeatMotor_PrepareNewTask(mode, commandSequence))) + { + return false; + } + travel = (uint32_t)((((uint64_t)speedValuePerSecondX100) * + durationMs) / 1000ull); + if((0ul == travel) || (travel > SeatMotor_MaxValueX100(mode)) || + (MOTOR_API_SUCCESS != Motor_SetSpeedPlan(mode, durationMs, travel))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + task = &tasks[(uint32_t)mode]; + { + motor_persistent_state_t rawState; + + if(MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &rawState)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + task->timedRawStartEdges = rawState.rawRisingEdges; + } + if(MOTOR_API_SUCCESS != Motor_StartOpenLoop(mode, direction)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_INVALID); + return false; + } + task->state = SEAT_MOTOR_TASK_WAIT_START; + task->stateAfterStart = SEAT_MOTOR_TASK_TIMED; + task->direction = direction; + task->durationMs = durationMs; + task->deadlineMs = 0ul; + task->speedValuePerSecondX100 = speedValuePerSecondX100; + task->dynamicSpeed = true; + return true; +} + +bool SeatMotor_MoveAbsoluteInTime(motor_mode_t mode, + uint32_t targetValueX100, + uint32_t durationMs, + uint32_t commandSequence) +{ + return SeatMotor_MoveAbsoluteInTimeWithGrace(mode, + targetValueX100, + durationMs, + 0ul, + commandSequence); +} + +bool SeatMotor_MoveAbsoluteInTimeWithGrace(motor_mode_t mode, + uint32_t targetValueX100, + uint32_t durationMs, + uint32_t deadlineGraceMs, + uint32_t commandSequence) +{ + motor_position_info_t info; + uint32_t travel; + uint32_t speed; + uint32_t speedPlanDurationMs; + uint32_t speedPlanExtensionMs; + motor_direction_t direction; + bool started; + + if((0ul == durationMs) || + (MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid)) + { + return false; + } + travel = SeatMotor_AbsDelta(targetValueX100, info.valueX100); + if(0ul == travel) + { + return SeatMotor_MoveAbsoluteDuty(mode, + targetValueX100, + MOTOR_SPEED_MIN_DUTY_PERCENT, + commandSequence); + } + direction = (targetValueX100 >= info.valueX100) ? + MOTOR_DIRECTION_TOWARD_FAR : + MOTOR_DIRECTION_TOWARD_NEAR; + speedPlanDurationMs = (durationMs > SEAT_MOTOR_TIMED_ARRIVAL_SETTLE_MS) ? + (durationMs - SEAT_MOTOR_TIMED_ARRIVAL_SETTLE_MS) : durationMs; + speedPlanExtensionMs = SeatMotor_TimedPlanExtensionMs(mode, direction); + if(speedPlanExtensionMs > (UINT32_MAX - speedPlanDurationMs)) + { + return false; + } + speedPlanDurationMs += speedPlanExtensionMs; + speed = (uint32_t) + ((((uint64_t)travel) * 1000ull) / speedPlanDurationMs); + started = SeatMotor_StartPositionTask(mode, + targetValueX100, + 0u, + speed, + durationMs, + speedPlanDurationMs, + commandSequence, + true, + false, + SEAT_MOTOR_TASK_POSITION); + if(started) + { + tasks[(uint32_t)mode].deadlineGraceMs = deadlineGraceMs; + } + return started; +} + +bool SeatMotor_MoveAbsoluteHallDuty(motor_mode_t mode, + uint32_t targetHallCount, + uint8_t dutyPercent, + uint32_t commandSequence) +{ + motor_position_info_t info; + uint32_t targetValueX100; + + if((MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid) || (0ul == info.maxHallCount) || + (targetHallCount > info.maxHallCount)) + { + return false; + } + targetValueX100 = (uint32_t) + (((uint64_t)targetHallCount * info.maxValueX100 + + (info.maxHallCount / 2ul)) / info.maxHallCount); + return SeatMotor_MoveAbsoluteDuty(mode, + targetValueX100, + dutyPercent, + commandSequence); +} + +bool SeatMotor_MoveAbsoluteHallInTime(motor_mode_t mode, + uint32_t targetHallCount, + uint32_t durationMs, + uint32_t commandSequence) +{ + return SeatMotor_MoveAbsoluteHallInTimeWithGrace(mode, + targetHallCount, + durationMs, + 0ul, + commandSequence); +} + +bool SeatMotor_MoveAbsoluteHallInTimeWithGrace(motor_mode_t mode, + uint32_t targetHallCount, + uint32_t durationMs, + uint32_t deadlineGraceMs, + uint32_t commandSequence) +{ + motor_position_info_t info; + uint32_t targetValueX100; + + if((MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid) || (0ul == info.maxHallCount) || + (targetHallCount > info.maxHallCount)) + { + return false; + } + targetValueX100 = (uint32_t) + (((uint64_t)targetHallCount * info.maxValueX100 + + (info.maxHallCount / 2ul)) / info.maxHallCount); + return SeatMotor_MoveAbsoluteInTimeWithGrace(mode, + targetValueX100, + durationMs, + deadlineGraceMs, + commandSequence); +} + +bool SeatMotor_CalibrateRange(motor_mode_t mode, + uint32_t legTimeoutMs, + uint32_t commandSequence) +{ + motor_calibration_result_t result; + uint8_t dutyPercent; + + if((false == SeatMotor_ModeValid(mode)) || (0ul == legTimeoutMs)) + { + return false; + } + /* The connected four-axis bench needs the same verified loaded duty used + * by normal absolute moves and known-range homing. The protocol minimum + * (50%) cannot reliably start M0/M1 at a loaded endpoint; using it makes + * the first quiet window look like an endpoint and invalidates an + * otherwise healthy calibration. Hall endpoint, timeout and current + * protections remain active throughout both legs. */ + dutyPercent = SeatMotor_DefaultDutyPercent(mode); + if((false == SeatMotor_PrepareNewTask(mode, commandSequence)) || + (MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent( + mode, dutyPercent))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return false; + } + tasks[(uint32_t)mode].state = SEAT_MOTOR_TASK_RANGE_CALIBRATION; + if((MOTOR_API_SUCCESS != Motor_Calibrate(mode, + MOTOR_CALIBRATE_FIND_BOTH, + legTimeoutMs, + &result)) || + (false == result.nearEndpointFound) || + (false == result.farEndpointFound)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return false; + } + SeatMotor_SetComplete(mode); + return true; +} + +bool SeatMotor_HomeKnownRange(motor_mode_t mode, + uint32_t legTimeoutMs, + uint32_t commandSequence) +{ + motor_calibration_result_t result; + uint8_t dutyPercent; + + if((false == SeatMotor_ModeValid(mode)) || (0ul == legTimeoutMs)) + { + return false; + } + dutyPercent = SeatMotor_DefaultDutyPercent(mode); + if((false == SeatMotor_PrepareNewTask(mode, commandSequence)) || + (MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent( + mode, dutyPercent))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return false; + } + tasks[(uint32_t)mode].state = SEAT_MOTOR_TASK_RANGE_CALIBRATION; + if((MOTOR_API_SUCCESS != Motor_Calibrate(mode, + MOTOR_CALIBRATE_FIND_NEAR, + legTimeoutMs, + &result)) || + (false == result.nearEndpointFound)) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return false; + } + SeatMotor_SetComplete(mode); + return true; +} + +bool SeatMotor_StartMaximumSpeedCalibration(motor_mode_t mode, + uint32_t commandSequence) +{ + motor_position_info_t info; + seat_motor_task_t *task; + motor_direction_t direction; + uint32_t target; + + if((false == SeatMotor_ModeValid(mode)) || + (MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &info)) || + (false == info.positionValid) || + (false == SeatMotor_PrepareNewTask(mode, commandSequence))) + { + return false; + } + direction = (info.percentX100 <= 5000ul) ? + MOTOR_DIRECTION_TOWARD_FAR : MOTOR_DIRECTION_TOWARD_NEAR; + target = (direction == MOTOR_DIRECTION_TOWARD_FAR) ? + SeatMotor_MaxValueX100(mode) : 0ul; + if((MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent( + mode, SEAT_MOTOR_SPEED_CAL_DUTY_PERCENT)) || + (MOTOR_API_SUCCESS != Motor_StartMoveToValue(mode, target))) + { + SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED); + return false; + } + task = &tasks[(uint32_t)mode]; + task->state = SEAT_MOTOR_TASK_WAIT_START; + task->stateAfterStart = SEAT_MOTOR_TASK_SPEED_CAL_WARMUP; + task->direction = direction; + task->dutyPercent = SEAT_MOTOR_SPEED_CAL_DUTY_PERCENT; + task->calibrationStartMs = 0ul; + return true; +} + +bool SeatMotorManager_ClearFault(motor_mode_t mode) +{ + seat_motor_task_t *task; + uint32_t sequence; + + if((false == SeatMotor_ModeValid(mode)) || Motor_IsMoving(mode) || + Motor_IsDriverFaultLatched()) + { + return false; + } + task = &tasks[(uint32_t)mode]; + if(false != task->active) + { + return false; + } + sequence = task->commandSequence; + (void)memset(task, 0, sizeof(*task)); + task->commandSequence = sequence; + return true; +} + +bool SeatMotor_RecoverPosition(motor_mode_t mode, uint32_t savedHallCount) +{ + motor_calibration_result_t calibration; + motor_persistent_state_t state; + uint32_t targetValueX100; + + if((MOTOR_API_SUCCESS != Motor_Calibrate(mode, + MOTOR_CALIBRATE_FIND_NEAR, + SEAT_MOTOR_DEFAULT_CAL_LEG_TIMEOUT_MS, + &calibration)) || + (false == calibration.nearEndpointFound) || + (MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &state))) + { + return false; + } + if(savedHallCount > state.maxHallCount) + { + savedHallCount = state.maxHallCount; + } + targetValueX100 = (uint32_t) + (((uint64_t)savedHallCount * SeatMotor_MaxValueX100(mode) + + (state.maxHallCount / 2ul)) / state.maxHallCount); + if(0ul == targetValueX100) + { + return true; + } + if((MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent( + mode, MOTOR_SPEED_MIN_DUTY_PERCENT)) || + (MOTOR_API_SUCCESS != Motor_MoveToValue(mode, targetValueX100))) + { + return false; + } + return true; +} + +bool SeatMotorManager_GetStatus(motor_mode_t mode, + seat_motor_status_t *status) +{ + motor_position_info_t position; + motor_speed_info_t speed; + motor_persistent_state_t persistent; + const seat_motor_task_t *task; + + if((NULL == status) || (false == SeatMotor_ModeValid(mode)) || + (MOTOR_API_SUCCESS != Motor_GetPositionInfo(mode, &position)) || + (MOTOR_API_SUCCESS != Motor_GetSpeedInfo(mode, &speed)) || + (MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &persistent))) + { + return false; + } + task = &tasks[(uint32_t)mode]; + status->taskState = task->state; + status->result = task->result; + status->lastMotorEvent = position.lastEvent; + status->direction = position.direction; + status->commandSequence = task->commandSequence; + status->valueX100 = position.valueX100; + status->hallCount = position.hallCount; + status->maxHallCount = position.maxHallCount; + status->maxSpeedPpsX100 = persistent.maxSpeedPulsesPerSecondX100; + status->hallSpeedPpsX100 = speed.actualPulsesPerSecondX100; + status->positionPercentX100 = position.percentX100; + status->positionMmX100 = position.positionMmX100; + status->horizontalMmX100 = position.horizontalMmX100; + status->verticalMmX100 = position.verticalMmX100; + status->angleDegX100 = position.angleDegX100; + status->dutyPercent = speed.currentDutyPercent; + status->retryCount = task->retryCount; + status->active = task->active; + status->retried = (task->retryCount != 0u); + status->positionValid = position.positionValid; + return true; +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_motor_manager.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_motor_manager.h new file mode 100644 index 0000000..7b17eb4 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_motor_manager.h @@ -0,0 +1,154 @@ +#ifndef SEAT_MOTOR_MANAGER_H +#define SEAT_MOTOR_MANAGER_H + +#include +#include + +#include "motor_control.h" + +typedef enum +{ + SEAT_MOTOR_TASK_IDLE = 0, + SEAT_MOTOR_TASK_WAIT_START, + SEAT_MOTOR_TASK_POSITION, + SEAT_MOTOR_TASK_TIMED, + SEAT_MOTOR_TASK_RETRY_DELAY, + SEAT_MOTOR_TASK_RANGE_CALIBRATION, + SEAT_MOTOR_TASK_SPEED_CAL_WARMUP, + SEAT_MOTOR_TASK_SPEED_CAL_MEASURE, + SEAT_MOTOR_TASK_SPEED_CAL_SETTLE, + SEAT_MOTOR_TASK_COMPLETE, + SEAT_MOTOR_TASK_FAILED +} seat_motor_task_state_t; + +typedef enum +{ + SEAT_MOTOR_RESULT_NONE = 0, + SEAT_MOTOR_RESULT_RUNNING, + SEAT_MOTOR_RESULT_OK, + SEAT_MOTOR_RESULT_STOPPED, + SEAT_MOTOR_RESULT_STALL, + SEAT_MOTOR_RESULT_ENDPOINT, + SEAT_MOTOR_RESULT_TIMEOUT, + SEAT_MOTOR_RESULT_DRIVER_FAULT, + SEAT_MOTOR_RESULT_INVALID, + SEAT_MOTOR_RESULT_CALIBRATION_FAILED +} seat_motor_result_t; + +typedef struct +{ + seat_motor_task_state_t taskState; + seat_motor_result_t result; + motor_event_t lastMotorEvent; + motor_direction_t direction; + uint32_t commandSequence; + uint32_t valueX100; + uint32_t hallCount; + uint32_t maxHallCount; + uint32_t maxSpeedPpsX100; + uint32_t hallSpeedPpsX100; + uint32_t positionPercentX100; + uint32_t positionMmX100; + uint32_t horizontalMmX100; + uint32_t verticalMmX100; + uint32_t angleDegX100; + uint8_t dutyPercent; + uint8_t retryCount; + bool active; + bool retried; + bool positionValid; +} seat_motor_status_t; + +void SeatMotorManager_Init(void); +void SeatMotorManager_Service(void); +void SeatMotorManager_Stop(motor_mode_t mode, uint32_t commandSequence); +void SeatMotorManager_StopAll(uint32_t commandSequence); +bool SeatMotorManager_IsAnyActive(void); +bool SeatMotorManager_GetStatus(motor_mode_t mode, + seat_motor_status_t *status); + +/* Six standardized, non-blocking motion primitives. Physical values are x100. */ +bool SeatMotor_MoveAbsoluteDuty(motor_mode_t mode, + uint32_t targetValueX100, + uint8_t dutyPercent, + uint32_t commandSequence); +bool SeatMotor_MoveAbsoluteNormalized(motor_mode_t mode, + uint32_t targetPercentX100, + uint32_t timeoutMs, + uint8_t dutyPercent, + uint32_t commandSequence); +bool SeatMotor_MoveRelativeNormalized(motor_mode_t mode, + int32_t deltaPercentX100, + uint32_t timeoutMs, + uint8_t dutyPercent, + uint32_t commandSequence); +bool SeatMotor_MoveRelativeDuty(motor_mode_t mode, + int32_t deltaValueX100, + uint8_t dutyPercent, + uint32_t commandSequence); +bool SeatMotor_MoveTimedDuty(motor_mode_t mode, + motor_direction_t direction, + uint32_t durationMs, + uint8_t dutyPercent, + uint32_t commandSequence); +bool SeatMotor_MoveAbsoluteSpeed(motor_mode_t mode, + uint32_t targetValueX100, + uint32_t speedValuePerSecondX100, + uint32_t commandSequence); +bool SeatMotor_MoveRelativeSpeed(motor_mode_t mode, + int32_t deltaValueX100, + uint32_t speedValuePerSecondX100, + uint32_t commandSequence); +bool SeatMotor_MoveTimedSpeed(motor_mode_t mode, + motor_direction_t direction, + uint32_t durationMs, + uint32_t speedValuePerSecondX100, + uint32_t commandSequence); + +/* Position command with an explicit requested arrival time. */ +bool SeatMotor_MoveAbsoluteInTime(motor_mode_t mode, + uint32_t targetValueX100, + uint32_t durationMs, + uint32_t commandSequence); +/* Keep the same speed plan while allowing a bounded scheduler/settling grace + * before the manager reports TIMEOUT. */ +bool SeatMotor_MoveAbsoluteInTimeWithGrace(motor_mode_t mode, + uint32_t targetValueX100, + uint32_t durationMs, + uint32_t deadlineGraceMs, + uint32_t commandSequence); + +/* Hall-coordinate variants used by firmware-owned synchronized trajectories. + * The target is checked against the live calibrated MaxHall before conversion + * to the motor's physical value. */ +bool SeatMotor_MoveAbsoluteHallDuty(motor_mode_t mode, + uint32_t targetHallCount, + uint8_t dutyPercent, + uint32_t commandSequence); +bool SeatMotor_MoveAbsoluteHallInTime(motor_mode_t mode, + uint32_t targetHallCount, + uint32_t durationMs, + uint32_t commandSequence); +bool SeatMotor_MoveAbsoluteHallInTimeWithGrace(motor_mode_t mode, + uint32_t targetHallCount, + uint32_t durationMs, + uint32_t deadlineGraceMs, + uint32_t commandSequence); + +/* Range calibration is interruptible by Motor_RequestAbortAllFromIsr(). */ +bool SeatMotor_CalibrateRange(motor_mode_t mode, + uint32_t legTimeoutMs, + uint32_t commandSequence); +/* Re-establish zero at the known near endpoint while retaining the confirmed + * default MaxHall. This performs one near-seek only, not a full-range test. */ +bool SeatMotor_HomeKnownRange(motor_mode_t mode, + uint32_t legTimeoutMs, + uint32_t commandSequence); +bool SeatMotor_StartMaximumSpeedCalibration(motor_mode_t mode, + uint32_t commandSequence); +bool SeatMotorManager_ClearFault(motor_mode_t mode); + +/* Optional endpoint-based position recovery primitive. */ +bool SeatMotor_RecoverPosition(motor_mode_t mode, uint32_t savedHallCount); + +#endif /* SEAT_MOTOR_MANAGER_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.c new file mode 100644 index 0000000..0c93c4f --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.c @@ -0,0 +1,1147 @@ +#include "seat_timed_application.h" + +#include +#include + +#include "bsp_seat_move_can.h" +#include "motor_control.h" +#include "safety_manager.h" +#include "seat_motor_manager.h" +#include "seat_move_config.h" + +#define SEAT_TIMED_CLEAR_FAULT_KEY (0xC33Cu) + +typedef enum +{ + SEAT_TIMED_ACK_NONE = 0u, + SEAT_TIMED_ACK_ACCEPTED = 1u, + SEAT_TIMED_ACK_REJECTED = 2u, + SEAT_TIMED_ACK_DUPLICATE = 3u, + SEAT_TIMED_ACK_STOP_ALL_ACCEPTED = 4u +} seat_timed_ack_t; + +typedef enum +{ + SEAT_TIMED_SYSTEM_INIT = 0u, + SEAT_TIMED_SYSTEM_READY = 1u, + SEAT_TIMED_SYSTEM_ACTIVE = 2u, + SEAT_TIMED_SYSTEM_INHIBITED = 3u, + SEAT_TIMED_SYSTEM_FAULT = 4u +} seat_timed_system_state_t; + +typedef enum +{ + SEAT_TIMED_REJECT_NONE = 0u, + SEAT_TIMED_REJECT_BAD_DLC = 1u, + SEAT_TIMED_REJECT_BAD_VERSION = 2u, + SEAT_TIMED_REJECT_BAD_OPCODE = 3u, + SEAT_TIMED_REJECT_BAD_MOTOR = 4u, + SEAT_TIMED_REJECT_BAD_DIRECTION = 5u, + SEAT_TIMED_REJECT_PARAM_RANGE = 6u, + SEAT_TIMED_REJECT_SEQUENCE_OLD = 8u, + SEAT_TIMED_REJECT_BUSY_SAME_MOTOR = 9u, + SEAT_TIMED_REJECT_INTERLOCK = 10u, + SEAT_TIMED_REJECT_FAULT_ACTIVE = 11u, + SEAT_TIMED_REJECT_INTERNAL_ERROR = 14u +} seat_timed_reject_t; + +/* These are the only accepted command opcodes in this image. */ +typedef enum +{ + SEAT_TIMED_OPCODE_INVALID = 0x00u, + SEAT_TIMED_OPCODE_MOVE_TIMED = 0x03u, + SEAT_TIMED_OPCODE_STOP_MOTOR = 0x08u, + SEAT_TIMED_OPCODE_STOP_ALL = 0x09u, + SEAT_TIMED_OPCODE_QUERY_STATUS = 0x0Cu, + SEAT_TIMED_OPCODE_CLEAR_FAULT = 0x0Du +} seat_timed_opcode_t; + +typedef enum +{ + SEAT_TIMED_CAN_TASK_IDLE = 0u, + SEAT_TIMED_CAN_TASK_ACCEPTED = 1u, + SEAT_TIMED_CAN_TASK_TIMED_RUN = 3u, + SEAT_TIMED_CAN_TASK_COMPLETE = 6u, + SEAT_TIMED_CAN_TASK_FAULT = 7u +} seat_timed_can_task_state_t; + +typedef enum +{ + SEAT_TIMED_CAN_RESULT_NONE = 0u, + SEAT_TIMED_CAN_RESULT_RUNNING = 1u, + SEAT_TIMED_CAN_RESULT_OK = 2u, + SEAT_TIMED_CAN_RESULT_STOPPED_BY_CMD = 3u, + SEAT_TIMED_CAN_RESULT_ENDPOINT_REACHED = 4u, + SEAT_TIMED_CAN_RESULT_STALL = 5u, + SEAT_TIMED_CAN_RESULT_TIMEOUT = 6u, + SEAT_TIMED_CAN_RESULT_DRIVER_FAULT = 7u, + SEAT_TIMED_CAN_RESULT_INVALID_CMD = 9u, + SEAT_TIMED_CAN_RESULT_CALIBRATION_FAILED = 11u +} seat_timed_can_result_t; + +typedef struct +{ + uint8_t opcode; + uint8_t motor; + motor_direction_t direction; + uint16_t sequence; + uint16_t paramA; + uint16_t paramB; +} seat_timed_command_t; + +typedef struct +{ + seat_timed_command_t command; + bool valid; +} seat_timed_pending_t; + +volatile uint32_t g_seatTimedRxCommandCount = 0u; +volatile uint32_t g_seatTimedInvalidCommandCount = 0u; +volatile uint32_t g_seatTimedConflictCount = 0u; +volatile uint32_t g_seatTimedAcceptedCount = 0u; +volatile uint32_t g_seatTimedFeedbackTxCount = 0u; +volatile uint32_t g_seatTimedFeedbackTxFailureCount = 0u; +volatile uint8_t g_seatTimedAck = SEAT_TIMED_ACK_NONE; +volatile uint8_t g_seatTimedRejectReason = SEAT_TIMED_REJECT_NONE; +volatile uint8_t g_seatTimedLastOpcode = SEAT_TIMED_OPCODE_INVALID; +volatile uint16_t g_seatTimedLastSequence = 0u; +volatile uint8_t g_seatTimedPendingMask = 0u; + +static const motor_mode_t s_logicalToPhysical[SEAT_MOVE_MOTOR_COUNT] = +{ + (motor_mode_t)SEAT_MOVE_LOGICAL_M0_PHYSICAL_MODE, + (motor_mode_t)SEAT_MOVE_LOGICAL_M1_PHYSICAL_MODE, + (motor_mode_t)SEAT_MOVE_LOGICAL_M2_PHYSICAL_MODE, + (motor_mode_t)SEAT_MOVE_LOGICAL_M3_PHYSICAL_MODE +}; + +static const uint32_t s_maxDurationMs[SEAT_MOVE_MOTOR_COUNT] = +{ + SEAT_TIMED_M0_MAX_DURATION_MS, + SEAT_TIMED_M1_MAX_DURATION_MS, + SEAT_TIMED_M2_MAX_DURATION_MS, + SEAT_TIMED_M3_MAX_DURATION_MS +}; + +static seat_timed_pending_t s_pending[SEAT_MOVE_MOTOR_COUNT]; +static seat_motor_status_t s_lastStatus[SEAT_MOVE_MOTOR_COUNT]; +static bool s_lastStatusValid[SEAT_MOVE_MOTOR_COUNT]; +static bool s_lastSequenceValid = false; +static bool s_lastCommandValid = false; +static bool s_ackNeedsClear = false; +static bool s_inhibitHandled = false; +static seat_timed_command_t s_lastCommand; +static uint16_t s_sequenceReference = 0u; +static bool s_systemStatusPending = true; +static uint8_t s_motorStatusPendingMask = SEAT_MOVE_ACTIVE_MOTOR_MASK; +static uint8_t s_diagnosticPendingMask = SEAT_MOVE_ACTIVE_MOTOR_MASK; +static bool s_firmwareIdPending = true; +static uint8_t s_dispatchCursor = 0u; +static uint8_t s_motorPollCursor = 0u; +static uint8_t s_diagnosticPollCursor = 0u; +static uint8_t s_motorPeriodicId = 0u; +static uint8_t s_diagnosticPeriodicId = 0u; +static bool s_motorPeriodicPending = false; +static bool s_diagnosticPeriodicPending = false; +static uint8_t s_systemAlive = 0u; +static uint8_t s_diagnosticAlive[SEAT_MOVE_MOTOR_COUNT]; +static uint32_t s_nextStartMs = 0u; +static uint32_t s_nextSystemStatusMs = 0u; +static uint32_t s_nextMotorPollMs = 0u; +static uint32_t s_nextDiagnosticPollMs = 0u; +static uint32_t s_nextFirmwareIdMs = 0u; + +static bool SeatTimed_TimeReached(uint32_t now, uint32_t deadline) +{ + return ((int32_t)(now - deadline) >= 0); +} + +static bool SeatTimed_SequenceNewer(uint16_t candidate, uint16_t reference) +{ + return ((int16_t)(candidate - reference) > 0); +} + +static bool SeatTimed_CommandEqual(const seat_timed_command_t *lhs, + const seat_timed_command_t *rhs) +{ + return (lhs != NULL) && (rhs != NULL) && + (lhs->opcode == rhs->opcode) && + (lhs->motor == rhs->motor) && + (lhs->direction == rhs->direction) && + (lhs->sequence == rhs->sequence) && + (lhs->paramA == rhs->paramA) && + (lhs->paramB == rhs->paramB); +} + +static motor_mode_t SeatTimed_PhysicalMode(uint8_t logicalMotor) +{ + return (logicalMotor < SEAT_MOVE_MOTOR_COUNT) ? + s_logicalToPhysical[logicalMotor] : MOTOR_MODE_COUNT; +} + +static bool SeatTimed_GetStatus(uint8_t logicalMotor, + seat_motor_status_t *status) +{ + if((logicalMotor >= SEAT_MOVE_MOTOR_COUNT) || (status == NULL)) + { + return false; + } + return SeatMotorManager_GetStatus(SeatTimed_PhysicalMode(logicalMotor), + status); +} + +static uint8_t SeatTimed_CommandDutyPercent( + const seat_timed_command_t *command) +{ + uint32_t duty; + + if((command == NULL) || (command->motor >= SEAT_MOVE_MOTOR_COUNT)) + { + return 0u; + } + duty = ((uint32_t)command->paramB + 5u) / 10u; + if(duty < MOTOR_SPEED_MIN_DUTY_PERCENT) + { + duty = MOTOR_SPEED_MIN_DUTY_PERCENT; + } + if(duty > MOTOR_SPEED_MAX_DUTY_PERCENT) + { + duty = MOTOR_SPEED_MAX_DUTY_PERCENT; + } + if((command->motor == 3u) && + (duty > SEAT_MOVE_M3_DEFAULT_DUTY_PERCENT)) + { + duty = SEAT_MOVE_M3_DEFAULT_DUTY_PERCENT; + } + return (uint8_t)duty; +} + +static void SeatTimed_SetResponse(seat_timed_ack_t ack, + seat_timed_reject_t reject, + const seat_timed_command_t *command) +{ + g_seatTimedAck = (uint8_t)ack; + g_seatTimedRejectReason = (uint8_t)reject; + if(command != NULL) + { + g_seatTimedLastOpcode = command->opcode; + g_seatTimedLastSequence = command->sequence; + } + s_ackNeedsClear = (ack != SEAT_TIMED_ACK_NONE); + s_systemStatusPending = true; +} + +static bool SeatTimed_ParseCommand(const bsp_seat_move_can_frame_t *frame, + seat_timed_command_t *command, + seat_timed_reject_t *reject) +{ + uint8_t control; + + if((frame == NULL) || (command == NULL) || (reject == NULL)) + { + return false; + } + (void)memset(command, 0, sizeof(*command)); + command->opcode = (uint8_t)(frame->data0 & 0xFFu); + control = (uint8_t)((frame->data0 >> 8u) & 0xFFu); + command->motor = control & 0x03u; + command->direction = (motor_direction_t)((control >> 2u) & 0x03u); + command->sequence = (uint16_t)((frame->data0 >> 16u) & 0xFFFFu); + command->paramA = (uint16_t)(frame->data1 & 0xFFFFu); + command->paramB = (uint16_t)((frame->data1 >> 16u) & 0xFFFFu); + + if((frame->identifier != SEAT_MOVE_CAN_COMMAND_ID) || + (frame->dlc != SEAT_MOVE_CAN_DLC)) + { + *reject = SEAT_TIMED_REJECT_BAD_DLC; + return false; + } + if((((control >> 4u) & 0x03u) != SEAT_MOVE_CAN_PROTOCOL_VERSION) || + ((control & 0xC0u) != 0u)) + { + *reject = SEAT_TIMED_REJECT_BAD_VERSION; + return false; + } + switch((seat_timed_opcode_t)command->opcode) + { + case SEAT_TIMED_OPCODE_MOVE_TIMED: + case SEAT_TIMED_OPCODE_STOP_MOTOR: + case SEAT_TIMED_OPCODE_STOP_ALL: + case SEAT_TIMED_OPCODE_QUERY_STATUS: + case SEAT_TIMED_OPCODE_CLEAR_FAULT: + return true; + default: + *reject = SEAT_TIMED_REJECT_BAD_OPCODE; + return false; + } +} + +static bool SeatTimed_ParametersValid(const seat_timed_command_t *command, + seat_timed_reject_t *reject) +{ + if((command == NULL) || (reject == NULL)) + { + return false; + } + if(command->motor >= SEAT_MOVE_MOTOR_COUNT) + { + *reject = SEAT_TIMED_REJECT_BAD_MOTOR; + return false; + } + if((command->opcode == SEAT_TIMED_OPCODE_STOP_ALL) && + (command->motor != 0u)) + { + *reject = SEAT_TIMED_REJECT_BAD_MOTOR; + return false; + } + if(command->opcode == SEAT_TIMED_OPCODE_MOVE_TIMED) + { + if((command->direction != MOTOR_DIRECTION_TOWARD_FAR) && + (command->direction != MOTOR_DIRECTION_TOWARD_NEAR)) + { + *reject = SEAT_TIMED_REJECT_BAD_DIRECTION; + return false; + } + if((command->paramA < SEAT_TIMED_MIN_DURATION_MS) || + ((uint32_t)command->paramA > s_maxDurationMs[command->motor]) || + (command->paramB < SEAT_TIMED_LEVEL_MIN_X10) || + (command->paramB > SEAT_TIMED_LEVEL_MAX_X10)) + { + *reject = SEAT_TIMED_REJECT_PARAM_RANGE; + return false; + } + } + else + { + if(command->direction != MOTOR_DIRECTION_STOPPED) + { + *reject = SEAT_TIMED_REJECT_BAD_DIRECTION; + return false; + } + if(command->opcode == SEAT_TIMED_OPCODE_CLEAR_FAULT) + { + if((command->paramA != SEAT_TIMED_CLEAR_FAULT_KEY) || + (command->paramB != 0u)) + { + *reject = SEAT_TIMED_REJECT_PARAM_RANGE; + return false; + } + } + else if((command->paramA != 0u) || (command->paramB != 0u)) + { + *reject = SEAT_TIMED_REJECT_PARAM_RANGE; + return false; + } + } + *reject = SEAT_TIMED_REJECT_NONE; + return true; +} + +static bool SeatTimed_MotorBusy(uint8_t motor) +{ + seat_motor_status_t status; + + return (motor < SEAT_MOVE_MOTOR_COUNT) && + (s_pending[motor].valid || + (SeatTimed_GetStatus(motor, &status) && status.active)); +} + +static uint8_t SeatTimed_ActiveCount(void) +{ + uint8_t motor; + uint8_t count = 0u; + + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + if(SeatTimed_MotorBusy(motor)) + { + count++; + } + } + return count; +} + +static uint8_t SeatTimed_ActiveMask(void) +{ + uint8_t motor; + uint8_t mask = 0u; + + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + if(SeatTimed_MotorBusy(motor)) + { + mask |= (uint8_t)(1u << motor); + } + } + return mask; +} + +static uint8_t SeatTimed_FaultedMask(void) +{ + uint8_t motor; + uint8_t mask = 0u; + + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + seat_motor_status_t status; + + if(SeatTimed_GetStatus(motor, &status) && + ((status.result == SEAT_MOTOR_RESULT_STALL) || + (status.result == SEAT_MOTOR_RESULT_TIMEOUT) || + (status.result == SEAT_MOTOR_RESULT_DRIVER_FAULT) || + (status.result == SEAT_MOTOR_RESULT_INVALID) || + (status.result == SEAT_MOTOR_RESULT_CALIBRATION_FAILED))) + { + mask |= (uint8_t)(1u << motor); + } + } + return mask; +} + +static uint8_t SeatTimed_PositionValidMask(void) +{ + uint8_t motor; + uint8_t mask = 0u; + + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + seat_motor_status_t status; + + if(SeatTimed_GetStatus(motor, &status) && status.positionValid) + { + mask |= (uint8_t)(1u << motor); + } + } + return mask; +} + +static void SeatTimed_StopMotor(uint8_t motor, uint16_t sequence) +{ + motor_mode_t mode; + + if(motor >= SEAT_MOVE_MOTOR_COUNT) + { + return; + } + mode = SeatTimed_PhysicalMode(motor); + Motor_RequestAbortMaskFromIsr((uint8_t)(1u << (uint8_t)mode), sequence); + Motor_ServiceAll(); + (void)Motor_AcknowledgeCommand(mode, sequence); + SeatMotorManager_Stop(mode, sequence); + s_pending[motor].valid = false; + g_seatTimedPendingMask &= (uint8_t)(~(uint8_t)(1u << motor)); + s_motorStatusPendingMask |= (uint8_t)(1u << motor); + s_diagnosticPendingMask |= (uint8_t)(1u << motor); + s_systemStatusPending = true; +} + +void SeatTimedApplication_StopAll(uint16_t sequence) +{ + uint8_t motor; + + Motor_RequestAbortAllFromIsr(sequence); + Motor_ServiceAll(); + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + motor_mode_t mode = SeatTimed_PhysicalMode(motor); + + (void)Motor_AcknowledgeCommand(mode, sequence); + SeatMotorManager_Stop(mode, sequence); + s_pending[motor].valid = false; + } + g_seatTimedPendingMask = 0u; + s_motorStatusPendingMask = SEAT_MOVE_ACTIVE_MOTOR_MASK; + s_diagnosticPendingMask = SEAT_MOVE_ACTIVE_MOTOR_MASK; + s_systemStatusPending = true; +} + +static void SeatTimed_QueueCommand(const seat_timed_command_t *command) +{ + uint8_t motor = command->motor; + + s_pending[motor].command = *command; + s_pending[motor].valid = true; + g_seatTimedPendingMask |= (uint8_t)(1u << motor); + s_motorStatusPendingMask |= (uint8_t)(1u << motor); + s_systemStatusPending = true; +} + +static void SeatTimed_HandleCommand(const bsp_seat_move_can_frame_t *frame) +{ + seat_timed_command_t command; + seat_timed_reject_t reject = SEAT_TIMED_REJECT_BAD_DLC; + + g_seatTimedRxCommandCount++; + if(!SeatTimed_ParseCommand(frame, &command, &reject) || + !SeatTimed_ParametersValid(&command, &reject)) + { + g_seatTimedInvalidCommandCount++; + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, reject, &command); + return; + } + + if(command.opcode != SEAT_TIMED_OPCODE_STOP_ALL) + { + if(s_lastSequenceValid && + (command.sequence == s_sequenceReference)) + { + if(s_lastCommandValid && + SeatTimed_CommandEqual(&command, &s_lastCommand)) + { + SeatTimed_SetResponse(SEAT_TIMED_ACK_DUPLICATE, + SEAT_TIMED_REJECT_NONE, + &command); + } + else + { + g_seatTimedInvalidCommandCount++; + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_SEQUENCE_OLD, + &command); + } + return; + } + if(s_lastSequenceValid && + !SeatTimed_SequenceNewer(command.sequence, s_sequenceReference)) + { + g_seatTimedInvalidCommandCount++; + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_SEQUENCE_OLD, + &command); + return; + } + } + + s_lastSequenceValid = true; + s_lastCommandValid = true; + s_sequenceReference = command.sequence; + s_lastCommand = command; + + if(command.opcode == SEAT_TIMED_OPCODE_STOP_ALL) + { + SeatTimedApplication_StopAll(command.sequence); + SeatTimed_SetResponse(SEAT_TIMED_ACK_STOP_ALL_ACCEPTED, + SEAT_TIMED_REJECT_NONE, + &command); + g_seatTimedAcceptedCount++; + return; + } + if(command.opcode == SEAT_TIMED_OPCODE_STOP_MOTOR) + { + SeatTimed_StopMotor(command.motor, command.sequence); + SeatTimed_SetResponse(SEAT_TIMED_ACK_ACCEPTED, + SEAT_TIMED_REJECT_NONE, + &command); + g_seatTimedAcceptedCount++; + return; + } + if(command.opcode == SEAT_TIMED_OPCODE_QUERY_STATUS) + { + uint8_t bit = (uint8_t)(1u << command.motor); + + s_motorStatusPendingMask |= bit; + s_diagnosticPendingMask |= bit; + SeatTimed_SetResponse(SEAT_TIMED_ACK_ACCEPTED, + SEAT_TIMED_REJECT_NONE, + &command); + g_seatTimedAcceptedCount++; + return; + } + if(command.opcode == SEAT_TIMED_OPCODE_CLEAR_FAULT) + { + if(SeatTimed_MotorBusy(command.motor)) + { + g_seatTimedConflictCount++; + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_BUSY_SAME_MOTOR, + &command); + return; + } + if(SafetyManager_IsInhibited() || Motor_IsDriverFaultLatched() || + !SeatMotorManager_ClearFault( + SeatTimed_PhysicalMode(command.motor))) + { + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_FAULT_ACTIVE, + &command); + return; + } + s_motorStatusPendingMask |= (uint8_t)(1u << command.motor); + s_diagnosticPendingMask |= (uint8_t)(1u << command.motor); + SeatTimed_SetResponse(SEAT_TIMED_ACK_ACCEPTED, + SEAT_TIMED_REJECT_NONE, + &command); + g_seatTimedAcceptedCount++; + return; + } + + if(SafetyManager_IsInhibited()) + { + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_FAULT_ACTIVE, + &command); + return; + } + if(SeatTimed_MotorBusy(command.motor)) + { + /* A newer timed command replaces only the selected axis. */ + SeatTimed_StopMotor(command.motor, command.sequence); + } + else if(SeatTimed_ActiveCount() >= SEAT_MOVE_MAX_CONCURRENT_MOTORS) + { + g_seatTimedConflictCount++; + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_INTERLOCK, + &command); + return; + } + + SeatTimed_QueueCommand(&command); + SeatTimed_SetResponse(SEAT_TIMED_ACK_ACCEPTED, + SEAT_TIMED_REJECT_NONE, + &command); + g_seatTimedAcceptedCount++; +} + +static bool SeatTimed_DispatchCommand(const seat_timed_command_t *command) +{ + /* No position, relative, homing, calibration or dynamic dispatch path + * exists in this image. */ + if((command == NULL) || + (command->opcode != SEAT_TIMED_OPCODE_MOVE_TIMED)) + { + return false; + } + return SeatMotor_MoveTimedDuty(SeatTimed_PhysicalMode(command->motor), + command->direction, + command->paramA, + SeatTimed_CommandDutyPercent(command), + command->sequence); +} + +static void SeatTimed_ServiceDispatcher(void) +{ + uint32_t now = Motor_GetTimeMs(); + uint8_t checked; + + if(SafetyManager_IsInhibited() || + !SeatTimed_TimeReached(now, s_nextStartMs)) + { + return; + } + for(checked = 0u; checked < SEAT_MOVE_MOTOR_COUNT; checked++) + { + uint8_t motor = (uint8_t) + ((s_dispatchCursor + checked) % SEAT_MOVE_MOTOR_COUNT); + + if(s_pending[motor].valid) + { + seat_timed_command_t command = s_pending[motor].command; + + s_pending[motor].valid = false; + g_seatTimedPendingMask &= (uint8_t)(~(uint8_t)(1u << motor)); + s_dispatchCursor = (uint8_t)((motor + 1u) % + SEAT_MOVE_MOTOR_COUNT); + s_nextStartMs = now + SEAT_MOVE_START_STAGGER_MS; + if(!SeatTimed_DispatchCommand(&command)) + { + SeatTimed_SetResponse(SEAT_TIMED_ACK_REJECTED, + SEAT_TIMED_REJECT_INTERNAL_ERROR, + &command); + } + s_motorStatusPendingMask |= (uint8_t)(1u << motor); + s_diagnosticPendingMask |= (uint8_t)(1u << motor); + s_systemStatusPending = true; + break; + } + } +} + +static bool SeatTimed_StatusChanged(const seat_motor_status_t *lhs, + const seat_motor_status_t *rhs) +{ + return (lhs->taskState != rhs->taskState) || + (lhs->result != rhs->result) || + (lhs->lastMotorEvent != rhs->lastMotorEvent) || + (lhs->direction != rhs->direction) || + (lhs->commandSequence != rhs->commandSequence) || + (lhs->dutyPercent != rhs->dutyPercent) || + (lhs->active != rhs->active) || + (lhs->retryCount != rhs->retryCount) || + (lhs->positionValid != rhs->positionValid); +} + +static void SeatTimed_ServiceStateChanges(void) +{ + uint8_t motor; + + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + seat_motor_status_t status; + + if(SeatTimed_GetStatus(motor, &status) && + (!s_lastStatusValid[motor] || + SeatTimed_StatusChanged(&status, &s_lastStatus[motor]))) + { + s_lastStatus[motor] = status; + s_lastStatusValid[motor] = true; + s_motorStatusPendingMask |= (uint8_t)(1u << motor); + s_diagnosticPendingMask |= (uint8_t)(1u << motor); + s_systemStatusPending = true; + } + } +} + +static uint8_t SeatTimed_MapTaskState(const seat_motor_status_t *status) +{ + switch(status->taskState) + { + case SEAT_MOTOR_TASK_IDLE: + return SEAT_TIMED_CAN_TASK_IDLE; + case SEAT_MOTOR_TASK_WAIT_START: + case SEAT_MOTOR_TASK_RETRY_DELAY: + return SEAT_TIMED_CAN_TASK_ACCEPTED; + case SEAT_MOTOR_TASK_TIMED: + return SEAT_TIMED_CAN_TASK_TIMED_RUN; + case SEAT_MOTOR_TASK_COMPLETE: + return SEAT_TIMED_CAN_TASK_COMPLETE; + case SEAT_MOTOR_TASK_POSITION: + case SEAT_MOTOR_TASK_RANGE_CALIBRATION: + case SEAT_MOTOR_TASK_SPEED_CAL_WARMUP: + case SEAT_MOTOR_TASK_SPEED_CAL_MEASURE: + case SEAT_MOTOR_TASK_SPEED_CAL_SETTLE: + case SEAT_MOTOR_TASK_FAILED: + default: + return SEAT_TIMED_CAN_TASK_FAULT; + } +} + +static uint8_t SeatTimed_MapTaskResult(const seat_motor_status_t *status) +{ + switch(status->result) + { + case SEAT_MOTOR_RESULT_NONE: + return SEAT_TIMED_CAN_RESULT_NONE; + case SEAT_MOTOR_RESULT_RUNNING: + return SEAT_TIMED_CAN_RESULT_RUNNING; + case SEAT_MOTOR_RESULT_OK: + return SEAT_TIMED_CAN_RESULT_OK; + case SEAT_MOTOR_RESULT_STOPPED: + return SEAT_TIMED_CAN_RESULT_STOPPED_BY_CMD; + case SEAT_MOTOR_RESULT_ENDPOINT: + return SEAT_TIMED_CAN_RESULT_ENDPOINT_REACHED; + case SEAT_MOTOR_RESULT_STALL: + return SEAT_TIMED_CAN_RESULT_STALL; + case SEAT_MOTOR_RESULT_TIMEOUT: + return SEAT_TIMED_CAN_RESULT_TIMEOUT; + case SEAT_MOTOR_RESULT_DRIVER_FAULT: + return SEAT_TIMED_CAN_RESULT_DRIVER_FAULT; + case SEAT_MOTOR_RESULT_CALIBRATION_FAILED: + return SEAT_TIMED_CAN_RESULT_CALIBRATION_FAILED; + case SEAT_MOTOR_RESULT_INVALID: + default: + return SEAT_TIMED_CAN_RESULT_INVALID_CMD; + } +} + +static uint8_t SeatTimed_SystemState(void) +{ + if(SafetyManager_IsInhibited()) + { + return (SafetyManager_GetFault() == SAFETY_FAULT_NONE) ? + SEAT_TIMED_SYSTEM_INHIBITED : SEAT_TIMED_SYSTEM_FAULT; + } + if((SeatTimed_FaultedMask() != 0u) || + (SafetyManager_GetFault() != SAFETY_FAULT_NONE)) + { + return SEAT_TIMED_SYSTEM_FAULT; + } + if(SeatTimed_ActiveMask() != 0u) + { + return SEAT_TIMED_SYSTEM_ACTIVE; + } + return SEAT_TIMED_SYSTEM_READY; +} + +static bool SeatTimed_SendSystemStatus(void) +{ + uint8_t activeMask = SeatTimed_ActiveMask(); + uint8_t faultedMask = SeatTimed_FaultedMask(); + uint8_t positionValidMask = SeatTimed_PositionValidMask(); + bool faultSummary = (faultedMask != 0u) || + (SafetyManager_GetFault() != SAFETY_FAULT_NONE); + uint8_t byte0 = (uint8_t)((g_seatTimedAck & 0x0Fu) | + ((SeatTimed_SystemState() & 0x0Fu) << 4u)); + uint8_t byte1 = g_seatTimedRejectReason; + uint8_t byte4 = g_seatTimedLastOpcode; + uint8_t byte5 = (uint8_t)((activeMask & 0x0Fu) | + (SafetyManager_IsInhibited() ? 0x10u : 0u) | + (faultSummary ? 0x80u : 0u)); + uint8_t byte6 = (uint8_t)((faultedMask & 0x0Fu) | + ((positionValidMask & 0x0Fu) << 4u)); + uint8_t byte7 = (uint8_t)((s_systemAlive & 0x0Fu) | + ((SEAT_MOVE_FIRMWARE_REVISION & 0x0Fu) << 4u)); + uint32_t data0 = (uint32_t)byte0 | + ((uint32_t)byte1 << 8u) | + ((uint32_t)g_seatTimedLastSequence << 16u); + uint32_t data1 = (uint32_t)byte4 | + ((uint32_t)byte5 << 8u) | + ((uint32_t)byte6 << 16u) | + ((uint32_t)byte7 << 24u); + + return BspSeatMoveCan_Send(SEAT_MOVE_CAN_SYSTEM_STATUS_ID, data0, data1); +} + +static bool SeatTimed_SendMotorStatus(uint8_t motor) +{ + seat_motor_status_t status; + seat_timed_command_t *pendingCommand = &s_pending[motor].command; + uint8_t taskState; + uint8_t result; + uint8_t direction; + uint8_t flags; + uint8_t duty; + uint16_t sequence; + uint16_t position; + uint32_t data0; + uint32_t data1; + + if(!SeatTimed_GetStatus(motor, &status)) + { + return false; + } + taskState = SeatTimed_MapTaskState(&status); + result = SeatTimed_MapTaskResult(&status); + direction = (uint8_t)status.direction; + duty = status.active ? status.dutyPercent : 0u; + sequence = (uint16_t)status.commandSequence; + if(s_pending[motor].valid) + { + taskState = SEAT_TIMED_CAN_TASK_ACCEPTED; + result = SEAT_TIMED_CAN_RESULT_RUNNING; + direction = (uint8_t)pendingCommand->direction; + sequence = pendingCommand->sequence; + duty = SeatTimed_CommandDutyPercent(pendingCommand); + } + flags = (uint8_t)((direction & 0x03u) | + ((status.active || s_pending[motor].valid) ? 0x04u : 0u) | + (status.positionValid ? 0x08u : 0u) | + ((status.positionValid && (status.hallCount == 0u)) ? 0x10u : 0u) | + ((status.positionValid && (status.maxHallCount != 0u) && + (status.hallCount >= status.maxHallCount)) ? 0x20u : 0u) | + ((status.result == SEAT_MOTOR_RESULT_STALL) ? 0x40u : 0u) | + (((status.result == SEAT_MOTOR_RESULT_DRIVER_FAULT) || + Motor_IsDriverFaultLatched()) ? 0x80u : 0u)); + position = status.positionValid ? + (uint16_t)((status.positionPercentX100 > 10000u) ? + 10000u : status.positionPercentX100) : UINT16_MAX; + data0 = (uint32_t)motor | + ((uint32_t)((taskState & 0x0Fu) | + ((result & 0x0Fu) << 4u)) << 8u) | + ((uint32_t)flags << 16u) | + ((uint32_t)(sequence & 0xFFu) << 24u); + data1 = (uint32_t)(sequence >> 8u) | + ((uint32_t)position << 8u) | + ((uint32_t)duty << 24u); + return BspSeatMoveCan_Send(SEAT_MOVE_CAN_MOTOR_STATUS_ID, data0, data1); +} + +static bool SeatTimed_SendCurrentDiagnostic(uint8_t motor) +{ + motor_current_info_t current; + uint8_t flags; + uint32_t data0; + uint32_t data1; + + if((motor >= SEAT_MOVE_MOTOR_COUNT) || + (MOTOR_API_SUCCESS != Motor_GetCurrentInfo( + SeatTimed_PhysicalMode(motor), ¤t))) + { + return false; + } + flags = (uint8_t) + ((current.sampleValid ? 0x01u : 0u) | + (current.baselineValid ? 0x02u : 0u) | + (current.highCurrent ? 0x04u : 0u) | + (current.hallActivityLow ? 0x08u : 0u) | + (current.calibrating ? 0x10u : 0u)); + data0 = (uint32_t)motor | + ((uint32_t)flags << 8u) | + ((uint32_t)current.raw << 16u); + data1 = (uint32_t)current.baselineRaw | + ((uint32_t)current.stallThresholdRaw << 16u); + return BspSeatMoveCan_Send(SEAT_MOVE_CAN_CURRENT_DIAGNOSTIC_ID, + data0, + data1); +} + +static bool SeatTimed_SendDiagnostic(uint8_t motor) +{ + seat_motor_status_t status; + uint16_t hallCount; + uint16_t maxHallCount; + uint32_t speedRaw; + uint8_t faultFlags; + uint8_t byte1; + uint8_t byte7; + uint32_t data0; + uint32_t data1; + + if(!SeatTimed_GetStatus(motor, &status)) + { + return false; + } + hallCount = (status.hallCount > UINT16_MAX) ? + UINT16_MAX : (uint16_t)status.hallCount; + maxHallCount = (status.maxHallCount > UINT16_MAX) ? + UINT16_MAX : (uint16_t)status.maxHallCount; + speedRaw = (status.hallSpeedPpsX100 + 25u) / 50u; + if(speedRaw > UINT8_MAX) + { + speedRaw = UINT8_MAX; + } + faultFlags = (uint8_t) + (((status.result == SEAT_MOTOR_RESULT_STALL) ? 0x01u : 0u) | + (((status.result == SEAT_MOTOR_RESULT_DRIVER_FAULT) || + Motor_IsDriverFaultLatched()) ? 0x02u : 0u) | + ((status.result == SEAT_MOTOR_RESULT_INVALID) ? 0x04u : 0u) | + ((status.result == SEAT_MOTOR_RESULT_CALIBRATION_FAILED) ? + 0x08u : 0u)); + byte1 = (uint8_t)(((uint8_t)status.lastMotorEvent & 0x0Fu) | + ((faultFlags & 0x0Fu) << 4u)); + byte7 = (uint8_t)((status.retryCount & 0x0Fu) | + ((s_diagnosticAlive[motor] & 0x0Fu) << 4u)); + data0 = (uint32_t)motor | + ((uint32_t)byte1 << 8u) | + ((uint32_t)hallCount << 16u); + data1 = (uint32_t)maxHallCount | + ((uint32_t)((uint8_t)speedRaw) << 16u) | + ((uint32_t)byte7 << 24u); + if(!BspSeatMoveCan_Send(SEAT_MOVE_CAN_DIAGNOSTIC_ID, data0, data1)) + { + return false; + } + return SeatTimed_SendCurrentDiagnostic(motor); +} + +static bool SeatTimed_SendFirmwareIdentity(void) +{ + /* Bytes 0..3 are ASCII "PST1" (Passenger Seat Timed v1). */ + uint32_t data0 = (uint32_t)0x50u | + ((uint32_t)0x53u << 8u) | + ((uint32_t)0x54u << 16u) | + ((uint32_t)0x31u << 24u); + uint32_t data1 = (uint32_t)SEAT_MOVE_FIRMWARE_REVISION | + ((uint32_t)SEAT_MOVE_FIRMWARE_BUILD_ID << 8u) | + ((uint32_t)SEAT_MOVE_MOTOR_COUNT << 16u) | + ((uint32_t)SEAT_MOVE_CAN_PROTOCOL_VERSION << 24u); + + return BspSeatMoveCan_Send(SEAT_MOVE_CAN_FIRMWARE_ID_ID, data0, data1); +} + +static void SeatTimed_ServiceFeedback(void) +{ + uint32_t now = Motor_GetTimeMs(); + uint8_t motor; + + if(SeatTimed_TimeReached(now, s_nextSystemStatusMs)) + { + s_systemStatusPending = true; + s_nextSystemStatusMs = now + SEAT_MOVE_SYSTEM_STATUS_PERIOD_MS; + } + if(SeatTimed_TimeReached(now, s_nextFirmwareIdMs)) + { + s_firmwareIdPending = true; + s_nextFirmwareIdMs = now + SEAT_MOVE_FIRMWARE_ID_PERIOD_MS; + } + if(!s_motorPeriodicPending && + SeatTimed_TimeReached(now, s_nextMotorPollMs)) + { + s_motorPeriodicId = s_motorPollCursor; + s_motorStatusPendingMask |= (uint8_t)(1u << s_motorPeriodicId); + s_motorPeriodicPending = true; + } + if(!s_diagnosticPeriodicPending && + SeatTimed_TimeReached(now, s_nextDiagnosticPollMs)) + { + s_diagnosticPeriodicId = s_diagnosticPollCursor; + s_diagnosticPendingMask |= (uint8_t)(1u << s_diagnosticPeriodicId); + s_diagnosticPeriodicPending = true; + } + + if(s_systemStatusPending) + { + if(SeatTimed_SendSystemStatus()) + { + s_systemStatusPending = false; + s_systemAlive = (uint8_t)((s_systemAlive + 1u) & 0x0Fu); + if(s_ackNeedsClear) + { + g_seatTimedAck = SEAT_TIMED_ACK_NONE; + g_seatTimedRejectReason = SEAT_TIMED_REJECT_NONE; + s_ackNeedsClear = false; + } + g_seatTimedFeedbackTxCount++; + } + else + { + g_seatTimedFeedbackTxFailureCount++; + } + return; + } + if(s_firmwareIdPending) + { + if(SeatTimed_SendFirmwareIdentity()) + { + s_firmwareIdPending = false; + g_seatTimedFeedbackTxCount++; + } + else + { + g_seatTimedFeedbackTxFailureCount++; + } + return; + } + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + uint8_t bit = (uint8_t)(1u << motor); + + if((s_motorStatusPendingMask & bit) != 0u) + { + if(SeatTimed_SendMotorStatus(motor)) + { + s_motorStatusPendingMask &= (uint8_t)(~bit); + if(s_motorPeriodicPending && + (motor == s_motorPeriodicId)) + { + s_motorPollCursor = (uint8_t)((s_motorPollCursor + 1u) % + SEAT_MOVE_MOTOR_COUNT); + s_motorPeriodicPending = false; + s_nextMotorPollMs = now + SEAT_MOVE_MOTOR_STATUS_POLL_MS; + } + g_seatTimedFeedbackTxCount++; + } + else + { + g_seatTimedFeedbackTxFailureCount++; + } + return; + } + } + for(motor = 0u; motor < SEAT_MOVE_MOTOR_COUNT; motor++) + { + uint8_t bit = (uint8_t)(1u << motor); + + if((s_diagnosticPendingMask & bit) != 0u) + { + if(SeatTimed_SendDiagnostic(motor)) + { + s_diagnosticPendingMask &= (uint8_t)(~bit); + s_diagnosticAlive[motor] = (uint8_t) + ((s_diagnosticAlive[motor] + 1u) & 0x0Fu); + if(s_diagnosticPeriodicPending && + (motor == s_diagnosticPeriodicId)) + { + s_diagnosticPollCursor = (uint8_t) + ((s_diagnosticPollCursor + 1u) % + SEAT_MOVE_MOTOR_COUNT); + s_diagnosticPeriodicPending = false; + s_nextDiagnosticPollMs = now + + SEAT_MOVE_DIAGNOSTIC_POLL_MS; + } + g_seatTimedFeedbackTxCount++; + } + else + { + g_seatTimedFeedbackTxFailureCount++; + } + return; + } + } +} + +bool SeatTimedApplication_Init(void) +{ + uint32_t now; + + (void)memset(s_pending, 0, sizeof(s_pending)); + (void)memset(s_lastStatus, 0, sizeof(s_lastStatus)); + (void)memset(s_lastStatusValid, 0, sizeof(s_lastStatusValid)); + (void)memset(s_diagnosticAlive, 0, sizeof(s_diagnosticAlive)); + SeatMotorManager_Init(); + if(!BspSeatMoveCan_Init()) + { + SafetyManager_LatchFault(SAFETY_FAULT_CAN_INIT); + return false; + } + + now = Motor_GetTimeMs(); + s_nextStartMs = now; + s_nextSystemStatusMs = now; + s_nextMotorPollMs = now; + s_nextDiagnosticPollMs = now; + s_nextFirmwareIdMs = now; + s_systemStatusPending = true; + s_motorStatusPendingMask = SEAT_MOVE_ACTIVE_MOTOR_MASK; + s_diagnosticPendingMask = SEAT_MOVE_ACTIVE_MOTOR_MASK; + s_firmwareIdPending = true; + return true; +} + +void SeatTimedApplication_Service(void) +{ + bsp_seat_move_can_frame_t frame; + + if(g_bspSeatMoveCanBusOff != 0u) + { + SafetyManager_LatchFault(SAFETY_FAULT_CAN_BUS_OFF); + } + SafetyManager_Service(); + if(SafetyManager_IsInhibited()) + { + if(!s_inhibitHandled) + { + SeatTimedApplication_StopAll(g_seatTimedLastSequence); + s_inhibitHandled = true; + } + } + else + { + s_inhibitHandled = false; + } + + while(BspSeatMoveCan_Read(&frame)) + { + if(frame.identifier == SEAT_MOVE_CAN_COMMAND_ID) + { + SeatTimed_HandleCommand(&frame); + } + } + SeatTimed_ServiceDispatcher(); + SeatMotorManager_Service(); + SeatTimed_ServiceStateChanges(); + SeatTimed_ServiceFeedback(); +} + +/* The low-level driver declares this hook for blocking service primitives. + * This timed-only application never invokes those primitives, but supplies + * the hook so STOP_ALL/status remain safe if a diagnostic build calls one. */ +void Motor_CalibrationServiceHook(void) +{ + bsp_seat_move_can_frame_t frame; + + while(BspSeatMoveCan_Read(&frame)) + { + if(frame.identifier == SEAT_MOVE_CAN_COMMAND_ID) + { + SeatTimed_HandleCommand(&frame); + } + } + if(g_bspSeatMoveCanBusOff != 0u) + { + SafetyManager_LatchFault(SAFETY_FAULT_CAN_BUS_OFF); + } + SafetyManager_Service(); + SeatTimed_ServiceStateChanges(); + SeatTimed_ServiceFeedback(); +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.h new file mode 100644 index 0000000..2a253a1 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.h @@ -0,0 +1,11 @@ +#ifndef SEAT_TIMED_APPLICATION_H +#define SEAT_TIMED_APPLICATION_H + +#include +#include + +bool SeatTimedApplication_Init(void); +void SeatTimedApplication_Service(void); +void SeatTimedApplication_StopAll(uint16_t sequence); + +#endif /* SEAT_TIMED_APPLICATION_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_backrest_heater.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_backrest_heater.c new file mode 100644 index 0000000..9482008 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_backrest_heater.c @@ -0,0 +1,687 @@ +#include "bsp_seat_backrest_heater.h" + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "seat_backrest_heater_config.h" + +static bool BspSeatBackrestHeater_ConfigClock(void); +static uint32_t BspSeatBackrestHeater_DutyToCompare(uint32_t dutyPercent); +static void BspSeatBackrestHeater_SetPinGpioLow(void); +static bool BspSeatBackrestHeaterCan_ConfigClock(void); +static bool BspSeatBackrestHeaterCan_ConfigPins(void); +static void BspSeatBackrestHeaterCan_IrqHandler(void); +static void BspSeatBackrestHeaterCan_RxCallback( + bool rxFifoMessage, + uint8_t rxBufferOrFifo, + cy_stc_canfd_msg_t *message); + +static uint32_t s_heaterDutyPercent = 0u; + +volatile uint32_t g_bspSeatBackrestHeaterStartFailStage = 0u; + +volatile uint32_t g_bspSeatBackrestHeaterCanInitStatus = + (uint32_t)CY_CANFD_SUCCESS; +volatile uint32_t g_bspSeatBackrestHeaterCanRxCount = 0u; +volatile uint32_t g_bspSeatBackrestHeaterCanRxQueueOverflowCount = 0u; +volatile uint32_t g_bspSeatBackrestHeaterCanTxCount = 0u; +volatile uint32_t g_bspSeatBackrestHeaterCanTxBusyCount = 0u; +volatile uint32_t g_bspSeatBackrestHeaterCanLastRxId = 0u; +volatile uint32_t g_bspSeatBackrestHeaterCanLastTxId = 0u; +volatile uint32_t g_bspSeatBackrestHeaterCanLastTxStatus = + (uint32_t)CY_CANFD_SUCCESS; + +static volatile bsp_seat_backrest_heater_can_frame_t + s_canRxQueue[SEAT_BACKREST_HEATER_CAN_RX_QUEUE_DEPTH]; +static volatile uint8_t s_canRxQueueHead = 0u; +static volatile uint8_t s_canRxQueueTail = 0u; + +static const cy_stc_id_filter_t s_canStandardIdFilters[] = +{ + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_BACKREST_HEATER_CAN_CONTROL_29E_ID, + SEAT_BACKREST_HEATER_CAN_CONTROL_29E_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_BACKREST_HEATER_CAN_CONFIG_2AA_ID, + SEAT_BACKREST_HEATER_CAN_CONFIG_2AA_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_BACKREST_HEATER_CAN_MODE_3CE_ID, + SEAT_BACKREST_HEATER_CAN_MODE_3CE_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_BACKREST_HEATER_CAN_SWITCH_42A_ID, + SEAT_BACKREST_HEATER_CAN_SWITCH_42A_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), +}; + +static cy_stc_sysint_irq_t s_canIrqConfig = +{ + .sysIntSrc = SEAT_BACKREST_HEATER_CAN_IRQN, + .intIdx = SEAT_BACKREST_HEATER_CAN_CPU_IRQ, + .isEnabled = true, +}; + +static cy_stc_canfd_config_t s_canConfig = +{ + .txCallback = NULL, + .rxCallback = BspSeatBackrestHeaterCan_RxCallback, + .rxFifoWithTopCallback = NULL, + .statusCallback = NULL, + .errorCallback = NULL, + .canFDMode = false, + .bitrate = + { + /* 40 MHz / (10 * (1 + 5 + 2)) = 500 kbit/s. */ + .prescaler = 10u - 1u, + .timeSegment1 = 5u - 1u, + .timeSegment2 = 2u - 1u, + .syncJumpWidth = 2u - 1u, + }, + .fastBitrate = + { + .prescaler = 0u, + .timeSegment1 = 0u, + .timeSegment2 = 0u, + .syncJumpWidth = 0u, + }, + .tdcConfig = + { + .tdcEnabled = false, + .tdcOffset = 0u, + .tdcFilterWindow = 0u, + }, + .sidFilterConfig = + { + .numberOfSIDFilters = + sizeof(s_canStandardIdFilters) / + sizeof(s_canStandardIdFilters[0]), + .sidFilter = s_canStandardIdFilters, + }, + .extidFilterConfig = + { + .numberOfEXTIDFilters = 0u, + .extidFilter = NULL, + .extIDANDMask = 0x1FFFFFFFu, + }, + .globalFilterConfig = + { + .nonMatchingFramesStandard = CY_CANFD_REJECT_NON_MATCHING, + .nonMatchingFramesExtended = CY_CANFD_REJECT_NON_MATCHING, + .rejectRemoteFramesStandard = true, + .rejectRemoteFramesExtended = true, + }, + .rxBufferDataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo1DataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo0DataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .txBufferDataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo0Config = + { + .mode = CY_CANFD_FIFO_MODE_BLOCKING, + .watermark = 0u, + .numberOfFifoElements = + SEAT_BACKREST_HEATER_CAN_RX_FIFO0_ELEMENTS, + .topPointerLogicEnabled = false, + }, + .rxFifo1Config = + { + .mode = CY_CANFD_FIFO_MODE_BLOCKING, + .watermark = 0u, + .numberOfFifoElements = 0u, + .topPointerLogicEnabled = false, + }, + .noOfRxBuffers = 0u, + .noOfTxBuffers = SEAT_BACKREST_HEATER_CAN_TX_BUFFER_COUNT, +}; + +static const cy_stc_tcpwm_pwm_config_t s_heaterPwmConfig = +{ + .pwmMode = CY_TCPWM_PWM_MODE_PWM, + .clockPrescaler = CY_TCPWM_PRESCALER_DIVBY_1, + .debug_pause = false, + .deadTime = 0u, + .deadTimeComp = 0u, + .runMode = CY_TCPWM_PWM_CONTINUOUS, + .countDirection = CY_TCPWM_COUNTER_COUNT_UP, + .cc0MatchMode = CY_TCPWM_PWM_TR_CTRL2_CLEAR, + .overflowMode = CY_TCPWM_PWM_TR_CTRL2_SET, + .underflowMode = CY_TCPWM_PWM_TR_CTRL2_NO_CHANGE, + .cc1MatchMode = CY_TCPWM_PWM_TR_CTRL2_NO_CHANGE, + .period = SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS - 1u, + .period_buff = 0u, + .enablePeriodSwap = false, + .enableLineSelSwap = false, + .compare0 = 0u, + .compare0_buff = 0u, + .enableCompare0Swap = false, + .compare1 = 0u, + .compare1_buff = 0u, + .enableCompare1Swap = false, + .interruptSources = CY_TCPWM_INT_NONE, + .invertPWMOut = 0u, + .invertPWMOutN = 0u, + .killMode = CY_TCPWM_PWM_STOP_ON_KILL, + .switchInputMode = CY_TCPWM_INPUT_LEVEL, + .switchInput = 0u, + .reloadInputMode = CY_TCPWM_INPUT_LEVEL, + .reloadInput = 0u, + .startInputMode = CY_TCPWM_INPUT_LEVEL, + .startInput = 0u, + .kill0InputMode = CY_TCPWM_INPUT_LEVEL, + .kill0Input = 0u, + .kill1InputMode = CY_TCPWM_INPUT_LEVEL, + .kill1Input = 0u, + .countInputMode = CY_TCPWM_INPUT_LEVEL, + .countInput = 1u, + .immediateKill = true, + .pwmOnDisable = CY_TCPWM_PWM_OUT_MODE_LOW, + .trigger0EventCfg = CY_TCPWM_COUNTER_OVERFLOW, + .trigger1EventCfg = CY_TCPWM_COUNTER_OVERFLOW, +}; + +bool BspSeatBackrestHeater_ForceOff(void) +{ + cy_stc_gpio_pin_config_t pinConfig = + { + .outVal = 0u, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = SEAT_BACKREST_HEATER_PWM_GPIO_HSIOM, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = CY_GPIO_DRIVE_FULL, + }; + cy_en_gpio_status_t status; + + status = Cy_GPIO_Pin_Init(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + &pinConfig); + Cy_GPIO_Write(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + 0u); + s_heaterDutyPercent = 0u; + + return ((status == CY_GPIO_SUCCESS) && + (Cy_GPIO_GetHSIOM(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN) == + SEAT_BACKREST_HEATER_PWM_GPIO_HSIOM)); +} + +bool BspSeatBackrestHeater_InitPwm(void) +{ + if(!BspSeatBackrestHeater_ConfigClock()) + { + return false; + } + + Cy_Tcpwm_Pwm_Disable(SEAT_BACKREST_HEATER_PWM_TCPWM); + if(Cy_Tcpwm_Pwm_Init(SEAT_BACKREST_HEATER_PWM_TCPWM, + &s_heaterPwmConfig) != CY_RET_SUCCESS) + { + return false; + } + + return ((Cy_Tcpwm_Pwm_GetPeriod(SEAT_BACKREST_HEATER_PWM_TCPWM) == + (SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS - 1u)) && + (Cy_Tcpwm_Pwm_GetCompare0(SEAT_BACKREST_HEATER_PWM_TCPWM) == 0u)); +} + +bool BspSeatBackrestHeater_Start(uint32_t dutyPercent) +{ + g_bspSeatBackrestHeaterStartFailStage = 0u; + if(!BspSeatBackrestHeater_SetDutyPercent(dutyPercent)) + { + g_bspSeatBackrestHeaterStartFailStage = 1u; + return false; + } + + /* P16.7 remains GPIO low until the PWM counter is confirmed running. */ + Cy_GPIO_Write(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + 0u); + Cy_Tcpwm_Pwm_SetCounter(SEAT_BACKREST_HEATER_PWM_TCPWM, + SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS - 1u); + Cy_Tcpwm_Pwm_Enable(SEAT_BACKREST_HEATER_PWM_TCPWM); + Cy_Tcpwm_TriggerStart(SEAT_BACKREST_HEATER_PWM_TCPWM); + Cy_SysTick_DelayInUs(2u); + + if((Cy_Tcpwm_Pwm_GetStatus(SEAT_BACKREST_HEATER_PWM_TCPWM) & + TCPWM_GRP_CNT_STATUS_RUNNING_Msk) == 0u) + { + g_bspSeatBackrestHeaterStartFailStage = 2u; + BspSeatBackrestHeater_FailSafeOff(); + return false; + } + + Cy_GPIO_SetDriveSel(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + CY_GPIO_DRIVE_FULL); + Cy_GPIO_SetDrivemode(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + CY_GPIO_DM_STRONG_IN_OFF); + Cy_GPIO_SetHSIOM(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + SEAT_BACKREST_HEATER_PWM_HSIOM); + + if(Cy_GPIO_GetHSIOM(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN) != + SEAT_BACKREST_HEATER_PWM_HSIOM) + { + g_bspSeatBackrestHeaterStartFailStage = 3u; + BspSeatBackrestHeater_FailSafeOff(); + return false; + } + + return true; +} + +bool BspSeatBackrestHeater_SetDutyPercent(uint32_t dutyPercent) +{ + uint32_t compare; + + /* A true OFF uses FailSafeOff, avoiding a possible zero-compare pulse. */ + if((dutyPercent == 0u) || (dutyPercent > 100u)) + { + return false; + } + + compare = BspSeatBackrestHeater_DutyToCompare(dutyPercent); + Cy_Tcpwm_Pwm_SetCompare0(SEAT_BACKREST_HEATER_PWM_TCPWM, compare); + + if(Cy_Tcpwm_Pwm_GetCompare0(SEAT_BACKREST_HEATER_PWM_TCPWM) != compare) + { + return false; + } + + s_heaterDutyPercent = dutyPercent; + return true; +} + +void BspSeatBackrestHeater_FailSafeOff(void) +{ + Cy_Tcpwm_Pwm_Disable(SEAT_BACKREST_HEATER_PWM_TCPWM); + BspSeatBackrestHeater_SetPinGpioLow(); + s_heaterDutyPercent = 0u; +} + +bool BspSeatBackrestHeater_IsRunning(void) +{ + return ((Cy_Tcpwm_Pwm_GetStatus(SEAT_BACKREST_HEATER_PWM_TCPWM) & + TCPWM_GRP_CNT_STATUS_RUNNING_Msk) != 0u); +} + +uint32_t BspSeatBackrestHeater_GetDutyPercent(void) +{ + return s_heaterDutyPercent; +} + +bool BspSeatBackrestHeaterCan_Init(void) +{ + cy_en_canfd_status_t canStatus; + + if(!BspSeatBackrestHeaterCan_ConfigClock() || + !BspSeatBackrestHeaterCan_ConfigPins()) + { + g_bspSeatBackrestHeaterCanInitStatus = + (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + if(Cy_SysInt_InitIRQ(&s_canIrqConfig) != CY_SYSINT_SUCCESS) + { + g_bspSeatBackrestHeaterCanInitStatus = + (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + Cy_SysInt_SetSystemIrqVector(s_canIrqConfig.sysIntSrc, + BspSeatBackrestHeaterCan_IrqHandler); + NVIC_SetPriority(s_canIrqConfig.intIdx, 0u); + NVIC_ClearPendingIRQ(s_canIrqConfig.intIdx); + + (void)Cy_CANFD_DeInit(SEAT_BACKREST_HEATER_CAN_TYPE); + canStatus = Cy_CANFD_Init(SEAT_BACKREST_HEATER_CAN_TYPE, &s_canConfig); + g_bspSeatBackrestHeaterCanInitStatus = (uint32_t)canStatus; + if(canStatus != CY_CANFD_SUCCESS) + { + return false; + } + + NVIC_EnableIRQ(s_canIrqConfig.intIdx); + return true; +} + +bool BspSeatBackrestHeaterCan_Read( + bsp_seat_backrest_heater_can_frame_t *frame) +{ + uint32_t interruptState; + uint8_t tail; + + if(frame == NULL) + { + return false; + } + + interruptState = Cy_SysLib_EnterCriticalSection(); + tail = s_canRxQueueTail; + if(tail == s_canRxQueueHead) + { + Cy_SysLib_ExitCriticalSection(interruptState); + return false; + } + + frame->identifier = s_canRxQueue[tail].identifier; + frame->data0 = s_canRxQueue[tail].data0; + frame->data1 = s_canRxQueue[tail].data1; + frame->dlc = s_canRxQueue[tail].dlc; + + tail++; + if(tail >= SEAT_BACKREST_HEATER_CAN_RX_QUEUE_DEPTH) + { + tail = 0u; + } + s_canRxQueueTail = tail; + Cy_SysLib_ExitCriticalSection(interruptState); + + return true; +} + +bool BspSeatBackrestHeaterCan_Send(uint32_t identifier, + uint32_t data0, + uint32_t data1) +{ + cy_stc_canfd_msg_t message; + uint8_t bufferIndex; + uint8_t dataIndex; + + if(identifier > 0x7FFu) + { + g_bspSeatBackrestHeaterCanLastTxStatus = + (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + message.canFDFormat = false; + message.idConfig.extended = false; + message.idConfig.identifier = identifier; + message.dataConfig.dataLengthCode = SEAT_BACKREST_HEATER_CAN_DLC; + message.dataConfig.data[0] = data0; + message.dataConfig.data[1] = data1; + for(dataIndex = 2u; + dataIndex < CY_CANFD_MESSAGE_DATA_BUFFER_SIZE; + dataIndex++) + { + message.dataConfig.data[dataIndex] = 0u; + } + + for(bufferIndex = 0u; + bufferIndex < SEAT_BACKREST_HEATER_CAN_TX_BUFFER_COUNT; + bufferIndex++) + { + cy_en_canfd_tx_buffer_status_t bufferStatus = + Cy_CANFD_GetTxBufferStatus(SEAT_BACKREST_HEATER_CAN_TYPE, + bufferIndex); + + if((bufferStatus != CY_CANFD_TX_BUFFER_PENDING) && + (bufferStatus != CY_CANFD_TX_BUFFER_CANCEL_REQUESTED)) + { + cy_en_canfd_status_t sendStatus; + + g_bspSeatBackrestHeaterCanLastTxId = identifier; + sendStatus = Cy_CANFD_UpdateAndTransmitMsgBuffer( + SEAT_BACKREST_HEATER_CAN_TYPE, + bufferIndex, + &message); + g_bspSeatBackrestHeaterCanLastTxStatus = (uint32_t)sendStatus; + if(sendStatus == CY_CANFD_SUCCESS) + { + g_bspSeatBackrestHeaterCanTxCount++; + return true; + } + return false; + } + } + + g_bspSeatBackrestHeaterCanTxBusyCount++; + return false; +} + +static bool BspSeatBackrestHeaterCan_ConfigClock(void) +{ + uint32_t periClockHz = 0u; + uint32_t divider; + uint32_t clockGroup; + + if(Cy_SysClk_GetClkPeriFrequency(&periClockHz) != CY_SYSCLK_SUCCESS) + { + return false; + } + + if((periClockHz < SEAT_BACKREST_HEATER_CAN_CLOCK_HZ) || + ((periClockHz % SEAT_BACKREST_HEATER_CAN_CLOCK_HZ) != 0u)) + { + return false; + } + + divider = periClockHz / SEAT_BACKREST_HEATER_CAN_CLOCK_HZ; + if((divider == 0u) || (divider > 256u)) + { + return false; + } + + clockGroup = Cy_SysClk_GetClockGroup(SEAT_BACKREST_HEATER_CAN_PCLK); + if(Cy_SysClk_PeriphDisableDivider( + clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_BACKREST_HEATER_CAN_DIVIDER_NUMBER) != CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphAssignDivider( + SEAT_BACKREST_HEATER_CAN_PCLK, + CY_SYSCLK_DIV_8_BIT, + SEAT_BACKREST_HEATER_CAN_DIVIDER_NUMBER) != CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphSetDivider( + clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_BACKREST_HEATER_CAN_DIVIDER_NUMBER, + divider - 1u) != CY_SYSCLK_SUCCESS) + { + return false; + } + + return (Cy_SysClk_PeriphEnableDivider( + clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_BACKREST_HEATER_CAN_DIVIDER_NUMBER) == + CY_SYSCLK_SUCCESS); +} + +static bool BspSeatBackrestHeaterCan_ConfigPins(void) +{ + cy_stc_gpio_pin_config_t rxConfig = + { + .outVal = 0u, + .driveMode = CY_GPIO_DM_HIGHZ, + .hsiom = SEAT_BACKREST_HEATER_CAN_RX_MUX, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_gpio_pin_config_t txConfig = + { + .outVal = 1u, + .driveMode = CY_GPIO_DM_STRONG, + .hsiom = SEAT_BACKREST_HEATER_CAN_TX_MUX, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_gpio_pin_config_t transceiverConfig = + { + .outVal = SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_ENABLE, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_GPIO_HSIOM, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = CY_GPIO_DRIVE_FULL, + }; + + if(Cy_GPIO_Pin_Init(SEAT_BACKREST_HEATER_CAN_RX_PORT, + SEAT_BACKREST_HEATER_CAN_RX_PIN, + &rxConfig) != CY_GPIO_SUCCESS) + { + return false; + } + if(Cy_GPIO_Pin_Init(SEAT_BACKREST_HEATER_CAN_TX_PORT, + SEAT_BACKREST_HEATER_CAN_TX_PIN, + &txConfig) != CY_GPIO_SUCCESS) + { + return false; + } + if(Cy_GPIO_Pin_Init(SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PORT, + SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PIN, + &transceiverConfig) != CY_GPIO_SUCCESS) + { + return false; + } + + Cy_GPIO_Write(SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PORT, + SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PIN, + SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_ENABLE); + Cy_SysTick_DelayInUs(1000u); + + return (Cy_GPIO_ReadOut(SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PORT, + SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PIN) == + SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_ENABLE); +} + +static void BspSeatBackrestHeaterCan_IrqHandler(void) +{ + Cy_CANFD_IrqHandler(SEAT_BACKREST_HEATER_CAN_TYPE); +} + +static void BspSeatBackrestHeaterCan_RxCallback( + bool rxFifoMessage, + uint8_t rxBufferOrFifo, + cy_stc_canfd_msg_t *message) +{ + uint8_t head; + uint8_t nextHead; + + (void)rxFifoMessage; + (void)rxBufferOrFifo; + + if((message == NULL) || message->idConfig.extended || message->canFDFormat) + { + return; + } + + g_bspSeatBackrestHeaterCanRxCount++; + g_bspSeatBackrestHeaterCanLastRxId = message->idConfig.identifier; + + head = s_canRxQueueHead; + nextHead = head + 1u; + if(nextHead >= SEAT_BACKREST_HEATER_CAN_RX_QUEUE_DEPTH) + { + nextHead = 0u; + } + + if(nextHead == s_canRxQueueTail) + { + g_bspSeatBackrestHeaterCanRxQueueOverflowCount++; + return; + } + + s_canRxQueue[head].identifier = message->idConfig.identifier; + s_canRxQueue[head].dlc = message->dataConfig.dataLengthCode; + s_canRxQueue[head].data0 = message->dataConfig.data[0]; + s_canRxQueue[head].data1 = message->dataConfig.data[1]; + s_canRxQueueHead = nextHead; +} + +static bool BspSeatBackrestHeater_ConfigClock(void) +{ + uint32_t periClockHz = 0u; + uint32_t divider; + uint32_t clockGroup; + + if(Cy_SysClk_GetClkPeriFrequency(&periClockHz) != CY_SYSCLK_SUCCESS) + { + return false; + } + + if((periClockHz < SEAT_BACKREST_HEATER_COUNTER_CLOCK_HZ) || + ((periClockHz % SEAT_BACKREST_HEATER_COUNTER_CLOCK_HZ) != 0u)) + { + return false; + } + + divider = periClockHz / SEAT_BACKREST_HEATER_COUNTER_CLOCK_HZ; + if((divider == 0u) || (divider > 65536u)) + { + return false; + } + + clockGroup = Cy_SysClk_GetClockGroup(SEAT_BACKREST_HEATER_PWM_PCLK); + if(Cy_SysClk_PeriphDisableDivider(clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_BACKREST_HEATER_PWM_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphAssignDivider(SEAT_BACKREST_HEATER_PWM_PCLK, + CY_SYSCLK_DIV_16_BIT, + SEAT_BACKREST_HEATER_PWM_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphSetDivider(clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_BACKREST_HEATER_PWM_DIVIDER_NUMBER, + divider - 1u) != CY_SYSCLK_SUCCESS) + { + return false; + } + + return (Cy_SysClk_PeriphEnableDivider(clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_BACKREST_HEATER_PWM_DIVIDER_NUMBER) == + CY_SYSCLK_SUCCESS); +} + +static uint32_t BspSeatBackrestHeater_DutyToCompare(uint32_t dutyPercent) +{ + return (SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS * dutyPercent) / 100u; +} + +static void BspSeatBackrestHeater_SetPinGpioLow(void) +{ + Cy_GPIO_Write(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + 0u); + Cy_GPIO_SetHSIOM(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + SEAT_BACKREST_HEATER_PWM_GPIO_HSIOM); + Cy_GPIO_SetDriveSel(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + CY_GPIO_DRIVE_FULL); + Cy_GPIO_SetDrivemode(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + CY_GPIO_DM_STRONG_IN_OFF); + Cy_GPIO_Write(SEAT_BACKREST_HEATER_PWM_PORT, + SEAT_BACKREST_HEATER_PWM_PIN, + 0u); +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_backrest_heater.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_backrest_heater.h new file mode 100644 index 0000000..c9f33da --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_backrest_heater.h @@ -0,0 +1,62 @@ +#ifndef BSP_SEAT_BACKREST_HEATER_H +#define BSP_SEAT_BACKREST_HEATER_H + +#include +#include + +typedef struct +{ + uint32_t identifier; + uint32_t data0; + uint32_t data1; + uint8_t dlc; +} bsp_seat_backrest_heater_can_frame_t; + +/* Force the BTS7010 IN path to GPIO low before PWM initialization. */ +bool BspSeatBackrestHeater_ForceOff(void); + +/* Configure TCPWM1 counter 65 while P16.7 remains a strong GPIO low. */ +bool BspSeatBackrestHeater_InitPwm(void); + +/* Start a non-zero PWM duty, then connect P16.7 to TCPWM line 65. */ +bool BspSeatBackrestHeater_Start(uint32_t dutyPercent); + +/* Change a running non-zero duty. Use FailSafeOff for a true 0% output. */ +bool BspSeatBackrestHeater_SetDutyPercent(uint32_t dutyPercent); + +/* Disable TCPWM and restore P16.7 to strong GPIO low. */ +void BspSeatBackrestHeater_FailSafeOff(void); + +bool BspSeatBackrestHeater_IsRunning(void); +uint32_t BspSeatBackrestHeater_GetDutyPercent(void); + +/* + * Start-path diagnostic for bench/HIL fault isolation. + * 0 = no start failure, 1 = duty/compare write, 2 = counter did not run, + * 3 = P16.7 HSIOM switch/readback. + */ +extern volatile uint32_t g_bspSeatBackrestHeaterStartFailStage; + +/* Initialize the proven CAN4/TJA1044 board interface at 500 kbit/s. */ +bool BspSeatBackrestHeaterCan_Init(void); + +/* Pop one received standard Classic CAN frame from the interrupt-safe queue. */ +bool BspSeatBackrestHeaterCan_Read( + bsp_seat_backrest_heater_can_frame_t *frame); + +/* Send one 8-byte standard Classic CAN frame. */ +bool BspSeatBackrestHeaterCan_Send(uint32_t identifier, + uint32_t data0, + uint32_t data1); + +/* Watch-window diagnostics for CAN hardware integration. */ +extern volatile uint32_t g_bspSeatBackrestHeaterCanInitStatus; +extern volatile uint32_t g_bspSeatBackrestHeaterCanRxCount; +extern volatile uint32_t g_bspSeatBackrestHeaterCanRxQueueOverflowCount; +extern volatile uint32_t g_bspSeatBackrestHeaterCanTxCount; +extern volatile uint32_t g_bspSeatBackrestHeaterCanTxBusyCount; +extern volatile uint32_t g_bspSeatBackrestHeaterCanLastRxId; +extern volatile uint32_t g_bspSeatBackrestHeaterCanLastTxId; +extern volatile uint32_t g_bspSeatBackrestHeaterCanLastTxStatus; + +#endif /* BSP_SEAT_BACKREST_HEATER_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_current_adc.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_current_adc.c new file mode 100644 index 0000000..8571897 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_current_adc.c @@ -0,0 +1,332 @@ +#include "bsp_seat_current_adc.h" + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "seat_current_adc_config.h" + +#define SEAT_CURRENT_ADC_DIV_ROUND_UP(a, b) (((a) + (b) - 1u) / (b)) + +typedef struct +{ + volatile stc_GPIO_PRT_t *gpioPort; + uint32_t gpioPin; + en_hsiom_sel_t gpioHsiom; + volatile stc_PASS_SAR_CH_t *adcChannel; + cy_en_adc_pin_address_t pinAddress; +} bsp_seat_current_adc_profile_t; + +static const bsp_seat_current_adc_profile_t s_profiles[] = +{ + { + SEAT_CURRENT_M0_PORT, + SEAT_CURRENT_M0_PIN, + SEAT_CURRENT_M0_GPIO_HSIOM, + SEAT_CURRENT_M0_CHANNEL, + SEAT_CURRENT_M0_PIN_ADDRESS, + }, + { + SEAT_CURRENT_M1_PORT, + SEAT_CURRENT_M1_PIN, + SEAT_CURRENT_M1_GPIO_HSIOM, + SEAT_CURRENT_M1_CHANNEL, + SEAT_CURRENT_M1_PIN_ADDRESS, + }, + { + SEAT_CURRENT_M2_PORT, + SEAT_CURRENT_M2_PIN, + SEAT_CURRENT_M2_GPIO_HSIOM, + SEAT_CURRENT_M2_CHANNEL, + SEAT_CURRENT_M2_PIN_ADDRESS, + }, + { + SEAT_CURRENT_M3_PORT, + SEAT_CURRENT_M3_PIN, + SEAT_CURRENT_M3_GPIO_HSIOM, + SEAT_CURRENT_M3_CHANNEL, + SEAT_CURRENT_M3_PIN_ADDRESS, + }, + { + SEAT_CURRENT_HEATER_PORT, + SEAT_CURRENT_HEATER_PIN, + SEAT_CURRENT_HEATER_GPIO_HSIOM, + SEAT_CURRENT_HEATER_CHANNEL, + SEAT_CURRENT_HEATER_PIN_ADDRESS, + }, + { + SEAT_CURRENT_FAN_PORT, + SEAT_CURRENT_FAN_PIN, + SEAT_CURRENT_FAN_GPIO_HSIOM, + SEAT_CURRENT_FAN_CHANNEL, + SEAT_CURRENT_FAN_PIN_ADDRESS, + }, +}; + +typedef char bsp_seat_current_adc_profile_count_must_match[ + ((sizeof(s_profiles) / sizeof(s_profiles[0])) == + (uint32_t)BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT) ? 1 : -1]; + +static bool s_initialized = false; +static bool s_sampleActive = false; +static uint16_t s_lastRaw[BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT]; +static bool s_lastRawValid[BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT]; + +static bool BspSeatCurrentAdc_ConfigClock(uint32_t *actualClockHz); +static bool BspSeatCurrentAdc_WaitDone( + volatile stc_PASS_SAR_CH_t *channel); +static void BspSeatCurrentAdc_ClearStatus( + volatile stc_PASS_SAR_CH_t *channel); + +bool BspSeatCurrentAdc_Init(void) +{ + uint32_t actualClockHz; + uint32_t samplingCycles; + uint32_t index; + cy_stc_gpio_pin_config_t pinConfig = + { + .outVal = 0u, + .driveMode = CY_GPIO_DM_ANALOG, + .hsiom = HSIOM_SEL_GPIO, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_adc_config_t adcConfig = + { + .preconditionTime = 0u, + .powerupTime = 0u, + .enableIdlePowerDown = false, + .msbStretchMode = CY_ADC_MSB_STRETCH_MODE_1CYCLE, + .enableHalfLsbConv = false, + .sarMuxEnable = true, + .adcEnable = true, + .sarIpEnable = true, + }; + cy_stc_adc_channel_config_t channelConfig = + { + .triggerSelection = CY_ADC_TRIGGER_OFF, + .channelPriority = 0u, + .preenptionType = CY_ADC_PREEMPTION_FINISH_RESUME, + .isGroupEnd = true, + .doneLevel = CY_ADC_DONE_LEVEL_PULSE, + .pinAddress = CY_ADC_PIN_ADDRESS_AN0, + .portAddress = SEAT_CURRENT_ADC_PORT_ADDRESS, + .extMuxSelect = 0u, + .extMuxEnable = true, + .preconditionMode = CY_ADC_PRECONDITION_MODE_OFF, + .overlapDiagMode = CY_ADC_OVERLAP_DIAG_MODE_OFF, + .sampleTime = 0u, + .calibrationValueSelect = CY_ADC_CALIBRATION_VALUE_REGULAR, + .postProcessingMode = CY_ADC_POST_PROCESSING_MODE_NONE, + .resultAlignment = CY_ADC_RESULT_ALIGNMENT_RIGHT, + .signExtention = CY_ADC_SIGN_EXTENTION_UNSIGNED, + .averageCount = 0u, + .rightShift = 0u, + .rangeDetectionMode = CY_ADC_RANGE_DETECTION_MODE_INSIDE_RANGE, + .rangeDetectionLoThreshold = 0u, + .rangeDetectionHiThreshold = SEAT_CURRENT_ADC_FULL_SCALE, + .mask.grpDone = true, + .mask.grpCancelled = false, + .mask.grpOverflow = false, + .mask.chRange = false, + .mask.chPulse = false, + .mask.chOverflow = false, + }; + + if(s_initialized) + { + return true; + } + + for(index = 0u; + index < (uint32_t)BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT; + index++) + { + pinConfig.hsiom = s_profiles[index].gpioHsiom; + if(Cy_GPIO_Pin_Init(s_profiles[index].gpioPort, + s_profiles[index].gpioPin, + &pinConfig) != CY_GPIO_SUCCESS) + { + return false; + } + s_lastRaw[index] = 0u; + s_lastRawValid[index] = false; + } + + if(!BspSeatCurrentAdc_ConfigClock(&actualClockHz)) + { + return false; + } + samplingCycles = (uint32_t)SEAT_CURRENT_ADC_DIV_ROUND_UP( + (SEAT_CURRENT_ADC_SAMPLE_TIME_MIN_NS * (uint64_t)actualClockHz), + 1000000000ull); + if(samplingCycles == 0u) + { + samplingCycles = 1u; + } + channelConfig.sampleTime = (uint16_t)samplingCycles; + + Cy_Adc_DeInit(SEAT_CURRENT_ADC_INSTANCE); + if(Cy_Adc_Init(SEAT_CURRENT_ADC_INSTANCE, + &adcConfig) != CY_ADC_SUCCESS) + { + return false; + } + + for(index = 0u; + index < (uint32_t)BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT; + index++) + { + channelConfig.pinAddress = s_profiles[index].pinAddress; + if(Cy_Adc_Channel_Init(s_profiles[index].adcChannel, + &channelConfig) != CY_ADC_SUCCESS) + { + Cy_Adc_DeInit(SEAT_CURRENT_ADC_INSTANCE); + return false; + } + Cy_Adc_Channel_Enable(s_profiles[index].adcChannel); + } + + s_initialized = true; + return true; +} + +bool BspSeatCurrentAdc_Sample(bsp_seat_current_adc_signal_t signal, + uint16_t *raw) +{ + uint32_t index = (uint32_t)signal; + uint16_t sample = 0u; + cy_stc_adc_ch_status_t resultStatus = { false }; + volatile stc_PASS_SAR_CH_t *channel; + bool success = false; + + if(!s_initialized || s_sampleActive || + (index >= (uint32_t)BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT)) + { + return false; + } + + s_sampleActive = true; + channel = s_profiles[index].adcChannel; + BspSeatCurrentAdc_ClearStatus(channel); + Cy_Adc_Channel_SoftwareTrigger(channel); + if(BspSeatCurrentAdc_WaitDone(channel) && + (Cy_Adc_Channel_GetResult(channel, + &sample, + &resultStatus) == CY_ADC_SUCCESS) && + resultStatus.valid) + { + sample = (uint16_t)(sample & SEAT_CURRENT_ADC_FULL_SCALE); + s_lastRaw[index] = sample; + s_lastRawValid[index] = true; + if(raw != NULL) + { + *raw = sample; + } + success = true; + } + BspSeatCurrentAdc_ClearStatus(channel); + s_sampleActive = false; + return success; +} + +bool BspSeatCurrentAdc_GetLastRaw(bsp_seat_current_adc_signal_t signal, + uint16_t *raw) +{ + uint32_t index = (uint32_t)signal; + + if((raw == NULL) || + (index >= (uint32_t)BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT) || + !s_lastRawValid[index]) + { + return false; + } + *raw = s_lastRaw[index]; + return true; +} + +static bool BspSeatCurrentAdc_ConfigClock(uint32_t *actualClockHz) +{ + uint32_t periClockHz = 0u; + uint32_t divider; + uint32_t clockGroup = Cy_SysClk_GetClockGroup(SEAT_CURRENT_ADC_PCLK); + + if((actualClockHz == NULL) || + (Cy_SysClk_GetClkPeriFrequency(&periClockHz) != CY_SYSCLK_SUCCESS)) + { + return false; + } + divider = SEAT_CURRENT_ADC_DIV_ROUND_UP( + periClockHz, SEAT_CURRENT_ADC_OPERATION_FREQ_MAX_HZ); + if((divider == 0u) || (divider > 65536u)) + { + return false; + } + if(Cy_SysClk_PeriphDisableDivider( + clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_CURRENT_ADC_DIVIDER_NUMBER) != CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphAssignDivider( + SEAT_CURRENT_ADC_PCLK, + CY_SYSCLK_DIV_16_BIT, + SEAT_CURRENT_ADC_DIVIDER_NUMBER) != CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphSetDivider( + clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_CURRENT_ADC_DIVIDER_NUMBER, + divider - 1u) != CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphEnableDivider( + clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_CURRENT_ADC_DIVIDER_NUMBER) != CY_SYSCLK_SUCCESS) + { + return false; + } + + *actualClockHz = periClockHz / divider; + return true; +} + +static bool BspSeatCurrentAdc_WaitDone( + volatile stc_PASS_SAR_CH_t *channel) +{ + uint32_t timeout = SEAT_CURRENT_ADC_POLL_TIMEOUT; + cy_stc_adc_interrupt_source_t status = { false }; + + do + { + (void)Cy_Adc_Channel_GetInterruptStatus(channel, &status); + if(status.grpDone) + { + return true; + } + timeout--; + } while(timeout > 0u); + return false; +} + +static void BspSeatCurrentAdc_ClearStatus( + volatile stc_PASS_SAR_CH_t *channel) +{ + cy_stc_adc_interrupt_source_t clearSource = + { + .grpDone = true, + .grpCancelled = true, + .grpOverflow = true, + .chRange = false, + .chPulse = false, + .chOverflow = true, + }; + + (void)Cy_Adc_Channel_ClearInterruptStatus(channel, &clearSource); +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_current_adc.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_current_adc.h new file mode 100644 index 0000000..e1f5567 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_current_adc.h @@ -0,0 +1,27 @@ +#ifndef BSP_SEAT_CURRENT_ADC_H +#define BSP_SEAT_CURRENT_ADC_H + +#include +#include + +typedef enum +{ + BSP_SEAT_CURRENT_ADC_MOTOR0 = 0, + BSP_SEAT_CURRENT_ADC_MOTOR1, + BSP_SEAT_CURRENT_ADC_MOTOR2, + BSP_SEAT_CURRENT_ADC_MOTOR3, + BSP_SEAT_CURRENT_ADC_HEATER, + BSP_SEAT_CURRENT_ADC_FAN, + BSP_SEAT_CURRENT_ADC_SIGNAL_COUNT +} bsp_seat_current_adc_signal_t; + +/* Initializes PASS0 SAR1 once and enables every shared seat-current channel. */ +bool BspSeatCurrentAdc_Init(void); + +/* Main-loop polling API. It must not be called from an ISR. */ +bool BspSeatCurrentAdc_Sample(bsp_seat_current_adc_signal_t signal, + uint16_t *raw); +bool BspSeatCurrentAdc_GetLastRaw(bsp_seat_current_adc_signal_t signal, + uint16_t *raw); + +#endif /* BSP_SEAT_CURRENT_ADC_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_fan.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_fan.c new file mode 100644 index 0000000..018f767 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_fan.c @@ -0,0 +1,659 @@ +#include "bsp_seat_fan.h" + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "seat_fan_config.h" + +static bool BspSeatFan_ConfigClock(void); +static uint32_t BspSeatFan_DutyToCompare(uint32_t dutyPercent); +static void BspSeatFan_SetPinGpioLow(volatile stc_GPIO_PRT_t *port, + uint32_t pin, + en_hsiom_sel_t gpioHsiom); +static bool BspSeatCan_ConfigClock(void); +static bool BspSeatCan_ConfigPins(void); +static void BspSeatCan_IrqHandler(void); +static void BspSeatCan_RxCallback(bool rxFifoMessage, + uint8_t rxBufferOrFifo, + cy_stc_canfd_msg_t *message); + +volatile uint32_t g_bspSeatCanInitStatus = (uint32_t)CY_CANFD_SUCCESS; +volatile uint32_t g_bspSeatCanRxCount = 0u; +volatile uint32_t g_bspSeatCanRxQueueOverflowCount = 0u; +volatile uint32_t g_bspSeatCanTxCount = 0u; +volatile uint32_t g_bspSeatCanTxBusyCount = 0u; +volatile uint32_t g_bspSeatCanLastRxId = 0u; +volatile uint32_t g_bspSeatCanLastTxId = 0u; +volatile uint32_t g_bspSeatCanLastTxStatus = (uint32_t)CY_CANFD_SUCCESS; + +static volatile bsp_seat_can_frame_t s_canRxQueue[SEAT_FAN_CAN_RX_QUEUE_DEPTH]; +static volatile uint8_t s_canRxQueueHead = 0u; +static volatile uint8_t s_canRxQueueTail = 0u; + +volatile uint32_t g_bspSeatFanStartFailStage = 0u; + +static const cy_stc_id_filter_t s_canStandardIdFilters[] = +{ + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_FAN_CAN_CONTROL_29E_ID, + SEAT_FAN_CAN_CONTROL_29E_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_FAN_CAN_CONFIG_2AA_ID, + SEAT_FAN_CAN_CONFIG_2AA_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_FAN_CAN_SWITCH_42A_ID, + SEAT_FAN_CAN_SWITCH_42A_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), +}; + +static cy_stc_sysint_irq_t s_canIrqConfig = +{ + .sysIntSrc = SEAT_FAN_CAN_IRQN, + .intIdx = SEAT_FAN_CAN_CPU_IRQ, + .isEnabled = true, +}; + +static cy_stc_canfd_config_t s_canConfig = +{ + .txCallback = NULL, + .rxCallback = BspSeatCan_RxCallback, + .rxFifoWithTopCallback = NULL, + .statusCallback = NULL, + .errorCallback = NULL, + .canFDMode = false, + .bitrate = + { + /* 40 MHz / (10 * (1 + 5 + 2)) = 500 kbit/s. */ + .prescaler = 10u - 1u, + .timeSegment1 = 5u - 1u, + .timeSegment2 = 2u - 1u, + .syncJumpWidth = 2u - 1u, + }, + .fastBitrate = + { + .prescaler = 0u, + .timeSegment1 = 0u, + .timeSegment2 = 0u, + .syncJumpWidth = 0u, + }, + .tdcConfig = + { + .tdcEnabled = false, + .tdcOffset = 0u, + .tdcFilterWindow = 0u, + }, + .sidFilterConfig = + { + .numberOfSIDFilters = + sizeof(s_canStandardIdFilters) / sizeof(s_canStandardIdFilters[0]), + .sidFilter = s_canStandardIdFilters, + }, + .extidFilterConfig = + { + .numberOfEXTIDFilters = 0u, + .extidFilter = NULL, + .extIDANDMask = 0x1FFFFFFFu, + }, + .globalFilterConfig = + { + .nonMatchingFramesStandard = CY_CANFD_REJECT_NON_MATCHING, + .nonMatchingFramesExtended = CY_CANFD_REJECT_NON_MATCHING, + .rejectRemoteFramesStandard = true, + .rejectRemoteFramesExtended = true, + }, + .rxBufferDataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo1DataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo0DataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .txBufferDataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo0Config = + { + .mode = CY_CANFD_FIFO_MODE_BLOCKING, + .watermark = 0u, + .numberOfFifoElements = SEAT_FAN_CAN_RX_FIFO0_ELEMENTS, + .topPointerLogicEnabled = false, + }, + .rxFifo1Config = + { + .mode = CY_CANFD_FIFO_MODE_BLOCKING, + .watermark = 0u, + .numberOfFifoElements = 0u, + .topPointerLogicEnabled = false, + }, + .noOfRxBuffers = 0u, + .noOfTxBuffers = SEAT_FAN_CAN_TX_BUFFER_COUNT, +}; + +static const cy_stc_tcpwm_pwm_config_t s_seatFanPwmConfig = +{ + .pwmMode = CY_TCPWM_PWM_MODE_PWM, + .clockPrescaler = CY_TCPWM_PRESCALER_DIVBY_1, + .debug_pause = false, + .deadTime = 0u, + .deadTimeComp = 0u, + .runMode = CY_TCPWM_PWM_CONTINUOUS, + .countDirection = CY_TCPWM_COUNTER_COUNT_UP, + .cc0MatchMode = CY_TCPWM_PWM_TR_CTRL2_CLEAR, + .overflowMode = CY_TCPWM_PWM_TR_CTRL2_SET, + .underflowMode = CY_TCPWM_PWM_TR_CTRL2_NO_CHANGE, + .cc1MatchMode = CY_TCPWM_PWM_TR_CTRL2_NO_CHANGE, + .period = SEAT_FAN_PWM_PERIOD_TICKS - 1u, + .period_buff = 0u, + .enablePeriodSwap = false, + .enableLineSelSwap = false, + .compare0 = SEAT_FAN_PWM_PERIOD_TICKS, + .compare0_buff = 0u, + .enableCompare0Swap = false, + .compare1 = 0u, + .compare1_buff = 0u, + .enableCompare1Swap = false, + .interruptSources = CY_TCPWM_INT_NONE, + .invertPWMOut = 0u, + .invertPWMOutN = 0u, + .killMode = CY_TCPWM_PWM_STOP_ON_KILL, + .switchInputMode = CY_TCPWM_INPUT_LEVEL, + .switchInput = 0u, + .reloadInputMode = CY_TCPWM_INPUT_LEVEL, + .reloadInput = 0u, + .startInputMode = CY_TCPWM_INPUT_LEVEL, + .startInput = 0u, + .kill0InputMode = CY_TCPWM_INPUT_LEVEL, + .kill0Input = 0u, + .kill1InputMode = CY_TCPWM_INPUT_LEVEL, + .kill1Input = 0u, + .countInputMode = CY_TCPWM_INPUT_LEVEL, + .countInput = 1u, + .immediateKill = true, + .pwmOnDisable = CY_TCPWM_PWM_OUT_MODE_LOW, + .trigger0EventCfg = CY_TCPWM_COUNTER_OVERFLOW, + .trigger1EventCfg = CY_TCPWM_COUNTER_OVERFLOW, +}; + +bool BspSeatFan_ForceOff(void) +{ + cy_stc_gpio_pin_config_t pinConfig = + { + .outVal = 0u, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = SEAT_FAN_PWM_GPIO_HSIOM, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = CY_GPIO_DRIVE_FULL, + }; + cy_en_gpio_status_t pwmPinStatus; + cy_en_gpio_status_t unusedPinStatus; + + pwmPinStatus = Cy_GPIO_Pin_Init(SEAT_FAN_PWM_PORT, + SEAT_FAN_PWM_PIN, + &pinConfig); + + pinConfig.hsiom = SEAT_FAN_UNUSED_GPIO_HSIOM; + unusedPinStatus = Cy_GPIO_Pin_Init(SEAT_FAN_UNUSED_PORT, + SEAT_FAN_UNUSED_PIN, + &pinConfig); + + /* Write the output latches again after configuration for an explicit safe state. */ + Cy_GPIO_Write(SEAT_FAN_PWM_PORT, SEAT_FAN_PWM_PIN, 0u); + Cy_GPIO_Write(SEAT_FAN_UNUSED_PORT, SEAT_FAN_UNUSED_PIN, 0u); + + return ((pwmPinStatus == CY_GPIO_SUCCESS) && + (unusedPinStatus == CY_GPIO_SUCCESS)); +} + +bool BspSeatFan_InitPwm(void) +{ + if(!BspSeatFan_ConfigClock()) + { + return false; + } + + Cy_Tcpwm_Pwm_Disable(SEAT_FAN_PWM_TCPWM); + + if(Cy_Tcpwm_Pwm_Init(SEAT_FAN_PWM_TCPWM, + &s_seatFanPwmConfig) != CY_RET_SUCCESS) + { + return false; + } + + return ((Cy_Tcpwm_Pwm_GetPeriod(SEAT_FAN_PWM_TCPWM) == + (SEAT_FAN_PWM_PERIOD_TICKS - 1u)) && + (Cy_Tcpwm_Pwm_GetCompare0(SEAT_FAN_PWM_TCPWM) == + SEAT_FAN_PWM_PERIOD_TICKS)); +} + +bool BspSeatFan_Start(uint32_t dutyPercent) +{ + g_bspSeatFanStartFailStage = 0u; + if(!BspSeatFan_SetDutyPercent(dutyPercent)) + { + g_bspSeatFanStartFailStage = 1u; + return false; + } + + /* + * P11.1 is still GPIO low here. Starting from PERIOD makes the first + * counter clock generate overflow immediately, which asserts LINE high + * for the 100% startup setting before the pin is switched to TCPWM. + */ + Cy_GPIO_Write(SEAT_FAN_PWM_PORT, SEAT_FAN_PWM_PIN, 0u); + Cy_Tcpwm_Pwm_SetCounter(SEAT_FAN_PWM_TCPWM, + SEAT_FAN_PWM_PERIOD_TICKS - 1u); + Cy_Tcpwm_Pwm_Enable(SEAT_FAN_PWM_TCPWM); + Cy_Tcpwm_TriggerStart(SEAT_FAN_PWM_TCPWM); + + /* Keep the pin at GPIO low until the 2 MHz counter has started and overflowed. */ + Cy_SysTick_DelayInUs(2u); + if((Cy_Tcpwm_Pwm_GetStatus(SEAT_FAN_PWM_TCPWM) & + TCPWM_GRP_CNT_STATUS_RUNNING_Msk) == 0u) + { + g_bspSeatFanStartFailStage = 2u; + BspSeatFan_FailSafeOff(); + return false; + } + + Cy_GPIO_SetDriveSel(SEAT_FAN_PWM_PORT, + SEAT_FAN_PWM_PIN, + CY_GPIO_DRIVE_FULL); + Cy_GPIO_SetDrivemode(SEAT_FAN_PWM_PORT, + SEAT_FAN_PWM_PIN, + CY_GPIO_DM_STRONG_IN_OFF); + Cy_GPIO_SetHSIOM(SEAT_FAN_PWM_PORT, + SEAT_FAN_PWM_PIN, + SEAT_FAN_PWM_HSIOM); + + if(Cy_GPIO_GetHSIOM(SEAT_FAN_PWM_PORT, + SEAT_FAN_PWM_PIN) != SEAT_FAN_PWM_HSIOM) + { + g_bspSeatFanStartFailStage = 3u; + BspSeatFan_FailSafeOff(); + return false; + } + + return true; +} + +bool BspSeatFan_SetDutyPercent(uint32_t dutyPercent) +{ + uint32_t compare; + + if(dutyPercent > 100u) + { + return false; + } + + compare = BspSeatFan_DutyToCompare(dutyPercent); + Cy_Tcpwm_Pwm_SetCompare0(SEAT_FAN_PWM_TCPWM, compare); + + return (Cy_Tcpwm_Pwm_GetCompare0(SEAT_FAN_PWM_TCPWM) == compare); +} + +void BspSeatFan_FailSafeOff(void) +{ + Cy_Tcpwm_Pwm_Disable(SEAT_FAN_PWM_TCPWM); + + BspSeatFan_SetPinGpioLow(SEAT_FAN_PWM_PORT, + SEAT_FAN_PWM_PIN, + SEAT_FAN_PWM_GPIO_HSIOM); + BspSeatFan_SetPinGpioLow(SEAT_FAN_UNUSED_PORT, + SEAT_FAN_UNUSED_PIN, + SEAT_FAN_UNUSED_GPIO_HSIOM); +} + +bool BspSeatCan_Init(void) +{ + cy_en_canfd_status_t canStatus; + + if(!BspSeatCan_ConfigClock() || !BspSeatCan_ConfigPins()) + { + g_bspSeatCanInitStatus = (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + if(Cy_SysInt_InitIRQ(&s_canIrqConfig) != CY_SYSINT_SUCCESS) + { + g_bspSeatCanInitStatus = (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + Cy_SysInt_SetSystemIrqVector(s_canIrqConfig.sysIntSrc, + BspSeatCan_IrqHandler); + NVIC_SetPriority(s_canIrqConfig.intIdx, 0u); + NVIC_ClearPendingIRQ(s_canIrqConfig.intIdx); + + (void)Cy_CANFD_DeInit(SEAT_FAN_CAN_TYPE); + canStatus = Cy_CANFD_Init(SEAT_FAN_CAN_TYPE, &s_canConfig); + g_bspSeatCanInitStatus = (uint32_t)canStatus; + if(canStatus != CY_CANFD_SUCCESS) + { + return false; + } + + NVIC_EnableIRQ(s_canIrqConfig.intIdx); + return true; +} + +bool BspSeatCan_Read(bsp_seat_can_frame_t *frame) +{ + uint32_t interruptState; + uint8_t tail; + + if(frame == NULL) + { + return false; + } + + interruptState = Cy_SysLib_EnterCriticalSection(); + tail = s_canRxQueueTail; + if(tail == s_canRxQueueHead) + { + Cy_SysLib_ExitCriticalSection(interruptState); + return false; + } + + frame->identifier = s_canRxQueue[tail].identifier; + frame->data0 = s_canRxQueue[tail].data0; + frame->data1 = s_canRxQueue[tail].data1; + frame->dlc = s_canRxQueue[tail].dlc; + + tail++; + if(tail >= SEAT_FAN_CAN_RX_QUEUE_DEPTH) + { + tail = 0u; + } + s_canRxQueueTail = tail; + Cy_SysLib_ExitCriticalSection(interruptState); + + return true; +} + +bool BspSeatCan_Send(uint32_t identifier, uint32_t data0, uint32_t data1) +{ + cy_stc_canfd_msg_t message; + uint8_t bufferIndex; + uint8_t dataIndex; + + if(identifier > 0x7FFu) + { + g_bspSeatCanLastTxStatus = (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + message.canFDFormat = false; + message.idConfig.extended = false; + message.idConfig.identifier = identifier; + message.dataConfig.dataLengthCode = SEAT_FAN_CAN_DLC; + message.dataConfig.data[0] = data0; + message.dataConfig.data[1] = data1; + for(dataIndex = 2u; + dataIndex < CY_CANFD_MESSAGE_DATA_BUFFER_SIZE; + dataIndex++) + { + message.dataConfig.data[dataIndex] = 0u; + } + + for(bufferIndex = 0u; + bufferIndex < SEAT_FAN_CAN_TX_BUFFER_COUNT; + bufferIndex++) + { + cy_en_canfd_tx_buffer_status_t bufferStatus = + Cy_CANFD_GetTxBufferStatus(SEAT_FAN_CAN_TYPE, bufferIndex); + + if((bufferStatus != CY_CANFD_TX_BUFFER_PENDING) && + (bufferStatus != CY_CANFD_TX_BUFFER_CANCEL_REQUESTED)) + { + cy_en_canfd_status_t sendStatus; + + g_bspSeatCanLastTxId = identifier; + sendStatus = Cy_CANFD_UpdateAndTransmitMsgBuffer( + SEAT_FAN_CAN_TYPE, + bufferIndex, + &message); + g_bspSeatCanLastTxStatus = (uint32_t)sendStatus; + if(sendStatus == CY_CANFD_SUCCESS) + { + g_bspSeatCanTxCount++; + return true; + } + return false; + } + } + + g_bspSeatCanTxBusyCount++; + return false; +} + +static bool BspSeatCan_ConfigClock(void) +{ + uint32_t periClockHz = 0u; + uint32_t divider; + uint32_t clockGroup; + + if(Cy_SysClk_GetClkPeriFrequency(&periClockHz) != CY_SYSCLK_SUCCESS) + { + return false; + } + + if((periClockHz < SEAT_FAN_CAN_CLOCK_HZ) || + ((periClockHz % SEAT_FAN_CAN_CLOCK_HZ) != 0u)) + { + return false; + } + + divider = periClockHz / SEAT_FAN_CAN_CLOCK_HZ; + if((divider == 0u) || (divider > 256u)) + { + return false; + } + + clockGroup = Cy_SysClk_GetClockGroup(SEAT_FAN_CAN_PCLK); + if(Cy_SysClk_PeriphDisableDivider(clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_FAN_CAN_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + + if(Cy_SysClk_PeriphAssignDivider(SEAT_FAN_CAN_PCLK, + CY_SYSCLK_DIV_8_BIT, + SEAT_FAN_CAN_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + + if(Cy_SysClk_PeriphSetDivider(clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_FAN_CAN_DIVIDER_NUMBER, + divider - 1u) != CY_SYSCLK_SUCCESS) + { + return false; + } + + return (Cy_SysClk_PeriphEnableDivider(clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_FAN_CAN_DIVIDER_NUMBER) == + CY_SYSCLK_SUCCESS); +} + +static bool BspSeatCan_ConfigPins(void) +{ + cy_stc_gpio_pin_config_t rxConfig = + { + .outVal = 0u, + .driveMode = CY_GPIO_DM_HIGHZ, + .hsiom = SEAT_FAN_CAN_RX_MUX, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_gpio_pin_config_t txConfig = + { + .outVal = 1u, + .driveMode = CY_GPIO_DM_STRONG, + .hsiom = SEAT_FAN_CAN_TX_MUX, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_gpio_pin_config_t transceiverConfig = + { + .outVal = SEAT_FAN_CAN_TRANSCEIVER_ENABLE, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = SEAT_FAN_CAN_TRANSCEIVER_GPIO_HSIOM, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = CY_GPIO_DRIVE_FULL, + }; + + if(Cy_GPIO_Pin_Init(SEAT_FAN_CAN_RX_PORT, + SEAT_FAN_CAN_RX_PIN, + &rxConfig) != CY_GPIO_SUCCESS) + { + return false; + } + if(Cy_GPIO_Pin_Init(SEAT_FAN_CAN_TX_PORT, + SEAT_FAN_CAN_TX_PIN, + &txConfig) != CY_GPIO_SUCCESS) + { + return false; + } + if(Cy_GPIO_Pin_Init(SEAT_FAN_CAN_TRANSCEIVER_PORT, + SEAT_FAN_CAN_TRANSCEIVER_PIN, + &transceiverConfig) != CY_GPIO_SUCCESS) + { + return false; + } + + Cy_GPIO_Write(SEAT_FAN_CAN_TRANSCEIVER_PORT, + SEAT_FAN_CAN_TRANSCEIVER_PIN, + SEAT_FAN_CAN_TRANSCEIVER_ENABLE); + Cy_SysTick_DelayInUs(1000u); + + return (Cy_GPIO_ReadOut(SEAT_FAN_CAN_TRANSCEIVER_PORT, + SEAT_FAN_CAN_TRANSCEIVER_PIN) == + SEAT_FAN_CAN_TRANSCEIVER_ENABLE); +} + +static void BspSeatCan_IrqHandler(void) +{ + Cy_CANFD_IrqHandler(SEAT_FAN_CAN_TYPE); +} + +static void BspSeatCan_RxCallback(bool rxFifoMessage, + uint8_t rxBufferOrFifo, + cy_stc_canfd_msg_t *message) +{ + uint8_t head; + uint8_t nextHead; + + (void)rxFifoMessage; + (void)rxBufferOrFifo; + + if((message == NULL) || message->idConfig.extended || message->canFDFormat) + { + return; + } + + g_bspSeatCanRxCount++; + g_bspSeatCanLastRxId = message->idConfig.identifier; + + head = s_canRxQueueHead; + nextHead = head + 1u; + if(nextHead >= SEAT_FAN_CAN_RX_QUEUE_DEPTH) + { + nextHead = 0u; + } + + if(nextHead == s_canRxQueueTail) + { + g_bspSeatCanRxQueueOverflowCount++; + return; + } + + s_canRxQueue[head].identifier = message->idConfig.identifier; + s_canRxQueue[head].dlc = message->dataConfig.dataLengthCode; + s_canRxQueue[head].data0 = message->dataConfig.data[0]; + s_canRxQueue[head].data1 = message->dataConfig.data[1]; + s_canRxQueueHead = nextHead; +} + +static bool BspSeatFan_ConfigClock(void) +{ + uint32_t periClockHz = 0u; + uint32_t divider; + uint32_t clockGroup; + + if(Cy_SysClk_GetClkPeriFrequency(&periClockHz) != CY_SYSCLK_SUCCESS) + { + return false; + } + + if((periClockHz < SEAT_FAN_COUNTER_CLOCK_HZ) || + ((periClockHz % SEAT_FAN_COUNTER_CLOCK_HZ) != 0u)) + { + return false; + } + + divider = periClockHz / SEAT_FAN_COUNTER_CLOCK_HZ; + if((divider == 0u) || (divider > 65536u)) + { + return false; + } + + clockGroup = Cy_SysClk_GetClockGroup(SEAT_FAN_PWM_PCLK); + + if(Cy_SysClk_PeriphDisableDivider(clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_FAN_PWM_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + + if(Cy_SysClk_PeriphAssignDivider(SEAT_FAN_PWM_PCLK, + CY_SYSCLK_DIV_16_BIT, + SEAT_FAN_PWM_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + + if(Cy_SysClk_PeriphSetDivider(clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_FAN_PWM_DIVIDER_NUMBER, + divider - 1u) != CY_SYSCLK_SUCCESS) + { + return false; + } + + return (Cy_SysClk_PeriphEnableDivider(clockGroup, + CY_SYSCLK_DIV_16_BIT, + SEAT_FAN_PWM_DIVIDER_NUMBER) == + CY_SYSCLK_SUCCESS); +} + +static uint32_t BspSeatFan_DutyToCompare(uint32_t dutyPercent) +{ + return (SEAT_FAN_PWM_PERIOD_TICKS * dutyPercent) / 100u; +} + +static void BspSeatFan_SetPinGpioLow(volatile stc_GPIO_PRT_t *port, + uint32_t pin, + en_hsiom_sel_t gpioHsiom) +{ + /* Set the latch low before changing HSIOM to avoid a transient high. */ + Cy_GPIO_Write(port, pin, 0u); + Cy_GPIO_SetHSIOM(port, pin, gpioHsiom); + Cy_GPIO_SetDriveSel(port, pin, CY_GPIO_DRIVE_FULL); + Cy_GPIO_SetDrivemode(port, pin, CY_GPIO_DM_STRONG_IN_OFF); + Cy_GPIO_Write(port, pin, 0u); +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_fan.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_fan.h new file mode 100644 index 0000000..abcdc15 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_fan.h @@ -0,0 +1,52 @@ +#ifndef BSP_SEAT_FAN_H +#define BSP_SEAT_FAN_H + +#include +#include + +typedef struct +{ + uint32_t identifier; + uint32_t data0; + uint32_t data1; + uint8_t dlc; +} bsp_seat_can_frame_t; + +/* Configure P11.1 and P11.2 as strong GPIO outputs at logic low. */ +bool BspSeatFan_ForceOff(void); + +/* Configure the peripheral clock and PWM0-60 while P11.1 remains GPIO low. */ +bool BspSeatFan_InitPwm(void); + +/* Start PWM and only then connect P11.1 to the TCPWM line output. */ +bool BspSeatFan_Start(uint32_t dutyPercent); + +/* Change the active PWM compare value. Valid range is 0 through 100 percent. */ +bool BspSeatFan_SetDutyPercent(uint32_t dutyPercent); + +/* Disable TCPWM and restore both known fan-control pins to GPIO low. */ +void BspSeatFan_FailSafeOff(void); + +/* Read-only HIL diagnostic: 0=none, 1=compare, 2=counter, 3=HSIOM. */ +extern volatile uint32_t g_bspSeatFanStartFailStage; + +/* Initialize the proven CAN4/TJA1044 board interface at 500 kbit/s. */ +bool BspSeatCan_Init(void); + +/* Pop one received standard Classic CAN frame from the interrupt-safe queue. */ +bool BspSeatCan_Read(bsp_seat_can_frame_t *frame); + +/* Send one 8-byte standard Classic CAN frame. */ +bool BspSeatCan_Send(uint32_t identifier, uint32_t data0, uint32_t data1); + +/* Watch-window diagnostics for hardware integration. */ +extern volatile uint32_t g_bspSeatCanInitStatus; +extern volatile uint32_t g_bspSeatCanRxCount; +extern volatile uint32_t g_bspSeatCanRxQueueOverflowCount; +extern volatile uint32_t g_bspSeatCanTxCount; +extern volatile uint32_t g_bspSeatCanTxBusyCount; +extern volatile uint32_t g_bspSeatCanLastRxId; +extern volatile uint32_t g_bspSeatCanLastTxId; +extern volatile uint32_t g_bspSeatCanLastTxStatus; + +#endif /* BSP_SEAT_FAN_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_move_can.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_move_can.c new file mode 100644 index 0000000..7f15ed9 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_move_can.c @@ -0,0 +1,400 @@ +#include "bsp_seat_move_can.h" + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "motor_control.h" +#include "seat_move_config.h" + +static bool BspSeatMoveCan_ConfigClock(void); +static bool BspSeatMoveCan_ConfigPins(void); +static void BspSeatMoveCan_IrqHandler(void); +static void BspSeatMoveCan_RxCallback(bool rxFifoMessage, + uint8_t rxBufferOrFifo, + cy_stc_canfd_msg_t *message); +static void BspSeatMoveCan_ErrorCallback(cy_en_canfd_bus_error_t error); + +volatile uint32_t g_bspSeatMoveCanInitStatus = (uint32_t)CY_CANFD_SUCCESS; +volatile uint32_t g_bspSeatMoveCanRxCount = 0u; +volatile uint32_t g_bspSeatMoveCanRxQueueOverflowCount = 0u; +volatile uint32_t g_bspSeatMoveCanTxCount = 0u; +volatile uint32_t g_bspSeatMoveCanTxBusyCount = 0u; +volatile uint32_t g_bspSeatMoveCanLastRxId = 0u; +volatile uint32_t g_bspSeatMoveCanLastTxId = 0u; +volatile uint32_t g_bspSeatMoveCanLastTxStatus = (uint32_t)CY_CANFD_SUCCESS; +volatile uint32_t g_bspSeatMoveCanBusOff = 0u; + +static volatile bsp_seat_move_can_frame_t + s_rxQueue[SEAT_MOVE_CAN_RX_QUEUE_DEPTH]; +static volatile uint8_t s_rxQueueHead = 0u; +static volatile uint8_t s_rxQueueTail = 0u; + +static const cy_stc_id_filter_t s_standardIdFilters[] = +{ + CANFD_CONFIG_STD_ID_FILTER_RANGE( + SEAT_MOVE_CAN_COMMAND_ID, + SEAT_MOVE_CAN_COMMAND_ID, + CY_CANFD_ID_FILTER_ELEMNT_CONFIG_STORE_RXFIFO0), +}; + +static cy_stc_sysint_irq_t s_canIrqConfig = +{ + .sysIntSrc = SEAT_MOVE_CAN_IRQN, + .intIdx = SEAT_MOVE_CAN_CPU_IRQ, + .isEnabled = true, +}; + +static cy_stc_canfd_config_t s_canConfig = +{ + .txCallback = NULL, + .rxCallback = BspSeatMoveCan_RxCallback, + .rxFifoWithTopCallback = NULL, + .statusCallback = NULL, + .errorCallback = BspSeatMoveCan_ErrorCallback, + .canFDMode = false, + .bitrate = + { + /* 40 MHz / (10 * (1 + 5 + 2)) = 500 kbit/s. */ + .prescaler = 10u - 1u, + .timeSegment1 = 5u - 1u, + .timeSegment2 = 2u - 1u, + .syncJumpWidth = 2u - 1u, + }, + .fastBitrate = + { + .prescaler = 0u, + .timeSegment1 = 0u, + .timeSegment2 = 0u, + .syncJumpWidth = 0u, + }, + .tdcConfig = + { + .tdcEnabled = false, + .tdcOffset = 0u, + .tdcFilterWindow = 0u, + }, + .sidFilterConfig = + { + .numberOfSIDFilters = + sizeof(s_standardIdFilters) / sizeof(s_standardIdFilters[0]), + .sidFilter = s_standardIdFilters, + }, + .extidFilterConfig = + { + .numberOfEXTIDFilters = 0u, + .extidFilter = NULL, + .extIDANDMask = 0x1FFFFFFFu, + }, + .globalFilterConfig = + { + .nonMatchingFramesStandard = CY_CANFD_REJECT_NON_MATCHING, + .nonMatchingFramesExtended = CY_CANFD_REJECT_NON_MATCHING, + .rejectRemoteFramesStandard = true, + .rejectRemoteFramesExtended = true, + }, + .rxBufferDataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo1DataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo0DataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .txBufferDataSize = CY_CANFD_BUFFER_DATA_SIZE_8, + .rxFifo0Config = + { + .mode = CY_CANFD_FIFO_MODE_BLOCKING, + .watermark = 0u, + .numberOfFifoElements = SEAT_MOVE_CAN_RX_FIFO0_ELEMENTS, + .topPointerLogicEnabled = false, + }, + .rxFifo1Config = + { + .mode = CY_CANFD_FIFO_MODE_BLOCKING, + .watermark = 0u, + .numberOfFifoElements = 0u, + .topPointerLogicEnabled = false, + }, + .noOfRxBuffers = 0u, + .noOfTxBuffers = SEAT_MOVE_CAN_TX_BUFFER_COUNT, +}; + +bool BspSeatMoveCan_Init(void) +{ + cy_en_canfd_status_t canStatus; + + s_rxQueueHead = 0u; + s_rxQueueTail = 0u; + g_bspSeatMoveCanBusOff = 0u; + if(!BspSeatMoveCan_ConfigClock() || !BspSeatMoveCan_ConfigPins()) + { + g_bspSeatMoveCanInitStatus = (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + if(Cy_SysInt_InitIRQ(&s_canIrqConfig) != CY_SYSINT_SUCCESS) + { + g_bspSeatMoveCanInitStatus = (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + Cy_SysInt_SetSystemIrqVector(s_canIrqConfig.sysIntSrc, + BspSeatMoveCan_IrqHandler); + NVIC_SetPriority(s_canIrqConfig.intIdx, 0u); + NVIC_ClearPendingIRQ(s_canIrqConfig.intIdx); + + (void)Cy_CANFD_DeInit(SEAT_MOVE_CAN_TYPE); + canStatus = Cy_CANFD_Init(SEAT_MOVE_CAN_TYPE, &s_canConfig); + g_bspSeatMoveCanInitStatus = (uint32_t)canStatus; + if(canStatus != CY_CANFD_SUCCESS) + { + return false; + } + + NVIC_EnableIRQ(s_canIrqConfig.intIdx); + return true; +} + +bool BspSeatMoveCan_Read(bsp_seat_move_can_frame_t *frame) +{ + uint32_t interruptState; + uint8_t tail; + + if(frame == NULL) + { + return false; + } + + interruptState = Cy_SysLib_EnterCriticalSection(); + tail = s_rxQueueTail; + if(tail == s_rxQueueHead) + { + Cy_SysLib_ExitCriticalSection(interruptState); + return false; + } + frame->identifier = s_rxQueue[tail].identifier; + frame->data0 = s_rxQueue[tail].data0; + frame->data1 = s_rxQueue[tail].data1; + frame->dlc = s_rxQueue[tail].dlc; + tail++; + if(tail >= SEAT_MOVE_CAN_RX_QUEUE_DEPTH) + { + tail = 0u; + } + s_rxQueueTail = tail; + Cy_SysLib_ExitCriticalSection(interruptState); + return true; +} + +bool BspSeatMoveCan_Send(uint32_t identifier, + uint32_t data0, + uint32_t data1) +{ + cy_stc_canfd_msg_t message = { 0 }; + uint8_t bufferIndex; + + if((identifier > 0x7FFu) || (g_bspSeatMoveCanBusOff != 0u)) + { + g_bspSeatMoveCanLastTxStatus = (uint32_t)CY_CANFD_BAD_PARAM; + return false; + } + + message.canFDFormat = false; + message.idConfig.extended = false; + message.idConfig.identifier = identifier; + message.dataConfig.dataLengthCode = SEAT_MOVE_CAN_DLC; + message.dataConfig.data[0] = data0; + message.dataConfig.data[1] = data1; + + for(bufferIndex = 0u; + bufferIndex < SEAT_MOVE_CAN_TX_BUFFER_COUNT; + bufferIndex++) + { + cy_en_canfd_tx_buffer_status_t bufferStatus = + Cy_CANFD_GetTxBufferStatus(SEAT_MOVE_CAN_TYPE, bufferIndex); + + if((bufferStatus != CY_CANFD_TX_BUFFER_PENDING) && + (bufferStatus != CY_CANFD_TX_BUFFER_CANCEL_REQUESTED)) + { + cy_en_canfd_status_t sendStatus; + + g_bspSeatMoveCanLastTxId = identifier; + sendStatus = Cy_CANFD_UpdateAndTransmitMsgBuffer( + SEAT_MOVE_CAN_TYPE, bufferIndex, &message); + g_bspSeatMoveCanLastTxStatus = (uint32_t)sendStatus; + if(sendStatus == CY_CANFD_SUCCESS) + { + g_bspSeatMoveCanTxCount++; + return true; + } + return false; + } + } + + g_bspSeatMoveCanTxBusyCount++; + return false; +} + +static bool BspSeatMoveCan_ConfigClock(void) +{ + uint32_t periClockHz = 0u; + uint32_t divider; + uint32_t clockGroup; + + if(Cy_SysClk_GetClkPeriFrequency(&periClockHz) != CY_SYSCLK_SUCCESS) + { + return false; + } + if((periClockHz < SEAT_MOVE_CAN_CLOCK_HZ) || + ((periClockHz % SEAT_MOVE_CAN_CLOCK_HZ) != 0u)) + { + return false; + } + + divider = periClockHz / SEAT_MOVE_CAN_CLOCK_HZ; + if((divider == 0u) || (divider > 256u)) + { + return false; + } + + clockGroup = Cy_SysClk_GetClockGroup(SEAT_MOVE_CAN_PCLK); + if(Cy_SysClk_PeriphDisableDivider(clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_MOVE_CAN_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphAssignDivider(SEAT_MOVE_CAN_PCLK, + CY_SYSCLK_DIV_8_BIT, + SEAT_MOVE_CAN_DIVIDER_NUMBER) != + CY_SYSCLK_SUCCESS) + { + return false; + } + if(Cy_SysClk_PeriphSetDivider(clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_MOVE_CAN_DIVIDER_NUMBER, + divider - 1u) != CY_SYSCLK_SUCCESS) + { + return false; + } + return (Cy_SysClk_PeriphEnableDivider(clockGroup, + CY_SYSCLK_DIV_8_BIT, + SEAT_MOVE_CAN_DIVIDER_NUMBER) == + CY_SYSCLK_SUCCESS); +} + +static bool BspSeatMoveCan_ConfigPins(void) +{ + cy_stc_gpio_pin_config_t rxConfig = + { + .outVal = 0u, + .driveMode = CY_GPIO_DM_HIGHZ, + .hsiom = SEAT_MOVE_CAN_RX_MUX, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_gpio_pin_config_t txConfig = + { + .outVal = 1u, + .driveMode = CY_GPIO_DM_STRONG, + .hsiom = SEAT_MOVE_CAN_TX_MUX, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = 0u, + }; + cy_stc_gpio_pin_config_t enableConfig = + { + .outVal = SEAT_MOVE_CAN_TRANSCEIVER_ENABLE, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = SEAT_MOVE_CAN_TRANSCEIVER_GPIO_HSIOM, + .intEdge = 0u, + .intMask = 0u, + .vtrip = 0u, + .slewRate = 0u, + .driveSel = CY_GPIO_DRIVE_FULL, + }; + + if(Cy_GPIO_Pin_Init(SEAT_MOVE_CAN_RX_PORT, + SEAT_MOVE_CAN_RX_PIN, + &rxConfig) != CY_GPIO_SUCCESS) + { + return false; + } + if(Cy_GPIO_Pin_Init(SEAT_MOVE_CAN_TX_PORT, + SEAT_MOVE_CAN_TX_PIN, + &txConfig) != CY_GPIO_SUCCESS) + { + return false; + } + if(Cy_GPIO_Pin_Init(SEAT_MOVE_CAN_TRANSCEIVER_PORT, + SEAT_MOVE_CAN_TRANSCEIVER_PIN, + &enableConfig) != CY_GPIO_SUCCESS) + { + return false; + } + + Cy_GPIO_Write(SEAT_MOVE_CAN_TRANSCEIVER_PORT, + SEAT_MOVE_CAN_TRANSCEIVER_PIN, + SEAT_MOVE_CAN_TRANSCEIVER_ENABLE); + Cy_SysTick_DelayInUs(1000u); + return (Cy_GPIO_ReadOut(SEAT_MOVE_CAN_TRANSCEIVER_PORT, + SEAT_MOVE_CAN_TRANSCEIVER_PIN) == + SEAT_MOVE_CAN_TRANSCEIVER_ENABLE); +} + +static void BspSeatMoveCan_IrqHandler(void) +{ + Cy_CANFD_IrqHandler(SEAT_MOVE_CAN_TYPE); +} + +static void BspSeatMoveCan_RxCallback(bool rxFifoMessage, + uint8_t rxBufferOrFifo, + cy_stc_canfd_msg_t *message) +{ + uint8_t head; + uint8_t nextHead; + + (void)rxFifoMessage; + (void)rxBufferOrFifo; + if((message == NULL) || message->idConfig.extended || message->canFDFormat) + { + return; + } + + g_bspSeatMoveCanRxCount++; + g_bspSeatMoveCanLastRxId = message->idConfig.identifier; + if((message->idConfig.identifier == SEAT_MOVE_CAN_COMMAND_ID) && + (message->dataConfig.dataLengthCode == SEAT_MOVE_CAN_DLC) && + (((uint8_t)(message->dataConfig.data[0] & 0xFFu)) == 0x09u) && + (((uint8_t)((message->dataConfig.data[0] >> 8u) & 0xFFu)) == 0x10u) && + (message->dataConfig.data[1] == 0u)) + { + Motor_RequestAbortAllFromIsr( + (uint16_t)(message->dataConfig.data[0] >> 16u)); + } + head = s_rxQueueHead; + nextHead = head + 1u; + if(nextHead >= SEAT_MOVE_CAN_RX_QUEUE_DEPTH) + { + nextHead = 0u; + } + if(nextHead == s_rxQueueTail) + { + g_bspSeatMoveCanRxQueueOverflowCount++; + return; + } + + s_rxQueue[head].identifier = message->idConfig.identifier; + s_rxQueue[head].dlc = message->dataConfig.dataLengthCode; + s_rxQueue[head].data0 = message->dataConfig.data[0]; + s_rxQueue[head].data1 = message->dataConfig.data[1]; + s_rxQueueHead = nextHead; +} + +static void BspSeatMoveCan_ErrorCallback(cy_en_canfd_bus_error_t error) +{ + if(error == CY_CANFD_BUSOFF) + { + g_bspSeatMoveCanBusOff = 1u; + } +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_move_can.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_move_can.h new file mode 100644 index 0000000..98106a7 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Bsp/bsp_seat_move_can.h @@ -0,0 +1,29 @@ +#ifndef BSP_SEAT_MOVE_CAN_H +#define BSP_SEAT_MOVE_CAN_H + +#include +#include + +typedef struct +{ + uint32_t identifier; + uint32_t data0; + uint32_t data1; + uint8_t dlc; +} bsp_seat_move_can_frame_t; + +bool BspSeatMoveCan_Init(void); +bool BspSeatMoveCan_Read(bsp_seat_move_can_frame_t *frame); +bool BspSeatMoveCan_Send(uint32_t identifier, uint32_t data0, uint32_t data1); + +extern volatile uint32_t g_bspSeatMoveCanInitStatus; +extern volatile uint32_t g_bspSeatMoveCanRxCount; +extern volatile uint32_t g_bspSeatMoveCanRxQueueOverflowCount; +extern volatile uint32_t g_bspSeatMoveCanTxCount; +extern volatile uint32_t g_bspSeatMoveCanTxBusyCount; +extern volatile uint32_t g_bspSeatMoveCanLastRxId; +extern volatile uint32_t g_bspSeatMoveCanLastTxId; +extern volatile uint32_t g_bspSeatMoveCanLastTxStatus; +extern volatile uint32_t g_bspSeatMoveCanBusOff; + +#endif /* BSP_SEAT_MOVE_CAN_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_backrest_heater_config.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_backrest_heater_config.h new file mode 100644 index 0000000..f7e2412 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_backrest_heater_config.h @@ -0,0 +1,155 @@ +#ifndef SEAT_BACKREST_HEATER_CONFIG_H +#define SEAT_BACKREST_HEATER_CONFIG_H + +#include "cy_project.h" +#include "cy_device_headers.h" + +/* + * Schematic-confirmed backrest-heater path: + * P16.7 / KBJRD -> R126 (1 kOhm) -> BTS7010-1EPA IN -> backrest heater. + * The rev_d SDK maps P16.7 to TCPWM1 line 65. + */ +#define SEAT_BACKREST_HEATER_PWM_PORT GPIO_PRT16 +#define SEAT_BACKREST_HEATER_PWM_PIN (7u) +#define SEAT_BACKREST_HEATER_PWM_GPIO_HSIOM P16_7_GPIO +#define SEAT_BACKREST_HEATER_PWM_HSIOM P16_7_TCPWM1_LINE65 +#define SEAT_BACKREST_HEATER_PWM_TCPWM TCPWM1_GRP0_CNT65 +#define SEAT_BACKREST_HEATER_PWM_PCLK PCLK_TCPWM1_CLOCKS65 + +#define SEAT_BACKREST_HEATER_PWM_DIVIDER_NUMBER (3u) +#define SEAT_BACKREST_HEATER_COUNTER_CLOCK_HZ (2000000u) +#define SEAT_BACKREST_HEATER_PWM_FREQUENCY_HZ (100u) +#define SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS \ + (SEAT_BACKREST_HEATER_COUNTER_CLOCK_HZ / \ + SEAT_BACKREST_HEATER_PWM_FREQUENCY_HZ) + +/* Three open-loop power levels. Tune only after thermal validation. */ +#define SEAT_BACKREST_HEATER_LOW_DUTY_PERCENT (30u) +#define SEAT_BACKREST_HEATER_MEDIUM_DUTY_PERCENT (60u) +#define SEAT_BACKREST_HEATER_HIGH_DUTY_PERCENT (90u) +#define SEAT_BACKREST_HEATER_DEFAULT_LEVEL (0u) + +#define SEAT_BACKREST_HEATER_TASK_PERIOD_US (1000u) +/* + * Do not sample at the 10 ms PWM period. Equal periods phase-lock the ADC + * to one PWM point and can report zero current continuously for a healthy + * low-duty heater. Seven milliseconds walks through every PWM phase while + * remaining slow enough for the existing SAR ADC service. + */ +#define SEAT_BACKREST_HEATER_SENSE_SAMPLE_MS (7u) +#define SEAT_BACKREST_HEATER_SENSE_MAX_ERRORS (3u) +#define SEAT_BACKREST_HEATER_MAX_ACTIVE_TIME_MS (600000u) + +/* + * Vehicle CAN4 wiring proven by D:\wcl\can and Seat_Fan_demo: + * P3.7 RX, P3.6 TX, P3.1 high enables the board TJA1044 circuit. + */ +#define SEAT_BACKREST_HEATER_CAN_TYPE CY_CANFD1_2_TYPE +#define SEAT_BACKREST_HEATER_CAN_RX_PORT GPIO_PRT3 +#define SEAT_BACKREST_HEATER_CAN_RX_PIN (7u) +#define SEAT_BACKREST_HEATER_CAN_RX_MUX P3_7_CANFD1_TTCAN_RX2 +#define SEAT_BACKREST_HEATER_CAN_TX_PORT GPIO_PRT3 +#define SEAT_BACKREST_HEATER_CAN_TX_PIN (6u) +#define SEAT_BACKREST_HEATER_CAN_TX_MUX P3_6_CANFD1_TTCAN_TX2 +#define SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PORT GPIO_PRT3 +#define SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_PIN (1u) +#define SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_GPIO_HSIOM P3_1_GPIO +#define SEAT_BACKREST_HEATER_CAN_TRANSCEIVER_ENABLE (1u) +#define SEAT_BACKREST_HEATER_CAN_PCLK PCLK_CANFD1_CLOCK_CAN2 +#define SEAT_BACKREST_HEATER_CAN_IRQN canfd_1_interrupts0_2_IRQn +#define SEAT_BACKREST_HEATER_CAN_CPU_IRQ CPUIntIdx2_IRQn +#define SEAT_BACKREST_HEATER_CAN_DIVIDER_NUMBER (0u) +#define SEAT_BACKREST_HEATER_CAN_CLOCK_HZ (40000000u) +#define SEAT_BACKREST_HEATER_CAN_TX_BUFFER_COUNT (4u) +#define SEAT_BACKREST_HEATER_CAN_RX_FIFO0_ELEMENTS (8u) +#define SEAT_BACKREST_HEATER_CAN_RX_QUEUE_DEPTH (8u) + +/* Classic CAN matrix messages, 11-bit IDs, 8-byte payloads, Intel bit order. */ +#define SEAT_BACKREST_HEATER_CAN_CONTROL_29E_ID (0x29Eu) +#define SEAT_BACKREST_HEATER_CAN_CONFIG_2AA_ID (0x2AAu) +#define SEAT_BACKREST_HEATER_CAN_MODE_3CE_ID (0x3CEu) +#define SEAT_BACKREST_HEATER_CAN_SWITCH_42A_ID (0x42Au) +#define SEAT_BACKREST_HEATER_CAN_FEEDBACK_4FA_ID (0x4FAu) +#define SEAT_BACKREST_HEATER_CAN_DLC (8u) +#define SEAT_BACKREST_HEATER_CAN_FEEDBACK_PERIOD_MS (100u) +#define SEAT_BACKREST_HEATER_CAN_ACCEPT_42A_COMMAND (1u) +#define SEAT_BACKREST_HEATER_DEFAULT_CAN_GEAR (2u) + +/* Safety watchdog and retained C-SPY session latch, derived from Seat_Fan_demo. */ +#define SEAT_BACKREST_HEATER_WDT_TIMEOUT_TICKS (3200u) +#define SEAT_BACKREST_HEATER_DEBUG_BREG_INDEX (3u) +#define SEAT_BACKREST_HEATER_DEBUG_ACTIVE_MAGIC (0x48444247u) /* "HDBG" */ +#define SEAT_BACKREST_HEATER_DEBUG_EXIT_MAGIC (0x484F4646u) /* "HOFF" */ + +/* + * Schematic-confirmed diagnostic current-sense path: + * KBJRD-AD -> P12.2 / ADC[1]_6. The BTS7010 IS pin uses a 1.2 kOhm + * sense resistor and the schematic annotates K=5000. The calculated current + * is for bench visibility only; production thresholds require calibration. + */ +#define SEAT_BACKREST_HEATER_SENSE_ENABLE (1u) +#define SEAT_BACKREST_HEATER_SENSE_PORT GPIO_PRT12 +#define SEAT_BACKREST_HEATER_SENSE_PIN (2u) +#define SEAT_BACKREST_HEATER_SENSE_GPIO_HSIOM P12_2_GPIO +#define SEAT_BACKREST_HEATER_SENSE_RESISTOR_OHM (1200u) +#define SEAT_BACKREST_HEATER_SENSE_KILIS (5000u) + +/* BTS7010 control facts confirmed on the power-driver schematic page. */ +#define SEAT_BACKREST_HEATER_BTS7010_IN_ACTIVE_HIGH (1u) +#define SEAT_BACKREST_HEATER_BTS7010_DEN_MCU_CONTROLLED (0u) + +#if !defined(CYT4BFBCJE) || !defined(CY_MCU_rev_d) +#error "Seat_Controller_demo requires CYT4BFBCJE rev_d headers." +#endif + +#if ((SEAT_BACKREST_HEATER_COUNTER_CLOCK_HZ % \ + SEAT_BACKREST_HEATER_PWM_FREQUENCY_HZ) != 0u) +#error "The heater counter clock must divide evenly by the PWM frequency." +#endif + +#if ((SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS == 0u) || \ + (SEAT_BACKREST_HEATER_PWM_PERIOD_TICKS > 65535u)) +#error "The heater PWM period does not fit TCPWM1 group 0 counter 65." +#endif + +#if ((SEAT_BACKREST_HEATER_LOW_DUTY_PERCENT == 0u) || \ + (SEAT_BACKREST_HEATER_LOW_DUTY_PERCENT >= \ + SEAT_BACKREST_HEATER_MEDIUM_DUTY_PERCENT) || \ + (SEAT_BACKREST_HEATER_MEDIUM_DUTY_PERCENT >= \ + SEAT_BACKREST_HEATER_HIGH_DUTY_PERCENT) || \ + (SEAT_BACKREST_HEATER_HIGH_DUTY_PERCENT > 100u)) +#error "Heater duties must be ordered LOW < MEDIUM < HIGH <= 100." +#endif + +#if (SEAT_BACKREST_HEATER_DEFAULT_LEVEL > 3u) +#error "Default heater level must be 0 (off) through 3 (high)." +#endif + +#if ((SEAT_BACKREST_HEATER_CAN_CONTROL_29E_ID > 0x7FFu) || \ + (SEAT_BACKREST_HEATER_CAN_CONFIG_2AA_ID > 0x7FFu) || \ + (SEAT_BACKREST_HEATER_CAN_MODE_3CE_ID > 0x7FFu) || \ + (SEAT_BACKREST_HEATER_CAN_SWITCH_42A_ID > 0x7FFu) || \ + (SEAT_BACKREST_HEATER_CAN_FEEDBACK_4FA_ID > 0x7FFu)) +#error "Seat heating CAN IDs must be 11-bit standard identifiers." +#endif + +#if (SEAT_BACKREST_HEATER_CAN_RX_QUEUE_DEPTH < 5u) +#error "The heater CAN receive queue must hold at least five frames." +#endif + +#if ((SEAT_BACKREST_HEATER_DEFAULT_CAN_GEAR == 0u) || \ + (SEAT_BACKREST_HEATER_DEFAULT_CAN_GEAR > 3u)) +#error "Default heater CAN gear must be in the range 1 through 3." +#endif + +#if ((SEAT_BACKREST_HEATER_SENSE_SAMPLE_MS == 0u) || \ + (SEAT_BACKREST_HEATER_SENSE_MAX_ERRORS == 0u) || \ + (SEAT_BACKREST_HEATER_MAX_ACTIVE_TIME_MS == 0u)) +#error "Heater sample period and maximum active time must be non-zero." +#endif + +#if (SEAT_BACKREST_HEATER_DEBUG_BREG_INDEX >= SRSS_BACKUP_NUM_BREG) +#error "The selected heater backup register is not present on this device." +#endif + +#endif /* SEAT_BACKREST_HEATER_CONFIG_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_current_adc_config.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_current_adc_config.h new file mode 100644 index 0000000..1862e6c --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_current_adc_config.h @@ -0,0 +1,71 @@ +#ifndef SEAT_CURRENT_ADC_CONFIG_H +#define SEAT_CURRENT_ADC_CONFIG_H + +#include "cy_project.h" +#include "cy_device_headers.h" + +/* + * PASS0 SAR1 is shared by all seat-current measurements. The schematic + * connects VREFH and the DRV8718 AREF rail to filtered 5 V, so raw-to-voltage + * calculations must use 5000 mV rather than the GPIO-domain 3.3 V supply. + * + * Logical motor mapping: + * M0 horizontal : ZYTTKZDY-AD, P10.6, ADC[1]_2 + * M1 seat pan : JXBKZDY-AD, P12.1, ADC[1]_5 + * M2 backrest : ZYKBKZDY-AD, P10.7, ADC[1]_3 + * M3 legrest : ZYGDKZDY-AD, P10.5, ADC[1]_1 + * heater current : KBJRD-AD, P12.2, ADC[1]_6 + * fan current : FSDY-AD, P12.6, ADC[1]_10 + */ +#define SEAT_CURRENT_ADC_INSTANCE PASS0_SAR1 +#define SEAT_CURRENT_ADC_PCLK PCLK_PASS0_CLOCK_SAR1 +#define SEAT_CURRENT_ADC_DIVIDER_NUMBER (4u) +#define SEAT_CURRENT_ADC_REFERENCE_MV (5000u) +#define SEAT_CURRENT_ADC_FULL_SCALE (4095u) +#define SEAT_CURRENT_ADC_OPERATION_FREQ_MAX_HZ (13340000u) +#define SEAT_CURRENT_ADC_SAMPLE_TIME_MIN_NS (600ull) +#define SEAT_CURRENT_ADC_POLL_TIMEOUT (100000u) + +#define SEAT_CURRENT_M0_PORT GPIO_PRT10 +#define SEAT_CURRENT_M0_PIN (6u) +#define SEAT_CURRENT_M0_GPIO_HSIOM P10_6_GPIO +#define SEAT_CURRENT_M0_CHANNEL PASS0_SAR1_CH2 +#define SEAT_CURRENT_M0_PIN_ADDRESS CY_ADC_PIN_ADDRESS_AN2 + +#define SEAT_CURRENT_M1_PORT GPIO_PRT12 +#define SEAT_CURRENT_M1_PIN (1u) +#define SEAT_CURRENT_M1_GPIO_HSIOM P12_1_GPIO +#define SEAT_CURRENT_M1_CHANNEL PASS0_SAR1_CH5 +#define SEAT_CURRENT_M1_PIN_ADDRESS CY_ADC_PIN_ADDRESS_AN5 + +#define SEAT_CURRENT_M2_PORT GPIO_PRT10 +#define SEAT_CURRENT_M2_PIN (7u) +#define SEAT_CURRENT_M2_GPIO_HSIOM P10_7_GPIO +#define SEAT_CURRENT_M2_CHANNEL PASS0_SAR1_CH3 +#define SEAT_CURRENT_M2_PIN_ADDRESS CY_ADC_PIN_ADDRESS_AN3 + +#define SEAT_CURRENT_M3_PORT GPIO_PRT10 +#define SEAT_CURRENT_M3_PIN (5u) +#define SEAT_CURRENT_M3_GPIO_HSIOM P10_5_GPIO +#define SEAT_CURRENT_M3_CHANNEL PASS0_SAR1_CH1 +#define SEAT_CURRENT_M3_PIN_ADDRESS CY_ADC_PIN_ADDRESS_AN1 + +#define SEAT_CURRENT_HEATER_PORT GPIO_PRT12 +#define SEAT_CURRENT_HEATER_PIN (2u) +#define SEAT_CURRENT_HEATER_GPIO_HSIOM P12_2_GPIO +#define SEAT_CURRENT_HEATER_CHANNEL PASS0_SAR1_CH6 +#define SEAT_CURRENT_HEATER_PIN_ADDRESS CY_ADC_PIN_ADDRESS_AN6 + +#define SEAT_CURRENT_FAN_PORT GPIO_PRT12 +#define SEAT_CURRENT_FAN_PIN (6u) +#define SEAT_CURRENT_FAN_GPIO_HSIOM P12_6_GPIO +#define SEAT_CURRENT_FAN_CHANNEL PASS0_SAR1_CH10 +#define SEAT_CURRENT_FAN_PIN_ADDRESS CY_ADC_PIN_ADDRESS_AN10 + +#define SEAT_CURRENT_ADC_PORT_ADDRESS CY_ADC_PORT_ADDRESS_SARMUX0 + +#if !defined(CYT4BFBCJE) || !defined(CY_MCU_rev_d) +#error "Seat current ADC mapping requires CYT4BFBCJE rev_d headers." +#endif + +#endif /* SEAT_CURRENT_ADC_CONFIG_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_fan_config.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_fan_config.h new file mode 100644 index 0000000..ba591bf --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_fan_config.h @@ -0,0 +1,139 @@ +#ifndef SEAT_FAN_CONFIG_H +#define SEAT_FAN_CONFIG_H + +#include "cy_project.h" +#include "cy_device_headers.h" + +/* + * Backrest fan: + * P11.1 -> KBFSDY-PWM-IN -> BTS7030 IN0 -> backrest fan. + * The schematic name "PWM0-60" maps to TCPWM1 group 0 counter 60 in SDL. + */ +#define SEAT_FAN_PWM_PORT GPIO_PRT11 +#define SEAT_FAN_PWM_PIN (1u) +#define SEAT_FAN_PWM_GPIO_HSIOM P11_1_GPIO +#define SEAT_FAN_PWM_HSIOM P11_1_TCPWM1_LINE60 +#define SEAT_FAN_PWM_TCPWM TCPWM1_GRP0_CNT60 +#define SEAT_FAN_PWM_PCLK PCLK_TCPWM1_CLOCKS60 + +/* + * Seat-cushion fan output is unused in this first version and must stay low: + * P11.2 -> ZDFSDY-PWM-IN. + */ +#define SEAT_FAN_UNUSED_PORT GPIO_PRT11 +#define SEAT_FAN_UNUSED_PIN (2u) +#define SEAT_FAN_UNUSED_GPIO_HSIOM P11_2_GPIO + +#define SEAT_FAN_PWM_DIVIDER_NUMBER (0u) +#define SEAT_FAN_COUNTER_CLOCK_HZ (2000000u) +#define SEAT_FAN_PWM_FREQUENCY_HZ (100u) +#define SEAT_FAN_PWM_PERIOD_TICKS \ + (SEAT_FAN_COUNTER_CLOCK_HZ / SEAT_FAN_PWM_FREQUENCY_HZ) + +#define SEAT_FAN_STARTUP_DUTY_PERCENT (100u) +#define SEAT_FAN_RUNNING_DUTY_PERCENT (70u) +#define SEAT_FAN_STARTUP_TIME_US (2000000u) +#define SEAT_FAN_DEBUG_POLL_TIME_US (1000u) +#define SEAT_FAN_WDT_TIMEOUT_TICKS (3200u) + +/* CAN-controlled application timing and the three physical fan levels. */ +#define SEAT_FAN_TASK_PERIOD_US (1000u) +#define SEAT_FAN_STARTUP_TIME_MS (SEAT_FAN_STARTUP_TIME_US / 1000u) +#define SEAT_FAN_GEAR1_DUTY_PERCENT (50u) +#define SEAT_FAN_GEAR2_DUTY_PERCENT (70u) +#define SEAT_FAN_GEAR3_DUTY_PERCENT (100u) +#define SEAT_FAN_DEFAULT_GEAR (2u) + +/* + * Vehicle CAN4 wiring proven by D:\wcl\can: + * P3.7 RX, P3.6 TX, P3.1 high enables the board TJA1044 circuit. + */ +#define SEAT_FAN_CAN_TYPE CY_CANFD1_2_TYPE +#define SEAT_FAN_CAN_RX_PORT GPIO_PRT3 +#define SEAT_FAN_CAN_RX_PIN (7u) +#define SEAT_FAN_CAN_RX_MUX P3_7_CANFD1_TTCAN_RX2 +#define SEAT_FAN_CAN_TX_PORT GPIO_PRT3 +#define SEAT_FAN_CAN_TX_PIN (6u) +#define SEAT_FAN_CAN_TX_MUX P3_6_CANFD1_TTCAN_TX2 +#define SEAT_FAN_CAN_TRANSCEIVER_PORT GPIO_PRT3 +#define SEAT_FAN_CAN_TRANSCEIVER_PIN (1u) +#define SEAT_FAN_CAN_TRANSCEIVER_GPIO_HSIOM P3_1_GPIO +#define SEAT_FAN_CAN_TRANSCEIVER_ENABLE (1u) +#define SEAT_FAN_CAN_PCLK PCLK_CANFD1_CLOCK_CAN2 +#define SEAT_FAN_CAN_IRQN canfd_1_interrupts0_2_IRQn +#define SEAT_FAN_CAN_CPU_IRQ CPUIntIdx2_IRQn +#define SEAT_FAN_CAN_DIVIDER_NUMBER (0u) +#define SEAT_FAN_CAN_CLOCK_HZ (40000000u) +#define SEAT_FAN_CAN_TX_BUFFER_COUNT (4u) +#define SEAT_FAN_CAN_RX_FIFO0_ELEMENTS (8u) +#define SEAT_FAN_CAN_RX_QUEUE_DEPTH (8u) + +/* Classic CAN matrix messages, 11-bit IDs, 8-byte payloads, Intel bit order. */ +#define SEAT_FAN_CAN_CONTROL_29E_ID (0x29Eu) +#define SEAT_FAN_CAN_CONFIG_2AA_ID (0x2AAu) +#define SEAT_FAN_CAN_SWITCH_42A_ID (0x42Au) +#define SEAT_FAN_CAN_FEEDBACK_4FA_ID (0x4FAu) +#define SEAT_FAN_CAN_DLC (8u) +#define SEAT_FAN_CAN_FEEDBACK_PERIOD_MS (100u) +#define SEAT_FAN_CAN_ACCEPT_42A_COMMAND (1u) + +/* FSDY-AD is P12.6 / ADC[1]_10 on the shared seat-current ADC. */ +#define SEAT_FAN_CURRENT_SAMPLE_MS (10u) + +/* + * BREG3 retains the debug-session state through debug/XRES/WDT resets. + * DEBUG_ACTIVE is written by firmware as soon as it observes C_DEBUGEN, so a + * debugger-induced reset cannot accidentally restart the fan even if the + * C-SPY exit hook was skipped. DEBUG_EXIT is written when detach is observed + * or by the C-SPY exit hook. A genuine POR releases either marker. + */ +#define SEAT_FAN_DEBUG_SESSION_BREG_INDEX (3u) +#define SEAT_FAN_DEBUG_ACTIVE_MAGIC (0x44424741u) /* ASCII "DBGA" */ +#define SEAT_FAN_DEBUG_EXIT_MAGIC (0x5346414Eu) /* ASCII "SFAN" */ + +#if !defined(CYT4BFBCJE) || !defined(CY_MCU_rev_d) +#error "Seat_Controller_demo must be built for CYT4BFBCJE with the rev_d headers." +#endif + +#if ((SEAT_FAN_COUNTER_CLOCK_HZ % SEAT_FAN_PWM_FREQUENCY_HZ) != 0u) +#error "The TCPWM counter clock must divide evenly by the requested PWM frequency." +#endif + +#if ((SEAT_FAN_PWM_PERIOD_TICKS == 0u) || (SEAT_FAN_PWM_PERIOD_TICKS > 65535u)) +#error "The PWM period does not fit TCPWM1 group 0 counter 60." +#endif + +#if ((SEAT_FAN_STARTUP_TIME_US % SEAT_FAN_DEBUG_POLL_TIME_US) != 0u) +#error "The startup duration must be an integer number of debug polling periods." +#endif + +#if ((SEAT_FAN_STARTUP_TIME_US % 1000u) != 0u) +#error "The fan startup time must be an integer number of milliseconds." +#endif + +#if ((SEAT_FAN_GEAR1_DUTY_PERCENT > SEAT_FAN_GEAR2_DUTY_PERCENT) || \ + (SEAT_FAN_GEAR2_DUTY_PERCENT > SEAT_FAN_GEAR3_DUTY_PERCENT) || \ + (SEAT_FAN_GEAR3_DUTY_PERCENT > 100u)) +#error "Fan gear duty percentages must be monotonic and no greater than 100." +#endif + +#if ((SEAT_FAN_DEFAULT_GEAR == 0u) || (SEAT_FAN_DEFAULT_GEAR > 3u)) +#error "The default physical fan gear must be in the range 1 through 3." +#endif + +#if ((SEAT_FAN_CAN_CONTROL_29E_ID > 0x7FFu) || \ + (SEAT_FAN_CAN_CONFIG_2AA_ID > 0x7FFu) || \ + (SEAT_FAN_CAN_SWITCH_42A_ID > 0x7FFu) || \ + (SEAT_FAN_CAN_FEEDBACK_4FA_ID > 0x7FFu)) +#error "Seat ventilation CAN IDs must be 11-bit standard identifiers." +#endif + +#if (SEAT_FAN_CAN_RX_QUEUE_DEPTH < 4u) +#error "The CAN receive queue must hold at least four frames." +#endif + +#if (SEAT_FAN_DEBUG_SESSION_BREG_INDEX >= SRSS_BACKUP_NUM_BREG) +#error "The selected backup register is not present on this device." +#endif + +#endif /* SEAT_FAN_CONFIG_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_move_config.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_move_config.h new file mode 100644 index 0000000..540fdab --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Config/seat_move_config.h @@ -0,0 +1,150 @@ +#ifndef SEAT_MOVE_CONFIG_H +#define SEAT_MOVE_CONFIG_H + +#include "cy_project.h" +#include "cy_device_headers.h" + +/* This configuration is intentionally tied to the timed-only IAR target. */ +#if !defined(PASSENGER_SEAT_TIMED_ONLY) +#error "Build this source only from Passenger_Seat_Timed_Controller_demo." +#endif + +#define SEAT_MOVE_MOTOR_COUNT (4u) +#define SEAT_MOVE_ACTIVE_MOTOR_MASK (0x0Fu) +#define SEAT_MOVE_TASK_PERIOD_US (1000u) +#define SEAT_MOVE_START_STAGGER_MS (100u) +#define SEAT_MOVE_MAX_CONCURRENT_MOTORS (4u) + +/* Timed-only CAN limits. ParamA is duration in ms. */ +#define SEAT_TIMED_MIN_DURATION_MS (300u) +#define SEAT_TIMED_M0_MAX_DURATION_MS (30000u) /* horizontal */ +#define SEAT_TIMED_M1_MAX_DURATION_MS (20000u) /* seat-pan */ +#define SEAT_TIMED_M2_MAX_DURATION_MS (30000u) /* backrest */ +#define SEAT_TIMED_M3_MAX_DURATION_MS (25000u) /* legrest */ +#define SEAT_TIMED_LEVEL_MIN_X10 (300u) +#define SEAT_TIMED_LEVEL_MAX_X10 (1000u) + +/* Logical MotorID and connected DRV8718 channel are identical. */ +#define SEAT_MOVE_LOGICAL_M0_PHYSICAL_MODE (0u) +#define SEAT_MOVE_LOGICAL_M1_PHYSICAL_MODE (1u) +#define SEAT_MOVE_LOGICAL_M2_PHYSICAL_MODE (2u) +#define SEAT_MOVE_LOGICAL_M3_PHYSICAL_MODE (3u) + +/* Retained by the verified motor manager; timed requests provide duty. */ +#define SEAT_MOVE_M0_DEFAULT_TIMEOUT_MS (12000u) +#define SEAT_MOVE_M1_DEFAULT_TIMEOUT_MS (8000u) +#define SEAT_MOVE_M2_DEFAULT_TIMEOUT_MS (12000u) +#define SEAT_MOVE_M3_DEFAULT_TIMEOUT_MS (30000u) +#define SEAT_MOVE_M0_DEFAULT_DUTY_PERCENT (90u) +#define SEAT_MOVE_M1_DEFAULT_DUTY_PERCENT (90u) +#define SEAT_MOVE_M2_DEFAULT_DUTY_PERCENT (90u) +#define SEAT_MOVE_M3_DEFAULT_DUTY_PERCENT (50u) +#define SEAT_MOVE_M3_BREAKAWAY_DUTY_PERCENT (50u) +#define SEAT_MOVE_POSITION_TARGET_HALL_TOLERANCE (4u) + +/* CAN4, classic CAN, 500 kbit/s. */ +#define SEAT_MOVE_CAN_TYPE CY_CANFD1_2_TYPE +#define SEAT_MOVE_CAN_RX_PORT GPIO_PRT3 +#define SEAT_MOVE_CAN_RX_PIN (7u) +#define SEAT_MOVE_CAN_RX_MUX P3_7_CANFD1_TTCAN_RX2 +#define SEAT_MOVE_CAN_TX_PORT GPIO_PRT3 +#define SEAT_MOVE_CAN_TX_PIN (6u) +#define SEAT_MOVE_CAN_TX_MUX P3_6_CANFD1_TTCAN_TX2 +#define SEAT_MOVE_CAN_TRANSCEIVER_PORT GPIO_PRT3 +#define SEAT_MOVE_CAN_TRANSCEIVER_PIN (1u) +#define SEAT_MOVE_CAN_TRANSCEIVER_GPIO_HSIOM P3_1_GPIO +#define SEAT_MOVE_CAN_TRANSCEIVER_ENABLE (1u) +#define SEAT_MOVE_CAN_PCLK PCLK_CANFD1_CLOCK_CAN2 +#define SEAT_MOVE_CAN_IRQN canfd_1_interrupts0_2_IRQn +#define SEAT_MOVE_CAN_CPU_IRQ CPUIntIdx2_IRQn +#define SEAT_MOVE_CAN_DIVIDER_NUMBER (0u) +#define SEAT_MOVE_CAN_CLOCK_HZ (40000000u) +#define SEAT_MOVE_CAN_TX_BUFFER_COUNT (8u) +#define SEAT_MOVE_CAN_RX_FIFO0_ELEMENTS (8u) +#define SEAT_MOVE_CAN_RX_QUEUE_DEPTH (16u) +#define SEAT_MOVE_CAN_DLC (8u) +#define SEAT_MOVE_CAN_PROTOCOL_VERSION (1u) + +#define SEAT_MOVE_CAN_COMMAND_ID (0x300u) +#define SEAT_MOVE_CAN_SYSTEM_STATUS_ID (0x301u) +#define SEAT_MOVE_CAN_MOTOR_STATUS_ID (0x302u) +#define SEAT_MOVE_CAN_DIAGNOSTIC_ID (0x303u) +#define SEAT_MOVE_CAN_CURRENT_DIAGNOSTIC_ID (0x304u) +#define SEAT_MOVE_CAN_FIRMWARE_ID_ID (0x307u) +#define SEAT_MOVE_FIRMWARE_REVISION (15u) +#define SEAT_MOVE_FIRMWARE_BUILD_ID (17u) + +#define SEAT_MOVE_SYSTEM_STATUS_PERIOD_MS (100u) +#define SEAT_MOVE_MOTOR_STATUS_POLL_MS (25u) +#define SEAT_MOVE_DIAGNOSTIC_POLL_MS (25u) +#define SEAT_MOVE_FIRMWARE_ID_PERIOD_MS (500u) + +/* Current-aware stall detection remains active for timed movement. */ +#define SEAT_MOVE_MOTOR_CURRENT_SAMPLE_MS (10u) +#define SEAT_MOVE_MOTOR_CURRENT_MAX_AGE_MS (100u) +#define SEAT_MOVE_MOTOR_CURRENT_BASELINE_FILTER_SHIFT (3u) +#define SEAT_MOVE_MOTOR_STALL_CURRENT_CONFIRM_MS (200u) +#define SEAT_MOVE_MOTOR_NO_MOTION_TIMEOUT_MS (5000u) +#define SEAT_MOVE_M0_STALL_CURRENT_DELTA_RAW (500u) +#define SEAT_MOVE_M1_STALL_CURRENT_DELTA_RAW (500u) +#define SEAT_MOVE_M2_STALL_CURRENT_DELTA_RAW (500u) +#define SEAT_MOVE_M3_STALL_CURRENT_DELTA_RAW (500u) +#define SEAT_MOVE_MOTOR_CURRENT_MAX_ERRORS (3u) + +/* Debug-detach and watchdog safety. HIL registers remain internal to the + * reused safety manager; this image exposes no HIL CAN command/filter. */ +#define SEAT_MOVE_DEBUG_SESSION_BREG_INDEX (3u) +#define SEAT_MOVE_DEBUG_ACTIVE_MAGIC (0x4D4F5641u) +#define SEAT_MOVE_DEBUG_EXIT_MAGIC (0x4D4F5645u) +#define SEAT_MOVE_WDT_TIMEOUT_TICKS (3200u) +#define SEAT_MOVE_HIL_WDT_MAGIC_BREG_INDEX (0u) +#define SEAT_MOVE_HIL_WDT_TOKEN_BREG_INDEX (1u) +#define SEAT_MOVE_HIL_WDT_CHECK_BREG_INDEX (2u) +#define SEAT_MOVE_HIL_WDT_MARKER (0x48494C36u) + +#if !defined(CYT4BFBCJE) || !defined(CY_MCU_rev_d) +#error "Timed passenger-seat firmware requires CYT4BFBCJE rev_d." +#endif + +#if (SEAT_MOVE_ACTIVE_MOTOR_MASK != 0x0Fu) +#error "All four passenger-seat motor outputs must be enabled." +#endif + +#if (SEAT_MOVE_MAX_CONCURRENT_MOTORS == 0u) || \ + (SEAT_MOVE_MAX_CONCURRENT_MOTORS > SEAT_MOVE_MOTOR_COUNT) +#error "Concurrent motor budget must be between one and four." +#endif + +#if ((SEAT_TIMED_MIN_DURATION_MS == 0u) || \ + (SEAT_TIMED_M0_MAX_DURATION_MS > 65535u) || \ + (SEAT_TIMED_M1_MAX_DURATION_MS > 65535u) || \ + (SEAT_TIMED_M2_MAX_DURATION_MS > 65535u) || \ + (SEAT_TIMED_M3_MAX_DURATION_MS > 65535u)) +#error "Timed-command duration limits must fit ParamA." +#endif + +#if ((SEAT_MOVE_CAN_COMMAND_ID > 0x7FFu) || \ + (SEAT_MOVE_CAN_SYSTEM_STATUS_ID > 0x7FFu) || \ + (SEAT_MOVE_CAN_MOTOR_STATUS_ID > 0x7FFu) || \ + (SEAT_MOVE_CAN_DIAGNOSTIC_ID > 0x7FFu) || \ + (SEAT_MOVE_CAN_CURRENT_DIAGNOSTIC_ID > 0x7FFu) || \ + (SEAT_MOVE_CAN_FIRMWARE_ID_ID > 0x7FFu)) +#error "Seat movement CAN identifiers must be 11-bit standard IDs." +#endif + +#if (SEAT_MOVE_FIRMWARE_REVISION > 0x0Fu) || \ + (SEAT_MOVE_FIRMWARE_BUILD_ID > 0xFFu) +#error "Firmware identity fields exceed their CAN encoding." +#endif + +#if ((SEAT_MOVE_MOTOR_CURRENT_SAMPLE_MS == 0u) || \ + (SEAT_MOVE_MOTOR_CURRENT_MAX_AGE_MS < \ + SEAT_MOVE_MOTOR_CURRENT_SAMPLE_MS) || \ + (SEAT_MOVE_MOTOR_STALL_CURRENT_CONFIRM_MS == 0u) || \ + (SEAT_MOVE_MOTOR_NO_MOTION_TIMEOUT_MS <= \ + SEAT_MOVE_MOTOR_STALL_CURRENT_CONFIRM_MS) || \ + (SEAT_MOVE_MOTOR_CURRENT_MAX_ERRORS == 0u)) +#error "Motor current/stall timing configuration is invalid." +#endif + +#endif /* SEAT_MOVE_CONFIG_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Docs/BUILD_INFO_2026-09-16.md b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Docs/BUILD_INFO_2026-09-16.md new file mode 100644 index 0000000..8cf0b17 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Docs/BUILD_INFO_2026-09-16.md @@ -0,0 +1,25 @@ +# Build information — 2026-09-16 + +- Project: `Passenger_Seat_Timed_Controller_demo.ewp` +- Configuration: `rev_d` +- IAR command-line builder: 9.2.2.10770 +- IAR ARM compiler/linker: 9.40.1.364 +- Result: 0 errors, 0 warnings +- Read-only code: 40,626 bytes +- Read-only data: 1,977 bytes +- Read-write data: 6,182 bytes + +Artifacts: + +| File | SHA-256 | +|---|---| +| `IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.hex` | `7B47002B7B1A882A90717CE2727D60349B6C58CF0C1E95E48094C85EDB376CDC` | +| `IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.out` | `B0C740CB2BE02008BC03803B0A3422D817ABD1CF2BF977156294FB04BBDFAB7F` | + +The linker map contains `SeatMotor_MoveTimedDuty` as the sole linked +`SeatMotor_Move*`/calibration/homing entry point. No dynamic-mode, position +store, or thermal-controller application symbol is linked. The HEX contains +the `PST1`, revision 15, build 17 identity payload. + +This result is a compile/link verification only. No target was flashed and no +physical actuator movement was performed during this build. diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Docs/README_Passenger_Seat_Timed_Controller.md b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Docs/README_Passenger_Seat_Timed_Controller.md new file mode 100644 index 0000000..ddfcdd9 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Docs/README_Passenger_Seat_Timed_Controller.md @@ -0,0 +1,88 @@ +# 副驾座椅四轴定时控制固件 + +本目录是从 `Seat_Controller_demo` 独立出的副驾定时控制版本。原工程保持不变;本工程的 CAN 应用层只允许四轴定时运动,不提供位置、相对位移、回零、标定、动态模式或热控能力。 + +## IAR 工程 + +- 工程文件:`IAR/Passenger_Seat_Timed_Controller_demo.ewp` +- 目标器件:Infineon TRAVEO T2G `CYT4BFBCJE`,`rev_d` +- IAR 工程宏:`PASSENGER_SEAT_TIMED_ONLY=1` +- HEX 输出:`Passenger_Seat_Timed_Controller_demo.hex` +- 固件身份帧:CAN `0x307`,字节 0..3 为 ASCII `PST1`,Build ID 为 `17` + +工程复用原固件已验证的 DRV8718、Hall、电流检测、堵转/端点保护、看门狗和调试器退出安全逻辑。加热和风扇应用没有链接进固件;其 BSP 只用于启动及异常路径中强制输出关闭。 + +## 四轴能力 + +| MotorID | 机构 | 最短时间 | 最长时间 | 实际占空比限制 | +|---:|---|---:|---:|---:| +| 0 | 水平前后 | 300 ms | 30000 ms | 50%..95% | +| 1 | 坐垫升降 | 300 ms | 20000 ms | 50%..95% | +| 2 | 靠背角度 | 300 ms | 30000 ms | 50%..95% | +| 3 | 腿托升降 | 300 ms | 25000 ms | 固定上限 50% | + +四轴可并发,启动间隔至少 100 ms。同一轴收到更新的定时命令时,先停止该轴,再执行新命令;不影响其他轴。定时运动不依赖绝对位置有效性,但端点、堵转、电流和驱动故障保护始终生效。 + +## CAN 命令 + +CAN4 使用经典 CAN、标准 11 位 ID、500 kbit/s。接收过滤器只接受 `0x300`,DLC 必须为 8。 + +`0x300` 的 8 字节按小端编码: + +| 字节 | 字段 | +|---|---| +| B0 | Opcode | +| B1 | Control:bit1:0 MotorID,bit3:2 Direction,bit5:4 ProtocolVersion=1,bit7:6=0 | +| B3:B2 | Sequence,uint16 | +| B5:B4 | ParamA,uint16 | +| B7:B6 | ParamB,uint16 | + +支持的 Opcode: + +| Opcode | 功能 | Direction | ParamA | ParamB | +|---:|---|---|---|---| +| `0x03` | 定时运动 | `1` 向远端,`2` 向近端 | 时间,ms | 请求占空比 ×10,范围 300..1000 | +| `0x08` | 停止单轴 | `0` | `0` | `0` | +| `0x09` | 停止全部 | `0`,MotorID=`0` | `0` | `0` | +| `0x0C` | 查询单轴状态 | `0` | `0` | `0` | +| `0x0D` | 清除单轴任务故障 | `0` | `0xC33C` | `0` | + +请求占空比会被安全范围约束:M0..M2 最终为 50%..95%,M3 不超过 50%。`STOP_ALL` 不受序列号新旧限制,可用于随时重新同步和急停;其他命令要求 16 位 Sequence 按环形序列递增,完全相同的重复帧返回 Duplicate。 + +以下运动 Opcode 固定返回 `BAD_OPCODE(3)`: + +- `0x01` 绝对位置 +- `0x02` 相对位置 +- `0x0A` 行程标定 +- `0x0B` 已知行程回零 +- `0x0E` 启动动态模式 +- `0x0F` 停止动态模式 + +热控和 HIL 自愈帧不在硬件接收过滤器中,`0x306` 动态模式状态也不再发送。 + +## 状态反馈 + +保留与原座椅协议兼容的诊断帧: + +- `0x301`:系统状态、ACK、拒绝原因、活动/故障轴掩码 +- `0x302`:单轴任务状态;定时运行状态值为 `3` +- `0x303`:Hall、速度和故障诊断 +- `0x304`:电流诊断 +- `0x307`:固件身份 `PST1` + +`0x301` 中动态模式位和标定位始终为 0。位置有效位及百分比只作为 Hall 诊断信息返回,不用于目标位置控制。 + +## 构建与校验 + +1. 用 IAR Embedded Workbench for Arm 打开 `IAR/Passenger_Seat_Timed_Controller_demo.ewp`。 +2. 选择 `rev_d` 配置并 Build。 +3. 烧录前通过 `0x307` 确认目标固件身份为 `PST1`。 +4. 烧录和台架运动测试期间保证座椅机构无人员/障碍物,先验证 `STOP_ALL`,再逐轴低时长测试。 + +仓库静态校验: + +```text +python firmware/t2g/Passenger_Seat_Timed_Controller_demo/Tests/audit_timed_only.py +``` + +该脚本检查 IAR XML、工程引用、定时版编译宏、CAN 接收过滤器、允许的 Opcode,以及位置/动态/热控模块未被链接。 diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Driver/motor_control.c b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Driver/motor_control.c new file mode 100644 index 0000000..6bcb378 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Driver/motor_control.c @@ -0,0 +1,4340 @@ +/***************************************************************************//** +* \file motor_control.c +* +* CYT4BFBCJE CM0+ low-level four-channel DRV8718S-Q1 motor control. +*******************************************************************************/ + +#include "cy_project.h" +#include "cy_device_headers.h" +#include "bsp_seat_current_adc.h" +#include "motor_control.h" +#include "seat_move_config.h" + +#define MOTOR_DIAGNOSTIC_LOG_ENABLE (0u) + +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) +#include +#define MOTOR_LOG(...) printf(__VA_ARGS__) +#else +#define MOTOR_LOG(...) ((void)0) +#endif + +#define DRV8718_REG_IC_STAT1 (0x00u) +#define DRV8718_REG_IC_CTRL1 (0x07u) +#define DRV8718_REG_IC_CTRL2 (0x08u) +#define DRV8718_REG_BRG_CTRL1 (0x09u) +#define DRV8718_REG_BRG_CTRL2 (0x0Au) +#define DRV8718_REG_PWM_CTRL1 (0x0Bu) +#define DRV8718_REG_PWM_CTRL2 (0x0Cu) +#define DRV8718_REG_PWM_CTRL3 (0x0Du) +#define DRV8718_REG_PWM_CTRL4 (0x0Eu) + +#define DRV8718_WRITE_FRAME(addr, data) \ + ((uint16_t)((((uint16_t)(addr) & 0x3Fu) << 8u) | ((uint16_t)(data) & 0xFFu))) +#define DRV8718_READ_FRAME(addr) \ + ((uint16_t)(0x4000u | ((((uint16_t)(addr)) & 0x3Fu) << 8u))) + +/* BRG_CTRL value encoding per half bridge: 0=Hi-Z, 1=Low, 2=High, 3=PWM. */ +#define HB_CTRL_HIZ (0u) +#define HB_CTRL_LOW (1u) +#define HB_CTRL_HIGH (2u) +#define HB_CTRL_PWM (3u) + +#define PWM_SRC_IN1 (0u) +#define PWM_SRC_IN2 (1u) +#define PWM_SRC_IN3 (2u) +#define PWM_SRC_IN4 (3u) + +#define MOTOR_COAST_BRG_CTRL1 (0x00u) +#define MOTOR_COAST_BRG_CTRL2 (0x00u) + +#define MOTOR_PROFILE_COUNT ((uint32_t)MOTOR_MODE_COUNT) +#define MOTOR_PWM_DUTY_PERCENT (50ul) +#define HALL_SAMPLE_INTERVAL_US (2000ul) +#define MOTOR_SAFETY_CHECK_INTERVAL_MS (50ul) +#define MOTOR_SAFETY_WINDOW_MS (500ul) +#define MOTOR_MODE0_STARTUP_GRACE_MS (1000ul) +#define MOTOR_MODE1_STARTUP_GRACE_MS (1000ul) +#define MOTOR_MODE2_STARTUP_GRACE_MS (1000ul) +#define MOTOR_MODE3_STARTUP_GRACE_MS (2000ul) +#define MOTOR_SAFETY_WINDOW_SAMPLES \ + (MOTOR_SAFETY_WINDOW_MS / MOTOR_SAFETY_CHECK_INTERVAL_MS) +#define MOTOR_ENDPOINT_EDGE_THRESHOLD (0ul) +#define MOTOR_STALL_EDGE_THRESHOLD (2ul) +#define MOTOR_OPERATION_TIMEOUT_MS (70000ul) +#define MOTOR_REVERSAL_COAST_MS (100ul) +#define MOTOR_COAST_HALL_QUIET_MS (100ul) +#define MOTOR_COAST_SETTLE_TIMEOUT_MS (5000ul) +#define MOTOR_MAX_AUTOMATIC_REVERSALS (1u) +#define MOTOR_CALIBRATION_RANGE_MIN_PERCENT (70ul) +#define MOTOR_CALIBRATION_RANGE_MAX_PERCENT (130ul) +#define MOTOR_HOME_PROBE_VERIFY_EDGES (4ul) +#define MOTOR_HOME_RETURN_EDGE_TOLERANCE (1ul) +#define MOTOR_MAX_CALIBRATED_SPEED_MULTIPLIER (2ul) +#define MOTOR_SPEED_CONTROL_START_DELAY_MS (500ul) +#define MOTOR_SPEED_CONTROL_CHECK_MS (100ul) +#define MOTOR_SPEED_CONTROL_WINDOW_MS (500ul) +#define MOTOR_SPEED_PROGRESS_CONTROL_START_MS (100ul) +#define MOTOR_SPEED_CONTROL_MIN_DUTY_PERCENT (5u) +#define MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT (0u) +#define MOTOR_SPEED_PROGRESS_TOLERANCE_PULSES (1ul) +#define MOTOR_SPEED_CONTROL_HISTORY_SAMPLES \ + (MOTOR_SPEED_CONTROL_WINDOW_MS / MOTOR_SPEED_CONTROL_CHECK_MS) +#define MOTOR_SPEED_TOLERANCE_PERCENT (10ul) +#define MOTOR_MODE0_SPEED_STEP_PPS_X100 (500ul) +#define MOTOR_MODE1_SPEED_STEP_PPS_X100 (350ul) +#define MOTOR_MODE2_SPEED_STEP_PPS_X100 (500ul) +#define MOTOR_MODE3_SPEED_STEP_PPS_X100 (500ul) +#define DRV8718_SPI_TIMEOUT_LOOPS (100000ul) +#define DRV8718_STATUS_POLL_INTERVAL_MS (50ul) +#define DRV8718_SDO_HEADER_MASK (0xC000u) +#define DRV8718_SDO_HEADER_VALUE (0xC000u) +#define DRV8718_SDO_ALERT_MASK (0x3F00u) +#define DRV8718_IC_STAT1_SPI_OK (0x80u) +#define DRV8718_IC_STAT1_ALERT_MASK (0x3Fu) + +/* One fixed PWM mapping assigns one independent DRV input to each H-bridge. */ +#define DRV8718_PWM_CTRL1_ALL_MOTORS \ + ((uint8_t)((PWM_SRC_IN1 << 6u) | (PWM_SRC_IN1 << 4u) | \ + (PWM_SRC_IN4 << 2u) | PWM_SRC_IN4)) +#define DRV8718_PWM_CTRL2_ALL_MOTORS \ + ((uint8_t)((PWM_SRC_IN3 << 6u) | (PWM_SRC_IN3 << 4u) | \ + (PWM_SRC_IN2 << 2u) | PWM_SRC_IN2)) +#define MOTOR_PWM_CTRL3_HIGHSIDE (0x00u) +#define MOTOR_PWM_CTRL4_ACTIVE_HI (0x00u) +#define DRV8718_IC_CTRL1_ENABLE_UNLOCKED (0x86u) +#define DRV8718_IC_CTRL1_CLEAR_FAULTS (0x87u) +#define DRV8718_IC_CTRL2_DRVOFF_MODE (0x02u) + +#define SPI_BAUDRATE (1000000ul) +#define SPI_OVERSAMPLING (16ul) +#define SPI_INPUT_CLOCK_HZ (SPI_BAUDRATE * SPI_OVERSAMPLING) +#define SOURCE_CLOCK_HZ (80000000ul) +#define SCB4_DIVIDER_NO (1u) + +#define MOTOR_TIMER_INPUT_CLOCK_HZ (2000000ul) +#define MOTOR_TIMER_DIVIDER_NO (2u) +#define MOTOR_PWM_FREQUENCY_HZ (20000ul) +#define MOTOR_PWM_PERIOD_COUNTS (MOTOR_TIMER_INPUT_CLOCK_HZ / MOTOR_PWM_FREQUENCY_HZ) +#define MOTOR_PWM_COMPARE_COUNTS ((MOTOR_PWM_PERIOD_COUNTS * MOTOR_PWM_DUTY_PERCENT) / 100ul) + +#define HALL_TIMEBASE_TCPWM TCPWM1_GRP0_CNT20 +#define HALL_TIMEBASE_CLOCK PCLK_TCPWM1_CLOCKS20 +#define HALL_TIMEBASE_IRQ tcpwm_1_interrupts_20_IRQn +#define HALL_TIMEBASE_PERIOD_COUNTS \ + ((MOTOR_TIMER_INPUT_CLOCK_HZ / 1000000ul) * HALL_SAMPLE_INTERVAL_US) +#define MOTOR_MS_TO_TICKS(ms) \ + ((uint32_t)(((((uint64_t)(ms)) * 1000ull) + \ + HALL_SAMPLE_INTERVAL_US - 1ull) / HALL_SAMPLE_INTERVAL_US)) +#define MOTOR_TICKS_TO_MS(ticks) \ + ((uint32_t)((((uint64_t)(ticks)) * HALL_SAMPLE_INTERVAL_US) / 1000ull)) +#define MOTOR_SAFETY_CHECK_TICKS \ + MOTOR_MS_TO_TICKS(MOTOR_SAFETY_CHECK_INTERVAL_MS) + +#define DRV_DVDD_EN_PORT P3_1_GPIO_STD_PORT +#define DRV_DVDD_EN_PIN P3_1_GPIO_STD_PIN +#define DRV_NCS_PORT P31_2_GPIO_STD_PORT +#define DRV_NCS_PIN P31_2_GPIO_STD_PIN +#define DRV_NSLEEP_PORT P9_1_GPIO_STD_PORT +#define DRV_NSLEEP_PIN P9_1_GPIO_STD_PIN +#define DRV_DRVOFF_PORT P8_1_GPIO_STD_PORT +#define DRV_DRVOFF_PIN P8_1_GPIO_STD_PIN +#define DRV_IN1_PORT P1_0_GPIO_STD_PORT +#define DRV_IN1_PIN P1_0_GPIO_STD_PIN +#define DRV_IN1_PWM_MUX P1_0_TCPWM1_LINE12 +#define DRV_IN1_PWM_TCPWM TCPWM1_GRP0_CNT12 +#define DRV_IN1_PWM_CLOCK PCLK_TCPWM1_CLOCKS12 +#define DRV_IN2_PORT P5_4_GPIO_STD_PORT +#define DRV_IN2_PIN P5_4_GPIO_STD_PIN +#define DRV_IN2_PWM_MUX P5_4_TCPWM1_LINE13 +#define DRV_IN2_PWM_TCPWM TCPWM1_GRP0_CNT13 +#define DRV_IN2_PWM_CLOCK PCLK_TCPWM1_CLOCKS13 +#define DRV_IN3_PORT P5_5_GPIO_STD_PORT +#define DRV_IN3_PIN P5_5_GPIO_STD_PIN +#define DRV_IN3_PWM_MUX P5_5_TCPWM1_LINE14 +#define DRV_IN3_PWM_TCPWM TCPWM1_GRP0_CNT14 +#define DRV_IN3_PWM_CLOCK PCLK_TCPWM1_CLOCKS14 +#define DRV_IN4_PORT P8_0_GPIO_STD_PORT +#define DRV_IN4_PIN P8_0_GPIO_STD_PIN +#define DRV_IN4_PWM_MUX P8_0_TCPWM1_LINE19 +#define DRV_IN4_PWM_TCPWM TCPWM1_GRP0_CNT19 +#define DRV_IN4_PWM_CLOCK PCLK_TCPWM1_CLOCKS19 + +#define DRV_SPI_MISO_PORT P6_0_GPIO_STD_PORT +#define DRV_SPI_MISO_PIN P6_0_GPIO_STD_PIN +#define DRV_SPI_MISO_MUX P6_0_SCB4_SPI_MISO +#define DRV_SPI_MOSI_PORT P6_1_GPIO_STD_PORT +#define DRV_SPI_MOSI_PIN P6_1_GPIO_STD_PIN +#define DRV_SPI_MOSI_MUX P6_1_SCB4_SPI_MOSI +#define DRV_SPI_CLK_PORT P6_2_GPIO_STD_PORT +#define DRV_SPI_CLK_PIN P6_2_GPIO_STD_PIN +#define DRV_SPI_CLK_MUX P6_2_SCB4_SPI_CLK + +#define HALL_BACK_PORT P14_0_GPIO_STD_PORT +#define HALL_BACK_PIN P14_0_GPIO_STD_PIN +#define HALL_FOOT_2030_PORT P10_1_GPIO_STD_PORT +#define HALL_FOOT_2030_PIN P10_1_GPIO_STD_PIN +#define HALL_LEG_PORT P18_2_GPIO_STD_PORT +#define HALL_LEG_PIN P18_2_GPIO_STD_PIN +#define HALL_HEIGHT_PORT P12_5_GPIO_STD_PORT +#define HALL_HEIGHT_PIN P12_5_GPIO_STD_PIN + +#if ((SOURCE_CLOCK_HZ % MOTOR_TIMER_INPUT_CLOCK_HZ) != 0ul) +#error "MOTOR_TIMER_INPUT_CLOCK_HZ must divide SOURCE_CLOCK_HZ exactly." +#endif + +#if ((MOTOR_TIMER_INPUT_CLOCK_HZ % MOTOR_PWM_FREQUENCY_HZ) != 0ul) +#error "MOTOR_PWM_FREQUENCY_HZ must divide MOTOR_TIMER_INPUT_CLOCK_HZ exactly." +#endif + +#if ((HALL_SAMPLE_INTERVAL_US % 1000ul) != 0ul) +#error "HALL_SAMPLE_INTERVAL_US must be an integer number of milliseconds." +#endif + +#if ((MOTOR_SAFETY_WINDOW_MS % MOTOR_SAFETY_CHECK_INTERVAL_MS) != 0ul) +#error "MOTOR_SAFETY_WINDOW_MS must be a multiple of the safety check period." +#endif + +#if (((MOTOR_MODE0_STARTUP_GRACE_MS % \ + MOTOR_SAFETY_CHECK_INTERVAL_MS) != 0ul) || \ + ((MOTOR_MODE1_STARTUP_GRACE_MS % \ + MOTOR_SAFETY_CHECK_INTERVAL_MS) != 0ul) || \ + ((MOTOR_MODE2_STARTUP_GRACE_MS % \ + MOTOR_SAFETY_CHECK_INTERVAL_MS) != 0ul) || \ + ((MOTOR_MODE3_STARTUP_GRACE_MS % \ + MOTOR_SAFETY_CHECK_INTERVAL_MS) != 0ul)) +#error "Every axis startup grace must align with the safety check period." +#endif + +#if ((MOTOR_SAFETY_CHECK_INTERVAL_MS * 1000ul) < HALL_SAMPLE_INTERVAL_US) +#error "The safety task cannot run faster than the Hall sampling timebase." +#endif + +#if ((MOTOR_SPEED_CONTROL_WINDOW_MS % MOTOR_SPEED_CONTROL_CHECK_MS) != 0ul) +#error "MOTOR_SPEED_CONTROL_WINDOW_MS must be a multiple of the speed check period." +#endif + +#if ((MOTOR_SPEED_CONTROL_MIN_DUTY_PERCENT == 0u) || \ + (MOTOR_SPEED_CONTROL_MIN_DUTY_PERCENT >= MOTOR_SPEED_MIN_DUTY_PERCENT)) +#error "Adaptive speed duty must be below the fixed-duty command minimum." +#endif + +#if ((MOTOR_PWM_DUTY_PERCENT < MOTOR_SPEED_MIN_DUTY_PERCENT) || \ + (MOTOR_PWM_DUTY_PERCENT > MOTOR_SPEED_MAX_DUTY_PERCENT)) +#error "The default motor duty must stay inside the speed-control duty limits." +#endif + +typedef struct +{ + const char *name; + const char *hallName; + volatile stc_GPIO_PRT_t *pwmPort; + uint32_t pwmPin; + en_hsiom_sel_t pwmHsiom; + volatile stc_TCPWM_GRP_CNT_t *pwmTcpwm; + en_clk_dst_t pwmClock; + volatile stc_GPIO_PRT_t *hallPort; + uint32_t hallPin; + uint8_t bridgeRegister; + uint8_t bridgeMask; + uint8_t towardFarBridgeValue; + uint8_t towardNearBridgeValue; + uint32_t defaultMaxHallCount; + uint32_t maxPhysicalValueX100; + uint32_t maxSpeedPpsX100; + uint32_t speedStepPpsX100; + uint32_t startupGraceMs; + uint16_t stallCurrentDeltaRaw; +} motor_profile_t; + +typedef struct +{ + uint32_t rawRisingEdges; + uint32_t rawTotalEdges; + uint32_t positionHallCount; + uint32_t maxHallCount; + uint32_t targetHallCount; + uint32_t motionStartTicks; + uint32_t calibrationTimeoutTicks; + uint32_t scheduledStartTicks; + uint32_t coastStartTicks; + uint32_t activityStartTicks; + uint32_t activityRisingBaseline; + uint32_t activityTotalBaseline; + uint32_t activityMonitorStartTicks; + uint32_t activityWindowBaseline; + uint32_t currentSenseLastSampleTicks; + uint32_t lowHallStartTicks; + uint32_t lowHallEdgeBaseline; + uint32_t stallCurrentStartTicks; + uint32_t activityHistoryEdges[MOTOR_SAFETY_WINDOW_SAMPLES]; + uint32_t recentWindowEdges; + uint16_t currentSenseRaw; + uint16_t currentSenseBaselineRaw; + uint16_t currentSenseDeltaRaw; + uint16_t activityHistoryIndex; + uint16_t activityValidSamples; + motor_direction_t direction; + motor_direction_t eventDirection; + motor_direction_t scheduledDirection; + motor_direction_t coastDirection; + motor_event_t pendingEvent; + motor_event_t lastEvent; + motor_event_t moveResultEvent; + uint8_t lastLevel; + uint8_t automaticReverseCount; + uint8_t currentSenseErrorCount; + bool hallInitialized; + bool positionValid; + bool targetActive; + bool calibrating; + bool activityWindowReady; + bool activityMonitorStarted; + bool currentSenseValid; + bool currentSenseBaselineValid; + bool lowHallTracking; + bool stallCurrentTracking; + bool reverseAtEndpoint; + bool reverseAtStall; + bool startPending; + bool coastTracking; + bool operationActive; + bool eventWasTargetMove; + bool moveResultReady; + uint32_t moveCommandSequence; + uint32_t moveResultSequence; + uint32_t speedDurationMs; + uint32_t speedTravelValueX100; + uint32_t speedPlannedHallPulses; + uint32_t speedTargetPpsX100; + uint32_t speedActualPpsX100; + uint32_t maxSpeedPpsX100; + uint32_t speedControlStartTicks; + uint32_t speedLastSampleTicks; + uint32_t speedBaselineRawEdges; + uint32_t speedHistoryRawEdges[MOTOR_SPEED_CONTROL_HISTORY_SAMPLES]; + uint32_t speedHistoryTicks[MOTOR_SPEED_CONTROL_HISTORY_SAMPLES]; + uint8_t speedHistoryIndex; + uint8_t speedHistoryValidSamples; + uint8_t speedInitialDutyPercent; + uint8_t speedCurrentDutyPercent; + uint8_t fixedDutyPercent; + uint16_t latestAbortSequence; + uint16_t acceptedCommandSequence; + uint32_t cancellationGeneration; + uint32_t acceptedCancellationGeneration; + bool abortSequenceValid; + bool acceptedCommandValid; + bool speedControlEnabled; +} motor_runtime_t; + +static const motor_profile_t motorProfiles[] = +{ + { + "MOTOR_IN2_HB7_HB8_HORIZONTAL", + "HORIZONTAL_HALL", + DRV_IN2_PORT, + DRV_IN2_PIN, + DRV_IN2_PWM_MUX, + DRV_IN2_PWM_TCPWM, + DRV_IN2_PWM_CLOCK, + HALL_LEG_PORT, + HALL_LEG_PIN, + DRV8718_REG_BRG_CTRL2, + 0x0Fu, + (uint8_t)((HB_CTRL_PWM << 2u) | HB_CTRL_LOW), + (uint8_t)((HB_CTRL_LOW << 2u) | HB_CTRL_PWM), + MOTOR_MODE0_DEFAULT_MAX_HALL_COUNT, + MOTOR_MODE0_MAX_POSITION_MM_X100, + MOTOR_MODE0_MAX_SPEED_PPS_X100, + MOTOR_MODE0_SPEED_STEP_PPS_X100, + MOTOR_MODE0_STARTUP_GRACE_MS, + SEAT_MOVE_M0_STALL_CURRENT_DELTA_RAW, + }, + { + "MOTOR_IN3_HB5_HB6_SEAT_PAN", + "SEAT_PAN_HALL", + DRV_IN3_PORT, + DRV_IN3_PIN, + DRV_IN3_PWM_MUX, + DRV_IN3_PWM_TCPWM, + DRV_IN3_PWM_CLOCK, + HALL_FOOT_2030_PORT, + HALL_FOOT_2030_PIN, + DRV8718_REG_BRG_CTRL2, + 0xF0u, + (uint8_t)((HB_CTRL_PWM << 6u) | (HB_CTRL_LOW << 4u)), + (uint8_t)((HB_CTRL_LOW << 6u) | (HB_CTRL_PWM << 4u)), + MOTOR_MODE1_DEFAULT_MAX_HALL_COUNT, + MOTOR_MODE1_MAX_VERTICAL_MM_X100, + MOTOR_MODE1_MAX_SPEED_PPS_X100, + MOTOR_MODE1_SPEED_STEP_PPS_X100, + MOTOR_MODE1_STARTUP_GRACE_MS, + SEAT_MOVE_M1_STALL_CURRENT_DELTA_RAW, + }, + { + "MOTOR_IN4_HB3_HB4_BACKREST", + "BACKREST_HALL", + DRV_IN4_PORT, + DRV_IN4_PIN, + DRV_IN4_PWM_MUX, + DRV_IN4_PWM_TCPWM, + DRV_IN4_PWM_CLOCK, + HALL_BACK_PORT, + HALL_BACK_PIN, + DRV8718_REG_BRG_CTRL1, + 0x0Fu, + (uint8_t)((HB_CTRL_PWM << 2u) | HB_CTRL_LOW), + (uint8_t)((HB_CTRL_LOW << 2u) | HB_CTRL_PWM), + MOTOR_MODE2_DEFAULT_MAX_HALL_COUNT, + MOTOR_MODE2_MAX_ANGLE_DEG_X100, + MOTOR_MODE2_MAX_SPEED_PPS_X100, + MOTOR_MODE2_SPEED_STEP_PPS_X100, + MOTOR_MODE2_STARTUP_GRACE_MS, + SEAT_MOVE_M2_STALL_CURRENT_DELTA_RAW, + }, + { + "MOTOR_IN1_HB1_HB2_LEGREST", + "LEGREST_HALL", + DRV_IN1_PORT, + DRV_IN1_PIN, + DRV_IN1_PWM_MUX, + DRV_IN1_PWM_TCPWM, + DRV_IN1_PWM_CLOCK, + HALL_HEIGHT_PORT, + HALL_HEIGHT_PIN, + DRV8718_REG_BRG_CTRL1, + 0xF0u, + /* Keep direction aligned with the range-calibration firmware: + * near is legrest down/vertical and far is legrest up. */ + (uint8_t)((HB_CTRL_PWM << 6u) | (HB_CTRL_LOW << 4u)), + (uint8_t)((HB_CTRL_LOW << 6u) | (HB_CTRL_PWM << 4u)), + MOTOR_MODE3_DEFAULT_MAX_HALL_COUNT, + MOTOR_MODE3_MAX_ANGLE_DEG_X100, + MOTOR_MODE3_MAX_SPEED_PPS_X100, + MOTOR_MODE3_SPEED_STEP_PPS_X100, + MOTOR_MODE3_STARTUP_GRACE_MS, + SEAT_MOVE_M3_STALL_CURRENT_DELTA_RAW, + }, +}; + +/* Keep the public mode enumeration and the hardware profile table locked + * together. A missing profile must fail the build instead of indexing past + * the table from the 2 ms Hall ISR. */ +typedef char motor_profile_count_must_match_mode_count[ + ((sizeof(motorProfiles) / sizeof(motorProfiles[0])) == + (uint32_t)MOTOR_MODE_COUNT) ? 1 : -1]; + +static cy_stc_gpio_pin_config_t gpioOutCfg = +{ + .outVal = 0ul, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = HSIOM_SEL_GPIO, + .intEdge = 0ul, + .intMask = 0ul, + .vtrip = 0ul, + .slewRate = 0ul, + .driveSel = 0ul, +}; + +static cy_stc_gpio_pin_config_t spiPinCfg = +{ + .outVal = 0ul, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = HSIOM_SEL_GPIO, + .intEdge = 0ul, + .intMask = 0ul, + .vtrip = 0ul, + .slewRate = 0ul, + .driveSel = 0ul, +}; + +static cy_stc_gpio_pin_config_t gpioInCfg = +{ + .outVal = 0ul, + .driveMode = CY_GPIO_DM_HIGHZ, + .hsiom = HSIOM_SEL_GPIO, + .intEdge = 0ul, + .intMask = 0ul, + .vtrip = 0ul, + .slewRate = 0ul, + .driveSel = 0ul, +}; + +static cy_stc_gpio_pin_config_t pwmPinCfg = +{ + .outVal = 0ul, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = HSIOM_SEL_GPIO, + .intEdge = 0ul, + .intMask = 0ul, + .vtrip = 0ul, + .slewRate = 0ul, + .driveSel = 0ul, +}; + +static volatile motor_runtime_t motorRuntime[MOTOR_MODE_COUNT]; +static volatile uint32_t hallTimebaseTicks = 0ul; +static volatile uint32_t safetyTickDivider = 0ul; +static uint8_t bridgeCtrl1Shadow = MOTOR_COAST_BRG_CTRL1; +static uint8_t bridgeCtrl2Shadow = MOTOR_COAST_BRG_CTRL2; +static bool motorControlReady = false; +static bool pwmTimersInitialized = false; +static volatile bool driverFaultLatched = false; +static volatile bool safetyInhibitLatched = false; +static volatile bool motorServiceActive = false; +static volatile bool driverSpiActive = false; +static volatile uint8_t abortRequestedMask = 0u; +static bool watchdogServiceEnabled = false; +static bool driverStatusMonitoringEnabled = false; +static uint32_t driverStatusLastPollTicks = 0ul; +static uint32_t currentSenseLastServiceTicks = 0ul; + +static bool Motor_CalibrationRangeIsPlausible(uint32_t motorIndex, + uint32_t measuredPulses); +static bool Motor_WaitForCoastToSettle(motor_mode_t mode); + +CY_WEAK void Motor_CalibrationServiceHook(void) +{ + /* Optional application service point. */ +} + +static const cy_stc_tcpwm_pwm_config_t motorPwmCfg = +{ + .pwmMode = CY_TCPWM_PWM_MODE_PWM, + .clockPrescaler = CY_TCPWM_PRESCALER_DIVBY_1, + .debug_pause = false, + .cc0MatchMode = CY_TCPWM_PWM_TR_CTRL2_CLEAR, + .overflowMode = CY_TCPWM_PWM_TR_CTRL2_SET, + .underflowMode = CY_TCPWM_PWM_TR_CTRL2_NO_CHANGE, + .cc1MatchMode = CY_TCPWM_PWM_TR_CTRL2_NO_CHANGE, + .deadTime = 0ul, + .deadTimeComp = 0ul, + .runMode = CY_TCPWM_PWM_CONTINUOUS, + .period = MOTOR_PWM_PERIOD_COUNTS - 1ul, + .period_buff = 0ul, + .enablePeriodSwap = false, + .compare0 = MOTOR_PWM_COMPARE_COUNTS, + .compare1 = 0ul, + .enableCompare0Swap = false, + .enableCompare1Swap = false, + .interruptSources = CY_TCPWM_INT_NONE, + .invertPWMOut = 0ul, + .invertPWMOutN = 0ul, + .killMode = CY_TCPWM_PWM_STOP_ON_KILL, + .switchInputMode = CY_TCPWM_INPUT_LEVEL, + .switchInput = 0ul, + .reloadInputMode = CY_TCPWM_INPUT_LEVEL, + .reloadInput = 0ul, + .startInputMode = CY_TCPWM_INPUT_LEVEL, + .startInput = 0ul, + .kill0InputMode = CY_TCPWM_INPUT_LEVEL, + .kill0Input = 0ul, + .kill1InputMode = CY_TCPWM_INPUT_LEVEL, + .kill1Input = 0ul, + .countInputMode = CY_TCPWM_INPUT_LEVEL, + .countInput = 1ul, + .immediateKill = true, + .pwmOnDisable = CY_TCPWM_PWM_OUT_MODE_LOW, +}; + +static const cy_stc_tcpwm_counter_config_t hallTimebaseCfg = +{ + .period = HALL_TIMEBASE_PERIOD_COUNTS - 1ul, + .clockPrescaler = CY_TCPWM_PRESCALER_DIVBY_1, + .runMode = CY_TCPWM_COUNTER_CONTINUOUS, + .countDirection = CY_TCPWM_COUNTER_COUNT_UP, + .debug_pause = false, + .compareOrCapture = CY_TCPWM_COUNTER_MODE_COMPARE, + .compare0 = 0ul, + .compare0_buff = 0ul, + .compare1 = 0ul, + .compare1_buff = 0ul, + .enableCompare0Swap = false, + .enableCompare1Swap = false, + .interruptSources = CY_TCPWM_INT_NONE, + .capture0InputMode = CY_TCPWM_INPUT_LEVEL, + .capture0Input = 0ul, + .reloadInputMode = CY_TCPWM_INPUT_LEVEL, + .reloadInput = 0ul, + .startInputMode = CY_TCPWM_INPUT_LEVEL, + .startInput = 0ul, + .stopInputMode = CY_TCPWM_INPUT_LEVEL, + .stopInput = 0ul, + .capture1InputMode = CY_TCPWM_INPUT_LEVEL, + .capture1Input = 0ul, + .countInputMode = CY_TCPWM_INPUT_LEVEL, + .countInput = 1ul, + .trigger0EventCfg = CY_TCPWM_COUNTER_OVERFLOW, + .trigger1EventCfg = CY_TCPWM_COUNTER_OVERFLOW, +}; + +static const cy_stc_sysint_irq_t hallTimebaseIrqCfg = +{ + .sysIntSrc = HALL_TIMEBASE_IRQ, + .intIdx = CPUIntIdx3_IRQn, + .isEnabled = true, +}; + +static const cy_stc_scb_spi_config_t spiCfg = +{ + .spiMode = CY_SCB_SPI_MASTER, + .subMode = CY_SCB_SPI_MOTOROLA, + .sclkMode = CY_SCB_SPI_CPHA0_CPOL0, + .oversample = SPI_OVERSAMPLING, + .rxDataWidth = 16ul, + .txDataWidth = 16ul, + .enableMsbFirst = true, + .enableFreeRunSclk = false, + .enableInputFilter = false, + .enableMisoLateSample = true, + .enableTransferSeperation = false, + .ssPolarity0 = false, + .ssPolarity1 = false, + .ssPolarity2 = false, + .ssPolarity3 = false, + .enableWakeFromSleep = false, + .rxFifoTriggerLevel = 0ul, + .rxFifoIntEnableMask = 0ul, + .txFifoTriggerLevel = 0ul, + .txFifoIntEnableMask = 0ul, + .masterSlaveIntEnableMask = 0ul, + .enableSpiDoneInterrupt = false, + .enableSpiBusErrorInterrupt = false, +}; + +static void SetPeripheFracDiv24_5(uint64_t targetFreq, uint64_t sourceFreq, uint8_t divNum) +{ + uint64_t temp = ((uint64_t)sourceFreq << 5ull); + uint32_t divSetting = (uint32_t)(temp / targetFreq); + + Cy_SysClk_PeriphSetFracDivider(Cy_SysClk_GetClockGroup(PCLK_SCB4_CLOCK), + CY_SYSCLK_DIV_24_5_BIT, + divNum, + (((divSetting >> 5ul) & 0x00000FFFul) - 1ul), + (divSetting & 0x0000001Ful)); +} + +static void InitOutputPin(volatile stc_GPIO_PRT_t *port, uint32_t pin, uint32_t initialValue) +{ + gpioOutCfg.outVal = initialValue; + gpioOutCfg.hsiom = HSIOM_SEL_GPIO; + Cy_GPIO_Pin_Init(port, pin, &gpioOutCfg); +} + +static bool Motor_ModeIsValid(motor_mode_t mode) +{ + return ((uint32_t)mode < MOTOR_PROFILE_COUNT); +} + +static bool Motor_SequenceIsNewer(uint16_t candidate, uint16_t reference) +{ + return ((int16_t)((uint16_t)(candidate - reference)) > 0); +} + +static bool Motor_CommandIsCurrentUnsafe( + const volatile motor_runtime_t *state) +{ + if(false != safetyInhibitLatched) + { + return false; + } + if(false != state->abortSequenceValid) + { + return false; + } + if(false == state->acceptedCommandValid) + { + return (0ul == state->cancellationGeneration); + } + return (state->acceptedCancellationGeneration == + state->cancellationGeneration); +} + +static uint32_t Motor_DutyPercentToCompare(uint8_t dutyPercent) +{ + return (MOTOR_PWM_PERIOD_COUNTS * (uint32_t)dutyPercent) / 100ul; +} + +static void Motor_ApplyDutyPercent(uint32_t motorIndex, uint8_t dutyPercent) +{ + Cy_Tcpwm_Pwm_SetCompare0(motorProfiles[motorIndex].pwmTcpwm, + Motor_DutyPercentToCompare(dutyPercent)); +} + +static void Motor_ResetSpeedMeasurementUnsafe(volatile motor_runtime_t *state) +{ + state->speedActualPpsX100 = 0ul; + state->speedControlStartTicks = hallTimebaseTicks; + state->speedLastSampleTicks = hallTimebaseTicks; + state->speedBaselineRawEdges = state->rawRisingEdges; + state->speedHistoryIndex = 0u; + state->speedHistoryValidSamples = 0u; +} + +static bool Motor_CalculateSpeedPlan(uint32_t motorIndex, + uint32_t maxHallCount, + uint32_t durationMs, + uint32_t travelValueX100, + uint32_t *plannedHallPulses, + uint32_t *targetPpsX100, + uint8_t *initialDutyPercent) +{ + const motor_profile_t *profile = &motorProfiles[motorIndex]; + uint64_t pulseNumerator; + uint64_t speedNumerator; + uint64_t speedDenominator; + uint32_t calculatedHallPulses; + uint32_t calculatedTargetPpsX100; + uint32_t initialDuty; + uint32_t dutySteps; + + if((NULL == plannedHallPulses) || (NULL == targetPpsX100) || + (NULL == initialDutyPercent) || (0ul == durationMs) || + (0ul == travelValueX100) || + (travelValueX100 > profile->maxPhysicalValueX100) || + (0ul == maxHallCount) || (0ul == profile->speedStepPpsX100)) + { + return false; + } + + pulseNumerator = ((uint64_t)travelValueX100) * maxHallCount; + calculatedHallPulses = (uint32_t) + ((pulseNumerator + (profile->maxPhysicalValueX100 / 2ul)) / + profile->maxPhysicalValueX100); + + speedNumerator = pulseNumerator * 1000ull * MOTOR_SPEED_VALUE_SCALE; + speedDenominator = ((uint64_t)profile->maxPhysicalValueX100) * + durationMs; + calculatedTargetPpsX100 = (uint32_t) + ((speedNumerator + (speedDenominator / 2ull)) / speedDenominator); + + if((0ul == calculatedHallPulses) || + (0ul == calculatedTargetPpsX100) || + (calculatedTargetPpsX100 > + motorRuntime[motorIndex].maxSpeedPpsX100)) + { + return false; + } + + dutySteps = (calculatedTargetPpsX100 + + profile->speedStepPpsX100 - 1ul) / + profile->speedStepPpsX100; + initialDuty = dutySteps * MOTOR_SPEED_DUTY_STEP_PERCENT; + if(initialDuty < MOTOR_SPEED_CONTROL_MIN_DUTY_PERCENT) + { + initialDuty = MOTOR_SPEED_CONTROL_MIN_DUTY_PERCENT; + } + else if(initialDuty > MOTOR_SPEED_MAX_DUTY_PERCENT) + { + initialDuty = MOTOR_SPEED_MAX_DUTY_PERCENT; + } + else + { + /* The estimated duty is already inside the allowed range. */ + } + + *plannedHallPulses = calculatedHallPulses; + *targetPpsX100 = calculatedTargetPpsX100; + *initialDutyPercent = (uint8_t)initialDuty; + return true; +} + +static uint8_t Hall_ReadLevel(uint32_t motorIndex) +{ + const motor_profile_t *motor = &motorProfiles[motorIndex]; + + return (Cy_GPIO_Read(motor->hallPort, motor->hallPin) != 0ul) ? 1u : 0u; +} + +static void Motor_ResetActivityWindowUnsafe(volatile motor_runtime_t *state) +{ + state->activityStartTicks = hallTimebaseTicks; + state->activityRisingBaseline = state->rawRisingEdges; + state->activityTotalBaseline = state->rawTotalEdges; + state->activityMonitorStartTicks = hallTimebaseTicks; + state->activityWindowBaseline = state->rawTotalEdges; + state->recentWindowEdges = 0ul; + state->activityHistoryIndex = 0u; + state->activityValidSamples = 0u; + state->activityWindowReady = false; + state->activityMonitorStarted = false; + state->lowHallStartTicks = hallTimebaseTicks; + state->lowHallEdgeBaseline = state->rawTotalEdges; + state->stallCurrentStartTicks = hallTimebaseTicks; + state->lowHallTracking = false; + state->stallCurrentTracking = false; +} + +static uint16_t Motor_AbsRawDelta(uint16_t sample, uint16_t baseline) +{ + return (sample >= baseline) ? + (uint16_t)(sample - baseline) : + (uint16_t)(baseline - sample); +} + +static bool Motor_CurrentSampleFreshFromIsr(uint32_t motorIndex) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + + return state->currentSenseValid && state->currentSenseBaselineValid && + ((uint32_t)(hallTimebaseTicks - + state->currentSenseLastSampleTicks) <= + MOTOR_MS_TO_TICKS(SEAT_MOVE_MOTOR_CURRENT_MAX_AGE_MS)); +} + +static bool Motor_PrimeCurrentSensing(void) +{ + uint32_t index; + + if(!BspSeatCurrentAdc_Init()) + { + return false; + } + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + uint16_t raw; + + if(!BspSeatCurrentAdc_Sample( + (bsp_seat_current_adc_signal_t) + ((uint32_t)BSP_SEAT_CURRENT_ADC_MOTOR0 + index), + &raw)) + { + return false; + } + motorRuntime[index].currentSenseRaw = raw; + motorRuntime[index].currentSenseBaselineRaw = raw; + motorRuntime[index].currentSenseDeltaRaw = 0u; + motorRuntime[index].currentSenseLastSampleTicks = hallTimebaseTicks; + motorRuntime[index].currentSenseErrorCount = 0u; + motorRuntime[index].currentSenseValid = true; + motorRuntime[index].currentSenseBaselineValid = true; + } + currentSenseLastServiceTicks = hallTimebaseTicks; + return true; +} + +static void Motor_ServiceCurrentSensing(void) +{ + uint32_t index; + + if((uint32_t)(hallTimebaseTicks - currentSenseLastServiceTicks) < + MOTOR_MS_TO_TICKS(SEAT_MOVE_MOTOR_CURRENT_SAMPLE_MS)) + { + return; + } + currentSenseLastServiceTicks = hallTimebaseTicks; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + uint16_t raw; + uint32_t savedIntrStatus; + bool stopped; + + if(!BspSeatCurrentAdc_Sample( + (bsp_seat_current_adc_signal_t) + ((uint32_t)BSP_SEAT_CURRENT_ADC_MOTOR0 + index), + &raw)) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(motorRuntime[index].currentSenseErrorCount < UINT8_MAX) + { + motorRuntime[index].currentSenseErrorCount++; + } + if(motorRuntime[index].currentSenseErrorCount >= + SEAT_MOVE_MOTOR_CURRENT_MAX_ERRORS) + { + motorRuntime[index].currentSenseValid = false; + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + continue; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + stopped = (motorRuntime[index].direction == MOTOR_DIRECTION_STOPPED) && + !motorRuntime[index].startPending; + motorRuntime[index].currentSenseRaw = raw; + motorRuntime[index].currentSenseLastSampleTicks = hallTimebaseTicks; + motorRuntime[index].currentSenseErrorCount = 0u; + motorRuntime[index].currentSenseValid = true; + + if(!motorRuntime[index].currentSenseBaselineValid) + { + motorRuntime[index].currentSenseBaselineRaw = raw; + motorRuntime[index].currentSenseBaselineValid = true; + } + else if(stopped) + { + uint32_t filtered = + (((uint32_t)motorRuntime[index].currentSenseBaselineRaw * + ((1ul << SEAT_MOVE_MOTOR_CURRENT_BASELINE_FILTER_SHIFT) - + 1ul)) + + (uint32_t)raw) >> + SEAT_MOVE_MOTOR_CURRENT_BASELINE_FILTER_SHIFT; + motorRuntime[index].currentSenseBaselineRaw = (uint16_t)filtered; + } + motorRuntime[index].currentSenseDeltaRaw = Motor_AbsRawDelta( + raw, motorRuntime[index].currentSenseBaselineRaw); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + } +} + +static void Motor_RequestEventFromIsr(uint32_t motorIndex, motor_event_t event) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + + if((state->pendingEvent == MOTOR_EVENT_NONE) && + ((state->direction != MOTOR_DIRECTION_STOPPED) || + ((event == MOTOR_EVENT_TIMEOUT) && (false != state->startPending)))) + { + if(state->direction != MOTOR_DIRECTION_STOPPED) + { + state->eventDirection = state->direction; + } + else if(false != state->coastTracking) + { + /* A timeout during a reversal pause must keep counting any + * remaining inertia in the old physical direction, not in the + * direction that had only been scheduled. */ + state->eventDirection = state->coastDirection; + } + else + { + state->eventDirection = MOTOR_DIRECTION_STOPPED; + } + state->eventWasTargetMove = state->targetActive; + state->pendingEvent = event; + state->targetActive = false; + state->startPending = false; + + /* Hardware PWM is stopped immediately; SPI bridge cleanup stays in main. */ + if(state->direction != MOTOR_DIRECTION_STOPPED) + { + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[motorIndex].pwmTcpwm); + } + } +} + +static void Motor_CheckOperationTimeoutsFromIsr(void) +{ + uint32_t index; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + uint32_t timeoutTicks = + ((false != motorRuntime[index].calibrating) && + (0ul != motorRuntime[index].calibrationTimeoutTicks)) ? + motorRuntime[index].calibrationTimeoutTicks : + MOTOR_MS_TO_TICKS(MOTOR_OPERATION_TIMEOUT_MS); + if((false != motorRuntime[index].operationActive) && + ((uint32_t)(hallTimebaseTicks - + motorRuntime[index].motionStartTicks) >= + timeoutTicks)) + { + Motor_RequestEventFromIsr(index, MOTOR_EVENT_TIMEOUT); + } + } +} + +static void Hall_AllInit(void) +{ + uint32_t index; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + Cy_GPIO_Pin_Init(motorProfiles[index].hallPort, + motorProfiles[index].hallPin, + &gpioInCfg); + + motorRuntime[index].rawRisingEdges = 0ul; + motorRuntime[index].rawTotalEdges = 0ul; + motorRuntime[index].positionHallCount = 0ul; + motorRuntime[index].maxHallCount = motorProfiles[index].defaultMaxHallCount; + motorRuntime[index].targetHallCount = 0ul; + motorRuntime[index].motionStartTicks = 0ul; + motorRuntime[index].calibrationTimeoutTicks = 0ul; + motorRuntime[index].scheduledStartTicks = 0ul; + motorRuntime[index].coastStartTicks = 0ul; + motorRuntime[index].currentSenseLastSampleTicks = 0ul; + motorRuntime[index].lowHallStartTicks = 0ul; + motorRuntime[index].lowHallEdgeBaseline = 0ul; + motorRuntime[index].stallCurrentStartTicks = 0ul; + motorRuntime[index].currentSenseRaw = 0u; + motorRuntime[index].currentSenseBaselineRaw = 0u; + motorRuntime[index].currentSenseDeltaRaw = 0u; + motorRuntime[index].direction = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].eventDirection = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].scheduledDirection = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].coastDirection = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].pendingEvent = MOTOR_EVENT_NONE; + motorRuntime[index].lastEvent = MOTOR_EVENT_NONE; + motorRuntime[index].moveResultEvent = MOTOR_EVENT_NONE; + motorRuntime[index].lastLevel = Hall_ReadLevel(index); + motorRuntime[index].automaticReverseCount = 0u; + motorRuntime[index].currentSenseErrorCount = 0u; + motorRuntime[index].hallInitialized = true; + motorRuntime[index].positionValid = false; + motorRuntime[index].targetActive = false; + motorRuntime[index].calibrating = false; + motorRuntime[index].currentSenseValid = false; + motorRuntime[index].currentSenseBaselineValid = false; + motorRuntime[index].lowHallTracking = false; + motorRuntime[index].stallCurrentTracking = false; + motorRuntime[index].reverseAtEndpoint = false; + motorRuntime[index].reverseAtStall = false; + motorRuntime[index].startPending = false; + motorRuntime[index].coastTracking = false; + motorRuntime[index].operationActive = false; + motorRuntime[index].eventWasTargetMove = false; + motorRuntime[index].moveResultReady = false; + motorRuntime[index].moveCommandSequence = 0ul; + motorRuntime[index].moveResultSequence = 0ul; + motorRuntime[index].speedDurationMs = 0ul; + motorRuntime[index].speedTravelValueX100 = 0ul; + motorRuntime[index].speedPlannedHallPulses = 0ul; + motorRuntime[index].speedTargetPpsX100 = 0ul; + motorRuntime[index].speedActualPpsX100 = 0ul; + motorRuntime[index].maxSpeedPpsX100 = + motorProfiles[index].maxSpeedPpsX100; + motorRuntime[index].speedInitialDutyPercent = + (uint8_t)MOTOR_PWM_DUTY_PERCENT; + motorRuntime[index].speedCurrentDutyPercent = + (uint8_t)MOTOR_PWM_DUTY_PERCENT; + motorRuntime[index].fixedDutyPercent = + (uint8_t)MOTOR_PWM_DUTY_PERCENT; + motorRuntime[index].latestAbortSequence = 0u; + motorRuntime[index].acceptedCommandSequence = 0u; + motorRuntime[index].cancellationGeneration = 0ul; + motorRuntime[index].acceptedCancellationGeneration = 0ul; + motorRuntime[index].abortSequenceValid = false; + motorRuntime[index].acceptedCommandValid = false; + motorRuntime[index].speedControlEnabled = false; + Motor_ResetSpeedMeasurementUnsafe(&motorRuntime[index]); + Motor_ResetActivityWindowUnsafe(&motorRuntime[index]); + } +} + +static void Hall_SampleMotorFromIsr(uint32_t motorIndex) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + motor_direction_t countingDirection; + uint8_t level = Hall_ReadLevel(motorIndex); + + if(false == state->hallInitialized) + { + state->lastLevel = level; + state->hallInitialized = true; + return; + } + + if(level != state->lastLevel) + { + state->rawTotalEdges++; + + if((state->direction == MOTOR_DIRECTION_STOPPED) && + (false != state->coastTracking)) + { + /* Any sampled Hall transition proves the mechanism is still + * moving. Extend direction attribution and delayed restart until + * a complete quiet interval has elapsed. */ + state->coastStartTicks = hallTimebaseTicks; + } + + if(level != 0u) + { + state->rawRisingEdges++; + + countingDirection = state->direction; + if((countingDirection == MOTOR_DIRECTION_STOPPED) && + (false != state->coastTracking)) + { + countingDirection = state->coastDirection; + } + + /* Keep the global position aligned with every sampled motor + * pulse, including the short interval after ISR kill and before + * main-loop event service. */ + if(countingDirection == MOTOR_DIRECTION_TOWARD_FAR) + { + if((false == state->positionValid) && + (state->positionHallCount < UINT32_MAX)) + { + /* An uncalibrated/invalid position has no trustworthy + * range ceiling. Keep every inferred far-direction pulse + * so calibration and failure recovery do not lose motion. */ + state->positionHallCount++; + } + else if((false != state->positionValid) && + (state->positionHallCount < state->maxHallCount)) + { + state->positionHallCount++; + } + } + else if(countingDirection == MOTOR_DIRECTION_TOWARD_NEAR) + { + if(state->positionHallCount > 0ul) + { + state->positionHallCount--; + } + } + else + { + /* Position is not changed while the motor is stopped. */ + } + + if((state->pendingEvent == MOTOR_EVENT_NONE) && + (false != state->targetActive) && + (state->direction != MOTOR_DIRECTION_STOPPED)) + { + if(((state->direction == MOTOR_DIRECTION_TOWARD_FAR) && + (state->positionHallCount >= state->targetHallCount)) || + ((state->direction == MOTOR_DIRECTION_TOWARD_NEAR) && + (state->positionHallCount <= state->targetHallCount))) + { + Motor_RequestEventFromIsr(motorIndex, + MOTOR_EVENT_TARGET_REACHED); + } + } + } + + state->lastLevel = level; + } + + /* A fixed delay alone can drop late inertia pulses. Close the inferred + * old-direction window only after no Hall edge has been sampled for the + * full quiet interval; an edge on the boundary resets this timer. */ + if((false != state->coastTracking) && + ((uint32_t)(hallTimebaseTicks - state->coastStartTicks) >= + MOTOR_MS_TO_TICKS(MOTOR_COAST_HALL_QUIET_MS))) + { + state->coastTracking = false; + state->coastDirection = MOTOR_DIRECTION_STOPPED; + } +} + +static void Motor_UpdateActivityWindowFromIsr(uint32_t motorIndex) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + uint32_t oldestCount; + uint16_t nextIndex; + + if(state->direction == MOTOR_DIRECTION_STOPPED) + { + return; + } + + /* Keep sampling Hall position every 2 ms from the physical PWM start, but + * do not use startup transients in the stall/endpoint window. Each axis + * has its own mechanical startup grace; after that grace establish a fresh + * raw-edge baseline and require one complete activity window. Calibration + * still maps missing Hall to the requested endpoint, while normal moves + * map it to stall. */ + if(false == state->activityMonitorStarted) + { + if((uint32_t)(hallTimebaseTicks - state->activityStartTicks) < + MOTOR_MS_TO_TICKS(motorProfiles[motorIndex].startupGraceMs)) + { + return; + } + + state->activityMonitorStarted = true; + state->activityMonitorStartTicks = hallTimebaseTicks; + state->activityWindowBaseline = state->rawTotalEdges; + state->recentWindowEdges = 0ul; + state->activityHistoryIndex = 0u; + state->activityValidSamples = 0u; + state->activityWindowReady = false; + return; + } + + if(state->activityValidSamples < MOTOR_SAFETY_WINDOW_SAMPLES) + { + state->activityHistoryEdges[state->activityHistoryIndex] = + state->rawTotalEdges; + state->activityValidSamples++; + state->recentWindowEdges = + state->rawTotalEdges - state->activityWindowBaseline; + } + else + { + oldestCount = + state->activityHistoryEdges[state->activityHistoryIndex]; + state->activityHistoryEdges[state->activityHistoryIndex] = + state->rawTotalEdges; + state->recentWindowEdges = state->rawTotalEdges - oldestCount; + } + + nextIndex = (uint16_t)(state->activityHistoryIndex + 1u); + if(nextIndex >= MOTOR_SAFETY_WINDOW_SAMPLES) + { + nextIndex = 0u; + } + state->activityHistoryIndex = nextIndex; + + if((state->activityValidSamples >= MOTOR_SAFETY_WINDOW_SAMPLES) && + ((uint32_t)(hallTimebaseTicks - state->activityMonitorStartTicks) >= + MOTOR_MS_TO_TICKS(MOTOR_SAFETY_WINDOW_MS))) + { + state->activityWindowReady = true; + } +} + +static void Motor_SafetyTaskFromIsr(void) +{ + uint32_t index; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + volatile motor_runtime_t *state = &motorRuntime[index]; + + Motor_UpdateActivityWindowFromIsr(index); + + if((state->direction == MOTOR_DIRECTION_STOPPED) || + (state->pendingEvent != MOTOR_EVENT_NONE) || + (false == state->activityWindowReady)) + { + continue; + } + + /* Long synchronized trajectories use pulse-density control whenever + * their requested speed is below the loaded mechanism's proven 50% + * breakaway duty. A zero-duty pause is intentional de-energization, + * not a no-motion fault. The absolute task/phase deadline still + * bounds the pause, and the activity window is reset when drive + * resumes. */ + if((false != state->speedControlEnabled) && + (state->speedCurrentDutyPercent == + MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT)) + { + state->lowHallTracking = false; + state->stallCurrentTracking = false; + continue; + } + + if(false != state->calibrating) + { + state->lowHallTracking = false; + state->stallCurrentTracking = false; + /* An endpoint requires a completely quiet Hall input. Counting + * both edges avoids classifying a slow motor as stopped merely + * because a 500 ms window contains a falling edge but no rising + * edge. Rising edges remain the calibrated position unit. */ + if(state->recentWindowEdges <= MOTOR_ENDPOINT_EDGE_THRESHOLD) + { + Motor_RequestEventFromIsr( + index, + (state->direction == MOTOR_DIRECTION_TOWARD_NEAR) ? + MOTOR_EVENT_NEAR_ENDPOINT : MOTOR_EVENT_FAR_ENDPOINT); + } + continue; + } + + /* + * A normal move is classified as STALL only when both independent + * observations overlap: low Hall activity and sustained high current. + * Low Hall with low/stale current is a separate no-motion/Hall-path + * failure and is stopped with TIMEOUT instead of being mislabeled as + * a mechanical stall. Endpoint seeking above deliberately remains + * Hall-based because no Hall at the requested end is its success + * condition, even though endpoint current can be high. + */ + if(state->recentWindowEdges <= MOTOR_STALL_EDGE_THRESHOLD) + { + bool currentFresh = Motor_CurrentSampleFreshFromIsr(index); + bool currentHigh = currentFresh && + (state->currentSenseDeltaRaw >= + motorProfiles[index].stallCurrentDeltaRaw); + + if(false == state->lowHallTracking) + { + state->lowHallTracking = true; + state->lowHallStartTicks = hallTimebaseTicks; + state->lowHallEdgeBaseline = state->rawTotalEdges; + state->stallCurrentTracking = false; + } + else if(state->rawTotalEdges != state->lowHallEdgeBaseline) + { + /* A slow but healthy mechanism may produce no more than two + * edges in every rolling 500 ms window. Any real Hall edge + * is progress and restarts the no-motion clock; otherwise a + * full-stroke M0 move is incorrectly stopped after five + * seconds even though its Hall count keeps increasing. */ + state->lowHallEdgeBaseline = state->rawTotalEdges; + state->lowHallStartTicks = hallTimebaseTicks; + } + + if(false != currentHigh) + { + if(false == state->stallCurrentTracking) + { + state->stallCurrentTracking = true; + state->stallCurrentStartTicks = hallTimebaseTicks; + } + else if((uint32_t)(hallTimebaseTicks - + state->stallCurrentStartTicks) >= + MOTOR_MS_TO_TICKS( + SEAT_MOVE_MOTOR_STALL_CURRENT_CONFIRM_MS)) + { + Motor_RequestEventFromIsr(index, MOTOR_EVENT_STALL); + } + else + { + /* High current confirmation is still accumulating. */ + } + } + else + { + state->stallCurrentTracking = false; + } + + if((state->pendingEvent == MOTOR_EVENT_NONE) && + ((uint32_t)(hallTimebaseTicks - state->lowHallStartTicks) >= + MOTOR_MS_TO_TICKS( + SEAT_MOVE_MOTOR_NO_MOTION_TIMEOUT_MS))) + { + Motor_RequestEventFromIsr(index, MOTOR_EVENT_TIMEOUT); + } + } + else + { + state->lowHallTracking = false; + state->stallCurrentTracking = false; + } + } +} + +static void Hall_TimebaseIrqHandler(void) +{ + uint32_t index; + + if(Cy_Tcpwm_Counter_GetTC_IntrMasked(HALL_TIMEBASE_TCPWM) != 0ul) + { + Cy_Tcpwm_Counter_ClearTC_Intr(HALL_TIMEBASE_TCPWM); + hallTimebaseTicks++; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + Hall_SampleMotorFromIsr(index); + } + + Motor_CheckOperationTimeoutsFromIsr(); + + safetyTickDivider++; + if(safetyTickDivider >= MOTOR_SAFETY_CHECK_TICKS) + { + safetyTickDivider = 0ul; + Motor_SafetyTaskFromIsr(); + } + } +} + +static uint32_t MotorControl_GetTicks(void) +{ + return hallTimebaseTicks; +} + +static void MotorControlTimers_Init(void) +{ + uint32_t index; + uint32_t divider = SOURCE_CLOCK_HZ / MOTOR_TIMER_INPUT_CLOCK_HZ; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + Cy_SysClk_PeriphAssignDivider(motorProfiles[index].pwmClock, + CY_SYSCLK_DIV_16_BIT, + MOTOR_TIMER_DIVIDER_NO); + } + Cy_SysClk_PeriphAssignDivider(HALL_TIMEBASE_CLOCK, + CY_SYSCLK_DIV_16_BIT, + MOTOR_TIMER_DIVIDER_NO); + Cy_SysClk_PeriphSetDivider(Cy_SysClk_GetClockGroup(motorProfiles[0].pwmClock), + CY_SYSCLK_DIV_16_BIT, + MOTOR_TIMER_DIVIDER_NO, + divider - 1ul); + Cy_SysClk_PeriphEnableDivider(Cy_SysClk_GetClockGroup(motorProfiles[0].pwmClock), + CY_SYSCLK_DIV_16_BIT, + MOTOR_TIMER_DIVIDER_NO); + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + (void)Cy_Tcpwm_Pwm_Init(motorProfiles[index].pwmTcpwm, &motorPwmCfg); + Cy_Tcpwm_Pwm_Enable(motorProfiles[index].pwmTcpwm); + Cy_Tcpwm_Pwm_SetCounter(motorProfiles[index].pwmTcpwm, 0ul); + } + pwmTimersInitialized = true; + + hallTimebaseTicks = 0ul; + safetyTickDivider = 0ul; + (void)Cy_Tcpwm_Counter_Init(HALL_TIMEBASE_TCPWM, &hallTimebaseCfg); + Cy_Tcpwm_Counter_ClearTC_Intr(HALL_TIMEBASE_TCPWM); + Cy_Tcpwm_Counter_SetTC_IntrMask(HALL_TIMEBASE_TCPWM); + + Cy_SysInt_InitIRQ(&hallTimebaseIrqCfg); + Cy_SysInt_SetSystemIrqVector(hallTimebaseIrqCfg.sysIntSrc, + Hall_TimebaseIrqHandler); + NVIC_SetPriority(hallTimebaseIrqCfg.intIdx, 3ul); + NVIC_ClearPendingIRQ(hallTimebaseIrqCfg.intIdx); + NVIC_EnableIRQ(hallTimebaseIrqCfg.intIdx); + + Cy_Tcpwm_Counter_Enable(HALL_TIMEBASE_TCPWM); + Cy_Tcpwm_TriggerStart(HALL_TIMEBASE_TCPWM); + motorControlReady = true; +} + +static void BoardPins_Init(void) +{ + InitOutputPin(DRV_DVDD_EN_PORT, DRV_DVDD_EN_PIN, 0ul); + InitOutputPin(DRV_NCS_PORT, DRV_NCS_PIN, 1ul); + InitOutputPin(DRV_NSLEEP_PORT, DRV_NSLEEP_PIN, 0ul); + /* Keep all bridge outputs disabled until initialization is verified. */ + InitOutputPin(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN, 1ul); + InitOutputPin(DRV_IN1_PORT, DRV_IN1_PIN, 0ul); + InitOutputPin(DRV_IN2_PORT, DRV_IN2_PIN, 0ul); + InitOutputPin(DRV_IN3_PORT, DRV_IN3_PIN, 0ul); + InitOutputPin(DRV_IN4_PORT, DRV_IN4_PIN, 0ul); + Hall_AllInit(); + + spiPinCfg.driveMode = CY_GPIO_DM_HIGHZ; + spiPinCfg.hsiom = DRV_SPI_MISO_MUX; + Cy_GPIO_Pin_Init(DRV_SPI_MISO_PORT, DRV_SPI_MISO_PIN, &spiPinCfg); + + spiPinCfg.driveMode = CY_GPIO_DM_STRONG_IN_OFF; + spiPinCfg.hsiom = DRV_SPI_MOSI_MUX; + Cy_GPIO_Pin_Init(DRV_SPI_MOSI_PORT, DRV_SPI_MOSI_PIN, &spiPinCfg); + + spiPinCfg.driveMode = CY_GPIO_DM_STRONG_IN_OFF; + spiPinCfg.hsiom = DRV_SPI_CLK_MUX; + Cy_GPIO_Pin_Init(DRV_SPI_CLK_PORT, DRV_SPI_CLK_PIN, &spiPinCfg); +} + +void Motor_ForceSafeOff(void) +{ + cy_stc_gpio_pin_config_t safeOutput = + { + .outVal = 0ul, + .driveMode = CY_GPIO_DM_STRONG_IN_OFF, + .hsiom = HSIOM_SEL_GPIO, + .intEdge = 0ul, + .intMask = 0ul, + .vtrip = 0ul, + .slewRate = 0ul, + .driveSel = CY_GPIO_DRIVE_FULL, + }; + + if(false != pwmTimersInitialized) + { + Cy_Tcpwm_TriggerStopOrKill(DRV_IN1_PWM_TCPWM); + Cy_Tcpwm_TriggerStopOrKill(DRV_IN2_PWM_TCPWM); + Cy_Tcpwm_TriggerStopOrKill(DRV_IN3_PWM_TCPWM); + Cy_Tcpwm_TriggerStopOrKill(DRV_IN4_PWM_TCPWM); + } + + (void)Cy_GPIO_Pin_Init(DRV_IN1_PORT, DRV_IN1_PIN, &safeOutput); + (void)Cy_GPIO_Pin_Init(DRV_IN2_PORT, DRV_IN2_PIN, &safeOutput); + (void)Cy_GPIO_Pin_Init(DRV_IN3_PORT, DRV_IN3_PIN, &safeOutput); + (void)Cy_GPIO_Pin_Init(DRV_IN4_PORT, DRV_IN4_PIN, &safeOutput); + Cy_GPIO_Clr(DRV_IN1_PORT, DRV_IN1_PIN); + Cy_GPIO_Clr(DRV_IN2_PORT, DRV_IN2_PIN); + Cy_GPIO_Clr(DRV_IN3_PORT, DRV_IN3_PIN); + Cy_GPIO_Clr(DRV_IN4_PORT, DRV_IN4_PIN); + + safeOutput.outVal = 1ul; + (void)Cy_GPIO_Pin_Init(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN, &safeOutput); + Cy_GPIO_Set(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN); +} + +static void Scb4Spi_Init(void) +{ + Cy_SysClk_PeriphAssignDivider(PCLK_SCB4_CLOCK, CY_SYSCLK_DIV_24_5_BIT, SCB4_DIVIDER_NO); + SetPeripheFracDiv24_5(SPI_INPUT_CLOCK_HZ, SOURCE_CLOCK_HZ, SCB4_DIVIDER_NO); + Cy_SysClk_PeriphEnableDivider(Cy_SysClk_GetClockGroup(PCLK_SCB4_CLOCK), + CY_SYSCLK_DIV_24_5_BIT, + SCB4_DIVIDER_NO); + + Cy_SCB_SPI_DeInit(SCB4); + (void)Cy_SCB_SPI_Init(SCB4, &spiCfg, NULL); + Cy_SCB_SPI_Enable(SCB4); +} + +static bool Drv8718_SpiTransfer16(uint16_t txWord, uint16_t *rxWord) +{ + uint32_t timeout; + uint32_t savedIntrStatus; + bool success = false; + + if(NULL == rxWord) + { + return false; + } + *rxWord = 0u; + + /* SCB4, nSCS and the shared DRV8718 register file have one owner. The + * current application calls this only from the main loop; the guard also + * prevents a future callback or nested service from interleaving frames. */ + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false != driverSpiActive) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return false; + } + driverSpiActive = true; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + Cy_SCB_SPI_ClearRxFifo(SCB4); + Cy_SCB_SPI_ClearTxFifo(SCB4); + + Cy_GPIO_Clr(DRV_NCS_PORT, DRV_NCS_PIN); + Cy_SysTick_DelayInUs(1ul); + + if(Cy_SCB_SPI_Write(SCB4, txWord) != 1ul) + { + goto transfer_done; + } + + timeout = DRV8718_SPI_TIMEOUT_LOOPS; + while((false == Cy_SCB_SPI_IsTxComplete(SCB4)) && (timeout > 0ul)) + { + timeout--; + } + + if(false == Cy_SCB_SPI_IsTxComplete(SCB4)) + { + goto transfer_done; + } + + timeout = DRV8718_SPI_TIMEOUT_LOOPS; + while((0ul == Cy_SCB_SPI_GetNumInRxFifo(SCB4)) && (timeout > 0ul)) + { + timeout--; + } + + if((Cy_SCB_SPI_GetNumInRxFifo(SCB4) > 0ul) && + (Cy_SCB_SPI_ReadArray(SCB4, rxWord, 1ul) == 1ul)) + { + success = ((*rxWord & DRV8718_SDO_HEADER_MASK) == + DRV8718_SDO_HEADER_VALUE); + if((false != success) && + (false != driverStatusMonitoringEnabled) && + ((*rxWord & DRV8718_SDO_ALERT_MASK) != 0u)) + { + success = false; + } + } + +transfer_done: + Cy_SysTick_DelayInUs(1ul); + Cy_GPIO_Set(DRV_NCS_PORT, DRV_NCS_PIN); + Cy_SysTick_DelayInUs(1ul); + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + driverSpiActive = false; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + return success; +} + +static bool Drv8718_WriteRegRaw(uint8_t addr, uint8_t data) +{ + uint16_t frame = DRV8718_WRITE_FRAME(addr, data); + uint16_t rxWord; + + return Drv8718_SpiTransfer16(frame, &rxWord); +} + +static bool Drv8718_ReadRegRaw(uint8_t addr, uint8_t *data) +{ + uint16_t frame = DRV8718_READ_FRAME(addr); + uint16_t rxWord; + + if((NULL == data) || + (false == Drv8718_SpiTransfer16(frame, &rxWord))) + { + return false; + } + + *data = (uint8_t)(rxWord & 0xFFu); + return true; +} + +static bool Drv8718_WriteRegVerified(uint8_t addr, uint8_t data) +{ + uint8_t readback; + + if(false == Drv8718_WriteRegRaw(addr, data)) + { + return false; + } + + return (Drv8718_ReadRegRaw(addr, &readback) && (readback == data)); +} + +static void Motor_EnablePwmInput(const motor_profile_t *motor) +{ + pwmPinCfg.outVal = 0ul; + pwmPinCfg.hsiom = motor->pwmHsiom; + Cy_GPIO_Pin_Init(motor->pwmPort, motor->pwmPin, &pwmPinCfg); +} + +static bool Drv8718_Init(void) +{ + bool success = true; + uint8_t driverStatus = 0u; + + driverStatusMonitoringEnabled = false; + + Cy_GPIO_Set(DRV_DVDD_EN_PORT, DRV_DVDD_EN_PIN); + Cy_GPIO_Set(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN); + Cy_SysTick_DelayInUs(10000ul); + + Cy_GPIO_Set(DRV_NSLEEP_PORT, DRV_NSLEEP_PIN); + Cy_SysTick_DelayInUs(10000ul); + + success = Drv8718_WriteRegVerified(DRV8718_REG_IC_CTRL2, + DRV8718_IC_CTRL2_DRVOFF_MODE); + if(false != success) + { + success = Drv8718_WriteRegVerified(DRV8718_REG_BRG_CTRL1, + MOTOR_COAST_BRG_CTRL1); + } + if(false != success) + { + success = Drv8718_WriteRegVerified(DRV8718_REG_BRG_CTRL2, + MOTOR_COAST_BRG_CTRL2); + } + if(false != success) + { + success = Drv8718_WriteRegVerified(DRV8718_REG_PWM_CTRL1, + DRV8718_PWM_CTRL1_ALL_MOTORS); + } + if(false != success) + { + success = Drv8718_WriteRegVerified(DRV8718_REG_PWM_CTRL2, + DRV8718_PWM_CTRL2_ALL_MOTORS); + } + if(false != success) + { + success = Drv8718_WriteRegVerified(DRV8718_REG_PWM_CTRL3, + MOTOR_PWM_CTRL3_HIGHSIDE); + } + if(false != success) + { + success = Drv8718_WriteRegVerified(DRV8718_REG_PWM_CTRL4, + MOTOR_PWM_CTRL4_ACTIVE_HI); + } + if(false != success) + { + success = Drv8718_WriteRegRaw(DRV8718_REG_IC_CTRL1, + DRV8718_IC_CTRL1_CLEAR_FAULTS); + Cy_SysTick_DelayInUs(1000ul); + } + if(false != success) + { + success = Drv8718_WriteRegVerified( + DRV8718_REG_IC_CTRL1, + DRV8718_IC_CTRL1_ENABLE_UNLOCKED); + } + if(false != success) + { + success = Drv8718_ReadRegRaw(DRV8718_REG_IC_STAT1, &driverStatus) && + ((driverStatus & DRV8718_IC_STAT1_SPI_OK) != 0u) && + ((driverStatus & DRV8718_IC_STAT1_ALERT_MASK) == 0u); + } + + if(false != success) + { + bridgeCtrl1Shadow = MOTOR_COAST_BRG_CTRL1; + bridgeCtrl2Shadow = MOTOR_COAST_BRG_CTRL2; + driverFaultLatched = false; + driverStatusMonitoringEnabled = true; + driverStatusLastPollTicks = hallTimebaseTicks; + Cy_GPIO_Clr(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN); + } + else + { + driverFaultLatched = true; + driverStatusMonitoringEnabled = false; + Cy_GPIO_Set(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN); + } + + return success; +} + +static void Motor_SetPwmInputLow(const motor_profile_t *motor) +{ + pwmPinCfg.outVal = 0ul; + pwmPinCfg.hsiom = HSIOM_SEL_GPIO; + Cy_GPIO_Pin_Init(motor->pwmPort, motor->pwmPin, &pwmPinCfg); + Cy_GPIO_Clr(motor->pwmPort, motor->pwmPin); +} + +static void Motor_EnterDriverFault(void) +{ + uint32_t index; + uint32_t savedIntrStatus; + + driverFaultLatched = true; + driverStatusMonitoringEnabled = false; + Cy_GPIO_Set(DRV_DRVOFF_PORT, DRV_DRVOFF_PIN); + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + if(false != pwmTimersInitialized) + { + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[index].pwmTcpwm); + } + Motor_SetPwmInputLow(&motorProfiles[index]); + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + volatile motor_runtime_t *state = &motorRuntime[index]; + bool hadTarget = state->targetActive || state->eventWasTargetMove || + state->moveResultReady; + + state->direction = MOTOR_DIRECTION_STOPPED; + state->eventDirection = MOTOR_DIRECTION_STOPPED; + state->scheduledDirection = MOTOR_DIRECTION_STOPPED; + state->pendingEvent = MOTOR_EVENT_NONE; + state->lastEvent = MOTOR_EVENT_DRIVER_FAULT; + state->targetActive = false; + state->calibrating = false; + state->startPending = false; + state->coastTracking = false; + state->operationActive = false; + state->eventWasTargetMove = false; + /* The status is shared by all bridges. Once it faults, software + * cannot prove which axis moved before DRVOFF or assign later inertia + * pulses to a reliable direction, so no absolute count remains clean. */ + state->positionValid = false; + if(false != hadTarget) + { + state->moveResultEvent = MOTOR_EVENT_DRIVER_FAULT; + state->moveResultSequence = state->moveCommandSequence; + state->moveResultReady = true; + } + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); +} + +static void Motor_PollDriverStatus(void) +{ + uint8_t driverStatus; + + if((false == driverStatusMonitoringEnabled) || + (false != driverFaultLatched) || + ((uint32_t)(hallTimebaseTicks - driverStatusLastPollTicks) < + MOTOR_MS_TO_TICKS(DRV8718_STATUS_POLL_INTERVAL_MS))) + { + return; + } + + driverStatusLastPollTicks = hallTimebaseTicks; + if((false == Drv8718_ReadRegRaw(DRV8718_REG_IC_STAT1, &driverStatus)) || + ((driverStatus & DRV8718_IC_STAT1_SPI_OK) == 0u) || + ((driverStatus & DRV8718_IC_STAT1_ALERT_MASK) != 0u)) + { + Motor_EnterDriverFault(); + } +} + +static bool Motor_WriteBridgeBits(uint32_t motorIndex, uint8_t bridgeValue) +{ + const motor_profile_t *motor = &motorProfiles[motorIndex]; + uint8_t oldValue; + uint8_t newValue; + bool success; + + if(false != driverFaultLatched) + { + return false; + } + + if(motor->bridgeRegister == DRV8718_REG_BRG_CTRL1) + { + oldValue = bridgeCtrl1Shadow; + newValue = (uint8_t)((oldValue & (uint8_t)(~motor->bridgeMask)) | + (bridgeValue & motor->bridgeMask)); + success = Drv8718_WriteRegVerified(DRV8718_REG_BRG_CTRL1, newValue); + if(false != success) + { + bridgeCtrl1Shadow = newValue; + } + } + else + { + oldValue = bridgeCtrl2Shadow; + newValue = (uint8_t)((oldValue & (uint8_t)(~motor->bridgeMask)) | + (bridgeValue & motor->bridgeMask)); + success = Drv8718_WriteRegVerified(DRV8718_REG_BRG_CTRL2, newValue); + if(false != success) + { + bridgeCtrl2Shadow = newValue; + } + } + + if(false == success) + { + Motor_EnterDriverFault(); + } + return success; +} + +static bool Motor_StopHardware(uint32_t motorIndex) +{ + const motor_profile_t *motor = &motorProfiles[motorIndex]; + + Cy_Tcpwm_TriggerStopOrKill(motor->pwmTcpwm); + Motor_SetPwmInputLow(motor); + return Motor_WriteBridgeBits(motorIndex, 0u); +} + +static bool Motor_PrepareHardwareStart(uint32_t motorIndex, + motor_direction_t direction) +{ + const motor_profile_t *motor = &motorProfiles[motorIndex]; + uint8_t bridgeValue = + (direction == MOTOR_DIRECTION_TOWARD_FAR) ? + motor->towardFarBridgeValue : motor->towardNearBridgeValue; + + /* Last-line application isolation: a build-disabled bridge can never be + * energized, even if a corrupted command reaches this layer. */ + if((SEAT_MOVE_ACTIVE_MOTOR_MASK & + (uint8_t)(1u << motorIndex)) == 0u) + { + return false; + } + + Cy_Tcpwm_TriggerStopOrKill(motor->pwmTcpwm); + Motor_SetPwmInputLow(motor); + if(false == Motor_WriteBridgeBits(motorIndex, bridgeValue)) + { + return false; + } + return true; +} + +static void Motor_CommitPreparedHardwareStartUnsafe(uint32_t motorIndex) +{ + const motor_profile_t *motor = &motorProfiles[motorIndex]; + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + uint8_t dutyPercent = state->fixedDutyPercent; + + /* The bridge was configured while the PWM pin remained GPIO-low. The + * caller holds the Hall ISR critical section while publishing the running + * state and enabling PWM, so a timeout/event cannot be inserted between + * the state transition and the physical start. */ + if((false != state->speedControlEnabled) && + (false == state->calibrating)) + { + dutyPercent = state->speedInitialDutyPercent; + /* Dynamic trajectories may need a low steady-state PWM to stretch a + * short Hall travel over tens of seconds. Give every loaded axis the + * proven 50% breakaway pulse first; after the 500 ms measurement delay + * the closed loop may ramp below the public fixed-duty command floor. */ + if(dutyPercent < MOTOR_SPEED_MIN_DUTY_PERCENT) + { + dutyPercent = MOTOR_SPEED_MIN_DUTY_PERCENT; + } + } + state->speedCurrentDutyPercent = dutyPercent; + Motor_ResetSpeedMeasurementUnsafe(state); + Motor_ApplyDutyPercent(motorIndex, dutyPercent); + Cy_Tcpwm_Pwm_SetCounter(motor->pwmTcpwm, 0ul); + Cy_Tcpwm_TriggerStart(motor->pwmTcpwm); + Motor_EnablePwmInput(motor); +} + +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) +static const char *Motor_EventToString(motor_event_t event) +{ + switch(event) + { + case MOTOR_EVENT_NONE: + return "none"; + case MOTOR_EVENT_TARGET_REACHED: + return "target_reached"; + case MOTOR_EVENT_NEAR_ENDPOINT: + return "near_endpoint"; + case MOTOR_EVENT_FAR_ENDPOINT: + return "far_endpoint"; + case MOTOR_EVENT_STALL: + return "stall"; + case MOTOR_EVENT_TIMEOUT: + return "timeout"; + case MOTOR_EVENT_ABORTED: + return "aborted"; + case MOTOR_EVENT_DRIVER_FAULT: + return "driver_fault"; + case MOTOR_EVENT_CALIBRATION_FAILED: + return "calibration_failed"; + default: + return "unknown"; + } +} + +static const char *Motor_DirectionToString(motor_direction_t direction) +{ + switch(direction) + { + case MOTOR_DIRECTION_TOWARD_FAR: + return "toward_far"; + case MOTOR_DIRECTION_TOWARD_NEAR: + return "toward_near"; + default: + return "stopped"; + } +} +#endif + +static void Motor_BeginCoastTrackingUnsafe(volatile motor_runtime_t *state, + motor_direction_t direction) +{ + if(direction != MOTOR_DIRECTION_STOPPED) + { + state->coastDirection = direction; + state->coastStartTicks = hallTimebaseTicks; + state->coastTracking = true; + } +} + +static void Motor_SetMoveResultUnsafe(volatile motor_runtime_t *state, + motor_event_t event) +{ + state->moveResultEvent = event; + state->moveResultSequence = state->moveCommandSequence; + state->moveResultReady = true; +} + +static void Motor_BeginMoveCommandUnsafe(volatile motor_runtime_t *state) +{ + state->moveCommandSequence++; + if(0ul == state->moveCommandSequence) + { + /* Reserve zero for "no move command has ever been accepted". */ + state->moveCommandSequence = 1ul; + } + state->moveResultEvent = MOTOR_EVENT_NONE; + state->moveResultSequence = 0ul; + state->moveResultReady = false; +} + +static bool Motor_OperationTimeoutExpiredUnsafe( + const volatile motor_runtime_t *state) +{ + uint32_t timeoutTicks = + ((false != state->calibrating) && + (0ul != state->calibrationTimeoutTicks)) ? + state->calibrationTimeoutTicks : + MOTOR_MS_TO_TICKS(MOTOR_OPERATION_TIMEOUT_MS); + + return (false != state->operationActive) && + ((uint32_t)(hallTimebaseTicks - state->motionStartTicks) >= + timeoutTicks); +} + +static void Motor_CompleteTargetMoveUnsafe(volatile motor_runtime_t *state) +{ + state->direction = MOTOR_DIRECTION_STOPPED; + state->eventDirection = MOTOR_DIRECTION_STOPPED; + state->scheduledDirection = MOTOR_DIRECTION_STOPPED; + state->startPending = false; + state->coastTracking = false; + state->coastDirection = MOTOR_DIRECTION_STOPPED; + state->operationActive = false; + state->targetActive = false; + state->eventWasTargetMove = false; + state->lastEvent = MOTOR_EVENT_TARGET_REACHED; + Motor_SetMoveResultUnsafe(state, MOTOR_EVENT_TARGET_REACHED); +} + +static bool Motor_TryStartScheduledHardware(uint32_t motorIndex, + bool requireCoastElapsed) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + motor_direction_t direction = MOTOR_DIRECTION_STOPPED; + motor_direction_t finalDirection = MOTOR_DIRECTION_STOPPED; + uint32_t savedIntrStatus; + bool eligible = false; + bool targetCompleted = false; + bool committed = false; + bool accepted = false; + + /* Phase one only selects a candidate direction. The PWM input remains + * GPIO-low throughout the (comparatively slow) verified SPI write. */ + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((false != state->startPending) && + (state->pendingEvent == MOTOR_EVENT_NONE) && + (false == driverFaultLatched) && + Motor_CommandIsCurrentUnsafe(state) && + ((false == requireCoastElapsed) || + (((uint32_t)(hallTimebaseTicks - state->scheduledStartTicks) >= + MOTOR_MS_TO_TICKS(MOTOR_REVERSAL_COAST_MS)) && + (false == state->coastTracking)))) + { + if(false != Motor_OperationTimeoutExpiredUnsafe(state)) + { + Motor_RequestEventFromIsr(motorIndex, MOTOR_EVENT_TIMEOUT); + } + else if((false != state->targetActive) && + (state->positionHallCount == state->targetHallCount)) + { + Motor_CompleteTargetMoveUnsafe(state); + targetCompleted = true; + } + else + { + direction = (false != state->targetActive) ? + ((state->targetHallCount > state->positionHallCount) ? + MOTOR_DIRECTION_TOWARD_FAR : + MOTOR_DIRECTION_TOWARD_NEAR) : + state->scheduledDirection; + eligible = (direction != MOTOR_DIRECTION_STOPPED); + } + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false != targetCompleted) + { + /* A completed settling cycle must leave both the PWM input and bridge + * high impedance, even if a future caller never services again. */ + return Motor_StopHardware(motorIndex); + } + if(false == eligible) + { + return false; + } + if(false == Motor_PrepareHardwareStart(motorIndex, direction)) + { + return false; + } + + /* Phase two rechecks every ISR-owned condition after SPI. PWM and the + * published running direction are committed together with interrupts + * masked, closing the timeout/target race at hardware start. */ + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((false != state->startPending) && + (state->pendingEvent == MOTOR_EVENT_NONE) && + (false == driverFaultLatched) && + Motor_CommandIsCurrentUnsafe(state)) + { + if(false != Motor_OperationTimeoutExpiredUnsafe(state)) + { + Motor_RequestEventFromIsr(motorIndex, MOTOR_EVENT_TIMEOUT); + } + else + { + if(false != state->targetActive) + { + if(state->positionHallCount == state->targetHallCount) + { + Motor_CompleteTargetMoveUnsafe(state); + targetCompleted = true; + } + else + { + finalDirection = + (state->targetHallCount > state->positionHallCount) ? + MOTOR_DIRECTION_TOWARD_FAR : + MOTOR_DIRECTION_TOWARD_NEAR; + } + } + else + { + finalDirection = state->scheduledDirection; + } + + if((false == targetCompleted) && + (finalDirection == direction)) + { + state->direction = direction; + state->scheduledDirection = MOTOR_DIRECTION_STOPPED; + state->startPending = false; + state->coastTracking = false; + state->coastDirection = MOTOR_DIRECTION_STOPPED; + Motor_ResetActivityWindowUnsafe(state); + Motor_CommitPreparedHardwareStartUnsafe(motorIndex); + committed = true; + accepted = true; + } + else if(false == targetCompleted) + { + /* A last coast pulse changed which side of the target we are + * on during SPI. Keep the command pending and retry from the + * newly observed side on the next service pass. */ + state->scheduledDirection = finalDirection; + accepted = true; + } + else + { + accepted = true; + } + } + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false == committed) + { + if((false != driverFaultLatched) || + (false == Motor_StopHardware(motorIndex))) + { + return false; + } + } + + return accepted; +} + +static uint8_t Motor_StartDirectionInternal(motor_mode_t mode, + motor_direction_t direction, + bool targetActive, + uint32_t targetHallCount, + bool calibrating, + bool clearLastEvent, + motor_direction_t coastFromDirection, + bool preserveOperationTimer) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + motor_direction_t currentDirection; + bool needsCoast; + bool stopCurrentHardware; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode)) || + (false != safetyInhibitLatched) || (false != driverFaultLatched) || + ((direction != MOTOR_DIRECTION_TOWARD_FAR) && + (direction != MOTOR_DIRECTION_TOWARD_NEAR))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((motorRuntime[index].pendingEvent != MOTOR_EVENT_NONE) || + (false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index]))) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + currentDirection = motorRuntime[index].direction; + + if(coastFromDirection == MOTOR_DIRECTION_STOPPED) + { + coastFromDirection = currentDirection; + if((coastFromDirection == MOTOR_DIRECTION_STOPPED) && + (false != motorRuntime[index].coastTracking)) + { + coastFromDirection = motorRuntime[index].coastDirection; + } + } + stopCurrentHardware = (currentDirection != MOTOR_DIRECTION_STOPPED); + needsCoast = (coastFromDirection != MOTOR_DIRECTION_STOPPED) && + ((currentDirection == MOTOR_DIRECTION_STOPPED) || + (coastFromDirection != direction)); + + if(false != stopCurrentHardware) + { + /* Close the ISR race before the slower SPI bridge update below. */ + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[index].pwmTcpwm); + } + motorRuntime[index].pendingEvent = MOTOR_EVENT_NONE; + motorRuntime[index].eventDirection = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].targetActive = targetActive; + motorRuntime[index].targetHallCount = targetHallCount; + motorRuntime[index].calibrating = calibrating; + motorRuntime[index].eventWasTargetMove = false; + if((false != targetActive) && (false != clearLastEvent)) + { + Motor_BeginMoveCommandUnsafe(&motorRuntime[index]); + } + if((false == preserveOperationTimer) || + (false == motorRuntime[index].operationActive)) + { + motorRuntime[index].motionStartTicks = hallTimebaseTicks; + motorRuntime[index].operationActive = true; + motorRuntime[index].automaticReverseCount = 0u; + } + if(false != clearLastEvent) + { + motorRuntime[index].lastEvent = MOTOR_EVENT_NONE; + } + if(false != needsCoast) + { + motorRuntime[index].direction = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].scheduledDirection = direction; + motorRuntime[index].scheduledStartTicks = hallTimebaseTicks; + motorRuntime[index].startPending = true; + Motor_BeginCoastTrackingUnsafe(&motorRuntime[index], + coastFromDirection); + } + else + { + /* Direct starts use the same two-phase commit as delayed reversals. + * Keeping startPending set lets the 1 ms ISR cancel an expired + * operation while the verified bridge SPI write is in progress. */ + motorRuntime[index].direction = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].scheduledDirection = direction; + motorRuntime[index].scheduledStartTicks = hallTimebaseTicks; + motorRuntime[index].startPending = true; + motorRuntime[index].coastTracking = false; + motorRuntime[index].coastDirection = MOTOR_DIRECTION_STOPPED; + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false != stopCurrentHardware) + { + Motor_SetPwmInputLow(&motorProfiles[index]); + if(false == Motor_WriteBridgeBits(index, 0u)) + { + return MOTOR_API_FAILURE; + } + } + + if(false != needsCoast) + { + return MOTOR_API_SUCCESS; + } + + return (false != Motor_TryStartScheduledHardware(index, false)) ? + MOTOR_API_SUCCESS : MOTOR_API_FAILURE; +} + +static void Motor_ServiceScheduledStart(uint32_t motorIndex) +{ + (void)Motor_TryStartScheduledHardware(motorIndex, true); +} + +static void Motor_ServicePendingEvent(uint32_t motorIndex) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + motor_event_t event; + motor_direction_t eventDirection; + motor_direction_t reverseDirection = MOTOR_DIRECTION_STOPPED; +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) + uint32_t recentPulses; + uint32_t position; +#endif + uint32_t savedIntrStatus; + bool wasCalibrating; + bool hadTargetMove; + bool shouldReverse = false; + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + event = state->pendingEvent; + if(event == MOTOR_EVENT_NONE) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return; + } + + eventDirection = state->eventDirection; + wasCalibrating = state->calibrating; + hadTargetMove = state->eventWasTargetMove; +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) + recentPulses = state->recentWindowEdges; +#endif + state->pendingEvent = MOTOR_EVENT_NONE; + state->direction = MOTOR_DIRECTION_STOPPED; + state->eventDirection = MOTOR_DIRECTION_STOPPED; + state->scheduledDirection = MOTOR_DIRECTION_STOPPED; + state->startPending = false; + state->targetActive = false; + state->lastEvent = event; + state->eventWasTargetMove = false; + Motor_BeginCoastTrackingUnsafe(state, eventDirection); + + if(false != hadTargetMove) + { + /* Crossing the requested target completes the move without automatic + * reverse correction. Inertia Hall pulses remain assigned to the old + * direction and are preserved in the global position; the final count + * is allowed to remain beyond the requested target. */ + Motor_SetMoveResultUnsafe(state, event); + } + + if(event == MOTOR_EVENT_NEAR_ENDPOINT) + { + reverseDirection = MOTOR_DIRECTION_TOWARD_FAR; + shouldReverse = (false == wasCalibrating) && + state->reverseAtEndpoint && + (state->automaticReverseCount < + MOTOR_MAX_AUTOMATIC_REVERSALS); + } + else if(event == MOTOR_EVENT_FAR_ENDPOINT) + { + reverseDirection = MOTOR_DIRECTION_TOWARD_NEAR; + shouldReverse = (false == wasCalibrating) && + state->reverseAtEndpoint && + (state->automaticReverseCount < + MOTOR_MAX_AUTOMATIC_REVERSALS); + } + else if(event == MOTOR_EVENT_STALL) + { + reverseDirection = + (eventDirection == MOTOR_DIRECTION_TOWARD_FAR) ? + MOTOR_DIRECTION_TOWARD_NEAR : MOTOR_DIRECTION_TOWARD_FAR; + shouldReverse = (false == wasCalibrating) && + state->reverseAtStall && + (state->automaticReverseCount < + MOTOR_MAX_AUTOMATIC_REVERSALS); + } + else + { + /* Target and timeout events always stop. */ + } + if(false != shouldReverse) + { + state->automaticReverseCount++; + } + else + { + state->operationActive = false; + } +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) + position = state->positionHallCount; +#endif + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false == Motor_StopHardware(motorIndex)) + { + return; + } + + MOTOR_LOG("MOTOR_EVENT mode=%lu profile=%s event=%s direction=%s " + "recent_%lums_edges=%lu hall_position=%lu reverse=%lu\r\n", + (unsigned long)motorIndex, + motorProfiles[motorIndex].name, + Motor_EventToString(event), + Motor_DirectionToString(eventDirection), + (unsigned long)MOTOR_SAFETY_WINDOW_MS, + (unsigned long)recentPulses, + (unsigned long)position, + (unsigned long)((false != shouldReverse) ? 1ul : 0ul)); + + if(false != shouldReverse) + { + (void)Motor_StartDirectionInternal((motor_mode_t)motorIndex, + reverseDirection, + false, + 0ul, + false, + false, + eventDirection, + true); + } +} + +static void Motor_SpeedControlService(uint32_t motorIndex) +{ + volatile motor_runtime_t *state = &motorRuntime[motorIndex]; + uint32_t nowTicks; + uint32_t baseTicks; + uint32_t baseRawEdges; + uint32_t rawEdges; + uint32_t elapsedTicks; + uint32_t elapsedMs; + uint32_t actualPpsX100; + uint32_t targetPpsX100; + uint32_t planElapsedMs; + uint32_t plannedProgressPulses; + uint32_t actualProgressPulses; + uint32_t savedIntrStatus; + uint8_t historyIndex; + uint8_t newDutyPercent; + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + nowTicks = hallTimebaseTicks; + if((false == state->speedControlEnabled) || + (state->direction == MOTOR_DIRECTION_STOPPED) || + (false != state->calibrating) || + (false != state->startPending) || + (state->pendingEvent != MOTOR_EVENT_NONE) || + (false != driverFaultLatched) || + ((uint32_t)(nowTicks - state->speedLastSampleTicks) < + MOTOR_MS_TO_TICKS(MOTOR_SPEED_CONTROL_CHECK_MS))) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return; + } + + rawEdges = state->rawRisingEdges; + historyIndex = state->speedHistoryIndex; + if(state->speedHistoryValidSamples < + MOTOR_SPEED_CONTROL_HISTORY_SAMPLES) + { + baseRawEdges = state->speedBaselineRawEdges; + baseTicks = state->speedControlStartTicks; + state->speedHistoryValidSamples++; + } + else + { + baseRawEdges = state->speedHistoryRawEdges[historyIndex]; + baseTicks = state->speedHistoryTicks[historyIndex]; + } + + state->speedHistoryRawEdges[historyIndex] = rawEdges; + state->speedHistoryTicks[historyIndex] = nowTicks; + historyIndex++; + if(historyIndex >= MOTOR_SPEED_CONTROL_HISTORY_SAMPLES) + { + historyIndex = 0u; + } + state->speedHistoryIndex = historyIndex; + state->speedLastSampleTicks = nowTicks; + + elapsedTicks = (uint32_t)(nowTicks - baseTicks); + planElapsedMs = MOTOR_TICKS_TO_MS( + (uint32_t)(nowTicks - state->speedControlStartTicks)); + if(planElapsedMs > state->speedDurationMs) + { + planElapsedMs = state->speedDurationMs; + } + plannedProgressPulses = (uint32_t) + (((uint64_t)state->speedPlannedHallPulses * planElapsedMs + + (state->speedDurationMs / 2ul)) / state->speedDurationMs); + actualProgressPulses = rawEdges - state->speedBaselineRawEdges; + + /* Loaded axes need a short 50% breakaway pulse, but waiting for the full + * 500 ms speed window lets a short-travel axis build an unrecoverable + * lead. Position progress is meaningful from the first 100 ms sample, + * so de-energize an already-ahead axis immediately. Driving it at an + * arbitrary sub-breakaway duty can create current without Hall progress + * and was the cause of false cradle STALLs. */ + if(((uint32_t)(nowTicks - state->speedControlStartTicks) >= + MOTOR_MS_TO_TICKS(MOTOR_SPEED_PROGRESS_CONTROL_START_MS)) && + (actualProgressPulses > + (plannedProgressPulses + MOTOR_SPEED_PROGRESS_TOLERANCE_PULSES))) + { + if(state->speedCurrentDutyPercent != + MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT) + { + state->speedCurrentDutyPercent = + MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT; + Motor_ApplyDutyPercent(motorIndex, + MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT); + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return; + } + if(((uint32_t)(nowTicks - state->speedControlStartTicks) < + MOTOR_MS_TO_TICKS(MOTOR_SPEED_CONTROL_START_DELAY_MS)) || + (elapsedTicks < MOTOR_MS_TO_TICKS(MOTOR_SPEED_CONTROL_WINDOW_MS))) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return; + } + + elapsedMs = MOTOR_TICKS_TO_MS(elapsedTicks); + if(0ul == elapsedMs) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return; + } + + actualPpsX100 = (uint32_t) + (((((uint64_t)(rawEdges - baseRawEdges)) * 1000ull * + MOTOR_SPEED_VALUE_SCALE) + (elapsedMs / 2ul)) / elapsedMs); + targetPpsX100 = state->speedTargetPpsX100; + newDutyPercent = state->speedCurrentDutyPercent; + state->speedActualPpsX100 = actualPpsX100; + + /* Speed-only regulation cannot synchronize very short and long axes: the + * loaded motor breakaway pulse puts a short travel several seconds ahead + * even when its eventual speed is correct. Track the time-position line + * as well. An axis ahead of the line drops immediately to the adaptive + * floor; an axis behind it ramps up. Within one Hall pulse, the ordinary + * measured-speed loop keeps each energized burst bounded. */ + if(actualProgressPulses > + (plannedProgressPulses + MOTOR_SPEED_PROGRESS_TOLERANCE_PULSES)) + { + newDutyPercent = MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT; + } + else if((actualProgressPulses + MOTOR_SPEED_PROGRESS_TOLERANCE_PULSES) < + plannedProgressPulses) + { + if(newDutyPercent < MOTOR_SPEED_MIN_DUTY_PERCENT) + { + newDutyPercent = MOTOR_SPEED_MIN_DUTY_PERCENT; + } + else if(newDutyPercent < MOTOR_SPEED_MAX_DUTY_PERCENT) + { + newDutyPercent = (uint8_t) + (newDutyPercent + MOTOR_SPEED_DUTY_STEP_PERCENT); + if(newDutyPercent > MOTOR_SPEED_MAX_DUTY_PERCENT) + { + newDutyPercent = MOTOR_SPEED_MAX_DUTY_PERCENT; + } + } + } + else if((((uint64_t)actualPpsX100) * 100ull) < + (((uint64_t)targetPpsX100) * + (100ul - MOTOR_SPEED_TOLERANCE_PERCENT))) + { + if(newDutyPercent < MOTOR_SPEED_MIN_DUTY_PERCENT) + { + newDutyPercent = MOTOR_SPEED_MIN_DUTY_PERCENT; + } + else if(newDutyPercent < MOTOR_SPEED_MAX_DUTY_PERCENT) + { + newDutyPercent = (uint8_t) + (newDutyPercent + MOTOR_SPEED_DUTY_STEP_PERCENT); + if(newDutyPercent > MOTOR_SPEED_MAX_DUTY_PERCENT) + { + newDutyPercent = MOTOR_SPEED_MAX_DUTY_PERCENT; + } + } + } + else if((((uint64_t)actualPpsX100) * 100ull) > + (((uint64_t)targetPpsX100) * + (100ul + MOTOR_SPEED_TOLERANCE_PERCENT))) + { + if(newDutyPercent > MOTOR_SPEED_MIN_DUTY_PERCENT) + { + if(newDutyPercent >= (MOTOR_SPEED_MIN_DUTY_PERCENT + + MOTOR_SPEED_DUTY_STEP_PERCENT)) + { + newDutyPercent = (uint8_t) + (newDutyPercent - MOTOR_SPEED_DUTY_STEP_PERCENT); + } + else + { + newDutyPercent = MOTOR_SPEED_MIN_DUTY_PERCENT; + } + } + else if(newDutyPercent != MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT) + { + newDutyPercent = MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT; + } + } + else + { + /* The measured speed is inside the +/-10 percent deadband. */ + } + + if(newDutyPercent != state->speedCurrentDutyPercent) + { + if((state->speedCurrentDutyPercent == + MOTOR_SPEED_CONTROL_PAUSE_DUTY_PERCENT) && + (newDutyPercent >= MOTOR_SPEED_MIN_DUTY_PERCENT)) + { + /* Resume gets a fresh 500 ms activity window, matching a normal + * hardware start. This prevents the first loaded-current sample + * from being compared with the intentionally quiet pause. */ + Motor_ResetActivityWindowUnsafe(state); + } + state->speedCurrentDutyPercent = newDutyPercent; + Motor_ApplyDutyPercent(motorIndex, newDutyPercent); + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); +} + +void Motor_ServiceAll(void) +{ + uint32_t index; + uint32_t savedIntrStatus; + uint8_t abortMask; + + if(false == motorControlReady) + { + return; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false != motorServiceActive) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return; + } + motorServiceActive = true; + abortMask = abortRequestedMask; + abortRequestedMask = 0u; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(0u != abortMask) + { + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + if((abortMask & (uint8_t)(1u << index)) != 0u) + { + bool stopRequested; + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + stopRequested = motorRuntime[index].abortSequenceValid && + (false == Motor_CommandIsCurrentUnsafe( + &motorRuntime[index])); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + if(false != stopRequested) + { + (void)Motor_StopMode((motor_mode_t)index); + } + } + } + } + + /* ADC polling is main-loop-only. The 2 ms Hall ISR consumes only the + * atomically published cache and can never block on a conversion. */ + Motor_ServiceCurrentSensing(); + Motor_PollDriverStatus(); + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + Motor_ServicePendingEvent(index); + Motor_ServiceScheduledStart(index); + Motor_SpeedControlService(index); + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + motorServiceActive = false; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); +} + +bool Motor_IsDriverFaultLatched(void) +{ + return driverFaultLatched; +} + +uint8_t Motor_AcknowledgeCommand(motor_mode_t mode, uint16_t sequence) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + volatile motor_runtime_t *state; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + state = &motorRuntime[index]; + if(false != safetyInhibitLatched) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + if((false != state->acceptedCommandValid) && + Motor_SequenceIsNewer(state->acceptedCommandSequence, sequence)) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + if(false != state->abortSequenceValid) + { + if(state->latestAbortSequence == sequence) + { + state->abortSequenceValid = false; + abortRequestedMask &= (uint8_t)(~(uint8_t)(1u << index)); + } + else + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + } + state->acceptedCommandSequence = sequence; + state->acceptedCancellationGeneration = state->cancellationGeneration; + state->acceptedCommandValid = true; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +bool Motor_IsCommandCurrent(motor_mode_t mode, uint16_t sequence) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + bool current; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return false; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + current = Motor_CommandIsCurrentUnsafe(&motorRuntime[index]) && + motorRuntime[index].acceptedCommandValid && + (motorRuntime[index].acceptedCommandSequence == sequence); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return current; +} + +void Motor_RequestAbortMaskFromIsr(uint8_t motorMask, uint16_t sequence) +{ + uint32_t index; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + if((motorMask & (uint8_t)(1u << index)) != 0u) + { + volatile motor_runtime_t *state = &motorRuntime[index]; + bool staleAccepted = state->acceptedCommandValid && + Motor_SequenceIsNewer(state->acceptedCommandSequence, + sequence); + bool staleAbort = state->abortSequenceValid && + Motor_SequenceIsNewer(state->latestAbortSequence, sequence); + + if((false == staleAccepted) && (false == staleAbort)) + { + state->latestAbortSequence = sequence; + state->cancellationGeneration++; + state->abortSequenceValid = true; + abortRequestedMask |= (uint8_t)(1u << index); + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[index].pwmTcpwm); + } + } + } +} + +void Motor_RequestAbortAllFromIsr(uint16_t sequence) +{ + uint32_t index; + + /* System/STOP_ALL cancellation is also the protocol resynchronization + * barrier. It must remain effective even when sequence restarts at zero. */ + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + motorRuntime[index].latestAbortSequence = sequence; + motorRuntime[index].cancellationGeneration++; + motorRuntime[index].abortSequenceValid = true; + motorRuntime[index].acceptedCommandValid = false; + abortRequestedMask |= (uint8_t)(1u << index); + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[index].pwmTcpwm); + } +} + +void Motor_LatchSafetyInhibitFromIsr(uint16_t sequence) +{ + /* Publish the independent gate before the sequence-based abort. A later + * STOP may replace that sequence for resynchronization, but it must not + * clear an independent latched safety shutdown. */ + safetyInhibitLatched = true; + Motor_RequestAbortAllFromIsr(sequence); +} + +void Motor_StopAll(void) +{ + uint32_t index; + + for(index = 0ul; index < MOTOR_PROFILE_COUNT; index++) + { + (void)Motor_StopMode((motor_mode_t)index); + } +} + +uint8_t Motor_StopMode(motor_mode_t mode) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + motor_direction_t oldDirection; + motor_event_t priorLastEvent; + bool hadTargetMove; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + oldDirection = motorRuntime[index].direction; + priorLastEvent = motorRuntime[index].lastEvent; + hadTargetMove = motorRuntime[index].targetActive || + motorRuntime[index].eventWasTargetMove; + Motor_BeginCoastTrackingUnsafe(&motorRuntime[index], oldDirection); + motorRuntime[index].direction = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].eventDirection = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].scheduledDirection = MOTOR_DIRECTION_STOPPED; + motorRuntime[index].pendingEvent = MOTOR_EVENT_NONE; + if(false != driverFaultLatched) + { + motorRuntime[index].lastEvent = MOTOR_EVENT_DRIVER_FAULT; + } + else if(false != hadTargetMove) + { + motorRuntime[index].lastEvent = MOTOR_EVENT_ABORTED; + } + else if((priorLastEvent == MOTOR_EVENT_NEAR_ENDPOINT) || + (priorLastEvent == MOTOR_EVENT_FAR_ENDPOINT) || + (priorLastEvent == MOTOR_EVENT_STALL) || + (priorLastEvent == MOTOR_EVENT_TIMEOUT) || + (priorLastEvent == MOTOR_EVENT_DRIVER_FAULT) || + (priorLastEvent == MOTOR_EVENT_CALIBRATION_FAILED)) + { + /* The bridge has already stopped for this terminal event. Preserve + * it across the manager's cleanup stop and a later STOP_ALL so 0x303 + * keeps the physical failure reason available to the host. Starting + * a genuinely new motion command still clears lastEvent. */ + motorRuntime[index].lastEvent = priorLastEvent; + } + else + { + motorRuntime[index].lastEvent = MOTOR_EVENT_NONE; + } + motorRuntime[index].targetActive = false; + motorRuntime[index].calibrating = false; + motorRuntime[index].startPending = false; + motorRuntime[index].operationActive = false; + motorRuntime[index].eventWasTargetMove = false; + if(false != hadTargetMove) + { + Motor_SetMoveResultUnsafe(&motorRuntime[index], MOTOR_EVENT_ABORTED); + } + else + { + /* An explicit stop invalidates any unconsumed completion from an + * older command, so a later Wait cannot report a stale result. */ + motorRuntime[index].moveResultEvent = MOTOR_EVENT_NONE; + motorRuntime[index].moveResultSequence = 0ul; + motorRuntime[index].moveResultReady = false; + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false != driverFaultLatched) + { + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[index].pwmTcpwm); + Motor_SetPwmInputLow(&motorProfiles[index]); + return MOTOR_API_FAILURE; + } + + return (false != Motor_StopHardware(index)) ? + MOTOR_API_SUCCESS : MOTOR_API_FAILURE; +} + +uint8_t Motor_SetFixedDutyPercent(motor_mode_t mode, uint8_t dutyPercent) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + bool applyImmediately; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode)) || + (false != safetyInhibitLatched) || + (dutyPercent < MOTOR_SPEED_MIN_DUTY_PERCENT) || + (dutyPercent > MOTOR_SPEED_MAX_DUTY_PERCENT) || + (false != driverFaultLatched)) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((false != safetyInhibitLatched) || (false != driverFaultLatched) || + (false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index]))) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + motorRuntime[index].fixedDutyPercent = dutyPercent; + motorRuntime[index].speedControlEnabled = false; + motorRuntime[index].speedDurationMs = 0ul; + motorRuntime[index].speedTravelValueX100 = 0ul; + motorRuntime[index].speedPlannedHallPulses = 0ul; + motorRuntime[index].speedTargetPpsX100 = 0ul; + motorRuntime[index].speedInitialDutyPercent = dutyPercent; + motorRuntime[index].speedCurrentDutyPercent = dutyPercent; + Motor_ResetSpeedMeasurementUnsafe(&motorRuntime[index]); + applyImmediately = + (motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) && + (false == motorRuntime[index].calibrating) && + (motorRuntime[index].pendingEvent == MOTOR_EVENT_NONE); + if(false != applyImmediately) + { + Motor_ApplyDutyPercent(index, dutyPercent); + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_StartOpenLoop(motor_mode_t mode, motor_direction_t direction) +{ + return Motor_StartDirectionInternal(mode, + direction, + false, + 0ul, + false, + true, + MOTOR_DIRECTION_STOPPED, + false); +} + +bool Motor_IsMoving(motor_mode_t mode) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + bool moving; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return false; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + moving = (motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) || + motorRuntime[index].startPending || + motorRuntime[index].coastTracking; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return moving; +} + +bool Motor_WaitUntilStopped(motor_mode_t mode) +{ + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return false; + } + if(Motor_IsMoving(mode)) + { + (void)Motor_StopMode(mode); + } + return Motor_WaitForCoastToSettle(mode) && !Motor_IsMoving(mode); +} + +uint8_t Motor_ValidatePersistentState(motor_mode_t mode, + const motor_persistent_state_t *state) +{ + uint32_t index = (uint32_t)mode; + + if((NULL == state) || (false == Motor_ModeIsValid(mode)) || + (0ul == state->maxHallCount) || + (false == Motor_CalibrationRangeIsPlausible( + index, state->maxHallCount)) || + (state->positionValid && + (state->positionHallCount > state->maxHallCount)) || + (0ul == state->maxSpeedPulsesPerSecondX100) || + (state->maxSpeedPulsesPerSecondX100 > + (motorProfiles[index].maxSpeedPpsX100 * + MOTOR_MAX_CALIBRATED_SPEED_MULTIPLIER))) + { + return MOTOR_API_FAILURE; + } + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_ApplyPersistentState(motor_mode_t mode, + const motor_persistent_state_t *state) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + + if((false == motorControlReady) || + (MOTOR_API_SUCCESS != Motor_ValidatePersistentState(mode, state))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) || + motorRuntime[index].startPending || + motorRuntime[index].coastTracking || + (motorRuntime[index].pendingEvent != MOTOR_EVENT_NONE)) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + motorRuntime[index].positionHallCount = state->positionHallCount; + motorRuntime[index].maxHallCount = state->maxHallCount; + motorRuntime[index].maxSpeedPpsX100 = + state->maxSpeedPulsesPerSecondX100; + motorRuntime[index].rawRisingEdges = state->rawRisingEdges; + motorRuntime[index].positionValid = state->positionValid; + motorRuntime[index].lastEvent = MOTOR_EVENT_NONE; + Motor_ResetActivityWindowUnsafe(&motorRuntime[index]); + Motor_ResetSpeedMeasurementUnsafe(&motorRuntime[index]); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_GetPersistentState(motor_mode_t mode, + motor_persistent_state_t *state) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + + if((NULL == state) || (false == motorControlReady) || + (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + state->positionHallCount = motorRuntime[index].positionHallCount; + state->maxHallCount = motorRuntime[index].maxHallCount; + state->maxSpeedPulsesPerSecondX100 = + motorRuntime[index].maxSpeedPpsX100; + state->rawRisingEdges = motorRuntime[index].rawRisingEdges; + state->positionValid = motorRuntime[index].positionValid; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_InvalidatePosition(motor_mode_t mode) +{ + motor_persistent_state_t state; + + if(mode >= MOTOR_MODE_COUNT) + { + return MOTOR_API_FAILURE; + } + /* The CAN hook may run in the same owner-loop iteration that accepted a + * STOP. Finish the bounded stop and Hall-coast transition before + * enforcing the persistent-state API's stopped-axis precondition. */ + if(Motor_IsMoving(mode)) + { + (void)Motor_StopMode(mode); + } + if((false == Motor_WaitForCoastToSettle(mode)) || + Motor_IsMoving(mode) || + (MOTOR_API_SUCCESS != Motor_GetPersistentState(mode, &state))) + { + return MOTOR_API_FAILURE; + } + state.positionValid = false; + return Motor_ApplyPersistentState(mode, &state); +} + +uint8_t Motor_SetMaximumSpeedPpsX100(motor_mode_t mode, + uint32_t maxSpeedPpsX100) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode)) || + (false != safetyInhibitLatched) || + (false != driverFaultLatched) || (0ul == maxSpeedPpsX100) || + (maxSpeedPpsX100 > + (motorProfiles[index].maxSpeedPpsX100 * + MOTOR_MAX_CALIBRATED_SPEED_MULTIPLIER))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) || + motorRuntime[index].startPending || + motorRuntime[index].coastTracking || + (motorRuntime[index].pendingEvent != MOTOR_EVENT_NONE) || + (false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index]))) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + motorRuntime[index].maxSpeedPpsX100 = maxSpeedPpsX100; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint32_t Motor_GetTimeMs(void) +{ + return MOTOR_TICKS_TO_MS(hallTimebaseTicks); +} + +uint8_t Motor_GetCurrentInfo(motor_mode_t mode, motor_current_info_t *info) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + uint32_t ageTicks; + + if((NULL == info) || (false == motorControlReady) || + (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + ageTicks = hallTimebaseTicks - + motorRuntime[index].currentSenseLastSampleTicks; + info->mode = mode; + info->sampleValid = motorRuntime[index].currentSenseValid && + (ageTicks <= + MOTOR_MS_TO_TICKS(SEAT_MOVE_MOTOR_CURRENT_MAX_AGE_MS)); + info->baselineValid = motorRuntime[index].currentSenseBaselineValid; + info->raw = motorRuntime[index].currentSenseRaw; + info->baselineRaw = motorRuntime[index].currentSenseBaselineRaw; + info->deltaRaw = motorRuntime[index].currentSenseDeltaRaw; + info->stallThresholdRaw = motorProfiles[index].stallCurrentDeltaRaw; + info->highCurrent = info->sampleValid && info->baselineValid && + (info->deltaRaw >= info->stallThresholdRaw); + info->hallActivityLow = motorRuntime[index].activityWindowReady && + (motorRuntime[index].recentWindowEdges <= + MOTOR_STALL_EDGE_THRESHOLD); + info->calibrating = motorRuntime[index].calibrating; + info->sampleAgeMs = MOTOR_TICKS_TO_MS(ageTicks); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +void Motor_SetWatchdogServiceEnabled(bool enabled) +{ + watchdogServiceEnabled = enabled; +} + +uint8_t Motor_GetPositionInfo(motor_mode_t mode, motor_position_info_t *info) +{ + uint32_t index = (uint32_t)mode; + uint32_t hallCount; + uint32_t scaledHallCount; + uint32_t maxHallCount; + uint32_t maxValueX100; + uint32_t savedIntrStatus; + + if((NULL == info) || (false == motorControlReady) || + (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + hallCount = motorRuntime[index].positionHallCount; + maxHallCount = motorRuntime[index].maxHallCount; + info->positionValid = motorRuntime[index].positionValid; + info->direction = motorRuntime[index].direction; + info->lastEvent = motorRuntime[index].lastEvent; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(0ul == maxHallCount) + { + return MOTOR_API_FAILURE; + } + scaledHallCount = hallCount; + if(scaledHallCount > maxHallCount) + { + scaledHallCount = maxHallCount; + } + + maxValueX100 = motorProfiles[index].maxPhysicalValueX100; + info->mode = mode; + info->hallCount = hallCount; + info->maxHallCount = maxHallCount; + info->maxValueX100 = maxValueX100; + info->valueX100 = (uint32_t) + (((((uint64_t)scaledHallCount) * maxValueX100) + + (maxHallCount / 2ul)) / + maxHallCount); + info->percentX100 = (uint32_t) + (((((uint64_t)scaledHallCount) * 10000ul) + + (maxHallCount / 2ul)) / + maxHallCount); + info->horizontalMmX100 = 0ul; + info->verticalMmX100 = 0ul; + if(mode == MOTOR_MODE_0_LINEAR) + { + info->positionMmX100 = info->valueX100; + info->horizontalMmX100 = info->valueX100; + info->angleDegX100 = 0ul; + } + else if(mode == MOTOR_MODE_1_ANGLE) + { + info->positionMmX100 = info->valueX100; + info->horizontalMmX100 = (uint32_t) + (((((uint64_t)scaledHallCount) * + MOTOR_MODE1_MAX_HORIZONTAL_MM_X100) + + (maxHallCount / 2ul)) / + maxHallCount); + info->verticalMmX100 = info->valueX100; + info->angleDegX100 = 0ul; + } + else + { + info->positionMmX100 = 0ul; + info->angleDegX100 = info->valueX100; + } + + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_SetSafetyOptions(motor_mode_t mode, + const motor_safety_options_t *options) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + + if((NULL == options) || (false == motorControlReady) || + (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + motorRuntime[index].reverseAtEndpoint = options->reverseAtEndpoint; + motorRuntime[index].reverseAtStall = options->reverseAtStall; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_SetSpeedPlan(motor_mode_t mode, + uint32_t durationMs, + uint32_t travelValueX100) +{ + uint32_t index = (uint32_t)mode; + uint32_t maxHallCount; + uint32_t plannedHallPulses; + uint32_t targetPpsX100; + uint32_t savedIntrStatus; + uint8_t initialDutyPercent; + bool applyImmediately; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode)) || + (false != safetyInhibitLatched) || (false != driverFaultLatched)) + { + return MOTOR_API_FAILURE; + } + + Motor_ServiceAll(); + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + maxHallCount = motorRuntime[index].maxHallCount; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false == Motor_CalculateSpeedPlan(index, + maxHallCount, + durationMs, + travelValueX100, + &plannedHallPulses, + &targetPpsX100, + &initialDutyPercent)) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((false != safetyInhibitLatched) || (false != driverFaultLatched) || + (false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index]))) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + + motorRuntime[index].speedDurationMs = durationMs; + motorRuntime[index].speedTravelValueX100 = travelValueX100; + motorRuntime[index].speedPlannedHallPulses = plannedHallPulses; + motorRuntime[index].speedTargetPpsX100 = targetPpsX100; + motorRuntime[index].speedInitialDutyPercent = initialDutyPercent; + motorRuntime[index].speedCurrentDutyPercent = initialDutyPercent; + motorRuntime[index].speedControlEnabled = true; + Motor_ResetSpeedMeasurementUnsafe(&motorRuntime[index]); + + applyImmediately = + (motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) && + (false == motorRuntime[index].calibrating) && + (motorRuntime[index].pendingEvent == MOTOR_EVENT_NONE); + if(false != applyImmediately) + { + Motor_ApplyDutyPercent(index, initialDutyPercent); + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_DisableSpeedControl(motor_mode_t mode) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + bool applyImmediately; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + Motor_ServiceAll(); + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + motorRuntime[index].speedControlEnabled = false; + motorRuntime[index].speedDurationMs = 0ul; + motorRuntime[index].speedTravelValueX100 = 0ul; + motorRuntime[index].speedPlannedHallPulses = 0ul; + motorRuntime[index].speedTargetPpsX100 = 0ul; + motorRuntime[index].speedInitialDutyPercent = + motorRuntime[index].fixedDutyPercent; + motorRuntime[index].speedCurrentDutyPercent = + motorRuntime[index].fixedDutyPercent; + Motor_ResetSpeedMeasurementUnsafe(&motorRuntime[index]); + + applyImmediately = + (motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) && + (false == motorRuntime[index].calibrating) && + (motorRuntime[index].pendingEvent == MOTOR_EVENT_NONE) && + (false == driverFaultLatched); + if(false != applyImmediately) + { + Motor_ApplyDutyPercent(index, motorRuntime[index].fixedDutyPercent); + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return (false == driverFaultLatched) ? + MOTOR_API_SUCCESS : MOTOR_API_FAILURE; +} + +uint8_t Motor_GetSpeedInfo(motor_mode_t mode, motor_speed_info_t *info) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + + if((NULL == info) || (false == motorControlReady) || + (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + info->mode = mode; + info->enabled = motorRuntime[index].speedControlEnabled; + info->durationMs = motorRuntime[index].speedDurationMs; + info->travelValueX100 = motorRuntime[index].speedTravelValueX100; + info->plannedHallPulses = motorRuntime[index].speedPlannedHallPulses; + info->targetPulsesPerSecondX100 = + motorRuntime[index].speedTargetPpsX100; + info->actualPulsesPerSecondX100 = + motorRuntime[index].speedActualPpsX100; + info->initialDutyPercent = motorRuntime[index].speedInitialDutyPercent; + info->currentDutyPercent = motorRuntime[index].speedCurrentDutyPercent; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_StartMoveToValue(motor_mode_t mode, uint32_t targetValueX100) +{ + uint32_t index = (uint32_t)mode; + uint32_t maxValueX100; + uint32_t targetHallCount = 0ul; + uint32_t savedIntrStatus; + volatile motor_runtime_t *state; + motor_direction_t directStartDirection = MOTOR_DIRECTION_STOPPED; + motor_direction_t actualDirection; + bool stopHardware = false; + bool startDirectly = false; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode)) || + (false != driverFaultLatched)) + { + return MOTOR_API_FAILURE; + } + + Motor_ServiceAll(); + maxValueX100 = motorProfiles[index].maxPhysicalValueX100; + if(targetValueX100 > maxValueX100) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + state = &motorRuntime[index]; + if((false == state->positionValid) || (0ul == state->maxHallCount) || + (false != state->calibrating) || + (state->pendingEvent != MOTOR_EVENT_NONE) || + (false == Motor_CommandIsCurrentUnsafe(state)) || + (false != driverFaultLatched)) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + return MOTOR_API_FAILURE; + } + + targetHallCount = (uint32_t) + (((((uint64_t)targetValueX100) * state->maxHallCount) + + (maxValueX100 / 2ul)) / maxValueX100); + actualDirection = state->direction; + + /* All target replacement decisions use this single atomic position + * snapshot. This avoids selecting a direction from one Hall count and + * committing the command after an ISR has already crossed the target. */ + Motor_BeginMoveCommandUnsafe(state); + state->targetHallCount = targetHallCount; + state->targetActive = true; + state->calibrating = false; + state->eventWasTargetMove = false; + state->lastEvent = MOTOR_EVENT_NONE; + + if(false != state->startPending) + { + /* An explicit direction-change coast is already in progress. Keep + * its original coast and absolute timeout clocks; the delayed-start + * service recomputes the direction requested by the latest command + * from the final settled Hall count. */ + state->scheduledDirection = + (targetHallCount == state->positionHallCount) ? + MOTOR_DIRECTION_STOPPED : + ((targetHallCount > state->positionHallCount) ? + MOTOR_DIRECTION_TOWARD_FAR : MOTOR_DIRECTION_TOWARD_NEAR); + if(false == state->operationActive) + { + state->motionStartTicks = hallTimebaseTicks; + state->operationActive = true; + state->automaticReverseCount = 0u; + } + } + else if(actualDirection != MOTOR_DIRECTION_STOPPED) + { + bool targetStillAhead = + ((actualDirection == MOTOR_DIRECTION_TOWARD_FAR) && + (targetHallCount > state->positionHallCount)) || + ((actualDirection == MOTOR_DIRECTION_TOWARD_NEAR) && + (targetHallCount < state->positionHallCount)); + + if(false == state->operationActive) + { + state->motionStartTicks = hallTimebaseTicks; + state->operationActive = true; + state->automaticReverseCount = 0u; + } + + if(false == targetStillAhead) + { + /* The replacement target is at or behind the live position. Kill + * PWM in the same critical section, count inertia in the old + * direction, and apply the explicitly requested direction change + * only after settling. */ + Cy_Tcpwm_TriggerStopOrKill(motorProfiles[index].pwmTcpwm); + state->direction = MOTOR_DIRECTION_STOPPED; + state->scheduledDirection = MOTOR_DIRECTION_STOPPED; + state->scheduledStartTicks = hallTimebaseTicks; + state->startPending = true; + Motor_BeginCoastTrackingUnsafe(state, actualDirection); + stopHardware = true; + } + /* A target still ahead in the actual direction is an atomic target + * replacement only: PWM, safety window, and timeout are preserved. */ + } + else if(false != state->coastTracking) + { + /* Even a same-direction retarget waits for residual motion to settle. + * Otherwise a coast pulse during SPI can cross the target just before + * PWM is enabled and force an avoidable one-pulse overshoot. */ + state->scheduledDirection = + (targetHallCount == state->positionHallCount) ? + MOTOR_DIRECTION_STOPPED : + ((targetHallCount > state->positionHallCount) ? + MOTOR_DIRECTION_TOWARD_FAR : MOTOR_DIRECTION_TOWARD_NEAR); + state->scheduledStartTicks = state->coastStartTicks; + state->startPending = true; + if(false == state->operationActive) + { + state->motionStartTicks = hallTimebaseTicks; + state->operationActive = true; + state->automaticReverseCount = 0u; + } + } + else if(targetHallCount == state->positionHallCount) + { + /* With no running or coasting direction, Hall transitions cannot alter + * the inferred global position; immediate exact completion is safe. */ + state->automaticReverseCount = 0u; + Motor_CompleteTargetMoveUnsafe(state); + } + else + { + directStartDirection = + (targetHallCount > state->positionHallCount) ? + MOTOR_DIRECTION_TOWARD_FAR : MOTOR_DIRECTION_TOWARD_NEAR; + startDirectly = true; + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false != stopHardware) + { + return (false != Motor_StopHardware(index)) ? + MOTOR_API_SUCCESS : MOTOR_API_FAILURE; + } + if(false != startDirectly) + { + /* The command sequence was already created atomically above. */ + return Motor_StartDirectionInternal(mode, + directStartDirection, + true, + targetHallCount, + false, + false, + MOTOR_DIRECTION_STOPPED, + false); + } + + return MOTOR_API_SUCCESS; +} + +uint8_t Motor_WaitForMove(motor_mode_t mode) +{ + uint32_t index = (uint32_t)mode; + uint32_t savedIntrStatus; + uint32_t waitSequence; + uint32_t currentSequence; + motor_event_t resultEvent; + bool resultReady; + bool movePending; + bool motorStillRunning; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode))) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + waitSequence = motorRuntime[index].moveCommandSequence; + resultReady = motorRuntime[index].moveResultReady && + (motorRuntime[index].moveResultSequence == waitSequence); + movePending = motorRuntime[index].targetActive || + motorRuntime[index].startPending || + ((motorRuntime[index].pendingEvent != MOTOR_EVENT_NONE) && + motorRuntime[index].eventWasTargetMove); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if((0ul == waitSequence) || ((false == resultReady) && + (false == movePending))) + { + return MOTOR_API_FAILURE; + } + + for(;;) + { + Motor_ServiceAll(); + Motor_CalibrationServiceHook(); + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + currentSequence = motorRuntime[index].moveCommandSequence; + resultReady = motorRuntime[index].moveResultReady && + (motorRuntime[index].moveResultSequence == waitSequence); + resultEvent = motorRuntime[index].moveResultEvent; + movePending = motorRuntime[index].targetActive || + motorRuntime[index].startPending || + ((motorRuntime[index].pendingEvent != MOTOR_EVENT_NONE) && + motorRuntime[index].eventWasTargetMove); + motorStillRunning = + (motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) || + motorRuntime[index].startPending || + motorRuntime[index].coastTracking; + if((false != resultReady) && (false == motorStillRunning)) + { + motorRuntime[index].moveResultReady = false; + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(currentSequence != waitSequence) + { + return MOTOR_API_FAILURE; + } + if(false != resultReady) + { + if(false == motorStillRunning) + { + return (resultEvent == MOTOR_EVENT_TARGET_REACHED) ? + MOTOR_API_SUCCESS : MOTOR_API_FAILURE; + } + + /* A requested safety reverse is part of the same operation. + * Wait remains blocking until the one permitted reverse stops at + * its next safety event or the absolute operation timeout. */ + movePending = true; + } + if(false == movePending) + { + return MOTOR_API_FAILURE; + } + + if(false != watchdogServiceEnabled) + { + Cy_WDT_ClearWatchdog(); + } + __WFI(); + } +} + +uint8_t Motor_MoveToValue(motor_mode_t mode, uint32_t targetValueX100) +{ + if(MOTOR_API_SUCCESS != Motor_StartMoveToValue(mode, targetValueX100)) + { + return MOTOR_API_FAILURE; + } + + return Motor_WaitForMove(mode); +} + +static bool Motor_WaitForCoastToSettle(motor_mode_t mode) +{ + uint32_t index = (uint32_t)mode; + uint32_t waitStartTicks = MotorControl_GetTicks(); + uint32_t savedIntrStatus; + bool coastTracking; + bool invalidState; + + for(;;) + { + Motor_ServiceAll(); + Motor_CalibrationServiceHook(); + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + coastTracking = motorRuntime[index].coastTracking; + invalidState = (false != driverFaultLatched) || + (false == Motor_CommandIsCurrentUnsafe( + &motorRuntime[index])) || + (motorRuntime[index].direction != + MOTOR_DIRECTION_STOPPED) || + (false != motorRuntime[index].startPending) || + (motorRuntime[index].pendingEvent != MOTOR_EVENT_NONE); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false != invalidState) + { + return false; + } + if(false == coastTracking) + { + return true; + } + if((uint32_t)(MotorControl_GetTicks() - waitStartTicks) >= + MOTOR_MS_TO_TICKS(MOTOR_COAST_SETTLE_TIMEOUT_MS)) + { + return false; + } + + if(false != watchdogServiceEnabled) + { + Cy_WDT_ClearWatchdog(); + } + __WFI(); + } +} + +static void Motor_UpdateCalibrationTravel(uint32_t motorIndex, + uint32_t rawRisingStart, + uint32_t rawTotalStart, + uint32_t *travelPulses, + uint32_t *travelEdges) +{ + uint32_t rawRisingEnd; + uint32_t rawTotalEnd; + uint32_t savedIntrStatus; + + if((NULL != travelPulses) || (NULL != travelEdges)) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + rawRisingEnd = motorRuntime[motorIndex].rawRisingEdges; + rawTotalEnd = motorRuntime[motorIndex].rawTotalEdges; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + if(NULL != travelPulses) + { + *travelPulses = rawRisingEnd - rawRisingStart; + } + if(NULL != travelEdges) + { + *travelEdges = rawTotalEnd - rawTotalStart; + } + } +} + +static uint8_t Motor_CalibrationProbeMotion( + motor_mode_t mode, + motor_direction_t direction, + uint32_t legTimeoutMs, + uint32_t *travelEdges) +{ + uint32_t index = (uint32_t)mode; + uint32_t verifyEdges = MOTOR_HOME_PROBE_VERIFY_EDGES; + uint32_t rawTotalStart; + uint32_t rawTotalEnd; + uint32_t savedIntrStatus; + motor_event_t lastEvent; + motor_direction_t currentDirection; + bool startPending; + + if(NULL != travelEdges) + { + *travelEdges = 0ul; + } + + /* A Hall-only endpoint search cannot distinguish "already at endpoint" + * from "bridge energized but the loaded motor never started". When a + * near seek reports an endpoint with too little travel, move across a few + * real Hall transitions and then let the caller seek near again. Both + * edges are used for this proof so its result does not depend on Hall + * phase or travel direction. */ + if(false == Motor_WaitForCoastToSettle(mode)) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + motorRuntime[index].calibrationTimeoutTicks = + MOTOR_MS_TO_TICKS(legTimeoutMs); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(MOTOR_API_SUCCESS != + Motor_StartDirectionInternal(mode, + direction, + false, + 0ul, + true, + true, + MOTOR_DIRECTION_STOPPED, + false)) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + /* Captured atomically with PWM commit by + * Motor_ResetActivityWindowUnsafe(). */ + rawTotalStart = motorRuntime[index].activityTotalBaseline; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + for(;;) + { + Motor_ServiceAll(); + Motor_CalibrationServiceHook(); + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + rawTotalEnd = motorRuntime[index].rawTotalEdges; + lastEvent = motorRuntime[index].lastEvent; + currentDirection = motorRuntime[index].direction; + startPending = motorRuntime[index].startPending; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if((rawTotalEnd - rawTotalStart) >= verifyEdges) + { + if((MOTOR_API_SUCCESS != Motor_StopMode(mode)) || + (false == Motor_WaitForCoastToSettle(mode))) + { + Motor_UpdateCalibrationTravel(index, + 0ul, + rawTotalStart, + NULL, + travelEdges); + return MOTOR_API_FAILURE; + } + Motor_UpdateCalibrationTravel(index, + 0ul, + rawTotalStart, + NULL, + travelEdges); + return MOTOR_API_SUCCESS; + } + + if((lastEvent != MOTOR_EVENT_NONE) || + ((currentDirection == MOTOR_DIRECTION_STOPPED) && + (false == startPending))) + { + (void)Motor_StopMode(mode); + (void)Motor_WaitForCoastToSettle(mode); + Motor_UpdateCalibrationTravel(index, + 0ul, + rawTotalStart, + NULL, + travelEdges); + return MOTOR_API_FAILURE; + } + + if(false != watchdogServiceEnabled) + { + Cy_WDT_ClearWatchdog(); + } + __WFI(); + } +} + +static uint8_t Motor_CalibrationSeekEndpoint(motor_mode_t mode, + motor_direction_t direction, + uint32_t legTimeoutMs, + uint32_t *travelPulses, + uint32_t *travelEdges) +{ + uint32_t index = (uint32_t)mode; + uint32_t rawRisingStart; + uint32_t rawRisingEnd; + uint32_t rawTotalStart; + uint32_t rawTotalEnd; + uint32_t savedIntrStatus; + motor_event_t expectedEvent = + (direction == MOTOR_DIRECTION_TOWARD_NEAR) ? + MOTOR_EVENT_NEAR_ENDPOINT : MOTOR_EVENT_FAR_ENDPOINT; + motor_event_t lastEvent; + motor_direction_t currentDirection; + bool startPending; + bool coastTracking; + + if(NULL != travelPulses) + { + *travelPulses = 0ul; + } + if(NULL != travelEdges) + { + *travelEdges = 0ul; + } + + /* Exclude residual motion from a previous operation or calibration leg. + * This wait is Hall-only and bounded, matching the observable hardware. */ + if(false == Motor_WaitForCoastToSettle(mode)) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + motorRuntime[index].calibrationTimeoutTicks = + MOTOR_MS_TO_TICKS(legTimeoutMs); + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(MOTOR_API_SUCCESS != + Motor_StartDirectionInternal(mode, + direction, + false, + 0ul, + true, + true, + MOTOR_DIRECTION_STOPPED, + false)) + { + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + /* Motor_ResetActivityWindowUnsafe captured both baselines in the same + * critical section that committed PWM start, after the bridge SPI phase. */ + rawRisingStart = motorRuntime[index].activityRisingBaseline; + rawTotalStart = motorRuntime[index].activityTotalBaseline; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + for(;;) + { + Motor_ServiceAll(); + Motor_CalibrationServiceHook(); + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + lastEvent = motorRuntime[index].lastEvent; + currentDirection = motorRuntime[index].direction; + startPending = motorRuntime[index].startPending; + coastTracking = motorRuntime[index].coastTracking; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if((lastEvent == expectedEvent) && + (currentDirection == MOTOR_DIRECTION_STOPPED) && + (false == startPending)) + { + if((false != coastTracking) && + (false == Motor_WaitForCoastToSettle(mode))) + { + (void)Motor_StopMode(mode); + Motor_UpdateCalibrationTravel(index, + rawRisingStart, + rawTotalStart, + travelPulses, + travelEdges); + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((motorRuntime[index].lastEvent != expectedEvent) || + (motorRuntime[index].direction != MOTOR_DIRECTION_STOPPED) || + (false != motorRuntime[index].startPending) || + (false != motorRuntime[index].coastTracking) || + (false == Motor_CommandIsCurrentUnsafe( + &motorRuntime[index])) || + (false != driverFaultLatched)) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + (void)Motor_StopMode(mode); + Motor_UpdateCalibrationTravel(index, + rawRisingStart, + rawTotalStart, + travelPulses, + travelEdges); + return MOTOR_API_FAILURE; + } + rawRisingEnd = motorRuntime[index].rawRisingEdges; + rawTotalEnd = motorRuntime[index].rawTotalEdges; + motorRuntime[index].calibrating = false; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + if(NULL != travelPulses) + { + *travelPulses = rawRisingEnd - rawRisingStart; + } + if(NULL != travelEdges) + { + *travelEdges = rawTotalEnd - rawTotalStart; + } + return MOTOR_API_SUCCESS; + } + + if((lastEvent != MOTOR_EVENT_NONE) || + ((currentDirection == MOTOR_DIRECTION_STOPPED) && + (false == startPending))) + { + (void)Motor_StopMode(mode); + (void)Motor_WaitForCoastToSettle(mode); + Motor_UpdateCalibrationTravel(index, + rawRisingStart, + rawTotalStart, + travelPulses, + travelEdges); + return MOTOR_API_FAILURE; + } + + if(false != watchdogServiceEnabled) + { + Cy_WDT_ClearWatchdog(); + } + __WFI(); + } +} + +static bool Motor_CalibrationRangeIsPlausible(uint32_t motorIndex, + uint32_t measuredPulses) +{ + uint64_t nominal = motorProfiles[motorIndex].defaultMaxHallCount; + uint64_t minimum = + (nominal * MOTOR_CALIBRATION_RANGE_MIN_PERCENT) / 100ull; + uint64_t maximum = + (nominal * MOTOR_CALIBRATION_RANGE_MAX_PERCENT) / 100ull; + + return (measuredPulses >= 8ul) && + (((uint64_t)measuredPulses) >= minimum) && + (((uint64_t)measuredPulses) <= maximum); +} + +uint8_t Motor_Calibrate(motor_mode_t mode, + motor_calibration_action_t action, + uint32_t legTimeoutMs, + motor_calibration_result_t *result) +{ + motor_calibration_result_t localResult; + uint32_t index = (uint32_t)mode; + uint32_t measuredPulses = 0ul; + uint32_t measuredEdges = 0ul; + uint32_t probeEdges = 0ul; + uint32_t preloadEdges = 0ul; + uint8_t probeAttempt = 0u; +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) + uint32_t firstNearPulses = 0ul; + uint32_t firstNearEdges = 0ul; +#endif + uint32_t previousMaxHallCount = 0ul; + uint32_t speedDurationMs = 0ul; + uint32_t speedTravelValueX100 = 0ul; + uint32_t speedPlannedHallPulses = 0ul; + uint32_t speedTargetPpsX100 = 0ul; + uint32_t savedIntrStatus; + uint8_t speedInitialDutyPercent = (uint8_t)MOTOR_PWM_DUTY_PERCENT; + uint8_t status = MOTOR_API_FAILURE; + bool speedPlanEnabled = false; + bool speedPlanValid = false; + + localResult.nearEndpointFound = false; + localResult.farEndpointFound = false; + localResult.measuredMaxHallCount = 0ul; + + if((false == motorControlReady) || (false == Motor_ModeIsValid(mode)) || + (false != safetyInhibitLatched) || (false != driverFaultLatched) || + (0ul == legTimeoutMs) || + ((action != MOTOR_CALIBRATE_FIND_NEAR) && + (action != MOTOR_CALIBRATE_FIND_FAR) && + (action != MOTOR_CALIBRATE_FIND_BOTH))) + { + if(NULL != result) + { + *result = localResult; + } + return MOTOR_API_FAILURE; + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index])) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + if(NULL != result) + { + *result = localResult; + } + return MOTOR_API_FAILURE; + } + previousMaxHallCount = motorRuntime[index].maxHallCount; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(MOTOR_API_SUCCESS != Motor_StopMode(mode)) + { + goto calibration_failed; + } + + MOTOR_LOG("CAL_START mode=%lu profile=%s action=%lu\r\n", + (unsigned long)index, + motorProfiles[index].name, + (unsigned long)action); + + if(action == MOTOR_CALIBRATE_FIND_NEAR) + { + status = Motor_CalibrationSeekEndpoint( + mode, MOTOR_DIRECTION_TOWARD_NEAR, + legTimeoutMs, &measuredPulses, &measuredEdges); +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) + firstNearPulses = measuredPulses; + firstNearEdges = measuredEdges; +#endif + if((status == MOTOR_API_SUCCESS) && + (measuredEdges < MOTOR_HOME_PROBE_VERIFY_EDGES)) + { + /* Do not turn a non-start into a fake Hall zero. A genuine + * already-near condition is proved by moving across several Hall + * edges toward far and seeking the same path back toward near. */ + /* A loaded mechanism can need more than one bounded energize + * cycle to leave a mechanical hard stop. Retry the departure, + * but still require real Hall edges before accepting the home. + * This improves endpoint breakaway without weakening the open + * Hall / disconnected-motor diagnostic. Keep the same four-edge + * proof on M3 as the other axes: accepting a single complete Hall + * pulse can make a temporarily stuck legrest look like a real + * mechanical endpoint and incorrectly rewrite its position to + * Hall=0 before the linkage is fully lowered. */ + for(probeAttempt = 0u; probeAttempt < 3u; probeAttempt++) + { + status = Motor_CalibrationProbeMotion( + mode, + MOTOR_DIRECTION_TOWARD_FAR, + legTimeoutMs, + &probeEdges); + if(status == MOTOR_API_SUCCESS) + { + break; + } + if((mode == MOTOR_MODE_3_LINEAR_TRACK) && + (MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent( + mode, SEAT_MOVE_M3_BREAKAWAY_DUTY_PERCENT))) + { + status = MOTOR_API_FAILURE; + break; + } + /* A failed probe has already stopped and settled the bridge. + * Briefly re-energize toward the known hard stop before the + * next departure. On the loaded M3 linkage this alternating + * preload releases static lash which repeated starts in the + * same direction do not. Its result is deliberately ignored: + * only a later far probe with real Hall edges can pass. */ + if(probeAttempt < 2u) + { + preloadEdges = 0ul; + (void)Motor_CalibrationProbeMotion( + mode, + MOTOR_DIRECTION_TOWARD_NEAR, + legTimeoutMs, + &preloadEdges); + } + } + if(status == MOTOR_API_SUCCESS) + { + status = Motor_CalibrationSeekEndpoint( + mode, MOTOR_DIRECTION_TOWARD_NEAR, + legTimeoutMs, &measuredPulses, &measuredEdges); + if((status == MOTOR_API_SUCCESS) && + (((uint64_t)measuredEdges + + MOTOR_HOME_RETURN_EDGE_TOLERANCE) < + (uint64_t)probeEdges)) + { + status = MOTOR_API_FAILURE; + } + } + } +#if (MOTOR_DIAGNOSTIC_LOG_ENABLE != 0u) + MOTOR_LOG("CAL_HOME_VERIFY mode=%lu first_rising=%lu " + "first_edges=%lu probe_edges=%lu return_rising=%lu " + "return_edges=%lu status=%lu\r\n", + (unsigned long)index, + (unsigned long)firstNearPulses, + (unsigned long)firstNearEdges, + (unsigned long)probeEdges, + (unsigned long)measuredPulses, + (unsigned long)measuredEdges, + (unsigned long)status); +#endif + if(status == MOTOR_API_SUCCESS) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index])) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + status = MOTOR_API_FAILURE; + goto calibration_failed; + } + motorRuntime[index].positionHallCount = 0ul; + motorRuntime[index].positionValid = true; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + localResult.nearEndpointFound = true; + } + else + { + status = MOTOR_API_FAILURE; + } + } + else if(action == MOTOR_CALIBRATE_FIND_FAR) + { + status = Motor_CalibrationSeekEndpoint( + mode, MOTOR_DIRECTION_TOWARD_FAR, + legTimeoutMs, &measuredPulses, NULL); + if(status == MOTOR_API_SUCCESS) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index])) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + status = MOTOR_API_FAILURE; + goto calibration_failed; + } + motorRuntime[index].positionHallCount = previousMaxHallCount; + motorRuntime[index].positionValid = true; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + localResult.farEndpointFound = true; + } + else + { + status = MOTOR_API_FAILURE; + } + } + else + { + status = Motor_CalibrationSeekEndpoint( + mode, MOTOR_DIRECTION_TOWARD_NEAR, legTimeoutMs, NULL, NULL); + if(status == MOTOR_API_SUCCESS) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index])) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + status = MOTOR_API_FAILURE; + goto calibration_failed; + } + motorRuntime[index].positionHallCount = 0ul; + motorRuntime[index].positionValid = false; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + localResult.nearEndpointFound = true; + status = Motor_CalibrationSeekEndpoint( + mode, MOTOR_DIRECTION_TOWARD_FAR, + legTimeoutMs, &measuredPulses, NULL); + } + + if((status == MOTOR_API_SUCCESS) && + Motor_CalibrationRangeIsPlausible(index, measuredPulses)) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + speedPlanEnabled = motorRuntime[index].speedControlEnabled; + speedDurationMs = motorRuntime[index].speedDurationMs; + speedTravelValueX100 = + motorRuntime[index].speedTravelValueX100; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + + if(false != speedPlanEnabled) + { + speedPlanValid = Motor_CalculateSpeedPlan( + index, + measuredPulses, + speedDurationMs, + speedTravelValueX100, + &speedPlannedHallPulses, + &speedTargetPpsX100, + &speedInitialDutyPercent); + } + + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if(false == Motor_CommandIsCurrentUnsafe(&motorRuntime[index])) + { + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + status = MOTOR_API_FAILURE; + goto calibration_failed; + } + motorRuntime[index].maxHallCount = measuredPulses; + motorRuntime[index].positionHallCount = measuredPulses; + motorRuntime[index].positionValid = true; + if(false != speedPlanEnabled) + { + if(false != speedPlanValid) + { + motorRuntime[index].speedPlannedHallPulses = + speedPlannedHallPulses; + motorRuntime[index].speedTargetPpsX100 = + speedTargetPpsX100; + motorRuntime[index].speedInitialDutyPercent = + speedInitialDutyPercent; + motorRuntime[index].speedCurrentDutyPercent = + speedInitialDutyPercent; + Motor_ResetSpeedMeasurementUnsafe(&motorRuntime[index]); + } + else + { + /* A range change can make an old physical speed plan + * exceed the measured motor maximum. Disable it rather + * than silently commanding an impossible speed. */ + motorRuntime[index].speedControlEnabled = false; + motorRuntime[index].speedDurationMs = 0ul; + motorRuntime[index].speedTravelValueX100 = 0ul; + motorRuntime[index].speedPlannedHallPulses = 0ul; + motorRuntime[index].speedTargetPpsX100 = 0ul; + motorRuntime[index].speedActualPpsX100 = 0ul; + motorRuntime[index].speedInitialDutyPercent = + (uint8_t)MOTOR_PWM_DUTY_PERCENT; + motorRuntime[index].speedCurrentDutyPercent = + (uint8_t)MOTOR_PWM_DUTY_PERCENT; + } + } + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + localResult.farEndpointFound = true; + localResult.measuredMaxHallCount = measuredPulses; + } + else + { + status = MOTOR_API_FAILURE; + } + } + + if((status == MOTOR_API_SUCCESS) && + (action != MOTOR_CALIBRATE_FIND_BOTH)) + { + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + localResult.measuredMaxHallCount = motorRuntime[index].maxHallCount; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + } + +calibration_failed: + if(status != MOTOR_API_SUCCESS) + { + if(false == driverFaultLatched) + { + (void)Motor_StopMode(mode); + } + + /* Physical motion cannot be rolled back. Preserve the live Hall + * count, but invalidate the absolute position after any failed + * calibration. Only the uncommitted range parameter is restored. */ + savedIntrStatus = Cy_SysLib_EnterCriticalSection(); + if((action == MOTOR_CALIBRATE_FIND_BOTH) && + (false != localResult.nearEndpointFound)) + { + /* The near endpoint established a new zero. Keep every observed + * far-leg pulse even when it exceeds the previous range. */ + motorRuntime[index].positionHallCount = measuredPulses; + } + motorRuntime[index].maxHallCount = previousMaxHallCount; + motorRuntime[index].positionValid = false; + motorRuntime[index].calibrating = false; + motorRuntime[index].operationActive = false; + motorRuntime[index].lastEvent = (false != driverFaultLatched) ? + MOTOR_EVENT_DRIVER_FAULT : + MOTOR_EVENT_CALIBRATION_FAILED; + Cy_SysLib_ExitCriticalSection(savedIntrStatus); + } + + if(NULL != result) + { + *result = localResult; + } + + MOTOR_LOG("CAL_DONE mode=%lu status=%lu near=%lu far=%lu max_hall=%lu\r\n", + (unsigned long)index, + (unsigned long)status, + (unsigned long)((false != localResult.nearEndpointFound) ? 1ul : 0ul), + (unsigned long)((false != localResult.farEndpointFound) ? 1ul : 0ul), + (unsigned long)localResult.measuredMaxHallCount); + return status; +} + +uint8_t Motor0_GetPosition(motor_position_info_t *info) +{ + return Motor_GetPositionInfo(MOTOR_MODE_0_LINEAR, info); +} + +uint8_t Motor1_GetPosition(motor_position_info_t *info) +{ + return Motor_GetPositionInfo(MOTOR_MODE_1_ANGLE, info); +} + +uint8_t Motor2_GetAngle(motor_position_info_t *info) +{ + return Motor_GetPositionInfo(MOTOR_MODE_2_ANGLE, info); +} + +uint8_t Motor3_GetAngle(motor_position_info_t *info) +{ + return Motor_GetPositionInfo(MOTOR_MODE_3_LINEAR_TRACK, info); +} + +uint8_t Motor0_MoveToPositionMmX100(uint32_t positionMmX100) +{ + return Motor_MoveToValue(MOTOR_MODE_0_LINEAR, positionMmX100); +} + +uint8_t Motor1_MoveToVerticalMmX100(uint32_t verticalMmX100) +{ + return Motor_MoveToValue(MOTOR_MODE_1_ANGLE, verticalMmX100); +} + +uint8_t Motor2_MoveToAngleDegX100(uint32_t angleDegX100) +{ + return Motor_MoveToValue(MOTOR_MODE_2_ANGLE, angleDegX100); +} + +uint8_t Motor3_MoveToAngleDegX100(uint32_t angleDegX100) +{ + return Motor_MoveToValue(MOTOR_MODE_3_LINEAR_TRACK, angleDegX100); +} + +uint8_t Motor0_SetSpeedPlan(uint32_t durationMs, uint32_t distanceMmX100) +{ + return Motor_SetSpeedPlan(MOTOR_MODE_0_LINEAR, + durationMs, + distanceMmX100); +} + +uint8_t Motor1_SetSpeedPlan(uint32_t durationMs, uint32_t verticalMmX100) +{ + return Motor_SetSpeedPlan(MOTOR_MODE_1_ANGLE, + durationMs, + verticalMmX100); +} + +uint8_t Motor2_SetSpeedPlan(uint32_t durationMs, uint32_t angleDegX100) +{ + return Motor_SetSpeedPlan(MOTOR_MODE_2_ANGLE, + durationMs, + angleDegX100); +} + +uint8_t Motor3_SetSpeedPlan(uint32_t durationMs, uint32_t angleDegX100) +{ + return Motor_SetSpeedPlan(MOTOR_MODE_3_LINEAR_TRACK, + durationMs, + angleDegX100); +} + +uint8_t Motor_Init(void) +{ + bool driverInitOk; + bool currentSenseInitOk; + + MOTOR_LOG("\r\nCYT4BFBCJE CM0+ DRV8718S-Q1 motor demo start\r\n"); + + safetyInhibitLatched = false; + BoardPins_Init(); + Scb4Spi_Init(); + driverInitOk = Drv8718_Init(); + currentSenseInitOk = BspSeatCurrentAdc_Init(); + MotorControlTimers_Init(); + if((false == driverInitOk) || (false == currentSenseInitOk) || + !Motor_PrimeCurrentSensing()) + { + Motor_EnterDriverFault(); + return MOTOR_API_FAILURE; + } + + MOTOR_LOG("MOTOR_TIMING pwm_hz=%lu hall_sample_us=%lu safety_check_ms=%lu " + "window_ms=%lu startup_grace_ms=%lu/%lu/%lu/%lu " + "endpoint_edge_threshold=%lu stall_edge_threshold=%lu\r\n", + (unsigned long)MOTOR_PWM_FREQUENCY_HZ, + (unsigned long)HALL_SAMPLE_INTERVAL_US, + (unsigned long)MOTOR_SAFETY_CHECK_INTERVAL_MS, + (unsigned long)MOTOR_SAFETY_WINDOW_MS, + (unsigned long)MOTOR_MODE0_STARTUP_GRACE_MS, + (unsigned long)MOTOR_MODE1_STARTUP_GRACE_MS, + (unsigned long)MOTOR_MODE2_STARTUP_GRACE_MS, + (unsigned long)MOTOR_MODE3_STARTUP_GRACE_MS, + (unsigned long)MOTOR_ENDPOINT_EDGE_THRESHOLD, + (unsigned long)MOTOR_STALL_EDGE_THRESHOLD); + + MOTOR_LOG("MOTOR_CURRENT sample_ms=%lu max_age_ms=%lu " + "confirm_ms=%lu no_motion_ms=%lu thresholds=%u/%u/%u/%u\r\n", + (unsigned long)SEAT_MOVE_MOTOR_CURRENT_SAMPLE_MS, + (unsigned long)SEAT_MOVE_MOTOR_CURRENT_MAX_AGE_MS, + (unsigned long)SEAT_MOVE_MOTOR_STALL_CURRENT_CONFIRM_MS, + (unsigned long)SEAT_MOVE_MOTOR_NO_MOTION_TIMEOUT_MS, + (unsigned int)motorProfiles[0].stallCurrentDeltaRaw, + (unsigned int)motorProfiles[1].stallCurrentDeltaRaw, + (unsigned int)motorProfiles[2].stallCurrentDeltaRaw, + (unsigned int)motorProfiles[3].stallCurrentDeltaRaw); + + MOTOR_LOG("MOTOR_READY position_is_invalid_until_endpoint_calibration\r\n"); + + return MOTOR_API_SUCCESS; +} + +/* [] END OF FILE */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Driver/motor_control.h b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Driver/motor_control.h new file mode 100644 index 0000000..ffe68d4 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Driver/motor_control.h @@ -0,0 +1,277 @@ +#ifndef MOTOR_CONTROL_H +#define MOTOR_CONTROL_H + +#include +#include + +#define MOTOR_API_SUCCESS (0u) +#define MOTOR_API_FAILURE (1u) +#define MOTOR_PHYSICAL_VALUE_SCALE (100ul) + +/* + * 2026-07-30 endpoint-to-endpoint range calibration. Bench verification + * established identity mapping: logical M0..M3 use physical mode 0..3. + */ +#define MOTOR_MODE0_DEFAULT_MAX_HALL_COUNT (480ul) /* horizontal */ +#define MOTOR_MODE0_MAX_POSITION_MM_X100 (21500ul) +#define MOTOR_MODE1_DEFAULT_MAX_HALL_COUNT (374ul) /* seat pan */ +#define MOTOR_MODE1_MAX_VERTICAL_MM_X100 (5600ul) +#define MOTOR_MODE1_MAX_HORIZONTAL_MM_X100 (5100ul) +/* Compatibility name used by the generic scalar position API. */ +#define MOTOR_MODE1_MAX_ANGLE_DEG_X100 \ + MOTOR_MODE1_MAX_VERTICAL_MM_X100 +#define MOTOR_MODE2_DEFAULT_MAX_HALL_COUNT (917ul) /* backrest */ +#define MOTOR_MODE2_MAX_ANGLE_DEG_X100 (6800ul) +#define MOTOR_MODE3_DEFAULT_MAX_HALL_COUNT (647ul) /* legrest */ +#define MOTOR_MODE3_MAX_ANGLE_DEG_X100 (7200ul) +/* Compatibility name used by the generic scalar position API. */ +#define MOTOR_MODE3_MAX_POSITION_MM_X100 \ + MOTOR_MODE3_MAX_ANGLE_DEG_X100 + +#define MOTOR_SPEED_VALUE_SCALE (100ul) +#define MOTOR_SPEED_MIN_DUTY_PERCENT (50u) +#define MOTOR_SPEED_MAX_DUTY_PERCENT (95u) +#define MOTOR_SPEED_DUTY_STEP_PERCENT (5u) +#define MOTOR_MODE0_MAX_SPEED_PPS_X100 (8600ul) +#define MOTOR_MODE1_MAX_SPEED_PPS_X100 (6100ul) +#define MOTOR_MODE2_MAX_SPEED_PPS_X100 (9000ul) +#define MOTOR_MODE3_MAX_SPEED_PPS_X100 (8600ul) + +typedef enum +{ + MOTOR_MODE_0_LINEAR = 0, + MOTOR_MODE_1_ANGLE, + MOTOR_MODE_2_ANGLE, + MOTOR_MODE_3_LINEAR_TRACK, + MOTOR_MODE_COUNT +} motor_mode_t; + +typedef enum +{ + MOTOR_DIRECTION_STOPPED = 0, + MOTOR_DIRECTION_TOWARD_FAR, + MOTOR_DIRECTION_TOWARD_NEAR +} motor_direction_t; + +typedef enum +{ + MOTOR_EVENT_NONE = 0, + MOTOR_EVENT_TARGET_REACHED, + MOTOR_EVENT_NEAR_ENDPOINT, + MOTOR_EVENT_FAR_ENDPOINT, + MOTOR_EVENT_STALL, + MOTOR_EVENT_TIMEOUT, + MOTOR_EVENT_ABORTED, + MOTOR_EVENT_DRIVER_FAULT, + MOTOR_EVENT_CALIBRATION_FAILED +} motor_event_t; + +typedef enum +{ + MOTOR_CALIBRATE_FIND_NEAR = 0, + MOTOR_CALIBRATE_FIND_FAR, + MOTOR_CALIBRATE_FIND_BOTH +} motor_calibration_action_t; + +typedef struct +{ + motor_mode_t mode; + bool positionValid; + motor_direction_t direction; + motor_event_t lastEvent; + uint32_t hallCount; + uint32_t maxHallCount; + uint32_t valueX100; + uint32_t maxValueX100; + uint32_t positionMmX100; + uint32_t horizontalMmX100; + uint32_t verticalMmX100; + uint32_t angleDegX100; + uint32_t percentX100; +} motor_position_info_t; + +typedef struct +{ + bool reverseAtEndpoint; + bool reverseAtStall; +} motor_safety_options_t; + +typedef struct +{ + bool nearEndpointFound; + bool farEndpointFound; + uint32_t measuredMaxHallCount; +} motor_calibration_result_t; + +typedef struct +{ + motor_mode_t mode; + bool enabled; + uint32_t durationMs; + uint32_t travelValueX100; + uint32_t plannedHallPulses; + uint32_t targetPulsesPerSecondX100; + uint32_t actualPulsesPerSecondX100; + uint8_t initialDutyPercent; + uint8_t currentDutyPercent; +} motor_speed_info_t; + +typedef struct +{ + motor_mode_t mode; + bool sampleValid; + bool baselineValid; + bool highCurrent; + bool hallActivityLow; + bool calibrating; + uint16_t raw; + uint16_t baselineRaw; + uint16_t deltaRaw; + uint16_t stallThresholdRaw; + uint32_t sampleAgeMs; +} motor_current_info_t; + +typedef struct +{ + uint32_t positionHallCount; + uint32_t maxHallCount; + uint32_t maxSpeedPulsesPerSecondX100; + uint32_t rawRisingEdges; + bool positionValid; +} motor_persistent_state_t; + +/* Hardware initialization. SystemInit() and global IRQ enable remain owned by main. */ +uint8_t Motor_Init(void); +/* Immediate GPIO-only safe state, also valid before Motor_Init(). */ +void Motor_ForceSafeOff(void); + +/* The Hall timebase is a precise 2 ms hardware interrupt. */ +uint32_t Motor_GetTimeMs(void); +void Motor_SetWatchdogServiceEnabled(bool enabled); + +/* + * Physical values use fixed point x100: 215.00 mm is 21500 and + * 68.00 degrees is 6800. positionValid is restored from a verified clean + * record or becomes true after a known endpoint has been found. + * + * Ownership contract: call all control APIs and Motor_ServiceAll from one + * main-loop/owner context. They are not ISR-safe, thread-safe, or reentrant. + * The four motors may still run concurrently after sequential non-blocking + * start calls from that owner. + */ +uint8_t Motor_GetPositionInfo(motor_mode_t mode, motor_position_info_t *info); +uint8_t Motor_GetCurrentInfo(motor_mode_t mode, motor_current_info_t *info); + +/* Each mode has independent endpoint and stall reverse switches. */ +uint8_t Motor_SetSafetyOptions(motor_mode_t mode, + const motor_safety_options_t *options); + +/* Open-loop primitives used by the standardized application controller. */ +uint8_t Motor_SetFixedDutyPercent(motor_mode_t mode, uint8_t dutyPercent); +uint8_t Motor_StartOpenLoop(motor_mode_t mode, motor_direction_t direction); +bool Motor_IsMoving(motor_mode_t mode); +/* Complete a commanded stop plus bounded Hall-coast tracking before a + * safety decision that requires the mechanism to be physically settled. */ +bool Motor_WaitUntilStopped(motor_mode_t mode); + +/* + * Restore/store Hall-domain state. These calls are accepted only while the + * selected motor is stopped. max speed uses Hall rising edges/s x100. + */ +uint8_t Motor_ValidatePersistentState(motor_mode_t mode, + const motor_persistent_state_t *state); +uint8_t Motor_ApplyPersistentState(motor_mode_t mode, + const motor_persistent_state_t *state); +uint8_t Motor_GetPersistentState(motor_mode_t mode, + motor_persistent_state_t *state); +/* Bench HIL support: invalidate only the stopped axis' absolute-position + * confidence while preserving its measured Hall count and confirmed range. + * Production command handling never calls this API. */ +uint8_t Motor_InvalidatePosition(motor_mode_t mode); +uint8_t Motor_SetMaximumSpeedPpsX100(motor_mode_t mode, + uint32_t maxSpeedPpsX100); + +/* + * Pair an owner-context command with cancellation published asynchronously. + * An acknowledge succeeds when no cancellation is pending, or when sequence + * exactly matches it. It fails if a newer cancellation has already arrived. + */ +uint8_t Motor_AcknowledgeCommand(motor_mode_t mode, uint16_t sequence); +bool Motor_IsCommandCurrent(motor_mode_t mode, uint16_t sequence); + +/* + * Start is non-blocking and permits all four modes to move concurrently. + * Wait and MoveToValue return 0 only after the target is reached; endpoint, + * stall, invalid position, invalid target, and timeout return 1. + * Replacing a target still ahead in the same direction does not restart PWM + * or refresh safety/timeout clocks. A direction change waits at least 100 ms + * and until Hall has been quiet for 100 ms. Crossing a target stops PWM once; + * residual Hall pulses remain in the global position and may leave the final + * count beyond the target. No automatic reverse correction is performed. + * If a safety reverse is enabled, Wait remains blocked until that reverse has + * stopped at its next safety event or at the absolute operation timeout. + */ +uint8_t Motor_StartMoveToValue(motor_mode_t mode, uint32_t targetValueX100); +uint8_t Motor_WaitForMove(motor_mode_t mode); +uint8_t Motor_MoveToValue(motor_mode_t mode, uint32_t targetValueX100); +uint8_t Motor_StopMode(motor_mode_t mode); + +/* + * Configure closed-loop speed from a planned travel amount and duration. + * durationMs is milliseconds. travelValueX100 is mm x100 for mode 0 and + * degrees x100 for modes 1/2, and mm x100 for mode 3. This configures speed + * only; use an existing move API to start motion. After each real PWM start + * the controller waits + * 500 ms, then evaluates a 500 ms rolling Hall window every 100 ms. A speed + * error outside +/-10 percent changes that motor's duty by one 5 percent step. + */ +uint8_t Motor_SetSpeedPlan(motor_mode_t mode, + uint32_t durationMs, + uint32_t travelValueX100); +uint8_t Motor_DisableSpeedControl(motor_mode_t mode); +uint8_t Motor_GetSpeedInfo(motor_mode_t mode, motor_speed_info_t *info); + +/* + * Calibration is blocking for the selected mode; other active modes continue. + * A new range is committed only after validation. Failure restores the old + * range, preserves observed motion, and invalidates absolute position because + * physical travel cannot be rolled back. Each leg requires Hall coast + * tracking to settle before/after travel; quiet wait is bounded to five seconds. + * legTimeoutMs is the independent motion timeout applied to each endpoint leg. + */ +uint8_t Motor_Calibrate(motor_mode_t mode, + motor_calibration_action_t action, + uint32_t legTimeoutMs, + motor_calibration_result_t *result); + +/* Application override used to keep CAN/status/safety services alive while + * the selected motor executes the blocking endpoint-calibration primitive. */ +void Motor_CalibrationServiceHook(void); + +/* Call continuously from the main loop; the ISR still kills PWM immediately. */ +void Motor_ServiceAll(void); + +/* ISR-safe immediate PWM kill; bridge Hi-Z is completed by Motor_ServiceAll. */ +void Motor_RequestAbortMaskFromIsr(uint8_t motorMask, uint16_t sequence); +void Motor_RequestAbortAllFromIsr(uint16_t sequence); +/* Irreversible until reset; unlike STOP, no sequence acknowledge can clear it. */ +void Motor_LatchSafetyInhibitFromIsr(uint16_t sequence); +void Motor_StopAll(void); + +/* A latched driver fault requires a power cycle before motion is accepted. */ +bool Motor_IsDriverFaultLatched(void); + +uint8_t Motor0_GetPosition(motor_position_info_t *info); +uint8_t Motor1_GetPosition(motor_position_info_t *info); +uint8_t Motor2_GetAngle(motor_position_info_t *info); +uint8_t Motor3_GetAngle(motor_position_info_t *info); +uint8_t Motor0_MoveToPositionMmX100(uint32_t positionMmX100); +uint8_t Motor1_MoveToVerticalMmX100(uint32_t verticalMmX100); +uint8_t Motor2_MoveToAngleDegX100(uint32_t angleDegX100); +uint8_t Motor3_MoveToAngleDegX100(uint32_t angleDegX100); +uint8_t Motor0_SetSpeedPlan(uint32_t durationMs, uint32_t distanceMmX100); +uint8_t Motor1_SetSpeedPlan(uint32_t durationMs, uint32_t verticalMmX100); +uint8_t Motor2_SetSpeedPlan(uint32_t durationMs, uint32_t angleDegX100); +uint8_t Motor3_SetSpeedPlan(uint32_t durationMs, uint32_t angleDegX100); + +#endif /* MOTOR_CONTROL_H */ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.ewd b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.ewd new file mode 100644 index 0000000..557cdc1 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.ewd @@ -0,0 +1,1619 @@ + + + 4 + + rev_d + + ARM + + 1 + + C-SPY + 2 + + 33 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ARMSIM_ID + 2 + + 1 + 1 + 1 + + + + + + + + CADI_ID + 2 + + 0 + 1 + 1 + + + + + + + + + CMSISDAP_ID + 2 + + 4 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + E2_ID + 2 + + 0 + 1 + 1 + + + + + + + + + + GDBSERVER_ID + 2 + + 0 + 1 + 1 + + + + + + + + + + + IJET_ID + 2 + + 9 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JLINK_ID + 2 + + 16 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LMIFTDI_ID + 2 + + 3 + 1 + 1 + + + + + + + + + + + + + NULINK_ID + 2 + + 0 + 1 + 1 + + + + + + + PEMICRO_ID + 2 + + 3 + 1 + 1 + + + + + + + + STLINK_ID + 2 + + 8 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + THIRDPARTY_ID + 2 + + 0 + 1 + 1 + + + + + + + + TIFET_ID + 2 + + 1 + 1 + 1 + + + + + + + + + + + + + + + + + + + XDS100_ID + 2 + + 9 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $TOOLKIT_DIR$\plugins\rtos\CMX\CmxArmPlugin.ENU.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\CMX\CmxTinyArmPlugin.ENU.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\embOS\embOSPlugin.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\FreeRtos\FreeRtosArmPlugin.ENU.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\Mbed\MbedArmPlugin.ENU.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\Mbed\MbedArmPlugin2.ENU.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\OpenRTOS\OpenRTOSPlugin.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\SafeRTOS\SafeRTOSPlugin.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\SMX\smxAwareIarArm9.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\SMX\smxAwareIarArm9BE.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\ThreadX\ThreadXArmPlugin.ENU.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\uCOS-II\uCOS-II-286-KA-CSpy.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\uCOS-II\uCOS-II-KA-CSpy.ewplugin + 0 + + + $TOOLKIT_DIR$\plugins\rtos\uCOS-III\uCOS-III-KA-CSpy.ewplugin + 0 + + + $EW_DIR$\common\plugins\Orti\Orti.ENU.ewplugin + 0 + + + $EW_DIR$\common\plugins\TargetAccessServer\TargetAccessServer.ENU.ewplugin + 0 + + + $EW_DIR$\common\plugins\uCProbe\uCProbePlugin.ENU.ewplugin + 0 + + + + diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.ewp b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.ewp new file mode 100644 index 0000000..88a9886 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.ewp @@ -0,0 +1,2359 @@ + + + 4 + + rev_d + + ARM + + 1 + + General + 3 + + 36 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ICCARM + 2 + + 38 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AARM + 2 + + 12 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OBJCOPY + 0 + + 1 + 1 + 1 + + + + + + + + + CUSTOM + 3 + + + + 0 + inputOutputBased + + + + ILINK + 0 + + 27 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IARCHIVE + 0 + + 0 + 1 + 1 + + + + + + + BUILDACTION + 2 + + + + + hdr + + cmsis + + include + + $PROJ_DIR$\..\..\common\hdr\cmsis\include\cmsis_compiler.h + + + $PROJ_DIR$\..\..\common\hdr\cmsis\include\core_cm0plus.h + + + + + rev_b + + rev_c + rev_d + rtos + + + ip + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_backup_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_canfd.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_cpuss.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_crypto.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_dmac.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_dw.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_efuse_data_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_efuse_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_eth.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_evtgen.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_fault.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_flashc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_flexray.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_gpio_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_headers.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_hsiom_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_i2s_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_ipc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_lin.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_pass.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_peri_ms_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_peri_pclk_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_peri_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_prot.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_scb_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_sdhc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_sflash_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_smartio_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_smif_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_srss_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\ip\cyip_tcpwm_v2.h + + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\bb_bsp_tviibh8m.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\cy_device_headers.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_b\cyt4bf_config.h + + + + rev_c + + rev_b + rev_d + rtos + + + ip + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_backup_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_canfd.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_cpuss.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_crypto.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_dmac.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_dw.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_efuse_data_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_efuse_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_eth.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_evtgen.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_fault.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_flashc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_flexray.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_gpio_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_headers.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_hsiom_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_i2s_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_ipc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_lin.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_pass.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_peri_ms_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_peri_pclk_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_peri_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_prot.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_scb_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_sdhc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_sflash_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_smartio_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_smif_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_srss_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\ip\cyip_tcpwm_v2.h + + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\bb_bsp_tviibh8m.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\cy_device_headers.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_c\cyt4bf_config.h + + + + rev_d + + rev_b + rev_c + + + ip + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_backup_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_canfd.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_coredefines.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_cpuss.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_crypto.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_dmac.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_dw.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_efuse_data_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_efuse_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_eth.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_evtgen.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_fault.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_flashc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_flexray.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_gpio_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_headers.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_hsiom_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_i2s_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_ipc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_lin.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_pass.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_peri_ms_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_peri_pclk_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_peri_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_prot.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_scb_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_sdhc.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_sflash_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_smartio_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_smif_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_srss_v3.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\ip\cyip_tcpwm_v2.h + + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\bb_bsp_tviibh8m.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\cy_device_headers.h + + + $PROJ_DIR$\..\..\tviibh8m\hdr\rev_d\cyt4bf_config.h + + + + rtos + + rev_b + rev_c + rev_d + + + $PROJ_DIR$\..\..\common\src\rtos\include\atomic.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\croutine.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\deprecated_definitions.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\event_groups.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\FreeRTOS.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\FreeRTOSConfig.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\list.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\message_buffer.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\mpu_prototypes.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\mpu_wrappers.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\portable.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\projdefs.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\queue.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\semphr.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\stack_macros.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\StackMacros.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\stream_buffer.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\task.h + + + $PROJ_DIR$\..\..\common\src\rtos\include\timers.h + + + + $PROJ_DIR$\..\..\common\hdr\cy_project.h + + + + src + + drivers + + adc + + $PROJ_DIR$\..\..\common\src\drivers\adc\cy_adc.c + + + $PROJ_DIR$\..\..\common\src\drivers\adc\cy_adc.h + + + + audioss + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\audioss\cy_i2s.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\audioss\cy_i2s.h + + + + canfd + + $PROJ_DIR$\..\..\common\src\drivers\canfd\cy_canfd.c + + + $PROJ_DIR$\..\..\common\src\drivers\canfd\cy_canfd.h + + + + cpu + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\cpu\cy_cpu.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\cpu\cy_cpu.h + + + + crypto + + $PROJ_DIR$\..\..\common\src\drivers\crypto\cy_crypto.h + + + $PROJ_DIR$\..\..\common\src\drivers\crypto\cy_crypto_common.h + + + $PROJ_DIR$\..\..\common\src\drivers\crypto\cy_crypto_config.c + + + $PROJ_DIR$\..\..\common\src\drivers\crypto\cy_crypto_config.h + + + $PROJ_DIR$\..\..\common\src\drivers\crypto\cy_crypto_server.h + + + + dma + + $PROJ_DIR$\..\..\common\src\drivers\dma\cy_mdma.c + + + $PROJ_DIR$\..\..\common\src\drivers\dma\cy_mdma.h + + + $PROJ_DIR$\..\..\common\src\drivers\dma\cy_pdma.c + + + $PROJ_DIR$\..\..\common\src\drivers\dma\cy_pdma.h + + + + ethernet + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\cdn_errno.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\cdn_stdint.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\cedi.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\cps_v2.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\cy_ethif.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\cy_ethif.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\edd.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\edd_int.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\edd_rx.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\edd_tx.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\emac_regs.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\emac_regs_macro.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\ethernet\log.h + + + + evtgen + + $PROJ_DIR$\..\..\common\src\drivers\evtgen\cy_evtgen.c + + + $PROJ_DIR$\..\..\common\src\drivers\evtgen\cy_evtgen.h + + + + flash + + $PROJ_DIR$\..\..\common\src\drivers\flash\cy_flash.c + + + $PROJ_DIR$\..\..\common\src\drivers\flash\cy_flash.h + + + + flexray + + api + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_control_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_init.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_interrupt_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_rx_handler.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_status_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_time_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_timer_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\api\cy_fr_api_tx_handler.c + + + + ccal + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_control_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_init.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_interrupt_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_rx_handler.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_status_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_time_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_timer_service.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\flexray\ccal\cy_fr_ccal_tx_handler.c + + + + + gpio + + $PROJ_DIR$\..\..\common\src\drivers\gpio\cy_gpio.c + + + $PROJ_DIR$\..\..\common\src\drivers\gpio\cy_gpio.h + + + + ipc + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_config.h + + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_drv.c + + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_drv.h + + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_pipe.c + + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_pipe.h + + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_sema.c + + + $PROJ_DIR$\..\..\common\src\drivers\ipc\cy_ipc_sema.h + + + + lin + + $PROJ_DIR$\..\..\common\src\drivers\lin\cy_lin.c + + + $PROJ_DIR$\..\..\common\src\drivers\lin\cy_lin.h + + + + lvd + + $PROJ_DIR$\..\..\common\src\drivers\lvd\cy_lvd.c + + + $PROJ_DIR$\..\..\common\src\drivers\lvd\cy_lvd.h + + + + mcwdt + + $PROJ_DIR$\..\..\common\src\drivers\mcwdt\cy_mcwdt.c + + + $PROJ_DIR$\..\..\common\src\drivers\mcwdt\cy_mcwdt.h + + + + mpu + + $PROJ_DIR$\..\..\common\src\drivers\mpu\cy_mpu.c + + + $PROJ_DIR$\..\..\common\src\drivers\mpu\cy_mpu.h + + + + prot + + $PROJ_DIR$\..\..\common\src\drivers\prot\cy_prot.c + + + $PROJ_DIR$\..\..\common\src\drivers\prot\cy_prot.h + + + + scb + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_common.c + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_common.h + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_ezi2c.c + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_ezi2c.h + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_i2c.c + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_i2c.h + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_spi.c + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_spi.h + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_uart.c + + + $PROJ_DIR$\..\..\common\src\drivers\scb\cy_scb_uart.h + + + + sd_host + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sd_host\cy_sd_host.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sd_host\cy_sd_host.h + + + + smartio + + $PROJ_DIR$\..\..\common\src\drivers\smartio\cy_smartio.c + + + $PROJ_DIR$\..\..\common\src\drivers\smartio\cy_smartio.h + + + + smif + + $PROJ_DIR$\..\..\common\src\drivers\smif\common\cy_smif.c + + + $PROJ_DIR$\..\..\common\src\drivers\smif\common\cy_smif.h + + + $PROJ_DIR$\..\..\common\src\drivers\smif\common\cy_smif_memslot.c + + + $PROJ_DIR$\..\..\common\src\drivers\smif\common\cy_smif_memslot.h + + + $PROJ_DIR$\..\..\common\src\drivers\smif\ver2\cy_smif_ver_specific.c + + + $PROJ_DIR$\..\..\common\src\drivers\smif\ver2\cy_smif_ver_specific.h + + + + srom + + $PROJ_DIR$\..\..\common\src\drivers\srom\cy_srom.c + + + $PROJ_DIR$\..\..\common\src\drivers\srom\cy_srom.h + + + + sysclk + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sysclk\cy_sysclk.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sysclk\cy_sysclk.h + + + + sysflt + + $PROJ_DIR$\..\..\common\src\drivers\sysflt\cy_sysflt.c + + + $PROJ_DIR$\..\..\common\src\drivers\sysflt\cy_sysflt.h + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sysflt\cy_sysflt_config.h + + + + sysint + + $PROJ_DIR$\..\..\common\src\drivers\sysint\cy_sysint.c + + + $PROJ_DIR$\..\..\common\src\drivers\sysint\cy_sysint.h + + + + syslib + + $PROJ_DIR$\..\..\common\src\drivers\syslib\cy_syslib.c + + + $PROJ_DIR$\..\..\common\src\drivers\syslib\cy_syslib.h + + + $PROJ_DIR$\..\..\common\src\drivers\syslib\iar\cy_syslib_iar.s + + + + syspm + + $PROJ_DIR$\..\..\common\src\drivers\syspm\cy_syspm.c + + + $PROJ_DIR$\..\..\common\src\drivers\syspm\cy_syspm.h + + + + sysreghc + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sysreghc\cy_sysreghc.c + + + $PROJ_DIR$\..\..\tviibh8m\src\drivers\sysreghc\cy_sysreghc.h + + + + sysreset + + $PROJ_DIR$\..\..\common\src\drivers\sysreset\cy_sysreset.c + + + $PROJ_DIR$\..\..\common\src\drivers\sysreset\cy_sysreset.h + + + + sysrtc + + $PROJ_DIR$\..\..\common\src\drivers\sysrtc\cy_sysrtc.c + + + $PROJ_DIR$\..\..\common\src\drivers\sysrtc\cy_sysrtc.h + + + + systick + + $PROJ_DIR$\..\..\common\src\drivers\systick\cy_systick.c + + + $PROJ_DIR$\..\..\common\src\drivers\systick\cy_systick.h + + + + syswdt + + $PROJ_DIR$\..\..\common\src\drivers\syswdt\cy_syswdt.c + + + $PROJ_DIR$\..\..\common\src\drivers\syswdt\cy_syswdt.h + + + + tcpwm + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm.c + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm.h + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_counter.c + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_counter.h + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_pwm.c + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_pwm.h + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_quaddec.c + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_quaddec.h + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_sr.c + + + $PROJ_DIR$\..\..\common\src\drivers\tcpwm\cy_tcpwm_sr.h + + + + trigmux + + $PROJ_DIR$\..\..\common\src\drivers\trigmux\cy_trigmux.c + + + $PROJ_DIR$\..\..\common\src\drivers\trigmux\cy_trigmux.h + + + + + interrupts + + rev_b + + rev_c + rev_d + rtos + + + $PROJ_DIR$\..\..\tviibh8m\src\interrupts\rev_b\cy_interrupt_map.c + + + $PROJ_DIR$\..\..\tviibh8m\src\interrupts\rev_b\cy_interrupt_map_cm0plus.h + + + + rev_c + + rev_b + rev_d + rtos + + + $PROJ_DIR$\..\..\tviibh8m\src\interrupts\rev_c\cy_interrupt_map.c + + + $PROJ_DIR$\..\..\tviibh8m\src\interrupts\rev_c\cy_interrupt_map_cm0plus.h + + + + rev_d + + rev_b + rev_c + + + $PROJ_DIR$\..\..\tviibh8m\src\interrupts\rev_d\cy_interrupt_map.c + + + $PROJ_DIR$\..\..\tviibh8m\src\interrupts\rev_d\cy_interrupt_map_cm0plus.h + + + + + mw + + aic261 + + $PROJ_DIR$\..\..\common\src\mw\aic261\cy_aic261.c + + + $PROJ_DIR$\..\..\common\src\mw\aic261\cy_aic261.h + + + + button + + $PROJ_DIR$\..\..\common\src\mw\button\cy_button.c + + + $PROJ_DIR$\..\..\common\src\mw\button\cy_button.h + + + + eth_phy + + $PROJ_DIR$\..\..\tviibh8m\src\mw\eth_phy\cy_dp83867.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\eth_phy\cy_dp83867.h + + + + flash + + $PROJ_DIR$\..\..\common\src\mw\flash\cy_mw_flash.c + + + $PROJ_DIR$\..\..\common\src\mw\flash\cy_mw_flash.h + + + + power + + $PROJ_DIR$\..\..\tviibh8m\src\mw\power\cy_power.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\power\cy_power.h + + + + semihosting + + $PROJ_DIR$\..\..\common\src\mw\semihosting\cy_semihosting.c + + + $PROJ_DIR$\..\..\common\src\mw\semihosting\cy_semihosting.h + + + + smif_mem + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_device_common.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_device_common.h + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_hb_flash.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_hb_flash.h + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_s25fl.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_s25fl.h + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_s25fs.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_s25fs.h + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_semp.c + + + $PROJ_DIR$\..\..\tviibh8m\src\mw\smif_mem\cy_smif_semp.h + + + + sw_timer + + $PROJ_DIR$\..\..\common\src\mw\sw_timer\cy_sw_tmr.c + + + $PROJ_DIR$\..\..\common\src\mw\sw_timer\cy_sw_tmr.h + + + + + rtos + + rev_b + rev_c + rev_d + + + mem_mgmt + + $PROJ_DIR$\..\..\common\src\rtos\portable\MemMang\heap_4.c + + + + portable + + $PROJ_DIR$\..\..\common\src\rtos\portable\IAR\ARM_CM7\r0p1\port.c + + + $PROJ_DIR$\..\..\common\src\rtos\portable\IAR\ARM_CM7\r0p1\portasm.s + + + $PROJ_DIR$\..\..\common\src\rtos\portable\IAR\ARM_CM7\r0p1\portmacro.h + + + + $PROJ_DIR$\..\..\common\src\rtos\croutine.c + + + $PROJ_DIR$\..\..\common\src\rtos\event_groups.c + + + $PROJ_DIR$\..\..\common\src\rtos\list.c + + + $PROJ_DIR$\..\..\common\src\rtos\queue.c + + + $PROJ_DIR$\..\..\common\src\rtos\stream_buffer.c + + + $PROJ_DIR$\..\..\common\src\rtos\tasks.c + + + $PROJ_DIR$\..\..\common\src\rtos\timers.c + + + + startup + + $PROJ_DIR$\..\..\common\src\startup\startup.c + + + $PROJ_DIR$\..\..\common\src\startup\iar\startup_cm0plus.s + + + $PROJ_DIR$\..\..\common\src\startup\startup_customize.h + + + + system + + rev_b + + rev_c + rev_d + rtos + + + $PROJ_DIR$\..\..\tviibh8m\src\system\rev_b\system_cyt4bf.h + + + $PROJ_DIR$\..\..\tviibh8m\src\system\rev_b\system_tviibh8m_cm0plus.c + + + + rev_c + + rev_b + rev_d + rtos + + + $PROJ_DIR$\..\..\tviibh8m\src\system\rev_c\system_cyt4bf.h + + + $PROJ_DIR$\..\..\tviibh8m\src\system\rev_c\system_tviibh8m_cm0plus.c + + + + rev_d + + rev_b + rev_c + + + $PROJ_DIR$\..\..\tviibh8m\src\system\rev_d\system_cyt4bf.h + + + $PROJ_DIR$\..\..\tviibh8m\src\system\rev_d\system_tviibh8m_cm0plus.c + + + + + $PROJ_DIR$\..\App\main_cm0plus.c + + + + Passenger_Seat_Timed_Controller_demo + + App + + $PROJ_DIR$\..\App\seat_timed_application.c + + + $PROJ_DIR$\..\App\seat_timed_application.h + + + $PROJ_DIR$\..\App\seat_motor_manager.c + + + $PROJ_DIR$\..\App\seat_motor_manager.h + + + $PROJ_DIR$\..\App\safety_manager.c + + + $PROJ_DIR$\..\App\safety_manager.h + + + + Bsp + + $PROJ_DIR$\..\Bsp\bsp_seat_move_can.c + + + $PROJ_DIR$\..\Bsp\bsp_seat_move_can.h + + + $PROJ_DIR$\..\Bsp\bsp_seat_backrest_heater.c + + + $PROJ_DIR$\..\Bsp\bsp_seat_backrest_heater.h + + + $PROJ_DIR$\..\Bsp\bsp_seat_current_adc.c + + + $PROJ_DIR$\..\Bsp\bsp_seat_current_adc.h + + + $PROJ_DIR$\..\Bsp\bsp_seat_fan.c + + + $PROJ_DIR$\..\Bsp\bsp_seat_fan.h + + + + Driver + + $PROJ_DIR$\..\Driver\motor_control.c + + + $PROJ_DIR$\..\Driver\motor_control.h + + + + Config + + $PROJ_DIR$\..\Config\seat_move_config.h + + + $PROJ_DIR$\..\Config\seat_backrest_heater_config.h + + + $PROJ_DIR$\..\Config\seat_fan_config.h + + + $PROJ_DIR$\..\Config\seat_current_adc_config.h + + + + Docs + + $PROJ_DIR$\..\Docs\README_Passenger_Seat_Timed_Controller.md + + + $PROJ_DIR$\..\Docs\BUILD_INFO_2026-09-16.md + + + + IAR + + $PROJ_DIR$\Passenger_Seat_Timed_Controller_demo.mac + + + + diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.mac b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.mac new file mode 100644 index 0000000..8be97b5 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_demo.mac @@ -0,0 +1,185 @@ +/* C-SPY host-side safety hook for the timed-only passenger-seat controller. */ + +execUserSetup() +{ + /* A new Download and Debug session releases a previous debug-exit latch. */ + __writeMemory32(0x00000000, 0x4027100C, "Memory"); + __message "Passenger_Seat_Timed_Controller_demo: C-SPY setup cleared the debug-session latch.\n"; +} + +execUserExit() +{ + __var value; + __var safe; + + /* Retain safe-off across debug/XRES/WDT resets. */ + __writeMemory32(0x4D4F5645, 0x4027100C, "Memory"); + + /* Assert DRVOFF=P8.1 before touching PWM or pin muxing. */ + __writeMemory32(0x00000002, 0x40310408, "Memory"); + + /* Disable TCPWM1 group-0 counters IN1/IN2/IN3/IN4 and Hall timebase. */ + value = __readMemory32(0x40580600, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580600, "Memory"); + value = __readMemory32(0x40580680, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580680, "Memory"); + value = __readMemory32(0x40580700, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580700, "Memory"); + value = __readMemory32(0x40580980, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580980, "Memory"); + value = __readMemory32(0x40580A00, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580A00, "Memory"); + + /* Disable fan TCPWM60 and heater TCPWM65 before changing their muxes. */ + value = __readMemory32(0x40581E00, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40581E00, "Memory"); + value = __readMemory32(0x40582080, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40582080, "Memory"); + + /* Force P11.1/P11.2 fan paths and P16.7 heater path to GPIO low. */ + __writeMemory32(0x00000006, 0x40310584, "Memory"); + __writeMemory32(0x00000080, 0x40310804, "Memory"); + value = __readMemory32(0x403000B0, "Memory"); + __writeMemory32(value & 0xFFE0E0FF, 0x403000B0, "Memory"); + value = __readMemory32(0x40300104, "Memory"); + __writeMemory32(value & 0xE0FFFFFF, 0x40300104, "Memory"); + value = __readMemory32(0x403105C4, "Memory"); + __writeMemory32((value & 0xFFFFF00F) | 0x00000660, + 0x403105C4, "Memory"); + value = __readMemory32(0x40310844, "Memory"); + __writeMemory32((value & 0x0FFFFFFF) | 0x60000000, + 0x40310844, "Memory"); + __writeMemory32(0x00000006, 0x40310584, "Memory"); + __writeMemory32(0x00000080, 0x40310804, "Memory"); + + /* Preload IN1=P1.0, IN2/IN3=P5.4/P5.5 and IN4=P8.0 low. */ + __writeMemory32(0x00000001, 0x40310084, "Memory"); + __writeMemory32(0x00000030, 0x40310284, "Memory"); + __writeMemory32(0x00000001, 0x40310404, "Memory"); + + /* Select GPIO for every DRV8718 PWM input. */ + value = __readMemory32(0x40300010, "Memory"); + __writeMemory32(value & 0xFFFFFFE0, 0x40300010, "Memory"); + value = __readMemory32(0x40300054, "Memory"); + __writeMemory32(value & 0xFFFFE0E0, 0x40300054, "Memory"); + value = __readMemory32(0x40300080, "Memory"); + __writeMemory32(value & 0xFFFFE0E0, 0x40300080, "Memory"); + + /* Strong output, input buffer off (CY_GPIO_DM_STRONG_IN_OFF = 0x6). */ + value = __readMemory32(0x403100C4, "Memory"); + __writeMemory32((value & 0xFFFFFFF0) | 0x00000006, + 0x403100C4, "Memory"); + value = __readMemory32(0x403102C4, "Memory"); + __writeMemory32((value & 0xFF00FFFF) | 0x00660000, + 0x403102C4, "Memory"); + value = __readMemory32(0x40310444, "Memory"); + __writeMemory32((value & 0xFFFFFF00) | 0x00000066, + 0x40310444, "Memory"); + + /* Full drive strength for the same output pins. */ + value = __readMemory32(0x403100CC, "Memory"); + __writeMemory32(value & 0xFFFCFFFF, 0x403100CC, "Memory"); + value = __readMemory32(0x403102CC, "Memory"); + __writeMemory32(value & 0xF0FFFFFF, 0x403102CC, "Memory"); + value = __readMemory32(0x4031044C, "Memory"); + __writeMemory32(value & 0xFFF0FFFF, 0x4031044C, "Memory"); + + /* Reassert the final physical levels after mux/drive configuration. */ + __writeMemory32(0x00000001, 0x40310084, "Memory"); + __writeMemory32(0x00000030, 0x40310284, "Memory"); + __writeMemory32(0x00000001, 0x40310404, "Memory"); + __writeMemory32(0x00000002, 0x40310408, "Memory"); + + /* Verify the retained latch, every PWM counter, every PWM HSIOM field and + every physical output latch before C-SPY releases the target. */ + safe = 1; + value = __readMemory32(0x4027100C, "Memory"); + if(value != 0x4D4F5645) + { + safe = 0; + } + value = __readMemory32(0x40580600, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580680, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580700, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580980, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580A00, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40581E00, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40582080, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40310580, "Memory"); + if(value & 0x00000006) + { + safe = 0; + } + value = __readMemory32(0x40310800, "Memory"); + if(value & 0x00000080) + { + safe = 0; + } + value = __readMemory32(0x40300010, "Memory"); + if(value & 0x0000001F) + { + safe = 0; + } + value = __readMemory32(0x40300054, "Memory"); + if(value & 0x00001F1F) + { + safe = 0; + } + value = __readMemory32(0x40300080, "Memory"); + if(value & 0x00001F1F) + { + safe = 0; + } + value = __readMemory32(0x40310080, "Memory"); + if(value & 0x00000001) + { + safe = 0; + } + value = __readMemory32(0x40310280, "Memory"); + if(value & 0x00000030) + { + safe = 0; + } + value = __readMemory32(0x40310400, "Memory"); + if((value & 0x00000003) != 0x00000002) + { + safe = 0; + } + + if(safe == 0) + { + __message "Passenger_Seat_Timed_Controller_demo ERROR: C-SPY safe-off register verification failed.\n"; + } + else + { + __message "Passenger_Seat_Timed_Controller_demo: C-SPY exit forced motor, fan and heater outputs low.\n"; + } +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_program.mac b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_program.mac new file mode 100644 index 0000000..97c68a9 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/Passenger_Seat_Timed_Controller_program.mac @@ -0,0 +1,213 @@ +/* C-SPY program-only safety hook used by the authorized backend flasher. + * It forces every output safe but deliberately clears the debug-session + * marker so --leave_target_running can boot normal firmware afterwards. */ + +execUserSetup() +{ + __var value; + + /* Make the pre-download target safe before the flash loader or the new + application can run. The post-session block below repeats and verifies + the same invariants when C-SPY reaches the normal exit hook. */ + __writeMemory32(0x00000002, 0x40310408, "Memory"); + value = __readMemory32(0x40580600, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580600, "Memory"); + value = __readMemory32(0x40580680, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580680, "Memory"); + value = __readMemory32(0x40580700, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580700, "Memory"); + value = __readMemory32(0x40580980, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580980, "Memory"); + value = __readMemory32(0x40580A00, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580A00, "Memory"); + value = __readMemory32(0x40581E00, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40581E00, "Memory"); + value = __readMemory32(0x40582080, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40582080, "Memory"); + __writeMemory32(0x00000001, 0x40310084, "Memory"); + __writeMemory32(0x00000030, 0x40310284, "Memory"); + __writeMemory32(0x00000001, 0x40310404, "Memory"); + __writeMemory32(0x00000006, 0x40310584, "Memory"); + __writeMemory32(0x00000080, 0x40310804, "Memory"); + + /* A new Download and Debug session releases a previous debug-exit latch. */ + __writeMemory32(0x00000000, 0x4027100C, "Memory"); + __message "Passenger_Seat_Timed_Controller_demo: C-SPY setup forced outputs safe and cleared the debug-session latch.\n"; +} + +execUserExit() +{ + __var value; + __var safe; + + /* This is a program-only session, not an interactive debugger detach. */ + __writeMemory32(0x00000000, 0x4027100C, "Memory"); + + /* Assert DRVOFF=P8.1 before touching PWM or pin muxing. */ + __writeMemory32(0x00000002, 0x40310408, "Memory"); + + /* Disable TCPWM1 group-0 counters IN1/IN2/IN3/IN4 and Hall timebase. */ + value = __readMemory32(0x40580600, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580600, "Memory"); + value = __readMemory32(0x40580680, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580680, "Memory"); + value = __readMemory32(0x40580700, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580700, "Memory"); + value = __readMemory32(0x40580980, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580980, "Memory"); + value = __readMemory32(0x40580A00, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40580A00, "Memory"); + + /* Disable fan TCPWM60 and heater TCPWM65 before changing their muxes. */ + value = __readMemory32(0x40581E00, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40581E00, "Memory"); + value = __readMemory32(0x40582080, "Memory"); + __writeMemory32(value & 0x7FFFFFFF, 0x40582080, "Memory"); + + /* Force P11.1/P11.2 fan paths and P16.7 heater path to GPIO low. */ + __writeMemory32(0x00000006, 0x40310584, "Memory"); + __writeMemory32(0x00000080, 0x40310804, "Memory"); + value = __readMemory32(0x403000B0, "Memory"); + __writeMemory32(value & 0xFFE0E0FF, 0x403000B0, "Memory"); + value = __readMemory32(0x40300104, "Memory"); + __writeMemory32(value & 0xE0FFFFFF, 0x40300104, "Memory"); + value = __readMemory32(0x403105C4, "Memory"); + __writeMemory32((value & 0xFFFFF00F) | 0x00000660, + 0x403105C4, "Memory"); + value = __readMemory32(0x40310844, "Memory"); + __writeMemory32((value & 0x0FFFFFFF) | 0x60000000, + 0x40310844, "Memory"); + __writeMemory32(0x00000006, 0x40310584, "Memory"); + __writeMemory32(0x00000080, 0x40310804, "Memory"); + + /* Preload IN1=P1.0, IN2/IN3=P5.4/P5.5 and IN4=P8.0 low. */ + __writeMemory32(0x00000001, 0x40310084, "Memory"); + __writeMemory32(0x00000030, 0x40310284, "Memory"); + __writeMemory32(0x00000001, 0x40310404, "Memory"); + + /* Select GPIO for every DRV8718 PWM input. */ + value = __readMemory32(0x40300010, "Memory"); + __writeMemory32(value & 0xFFFFFFE0, 0x40300010, "Memory"); + value = __readMemory32(0x40300054, "Memory"); + __writeMemory32(value & 0xFFFFE0E0, 0x40300054, "Memory"); + value = __readMemory32(0x40300080, "Memory"); + __writeMemory32(value & 0xFFFFE0E0, 0x40300080, "Memory"); + + /* Strong output, input buffer off (CY_GPIO_DM_STRONG_IN_OFF = 0x6). */ + value = __readMemory32(0x403100C4, "Memory"); + __writeMemory32((value & 0xFFFFFFF0) | 0x00000006, + 0x403100C4, "Memory"); + value = __readMemory32(0x403102C4, "Memory"); + __writeMemory32((value & 0xFF00FFFF) | 0x00660000, + 0x403102C4, "Memory"); + value = __readMemory32(0x40310444, "Memory"); + __writeMemory32((value & 0xFFFFFF00) | 0x00000066, + 0x40310444, "Memory"); + + /* Full drive strength for the same output pins. */ + value = __readMemory32(0x403100CC, "Memory"); + __writeMemory32(value & 0xFFFCFFFF, 0x403100CC, "Memory"); + value = __readMemory32(0x403102CC, "Memory"); + __writeMemory32(value & 0xF0FFFFFF, 0x403102CC, "Memory"); + value = __readMemory32(0x4031044C, "Memory"); + __writeMemory32(value & 0xFFF0FFFF, 0x4031044C, "Memory"); + + /* Reassert the final physical levels after mux/drive configuration. */ + __writeMemory32(0x00000001, 0x40310084, "Memory"); + __writeMemory32(0x00000030, 0x40310284, "Memory"); + __writeMemory32(0x00000001, 0x40310404, "Memory"); + __writeMemory32(0x00000002, 0x40310408, "Memory"); + + /* Verify the retained latch, every PWM counter, every PWM HSIOM field and + every physical output latch before C-SPY releases the target. */ + safe = 1; + value = __readMemory32(0x4027100C, "Memory"); + if(value != 0x00000000) + { + safe = 0; + } + value = __readMemory32(0x40580600, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580680, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580700, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580980, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40580A00, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40581E00, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40582080, "Memory"); + if(value & 0x80000000) + { + safe = 0; + } + value = __readMemory32(0x40310580, "Memory"); + if(value & 0x00000006) + { + safe = 0; + } + value = __readMemory32(0x40310800, "Memory"); + if(value & 0x00000080) + { + safe = 0; + } + value = __readMemory32(0x40300010, "Memory"); + if(value & 0x0000001F) + { + safe = 0; + } + value = __readMemory32(0x40300054, "Memory"); + if(value & 0x00001F1F) + { + safe = 0; + } + value = __readMemory32(0x40300080, "Memory"); + if(value & 0x00001F1F) + { + safe = 0; + } + value = __readMemory32(0x40310080, "Memory"); + if(value & 0x00000001) + { + safe = 0; + } + value = __readMemory32(0x40310280, "Memory"); + if(value & 0x00000030) + { + safe = 0; + } + value = __readMemory32(0x40310400, "Memory"); + if((value & 0x00000003) != 0x00000002) + { + safe = 0; + } + + if(safe == 0) + { + __message "Passenger_Seat_Timed_Controller_demo ERROR: C-SPY safe-off register verification failed.\n"; + } + else + { + __message "Passenger_Seat_Timed_Controller_demo: program-only exit forced all outputs low and released the debug latch.\n"; + } +} diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.hex b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.hex new file mode 100644 index 0000000..e13e7d6 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.hex @@ -0,0 +1,2690 @@ +:020000041000EA +:1000000000000228F59E0010ADA5001025A30010E9 +:1000100000000000000000000000000000000000E0 +:10002000000000000000000000000000E5A3001038 +:100030000000000000000000B1A50010B5A50010F0 +:1000400025990010499900106D99001091990010A0 +:10005000B5990010D9990010FD990010219A00104F +:10006000B9A50010BDA50010C1A50010C5A50010C0 +:10007000C9A50010CDA50010D1A50010D5A5001070 +:1000800000B5010009B2002905D40121C006C00E47 +:100090008140C648016000BD00B5010009B20029D9 +:1000A00005D40121C006C00E8140C148016000BDD9 +:1000B000F0B5020012B2002A18D41822BD4B040079 +:1000C00024B2A408A4001C59FF250600F60016401F +:1000D000B540AC438901C9B20500ED002A4091400A +:1000E000214300B28008800019501CE00F22B24C5E +:1000F000182503001BB21340083B9B089B00060019 +:1001000036B23240083A92089200A258FF26070001 +:10011000FF002F40BE40B2438901C9B2C000054074 +:10012000A9401143E150F0BD0269CA401000C00768 +:10013000C00F704701228A408260704701228A40C6 +:1001400042607047C22189004058C005C00D704709 +:10015000C121890041180A6880235B0213430B60A8 +:10016000C121890041180A68B54B13400B60C121B9 +:10017000890040587047822189004058C005C00D51 +:10018000704782218900415808000004C00F704761 +:1001900038B504002000FFF7EEFF05002000FFF750 +:1001A000F0FF2818002801D1012000E00020C0B293 +:1001B00032BD8121890041180A6880235B02134304 +:1001C0000B608121890041180A689D4B13400B6028 +:1001D00081218900405870470168802212060A4335 +:1001E0000260704780B5FFF7ADFF02BD80B5FFF735 +:1001F000AFFF01BD80B5FFF7CBFF02BD80B5FFF7B4 +:10020000D8FF01BD80B503F090F802BD80B503F0C2 +:100210009EF802BD00B5022801D301200FE00B00BB +:10022000DBB2042B01DB012009E0B34B800018187E +:10023000C9B2405C824201D3012000E0002000BD31 +:10024000F8B504001F00022C01D3012023E01500A3 +:100250000E002A003100C9B22000FFF7DBFF0128A1 +:1002600001D1012017E03000C0B2002816D1FF2FC5 +:1002700012D8CB4861034018A900401880214901D9 +:1002800040180168C74A0A403902FF231B020B408D +:10029000134303600020F2BD0120FCE7F6B2012EFB +:1002A00016D180204002874210D2BD486103401819 +:1002B000A9004018A021490140180168C54A0A4018 +:1002C0003902C54B0B4013430360E3E70120E2E72B +:1002D0000120E0E7F8B504001F00022C01D3012043 +:1002E00036E015000E002A003100C9B22000FFF7E9 +:1002F00091FF012801D101202AE006983100C9B2FE +:10030000022928D1802149028F4222D2202820D2DE +:10031000A34962038A18AB00D218C0235B01D2182C +:100320001668AC4B33403E02AB4F37401F4317605B +:1003300062038918AA008918C022520189180A6824 +:10034000F8239A43C000F823034013430B600020B6 +:10035000F2BD0120FCE7F6B2032E24D1802149042E +:100360008F421ED220281CD28D4962038A18AB000E +:10037000D218E0235B01D2181368DBB23E021E43A1 +:10038000166062038918AA008918E02252018918B0 +:100390000A68F8239A43C000F823034013430B6014 +:1003A000D5E70120D4E70120D2E7000000E100E01A +:1003B00080E200E000E400E01CED00E000B200128A +:1003C000C0B27047F8B505000F001600280000B253 +:1003D000FFF7F4FF04002000C0B2022801D301207F +:1003E0002CE032003900C9B22000C0B2FFF712FF82 +:1003F000012801D1012021E069482100C9B2490347 +:1004000041182A00D2B292008918C02212018A58DB +:10041000C9491140FFB23B02C02292001A400A4370 +:10042000FF218A43F1B21143E4B262038018EDB2B6 +:10043000AA008018C022120181500020F2BD0000E5 +:10044000FFFFFEFF70B50400022C01D3012028E05D +:1004500015000E002A003100C9B22000FFF7DAFEB5 +:10046000012801D101201CE04D4861034158802240 +:1004700012060A43C02189041143FF2212040A43D1 +:10048000AD4B1340F6B23202C0218900114019432E +:10049000FF229143EAB20A4361034250610340588C +:1004A000002070BDDCB506000F0010001900069C8E +:1004B0004901C20E1143400132003B0002F05BFFD4 +:1004C000060091204000FFF779FFF106C90E009168 +:1004D00071090B051B0D5B1E2200D2B20321C0B2B5 +:1004E000FFF7F8FED3BD80B51300A74A13600023C1 +:1004F000137203F006F801BDB8A4001000B5C0B235 +:10050000042801D2012000E00020C0B200BD00B5E7 +:10051000401A00B2012801DB012000E00020C0B237 +:1005200000BD00B5BE490978002901D000201FE0B8 +:1005300082214900415C002901D0002018E0FF2100 +:10054000891D415C002908D1FC214058002801D1B7 +:10055000012000E00020C0B20AE08021490041589B +:10056000FC228058814201D1012000E00020C0B26D +:1005700000BD00B5C0B264214843642103F000F817 +:1005800000BD10B504000800C0B2FFF7F2FF010083 +:10059000A448A2018018406903F0D0F910BD000002 +:1005A00000000440FF00FFFF10B40021B42281507E +:1005B0009D4A1368BC2403511268C023C2500268CC +:1005C000C423C2500A00F023C254F122815410BC4B +:1005D00070470000FF0000FF00FFFF00F1B582B090 +:1005E0001800904B029CA4011C19089B002B14D0EE +:1005F000099B002B11D00A9B002B0ED00092009A71 +:10060000002A0AD0002808D0E26A824205D30A00F4 +:10061000002A02D0616B002901D1002054E00021A2 +:10062000002303F08FF906000F00E06A4008002164 +:1006300080197941E26A002302F09DFE0500300036 +:1006400039007A4A002303F07DF906000F00E06AC2 +:100650000021009A002303F075F902000B000096B8 +:100660000197100019004008CE0730434908009E4A +:10067000019F8019794102F07EFE0600002D0BD00B +:10068000002E09D0BE4802998422520051434018DE +:10069000B8214058B04201D2002015E0606B3018FC +:1006A000401E616B02F06CFF05214843052801D212 +:1006B000052002E0602800D35F2008990D600999A9 +:1006C0000E600A9908700120FEBD80B55549800171 +:1006D0000818016AC069FFF727FD002801D0012032 +:1006E00000E00020C0B202BD10B44F490A68426267 +:1006F000026882624268C2620A68026342684263B8 +:100700000022026713007A24035313007C2403534E +:1007100013008C24035513008D2403550B68C36309 +:10072000436803640968416411009023C154912116 +:10073000425410BC70470000FFFCFFFF00B50200F0 +:100740000B0092B29BB29A4201D3401A00E0081A01 +:1007500080B200BD00B58A49842252005043081877 +:100760008E21415C00290BD08F21415C002907D0EC +:100770002D490968806B081A332801D2012000E056 +:100780000020C0B200BD00002C120028F8B503F014 +:10079000F4F9002801D1002041E0002425002EE0DA +:1007A00084214900764A6846038828004843101887 +:1007B0007426835368460388280048431018762619 +:1007C0008353230028004843101878268353164883 +:1007D0000368280048431018836323002800484317 +:1007E00010188726835501231E002800484310183F +:1007F0008E27C655694350188F2143546D1C042D14 +:1008000008D269462800C0B203F046FA0028C7D1D2 +:10081000002004E004480068934908600120F2BD0C +:100820008B1B00288C9A0010241B0028A086010036 +:10083000FEB58D488D4909680268891A052900D2DC +:100840009DE08A4909680160002452E0D2B2002A82 +:100850003ED02000704328187621405A072141439A +:10086000684600880818C1082000704328187622BE +:1008700081522DE00022684601882000704328182C +:100880007423C1527948016820007043281881639D +:1008900000212000704328188723C15401210B0038 +:1008A0002000704328188E27C35520007043281855 +:1008B0008F23C05C0028C9D168460288200070439D +:1008C00028187623C2522000704328188F228154A2 +:1008D0002000704328187621415A68460088FFF7A7 +:1008E0002DFF6643A91978228852019803F0D4FAA3 +:1008F000641C042C43D269462000C0B203F0CCF93A +:10090000002825D103F0C4FA84225200584B21005C +:10091000514359188725495DFF290AD021005143C9 +:10092000591887254D5D6D1C2100514359188726A4 +:100930008D552100514359188725495D032904D35A +:10094000002162439A188E23D15403F0A5FACFE711 +:1009500003F09EFA019084267600454D20007043F6 +:1009600028187E21405C002884D12000704328187C +:100970009421405C002800D07CE701227BE7F7BD92 +:100980000013002810B53A4A84235B004343D21871 +:100990008223D35C002B35D17E23D35C002B07D17F +:1009A0000B00DBB2052B2DD19423D35C002B29D077 +:1009B0007E23D35C002B04D07E23D35C7F2413558D +:1009C0000BE09523D35C002B04D08123D35C7F24E0 +:1009D000135502E000237F2413558A23D35C972408 +:1009E00013558223D15400218A23D1549423D15406 +:1009F0007E21515C002905D0D34980010818406947 +:100A000003F053FA10BD38B5002429E0CF4B13E0B2 +:100A100084214900164A2000484310188B23C05CEB +:100A20000028F3D020004843101880690028EDD03A +:100A30002000484310188369200048431018962569 +:100A4000405D00280BD0094800686143511849698E +:100A5000401A984203D305212000FFF793FF641C3E +:100A6000042CD5D331BD0000301B0028241B0028E6 +:100A700000130028F8B500273C0061E1B44AB248F1 +:100A8000A1014018016AB048A301C018C06902F072 +:100A900038FD84256D00AF4E20006843375020009C +:100AA0006843301847602000684330188760A648C4 +:100AB000A1014018816A200068433018C1602000FD +:100AC00068433018076120006843301847612000F0 +:100AD000684330188761200068433018C7612000E0 +:100AE000684330180762200068433018876320008D +:100AF00068433018C763200068433018076420003B +:100B00006843301847643900200068433018742265 +:100B10008152390020006843301876228152390012 +:100B20002000684330187822815239002000684341 +:100B300030187E22815439002000684330187F220B +:100B400081543900200068433018802281543900D4 +:100B50002000684330188122815439002000684306 +:100B600030188222815439002000684330188322D3 +:100B700081543900200068433018842281542000B9 +:100B8000FFF7A3FD0100200068433018852281543F +:100B90003900200068433018862281543900200033 +:100BA000684330188722815401212000684330189F +:100BB00088228154390020006843301889228154EA +:100BC00039002000684330188A22815439002000FF +:100BD000684330188B228154390020006843301854 +:100BE0008E22815439002000684330188F228154AE +:100BF00039002000684330189022815439002000C9 +:100C0000684330189122815439002000684330181D +:100C10009222815439002000684330189322815475 +:100C20003900200068433018942281543900200094 +:100C300068433018952281543900200068433018E9 +:100C4000962281543900200068433018972281543D +:100C500039002000684330189822815420006843EE +:100C600030189C214750200068433018A02147507D +:100C7000200068433018A4214750200068433018F2 +:100C8000A8214750200068433018AC21475020006D +:100C900068433018B0214750200068433018B42111 +:100CA00047502948A1014018016B200068433018C3 +:100CB000B82281503221200068433018F22281543A +:100CC000200068433018F322815420006843301814 +:100CD000F42281543900200068433018F6228152F2 +:100CE0003900200068433018F82281522000684300 +:100CF0003018FC21475020006843301880214900FB +:100D00004750390020006843301882225200815435 +:100D10003900200068433018FF22921D81543900A9 +:100D20002000684330188322520081542000684319 +:100D30003018FFF739FC65437019FFF7D5FC641CC8 +:100D4000042C00D29AE6F1BD8C9A0010B8880000FD +:100D5000941200280013002870B50600BF488421B3 +:100D60004900714344183000FFF7AFFC05008820AC +:100D7000205C002805D1852025540120882160545D +:100D80007EE028008521615CC0B2884268D060683E +:100D9000401C60607E20205C002806D19520205CED +:100DA000002802D07348006820622800C0B20028E2 +:100DB00054D02068401C20607E20205C0100C9B215 +:100DC000002905D19521615C002901D08120205C9A +:100DD0000100C9B2012918D18920205C002808D15E +:100DE000A0680021C943884203D0A068401CA060CD +:100DF00014E08920205C002810D0A068E1688842B7 +:100E00000CD2A068401CA06008E0C0B2022805D146 +:100E1000A068002802D0A068401EA0608220205C4C +:100E200000281BD18A20205C002817D07E20205C5F +:100E3000002813D07E20205C012803D1A0682169FE +:100E4000884207D27E20205C022807D12069A16851 +:100E5000884203D301213000FFF794FD85202554FB +:100E60009520205C00280BD042480068216A401A77 +:100E7000322805D3002001009522A154812160541D +:100E800070BD70B575498422520042438C187E2192 +:100E9000615C00296CD08D21615C00292CD1354E1C +:100EA00035689C4980010818806B0021FA22920065 +:100EB000002302F047FD02000B0097480021801834 +:100EC0005941FA22D200002302F055FA616A691AE8 +:100ED00081424DD301208D216054306820636068C9 +:100EE00060630020206701007A22A15201007C2269 +:100EF000A1528C2160543BE07C20205A0A2810D259 +:100F00007A20205A616880B28000201881647C2099 +:100F1000205A401C7C2160526068616B401A206737 +:100F20000FE07A20205A80B280002018806C7A214D +:100F3000615A626889B2890061188A646168081A16 +:100F400020677A20205A401C010089B20A2900D368 +:100F500000207A2160527C20205A0A2808D30548B4 +:100F60000068216B401AFA2802D301208C216054BA +:100F700070BD0000241B002870B50026340019E065 +:100F800030009021685430009121685411E0300005 +:100F9000912168548220285C00280AD1C148006849 +:100FA000E96B401A9749884203D305212000FFF7D7 +:100FB000E9FC641C042C00D387E028488421490004 +:100FC000614345182000FFF75CFF7E20285C002865 +:100FD00007D08220285C002803D18C20285C0028C0 +:100FE00000D1E6E783204000285C00280AD0F320E7 +:100FF000285C002806D130009021685430009121EF +:101000006854D6E78B20285C002814D0300090214B +:101010006854300091216854286F00280AD17E203E +:10102000285C022801D1022100E00321C9B220007E +:10103000FFF7A8FCBDE7286F0328A1D22000FFF727 +:1010400089FBC0B200280BD07820285A3149A20170 +:101050008918898F884203D3012002E000130028F9 +:1010600030009021695C00290BD101219022A95404 +:101070008C490968E9636968296431009122A9549F +:1010800008E069682A6C914204D06968296485493E +:101090000968E963C0B2002800D178E79120285C94 +:1010A000002806D10120912168547E4800686864B8 +:1010B00070E77C480068696C401A642800D269E7D0 +:1010C00004212000FFF75EFC64E770BD38B59C4C3E +:1010D000200003F009F800281BD0200003F009F8D5 +:1010E00070480168491C01600024250003E02800C5 +:1010F000FFF732FE6D1C042DF9D3FFF784FCA448E2 +:101100000168491C01600168192902D30460FFF7D6 +:1011100033FF31BD8C9A0010CF07000070B5282531 +:101120000026340009E0022201219A48A301C018D8 +:101130001823C05EFFF746F9641C042CF3D3022287 +:101140000121FF204230FFF73DF9924C1820205E2C +:10115000FFF734F92B005B1E02220121C0B2FFF71A +:101160006FF81820205EFFF729F902220121C0B292 +:10117000FFF768F9350011E08749A8012018406998 +:1011800002F056FAA8012018406902F0D1FB0021B4 +:10119000A8012018406902F0D3FB6D1C042DEBD38D +:1011A000012420007D4908703E48066078480660AA +:1011B000634E7B49300002F07FFE300002F099FF61 +:1011C000300002F08BFF774D280002F09BFF76493C +:1011D0000020285E02F0B5FF03210220285EFEF702 +:1011E00067FF0220285EFEF757FF0220285EFEF709 +:1011F00047FF300002F06CFF300002F050FEAC48B8 +:10120000047070BDC4090000F8B500220121A9488E +:10121000FFF769F901220221A748FFF764F90022CC +:101220000121A648FFF75FF9A54D01220121280001 +:10123000FFF759F900220021A248FFF754F9A24C08 +:10124000002204212000FFF74EF900220521200092 +:10125000FFF749F9002200212800FFF744F9FFF7C2 +:1012600009FC9A480090082000990860984E1324C1 +:101270003470984FC04A0021380002F042F9062528 +:10128000009805603470BC4A0121380002F039F939 +:10129000009805603470B84A0221380002F031F934 +:1012A000F1BD0000241B002878B58DB06846B34915 +:1012B000342202F059FF3948007800280BD0B0489A +:1012C00002F0F3FDAF4802F0F0FDAF4802F0EDFD93 +:1012D000AE4802F0EAFD7B4E6A460021300002F083 +:1012E00010F9794D6A460421280002F00AF96A468D +:1012F0000521280002F005F9714C6A460021200002 +:1013000002F0FFF800213000FEF718FF042128004A +:10131000FEF714FF05212800FEF710FF0021200032 +:10132000FEF70CFF012000906A460121200002F028 +:10133000E8F801212000FEF7FDFE0EB070BD0000B0 +:10134000000A58401CB591246400012203212000AA +:10135000FFF738F8012000908D4A00238D480021C6 +:10136000FFF7A0F82000FFF729F801220321C0B2FF +:10137000FFF768F8884C200003F058F800228749EE +:10138000200002F019FF2000FEF726FF13BD000029 +:10139000281B00288C9A0010809E0010891B0028B2 +:1013A000B89F0010C0A40010CD100010F2B50600C8 +:1013B00000273C000098002801D1002077E0380089 +:1013C0000099088002F064FDCD490978002903D016 +:1013D00002F062FD00206AE00121C94A117002F0AA +:1013E0005BFD6D4D2800FEF701FF2800FEF706FFAC +:1013F00002213148FEF7A2FE012003F09FF8B6B2A9 +:1014000031002800FEF702FF01283ED1BD4E00E06A +:10141000761E2800FEF7EEFE002801D1002EF7D13F +:101420002800FEF7E7FE00282FD0B64E00E0761E1B +:101430002800FEF7D7FE002801D1002EF7D12800A2 +:10144000FEF7D0FE002820D0012200992800FEF7E8 +:10145000D9FE012819D1C020000200990988014055 +:10146000814201D1012400E03C002000C0B20028EC +:101470000BD0A5480078002807D000980088FC21F0 +:1014800089010140002900D03C00012003F056F8FA +:1014900002210948FEF74EFE012003F04FF802F04A +:1014A000F7FC97490F7002F0F7FC2000C0B2F2BDC4 +:1014B000881B002880013140800F3140800431407A +:1014C0000004314080003140800231406412002825 +:1014D000681200280003314080B5C0B20202FC202F +:1014E00080011040C9B20843694680B2FFF75EFF31 +:1014F00002BD1CB50C00C0B20002FC2189010140F4 +:101500008020C0010843002C05D0694680B2FFF757 +:101510004DFF002801D1002003E0684600882070BC +:10152000012016BD38B505000C002100C9B2280005 +:10153000C0B2FFF7D1FF002801D100200FE06946BB +:101540002800C0B2FFF7D5FF002806D06846007813 +:10155000E4B2A04201D1012000E00020C0B232BDBF +:1015600080B5B44A00211160017C1172C1688068A5 +:1015700001F0C7FF01BD00006012002868A2001042 +:1015800000065840800658400007584080095840DF +:1015900000B4C4040024F40000006440ECA0001077 +:1015A000F8B50126300000246846047056480470DF +:1015B0000121A148FEF7BEFDC24F01213800FEF710 +:1015C000B9FD9E4D280002F0B9FF01219C48FEF7AD +:1015D000B1FD280002F0B2FF02210820FFF7A2FFB0 +:1015E00005002800C0B2002804D000210920FFF720 +:1015F00099FF05002800C0B2002804D000210A206D +:10160000FFF790FF05002800C0B2002804D00F218A +:101610000B20FFF787FF05002800C0B2002804D088 +:10162000A5210C20FFF77EFF05002800C0B200288E +:1016300004D000210D20FFF775FF05002800C0B27F +:10164000002804D000210E20FFF76CFF05002800C1 +:10165000C0B2002808D087210720FFF73DFF050012 +:10166000FA20800002F06AFF2800C0B2002804D0EF +:1016700086210720FFF756FF05002800C0B200288A +:101680000ED069460020FFF734FF002807D06846D7 +:101690000078BF210140802901D1350000E02500FC +:1016A0002800C0B2002810D0A1480470A1480470DE +:1016B000B948047014480670B8480068B849086012 +:1016C00001213800FEF73AFD07E0B34806700E48E6 +:1016D000047001213800FEF72DFD2800C0B2F2BDD4 +:1016E00010B50400534A002010601072E168A06831 +:1016F00001F007FFE168A068FEF720FD10BD0000C3 +:101700008D1B0028A0860100901B0028F8B501243D +:101710002000A149087000252800A949087001216E +:101720006848FEF707FD2E000FE0A6480078002865 +:1017300005D0A548B1014018406902F0B6FBA248A7 +:10174000B1014018FFF7CCFF761C042EEDD302F058 +:101750009FFB0090280044E02A0011E0B849842251 +:101760005200424389188A228A5C002A07D19722B4 +:101770008A5C002A03D198228A5C002AECD02200DD +:101780002B007E268B552B007F268B552B00802629 +:101790008B552B0082268B55072383268B552E00D5 +:1017A0008A27CE552E008B27CE552E009427CE5556 +:1017B0002E009527CE552E009627CE552E00972722 +:1017C000CE552E008927CE55D2B2002A08D08422C9 +:1017D0008B549C228A58A023CA5022009823CA54B2 +:1017E000401C0428BAD3009802F056FBF1BD80B526 +:1017F0007348007800280AD067480078002806D18E +:10180000674866490A680368D21A192A00D20FE0AD +:101810000968016069460020FFF76BFE002805D0CB +:1018200068460078BF210140802901D0FFF76EFF94 +:1018300001BD0000C81200288001314010270000BF +:101840008004314070B5604A80011018524A127805 +:10185000002A01D0002034E02422825C092A14D11D +:10186000334D2A782523C35C9A432523C45C0C405E +:1018700014432100C9B20920FFF754FE06003000CE +:10188000C0B2002815D02C7013E02A4D2A782523E9 +:10189000C35C9A432523C45C0C4014432100C9B2A5 +:1018A0000A20FFF73FFE06003000C0B2002800D03B +:1018B0002C703000C0B2002801D1FFF727FF3000A4 +:1018C000C0B270BD0004314038B504003E48A101EB +:1018D0004518686902F0E9FA2800FFF701FF0021C6 +:1018E0002000FFF7AFFF32BD70B505003648AA01F2 +:1018F0008618C9B2012902D12620345C01E02720D4 +:10190000345C0F20E840C00701D400200FE070696C +:1019100002F0CBFA3000FFF7E3FE2100C9B2280045 +:10192000FFF790FF002801D1002000E0012070BDEA +:10193000861B0028871B002870B504002248A101DF +:1019400045183F488421490061434018F421465C12 +:1019500083214900415C00290AD08B21415C002988 +:1019600006D1F221465C3100C9B2322900D23226BA +:10197000F3214654FEF718FE3100C9B22000FEF7ED +:1019800000FE0021686901F0DBFF686902F087FA58 +:101990002800FFF7E5FD70BD8A1B0028241B0028E6 +:1019A0002C1B002800B50A00D2B2002A07D08122E1 +:1019B0008154AD490968016201219522815400BD1D +:1019C000901B0028891B00288C9A001084228154C7 +:1019D0009C214158A02281500121982281547047B6 +:1019E00000B59C214158491C9C2281509C214158A2 +:1019F000002902D101219C22815000210A00842368 +:101A0000C254A02281509822815400BD00B58B2180 +:101A1000415C002904D08169002901D0816900E07E +:101A200092499622825C002A07D08F4A1268406948 +:101A3000101A884201D3012000E00020C0B200BD8E +:101A40000013002880B500217E2281547F2281541A +:101A50008022815494228154952281548122815480 +:101A6000962281548A228154972281540121832213 +:101A700081540121FFF7AAFF01BDF3B583B07C4873 +:101A8000039984225200514344180020694608708B +:101A900000250020694648700027002069468870AC +:101AA000002602F0F5F901909420205C00284DD02A +:101AB0008220205C002849D16E480078002845D15A +:101AC0002000FEF72EFD002840D06846007CC0B202 +:101AD000002809D064480068E169401A322835D3EB +:101AE0009520205C002831D12000FFF78FFF0028CF +:101AF00004D005210398FEF745FF27E08A20205CEB +:101B0000002808D0A0682169884204D12000FFF78E +:101B100099FF01271AE08A20205C002807D0A068DE +:101B20002169884201D2012003E0022001E08020E7 +:101B3000205C6946087068460078002803D00120C0 +:101B40006946487002E0002069464870019802F03A +:101B5000A3F93800C0B2002803D00398FFF7B4FE01 +:101B600080E068464078002801D100207AE068468D +:101B700001780398FFF7B8FE002801D1002071E03A +:101B800002F086F901909420205C002856D0822033 +:101B9000205C002852D13748007800284ED1200020 +:101BA000FEF7BFFC002849D02000FFF72FFF0028D8 +:101BB00004D005210398FEF7E5FE3FE08A20205C73 +:101BC000002810D0A0682169884204D12000FFF7C6 +:101BD00039FF012709E0A0682169884201D2012567 +:101BE00003E0022501E08020255C3800C0B2002817 +:101BF0001CD1280069460978C0B2884216D16846CF +:101C000000787E216054002080216054942160542B +:101C100095216054812160542000FEF765FD0398F2 +:101C2000FFF78AFE01266846867007E0FFB2002FA4 +:101C300003D180202554012600E00126019802F0FE +:101C40002BF96846807800280AD10A4800780028D5 +:101C500004D10398FFF738FE002801D1002001E0ED +:101C60003000C0B205B0F0BD241B0028B8880000C9 +:101C7000001300288A1B0028F6B583B00D001F0052 +:101C80000400E4B29C490978002916D0C0B2FEF7DE +:101C900035FC002811D09948007800280DD19848CB +:101CA0000078002809D16846057068460078012848 +:101CB00005D068460078022801D001201AE102F020 +:101CC000E7F80190842676008E4D20007043281896 +:101CD0008221405C002806D1200070432818FEF7BE +:101CE00020FC002804D1019802F0D6F8012001E17F +:101CF0002000704328187E21415C0CA80078002841 +:101D000013D10CA801700CA8007800280DD1200078 +:101D1000704328189522805C002806D0200070436C +:101D200028188122805C0CAA10700800C0B200281C +:101D300003D001206A46507002E000206A465070CD +:101D40000CA8007800280CD0C9B2002905D00CA836 +:101D5000007869460978884203D001206946887076 +:101D600002E000206946887068464078002805D067 +:101D7000C548A1014018406902F097F80A990297F6 +:101D80006846027C00272000704328188223C7542D +:101D90002000704328187F23C75420007043281860 +:101DA0008A23C254029B200070432818036120003C +:101DB000704328188B23C1542000704328189721A2 +:101DC0004754D2B2002A08D00BA80078002804D0CB +:101DD000200070432818FFF703FE0D98C0B20028BA +:101DE00006D02000704328189621405C002810D1AE +:101DF000A648016820007043281841610121200095 +:101E00007043281896228154200070432818862198 +:101E100047540BA80078002804D0200070432818ED +:101E2000832147546846807800281ED020007043E4 +:101E300028187E21475468460178200070432818EE +:101E40008022815491480168200070432818C161A4 +:101E50000121200070432818942281540CA8017895 +:101E60006643A819FFF79EFD20E020007043281864 +:101E70007E2147546846017820007043281880224C +:101E8000815482480168200070432818C1610121F3 +:101E90002000704328189422815420007043281891 +:101EA000952147546643A81981214754019801F0B0 +:101EB000F3FF6846407800280CD07348A101401811 +:101EC000FFF70EFC00212000FFF7BCFC002801D129 +:101ED00001200FE068468078002801D0002009E04A +:101EE00000212000FFF7C9FD002801D0380000E0E4 +:101EF0000120C0B205B0F0BD881B00288B1B002854 +:101F00008A1B00280013002880B50121FFF7B5FDCA +:101F100001BDF1B586B05E48069984225200514356 +:101F200044180027002501F0B3FF01908220205CB7 +:101F30006946087068460078002803D1019801F0CE +:101F4000ABFF9EE07F20205C694608748B20205CFC +:101F5000694648709720205C6946887000263000EA +:101F60008221605430007E21605430007F21605413 +:101F700030008021605430009421605430008A2168 +:101F80006054684600788321605430009721605483 +:101F90006846017C2000FFF705FD68468078002830 +:101FA00004D0684601782000FFF710FD68460078ED +:101FB00002280FD101252F0068464078002807D15C +:101FC0009220205C002803D08620205C002830D09E +:101FD00035002EE068460078032810D102276846B5 +:101FE0004078002809D19220205C002805D0862066 +:101FF000205C002801D101251BE0350019E068466E +:102000000078042815D16846007C012801D10227F8 +:1020100000E0012768464078002809D19320205C21 +:10202000002805D08620205C002801D1012500E091 +:1020300035002800C0B2002805D08620205C401C56 +:102040008621605401E096202654019801F024FF77 +:102050000698FFF739FC002813D0EDB2002D10D000 +:10206000012003906846007C02900020019000202F +:102070000090002300223900C9B20698C0B2FFF7D1 +:10208000FBFD07B0F0BD00008C9A0010241B002857 +:1020900000130028F1B584B0B1480499842252009D +:1020A0005143451801F0F4FE0090AE4804688320C7 +:1020B0004000285C002818D07E20285C002814D01E +:1020C0008B20285C002810D19420285C00280CD19B +:1020D0008220285C002808D1A3480078002804D179 +:1020E000C0202858201A322803D2009801F0D4FECC +:1020F00034E128680190F020285CF121695C052911 +:102100000AD2C42169580391BC216958F122AA5C02 +:10211000521CF123EA540CE00100C9B2890069188D +:10212000C822895803910100C9B289006918DC22CC +:102130008958019A0300DBB29B00EB18C8269A511C +:102140000200D2B29200AA18DC23D450401C020034 +:10215000D2B2052A00D30020F022A854C0202C506F +:10216000601A0290BC202858201A0021FA22D200BE +:10217000002301F0E7FBFA229200002301F0FBF8B4 +:102180000200A4202858904201D2A4202A58AC2052 +:102190002858A4216E580021002301F0D3FB02002F +:1021A0000B007008002180185941A422AA5800236E +:1021B00001F0E1F806000198C4216958471ABC20D3 +:1021C0002858201A322812D33000401CB8420ED2B0 +:1021D000F320285C002806D00020F3216854002159 +:1021E0000498FEF7CEF9009801F056FEB6E0BC2048 +:1021F0002858201AFA2802D30298FA2803D2009805 +:1022000001F04AFEAAE002980021FA22D20000233F +:1022100001F098FBFA229200002301F0ACF80400D0 +:10222000002C03D1009801F037FE97E00198039944 +:10223000401A0021AF4A002301F084FB02000B008A +:1022400060080021801859412200002301F093F812 +:102250000190B02028580290F3202C5C0198B42102 +:1022600068503000401CB84201D200245BE07F1C63 +:10227000B74210D22000C0B2322801D2322452E03C +:102280002000C0B25F284ED2641D2000C0B260287A +:1022900049D35F2447E0019800216422002301F024 +:1022A00051FB06000F00029800215A22002301F082 +:1022B00049FB8F4213D801D3864210D22000C0B20E +:1022C000322801D232242EE02000C0B25F282AD268 +:1022D000641D2000C0B2602825D35F2423E002984B +:1022E00000216E22002301F02DFB06000F00019853 +:1022F00000216422002301F025FB8F4213D801D373 +:10230000864210D22000C0B2332807D32000C0B2CA +:10231000372801D3FB3406E0322404E02000C0B2A9 +:10232000002800D000242000F321695CC0B288425C +:1023300011D0F320285C002806D12000C0B232283A +:1023400002D32800FEF7D0F9F3202C542100C9B2A3 +:102350000498FEF716F9009801F09EFD05B0F0BD57 +:1023600000130028241B00288A1B0028FEB5A5485E +:10237000007800285DD001F08BFDC3490978002961 +:1023800002D001F089FD54E001273900BE4A1170E6 +:10239000B8490A786B461A70002422000A7001F0CE +:1023A0007BFD6846007800282BD0250027E026001A +:1023B0001AE0684600783900A94008421ED001F0B2 +:1023C00067FD019084214900B04A28004843101855 +:1023D00082235B00C05C0028E9D069435018FEF7F7 +:1023E000A0F80028E3D13E00019801F055FDF6B2B7 +:1023F000002E03D02800C0B200F059F96D1C042D46 +:10240000D7D3FEF715FAFFF7F2F9250009E0280007 +:10241000FFF77FFD2800FFF777FD2800FFF73AFE62 +:102420006D1C042DF3D301F033FD97490C7001F0BE +:1024300033FDF7BD964800787047F8B50E000700E9 +:10244000FFB270490978002904D0C0B2FEF756F8EF +:10245000002801D1012046E001F01AFD04008B485C +:1024600084214900794345188A480078002804D01F +:10247000200001F011FD012035E0FF20801D285CC7 +:1024800000280CD0310089B2F820285AFEF73FF816 +:10249000002804D0200001F0FFFC012023E082206E +:1024A0004000285C00280FD0F620285A310089B25D +:1024B000884219D100208221490068546D48017872 +:1024C0000122BA4091430170F8202E52FC20285876 +:1024D0008021490068500120FF21891D6854200097 +:1024E00001F0DAFC0020F2BD200001F0D5FC012053 +:1024F000F9E70000A0860100F2B50400E4B241490A +:102500000978002904D0C0B2FDF7F8FF002801D1F6 +:10251000002025E001F0BCFC070084256D005B4E27 +:10252000200068433018FDF7FCFF002812D020007F +:1025300068433018FF21891D405C00280AD0684696 +:1025400000886C433119F822895A80B2814201D146 +:10255000012400E00024380001F09EFC2000C0B2FD +:10256000F2BDF3B581B000262DE000200EE000247E +:1025700082204000385C0028F7D068460189F620A8 +:10258000385AFDF7C4FF0028EFD028002043C0B21E +:10259000002817D168460089F6217852FC20385867 +:1025A000401CFC2178502800822149007854314891 +:1025B0000178B5400D430570AF48B101401840693E +:1025C00001F073FC761C042E1AD2012568460079AE +:1025D0002900B1400842F5D02C48842149007143BC +:1025E0004718FF20801D385C0028C0D0684601894C +:1025F000F820385AFDF78BFF0028B8D02C00B7E739 +:10260000F7BD0000881B0028F1B5002425002EE04E +:10261000842149001D4A6846038828004843101851 +:10262000F6268353280048431018FC23C3585B1C2C +:10263000280048431018FC26835101231E0028005F +:102640004843101882277F00C6552000694351185F +:10265000FF22921D885407480178AB400B4303705A +:102660008548A9014018406901F01FFC6D1C042D2C +:10267000CED3F1BD8E1B002880B50121054A117013 +:1026800080B2FFF7C1FF01BD8C1B0028001300289A +:102690008A1B00288B1B002810B5002404E02000B2 +:1026A000C0B200F004F8641C042CF8D310BDFEB5D1 +:1026B0000400E4B2B9490978002904D0C0B2FDF79A +:1026C0001DFF002801D10120D5E001F0E1FB0190C0 +:1026D00084256D00B24E2000684330187E21415C95 +:1026E0002000684330188322805C6A461070200006 +:1026F000684330188A22805C002806D12000684395 +:1027000030189722805C002803D001206A46507060 +:1027100002E000206A465070C9B2200068433018B9 +:10272000FFF740F9002739002000684330187E2267 +:10273000815439002000684330187F2281543900C9 +:1027400020006843301880228154390020006843FB +:10275000301882228154B3480078002806D007211F +:102760002000684330188322815430E06846407866 +:10277000002806D0062120006843301883228154A7 +:1027800025E068460078022813D0684600780328C0 +:102790000FD06846007804280BD0684600780528DA +:1027A00007D068460078072803D0684600780828D4 +:1027B00007D168460178200068433018832281548D +:1027C00005E0390020006843301883228154390025 +:1027D0002000684330188A22815439002000684361 +:1027E00030188B228154390020006843301894221D +:1027F00081543900200068433018962281543900F2 +:102800002000684330189722815468464078002899 +:1028100005D0062165437019FFF7D8F80FE039009D +:102820002000684330188422815420006843301807 +:10283000A0214750380065437119982288540198A7 +:1028400001F02AFB7748007800280BD00A4DA00140 +:102850002818406901F029FBA0012818FEF740FF65 +:10286000012008E02000FFF72FF8002801D03800F1 +:1028700000E00120C0B2FEBD8C9A0010FEB50D0034 +:102880000400E4B245490978002913D0C0B2FDF72D +:1028900035FE00280ED0644E307800280AD16846F4 +:1028A00005706846007832382E2803D25D4D2878AE +:1028B000002801D001206FE001F0EAFA01903078A1 +:1028C00000280CD12878002809D184256D00344EC9 +:1028D000200068433018FDF724FE002804D1019839 +:1028E00001F0DAFA012057E06846017820006843D9 +:1028F0003018F42281540027390020006843301832 +:10290000832252008154200068433018A42147508C +:10291000200068433018A821475020006843301831 +:10292000AC214750200068433018B021475068461A +:102930000178200068433018F222815468460178FB +:10294000200068433018F322815420006843301877 +:10295000FDF72AFE2000684330187E21405C0028E5 +:102960000ED02000684330188B21405C002807D12E +:10297000654370198221405C002801D10127FFE7DF +:10298000FFB2002F04D0684601782000FDF7F9FD62 +:10299000019801F081FA0020FEBD0000881B00288C +:1029A00000130028F8B500220392002202920122AF +:1029B00001920022009200230022C9B2C0B2FFF7A8 +:1029C0005BF905B000BD38B50400E4B231490978BF +:1029D000002904D0C0B2FDF791FD002801D10020EC +:1029E0001FE001F055FA842252002B4B2100514385 +:1029F00059187E25495D00290CD1210051435918F1 +:102A00009425495D002905D1544319199522895C03 +:102A1000002901D0012400E0002401F03DFA20004B +:102A2000C0B232BD8A1B00288B1B002870B50C0079 +:102A30000500EDB2002C08D016490978002904D011 +:102A4000C0B2FDF75BFD002801D1012020E001F0BC +:102A50001FFA84225200104B2900514359188968EB +:102A60002160290051435918C96861602900514308 +:102A70005918B8268959A160290051435958E16075 +:102A8000554359198922895C217401F005FA002007 +:102A900070BD0000881B002800130028F8B50D0049 +:102AA0000400E4B275490978002918D0C0B2FDF7D6 +:102AB00025FD002813D07248007800280FD1BF48A8 +:102AC000007800280BD100950098002807D02848EE +:102AD000A1014018006B40000099884201D20120FA +:102AE00036E001F0D5F9070084256D00B54E2000D1 +:102AF000684330187E21405C00281BD120006843C9 +:102B000030189421405C002814D12000684330180C +:102B10009521405C00280DD12000684330188221A7 +:102B2000405C002806D1200068433018FDF7F9FC0E +:102B3000002804D1380001F0AFF9012008E0009826 +:102B40006C433119B8228850380001F0A5F90020F3 +:102B5000F2BD00B59C4800680021FA22D200002393 +:102B600000F0F0FEFA229200002300F004FC00BD09 +:102B70008C9A0010F8B505000C002E00F6B2002C5F +:102B800009D03E480078002805D02800C0B2FDF7E3 +:102B9000B5FC002801D101206CE001F079F90700B3 +:102BA0008948016884225200864B3000504318182F +:102BB000806B091A25703000504318188E25405D2F +:102BC000002803D0332901D2012000E000206070EA +:102BD0003000504318188F25405DA07030005043DE +:102BE00018187425405BE080300050431818762593 +:102BF000405B20813000504318187825405B60818D +:102C00006F48B5014019808FA0816078002808D0F6 +:102C1000A078002805D06089A589A84201D30120A9 +:102C200000E00020E0703000504318188C25405D13 +:102C3000002807D0300050431818006F032801D235 +:102C4000012000E000202071564398198B22805CFF +:102C5000607108000021FA22D200002300F072FE09 +:102C6000FA229200002300F086FB2061380001F078 +:102C700013F90020F2BDA84908707047881B00288E +:102C80008B1B0028FEB505000C002E00F6B2002CB0 +:102C900009D0A2480078002805D02800C0B2FDF76E +:102CA0002DFC002801D1012086E001F0F1F801900F +:102CB00084214900434A3000484310188768300097 +:102CC00048431018C068009030004843101889230A +:102CD000C05C60703000484310187E23C05CA07058 +:102CE000714350188321405CE070019801F0D4F8E2 +:102CF0000098002801D101205EE001970098019919 +:102D0000884201D2009801902D48B1014018C26A52 +:102D1000257067600098A06022610198002100235F +:102D200000F010FE02000B000098400800218018FF +:102D30005941009A002300F01EFBE0600198002139 +:102D4000774A002300F0FEFD02000B0000984008C7 +:102D5000002180185941009A002300F00CFB6062AA +:102D60000026A661E6612800C0B2002805D1E0680F +:102D70006061E068A06126621DE0EDB2012D17D10F +:102D8000E068606101980021664A002300F0DAFDE6 +:102D900002000B0000984008002180185941009A59 +:102DA000002300F0E8FAA061E068E061266202E03A +:102DB0006661E06820620020FEBD00008A1B0028DA +:102DC0008C9A001000130028241B0028F8B588B046 +:102DD0000D0016000400E4B25049097800290CD017 +:102DE000C0B2FDF78BFB002807D077480078002899 +:102DF00003D176480078002801D001208AE00696A9 +:102E00000795FFF7B3FA01F043F8842676006D4D7D +:102E1000210071436918CF6801F03EF803A80290C1 +:102E200004A8019005A80090069B079A390020008D +:102E3000FDF7D4FB002801D101206BE001F028F858 +:102E400007006148007800280AD16048007800280F +:102E500006D1200070432818FDF763FB002804D139 +:102E6000380001F019F8012054E007992000704360 +:102E70002818A42281500699200070432818A822FF +:102E800081500599200070432818AC228150049984 +:102E9000200070432818B02281506846017B200032 +:102EA00070432818F22281546846017B2000704349 +:102EB0002818F3228154012120007043281883220E +:102EC00052008154200070432818FDF76DFB20004C +:102ED000704328187E21405C00280ED020007043EB +:102EE00028188B21405C002807D16643A81982214D +:102EF000405C002801D1012000E00020C0B2002881 +:102F000004D06846017B2000FDF73BFB380000F051 +:102F1000C3FF002009B0F0BD8F1B0028881B0028CC +:102F200010270000EC13000070B505000C002E0007 +:102F3000F6B2002C09D026480078002805D02800D9 +:102F4000C0B2FDF7DBFA002801D1012038E000F023 +:102F50009FFF257084225200B84B3100514359180D +:102F600083256D00495D6170310051435918A425D6 +:102F700049596160310051435918A8254959A16048 +:102F8000310051435918AC254959E1603100514392 +:102F90005918B02549592161310051435918B425B8 +:102FA00049596161310051435918F225495D217633 +:102FB00056439919F322895C617600F06DFF002079 +:102FC00070BD0000001300288B1B00288A1B0028FE +:102FD000881B0028F1B586B00C006846007E019081 +:102FE0000026300030006946087430006946887059 +:102FF00030006946487092480078002809D0684639 +:10300000007EFDF77BFA002803D08E4D287800283B +:1030100001D0012010E10394FFF7A8F98A48019933 +:1030200089014018C76A0398874201D2012003E151 +:1030300000F02EFF029081480199842252005143F2 +:1030400044188920205C002812D0E06800280FD0A6 +:103050008B20205C00280BD18220205C002807D127 +:103060002000FDF75EFA002802D02878002804D05E +:10307000029800F011FF0120DEE003980021E268D1 +:10308000002300F05FFC02000B007808002180188C +:1030900059413A00002300F06EF907007E20205CC1 +:1030A000694608702000FEF79BFC276101258A20F5 +:1030B000255430008B21605430009721605430003B +:1030C000832160549420205C002819D0A068874296 +:1030D00001D1300005E0A068B84201D2280000E02C +:1030E0000220802160549620205C00287DD1574822 +:1030F000006860619620255430008621605474E099 +:1031000068460078002839D068460078012802D146 +:10311000A068B84206D368460078022804D1A068A7 +:10312000874201D2280000E030009621615C00292E +:1031300007D14649096861619621655431008622AC +:10314000A154C0B2002850D13F48019989014018CC +:10315000406900F0AAFE30007E21605430008021DA +:1031600060543A480068E06194202554684601782C +:103170002000FEF717FC6846857036E09520205C3D +:1031800000281DD0A068874201D1300005E0A0686A +:10319000B84201D2280000E0022080216054206A59 +:1031A000E061942025549620205C00281DD12748FA +:1031B000006860619620255430008621605414E038 +:1031C000A068874206D13000862160542000FEF7B7 +:1031D00039FC0AE0A068B84202D26846057402E0F1 +:1031E00002206946087468464570029800F054FE53 +:1031F00068468078002809D00198FEF765FB002812 +:1032000001D0300000E02800C0B215E068464078E8 +:10321000002810D000200390002002900020019090 +:10322000002000903B0001226846017C6846007E39 +:10323000FEF722FD00E0002007B0F0BD00130028DB +:10324000881B00288A1B00288C9A0010241B002849 +:1032500038B500200E490870FDF7D6FFFEF772F86A +:10326000FEF79EF9050000F088FC0400FDF756FF0C +:10327000EDB2002D06D0E4B2002C03D0FDF786FAA3 +:10328000002803D1FEF742FA012000E0002032BD01 +:103290008B1B0028D021890040587047C22189002B +:1032A0004058C005C00D7047902292008150704771 +:1032B000822189004058C005C00D704700B50068E4 +:1032C000800B8007800F002801D1802000E0402083 +:1032D00000BD00B5C02189004058C006C00E0828B6 +:1032E00001D2012000E00020C0B200BDF8B504000A +:1032F0000D0016002000FFF7ECFF002809D02F007A +:103300000025B54210D22000FFF7C4FF78556D1C90 +:10331000F7E72F00002505E02000FFF7BBFF69005D +:1033200078526D1CB542F7D3F1BDF8B507000D001A +:1033300016003800FFF7B2FF0400A64200D23400A6 +:10334000290022003800FFF7D1FF2000F2BDF8B5B8 +:1033500005000C0000262800FFF7B0FF070028003A +:10336000FFF7A6FF874204D021002800FFF79CFF4B +:1033700001263000F2BD12422DD0994231D970B5EC +:1033800000251B4209D00C00002112E0110000F0C2 +:10339000F7F80A000021002370BD0942F6D000248E +:1033A000012649186441944200D3A41A7641F8D307 +:1033B0003100012601E0764109D2001864416D41D7 +:1033C000A41A9D41F7D2A4185D41B619F5D3300077 +:1033D00022002B0070BD1B42CFD108B501F0E8F8E8 +:1033E00008BD01D19042CAD202000B00002100208A +:1033F0007047000070B55F4BC018C00900015E4BFC +:10340000C31804290CD21F251C68CE002800B04028 +:103410008443D2B21540C800854025431D600CE0AE +:10342000FC311F255C68CE002800B0408443D2B236 +:103430001540C800854025435D6070BD00B5002A79 +:1034400003D101228A40426002E001228A40826068 +:1034500000BD30B403008C000F25596C2800A0403B +:1034600081431540A5400D435D6430BC704730B4C6 +:10347000030001259C6C28008840844315408D4042 +:1034800025439D6430BC704730B403000125DC6CDB +:1034900028008840844315408D402543DD6430BCBE +:1034A000704730B403004C0010340325D96C280059 +:1034B000A04081431540A5400D43DD6430BC7047FA +:1034C000426901228A4042614069704730B403007A +:1034D00001259C6928008840844315408D40254380 +:1034E0009D6130BC704730B403004C000325196C5B +:1034F0002800A04081431540A5400D431D6430BC09 +:103500007047F8B504000025002C30D01600002EBE +:103510002DD00F00326839002000FFF78FFF327A7C +:1035200039002000FFF766FF726939002000FFF7BD +:103530009EFFB26939002000FFF7A6FFF26939004B +:103540002000FFF7AEFF726839002000FFF781FF0F +:10355000F26839002000FFF7C6FF39002000FFF7AE +:10356000AFFF326939002000FFF7B0FF00E0034DE4 +:103570002800F2BD0000CFBF0000304001005A001B +:103580000022030A8B420BD203098B4219D2430853 +:103590008B422ED2411A00D201465241104670474A +:1035A000FF2209023FD012068B4205D312120902F4 +:1035B0008B4201D31212090203098B4219D300E096 +:1035C000090AC3098B4201D3CB01C01A52418309B6 +:1035D0008B4201D38B01C01A524143098B4201D364 +:1035E0004B01C01A524103098B4201D30B01C01A8F +:1035F0005241C3088B4201D3CB00C01A5241830809 +:103600008B4201D38B00C01A524143088B4201D335 +:103610004B00C01A5241884200D3401A5241CFD2C7 +:1036200001461046704708B500F0C4FF08BD000011 +:10363000F8B501231A00002800D151E1002900D17A +:103640004EE1A84A12680C7AA74D1540A4048022C6 +:10365000D20214402C430322CE681640A34D2540CD +:103660003604C024A40234402C430D786E07760F34 +:103670009F4D25403606E024E40434402C438E78E8 +:103680009C4D2540B4078026F60534402C43282560 +:103690004D5D0126B4431D40254334240C5D0226B4 +:1036A000B5436400022634402C430D7F0426B44306 +:1036B000AD000426354025434C7F0826B543E40081 +:1036C000082634402C4340254E5D1E408A4D25403F +:1036D000B4028026F60034402C4340254D5DEDB207 +:1036E0006D081E002E40F6B2844D254074028026DF +:1036F000B60034402C4370254E5D814D2540340288 +:103700008026760034402C4371254E5D16407D4D59 +:1037100025403603C024A40134402C4304600C7AB5 +:10372000002C02D10023836006E00C7A012C02D128 +:103730004B69836000E083600B6A03614B6A4361FD +:10374000CB6A83610B6BC3614B6903628B69436214 +:103750006D4B1C684423CB5CDBB2FF25AC43234399 +:103760004C240C5DE4B2694D1D402404FF231B046E +:1037700023402B435C240C5DE4B21D022D0A23067A +:103780002B436C240C5DE4B2614D1D402402FF23E9 +:103790001B0223402B4343645E4B1B6864240C5D77 +:1037A000E4B25B4D1D402402FF231B0223402B4348 +:1037B00054240C5DE4B2FF25AB431C438464564B98 +:1037C0001C684123CB5C13400325AC432343602496 +:1037D0000C5D1440514E1E40A302C0252D011D401A +:1037E00035434823CE5C16403023DB4330241D4054 +:1037F000370127402F4350254D5D1540484E3E4030 +:103800002F02C025AD003D40354358268E5D164041 +:10381000C027BD43B701C0263E402E4368254D5DFD +:1038200015400C27BE43AF000C253D403543C56411 +:103830003C4D2D6807267227CF5D3740009707273C +:10384000BD432F00009D3D437327CF5D3E40702751 +:10385000BD43370170263E402E43466535254D5DFC +:103860008567314D2D680E7C16400327BD432E43DE +:10387000CD7C1540C027BE43AF01C0253D40354338 +:103880004E7C16400C27BD43B7000C263E402E430D +:103890008D7C2A403340120114401C430465234AA6 +:1038A0001268CB8894B21A0422438B889BB21B0AFD +:1038B0009BB2174C14401B02FF2212021A402243F3 +:1038C0000B78052B05D10979C9B2FF239A4311431F +:1038D00004E04978FF239A43C9B2114301630022EF +:1038E0001000F2BD8CA50010FFFFFBFFFFFFFCFFE7 +:1038F000FFFFFFF8FFFFFFBFFFFBFFFFFFFDFFFF25 +:10390000FFFEFFFFFFCFFFFF90A50010FFFF00FFAE +:10391000FF00FFFF94A5001098A50010FFF3FFFF24 +:10392000FFFCFFFF9CA50010A0A50010A4A500109F +:103930000168802212060A430260704701617047E5 +:103940008160704730B4434351435D18010C0C0053 +:10395000130C5C432D1980B2434392B251430024AF +:10396000C918644124040B04090C09195043C018F8 +:10397000694130BC7047000000B5022801D3012026 +:103980000FE00B00DBB2042B01DB012009E0BE4B92 +:1039900080001818C9B2405C824201D3012000E0C7 +:1039A000002000BDF8B504001F00022C01D3012047 +:1039B00023E015000E002A003100C9B22000FFF7F5 +:1039C000DBFF012801D1012017E03000C0B2002840 +:1039D00016D1FF2F12D8AD4861034018A900401836 +:1039E0008021490140180168A94A0A403902FF2391 +:1039F0001B020B40134303600020F2BD0120FCE7D3 +:103A0000F6B2012E16D180204002874210D29F4884 +:103A100061034018A9004018A0214901401801681D +:103A20009C4A0A403902C94B0B4013430360E3E749 +:103A30000120E2E70120E0E700B20012C0B27047C7 +:103A4000F8B505000F001600280000B2FFF7F4FFDC +:103A500004002000C0B2022801D301202CE0320073 +:103A60003900C9B22000C0B2FFF786FF012801D19A +:103A7000012021E085482100C9B2490341182A00EC +:103A8000D2B292008918C02212018A58B04911405E +:103A9000FFB23B02C02292001A400A43FF218A4330 +:103AA000F1B21143E4B262038018EDB2AA008018AB +:103AB000C022120181500020F2BD70B50400022C1A +:103AC00001D3012028E015000E002A003100C9B200 +:103AD0002000FFF751FF012801D101201CE06B48B5 +:103AE00061034158802212060A43C0218904114310 +:103AF000FF2212040A43964B1340F6B23202C02151 +:103B0000890011401943FF229143EAB20A4361033D +:103B1000425061034058002070BD70B50400022C73 +:103B200001D3012028E015000E002A003100C9B29F +:103B30002000FFF721FF012801D101201CE053489C +:103B4000610341588022D2050A43C02189041143F0 +:103B5000FF2212040A437E4B1340F6B23202C02108 +:103B6000890011401943FF229143EAB20A436103DD +:103B7000425061034058002070BDF8B598B00BA8C2 +:103B8000342100F029FD9A4803C801AA03C2083A6B +:103B900003A8A549202200F0E7FAAA4800780028E7 +:103BA00001D001206DE000252C0007E02800A64987 +:103BB000620088522800A5490855641C062C14D2BE +:103BC000A348142161434118097A0BAA11720BAA68 +:103BD000142161434118496814236343C058FFF717 +:103BE00090FC0028E2D000204BE0684600F0B2F8DC +:103BF000002801D1002044E0009800219622920084 +:103C00000023FFF79FFE934A002312184B41100038 +:103C10001900914A0023FFF7AEFB002800D10120D4 +:103C200003AFB8818D4E300000F054FD01A9300083 +:103C300000F0ECFC002801D0002022E02C0006E07F +:103C4000142060432818C06800F042FE641C062C53 +:103C500013D27F4D142060432818007C787103A98B +:103C6000142060432818C06800F038FD0028E7D011 +:103C7000300000F02FFD002003E0012071490870A2 +:103C8000012019B0F0BD0000B8A4001000000440ED +:103C9000FF00FFFFFF0000FFF8B584B00E00C0B2C8 +:103CA0000290002528006946088004212A0003AC00 +:103CB000200000F081FC2C0062480078002806D02B +:103CC000674F3878002802D10298062801D30020D7 +:103CD00039E0012038705E4802991422514340189F +:103CE000C0680190019800F0A1F8019800F0FCFD77 +:103CF000019800F07FF800281FD003AA69460198B8 +:103D000000F0F8FD002818D103A8C078002814D0CE +:103D1000684600880005000D6946088068460088EE +:103D20004949029A52008852012448480299445451 +:103D3000002E02D0684600883080019800F076F8A6 +:103D40003D702000C0B205B0F0BD000000FFFF00D4 +:103D5000FFFCFFFFF8B5070000200090FF262C367F +:103D60003000FFF769FE0500002F04D0684601F01F +:103D7000D1FA002801D0002038E0009839494018D5 +:103D80003949FFF7FDFB0400002C02D0374884427C +:103D900001D300202AE0042201212800FFF7BDFE04 +:103DA000002801D0002021E0042201213000FFF78B +:103DB00047FE002801D0002018E0631E04220121E4 +:103DC0002800FFF7EFFD002801D000200EE00422BC +:103DD00001212800FFF771FE002801D0002005E036 +:103DE00000982100FFF7CCFB38600120F2BD0000F5 +:103DF000A0A400107CB505001D4C062100226E46D3 +:103E0000300000F0D9FB6946280000F05FFE6846EC +:103E10000078002803D1641E002CF4D101E00120B9 +:103E200000E0002076BD0000E4A20010E0B51149DA +:103E30000CC9083969460CC10839694600F0F0FD23 +:103E400007BD0000801B0028D01A0028E81A0028AF +:103E5000089E0010FFC99A3B00CA9A3B0010904090 +:103E6000811B00285F8DCB00608DCB00010001001D +:103E7000A0860100A8A400100000000000000000BF +:103E8000801C800803D0401C801EFCD100BF7047FE +:103E9000EFF3108072B6704780F31088704740309F +:103EA000016810220A43026070474030016808220E +:103EB0000A43026070470000F8B5012422004C2339 +:103EC000CB5C052B03D24D23CB5C052B01D310001B +:103ED000E2E0002800D1DEE0002900D1DBE06F4AFB +:103EE00012684B7923406E4D15409B048022D2020C +:103EF00013402B4303228D7915406A4E1E402B043C +:103F0000C025AD021D40354307230E7A1E40664F83 +:103F10002F403606E025ED0435403D43CE79634F12 +:103F20002F40B5078026F60535403D430E7D01271D +:103F3000BD4326402E4324254D5D0227BE436D0020 +:103F400002273D40354305600D7906003036009666 +:103F5000009E3668FF27BE43EDB23543009E3560B4 +:103F60004D79012D0BD18C79002C02D10024846075 +:103F700006E08C79012C03D10C68846000E0846039 +:103F8000CC6804610C694461CC6984610C6AC461C9 +:103F90000C680462464C25680C7EE4B2FF26B543EB +:103FA0002C4330254D5DEEB2424D25403604FF24B2 +:103FB000240434402C4340254D5DEDB22402240AF4 +:103FC0002E06264348240C5DE7B2FF2424023A4D16 +:103FD0002E403F02274037434764384E3668382723 +:103FE000CF5DFFB20097FF27BE43009F37432826CF +:103FF0008E5DF6B23D40360234402C438464304C32 +:1040000025688C7D14400326B5432C4325254E5D41 +:1040100016402C4D2540B602C024240134402C43C8 +:104020002C254D5D15403026B4432E0130253540FA +:10403000254334240C5D1440234E2E402502C02419 +:10404000A4002C4034433C254D5D1540C026B443AC +:10405000AE01C0253540254344240C5D22400C248C +:10406000A54394000C2222402A43C264174A1468D4 +:104070004C228A5C1A400725AC4322434D240C5D38 +:1040800023407024A2431C01702323401343436543 +:10409000497D816700221000F2BD000078A5001064 +:1040A000FFFFFBFFFFFFFCFFFFFFFFF8FFFFFFBF6E +:1040B0007CA50010FFFF00FFFF00FFFF80A50010A0 +:1040C00084A50010FFF3FFFFFFFCFFFF88A5001091 +:1040D0000168802212060A43026070477830016846 +:1040E00001220A4302607047C16F0800C007C00F79 +:1040F0007047010070310A68012313430B60006FA1 +:104100007047000030B5010000221000134B1B68FF +:10411000002914D08C786407640F0725AB431C4337 +:104120000B79002B01D00122FFE763005B08D20767 +:104130001A430B4B0024095F89005A5000E00648DF +:1041400030BD00B5074A1368002B03D0126800B2D7 +:104150008000115000BD00000100560074A5001041 +:10416000008020402412002870B4103A03D378C98C +:10417000103A78C0FBD2530701D318C918C001D533 +:1041800008C908C0D20704D30B880380891C801C8F +:10419000002A01D50B78037070BC70478B0701D1E2 +:1041A0008307E1D0124205D00B78491C0370401CF4 +:1041B000521EF9D170470000F7B5AD4BAD4A1568F6 +:1041C000AD4A1668AD4A12680192AD4A1268009273 +:1041D000002422002200002800D11EE10A00002A4B +:1041E00000D11AE15168002901D1936805E05168B6 +:1041F000022901D1230000E0022311695769B94265 +:1042000001D3116900E05169092909D29D4929406A +:104210002125555D002D15D080256D000D4313E03F +:10422000112905D2974F2F408021C9013943EFE76B +:10423000212905D2934F2F40802109023943E7E716 +:104240008B48EBE0904D0D40D168491E0907090FDE +:104250000F27BD4329438D4D0D408021490429433B +:104260000160117F002901D0210000E0012101251A +:104270000127BE43294031430E00516809092940F6 +:104280000227BE434900022739403143D67E10271A +:10429000B943360110273E400E43517E2027BE43CE +:1042A00049012027394031431668012E00D02500EE +:1042B0004E007608E90731430425A9435D00042632 +:1042C00035400D4308218D43D90008231940294367 +:1042D000537F6D4D0D40190280235B001940294327 +:1042E000957F80239B006A4E0E406902194031433E +:1042F000D67F684D0D40B1028026F600314029433B +:104300002025565D644D2940F60280273F013E403E +:104310000E4311798F07BF0F5C4931403F06C0261D +:10432000B6043E400E430662167E009F5649394051 +:10433000360280277F003E400E43977E544931402D +:104340007E021E400E431169491EC906C90E1F2771 +:10435000BE433143C026B60081512421515CC9B20D +:104360000091C121890041180E68FF27BE43009FBC +:1043700037430F60167E019F43493940360280273C +:104380007F003E400E435169491EC906C90E1F27D2 +:10439000BE4331438026B60081512C21515CC9B205 +:1043A00000918121890041180E68FF27BE43009FBC +:1043B00037430F60116B1B4E8151916A184E81512A +:1043C0003821515CF026360186190096009E376828 +:1043D0002F4E3E4049020B40334300990B60392178 +:1043E000515CF4221201821813681D40C902802317 +:1043F0001B011940294311600B49445002980028C1 +:104400000AD002980460029884610298C4600298FD +:10441000C46126480299086223001800FEBD00000E +:10442000C80F0000880F0000C80E00001B49016083 +:10443000C02189040162FF220832C0218900425054 +:104440000021C1239B00C15080239B00C2508122C8 +:1044500092008150124A8150154A8150114A815070 +:10446000114A8150134A8150134A8150704700000D +:104470000140AA0064A5001068A500106CA50010FA +:1044800070A50010FF3FFFFFFFFEFFFFFFFFFFFCD7 +:10449000FFFDFFFFFFFBFFFFFFF7FFFF0F00000324 +:1044A000C80E0000C80F0000880F0000EFCDAB0061 +:1044B000880E0000080F0000480F0000F8B5040047 +:1044C0000D00334F387800280ED02D4E18222D497C +:1044D000300000F069FF0020F075CF2040002A492D +:1044E0000860002000F0C0FF2000290001223A707F +:1044F00080225204002399420AD301D8904207D364 +:10450000244A04000D0024A003C80019694102E0F8 +:104510000200002000211D4B00241C60204D2A6059 +:10452000214A146005221A601A68D203FCD51C6067 +:104530000029DDD10028DBD13C70F1BD70B505004C +:10454000184E306800280ED10D4C18220D4920005D +:1045500000F02AFF0020E075E52040000A490860CD +:10456000002000F081FF2C0030681049FFF708F8A8 +:10457000002122000023FFF7E5F9FFF79FFF70BD40 +:104580005C1A0028A0A000101C1B002810E000E00E +:10459000851B0028FFFFFF00010000FFFFFFFFFF5A +:1045A00014E000E0681B002818E000E040420F0023 +:1045B00070470000704700001206130A1A43130CDC +:1045C0001A4300BF094205D0830707D00270401C80 +:1045D000491EF9D1704700000022FFE7130010398F +:1045E00006D330B41400150010393CC0FCD230BCE6 +:1045F000490700D30CC000D504C0890002D3028053 +:10460000801C002900D5027070470000F8B5020038 +:1046100000231800A94C2468002959D000920A7878 +:104620001207120F009D10352E680F27BE4332432C +:104630002A604A78FF25AC43D2B222438C78002C02 +:1046400001D0012400E01C009D4D15402402802271 +:10465000520014402C430122CE781640994D25403B +:1046600074028026B60034402C430D79002D01D011 +:10467000160000E01E00944D2540B4028026F6008E +:1046800034402C434D79002D01D0160000E01E006F +:104690008E4D254074078026B60534402C438D7915 +:1046A000002D01D0160000E01E00A14D2540B407EA +:1046B0008026F60534402C43C979002901D0130027 +:1046C000FFE762005208D9071143009A116000E029 +:1046D0009848F2BD0021016001617047F0B585B0D6 +:1046E0000C000025019594490E68944909680491CD +:1046F00093490968039193490F68934909680291A6 +:10470000002C00D1D4E00090009800F0E7F8029966 +:104710008020C000084380218900014380204000A0 +:1047200008430222024301210A4304201043009A55 +:10473000106120784007400F07229643304362788B +:104740005207520F702398431201702616400643F9 +:104750000322A07810407D4B33400602C020800029 +:1047600030401843E378002B01D00B0000E02B0011 +:10477000774E0640D80280231B011840304323792E +:10478000002B01D00B0000E02B0046007608D80774 +:104790003043009B186060798006800E049B3F26A2 +:1047A000B3431843A3791340C026B0439B01C026EE +:1047B0001E400643E07943075B0F664830401E0207 +:1047C000E023DB0033400343207A002801D00D00B2 +:1047D000FFE75F4E1E40E80280231B011840304374 +:1047E000657A1540874B03402D03C0208001284087 +:1047F0001843A57A1540844B0340AD03C020000246 +:1048000028401843A3891D052D0D804B03402D041E +:104810007F4828401843A37B0B4045006D08D8070C +:104820002843009B5860E07B4007400F039B07250F +:10483000AB431843237C0B404025A8439B014025F4 +:104840002B400343607C014080208343C8018021CA +:1048500008401843A17C6F4B03400902FF2000026F +:1048600008401843E17CC906C90E6B4B034009049C +:10487000F820400308401843217D0A406749014061 +:104880009205C02000041040084300998860E08A27 +:10489000390C090480B20843218B82B2080410430A +:1048A0000099C86021001A31009800F055F801E025 +:1048B00020480190019805B0F0BD0000D4A400107C +:1048C000FFFEFFFFFFFDFFFFFFFBFFFFFFFFFFDF1F +:1048D0003830016801220A430260704738300168AD +:1048E00001229143016070473C30016801220A4374 +:1048F0000260704710B503000020002916D0002A7E +:1049000014D05B6A1C000C80190F0C00E407E40F44 +:104910001470990F0C00E407E40F9470590F0C0009 +:10492000E407E40F5470D90FD17000E0014810BDC6 +:10493000FFFFFFBF0100DA00D8A40010DCA40010C4 +:10494000E0A40010E4A40010E8A40010FFFCFFFFA6 +:10495000FFF7FFFFFFF8FFFFF0B50A000023190083 +:104960006F4C2468002A49D05579002D01D00126CA +:1049700000E01E006B4D2540B4028026F600344056 +:104980002C431579002D01D0012600E01E00664D54 +:10499000254074028026B60034402C43D578002D83 +:1049A00001D0012600E01E00604D25403402802623 +:1049B000760034402C435578002D01D0012500E0CD +:1049C0001D000226B4436D000226354025431478AD +:1049D000002C01D0012400E01C0001260127BD436A +:1049E00034402C439278002A00D11E0004229443C4 +:1049F000B20004231A402243826100E047490800C4 +:104A0000F0BD0000FFCFFFFFFF3FFFFFFFFF00F003 +:104A10000000FF0FFF00FFFFFFFFE0FFFFFF3FFF72 +:104A2000F0B50A0000231900414C2468002A49D03F +:104A30005579002D01D0012600E01E00394D25409A +:104A4000B4028026F60034402C431579002D01D0A5 +:104A5000012600E01E00344D254074028026B60079 +:104A600034402C43D578002D01D0012600E01E00F3 +:104A70002E4D254034028026760034402C43557854 +:104A8000002D01D0012500E01D000226B4436D0079 +:104A90000226354025431478002C01D0012400E083 +:104AA0001C0001260127BD4334402C439278002A84 +:104AB00000D11E0004229443B20004231A40224372 +:104AC000026100E015490800F0BD000010B50200C9 +:104AD000002000291FD01269930A1C00E407E40F8C +:104AE0004C71530A1C00E407E40F0C71130A1C00FC +:104AF000E407E40FCC7053081C00E407E40F4C708B +:104B000013001C00E407E40F0C7092081300DB078D +:104B1000DB0F8B7000E0014810BD00000100DA00DF +:104B2000ECA40010FFFBFFFFFFFDFFFFFFFEFFFFF8 +:104B3000F0A4001000B5012027490A68520802407D +:104B4000002A01D0022001E00968084000BD274882 +:104B500001680800C007C00F704700B5C21E022AD6 +:104B600001D3012002E0C01E0860002000BD1CB57A +:104B7000FF246946FFF7F1FFC0B2002802D02000F1 +:104B8000C0B207E03C480099890041580800C007BE +:104B9000C00FC0B216BD00B5002801D0032801D354 +:104BA000012002E0401E0860002000BD1CB5FF246B +:104BB0006946FFF7F0FFC0B2002802D02000C0B263 +:104BC00008E03448009909014018C1680800C0078E +:104BD000C00FC0B216BD0000241526402E48006844 +:104BE00081080800C007C00F70470000401526402C +:104BF00038B504002000C0B208280DD3274D182274 +:104C00002749280000F0D0FB0020E87525482649F8 +:104C10000860002000F028FC24482100C9B2890067 +:104C200041580A00D205D20F002A05D0E4B2A100F3 +:104C300040580007000F00E0FF2032BD38B50500E6 +:104C40002800C0B208280ED3144C182214492000A2 +:104C500000F0AAFB0020E0758920000112490860DD +:104C6000002000F001FC1148EDB2A90040580009F5 +:104C70008007800F32BD0000401626400F480068B4 +:104C8000000AC0B270470E480068000AC0B2704700 +:104C90000000000000192640100027405C1A002880 +:104CA00008A000105B0800001C1B002840122640D2 +:104CB00004480068000AC0B270470000101020408D +:104CC0000C1020400810204038B50400072C0ED3EB +:104CD000124D18221249280000F066FB0020E875EA +:104CE000CE2040000F490860002000F0BDFB0A48BC +:104CF000A10040584007400F010009B2042907D124 +:104D00000448A1004058C106C90E80204000084355 +:104D100000B232BD00012640001226405C1A002875 +:104D200054A000101C1B00281E4909688A03920B1E +:104D30000260090E0A00D207D20FC2711749096832 +:104D40000A00D204D20C82800A00120CD2B2028174 +:104D5000090CC9B24181264909680F220B00134092 +:104D600003730B091A4042730A0AD204D20CC2819F +:104D7000090F8907890F0174094909680A0A52074E +:104D8000520F82710A00120CD205D20D4282890F95 +:104D90000A00D207D20F0275704700003415264072 +:104DA0003C152640301526401CB50C006946FFF71F +:104DB000D4FE0100C9B2002901D0C0B217E00D48ED +:104DC0000099890040584106490E21601F21020ABE +:104DD0000A406260020C1140A160010F8907890F2F +:104DE0006173C00E0100C907C90F2173002016BDF1 +:104DF00038152640001626401CB50C006946FFF702 +:104E0000CAFE0100C9B2002901D0C0B233E01A487D +:104E1000009909014158CAB222601F220B0A1340AF +:104E200063600B0C1A40A260090F8907890F217378 +:104E30000099090141184A68D10F61730721130FC6 +:104E40000B40002B01D0012300E00023A3731202CA +:104E5000120A2261009A120180188068C20F22751E +:104E6000020E1300DB07DB0F63750200A275000C56 +:104E70000140E175002016BD0019264080B50100F3 +:104E800086481822FFF770F9012085490870002034 +:104E900002BD000000B583490968C90F002907D089 +:104EA0007F490978002907D100210160032000BD56 +:104EB000002101600320FAE77849096801600020B9 +:104EC000F5E7000010B50400FFF734FE002803D119 +:104ED0000020206003200BE071480078002804D0F7 +:104EE0006E4880682060002002E0002020600320DF +:104EF00010BD00B56A490978002904D0674949689E +:104F00000160002002E000210160032000BD0000DC +:104F100000B565490968C90F002907D060490978BB +:104F2000002907D100210160032000BD002101609C +:104F30000320FAE75949C96801600020F5E700003D +:104F400000B55A490968C90F002907D054490978A2 +:104F5000002907D100210160032000BD002101606C +:104F60000320FAE74D49096901600020F5E70000D8 +:104F700010B50400FFF732FE002803D100202060A6 +:104F800003200BE046480078002804D043484069DD +:104F90002060002002E000202060032010BD0000FF +:104FA00010B50C00C0B2FFF78FFE00280CD0401ED9 +:104FB0000ED0401E10D087214900401A10D0401E4C +:104FC00012D0C01E14D017E02000FFF763FF16E0D8 +:104FD0002000FFF78EFF12E02000FFF773FF0EE0C6 +:104FE0002000FFF795FF0AE02000FFF7C1FF06E071 +:104FF0002000FFF7A5FF02E000202060032010BD85 +:1050000080B501000020FFF7CBFF02BD80B5032271 +:10501000D2B2824203D200200860012003E0401C8B +:10502000C0B2FFF7BDFF02BD30B587B004006846CF +:10503000FFF7E6FF0100C9B2002903D0002121607B +:10504000C0B228E001A8FFF76FFE01AD287C02285E +:1050500003D10098206000201DE07B480068C00F4D +:10506000002808D0FFF773FDC0B2002807D1002048 +:10507000206003200FE00098206000200BE00098E3 +:1050800001994843A988FEF77BFAE979491CFEF7A4 +:1050900077FA2060002007B030BD0000441A0028D5 +:1050A000841B002818152640081526400C1526409C +:1050B000F0B58BB004000D0069462000C0B2FFF7C8 +:1050C000A5FF0100C9B2002903D000212960C0B2A8 +:1050D000B7E02000C0B2022848D307A92000C0B220 +:1050E000401CFFF761FEC0B200280DD0824E18228E +:1050F0008249300000F058F90020F075804881495D +:105100000860002000F0B0F97F482100C9B2890092 +:10511000401808380068C00F002807D007A8407B57 +:10512000022807D10098286000208AE000982860B3 +:10513000002086E0E4B2641C2000FFF718FD0100A7 +:10514000C9B2002902D0C0B2FF2803D100202860D4 +:10515000032076E00098079948430899FEF710FA73 +:105160000999FEF70DFA286000206AE001A92000E5 +:10517000C0B2401CFFF740FEC0B200280DD05E4E0A +:1051800018225E49300000F00FF90020F0755F48EA +:105190005C490860002000F067F95D482100C9B251 +:1051A00009014058C00F002807D001AE307B02280B +:1051B00007D100982860002043E000982860002074 +:1051C0003FE0E4B2641C2000FFF7F0FC0100C9B22C +:1051D000002902D0C0B2FF2803D10020286003209C +:1051E0002FE0707B012821D100980021019E00272B +:1051F0003F06320A17433606059A002392197B416F +:10520000FEF7A0FB06000F0002980021039A00237E +:10521000FEF798FB02000B0030003900FEF7ABF8F8 +:10522000000E0A021043090E286009E00098019957 +:1052300048430299FEF7A4F90399FEF7A1F9286003 +:1052400000200BB0F0BD00003015264010B5032340 +:10525000421E0400E4B2002C03D10800FFF7E4FE74 +:105260000CE01400DBB2E4B2A34204D31000C0B2DD +:10527000FFF71EFF02E0C0B2FFF792FE10BD38B587 +:1052800004000D002000C0B2FFF7B2FC0100C9B25B +:10529000FF2904D12800FFF7FDFDC0B21BE06946DD +:1052A000C0B2FFF7D3FF0100C9B2002903D000212B +:1052B0002960C0B20FE02000C0B2FFF7BFFCC0B24F +:1052C000002805D002280BD006D303280CD00FE00D +:1052D00000982860002032BD009840082860F9E757 +:1052E000009880082860F5E70098C0082860F1E77A +:1052F000002028600320EEE75C1A002854A000106C +:10530000630900001C1B0028001626407F090000CE +:10531000001926401CB5040069460020FFF7AFFFC6 +:105320000100C9B2002903D000212160C0B209E008 +:10533000FFF7A9FC01000098C9B2491CFEF720F94B +:105340002060002016BD1CB5040069460020FFF750 +:1053500096FF0100C9B2002903D000212160C0B22C +:1053600008E0FFF78BFC01000098491CFEF708F9E4 +:105370002060002016BD1CB504006846FFF7E3FF5F +:105380000100C9B2002901D0C0B208E0FFF790FCCB +:1053900001000098491CFEF7F3F82060002016BDBC +:1053A00080B5FFF7E8FF02BD71B58B0709D0521E2B +:1053B00028D30B780370401C1B421FD0491C8B075D +:1053C000F5D1121F3ED3274CE50140C9331BB3432F +:1053D0002B4209D1830716D140C0121F32D340C9D6 +:1053E000331BB3432B42F7D0121DFF231E4205D0BF +:1053F0000670401C360A521E1E42F9D111000022CE +:10540000FFF7E0F871BDDB4216D0401E4670360A49 +:105410004680360C0671001D121F06D340C9331B8F +:10542000B3432B42F2D0401CDEE7401C0AE040C9E7 +:10543000331BB3432B42D7D10680360C4680001D68 +:10544000121FF4D2121D0BD00B780370521E07D01E +:10545000401C491C1B42F7D10370401C521EFBD15B +:1054600071BD00000101010100B501BEFEE780B57C +:105470000C4A0D490B68934210D00A6002684A60DA +:1054800042688A608268CA60C2680A6102694A61C9 +:1054900042698A618269CA61C069086200F008F8DD +:1054A00001BD0000F3F2F1F0201A002870477047A8 +:1054B00000B5FEE700B5401A002801D4012000E045 +:1054C0000020C0B200BD00B5401A00B2012801DBC7 +:1054D000012000E00020C0B200BD00B500281BD0B4 +:1054E000002919D002780B789A4215D142784B786E +:1054F0009A4211D182788B789A420DD182888B881A +:105500009A4209D1C288CB889A4205D1008909897B +:10551000884201D1012000E00020C0B200BD00B5EA +:105520000100C9B2042903D28B49C0B2085C00E073 +:105530000420C0B200BD10B50C000100C9B204299E +:1055400001D2002C01D1002006E0C0B2FFF7E7FF36 +:105550002100C0B201F044FD10BD10B50400002CC4 +:1055600002D06078042801D3002011E02089401D7A +:105570000A21FEF705F8322800D23220602800D335 +:105580005F206178032902D1332800D33220C0B2D2 +:1055900010BD00B5714B1870714B1970002A05D001 +:1055A0001178704B197091886F4A1180C0B2002831 +:1055B00003D001206D49087002E000206B4908709B +:1055C00001206B49087000BDF8B50500002D05D01D +:1055D0000C00002C02D01600002E01D100203BE070 +:1055E0000A21002227003800FEF7E6FF68682070D5 +:1055F0006868000A03210A00024062700200D2B209 +:1056000092080A40A2706A68120CA280AA68E2801E +:10561000AA68120C22812A68C0239B009A4202D1F8 +:105620002A7B082A03D001203070002014E0F022E9 +:105630000240102A03D00220307000200CE02078B5 +:10564000C01E05D0401F012802D9001F012801D823 +:10565000012001E031700020F2BD00B5002801D02A +:10566000002901D1002055E04278042A03D3042008 +:10567000087000204EE00278092A06D14278002AFC +:1056800003D004200870002044E00278032A1FD1D0 +:105690008278012A06D08278022A03D00520087079 +:1056A000002037E0C28896235B009A420CD3B44AAC +:1056B00043789B00D258C3889A4205D30089B14AE7 +:1056C0008018B14A904222D306200870002021E0C1 +:1056D0008278002A03D00520087000201AE00278A2 +:1056E0000D2A0AD1C288A94B9A4202D1008900280A +:1056F0000DD00620087000200CE0C288002A02D1DC +:105700000089002803D006200870002002E0002055 +:105710000870012000BD80B58EB00100C9B2042917 +:1057200015D29B490200D2B20C235A438918897AB8 +:1057300000290AD16946C0B2FFF7FDFE002806D055 +:1057400068463221405C002801D0012000E00020A2 +:10575000C0B20FB000BD0000A8A50010961B002825 +:10576000971B0028981B00287C1B00289C1B0028E6 +:10577000FC12002838B50024250007E02000C0B244 +:10578000FFF7C9FF002800D06D1C641C2000C0B2C8 +:105790000428F3D32800C0B232BD38B500252C0050 +:1057A0000AE02800C0B2FFF7B6FF002803D02000AF +:1057B0000124AC4004436D1C2800C0B20428F0D37F +:1057C0002000C0B232BD38B58EB000252C001BE0E1 +:1057D00069462800C0B2FFF7AEFE002813D0684625 +:1057E000417804290BD04178062908D0417807294F +:1057F00005D04178082902D04078092803D120003B +:105800000124AC4004436D1C2800C0B20428DFD33F +:105810002000C0B20FB030BD38B58EB000252C00CE +:1058200010E069462800C0B2FFF785FE002808D0C6 +:1058300068463421405C002803D020000124AC409D +:1058400004436D1C2800C0B20428EAD32000C0B273 +:105850000FB030BDF8B504002000C0B2042837D224 +:105860000F002000C0B2FFF75AFE060001253900E4 +:1058700089B22800B040C0B2FCF773FEFCF776FD99 +:10588000390089B23000C0B2FCF7D7FDBFB2390091 +:105890003000C0B201F0BAF800203D492200D2B277 +:1058A0000C235A43891888723A490A782800A04084 +:1058B00082430A70C04801782A00A2400A4302705D +:1058C000BE4801782A00A2400A430270BC48057015 +:1058D000F1BDF8B50700380080B2FCF795FEFCF783 +:1058E00045FD0026340018E02000C0B2FFF717FE87 +:1058F0000500390089B22800C0B2FCF79EFD3900CE +:1059000089B22800C0B201F081F821482100C9B253 +:105910000C22514340188672641C2000C0B2042837 +:10592000E2D31C4806700F20A3490870A3490870F1 +:105930000120A3490870F1BD38B501004C78144D21 +:105940002000C0B20C22504328180A22FEF726FC81 +:10595000012001002200D2B20C235A43AA189172EE +:105960000C490A780300A34013430B7092490A784C +:105970000300A34013430B709149087031BD000030 +:1059800074A40010D4FEFFFFBD0200003CC3000061 +:10599000F0190028991B00287FB5012421006A46D0 +:1059A000117088490A68521C0A606A4601A9FFF70B +:1059B0000BFE002805D0694601A8FFF74EFE00281F +:1059C0000AD181480168491C016001AA6846017832 +:1059D0000220FFF7DEFDDFE001AD2878092836D090 +:1059E000B748017800291ED0A988B64A128891428A +:1059F00019D1B548007800280BD0B44901A8FFF7A9 +:105A00006CFD002805D001AA00210320FFF7C1FD8D +:105A100008E06D480168491C016001AA08210220C4 +:105A2000FFF7B7FDB8E00078002810D0A54801883E +:105A3000A888FFF748FD002809D163480168491C80 +:105A4000016001AA08210220FFF7A3FDA4E02000C5 +:105A50009B49087020009C490870A88899490880D3 +:105A60009A4801A94CC94CC00C390C38287809282F +:105A70000CD1A888FFF72DFF01AA00210420FFF711 +:105A800088FD93480168491C016085E02878082852 +:105A90000DD1A9886878FFF7DDFE01AA0021012059 +:105AA000FFF777FD8A480168491C016074E0287897 +:105AB0000C2813D1687884403F4801782143017055 +:105AC0003E4801780C43047001AA00210120FFF731 +:105AD00060FD7F480168491C01605DE028780D2861 +:105AE0003BD16878FFF717FE002809D0B24801685B +:105AF000491C016001AA09210220FFF74AFD4BE081 +:105B000001F05AFC00280BD1FCF794FC002807D1C7 +:105B10006878FFF704FDC0B201F036FA002805D11D +:105B200001AA0B210220FFF734FD35E0224801785D +:105B30006A78230093400B430370204801786A7809 +:105B400094400C43047001AA00210120FFF721FDBD +:105B50005F480168491C01601EE001F02DFC00282F +:105B600005D001AA0B210220FFF713FD14E068788D +:105B7000FFF7D1FD002810D0A9886878FFF76AFEEA +:105B800001A8FFF7D9FE01AA00210120FFF701FDBE +:105B90004F480168491C01607FBDFFF7EBFD0428F9 +:105BA000EED385480168491C016001AA0A21022040 +:105BB000FFF7EFFCF0E70000FD120028FE120028BE +:105BC000FC120028341B0028381B002838B50400BC +:105BD000002C02D02078032801D000200FE0200004 +:105BE000FFF7BBFC05006078FFF799FCA1880091E6 +:105BF0002B00DBB2E288A178C0B201F061F932BDBE +:105C0000F8B584B0FCF7A5FF009001F0D5FB0028A3 +:105C100006D16A4801680098FFF74CFC002800D1C3 +:105C20004BE0002000E0401C0100C9B2042944D22E +:105C30000326634F39780D183540624C2900C9B2EC +:105C40000C2251436118897A0029ECD001A829005F +:105C5000C9B20C22514361180A22FEF79FFA0020B4 +:105C60002900C9B20C225143611888720124564997 +:105C70000A782000A84082430A702800401C064091 +:105C80003E70009864304D49086001A8FFF79EFF00 +:105C9000002804D101AA0E210220FFF77AFCA648B1 +:105CA00001782200AA400A430270A4480178220029 +:105CB000AA400A430270A248047005B0F0BD00007B +:105CC0009A1B00287E1B00289B1B0028DC1A00283A +:105CD000401B002800B502780B789A4227D1427801 +:105CE0004B789A4223D182788B789A421FD1C2781E +:105CF000CB789A421BD142684B689A4217D1302226 +:105D0000825C3023CB5C9A4211D13222825C3223F6 +:105D1000CB5C9A420BD13122825C3123CB5C9A421C +:105D200005D13422805C3422895C884201D0012074 +:105D300000E00020C0B200BD38B58EB0002435E0D0 +:105D400069462000C0B2FFF7F6FB00282DD07D4D3C +:105D50002000C0B2285C00280AD07B482100C9B2CC +:105D60003822514341186846FFF7B4FF00281CD081 +:105D700075482100C9B2382251434018694638227B +:105D8000FEF7F2F9012001002200D2B2A9546A49BB +:105D90000A780300A34013430B7068490A78030094 +:105DA000A34013430B7066490870641C2000C0B206 +:105DB0000428C5D30FB030BD3C1B0028541B00285D +:105DC0009E1B0028F0190028991B002800B50078B8 +:105DD000002808D0012808D0032808D0042804D0BF +:105DE000092806D007E0002006E0012004E0032097 +:105DF00002E0062000E0072000BD00B54078002842 +:105E000010D0012810D0022810D0032810D0042868 +:105E100012D005280ED0062810D0072810D0092847 +:105E200010D011E0002010E001200EE002200CE074 +:105E300003200AE0042008E0052006E0062004E034 +:105E4000072002E00B2000E0092000BD80B501F032 +:105E5000B3FA002808D001F0B9FA002801D10320D4 +:105E600000E00420C0B210E0FFF7ADFC002803D131 +:105E700001F0ACFA002801D0042006E0FFF78DFC09 +:105E8000002801D0022000E0012002BDF8B5FFF794 +:105E900084FC6946C870FFF796FC0400FFF7BCFC61 +:105EA000694688702000C0B2002803D101F08EFA44 +:105EB000002801D0012500E00025884807780F263A +:105EC0003740FFF7C3FF00013843694648708448F4 +:105ED0000078694608708348077801F06DFA002859 +:105EE00001D0102100E000216846C0783040014315 +:105EF000E80108432640694689780A0132437A4915 +:105F00000978F0230B43694649786C4624782402CB +:105F10000C437649098809042143FFB2C0B200024C +:105F20003843D2B214040443DBB21A062243704849 +:105F300001F05EFCF2BD0000FD120028FE120028F8 +:105F4000FC1200284C1B002810190028F1B594B051 +:105F5000684814A909780C2251434018019006A9F9 +:105F600014A80078FFF7E7FA002801D10020B3E079 +:105F700006A8FFF72BFF070006A8FFF73EFF040067 +:105F800006ADE878694608703220285C002802D007 +:105F900030202E5C00E0002607986946488055486E +:105FA00014A909780C2251434018807A00280DD09A +:105FB000012427000198807869460870019880883C +:105FC000694648800198FFF7C8FA06003220285C2D +:105FD000002808D1474814A909780C2251434018D9 +:105FE000807A002802D00420059001E0002005906E +:105FF0003420285C002802D00820049001E0002012 +:1060000004903420285C002805D00998002802D18B +:106010001020039001E0002003903420285C002829 +:1060200009D00A98002806D009980A99884202D314 +:106030002020029001E0002002906878042802D11C +:106040004020019001E0002001906878072803D0EB +:10605000FCF7F0F9002801D0802000E0002069461C +:1060600009788907890F059A0A4304991143039A0D +:106070000A4302991143019A0A4310433421695C8F +:10608000002907D00D997C4A914201D37B4A02E056 +:106090000D9A00E07A4A14A90978FFB23B071B0F5A +:1060A000E4B22401F02525401D432B020B43C0B26E +:1060B0000004184369464988090601436846408838 +:1060C00080B2000A80B292B213020343F6B23206E3 +:1060D0001A436C4801F08CFB15B0F0BD961B0028EC +:1060E000971B0028981B0028A51B00287C1B002854 +:1060F00001030000F019002878B585B004002000E5 +:10610000C0B2042809D22000C0B2FFF708FA6946DD +:10611000C0B2FCF72FFD002801D0002031E0684616 +:106120004178002901D0012100E000218278002A75 +:1061300001D0022200E00022C378002B01D004230A +:1061400000E000230579002D01D0082600E000269C +:106150004579002D01D0102500E000250A431343A6 +:106160001E433543E4B2EDB22A022243C18809043A +:1061700011430389808902041A43C120800001F081 +:1061800037FB06B070BDF1B590B001A910A800783A +:10619000FFF7D1F9002801D1002063E08020400200 +:1061A0000499814203D336496A46118002E004997A +:1061B0006A4611800599814201D3314F00E0059F65 +:1061C000079819303221FDF7DBF90400FF2C00D9C4 +:1061D000FF2401AD6878042801D1012600E00026E3 +:1061E0006878072803D0FCF725F9002801D00220A1 +:1061F00000E000206978082901D1042200E0002293 +:106200006978092901D1082100E0002130430243C7 +:1062100011430F20AA780240090111433122AA5CE0 +:106220001040974A10AB1B78D25C1201024310A8B1 +:106230000078C9B20B020343684600880104194381 +:10624000BFB2E4B220043843D2B212060243B148CE +:1062500001F0CEFA002801D1002003E010A8007858 +:10626000FFF74AFF11B0F0BD80B58649864AAA48BB +:1062700001F0BEFA02BD0000112700001027000047 +:10628000FFFF000002030000F8B5FCF762FC040009 +:106290007E4D29682000FFF70DF9002805D0012068 +:1062A0007B4908702000643028607A4D29682000FE +:1062B000FFF700F9002806D0012098490870FA205D +:1062C00040002018286096480078002814D195488E +:1062D00001682000FFF7EEF800280DD0924A93489D +:1062E00000781070012092490B78127805009540D3 +:1062F0001D430D708A4908708E480078002814D11B +:10630000A74801682000FFF7D5F800280DD0A54A5E +:10631000A54800781070A54901200B781278050077 +:1063200095401D430D7083490870594D2878002809 +:106330001ED0FFF7ABFD002815D0002028709C4927 +:106340000A78521C1207120F0A709A490A78002A1A +:1063500004D0994A1070994A1070087098480168E2 +:10636000491C016003E097480168491C016085E011 +:106370006A4D287800280FD0FFF776FF002806D056 +:10638000002028708E480168491C016003E08D4898 +:106390000168491C016071E0002500E06D1C2800C7 +:1063A000C0B204282CD20127AF40614E3078384269 +:1063B000F4D02800C0B2FFF7C9FD00281BD0307808 +:1063C000B84330705648017800290FD056490978F3 +:1063D000EDB28D420AD155490A78521C9207920FAC +:1063E0000A700021017019344E4804607448016835 +:1063F000491C016003E073480168491C01603DE0ED +:10640000002500E06D1C2800C0B2042836D2012708 +:10641000AF40664E30783842F4D02800C0B2FFF763 +:10642000B2FE002825D03078B843307014482900D7 +:10643000C9B2415C491C0907090F2A00D2B2815434 +:106440003C48017800290FD056490978EDB28D42B9 +:106450000AD155490A78521C9207920F0A700021FE +:10646000017019344E48046055480168491C0160A8 +:1064700003E054480168491C0160FFE7F1BD0000DA +:10648000501B0028505354310F110401581B002891 +:10649000FC120028641B002870B530210024220063 +:1064A000494D2800FEF788F8E0212200474D2800DA +:1064B000FEF782F8042529002200454E3000FEF741 +:1064C0007BF82200434C29002000FEF775F800F00D +:1064D00093FA01F005F9002804D1062000F03CFFF2 +:1064E000002016E0FCF735FB3B4908603B4908609B +:1064F0000C4908602A490860394908600120394977 +:1065000008700F210A4A1170284A11700349087057 +:10651000012070BD0303000007030000FF120028E4 +:10652000A31B00285C1B0028A11B00289F1B002820 +:10653000FD120028A41B00281FB52B480068002866 +:1065400002D0022000F008FF00F01EFF00F034FF30 +:1065500000280AD0254C2078002812D12448008831 +:10656000FFF7B7F9012020700BE000201F490870E9 +:1065700007E00098C0218900884202D16846FFF7F1 +:106580000BFA684601F0F4F80028F2D1FFF738FB67 +:1065900000F01EFBFFF7D0FBFFF776FE1FBD0000EB +:1065A000601B0028A21B0028A01B0028FE12002848 +:1065B000A51B00289C1B0028961B0028971B002861 +:1065C000441B0028481B0028F01900281019002837 +:1065D0004C1B0028501B0028541B0028581B002867 +:1065E000641B0028FC120028141B00289D1B002897 +:1065F0007C1B002800B5C0B2042801D2012000E0B5 +:106600000020C0B200BD00B50100C9B2002901D10F +:106610006F4813E00100C9B2012902D1AF20400147 +:106620000CE00100C9B2022901D16A4806E0C0B2FB +:10663000032802D1E120400100E0002000BD00B5A8 +:10664000401A002801D4012000E00020C0B200BDA3 +:1066500000B5884201D3401A00E0081A00BD10B509 +:1066600000280FD00C00002C0CD06178002909D034 +:106670002169002906D0A168002903D02169826818 +:10668000914201D2002019E080680021A268002315 +:10669000FDF758F902000B0020694008002180181E +:1066A000594122690023FCF766FE6168FFF7D0FFBD +:1066B000052801D2012000E00020C0B210BD00B5C5 +:1066C000C0B232382E2801D2012000E00020C0B232 +:1066D00000BD00B50178022905D00178012904D157 +:1066E0004078022801D1012000E00020C0B200BDA6 +:1066F00000B50178032905D00178012904D140783B +:10670000032801D1012000E00020C0B200BD38B54F +:106710000C00BD490200D2B23C235A438D18C0B2CE +:10672000FBF7C5FF0A202870AC70002038216854A0 +:1067300031BDB549C0B23C2250430818092101704F +:10674000022181700021382281547047AE49C0B2C5 +:106750003C22504308180021017003228270382225 +:106760008154704770B505000C002800C0B2FFF7D7 +:1067700041FF002807D0FBF75DFE002803D18020F1 +:106780004002844201D3002020E02800C0B2FBF781 +:106790008EFF002807D1210089B22800C0B2FBF784 +:1067A0004CFE002801D0002010E09748EDB23C21BB +:1067B0004D4345193C2100222E003000FDF7FCFE20 +:1067C0006C600120A87038216854012070BD000061 +:1067D000FC530000901A0000F2B59EB007001600AE +:1067E0001C00002001903800C0B2FFF703FF002812 +:1067F00019D029A80078022803D029A800780328F6 +:1068000011D13800C0B2FFF7FEFE1E9988420AD3AC +:1068100005A93800C0B2FCF735FA002803D105A855 +:106820004078002801D10020E4E0774D3800C0B264 +:106830003C2148432818049004983621405C69465E +:1068400048706846067027A80078002807D1684677 +:106850000078FFF734FF002801D10020CAE0089932 +:106860001E98FFF7F5FE02900394269E289C27A809 +:10687000007800281CD00398002802D00298002835 +:1068800016D10298002811D1E4B2002C08D13100B1 +:106890003800C0B2FFF766FF002801D10020A9E050 +:1068A0003800C0B2FFF745FF0120A3E00020A1E0BF +:1068B0000FA804993C22FDF757FC2000C0B2002825 +:1068C00008D131003800C0B2FFF74CFF002801D1D9 +:1068D00000208FE03800C0B23C2148432D18E4B2BC +:1068E000002C0CD028000FA93C22FDF73DFC012014 +:1068F0000100A97001003822A95469464870249CFF +:1069000027A8007800282FD02598002801D00190D2 +:1069100018E0002C01D0019414E002980021FA2222 +:1069200092000023FDF70EF802000B0003980021EF +:10693000801859410022401E9141039A0023FCF720 +:106940001AFD01900198002807D0029A0199380099 +:10695000C0B2FCF73BFA002815D008213800C0B2BD +:10696000FFF7D5FE002045E0684601783800C0B248 +:10697000FBF784FF002806D008213800C0B2FFF7DB +:10698000C6FE002036E029A800786870002C01D0EF +:10699000012001E029A8007828701E98A860EC600A +:1069A000019828610020A861039828626846007851 +:1069B0003521685427A80078372168546846407804 +:1069C000362168541E980899884201D3012000E0BE +:1069D0000220342168541E993800C0B2FCF7FAFA3C +:1069E000002806D008213800C0B2FFF790FE002032 +:1069F00000E001201FB0F0BD10B5F0210022724C64 +:106A00002000FDF7D9FD10BD20180028F8B50500BD +:106A10000C002800C0B2FFF7EDFD002813D02800BD +:106A2000C0B2FBF744FE6848EDB23C214D43451926 +:106A30003C21002632002F003800FDF7BDFD6C60C0 +:106A40002E700320A870F1BD38B50400002505E0C4 +:106A500021002800C0B2FFF7D9FF6D1C042DF7D329 +:106A600031BD78B585B0040057482300DBB23C2522 +:106A70006B43C5182878082835D1696889B2200089 +:106A8000C0B2FBF739FD002804D12000C0B2FFF7E7 +:106A90005DFE96E02000C0B2FBF795FF002800D015 +:106AA0008FE0286B002806D0296B2000C0B2FBF7CE +:106AB000F5FF002812D0696889B22000C0B2FBF748 +:106AC0001BFD002804D12000C0B2FFF73FFE04E008 +:106AD00009212000C0B2FFF71AFE72E02000C0B208 +:106AE000FFF727FE6DE0C87804280BD0C87802288D +:106AF00008D0C878032805D0C878052802D0C878FF +:106B0000012805D109212000C0B2FFF700FE58E09E +:106B10002878012855D01600287806281BD1E86A65 +:106B2000FA21490041183000FFF789FD002811D0F3 +:106B300069462000C0B2FBF779FF002805D0092183 +:106B40002000C0B2FFF7E3FD3BE00398A862EE62CD +:106B50000720287035E0E86AFA21890041183000E2 +:106B6000FFF76DFD00282CD069462000C0B2FBF76E +:106B70005DFF002805D009212000C0B2FFF7C7FD46 +:106B80001FE0E86A361A0398A96A401A00210F4AE2 +:106B90000023FCF7D7FE32000023FCF7ECFB0600D5 +:106BA000002E05D02000C0B2FBF781FD002805D0E3 +:106BB00009212000C0B2FFF7AAFD02E02E630820E1 +:106BC000287006B070BD000020180028A0860100C3 +:106BD000F8B592B0FBF7CAFBFBF7BBFF06900020AD +:106BE00007900CE006212800C0B2FFF790FD03E0FB +:106BF0002800C0B2FFF79DFD0798401C079007983A +:106C0000042800D358E1079DAC4807993C22514322 +:106C100044183820205C002806D008A92800C0B2FB +:106C2000FCF730F8002800D0E6E7FBF703FC00286B +:106C300003D108AEF078072805D107212800C0B29B +:106C4000FFF765FDD8E7616889B22800C0B2FBF79D +:106C500053FC002804D12800C0B2FFF777FDCBE732 +:106C6000F078062807D12078082804D02800C0B280 +:106C7000FFF76CFDC0E72078012816D1B078002816 +:106C800013D0607820702078062802D10698E06240 +:106C90000BE0E068002806D00698E16840186169BA +:106CA0004018A06101E00020A061207806280BD0E8 +:106CB0002078072808D02078082805D020780128D7 +:106CC00009D16078062806D1069A08A92800C0B222 +:106CD000FFF7C7FE90E7207804282FD1E1690698D6 +:106CE000FFF7ADFC002828D06078059001200490C3 +:106CF0003720205C03906068029020690190E06872 +:106D00000090236A3520225CA1682800C0B2FFF7FA +:106D100063FD002811D1616889B22800C0B2FBF779 +:106D2000EBFB002804D12800C0B2FFF70FFD04E000 +:106D300004212800C0B2FFF7EAFC5DE7F0780228E2 +:106D400002D0F078032801D1012700E00027F07875 +:106D5000042803D03800C0B2002852D02000FFF72A +:106D6000B8FC00280AD008A92000FFF778FC00280A +:106D700004D02800C0B2FFF7DCFC3DE72000FFF79D +:106D8000A8FC002823D03620205C00281FD1280032 +:106D9000C0B2FBF78CFC00280CD0FBF74BFB0028A3 +:106DA00001D0072100E00821C9B22800C0B2FFF7D6 +:106DB000AEFC21E7042020700020A0610698FA2193 +:106DC000C9004018E06101203621605418E0380005 +:106DD000C0B2002809D02000FFF78AFC002804D0A8 +:106DE0002800C0B2FFF7A5FC0AE0FFB2002F01D0D7 +:106DF000052100E00421C9B22800C0B2FFF787FCDA +:106E0000FAE6F078012804D12800C0B2FFF791FC1F +:106E1000F2E6F078052815D12000FFF75AFC00288B +:106E20000AD008A92000FFF71AFC002804D0280087 +:106E3000C0B2FFF77EFCDFE606212800C0B2FFF7F4 +:106E400066FCD9E6A069002800D1D5E6A1690698BC +:106E5000FFF7F5FB002800D1CEE62800C0B2FBF713 +:106E600026FC00280CD0FBF7E5FA002801D007210A +:106E700000E00821C9B22800C0B2FFF748FCBBE619 +:106E80002078032800D0ADE669462800C0B2FBF7A1 +:106E9000CDFD002805D008212800C0B2FFF737FC3F +:106EA000AAE60398616A884200D0A1E6042128007E +:106EB000C0B2FFF72CFC9FE613B0F0BD20180028ED +:106EC000F6B585B004000E000698002815D01F0006 +:106ED0003800C0B2FFF7F3FB00280ED03000C0B27C +:106EE000012803D03000C0B2022806D10C9920003E +:106EF000C0B2FFF737FC002801D1002040E0390084 +:106F0000C9B22000C0B2FBF7B9FC002806D00821A6 +:106F10002000C0B2FFF7FBFB002031E01948210040 +:106F2000C9B23C225143451869462000C0B2FBF764 +:106F30007DFD002806D008212000C0B2FFF7E7FB46 +:106F400000201DE0039868623100C9B22000C0B281 +:106F5000FBF728FD002806D008212000C0B2FFF76B +:106F6000D6FB00200CE0012028700320687034203C +:106F70002E540698E8600020A86135202F54012087 +:106F800007B0F0BD2018002870B504002000C0B282 +:106F9000FFF730FB002809D02000C0B2FBF713FD3B +:106FA000002803D1FBF746FA002801D0002013E0A7 +:106FB0000A48E4B23C214C4304193820205C0028E4 +:106FC00001D0002008E065683C2100222600300046 +:106FD000FDF7F2FA6560012070BD0000201800285E +:106FE00038B596B005000C00002C1AD02800C0B2AD +:106FF000FFF700FB002814D069462800C0B2FBF759 +:1070000041FE00280DD10FA92800C0B2FBF78CFF6C +:10701000002806D10AA92800C0B2FBF707FD002806 +:1070200001D000203CE01F48EDB23C214D43401907 +:1070300001782170817861706946CA78A2708A7877 +:10704000E27042686260039AA260019AE260029A6A +:1070500022610C9A6261149AA261099AE261059A0E +:107060002262069A6262079AA262089AE2620FAAF4 +:10707000527E3023E2543622825C3123E25438229D +:10708000825C3223E2543622805C002801D0012049 +:1070900000E000203322A0544878342160540120BD +:1070A00017B030BD20180028AA490A680860704748 +:1070B000B3490A6810239A43C0B200011023184054 +:1070C000104308607047AE490A68AE4B1340C0B227 +:1070D0000002802252001040184308607047A849FF +:1070E0000A68B44B1340C0B2000380225201104022 +:1070F000184308607047A2490A6852005208C0B29B +:10710000C007104308607047B2480068704700200D +:107110007047B1480068704710B50400AE4804607D +:10712000BFF34F8FFFF7F5FFA04201D1012000E030 +:107130000020C0B210BD38B5C824C0B2002801D0AC +:10714000012505E0002503E00120FDF7F7F9641EA5 +:10715000A24901200A680240AA4203D0002C01D0B3 +:10716000020000E00022D2B2002AEDD109680140FD +:10717000A94200D00020C0B232BD0020984908606A +:107180009849086098490860BFF34F8F7047000026 +:10719000FEB50400FF20E230019001252F0000F031 +:1071A0002AFD0020FFF7C7FF69464870FFF7AFFFD1 +:1071B00069460870FFF7ADFF0600894800688949F5 +:1071C0000968894A1268894B98420DD141400023D1 +:1071D000DB434B409A4207D101992140002901D15C +:1071E000E10601D5290000E000210022804B1A7041 +:1071F0000022804B1A7000227F4B1A70019A2240A5 +:10720000002A02D17D4A117002E02A007B4B1A70DD +:107210007B4A117061000CD50020FFF77DFF07004D +:10722000FFF777FF0600FFF7A8FF002074490870FA +:107230000BE073490978002902D0FFF79EFF04E0B4 +:107240006A49884201D1FFF798FFFFB2002F09D0A9 +:107250006C48864206D06C48864207D16946097858 +:10726000002903D105206249087016E063490978B6 +:10727000002907D062490978002903D104205C491C +:1072800008700AE069460978002906D0FFF744FF34 +:10729000002802D105205649087068464078002829 +:1072A00002D15348057048E000F05CFC00F084FC1B +:1072B000C82424012000FFF7F7FE0120FFF7F8FEA5 +:1072C0000020FFF700FF0020FFF709FF0120FFF774 +:1072D00012FF00F066FC1F480068A0420BD128484E +:1072E000016809092940002905D00068010B080040 +:1072F000C007C00F00E02800C0B2002802D03C4800 +:1073000005701AE000F06BFC0120FFF714FF002865 +:1073100002D13748057010E000F088FCFA208000A8 +:10732000FDF70CF9FFF7F0FE002802D13048057098 +:1073300003E03048057000F001F8F7BD80B52D4836 +:107340000078002805D02C480078002801D100F0F2 +:107350006DFC01BD08C0264080B50100C9B20029FE +:107360000CD023490A78002A08D108700020FBF7C6 +:1073700083F9FBF791F90020FFF766FB01BD0000E0 +:1073800010C02640FFFEFFFF80B5FFF7C0FEFFF7ED +:10739000C0FE1C49884202D10520FFF7DDFFFBF744 +:1073A00049F8002802D00120FFF7D6FFFFF7C6FFFB +:1073B00001BD0000FFEFFFFF00B50D480078002879 +:1073C00001D0012000E00020C0B200BD08480078D4 +:1073D0007047000014C026400C10274000C0264013 +:1073E000001027400410274008102740364C494819 +:1073F000911B0028931B0028941B0028921B002837 +:10740000951B002845564F4D41564F4D00B5010084 +:1074100009B2002905D40121C006C00E8140AE4842 +:10742000016000BD00B5010009B2002905D40121A9 +:10743000C006C00E8140CC48016000BDF0B502001E +:1074400012B2002A18D41822A44B040024B2A408B3 +:10745000A4001C59FF250600F6001640B540AC43B9 +:107460008901C9B20500ED002A409140214300B2D4 +:107470008008800019501CE00F22BC4C1825030026 +:107480001BB21340083B9B089B00060036B23240FB +:10749000083A92089200A258FF260700FF002F40EA +:1074A000BE40B2438901C9B2C0000540A9401143A2 +:1074B000E150F0BD00B5002A03D101228A404260AC +:1074C00002E001228A40826000BD0268CA401000CA +:1074D000C007C00F704700B5022801D301200FE09C +:1074E0000B00DBB2042B01DB012009E0BC4B800068 +:1074F0001818C9B2405C824201D3012000E000208C +:1075000000BDF8B504001F00022C01D3012023E0C8 +:1075100015000E002A003100C9B22000FFF7DBFF82 +:10752000012801D1012017E03000C0B2002816D197 +:10753000FF2F12D8AB4861034018A90040188021E2 +:10754000490140180168A84A0A403902FF231B027A +:107550000B40134303600020F2BD0120FCE7F6B2AC +:10756000012E16D180204002874210D29D4861032F +:107570004018A9004018A0214901401801689B4A01 +:107580000A403902C44B0B4013430360E3E7012078 +:10759000E2E70120E0E700B20012C0B27047F8B5A0 +:1075A00005000F001600280000B2FFF7F4FF0400EA +:1075B0002000C0B2022801D301202CE032003900A3 +:1075C000C9B22000C0B2FFF786FF012801D1012017 +:1075D00021E084482100C9B2490341182A00D2B2EF +:1075E00092008918C02212018A58AC491140FFB29A +:1075F0003B02C02292001A400A43FF218A43F1B2A3 +:107600001143E4B262038018EDB2AA008018C022D0 +:10761000120181500020F2BD70B50400022C01D38C +:10762000012028E015000E002A003100C9B2200018 +:10763000FFF751FF012801D101201CE069486103D7 +:107640004158802212060A43C02189041143FF22B7 +:1076500012040A43914B1340F6B23202C021890052 +:1076600011401943FF229143EAB20A436103425099 +:1076700061034058002070BD70B50400022C01D396 +:10768000012028E015000E002A003100C9B22000B8 +:10769000FFF721FF012801D101201CE051486103BF +:1076A00041588022D2050A43C02189041143FF2298 +:1076B00012040A43794B1340F6B23202C02189000A +:1076C00011401943FF229143EAB20A436103425039 +:1076D00061034058002070BD00E100E000E400E0DC +:1076E00038B500200100974A11700100964A1170C8 +:1076F0009649086000F0D4F8002803D000F02CF977 +:10770000002804D19248B849086000202BE0914C31 +:107710002000FCF7F7FC002804D08D48B24908602F +:10772000002020E0B1490020205EFCF70AFD002186 +:107730000220205EFFF782FE0220205EFFF772FE2D +:10774000AB4D280001F0ECF8AA49280000F05EFDDE +:10775000A5490860002801D0002004E00220205E36 +:10776000FFF754FE012032BD80E200E01CED00E096 +:1077700038B50400002C01D100202FE0FCF788FB75 +:1077800071490A7813006F4D2D78DBB2AB4203D1FB +:10779000FCF782FB002021E0C44B1500EDB22D0167 +:1077A0005D5925601500EDB22D015D196D686560AC +:1077B0001500EDB22D015D19AD68A5601500EDB2A3 +:1077C0002D015B191B7B2373521C1300DBB2102BA2 +:1077D00000D300220A70FCF75FFB012032BD0000DD +:1077E000B8A4001000000440FF00FFFFFF0000FFEE +:1077F000F1B594B00D0016005021002422006F4610 +:107800003800FCF7D9FE149880210901884203D280 +:107810004E480068002804D04D48A949086000205F +:1078200038E068462100017021000172149901912D +:1078300008214C2281540395049600E0641C20002A +:10784000C0B2082821D26A4D2100C9B2280001F037 +:10785000BFF90100C9B20129F0D0C0B20328EDD0B0 +:107860001498984908606A462100C9B2280001F0BE +:10787000DBF893490860002805D193480168491C4A +:107880000160012006E0002004E090480168491CE6 +:107890000160002015B0F0BD00FFFF00FFFCFFFFFE +:1078A000F8B5002500956846FDF734FD002801D0A5 +:1078B000002046E0864C0098A04205D300982100A5 +:1078C000FBF75EFE002901D000203AE0009821007D +:1078D000FBF756FE0400002C03D0FF20801C8442DE +:1078E00001D300202DE0FF2608363000FFF753FEBD +:1078F0000700002200213800FFF7BEFE002801D05B +:1079000000201EE0002200213000FFF748FE002882 +:1079100001D0002015E023005B1E0022002138006A +:10792000FFF7EFFD002801D000200AE0002200212F +:107930003800FFF771FE002801D1012000E0280087 +:10794000C0B2F2BD821B0028831B0028141B002834 +:10795000010062000008002818B5A7B01AA85D4908 +:107960003422FCF701FC0DA85B493422FCF7FCFB38 +:1079700068465A493422FCF7F7FB594C1AAA0721EA +:107980002000FBF7BEFD002801D0002024E00DAA56 +:1079900006212000FBF7B5FD002801D000201BE0E8 +:1079A0006A4601212000FBF7ACFD002801D0002031 +:1079B00012E0012201212000FFF77CFDFA20800067 +:1079C000FCF7BCFD01212000FFF77FFD012801D15C +:1079D000012000E00020C0B228B010BD80B50448EE +:1079E00000F0D4FA01BD0000F41A0028DD7900107F +:1079F000000454400808002870B51400002C05D07D +:107A0000207A002802D12078002800D04DE03548A7 +:107A10000168491C01606068334908606068C021E2 +:107A20008900884214D14C20205C082810D1E068DD +:107A3000C0B209280CD1E068000AC0B2102807D1F2 +:107A40002069002804D1E068000C80B2FAF7DCFD60 +:107A5000264801780A00521C1300DBB2102B00D319 +:107A600000221300224D2D78DBB2AB4204D1214815 +:107A70000168491C016018E00C4B65680E00F6B205 +:107A800036019D514C25655D0E00F6B236019E19FA +:107A90003573E5680E00F6B236019E1975602469EB +:107AA000C9B2090159188C60027070BD20170028F6 +:107AB00000B5C0B2002802D101200F49086000BD06 +:107AC000101B00280C1B0028001B0028041B00288A +:107AD000005A6202CCA1001000A2001034A20010D3 +:107AE00080013140F81A0028081B0028821B00285A +:107AF000831B0028FC1A0028141B002843490A682D +:107B00000860704742490A680860704741490A683E +:107B10000860704740490A6801239A43C0B20300D5 +:107B2000DB07DB0F13430B6070473B490A681023E8 +:107B30009A43C0B2000110231840104308607047F8 +:107B400035490A68354B1340C0B20002802252000A +:107B500010401843086070473148016801220A4309 +:107B60000260704710B5FCF793F9040000F024F8A8 +:107B70000020FFF7C3FFFA20C001FFF7C3FF00207A +:107B8000FFF7C4FF0020FFF7C5FF0120FFF7CDFF7F +:107B90000020FFF7D5FF00F004F82000FCF77CF987 +:107BA00010BD80B5FCF774F91E490A68032313431E +:107BB0000B60FCF771F901BD80B5FCF769F9194953 +:107BC0000A6803239A43012313430B600A680323C3 +:107BD0009A43022313430B60FCF75EF901BD80B5A5 +:107BE000FFF7EAFF10480168802212060A4302608C +:107BF000FFF7D7FF01BD80B5FFF7DEFF0A48016838 +:107C0000490049080160FFF7CCFF01BD04C02640D0 +:107C100008C026400CC0264010C02640FFFEFFFFD3 +:107C200044C0264040C0264000C0264080B5FFF733 +:107C3000C3FFFFF791FFFFF7B4FF01BD00B50028B8 +:107C400001D0002901D1A04808E0A04A904206D105 +:107C500000200880802080014860002000BD9C4AF0 +:107C6000904205D18020800102000A804860F4E73C +:107C7000BA4A904206D18020C0010880802080014D +:107C80004860EAE7B64A904206D1C020C0010880A9 +:107C9000802080014860E0E7B24A904206D180200F +:107CA00000020880802080014860D6E7AE4A9042FA +:107CB00005D100200880802080014860CDE7AB4AD4 +:107CC000904205D18020800102000A804860C4E70C +:107CD000A74A904206D18020C00108808020800100 +:107CE0004860BAE7A34A904206D1C020C00108808C +:107CF000802080014860B0E79F4A904206D18020F2 +:107D000000020880802080014860A6E76E48A5E751 +:107D100000B5002100E0491C0A290AD2974A18231D +:107D20004B43D3589842F6D1182041435018001DB8 +:107D300000E0002000BD10B50A00D2B2402A01DBED +:107D4000002017E0020C120480235B02D218AC233F +:107D5000C3589B089B049B0C9B00D218C9B2884B4C +:107D6000BC240059000A4007400F185C801C4143A6 +:107D70008800101810BD10B50A00D2B2202A01DB0D +:107D8000002016E0020C120480235B02D218C023EC +:107D9000C3589B089B049B0C9B00D218C9B2784B1C +:107DA000C82400594007400F185C801C41438800DC +:107DB000101810BD10B50B00DBB2022B01DB002048 +:107DC00028E0030C1B04802464021B190C00E4B29D +:107DD000002C05D1A0240459A408A404A40C04E098 +:107DE000B0240459A408A404A40CA4001B19C9B20B +:107DF000002904D1BC2140584007400F04E0BC21B9 +:107E0000405800094007400F5D49085C801C424310 +:107E10009000181810BD00B55A49884201D30521B9 +:107E200000E00021400A4006400E0818C0B200BD24 +:107E300038B504000020002C44D02000FFF7EBFFF1 +:107E40000121226D520F0A40002A05D00A004E4B34 +:107E50000500EDB26D005A53226D120F0A40002A40 +:107E600005D00222484B0500EDB26D005A53226D39 +:107E7000D20E0A40002A05D00322434B0500EDB282 +:107E80006D005A53226D920E0A40002A05D004223A +:107E90003D4B0500EDB26D005A53226D520E0A4063 +:107EA000002A05D00522384B0500EDB26D005A536B +:107EB000226D520D1140002904D00621324AC0B271 +:107EC0004000115231BD000001006200000052402C +:107ED0000002524070B500221300130000283CD06D +:107EE00003689B0F1C00E407E40F0C720B7A002B55 +:107EF00005D10368DB00DB089B144B6003E00368DB +:107F0000DB00DB084B6043685B0D1C00E407E40FFB +:107F10000C7043681B0C1B071B0F4C240B554C2388 +:107F2000CB5C082B01DA130002E04C23CB5C083B4E +:107F30000AE01400A4B2A4000419A4681500ADB2AC +:107F4000AD004D19EC60521C14000D4D1E00B6B270 +:107F5000AD5DA4B2ADB2AC42EBD370BD00045240F3 +:107F600000065240000852400000544000025440B5 +:107F700000045440000654400008544058080028AB +:107F8000B0A4001001085240741A0028F1B5A8B03E +:107F90000020009000202898FFF7BAFE0700002F6D +:107FA00029D0289E306D97490140002902D0289899 +:107FB000FFF73EFF0125306DC00C284000281CD182 +:107FC000306D2840002868D1306D00092840002815 +:107FD00000D0BAE0306D400A0540002D0BD03000D3 +:107FE00050300168802292000A43026038680028FD +:107FF00001D03868804729B0F0BD300050300168AA +:10800000802212030A43026098203058002816D0BC +:10801000002400E0641C2000C0B220282CDA982044 +:108020003058E04028400028F4D02100C9B22898F8 +:10803000FFF781FE0090A540982035501CE09C2061 +:108040003058002818D0002400E0641C2000C0B282 +:10805000202811DA9C203058E04028400028F4D035 +:1080600020342100C9B22898FFF765FE0090200057 +:10807000E03085409C20355000980028BBD013A9E3 +:108080000098FFF727FF78680028B4D013AA2100D2 +:10809000C9B200207B689847ADE7300050300168D6 +:1080A0002943016017E0289AD4235B00D45801AA21 +:1080B0000300DBB29B00D450401C02000B00D2B284 +:1080C000DBB29A42EFD3B868002804D001AAC9B243 +:1080D0000020BB689847A42030584006400E012875 +:1080E00089DB2898C021490040582840002822D127 +:1080F000A420305800903F240098020A224000211A +:108100002898FFF757FE0028E5D013A9FFF7E2FEF5 +:108110003000A83001683F229143009A120A1440AF +:108120000C43046078680028D5D013AA00210120F0 +:108130007B689847CFE734482899BC22895849077B +:10814000490F415C891C0020B7E7300050300168BE +:1081500010220A43026017E0289ADC235B00D458FF +:1081600001AA0300DBB29B00D450401C02000B00AC +:10817000D2B2DBB29A42EFD3B868002804D001AA89 +:10818000C9B20120BB689847B42030584006400E61 +:10819000012800DA2FE72898C021490040584008FC +:1081A0002840002822D1B420305800903F24009865 +:1081B000020A224001212898FFF7FCFD0028E3D0A5 +:1081C00013A9FFF787FE3000B83001683F229143C2 +:1081D000009A120A14400C43046078680028D3D037 +:1081E00013AA012101207B689847CDE7064828990A +:1081F000BC22895809094907490F415C891C0020A4 +:10820000B4E70000EEE0F73FB0A40010F1B594B081 +:10821000D34800681290D34800681190D24807688C +:10822000D2480668D24800681090D24800680F9083 +:10823000D14800680490D14800680E90D04800688A +:108240000190D04800680D90CF4800680C90CF484E +:1082500000680B90CE4800680A90CE4800680990EC +:10826000CD4800680890CD4800680390CC4800686D +:108270000290DC4800680790DB4800680090149882 +:10828000002810D00C00002C0DD02820205C0028E5 +:1082900002D0E06A002806D03020205C002804D0FC +:1082A000606B002801D1D14824E31498FFF730FD1A +:1082B000002801D1CD481DE321680160616841605B +:1082C000A1688160E168C16021690161149D01209C +:1082D000A861A869C007FCD50320A86105A9149866 +:1082E000FFF7ACFC002801D0C04803E32820215C44 +:1082F000129ABF4810400A04FF21090411400143AB +:10830000BC4808406946898A89B2890889B28900CF +:108310000143842029503020205C4006400EFE217D +:10832000C903119BE44A1A40000408401043842208 +:10833000AA5892082823E35CD2189204920CAD4B01 +:1083400003409200DD48104018438822A850A06BDB +:10835000C000C0087A0F520710439022A85007228D +:108360004020205C1040099FD54B3B400702E02095 +:10837000C000384018434123E35C13407027B843E2 +:108380001F0170233B4003434220205C104007271D +:10839000BB431843BC23E8504420235C1800C007AB +:1083A000C00F73005B08C6071E434520205C4006D3 +:1083B000400EC44B1E400706FE20C0053840304327 +:1083C0004626A65D7606760EBB4F07403004084071 +:1083D00038438826AE59B6083027E75D7F00F61986 +:1083E000B604B60C834F0740B600B448304038435B +:1083F000A026A8514820265C3000C007C00F109E60 +:1084000076007608C00730434926A65D7606760ECC +:1084100003403606FE20C005304018434A23E35C83 +:108420005B065B0EA44E0640180401403143A64F84 +:10843000A020285880084623E35C4226A65DBE5D46 +:10844000B61C7343C01883049B0C6A4808409B0009 +:108450009A4919400143B02029504720205C002848 +:1084600001D1002100E00121009B0120834308008D +:10847000C007C00F18434B21615C002901D10021C6 +:1084800000E00121022398434900022319400143DF +:108490001498C0235B00C150B020285880084A219E +:1084A000615C4123E35CFB5C9B1C594340188304E3 +:1084B0009B0C82484F490F9E0E409B000340334364 +:1084C000AC26AB514323E35C1A40089B0726B34319 +:1084D0001A43C823EA507D4A049E16407C4B1E4036 +:1084E0000496AC26AE59B60800964C26A65DB44656 +:1084F0004026A65DBE5DB61C67467743009EF61912 +:10850000B604B60C049F0F40B60006403E43F02769 +:10851000EE510E9F6F4E3E4032404D26A65DB60690 +:10852000B60E13403604FC22920332401A43F02365 +:10853000EB589B0814339B049B0C11409A0010408D +:108540000843C0216850802252021498000C000495 +:108550006946898A4018801806998B08002125E011 +:10856000F4A40010F8A40010FCA4001000A5001052 +:1085700004A5001008A500100CA5001010A50010FF +:1085800014A5001018A500101CA5001020A50010AF +:1085900024A5001028A500102CA5001030A500105F +:1085A00034A5001000260660001D491C9942F9D32D +:1085B00001990120084302210143CF480840217D51 +:1085C000012901D1012100E00021CC4B03404802E8 +:1085D0008021890008401843217D01290CD1012107 +:1085E0000BE000BF38A500103CA5001001006200A0 +:1085F000FFFF00FF0300FFFF0021C14B0340080203 +:108600008021490008401843802188434021884345 +:108610002021884310218843042188430190019838 +:10862000A861E08AC005C00D0D99B64B0B4001044E +:10863000B54808401843217EB44B03400902FF208F +:108640000002084018437F23617E19407F26B04313 +:108650000143A07E1840C901C90940060843E861EA +:10866000207D012853D11F20A18B01400C9FA84ED3 +:108670003E400F04F821490339403143A67F304082 +:10868000A44E0E400102F8204001084030430F2163 +:10869000E67F0E40F027B8433701F0263E40064300 +:1086A0002020205C01400F20864331432220205CA3 +:1086B000012811D1012610E0FFFF80FFFCFF000020 +:1086C000FFF8FFFFFFFFFF80B0A40010FFFFFFC017 +:1086D000FFFFC0FFFFFFFFBF00268F480840F105E6 +:1086E0008026360431400143E9602320215C194093 +:1086F0000B9E8A4830400E02FE21C90131400143E1 +:108700002420205C03407F2081430B43AB64032083 +:108710003C21635C03400A993026B1431E0130239B +:1087200033400B433D21615C08400C218B438100A9 +:108730000C20084018433E21615C012901D1012130 +:1087400000E000210223984349000223194001431D +:108750003F20205C012801D1012000E000200126FB +:108760000123994330400843802168501498000C3D +:1087700000048421695889088904890C89004018FB +:108780008018002105E0E36A8F00DB598F00C35198 +:10879000491C2823E35C9942F5D31498000C00048B +:1087A0008821695889088904890C89004018821831 +:1087B000002308E0606BD900401803C8DF00D71918 +:1087C00003C7083F5B1C3020205C8342F2D38448FF +:1087D00003990140834A0A4083491140834A0A4071 +:1087E00083491140834A0A404B491140824A0A405A +:1087F00082491140824A0A40802109031143814A7B +:108800000A4081491140814A0A40814911403A4A4F +:108810000A4080491140804A0A4080491140804AFC +:108820000A4036491140364B0B4080218B43402192 +:10883000C9430B402022D243134010271F43082373 +:108840009F430423DB431F400224E4432740374374 +:108850000397039F6F65029F384002900298614F13 +:10886000074061483840614F074061483840614FD8 +:10887000074029483840604F074060483840604F03 +:108880000740684838405F4F07405F4838405F4FB7 +:1088900007405F483840184F07405E4838405E4FF9 +:1088A00007405E4838405E4F074014483840144F38 +:1088B00007408020874339400A4010208243082027 +:1088C00082431340234001208343AB650798304324 +:1088D0000440EC651498FFF79EFA4000B04900226E +:1088E0000A52019801218843A861A86930400028F4 +:1088F000FBD1002015B0F0BDFFBFFFFFFFFDFFFF64 +:10890000FFFEFFFFFFFF00FE0000FF01FF00FFFF73 +:10891000FFFFE0FFFFE0FFFFFFFF7FFFFF80FFFFA4 +:10892000FEB50700002F01D19E4857E03800FFF741 +:10893000EFF9002801D19B4850E000240460446016 +:10894000C46004613D000120A861A96901400029BB +:10895000FBD00326AE6169463800FFF76FF90028A7 +:1089600001D090483AE084202C5088202C508E482A +:1089700090216850BC202C50A0202C50B0202C50AE +:10898000C02040003C50AC202C50C8202C50F0207F +:108990002C50C0202C50380C00046946098840181F +:1089A00080214902401801998A08210002E00460F0 +:1089B000001D491C9142FAD3AE617C48E8617C48B5 +:1089C000E860AC6480202C506C65AC65EC653800C8 +:1089D000FFF721FA400072490C520020FEBD000052 +:1089E000FFFFFFDFFFFFFFEFFFFFFFF7FFFFFFFBD3 +:1089F000FFFFFFFDFFFFFFFEFFFFBFFFFFFFDFFFEA +:108A0000FFFFEFFFFFFFFBFFFFFFFDFFFFFFFEFF8D +:108A1000FF7FFFFFFFDFFFFFFFEFFFFFFFF7FFFF1E +:108A2000FFFBFFFFFFFFF7FFF8B50600002E02D0A7 +:108A30001400002C01D15B48B0E068460170684624 +:108A40000078202801DB5748A8E03000FFF760F9E4 +:108A5000002801D15348A1E037000125B86928401A +:108A6000002808D1CC20395868460078C140080059 +:108A7000C007C00F00E02800C0B2002801D049485C +:108A80008CE0684601783000FFF775F9002801D1C5 +:108A9000444883E001000A68464B13400B60217A8A +:108AA000012901D12B0000E0002302001668424991 +:108AB00031409B078026F60533400B431360016865 +:108AC000890F2940002902D161688A0400E06268A8 +:108AD00001000B685B0F5B07D200D2081A430A60E3 +:108AE000011D0A68354B13400B60011D0A68120214 +:108AF000120A0A604C21615C0B071B0F011D0E68F6 +:108B00002F4A32401B04F02636031E4016430E60E7 +:108B10002178012901D12B0000E00023021D1668F5 +:108B2000284931405B058026B60333400B43136070 +:108B30002178012901D12B0000E00023021D1668D5 +:108B4000214931401B058026760333400B431360D7 +:108B50004C21615C082901DA002102E04C21615CB2 +:108B600008391A4A89B2515C00220AE013009BB20C +:108B70009B00E318DB681600B6B2B6008619B36036 +:108B8000521C13000E00F6B29BB2B6B2B342EDD344 +:108B9000684600788540D0203D500020F2BD00009E +:108BA000741A002801006200FFFFFF1F030A00067D +:108BB000330A0000FFFFFFDFFFFFFFBFFFFF7FFF64 +:108BC000FFFFF0FFFFFFDFFFFFFFEFFFB0A400108C +:108BD00010B50200002A03D00800C0B2202801DB33 +:108BE000002021E000200123CC241459CC401C405B +:108BF000002C09D0D4201058C8401840002801D1BA +:108C0000180010E003200EE0D8241459CC401C407A +:108C1000002C01D0022006E0DC241259CA40134087 +:108C2000002B00D00420C0B210BD0000F8B584B005 +:108C3000FEF7E1FFBA490127086803229043384351 +:108C40000860B8480A6802400A60B7490A68032306 +:108C50009A433A430A600A6802400A60B3490A68C4 +:108C600003239A433A430A600A6802400A60B04903 +:108C70000A6803239A433A430A600A68104008606E +:108C8000AC4801680F22914339430160AA4801684A +:108C9000072291430160A9480168072291430422F9 +:108CA0000A4302600168F0229143C0220A43026035 +:108CB0000168C02292000A4302600168C0221201CA +:108CC0000A43026001689E4A0A408021490111431B +:108CD0000160E1480168802212060A430260DF4811 +:108CE000016839400029FAD0016849083940002953 +:108CF000FAD0DB480168DB4A0A400260DA480168C2 +:108D0000D84A0A408021490011430160D7480168D0 +:108D1000D44A0A408021490011430160D4480168C7 +:108D2000D04A0A400260D3480168072291430222D8 +:108D30000A430260D0480168490049080160CF48F1 +:108D40000168890A890229220A4302600168CC4A23 +:108D50000A4002600168802252040A4302600168EE +:108D6000C84A0A4002600168802212060A43026073 +:108D7000C5480168C54A0A408021C9001143016005 +:108D80000168FF229143AF220A43026080218902D9 +:108D90000268BF4B13400B430360802292040368B8 +:108DA000BC4C1C4014430460BB4B04681C40046072 +:108DB000046880252D0625430560B848046807250A +:108DC000AC43022525430560B5480468240E2406FB +:108DD000B44D254305600468B34D254080246405E7 +:108DE0002C430460046880252D0625430560AF48A8 +:108DF0000468640064080460AD4C8025AD002668FA +:108E0000A2483040284320602068FF26B0436226F5 +:108E10000643266026689E4830408026F6020643B8 +:108E2000266026689B483040024322602068184034 +:108E30002060206880221206024322609D4802685A +:108E40000724A2430224144304609B4802687F2441 +:108E5000A24328241443046002688C4C14402C4321 +:108E6000046002688A4C1440214301600168894A09 +:108E70000A400260914902680A40026002681A4092 +:108E80000260026880242406144304608C4802684F +:108E90000724A243022414430460B04802687F24DC +:108EA000A2431E24144304600268784C14402543F6 +:108EB00005600268764C1440C02292022243026090 +:108EC0000268744C1440046002681140016001683B +:108ED0000B4003600168802212060A430260A0482A +:108EE000006838400028FAD09E48006838400028C2 +:108EF000FAD09D48006838400028FAD09B480068A6 +:108F000038400028FAD09A4801680722914301604E +:108F1000684698491022FBF727F9002635E000BF84 +:108F2000C4132040FFFCFFFF0013204080132040AB +:108F3000A013204000002440001526404415264080 +:108F4000FF8FFFFF32200021FBF7B8FA300040B25C +:108F500080002818016830229143220052B2120189 +:108F6000302313400B430360641E200040B2694667 +:108F7000320052B25200895C00B209B28842E1DA92 +:108F800032200021FBF79AFA761C300040B2082804 +:108F90003FD2300040B204280DD1EFF310840020FE +:108FA00080F31088FA239B0000220121102000F09A +:108FB0000BFA84F31088704D300040B280002818FE +:108FC00001680F2291436A4633005BB25B00D218FE +:108FD00052781207120F0A430260300040B280003C +:108FE0002818016830220A430260300040B2800035 +:108FF00028180168802252000A430260300040B203 +:10900000800028180168802212060A4302600224A8 +:10901000ABE75A48134A016811400160F821C9437F +:1090200003680B400360564803681A4002600268F8 +:1090300011400160FEF7C0FD52480168802212060F +:109040000A43026001680F430760FEF7AAFD00F0C3 +:1090500039F805B0F0BD00BF1C1526402415264088 +:1090600010102040FF00FFFF081020400C1020408F +:1090700020032040041226400419264008192640E7 +:10908000FFFFF8FFFFFFFFEF00192640FFE0FFFFA3 +:10909000FFFFE0FFFFFFFFF9FFFFFFCF08122640B1 +:1090A00014192640F1D24D00FFFFFF8F18192640FA +:1090B000101926400C12264000162640FFFFFFF72D +:1090C0001012264038B586B068462F491822FBF7A3 +:1090D0004BF86846FBF7D2FEC0B200280ED02B4CEE +:1090E00018222B492000FCF75FF90020E075FF20D3 +:1090F000A830284908600020FCF7B6F9264D280062 +:10910000FCF74EF9C0B200280ED0204C182220499E +:109110002000FCF749F90020E075FF20AA301D4926 +:1091200008600020FCF7A0F91C4C2868206020682B +:109130001B4940181B49FAF723FA1B4908601B4DCD +:1091400020681B494018FA218900FAF719FA2860AB +:109150002868C0031749086007B030BD04162640D0 +:109160000C1926401C19264040162640441626405D +:109170001412264084A4001040122640080020400B +:1091800008042040081526401CA400105C1A002882 +:10919000609F00101C1B0028681B00286C1B002807 +:1091A0003F420F0040420F00741B0028701B002834 +:1091B000E7030000781B0028F3B595B017000021E5 +:1091C0000D000800684601801C00A4B2A40820001D +:1091D00080B280218900884203D30125684604803B +:1091E0007E4C1C9E00F011F9002800D099E06846E2 +:1091F000877016A8077801A81021FBF7EDF90198F0 +:10920000022188433100C9B24900022211400143C2 +:109210000191019804218843FFB2B9000422114052 +:109220000143019101980821884369468978C90062 +:1092300008221140014301910198102188430190B7 +:109240000198202188430190019840218843019092 +:109250000198802101430191614F0198384015A97F +:1092600009780902F82252010A400243019201984A +:109270005C490140019101980002000AA8214905BA +:109280000143019111A81021FBF7A6F91198800A5A +:109290008002A4B2A105890D0143119152480068D2 +:1092A000002806D101980740D020400138430190A2 +:1092B00007E0002805D0019807408020400138438E +:1092C0000190002211A901A800F013F9EDB2002DC0 +:1092D00003D06846008800F08DF809A81021FBF73C +:1092E0007BF9099802218843F6B2710002221140ED +:1092F0000143099109983D490140099109983C4968 +:109300000140802040020843099009980002000AA9 +:109310008821490501430991002109A800F092F82C +:1093200050E000F07DF80128FBD000F073F8012830 +:10933000F7D10DA81021FBF74FF90DA80078FD21FA +:1093400001400DA801700DA800780C2101430DA863 +:1093500001700D980002000AB021490501430D91EA +:1093600000210DA800F06EF800F054F8002829D173 +:10937000EDB2002D03D06846008800F03BF805A848 +:109380001021FBF729F9059802218843F6B27100F4 +:1093900002221140014305910598144901400591AD +:1093A0000598134901408020400208430590059824 +:1093B0000002000A8821490501430591002105A802 +:1093C00000F040F817B0F0BD1CB5012400949BB22A +:1093D000D2B2C9B2C0B2FFF7EFFE13BDFF01000069 +:1093E000FFE0FFFFFF1FFFFF30070017FF00FFFF39 +:1093F000FFFF00FF0D490A680D4B134080B20005C6 +:109400000C4A02401A430A60704708480068C00FBF +:10941000C0B27047084801680800C007C00F704715 +:1094200005480068C00FC0B27047000028102640F1 +:10943000FFFF0FC00000F03F2C102640C069C00F96 +:10944000C0B27047F8B502000E00002A01D1052015 +:109450004EE00F2069490B681B0A0340002B01D125 +:10946000002007E00968090A08400E2801D1012000 +:1094700000E00220624940010F18624C624D280052 +:1094800011001022FAF78AFE28680121884328601B +:1094900010212800FCF70BF85C4B2A000121380052 +:1094A00000F0CFF8002804D0012021E0641E002C39 +:1094B00015D03800FFF7C2FF0028F7D1002E08D0E2 +:1094C00010212800FBF7F2FF300029001022FAF7E4 +:1094D00065FE00F079F8012803D1032008E002209E +:1094E00006E000F071F8002801D1002000E004201F +:1094F000F2BDFEB503001600002B03D001910198C8 +:10950000002801D105205DE00F203C490A68120ABD +:109510000240002A01D1002007E00968090A08403A +:109520000E2801D1012000E0022035494001081831 +:109530000090374C344D280019001022FAF72EFE07 +:10954000344F380001991022FAF728FE28680121CB +:10955000884328603868012188433860102128003A +:10956000FBF7A5FF10213800FBF7A1FF3B002A0005 +:109570000121009800F065F8002804D0012021E0C6 +:10958000641E002C15D00098FFF758FF0028F7D173 +:10959000002E08D010212800FBF788FF300029009A +:1095A0001022FAF7FBFD00F00FF8012803D1032089 +:1095B00008E0022006E000F007F8002801D10020B2 +:1095C00000E00420FEBD000010B50F4C102120006B +:1095D000FBF76CFF2068000F0007A02109068842F6 +:1095E00001D1002009E02068000F0007F0210906E2 +:1095F000884201D1012000E0022010BD000020407F +:1096000000002240FFFFFF0FA01A0028181B0028AF +:10961000FFFFFF00C01A0028174A1268120C12043C +:1096200089B2114381607047C16070470161704722 +:1096300000B50068C00F002801D1104800E00020EC +:1096400000BDF8B507000C0015001E003800FFF73C +:10965000EFFF00280DD129003800FFF7E5FF3100AA +:109660003800FFF7E3FF21003800FFF7D5FF0020A7 +:1096700000E00248F2BD000060A5001001008A0071 +:1096800000B5010009B2002905D40121C006C00EB1 +:1096900081408F48016000BDF0B5020012B2002A7F +:1096A00018D418228B4B040024B2A408A4001C591F +:1096B000FF250600F6001640B540AC438901C9B24B +:1096C0000500ED002A409140214300B2800880004F +:1096D00019501CE00F22804C182503001BB21340C8 +:1096E000083B9B089B00060036B23240083A9208BD +:1096F0009200A258FF260700FF002F40BE40B24351 +:109700008901C9B2C0000540A9401143E150F0BD34 +:1097100080B500F012F87148714902680A40026091 +:10972000704802680A4002606F4802681140016098 +:1097300000F092F800F0A9F801BDFEB568466B494B +:109740000CC90CC008390838002630000AE06A4607 +:109750000300DBB2DB00D2185268521C1140002912 +:109760000ED1401C0100C9B200290AD107216A4666 +:109770000300DBB2DB00D2580A40002AE7D0FEE744 +:10978000FEE75B4C072021680140002900D0FEE77E +:10979000584F3968491C0840002800D0FEE73500C2 +:1097A00024E068462900C9B2C900401841686846EB +:1097B0002A00D2B2D200805800F044F815E0300000 +:1097C0000028EED02168491E68462A00D2B2D20095 +:1097D000805800F037F868462900C9B2C90040181F +:1097E00041683868401C00F02DF86D1C2800C0B29C +:1097F000002826D168462900C9B2C900405800286F +:1098000014D0206869462A00D2B2D2008958884212 +:109810000AD368462900C9B2C900401840682168C7 +:10982000884201D30120CBE73000C9E76846290010 +:10983000C9B2C9004018406821688842BFD30120DE +:10984000BEE7F7BD80B50022002302E00CC0083857 +:1098500008308842FAD301BD30B52748800827492F +:10986000274A002308E01C00E4B2A40014591D009C +:10987000EDB2AD004C515B1C1C000500E4B2EDB232 +:10988000AC42F0D31F48016030BD10B51023112148 +:109890001A4800221C00E4B2A4001459DBB29B0059 +:1098A000C4500B00DBB29B00D258C9B289004250B1 +:1098B00001210020FFF7F0FE00210120FFF7ECFE60 +:1098C0000020FFF7DDFE0120FFF7DAFE10BD0000EB +:1098D00000E100E000E400E01CED00E000132040A7 +:1098E000FFFFF7FF80132040A0132040C8A4001002 +:1098F000589F00105C9F001080000000001C002892 +:109900000000001008ED00E000B5010009B20029D8 +:1099100005D40121C006C00E81404A48016000BD47 +:1099200000B5FEE780B54848006848480068C10FA8 +:10993000002905D046498005800D800008588047E1 +:109940000020FFF7E1FF01BD80B5424800684248B2 +:109950000068C10F002905D03D498005800D8000B9 +:10996000085880470120FFF7CFFF01BD80B53B4875 +:1099700000683B480068C10F002905D034498005C4 +:10998000800D8000085880470220FFF7BDFF01BD11 +:1099900080B53448006834480068C10F002905D0FC +:1099A0002B498005800D8000085880470320FFF771 +:1099B000ABFF01BD80B52D4800682D480068C10F80 +:1099C000002905D022498005800D80000858804775 +:1099D0000420FFF799FF01BD80B52648006826489E +:1099E0000068C10F002905D019498005800D80004D +:1099F000085880470520FFF787FF01BD80B51F4845 +:109A000000681F480068C10F002905D01049800573 +:109A1000800D8000085880470620FFF775FF01BDC4 +:109A200080B51848006818480068C10F002905D0A3 +:109A300007498005800D8000085880470720FFF700 +:109A400063FF01BD80E200E040A50010001120404E +:109A50004809002844A500100411204048A5001022 +:109A6000081120404CA500100C11204050A50010FA +:109A70001011204054A500101411204058A50010CA +:109A8000181120405CA500101C11204004A30010F8 +:109A900034A40010800231400400000008000000DF +:109AA000800658403A0100000009314002000000E1 +:109AB0000A0F0D07E0010000FC5300009821000090 +:109AC000F4010000E8030000F401000090A300107E +:109AD00044A400108002314005000000080000008E +:109AE000000758403B010000000531400100000024 +:109AF0000AF0D07076010000E0150000D4170000D5 +:109B00005E010000E8030000F4010000ACA30010B7 +:109B100054A40010000431400000000008000000C0 +:109B2000800958404001000000073140000000005B +:109B3000090F0D0795030000901A0000282300006C +:109B4000F4010000E8030000F4010000C8A30010C5 +:109B500064A40010800031400000000008000000F4 +:109B600000065840390100000006314005000000A1 +:109B700009F0D07087020000201C0000982100002E +:109B8000F4010000D0070000F401000080B5F7F7F1 +:109B90008BFB00F07DF800F0B2F8FDF7CFFBFA2068 +:109BA0008000FAF7CBFCF2E710B500F071F800F096 +:109BB000A6F8FFF73BF8F7F777FB00F069F8002805 +:109BC00003D000F09CF8002801D1FFF7DFFF1C4C08 +:109BD00000F0C6F8206000F0C6F82068FDF7D8FA5B +:109BE000FDF7EAFB002801D0FFF7D0FF62B6154C65 +:109BF000F9F72EFB20602068002804D00120FDF733 +:109C0000ABFBFFF7C3FF0120F9F735F8FCF744FC85 +:109C1000002801D1FFF7BAFFFCF78EFCFDF7CCFB63 +:109C2000002805D0F7F740FB00F032F800F067F8A5 +:109C3000FDF784FBFA208000FAF780FCECE70000D7 +:109C4000201B00282812002800B5104A8018C009DF +:109C50000001214A8018042905D20068C900C840C3 +:109C6000C006C00E05E0FC314068C900C840C0060F +:109C7000C00EC0B200BD00B5002A03D101228A4047 +:109C8000426002E001228A40826000BD0000CFBF36 +:109C900078B58DB0684611493422FAF765FA104E4E +:109CA0006A4607213000F9F72CFC04000022072146 +:109CB0003000FFF7E0FF00250A480560002C07D1BF +:109CC00007213000FFF7C0FF002801D1012000E08C +:109CD0002800C0B20EB070BD0000304064A100107A +:109CE00000083140F01A002800B5002A03D10122F3 +:109CF0008A40426002E001228A40826000BDF0B5E5 +:109D00008DB0684614493422FAF72EFA134F6A468A +:109D100001213800F9F7F5FB060000242000694610 +:109D200008726A4602213800F9F7EBFB05000022B1 +:109D300001213800FFF7D8FF002202213800FFF789 +:109D4000D3FF3543002D01D1012000E02000C0B237 +:109D50000DB0F0BD0000000098A10010800531405A +:109D60000648006870470020C04304490860044961 +:109D70000860002003490860704700000018264072 +:109D80000418264008102640F1B4016841184368C1 +:109D900058080A1800988068DB0701D54B46C018A0 +:109DA00091422DD00D78491C032403262E4002D168 +:109DB0000E78F61C491C2B090F2B07D10B780F339B +:109DC000491C03E00F78491C0770401C761EF9D12E +:109DD000002BE5D00E78491CAD082C40032C01D196 +:109DE0000C78491C24023419654244190025AE1E22 +:109DF0009E42D5D02678641C0670401C6D1CF6E788 +:109E000000980C30F2BC704700053140060000009D +:109E10000000000080189040020000000006314061 +:109E20000100000000000000401990400500000003 +:109E3000000531400700000000000000C0189040FD +:109E40000300000000053140050000000000000094 +:109E50004018904001000000000631400200000060 +:109E6000000000008019904006000000000631400C +:109E70000600000000000000801A90400A00000068 +:109E800004000000000000000000000000000000CE +:109E90000100030363000000000000000000000058 +:109EA0003200000000000000000000000000000080 +:109EB00000000000000000000000000000000000A2 +:109EC000020300000000000003000000000000008A +:109ED000030000000000000003000000000000007C +:109EE000030000000000000003000000010000006B +:109EF0000102000072B6104810490860BFF34F8F8E +:109F00000120C0040E490A6802430A600D490A682C +:109F100002430A600C490A6802430A6000200021DB +:109F20000D4A03C26A45FCD1084FB847084FB847ED +:109F3000084FB847FEE700000000001008ED00E001 +:109F40000013204080132040A013204011970010E0 +:109F50009DA2001001A4001000FF0128FFFF0128AE +:109F6000443A5C50726F6A6563745C3531315C4EA3 +:109F7000375765625C6669726D776172655C7432D1 +:109F8000675C747669696268386D5C7372635C7370 +:109F9000797374656D5C7265765F645C73797374F4 +:109FA000656D5F747669696268386D5F636D307086 +:109FB0006C75732E630000009F0F0000000000000E +:109FC000000000000000000000000000000003008E +:109FD000000000000000000000000000000300007E +:109FE000000000000300000000000000030000006B +:109FF000000000000300000000000000030000005B +:10A000000100000000000000443A5C50726F6A6575 +:10A0100063745C3531315C4E375765625C666972DA +:10A020006D776172655C7432675C747669696268C9 +:10A03000386D5C7372635C647269766572735C73AD +:10A040007973636C6B5C63795F737973636C6B2E8C +:10A0500068000000443A5C50726F6A6563745C3556 +:10A0600031315C4E375765625C6669726D7761723B +:10A07000655C7432675C747669696268386D5C73BC +:10A0800072635C647269766572735C737973636C16 +:10A090006B5C63795F737973636C6B2E6300000094 +:10A0A000443A5C50726F6A6563745C3531315C4E62 +:10A0B000375765625C6669726D776172655C743290 +:10A0C000675C636F6D6D6F6E5C7372635C64726905 +:10A0D000766572735C7379737469636B5C63795FC3 +:10A0E0007379737469636B2E6300000001000000D4 +:10A0F0000000000000000000100000001000000040 +:10A10000100000000100000100000000000000003D +:10A11000000000000000000000000000000000003F +:10A12000000000000000000070B40123002413E0D0 +:10A130000168001D194202D04D466918491E0C6085 +:10A14000091D121F042AFAD20D00960701D50C80B2 +:10A15000AD1C1A4000D02C700268001D002AE7D107 +:10A1600070BC704700000000060000000000000006 +:10A1700000000000000000000000000000000000DF +:10A1800000000000000000000000000000000000CF +:10A1900000000000000000000000000006000000B9 +:10A1A00000000000000000000000000000000000AF +:10A1B000000000000000000000000000000000009F +:10A1C000000000000000000000000000000000008F +:10A1D0000800000015000000000000000000000062 +:10A1E000000000000000000000000000000000006F +:10A1F000000000000000000000000000000000005F +:10A20000010000000E00000015000000000000002A +:10A21000000000000000000000000000000000003E +:10A22000000000000000000000000000000000002E +:10A230000000000001000000060000000000000017 +:10A24000000000000000000000000000000000000E +:10A2500000000000000000000000000000000000FE +:10A2600000000000000000000000000006000000E8 +:10A2700000000000000000000000000000000000DE +:10A2800000000000000000000000000000000000CE +:10A2900000000000000000000000000010B50749A9 +:10A2A00079441831064C7C44163404E0081D0A68D1 +:10A2B000891888470100A142F8D110BD08000000AC +:10A2C0002400000065FEFFFFA8080000001300281E +:10A2D00000000000B5FAFFFFFE0200002A010000A6 +:10A2E0000008002800000301000000000100000039 +:10A2F000000000000000000001000000FF0F01004E +:10A30000000000004D4F544F525F494E325F4842AB +:10A31000375F4842385F484F52495A4F4E54414C7C +:10A320000000000004207146084202D0EFF30980CB +:10A3300001E0EFF3088000B50149884700BD000047 +:10A340006F54001000F00EF8002801D0FFF7A6FFB0 +:10A3500000BF00BF002000BF00BFFFF725FC00F0DA +:10A3600003F800000020704780B500F001F801BD3F +:10A370000746384600F002F8FBE7000080B500BF52 +:10A3800000BF024A11001820ABBEFBE72600020006 +:10A390004D4F544F525F494E335F4842355F4842FC +:10A3A000365F534541545F50414E00004D4F544F6E +:10A3B000525F494E345F4842335F4842345F424106 +:10A3C000434B5245535400004D4F544F525F494E3A +:10A3D000315F4842315F4842325F4C4547524553F6 +:10A3E0005400000004207146084201D0EFF30980B8 +:10A3F000EFF3088000B50149884700BD95A400101F +:10A400000548014603B4684680F3098800BF00BFD1 +:10A4100000BF00BFFFF796FFA5EDF5FE00127A0022 +:10A42000000000000024F400008000000080000014 +:10A4300000800000484F52495A4F4E54414C5F48EB +:10A44000414C4C00534541545F50414E5F48414C94 +:10A450004C0000004241434B524553545F48414C2D +:10A460004C0000004C4547524553545F48414C4C0A +:10A470000000000030750000204E00003075000024 +:10A48000A8610000000300010004010302020002B1 +:10A49000000300058169891E0978C9B2016070470F +:10A4A00000000000000101010101010000010000A5 +:10A4B0000203040506080C10040300011314001520 +:10A4C000C70103000100000000080028FFFF012869 +:10A4D0000003000B0000000000000000000000006E +:10A4E000000000000000000000000000000000006C +:10A4F000000000000000000000000000000000005C +:10A50000000000000000000000000000000000004B +:10A51000000000000000000000000000000000003B +:10A52000000000000000000000000000000000002B +:10A53000000000000000000000000000000000001B +:10A54000000000000000000000000000000000000B +:10A5500000000000000000000000000000000000FB +:10A5600000000000000000000000000000000000EB +:10A5700000000000000000000000000000000000DB +:10A5800000000000000000000000000000000000CB +:10A5900000000000000000000000000000000000BB +:10A5A000000000000000000000010203FEE70000C0 +:10A5B000FEE70000FEE70000FEE70000FEE7000007 +:10A5C000FEE70000FEE70000FEE70000FEE70000F7 +:10A5D000FEE70000FEE740045500020001000150C4 +:10A5E00003F9790010000123B17A0CA004090004DA +:10A5F00001010001210E83D0A4286004FFFFFF1F8A +:10A60000020225920807F0010352400001F20602FF +:10A6100018F2060418F2060618F2060818F30600E7 +:10A620005478F10618F2060418F2060618F2050826 +:10A6300018F002FF2199001004F1FF04F1FF04F16A +:10A64000FF04F1FF04F1FF04F1FF04F1FF04F147FF +:10A65000046C0248090028060AF321060001F123D0 +:10A6600034F25208680002010F0F010000000000E0 +:10A6700000000000000000000000000000000000DA +:10A6800000000000000000000000000000000000CA +:10A6900000000000000000000000000000000000BA +:10A6A00000000000000000000000000000000000AA +:10A6B000000000000000000000000000000000009A +:10A6C000000000000000000000000000000000008A +:10A6D000000000000000000000000000000000007A +:10A6E000000000000000000000000000000000006A +:10A6F000000000000000000000000000000000005A +:10A700000000000000000000000000000000000049 +:10A710000000000000000000000000000000000039 +:10A720000000000000000000000000000000000029 +:10A730000000000000000000000000000000000019 +:10A740000000000000000000000000000000000009 +:10A7500000000000000000000000000000000000F9 +:10A7600000000000000000000000000000000000E9 +:10A7700000000000000000000000000000000000D9 +:10A7800000000000000000000000000000000000C9 +:10A7900000000000000000000000000000000000B9 +:10A7A00000000000000000000000000000000000A9 +:10A7B0000000000000000000000000000000000099 +:10A7C0000000000000000000000000000000000089 +:10A7D0000000000000000000000000000000000079 +:08A7E000000000000000000071 +:040000051000A40142 +:00000001FF diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.out b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.out new file mode 100644 index 0000000..6a381a7 Binary files /dev/null and b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/IAR/rev_d/Exe/Passenger_Seat_Timed_Controller_demo.out differ diff --git a/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Tests/audit_timed_only.py b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Tests/audit_timed_only.py new file mode 100644 index 0000000..b304854 --- /dev/null +++ b/firmware/t2g/Passenger_Seat_Timed_Controller_demo/Tests/audit_timed_only.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import re +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EWP = ROOT / "IAR" / "Passenger_Seat_Timed_Controller_demo.ewp" +APP = ROOT / "App" / "seat_timed_application.c" +CAN_BSP = ROOT / "Bsp" / "bsp_seat_move_can.c" +CONFIG = ROOT / "Config" / "seat_move_config.h" + + +class TimedOnlyFirmwareAudit(unittest.TestCase): + def test_iar_project_is_valid_xml_and_local_references_exist(self) -> None: + tree = ET.parse(EWP) + project_dir = EWP.parent + missing: list[str] = [] + for element in tree.findall(".//file/name"): + value = element.text or "" + if not value.startswith("$PROJ_DIR$"): + continue + relative = value.removeprefix("$PROJ_DIR$\\").replace("\\", "/") + if not (project_dir / relative).resolve().is_file(): + missing.append(value) + self.assertEqual([], missing) + + def test_iar_target_declares_timed_only_and_excludes_other_apps(self) -> None: + text = EWP.read_text(encoding="utf-8") + self.assertIn("PASSENGER_SEAT_TIMED_ONLY=1", text) + self.assertIn("seat_timed_application.c", text) + self.assertIn("Passenger_Seat_Timed_Controller_demo.hex", text) + for forbidden in ( + "seat_move_application", + "seat_dynamic_mode", + "seat_move_store", + "seat_controller_thermal", + "..\\App\\seat_backrest_heater.c", + "bsp_seat_backrest_heater_adc", + ): + self.assertNotIn(forbidden, text) + + def test_can_hardware_filter_accepts_only_command_id(self) -> None: + text = CAN_BSP.read_text(encoding="utf-8") + block = re.search( + r"s_standardIdFilters\[\]\s*=\s*\{(?P.*?)\};", + text, + re.DOTALL, + ) + self.assertIsNotNone(block) + body = block.group("body") + self.assertEqual(2, body.count("SEAT_MOVE_CAN_COMMAND_ID")) + self.assertNotRegex(body, r"0x(?:29E|2AA|3CE|42A|2F0)u") + self.assertNotIn("HIL_SELF_HEAL", body) + + def test_application_exposes_only_timed_motion_dispatch(self) -> None: + text = APP.read_text(encoding="utf-8") + self.assertIn("SeatMotor_MoveTimedDuty", text) + for forbidden_call in ( + "SeatMotor_MoveAbsolute", + "SeatMotor_MoveRelative", + "SeatMotor_CalibrateRange", + "SeatMotor_HomeKnownRange", + "SeatDynamicMode_", + "SeatMoveStore_", + "SeatControllerThermal_", + ): + self.assertNotIn(forbidden_call, text) + + accepted = set( + re.findall( + r"case\s+(SEAT_TIMED_OPCODE_[A-Z_]+)\s*:", + text[text.index("static bool SeatTimed_ParseCommand") : + text.index("static bool SeatTimed_ParametersValid")], + ) + ) + self.assertEqual( + { + "SEAT_TIMED_OPCODE_MOVE_TIMED", + "SEAT_TIMED_OPCODE_STOP_MOTOR", + "SEAT_TIMED_OPCODE_STOP_ALL", + "SEAT_TIMED_OPCODE_QUERY_STATUS", + "SEAT_TIMED_OPCODE_CLEAR_FAULT", + }, + accepted, + ) + + def test_axis_specific_duration_limits_are_present(self) -> None: + text = CONFIG.read_text(encoding="utf-8") + expected = { + "SEAT_TIMED_MIN_DURATION_MS": "300", + "SEAT_TIMED_M0_MAX_DURATION_MS": "30000", + "SEAT_TIMED_M1_MAX_DURATION_MS": "20000", + "SEAT_TIMED_M2_MAX_DURATION_MS": "30000", + "SEAT_TIMED_M3_MAX_DURATION_MS": "25000", + } + for name, value in expected.items(): + self.assertRegex( + text, + rf"#define\s+{name}\s+\({value}u\)", + ) + self.assertNotIn("SEAT_MOVE_CAN_DYNAMIC_STATUS_ID", text) + self.assertNotIn("SEAT_MOVE_CAN_HIL_SELF_HEAL_REQUEST_ID", text) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/firmware/t2g/README.md b/firmware/t2g/README.md index 8b41c88..63a8505 100755 --- a/firmware/t2g/README.md +++ b/firmware/t2g/README.md @@ -2,7 +2,14 @@ `Seat_Controller_demo/` is the repository-owned IAR project used by the real seat-bench executor. It contains the 2026-07-30 heater, ventilation, four-axis -motion, SMV8 position memory, Hall feedback and ADC current-diagnostic code. +time/position motion, SMV8 position memory, Hall feedback and ADC +current-diagnostic code. + +`Passenger_Seat_Timed_Controller_demo/` is the separately buildable +passenger-seat variant. Its CAN application exposes four-axis timed movement +only; position, homing, calibration, dynamic-mode and thermal commands are not +linked into the application. See its `Docs/README_Passenger_Seat_Timed_Controller.md` +for the command contract and generated HEX identity (`PST1`). The matching Cypress 7.9.0 build dependencies are vendored beside it: @@ -10,7 +17,8 @@ The matching Cypress 7.9.0 build dependencies are vendored beside it: firmware/t2g/ ├── common/ ├── tviibh8m/ -└── Seat_Controller_demo/ +├── Seat_Controller_demo/ +└── Passenger_Seat_Timed_Controller_demo/ ``` This preserves the IAR project's `../../common` and `../../tviibh8m` paths and diff --git a/firmware/t2g/Seat_Controller_demo/App/main_cm0plus.c b/firmware/t2g/Seat_Controller_demo/App/main_cm0plus.c index fea17aa..eb0f81e 100755 --- a/firmware/t2g/Seat_Controller_demo/App/main_cm0plus.c +++ b/firmware/t2g/Seat_Controller_demo/App/main_cm0plus.c @@ -13,6 +13,7 @@ #include "bsp_seat_backrest_heater.h" #include "bsp_seat_fan.h" #include "seat_controller_thermal.h" +#include "seat_feature_profile.h" volatile uint32_t g_seatMoveResetReason = 0u; volatile uint32_t g_seatMoveInitResult = MOTOR_API_FAILURE; @@ -59,11 +60,13 @@ int main(void) } Motor_SetWatchdogServiceEnabled(true); +#if SEAT_FEATURE_THERMAL if(!SeatControllerThermal_Init()) { SafetyManager_LatchFault(SAFETY_FAULT_DRIVER); SeatMove_HoldSafeOff(); } +#endif if(!SeatMoveApplication_Init()) { SeatMove_HoldSafeOff(); @@ -72,7 +75,9 @@ int main(void) for(;;) { SeatMoveApplication_Service(); +#if SEAT_FEATURE_THERMAL SeatControllerThermal_Service1ms(); +#endif if(SafetyManager_IsInhibited()) { Motor_ForceSafeOff(); diff --git a/firmware/t2g/Seat_Controller_demo/App/seat_controller_thermal.c b/firmware/t2g/Seat_Controller_demo/App/seat_controller_thermal.c index 55f150f..57ee54b 100755 --- a/firmware/t2g/Seat_Controller_demo/App/seat_controller_thermal.c +++ b/firmware/t2g/Seat_Controller_demo/App/seat_controller_thermal.c @@ -7,6 +7,7 @@ #include "seat_backrest_heater.h" #include "seat_backrest_heater_config.h" #include "seat_fan_config.h" +#include "seat_feature_profile.h" #define SEAT_CONTROLLER_THERMAL_CONTROL_ID (0x29Eu) #define SEAT_CONTROLLER_THERMAL_CONFIG_ID (0x2AAu) @@ -247,6 +248,7 @@ static void SeatControllerThermal_HandleControl29e( fanGear = (uint8_t)((frame->data0 >> 16u) & 0x0Fu); heaterGear = (uint8_t)((frame->data0 >> 20u) & 0x0Fu); +#if SEAT_FEATURE_VENTILATION if(fanOn == 2u) { SeatControllerThermal_StopFan(1u); @@ -266,7 +268,14 @@ static void SeatControllerThermal_HandleControl29e( g_seatControllerThermalInvalidCount++; SeatControllerThermal_StopFan(0u); } +#else + (void)fanOn; + (void)fanMode; + (void)fanGear; + SeatControllerThermal_StopFan(1u); +#endif +#if SEAT_FEATURE_HEATING if(heaterOn == 2u) { SeatControllerThermal_StopHeater(1u); @@ -286,6 +295,11 @@ static void SeatControllerThermal_HandleControl29e( g_seatControllerThermalInvalidCount++; SeatControllerThermal_StopHeater(0u); } +#else + (void)heaterOn; + (void)heaterGear; + SeatControllerThermal_StopHeater(1u); +#endif } static void SeatControllerThermal_HandleConfig2aa( @@ -308,16 +322,23 @@ static void SeatControllerThermal_HandleConfig2aa( g_seatControllerThermalInvalidCount++; return; } +#if SEAT_FEATURE_VENTILATION s_fanConfiguredMaxGear = (fanConfig == 1u) ? 1u : ((fanConfig == 2u) ? 2u : 3u); +#endif +#if SEAT_FEATURE_HEATING s_heaterConfiguredMaxLevel = (heaterConfig == 1u) ? 1u : ((heaterConfig == 2u) ? 2u : 3u); +#endif +#if SEAT_FEATURE_VENTILATION if(s_fanRunning && !SeatControllerThermal_RequestFan(s_fanLastVehicleGear)) { g_seatControllerThermalOutputFaultStage = 4u; SeatControllerThermal_LatchOutputFault(); } +#endif +#if SEAT_FEATURE_HEATING if(g_seatBackrestHeaterAppliedLevel != SEAT_BACKREST_HEATER_LEVEL_OFF) { @@ -327,11 +348,13 @@ static void SeatControllerThermal_HandleConfig2aa( SeatControllerThermal_LatchOutputFault(); } } +#endif } static void SeatControllerThermal_HandleMode3ce( const bsp_seat_move_can_frame_t *frame) { +#if SEAT_FEATURE_HEATING uint8_t mode; if(frame->dlc != SEAT_FAN_CAN_DLC) { @@ -347,6 +370,10 @@ static void SeatControllerThermal_HandleMode3ce( g_seatControllerThermalInvalidCount++; SeatControllerThermal_StopHeater(0u); } +#else + (void)frame; + SeatControllerThermal_StopHeater(1u); +#endif } static void SeatControllerThermal_HandleSwitch42a( @@ -363,6 +390,7 @@ static void SeatControllerThermal_HandleSwitch42a( } fanSwitch = (uint8_t)(frame->data0 & 0x03u); heaterSwitch = (uint8_t)((frame->data0 >> 2u) & 0x03u); +#if SEAT_FEATURE_VENTILATION if(fanSwitch == 1u) { if(!SeatControllerThermal_RequestFan(s_fanLastVehicleGear)) @@ -375,6 +403,11 @@ static void SeatControllerThermal_HandleSwitch42a( { SeatControllerThermal_StopFan(1u); } +#else + (void)fanSwitch; + SeatControllerThermal_StopFan(1u); +#endif +#if SEAT_FEATURE_HEATING if(heaterSwitch == 1u) { if(!SeatControllerThermal_RequestHeater(s_heaterLastVehicleGear)) @@ -387,10 +420,15 @@ static void SeatControllerThermal_HandleSwitch42a( { SeatControllerThermal_StopHeater(1u); } +#else + (void)heaterSwitch; + SeatControllerThermal_StopHeater(1u); +#endif } static bool SeatControllerThermal_RequestFan(uint8_t vehicleGear) { +#if SEAT_FEATURE_VENTILATION uint8_t physicalGear = (vehicleGear >= 3u) ? 3u : vehicleGear; if((vehicleGear == 0u) || (vehicleGear > 5u)) { @@ -418,10 +456,15 @@ static bool SeatControllerThermal_RequestFan(uint8_t vehicleGear) return BspSeatFan_SetDutyPercent(s_fanTargetDutyPercent); } return true; +#else + (void)vehicleGear; + return false; +#endif } static bool SeatControllerThermal_RequestHeater(uint8_t vehicleGear) { +#if SEAT_FEATURE_HEATING uint8_t physicalLevel = (vehicleGear >= 3u) ? 3u : vehicleGear; if((vehicleGear == 0u) || (vehicleGear > 5u) || !s_heaterModeValid) { @@ -440,6 +483,10 @@ static bool SeatControllerThermal_RequestHeater(uint8_t vehicleGear) g_seatControllerHeaterFeedbackRaw = physicalLevel + 1u; s_feedbackPending = true; return true; +#else + (void)vehicleGear; + return false; +#endif } static void SeatControllerThermal_StopFan(uint8_t feedbackRaw) diff --git a/firmware/t2g/Seat_Controller_demo/App/seat_move_application.c b/firmware/t2g/Seat_Controller_demo/App/seat_move_application.c index 29a010d..628caff 100755 --- a/firmware/t2g/Seat_Controller_demo/App/seat_move_application.c +++ b/firmware/t2g/Seat_Controller_demo/App/seat_move_application.c @@ -8,6 +8,7 @@ #include "safety_manager.h" #include "seat_controller_thermal.h" #include "seat_dynamic_mode.h" +#include "seat_feature_profile.h" #include "seat_motor_manager.h" #include "seat_move_config.h" #include "seat_move_store.h" @@ -336,7 +337,9 @@ void SeatMoveApplication_Service(void) } else { +#if SEAT_FEATURE_THERMAL SeatControllerThermal_HandleFrame(&frame); +#endif } } SeatMove_ServiceDispatcher(); @@ -363,7 +366,9 @@ void Motor_CalibrationServiceHook(void) } else { +#if SEAT_FEATURE_THERMAL SeatControllerThermal_HandleFrame(&frame); +#endif } } if(g_bspSeatMoveCanBusOff != 0u) @@ -533,15 +538,28 @@ static bool SeatMove_ParseCommand(const bsp_seat_move_can_frame_t *frame, { case SEAT_MOVE_OPCODE_MOVE_ABSOLUTE: case SEAT_MOVE_OPCODE_MOVE_RELATIVE: +#if SEAT_FEATURE_POSITION_CONTROL + break; +#else + *reject = SEAT_MOVE_REJECT_BAD_OPCODE; + return false; +#endif case SEAT_MOVE_OPCODE_MOVE_TIMED: case SEAT_MOVE_OPCODE_STOP_MOTOR: case SEAT_MOVE_OPCODE_STOP_ALL: + break; case SEAT_MOVE_OPCODE_CALIBRATE_RANGE: case SEAT_MOVE_OPCODE_HOME_KNOWN_RANGE: - case SEAT_MOVE_OPCODE_QUERY_STATUS: - case SEAT_MOVE_OPCODE_CLEAR_FAULT: case SEAT_MOVE_OPCODE_START_DYNAMIC_MODE: case SEAT_MOVE_OPCODE_STOP_DYNAMIC_MODE: +#if SEAT_FEATURE_POSITION_CONTROL + break; +#else + *reject = SEAT_MOVE_REJECT_BAD_OPCODE; + return false; +#endif + case SEAT_MOVE_OPCODE_QUERY_STATUS: + case SEAT_MOVE_OPCODE_CLEAR_FAULT: break; default: *reject = SEAT_MOVE_REJECT_BAD_OPCODE; diff --git a/firmware/t2g/Seat_Controller_demo/Config/seat_feature_profile.h b/firmware/t2g/Seat_Controller_demo/Config/seat_feature_profile.h new file mode 100644 index 0000000..5cd0fa1 --- /dev/null +++ b/firmware/t2g/Seat_Controller_demo/Config/seat_feature_profile.h @@ -0,0 +1,27 @@ +#ifndef SEAT_FEATURE_PROFILE_H +#define SEAT_FEATURE_PROFILE_H + +/* + * Repository default is the complete controller. The workbench build script + * replaces this header only inside its isolated staging tree so every OTA + * task receives the exact capability profile selected by the user. + */ +#define SEAT_FEATURE_POSITION_CONTROL (1) +#define SEAT_FEATURE_VENTILATION (1) +#define SEAT_FEATURE_HEATING (1) + +#if ((SEAT_FEATURE_POSITION_CONTROL != 0) && \ + (SEAT_FEATURE_POSITION_CONTROL != 1)) +#error "SEAT_FEATURE_POSITION_CONTROL must be 0 or 1" +#endif +#if ((SEAT_FEATURE_VENTILATION != 0) && (SEAT_FEATURE_VENTILATION != 1)) +#error "SEAT_FEATURE_VENTILATION must be 0 or 1" +#endif +#if ((SEAT_FEATURE_HEATING != 0) && (SEAT_FEATURE_HEATING != 1)) +#error "SEAT_FEATURE_HEATING must be 0 or 1" +#endif + +#define SEAT_FEATURE_THERMAL \ + ((SEAT_FEATURE_VENTILATION != 0) || (SEAT_FEATURE_HEATING != 0)) + +#endif /* SEAT_FEATURE_PROFILE_H */ diff --git a/firmware/t2g/Seat_Controller_demo/IAR/Seat_Controller_demo.ewp b/firmware/t2g/Seat_Controller_demo/IAR/Seat_Controller_demo.ewp index d4ecfd8..dbb3ebb 100755 --- a/firmware/t2g/Seat_Controller_demo/IAR/Seat_Controller_demo.ewp +++ b/firmware/t2g/Seat_Controller_demo/IAR/Seat_Controller_demo.ewp @@ -2359,6 +2359,9 @@ $PROJ_DIR$\..\Config\seat_move_config.h + + $PROJ_DIR$\..\Config\seat_feature_profile.h + $PROJ_DIR$\..\Config\seat_backrest_heater_config.h diff --git a/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.hex b/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.hex index bdad282..215b93c 100755 --- a/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.hex +++ b/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.hex @@ -1,12 +1,12 @@ :020000041000EA -:100000000000022801EC00103DF4001055F1001032 +:1000000000000228F5EB0010E1F30010F9F00010F9 :1000100000000000000000000000000000000000E0 -:1000200000000000000000000000000015F20010B9 -:10003000000000000000000041F4001045F4001032 +:10002000000000000000000000000000B9F1001016 +:100030000000000000000000E5F30010E9F30010EC :10004000CDE50010F1E5001015E6001039E60010CE :100050005DE6001081E60010A5E60010C9E600107C -:1000600049F400104DF4001051F4001055F4001044 -:1000700059F400105DF4001061F4001065F40010F4 +:10006000EDF30010F1F30010F5F30010F9F30010B8 +:10007000FDF3001001F4001005F4001009F4001065 :1000800000B5010009B2002905D40121C006C00E47 :100090008140C648016000BD00B5010009B20029D9 :1000A00005D40121C006C00E8140C148016000BDD9 @@ -78,7 +78,7 @@ :1004C000060091204000FFF779FFF106C90E009168 :1004D00071090B051B0D5B1E2200D2B20321C0B2B5 :1004E000FFF7F8FED3BD80B51300A74A13600023C1 -:1004F000137203F048FE01BD24F3001000B5C0B232 +:1004F000137203F048FE01BDC8F2001000B5C0B28F :10050000042801D2012000E00020C0B200BD00B5E7 :10051000401A00B2012801DB012000E00020C0B237 :1005200000BD00B5BE490978002901D000201FE0B8 @@ -313,8 +313,8 @@ :101370002000FFF723F801220321C0B2FFF762F833 :10138000884C200003F0A2FE00228749200003F0D1 :1013900063FD2000FEF720FF13BD0000FC1B0028AA -:1013A00034E700108CEB0010C21C00283CEE00104B -:1013B0002CF30010CD100010F2B5060000273C0001 +:1013A00034E7001080EB0010C21C0028B8EC0010DD +:1013B000D0F20010CD100010F2B5060000273C005E :1013C0000098002801D1002077E0380000990880BB :1013D00003F0AEFBCD490978002903D003F0ACFB44 :1013E00000206AE00121C94A117003F0A5FB6D4D90 @@ -343,9 +343,9 @@ :10155000FFF7D5FF002806D068460078E4B2A04225 :1015600001D1012000E00020C0B232BD80B5B44AF4 :1015700000211160017C1172C168806802F003FED5 -:1015800001BD00006012002808F00010000658405D +:1015800001BD000060120028ACEF001000065840BA :1015900080065840000758408009584000B4C404F1 -:1015A0000024F400000064408CEE0010F8B5012621 +:1015A0000024F4000000644030EE0010F8B501267D :1015B0003000002468460470564804700121A14898 :1015C000FEF7B8FDC24F01213800FEF7B3FD9E4D76 :1015D000280003F003FE01219C48FEF7ABFD280024 @@ -1109,12 +1109,12 @@ :104530009BB21B0A9BB2174C14401B02FF221202B3 :104540001A4022430B78052B05D10979C9B2FF2304 :104550009A43114304E04978FF239A43C9B21143B7 -:10456000016300221000F2BD0CF40010FFFFFBFFFE +:10456000016300221000F2BDB0F30010FFFFFBFF5B :10457000FFFFFCFFFFFFFFF8FFFFFFBFFFFBFFFF99 -:10458000FFFDFFFFFFFEFFFFFFCFFFFF10F4001056 -:10459000FFFF00FFFF00FFFF14F4001018F40010ED -:1045A000FFF3FFFFFFFCFFFF1CF4001020F40010DE -:1045B00024F400100168802212060A43026070474A +:10458000FFFDFFFFFFFEFFFFFFCFFFFFB4F30010B3 +:10459000FFFF00FFFF00FFFFB8F30010BCF30010A7 +:1045A000FFF3FFFFFFFCFFFFC0F30010C4F3001098 +:1045B000C8F300100168802212060A4302607047A7 :1045C000016849004908016070474068704701610F :1045D00070470069704781607047006A704700004B :1045E00030B4434351435D18010C0C00130C5C4381 @@ -1169,7 +1169,7 @@ :1048F000142060432818007C787103A914206043B9 :104900002818C06802F0EAFC0028E7D0300002F066 :10491000E1FC002003E0012071490870012019B07A -:10492000F0BD000024F3001000000440FF00FFFF72 +:10492000F0BD0000C8F2001000000440FF00FFFFCF :10493000FF0000FFF8B584B00E00C0B20290002561 :1049400028006946088004212A0003AC200002F0F8 :1049500033FC2C0062480078002806D0674F387876 @@ -1191,16 +1191,16 @@ :104A500001D0002018E0631E042201212800FFF786 :104A6000EFFD002801D000200EE0042201212800E3 :104A7000FFF771FE002801D0002005E0009821001A -:104A8000FFF7C0FB38600120F2BD0000FCF200100F +:104A8000FFF7C0FB38600120F2BD0000A0F200106B :104A90007CB505001D4C062100226E46300002F058 :104AA0008BFB6946280002F011FE6846007800285A :104AB00003D1641E002CF4D101E0012000E00020AD -:104AC00076BD000014F10010E0B511490CC9083999 +:104AC00076BD0000B8F00010E0B511490CC90839F6 :104AD00069460CC10839694602F0A2FD07BD000015 -:104AE000B71C0028901B0028A81B00282CEA0010E7 +:104AE000B71C0028901B0028A81B002820EA0010F3 :104AF000FFC99A3B00CA9A3B00109040B81C00289E :104B00005F8DCB00608DCB0001000100A08601000D -:104B100004F300100000000000000000000000008E +:104B1000A8F20010000000000000000000000000EB :104B2000801C800803D0401C801EFCD100BF704751 :104B3000EFF3108072B6704780F3108870474030F2 :104B4000016810220A430260704740300168082261 @@ -1234,10 +1234,10 @@ :104D0000A54394000C2222402A43C264174A146827 :104D10004C228A5C1A400725AC4322434D240C5D8B :104D200023407024A2431C01702323401343436596 -:104D3000497D816700221000F2BD0000F8F30010E9 +:104D3000497D816700221000F2BD00009CF3001045 :104D4000FFFFFBFFFFFFFCFFFFFFFFF8FFFFFFBFC1 -:104D5000FCF30010FFFF00FFFF00FFFF00F4001056 -:104D600004F40010FFF3FFFFFFFCFFFF08F4001046 +:104D5000A0F30010FFFF00FFFF00FFFFA4F300100F +:104D6000A8F30010FFF3FFFFFFFCFFFFACF3001000 :104D70000168802212060A43026070477830016899 :104D800001220A4302607047C16F0800C007C00FCC :104D90007047010070310A68012313430B60006FF4 @@ -1246,7 +1246,7 @@ :104DC0000B79002B01D00122FFE763005B08D207BB :104DD0001A430B4B0024095F89005A5000E0064833 :104DE00030BD00B5074A1368002B03D0126800B22B -:104DF0008000115000BD000001005600F4F30010C7 +:104DF0008000115000BD00000100560098F3001023 :104E0000008020402412002870B4103A03D378C9DF :104E1000103A78C0FBD2530701D318C918C001D586 :104E200008C908C0D20704D30B880380891C801CE2 @@ -1296,8 +1296,8 @@ :1050E0000021C1239B00C15080239B00C25081221C :1050F00092008150124A8150154A8150114A8150C4 :10510000114A8150134A8150134A81507047000060 -:105110000140AA00DCF30010E0F30010E4F30010FB -:10512000E8F30010FF3FFFFFFFFEFFFFFFFFFFFC64 +:105110000140AA0080F3001084F3001088F300100F +:105120008CF30010FF3FFFFFFFFEFFFFFFFFFFFCC0 :10513000FFFDFFFFFFFBFFFFFFF7FFFF0F00000377 :10514000C80E0000C80F0000880F0000EFCDAB00B4 :10515000880E0000080F0000480F0000F8B504009A @@ -1313,7 +1313,7 @@ :1051F00002F0DAFE0020E075E52040000A49086070 :10520000002002F031FF2C0030681049FEF7FAFF51 :10521000002122000023FFF7E3F9FFF79FFF70BD95 -:10522000181B00288CED0010F01B002810E000E097 +:10522000181B0028A0ED0010F01B002810E000E083 :10523000BE1C0028FFFFFF00010000FFFFFFFFFF73 :1052400014E000E0941C002818E000E040420F0049 :1052500043490A680860704742490A680860704715 @@ -1402,8 +1402,8 @@ :1057800000B5114B1870114B1970002A05D0117813 :105790000F4B197091880F4A1180C0B2002803D0B6 :1057A00001209949087002E0002097490870012003 -:1057B0009649087000BD0000AC1A002834F40010AF -:1057C000D0F2001038F40010D71C0028D81C002894 +:1057B0009649087000BD0000AC1A0028D8F300100C +:1057C00074F20010DCF30010D71C0028D81C00284D :1057D000D91C0028B01C0028F8B50500002D05D004 :1057E0000C00002C02D01600002E01D1002039E060 :1057F0000A2100222700380001F0DEFC68682070D2 @@ -1866,7 +1866,7 @@ :107480009205C02000041040084300998860E08AFB :10749000390C090480B20843218B82B208041043DE :1074A0000099C86021001A31009800F055F801E0F9 -:1074B00020480190019805B0F0BD00004CF3001089 +:1074B00020480190019805B0F0BD0000F0F20010E6 :1074C000FFFEFFFFFFFDFFFFFFFBFFFFFFFFFFDFF3 :1074D0003830016801220A43026070473830016881 :1074E00001229143016070473C30016801220A4348 @@ -1874,8 +1874,8 @@ :1075000014D05B6A1C000C80190F0C00E407E40F18 :107510001470990F0C00E407E40F9470590F0C00DD :10752000E407E40F5470D90FD17000E0014810BD9A -:10753000FFFFFFBF0100DA0050F3001054F300100A -:1075400058F300105CF3001060F30010FFFCFFFF25 +:10753000FFFFFFBF0100DA00F4F20010F8F20010C4 +:10754000FCF2001000F3001004F30010FFFCFFFF3A :10755000FFF7FFFFFFF8FFFFF0B50A000023190057 :107560006F4C2468002A49D05579002D01D001269E :1075700000E01E006B4D2540B4028026F60034402A @@ -1905,8 +1905,8 @@ :1076F000E407E40FCC7053081C00E407E40F4C705F :1077000013001C00E407E40F0C7092081300DB0761 :10771000DB0F8B7000E0014810BD00000100DA00B3 -:1077200064F30010FFFBFFFFFFFDFFFFFFFEFFFF05 -:1077300068F3001000B5012027490A68520802408A +:1077200008F30010FFFBFFFFFFFDFFFFFFFEFFFF61 +:107730000CF3001000B5012027490A6852080240E6 :10774000002A01D0022001E00968084000BD274856 :1077500001680800C007C00F704700B5C21E022AAA :1077600001D3012002E0C01E0860002000BD1CB54E @@ -1929,7 +1929,7 @@ :107870008007800F32BD0000401626400F48006888 :10788000000AC0B270470E480068000AC0B27047D4 :10789000000000000019264010002740181B002897 -:1078A000D4EC00105B080000F01B002840122640BA +:1078A00008ED00105B080000F01B00284012264085 :1078B00004480068000AC0B2704700001010204061 :1078C0000C1020400810204038B50400072C0ED3BF :1078D000124D18221249280000F066FB0020E875BE @@ -1937,7 +1937,7 @@ :1078F000A10040584007400F010009B2042907D1F8 :107900000448A1004058C106C90E80204000084329 :1079100000B232BD0001264000122640181B00288C -:1079200030ED0010F01B00281E4909688A03920BF5 +:1079200054ED0010F01B00281E4909688A03920BD1 :107930000260090E0A00D207D20FC2711749096806 :107940000A00D204D20C82800A00120CD2B2028148 :10795000090CC9B24181264909680F220B00134066 @@ -2030,7 +2030,7 @@ :107EC000002805D002280BD006D303280CD00FE0E1 :107ED00000982860002032BD009840082860F9E72B :107EE000009880082860F5E70098C0082860F1E74E -:107EF000002028600320EEE7181B002830ED00105A +:107EF000002028600320EEE7181B002854ED001036 :107F000063090000F01B0028001626407F090000CE :107F1000001926401CB5040069460020FFF7AFFF9A :107F20000100C9B2002903D000212160C0B209E0DC @@ -2352,7 +2352,7 @@ :1092E0000C482A00D2B2805CFFF724FE002808D088 :1092F00068463221405C002803D020000124AC40A5 :1093000004436D1C2800C0B20428E8D32000C0B27A -:109310000FB030BD28F40010C027090000B5017857 +:109310000FB030BDCCF30010C027090000B50178B4 :109320000A290ED0417804290BD04178062908D0AB :109330004178072905D04178082902D040780928CA :1093400001D1012000E00020C0B200BDF1B590B015 @@ -2378,7 +2378,7 @@ :10948000691848832B895A2270682100C9B24900A3 :10949000415A8B482700FFB2C05DFFF719FC002836 :1094A000E1D1002000E00120F2BD0000FFFF00003C -:1094B0009CF00010781A0028F8B590B00700002F33 +:1094B00040F00010781A0028F8B590B00700002F8F :1094C00004D008003B7BC0B2984201D30020BEE02C :1094D000B8680B00DBB218246343C0180190904EAB :1094E00002207070F17001980078B07032610198BC @@ -2411,13 +2411,13 @@ :10969000641C2000C0B2042810D22B89B27D2000A7 :1096A000C0B240002818418D1E482700FFB2C05D9F :1096B000FFF70EFB0028EBD1002000E00120F2BDF7 -:1096C00028F4001038B5164D2C89641C200080B297 +:1096C000CCF3001038B5164D2C89641C200080B2F4 :1096D000F8F7A0FFF8F750FE200080B2FEF756FF23 :1096E0002C8103206870F9F724FB286131BD38B55F :1096F0000A000B4BD978491C0C00057BE4B2AC4244 :1097000003D30021DC88641CDC80C9B2FFF7D4FEDF :10971000002801D1FFF7D6FF31BD000030750000F1 -:10972000781A002828F40010F8B5C0B0002001F025 +:10972000781A0028CCF30010F8B5C0B0002001F082 :1097300046FD20A9002000F098FA070069460120A4 :1097400000F093FA04003800C0B2022801D10125CC :1097500000E000252000C0B2022801D1012600E06F @@ -2591,7 +2591,7 @@ :10A1D0001500EDB22D015D196D6865601500EDB2D9 :10A1E0002D015D19AD68A5601500EDB22D015B195B :10A1F0001B7B2373521C1300DBB2102B00D30022F5 -:10A200000A70FAF799FC012032BD000024F3001017 +:10A200000A70FAF799FC012032BD0000C8F2001074 :10A2100000000440FF00FFFFFF0000FFF1B594B015 :10A220000D0016005021002422006F463800FCF774 :10A23000C3FF149880210901884203D24E48006868 @@ -2639,7 +2639,7 @@ :10A4D00059188C60027070BD2817002800B5C0B2F2 :10A4E000002802D101200F49086000BDE01B0028B0 :10A4F000DC1B0028D01B0028D41B0028005A620255 -:10A500006CEF0010A0EF0010D4EF0010800131407C +:10A5000010EF001044EF001078EF00108001314090 :10A51000C81B0028D81B0028B91C0028BA1C00281A :10A52000CC1B0028E41B002800B5BFF34F8F3D482B :10A53000AE490860BFF34F8F00BFFDE7AC490A6822 @@ -2875,9 +2875,9 @@ :10B39000D2B26946002002F027F900280CD0094DEE :10B3A000182209492800FCF7FFFDEC75F520074934 :10B3B00008600020FCF758FE05B0F0BD00E100E099 -:10B3C0001CED00E0181B0028E8ED0010F01B002821 +:10B3C0001CED00E0181B0028ECED0010F01B00281D :10B3D0008BB100100800244000F4244000F5244004 -:10B3E000BB1C0028BC1C002814F3001000B500286A +:10B3E000BB1C0028BC1C0028B8F2001000B50028C7 :10B3F00001D0002901D1A04808E0A04A904206D11E :10B4000000200880802080014860002000BD9C4A08 :10B41000904205D18020800102000A804860F4E754 @@ -2930,7 +2930,7 @@ :10B70000AD5DA4B2ADB2AC42EBD370BD000452400B :10B7100000065240000852400000544000025440CD :10B7200000045440000654400008544058080028C3 -:10B730000CF3001001085240301B0028F1B5A8B0EE +:10B73000B0F2001001085240301B0028F1B5A8B04B :10B740000020009000202898FFF7BAFE0700002F85 :10B7500029D0289E306D97490140002902D02898B1 :10B76000FFF73EFF0125306DC00C284000281CD19A @@ -2970,7 +2970,7 @@ :10B98000009A120A14400C43046078680028D3D04F :10B9900013AA012101207B689847CDE70648289922 :10B9A000BC22895809094907490F415C891C0020BC -:10B9B000B4E70000EEE0F73F0CF30010F1B594B0EF +:10B9B000B4E70000EEE0F73FB0F20010F1B594B04C :10B9C000D34800681290D34800681190D2480768A5 :10B9D000D2480668D24800681090D24800680F909C :10B9E000D14800680490D14800680E90D0480068A3 @@ -3024,15 +3024,15 @@ :10BCE000EB589B0814339B049B0C11409A001040A6 :10BCF0000843C0216850802252021498000C0004AE :10BD00006946898A4018801806998B08002125E029 -:10BD10006CF3001070F3001074F3001078F300104F -:10BD20007CF3001080F3001084F3001088F30010FF -:10BD30008CF3001090F3001094F3001098F30010AF -:10BD40009CF30010A0F30010A4F30010A8F300105F -:10BD5000ACF3001000260660001D491C9942F9D37F +:10BD100010F3001014F3001018F300101CF30010BF +:10BD200020F3001024F3001028F300102CF300106F +:10BD300030F3001034F3001038F300103CF300101F +:10BD400040F3001044F3001048F300104CF30010CF +:10BD500050F3001000260660001D491C9942F9D3DB :10BD600001990120084302210143CF480840217D69 :10BD7000012901D1012100E00021CC4B0340480200 :10BD80008021890008401843217D01290CD101211F -:10BD90000BE000BFB0F30010B4F30010010062002C +:10BD90000BE000BF54F3001058F3001001006200E4 :10BDA000FFFF00FF0300FFFF0021C14B034008021B :10BDB000802149000840184380218843402188435E :10BDC0002021884310218843042188430190019851 @@ -3046,7 +3046,7 @@ :10BE4000E67F0E40F027B8433701F0263E40064318 :10BE50002020205C01400F20864331432220205CBB :10BE6000012811D1012610E0FFFF80FFFCFF000038 -:10BE7000FFF8FFFFFFFFFF800CF30010FFFFFFC084 +:10BE7000FFF8FFFFFFFFFF80B0F20010FFFFFFC0E1 :10BE8000FFFFC0FFFFFFFFBF00268F480840F105FE :10BE90008026360431400143E9602320215C1940AB :10BEA0000B9E8A4830400E02FE21C90131400143F9 @@ -3126,7 +3126,7 @@ :10C34000684600788540D0203D500020F2BD0000B6 :10C35000301B002801006200FFFFFF1F030A0006D8 :10C36000330A0000FFFFFFDFFFFFFFBFFFFF7FFF7C -:10C37000FFFFF0FFFFFFDFFFFFFFEFFF0CF30010F9 +:10C37000FFFFF0FFFFFFDFFFFFFFEFFFB0F2001056 :10C3800010B50200002A03D00800C0B2202801DB4B :10C39000002021E000200123CC241459CC401C4073 :10C3A000002C09D0D4201058C8401840002801D1D2 @@ -3229,7 +3229,7 @@ :10C9B0002000F7F705FE4D492000F7F77BFC002823 :10C9C00001D000200FE02000F7F707FE4849884219 :10C9D00007D12000F7F7FDFD4649884201D101202B -:10C9E00000E00020C0B210BD24F3001070B5424C2E +:10C9E00000E00020C0B210BDC8F2001070B5424C8B :10C9F0000021216000F046F8002803D101202060CA :10CA000000203AE0374E002201213000FFF76FFE90 :10CA1000354D37492800F7F7DEFD2800F7F7CAFD46 @@ -3245,7 +3245,7 @@ :10CAB00032BD10B50C48F7F783FD0A4C0022012166 :10CAC000200000F07BF800220221200000F076F820 :10CAD00010BD0000FF0000FF00FFFF00FFFCFFFF94 -:10CAE00038EF001080053140001E584018EB001050 +:10CAE000DCEE001080053140001E58400CEB0010B9 :10CAF0001F4E0000204E0000C01B0028F8B5002586 :10CB000000956846FBF706FA002801D0002045E0B2 :10CB1000274C0098A04205D300982100F7F772FB3C @@ -3316,7 +3316,7 @@ :10CF20001AE0534C2000F7F74BFB52492000F7F76B :10CF3000C1F9002801D000200EE02000F7F74DFBDA :10CF40004D49884206D12000F7F743FB002801D164 -:10CF5000012000E00020C0B210BD000024F300104A +:10CF5000012000E00020C0B210BD0000C8F20010A7 :10CF600070B5464C0021216000F046F8002803D13E :10CF70000120206000203AE03B4E002207213000D3 :10CF8000FFF777FE3A4D3C492800F7F724FB2800CD @@ -3333,8 +3333,8 @@ :10D03000FF0000FF80B50E48F7F7C2FA00F07AF85B :10D0400000200A49086001BD80B50948F7F7BDFA1C :10D05000C10B0800C007C00F02BD000000FFFF00A9 -:10D06000FFFCFFFF04EF001000083140B81B002850 -:10D0700080205840A4EA00101F4E0000BC1B00286E +:10D06000FFFCFFFFA8EE001000083140B81B0028AD +:10D070008020584098EA00101F4E0000BC1B00287A :10D08000F8B5002500956846FAF744FF002801D05E :10D09000002045E0354C0098A04205D300982100BF :10D0A000F7F7B0F8002901D0002039E000982100FE @@ -3388,11 +3388,11 @@ :10D3A000300029001022F7F749FD00F017F8012896 :10D3B00003D1032008E0022006E000F00FF8002867 :10D3C00001D1002000E00420FEBD0000181B002851 -:10D3D00024E90010F01B00280010224010B5274C53 +:10D3D000A4E90010F01B00280010224010B5274CD3 :10D3E00010212000FAF762FE2068000F0007A0213C :10D3F0000906884201D1002009E02068000F0007DB :10D40000F0210906884201D1012000E0022010BD70 -:10D41000ECF30010F0F30010000020400000224068 +:10D4100090F3001094F30010000020400000224020 :10D42000FFFFFF0F38B50400FFF7D8FF0500280005 :10D43000C0B2022801D142480DE0002C04D02000E7 :10D4400027491022F7F7FAFCEDB2002D01D1002098 @@ -3411,7 +3411,7 @@ :10D5100001D1012600E002262800F040C00705D511 :10D5200009480168002901D0006880472A0CA9B287 :10D530002000FFF746FE70BDFFFFFF00801B0028A4 -:10D54000FFFFFF0F1CF30010E81B0028E5D40010BC +:10D54000FFFFFF0FC0F20010E81B0028E5D4001019 :10D550000000204050480068410B0800C007C00F81 :10D5600070474D480068010B0800C007C00F7047A6 :10D5700000B54A48012102680A43026002680A4075 @@ -3445,7 +3445,7 @@ :10D73000294820188021490288420FD201200EE09A :10D740008020000255496118814201D2012006E083 :10D7500053496118814201D2012000E0002010BD30 -:10D76000FFFF00FF7CF2001010B50400FFF7F9FE88 +:10D76000FFFF00FF20F2001010B50400FFF7F9FEE4 :10D77000002810D1F02000062018FE21C90388429D :10D7800001D2002026E0654820188021C902884285 :10D790001FD200201EE0FE208003F0210906611840 @@ -3504,7 +3504,7 @@ :10DAE00015001E003800FFF7EFFF00280DD12900B8 :10DAF0003800FFF7E5FF31003800FFF7E3FF2100B2 :10DB00003800FFF7D5FF002000E00248F2BD00001A -:10DB1000D8F3001001008A00F8B584B0F7F715FCBF +:10DB10007CF3001001008A00F8B584B0F7F715FC1B :10DB2000BA49012708680322904338430860B8487F :10DB30000A6802400A60B7490A6803239A433A43D5 :10DB40000A600A6802400A60B3490A6803239A43DC @@ -3589,8 +3589,8 @@ :10E030004018FA218900F6F7E5F828602868C0033F :10E040001749086007B030BD041626400C19264059 :10E050001C1926404016264044162640141226401D -:10E06000E0F2001040122640080020400804204042 -:10E070000815264064F20010181B00286CEC0010F4 +:10E0600084F200104012264008002040080420409E +:10E070000815264008F20010181B002860EC00105C :10E08000F01B0028941C0028981C00283F420F0019 :10E0900040420F00A01C00289C1C0028E703000041 :10E0A000A41C0028F3B595B0170000210D0008004E @@ -3672,7 +3672,7 @@ :10E5600000210120FFF7ECFE0020FFF7DDFE012077 :10E57000FFF7DAFE10BD000000E100E000E400E07B :10E580001CED00E000132040FFFFF7FF8013204048 -:10E59000A013204044F3001064EC001068EC00105D +:10E59000A0132040E8F2001058EC00105CEC0010D2 :10E5A00080000000001D00280000001008ED00E0C1 :10E5B00000B5010009B2002905D40121C006C00E32 :10E5C00081404A48016000BD00B5FEE780B548487B @@ -3694,23 +3694,23 @@ :10E6C0000620FFF775FF01BD80B51848006818489F :10E6D0000068C10F002905D007498005800D800022 :10E6E000085880470720FFF763FF01BD80E200E084 -:10E6F000B8F300100011204048090028BCF30010B6 -:10E7000004112040C0F3001008112040C4F3001091 -:10E710000C112040C8F3001010112040CCF3001061 -:10E7200014112040D0F3001018112040D4F3001031 -:10E730001C11204034F1001090F200108002314092 +:10E6F0005CF30010001120404809002860F300106E +:10E700000411204064F300100811204068F3001049 +:10E710000C1120406CF300101011204070F3001019 +:10E720001411204074F300101811204078F30010E9 +:10E730001C112040D8F0001034F20010800231404B :10E740000400000008000000800658403A01000064 :10E7500000093140020000000A0F0D07E00100002F :10E76000FC53000098210000F4010000E8030000C1 -:10E77000F4010000C0F10010A0F20010800231404E +:10E77000F401000064F1001044F200108002314006 :10E780000500000008000000000758403B010000A1 :10E7900000053140010000000AF0D0707601000051 :10E7A000E0150000D41700005E010000E80300003F -:10E7B000F4010000DCF10010B0F200100004314060 +:10E7B000F401000080F1001054F200100004314018 :10E7C00000000000080000008009584040010000DF :10E7D0000007314000000000090F0D0795030000FD :10E7E000901A000028230000F4010000E803000054 -:10E7F000F4010000F8F10010C0F200108000314078 +:10E7F000F40100009CF1001064F200108000314030 :10E800000000000008000000000658403901000028 :10E81000000631400500000009F0D07087020000BA :10E82000201C000098210000F4010000D007000027 @@ -3729,169 +3729,169 @@ :10E8F00075FCECE7F41B0028281200280648006885 :10E9000070470020C04304490860044908600020A3 :10E91000034908607047000000182640041826408C -:10E9200008102640443A5C6875616E672E787565FC -:10E930007765695C4769745C436F676E6974697673 -:10E94000655F4167656E745C2E636F6465785F74A4 -:10E950006D705C736561742D6669726D7761726547 -:10E960002D6275696C642D3031363932303434396A -:10E97000363564343762306139303435613463613F -:10E98000646131636231385C636F6D6D6F6E5C73AF -:10E9900072635C647269766572735C6970635C63F0 -:10E9A000795F6970635F6472762E6800F1B4016804 -:10E9B0004118436858080A1800988068DB0701D599 -:10E9C0004B46C01891422DD00D78491C03240326D4 -:10E9D0002E4002D10E78F61C491C2B090F2B07D1B3 -:10E9E0000B780F33491C03E00F78491C0770401C5B -:10E9F000761EF9D1002BE5D00E78491CAD082C40CD -:10EA0000032C01D10C78491C2402341965424419A5 -:10EA10000025AE1E9E42D5D02678641C0670401C90 -:10EA20006D1CF6E700980C30F2BC704700053140D1 -:10EA30000600000000000000801890400200000066 -:10EA40000006314001000000000000004019904025 -:10EA50000500000000053140070000000000000034 -:10EA6000C018904003000000000531400500000080 -:10EA700000000000401890400100000000063140F6 -:10EA80000200000000000000801990400600000015 -:10EA9000000631400600000000000000801A90408F -:10EAA0000A00000004000000000000000000000058 -:10EAB00000000000010003031F4E000000000000E2 +:10E9200008102640F1B401684118436858080A18D5 +:10E9300000988068DB0701D54B46C01891422DD066 +:10E940000D78491C032403262E4002D10E78F61CB4 +:10E95000491C2B090F2B07D10B780F33491C03E0FF +:10E960000F78491C0770401C761EF9D1002BE5D0AA +:10E970000E78491CAD082C40032C01D10C78491CA1 +:10E9800024023419654244190025AE1E9E42D5D09A +:10E990002678641C0670401C6D1CF6E700980C304D +:10E9A000F2BC7047443A5C50726F6A6563745C35C0 +:10E9B00031315C4E375765625C2E636F6465785FFA +:10E9C000746D705C736561742D6669726D776172C8 +:10E9D000652D6275696C642D326530363863373762 +:10E9E00063306635346431386233393863383339EB +:10E9F00063663830633463625C636F6D6D6F6E5C49 +:10EA00007372635C647269766572735C6970635C6F +:10EA100063795F6970635F6472762E68000000003E +:10EA20000005314006000000000000008018904002 +:10EA3000020000000006314001000000000000005C +:10EA4000401990400500000000053140070000001B +:10EA500000000000C0189040030000000005314095 +:10EA60000500000000000000401890400100000078 +:10EA700000063140020000000000000080199040B4 +:10EA80000600000000063140060000000000000003 +:10EA9000801A90400A0000000400000000000000FE +:10EAA0000000000000000000010003031F4E0000F2 +:10EAB0000000000000000000000000000000000056 :10EAC0000000000000000000000000000000000046 -:10EAD0000000000000000000000000000000000036 -:10EAE000000000000203000000000000030000001E -:10EAF0000000000003000000000000000300000010 -:10EB000000000000030000000000000003000000FF -:10EB100001000000010200000400000000000000ED -:10EB20000000000000000000010003031F4E000071 -:10EB30000000000000000000204E00000000000067 -:10EB400000000000000000000000000000000000C5 -:10EB500000000000000000000203000000000000B0 -:10EB6000030000000000000003000000000000009F -:10EB7000030000000000000003000000000000008F -:10EB8000030000000100000001020000040000007A -:10EB9000000000000000000000000000010003036E -:10EBA00063000000000000000000000032000000D0 +:10EAD0000000000000000000020300000000000031 +:10EAE0000300000000000000030000000000000020 +:10EAF0000300000000000000030000000000000010 +:10EB000003000000010000000102000004000000FA +:10EB100000000000000000000000000001000303EE +:10EB20001F4E00000000000000000000204E00000A +:10EB300000000000000000000000000000000000D5 +:10EB400000000000000000000000000002030000C0 +:10EB500000000000030000000000000003000000AF +:10EB6000000000000300000000000000030000009F +:10EB7000000000000300000001000000010200008E +:10EB80000400000000000000000000000000000081 +:10EB9000010003036300000000000000000000000B +:10EBA0003200000000000000000000000000000033 :10EBB0000000000000000000000000000000000055 -:10EBC0000000000000000000000000000203000040 -:10EBD000000000000300000000000000030000002F -:10EBE000000000000300000000000000030000001F -:10EBF000000000000300000001000000010200000E -:10EC000072B6104810490860BFF34F8F0120C0044E -:10EC10000E490A6802430A600D490A6802430A6005 -:10EC20000C490A6802430A60002000210D4A03C211 -:10EC30006A45FCD1084FB847084FB847084FB84756 -:10EC4000FEE700000000001008ED00E00013204087 -:10EC500080132040A0132040B9E30010CDF0001035 -:10EC600031F2001000FF0128FFFF0128443A5C68E0 -:10EC700075616E672E7875657765695C4769745C48 -:10EC8000436F676E69746976655F4167656E745C32 -:10EC90006669726D776172655C7432675C747669FF -:10ECA000696268386D5C7372635C73797374656DE7 -:10ECB0005C7265765F645C73797374656D5F74769E -:10ECC00069696268386D5F636D30706C75732E634F -:10ECD00000000000443A5C6875616E672E787565C7 -:10ECE0007765695C4769745C436F676E69746976C0 -:10ECF000655F4167656E745C6669726D77617265A8 -:10ED00005C7432675C747669696268386D5C7372D2 -:10ED1000635C647269766572735C737973636C6B40 -:10ED20005C63795F737973636C6B2E68000000001D -:10ED3000443A5C6875616E672E7875657765695CC5 -:10ED40004769745C436F676E69746976655F416794 -:10ED5000656E745C6669726D776172655C7432674A -:10ED60005C747669696268386D5C7372635C647246 -:10ED700069766572735C737973636C6B5C63795FDE -:10ED8000737973636C6B2E6300000000443A5C6817 -:10ED900075616E672E7875657765695C4769745C27 -:10EDA000436F676E69746976655F4167656E745C11 -:10EDB0006669726D776172655C7432675C636F6DF2 -:10EDC0006D6F6E5C7372635C647269766572735C9E -:10EDD0007379737469636B5C63795F737973746956 -:10EDE000636B2E6300000000443A5C6875616E67D7 -:10EDF0002E7875657765695C4769745C436F676EEB -:10EE000069746976655F4167656E745C6669726D89 -:10EE1000776172655C7432675C636F6D6D6F6E5C99 -:10EE20007372635C6D775C666C6173685C63795F59 -:10EE30006D775F666C6173682E6300009F0F000042 -:10EE400000000000000000000000000000000000C2 -:10EE500000000300000000000000000000000000AF -:10EE6000000300000000000003000000000000009C -:10EE7000030000000000000003000000000000008C -:10EE8000030000000100000000000000010000007D -:10EE90000000000000000000100000001000000052 -:10EEA0001000000001000001000000000000000050 +:10EBC000020300000000000003000000000000003D +:10EBD000030000000000000003000000000000002F +:10EBE000030000000000000003000000010000001E +:10EBF0000102000072B6104810490860BFF34F8F41 +:10EC00000120C0040E490A6802430A600D490A68DF +:10EC100002430A600C490A6802430A60002000218E +:10EC20000D4A03C26A45FCD1084FB847084FB847A0 +:10EC3000084FB847FEE700000000001008ED00E0B4 +:10EC40000013204080132040A0132040B9E300109F +:10EC500071F00010D5F1001000FF0128FFFF01281E +:10EC6000443A5C50726F6A6563745C3531315C4E56 +:10EC7000375765625C6669726D776172655C743284 +:10EC8000675C747669696268386D5C7372635C7323 +:10EC9000797374656D5C7265765F645C73797374A7 +:10ECA000656D5F747669696268386D5F636D307039 +:10ECB0006C75732E630000009F0F000000000000C1 +:10ECC0000000000000000000000000000000030041 +:10ECD0000000000000000000000000000003000031 +:10ECE000000000000300000000000000030000001E +:10ECF000000000000300000000000000030000000E +:10ED00000100000000000000443A5C50726F6A6528 +:10ED100063745C3531315C4E375765625C6669728D +:10ED20006D776172655C7432675C7476696962687C +:10ED3000386D5C7372635C647269766572735C7360 +:10ED40007973636C6B5C63795F737973636C6B2E3F +:10ED500068000000443A5C50726F6A6563745C3509 +:10ED600031315C4E375765625C6669726D776172EE +:10ED7000655C7432675C747669696268386D5C736F +:10ED800072635C647269766572735C737973636CC9 +:10ED90006B5C63795F737973636C6B2E6300000047 +:10EDA000443A5C50726F6A6563745C3531315C4E15 +:10EDB000375765625C6669726D776172655C743243 +:10EDC000675C636F6D6D6F6E5C7372635C647269B8 +:10EDD000766572735C7379737469636B5C63795F76 +:10EDE0007379737469636B2E63000000443A5C505E +:10EDF000726F6A6563745C3531315C4E375765629A +:10EE00005C6669726D776172655C7432675C636FB2 +:10EE10006D6D6F6E5C7372635C6D775C666C617355 +:10EE2000685C63795F6D775F666C6173682E630001 +:10EE300001000000000000000000000010000000C1 +:10EE400010000000100000000100000100000000A0 +:10EE500000000000000000000000000000000000B2 +:10EE600000000000000000000000000070B401235A +:10EE7000002413E00168001D194202D04D466918B4 +:10EE8000491E0C60091D121F042AFAD20D009607B4 +:10EE900001D50C80AD1C1A4000D02C700268001DFA +:10EEA000002AE7D170BC7047000000000600000097 :10EEB0000000000000000000000000000000000052 -:10EEC000000000000000000070B40123002413E0E3 -:10EED0000168001D194202D04D466918491E0C6098 -:10EEE000091D121F042AFAD20D00960701D50C80C5 -:10EEF000AD1C1A4000D02C700268001D002AE7D11A -:10EF000070BC704700000000060000000000000018 -:10EF100000000000000000000000000000000000F1 +:10EEC0000000000000000000000000000000000042 +:10EED0000000000000000000000000000000000032 +:10EEE000060000000000000000000000000000001C +:10EEF0000000000000000000000000000000000012 +:10EF00000000000000000000000000000000000001 +:10EF100000000000080000001500000000000000D4 :10EF200000000000000000000000000000000000E1 -:10EF300000000000000000000000000006000000CB -:10EF400000000000000000000000000000000000C1 +:10EF300000000000000000000000000000000000D1 +:10EF400000000000010000000E000000150000009D :10EF500000000000000000000000000000000000B1 :10EF600000000000000000000000000000000000A1 -:10EF70000800000015000000000000000000000074 +:10EF7000000000000000000001000000060000008A :10EF80000000000000000000000000000000000081 :10EF90000000000000000000000000000000000071 -:10EFA000010000000E00000015000000000000003D -:10EFB0000000000000000000000000000000000051 +:10EFA0000000000000000000000000000000000061 +:10EFB000060000000000000000000000000000004B :10EFC0000000000000000000000000000000000041 -:10EFD000000000000100000006000000000000002A -:10EFE0000000000000000000000000000000000021 -:10EFF0000000000000000000000000000000000011 -:10F0000000000000000000000000000006000000FA -:10F0100000000000000000000000000000000000F0 -:10F0200000000000000000000000000000000000E0 -:10F0300000000000000000000000000002000000CE -:10F0400000000000983A00000E01FA00CC01900187 -:10F05000020000000300000000000000983A0000D9 -:10F06000720182001C02CC010200000005000000B9 -:10F070000000000030750000F6001901BD02EE012D -:10F0800002000000060000000000000030750000D3 -:10F0900087018C009501940102000000010000002E -:10F0A00034F300103CF000100200000030F40010B7 -:10F0B00008003200020000003CF300106CF0001069 -:10F0C000020000002CF400100C00320010B50749BB -:10F0D00079441831064C7C44163404E0081D0A6853 -:10F0E000891888470100A142F8D110BD080000002E -:10F0F00024000000D5FDFFFFE409000008130028EC -:10F1000000000000A9F8FFFF5E0300003A010000C4 -:10F1100000080028000003010000000001000000BA -:10F12000000000000000000001000000FF0F0100CF -:10F13000000000004D4F544F525F494E325F48422D -:10F14000375F4842385F484F52495A4F4E54414CFE -:10F150000000000004207146084202D0EFF309804D -:10F1600001E0EFF3088000B50149884700BD0000C9 -:10F170006F80001000F00EF8002801D0FFF7A6FF06 -:10F1800000BF00BF002000BF00BFFFF75FFB00F023 -:10F1900003F800000020704780B500F001F801BDC1 -:10F1A0000746384600F002F8FBE7000080B500BFD4 -:10F1B00000BF024A11001820ABBEFBE72600020088 -:10F1C0004D4F544F525F494E335F4842355F48427E -:10F1D000365F534541545F50414E00004D4F544FF0 -:10F1E000525F494E345F4842335F4842345F424188 -:10F1F000434B5245535400004D4F544F525F494EBC -:10F20000315F4842315F4842325F4C454752455377 -:10F210005400000004207146084201D0EFF3098039 -:10F22000EFF3088000B50149884700BDF1F20010F6 -:10F230000548014603B4684680F3098800BF00BF53 -:10F2400000BF00BFFFF796FFA5EDF5FE9E029E0AE8 -:10F25000AA02AA0ACE03CE0B2A042A0C0003000B32 -:10F26000F002F00A00127A00000000000024F4000E -:10F27000008000000080000000800000010002000B -:10F28000040008001000200040008000000100027F -:10F29000484F52495A4F4E54414C5F48414C4C00E4 -:10F2A000534541545F50414E5F48414C4C00000073 -:10F2B0004241434B524553545F48414C4C0000007F -:10F2C0004C4547524553545F48414C4C00000000A8 -:10F2D000E02E0000401F0000E02E0000307500000E -:10F2E0000003000100040103020200020003000504 -:10F2F0008169891E0978C9B2016070470000000069 -:10F3000000010101010101000001000002030405E8 -:10F3100006080C10000000000000000002000000C1 -:10F32000010000000403000113140015C7010300CD -:10F33000010000004001BE00F401AE0187018C0015 -:10F340009501940100080028FFFF0128000000003B +:10EFD0000000000000000000000000000000000031 +:10EFE0000200000000000000983A00000E01FA0044 +:10EFF000CC019001020000000300000000000000AE +:10F00000983A0000720182001C02CC01020000004C +:10F01000050000000000000030750000F600190136 +:10F02000BD02EE010200000006000000000000002A +:10F030003075000087018C009501940102000000EA +:10F0400001000000D8F20010E0EF00100200000004 +:10F05000D4F300100800320002000000E0F20010BB +:10F0600010F0001002000000D0F300100C0032007D +:10F0700010B5074979441831064C7C44163404E035 +:10F08000081D0A68891888470100A142F8D110BDFF +:10F090000800000024000000D5FDFFFFE409000087 +:10F0A00008130028000000007DF8FFFF5E03000049 +:10F0B0003A010000000800280000030100000000E1 +:10F0C000010000000000000000000000010000003E +:10F0D000FF0F0100000000004D4F544F525F494E9A +:10F0E000325F4842375F4842385F484F52495A4F73 +:10F0F0004E54414C0000000004207146084202D0EA +:10F10000EFF3098001E0EFF3088000B5014988477B +:10F1100000BD00006F80001000F00EF8002801D044 +:10F12000FFF7A6FF00BF00BF002000BF00BFFFF732 +:10F130008DFB00F003F800000020704780B500F060 +:10F1400001F801BD0746384600F002F8FBE7000071 +:10F1500080B500BF00BF024A11001820ABBEFBE71C +:10F16000260002004D4F544F525F494E335F4842D4 +:10F17000355F4842365F534541545F50414E000071 +:10F180004D4F544F525F494E345F4842335F4842BF +:10F19000345F4241434B5245535400004D4F544F4E +:10F1A000525F494E315F4842315F4842325F4C45C1 +:10F1B000475245535400000004207146084201D0D4 +:10F1C000EFF30980EFF3088000B50149884700BDDF +:10F1D00095F200100548014603B4684680F309889B +:10F1E00000BF00BF00BF00BFFFF796FFA5EDF5FE13 +:10F1F0009E029E0AAA02AA0ACE03CE0B2A042A0C59 +:10F200000003000BF002F00A00127A000000000078 +:10F210000024F40000800000008000000080000056 +:10F2200001000200040008001000200040008000DF +:10F2300000010002484F52495A4F4E54414C5F481A +:10F24000414C4C00534541545F50414E5F48414C46 +:10F250004C0000004241434B524553545F48414CDF +:10F260004C0000004C4547524553545F48414C4CBC +:10F2700000000000E02E0000401F0000E02E000013 +:10F2800030750000000300010004010302020002C7 +:10F29000000300058169891E0978C9B201607047C1 +:10F2A0000000000000010101010101000001000057 +:10F2B0000203040506080C10000000000000000016 +:10F2C00002000000010000000403000113140015F7 +:10F2D000C7010300010000004001BE00F401AE01BF +:10F2E00087018C009501940100080028FFFF012888 +:10F2F000000000000000000000000000000000000E +:10F3000000000000000000000000000000000000FD +:10F3100000000000000000000000000000000000ED +:10F3200000000000000000000000000000000000DD +:10F3300000000000000000000000000000000000CD +:10F3400000000000000000000000000000000000BD :10F3500000000000000000000000000000000000AD :10F36000000000000000000000000000000000009D :10F37000000000000000000000000000000000008D @@ -3899,27 +3899,27 @@ :10F39000000000000000000000000000000000006D :10F3A000000000000000000000000000000000005D :10F3B000000000000000000000000000000000004D -:10F3C000000000000000000000000000000000003D -:10F3D000000000000000000000000000000000002D -:10F3E000000000000000000000000000000000001D -:10F3F000000000000000000000000000000000000D -:10F4000000000000000000000000000000000000FC -:10F4100000000000000000000000000000000000EC -:10F4200000000000000000000001020332325A32E6 -:10F430005A5A5A32000102035A5A5A32FEE7000061 -:10F44000FEE70000FEE70000FEE70000FEE7000028 -:10F45000FEE70000FEE70000FEE70000FEE7000018 -:10F46000FEE70000FEE740045500020001000150E5 -:10F470000325A40010000132DD0CA00409000401E2 -:10F4800001000112060E834CF2286004FFFFFF1FEB -:10F49000020225920807F0010352400001F2060221 -:10F4A00018F2060418F2060618F2060818F3060009 -:10F4B0005478F10618F2060418F2060618F2050848 -:10F4C00018F002FFC9E5001004F1FF04F1FF04F198 -:10F4D000FF04F1FF04F1FF04F1FF04F1FF04F14721 -:10F4E000046C0248090028060AF321060001F123F2 -:10F4F00034F2520868000A01010102030203010FFD -:10F500000F010100000000000000000000000000EA +:10F3C0000000000000000000000000000001020337 +:10F3D00032325A325A5A5A32000102035A5A5A32B7 +:10F3E000FEE70000FEE70000FEE70000FEE7000089 +:10F3F000FEE70000FEE70000FEE70000FEE7000079 +:10F40000FEE70000FEE70000FEE7400455000200B2 +:10F41000010001500325A40010000132DD0CA004FE +:10F420000900040101000112060E83F0F1286004B6 +:10F43000FFFFFF1F020225920807F0010352400060 +:10F4400001F2060218F2060418F2060618F206087F +:10F4500018F306005478F10618F2060418F20606AE +:10F4600018F2050818F002FFC9E5001004F1FF04C6 +:10F47000F1FF04F1FF04F1FF04F1FF04F1FF04F1D7 +:10F48000FF04F147046C0248090028060AF321062C +:10F490000001F12334F2520868000A01010102035D +:10F4A0000203010F0F010100000000000000000036 +:10F4B000000000000000000000000000000000004C +:10F4C000000000000000000000000000000000003C +:10F4D000000000000000000000000000000000002C +:10F4E000000000000000000000000000000000001C +:10F4F000000000000000000000000000000000000C +:10F5000000000000000000000000000000000000FB :10F5100000000000000000000000000000000000EB :10F5200000000000000000000000000000000000DB :10F5300000000000000000000000000000000000CB @@ -3937,11 +3937,6 @@ :10F5F000000000000000000000000000000000000B :10F6000000000000000000000000000000000000FA :10F6100000000000000000000000000000000000EA -:10F6200000000000000000000000000000000000DA -:10F6300000000000000000000000000000000000CA -:10F6400000000000000000000000000000000000BA -:10F6500000000000000000000000000000000000AA -:10F66000000000000000000000000000000000009A -:0DF67000000000000000000000000000008D -:040000051000F231C4 +:01F6200000E9 +:040000051000F1D521 :00000001FF diff --git a/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.out b/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.out index aafc542..72afc1c 100755 Binary files a/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.out and b/firmware/t2g/Seat_Controller_demo/IAR/rev_d/Exe/Seat_Controller_demo.out differ diff --git a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md index 17f8c21..ec788f5 100644 --- a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md +++ b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md @@ -8,6 +8,7 @@ - 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。 - 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。 - 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。 +- 新建功能模式时,座椅默认只编排副驾;用户明确指定主驾时只编排主驾,只有明确要求两座时才同时编排。该规则适用于每个动态阶段,以本回合 `seat_scope_policy` 为准。不得把副驾姿态能力替换目标后冒充主驾能力,不得把后排或未知座位映射成前排。空调分区、车窗等非座椅设备的主副驾限定不改变座椅选择。单纯执行或修改已保存模式时,应保留其已保存的座位范围,不要重新套用新建默认值。 - “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。 - “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。 - 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。 diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py index f084f92..fcddd88 100644 --- a/src/cognitive_agent/application/assistant_capabilities.py +++ b/src/cognitive_agent/application/assistant_capabilities.py @@ -21,6 +21,7 @@ from cognitive_agent.application.assistant_plan_guard import ( ) from cognitive_agent.application.mode_profile_intent import ( normalize_mode_profile_name, + parse_mode_profile_create, parse_mode_profile_detail_query, ) from cognitive_agent.generated.can_semantic_protocol import CAN0_SUPPORTED_CAPABILITIES @@ -434,6 +435,9 @@ _MODE_CAPABILITY_LABELS = { "seat.set_legrest_extension": "座椅腿托伸展", "seat.set_heating": "座椅加热", "seat.set_ventilation": "座椅通风", + "seat.set_driver_ventilation_native": "座椅通风", + "seat.set_driver_heating_native": "座椅加热", + "seat.set_driver_massage_native": "座椅按摩", "seat.start_dynamic_mode": "座椅动态模式", "climate.set_power": "空调", "climate.set_temperature": "空调温度", @@ -581,6 +585,21 @@ def _mode_operation_text(raw: Mapping[str, object]) -> str: }: target_label = "" subject = f"{target_label}{label}" + if capability in {"seat.set_driver_ventilation_native", "seat.set_driver_heating_native"}: + if parameters.get("state") == "off": + return f"{subject}关闭" + level = {1: "高档", 2: "低档"}.get(parameters.get("level", 2), "") + return f"{subject}开启{level}" + if capability == "seat.set_driver_massage_native": + if parameters.get("action") == "off": + return f"{subject}关闭" + if parameters.get("action") == "strength": + strength = {1: "轻柔", 2: "中等", 3: "较强"}.get(parameters.get("strength"), "") + return f"{subject}设为{strength}力度" + mode = { + "wave": "波浪", "pulse": "脉冲", "stretch": "舒展", "slack": "舒缓", "relax": "放松", + }.get(str(parameters.get("mode") or ""), "") + return f"{subject}设为{mode}模式" if set(parameters) == {"state"}: return f"{subject}{values[0]}" if capability == "roof.set_shade_position" and parameters.get("position_pct") in {0, 100}: @@ -602,6 +621,19 @@ def _mode_detail_answer( if isinstance(item, Mapping) ] name = _safe_text(mode.get("name"), limit=80) + schedule = mode.get("schedule") + if isinstance(schedule, Mapping) and isinstance(schedule.get("phases"), list): + phases = schedule["phases"] + details = ";".join( + f"第{index}阶段({phase['name']},保持{phase['duration_ms'] // 1000}秒):" + + "、".join(_mode_operation_text(op) for op in phase["operations"]) + for index, phase in enumerate(phases, 1) + ) + return CapabilityAnswer( + f"“{name}”按时间轴执行,重复{schedule['repeat_count']}次。{details}。" + "停止动态模式可取消后续阶段,已下发目标保持。本次仅查询,未下发车辆控制。", + "specific_mode_detail", + ) if not operations: return CapabilityAnswer( f"已找到“{name}”,但当前模式目录没有返回可核验的动作明细;本次仅查询,未下发车辆控制。", @@ -1756,6 +1788,11 @@ class AssistantCapabilityCatalog: normalized = "".join(text.lower().split()) if not normalized: return None + # A creation request can contain both a device and the noun 功能模式. + # That noun is not evidence that the user is asking about capabilities. + # Use the guarded intent parser, so genuine questions remain answerable. + if parse_mode_profile_create(text) is not None: + return None catalog = snapshot or self.snapshot() unsupported_control = _unsupported_control_answer( _canonical_control_text(text), @@ -2255,6 +2292,7 @@ class AssistantCapabilityCatalog: "origin": _safe_text(raw.get("origin"), limit=32), "operation_ids": operation_ids, "operations": operations, + **({"schedule": raw["schedule"]} if raw.get("schedule") else {}), "assistant_callable": callable_mode, "n7_transfer_operation_ids": ( n7_transfer_operation_ids if n7_transfer_ready else [] @@ -2291,6 +2329,11 @@ class AssistantCapabilityCatalog: requests = ( raw_plan.get("requests", []) if isinstance(raw_plan, Mapping) else [] ) + schedule = raw.get("schedule") + if isinstance(schedule, Mapping): + requests = [operation for phase in schedule.get("phases", []) + if isinstance(phase, Mapping) + for operation in phase.get("operations", [])] operation_ids = [ _safe_text(item.get("capability"), limit=120) for item in requests @@ -2328,6 +2371,7 @@ class AssistantCapabilityCatalog: # The assistant consumes this only after an exact name/alias # match and the vehicle service validates it again. "plan": dict(raw_plan) if callable_template else None, + **({"schedule": schedule} if isinstance(schedule, Mapping) else {}), } ) return templates, "ready" diff --git a/src/cognitive_agent/application/assistant_plan_guard.py b/src/cognitive_agent/application/assistant_plan_guard.py index c4f85de..3ad9a1d 100644 --- a/src/cognitive_agent/application/assistant_plan_guard.py +++ b/src/cognitive_agent/application/assistant_plan_guard.py @@ -39,6 +39,9 @@ SCENE_COMPOSABLE_CAPABILITIES = frozenset( "seat.set_legrest_extension", "seat.set_ventilation", "seat.set_heating", + "seat.set_driver_ventilation_native", + "seat.set_driver_heating_native", + "seat.set_driver_massage_native", "climate.set_power", "climate.set_temperature", "climate.set_fan_level", @@ -1087,6 +1090,7 @@ def guard_assistant_plan( fallback_intents: Sequence[Mapping[str, Any]] = (), trusted_local: Mapping[str, Any] | None = None, allow_scene_composition: bool = False, + scene_scope_utterance: str | None = None, ) -> GuardedAssistantPlan: """Authorize a Codex plan against the server-owned unresolved text. @@ -1155,6 +1159,13 @@ def guard_assistant_plan( parser_claims.update(_intent_claims(intent)) local_keys = _resolved_keys(trusted_local or {}) non_execution = bool(_NON_EXECUTION_PATTERN.search(text)) + # An explicit empty internal scope is reserved for already validated saved + # schedules/draft revisions. New scenes use passenger when no seat is named. + scene_seat_scopes = None + if allow_scene_composition and scene_scope_utterance != "": + from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes + + scene_seat_scopes = creation_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance) accepted: list[dict[str, Any]] = [] rejected: list[dict[str, str]] = [] @@ -1206,6 +1217,24 @@ def guard_assistant_plan( } ) continue + if ( + allow_scene_composition + and domain == "seat" + and scene_seat_scopes is not None + and ( + "unsupported" in scene_seat_scopes + or {"front_left": "driver", "front_right": "passenger"}.get( + target_scope, target_scope + ) not in scene_seat_scopes + ) + ): + rejected.append( + { + "field": f"requests[{index}].target_scope", + "question": "场景座椅动作超出了用户明确指定的座位范围,已拒绝生成该功能模式。", + } + ) + continue request_text = _relevant_text(capability, text) if non_execution or ( not allow_scene_composition diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py index 958816f..0729b7f 100644 --- a/src/cognitive_agent/application/codex_assistant.py +++ b/src/cognitive_agent/application/codex_assistant.py @@ -20,19 +20,25 @@ from typing import Protocol from cognitive_agent.application.mode_profile_intent import ( is_mode_profile_query, + mode_creation_source_name, normalize_mode_profile_name, parse_mode_profile_create, parse_mode_profile_delete, parse_mode_profile_update, ) +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.mode_schedule_service import public_schedule_status from cognitive_agent.application.scene_composer import ( SCENE_COMPOSER_INSTRUCTIONS, SCENE_PLANNER_VERSION, + SCHEDULE_COMPOSER_INSTRUCTIONS, SceneCompositionError, build_scene_output_schema, normalize_scene_output, + scene_context_for_mode_creation, ) from cognitive_agent.application.scene_templates import normalize_scene_template_name +from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule LOGGER = logging.getLogger(__name__) INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力" @@ -374,6 +380,9 @@ def _catalog_exact_mode_match_count( }: matched_ids.add(str(raw_mode.get("id") or "")) matched_ids.discard("") + subject = mode_creation_source_name(requested_name) + if not matched_ids and normalize_mode_profile_name(subject) != needle: + return _catalog_exact_mode_match_count(catalog, subject) return len(matched_ids) @@ -475,6 +484,10 @@ def _catalog_exact_scene_template_plan( template_id = str(raw_template.get("id") or "").strip() if template_id: matches[template_id] = raw_template + if not matches: + subject = mode_creation_source_name(requested_name) + if normalize_scene_template_name(subject) != needle: + return _catalog_exact_scene_template_plan(catalog, subject) if len(matches) != 1: return None template = next(iter(matches.values())) @@ -499,7 +512,12 @@ def _catalog_exact_scene_template_plan( def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool: """Only a terminal, fully successful service result completes a tool call.""" - return str(result.get("status") or "").lower() == "succeeded" + schedule = result.get("schedule_execution") + return str(result.get("status") or "").lower() == "succeeded" or ( + result.get("status") == "running" + and result.get("orchestration_route") == "mode_schedule_execute" + and isinstance(schedule, Mapping) and schedule.get("status") == "running" + ) def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool: @@ -594,6 +612,10 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str] if not _vehicle_result_succeeded(result): return "failed", False, "车辆控制未完成" + if result.get("orchestration_route") == "mode_schedule_stop": + return ("simulated" if result.get("execution_mode") != "hardware" else "cancelled"), True, "动态模式停止请求已处理" + if result.get("status") == "running": + return "running", True, "时间轴已启动,尚未完成所有阶段" execution_mode = str(result.get("execution_mode") or "").lower() if execution_mode == "dry_run": return "simulated", True, "车辆控制模拟编排已完成" @@ -621,6 +643,8 @@ def _authorize_vehicle_request( message=message, ) normalized = "".join(str(message).lower().split()) + if is_schedule_stop(message): + return _VehicleAuthorization(message, "explicit_schedule_stop") mode_profile_create = _mode_profile_create_parts(message) mode_profile_update = _mode_profile_update_parts(message) mode_profile_delete = parse_mode_profile_delete(message) @@ -909,6 +933,9 @@ class CodexAssistantService: "explicit_mode_profile_draft_update", } mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None + if mode_parts: + scene_context = scene_context_for_mode_creation(scene_context, message) + model_context = scene_context_for_mode_creation(model_context, message) exact_mode_matches = ( _catalog_exact_mode_match_count(catalog, mode_parts[0]) if mode_parts @@ -919,10 +946,11 @@ class CodexAssistantService: if mode_parts and not mode_parts[1] and exact_mode_matches == 0 else None ) + dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message)) direct_mode_profile_preview = bool( mode_parts and ( - mode_parts[1] + (mode_parts[1] and not dynamic_scene_request) # Ambiguous or unavailable mode catalogs fail closed in the # local builder. Only a known-zero match is safe to hand to # semantic scene composition. @@ -939,7 +967,7 @@ class CodexAssistantService: }.get(authorization.reason) scene_composition_preview = bool( mode_parts - and not mode_parts[1] + and (not mode_parts[1] or dynamic_scene_request) and exact_mode_matches == 0 and scene_template_plan is None and scene_context.get("composition_revision") @@ -951,6 +979,7 @@ class CodexAssistantService: # vehicle service still limits it to dry-run and requires confirmation. direct_vehicle_control = ( fast_comfort_control + or authorization.reason == "explicit_schedule_stop" or exact_quick_control or local_first_control or saved_mode_execution_id is not None @@ -1280,11 +1309,17 @@ class CodexAssistantService: revision = str(scene_context.get("composition_revision") or "") if not revision: raise _ScenePlannerFallback("scene capability revision is unavailable") + scope_policy = scene_context.get("seat_scope_policy") + selected_scopes = ( + scope_policy.get("selected_scopes", []) + if isinstance(scope_policy, Mapping) else [] + ) + scope_key = ",".join(sorted(str(scope) for scope in selected_scopes)) return ( vehicle_id, revision, normalize_mode_profile_name(mode_name), - SCENE_PLANNER_VERSION, + f"{SCENE_PLANNER_VERSION}:seats={scope_key}", ) def _begin_scene_plan( @@ -1525,7 +1560,8 @@ class CodexAssistantService: raise _ScenePlannerFallback("structured turns are not supported") planning_started_at = time.monotonic() - output_schema = build_scene_output_schema(scene_context) + dynamic = needs_mode_schedule(message) + output_schema = build_scene_output_schema(scene_context, dynamic=dynamic) context_bytes = len( json.dumps( dict(scene_context), @@ -1554,7 +1590,7 @@ class CodexAssistantService: try: try: thread_id = self.client.start_thread( - developer_instructions=SCENE_COMPOSER_INSTRUCTIONS, + developer_instructions=SCHEDULE_COMPOSER_INSTRUCTIONS if dynamic else SCENE_COMPOSER_INSTRUCTIONS, ephemeral=True, replace_developer_instructions=True, timeout=self._remaining_scene_time(deadline), @@ -1693,7 +1729,7 @@ class CodexAssistantService: raise SceneCompositionError("scene composer output was not valid JSON") from exc if not isinstance(raw_output, Mapping): raise SceneCompositionError("scene composer output must be an object") - normalized = normalize_scene_output(raw_output, scene_context) + normalized = normalize_scene_output(raw_output, scene_context, dynamic=dynamic) finished_at = time.monotonic() LOGGER.info( "Scene Composer model completed context_bytes=%d schema_bytes=%d " @@ -2229,7 +2265,9 @@ class CodexAssistantService: "success": successful, "state": action_state, "display_text": ( - "功能模式预览已生成" + completion_text + if action_state == "running" or result.get("orchestration_route") == "mode_schedule_stop" + else "功能模式预览已生成" if mode_profile_preview and successful else "功能模式预览生成失败" if mode_profile_preview @@ -2397,6 +2435,13 @@ class CodexAssistantService: if isinstance(draft, Mapping) else "新功能模式" ) + if isinstance(draft, Mapping) and isinstance(draft.get("schedule"), dict): + schedule = ModeSchedule.from_dict(draft["schedule"]) + return (f"已为你生成“{name}”的分阶段时间轴:{len(schedule.phases)}个阶段," + f"每轮{schedule.operation_count}项动作,重复{schedule.repeat_count}次," + f"总时长{schedule.total_duration_ms // 1000}秒。" + "每个阶段的设备目标和持续时间见卡片。当前未保存或执行;确认后保存,进入模式才开始计时。" + "执行中可输入“停止动态模式”取消后续阶段,已下发目标不会自动回滚。") if ( isinstance(draft, Mapping) and draft.get("generation_source") == "draft_revision" @@ -2601,6 +2646,18 @@ class CodexAssistantService: result: Mapping[str, object], successful: bool, ) -> str: + if result.get("orchestration_route") == "mode_schedule_stop": + if result.get("execution_mode") == "hardware" and (result.get("schedule_execution") or {}).get("status") != "cancelled": + return "当前没有正在执行的动态时间轴。" + return ("已停止动态模式后续阶段。已下发的设备目标保持,不自动回滚。" + if result.get("execution_mode") == "hardware" + else "已模拟停止动态模式,未改变正在运行的车辆任务。") + if result.get("orchestration_route") == "mode_schedule_execute": + if not successful: + return "时间轴未能完成,后续阶段已停止。请检查车辆状态后重试。" + return ("动态模式时间轴已启动,将按卡片中的时长逐阶段调节。尚未完成所有阶段;输入“停止动态模式”可取消后续阶段。" + if result.get("status") == "running" + else "已按虚拟时钟模拟验证动态模式的全部阶段,未向实际车辆下发操作。") if reason in { "exact_quick_control", "explicit_registered_control", @@ -3672,7 +3729,7 @@ class CodexAssistantService: if isinstance(intent_ir, dict): spec = intent_ir.get("spec") if isinstance(spec, dict) and isinstance(spec.get("actions"), list): - for item in spec["actions"][:12]: + for item in spec["actions"][:96 if result.get("schedule") else 12]: if not isinstance(item, dict): continue actions.append( @@ -3786,6 +3843,22 @@ class CodexAssistantService: ) if key in mode_profile_draft } + for key in ("mode_profile_draft", "mode_profile"): + source = result.get(key) + if isinstance(source, dict) and isinstance(source.get("schedule"), dict): + try: + safe[key]["schedule"] = ModeSchedule.from_dict(source["schedule"]).to_dict() + except ValueError: + pass + if isinstance(result.get("schedule"), dict): + try: + safe["schedule"] = ModeSchedule.from_dict(result["schedule"]).to_dict() + except ValueError: + pass + if isinstance(result.get("schedule_execution"), dict): + safe["schedule_execution"] = public_schedule_status(result["schedule_execution"]) + if isinstance(result.get("vehicle"), dict): + safe["vehicle"] = {"id": str(result["vehicle"].get("id") or "")[:128]} failure = result.get("mode_creation_failure") if isinstance(failure, Mapping): reason, next_step = CodexAssistantService._mode_profile_failure_feedback( diff --git a/src/cognitive_agent/application/firmware_workbench.py b/src/cognitive_agent/application/firmware_workbench.py new file mode 100644 index 0000000..1cf61f5 --- /dev/null +++ b/src/cognitive_agent/application/firmware_workbench.py @@ -0,0 +1,1290 @@ +"""Seat firmware development pipeline and local OTA publication service.""" + +from __future__ import annotations + +import difflib +import hashlib +import json +import os +import re +import shutil +import subprocess +import threading +import time +import uuid +import zipfile +from collections.abc import Callable +from copy import deepcopy +from datetime import datetime +from pathlib import Path +from typing import Any + +PIPELINE_STAGES = ( + ("instruction", "指令输入"), + ("requirements", "需求解析"), + ("baseline", "基线检查"), + ("codegen", "代码生成"), + ("build", "编译构建"), + ("test", "测试验证"), + ("package", "固件打包"), + ("publish", "发布服务器"), +) +TERMINAL_TASK_STATES = frozenset({"success", "failed", "canceled", "blocked"}) +ACTIVE_TASK_STATES = frozenset({"waiting", "running", "pending_confirmation"}) +ALL_CAPABILITIES = ("时间控制", "位置控制", "座椅通风", "座椅加热") +BASELINE_CATALOG = { + "time_only": { + "name": "时间控制基线", + "capabilities": ("时间控制",), + }, +} +SOFTWARE_NAME = "副驾座椅控制器" +FIRMWARE_VERSION = "SMV8-r15-b16" +FINAL_RELEASE_MESSAGE = "已发布,待车机端下载升级" + +BuildRunner = Callable[[str, Callable[[str, str], None], threading.Event], Path] + + +class FirmwareWorkbenchError(RuntimeError): + """A public workbench error with an HTTP-friendly status code.""" + + def __init__(self, message: str, status: int = 400, code: str = "invalid_request"): + super().__init__(message) + self.status = status + self.code = code + + +def _now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _safe_file_id(value: str, kind: str) -> str: + normalized = str(value or "").strip() + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{7,80}", normalized): + raise FirmwareWorkbenchError(f"无效的{kind}标识。", 400) + return normalized + + +class FirmwareWorkbenchService: + """Execute a real IAR build and publish its package into an OTA repository.""" + + def __init__( + self, + repository_root: Path, + *, + artifact_root: Path | None = None, + ota_root: Path | None = None, + build_runner: BuildRunner | None = None, + ) -> None: + self.repository_root = Path(repository_root).resolve() + self.artifact_root = ( + Path(artifact_root) + if artifact_root is not None + else self.repository_root / "artifacts" / "server" / "firmware-workbench" + ).resolve() + configured_ota = os.getenv("FIRMWARE_WORKBENCH_OTA_ROOT", "").strip() + self.ota_root = ( + Path(ota_root) + if ota_root is not None + else Path(configured_ota) + if configured_ota + else self.repository_root / "artifacts" / "server" / "firmware-ota" + ).resolve() + self.public_base_url = os.getenv( + "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", "/api/firmware-workbench/releases" + ).rstrip("/") + self._state_file = self.artifact_root / "state.json" + self._lock = threading.RLock() + self._cancel_events: dict[str, threading.Event] = {} + self._threads: dict[str, threading.Thread] = {} + self._build_runner = build_runner or self._run_iar_build + self._state: dict[str, Any] = {"tasks": [], "artifacts": [], "releases": []} + self.artifact_root.mkdir(parents=True, exist_ok=True) + self.ota_root.mkdir(parents=True, exist_ok=True) + self._load_state() + self._ensure_time_only_baseline() + self._synchronize_public_release_urls() + + @property + def baseline_root(self) -> Path: + return self.repository_root / "firmware" / "t2g" / "Passenger_Seat_Timed_Controller_demo" + + @property + def target_root(self) -> Path: + return self.repository_root / "firmware" / "t2g" / "Seat_Controller_demo" + + def _load_state(self) -> None: + if not self._state_file.is_file(): + return + try: + data = json.loads(self._state_file.read_text(encoding="utf-8")) + if isinstance(data, dict): + for key in ("tasks", "artifacts", "releases"): + if not isinstance(data.get(key), list): + data[key] = [] + self._state = data + recovered = False + for task in self._state["tasks"]: + if task.get("status") in ACTIVE_TASK_STATES: + task["status"] = "blocked" + task["finished_at"] = _now() + task["message"] = "服务重启中断了任务,请点击重试。" + self._finish_running_stage(task, "blocked", task["message"]) + self._append_log_locked( + task, + "error", + "任务恢复", + task["message"], + stage="pipeline", + ) + recovered = True + if recovered: + self._save_locked() + except (OSError, ValueError, TypeError): + self._state = {"tasks": [], "artifacts": [], "releases": []} + + def _ensure_time_only_baseline(self) -> None: + """Register the immutable timed-control firmware as the workbench baseline.""" + with self._lock: + if any(artifact.get("is_baseline") for artifact in self._state["artifacts"]): + return + image_path = ( + self.baseline_root + / "IAR" + / "rev_d" + / "Exe" + / "Passenger_Seat_Timed_Controller_demo.hex" + ) + if not image_path.is_file(): + return + baseline_root = self.artifact_root / "baselines" / "time-only" + baseline_root.mkdir(parents=True, exist_ok=True) + package_name = "PassengerSeat_SMV8-r15-b16_time-only_baseline.zip" + package_path = baseline_root / package_name + image_hash = hashlib.sha256(image_path.read_bytes()).hexdigest() + build_time = datetime.fromtimestamp(image_path.stat().st_mtime).astimezone().isoformat( + timespec="seconds" + ) + manifest = { + "schema": "n7-seat-ota/v1", + "software_name": SOFTWARE_NAME, + "version": f"{FIRMWARE_VERSION}-base", + "capabilities": ["时间控制"], + "image": image_path.name, + "image_size": image_path.stat().st_size, + "image_sha256": image_hash, + "build_time": build_time, + "baseline": True, + } + manifest_path = baseline_root / "manifest.json" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(image_path, arcname=image_path.name) + archive.write(manifest_path, arcname="manifest.json") + artifact_id = "fw-art-time-only-baseline" + self._state["artifacts"].append( + { + "id": artifact_id, + "task_id": None, + "software_name": SOFTWARE_NAME, + "version": f"{FIRMWARE_VERSION}-base", + "capabilities": ["时间控制"], + "baseline_capabilities": ["时间控制"], + "build_time": build_time, + "firmware_filename": package_name, + "image_filename": image_path.name, + "size_bytes": package_path.stat().st_size, + "sha256": hashlib.sha256(package_path.read_bytes()).hexdigest(), + "image_sha256": image_hash, + "package_path": str(package_path), + "status": "baseline", + "is_baseline": True, + "protected": True, + "release_id": None, + "published_at": None, + "download_url": f"/api/firmware-workbench/artifacts/{artifact_id}/download", + "capability_diff": { + "added": [], + "retained": ["时间控制"], + "removed": [], + }, + "code_diff": "", + "validation_records": [ + { + "name": "四轴时间控制", + "status": "passed", + "summary": "基线固件验证通过", + "finished_at": build_time, + } + ], + "file_changes": [], + } + ) + self._save_locked() + + def _save_locked(self) -> None: + self.artifact_root.mkdir(parents=True, exist_ok=True) + temporary = self._state_file.with_suffix(".tmp") + temporary.write_text( + json.dumps(self._state, ensure_ascii=False, indent=2), encoding="utf-8" + ) + temporary.replace(self._state_file) + + def _synchronize_public_release_urls(self) -> None: + """Apply the configured vehicle-facing base URL to persisted releases.""" + with self._lock: + changed = False + releases_by_id: dict[str, str] = {} + for release in self._state["releases"]: + release_id = str(release.get("id") or release.get("release_id") or "") + if not release_id: + continue + download_url = f"{self.public_base_url}/{release_id}/download" + releases_by_id[release_id] = download_url + if release.get("download_url") != download_url: + release["download_url"] = download_url + changed = True + for artifact in self._state["artifacts"]: + release_id = str(artifact.get("release_id") or "") + download_url = releases_by_id.get(release_id) + if download_url and artifact.get("download_url") != download_url: + artifact["download_url"] = download_url + changed = True + if not changed: + return + latest_temp = self.ota_root / "latest.tmp" + if self._state["releases"]: + latest_temp.write_text( + json.dumps( + self._public_release_payload(self._state["releases"][0]), + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + latest_temp.replace(self.ota_root / "latest.json") + self._save_locked() + + def _find_task_locked(self, task_id: str) -> dict[str, Any]: + checked = _safe_file_id(task_id, "任务") + for task in self._state["tasks"]: + if task.get("id") == checked: + return task + raise FirmwareWorkbenchError("任务不存在。", 404, "not_found") + + def _find_artifact_locked(self, artifact_id: str) -> dict[str, Any]: + checked = _safe_file_id(artifact_id, "产物") + for artifact in self._state["artifacts"]: + if artifact.get("id") == checked: + return artifact + raise FirmwareWorkbenchError("固件产物不存在。", 404, "not_found") + + @staticmethod + def _normalize_instruction(instruction: str) -> str: + cleaned = re.sub(r"\s+", " ", str(instruction or "").strip()) + if not cleaned: + raise FirmwareWorkbenchError("请输入开发指令。") + if len(cleaned) > 2000: + raise FirmwareWorkbenchError("开发指令不能超过 2000 个字符。") + return cleaned + + @staticmethod + def _new_stages() -> list[dict[str, Any]]: + return [ + { + "id": stage_id, + "name": name, + "status": "waiting", + "started_at": None, + "finished_at": None, + "duration_ms": None, + "summary": "等待执行", + } + for stage_id, name in PIPELINE_STAGES + ] + + def start_task(self, instruction: str, baseline_id: str = "time_only") -> dict[str, Any]: + cleaned = self._normalize_instruction(instruction) + baseline = BASELINE_CATALOG.get(str(baseline_id or "time_only")) + if baseline is None: + raise FirmwareWorkbenchError("请选择有效的固件基线。") + baseline_id = str(baseline_id or "time_only") + requested = self._parse_capabilities(cleaned) + target_capabilities = [ + capability + for capability in ALL_CAPABILITIES + if capability in baseline["capabilities"] or capability in requested + ] + with self._lock: + task_id = f"fw-task-{uuid.uuid4().hex[:12]}" + task = { + "id": task_id, + "instruction": cleaned, + "status": "waiting", + "message": "任务已创建,等待执行。", + "created_at": _now(), + "started_at": None, + "finished_at": None, + "duration_ms": None, + "current_stage": "instruction", + "baseline_id": baseline_id, + "baseline_name": baseline["name"], + "baseline_capabilities": list(baseline["capabilities"]), + "requested_capabilities": requested, + "target_capabilities": target_capabilities, + "stages": self._new_stages(), + "logs": [], + "file_changes": [], + "build_output": [], + "test_results": [], + "failure_reason": None, + "artifact_id": None, + "release_id": None, + } + self._state["tasks"].insert(0, task) + self._append_log_locked( + task, + "info", + "任务创建", + "开发指令已登记,正在进入固件流水线。", + stage="instruction", + ) + cancel_event = threading.Event() + self._cancel_events[task_id] = cancel_event + self._save_locked() + worker = threading.Thread( + target=self._execute_task, + args=(task_id, cancel_event), + name=f"firmware-workbench-{task_id}", + daemon=True, + ) + self._threads[task_id] = worker + worker.start() + return {"task": deepcopy(task)} + + def retry_task(self, task_id: str) -> dict[str, Any]: + with self._lock: + task = self._find_task_locked(task_id) + if task.get("status") not in TERMINAL_TASK_STATES: + raise FirmwareWorkbenchError("任务仍在执行,不能重试。", 409, "task_active") + task.update( + { + "status": "waiting", + "message": "任务已重置,等待重新执行。", + "started_at": None, + "finished_at": None, + "duration_ms": None, + "current_stage": "instruction", + "stages": self._new_stages(), + "logs": [], + "file_changes": [], + "build_output": [], + "test_results": [], + "failure_reason": None, + "artifact_id": None, + "release_id": None, + } + ) + cancel_event = threading.Event() + self._cancel_events[task_id] = cancel_event + self._append_log_locked( + task, "info", "任务重试", "使用同一任务标识重新执行。", stage="instruction" + ) + self._save_locked() + worker = threading.Thread( + target=self._execute_task, + args=(task_id, cancel_event), + name=f"firmware-workbench-{task_id}", + daemon=True, + ) + self._threads[task_id] = worker + worker.start() + return deepcopy(task) + + def cancel_task(self, task_id: str) -> dict[str, Any]: + with self._lock: + task = self._find_task_locked(task_id) + if task.get("status") in TERMINAL_TASK_STATES: + return deepcopy(task) + event = self._cancel_events.get(task_id) + if event: + event.set() + task["message"] = "正在取消任务…" + self._append_log_locked( + task, "warning", "取消请求", "已收到取消请求,正在安全停止当前阶段。" + ) + self._save_locked() + return deepcopy(task) + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return { + "pipeline": [ + {"id": stage_id, "name": name} for stage_id, name in PIPELINE_STAGES + ], + "status_catalog": [ + "waiting", + "running", + "pending_confirmation", + "success", + "failed", + "canceled", + "blocked", + ], + "baselines": [ + { + "id": baseline_id, + "name": baseline["name"], + "capabilities": list(baseline["capabilities"]), + } + for baseline_id, baseline in BASELINE_CATALOG.items() + ], + "ota_server": { + "name": "车机升级服务器", + "repository": str(self.ota_root), + "endpoint": self.public_base_url, + }, + "tasks": deepcopy(self._state["tasks"][:30]), + "artifacts": deepcopy(self._state["artifacts"][:50]), + "releases": deepcopy(self._state["releases"][:50]), + } + + def task(self, task_id: str) -> dict[str, Any]: + with self._lock: + return deepcopy(self._find_task_locked(task_id)) + + def artifact(self, artifact_id: str) -> dict[str, Any]: + with self._lock: + return deepcopy(self._find_artifact_locked(artifact_id)) + + def publish_artifact(self, artifact_id: str) -> dict[str, Any]: + with self._lock: + artifact = self._find_artifact_locked(artifact_id) + if artifact.get("release_id"): + release = next( + ( + item + for item in self._state["releases"] + if item.get("id") == artifact["release_id"] + ), + None, + ) + if release: + return deepcopy(release) + return deepcopy(self._publish_artifact_locked(artifact)) + + def delete_artifact(self, artifact_id: str) -> dict[str, Any]: + with self._lock: + artifact = self._find_artifact_locked(artifact_id) + if artifact.get("is_baseline") or artifact.get("protected"): + raise FirmwareWorkbenchError( + "时间控制基线固件受保护,不能删除。", + 403, + "baseline_protected", + ) + package_path = Path(str(artifact.get("package_path", ""))).resolve() + self._assert_managed_path(package_path, self.artifact_root) + linked_releases = [ + release + for release in self._state["releases"] + if release.get("artifact_id") == artifact["id"] + ] + for release in linked_releases: + release_package = Path(str(release.get("package_path", ""))).resolve() + self._assert_managed_path(release_package, self.ota_root) + release_root = release_package.parent + if release_root.name != release.get("id") or release_root.parent.name != "releases": + raise FirmwareWorkbenchError( + "发布产物目录不符合托管规则,已停止删除。", + 500, + "artifact_path_invalid", + ) + + if package_path.is_file(): + package_path.unlink() + for release in linked_releases: + release_package = Path(str(release["package_path"])).resolve() + if release_package.parent.is_dir(): + shutil.rmtree(release_package.parent) + + release_ids = {str(release["id"]) for release in linked_releases} + self._state["releases"] = [ + release + for release in self._state["releases"] + if release.get("id") not in release_ids + ] + self._state["artifacts"] = [ + item for item in self._state["artifacts"] if item.get("id") != artifact["id"] + ] + for task in self._state["tasks"]: + if task.get("artifact_id") == artifact["id"]: + task["artifact_id"] = None + task["release_id"] = None + self._append_log_locked( + task, + "warning", + "产物删除", + "固件包和对应发布记录已删除,任务执行记录仍保留。", + stage="pipeline", + ) + + latest_path = self.ota_root / "latest.json" + if not self._state["releases"]: + latest_path.unlink(missing_ok=True) + else: + latest_manifest = self._public_release_payload(self._state["releases"][0]) + latest_temp = self.ota_root / "latest.tmp" + latest_temp.write_text( + json.dumps(latest_manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + latest_temp.replace(latest_path) + self._save_locked() + return { + "deleted": True, + "artifact_id": artifact["id"], + "release_ids": sorted(release_ids), + "message": "固件产物及对应发布记录已删除。", + } + + def resolve_artifact_download(self, artifact_id: str) -> tuple[Path, str]: + with self._lock: + artifact = self._find_artifact_locked(artifact_id) + path = Path(str(artifact.get("package_path", ""))).resolve() + self._assert_managed_path(path, self.artifact_root) + if not path.is_file(): + raise FirmwareWorkbenchError("固件包文件不存在。", 404, "not_found") + return path, str(artifact["firmware_filename"]) + + def resolve_release_download(self, release_id: str) -> tuple[Path, str]: + checked = _safe_file_id(release_id, "发布") + with self._lock: + release = next( + (item for item in self._state["releases"] if item.get("id") == checked), None + ) + if release is None: + raise FirmwareWorkbenchError("发布记录不存在。", 404, "not_found") + path = Path(str(release.get("package_path", ""))).resolve() + self._assert_managed_path(path, self.ota_root) + if not path.is_file(): + raise FirmwareWorkbenchError("OTA 固件包不存在。", 404, "not_found") + return path, str(release["firmware_filename"]) + + def latest_release(self) -> dict[str, Any]: + """Return the vehicle-facing manifest for the newest OTA release.""" + with self._lock: + if not self._state["releases"]: + raise FirmwareWorkbenchError("暂无可供车机升级的固件。", 404, "not_found") + return deepcopy(self._public_release_payload(self._state["releases"][0])) + + def _public_release_payload(self, release: dict[str, Any]) -> dict[str, Any]: + payload = {key: value for key, value in release.items() if key != "package_path"} + release_id = str(payload.get("release_id") or payload.get("id") or "") + payload.setdefault("schema", "n7-ota-release/v1") + payload.setdefault("release_id", release_id) + payload["download_url"] = f"{self.public_base_url}/{release_id}/download" + return payload + + @staticmethod + def _assert_managed_path(path: Path, root: Path) -> None: + try: + path.relative_to(root.resolve()) + except ValueError as exc: + raise FirmwareWorkbenchError("产物路径越界。", 500, "artifact_path_invalid") from exc + + def _stage_locked(self, task: dict[str, Any], stage_id: str) -> dict[str, Any]: + return next(stage for stage in task["stages"] if stage["id"] == stage_id) + + def _begin_stage(self, task_id: str, stage_id: str, summary: str) -> float: + with self._lock: + task = self._find_task_locked(task_id) + stage = self._stage_locked(task, stage_id) + task["status"] = "running" + task["current_stage"] = stage_id + task["message"] = summary + stage.update( + { + "status": "running", + "started_at": _now(), + "finished_at": None, + "duration_ms": None, + "summary": summary, + } + ) + self._append_log_locked(task, "info", stage["name"], summary, stage=stage_id) + self._save_locked() + return time.monotonic() + + def _finish_stage( + self, task_id: str, stage_id: str, started: float, summary: str + ) -> None: + with self._lock: + task = self._find_task_locked(task_id) + stage = self._stage_locked(task, stage_id) + stage.update( + { + "status": "success", + "finished_at": _now(), + "duration_ms": round((time.monotonic() - started) * 1000), + "summary": summary, + } + ) + task["message"] = summary + self._append_log_locked(task, "success", stage["name"], summary, stage=stage_id) + self._save_locked() + + def _execute_task(self, task_id: str, cancel_event: threading.Event) -> None: + started_monotonic = time.monotonic() + try: + with self._lock: + task = self._find_task_locked(task_id) + task["started_at"] = _now() + self._save_locked() + + started = self._begin_stage(task_id, "instruction", "校验并登记开发指令。") + self._check_cancel(cancel_event) + self._finish_stage(task_id, "instruction", started, "开发指令已登记。") + + started = self._begin_stage(task_id, "requirements", "解析座椅能力扩展需求。") + self._check_cancel(cancel_event) + with self._lock: + task = self._find_task_locked(task_id) + requested = self._parse_capabilities(str(task["instruction"])) + baseline_capabilities = task.get("baseline_capabilities", ["时间控制"]) + target_capabilities = [ + capability + for capability in ALL_CAPABILITIES + if capability in baseline_capabilities or capability in requested + ] + task["requested_capabilities"] = requested + task["target_capabilities"] = target_capabilities + self._append_log_locked( + task, + "info", + "需求解析结果", + "基线能力:" + + "、".join(baseline_capabilities) + + ";指令增量:" + + ("、".join(requested) or "无") + + ";目标能力:" + + "、".join(target_capabilities), + stage="requirements", + detail={ + "baseline": baseline_capabilities, + "requested": requested, + "target": target_capabilities, + }, + ) + self._save_locked() + self._finish_stage( + task_id, + "requirements", + started, + "已按所选基线与开发指令确定目标能力:" + + "+".join(target_capabilities) + + "。", + ) + + with self._lock: + task = self._find_task_locked(task_id) + baseline_name = str(task.get("baseline_name", "时间控制基线")) + started = self._begin_stage( + task_id, "baseline", f"检查{baseline_name}与目标 IAR 工程。" + ) + self._check_cancel(cancel_event) + baseline_file = self.baseline_root / "App" / "seat_timed_application.c" + target_file = self.target_root / "App" / "seat_move_application.c" + project_file = self.target_root / "IAR" / "Seat_Controller_demo.ewp" + missing = [ + path + for path in (baseline_file, target_file, project_file) + if not path.is_file() + ] + if missing: + raise FirmwareWorkbenchError( + "固件基线不完整:" + "、".join(str(path) for path in missing), + 409, + "baseline_incomplete", + ) + self._finish_stage( + task_id, + "baseline", + started, + f"{baseline_name}和 SMV8 目标 IAR 工程检查通过。", + ) + + started = self._begin_stage(task_id, "codegen", "生成能力变更清单并固化代码差异。") + self._check_cancel(cancel_event) + workspace = self.artifact_root / "tasks" / task_id + workspace.mkdir(parents=True, exist_ok=True) + baseline_text = baseline_file.read_text(encoding="utf-8", errors="replace").splitlines() + target_text = target_file.read_text(encoding="utf-8", errors="replace").splitlines() + diff_lines = list( + difflib.unified_diff( + baseline_text, + target_text, + fromfile="Passenger_Seat_Timed_Controller_demo/App/seat_timed_application.c", + tofile="Seat_Controller_demo/App/seat_move_application.c", + lineterm="", + n=2, + ) + ) + code_diff = "\n".join(diff_lines[:800]) + if len(diff_lines) > 800: + code_diff += f"\n... 差异过长,已截取前 800 行(共 {len(diff_lines)} 行)。" + with self._lock: + task = self._find_task_locked(task_id) + baseline_capabilities = list(task.get("baseline_capabilities", ["时间控制"])) + target_capabilities = list(task["target_capabilities"]) + baseline_name = str(task.get("baseline_name", "时间控制基线")) + feature_profile = { + "position_control": "位置控制" in target_capabilities, + "ventilation": "座椅通风" in target_capabilities, + "heating": "座椅加热" in target_capabilities, + } + requirements = { + "task_id": task_id, + "baseline": baseline_name, + "baseline_capabilities": baseline_capabilities, + "target": "Seat_Controller_demo", + "version": FIRMWARE_VERSION, + "capabilities": target_capabilities, + "feature_profile": feature_profile, + "generated_at": _now(), + } + (workspace / "requirements.json").write_text( + json.dumps(requirements, ensure_ascii=False, indent=2), encoding="utf-8" + ) + (workspace / "feature_profile.json").write_text( + json.dumps(feature_profile, ensure_ascii=False, indent=2), encoding="utf-8" + ) + with self._lock: + task = self._find_task_locked(task_id) + file_changes = [ + { + "path": "Config/seat_feature_profile.h", + "type": "modified", + "summary": "按目标能力生成编译期开关:" + + "+".join(target_capabilities), + }, + { + "path": "IAR/Seat_Controller_demo.ewp", + "type": "modified", + "summary": "使用 rev_d 配置编译能力裁剪后的固件", + }, + ] + if "位置控制" in target_capabilities: + file_changes.append( + { + "path": "App/seat_move_application.c", + "type": "modified", + "summary": "启用四轴绝对位置与相对位置参数控制", + } + ) + if "座椅通风" in target_capabilities: + file_changes.append( + { + "path": "App/seat_controller_thermal.c", + "type": "modified", + "summary": "启用座椅通风分级控制与 CAN 反馈", + } + ) + if "座椅加热" in target_capabilities: + file_changes.append( + { + "path": "App/seat_backrest_heater.c", + "type": "modified", + "summary": "启用靠背加热分级控制与超时保护", + } + ) + task["file_changes"] = file_changes + task["code_diff"] = code_diff + self._save_locked() + self._finish_stage( + task_id, + "codegen", + started, + "已生成并固化目标能力编译配置:" + + "+".join(target_capabilities) + + "。", + ) + + started = self._begin_stage( + task_id, "build", "调用 IAR Embedded Workbench 编译目标固件。" + ) + self._check_cancel(cancel_event) + image_path = self._build_runner( + task_id, self._build_log_callback(task_id), cancel_event + ) + self._check_cancel(cancel_event) + if not image_path.is_file(): + raise FirmwareWorkbenchError( + "IAR 构建未生成 HEX 文件。", 500, "build_output_missing" + ) + copied_hex = workspace / "Seat_Controller_demo.hex" + shutil.copy2(image_path, copied_hex) + self._finish_stage(task_id, "build", started, "IAR 编译成功,0 errors / 0 warnings。") + + started = self._begin_stage(task_id, "test", "执行固件能力与工程完整性验证。") + self._check_cancel(cancel_event) + validation = self._validate_target(copied_hex, target_capabilities, feature_profile) + with self._lock: + task = self._find_task_locked(task_id) + task["test_results"] = validation + self._save_locked() + (workspace / "validation.json").write_text( + json.dumps(validation, ensure_ascii=False, indent=2), encoding="utf-8" + ) + self._finish_stage( + task_id, + "test", + started, + f"{len(validation)} 项验证全部通过。", + ) + + started = self._begin_stage(task_id, "package", "生成 OTA 固件包、清单和 SHA-256。") + self._check_cancel(cancel_event) + artifact = self._package_artifact(task_id, workspace, copied_hex, validation, code_diff) + self._finish_stage( + task_id, + "package", + started, + f"固件包 {artifact['firmware_filename']} 已生成并完成哈希校验。", + ) + + started = self._begin_stage( + task_id, "publish", "将签名清单和固件包发布到车机升级服务器。" + ) + self._check_cancel(cancel_event) + with self._lock: + stored = self._find_artifact_locked(str(artifact["id"])) + release = self._publish_artifact_locked(stored) + task = self._find_task_locked(task_id) + task["artifact_id"] = artifact["id"] + task["release_id"] = release["id"] + self._save_locked() + self._finish_stage(task_id, "publish", started, FINAL_RELEASE_MESSAGE) + + with self._lock: + task = self._find_task_locked(task_id) + task.update( + { + "status": "success", + "message": FINAL_RELEASE_MESSAGE, + "finished_at": _now(), + "duration_ms": round((time.monotonic() - started_monotonic) * 1000), + } + ) + self._append_log_locked( + task, + "success", + "流水线完成", + FINAL_RELEASE_MESSAGE, + stage="pipeline", + detail={"release_id": release["id"], "download_url": release["download_url"]}, + ) + self._save_locked() + except InterruptedError: + with self._lock: + task = self._find_task_locked(task_id) + task.update( + { + "status": "canceled", + "message": "任务已取消。", + "finished_at": _now(), + "duration_ms": round((time.monotonic() - started_monotonic) * 1000), + } + ) + self._finish_running_stage(task, "canceled", "任务已由用户取消。") + self._append_log_locked( + task, "warning", "任务取消", "任务已安全停止。", stage="pipeline" + ) + self._save_locked() + except Exception as exc: # noqa: BLE001 - persist arbitrary build failures in task state + with self._lock: + task = self._find_task_locked(task_id) + message = str(exc) or exc.__class__.__name__ + task.update( + { + "status": "failed", + "message": "流水线执行失败,可查看原因并重试。", + "failure_reason": message, + "finished_at": _now(), + "duration_ms": round((time.monotonic() - started_monotonic) * 1000), + } + ) + self._finish_running_stage(task, "failed", message) + self._append_log_locked( + task, "error", "任务失败", message, stage=task.get("current_stage") + ) + self._save_locked() + finally: + with self._lock: + self._cancel_events.pop(task_id, None) + self._threads.pop(task_id, None) + + @staticmethod + def _check_cancel(cancel_event: threading.Event) -> None: + if cancel_event.is_set(): + raise InterruptedError + + def _finish_running_stage( + self, task: dict[str, Any], status: str, summary: str + ) -> None: + for stage in task.get("stages", []): + if stage.get("status") == "running": + stage["status"] = status + stage["finished_at"] = _now() + stage["summary"] = summary + + @staticmethod + def _parse_capabilities(instruction: str) -> list[str]: + requested: list[str] = [] + lowered = instruction.lower() + patterns = { + "时间控制": ("时间", "定时", "timed"), + "位置控制": ("位置", "position"), + "座椅通风": ("通风", "vent"), + "座椅加热": ("加热", "heater", "heat"), + } + for capability, aliases in patterns.items(): + if any(alias in lowered for alias in aliases): + requested.append(capability) + return requested + + def _build_log_callback(self, task_id: str) -> Callable[[str, str], None]: + def callback(level: str, line: str) -> None: + cleaned = str(line).rstrip() + if not cleaned: + return + with self._lock: + task = self._find_task_locked(task_id) + task["build_output"].append(cleaned) + task["build_output"] = task["build_output"][-500:] + self._append_log_locked( + task, level, "IAR Build", cleaned, stage="build" + ) + self._save_locked() + + return callback + + def _run_iar_build( + self, + task_id: str, + log: Callable[[str, str], None], + cancel_event: threading.Event, + ) -> Path: + script = self.repository_root / "tools" / "build_seat_firmware.ps1" + if not script.is_file(): + raise FirmwareWorkbenchError("IAR 构建脚本不存在。", 500, "build_script_missing") + shell = shutil.which("powershell.exe") or shutil.which("pwsh") + if not shell: + raise FirmwareWorkbenchError("未找到 PowerShell,无法执行 IAR 构建。", 500) + with self._lock: + task = self._find_task_locked(task_id) + capabilities = list(task.get("target_capabilities", ["时间控制"])) + output_directory = self.artifact_root / "tasks" / task_id / "build" + output_directory.mkdir(parents=True, exist_ok=True) + command = [ + shell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + "-RepositoryRoot", + str(self.repository_root), + "-OutputDirectory", + str(output_directory), + "-PositionControl", + "1" if "位置控制" in capabilities else "0", + "-Ventilation", + "1" if "座椅通风" in capabilities else "0", + "-Heating", + "1" if "座椅加热" in capabilities else "0", + ] + process = subprocess.Popen( + command, + cwd=self.repository_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + assert process.stdout is not None + try: + while True: + line = process.stdout.readline() + if line: + log("info", line) + if cancel_event.is_set() and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + raise InterruptedError + if process.poll() is not None and not line: + break + if not line: + time.sleep(0.05) + finally: + process.stdout.close() + if process.returncode != 0: + raise FirmwareWorkbenchError( + f"IAR 构建失败,退出码 {process.returncode}。", 500, "build_failed" + ) + return output_directory / "Seat_Controller_demo.hex" + + def _validate_target( + self, + image_path: Path, + target_capabilities: list[str], + feature_profile: dict[str, bool], + ) -> list[dict[str, Any]]: + application = (self.target_root / "App" / "seat_move_application.c").read_text( + encoding="utf-8", errors="replace" + ) + thermal = (self.target_root / "App" / "seat_controller_thermal.c").read_text( + encoding="utf-8", errors="replace" + ) + heater = (self.target_root / "App" / "seat_backrest_heater.c").read_text( + encoding="utf-8", errors="replace" + ) + project = (self.target_root / "IAR" / "Seat_Controller_demo.ewp").read_text( + encoding="utf-8", errors="replace" + ) + checks: list[tuple[str, bool]] = [ + ("四轴时间控制", "SEAT_MOVE_OPCODE_MOVE_TIMED" in application), + ("能力配置与目标一致", feature_profile == { + "position_control": "位置控制" in target_capabilities, + "ventilation": "座椅通风" in target_capabilities, + "heating": "座椅加热" in target_capabilities, + }), + ] + if "位置控制" in target_capabilities: + checks.append(( + "位置参数控制", + "SEAT_MOVE_OPCODE_MOVE_ABSOLUTE" in application + and "SEAT_MOVE_OPCODE_MOVE_RELATIVE" in application, + )) + if "座椅通风" in target_capabilities: + checks.append(( + "座椅通风控制", + "SeatControllerThermal_RequestFan" in thermal + and '"bsp_seat_fan.h"' in thermal, + )) + if "座椅加热" in target_capabilities: + checks.append(( + "座椅加热控制", + "SeatBackrestHeater_SetLevel" in thermal and "MAX_ACTIVE_TIME" in heater, + )) + checks.extend([ + ( + "IAR 工程模块完整性", + all( + name in project + for name in ( + "seat_move_application.c", + "seat_controller_thermal.c", + "seat_backrest_heater.c", + ) + ), + ), + ("HEX 镜像有效", image_path.is_file() and image_path.stat().st_size > 1024), + ]) + failed = [name for name, passed in checks if not passed] + results = [ + { + "name": name, + "status": "passed" if passed else "failed", + "summary": "验证通过" if passed else "验证失败", + "finished_at": _now(), + } + for name, passed in checks + ] + if failed: + raise FirmwareWorkbenchError( + "固件验证失败:" + "、".join(failed), 409, "validation_failed" + ) + return results + + def _package_artifact( + self, + task_id: str, + workspace: Path, + image_path: Path, + validation: list[dict[str, Any]], + code_diff: str, + ) -> dict[str, Any]: + with self._lock: + task = self._find_task_locked(task_id) + capabilities = list(task["target_capabilities"]) + baseline_capabilities = list(task.get("baseline_capabilities", ["时间控制"])) + artifact_id = f"fw-art-{uuid.uuid4().hex[:12]}" + build_time = _now() + image_hash = hashlib.sha256(image_path.read_bytes()).hexdigest() + capability_slug = "-".join( + { + "时间控制": "time", + "位置控制": "position", + "座椅通风": "vent", + "座椅加热": "heat", + }[capability] + for capability in capabilities + ) + package_name = ( + f"PassengerSeat_{FIRMWARE_VERSION}_{capability_slug}_{artifact_id[-6:]}.zip" + ) + package_path = workspace / package_name + manifest = { + "schema": "n7-seat-ota/v1", + "software_name": SOFTWARE_NAME, + "version": FIRMWARE_VERSION, + "capabilities": capabilities, + "image": image_path.name, + "image_size": image_path.stat().st_size, + "image_sha256": image_hash, + "build_time": build_time, + "task_id": task_id, + } + manifest_path = workspace / "manifest.json" + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(image_path, arcname=image_path.name) + archive.write(manifest_path, arcname="manifest.json") + archive.write(workspace / "requirements.json", arcname="requirements.json") + archive.write(workspace / "validation.json", arcname="validation.json") + archive.write(workspace / "feature_profile.json", arcname="feature_profile.json") + package_hash = hashlib.sha256(package_path.read_bytes()).hexdigest() + artifact = { + "id": artifact_id, + "task_id": task_id, + "software_name": SOFTWARE_NAME, + "version": FIRMWARE_VERSION, + "capabilities": capabilities, + "baseline_capabilities": baseline_capabilities, + "build_time": build_time, + "firmware_filename": package_name, + "image_filename": image_path.name, + "size_bytes": package_path.stat().st_size, + "sha256": package_hash, + "image_sha256": image_hash, + "package_path": str(package_path), + "status": "ready", + "release_id": None, + "published_at": None, + "download_url": f"/api/firmware-workbench/artifacts/{artifact_id}/download", + "capability_diff": { + "added": [item for item in capabilities if item not in baseline_capabilities], + "retained": [item for item in capabilities if item in baseline_capabilities], + "removed": [item for item in baseline_capabilities if item not in capabilities], + }, + "code_diff": code_diff, + "validation_records": deepcopy(validation), + "file_changes": [], + } + with self._lock: + task = self._find_task_locked(task_id) + artifact["file_changes"] = deepcopy(task["file_changes"]) + self._state["artifacts"].insert(0, artifact) + task["artifact_id"] = artifact_id + self._save_locked() + return deepcopy(artifact) + + def _publish_artifact_locked(self, artifact: dict[str, Any]) -> dict[str, Any]: + package_path = Path(str(artifact["package_path"])).resolve() + self._assert_managed_path(package_path, self.artifact_root) + if not package_path.is_file(): + raise FirmwareWorkbenchError("待发布固件包不存在。", 404, "not_found") + actual_hash = hashlib.sha256(package_path.read_bytes()).hexdigest() + if actual_hash != artifact.get("sha256"): + raise FirmwareWorkbenchError("固件包 SHA-256 校验失败,已阻止发布。", 409) + + release_id = f"fw-rel-{uuid.uuid4().hex[:12]}" + download_url = f"{self.public_base_url}/{release_id}/download" + release_root = self.ota_root / "releases" / release_id + staging_root = self.ota_root / ".staging" / release_id + staging_root.mkdir(parents=True, exist_ok=False) + published_package = staging_root / str(artifact["firmware_filename"]) + shutil.copy2(package_path, published_package) + release_manifest = { + "schema": "n7-ota-release/v1", + "release_id": release_id, + "artifact_id": artifact["id"], + "software_name": artifact["software_name"], + "version": artifact["version"], + "capabilities": artifact["capabilities"], + "firmware_filename": artifact["firmware_filename"], + "size_bytes": artifact["size_bytes"], + "sha256": artifact["sha256"], + "published_at": _now(), + "status": "published", + "message": FINAL_RELEASE_MESSAGE, + "download_url": download_url, + } + (staging_root / "release.json").write_text( + json.dumps(release_manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + release_root.parent.mkdir(parents=True, exist_ok=True) + staging_root.replace(release_root) + latest_temp = self.ota_root / "latest.tmp" + latest_temp.write_text( + json.dumps(release_manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + latest_temp.replace(self.ota_root / "latest.json") + final_package = release_root / str(artifact["firmware_filename"]) + release = { + **release_manifest, + "id": release_id, + "package_path": str(final_package), + } + artifact.update( + { + "status": "published", + "release_id": release_id, + "published_at": release["published_at"], + "download_url": release["download_url"], + } + ) + self._state["releases"].insert(0, release) + self._save_locked() + return release + + @staticmethod + def _append_log_locked( + task: dict[str, Any], + level: str, + title: str, + message: str, + *, + stage: str | None = None, + detail: dict[str, Any] | None = None, + ) -> None: + task.setdefault("logs", []).append( + { + "id": f"log-{uuid.uuid4().hex[:10]}", + "time": _now(), + "level": level, + "stage": stage, + "title": title, + "message": message, + "detail": detail, + } + ) + task["logs"] = task["logs"][-1000:] + + def close(self) -> None: + with self._lock: + events = list(self._cancel_events.values()) + threads = list(self._threads.values()) + for event in events: + event.set() + for worker in threads: + worker.join(timeout=3) diff --git a/src/cognitive_agent/application/mode_creation_seat_policy.py b/src/cognitive_agent/application/mode_creation_seat_policy.py new file mode 100644 index 0000000..ccfd228 --- /dev/null +++ b/src/cognitive_agent/application/mode_creation_seat_policy.py @@ -0,0 +1,76 @@ +"""Seat selection for new mode drafts, not for replaying saved user modes.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from copy import deepcopy +from typing import Any + +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes + + +class ModeCreationSeatPolicyError(ValueError): + """A source mode cannot satisfy the selected seat without substitution.""" + + +def creation_seat_scopes(text: str) -> frozenset[str]: + """Default to passenger; HVAC/window zone names do not select a seat.""" + normalized = text.replace("主副驾", "主驾和副驾") + # Do not let the generic 驾驶席/驾驶位 aliases inside 副驾驶席/位 + # accidentally select the driver as well as the passenger. + normalized = re.sub(r"副驾驶(?:席|位|座(?!椅))?", "副驾", normalized) + if re.search(r"(?:未知|不明|不存在的?|任意)(?:一个)?(?:座位|座椅)|第[四五六七八九0-9]+排座", normalized): + return frozenset({"unsupported"}) + normalized = re.sub(r"(?:前排)?两(?:个|张)?(?:座椅|座位|座)|双座(?:椅)?", "主驾和副驾", normalized) + excluded = set() + for scope, aliases in (("driver", r"主驾(?:驶)?|驾驶位|驾驶席|司机位"), + ("passenger", r"副驾(?:驶)?|乘客位")): + pattern = re.compile( + rf"(?:不要|别|不用|无需|不需要|不涉及|不包含|排除)" + rf"(?:再|去|动|移动|调节|调整|控制|联动|编排|使用|添加|包含)*" + rf"(?:{aliases})(?:座椅|座位)?|" + rf"(?:{aliases})(?:座椅|座位)?(?:不动|不要动|不用动|不联动|不参与|不编排)" + ) + if pattern.search(normalized): + excluded.add(scope) + normalized = pattern.sub("", normalized) + selected = resolve_scene_seat_scopes(normalized) or frozenset({"passenger"}) + return frozenset(selected - excluded) + + +def normalized_seat_scope(scope: object) -> str: + value = str(scope or "") + return {"front_left": "driver", "front_right": "passenger"}.get(value, value) + + +def project_seed_operations( + operations: Sequence[Mapping[str, Any]], scopes: frozenset[str], +) -> list[dict[str, Any]]: + """Choose from trusted seat variants. Never retarget a passenger capability.""" + if "unsupported" in scopes: + raise ModeCreationSeatPolicyError("当前功能模式仅支持明确的主驾或副驾座椅,不会替换为其他座位。") + seats = [op for op in operations if op.get("domain") == "seat"] + if any(normalized_seat_scope(op.get("target_scope")) not in {"driver", "passenger"} for op in seats): + raise ModeCreationSeatPolicyError("模式包含无法验证的座位范围,请重新生成。") + projected = [deepcopy(dict(op)) for op in operations + if op.get("domain") != "seat" or normalized_seat_scope(op.get("target_scope")) in scopes] + if seats and not any(op.get("domain") == "seat" for op in projected): + target = "主驾" if scopes == {"driver"} else "副驾" if scopes == {"passenger"} else "所选座位" + raise ModeCreationSeatPolicyError(f"该模式没有支持{target}的座椅动作,不会把另一座位的能力冒充为{target}能力。") + return projected + + +def project_seed_plan(plan: Mapping[str, Any], scopes: frozenset[str]) -> dict[str, Any]: + projected = deepcopy(dict(plan)) + projected["requests"] = project_seed_operations(plan.get("requests", []), scopes) + projected["detected_domains"] = list(dict.fromkeys(op["domain"] for op in projected["requests"])) + return projected + + +def project_seed_schedule(schedule: ModeSchedule, scopes: frozenset[str]) -> ModeSchedule: + document = schedule.to_dict() + for phase in document["phases"]: + phase["operations"] = project_seed_operations(phase["operations"], scopes) + return ModeSchedule.from_dict(document) diff --git a/src/cognitive_agent/application/mode_device_additions.py b/src/cognitive_agent/application/mode_device_additions.py new file mode 100644 index 0000000..ee35261 --- /dev/null +++ b/src/cognitive_agent/application/mode_device_additions.py @@ -0,0 +1,86 @@ +"""Conservative, local defaults for adding named devices to an existing mode. + +Only a complete *bare device list* uses these defaults. Explicit parameters and +actions stay in the normal parser; unrecognised list entries never disappear. +Defaults are proposals for a fully validated preview, not executable commands. +""" + +from __future__ import annotations + +import re +from copy import deepcopy +from typing import Any + +_ADD_PREFIX = re.compile( + r"^(?:(?:请|帮我|帮忙|再|还|也|同时|顺便|继续|并且|并)\s*)*" + r"(?:加入|添加|增加|补充|加上|追加|新增)\s*" +) +_DEVICE_GROUPS = { + "空调": "climate", "冷气": "climate", + "氛围灯": "ambient_light", "气氛灯": "ambient_light", + "阅读灯": "reading", "读书灯": "reading", + "车窗": "window", "窗户": "window", + "遮阳帘": "roof", "天幕遮阳帘": "roof", "天窗遮阳帘": "roof", + "室内灯": "indoor", "顶灯": "indoor", +} +_LIST_SEPARATOR = re.compile(r"[、,,;;\s]+|以及|还有|和|与|及") +_EXPLICIT_ACTION = re.compile( + r"\d|[零一二三四五六七八九十百]+(?:度|档|挡|秒|分钟|%)|" + r"打开|开启|关闭|关掉|开到|关到|调整|设置|调到|调成|变成|改成|" + r"升高|降低|调高|调低|调亮|调暗|全开|全关|半开|一半|" + r"红色|蓝色|绿色|白色|暖色|冷色|亮度|温度|风量|内循环|外循环" +) + + +def strip_mode_addition_prefix(change_text: str) -> str: + """Remove only the editing verb, not any device, value or negation.""" + return _ADD_PREFIX.sub("", change_text.strip(), count=1) + + +def mode_device_additions( + change_text: str, + base_operations: list[dict[str, Any]], + default_operations: list[dict[str, Any]], +) -> list[dict[str, Any]] | None: + """Return missing device defaults, None for non-list/explicit edits. + + Existing groups are already configured and are left completely untouched, + including their target zones, power state and custom parameter values. + Reading and indoor lights are separate groups within the same domain. + """ + if not _ADD_PREFIX.match(change_text.strip()): + return None + body = strip_mode_addition_prefix(change_text).strip("。.!!吧 ") + if _EXPLICIT_ACTION.search(body): + return None + body = re.sub(r"(?:等)?(?:相关)?(?:设备)?(?:的)?(?:控制)?(?:动作|能力|联动)?$", "", body) + body = re.sub(r"等$", "", body) + devices = [item for item in _LIST_SEPARATOR.split(body) if item] + groups: list[str] = [] + for device in devices: + device = re.sub(r"(?:设备)?(?:的)?(?:控制)?(?:动作|能力)$", "", device) + group = _DEVICE_GROUPS.get(device) + if group is None: + raise ValueError(f"未识别或暂不支持增补设备“{device}”;未修改任何模式动作。") + if group not in groups: + groups.append(group) + if not groups: + raise ValueError("请说明要加入哪些设备,或提供明确的设备动作。") + + def belongs(operation: dict[str, Any], group: str) -> bool: + capability = str(operation.get("capability") or "") + if group == "reading": + return capability == "interior_light.set_reading_power" + if group == "indoor": + return capability == "interior_light.set_indoor_power" + return operation.get("domain") == group + + additions: list[dict[str, Any]] = [] + for group in groups: + if any(belongs(operation, group) for operation in base_operations): + continue + candidates = [operation for operation in default_operations if belongs(operation, group)] + if not candidates: + raise ValueError(f"该设备尚无经过登记的默认编排,请补充明确目标:{group}。") + additions.extend(deepcopy(candidates)) + return additions diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index 4dcc597..2ccd1c5 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -13,6 +13,8 @@ from typing import Any import yaml +from cognitive_agent.application.mode_schedule import ModeSchedule + _MATCH_FORMATTING = re.compile(r"[\s()()]+") _MODE_INVOCATION_FORMATTING = re.compile(r"[\s,。!?,.!?::;;()()]+") _NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+") @@ -518,6 +520,7 @@ class ModeProfile: command_flow: dict[str, Any] | None = None sync_origin: str = "" upstream_origin: str = "" + schedule: ModeSchedule | None = None @classmethod def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile": @@ -558,10 +561,12 @@ class ModeProfile: upstream_origin=_clean_upstream_origin( document.get("upstream_origin") ), + schedule=(ModeSchedule.from_dict(document["schedule"]) + if document.get("schedule") is not None else None), ) def public_dict(self) -> dict[str, Any]: - is_cyclic = self.timeline is not None or any( + is_cyclic = self.schedule is not None or self.timeline is not None or any( operation.capability == "seat.start_dynamic_mode" for operation in self.operations ) @@ -595,6 +600,9 @@ class ModeProfile: result["upstream_origin"] = self.upstream_origin or None if self.timeline is not None: result["timeline"] = self.timeline.public_dict() + if self.schedule is not None: + result["schedule"] = self.schedule.to_dict() + result["operation_count"] = self.schedule.operation_count return result @@ -779,6 +787,7 @@ class ModeProfileRegistry: }, "operations": [operation.to_yaml() for operation in profile.operations], "timeline": profile.timeline.to_yaml() if profile.timeline is not None else None, + "schedule": profile.schedule.to_dict() if profile.schedule is not None else None, "instruction": profile.instruction, "command_flow": profile.command_flow, "sync_origin": profile.sync_origin, @@ -815,6 +824,7 @@ class ModeProfileRegistry: timeline: ModeTimeline | None = None, sync_origin: str = "", upstream_origin: str = "", + schedule: ModeSchedule | None = None, expected_revision: str | None = None, create_only: bool = False, ) -> ModeProfile: @@ -857,6 +867,8 @@ class ModeProfileRegistry: if clean_sync_origin: document["sync_origin"] = clean_sync_origin document["upstream_origin"] = clean_upstream_origin + if schedule is not None: + document["schedule"] = schedule.to_dict() path = ( self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml" if vehicle_id @@ -1128,6 +1140,8 @@ def _mode_profile_document( } if profile.timeline is not None: document["timeline"] = profile.timeline.to_yaml() + if profile.schedule is not None: + document["schedule"] = profile.schedule.to_dict() if profile.instruction: document["instruction"] = profile.instruction if profile.command_flow is not None: diff --git a/src/cognitive_agent/application/mode_profile_intent.py b/src/cognitive_agent/application/mode_profile_intent.py index db8252a..2e97dea 100644 --- a/src/cognitive_agent/application/mode_profile_intent.py +++ b/src/cognitive_agent/application/mode_profile_intent.py @@ -9,12 +9,37 @@ _MODE_NAME = r"(?P[^,。,.!!::\s“”\"'「」『』《》]{1,64}? _QUOTED_MODE_NAME = ( r"(?P[“\"'「『《]?)" + _MODE_NAME + r"(?P[”\"'」』》]?)" ) +_CREATION_DUAL_SEAT_NAMES = ( + r"主驾(?:和|与|及)副驾|主副驾|双座(?:椅)?|(?:前排)?两(?:个|张)?(?:座椅|座位|座)" +) +_CREATION_SEAT_NAMES = ( + rf"{_CREATION_DUAL_SEAT_NAMES}|" + r"副驾驶席|副驾驶位|副驾驶|副驾|乘客座椅|乘客席|" + r"主驾驶席|主驾驶位|主驾驶|主驾|驾驶席|驾驶位" +) +_CREATION_SEAT_PREFIX = re.compile( + rf"^(?:为|给|针对|供)?(?P{_CREATION_SEAT_NAMES})" + r"(?:(?:座椅|座位|座席)?(?:专属|专用))?的?" +) +_CREATION_SEAT_CLAUSE = re.compile( + rf"^(?:仅|只)?(?:为|给|针对|适用于|用于|面向|联动|编排)" + rf"(?P{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?" + r"(?:使用|联动|编排|的能力|的动作)?$" +) +_CREATION_SEAT_GENERIC_SCENE = re.compile( + r"^(?:座椅|座位|座席)的?(?P" + r"阅读|看书|读书|办公|观影|看电影|电影|露营)模式$" +) +_CREATION_SEAT_FUNCTION_SUBJECT = re.compile( + r"^(?:舒适|舒服|舒缓|放松|动态|循环|通风|加热|按摩|靠背|腿托|坐垫).{0,32}模式$" +) _MODE_PROFILE_CREATE_HEAD = re.compile( r"^\s*" r"(?:(?:请问(?:可以|能不能|能否|可不可以|能)帮我|" r"(?:可以|能不能|能否|可不可以|能)帮我|" r"我(?:想要|想|希望|需要|打算|准备)(?:让你)?|" r"想要|想|希望|需要|打算|准备|请你?|麻烦你?|帮我|帮忙|给我|先)\s*){0,6}" + rf"(?:(?:为|给|针对)(?P{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?\s*)?" r"(?P构建|创建|新建|新增|生成|添加)\s*" r"(?:一下\s*)?(?:一个|个|一套|套)?\s*" r"(?P命名为|名称为|取名为|名叫|名为|叫做)?\s*" @@ -34,6 +59,9 @@ _MODE_PROFILE_PURPOSE_WRAPPERS = ( r"^(?:可以|能够|能)?让(?:我们|我|人们?|乘员们?|乘客们?|大家)" r"(?P.+?)的(?:功能|场景|自定义)?模式$" ), + re.compile( + r"^(?P(?:座椅|座位|乘坐|坐着|上车|空调).+?)的(?:功能|场景|自定义)?模式$" + ), ) _MODE_PROFILE_PURPOSE_INVALID = frozenset( {"", "功能", "场景", "自定义", "使用", "日常使用", "合适", "需要"} @@ -67,6 +95,7 @@ _MODE_PROFILE_CREATE_CAPABILITY_CLAUSE = re.compile( ) _MODE_PROFILE_CREATE_ASSISTED_REQUEST = re.compile( r"^(?:请问)?(?:可以|能不能|能否|可不可以|能)帮我(?:先)?" + rf"(?:(?:为|给|针对)(?:{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?)?" r"(?:构建|创建|新建|新增|生成|添加)" ) _MODE_PROFILE_CREATE_SUFFIX_UNIT = re.compile( @@ -141,7 +170,12 @@ _MODE_PROFILE_DELETE_BA = re.compile( ) _MODE_PROFILE_UPDATE_NEGATION = re.compile( r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|" - r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存" + r"取消(?:修改|调整|更改|更新|变更|加入|添加|增加|补充|加上|追加|新增|操作)|" + r"撤销|算了|不执行|不下发|不保存" +) +_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile( + r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)" + r"(?:把|将|在|给|为|往|向|修改|调整|更改|更新|变更)" ) _MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)" _MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])" @@ -164,16 +198,30 @@ _CONTEXTUAL_NAME_FIRST_UPDATE = re.compile( rf"^{_LEADING_POLITE}{_QUOTED_MODE_NAME}[,,::]" r"(?P.+?)[。.!!]?$" ) +_MODE_PROFILE_APPEND_VERB = r"(?:加入|添加|增加|补充|加上|追加|新增)" +_APPEND_MODE_UPDATE = re.compile( + rf"^{_LEADING_POLITE}(?:在|给|为|往|向)?{_QUOTED_MODE_NAME}" + rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?[,,::]?" + rf"(?:再|还|也|同时|另外)?(?P{_MODE_PROFILE_APPEND_VERB}.+?)[。.!!]?$" +) +_APPEND_TO_MODE_UPDATE = re.compile( + rf"^{_LEADING_POLITE}(?:把|将)(?P.+?)" + rf"(?P{_MODE_PROFILE_APPEND_VERB})(?:到|进)?{_QUOTED_MODE_NAME}" + rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?(?:吧|呀|啊|哦|谢谢(?:你)?)?[。.!!]?$" +) MODE_PROFILE_CHANGE_PREDICATE = re.compile( + rf"{_MODE_PROFILE_APPEND_VERB}|" r"修改|改成|改为|更改|更新|变更|变成|变为|换成|" r"调整|调节|调高|调低|调到|调成|调为|调至|" r"设置|设为|设成|设到|设至|" r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|" r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|" - r"复位|半开|开到|关到|拉开|拉上|上锁|解锁" + r"复位|半开|开到|关到|拉开|拉上|上锁|解锁|" + r"(?:持续|保持)(?:时间)?(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)(?:分钟|秒钟|秒|s)|" + r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次" ) MODE_PROFILE_QUERY_CUE = re.compile( - r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|" + r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|能否|能不能|可不可以|会不会|" r"合适(?:吗)?|合理(?:吗)?|会(?:怎么样|怎样|如何)|" r"怎么|如何|列(?:出|一下)|介绍(?:一下)?|说明(?:一下)?|" r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]|(?:吗|么|呢)$" @@ -230,6 +278,41 @@ def normalize_mode_profile_name(value: object) -> str: return compact_mode_profile_text(value).strip("“”\"'「」『』《》").casefold() +def _creation_seat_label(value: str) -> str: + if re.fullmatch(_CREATION_DUAL_SEAT_NAMES, value): + return "主副驾" + return "副驾" if value.startswith(("副", "乘客")) else "主驾" + + +def mode_creation_source_name(value: object) -> str: + """Remove only an explicit front-seat qualifier for source lookup. + + A scoped draft keeps its distinct display name. This helper only allows its + existing scene seed to be located; it never authorizes any seat operation or + rewrites an unsupported rear/unknown target into a front seat. + """ + + name = compact_mode_profile_text(value).strip("“”\"'「」『』《》") + match = _CREATION_SEAT_PREFIX.match(name) + if match is None: + return name + subject = name[match.end():] + if not subject or subject == "模式" or not subject.endswith("模式"): + return name + # A seat location before a generic activity is a scope wrapper. Keep the + # actual device topic in 座椅舒适/座椅动态 rather than dropping every 座椅. + generic_scene = _CREATION_SEAT_GENERIC_SCENE.fullmatch(subject) + if generic_scene is not None: + subject = f"{generic_scene.group('scene')}模式" + elif ( + match.group("seat").endswith(("座椅", "座位", "座")) + and _CREATION_SEAT_FUNCTION_SUBJECT.fullmatch(subject) + ): + subject = f"座椅{subject}" + inferred = _infer_mode_profile_purpose_name(subject) + return inferred[0] if inferred is not None else subject + + def _infer_mode_profile_purpose_name(value: object) -> tuple[str, str] | None: """Turn one bounded purpose description into a concise mode name. @@ -445,12 +528,24 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None: # Keep explicit user names byte-for-byte while giving inferred scenes a # concise, reusable name (for example 看书模式 instead of # 适合看书的功能模式). - if not str(match.group("naming") or "") and not opening: + explicit_name = bool(str(match.group("naming") or "") or opening) + if not explicit_name: inferred_purpose = _infer_mode_profile_purpose_name(name) if inferred_purpose is not None: name = inferred_purpose[0] + seat_prefix = _CREATION_SEAT_PREFIX.match(name) + if seat_prefix is not None and mode_creation_source_name(name) != name: + name = _creation_seat_label(seat_prefix.group("seat")) + mode_creation_source_name(name) + if match.group("seat") and seat_prefix is None: + name = _creation_seat_label(match.group("seat")) + name if normalize_mode_profile_name(name) in _GENERIC_MODE_PROFILE_CREATE_NAMES: return None + name_seat = _CREATION_SEAT_PREFIX.match(name) + if ( + match.group("seat") and name_seat is not None + and _creation_seat_label(match.group("seat")) != _creation_seat_label(name_seat.group("seat")) + ): + return None raw_match_end = raw_ends[match.end() - 1] if "\n" in raw[:raw_match_end] or "\r" in raw[:raw_match_end]: @@ -495,6 +590,19 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None: return name, "" instruction = _strip_mode_profile_create_suffix(instruction) + seat_clause = _CREATION_SEAT_CLAUSE.fullmatch(compact_mode_profile_text(instruction)) + if seat_clause is not None: + # A seat-only clause selects the draft, not an extra actuator command. + # Real device settings in a following clause remain untouched below. + name_seat = _CREATION_SEAT_PREFIX.match(name) + if ( + name_seat is not None + and _creation_seat_label(name_seat.group("seat")) != _creation_seat_label(seat_clause.group("seat")) + ): + return None + if not explicit_name and name_seat is None: + name = _creation_seat_label(seat_clause.group("seat")) + name + return name, "" if ( not instruction or len(instruction) > 800 @@ -516,6 +624,8 @@ def _candidate_matches( matches: list[tuple[re.Match[str], bool]] = [] for pattern, predicate_required in ( (_PREFIX_UPDATE, False), + (_APPEND_MODE_UPDATE, True), + (_APPEND_TO_MODE_UPDATE, False), (_BA_UPDATE, True), (_NAME_FIRST_UPDATE, True), ): @@ -534,6 +644,14 @@ def _candidate_matches( return matches +def _mode_profile_update_change(match: re.Match[str], name: str) -> str: + change = _strip_repeated_mode_update_subject(str(match.group("change") or ""), name) + append_verb = str(match.groupdict().get("append_verb") or "") + # Keep reverse-order additions on the same service contract as name-first + # additions. Device names, explicit targets and parameters remain unchanged. + return f"{append_verb}{change}" if append_verb else change + + def _strip_repeated_mode_update_subject(change: str, mode_name: str) -> str: """Remove a repeated ``把的`` wrapper from an update body.""" @@ -613,10 +731,7 @@ def is_mode_profile_update_shape( and _is_unquoted_device_property_mode(match, name) ): continue - change = _strip_repeated_mode_update_subject( - str(match.group("change") or ""), - name, - ) + change = _mode_profile_update_change(match, name) if change and ( not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change) ): @@ -634,6 +749,7 @@ def parse_mode_profile_update( text = compact_mode_profile_text(value) if ( not text + or _MODE_PROFILE_UPDATE_PREFIX_NEGATION.search(text) or _MODE_PROFILE_CREATE_REFERENCE_CLAUSE.search(text) or _MODE_PROFILE_CREATE_UNSAFE_PREFIX.search(text) ): @@ -650,10 +766,9 @@ def parse_mode_profile_update( and _is_unquoted_device_property_mode(match, name) ): continue - change = _strip_repeated_mode_update_subject( - str(match.group("change") or ""), - name, - ) + change = _mode_profile_update_change(match, name) + if re.fullmatch(rf"(?:再|还|也|同时|另外)?{_MODE_PROFILE_APPEND_VERB}(?:一下)?", change): + continue if ( (not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change)) and not _MODE_PROFILE_UPDATE_NEGATION.search(change) diff --git a/src/cognitive_agent/application/mode_scene_catalog.py b/src/cognitive_agent/application/mode_scene_catalog.py index ff0f626..03b07c9 100644 --- a/src/cognitive_agent/application/mode_scene_catalog.py +++ b/src/cognitive_agent/application/mode_scene_catalog.py @@ -296,6 +296,41 @@ def _scene_from_profile( if profile.sync_origin: scene["source_topic"] = profile.sync_origin scene["upstream_origin"] = profile.upstream_origin or None + if profile.schedule is not None: + schedule = profile.schedule + preview_actions = [] + elapsed_ms = 0 + for phase in schedule.phases: + for raw_operation in phase.operations: + action = _scene_action(ModeOperation.from_dict(raw_operation)) + action.update( + at_ms=elapsed_ms, + phase_id=phase.id, + phase_name=phase.name, + phase_duration_ms=phase.duration_ms, + ) + preview_actions.append(action) + elapsed_ms += phase.duration_ms + # A v1 client may ignore timestamps and immediately send every action. + # Keep its executable list empty rather than flattening a new timeline. + # Schedule-aware clients can render the complete contract and ask the + # authenticated application to execute the saved mode by its identity. + scene.update( + behavior="cyclic", + operation_count=schedule.operation_count, + domains=sorted({op["domain"] for phase in schedule.phases for op in phase.operations}), + actions=[], + schedule=schedule.to_dict(), + preview_actions=preview_actions, + preview_actions_scope="single_cycle", + execution={ + "kind": "server_schedule", + "mode_id": profile.id, + "client_actions_supported": False, + "requires_authentication": True, + }, + execution_notice="分阶段动态模式需通过工程服务端时间轴执行,不支持按静态动作列表下发。", + ) return scene diff --git a/src/cognitive_agent/application/mode_schedule.py b/src/cognitive_agent/application/mode_schedule.py new file mode 100644 index 0000000..93c4c16 --- /dev/null +++ b/src/cognitive_agent/application/mode_schedule.py @@ -0,0 +1,429 @@ +"""Bounded, cancellable timelines for validated absolute cockpit operations. + +This module validates the *schedule* contract, not vehicle authorization. Callers +must validate every phase against the current registry and execute it through the +ordinary compiler/safety path. It deliberately has no CAN or ModeProfile imports. +Durations are holds after one phase dispatch, never actuator motion durations. +Cancelling prevents future phases; it does not roll back an already applied state. +""" + +from __future__ import annotations + +import math +import re +import time +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass, field +from threading import Event, Lock, RLock, Thread, current_thread +from typing import Any +from uuid import uuid4 + +from cognitive_agent.application.assistant_plan_guard import SCENE_COMPOSABLE_CAPABILITIES + +MODE_SCHEDULE_SCHEMA = "cognitive-agent-mode-schedule/v1" +MAX_SCHEDULE_DURATION_MS = 60 * 60 * 1000 +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_FORBIDDEN_PARAMETERS = frozenset( + {"duration", "duration_ms", "seconds", "delta", "relative", "relative_pct", + "direction", "toggle", "repeat", "repeat_count", "axis"} +) +_FAILURE_STATUSES = frozenset( + {"failed", "error", "cancelled", "canceled", "rejected", "blocked", "aborted"} +) +_SUCCESS_STATUSES = frozenset({"succeeded", "success", "ok", "completed"}) + + +def _object(value: Any, fields: set[str], required: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise ValueError(f"{label} must be an object with string keys") + if unknown := set(value) - fields: + raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}") + if missing := required - set(value): + raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}") + return value + + +def _text(value: Any, label: str, maximum: int, *, allow_empty: bool = False) -> str: + if not isinstance(value, str) or len(value) > maximum: + raise ValueError(f"{label} must be a string of at most {maximum} characters") + if not allow_empty and not value.strip(): + raise ValueError(f"{label} must not be empty") + if "\x00" in value: + raise ValueError(f"{label} contains a null character") + return value.strip() + + +def _integer(value: Any, minimum: int, maximum: int, label: str) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise ValueError(f"{label} must be an integer between {minimum} and {maximum}") + return value + + +def _operation(value: Any) -> dict[str, Any]: + required = {"domain", "capability", "target_scope", "parameters"} + document = _object(value, required | {"operation", "reason"}, required, "schedule operation") + domain = _text(document["domain"], "operation domain", 64) + capability = _text(document["capability"], "operation capability", 128) + if capability not in SCENE_COMPOSABLE_CAPABILITIES or not capability.startswith(domain + "."): + raise ValueError("schedule capability must be a stable, scene-composable capability") + if document.get("operation", "set") != "set": + raise ValueError("schedule operations must use absolute set operations") + parameters = document["parameters"] + if not isinstance(parameters, dict) or not 1 <= len(parameters) <= 12: + raise ValueError("schedule operation parameters must contain 1 to 12 scalar fields") + for key, parameter in parameters.items(): + _text(key, "parameter name", 64) + if key.casefold() in _FORBIDDEN_PARAMETERS: + raise ValueError("schedule operations cannot contain relative, motion or timing parameters") + if parameter is None or type(parameter) not in (str, int, float, bool): + raise ValueError("schedule parameter values must be non-null JSON scalars") + if type(parameter) is float and not math.isfinite(parameter): + raise ValueError("schedule numeric parameters must be finite") + if isinstance(parameter, str): + _text(parameter, "parameter value", 256) + if parameter.casefold() in {"toggle", "increase", "decrease", "relative"}: + raise ValueError("schedule operations cannot request relative state changes") + return { + "domain": domain, + "capability": capability, + "target_scope": _text(document["target_scope"], "operation target_scope", 128), + "operation": "set", + "parameters": deepcopy(parameters), + "reason": _text(document.get("reason", ""), "operation reason", 1000, allow_empty=True), + } + + +def _operation_key(operation: dict[str, Any]) -> tuple[str, str]: + target = operation["target_scope"] + if operation["domain"] == "seat": + target = {"front_left": "driver", "front_right": "passenger"}.get(target, target) + return operation["capability"], target + + +@dataclass(frozen=True) +class ModeSchedulePhase: + id: str + name: str + duration_ms: int + operations: tuple[dict[str, Any], ...] + + def __post_init__(self) -> None: + phase_id = _text(self.id, "schedule phase id", 64) + if not _ID_PATTERN.fullmatch(phase_id): + raise ValueError("schedule phase id must contain lowercase letters, numbers, '_' or '-'") + object.__setattr__(self, "id", phase_id) + object.__setattr__(self, "name", _text(self.name, "schedule phase name", 128)) + _integer(self.duration_ms, 1000, 15 * 60 * 1000, "schedule phase duration_ms") + if not isinstance(self.operations, (list, tuple)) or not 1 <= len(self.operations) <= 12: + raise ValueError("schedule phase must contain 1 to 12 operations") + operations = tuple(_operation(operation) for operation in self.operations) + keys = [_operation_key(operation) for operation in operations] + if len(set(keys)) != len(keys): + raise ValueError("schedule phase contains duplicate capability/target operations") + object.__setattr__(self, "operations", operations) + + @classmethod + def from_dict(cls, document: dict[str, Any]) -> ModeSchedulePhase: + fields = {"id", "name", "duration_ms", "operations"} + value = _object(document, fields, fields, "schedule phase") + if not isinstance(value["operations"], list): + raise ValueError("schedule phase operations must be an array") # noqa: TRY004 + return cls(value["id"], value["name"], value["duration_ms"], tuple(value["operations"])) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "duration_ms": self.duration_ms, + "operations": deepcopy(list(self.operations)), + } + + +@dataclass(frozen=True) +class ModeSchedule: + phases: tuple[ModeSchedulePhase, ...] + repeat_count: int = 1 + schema: str = MODE_SCHEDULE_SCHEMA + + def __post_init__(self) -> None: + if self.schema != MODE_SCHEDULE_SCHEMA: + raise ValueError("unsupported mode schedule schema") + _integer(self.repeat_count, 1, 20, "schedule repeat_count") + if not isinstance(self.phases, (list, tuple)) or not 2 <= len(self.phases) <= 8: + raise ValueError("schedule must contain 2 to 8 phases") + if any(not isinstance(phase, ModeSchedulePhase) for phase in self.phases): + raise ValueError("schedule phases must be ModeSchedulePhase objects") + object.__setattr__(self, "phases", tuple(self.phases)) + if len({phase.id for phase in self.phases}) != len(self.phases): + raise ValueError("schedule phase ids must be unique") + if self.total_duration_ms > MAX_SCHEDULE_DURATION_MS: + raise ValueError("schedule total duration including repetitions must not exceed 60 minutes") + + @property + def total_duration_ms(self) -> int: + return sum(phase.duration_ms for phase in self.phases) * self.repeat_count + + @property + def operation_count(self) -> int: + """Number of operations in one full repetition.""" + return sum(len(phase.operations) for phase in self.phases) + + @classmethod + def from_dict(cls, document: dict[str, Any]) -> ModeSchedule: + fields = {"schema", "repeat_count", "phases"} + value = _object(document, fields, fields, "schedule") + if not isinstance(value["phases"], list): + raise ValueError("schedule phases must be an array") # noqa: TRY004 + return cls( + phases=tuple(ModeSchedulePhase.from_dict(phase) for phase in value["phases"]), + repeat_count=value["repeat_count"], + schema=value["schema"], + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema": self.schema, + "repeat_count": self.repeat_count, + "phases": [phase.to_dict() for phase in self.phases], + } + + +ExecutePhase = Callable[[ModeSchedulePhase], dict[str, Any]] +WaitFunction = Callable[[Event, float], bool] + + +def _result_error(result: Any) -> str | None: + if not isinstance(result, dict): + return "phase executor did not return an object with an explicit success result" + status = result.get("status") + if result.get("success") is False or result.get("error") or status in _FAILURE_STATUSES: + return str(result.get("error") or result.get("message") or "phase execution failed")[:1000] + if result.get("success") is True or status in _SUCCESS_STATUSES: + return None + return str(result.get("error") or "phase executor did not confirm successful execution")[:1000] + + +@dataclass +class _Run: + vehicle_id: str + session_id: str + schedule: ModeSchedule + started_at: float + run_id: str = field(default_factory=lambda: uuid4().hex) + cancelled: Event = field(default_factory=Event) + thread: Thread | None = None + status: str = "running" + phase_index: int = -1 + repeat_index: int = 0 + executed_phases: int = 0 + error: str | None = None + cancel_reason: str | None = None + finished_at: float | None = None + + +class ModeScheduleRunner: + """At most one active timeline and one phase callback per vehicle. + + A replacement cancels its predecessor immediately and waits for any already + running phase callback to return before dispatching. Callbacks must themselves + be bounded and cancellation never implies undoing a previously sent operation. + ``wait(event, seconds)`` may be injected for deterministic tests; like Event.wait + it returns true on cancellation. No callback runs while the state lock is held. + """ + + def __init__( + self, + *, + clock: Callable[[], float] = time.monotonic, + wait: WaitFunction | None = None, + ) -> None: + self._clock = clock + self._wait = wait or (lambda event, seconds: event.wait(seconds)) + self._lock = RLock() + self._runs: dict[str, _Run] = {} + self._dispatch_locks: dict[str, Lock] = {} + self._workers: dict[str, _Run] = {} + self._closed = False + + def _snapshot(self, run: _Run) -> dict[str, Any]: + end = run.finished_at if run.finished_at is not None else self._clock() + return { + "run_id": run.run_id, + "vehicle_id": run.vehicle_id, + "session_id": run.session_id, + "status": run.status, + "phase_index": run.phase_index, + "repeat_index": run.repeat_index, + "executed_phases": run.executed_phases, + "phase_count": len(run.schedule.phases), + "repeat_count": run.schedule.repeat_count, + "elapsed_ms": max(0, round((end - run.started_at) * 1000)), + "total_duration_ms": run.schedule.total_duration_ms, + "completed": run.status == "completed", + "error": run.error, + "cancel_reason": run.cancel_reason, + } + + def status(self, vehicle_id: str) -> dict[str, Any]: + with self._lock: + run = self._runs.get(vehicle_id) + if run is not None: + return self._snapshot(run) + return { + "vehicle_id": vehicle_id, "status": "idle", "phase_index": -1, + "repeat_index": 0, "completed": False, "error": None, + } + + def _cancel(self, run: _Run, reason: str) -> None: + if run.status == "running": + run.cancelled.set() + run.status = "cancelled" + run.cancel_reason = reason + run.finished_at = self._clock() + + def cancel(self, vehicle_id: str, *, session_id: str | None = None) -> dict[str, Any]: + """Cancel a vehicle timeline, optionally only if it belongs to this session.""" + with self._lock: + run = self._runs.get(vehicle_id) + if run is not None and (session_id is None or session_id == run.session_id): + self._cancel(run, "cancelled") + return self.status(vehicle_id) + + def start( + self, + vehicle_id: str, + schedule: ModeSchedule, + execute_phase: ExecutePhase, + *, + session_id: str = "", + ) -> dict[str, Any]: + vehicle_id = _text(vehicle_id, "vehicle_id", 128) + session_id = _text(session_id, "session_id", 256, allow_empty=True) + if not isinstance(schedule, ModeSchedule) or not callable(execute_phase): + raise ValueError("start requires a ModeSchedule and callable phase executor") # noqa: TRY004 + # Snapshot/revalidate to prevent caller-owned parameter dictionaries from + # altering the executable contract after the runner has accepted it. + accepted = ModeSchedule.from_dict(schedule.to_dict()) + with self._lock: + if self._closed: + raise RuntimeError("mode schedule runner is closed") + previous = self._runs.get(vehicle_id) + if previous is not None: + self._cancel(previous, "replaced") + dispatch_lock = self._dispatch_locks.setdefault(vehicle_id, Lock()) + run = _Run(vehicle_id, session_id, accepted, self._clock()) + self._runs[vehicle_id] = run + self._workers[run.run_id] = run + run.thread = Thread( + target=self._run, args=(run, dispatch_lock, execute_phase), + name=f"mode-schedule-{run.run_id[:8]}", daemon=True, + ) + try: + run.thread.start() + except RuntimeError: + run.status = "failed" + run.error = "schedule worker could not start" + run.finished_at = self._clock() + self._workers.pop(run.run_id, None) + raise + return self._snapshot(run) + + def _run(self, run: _Run, dispatch_lock: Lock, execute_phase: ExecutePhase) -> None: + try: + for repeat_index in range(run.schedule.repeat_count): + for phase_index, phase in enumerate(run.schedule.phases): + # A cancelled replacement must not wait indefinitely behind + # an unrelated in-flight callback just to observe cancellation. + while not dispatch_lock.acquire(timeout=0.05): + if run.cancelled.is_set(): + return + try: + with self._lock: + if run.cancelled.is_set(): + return + run.repeat_index = repeat_index + run.phase_index = phase_index + # Isolate executor mutations from subsequent repetitions. + result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict())) + finally: + dispatch_lock.release() + with self._lock: + if run.cancelled.is_set(): + return + if error := _result_error(result): + run.status = "failed" + run.error = error + run.finished_at = self._clock() + return + run.executed_phases += 1 + if self._wait(run.cancelled, phase.duration_ms / 1000): + with self._lock: + self._cancel(run, "cancelled") + return + if run.cancelled.is_set(): + return + with self._lock: + if not run.cancelled.is_set(): + run.status = "completed" + run.finished_at = self._clock() + except Exception as exc: # noqa: BLE001 - any executor failure must stop later phases + with self._lock: + if not run.cancelled.is_set(): + run.status = "failed" + run.error = f"{type(exc).__name__}: {exc}"[:1000] + run.finished_at = self._clock() + finally: + with self._lock: + self._workers.pop(run.run_id, None) + + @staticmethod + def simulate(schedule: ModeSchedule, execute_phase: ExecutePhase) -> dict[str, Any]: + """Validate/execute phases synchronously with virtual holds and no thread. + + The supplied callback must select the ordinary *dry-run* execution path; + this method never changes an executor's hardware policy implicitly. + """ + if not isinstance(schedule, ModeSchedule) or not callable(execute_phase): + raise ValueError("simulate requires a ModeSchedule and callable phase executor") # noqa: TRY004 + schedule = ModeSchedule.from_dict(schedule.to_dict()) + report: dict[str, Any] = { + "status": "completed", "completed": True, "error": None, + "execution_mode": "dry_run", "virtual_elapsed_ms": 0, + "total_duration_ms": schedule.total_duration_ms, + "phase_index": -1, "repeat_index": 0, "executed_phases": 0, + "phase_results": [], + } + for repeat_index in range(schedule.repeat_count): + for phase_index, phase in enumerate(schedule.phases): + report.update(phase_index=phase_index, repeat_index=repeat_index) + try: + result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict())) + error = _result_error(result) + except Exception as exc: # noqa: BLE001 - simulation uses the same fail-closed policy + result = None + error = f"{type(exc).__name__}: {exc}"[:1000] + report["phase_results"].append({ + "phase_id": phase.id, "phase_index": phase_index, + "repeat_index": repeat_index, "duration_ms": phase.duration_ms, + "virtual_start_ms": report["virtual_elapsed_ms"], "result": result, + }) + if error: + report.update(status="failed", completed=False, error=error) + return report + report["executed_phases"] += 1 + report["virtual_elapsed_ms"] += phase.duration_ms + return report + + def close(self, *, timeout: float = 2.0) -> bool: + """Cancel all waits; return whether bounded, already-running callbacks exited.""" + with self._lock: + self._closed = True + workers = list(self._workers.values()) + for run in workers: + self._cancel(run, "closed") + deadline = time.monotonic() + max(0, timeout) + for run in workers: + if run.thread is not None and run.thread is not current_thread(): + run.thread.join(max(0, deadline - time.monotonic())) + return all(run.thread is None or not run.thread.is_alive() for run in workers) diff --git a/src/cognitive_agent/application/mode_schedule_edit.py b/src/cognitive_agent/application/mode_schedule_edit.py new file mode 100644 index 0000000..4b65ae8 --- /dev/null +++ b/src/cognitive_agent/application/mode_schedule_edit.py @@ -0,0 +1,314 @@ +"""Phase-aware natural-language edits, always revalidated before persistence.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from copy import deepcopy +from typing import Any + +from cognitive_agent.application.mode_schedule import ModeSchedule + +_NUMBER = r"[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+" +_PHASE = re.compile(r"第(?P[0-9]+|[一二两三四五六七八九十]+)(?:个)?(?:阶段|步)") +_ALL_PHASES = re.compile(r"(?:全部|所有|每个|每一|各个|各)(?:的)?阶段") +_HOLD = re.compile( + rf"(?:持续时间|保持时间|持续|保持|时长|^(?:把|将|的)?(?=调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为))\s*" + rf"(?:调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为)?\s*" + rf"(?P{_NUMBER})\s*(?P分钟|秒钟|秒|minutes?|mins?|min|seconds?|secs?|s)" +) +_REPEAT = re.compile( + rf"(?:重复|循环)(?:次数)?\s*(?:调整为|调整到|设置为|设为|改为|改成)?\s*" + rf"(?P{_NUMBER})\s*次" +) + + +def _number(value: str) -> float: + if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value): + return float(value) + digits = {char: index for index, char in enumerate("零一二三四五六七八九")} + if value == "两": + return 2 + if "十" in value: + tens, ones = value.split("十", 1) + if (tens and tens not in digits) or (ones and ones not in digits): + raise ValueError("请使用有效的整数编号或次数。") + return (digits[tens] if tens else 1) * 10 + (digits[ones] if ones else 0) + if value in digits: + return digits[value] + raise ValueError("请使用明确的阶段编号、持续时间或重复次数。") + + +def edit_schedule( + schedule: ModeSchedule, + change_text: str, + *, + parse_operations: Callable[[str], list[dict[str, Any]]], + target_is_explicit: Callable[[str, str], bool], +) -> ModeSchedule: + """Edit one/all phases; an unscoped differing trajectory needs clarification.""" + text = change_text.strip() + if not text: + raise ValueError("请说明要修改的阶段、动作、时长或重复次数。") + if re.search(r"(?:秒钟|分钟|秒|s)(?:之后|以后|后|再)", text): + raise ValueError( + "阶段时间表示动作执行后的保持时长,请明确阶段动作和持续时间,不使用延时后执行的表述。" + ) + source = schedule.to_dict() + selected: set[int] | None = None + phase_matches = list(_PHASE.finditer(text)) + all_match = _ALL_PHASES.search(text) + if all_match: + if phase_matches: + raise ValueError("请分别修改单个阶段或全部阶段,不要混用阶段范围。") + selected = set(range(len(schedule.phases))) + text = _ALL_PHASES.sub("", text) + elif phase_matches: + indexes = {int(_number(match.group("number"))) - 1 for match in phase_matches} + if len(indexes) != 1 or any( + index < 0 or index >= len(schedule.phases) for index in indexes + ): + raise ValueError(f"当前只有{len(schedule.phases)}个阶段,请明确一次修改哪一个阶段。") + selected = indexes + text = _PHASE.sub("", text) + else: + named = {index for index, phase in enumerate(schedule.phases) if phase.name in text} + if len(named) > 1: + raise ValueError("请一次修改一个阶段,或明确指定全部阶段。") + if named: + selected = named + phase_name = schedule.phases[next(iter(named))].name + text = text.replace(phase_name + "阶段", "").replace(phase_name, "") + + repeats = list(_REPEAT.finditer(text)) + if repeats: + if len(repeats) != 1: + raise ValueError("请提供一个明确的整条时间轴重复次数。") + count = _number(repeats[0].group("number")) + if count != int(count): + raise ValueError("重复次数必须为整数。") + source["repeat_count"] = int(count) + text = _REPEAT.sub("", text) + + holds = list(_HOLD.finditer(text)) + if holds: + if len(holds) != 1 or selected is None: + raise ValueError("修改持续时间需要明确第几个阶段或全部阶段。") + seconds = _number(holds[0].group("number")) + if holds[0].group("unit") in {"分钟", "minute", "minutes", "min", "mins"}: + seconds *= 60 + milliseconds = seconds * 1000 + if milliseconds != int(milliseconds): + raise ValueError("阶段持续时间最多精确到毫秒。") + for index in selected: + source["phases"][index]["duration_ms"] = int(milliseconds) + text = _HOLD.sub("", text) + + if re.search(rf"(?:{_NUMBER})\s*(?:分钟|秒钟|秒|minutes?|mins?|seconds?|secs?|s)", text): + raise ValueError("动态阶段只支持明确绝对设备目标;时长请单独表述为阶段保持时间,不支持按秒运动。") + + text = re.sub(r"改为|改成|变更为|换成|变成|变为", "调整为", text) + text = re.sub( + r"^(?:把|将|的|中|里|阶段)+|(?:然后|并且|同时|并|,|,|;|;|。|\s)+$", "", text + ).strip("的中里,,;;。 ") + changes = parse_operations(text) if text else [] + if not changes and not holds and not repeats: + raise ValueError("没有识别到可执行的阶段调整,请说明设备目标、阶段时长或重复次数。") + for raw_change in changes: + change = deepcopy(raw_change) + indexes = selected if selected is not None else set(range(len(schedule.phases))) + candidates = [ + (index, operation) + for index in indexes + for operation in source["phases"][index]["operations"] + if operation["domain"] == change["domain"] + and operation["capability"] == change["capability"] + ] + if not target_is_explicit(text, change["target_scope"]): + targets = {operation["target_scope"] for _, operation in candidates} + if len(targets) > 1: + raise ValueError("该阶段的设备有多个区域,请明确主驾、副驾或具体目标区域。") + if targets: + change["target_scope"] = next(iter(targets)) + candidates = [ + (index, operation) + for index, operation in candidates + if operation["target_scope"] == change["target_scope"] + ] + if selected is None: + values = { + json.dumps(operation["parameters"], sort_keys=True, ensure_ascii=False) + for _, operation in candidates + } + if len(values) > 1: + raise ValueError("该设备在多个阶段使用不同目标,请明确第几个阶段,或说“全部阶段”。") + if not candidates: + raise ValueError("新增设备动作需要明确放入第几个阶段,或明确全部阶段。") + indexes = {index for index, _ in candidates} + key = (change["capability"], change["target_scope"]) + for index in indexes: + operations = source["phases"][index]["operations"] + found = False + for position, operation in enumerate(operations): + if (operation["capability"], operation["target_scope"]) == key: + operations[position] = { + **deepcopy(operation), + "parameters": deepcopy(change["parameters"]), + } + found = True + if not found: + operations.append(deepcopy(change)) + return ModeSchedule.from_dict(source) + + +def revise_schedule( + service, + schedule: ModeSchedule, + *, + change_text: str, + utterance: str, + name: str, + conversation_id: str, + vehicle_profile, + context: dict[str, Any], + control_transport: str, + source_draft=None, + source_profile=None, +) -> dict[str, Any]: + """Parse changed atoms locally, then recompile every phase before success.""" + + def parse_changes(text): + if any( + status != "complete" + for _clause, _parsed, status in service._local_clause_results(text, context) + ): + raise ValueError("阶段调整说明不完整,请使用设备和明确目标值;未修改原时间轴。") + result = service._process_once( + text, + "local", + conversation_id, + "dry_run", + None, + vehicle_profile.id, + control_transport=control_transport, + allow_fuzzy_mode_profile_match=False, + ) + if ( + result.get("status") != "succeeded" + or result.get("deferred_requests") + or result.get("unresolved_clauses") + or result.get("supplement_required") + or any(issue.get("severity") == "error" for issue in result.get("validation", [])) + ): + raise ValueError("阶段动作未通过完整的设备能力或安全校验。") + operations = service._absolute_mode_operations(service._preview_mode_operations(result)) + if not operations: + raise ValueError("未识别到可复用的绝对设备目标。") + return operations + + revised = edit_schedule( + schedule, + change_text, + parse_operations=parse_changes, + target_is_explicit=service._mode_update_target_is_explicit, + ) + preview = service._preview_mode_schedule( + revised, + name=name, + utterance=utterance, + conversation_id=conversation_id, + vehicle_profile=vehicle_profile, + control_transport=control_transport, + generation_source="draft_revision" if source_draft else "registered_mode_profile", + source_draft=source_draft, + source_profile=source_profile, + ) + if preview.get("status") == "succeeded" and source_draft: + draft = preview["mode_profile_draft"] + draft["revises_source_request_id"] = source_draft["source_request_id"] + for key in ("source_template_id", "source_template_revision"): + if key in source_draft: + draft[key] = source_draft[key] + service._persist(preview) + return preview + + +def update_saved_schedule( + service, + profile, + *, + change_text: str, + utterance: str, + conversation_id: str, + vehicle_profile, + context: dict[str, Any], + result: dict[str, Any], +) -> dict[str, Any]: + try: + preview = revise_schedule( + service, + profile.schedule, + change_text=change_text, + utterance=utterance, + name=profile.name, + conversation_id=conversation_id, + vehicle_profile=vehicle_profile, + context=context, + control_transport="wired_can", + source_profile=profile, + ) + if preview.get("status") != "succeeded": + return preview + draft = preview["mode_profile_draft"] + saved = service.save_mode_profile_card( + profile.id, + draft["name"], + draft["instruction"], + draft["source_request_id"], + vehicle_profile.id, + ) + updated = service.mode_profiles.get_profile(profile.id, vehicle_profile.id) + report = service._mode_profile_control_report( + result["request_id"], + "update_mode_profile", + updated, + f"已更新“{profile.name}”的阶段时间轴;未向车辆下发控制。", + {"can_command_emitted": False, "phase_count": len(updated.schedule.phases)}, + ) + result.update( + status="succeeded", + execution_mode="dry_run", + hardware_execution_allowed=False, + orchestration_route="mode_profile_update", + provider_used="mode_schedule_editor", + mode_profile=saved["mode_profile"], + matched_modes=[saved["mode_profile"]], + schedule=updated.schedule.to_dict(), + execution_report=report, + validation=service.contracts.validate(report), + mode_profile_changes=[ + deepcopy(op) for phase in updated.schedule.phases for op in phase.operations + ], + decision_summary=[ + f"已更新“{profile.name}”的阶段时间轴,并完整重新校验全部阶段;未下发车辆控制。" + ], + ) + except ValueError as exc: + result.update( + status="needs_confirmation", + execution_mode="dry_run", + hardware_execution_allowed=False, + orchestration_route="mode_profile_update", + validation=[ + { + "severity": "error", + "code": "MODE_SCHEDULE_UPDATE_REJECTED", + "path": "schedule", + "message": str(exc), + } + ], + decision_summary=[f"未修改“{profile.name}”的时间轴:{exc}"], + ) + service._persist(result) + return result diff --git a/src/cognitive_agent/application/mode_schedule_service.py b/src/cognitive_agent/application/mode_schedule_service.py new file mode 100644 index 0000000..0f98cd5 --- /dev/null +++ b/src/cognitive_agent/application/mode_schedule_service.py @@ -0,0 +1,238 @@ +"""Service integration for server-owned, fully validated phase schedules.""" +from __future__ import annotations + +import uuid +from copy import deepcopy +from datetime import UTC, datetime +from typing import Any + +from cognitive_agent.application.mode_schedule import ModeSchedule + + +def public_schedule_status(state: dict[str, Any]) -> dict[str, Any]: + """Expose lifecycle evidence, never internal errors, commands or CAN frames.""" + keys = ("run_id", "vehicle_id", "session_id", "status", "phase_index", "repeat_index", + "executed_phases", "phase_count", "repeat_count", "elapsed_ms", "total_duration_ms", + "virtual_elapsed_ms", "completed", "cancel_reason") + public = {key: value for key in keys if isinstance((value := state.get(key)), (str, int, bool))} + if state.get("error"): + public["error"] = "阶段执行未通过校验或未返回成功结果,后续阶段已停止。" + return public + + +def phase_plan(phase) -> dict[str, Any]: + operations = phase.operations + return { + "detected_domains": list(dict.fromkeys(op["domain"] for op in operations)), + "requests": [{**{key: op[key] for key in ("domain", "capability", "target_scope")}, + "operation": "set", "duration_ms": None, + "parameters": [{"name": key, "value": value} for key, value in op["parameters"].items()], + "reason": op.get("reason") or "已校验时间轴阶段。"} for op in operations], + "ambiguities": [], "confidence": 1.0, + "decision_summary": ["按阶段保留动作顺序与持续时间。"], + } + + +class ModeScheduleServiceMixin: + def _validated_schedule_artifact(self, result: dict[str, Any]) -> ModeSchedule | None: + draft = result.get("mode_profile_draft") or {} + raw = result.get("schedule") + if raw is None and draft.get("schedule") is None: + return None + schedule = ModeSchedule.from_dict(raw) + if draft.get("schedule") != schedule.to_dict() or draft.get("operation_count") != schedule.operation_count: + raise ValueError("时间轴草稿结构和服务端证据不一致,请重新生成。") + actions = (result.get("intent_ir") or {}).get("spec", {}).get("actions", []) + if len(actions) != schedule.operation_count: + raise ValueError("时间轴草稿动作证据不完整,请重新生成。") + offset = 0 + for phase in schedule.phases: + phase_actions = actions[offset:offset + len(phase.operations)] + plan = phase_plan(phase) + if (self._scene_plan_signatures(plan) != self._scene_action_signatures(phase_actions) + or not self._scene_parameters_preserved(plan, phase_actions)): + raise ValueError("时间轴阶段目标与校验结果不一致,请重新生成。") + offset += len(phase.operations) + return schedule + + def _preview_mode_schedule( + self, schedule: ModeSchedule, *, name: str, utterance: str, + conversation_id: str, vehicle_profile, control_transport: str, + generation_source: str, source_profile=None, source_template=None, + source_draft: dict[str, Any] | None = None, + scene_scope_utterance: str | None = None, + ) -> dict[str, Any]: + results = [] + for phase in schedule.phases: + plan = phase_plan(phase) + preview = self._process_once( + f"进入{name}", "local", conversation_id, "dry_run", None, + vehicle_profile.id, control_transport=control_transport, + assistant_plan=plan, allow_scene_composition=True, + allow_fuzzy_mode_profile_match=False, + # An existing-name binding is not evidence that a newly + # supplied model plan came from the saved profile. New builds + # pass the original request even when reusing a saved name. + scene_scope_utterance=(scene_scope_utterance if scene_scope_utterance is not None + else "" if source_draft or source_profile else utterance), + ) + actions = (preview.get("intent_ir") or {}).get("spec", {}).get("actions", []) + complete = (preview.get("status") == "succeeded" + and self._scene_plan_signatures(plan) == self._scene_action_signatures(actions) + and self._scene_parameters_preserved(plan, actions)) + if not complete: + preview["status"] = "validation_failed" + self._scrub_failed_scene_preview(preview) + preview["mode_creation_failure"] = { + "code": "MODE_SCHEDULE_PHASE_INVALID", + "reason": f"时间轴阶段“{phase.name}”未完整通过设备能力和安全校验", + "next_step": "请调整该阶段的设备目标;没有保存或执行任何阶段", + } + preview["validation"] = [*preview.get("validation", []), { + "severity": "error", "code": "MODE_SCHEDULE_PHASE_INVALID", + "path": f"schedule.phases.{phase.id}", + "message": preview["mode_creation_failure"]["reason"], + }] + self._persist(preview) + return preview + results.append(preview) + result = deepcopy(results[0]) + request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}" + instruction = f"进入{name}" + result.update(request_id=request_id, utterance=instruction, status="succeeded", + execution_mode="dry_run", hardware_execution_allowed=False, + orchestration_route="mode_profile_create_preview") + actions = [] + for phase, preview in zip(schedule.phases, results, strict=True): + for original in preview["intent_ir"]["spec"]["actions"]: + action = deepcopy(original) + if "id" in action: + action["id"] = f"{phase.id}-{action['id']}" + actions.append(action) + result["intent_ir"]["spec"]["actions"] = actions + for field in ("function_configs", "command_plans"): + result[field] = {f"{phase.id}-{key}": deepcopy(value) + for phase, preview in zip(schedule.phases, results, strict=True) + for key, value in preview[field].items()} + result["function_config"] = None + result["command_plan"] = None + result["schedule"] = schedule.to_dict() + result["schedule_phase_previews"] = [ + {"phase_id": phase.id, "source_request_id": preview["request_id"], + "status": preview["status"], "duration_ms": phase.duration_ms} + for phase, preview in zip(schedule.phases, results, strict=True) + ] + binding = ({"existing_mode_id": source_profile.id, + "existing_mode_revision": self.mode_profiles.profile_revision(source_profile)} + if source_profile is not None else {}) + if source_draft: + binding = {key: source_draft[key] for key in ("existing_mode_id", "existing_mode_revision") if key in source_draft} + result["mode_profile_draft"] = { + "name": name, "instruction": instruction, "source_request_id": request_id, + "vehicle_id": vehicle_profile.id, "operation_count": schedule.operation_count, + "domains": sorted({op["domain"] for phase in schedule.phases for op in phase.operations}), + "generation_source": generation_source, "schedule": schedule.to_dict(), + "draft_revision": int((source_draft or {}).get("draft_revision", 0)) + 1, + **binding, + **({"source_template_id": source_template.id, "source_template_revision": source_template.revision} + if source_template is not None else {}), + } + result["mode_creation"] = {"requested_utterance": utterance, "source_utterance": instruction, + "source_status": "succeeded", "operation_count": schedule.operation_count} + result["decision_summary"] = [ + f"已逐阶段验证“{name}”:{len(schedule.phases)}个阶段,重复{schedule.repeat_count}次,总计{schedule.total_duration_ms // 1000}秒。", + "当前仅生成草稿;保存后进入模式才开始计时执行。停止会取消后续阶段,已应用的设备状态保持。", + ] + self._persist(result) + return result + + def _run_mode_schedule( + self, profile, *, utterance: str, conversation_id: str | None, + execution_mode: str, approval_token: str | None, vehicle_id: str, + simulation_fault: str, hardware_client_id: str | None, + hardware_access_bypass: bool, control_transport: str, + ) -> dict[str, Any]: + schedule = ModeSchedule.from_dict(profile.schedule.to_dict()) + requested, supported = self._explicit_mode_seat_scopes(profile, utterance) + if requested and not requested.issubset(supported): + raise ValueError("该动态模式不支持指定座位,不会替换或扩大控制目标。") + if requested and requested != supported: + document = schedule.to_dict() + for phase in document["phases"]: + phase["operations"] = [op for op in phase["operations"] + if op["domain"] != "seat" or op["target_scope"] in requested] + # An empty phase is not silently removed: that would alter timing. + schedule = ModeSchedule.from_dict(document) + conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}" + vehicle = self.vehicle_profiles.get(vehicle_id) + # Revalidate the entire saved schedule before the first write. No stale + # cached command bytes or client-authored phase plans are replayed. + preview = self._preview_mode_schedule( + schedule, name=profile.name, utterance="已保存时间轴复核", + conversation_id=conversation_id, vehicle_profile=vehicle, + control_transport=control_transport, generation_source="registered_mode_profile", + source_profile=profile, + ) + if preview["status"] != "succeeded": + return preview + + def execute_phase(phase): + context = self._context_for_vehicle(self.conversations.snapshot(conversation_id), vehicle) + parsed, _, _ = self._interpret( + "执行已验证的时间轴阶段", "local", context, vehicle, + assistant_plan=phase_plan(phase), allow_scene_composition=True, + scene_scope_utterance="", + ) + return self.process( + "执行已验证的时间轴阶段", provider="local", conversation_id=conversation_id, + execution_mode=execution_mode, approval_token=approval_token, + vehicle_id=vehicle_id, simulation_fault=simulation_fault, + hardware_client_id=hardware_client_id, hardware_access_bypass=hardware_access_bypass, + control_transport=control_transport, _direct_can_batch=([], parsed), + ) + + if execution_mode == "hardware": + # Authentication/bench ownership is checked now and again for each + # phase, so a later lease loss stops further device commands. + if not hardware_access_bypass and control_transport != "wireless_mqtt" and not self._driver_only_vehicle_execution_active(control_transport): + self.hardware_access.ensure_authorized(hardware_client_id) + execution = self.mode_schedule_runner.start(vehicle_id, schedule, execute_phase, session_id=conversation_id) + else: + execution = self.mode_schedule_runner.simulate(schedule, execute_phase) + result = deepcopy(preview) + result.pop("mode_profile_draft", None) + result.update( + status="running" if execution["status"] == "running" else "succeeded" if execution.get("completed") else "failed", + utterance=utterance, execution_mode=execution_mode, + hardware_execution_allowed=execution_mode == "hardware", + orchestration_route="mode_schedule_execute", schedule_execution=execution, + mode_profile=profile.public_dict(), matched_modes=[profile.public_dict()], + ) + result["decision_summary"] = [ + f"已启动“{profile.name}”时间轴。" if execution["status"] == "running" + else f"“{profile.name}”已按虚拟时钟验证全部阶段。" if execution.get("completed") + else "时间轴执行失败,后续阶段已停止。", + "停止动态模式可取消后续阶段;已下发的设备目标不会自动回滚。", + ] + self._persist(result) + return result + + def _cancel_mode_schedule(self, vehicle_id: str, conversation_id: str | None, utterance: str, + execution_mode: str) -> dict[str, Any]: + state = (self.mode_schedule_runner.cancel(vehicle_id) if execution_mode == "hardware" + else {"status": "simulated", "completed": True, "vehicle_id": vehicle_id}) + result = { + "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}", + "status": "succeeded", "execution_mode": execution_mode, "hardware_execution_allowed": False, + "vehicle": {"id": vehicle_id}, + "utterance": utterance, "conversation_id": conversation_id or f"conv-{uuid.uuid4().hex}", + "orchestration_route": "mode_schedule_stop", "schedule_execution": state, + "decision_summary": [ + "已模拟停止动态模式,未改变正在运行的车辆任务。" if execution_mode != "hardware" + else "已停止动态模式后续阶段。已下发目标保持,不自动回滚。" if state.get("status") == "cancelled" + else "当前没有正在执行的动态时间轴。" + ], + "validation": [], "trace_events": [], + } + self._persist(result) + return result diff --git a/src/cognitive_agent/application/scene_composer.py b/src/cognitive_agent/application/scene_composer.py index efb2188..7685401 100644 --- a/src/cognitive_agent/application/scene_composer.py +++ b/src/cognitive_agent/application/scene_composer.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import math from collections.abc import Mapping, Sequence +from copy import deepcopy from itertools import combinations from typing import Any @@ -13,8 +14,13 @@ from cognitive_agent.application.assistant_plan_guard import ( MAX_PLAN_PARAMETERS, MAX_PLAN_REQUESTS, ) +from cognitive_agent.application.mode_creation_seat_policy import ( + creation_seat_scopes, + normalized_seat_scope, +) +from cognitive_agent.application.mode_schedule import ModeSchedule -SCENE_PLANNER_VERSION = "n7web-scene-composer/v2" +SCENE_PLANNER_VERSION = "n7web-scene-composer/v5" _MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024 _JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"}) @@ -46,14 +52,81 @@ For a commonly understood scene, infer conservative cabin settings. If the name meaningless, unsafe, contradictory, or cannot be grounded in the supplied catalog, return no requests and a short ambiguity. The application will independently check the complete live registry, parameters, compiler, safety policy, and dry-run result. + +The primary user need must be represented in the actions. For sitting comfort, +seating relaxation or rest, consider supported seat functions before auxiliary +climate and lights; climate and lights alone do not fulfil a seating request. +Mode creation defaults to the passenger seat only. Include driver seat actions +only when the user explicitly selects the driver; in that case omit passenger +seat actions. Include both seats only when explicitly requested. Respect seat +exclusions. Seat selection does not exclude unrelated cabin devices or confuse +a driver/passenger HVAC zone with a request to move that seat. Never reinterpret +rear or unknown seats as either front seat. The seat_scope_policy in the context +is authoritative. Use each selected seat's own listed capabilities; never +retarget a passenger capability to the driver or vice versa. +Driver heating/ventilation native levels use 2 for low and 1 for high; passenger +levels use 1 for low. Driver massage supports action=mode with one listed mode, +action=strength with strength, or action=off. Do not mix these action parameters. +Driver absolute axis positions, unlearned memory slots and passenger massage must +not be invented. Do not use timed axis movement as a substitute for an absolute +posture; leave unsupported axes unchanged. If the primary requested seat function +is unavailable, return an ambiguity rather than an unrelated cabin-only scene. Return only the JSON object required by the output schema. """ +SCHEDULE_COMPOSER_INSTRUCTIONS = SCENE_COMPOSER_INSTRUCTIONS + """ +This request requires a bounded temporal mode, not one static final state. +The static root shape above is replaced by the phase schema below. Capability +uniqueness and the 12-action limit apply within each phase, not across phases. +The ban on timed actions means motor-duration commands, not bounded phase holds. +Return an empty root requests array and a schedule containing 2-8 ordered phases. +Each phase has id, name, duration_ms and requests using the same capability contract. +Hold a phase for its duration, then execute the next phase; repeat_count is 1-20. +Every phase lasts 1 second to 15 minutes; total duration including repeats <= 1 hour. +Use a finite, conservative timeline. Boarding warmup should first warm the cabin +then reduce temperature/fan for comfort. Seat dynamic modes must apply the same +selected seat scope in every phase: default passenger absolute targets, or driver +comfort states if the user selected the driver; never invent driver axes or add +the other seat merely to make the timeline dynamic. +Respect explicit user temperatures, times, repetitions, seat scopes and exclusions. +Do not collapse successive values of the same device into a single final value. +No unattended calendar scheduling or wakeup is supported. Stop cancels future phases; +already applied states remain. If the requested timing is unsupported, give ambiguity. +""" + class SceneCompositionError(ValueError): """Raised when a model scene candidate cannot enter the local safety chain.""" +def scene_context_for_mode_creation( + context: Mapping[str, object], message: str, +) -> dict[str, object]: + """Constrain model seat choices without hiding unrelated cabin capabilities.""" + + projected = deepcopy(dict(context)) + scopes = creation_seat_scopes(message) + capabilities = [] + for capability in projected.get("capabilities", []): + if not isinstance(capability, dict): + continue + if capability.get("domain") != "seat": + capabilities.append(capability) + continue + selected_targets = [target for target in capability.get("target_scopes", []) + if normalized_seat_scope(target) in scopes] + if selected_targets: + capability["target_scopes"] = selected_targets + capabilities.append(capability) + projected["capabilities"] = capabilities + projected["seat_scope_policy"] = { + "selected_scopes": sorted(scopes), + "default_scope": "passenger", + "applies_to": "seat capabilities in every phase", + } + return projected + + def _is_relative_parameter(parameter: Mapping[str, object]) -> bool: """Return whether a parameter describes a transient or relative movement.""" @@ -179,6 +252,29 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj } for names in parameter_combinations ] + if capability_id == "seat.set_driver_massage_native": + # The native command discriminates mode, strength and off. Its YAML + # scalar contracts cannot express that dependency on their own. + parameter_object_branches = [] + action_schema = value_schemas.get("action", {}) + for action, dependent_name in (("off", None), ("mode", "mode"), ("strength", "strength")): + if action not in action_schema.get("enum", []): + continue + if dependent_name is not None and dependent_name not in value_schemas: + continue + properties = {"action": {"type": "string", "enum": [action]}} + if dependent_name is not None: + properties[dependent_name] = value_schemas[dependent_name] + parameter_object_branches.append( + { + "type": "object", + "additionalProperties": False, + "required": list(properties), + "properties": properties, + } + ) + if not parameter_object_branches: + return None return { "type": "object", "additionalProperties": False, @@ -193,7 +289,7 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj } -def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object]: +def build_scene_output_schema(context: Mapping[str, object], *, dynamic: bool = False) -> dict[str, object]: """Build a compact strict schema from the projected scene capability data.""" raw_capabilities = context.get("capabilities", []) @@ -231,6 +327,27 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object "confidence": {"type": "number", "minimum": 0, "maximum": 1}, }, } + if dynamic: + phase_requests = schema["properties"]["requests"] + schema["properties"]["requests"] = {"type": "array", "maxItems": 0, "items": {"type": "string"}} + schema["required"].append("schedule") + schema["properties"]["schedule"] = { + "anyOf": [{"type": "null"}, { + "type": "object", "additionalProperties": False, + "required": ["repeat_count", "phases"], + "properties": { + "repeat_count": {"type": "integer", "minimum": 1, "maximum": 20}, + "phases": {"type": "array", "minItems": 2, "maxItems": 8, + "items": {"type": "object", "additionalProperties": False, + "required": ["id", "name", "duration_ms", "requests"], + "properties": { + "id": {"type": "string"}, "name": {"type": "string"}, + "duration_ms": {"type": "integer", "minimum": 1000, "maximum": 900000}, + "requests": phase_requests, + }}}, + }, + }], + } encoded_schema = json.dumps( schema, ensure_ascii=False, @@ -244,9 +361,41 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object def normalize_scene_output( value: Mapping[str, Any], context: Mapping[str, object], + *, dynamic: bool = False, ) -> dict[str, Any]: """Expand the lean model response into the existing guarded plan contract.""" + if dynamic: + if not isinstance(value, Mapping) or set(value) != {"requests", "ambiguities", "confidence", "schedule"}: + raise SceneCompositionError("dynamic scene response must include a schedule") + if value["requests"]: + raise SceneCompositionError("dynamic scene must not mix static root requests") + base = normalize_scene_output({key: value[key] for key in ("requests", "ambiguities", "confidence")}, context) + raw_schedule = value["schedule"] + if raw_schedule is None: + return base + if not isinstance(raw_schedule, Mapping) or set(raw_schedule) != {"repeat_count", "phases"}: + raise SceneCompositionError("invalid dynamic scene schedule") + phases = [] + if not isinstance(raw_schedule["phases"], list): + raise SceneCompositionError("dynamic phases must be an array") + for phase in raw_schedule["phases"]: + if not isinstance(phase, Mapping) or set(phase) != {"id", "name", "duration_ms", "requests"}: + raise SceneCompositionError("invalid dynamic phase") + normalized = normalize_scene_output({"requests": phase["requests"], "ambiguities": [], "confidence": value["confidence"]}, context) + operations = [{"domain": op["domain"], "capability": op["capability"], + "target_scope": op["target_scope"], "operation": "set", + "parameters": {p["name"]: p["value"] for p in op["parameters"]}, + "reason": "经过时间轴编排的候选状态。"} for op in normalized["requests"]] + phases.append({"id": phase["id"], "name": phase["name"], "duration_ms": phase["duration_ms"], "operations": operations}) + try: + schedule = ModeSchedule.from_dict({"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": raw_schedule["repeat_count"], "phases": phases}) + except (TypeError, ValueError) as exc: + raise SceneCompositionError(str(exc)) from exc + first = normalize_scene_output({"requests": raw_schedule["phases"][0]["requests"], "ambiguities": value["ambiguities"], "confidence": value["confidence"]}, context) + first["schedule"] = schedule.to_dict() + return first + if not isinstance(value, Mapping) or set(value) != { "requests", "ambiguities", diff --git a/src/cognitive_agent/application/scene_templates.py b/src/cognitive_agent/application/scene_templates.py index 36bae32..d5c0e8b 100644 --- a/src/cognitive_agent/application/scene_templates.py +++ b/src/cognitive_agent/application/scene_templates.py @@ -24,11 +24,13 @@ from cognitive_agent.application.assistant_plan_guard import ( SCENE_COMPOSABLE_CAPABILITIES, ) from cognitive_agent.application.mode_profile import ModeOperation +from cognitive_agent.application.mode_profile_intent import mode_creation_source_name +from cognitive_agent.application.mode_schedule import ModeSchedule _TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()" _DOCUMENT_FIELDS = frozenset( - {"id", "name", "aliases", "description", "icon", "operations"} + {"id", "name", "aliases", "description", "icon", "operations", "schedule"} ) @@ -66,6 +68,7 @@ class SceneTemplate: operations: tuple[ModeOperation, ...] source: str revision: str + schedule: ModeSchedule | None = None @classmethod def from_yaml(cls, path: Path) -> "SceneTemplate": @@ -100,7 +103,13 @@ class SceneTemplate: alias_keys.add(key) aliases.append(alias) + schedule = (ModeSchedule.from_dict(document["schedule"]) + if document.get("schedule") is not None else None) raw_operations = document.get("operations") + if schedule is not None: + if raw_operations: + raise ValueError(f"{path} cannot mix static operations and a schedule") + raw_operations = list(schedule.phases[0].operations) if not isinstance(raw_operations, list) or not raw_operations: raise ValueError(f"{path} must contain at least one operation") if len(raw_operations) > MAX_PLAN_REQUESTS: @@ -155,6 +164,8 @@ class SceneTemplate: ), ), } + if schedule is not None: + semantic_document["schedule"] = schedule.to_dict() canonical = json.dumps( semantic_document, ensure_ascii=False, @@ -171,6 +182,7 @@ class SceneTemplate: operations=tuple(operations), source=str(path), revision=revision, + schedule=schedule, ) def plan(self) -> dict[str, Any]: @@ -179,7 +191,7 @@ class SceneTemplate: request = operation.to_request(self.name) request["duration_ms"] = None requests.append(request) - return { + plan = { "detected_domains": list( dict.fromkeys(operation.domain for operation in self.operations) ), @@ -195,6 +207,9 @@ class SceneTemplate: "template_revision": self.revision, }, } + if self.schedule is not None: + plan["schedule"] = self.schedule.to_dict() + return plan def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]: result: dict[str, Any] = { @@ -204,13 +219,16 @@ class SceneTemplate: "description": self.description, "icon": self.icon, "domains": list(dict.fromkeys(item.domain for item in self.operations)), - "operation_count": len(self.operations), + "operation_count": self.schedule.operation_count if self.schedule else len(self.operations), "template_revision": self.revision, "origin": "builtin_scene_template", "read_only": True, } if include_plan: result["plan"] = self.plan() + if self.schedule is not None: + result["schedule"] = self.schedule.to_dict() + result["domains"] = sorted({op["domain"] for phase in self.schedule.phases for op in phase.operations}) return result @@ -257,7 +275,11 @@ class SceneTemplateRegistry: def resolve_exact_name(self, value: object) -> SceneTemplate | None: key = normalize_scene_template_name(value) - return self._by_name.get(key) if key else None + exact = self._by_name.get(key) if key else None + if exact is not None: + return exact + subject = normalize_scene_template_name(mode_creation_source_name(value)) + return self._by_name.get(subject) if subject and subject != key else None def get_template(self, template_id: str) -> SceneTemplate: try: diff --git a/src/cognitive_agent/application/schedule_intent.py b/src/cognitive_agent/application/schedule_intent.py new file mode 100644 index 0000000..7d50ce0 --- /dev/null +++ b/src/cognitive_agent/application/schedule_intent.py @@ -0,0 +1,25 @@ +"""Narrow detection of temporal scene needs; never authorizes execution.""" +from __future__ import annotations + +import re + +_TEMPORAL = re.compile( + r"动态|循环|分阶段|随时间|交替|轮流|每隔|预热|预冷|" + r"(?:秒|分钟|分|小时)(?:钟)?(?:后|再)|持续.{0,8}(?:秒|分钟|小时)" +) +_STOP = re.compile( + r"^(?:请|帮我|立即|马上|先)?(?:停止|停下|结束|取消|关闭)" + r"(?:当前|正在执行的|正在运行的|这个|该)?(?:座椅|空调|功能)?(?:动态|循环|分阶段|时间轴|定时)" + r"(?:座椅|空调|功能)?(?:模式|编排|任务)?[。!!\s]*$" +) + + +def needs_mode_schedule(text: str) -> bool: + # HVAC recirculation is a stable device enum, not a timed repetition. + normalized = re.sub(r"内外循环|内循环|外循环|自动循环|新风循环|循环风", "空气循环状态", text) + normalized = normalized.replace("空气循环状态", "空气状态") + return bool(_TEMPORAL.search(normalized)) + + +def is_schedule_stop(text: str) -> bool: + return bool(_STOP.fullmatch(text.strip())) diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index e632c27..105970c 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -49,6 +49,16 @@ from cognitive_agent.application.direct_can_control import ( # noqa: E402 build_direct_can_intent, ) from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402 +from cognitive_agent.application.mode_creation_seat_policy import ( # noqa: E402 + ModeCreationSeatPolicyError, + creation_seat_scopes, + project_seed_operations, + project_seed_plan, + project_seed_schedule, +) +from cognitive_agent.application.mode_device_additions import ( # noqa: E402 + mode_device_additions, +) from cognitive_agent.application.mode_profile import ( # noqa: E402 ModeProfile, ModeProfileRegistry, @@ -57,6 +67,14 @@ from cognitive_agent.application.mode_profile import ( # noqa: E402 from cognitive_agent.application.mode_scene_ingress import ( # noqa: E402 ModeSceneIngressConsumer, ) +from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 + is_mode_profile_query, + mode_creation_source_name, + normalize_mode_profile_name, + parse_mode_profile_create, + parse_mode_profile_delete, + parse_mode_profile_update, +) from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402 DEFAULT_N7_SCENE_SERVER_ID, DEFAULT_N7_SCENE_SERVER_NAME, @@ -64,13 +82,8 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402 ModeSceneMqttBurstPublisher, build_mode_scene_catalog, ) -from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 - is_mode_profile_query, - normalize_mode_profile_name, - parse_mode_profile_create, - parse_mode_profile_delete, - parse_mode_profile_update, -) +from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402 +from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402 from cognitive_agent.application.model_plan_adapter import ( # noqa: E402 APPLY_PRESET_CAPABILITY, DYNAMIC_MODE_STOP_CAPABILITY, @@ -103,6 +116,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402 NativeCanRuntime, ) from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402 +from cognitive_agent.application.schedule_intent import ( # noqa: E402 + is_schedule_stop, + needs_mode_schedule, +) from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402 from cognitive_agent.application.semantic_ir import ( # noqa: E402 apply_language_safety_guards, @@ -356,8 +373,9 @@ DYNAMIC_MODE_WORDS = ( ) -class SeatDemoService: +class SeatDemoService(ModeScheduleServiceMixin): def __init__(self, repository_root: Path = REPOSITORY_ROOT): + self.mode_schedule_runner = ModeScheduleRunner() self.root = repository_root self.domain_root = repository_root / "domains" / "seat" self.contracts = ContractValidator(repository_root) @@ -1190,7 +1208,7 @@ class SeatDemoService: resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id) - if not profile.command_flow or not profile.command_flow.get("plans"): + if profile.schedule is None and (not profile.command_flow or not profile.command_flow.get("plans")): raise ValueError("该模式尚未生成可直接执行的控制指令流。") result = self.process( profile.instruction or f"进入{profile.name}", @@ -1205,6 +1223,8 @@ class SeatDemoService: control_transport=control_transport, _direct_mode_profile_id=profile.id, ) + if profile.schedule is not None: + return result public_profile = profile.public_dict() result["provider_used"] = "saved_mode_command_flow" result["utterance"] = profile.name @@ -1323,7 +1343,7 @@ class SeatDemoService: ): raise ValueError("功能模式草稿未通过安全校验,请重新生成。") - return { + trial = { "request_id": clean_request_id, "status": "succeeded", "execution_mode": "dry_run", @@ -1336,6 +1356,14 @@ class SeatDemoService: "validation": deepcopy(validation), "trial_source": "server_validated_preview_artifact", } + schedule = self._validated_schedule_artifact(source_result) + if schedule is not None: + trial["schedule"] = schedule.to_dict() + trial["schedule_execution"] = self.mode_schedule_runner.simulate( + schedule, lambda phase: {"status": "succeeded", "phase_id": phase.id, + "operations": deepcopy(list(phase.operations))}, + ) + return trial def _persist_mode_profile_card( self, @@ -1534,6 +1562,11 @@ class SeatDemoService: # generation workflow. "execution_steps": [], } + schedule = self._validated_schedule_artifact(source_result) + if profile is not None and profile.schedule is not None and schedule is None: + raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。") + if schedule is not None: + operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations] saved = self.mode_profiles.save_mode( clean_name, operations, @@ -1553,6 +1586,7 @@ class SeatDemoService: upstream_origin=( profile.upstream_origin if profile is not None else "" ), + schedule=schedule, expected_revision=draft_existing_mode_revision or None, create_only=not bool(clean_mode_id), ) @@ -1753,6 +1787,7 @@ class SeatDemoService: self._vehicle_mqtt_transport.start() def close(self) -> None: + self.mode_schedule_runner.close() if self._vehicle_state_monitor is not None: self._vehicle_state_monitor.close() self._mode_scene_mqtt_publisher.close() @@ -2761,6 +2796,32 @@ class SeatDemoService: or mode_profile_query ): execution_mode = "dry_run" + schedule_requested = "模式" in utterance or _direct_mode_profile_id or is_schedule_stop(utterance) + schedule_vehicle_id = vehicle_id or (self.vehicle_profiles.default().id if schedule_requested else "") + if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}: + raise ValueError(f"unsupported execution mode: {execution_mode}") + if is_schedule_stop(utterance): + return self._cancel_mode_schedule(schedule_vehicle_id, conversation_id, utterance, execution_mode) + schedule_profile = (self.mode_profiles.get_profile(_direct_mode_profile_id, schedule_vehicle_id) + if _direct_mode_profile_id else None) + if schedule_requested and schedule_profile is None and not (draft_update or mode_delete_name or mode_profile_query or parse_mode_profile_create(utterance)): + candidates = self.mode_profiles.match_text(utterance, vehicle_id=schedule_vehicle_id) + if len(candidates) == 1 and candidates[0].schedule is not None: + aliases = "|".join(re.escape(name) for name in (candidates[0].name, *candidates[0].aliases)) + if re.fullmatch( + r"(?:(?:请|麻烦|帮我|帮忙|给我|我想|我要|想要)\s*){0,4}" + r"(?:(?:主驾|副驾|驾驶位|副驾驶|主驾驶)(?:座椅)?\s*)?" + r"(?:进入|启动|执行|开启|启用|运行|打开|调用|应用|使用|切换到)\s*" + rf"(?:{aliases})(?:吧|一下)?[。!!\s]*", utterance.strip(), + ): + schedule_profile = candidates[0] + if schedule_profile is not None and schedule_profile.schedule is not None: + return self._run_mode_schedule( + schedule_profile, utterance=utterance, conversation_id=conversation_id, + execution_mode=execution_mode, approval_token=approval_token, vehicle_id=schedule_vehicle_id, + simulation_fault=simulation_fault, hardware_client_id=hardware_client_id, + hardware_access_bypass=hardware_access_bypass, control_transport=control_transport, + ) if execution_mode != "hardware": return self._process_once( utterance, @@ -2899,6 +2960,8 @@ class SeatDemoService: assistant_plan: dict[str, Any] | None = None, allow_fuzzy_mode_profile_match: bool = True, allow_scene_composition: bool = False, + scene_scope_utterance: str | None = None, + mode_creation_scopes: frozenset[str] | None = None, mode_profile_draft_context: dict[str, Any] | None = None, cloud_instruction_context: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -3158,6 +3221,8 @@ class SeatDemoService: mode_requests, n7_transfer_actions = ( self._mode_requests_and_n7_transfers(direct_mode_profile) ) + if mode_creation_scopes is not None: + mode_requests = project_seed_operations(mode_requests, mode_creation_scopes) detected_domains = list( dict.fromkeys( str(item.get("domain") or "") @@ -3253,9 +3318,13 @@ class SeatDemoService: model_parse_source=model_parse_source, assistant_plan=assistant_plan, allow_scene_composition=allow_scene_composition, + scene_scope_utterance=scene_scope_utterance, cloud_instruction_context=cloud_instruction_context, ) - dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance) + scene_label_only = assistant_plan is not None and allow_scene_composition + dynamic_mode_stop = ( + not scene_label_only and self._is_dynamic_mode_stop_request(utterance) + ) if dynamic_mode_stop: parsed = self._apply_dynamic_mode_stop_request(parsed, utterance) # Reuse the same conservative clause segmentation for the final @@ -3263,7 +3332,12 @@ class SeatDemoService: # comma between independent controls can leak an early target # (for example ``副驾``) into a later HVAC clause and can also # hide otherwise valid window/temperature actions. - segmented_utterance = ",".join( + # Name-only scene plans have already been checked against exact + # targets by _interpret. Their synthesized 进入 label is not + # another atomic instruction: reparsing 座椅舒适模式 here applies + # the unscoped-seat driver default and discards valid passenger + # actions. Explicit user instructions never enter this branch. + segmented_utterance = "" if scene_label_only else ",".join( self._split_intent_clauses(utterance) ) relative_resolution = ( @@ -3345,7 +3419,12 @@ class SeatDemoService: parsed, excluded_control_signatures, ) - parsed = apply_language_safety_guards(parsed, utterance) + parsed = apply_language_safety_guards( + parsed, + utterance, + scene_composition=assistant_plan is not None and allow_scene_composition, + scene_scope_utterance=scene_scope_utterance, + ) parsed = self._partition_native_actions( parsed, promote_supported=( @@ -4413,6 +4492,16 @@ class SeatDemoService: ) -> dict[str, Any]: if not matched_modes: return parsed + if any(mode.schedule is not None for mode in matched_modes): + rejected = deepcopy(parsed) + for key in ("actions", "domain_actions", "native_can_actions", "preset_actions", + "preset_events", "deferred_requests", "model_requests"): + rejected[key] = [] + rejected["ambiguities"] = [{ + "field": "mode_profile", + "question": "动态模式必须通过时间轴执行,请独立输入“进入模式名称”;不会将各阶段压平执行。", + }] + return rejected if self._contains_seat_preset_request(parsed): return parsed if len(matched_modes) > 1: @@ -4496,6 +4585,19 @@ class SeatDemoService: mode_requests, n7_transfer_actions = ( self._mode_requests_and_n7_transfers(matched_profile) ) + selected_seat_scope = self._explicit_mode_seat_selection( + matched_profile, utterance, + ) + if selected_seat_scope is not None: + # A cabin profile can carry independent comfort operations for both + # front seats. An explicit single-seat invocation selects only that + # seat; it must not authorize its sibling or mutate the stored mode. + mode_requests = [ + request for request in mode_requests + if request["domain"] != "seat" + or self._normalized_mode_seat_scope(request["target_scope"]) + in selected_seat_scope + ] detected_domains = [] for request in mode_requests: if request["domain"] not in detected_domains: @@ -4516,6 +4618,24 @@ class SeatDemoService: context, vehicle_profile, ) + # Native-only comfort capabilities are represented as deferred items by + # the generic adapter. A verified stored mode must materialize them too; + # otherwise a successful named-mode run silently drops its driver seat. + mode_native_actions = [] + for item in mode_parsed.get("deferred_requests", []): + if item.get("execution_backend") != "native_can": + continue + action = { + "domain": item["domain"], + "capability": item["capability"], + "target": item["target_scope"], + "parameters": deepcopy(item.get("parameters", {})), + "operation": item.get("desired_change", "set"), + "reason": item.get("reason", ""), + "execution_backend": "native_can", + } + if self.native_can_runtime.supports(action): + mode_native_actions.append(action) parsed_has_explicit_request = bool( parsed.get("actions") or parsed.get("domain_actions") @@ -4532,6 +4652,10 @@ class SeatDemoService: mode_parsed.get("domain_actions", []), parsed.get("domain_actions", []), ) + merged["native_can_actions"] = self._merge_control_actions( + mode_native_actions, + parsed.get("native_can_actions", []), + ) for key in ( "preset_actions", "preset_events", @@ -4583,13 +4707,35 @@ class SeatDemoService: merged["_saved_mode_local_peripheral"] = True return merged - @staticmethod + @classmethod def _explicit_mode_target_conflict( + cls, profile: ModeProfile, utterance: str, ) -> tuple[str, tuple[str, ...]] | None: """Reject a named mode when its persisted seat scope contradicts text.""" + requested, normalized_scopes = cls._explicit_mode_seat_scopes(profile, utterance) + if requested is None or requested == normalized_scopes: + return None + if cls._explicit_mode_seat_selection(profile, utterance) is not None: + return None + return ",".join(sorted(requested)), tuple(sorted(normalized_scopes)) + + @staticmethod + def _normalized_mode_seat_scope(scope: str) -> str: + return ( + "driver" if scope == "front_left" else + "passenger" if scope == "front_right" else + "rear" if scope in {"rear", "rear_left", "rear_right"} else scope + ) + + @classmethod + def _explicit_mode_seat_scopes( + cls, + profile: ModeProfile, + utterance: str, + ) -> tuple[frozenset[str] | None, set[str]]: aliases = (profile.id, profile.name, *profile.aliases) mode_clause = next( ( @@ -4605,23 +4751,34 @@ class SeatDemoService: utterance, ) requested = resolve_explicit_seat_scopes( - mode_clause, + mode_clause.replace("主副驾", "主驾和副驾"), assume_seat_context=True, ) - if requested is None: - return None - normalized_scopes = { - "driver" if operation.target_scope == "front_left" else - "passenger" if operation.target_scope == "front_right" else - "rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else - operation.target_scope + cls._normalized_mode_seat_scope(operation.target_scope) for operation in profile.operations if operation.domain == "seat" } - if requested == normalized_scopes: + return requested, normalized_scopes + + @classmethod + def _explicit_mode_seat_selection( + cls, + profile: ModeProfile, + utterance: str, + ) -> frozenset[str] | None: + """Scope a dual-seat mode invocation, never mode creation or editing.""" + requested, supported = cls._explicit_mode_seat_scopes(profile, utterance) + if ( + requested is None + or len(requested) != 1 + or supported != {"driver", "passenger"} + or not requested.issubset(supported) + or cls.is_mode_profile_configuration_request(utterance) + or not re.search(r"进入|开启|启动|执行|启用|运行|切换|打开|调用|应用|使用", utterance) + ): return None - return ",".join(sorted(requested)), tuple(sorted(normalized_scopes)) + return requested @staticmethod def _apply_implicit_mode_target_policy( @@ -4954,7 +5111,9 @@ class SeatDemoService: for issue in validation ): raise ValueError("待保存功能模式草稿没有通过完整安全校验。") - operations = self._preview_mode_operations(source_result) + schedule = self._validated_schedule_artifact(source_result) + operations = ([deepcopy(op) for phase in schedule.phases for op in phase.operations] + if schedule is not None else self._preview_mode_operations(source_result)) operation_count = draft.get("operation_count") if ( not isinstance(operation_count, int) @@ -5035,6 +5194,17 @@ class SeatDemoService: "confidence": 1.0, } + def _mode_device_default_operations( + self, + mode_name: str, + ) -> list[dict[str, Any]]: + """Return registered static-scene defaults for a bounded device append.""" + + template = self.scene_templates.resolve_exact_name(mode_name) + if template is None or template.schedule is not None: + return [] + return [operation.to_yaml() for operation in template.operations] + @staticmethod def _mode_update_target_is_explicit(change_text: str, target: str) -> bool: patterns = { @@ -5084,79 +5254,99 @@ class SeatDemoService: vehicle_id=vehicle_profile.id, ) ) + if source_draft.get("schedule") is not None: + from cognitive_agent.application.mode_schedule_edit import revise_schedule + + return revise_schedule( + self, ModeSchedule.from_dict(source_draft["schedule"]), + change_text=change_text, utterance=utterance, name=mode_name, + conversation_id=conversation_id, vehicle_profile=vehicle_profile, + context=context, control_transport=control_transport, + source_draft=source_draft, + ) base_operations = self._absolute_mode_operations(source_operations) if not change_text: raise ValueError("请说明要调整的设备和明确目标值。") - parseable_change_text = re.sub( - r"改为|改成|变更为|换成|变成|变为", - "调整为", + registered_additions = mode_device_additions( change_text, + base_operations, + self._mode_device_default_operations(mode_name), ) - unresolved_change_clauses = [ - clause - for clause, _parsed, status in self._local_clause_results( - parseable_change_text, - context, + if registered_additions is not None: + normalized_changes = self._absolute_mode_operations( + registered_additions ) - if status != "complete" - ] - if unresolved_change_clauses and assistant_plan is None: - raise ValueError( - "调整说明包含未完整识别的内容:" - + ";".join(unresolved_change_clauses) + else: + parseable_change_text = re.sub( + r"改为|改成|变更为|换成|变成|变为", + "调整为", + change_text, ) - change_result = self._process_once( - parseable_change_text, - provider, - conversation_id, - "dry_run", - approval_token, - vehicle_profile.id, - simulation_fault, - control_transport=control_transport, - assistant_plan=assistant_plan, - allow_fuzzy_mode_profile_match=False, - ) - change_operations = self._absolute_mode_operations( - self._preview_mode_operations(change_result) - ) - if ( - change_result.get("status") != "succeeded" - or not change_operations - or change_result.get("supplement_required") is True - or bool(change_result.get("unresolved_clauses")) - or bool(change_result.get("deferred_requests")) - ): - raise ValueError("没有从调整说明中得到完整、可复用的设备目标。") - validation = change_result.get("validation") - if isinstance(validation, list) and any( - isinstance(issue, dict) and issue.get("severity") == "error" - for issue in validation - ): - raise ValueError("调整动作没有通过车辆能力或安全校验。") - - normalized_changes: list[dict[str, Any]] = [] - for change in change_operations: - same_capability = [ - item - for item in base_operations - if item["domain"] == change["domain"] - and item["capability"] == change["capability"] - ] - normalized = deepcopy(change) - if len(same_capability) == 1 and not self._mode_update_target_is_explicit( - change_text, - str(change["target_scope"]), - ): - normalized["target_scope"] = same_capability[0]["target_scope"] - elif len(same_capability) > 1 and not self._mode_update_target_is_explicit( - change_text, - str(change["target_scope"]), - ): - raise ValueError( - f"“{mode_name}”中该设备能力包含多个目标,请明确主驾、副驾或具体区域。" + unresolved_change_clauses = [ + clause + for clause, _parsed, status in self._local_clause_results( + parseable_change_text, + context, ) - normalized_changes.append(normalized) + if status != "complete" + ] + if unresolved_change_clauses and assistant_plan is None: + raise ValueError( + "调整说明包含未完整识别的内容:" + + ";".join(unresolved_change_clauses) + ) + change_result = self._process_once( + parseable_change_text, + provider, + conversation_id, + "dry_run", + approval_token, + vehicle_profile.id, + simulation_fault, + control_transport=control_transport, + assistant_plan=assistant_plan, + allow_fuzzy_mode_profile_match=False, + ) + change_operations = self._absolute_mode_operations( + self._preview_mode_operations(change_result) + ) + if ( + change_result.get("status") != "succeeded" + or not change_operations + or change_result.get("supplement_required") is True + or bool(change_result.get("unresolved_clauses")) + or bool(change_result.get("deferred_requests")) + ): + raise ValueError("没有从调整说明中得到完整、可复用的设备目标。") + validation = change_result.get("validation") + if isinstance(validation, list) and any( + isinstance(issue, dict) and issue.get("severity") == "error" + for issue in validation + ): + raise ValueError("调整动作没有通过车辆能力或安全校验。") + + normalized_changes = [] + for change in change_operations: + same_capability = [ + item + for item in base_operations + if item["domain"] == change["domain"] + and item["capability"] == change["capability"] + ] + normalized = deepcopy(change) + if len(same_capability) == 1 and not self._mode_update_target_is_explicit( + change_text, + str(change["target_scope"]), + ): + normalized["target_scope"] = same_capability[0]["target_scope"] + elif len(same_capability) > 1 and not self._mode_update_target_is_explicit( + change_text, + str(change["target_scope"]), + ): + raise ValueError( + f"“{mode_name}”中该设备能力包含多个目标,请明确主驾、副驾或具体区域。" + ) + normalized_changes.append(normalized) change_by_key = { ( @@ -5433,6 +5623,14 @@ class SeatDemoService: if profile is None: self._persist(result) return result + if profile.schedule is not None: + from cognitive_agent.application.mode_schedule_edit import update_saved_schedule + + return update_saved_schedule( + self, profile, change_text=change_text, utterance=utterance, + conversation_id=conversation_id, vehicle_profile=vehicle_profile, + context=context, result=result, + ) if not change_text: result["status"] = "needs_confirmation" result["validation"] = [ @@ -5446,27 +5644,64 @@ class SeatDemoService: self._persist(result) return result - parsed, provider_used, provider_notice = self._interpret( + registered_additions = mode_device_additions( change_text, - provider, - context, - vehicle_profile, + [operation.to_yaml() for operation in profile.operations], + self._mode_device_default_operations(mode_name), ) - parsed = apply_language_safety_guards(parsed, change_text) - parsed = self._partition_native_actions(parsed) - result["provider_used"] = f"mode_profile_registry+{provider_used}" - result["decision_summary"].extend(parsed.get("decision_summary", [])) - if provider_notice: - result["decision_summary"].append(provider_notice) - result["deferred_requests"] = parsed.get("deferred_requests", []) - result["model_requests"] = parsed.get("model_requests", []) + if registered_additions is not None: + parsed = { + "decision_summary": [ + "已从只读场景模板载入缺失设备的登记默认动作。" + ], + "deferred_requests": [], + "model_requests": [], + } + result["provider_used"] = ( + "mode_profile_registry+registered_scene_template" + ) + result["decision_summary"].extend(parsed["decision_summary"]) + result["deferred_requests"] = [] + result["model_requests"] = [] + mode_operations = self._absolute_mode_operations( + registered_additions + ) + source_actions = [ + { + "domain": operation["domain"], + "capability": operation["capability"], + "target": operation["target_scope"], + "operation": operation.get("operation", "set"), + "parameters": deepcopy(operation.get("parameters", {})), + "reason": operation.get( + "reason", + "从登记场景模板增补的设备默认动作。", + ), + } + for operation in mode_operations + ] + else: + parsed, provider_used, provider_notice = self._interpret( + change_text, + provider, + context, + vehicle_profile, + ) + parsed = apply_language_safety_guards(parsed, change_text) + parsed = self._partition_native_actions(parsed) + result["provider_used"] = f"mode_profile_registry+{provider_used}" + result["decision_summary"].extend(parsed.get("decision_summary", [])) + if provider_notice: + result["decision_summary"].append(provider_notice) + result["deferred_requests"] = parsed.get("deferred_requests", []) + result["model_requests"] = parsed.get("model_requests", []) - source_actions = [ - *parsed.get("actions", []), - *parsed.get("domain_actions", []), - *parsed.get("native_can_actions", []), - ] - mode_operations = self._mode_operations_from_actions(source_actions) + source_actions = [ + *parsed.get("actions", []), + *parsed.get("domain_actions", []), + *parsed.get("native_can_actions", []), + ] + mode_operations = self._mode_operations_from_actions(source_actions) mode_operations, ambiguous_targets = self._inherit_mode_update_targets( profile, mode_operations, @@ -6100,6 +6335,25 @@ class SeatDemoService: result["scene_config"] = None result["execution_report"] = None + def _mode_creation_seat_failure( + self, utterance: str, conversation_id: str, vehicle: VehicleProfile, reason: str, + ) -> dict[str, Any]: + result = { + "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}", + "utterance": utterance, "conversation_id": conversation_id, + "vehicle": vehicle.public_dict(), "status": "validation_failed", + "execution_mode": "dry_run", "hardware_execution_allowed": False, + "orchestration_route": "mode_profile_create_preview", "trace_events": [], + "validation": [{"severity": "error", "code": "MODE_SEAT_SCOPE_UNAVAILABLE", + "path": "mode_profile_draft.seat_scope", "message": reason}], + "mode_creation_failure": {"code": "MODE_SEAT_SCOPE_UNAVAILABLE", "reason": reason, + "next_step": "请选择该模式实际支持的座位,或改用当前座位支持的舒适功能"}, + "decision_summary": [reason, "没有保存模式或下发控制。"], + } + self._scrub_failed_scene_preview(result) + self._persist(result) + return result + def _process_mode_profile_create( self, utterance: str, @@ -6134,6 +6388,14 @@ class SeatDemoService: ): raise ValueError("功能模式名称与创建请求不一致。") requested_instruction = create_parts[1] if create_parts is not None else "" + selected_seats = creation_seat_scopes(utterance) if source_draft is None else None + if selected_seats is not None and "unsupported" in selected_seats and re.search( + r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩|零重力", utterance + ): + return self._mode_creation_seat_failure( + utterance, conversation_id, vehicle_profile, + "当前模式只支持明确的主驾或副驾座椅,不会将未支持的座位替换为其他座位。", + ) raw_plan_metadata = ( assistant_plan.get("metadata") if isinstance(assistant_plan, dict) @@ -6160,6 +6422,7 @@ class SeatDemoService: or requested_instruction or assistant_plan.get("requests") != candidate_template.plan().get("requests") + or assistant_plan.get("schedule") != candidate_template.plan().get("schedule") ): raise ValueError("内置常用模式模板已更新,请重新提交。") scene_template = candidate_template @@ -6190,6 +6453,10 @@ class SeatDemoService: if not requested_instruction and source_draft is None else None ) + if source_profile is None and source_draft is None and not requested_instruction and scene_template is None: + source_name = mode_creation_source_name(mode_name) + if source_name != mode_name: + source_profile = self.mode_profiles.resolve_exact_name(source_name, vehicle_id=vehicle_profile.id) draft_mode_name = ( str(source_draft.get("name") or mode_name).strip() if isinstance(source_draft, dict) @@ -6205,7 +6472,7 @@ class SeatDemoService: else "registered_mode_profile" if source_profile is not None else "codex_scene_composition" - if assistant_plan is not None and not requested_instruction + if assistant_plan is not None and (not requested_instruction or needs_mode_schedule(utterance)) else "explicit_scene_requirements" ) # A name-only request may already describe a repository-backed scene. @@ -6214,7 +6481,63 @@ class SeatDemoService: # being parsed as a second control clause on the assistant's local fast # path, turning a valid nine-action mode into an incomplete draft. source_utterance = requested_instruction or f"进入{draft_mode_name}" + if requested_instruction and selected_seats is not None and len(selected_seats) == 1: + # Qualify only explicit seat-device clauses, never an HVAC/window + # zone or unrelated control. General one-shot controls keep their + # existing target policy; this is scoped to new mode construction. + label = "主驾" if selected_seats == {"driver"} else "副驾" + instruction_clauses = self._split_intent_clauses(requested_instruction) + qualified_clauses = [ + f"{label}{clause}" if ( + re.search(r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩", clause) + and resolve_explicit_seat_scopes(clause, assume_seat_context=True) is None + ) else clause + for clause in instruction_clauses + ] + # Preserve the user's punctuation and wording unless a seat really + # needs qualification; explicit instructions need no rewriting. + if qualified_clauses != instruction_clauses: + source_utterance = ",".join(qualified_clauses) + raw_schedule = (assistant_plan.get("schedule") if isinstance(assistant_plan, dict) else None) + if source_profile is not None and source_profile.schedule is not None: + raw_schedule = source_profile.schedule.to_dict() + if raw_schedule is not None: + if isinstance(assistant_plan, dict) and assistant_plan.get("ambiguities"): + raise ValueError("动态需求仍有未解决的问题,不能生成部分成功的时间轴,请补充或调整需求。") + if source_profile is None and source_draft is None and scene_template is None and not needs_mode_schedule(utterance): + raise ValueError("用户未请求动态或分阶段模式,不能擅自生成定时任务。") + schedule = ModeSchedule.from_dict(raw_schedule) + if selected_seats is not None and (source_profile is not None or scene_template is not None): + try: + schedule = project_seed_schedule(schedule, selected_seats) + except ModeCreationSeatPolicyError as exc: + return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) + return self._preview_mode_schedule( + schedule, name=draft_mode_name, utterance=utterance, + conversation_id=conversation_id, vehicle_profile=vehicle_profile, + control_transport=control_transport, generation_source=generation_source, + source_profile=existing_profile, source_template=scene_template, source_draft=source_draft, + scene_scope_utterance="" if source_draft is not None else utterance, + ) + if needs_mode_schedule(utterance) and scene_template is None and not (source_profile is not None and source_profile.timeline): + result = { + "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}", + "utterance": utterance, "conversation_id": conversation_id, + "vehicle": vehicle_profile.public_dict(), "validation": [], "trace_events": [], + "status": "validation_failed", "execution_mode": "dry_run", "hardware_execution_allowed": False, + "orchestration_route": "mode_profile_create_preview", + } + result["mode_creation_failure"] = { + "code": "MODE_SCHEDULE_REQUIRED", + "reason": "该需求包含时间变化,但没有生成完整的分阶段时间轴", + "next_step": "请重试,或补充每个阶段的设备目标和持续时间;未保存或执行静态替代动作", + } + self._scrub_failed_scene_preview(result) + self._persist(result) + return result try: + if scene_template is not None and selected_seats is not None: + assistant_plan = project_seed_plan(assistant_plan, selected_seats) source_result = self._process_once( source_utterance, provider, @@ -6227,6 +6550,7 @@ class SeatDemoService: direct_mode_profile_id=( source_profile.id if source_profile is not None else None ), + mode_creation_scopes=selected_seats, assistant_plan=assistant_plan, # ``source_utterance`` is synthesized for a name-only, unknown # scene. It must not fall back to substring matching (for example @@ -6238,7 +6562,14 @@ class SeatDemoService: and source_profile is None and not requested_instruction ), + # A revision combines a server-loaded draft with changes that + # already passed the ordinary text/target authorization path. + # Its old display name cannot veto an explicitly requested new + # seat. This override is internal, never accepted from payloads. + scene_scope_utterance="" if source_draft is not None else utterance, ) + except ModeCreationSeatPolicyError as exc: + return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) except NativeCanCompileError: # Repository modes and model-composed scenes share the same native # compiler. A stale mode parameter must become a bounded failed @@ -8276,6 +8607,7 @@ class SeatDemoService: model_parse_source: str = "cloud_model", assistant_plan: dict[str, Any] | None = None, allow_scene_composition: bool = False, + scene_scope_utterance: str | None = None, cloud_instruction_context: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], str, str | None]: vehicle_profile = vehicle_profile or self.vehicle_profiles.default() @@ -8338,6 +8670,7 @@ class SeatDemoService: ], trusted_local=trusted_local, allow_scene_composition=allow_scene_composition, + scene_scope_utterance=scene_scope_utterance, ) model = self.model_plan_adapter.adapt( guarded.plan, diff --git a/src/cognitive_agent/application/semantic_ir.py b/src/cognitive_agent/application/semantic_ir.py index 3e6ef35..37771af 100644 --- a/src/cognitive_agent/application/semantic_ir.py +++ b/src/cognitive_agent/application/semantic_ir.py @@ -8,7 +8,6 @@ from collections.abc import Mapping from copy import deepcopy from typing import Any - CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+") NEGATED_OPERATION = re.compile( r"(?:不要|别|不想|无需|不必|禁止)" @@ -71,6 +70,28 @@ _SEAT_UNSUPPORTED_SCOPE = re.compile( r"左后|右后|前舱|后舱|左侧|右侧|左边|右边|" r"所有座椅|全部座椅|全车座椅" ) +_SCENE_NON_SEAT_SCOPED_DEVICE = re.compile( + rf"(?:{_SEAT_DRIVER_SCOPE.pattern}|{_SEAT_PASSENGER_SCOPE.pattern}|" + rf"{_SEAT_UNSUPPORTED_SCOPE.pattern})\s*(?:的)?\s*" + r"(?=空调|温度|风量|出风|风口|除霜|车窗|窗户|车门|门锁|" + r"阅读灯|氛围灯|灯光|遮阳帘|天幕)", + re.IGNORECASE, +) + + +def resolve_scene_seat_scopes(utterance: str) -> frozenset[str] | None: + """Keep a scene's explicit seat scope without borrowing an HVAC/light zone. + + A name such as 主驾舒适模式 still constrains seat actions even when it + omits 座椅. But 副驾空调与座椅舒适模式 does not authorize restricting + every seat action to the passenger. Only remove scope tokens directly + attached to another device; all negation text remains untouched. + """ + + scoped_text = _SCENE_NON_SEAT_SCOPED_DEVICE.sub( + "", utterance.replace("主副驾", "主驾和副驾") + ) + return resolve_explicit_seat_scopes(scoped_text, assume_seat_context=True) def resolve_explicit_seat_scopes( @@ -356,7 +377,13 @@ def _remove_conflicting_controls( result[field] = kept -def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]: +def apply_language_safety_guards( + parsed: dict[str, Any], + utterance: str, + *, + scene_composition: bool = False, + scene_scope_utterance: str | None = None, +) -> dict[str, Any]: """Preserve negation and unresolved control language without producing CAN.""" result = deepcopy(parsed) @@ -386,7 +413,29 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict ] result["confidence"] = min(float(result.get("confidence", 0)), 0.8) - explicit_seat_scopes = resolve_explicit_seat_scopes(utterance) + if scene_composition: + # Reuse exactly the selection already applied to the model schema and + # trusted seed. A second, older scope parser must not reject aliases + # such as 前排两座 or widen 副驾驶席 to both seats after validation. + from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes + + explicit_seat_scopes = (None if scene_scope_utterance == "" else creation_seat_scopes( + utterance if scene_scope_utterance is None else scene_scope_utterance + )) + else: + explicit_seat_scopes = resolve_explicit_seat_scopes(utterance) + if scene_composition and not any( + isinstance(item, dict) + and ( + item.get("domain") == "seat" + or str(item.get("capability", "")).startswith("seat.") + ) + for key in _EXECUTION_LIST_FIELDS + for item in result.get(key, []) + ): + # A rear-window ventilation scene is not a rear-seat request. The + # scene guard already checks every seat candidate before adaptation. + explicit_seat_scopes = None if explicit_seat_scopes is not None: removed = _filter_explicit_seat_scope(result, explicit_seat_scopes) if removed: diff --git a/src/cognitive_agent/firmware_ota_server.py b/src/cognitive_agent/firmware_ota_server.py new file mode 100644 index 0000000..87fed21 --- /dev/null +++ b/src/cognitive_agent/firmware_ota_server.py @@ -0,0 +1,308 @@ +"""Read-only HTTP surface for vehicle firmware discovery and download.""" + +from __future__ import annotations + +import argparse +import json +import mimetypes +import os +import re +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlparse + +from cognitive_agent.config_loader import load_local_env + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +load_local_env(REPOSITORY_ROOT / ".env", override=True) + +RELEASE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{7,80}") +DOWNLOAD_PATH_PATTERN = re.compile( + r"/api/firmware-workbench/releases/(?P[a-z0-9-]+)/download" +) +LATEST_PATHS = frozenset( + { + "/latest", + "/api/firmware-workbench/releases/latest", + } +) +RELEASES_PATHS = frozenset( + { + "/releases", + "/api/firmware-workbench/releases", + } +) + + +class FirmwareOtaRepository: + """Resolve atomically published OTA manifests without loading workbench state.""" + + def __init__(self, root: Path, public_base_url: str) -> None: + self.root = Path(root).resolve() + self.public_base_url = str(public_base_url).rstrip("/") + + def latest(self) -> dict[str, object]: + manifest_path = self.root / "latest.json" + manifest = self._read_manifest(manifest_path) + return self._public_manifest(manifest) + + def releases(self) -> dict[str, object]: + releases_root = self.root / "releases" + manifests: list[dict[str, object]] = [] + if releases_root.is_dir(): + for release_root in releases_root.iterdir(): + if not release_root.is_dir(): + continue + try: + manifest = self._read_manifest(release_root / "release.json") + release_id = self._release_id(manifest) + if release_id != release_root.name: + continue + package, _, _ = self.release_package(release_id) + expected_size = manifest.get("size_bytes") + if ( + expected_size is not None + and int(expected_size) != package.stat().st_size + ): + continue + if str(manifest.get("status") or "published") != "published": + continue + manifests.append(self._public_manifest(manifest)) + except (FileNotFoundError, OSError, TypeError, ValueError): + continue + manifests.sort( + key=lambda item: str(item.get("published_at") or ""), reverse=True + ) + latest_release_id = ( + str(manifests[0]["release_id"]) if manifests else None + ) + return { + "schema": "n7-ota-release-list/v1", + "count": len(manifests), + "latest_release_id": latest_release_id, + "releases": manifests, + } + + def release_package(self, release_id: str) -> tuple[Path, str, str]: + checked = str(release_id).strip() + if not RELEASE_ID_PATTERN.fullmatch(checked): + raise FileNotFoundError("release not found") + release_root = (self.root / "releases" / checked).resolve() + try: + release_root.relative_to((self.root / "releases").resolve()) + except ValueError as exc: + raise FileNotFoundError("release not found") from exc + manifest = self._read_manifest(release_root / "release.json") + if self._release_id(manifest) != checked: + raise FileNotFoundError("release not found") + filename = str(manifest.get("firmware_filename") or "") + if not filename or Path(filename).name != filename: + raise FileNotFoundError("firmware package not found") + package = (release_root / filename).resolve() + try: + package.relative_to(release_root) + except ValueError as exc: + raise FileNotFoundError("firmware package not found") from exc + if not package.is_file(): + raise FileNotFoundError("firmware package not found") + expected_hash = str(manifest.get("sha256") or "").lower() + return package, filename, expected_hash + + def _public_manifest(self, manifest: dict[str, object]) -> dict[str, object]: + payload = dict(manifest) + release_id = self._release_id(payload) + payload["schema"] = "n7-ota-release/v1" + payload["release_id"] = release_id + payload["download_url"] = f"{self.public_base_url}/{release_id}/download" + payload.pop("package_path", None) + return payload + + @staticmethod + def _read_manifest(path: Path) -> dict[str, object]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError) as exc: + raise FileNotFoundError("manifest not found") from exc + if not isinstance(document, dict): + raise FileNotFoundError("manifest not found") + return document + + @staticmethod + def _release_id(manifest: dict[str, object]) -> str: + release_id = str(manifest.get("release_id") or manifest.get("id") or "") + if not RELEASE_ID_PATTERN.fullmatch(release_id): + raise FileNotFoundError("release not found") + return release_id + + +class FirmwareOtaHandler(BaseHTTPRequestHandler): + """Expose health, release manifests, and immutable firmware packages.""" + + repository: FirmwareOtaRepository + server_version = "N7FirmwareOta/1.0" + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + self._handle_request(send_body=True) + + def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API + self._handle_request(send_body=False) + + def _handle_request(self, *, send_body: bool) -> None: + path = unquote(urlparse(self.path).path) + if path == "/healthz": + self._json({"status": "ok", "service": "n7-firmware-ota"}, send_body) + return + if path in LATEST_PATHS: + try: + self._json(self.repository.latest(), send_body) + except FileNotFoundError: + self._json( + {"error": "not_found", "message": "暂无可供车机升级的固件。"}, + send_body, + HTTPStatus.NOT_FOUND, + ) + return + if path in RELEASES_PATHS: + self._json(self.repository.releases(), send_body) + return + match = DOWNLOAD_PATH_PATTERN.fullmatch(path) + if match: + try: + package, filename, expected_hash = self.repository.release_package( + match.group("release_id") + ) + self._file(package, filename, expected_hash, send_body) + except FileNotFoundError: + self._json( + {"error": "not_found", "message": "OTA 固件包不存在。"}, + send_body, + HTTPStatus.NOT_FOUND, + ) + return + self._json( + {"error": "not_found", "message": "接口不存在。"}, + send_body, + HTTPStatus.NOT_FOUND, + ) + + def _file( + self, + path: Path, + filename: str, + expected_hash: str, + send_body: bool, + ) -> None: + size = path.stat().st_size + try: + start, end, partial = self._byte_range(size) + except ValueError: + self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + self.send_header("Content-Range", f"bytes */{size}") + self.send_header("Content-Length", "0") + self._security_headers() + self.end_headers() + return + content_length = end - start + 1 + self.send_response(HTTPStatus.PARTIAL_CONTENT if partial else HTTPStatus.OK) + self.send_header( + "Content-Type", mimetypes.guess_type(filename)[0] or "application/octet-stream" + ) + self.send_header("Content-Length", str(content_length)) + self.send_header("Content-Disposition", f'attachment; filename="{filename}"') + self.send_header("Accept-Ranges", "bytes") + self.send_header("Cache-Control", "public, max-age=31536000, immutable") + if expected_hash: + self.send_header("X-Firmware-SHA256", expected_hash) + if partial: + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self._security_headers() + self.end_headers() + if not send_body: + return + with path.open("rb") as source: + source.seek(start) + remaining = content_length + while remaining: + chunk = source.read(min(64 * 1024, remaining)) + if not chunk: + break + self.wfile.write(chunk) + remaining -= len(chunk) + + def _byte_range(self, size: int) -> tuple[int, int, bool]: + value = str(self.headers.get("Range") or "").strip() + if not value: + return 0, size - 1, False + match = re.fullmatch(r"bytes=(\d*)-(\d*)", value) + if not match or not any(match.groups()): + raise ValueError("invalid byte range") + first, last = match.groups() + if first: + start = int(first) + end = int(last) if last else size - 1 + else: + suffix = int(last) + if suffix <= 0: + raise ValueError("invalid byte range") + start = max(0, size - suffix) + end = size - 1 + if start >= size or end < start: + raise ValueError("invalid byte range") + return start, min(end, size - 1), True + + def _json( + self, + payload: object, + send_body: bool, + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + content = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-store") + self._security_headers() + self.end_headers() + if send_body: + self.wfile.write(content) + + def _security_headers(self) -> None: + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the read-only vehicle OTA service") + parser.add_argument( + "--host", default=os.getenv("FIRMWARE_OTA_BIND_HOST", "127.0.0.1") + ) + parser.add_argument( + "--port", type=int, default=int(os.getenv("FIRMWARE_OTA_PORT", "8012")) + ) + args = parser.parse_args() + configured_root = os.getenv("FIRMWARE_WORKBENCH_OTA_ROOT", "").strip() + ota_root = ( + Path(configured_root) + if configured_root + else REPOSITORY_ROOT / "artifacts" / "server" / "firmware-ota" + ) + public_base_url = os.getenv( + "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", + f"http://{args.host}:{args.port}/api/firmware-workbench/releases", + ) + FirmwareOtaHandler.repository = FirmwareOtaRepository(ota_root, public_base_url) + server = ThreadingHTTPServer((args.host, args.port), FirmwareOtaHandler) + print(f"N7 firmware OTA service: http://{args.host}:{args.port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 298e8c5..82cabe5 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -51,6 +51,10 @@ from cognitive_agent.application.extension_toy_publisher import ( # noqa: E402 EXTENSION_TOY_BURST_WINDOW_S, NfcExtensionToyPublisher, ) +from cognitive_agent.application.firmware_workbench import ( # noqa: E402 + FirmwareWorkbenchError, + FirmwareWorkbenchService, +) from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402 from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 normalize_mode_profile_name, @@ -61,6 +65,7 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402 MODE_SCENE_MQTT_BURST_WINDOW_S, ) from cognitive_agent.application.pet_cards import PetCardService # noqa: E402 +from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402 from cognitive_agent.application.seat_service import ( # noqa: E402 LOCAL_CONTROL_BUDGET_MS, SeatDemoService, @@ -127,7 +132,11 @@ PUBLIC_AUTH_PATHS = { "/network.js", "/api/auth/status", "/api/auth/login", + "/api/firmware-workbench/releases/latest", } +PUBLIC_OTA_DOWNLOAD_PATH = re.compile( + r"/api/firmware-workbench/releases/[a-z0-9-]+/download" +) NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"}) @@ -231,6 +240,7 @@ def _lan_ipv4_address() -> str | None: class SeatDemoHandler(BaseHTTPRequestHandler): service = SeatDemoService() + firmware_workbench = FirmwareWorkbenchService(REPOSITORY_ROOT) # Codex defaults to its own empty temporary workspace. A deployment may # opt into another isolated directory through CODEX_ASSISTANT_WORKSPACE, # but it must never inherit access to this application's source tree. @@ -361,9 +371,74 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path == "/api/mode-profiles/status": self._json(self.service.mode_profile_sync_status()) return + if path == "/api/firmware-workbench/state": + self._json(self.firmware_workbench.snapshot()) + return + if path == "/api/firmware-workbench/releases/latest": + try: + self._json(self.firmware_workbench.latest_release()) + except FirmwareWorkbenchError as exc: + self._json( + {"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status) + ) + return + firmware_task_match = re.fullmatch( + r"/api/firmware-workbench/tasks/(?P[a-z0-9-]+)", path + ) + if firmware_task_match: + try: + self._json( + self.firmware_workbench.task(firmware_task_match.group("task_id")) + ) + except FirmwareWorkbenchError as exc: + self._json( + {"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status) + ) + return + firmware_artifact_download_match = re.fullmatch( + r"/api/firmware-workbench/artifacts/(?P[a-z0-9-]+)/download", + path, + ) + if firmware_artifact_download_match: + self._serve_firmware_download( + "artifact", firmware_artifact_download_match.group("artifact_id") + ) + return + firmware_release_download_match = re.fullmatch( + r"/api/firmware-workbench/releases/(?P[a-z0-9-]+)/download", + path, + ) + if firmware_release_download_match: + self._serve_firmware_download( + "release", firmware_release_download_match.group("release_id") + ) + return + firmware_artifact_match = re.fullmatch( + r"/api/firmware-workbench/artifacts/(?P[a-z0-9-]+)", path + ) + if firmware_artifact_match: + try: + self._json( + self.firmware_workbench.artifact( + firmware_artifact_match.group("artifact_id") + ) + ) + except FirmwareWorkbenchError as exc: + self._json( + {"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status) + ) + return if path == "/api/runtime": self._json(self.service.runtime_info()) return + if path == "/api/modes/schedule/status": + query = parse_qs(parsed_url.query) + try: + vehicle = self.service.vehicle_profiles.get(str(query.get("vehicle_id", [""])[0]) or None) + self._json(public_schedule_status(self.service.mode_schedule_runner.status(vehicle.id))) + except ValueError as exc: + self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST) + return if path == "/api/vehicle-display-sync": self._json(self.vehicle_display_sync.snapshot(self._extension_health())) return @@ -581,6 +656,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler): return if not self._require_json_content_type(): return + if path == "/api/firmware-workbench/tasks" or path.startswith( + "/api/firmware-workbench/" + ): + self._handle_firmware_workbench_post(path) + return if path in { "/api/hardware/access/request", "/api/hardware/access/release", @@ -1816,8 +1896,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler): re.search( r"然后|随后|接着|之后|" r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)" - r"(?:ms|s|毫秒|秒|分钟)", + r"(?:毫秒|秒(?:钟)?|分钟|小时|ms(?![A-Za-z])|s(?![A-Za-z]))", residual, + flags=re.IGNORECASE, ) ) ): @@ -2953,6 +3034,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler): HTTPStatus.BAD_REQUEST, ) return + firmware_artifact_match = re.fullmatch( + r"/api/firmware-workbench/artifacts/(?P[a-z0-9-]+)", + path, + ) + if firmware_artifact_match is not None: + try: + self._json( + self.firmware_workbench.delete_artifact( + firmware_artifact_match.group("artifact_id") + ), + HTTPStatus.OK, + ) + except FirmwareWorkbenchError as exc: + self._json( + {"error": exc.code, "message": str(exc)}, + HTTPStatus(exc.status), + ) + return prefix = "/api/mode-profiles/" if not path.startswith(prefix): self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) @@ -3745,6 +3844,70 @@ class SeatDemoHandler(BaseHTTPRequestHandler): raise ValueError("request body must be a JSON object") return payload + def _handle_firmware_workbench_post(self, path: str) -> None: + try: + payload = self._read_json_body(max_bytes=16 * 1024) + if path == "/api/firmware-workbench/tasks": + result = self.firmware_workbench.start_task( + str(payload.get("instruction", "")), + str(payload.get("baseline_id", "time_only")), + ) + self._json(result, HTTPStatus.ACCEPTED) + return + task_action_match = re.fullmatch( + r"/api/firmware-workbench/tasks/(?P[a-z0-9-]+)/(?Pcancel|retry)", + path, + ) + if task_action_match: + task_id = task_action_match.group("task_id") + if task_action_match.group("action") == "cancel": + self._json(self.firmware_workbench.cancel_task(task_id)) + else: + self._json( + self.firmware_workbench.retry_task(task_id), HTTPStatus.ACCEPTED + ) + return + artifact_publish_match = re.fullmatch( + r"/api/firmware-workbench/artifacts/(?P[a-z0-9-]+)/publish", + path, + ) + if artifact_publish_match: + self._json( + self.firmware_workbench.publish_artifact( + artifact_publish_match.group("artifact_id") + ) + ) + return + self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + except (ValueError, json.JSONDecodeError) as exc: + self._json( + {"error": "invalid_request", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + except FirmwareWorkbenchError as exc: + self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)) + + def _serve_firmware_download(self, kind: str, item_id: str) -> None: + try: + if kind == "release": + path, filename = self.firmware_workbench.resolve_release_download(item_id) + else: + path, filename = self.firmware_workbench.resolve_artifact_download(item_id) + content = path.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "private, no-store") + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quote(filename, safe='.-_')}", + ) + self._send_security_headers() + self.end_headers() + self.wfile.write(content) + except FirmwareWorkbenchError as exc: + self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)) + def _require_json_content_type(self) -> bool: if is_json_content_type(self.headers.get("Content-Type", "")): return True @@ -3991,7 +4154,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler): pass def _authorize(self, path: str) -> bool: - if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS: + if ( + not self.auth.config.enabled + or path in PUBLIC_AUTH_PATHS + or PUBLIC_OTA_DOWNLOAD_PATH.fullmatch(path) + ): return True if self._session_username() is not None: return True @@ -4283,8 +4450,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler): self.end_headers() def _send_security_headers(self) -> None: + allow_same_origin_frame = ( + unquote(urlparse(self.path).path) == "/firmware-workbench.html" + ) self.send_header("X-Content-Type-Options", "nosniff") - self.send_header("X-Frame-Options", "DENY") + self.send_header( + "X-Frame-Options", "SAMEORIGIN" if allow_same_origin_frame else "DENY" + ) self.send_header("Referrer-Policy", "no-referrer") self.send_header( "Permissions-Policy", @@ -4293,13 +4465,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler): self.send_header( "Content-Security-Policy", "default-src 'self'; " - "script-src 'self' 'wasm-unsafe-eval'; " + "script-src 'self' 'wasm-unsafe-eval' " + "'sha256-+ppBhUx2xqD9lhKvY8pLwUq/ASyG5od8HSvB3kBoYj4='; " "style-src 'self'; " "img-src 'self' data: blob:; " "media-src 'self' blob:; " "frame-src 'self' http://*:3001; " "connect-src 'self' blob: http://172.20.10.6:8889; " - "object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + "object-src 'none'; base-uri 'none'; frame-ancestors " + + ("'self'" if allow_same_origin_frame else "'none'"), ) if self._is_https(): self.send_header( @@ -4439,6 +4613,7 @@ def main() -> None: SeatDemoHandler.extension.close() SeatDemoHandler.calibration.close() SeatDemoHandler.repair.close() + SeatDemoHandler.firmware_workbench.close() if __name__ == "__main__": diff --git a/tests/e2e/mode_rail_browser.cjs b/tests/e2e/mode_rail_browser.cjs new file mode 100644 index 0000000..2f4f848 --- /dev/null +++ b/tests/e2e/mode_rail_browser.cjs @@ -0,0 +1,226 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const [baseURL, evidenceDir] = process.argv.slice(2); +const desktop = { width: 1600, height: 1000 }; + +async function main() { + const browser = await chromium.launch({ + headless: true, + ...(process.env.N7WEB_BROWSER_CHANNEL + ? { channel: process.env.N7WEB_BROWSER_CHANNEL } + : process.platform === "win32" ? { channel: "msedge" } : {}), + }); + const context = await browser.newContext({ viewport: desktop, serviceWorkers: "block" }); + const page = await context.newPage(); + const requests = []; + const errors = []; + const unexpectedWrites = []; + let heldResponse = null; + let nextReply = "success"; + const assistantAnswer = (success) => [ + { type: "action.completed", success, payload: { + status: success ? "succeeded" : "failed", execution_mode: "dry_run", + } }, + { type: "answer.completed", answer: success ? "模式执行测试完成" : "模式执行测试失败" }, + ].map((item) => JSON.stringify(item)).join("\n") + "\n"; + const fulfill = (route, success = true) => { + const pathname = new URL(route.request().url()).pathname; + if (/^\/api\/mode-profiles\/[^/]+\/execute$/.test(pathname)) { + return route.fulfill({ json: { + status: success ? "succeeded" : "failed", + execution_mode: "dry_run", + operation_results: [], + } }); + } + return route.fulfill({ + status: 200, contentType: "application/x-ndjson", body: assistantAnswer(success), + }); + }; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ''}`); + }); + async function handleApiRoute(route) { + const pathname = new URL(route.request().url()).pathname; + if (['/api/hardware/seat-state', '/api/hardware/vehicle-state'].includes(pathname)) { + // This legacy GET may attempt automatic firmware recovery. Never touch + // the physical gateway while verifying browser interactions. + await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}}); + return; + } + // The isolated server deliberately does not launch Codex or publish a + // physical display mirror. Keep these background integration probes local. + if (pathname === '/api/health' || pathname === '/api/extension/display-sync') { + await route.fulfill({json:{status:'ok',assistant:{status:'ready',available:true}}}); + return; + } + const directModeExecution = /^\/api\/mode-profiles\/[^/]+\/execute$/.test(pathname); + if (pathname === "/api/assistant/stream" || directModeExecution) { + requests.push({ pathname, payload: route.request().postDataJSON() }); + if (nextReply === "hold") { heldResponse = route; return; } + const success = nextReply !== "fail"; + nextReply = "success"; + if (directModeExecution && success) { + await route.continue(); + return; + } + if (directModeExecution) { + const response = await route.fetch(); + const payload = await response.json(); + await route.fulfill({ status: 200, json: { ...payload, status: "failed" } }); + return; + } + await fulfill(route, success); + return; + } + if (route.request().method() !== "GET") { + unexpectedWrites.push(route.request().url()); + await route.fulfill({ status: 403, body: "test forbids vehicle writes" }); + return; + } + await route.continue(); + } + await context.route("**/api/**", handleApiRoute); + const rail = page.locator(".mode-chips"); + const office = rail.locator('button[data-mode-profile-id="office"]'); + const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending()); + const waitState = async (id, expectedState) => { + try { + await page.waitForFunction( + ({ id, expectedState }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === expectedState, + { id, expectedState }, + ); + } catch (error) { + const snapshot = await page.evaluate((modeId) => ({ + modeState: document.querySelector(`.mode-chips [data-mode-profile-id="${modeId}"]`)?.dataset.modeState, + resultTitle: document.querySelector('#resultTitle')?.textContent, + runStatus: document.querySelector('#runStatus')?.textContent, + toast: document.querySelector('#toast')?.textContent, + }), id); + throw new Error(`mode ${id} did not reach ${expectedState}: ${JSON.stringify(snapshot)}; browser errors: ${JSON.stringify(errors)}`, { cause: error }); + } + }; + async function expectOneCommand(action, modeId) { + const before = requests.length; + await action(); + await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false'); + await waitIdle(); + await page.waitForFunction( + (id) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.disabled === false, + modeId, + ); + assert.equal(requests.length, before + 1, "gesture must submit exactly once"); + assert.equal(requests.at(-1).pathname, `/api/mode-profiles/${modeId}/execute`); + } + try { + await page.goto(baseURL, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3); + await page.waitForFunction(() => document.body.classList.contains('assistant-chat-enabled')); + await page.locator('[data-execution-mode="dry_run"]').evaluate((button) => button.click()); + await page.waitForFunction(() => window.CognitiveAgentConsole.getExecutionMode() === 'dry_run'); + assert.equal(await page.title(), "511工程-智能座舱"); + assert.equal(new URL(page.url()).pathname, "/"); + assert.ok((await page.locator('body').innerText()).includes("整车孪生界面")); + assert.ok(await rail.evaluate((element) => element.scrollWidth > element.clientWidth)); + const firmwareToggle = page.locator('#firmwareWorkbenchToggle'); + await firmwareToggle.waitFor({ state: 'attached' }); + assert.equal( + await firmwareToggle.evaluate((element) => element.parentElement?.id), + 'v2PanelControlDock', + 'firmware workbench must mount into the dock created after Vue renders', + ); + + // This is a real mouse sequence: pointerdown -> pointerup -> click. The + // prior pointer capture bug cannot be detected by button.dispatchEvent(). + await page.evaluate(() => window.CognitiveAgentAssistant.close()); + await expectOneCommand(() => office.click(), "office"); + await waitState("office", "succeeded"); + assert.equal(await page.locator('#assistantDrawerToggle').getAttribute('aria-expanded'), 'true'); + assert.ok((await page.locator('#assistantMessages').innerText()).includes('进入办公模式')); + + // A small physical hand movement is still a click. + const box = await office.boundingBox(); + await expectOneCommand(async () => { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2); + await page.mouse.up(); + }, "office"); + + // Dragging from a card must scroll, without accidentally running it. + const beforeDrag = requests.length; + const startScroll = await rail.evaluate((element) => element.scrollLeft); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 - 90, box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + assert.ok(await rail.evaluate((element) => element.scrollLeft) > startScroll); + assert.equal(requests.length, beforeDrag); + await rail.evaluate((element) => { element.scrollLeft = 0; }); + await expectOneCommand(() => office.click(), "office"); + + // Keyboard activation and management cards share the same submission path. + await office.focus(); + await expectOneCommand(() => page.keyboard.press('Enter'), "office"); + await office.focus(); + await expectOneCommand(() => page.keyboard.press('Space'), "office"); + await page.locator('#modeGalleryToggle').click(); + await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "nap"); + await waitState("nap", "succeeded"); + + // Busy requests are not duplicated, nor does another card cancel them. + nextReply = 'hold'; + const beforeBusy = requests.length; + await office.click(); + await waitState('office', 'running'); + await office.evaluate((button) => button.click()); + assert.equal(requests.length, beforeBusy + 1); + assert.equal(await office.isDisabled(), true); + await heldResponse.continue(); + nextReply = 'success'; + await waitIdle(); + await waitState('office', 'succeeded'); + nextReply = 'fail'; + await expectOneCommand(() => office.click(), "office"); + await waitState('office', 'failed'); + + // A catalog with few visible cards must also work without overflow. + await rail.evaluate(element => { + for (const child of element.children) { + if (child.dataset.modeProfileId !== 'office') child.style.display = 'none'; + } + }); + await page.waitForFunction(() => {const e=document.querySelector('.mode-chips');return e.scrollWidth <= e.clientWidth;}); + await expectOneCommand(() => office.click(), "office"); + await rail.evaluate(element => { + for (const child of element.children) child.style.removeProperty('display'); + }); + await page.setViewportSize(desktop); + await page.screenshot({path:path.join(evidenceDir,'mode-rail-desktop.png')}); + assert.deepEqual(errors, []); + assert.deepEqual(unexpectedWrites, []); + + // Touch input uses native scrolling and also submits once when tapped. + const touchContext = await browser.newContext({viewport:{width:720,height:1000},hasTouch:true,serviceWorkers:'block'}); + await touchContext.route('**/api/**', handleApiRoute); + const touchPage = await touchContext.newPage(); + await touchPage.goto(baseURL, {waitUntil:'domcontentloaded'}); + await touchPage.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3); + await touchPage.locator('[data-execution-mode="dry_run"]').evaluate((button) => button.click()); + await touchPage.waitForFunction(() => window.CognitiveAgentConsole.getExecutionMode() === 'dry_run'); + const beforeTouch = requests.length; + await touchPage.locator('.mode-chips button[data-mode-profile-id="office"]').tap(); + await touchPage.waitForFunction(() => document.querySelector('.mode-chips [data-mode-profile-id="office"]').dataset.modeState === 'succeeded'); + assert.equal(requests.length,beforeTouch+1); + assert.equal(requests.at(-1).pathname,'/api/mode-profiles/office/execute'); + await touchPage.screenshot({path:path.join(evidenceDir,'mode-rail-touch.png')}); + await touchContext.close(); + console.log('PASS: click, jitter, drag, Enter, Space, gallery, busy, failure, resize, touch; no runtime errors'); + } finally { + await browser.close(); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/e2e/mode_schedule_browser.cjs b/tests/e2e/mode_schedule_browser.cjs new file mode 100644 index 0000000..02c0bec --- /dev/null +++ b/tests/e2e/mode_schedule_browser.cjs @@ -0,0 +1,326 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const [baseURL, evidenceDir] = process.argv.slice(2); + +async function main() { + const browser = await chromium.launch({ + headless: true, + ...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL } + : process.platform === "win32" ? { channel: "msedge" } : {}), + }); + const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, serviceWorkers: "block" }); + const page = await context.newPage(); + const errors = []; + const posted = []; + const responses = []; + let mockedExecution = null; + let statusReads = 0; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`); + }); + await context.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) { + await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } }); + return; + } + if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() }); + if (mockedExecution && /^\/api\/mode-profiles\/[^/]+\/execute$/.test(pathname)) { + assert.equal(request.postDataJSON()?.execution_mode, "dry_run"); + await route.fulfill({ json: mockedExecution }); + return; + } + if (mockedExecution && pathname === "/api/assistant/stream") { + await route.fulfill({ contentType: "application/x-ndjson", body: [ + { type: "action.completed", success: true, payload: mockedExecution }, + { type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" }, + ].map((event) => JSON.stringify(event)).join("\n") + "\n" }); + return; + } + if (mockedExecution && pathname === "/api/modes/schedule/status") { + statusReads += 1; + assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7"); + await route.fulfill({ json: { + ...mockedExecution.schedule_execution, vehicle_id: "denza_n7", + phase_index: 1, repeat_index: 0, completed: false, + status: statusReads === 1 ? "running" : "failed", + error: statusReads === 1 ? null : "QA阶段模拟失败", + } }); + return; + } + if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) { + // Chromium can discard completed streaming response bodies from CDP. + // Fetch the real isolated endpoint and retain its exact response bytes. + const response = await route.fetch(); + const text = await response.text(); + responses.push({ pathname, text, status: response.status() }); + await route.fulfill({ response, body: text }); + return; + } + await route.continue(); + }); + const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending()); + const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname); + try { + await page.goto(baseURL, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled") + && document.querySelectorAll(".mode-chips button").length > 3); + assert.equal(await page.title(), "511工程-智能座舱"); + assert.equal(new URL(page.url()).origin, baseURL); + assert.match(await page.locator("body").innerText(), /整车孪生界面/); + if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) { + await page.locator('#v2SidebarToggle').click(); + } + await page.locator('[data-execution-mode="dry_run"]').click(); + assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run"); + + async function submitDraft(text) { + const responsePromise = responseFor("/api/assistant/stream"); + await page.locator("#utterance").fill(text); + await page.locator("#utterance").press("Enter"); + const response = await responsePromise; + const body = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text; + assert.equal(response.status(), 200, `${text}: ${body}`); + const events = body.trim().split("\n").map((line) => JSON.parse(line)); + await waitIdle(); + const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload; + assert.ok(payload, `Missing draft for ${text}: ${body}`); + assert.equal(payload.status, "succeeded"); + assert.equal(payload.execution_mode, "dry_run"); + const draft = payload.mode_profile_draft; + const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`); + await card.waitFor({ state: "visible" }); + assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count); + return { payload, draft, card, request: posted.filter((item) => item.pathname === "/api/assistant/stream").at(-1).payload }; + } + + // The original failing two-turn request must revise the server-owned + // draft, not attempt immediate hardware execution or replace its seats. + const recline = await submitDraft("构建一个躺平模式"); + const originalActions = recline.payload.actions; + await recline.card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-before.png") }); + const expanded = await submitDraft("在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作"); + assert.deepEqual(expanded.request.mode_profile_draft_context, { + name: recline.draft.name, + source_request_id: recline.draft.source_request_id, + vehicle_id: recline.draft.vehicle_id, + }); + assert.notEqual(expanded.draft.source_request_id, recline.draft.source_request_id); + assert.equal(expanded.draft.draft_revision, 2); + const actionKey = (action) => `${action.capability}:${action.target?.instance || action.target?.zone || ""}`; + const expandedActions = new Map(expanded.payload.actions.map((action) => [actionKey(action), action])); + for (const original of originalActions) { + assert.deepEqual(expandedActions.get(actionKey(original))?.parameters, original.parameters, + "adding devices must preserve all existing action values"); + } + for (const capability of ["climate.set_temperature", "interior_light.set_reading_power", "window.set_position"]) { + assert.ok(expanded.payload.actions.some((action) => action.capability === capability), capability); + } + assert.ok(expanded.payload.actions.some((action) => action.domain === "ambient_light")); + assert.ok(expanded.payload.actions.every((action) => action.domain !== "seat" + || (action.target?.instance || action.target?.zone) === "passenger")); + // The current registered lay-flat template already contains these device + // groups. Repeating the append must be idempotent and must not duplicate + // any existing operation while still producing a new draft revision. + assert.equal(expanded.draft.operation_count, recline.draft.operation_count); + assert.equal(expandedActions.size, expanded.payload.actions.length); + assert.equal(await recline.card.evaluate((card) => card.classList.contains("is-superseded")), true); + assert.equal(await recline.card.locator(".assistant-mode-draft-save").isDisabled(), true); + assert.equal(posted.filter((item) => item.pathname === "/api/mode-profiles").length, 0); + await expanded.card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, "mode-device-addition-after.png") }); + const temperatureEdit = await submitDraft("把躺平模式的空调温度调整到26度"); + assert.equal(temperatureEdit.request.mode_profile_draft_context.source_request_id, expanded.draft.source_request_id); + assert.equal(temperatureEdit.draft.draft_revision, 3); + assert.equal(temperatureEdit.draft.operation_count, expanded.draft.operation_count); + assert.ok(temperatureEdit.payload.actions.some((action) => action.capability === "climate.set_temperature" + && action.parameters.temperature_c === 26)); + + // Exercise the same real assistant endpoint and rendered cards for both + // implicit passenger selection and an explicit driver request. These + // previews stay unsaved, so the timeline save/trial tests remain isolated. + for (const [index, testCase] of [ + { text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" }, + { text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" }, + { text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" }, + ].entries()) { + const responsePromise = responseFor("/api/assistant/stream"); + await page.locator("#utterance").fill(testCase.text); + await page.locator("#utterance").press("Enter"); + const response = await responsePromise; + assert.equal(response.status(), 200); + const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1) + .text.trim().split("\n").map((line) => JSON.parse(line)); + await waitIdle(); + const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload; + assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`); + const draft = payload.mode_profile_draft; + const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions; + const seats = actions.filter((action) => action.capability?.startsWith("seat.")) + .map((action) => action.target_scope || action.target?.instance || action.target?.zone); + assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`); + assert.deepEqual([...new Set(seats)], [testCase.seat]); + const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`); + await card.waitFor({ state: "visible" }); + const actionText = await card.locator(".assistant-mode-draft-row").allTextContents(); + assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n")); + assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n")); + await card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) }); + } + + const savedModes = []; + for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) { + const responsePromise = responseFor("/api/assistant/stream"); + await page.locator("#utterance").fill(text); + await page.locator("#utterance").press("Enter"); + const response = await responsePromise; + assert.equal(response.status(), 200); + const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line)); + await waitIdle(); + const draftEvent = events.find((event) => event.payload?.mode_profile_draft); + assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`); + const draft = draftEvent.payload.mode_profile_draft; + assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1"); + assert.ok(draft.schedule.phases.length >= 2); + const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`); + assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length); + assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count); + assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/); + assert.match(await card.innerText(), /停止动态模式/); + if (index === 0) { + assert.match(await card.innerText(), /副驾/); + assert.doesNotMatch(await card.innerText(), /主驾/); + } else { + const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations) + .filter((operation) => operation.capability === "climate.set_temperature") + .map((operation) => operation.parameters.temperature_c); + assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time"); + for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`)); + } + await card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) }); + + const trialPromise = responseFor("/api/mode-profile-drafts/trial"); + await card.locator(".assistant-mode-draft-trial").click(); + const trialResponse = await trialPromise; + const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text); + assert.equal(trialResponse.status(), 200, JSON.stringify(trial)); + assert.equal(trial.status, "succeeded", JSON.stringify(trial)); + assert.equal(trial.schedule_execution?.status, "completed"); + await page.waitForFunction((requestId) => { + const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`); + return status?.classList.contains("success") || status?.classList.contains("failed"); + }, draft.source_request_id); + assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/); + + const savePromise = responseFor("/api/mode-profiles"); + await card.locator(".assistant-mode-draft-save").click(); + const saveResponse = await savePromise; + const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text); + assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved)); + assert.ok(saved.mode_profile?.id); + savedModes.push(saved.mode_profile); + await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" }); + const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`); + await button.waitFor({ state: "visible" }); + // Saving reveals the card with smooth scrolling. A tooltip correctly + // closes while its rail is still moving, so wait for that motion to end. + await page.evaluate(() => new Promise((resolve) => { + const rail = document.querySelector('.mode-chips'); + let previous = rail.scrollLeft; + let stable = 0; + const sample = () => { + const current = rail.scrollLeft; + stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0; + previous = current; + if (stable >= 12) resolve(); + else requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })); + await page.mouse.move(1, 1); + const buttonBox = await button.boundingBox(); + const railBox = await page.locator('.mode-chips').boundingBox(); + const visibleLeft = Math.max(buttonBox.x, railBox.x); + const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width); + assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail'); + // A native mouse move does not request another smooth scroll, unlike + // locator.hover() which scrolls the target to the center first. + await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2); + const tooltip = page.locator("#modeChipActionPopover"); + await tooltip.waitFor({ state: "visible" }); + assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/); + assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count); + for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name)); + await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 }); + await page.mouse.move(1, 1); + } + + await page.locator("#modeGalleryToggle").click(); + for (const mode of savedModes) { + const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) }); + const details = shell.locator(".mode-gallery-schedule-details"); + await details.locator("summary").click(); + assert.match(await details.innerText(), /开始 · 持续/); + assert.match(await details.innerText(), /不自动回滚/); + } + await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; }); + await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") }); + await page.locator("#modeGalleryClose").click(); + await page.setViewportSize({ width: 720, height: 1000 }); + if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') { + await page.locator('#v2SidebarToggle').click(); + } + await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") }); + assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false); + + // The backend sequence above is real and strictly dry-run. Separately + // verify the asynchronous UI contract with a synthetic running/failed + // response; it must never launch an actual timed hardware execution. + await page.setViewportSize({ width: 1600, height: 1000 }); + const mode = savedModes[1]; + mockedExecution = { + request_id: "browser-running-qa", status: "running", execution_mode: "dry_run", + orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode, + decision_summary: [], trace_events: [], validation: [], model_requests: [], + function_configs: {}, command_plans: {}, saved_presets: [], + schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" }, + }; + const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count(); + await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click(); + await waitIdle(); + await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id); + const actionStatus = page.locator('.assistant-action-status').last(); + assert.match(await actionStatus.innerText(), /(?:已启动动态时间轴|动态时间轴执行中)/); + await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2')); + await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败')); + assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed'); + assert.equal(statusReads, 2); + assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2); + await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") }); + assert.deepEqual(errors, []); + assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run")); + assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2); + assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2); + console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`); + console.log(`Evidence: ${evidenceDir}`); + } catch (error) { + await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") }); + console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`); + throw error; + } finally { + await context.close(); + await browser.close(); + } +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/e2e/test_firmware_ota_server.py b/tests/e2e/test_firmware_ota_server.py new file mode 100644 index 0000000..e6e821d --- /dev/null +++ b/tests/e2e/test_firmware_ota_server.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import hashlib +import json +import threading +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path + +from cognitive_agent.firmware_ota_server import FirmwareOtaHandler, FirmwareOtaRepository + + +class TestFirmwareOtaServer: + def test_latest_download_head_and_resume_are_read_only(self, tmp_path: Path) -> None: + release_id = "fw-rel-123456789abc" + filename = "PassengerSeat_test.zip" + package = (b"vehicle-firmware-package" * 128) + b"end" + release_root = tmp_path / "releases" / release_id + release_root.mkdir(parents=True) + package_path = release_root / filename + package_path.write_bytes(package) + manifest = { + "schema": "n7-ota-release/v1", + "release_id": release_id, + "firmware_filename": filename, + "size_bytes": len(package), + "sha256": hashlib.sha256(package).hexdigest(), + "capabilities": ["时间控制", "座椅加热"], + "published_at": "2026-09-16T13:17:17+08:00", + "status": "published", + "download_url": "http://stale-address.invalid/download", + } + (release_root / "release.json").write_text( + json.dumps(manifest, ensure_ascii=False), encoding="utf-8" + ) + (tmp_path / "latest.json").write_text( + json.dumps(manifest, ensure_ascii=False), encoding="utf-8" + ) + older_release_id = "fw-rel-000000000001" + older_root = tmp_path / "releases" / older_release_id + older_root.mkdir(parents=True) + older_filename = "PassengerSeat_older.zip" + older_package = b"older-firmware-package" + (older_root / older_filename).write_bytes(older_package) + older_manifest = { + **manifest, + "release_id": older_release_id, + "firmware_filename": older_filename, + "size_bytes": len(older_package), + "sha256": hashlib.sha256(older_package).hexdigest(), + "published_at": "2026-09-15T13:17:17+08:00", + } + (older_root / "release.json").write_text( + json.dumps(older_manifest, ensure_ascii=False), encoding="utf-8" + ) + + class Handler(FirmwareOtaHandler): + repository = FirmwareOtaRepository( + tmp_path, "https://n7-ota.example.ts.net/api/firmware-workbench/releases" + ) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_port}" + try: + with urllib.request.urlopen( + f"{base_url}/api/firmware-workbench/releases/latest", timeout=3 + ) as response: + latest = json.loads(response.read().decode("utf-8")) + assert latest["download_url"] == ( + f"https://n7-ota.example.ts.net/api/firmware-workbench/releases/" + f"{release_id}/download" + ) + + with urllib.request.urlopen( + f"{base_url}/api/firmware-workbench/releases", timeout=3 + ) as response: + releases = json.loads(response.read().decode("utf-8")) + assert releases["schema"] == "n7-ota-release-list/v1" + assert releases["count"] == 2 + assert releases["latest_release_id"] == release_id + assert [item["release_id"] for item in releases["releases"]] == [ + release_id, + older_release_id, + ] + assert releases["releases"][1]["download_url"].endswith( + f"/{older_release_id}/download" + ) + + request = urllib.request.Request( + f"{base_url}/api/firmware-workbench/releases/{release_id}/download", + headers={"Range": "bytes=8-31"}, + ) + with urllib.request.urlopen(request, timeout=3) as response: + assert response.status == 206 + assert response.headers["Content-Range"] == f"bytes 8-31/{len(package)}" + assert response.headers["X-Firmware-SHA256"] == manifest["sha256"] + assert response.read() == package[8:32] + + head = urllib.request.Request( + f"{base_url}/api/firmware-workbench/releases/{release_id}/download", + method="HEAD", + ) + with urllib.request.urlopen(head, timeout=3) as response: + assert response.status == 200 + assert int(response.headers["Content-Length"]) == len(package) + assert response.read() == b"" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) diff --git a/tests/e2e/test_firmware_workbench_api.py b/tests/e2e/test_firmware_workbench_api.py new file mode 100644 index 0000000..fa83669 --- /dev/null +++ b/tests/e2e/test_firmware_workbench_api.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import time +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path + +from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService +from cognitive_agent.auth import AuthConfig, AuthRuntime +from cognitive_agent.server import SeatDemoHandler + +ROOT = Path(__file__).resolve().parents[2] + + +class TestFirmwareWorkbenchApi: + @classmethod + def setup_class(cls) -> None: + cls.temporary_directory = tempfile.TemporaryDirectory() + root = Path(cls.temporary_directory.name) + image = root / "Seat_Controller_demo.hex" + image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80) + + def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path: + log("info", f"IAR task {task_id}: 0 errors, 0 warnings") + return image + + cls.workbench = FirmwareWorkbenchService( + ROOT, + artifact_root=root / "workbench", + ota_root=root / "ota", + build_runner=build_runner, + ) + cls.original_workbench = SeatDemoHandler.firmware_workbench + cls.original_auth = SeatDemoHandler.auth + SeatDemoHandler.firmware_workbench = cls.workbench + SeatDemoHandler.auth = AuthRuntime( + AuthConfig( + enabled=False, + username="", + password_hash="", + session_secret=b"t" * 64, + session_ttl_seconds=3600, + login_attempts=5, + login_window_seconds=300, + requests_per_minute=120, + trust_proxy_headers=False, + ) + ) + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), SeatDemoHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.base_url = f"http://127.0.0.1:{cls.server.server_port}" + + @classmethod + def teardown_class(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=2) + cls.workbench.close() + SeatDemoHandler.firmware_workbench = cls.original_workbench + SeatDemoHandler.auth = cls.original_auth + cls.temporary_directory.cleanup() + + @classmethod + def post(cls, path: str, payload: dict[str, object]) -> tuple[int, dict[str, object]]: + request = urllib.request.Request( + f"{cls.base_url}{path}", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps(payload, ensure_ascii=False).encode(), + ) + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read().decode()) + + def test_real_http_task_publish_detail_and_download_flow(self) -> None: + instruction = "增加位置控制、座椅通风和座椅加热功能" + status, created = self.post( + "/api/firmware-workbench/tasks", {"instruction": instruction} + ) + assert status == 202 + task_id = str(created["task"]["id"]) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + with urllib.request.urlopen( + f"{self.base_url}/api/firmware-workbench/tasks/{task_id}", timeout=5 + ) as response: + task = json.loads(response.read().decode()) + if task["status"] not in {"waiting", "running", "pending_confirmation"}: + break + time.sleep(0.03) + assert task["status"] == "success" + assert task["message"] == "已发布,待车机端下载升级" + with urllib.request.urlopen( + f"{self.base_url}/api/firmware-workbench/releases/latest", timeout=5 + ) as response: + latest = json.loads(response.read().decode()) + assert latest["release_id"] == task["release_id"] + assert latest["download_url"].endswith(f"/{task['release_id']}/download") + + repeated_status, repeated = self.post( + "/api/firmware-workbench/tasks", {"instruction": instruction} + ) + assert repeated_status == 202 + assert repeated["task"]["id"] != task_id + repeated_task_id = str(repeated["task"]["id"]) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + with urllib.request.urlopen( + f"{self.base_url}/api/firmware-workbench/tasks/{repeated_task_id}", timeout=5 + ) as response: + repeated_task = json.loads(response.read().decode()) + if repeated_task["status"] not in {"waiting", "running", "pending_confirmation"}: + break + time.sleep(0.03) + assert repeated_task["status"] == "success" + + artifact_id = task["artifact_id"] + with urllib.request.urlopen( + f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", timeout=5 + ) as response: + artifact = json.loads(response.read().decode()) + assert artifact["status"] == "published" + assert artifact["sha256"] + + release_id = task["release_id"] + with urllib.request.urlopen( + f"{self.base_url}/api/firmware-workbench/releases/{release_id}/download", + timeout=5, + ) as response: + package = response.read() + assert response.headers["Content-Type"] == "application/zip" + assert "attachment" in response.headers["Content-Disposition"] + assert len(package) == artifact["size_bytes"] + + delete_request = urllib.request.Request( + f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}", + method="DELETE", + ) + with urllib.request.urlopen(delete_request, timeout=5) as response: + deleted = json.loads(response.read().decode()) + assert deleted["deleted"] is True + repeated_artifact_id = str(repeated_task["artifact_id"]) + repeated_delete_request = urllib.request.Request( + f"{self.base_url}/api/firmware-workbench/artifacts/{repeated_artifact_id}", + method="DELETE", + ) + with urllib.request.urlopen(repeated_delete_request, timeout=5) as response: + assert json.loads(response.read().decode())["deleted"] is True + with urllib.request.urlopen( + f"{self.base_url}/api/firmware-workbench/state", timeout=5 + ) as response: + state = json.loads(response.read().decode()) + assert [item["id"] for item in state["artifacts"]] == [ + "fw-art-time-only-baseline" + ] + + with urllib.request.urlopen( + f"{self.base_url}/firmware-workbench.html", timeout=5 + ) as response: + page = response.read().decode() + assert response.headers["X-Frame-Options"] == "SAMEORIGIN" + assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"] + assert "座椅固件开发工作台" in page diff --git a/tests/e2e/test_http_request_security.py b/tests/e2e/test_http_request_security.py index 73617e9..153fe6a 100644 --- a/tests/e2e/test_http_request_security.py +++ b/tests/e2e/test_http_request_security.py @@ -89,6 +89,13 @@ class TestMutationRequestSecurity: except urllib.error.HTTPError as exc: return exc.code, json.loads(exc.read().decode("utf-8")) + def get(self, path: str) -> tuple[int, dict[str, object]]: + try: + with urllib.request.urlopen(f"{self.base_url}{path}", timeout=3) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode("utf-8")) + def test_same_origin_json_assistant_request_is_accepted(self) -> None: status, body = self.post( "/api/assistant/stream", @@ -147,6 +154,29 @@ class TestMutationRequestSecurity: assert status == 200 assert MutationSecurityHandler.last_hardware_bypass is False + def test_vehicle_ota_download_is_public_but_workbench_state_remains_private(self) -> None: + MutationSecurityHandler.auth = _auth(enabled=True) + try: + download_status, download_body = self.get( + "/api/firmware-workbench/releases/fw-rel-missing1234/download" + ) + latest_status, latest_body = self.get( + "/api/firmware-workbench/releases/latest" + ) + state_status, state_body = self.get("/api/firmware-workbench/state") + finally: + MutationSecurityHandler.auth = _auth(enabled=False) + + assert download_status == 404 + assert download_body["error"] == "not_found" + assert latest_status in {200, 404} + if latest_status == 404: + assert latest_body["error"] == "not_found" + else: + assert latest_body["download_url"] + assert state_status == 401 + assert state_body["error"] == "authentication_required" + def test_hardware_bypass_requires_auth_enabled_and_direct_loopback(self) -> None: handler = object.__new__(SeatDemoHandler) handler.client_address = ("127.0.0.1", 12345) diff --git a/tests/e2e/test_mode_rail_browser.py b/tests/e2e/test_mode_rail_browser.py new file mode 100644 index 0000000..da0fe70 --- /dev/null +++ b/tests/e2e/test_mode_rail_browser.py @@ -0,0 +1,104 @@ +"""Exercise real pointer/click dispatch on the complete console in Chromium.""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import threading +from http import HTTPStatus +from http.server import ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch +from urllib.parse import urlparse + +import pytest + +from cognitive_agent.application.seat_service import SeatDemoService +from cognitive_agent.auth import AuthConfig, AuthRuntime +from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler + + +def test_mode_rail_real_browser_interactions(tmp_path: Path) -> None: + node = shutil.which("node") + if not node: + pytest.skip("Node.js is required for the browser interaction regression") + env = dict(os.environ) + bundled_modules = Path(node).resolve().parent.parent / "node_modules" + if bundled_modules.is_dir(): + env["NODE_PATH"] = os.pathsep.join( + item for item in (str(bundled_modules), env.get("NODE_PATH")) if item + ) + available = subprocess.run( + [node, "-e", "require.resolve('playwright')"], + env=env, + capture_output=True, + encoding="utf-8", + check=False, + ) + if available.returncode: + pytest.skip("Playwright is required for the real browser regression") + with patch.dict( + os.environ, + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + "COGNITIVE_AGENT_EXECUTION_MODE": "dry_run", + }, + ): + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + + class BrowserHandler(SeatDemoHandler): + auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False)) + + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/api/runtime": + runtime = self.service.runtime_info() + runtime["execution_mode"] = "dry_run" + self._json(runtime) + return + if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}: + self._json({"status": "unavailable", "hardware_execution_allowed": False}) + return + super().do_GET() + + def do_POST(self) -> None: + path = urlparse(self.path).path + if re.fullmatch(r"/api/mode-profiles/[a-z0-9_-]+/execute", path): + super().do_POST() + return + # Assistant requests are intercepted; unexpected writes fail closed. + self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN) + + def log_message(self, *_args: object) -> None: + pass + + BrowserHandler.service = service + server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + completed = subprocess.run( + [ + node, + str(Path(__file__).with_name("mode_rail_browser.cjs")), + f"http://127.0.0.1:{server.server_port}", + str(tmp_path), + ], + env=env, + capture_output=True, + encoding="utf-8", + timeout=120, + check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + service.close() diff --git a/tests/e2e/test_mode_schedule_browser.py b/tests/e2e/test_mode_schedule_browser.py new file mode 100644 index 0000000..2530226 --- /dev/null +++ b/tests/e2e/test_mode_schedule_browser.py @@ -0,0 +1,109 @@ +"""Full browser -> deterministic assistant -> schedule trial/save, without hardware.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import threading +from http import HTTPStatus +from http.server import ThreadingHTTPServer +from pathlib import Path +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from cognitive_agent.application.codex_assistant import CodexAssistantService +from cognitive_agent.application.seat_service import SeatDemoService +from cognitive_agent.auth import AuthConfig, AuthRuntime +from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler + + +def test_mode_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None: + node = shutil.which("node") + if not node: + runtime = Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "node" / "bin" + node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None) + if not node: + pytest.skip("Node.js is required for the schedule browser regression") + env = dict(os.environ) + modules = Path(node).resolve().parent.parent / "node_modules" + if modules.is_dir(): + env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value) + probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False) + if probe.returncode: + pytest.skip("Playwright is required for the schedule browser regression") + + with patch.dict(os.environ, { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + "COGNITIVE_AGENT_EXECUTION_MODE": "dry_run", + "COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"), + }): + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + # A failed deterministic route must fail visibly, never launch a model or + # make network calls to an external assistant during the browser test. + client = MagicMock() + client.available = False + client.status = "test-offline" + assistant = CodexAssistantService(client, fast_implicit_controls=True) + forbidden = [] + + class BrowserHandler(SeatDemoHandler): + auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False)) + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}: + # Both legacy GET paths can touch physical adapters or firmware. + self._json({"status": "unavailable", "hardware_execution_allowed": False}) + return + if path == "/api/health": + self._json({"status": "ok", "assistant": {"status": "ready", "available": True}}) + return + super().do_GET() + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/api/extension/display-sync": + self._json({"status": "ok"}) + return + if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}: + forbidden.append(path) + self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN) + return + super().do_POST() + + def _read_json_body(self, **kwargs): + payload = super()._read_json_body(**kwargs) + if payload.get("execution_mode", "dry_run") != "dry_run": + forbidden.append(f"execution_mode:{payload.get('execution_mode')}") + raise ValueError("Browser regression permits dry_run only") + return payload + + def log_message(self, *_args: object) -> None: + pass + + BrowserHandler.service = service + BrowserHandler.assistant = assistant + server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + completed = subprocess.run([ + node, str(Path(__file__).with_name("mode_schedule_browser.cjs")), + f"http://127.0.0.1:{server.server_port}", str(tmp_path), + ], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert not forbidden, forbidden + assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + assistant.close() + service.close() diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index cafa5a5..053f8f4 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -52,6 +52,7 @@ class SeatWebApiTests(unittest.TestCase): "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", }, clear=False, ): @@ -328,6 +329,37 @@ class SeatWebApiTests(unittest.TestCase): self.assertIn("imported_count", payload["mode_scene_ingress"]) self.assertNotIn("modes", payload) + def test_schedule_status_stays_authenticated_and_sanitized_beside_public_catalog(self) -> None: + endpoint = f"{self.base_url}/api/modes/schedule/status?vehicle_id=denza_n7" + unauthenticated = urllib.request.build_opener() + with self.assertRaises(urllib.error.HTTPError) as caught: + unauthenticated.open(endpoint, timeout=3) + self.assertEqual(caught.exception.code, 401) + state = { + "status": "running", "vehicle_id": "denza_n7", "session_id": "conv-mode-schedule-api", + "run_id": "schedule-private-safe-id", "phase_index": 0, "repeat_index": 0, + "phase_count": 2, "repeat_count": 2, "executed_phases": 0, + "elapsed_ms": 250, "total_duration_ms": 6000, "completed": False, + "phase_results": [{"raw_can": "private-command-bytes"}], + "hardware_client_id": "private-bench-client", + "error": "private transport stack or secret", + } + with ( + patch.object(self.service.mode_schedule_runner, "status", return_value=state) as status, + self.opener.open(endpoint, timeout=3) as response, + ): + payload = json.loads(response.read().decode("utf-8")) + status.assert_called_once_with("denza_n7") + self.assertEqual(payload["status"], "running") + self.assertEqual(payload["phase_count"], 2) + self.assertEqual(payload["total_duration_ms"], 6000) + self.assertEqual(payload["completed"], False) + self.assertIn("后续阶段已停止", payload["error"]) + self.assertNotIn("phase_results", payload) + self.assertNotIn("hardware_client_id", payload) + self.assertNotIn("private-command-bytes", json.dumps(payload)) + self.assertNotIn("private transport", json.dumps(payload)) + def test_user_mode_delete_endpoint_removes_only_user_profiles(self) -> None: original_registry = self.service.mode_profiles with tempfile.TemporaryDirectory() as directory: @@ -1353,7 +1385,7 @@ operations: self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn( - 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"', + 'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"', service_worker, ) self.assertIn('/console-v2.css?v=delivery-state-69', service_worker) @@ -1363,6 +1395,9 @@ operations: self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker) self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker) self.assertIn('"/app.js?v=dual-seat-145-n7-dispatch"', service_worker) + self.assertIn('/firmware-workbench-entry.css?v=1', service_worker) + self.assertIn('/firmware-workbench-entry.js?v=2', service_worker) + self.assertIn('/firmware-workbench.html', service_worker) self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker) self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker) self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker) @@ -2601,6 +2636,15 @@ operations: if item["type"] == "validated_commands" ] self.assertTrue(validated_payloads) + # The local-first stage publishes known IR controls before CAN parsing; + # subsequent evidence must include the complete combined command set. + self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"}) + self.assertTrue( + all( + "self_extension" in payload["function_configs"] + for payload in validated_payloads + ) + ) self.assertTrue( any( {"native_climate", "self_extension"}.issubset( @@ -2861,11 +2905,16 @@ operations: self.assertEqual(events[-1]["type"], "error") self.assertEqual(events[-1]["error"], "can_transport_failed") - validated = [ + validated_payloads = [ item["payload"] for item in events if item["type"] == "validated_commands" - ][-1] + ] + self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"}) + validated = next( + payload for payload in validated_payloads + if {"native_climate", "self_extension"}.issubset(payload["function_configs"]) + ) self.assertEqual(validated["status"], "processing") self.assertEqual( set(validated["function_configs"]), @@ -3238,6 +3287,31 @@ operations: planner.assert_called_once() self.assertEqual(planner.call_args.args[0]["utterance"], utterance) + def test_peripheral_timing_aliases_preserve_sequence_before_local_action_execution(self) -> None: + handler = object.__new__(SeatDemoHandler) + handler.service = SimpleNamespace(is_mode_profile_configuration_request=lambda _text: False) + capabilities = [ + {"id": identity, "status": "learned", "device_id": "third-party-light", + "device_name": "第三方氛围灯", "card_uid": "1A53DD95", "action": action, "slot": slot} + for identity, action, slot in (("on", "开启灯光", 1), ("red", "红色灯光", 2), ("off", "关闭灯光", 3)) + ] + handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: capabilities) + handler.nfc = SimpleNamespace(snapshot=lambda: { + "reader": {"online": True}, "card": {"action": "present", "uid": "1A53DD95"}, + }) + for duration in ("5s", "5S", "500ms", "500MS", "1.5s", "5秒", "半分钟"): + with ( + self.subTest(duration=duration), + patch.object(handler, "_execute_active_peripheral_request", return_value={"status": "succeeded"}) as planner, + patch.object(handler, "_execute_local_peripheral_actions", side_effect=AssertionError("sequence flattened")), + ): + result = handler._execute_request_with_peripherals("/api/requests", { + "utterance": f"打开第三方氛围灯,红灯闪烁{duration}后,最后关闭第三方氛围灯", + "provider": "local", "execution_mode": "dry_run", + }) + self.assertEqual(result["status"], "succeeded") + planner.assert_called_once() + def test_post_request_returns_complete_dry_run(self) -> None: request = urllib.request.Request( f"{self.base_url}/api/requests", diff --git a/tests/integration/test_assistant_capability_execution_matrix.py b/tests/integration/test_assistant_capability_execution_matrix.py index 839edbf..59a8dc3 100644 --- a/tests/integration/test_assistant_capability_execution_matrix.py +++ b/tests/integration/test_assistant_capability_execution_matrix.py @@ -15,6 +15,7 @@ from cognitive_agent.application.codex_assistant import ( CodexAssistantService, CodexVehicleToolError, ) +from cognitive_agent.application.mode_profile import ModeProfileRegistry from cognitive_agent.application.seat_service import SeatDemoService ROOT = Path(__file__).resolve().parents[2] @@ -472,6 +473,10 @@ def vehicle_service( os.environ["COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH"] = str(memory_path) service = SeatDemoService(ROOT) service.artifact_root = Path(tmp_path_factory.mktemp("assistant-matrix-runs")) + service.mode_profiles = ModeProfileRegistry( + ROOT / "config" / "modes", + user_root=Path(tmp_path_factory.mktemp("assistant-matrix-modes")), + ) yield service service.close() if previous_memory_path is None: @@ -715,9 +720,16 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes( for mode_index, mode in enumerate(modes): assert isinstance(mode, Mapping) + profile = vehicle_service.mode_profiles.get_profile(str(mode["id"]), "denza_n7") + has_driver = any( + operation.domain == "seat" and operation.target_scope in {"driver", "front_left"} + for operation in profile.operations + ) for scope_index, scope in enumerate( ("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅") ): + if has_driver and scope in {"主驾", "驾驶员"}: + continue # Valid dual-seat subsets have a separate exact-action matrix. message = f"请让{scope}进入{mode['name']}" result = vehicle_service.preview_native_can( message, @@ -754,7 +766,7 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes( def test_preview_and_process_share_mode_target_safety_gate( vehicle_service: SeatDemoService, ) -> None: - message = "主驾开启小憩模式" + message = "主驾开启零重力模式" direct = vehicle_service.process( message, provider="local", diff --git a/tests/integration/test_custom_mode_creation_matrix.py b/tests/integration/test_custom_mode_creation_matrix.py index 9ca9d2d..bc4db1a 100644 --- a/tests/integration/test_custom_mode_creation_matrix.py +++ b/tests/integration/test_custom_mode_creation_matrix.py @@ -754,7 +754,11 @@ def test_incomplete_ambiguous_or_unsupported_scene_never_creates_partial_draft( assert result["status"] != "succeeded" assert result.get("mode_profile_draft") is None - assert result.get("orchestration_route") != "mode_profile_create_preview" + # Seat policy can reject on the creation route before compiling any action. + assert not (result.get("intent_ir") or {}).get("spec", {}).get("actions") + assert not result.get("actions") + assert result.get("command_plan") is None + assert not result.get("command_plans") assert result.get("validation") failure = result.get("mode_creation_failure") assert isinstance(failure, dict) diff --git a/tests/integration/test_mode_creation_seat_policy.py b/tests/integration/test_mode_creation_seat_policy.py new file mode 100644 index 0000000..f46b62b --- /dev/null +++ b/tests/integration/test_mode_creation_seat_policy.py @@ -0,0 +1,471 @@ +"""Creation selects one seat by default without remapping incompatible actuators.""" + +from __future__ import annotations + +import json +import queue +from copy import deepcopy +from unittest.mock import MagicMock, patch + +import pytest +from jsonschema import Draft202012Validator + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.scene_composer import normalize_scene_output +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + + +@pytest.fixture +def policy_service(tmp_path, monkeypatch): + for variable in ( + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", + ): + monkeypatch.setenv(variable, "false") + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + user_root = tmp_path / "user-modes" + service.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", user_root=user_root, + ) + try: + with ( + patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical CAN forbidden")) as reserve, + patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT forbidden")) as mqtt, + ): + yield service, AssistantCapabilityCatalog(service), user_root + reserve.assert_not_called() + mqtt.assert_not_called() + finally: + service.close() + + +def _actions(result): + return (result.get("intent_ir") or {}).get("spec", {}).get("actions", []) + + +def _seat_scopes(result): + return { + action["target"]["instance"] for action in _actions(result) + if action["domain"] == "seat" + } + + +def _operation_key(operation): + target = operation.get("target_scope") + if target is None: + raw_target = operation.get("target") or {} + target = raw_target.get("instance") or raw_target.get("zone") + return ( + operation["capability"], target, + json.dumps(operation["parameters"], ensure_ascii=False, sort_keys=True), + ) + + +def _preview(service, message, *, plan=None, draft=None): + return service.preview_native_can( + message, provider="local", conversation_id="conv-seat-policy", + execution_mode="hardware", vehicle_id="denza_n7", + assistant_plan=plan, mode_profile_draft_context=draft, + ) + + +def _assert_created(result, scopes): + assert result["status"] == "succeeded", result.get("validation") + assert result.get("mode_profile_draft"), result + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert _seat_scopes(result) == set(scopes) + schedule = result.get("schedule") + if schedule: + for phase in schedule["phases"]: + assert { + op["target_scope"] for op in phase["operations"] if op["domain"] == "seat" + } <= set(scopes) + assert result["mode_profile_draft"]["schedule"] == schedule + + +def _assistant_preview(service, catalog, message, *, client=None): + if client is None: + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast = catalog.fast_answer(message, snapshot) + results, events = [], [] + + def execute(instruction, progress, assistant_plan=None): + result = _preview(service, instruction, plan=assistant_plan) + results.append(result) + return result + + response = assistant.stream( + message, conversation_id="conv-seat-policy", principal="seat-policy-test", history=[], + authoritative_answer=fast.answer if fast else None, + emit=lambda event: events.append(event) or True, + execute_control=execute, capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + assert len(results) == 1, (response, results) + return response, results[0], client + + +@pytest.mark.parametrize("name", [ + "阅读模式", "观影模式", "办公模式", "座椅舒适模式", "放松模式", "座椅动态模式", "上车预热模式", +]) +@pytest.mark.parametrize(("prefix", "scopes"), [ + ("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}), +]) +def test_registered_modes_use_selected_seat_through_assistant_fast_path(policy_service, name, prefix, scopes): + service, catalog, user_root = policy_service + response, preview, client = _assistant_preview(service, catalog, f"构建一个{prefix}{name}") + assert response["success"] is True, (response, preview) + _assert_created(preview, scopes) + client.start_thread.assert_not_called() + client.start_turn.assert_not_called() + client.start_structured_turn.assert_not_called() + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize(("message", "scopes"), [ + ("构建一个两个座椅的阅读模式", {"driver", "passenger"}), + ("构建一个两张座椅的阅读模式", {"driver", "passenger"}), + ("构建一个两座椅的阅读模式", {"driver", "passenger"}), + ("构建一个前排两座的阅读模式", {"driver", "passenger"}), + ("构建一个副驾驶席阅读模式", {"passenger"}), + ("构建一个副驾驶位阅读模式", {"passenger"}), + ("构建一个副驾驶座椅阅读模式", {"passenger"}), + ("构建一个两个座椅的动态模式", {"driver", "passenger"}), + ("构建一个两张座椅的动态模式", {"driver", "passenger"}), + ("构建一个两座椅的动态模式", {"driver", "passenger"}), + ("构建一个前排两座的动态模式", {"driver", "passenger"}), + ("构建一个副驾驶座椅动态模式", {"passenger"}), +]) +def test_registered_seat_aliases_select_correct_targets_without_model(policy_service, message, scopes): + service, catalog, _ = policy_service + response, preview, client = _assistant_preview(service, catalog, message) + assert response["success"] is True, ( + response["answer"], preview.get("validation"), preview.get("mode_profile_draft"), + ) + _assert_created(preview, scopes) + assert response["route"] == "scene_template" + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + + +@pytest.mark.parametrize("template_id", [ + "reading", "cinema", "camping", "quiet-night", "reception", "romantic", + "winter-warmth", "summer-cooling", "long-trip-comfort", "relaxation", "seat-comfort", + "seat-dynamic", "boarding-warmup", +]) +@pytest.mark.parametrize(("prefix", "scope"), [("", "passenger"), ("主驾", "driver")]) +def test_trusted_template_selection_keeps_nonseat_devices_and_native_parameters(policy_service, template_id, prefix, scope): + service, _, _ = policy_service + template = service.scene_templates.get_template(template_id) + original = deepcopy(template.plan()) + result = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan()) + _assert_created(result, {scope}) + raw_operations = ( + [op for phase in original["schedule"]["phases"] for op in phase["operations"]] + if original.get("schedule") else [op.to_yaml() for op in template.operations] + ) + expected = [ + _operation_key(op) for op in raw_operations + if op["domain"] != "seat" or op["target_scope"] == scope + ] + assert sorted(map(_operation_key, _actions(result))) == sorted(expected) + assert template.plan() == original, "projecting a request must not mutate the registered template" + + +def _model_output(scopes, *, dynamic=False): + requests = [] + if "driver" in scopes: + requests.append({ + "capability": "seat.set_driver_massage_native", "target_scope": "driver", + "parameters": {"action": "mode", "mode": "relax"}, + }) + if "passenger" in scopes: + requests.append({ + "capability": "seat.set_backrest_recline", "target_scope": "passenger", + "parameters": {"position_pct": 45}, + }) + requests.append({ + "capability": "climate.set_temperature", "target_scope": "vehicle", + "parameters": {"temperature_c": 24}, + }) + if not dynamic: + return {"requests": requests, "ambiguities": [], "confidence": 0.95} + return { + "requests": [], "ambiguities": [], "confidence": 0.95, + "schedule": { + "repeat_count": 2, + "phases": [ + {"id": "relax", "name": "舒缓", "duration_ms": 30000, "requests": deepcopy(requests)}, + {"id": "support", "name": "支撑", "duration_ms": 60000, "requests": deepcopy(requests)}, + ], + }, + } + + +@pytest.mark.parametrize("dynamic", [False, True]) +@pytest.mark.parametrize(("prefix", "scopes"), [ + ("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}), +]) +def test_unregistered_model_plan_preserves_correct_explicit_or_default_targets(policy_service, dynamic, prefix, scopes): + service, catalog, _ = policy_service + plan = normalize_scene_output( + _model_output(scopes, dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic, + ) + dynamic_label = "动态" if dynamic else "" + result = _preview(service, f"构建一个{prefix}星河释压{dynamic_label}模式", plan=plan) + _assert_created(result, scopes) + assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition" + + +@pytest.mark.parametrize("dynamic", [False, True]) +@pytest.mark.parametrize("prefix", ["", "主驾", "副驾"]) +def test_unregistered_model_cannot_invent_cross_seat_actions(policy_service, dynamic, prefix): + service, catalog, user_root = policy_service + plan = normalize_scene_output( + _model_output({"driver", "passenger"}, dynamic=dynamic), + catalog.scene_composition_context(), dynamic=dynamic, + ) + try: + result = _preview(service, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", plan=plan) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert result["hardware_execution_allowed"] is False + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize(("prefix", "requested_scope", "unexpected_scope"), [ + ("", "passenger", "driver"), ("主驾", "driver", "passenger"), +]) +def test_unexpected_seat_only_in_later_dynamic_phase_rejects_entire_draft( + policy_service, prefix, requested_scope, unexpected_scope, +): + service, catalog, user_root = policy_service + output = _model_output({requested_scope}, dynamic=True) + output["schedule"]["phases"][1]["requests"] = _model_output({unexpected_scope})["requests"] + plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=True) + try: + result = _preview(service, f"构建一个{prefix}星河释压动态模式", plan=plan) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert not result.get("command_plans") + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize("message", [ + "构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", + "构建一个办公模式,主驾座椅分两个阶段动态舒缓", +]) +def test_existing_mode_binding_cannot_authorize_cross_seat_model_schedule(policy_service, message): + service, catalog, user_root = policy_service + original = service.mode_profiles.get_profile("office", "denza_n7") + revision = service.mode_profiles.profile_revision(original) + plan = normalize_scene_output( + _model_output({"driver", "passenger"}, dynamic=True), + catalog.scene_composition_context(), dynamic=True, + ) + try: + result = _preview(service, message, plan=plan) + except ValueError: + pass + else: + assert result["status"] != "succeeded", "binding an existing mode is not authorization for model-added seats" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert not result.get("command_plans") + assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile("office", "denza_n7")) == revision + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize(("message", "scope"), [ + ("构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", "passenger"), + ("构建一个办公模式,主驾座椅分两个阶段动态舒缓", "driver"), +]) +def test_existing_mode_binding_keeps_valid_selected_seat_schedule_saveable(policy_service, message, scope): + service, catalog, _ = policy_service + plan = normalize_scene_output( + _model_output({scope}, dynamic=True), catalog.scene_composition_context(), dynamic=True, + ) + result = _preview(service, message, plan=plan) + _assert_created(result, {scope}) + draft = result["mode_profile_draft"] + assert draft["existing_mode_id"] == "office" + assert draft["existing_mode_revision"] + saved = service.save_mode_profile_card( + draft["existing_mode_id"], draft["name"], draft["instruction"], + draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + assert saved["mode_profile"]["schedule"] == result["schedule"] + executed = service.execute_mode_profile_card( + saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert executed["status"] == "succeeded" + assert _seat_scopes(executed) == {scope} + + +@pytest.mark.parametrize("dynamic", [False, True]) +def test_default_seat_preference_does_not_force_seat_actions_into_nonseat_scene(policy_service, dynamic): + service, catalog, _ = policy_service + plan = normalize_scene_output( + _model_output(set(), dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic, + ) + result = _preview(service, f"构建一个星河恒温{'动态' if dynamic else ''}模式", plan=plan) + _assert_created(result, set()) + assert all(action["domain"] == "climate" for action in _actions(result)) + + +def test_explicit_atomic_mode_requirements_default_unqualified_seat_to_passenger(policy_service): + service, _, _ = policy_service + result = _preview(service, "构建一个自定义支撑模式,座椅靠背调整到45%,空调24度") + _assert_created(result, {"passenger"}) + assert {_operation_key(action) for action in _actions(result)} == { + ("seat.set_backrest_recline", "passenger", '{"position_pct": 45}'), + # Only the seat default changes; the existing local HVAC zone stays driver. + ("climate.set_temperature", "driver", '{"temperature_c": 24}'), + } + + +@pytest.mark.parametrize("dynamic", [False, True]) +def test_passenger_position_capability_is_not_retargeted_to_driver(policy_service, dynamic): + service, catalog, _ = policy_service + output = _model_output({"passenger"}, dynamic=dynamic) + requests = output["schedule"]["phases"][0]["requests"] if dynamic else output["requests"] + requests[0]["target_scope"] = "driver" + try: + plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=dynamic) + result = _preview(service, f"构建一个主驾星河释压{'动态' if dynamic else ''}模式", plan=plan) + except ValueError: + return + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + + +@pytest.mark.parametrize(("message", "scopes"), [ + ("构建一个主驾空调与座椅舒适模式", {"passenger"}), + ("构建一个主驾阅读灯与座椅舒适模式", {"passenger"}), + ("构建一个副驾空调与主驾座椅舒适模式", {"driver"}), + ("构建一个主驾阅读灯与副驾座椅舒适模式", {"passenger"}), +]) +def test_other_device_zones_do_not_select_or_broaden_seat_scope(policy_service, message, scopes): + service, catalog, _ = policy_service + plan = normalize_scene_output(_model_output(scopes), catalog.scene_composition_context()) + result = _preview(service, message, plan=plan) + _assert_created(result, scopes) + + +@pytest.mark.parametrize(("template_id", "prefix", "scope"), [ + ("reading", "", "passenger"), ("reading", "主驾", "driver"), + ("seat-dynamic", "", "passenger"), ("seat-dynamic", "主驾", "driver"), + ("boarding-warmup", "", "passenger"), ("boarding-warmup", "主驾", "driver"), +]) +def test_save_reload_and_simulated_execution_preserve_selected_seat(policy_service, template_id, prefix, scope): + service, _, user_root = policy_service + template = service.scene_templates.get_template(template_id) + preview = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan()) + _assert_created(preview, {scope}) + draft = preview["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded", saved + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + executed = service.execute_mode_profile_card( + saved["mode_profile"]["id"], conversation_id="conv-seat-policy-replay", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert executed["status"] == "succeeded", executed.get("validation") + assert executed["hardware_execution_allowed"] is False + assert _seat_scopes(executed) == {scope} + assert sorted(map(_operation_key, _actions(executed))) == sorted(map(_operation_key, _actions(preview))) + if preview.get("schedule"): + assert executed["schedule"] == preview["schedule"] + assert executed["schedule_execution"]["completed"] is True + assert all( + _seat_scopes(phase["result"]) <= {scope} + for phase in executed["schedule_execution"]["phase_results"] + ) + + +@pytest.mark.parametrize(("initial_scope", "prefix"), [("driver", "主驾"), ("passenger", "")]) +def test_unrelated_draft_edit_retains_original_seat_and_explicit_edit_may_extend_it(policy_service, initial_scope, prefix): + service, catalog, _ = policy_service + name = f"{prefix}星河释压模式" + built = _preview( + service, f"构建一个{name}", + plan=normalize_scene_output(_model_output({initial_scope}), catalog.scene_composition_context()), + ) + _assert_created(built, {initial_scope}) + draft = built["mode_profile_draft"] + revised = _preview( + service, f"把{name}的空调温度调整到26度", + draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}, + ) + _assert_created(revised, {initial_scope}) + original_seat_ops = {_operation_key(a) for a in _actions(built) if a["domain"] == "seat"} + assert {_operation_key(a) for a in _actions(revised) if a["domain"] == "seat"} == original_seat_ops + added = "副驾座椅通风调整到1档" if initial_scope == "driver" else "主驾座椅通风调整到低档" + draft = revised["mode_profile_draft"] + extended = _preview( + service, f"把{name}的{added}", + draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}, + ) + _assert_created(extended, {"driver", "passenger"}) + assert {_operation_key(a) for a in _actions(extended) if a["domain"] == "seat"} >= original_seat_ops + + +def _structured_client(output): + client = MagicMock() + client.available = True + client.status = "ready" + client.start_thread.return_value = "thread-seat-policy" + client.start_turn.side_effect = AssertionError("scene fell back to unconstrained agent") + + def start(thread_id, text, *, application_context, output_schema, timeout=None): + Draft202012Validator(output_schema).validate(output) + turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-seat-policy", events=queue.Queue()) + turn.events.put({"method": "item/completed", "params": {"item": { + "id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False), + }}}) + turn.events.put({"method": "turn/completed", "params": {"turn": { + "id": turn.turn_id, "status": "completed", "items": [], "error": None, + }}}) + return turn + + client.start_structured_turn.side_effect = start + return client + + +@pytest.mark.parametrize(("prefix", "scopes"), [("", {"passenger"}), ("主驾", {"driver"})]) +@pytest.mark.parametrize("dynamic", [False, True]) +def test_unregistered_assistant_structured_path_uses_seat_policy(policy_service, prefix, scopes, dynamic): + service, catalog, _ = policy_service + client = _structured_client(_model_output(scopes, dynamic=dynamic)) + response, preview, _ = _assistant_preview( + service, catalog, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", client=client, + ) + assert response["success"] is True, (response, preview) + assert response["route"] == "scene_composer" + _assert_created(preview, scopes) + client.start_structured_turn.assert_called_once() + client.start_turn.assert_not_called() diff --git a/tests/integration/test_mode_device_additions.py b/tests/integration/test_mode_device_additions.py new file mode 100644 index 0000000..efc8bd3 --- /dev/null +++ b/tests/integration/test_mode_device_additions.py @@ -0,0 +1,321 @@ +"""A mode device list is an additive configuration edit, never live vehicle control.""" + +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from unittest.mock import MagicMock, patch + +import pytest + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.assistant_plan_guard import MAX_PLAN_REQUESTS +from cognitive_agent.application.codex_assistant import ( + CodexAssistantService, + _authorize_vehicle_request, +) +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + + +@pytest.fixture +def addition_service(tmp_path, monkeypatch): + for variable in ( + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", + ): + monkeypatch.setenv(variable, "false") + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "runs" + user_root = tmp_path / "user-modes" + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + try: + with ( + patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("mode edit reached real CAN")) as reserve, + patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("mode edit reached MQTT")) as mqtt, + patch.object(service.openai, "interpret", side_effect=AssertionError("deterministic mode edit contacted a model")), + ): + yield service, AssistantCapabilityCatalog(service), user_root + reserve.assert_not_called() + mqtt.assert_not_called() + finally: + service.close() + + +def _preview(service, text, *, draft=None, plan=None, conversation="conv-device-additions"): + return service.preview_native_can( + text, provider="local", conversation_id=conversation, vehicle_id="denza_n7", + execution_mode="hardware", mode_profile_draft_context=deepcopy(draft), assistant_plan=plan, + ) + + +def _actions(result): + return (result.get("intent_ir") or {}).get("spec", {}).get("actions", []) + + +def _action_map(result): + return { + (action["capability"], action["target"].get("instance") or action["target"].get("zone")): + action["parameters"] for action in _actions(result) + } + + +def _profile_map(profile): + return {(op.capability, op.target_scope): op.parameters for op in profile.operations} + + +def _seat_actions(mapping): + return {key: value for key, value in mapping.items() if key[0].startswith("seat.")} + + +def _reference(result): + return {key: result["mode_profile_draft"][key] for key in ("name", "source_request_id", "vehicle_id")} + + +def _hashes(root): + return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in root.rglob("*.yaml")} + + +def _seed_old_seat_only_draft(service, *, scope="passenger", name="躺平模式", extra=""): + # A deliberately explicit, seat-only request represents an older legitimate + # preview without relying on ignored developer artifacts or a model stub. + settings = ( + "副驾座椅水平向前调整到20%,副驾靠背调整到45%,副驾座椅坐盆调整到30%,副驾腿托调整到70%" + if scope == "passenger" else "主驾座椅通风调整到低档,主驾座椅加热调整到低档" + ) + result = _preview(service, f"构建一个{name},{settings}{extra}") + _assert_draft(result) + assert {target for _, target in _seat_actions(_action_map(result))} == {scope} + return result + + +def _assert_draft(result): + assert result["status"] == "succeeded", (result.get("mode_creation_failure"), result.get("validation")) + assert result["orchestration_route"] == "mode_profile_create_preview" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result.get("mode_profile_draft") + assert len(_actions(result)) == result["mode_profile_draft"]["operation_count"] + assert len(_actions(result)) <= MAX_PLAN_REQUESTS + assert len(_action_map(result)) == len(_actions(result)), "append duplicated an existing action key" + + +def _assistant(service, catalog, message, *, draft=None): + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast = catalog.fast_answer(message, snapshot) + raw = [] + + def execute(instruction, progress, assistant_plan=None): + result = _preview(service, instruction, draft=draft, plan=assistant_plan) + raw.append(result) + return result + + answer = assistant.stream( + message, conversation_id="conv-device-additions", principal="device-additions-test", history=[], + emit=lambda _event: True, execute_control=execute, + authoritative_answer=fast.answer if fast else None, + capability_snapshot=snapshot, capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), mode_profile_draft_context=draft, + ) + assert len(raw) == 1, answer + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + return answer, raw[0] + + +@pytest.mark.parametrize("message", ["构建一个躺平模式", "帮我构建一个躺平模式吧", "构建一个主驾躺平模式"]) +def test_flat_mode_fast_template_includes_requested_cabin_domains(addition_service, message): + service, catalog, user_root = addition_service + answer, result = _assistant(service, catalog, message) + assert answer["success"] is True, answer + assert answer["route"] == "scene_template" + _assert_draft(result) + assert {"seat", "climate", "ambient_light", "interior_light", "window"} <= { + action["domain"] for action in _actions(result) + } + assert {target for _, target in _seat_actions(_action_map(result))} == ( + {"driver"} if "主驾" in message else {"passenger"} + ) + assert not _hashes(user_root) + + +@pytest.mark.parametrize("message", [ + "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作", + "帮我给躺平模式加上空调、氛围灯、阅读灯和车窗", + "给躺平模式增加空调、氛围灯、阅读灯、车窗的联动", + "躺平模式中补充空调、氛围灯、阅读灯和车窗动作", + "调整躺平模式,加入空调、氛围灯、阅读灯、车窗", +]) +@pytest.mark.parametrize("scope", ["passenger", "driver"]) +def test_device_list_addition_revises_old_draft_without_execution_or_losing_seat_parameters(addition_service, message, scope): + service, catalog, user_root = addition_service + original = _seed_old_seat_only_draft(service, scope=scope) + before = deepcopy(_action_map(original)) + reference = _reference(original) + authorization = _authorize_vehicle_request(message, mode_profile_draft_context=reference) + assert authorization.reason == "explicit_mode_profile_draft_update" + answer, revised = _assistant(service, catalog, message, draft=reference) + assert answer["success"] is True, answer + _assert_draft(revised) + assert _seat_actions(_action_map(revised)) == _seat_actions(before) + assert {"climate", "ambient_light", "interior_light", "window"} <= { + action["domain"] for action in _actions(revised) + } + assert revised["mode_profile_draft"]["revises_source_request_id"] == original["request_id"] + assert revised["mode_profile_draft"]["draft_revision"] == 2 + assert _action_map(original) == before + assert not _hashes(user_root) + + +def test_repeated_device_list_is_idempotent_and_keeps_existing_custom_values(addition_service): + service, _, user_root = addition_service + original = _seed_old_seat_only_draft(service, extra=",空调调整到26度,关闭氛围灯") + message = "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作" + first = _preview(service, message, draft=_reference(original)) + _assert_draft(first) + for key, params in _action_map(original).items(): + assert _action_map(first)[key] == params + second = _preview(service, message, draft=_reference(first)) + _assert_draft(second) + assert _action_map(second) == _action_map(first) + assert second["mode_profile_draft"]["draft_revision"] == 3 + assert not _hashes(user_root) + + +@pytest.mark.parametrize("scope", ["passenger", "driver"]) +def test_saved_mode_addition_changes_only_selected_profile_and_can_be_replayed(addition_service, scope): + service, _, user_root = addition_service + original = _seed_old_seat_only_draft(service, scope=scope) + draft = original["mode_profile_draft"] + saved = service.create_mode_profile_card(draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"]) + mode_id = saved["mode_profile"]["id"] + unrelated = service.mode_profiles.save_mode("不会改变的模式", [{ + "domain": "climate", "capability": "climate.set_temperature", "target_scope": "vehicle", + "parameters": {"temperature_c": 23}, + }], vehicle_id="denza_n7") + unrelated_revision = service.mode_profiles.profile_revision(unrelated) + before = _profile_map(service.mode_profiles.get_profile(mode_id, "denza_n7")) + updated = _preview(service, "在躺平模式中加入空调、氛围灯、阅读灯、车窗等设备动作") + assert updated["status"] == "succeeded", updated.get("validation") + assert updated["orchestration_route"] == "mode_profile_update" + assert updated["execution_mode"] == "dry_run" + assert updated["hardware_execution_allowed"] is False + after = service.mode_profiles.get_profile(mode_id, "denza_n7") + assert _seat_actions(_profile_map(after)) == _seat_actions(before) + assert {"climate", "ambient_light", "interior_light", "window"} <= {op.domain for op in after.operations} + assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile(unrelated.id, "denza_n7")) == unrelated_revision + assert len(_hashes(user_root)) == 2 + replayed = service.execute_mode_profile_card(mode_id, execution_mode="dry_run", vehicle_id="denza_n7") + assert replayed["status"] == "succeeded" + assert _seat_actions(_action_map(replayed)) == _seat_actions(before) + + +@pytest.mark.parametrize("mutation", ["request_id", "vehicle", "name", "conversation"]) +def test_invalid_or_stale_context_never_becomes_live_control_or_changes_saved_mode(addition_service, mutation): + service, _, user_root = addition_service + original = _seed_old_seat_only_draft(service) + reference = _reference(original) + conversation = "conv-device-additions" + if mutation == "request_id": + reference["source_request_id"] = "req-missing-old-source" + elif mutation == "vehicle": + reference["vehicle_id"] = "other_vehicle" + elif mutation == "name": + reference["name"] = "其他模式" + else: + conversation = "conv-another-conversation" + try: + result = _preview(service, "在躺平模式中加入空调和车窗", draft=reference, conversation=conversation) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert not _actions(result) + assert not result.get("command_plans") + assert not _hashes(user_root) + + +@pytest.mark.parametrize("message", [ + "在躺平模式中加入空调和后备箱动作", + "在躺平模式中加入阅读灯和天窗动作", + "在躺平模式中加入空调并把温度调整到99度", +]) +def test_unsupported_or_out_of_range_addition_rejects_all_changes_atomically(addition_service, message): + service, _, user_root = addition_service + original = _seed_old_seat_only_draft(service) + try: + result = _preview(service, message, draft=_reference(original)) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert not result.get("command_plans") + assert result["hardware_execution_allowed"] is False + assert not _hashes(user_root) + + +def test_adding_devices_to_full_twelve_action_draft_never_truncates_original_actions(addition_service): + service, _, user_root = addition_service + operations = [ + ("climate.set_power", "vehicle", {"state": "on"}), + ("climate.set_temperature", "vehicle", {"temperature_c": 24}), + ("climate.set_fan_level", "vehicle", {"level": 2}), + ("climate.set_cycle_mode", "vehicle", {"mode": "internal"}), + ("climate.set_control_mode", "vehicle", {"mode": "auto"}), + ("climate.set_front_defrost", "vehicle", {"state": "off"}), + ("roof.set_shade_position", "vehicle", {"position_pct": 0}), + ("window.set_position", "front_left", {"position_pct": 0}), + ("window.set_position", "front_right", {"position_pct": 0}), + ("ambient_light.set_power", "vehicle", {"state": "off"}), + ("interior_light.set_indoor_power", "vehicle", {"state": "off"}), + ("interior_light.set_reading_power", "all", {"state": "off"}), + ] + plan = { + "detected_domains": sorted({cap.split(".", 1)[0] for cap, _, _ in operations}), + "requests": [{ + "domain": cap.split(".", 1)[0], "capability": cap, "target_scope": target, + "operation": "set", "parameters": [{"name": name, "value": value} for name, value in params.items()], + "duration_ms": None, "reason": "固定目标边界回归。", + } for cap, target, params in operations], + "ambiguities": [], "confidence": 0.95, "decision_summary": ["验证12项原动作保留。"], + } + original = _preview(service, "构建一个满载参数模式", plan=plan) + _assert_draft(original) + assert len(_actions(original)) == 12 + source_path = service.artifact_root / original["request_id"] / "result.json" + source_hash = hashlib.sha256(source_path.read_bytes()).hexdigest() + try: + result = _preview(service, "在满载参数模式中加入副驾座椅通风动作", draft=_reference(original)) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert hashlib.sha256(source_path.read_bytes()).hexdigest() == source_hash + assert not _hashes(user_root) + + +def test_previous_artifact_without_new_metadata_remains_an_editable_server_owned_draft(addition_service): + service, _, user_root = addition_service + original = _seed_old_seat_only_draft(service) + legacy = deepcopy(original) + legacy["mode_profile_draft"].pop("draft_revision", None) + legacy["mode_profile_draft"].pop("generation_source", None) + service._persist(legacy) + result = _preview(service, "在躺平模式中加入空调、氛围灯、阅读灯、车窗", draft=_reference(legacy)) + _assert_draft(result) + assert result["mode_profile_draft"]["draft_revision"] == 2 + assert _seat_actions(_action_map(result)) == _seat_actions(_action_map(original)) + assert not _hashes(user_root) + assert json.loads((service.artifact_root / original["request_id"] / "result.json").read_text(encoding="utf-8"))["request_id"] == original["request_id"] diff --git a/tests/integration/test_mode_draft_parameter_matrix.py b/tests/integration/test_mode_draft_parameter_matrix.py index 255d81e..8156148 100644 --- a/tests/integration/test_mode_draft_parameter_matrix.py +++ b/tests/integration/test_mode_draft_parameter_matrix.py @@ -1237,6 +1237,10 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied( ) -> None: service, user_root = mode_service nap = service.mode_profiles.get_profile("nap", "denza_n7") + selected_operations = [ + operation for operation in nap.operations + if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"} + ] result = _preview_without_hardware( service, "构建一个小憩模式", @@ -1249,15 +1253,16 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied( draft = _assert_successful_preview( result, name=nap.name, - operation_count=len(nap.operations), + operation_count=len(selected_operations), ) assert draft["generation_source"] == "registered_mode_profile" assert draft["existing_mode_id"] == "nap" expected = { (operation.capability, operation.target_scope): operation.parameters - for operation in nap.operations + for operation in selected_operations } actual = _action_map(result) + assert {target for capability, target in actual if capability.startswith("seat.")} == {"passenger"} for (capability, target), parameters in expected.items(): rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target for name, value in parameters.items(): diff --git a/tests/integration/test_mode_schedule_flow.py b/tests/integration/test_mode_schedule_flow.py new file mode 100644 index 0000000..9d215ca --- /dev/null +++ b/tests/integration/test_mode_schedule_flow.py @@ -0,0 +1,670 @@ +"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs.""" + +from __future__ import annotations + +import json +import queue +from copy import deepcopy +from threading import Event +from unittest.mock import MagicMock, patch + +import pytest +from jsonschema import Draft202012Validator + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.mode_schedule_service import public_schedule_status +from cognitive_agent.application.scene_composer import ( + SceneCompositionError, + build_scene_output_schema, + normalize_scene_output, +) +from cognitive_agent.application.schedule_intent import needs_mode_schedule +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService +from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError + + +@pytest.fixture +def schedule_service(tmp_path, monkeypatch): + for variable in ( + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", + ): + monkeypatch.setenv(variable, "false") + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + user_root = tmp_path / "user-modes" + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + with ( + patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve, + patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")), + ): + try: + yield service, AssistantCapabilityCatalog(service), user_root + finally: + service.close() + reserve.assert_not_called() + + +def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None): + if client is None: + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast = catalog.fast_answer(message, snapshot) + results, events, passed_plans = [], [], [] + + def execute(instruction, progress, assistant_plan=None): + passed_plans.append(deepcopy(assistant_plan)) + result = service.preview_native_can( + instruction, provider="local", conversation_id=conversation, + execution_mode="hardware", vehicle_id="denza_n7", + on_commands_validated=progress, assistant_plan=assistant_plan, + ) + results.append(result) + return result + + response = assistant.stream( + message, conversation_id=conversation, principal="schedule-test", history=[], + authoritative_answer=fast.answer if fast else None, + emit=lambda event: events.append(event) or True, + execute_control=execute, capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + assert len(results) == 1, (response, results) + return response, results[0], events, passed_plans, client + + +def create_saved(service, catalog, message="构建一个上车预热的功能模式"): + response, preview, _, _, _ = assistant_preview(service, catalog, message) + assert response["success"] is True, (response, preview) + draft = preview["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + return preview, saved["mode_profile"] + + +@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [ + ("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000), + ("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000), + ("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000), + ("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000), + ("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000), + ("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000), + ("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000), + ("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000), +]) +def test_user_temporal_requests_create_complete_multistage_template_without_model( + schedule_service, message, template_id, count, repeats, elapsed, +): + service, catalog, user_root = schedule_service + response, preview, events, plans, client = assistant_preview(service, catalog, message) + assert response["success"] is True, (response, preview) + assert response["route"] == "scene_template" + assert preview["status"] == "succeeded" + assert preview["execution_mode"] == "dry_run" + assert preview["hardware_execution_allowed"] is False + assert preview["orchestration_route"] == "mode_profile_create_preview" + draft = preview["mode_profile_draft"] + assert draft["source_template_id"] == template_id + assert draft["generation_source"] == "scene_template_copy" + assert draft["operation_count"] == count + expected_schedule = deepcopy(plans[0]["schedule"]) + for phase in expected_schedule["phases"]: + phase["operations"] = [ + op for op in phase["operations"] + if op["domain"] != "seat" or op["target_scope"] == "passenger" + ] + assert draft["schedule"] == preview["schedule"] == expected_schedule + schedule = ModeSchedule.from_dict(preview["schedule"]) + assert schedule.repeat_count == repeats + assert schedule.total_duration_ms == elapsed + actions = preview["intent_ir"]["spec"]["actions"] + assert len(actions) == count + assert len({action["id"] for action in actions}) == count + assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases} + assert len(preview["schedule_phase_previews"]) == 2 + assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"]) + assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases) + assert { + op["target_scope"] for phase in schedule.phases for op in phase.operations + if op["domain"] == "seat" + } == {"passenger"} + assert events + client.start_thread.assert_not_called() + client.start_turn.assert_not_called() + client.start_structured_turn.assert_not_called() + assert not list(user_root.rglob("*.yaml")) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"]) +def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message): + service, catalog, _ = schedule_service + _, preview, _, _, _ = assistant_preview(service, catalog, message) + draft = preview["mode_profile_draft"] + with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")): + trial = service.trial_mode_profile_draft( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert trial["status"] == "succeeded" + assert trial["hardware_execution_allowed"] is False + assert trial["schedule"] == preview["schedule"] + execution = trial["schedule_execution"] + schedule = ModeSchedule.from_dict(preview["schedule"]) + assert execution["completed"] is True + assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms + assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count + assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count + assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count + + +@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"]) +def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message): + service, catalog, user_root = schedule_service + preview, public_profile = create_saved(service, catalog, message) + assert public_profile["schedule"] == preview["schedule"] + assert public_profile["behavior"] == "cyclic" + mode_id = public_profile["id"] + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7") + assert reloaded.schedule.to_dict() == preview["schedule"] + with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")): + executed = service.execute_mode_profile_card( + mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert executed["status"] == "succeeded", executed + assert executed["orchestration_route"] == "mode_schedule_execute" + assert executed["execution_mode"] == "dry_run" + assert executed["hardware_execution_allowed"] is False + assert "mode_profile_draft" not in executed + execution = executed["schedule_execution"] + assert execution["completed"] is True + assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms + assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count + expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count + for expected, actual in zip(expected_phases, execution["phase_results"], strict=True): + phase_result = actual["result"] + assert phase_result["status"] == "succeeded" + assert phase_result["execution_mode"] == "dry_run" + actual_actions = phase_result["intent_ir"]["spec"]["actions"] + assert len(actual_actions) == len(expected.operations) + expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations) + actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions) + assert actual_params == expected_params + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service): + service, catalog, _ = schedule_service + preview, saved = create_saved(service, catalog) + result = service.process( + f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded", result + assert result["orchestration_route"] == "mode_schedule_execute" + assert result["schedule"] == preview["schedule"] + temperatures = [] + for phase in result["schedule_execution"]["phase_results"]: + temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature") + assert temperatures == [28, 24] + + +def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog) + with ( + patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")), + pytest.raises(HardwareAccessRequiredError), + ): + service.execute_mode_profile_card( + saved["id"], execution_mode="hardware", vehicle_id="denza_n7", + conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False, + hardware_client_id="hwc-schedule-unauthorized", + ) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +@pytest.mark.parametrize(("field", "value"), [ + ("name", "其他模式"), ("instruction", "直接执行危险指令"), + ("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"), +]) +def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value): + service, catalog, user_root = schedule_service + _, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式") + draft = preview["mode_profile_draft"] + arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")} + arguments[field] = value + with pytest.raises(ValueError): + service.trial_mode_profile_draft(**arguments) + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"]) +def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation): + service, catalog, user_root = schedule_service + template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup") + plan = deepcopy(template["plan"]) + phase = plan["schedule"]["phases"][0] + if mutation == "capability": + phase["operations"][0]["capability"] = "door.set_vehicle_lock" + elif mutation == "duration": + phase["duration_ms"] = 1 + elif mutation == "temperature": + phase["operations"][1]["parameters"]["temperature_c"] = 99 + else: + phase["operations"][3]["target_scope"] = "passenger" + try: + result = service.preview_native_can( + "构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper", + execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, + ) + except ValueError: + result = None + if result is not None: + assert result["status"] != "succeeded" + assert "mode_profile_draft" not in result + assert result.get("hardware_execution_allowed") is not True + assert not list(user_root.rglob("*.yaml")) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog) + profile = service.mode_profiles.get_profile(saved["id"], "denza_n7") + # Represents a stale or externally edited user profile, not a trusted runtime + # capability change. Its structure remains valid, but the target is unsafe. + profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99 + with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")): + result = service.execute_mode_profile_card( + saved["id"], conversation_id="conv-schedule-stale-capability", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert result["status"] == "validation_failed", result + assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID" + assert "mode_profile_draft" not in result + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式") + preview_method = service._preview_mode_schedule + virtual_method = service.native_can_runtime.execute_virtual + execution_started = False + executed_massage_modes = [] + + def validate_then_arm(*args, **kwargs): + nonlocal execution_started + result = preview_method(*args, **kwargs) + execution_started = True + return result + + def fault_on_second_phase(command_plan, function_config): + if execution_started: + for op in function_config["spec"]["operations"]: + if op["capability"] == "seat.set_driver_massage_native": + executed_massage_modes.append(op["parameters"]["mode"]) + if op["parameters"]["mode"] == "slack": + raise RuntimeError("injected lower-level actuator failure") + return virtual_method(command_plan, function_config) + + with ( + patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm), + patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase), + ): + result = service.execute_mode_profile_card( + saved["id"], conversation_id="conv-schedule-injected-failure", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + execution = result["schedule_execution"] + assert result["status"] == "failed", result + assert execution["completed"] is False + assert execution["error"] + assert execution["executed_phases"] == 1 + assert execution["virtual_elapsed_ms"] == 30000 + assert execution["repeat_index"] == 0 + assert len(execution["phase_results"]) == 2 + assert executed_massage_modes == ["relax", "slack"] + assert result["hardware_execution_allowed"] is False + + +def structured_schedule(): + return { + "requests": [], "ambiguities": [], "confidence": 0.96, + "schedule": { + "repeat_count": 2, + "phases": [ + {"id": "warm", "name": "暖舱", "duration_ms": 30000, + "requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]}, + {"id": "settle", "name": "恒温", "duration_ms": 60000, + "requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]}, + ], + }, + } + + +def structured_client(output): + client = MagicMock() + client.available = True + client.status = "ready" + client.start_thread.return_value = "thread-schedule-test" + client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent") + + def start(thread_id, text, *, application_context, output_schema, timeout=None): + Draft202012Validator(output_schema).validate(output) + turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue()) + turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}}) + turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}}) + return turn + + client.start_structured_turn.side_effect = start + return client + + +@pytest.mark.parametrize("message", [ + "构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次", + "构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次", + "构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次", +]) +def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message): + service, catalog, user_root = schedule_service + output = structured_schedule() + client = structured_client(output) + response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client) + assert response["route"] == "scene_composer", response + assert response["success"] is True, (response, preview) + assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy" + client.start_structured_turn.assert_called_once() + client.start_turn.assert_not_called() + assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"] + assert preview["schedule"] == plans[0]["schedule"] + assert preview["schedule"]["repeat_count"] == 2 + assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000] + assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24] + assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000 + assert not list(user_root.rglob("*.yaml")) + + +def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service): + _, catalog, _ = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = structured_schedule() + schema = build_scene_output_schema(context, dynamic=True) + Draft202012Validator(schema).validate(output) + plan = normalize_scene_output(output, context, dynamic=True) + assert plan["ambiguities"] == [] + schedule = ModeSchedule.from_dict(plan["schedule"]) + assert schedule.total_duration_ms == 180000 + assert schedule.operation_count == 2 + assert schedule.phases[0].operations[0]["domain"] == "climate" + assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24} + + +@pytest.mark.parametrize("mutation", [ + "static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability", + "duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration", + "bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability", +]) +def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation): + _, catalog, _ = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = structured_schedule() + schedule = output["schedule"] + phase = schedule["phases"][0] + if mutation == "static-root": + output["requests"] = deepcopy(phase["requests"]) + elif mutation == "unknown-root": + output["execute_now"] = True + elif mutation == "unknown-schedule": + schedule["unbounded"] = True + elif mutation == "unknown-phase": + phase["skip_safety"] = True + elif mutation == "unknown-capability": + phase["requests"][0]["capability"] = "unknown.actuate" + elif mutation == "motion-capability": + phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}} + elif mutation == "duplicate-phase": + schedule["phases"][1]["id"] = phase["id"] + elif mutation == "duplicate-operation": + phase["requests"].append(deepcopy(phase["requests"][0])) + elif mutation == "no-phases": + schedule["phases"] = [] + elif mutation == "empty-phase": + phase["requests"] = [] + elif mutation == "bool-duration": + phase["duration_ms"] = True + elif mutation == "bool-repeat": + schedule["repeat_count"] = True + elif mutation == "long-duration": + phase["duration_ms"] = 900001 + elif mutation == "long-total": + schedule["repeat_count"] = 20 + phase["duration_ms"] = 900000 + elif mutation == "nan-parameter": + phase["requests"][0]["parameters"]["temperature_c"] = float("nan") + with pytest.raises(SceneCompositionError): + normalize_scene_output(output, context, dynamic=True) + + +def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service): + _, catalog, _ = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None} + Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output) + plan = normalize_scene_output(output, context, dynamic=True) + assert not plan["requests"] + assert "schedule" not in plan + assert plan["ambiguities"] + + +def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service): + service, catalog, user_root = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = structured_schedule() + output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"] + plan = normalize_scene_output(output, context, dynamic=True) + assert "metadata" not in plan + with pytest.raises(ValueError, match="未解决"): + service.preview_native_can( + "构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous", + execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, + ) + assert not list(user_root.rglob("*.yaml")) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +@pytest.mark.parametrize(("seat", "allowed_targets"), [ + ("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}), +]) +def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式") + result = service.process( + f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + if result["status"] == "succeeded": + assert result["orchestration_route"] == "mode_schedule_execute" + execution = result["schedule_execution"] + for phase in execution["phase_results"]: + actions = phase["result"]["intent_ir"]["spec"]["actions"] + seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"} + assert seat_targets and seat_targets <= allowed_targets + else: + assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"} + assert not result.get("schedule_execution", {}).get("phase_results") + assert result.get("hardware_execution_allowed") is not True + + +def start_waiting_schedule(service): + """Run only a no-device test callback, holding until the tested stop request.""" + calls = [] + entered = Event() + schedule = ModeSchedule.from_dict({ + "schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1, + "phases": [ + {"id": phase_id, "name": phase_id, "duration_ms": 900000, + "operations": [{"domain": "climate", "capability": "climate.set_temperature", + "target_scope": "vehicle", "operation": "set", + "parameters": {"temperature_c": temperature}, "reason": "test only"}]} + for phase_id, temperature in (("first", 28), ("second", 24)) + ], + }) + + def execute(phase): + calls.append(phase.id) + entered.set() + return {"status": "succeeded"} + + service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status") + assert entered.wait(2) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "running" + return calls + + +def assistant_control(catalog, message, execute): + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + events = [] + response = assistant.stream( + message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[], + emit=lambda event: events.append(event) or True, + execute_control=execute, capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + client.start_thread.assert_not_called() + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + return response, events + + +def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service): + service, _, _ = schedule_service + calls = start_waiting_schedule(service) + result = service.process( + "停止动态模式", provider="local", conversation_id="conv-stop-status", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert result["schedule_execution"]["status"] == "simulated" + assert result["hardware_execution_allowed"] is False + assert service.mode_schedule_runner.status("denza_n7")["status"] == "running" + assert calls == ["first"] + persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8")) + assert persisted["orchestration_route"] == "mode_schedule_stop" + assert persisted["trace_events"] == [] + + +def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service): + service, _, _ = schedule_service + calls = start_waiting_schedule(service) + result = service.process( + "停止动态模式", provider="local", conversation_id="conv-stop-status", + execution_mode="hardware", vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert result["schedule_execution"]["status"] == "cancelled" + assert result["schedule_execution"]["completed"] is False + assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled" + assert service.mode_schedule_runner.close(timeout=1) + assert calls == ["first"] + persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8")) + assert persisted["orchestration_route"] == "mode_schedule_stop" + assert persisted["schedule_execution"]["status"] == "cancelled" + assert persisted["trace_events"] == [] + + +@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [ + ("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"), +]) +def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy( + schedule_service, execution_mode, action_state, runner_state, +): + service, catalog, _ = schedule_service + calls = start_waiting_schedule(service) + + def execute(message, progress): + return service.process( + message, provider="local", conversation_id="conv-stop-status", + execution_mode=execution_mode, vehicle_id="denza_n7", + ) + + response, events = assistant_control(catalog, "停止动态模式", execute) + assert response["success"] is True, response + assert response["route"] == "vehicle_control" + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["state"] == action_state + assert action["payload"]["orchestration_route"] == "mode_schedule_stop" + assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state + assert calls == ["first"] + assert "已完成" not in response["answer"] + assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"] + + +def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service): + service, catalog, _ = schedule_service + preview, profile = create_saved(service, catalog) + start_waiting_schedule(service) + state = service.mode_schedule_runner.status("denza_n7") + state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}] + + def execute(message, progress): + return { + "status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True, + "orchestration_route": "mode_schedule_execute", "schedule_execution": state, + "mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"], + } + + response, events = assistant_control(catalog, f"进入{profile['name']}", execute) + assert response["success"] is True, response + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["state"] == "running" + assert action["payload"]["status"] == "running" + assert action["payload"]["schedule_execution"]["completed"] is False + assert "phase_results" not in action["payload"]["schedule_execution"] + assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False) + assert "尚未完成所有阶段" in action["display_text"] + assert "尚未完成所有阶段" in response["answer"] + completed = next(event for event in events if event["type"] == "work.completed") + assert completed["state"] == "running" + assert "已完成" not in completed["display_text"] + + +def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error(): + state = { + "run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed", + "phase_index": 1, "repeat_index": 0, "completed": False, + "error": "secret-token /private/path/driver.dll CAN payload 123456", + "phase_results": [{"command_plan": {"data_hex": "deadbeef"}}], + "raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"}, + "phase_count": {"nested": "unexpected"}, + } + public = public_schedule_status(state) + assert public["status"] == "failed" + assert public["phase_index"] == 1 + assert public["completed"] is False + assert public["error"] and public["error"] != state["error"] + assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"} + for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"): + assert sensitive not in json.dumps(public, ensure_ascii=False) + + +@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"]) +def test_hvac_circulation_state_is_not_a_temporal_schedule(message): + assert needs_mode_schedule(message) is False diff --git a/tests/integration/test_mode_schedule_natural_language_edit.py b/tests/integration/test_mode_schedule_natural_language_edit.py new file mode 100644 index 0000000..ea1c278 --- /dev/null +++ b/tests/integration/test_mode_schedule_natural_language_edit.py @@ -0,0 +1,278 @@ +"""Natural-language edits cannot flatten or silently broaden dynamic phases.""" + +from __future__ import annotations + +import hashlib +from copy import deepcopy +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.mode_schedule_edit import edit_schedule +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + + +@pytest.fixture +def service(tmp_path): + with patch.dict( + "os.environ", + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + }, + ): + instance = SeatDemoService(REPOSITORY_ROOT) + instance.artifact_root = tmp_path / "runs" + instance.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path / "user-modes", + ) + try: + with patch.object( + instance.hardware_execution, + "reserve", + side_effect=AssertionError("schedule edit reached hardware"), + ): + yield instance + finally: + instance.close() + + +def _build(service): + template = service.scene_templates.get_template("boarding-warmup") + result = service.preview_native_can( + "构建一个上车预热模式", + provider="local", + conversation_id="conv-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=template.plan(), + ) + assert result["status"] == "succeeded", result.get("validation") + return result + + +def _edit(service, built, change): + draft = built["mode_profile_draft"] + reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")} + return service.preview_native_can( + f"把上车预热模式的{change}", + provider="local", + conversation_id="conv-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=reference, + ) + + +def _parameters(schedule, index, capability, target="vehicle"): + return next( + op["parameters"] + for op in schedule["phases"][index]["operations"] + if op["capability"] == capability and op["target_scope"] == target + ) + + +@pytest.mark.parametrize( + "change", + ( + "第二阶段空调温度调整到26度", + "第2个阶段的空调温度变成26度", + "舒适恒温阶段空调温度改为26度", + ), +) +def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change): + built = _build(service) + original = deepcopy(built["schedule"]) + revised = _edit(service, built, change) + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + expected = deepcopy(original) + _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26 + assert revised["schedule"] == expected + assert built["schedule"] == original + assert revised["mode_profile_draft"]["schedule"] == expected + assert revised["mode_profile_draft"]["draft_revision"] == 2 + assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"] + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + + +@pytest.mark.parametrize( + ("change", "expected_hold", "expected_repeat"), + ( + ("第二阶段持续时间调整为3分钟", 180000, 1), + ("第二阶段保持30秒", 30000, 1), + ("第二阶段时长改成2.5分钟", 150000, 1), + ("循环次数改成3次", 300000, 3), + ("重复两次", 300000, 2), + ), +) +def test_draft_time_and_repeat_edits_keep_operations( + service, change, expected_hold, expected_repeat +): + built = _build(service) + revised = _edit(service, built, change) + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold + assert revised["schedule"]["repeat_count"] == expected_repeat + for index in (0, 1): + assert ( + revised["schedule"]["phases"][index]["operations"] + == built["schedule"]["phases"][index]["operations"] + ) + + +def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service): + built = _build(service) + revised = _edit(service, built, "全部阶段空调温度调整到25度") + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + expected = deepcopy(built["schedule"]) + for index in (0, 1): + _parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25 + assert revised["schedule"] == expected + + +@pytest.mark.parametrize( + "change", + ( + "空调温度调整到25度", + "第三阶段空调温度调整到25度", + "第二阶段持续16分钟", + "循环21次", + "重复1.5次", + "持续30秒", + "第二阶段主驾靠背调整到90%", + "第二阶段空调温度调整到99度", + "第二阶段打开后备箱", + "第二阶段空调温度调整到26度并打开后备箱", + ), +) +def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change): + built = _build(service) + original = deepcopy(built["schedule"]) + revised = _edit(service, built, change) + assert revised["status"] in {"needs_confirmation", "validation_failed"} + assert not revised.get("mode_profile_draft") + assert revised["hardware_execution_allowed"] is False + source = service._load_mode_profile_draft_reference( + { + key: built["mode_profile_draft"][key] + for key in ("name", "source_request_id", "vehicle_id") + }, + conversation_id="conv-schedule-edit", + mode_name="上车预热模式", + vehicle_id="denza_n7", + )[1] + assert source["schedule"] == original + + +def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service): + built = _build(service) + draft = built["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"] + ) + mode_id = saved["mode_profile"]["id"] + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + path = Path(profile.source) + before = hashlib.sha256(path.read_bytes()).hexdigest() + rejected = service.preview_native_can( + "把上车预热模式的空调温度调整到26度", + provider="local", + conversation_id="conv-saved-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + assert rejected["status"] == "needs_confirmation" + assert hashlib.sha256(path.read_bytes()).hexdigest() == before + + revised = service.preview_native_can( + "把上车预热模式的第二阶段空调温度调整到26度", + provider="local", + conversation_id="conv-saved-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + assert revised["status"] == "succeeded", revised.get("validation") + assert revised["orchestration_route"] == "mode_profile_update" + assert revised["hardware_execution_allowed"] is False + actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict() + expected = deepcopy(built["schedule"]) + _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26 + assert actual == expected + assert revised["schedule"] == expected + assert revised["mode_profile"]["behavior"] == "cyclic" + + +def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service): + built = _build(service) + revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟") + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + repeated = _edit(service, revised, "重复两次") + assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure") + expected = deepcopy(built["schedule"]) + _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26 + expected["phases"][1]["duration_ms"] = 180000 + expected["repeat_count"] = 2 + assert repeated["schedule"] == expected + assert repeated["mode_profile_draft"]["draft_revision"] == 3 + draft = repeated["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"] + ) + assert ( + service.mode_profiles.get_profile( + saved["mode_profile"]["id"], "denza_n7" + ).schedule.to_dict() + == expected + ) + + +def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service): + source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"] + for phase in source["phases"]: + for operation in phase["operations"]: + if operation["capability"] == "climate.set_temperature": + operation["target_scope"] = "passenger" + schedule = ModeSchedule.from_dict(source) + updated = edit_schedule( + schedule, + "全部阶段空调温度调整到25度", + parse_operations=lambda _: [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "vehicle", + "operation": "set", + "parameters": {"temperature_c": 25}, + } + ], + target_is_explicit=service._mode_update_target_is_explicit, + ) + for index in (0, 1): + assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == { + "temperature_c": 25 + } + assert not any( + op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle" + for op in updated.phases[index].operations + ) + + +def test_phase_motor_duration_cannot_become_a_hold_duration(service): + built = _build(service) + result = _edit(service, built, "第二阶段副驾靠背向后移动3s") + assert result["status"] in {"needs_confirmation", "validation_failed"} + assert not result.get("mode_profile_draft") + + +def test_stage_selector_does_not_authorize_delayed_action_semantics(service): + built = _build(service) + result = _edit(service, built, "第二阶段30秒后空调温度调整到26度") + assert result["status"] == "needs_confirmation" + assert not result.get("mode_profile_draft") diff --git a/tests/integration/test_named_mode_inference_regression.py b/tests/integration/test_named_mode_inference_regression.py index 80c6129..8802bd4 100644 --- a/tests/integration/test_named_mode_inference_regression.py +++ b/tests/integration/test_named_mode_inference_regression.py @@ -273,7 +273,8 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile( draft = result["mode_profile_draft"] assert isinstance(draft, dict) assert draft["name"] == "小憩模式" - assert draft["operation_count"] == len(preset.operations) == 9 + assert draft["operation_count"] == 9 + assert len(preset.operations) == 10 # The registry retains both optional seat variants. assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"} assert draft["existing_mode_id"] == "nap" assert isinstance(draft["existing_mode_revision"], str) @@ -283,6 +284,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile( actions = _intent_actions(result) assert {action["capability"] for action in actions} == { operation.capability for operation in preset.operations + if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"} } == EXPECTED_NAP_CAPABILITIES expected_targets_and_parameters = { operation.capability: ( @@ -292,6 +294,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile( operation.parameters, ) for operation in preset.operations + if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"} } actual_targets_and_parameters = { action["capability"]: ( diff --git a/tests/integration/test_registered_scene_seat_coverage.py b/tests/integration/test_registered_scene_seat_coverage.py new file mode 100644 index 0000000..1b5c7c0 --- /dev/null +++ b/tests/integration/test_registered_scene_seat_coverage.py @@ -0,0 +1,347 @@ +"""Seat-aware built-in defaults must remain safe and preserve saved user intent.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + +COMFORT_TEMPLATE_IDS = ( + "reading", + "cinema", + "camping", + "quiet-night", + "reception", + "romantic", + "winter-warmth", + "summer-cooling", + "long-trip-comfort", + "relaxation", + "seat-comfort", +) +DRIVER_COMFORT_CAPABILITIES = { + "seat.set_driver_ventilation_native", + "seat.set_driver_heating_native", + "seat.set_driver_massage_native", +} + + +@pytest.fixture +def service(tmp_path: Path) -> Iterator[SeatDemoService]: + with patch.dict( + "os.environ", + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + }, + ): + instance = SeatDemoService(REPOSITORY_ROOT) + instance.artifact_root = tmp_path / "runs" + instance.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path / "user-modes", + ) + try: + with patch.object( + instance.hardware_execution, + "reserve", + side_effect=AssertionError("creating or previewing a mode reached real hardware"), + ): + yield instance + finally: + instance.close() + + +def _preview(service: SeatDemoService, instruction: str, *, plan=None) -> dict: + result = service.preview_native_can( + instruction, + provider="local", + conversation_id="conv-registered-seat-coverage", + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=plan, + ) + assert result["status"] == "succeeded", result.get("validation") + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + return result + + +def _action_map(result: dict) -> dict: + return { + ( + action["capability"], + action["target"].get("instance") or action["target"].get("zone"), + ): action["parameters"] + for action in result["intent_ir"]["spec"]["actions"] + } + + +@pytest.mark.parametrize("template_id", COMFORT_TEMPLATE_IDS) +@pytest.mark.parametrize(("seat_prefix", "targets"), ( + ("", {"passenger"}), ("主驾", {"driver"}), + ("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}), +)) +def test_comfort_templates_select_only_requested_seat_without_mutating_seat_seeds( + service: SeatDemoService, + template_id: str, + seat_prefix: str, + targets: set[str], +) -> None: + template = service.scene_templates.get_template(template_id) + original_revision = template.revision + result = _preview(service, f"构建一个{seat_prefix}{template.name}", plan=template.plan()) + actions = _action_map(result) + seat_keys = {key for key in actions if key[0].startswith("seat.")} + assert {target for _, target in seat_keys} == targets + assert { + operation.target_scope for operation in template.operations if operation.domain == "seat" + } == {"driver", "passenger"} + assert all( + operation.capability in DRIVER_COMFORT_CAPABILITIES + for operation in template.operations + if operation.domain == "seat" and operation.target_scope == "driver" + ), "driver positional or timed movement must not enter static comfort defaults" + assert not any( + "massage" in capability for capability, target in seat_keys if target == "passenger" + ) + assert actions == { + (operation.capability, operation.target_scope): operation.parameters + for operation in template.operations + if operation.domain != "seat" or operation.target_scope in targets + } + assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy" + assert service.scene_templates.get_template(template_id).revision == original_revision + + +@pytest.mark.parametrize( + ("mode_id", "mode_name"), + ( + ("office", "办公模式"), + ("nap", "小憩模式"), + ("meditation_space", "冥想空间模式"), + ("leisure_lounger", "休闲躺椅模式"), + ), +) +@pytest.mark.parametrize(("seat_prefix", "targets"), ( + ("", {"passenger"}), ("主驾", {"driver"}), + ("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}), +)) +def test_registered_cabin_modes_select_only_requested_seat_on_creation( + service: SeatDemoService, + mode_id: str, + mode_name: str, + seat_prefix: str, + targets: set[str], +) -> None: + result = _preview(service, f"构建一个{seat_prefix}{mode_name}") + actions = _action_map(result) + assert {target for capability, target in actions if capability.startswith("seat.")} == targets + if "passenger" in targets: + assert ("seat.set_backrest_recline", "passenger") in actions + draft = result["mode_profile_draft"] + if seat_prefix: + assert "existing_mode_id" not in draft, "a selected-seat copy must not overwrite the original preset" + else: + assert draft["existing_mode_id"] == mode_id + assert draft["generation_source"] == "registered_mode_profile" + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + assert actions == { + (operation.capability, operation.target_scope): operation.parameters + for operation in profile.operations + if operation.domain != "seat" or operation.target_scope in targets + } + + +@pytest.mark.parametrize("template_id", ("fresh-air", "rain-defrost")) +def test_air_quality_and_visibility_templates_do_not_invent_seat_actions( + service: SeatDemoService, + template_id: str, +) -> None: + template = service.scene_templates.get_template(template_id) + result = _preview(service, f"构建一个{template.name}", plan=template.plan()) + assert not any(capability.startswith("seat.") for capability, _ in _action_map(result)) + + +@pytest.mark.parametrize( + "template_id", + ( + "long-trip-comfort", + "summer-cooling", + "winter-warmth", + "reception", + "quiet-night", + "romantic", + ), +) +def test_non_posture_templates_keep_both_seat_positions_unchanged( + service: SeatDemoService, + template_id: str, +) -> None: + template = service.scene_templates.get_template(template_id) + assert all( + "position_pct" not in operation.parameters and "duration_ms" not in operation.parameters + for operation in template.operations + if operation.domain == "seat" + ) + + +def test_native_driver_and_passenger_thermal_levels_are_not_confused( + service: SeatDemoService, +) -> None: + for template_id in ( + "reading", + "camping", + "quiet-night", + "reception", + "romantic", + "long-trip-comfort", + "seat-comfort", + ): + template = service.scene_templates.get_template(template_id) + params = {(op.capability, op.target_scope): op.parameters for op in template.operations} + assert params[("seat.set_driver_ventilation_native", "driver")] == { + "state": "on", + "level": 2, + } + assert params[("seat.set_ventilation", "passenger")] == {"state": "on", "level": 1} + + +def test_upgraded_template_does_not_silently_add_seats_to_a_saved_custom_mode( + service: SeatDemoService, + tmp_path: Path, +) -> None: + # Simulate a previously saved user preference with the same name as a template. + original = _preview(service, "构建一个观影模式,空调温度调整到25度") + draft = original["mode_profile_draft"] + assert len(_action_map(original)) == 1 + saved = service.create_mode_profile_card( + draft["name"], + draft["instruction"], + draft["source_request_id"], + draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + paths = list((tmp_path / "user-modes").rglob("*.yaml")) + hashes = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} + + repeated = _preview(service, "构建一个观影模式") + assert repeated["mode_profile_draft"]["generation_source"] == "registered_mode_profile" + assert _action_map(repeated) == _action_map(original) + assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} == hashes + assert len(service.scene_templates.get_template("cinema").operations) == 11 + + +def test_explicit_seat_only_requirement_is_not_expanded_to_other_seat_defaults( + service: SeatDemoService, +) -> None: + result = _preview(service, "构建一个阅读模式,副驾座椅通风关闭") + assert _action_map(result) == {("seat.set_ventilation", "passenger"): {"state": "off"}} + assert result["mode_profile_draft"]["generation_source"] == "explicit_scene_requirements" + + +@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger")) +@pytest.mark.parametrize(("seat", "spoken_seat"), (("driver", "主驾"), ("passenger", "副驾驶"))) +def test_explicit_single_seat_invocation_selects_without_mutating_cabin_mode( + service: SeatDemoService, + mode_id: str, + seat: str, + spoken_seat: str, +) -> None: + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + original_revision = service.mode_profiles.profile_revision(profile) + expected = { + (operation.capability, operation.target_scope): operation.parameters + for operation in profile.operations + if operation.domain != "seat" or operation.target_scope == seat + } + result = service.preview_native_can( + f"请让{spoken_seat}进入{profile.name}", + provider="local", + conversation_id="conv-explicit-seat-mode-selection", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded", result.get("validation") + assert _action_map(result) == expected + assert ( + service.mode_profiles.profile_revision( + service.mode_profiles.get_profile(mode_id, "denza_n7"), + ) + == original_revision + ) + assert any( + operation.target_scope != seat + for operation in service.mode_profiles.get_profile(mode_id, "denza_n7").operations + if operation.domain == "seat" + ), "single-seat execution must not edit the persisted dual-seat profile" + + +@pytest.mark.parametrize( + ("mode_id", "spoken_seat"), + [ + (mode_id, seat) + for mode_id in ("zero_gravity", "easy_entry_exit", "rocking_horse", "office") + for seat in ("主驾", "后排", "所有座椅") + if (mode_id, seat) != ("office", "主驾") + ], +) +def test_mode_invocation_still_rejects_genuinely_unsupported_seat_scopes( + service: SeatDemoService, + mode_id: str, + spoken_seat: str, +) -> None: + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + result = service.preview_native_can( + f"请让{spoken_seat}进入{profile.name}", + provider="local", + conversation_id="conv-explicit-seat-mode-rejection", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + assert result["status"] in {"no_action", "needs_confirmation"} + assert _action_map(result) == {} + assert any( + issue.get("reason") == "mode_target_scope_mismatch" + for issue in result["intent_ir"]["spec"].get("ambiguities", []) + ) + + +@pytest.mark.parametrize( + "message", + ("创建一个主驾办公模式", "修改办公模式,只要主驾", "主驾办公模式"), +) +def test_mode_seat_selection_requires_an_explicit_invocation_not_configuration( + service: SeatDemoService, + message: str, +) -> None: + profile = service.mode_profiles.get_profile("office", "denza_n7") + assert service._explicit_mode_seat_selection(profile, message) is None + + +@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger")) +def test_unscoped_mode_invocation_keeps_registered_native_driver_actions( + service: SeatDemoService, + mode_id: str, +) -> None: + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + result = service.preview_native_can( + f"进入{profile.name}", + provider="local", + conversation_id="conv-mode-native-seat-preserved", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert _action_map(result) == { + (operation.capability, operation.target_scope): operation.parameters + for operation in profile.operations + } diff --git a/tests/integration/test_scene_explicit_seat_scope.py b/tests/integration/test_scene_explicit_seat_scope.py new file mode 100644 index 0000000..7ab6bab --- /dev/null +++ b/tests/integration/test_scene_explicit_seat_scope.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.scene_composer import normalize_scene_output +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService +from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes + + +@pytest.fixture(scope="module") +def scene_service(tmp_path_factory): + service = SeatDemoService() + service.artifact_root = tmp_path_factory.mktemp("scene-explicit-seat-scope") + service.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path_factory.mktemp("scene-explicit-seat-user-modes"), + ) + try: + yield service, AssistantCapabilityCatalog(service).scene_composition_context() + finally: + service.close() + + +def _plan(context, scopes): + requests = [] + if "driver" in scopes: + requests.append({ + "capability": "seat.set_driver_massage_native", + "target_scope": "driver", + "parameters": {"action": "mode", "mode": "relax"}, + }) + if "passenger" in scopes: + requests.append({ + "capability": "seat.set_backrest_recline", + "target_scope": "passenger", + "parameters": {"position_pct": 45}, + }) + requests.append({ + "capability": "climate.set_temperature", + "target_scope": "vehicle", + "parameters": {"temperature_c": 24}, + }) + return normalize_scene_output({ + "requests": requests, "ambiguities": [], "confidence": 0.95, + }, context) + + +def _preview( + scene_service, message, scopes=("driver", "passenger"), *, execution_mode="hardware", +): + service, context = scene_service + with patch.object( + service.hardware_execution, "reserve", + side_effect=AssertionError("scene scope validation must not execute hardware"), + ): + return service.preview_native_can( + message, + provider="local", + conversation_id="conv-explicit-scene-seat-scope", + execution_mode=execution_mode, + vehicle_id="denza_n7", + assistant_plan=_plan(context, scopes), + ) + + +@pytest.mark.parametrize("message", ( + "构建一个主驾座椅舒适模式", + "创建一个仅主驾舒适模式", + "创建一个只给驾驶员坐着放松的功能模式", + "构建一个副驾座椅舒适模式", + "构建一个仅副驾舒适模式", + "构建一个右前座椅舒适模式", + "构建一个后排座椅舒适模式", + "构建一个主驾座椅与副驾空调舒适模式", + "构建一个主驾阅读灯与副驾座椅舒适模式", +)) +def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_service, message): + result = _preview(scene_service, message) + assert result["status"] != "succeeded" + assert result.get("mode_profile_draft") is None + assert result["hardware_execution_allowed"] is False + assert result.get("command_plan") is None + assert result.get("command_plans") == {} + intent = result.get("intent_ir") or {} + assert (intent.get("spec") or {}).get("actions", []) == [] + assert any( + "座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE" + for item in result["validation"] + ) + + +@pytest.mark.parametrize(("message", "scopes"), ( + ("构建一个主驾座椅舒适模式", ("driver",)), + ("构建一个副驾座椅舒适模式", ("passenger",)), + ("创建一个仅主驾舒适模式", ("driver",)), + ("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")), + ("构建一个主副驾座椅舒适模式", ("driver", "passenger")), + ("构建一个云端座椅舒适模式", ("passenger",)), + ("构建一个副驾空调与座椅舒适模式", ("passenger",)), + ("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)), + ("构建一个主驾座椅与副驾空调舒适模式", ("driver",)), + ("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)), +)) +def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_zones( + scene_service, message, scopes, +): + result = _preview(scene_service, message, scopes) + assert result["status"] == "succeeded", result.get("validation") + assert result["mode_profile_draft"] + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + actions = result["intent_ir"]["spec"]["actions"] + assert { + action["target"]["instance"] + for action in actions if action["domain"] == "seat" + } == set(scopes) + + +@pytest.mark.parametrize("message", ( + "构建一个主驾舒适模式,副驾不要动", + "构建一个主驾舒适模式,不要动副驾", +)) +def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation( + scene_service, message, +): + result = _preview(scene_service, message, execution_mode="dry_run") + assert result["status"] != "succeeded" + assert result.get("mode_profile_draft") is None + assert result["hardware_execution_allowed"] is False + + +def test_unqualified_explicit_seat_requirement_defaults_to_passenger_without_model_additions(scene_service): + result = _preview(scene_service, "构建一个舒展模式,座椅靠背调整到45%", execution_mode="dry_run") + assert result["status"] == "succeeded", result.get("validation") + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["mode_profile_draft"]["operation_count"] == 1 + actions = result["intent_ir"]["spec"]["actions"] + assert len(actions) == 1 + assert actions[0]["capability"] == "seat.set_backrest_recline" + assert actions[0]["target"]["instance"] == "passenger" + assert actions[0]["parameters"] == {"position_pct": 45} + + +def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service): + result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%") + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + actions = result["intent_ir"]["spec"]["actions"] + assert len(actions) == 1 + assert actions[0]["capability"] == "seat.set_backrest_recline" + assert actions[0]["target"]["instance"] == "passenger" + assert actions[0]["parameters"]["position_pct"] == 45 + + +def test_rear_window_scene_is_not_rejected_as_an_unsupported_seat(scene_service): + service, context = scene_service + plan = normalize_scene_output({ + "requests": [{ + "capability": "window.set_position", + "target_scope": "rear_right", + "parameters": {"position_pct": 45}, + }], + "ambiguities": [], "confidence": 0.95, + }, context) + result = service.preview_native_can( + "构建一个后排换气模式", provider="local", + conversation_id="conv-rear-window-not-seat", + execution_mode="dry_run", vehicle_id="denza_n7", assistant_plan=plan, + ) + assert result["status"] == "succeeded" + assert result["mode_profile_draft"] + + +@pytest.mark.parametrize(("text", "expected"), ( + ("进入主驾舒适模式", frozenset({"driver"})), + ("进入仅副驾舒适模式", frozenset({"passenger"})), + ("进入主副驾座椅舒适模式", frozenset({"driver", "passenger"})), + ("进入主驾座椅与副驾的空调舒适模式", frozenset({"driver"})), + ("进入副驾座椅与主驾的阅读灯模式", frozenset({"passenger"})), + ("进入座椅舒适与右前车窗模式", None), + ("进入座椅舒适与后排阅读灯模式", None), + ("进入后排座椅舒适模式", frozenset({"unsupported"})), +)) +def test_scene_scope_resolution_only_uses_seat_or_unqualified_scene_targets(text, expected): + assert resolve_scene_seat_scopes(text) == expected + + +def test_new_creation_cannot_borrow_draft_reference_to_bypass_explicit_scope(scene_service): + service, context = scene_service + built = _preview(scene_service, "构建一个主驾独享舒适模式", ("driver",)) + reference = {key: built["mode_profile_draft"][key] for key in ( + "name", "source_request_id", "vehicle_id", + )} + result = service.preview_native_can( + "构建一个主驾独享舒适模式", provider="local", + conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run", + vehicle_id="denza_n7", assistant_plan=_plan(context, ("driver", "passenger")), + mode_profile_draft_context=reference, + ) + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + + +@pytest.mark.parametrize(("initial_scope", "name", "change", "added_capability"), ( + ("driver", "主驾释压舒适模式", "副驾座椅通风调整为1档", "seat.set_ventilation"), + ("passenger", "副驾释压舒适模式", "主驾座椅通风调整为低档", "seat.set_driver_ventilation_native"), +)) +def test_explicit_revision_can_extend_old_label_scope_then_save_and_replay( + scene_service, initial_scope, name, change, added_capability, +): + service, _context = scene_service + built = _preview(scene_service, f"构建一个{name}", (initial_scope,)) + source_draft = built["mode_profile_draft"] + reference = {key: source_draft[key] for key in ( + "name", "source_request_id", "vehicle_id", + )} + revised = service.preview_native_can( + f"把{name}的{change}", provider="local", + conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run", + vehicle_id="denza_n7", mode_profile_draft_context=reference, + ) + assert revised["status"] == "succeeded", revised.get("validation") + draft = revised["mode_profile_draft"] + assert draft["generation_source"] == "draft_revision" + assert draft["operation_count"] == source_draft["operation_count"] + 1 + actions = revised["intent_ir"]["spec"]["actions"] + assert added_capability in {action["capability"] for action in actions} + assert { + action["target"]["instance"] for action in actions if action["domain"] == "seat" + } == {"driver", "passenger"} + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + replayed = service.execute_mode_profile_card( + saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert replayed["status"] == "succeeded", replayed.get("validation") + assert { + (action["capability"], action["target"]["instance"]) + for action in replayed["intent_ir"]["spec"]["actions"] if action["domain"] == "seat" + } == { + (action["capability"], action["target"]["instance"]) + for action in actions if action["domain"] == "seat" + } diff --git a/tests/integration/test_scene_template_hybrid.py b/tests/integration/test_scene_template_hybrid.py index a71f01a..df636cc 100644 --- a/tests/integration/test_scene_template_hybrid.py +++ b/tests/integration/test_scene_template_hybrid.py @@ -12,6 +12,7 @@ import pytest from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog from cognitive_agent.application.codex_assistant import CodexAssistantService from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.scene_composer import normalize_scene_output from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService @@ -94,6 +95,7 @@ def _assistant_preview( client.available = False assistant = CodexAssistantService(client) snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast_answer = catalog.fast_answer(message, snapshot) events: list[dict[str, object]] = [] raw_results: list[dict[str, object]] = [] passed_plans: list[dict[str, object] | None] = [] @@ -122,6 +124,7 @@ def _assistant_preview( conversation_id=conversation_id, principal="scene-template-hybrid-test", history=[], + authoritative_answer=fast_answer.answer if fast_answer else None, emit=lambda event: events.append(event) or True, execute_control=execute_control, capability_snapshot=snapshot, @@ -141,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( templates = snapshot["scene_templates"] assert isinstance(templates, list) - assert len(templates) == 12 + assert len(templates) == 16 assert all(template["assistant_callable"] is True for template in templates) with patch.object( @@ -166,11 +169,30 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( assert result["orchestration_route"] == "mode_profile_create_preview" draft = result["mode_profile_draft"] assert draft["generation_source"] == "scene_template_copy" - assert draft["operation_count"] == template["operation_count"] assert draft["source_template_id"] == template["id"] assert draft["source_template_revision"] == template["template_revision"] assert "existing_mode_id" not in draft + expected_requests = plan["requests"] + if plan.get("schedule"): + expected_schedule = deepcopy(plan["schedule"]) + for phase in expected_schedule["phases"]: + phase["operations"] = [ + op for op in phase["operations"] + if op["domain"] != "seat" or op["target_scope"] == "passenger" + ] + assert draft["schedule"] == expected_schedule + expected_requests = [ + {**op, "parameters": [{"name": key, "value": value} + for key, value in op["parameters"].items()]} + for phase in expected_schedule["phases"] for op in phase["operations"] + ] + else: + expected_requests = [ + request for request in expected_requests + if request["domain"] != "seat" or request["target_scope"] == "passenger" + ] + assert draft["operation_count"] == len(expected_requests) expected = { ( str(request["capability"]), @@ -184,7 +206,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( sort_keys=True, ), ) - for request in plan["requests"] + for request in expected_requests } actual = { ( @@ -308,7 +330,10 @@ def test_purpose_descriptions_use_a_concise_name_and_zero_model_template_route( assert draft["name"] == expected_name assert draft["generation_source"] == "scene_template_copy" assert draft["source_template_id"] == template_id - assert draft["operation_count"] == len(template.operations) + assert draft["operation_count"] == sum( + operation.domain != "seat" or operation.target_scope == "passenger" + for operation in template.operations + ) assert passed_plans[0]["metadata"]["template_id"] == template_id assert events[0]["engine"] == "n7web-scene-template" assert not list(user_root.rglob("*.yaml")) @@ -339,6 +364,115 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults( client.start_turn.assert_not_called() +@pytest.mark.parametrize( + ("message", "expected_name", "template_id"), + ( + ("构建一个座椅舒适的功能模式", "座椅舒适模式", "seat-comfort"), + ("构建一个让人坐着放松的功能模式", "坐着放松模式", "relaxation"), + ("请帮我创建一个适合乘坐舒适的模式吧", "乘坐舒适模式", "seat-comfort"), + ("帮我构建一个让我们坐着舒服的功能模式", "坐着舒服模式", "seat-comfort"), + ("创建一个座椅放松模式并预览", "座椅放松模式", "relaxation"), + ("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"), + ), +) +def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], + message: str, + expected_name: str, + template_id: str, +) -> None: + service, catalog, user_root = hybrid_service + with patch.object( + service.hardware_execution, + "reserve", + side_effect=AssertionError("seat comfort preview reached hardware execution"), + ): + response, result, _events, client, _plans = _assistant_preview( + service, catalog, message, conversation_id="conv-seat-comfort-preview", + ) + assert response["route"] == "scene_template" + assert response["success"] is True + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["mode_profile_draft"]["name"] == expected_name + assert result["mode_profile_draft"]["source_template_id"] == template_id + seat_actions = { + (capability, target): parameters + for (capability, target), parameters in _action_map(result).items() + if capability.startswith("seat.") + } + assert {target for _capability, target in seat_actions} == {"passenger"} + assert all( + "driver_axis" not in capability and "driver_position" not in capability + for capability, _target in seat_actions + ) + assert ("seat.set_driver_massage_native", "passenger") not in seat_actions + if template_id in {"seat-comfort", "long-trip-comfort"}: + assert seat_actions["seat.set_ventilation", "passenger"] == { + "state": "on", "level": 1, + } + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize("name", ("云端座椅舒展模式", "晚间乘坐释压模式")) +def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merge( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], + name: str, +) -> None: + service, catalog, user_root = hybrid_service + plan = normalize_scene_output({ + "requests": [ + {"capability": "seat.set_driver_massage_native", "target_scope": "driver", + "parameters": {"action": "mode", "mode": "relax"}}, + {"capability": "seat.set_driver_heating_native", "target_scope": "driver", + "parameters": {"state": "on", "level": 2}}, + {"capability": "seat.set_backrest_recline", "target_scope": "passenger", + "parameters": {"position_pct": 45}}, + {"capability": "seat.set_heating", "target_scope": "passenger", + "parameters": {"state": "on", "level": 1}}, + ], + "ambiguities": [], "confidence": 0.95, + }, catalog.scene_composition_context()) + with patch.object( + service.hardware_execution, "reserve", + side_effect=AssertionError("scene composition reached hardware execution"), + ): + result = service.preview_native_can( + f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated", + execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, + ) + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition" + assert set(_action_map(result)) == { + ("seat.set_driver_massage_native", "driver"), + ("seat.set_driver_heating_native", "driver"), + ("seat.set_backrest_recline", "passenger"), + ("seat.set_heating", "passenger"), + } + assert not list(user_root.rglob("*.yaml")) + + +def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], +) -> None: + service, _catalog, _user_root = hybrid_service + result = service.preview_native_can( + "构建一个舒展模式,座椅靠背调整到45%", provider="local", + conversation_id="conv-seat-explicit-target", execution_mode="hardware", + vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert _action_map(result) == { + ("seat.set_backrest_recline", "passenger"): {"position_pct": 45}, + } + assert result.get("mode_profile_draft") + assert result["hardware_execution_allowed"] is False + + def test_template_metadata_cannot_authorize_modified_or_stale_template_actions( hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], ) -> None: @@ -440,7 +574,7 @@ def test_template_copy_can_be_revised_twice_without_losing_sibling_actions( assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run" assert shade_revised["hardware_execution_allowed"] is False assert final["hardware_execution_allowed"] is False - assert len(built_actions) == len(shade_actions) == len(final_actions) == 7 + assert len(built_actions) == len(shade_actions) == len(final_actions) == 10 assert built_actions[shade_key] == {"position_pct": 0} assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100} assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24} diff --git a/tests/unit/assistant_stream_runtime.test.cjs b/tests/unit/assistant_stream_runtime.test.cjs index 31e3b23..5ed5d83 100644 --- a/tests/unit/assistant_stream_runtime.test.cjs +++ b/tests/unit/assistant_stream_runtime.test.cjs @@ -595,6 +595,58 @@ test("the change-into predicate carries only a same-name opaque draft reference" assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false); }); +for (const phrase of [ + "在午休模式中加入空调、氛围灯、阅读灯、车窗等设备动作", + "请帮我在午休模式里添加空调和车窗动作", + "给午休模式增加氛围灯", + "为午休模式补充阅读灯", + "往午休模式内加入空调,温度26度", + "向午休模式中加上关闭车窗", + "午休模式中再加入空调温度26度", + "午休模式再补充车窗和阅读灯", + "把关闭车窗加入到午休模式中", + "请将空调26度和柔和氛围灯添加到午休模式", +]) { + test(`device additions carry only the same-name opaque draft: ${phrase}`, async () => { + const events = modeDraftEvents("req-draft-add-devices", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + events.splice(0, events.length, { type: "answer.completed", answer: "已收到草稿补充请求" }); + harness.input.value = phrase; + await harness.sandbox.CognitiveAgentAssistant.submit(); + const request = JSON.parse(harness.requestCalls.at(-1).options.body); + assert.equal(request.message, phrase); + assert.deepEqual(request.mode_profile_draft_context, { + name: "午休模式", source_request_id: "req-draft-add-devices", vehicle_id: "demo-vehicle", + }); + assert.equal(harness.requestCalls.every((call) => call.url === "/api/assistant/stream"), true); + }); +} + +for (const phrase of [ + "在露营模式中加入空调和车窗", + "把空调添加到露营模式中", + "在午休模式中加入空调可以吗?", + "午休模式中加入哪些设备合适?", + "在午休模式中不要加入空调", + "在午休模式中先别添加车窗", + "打开空调", + "设置空调出风模式为吹脚", +]) { + test(`non-update or other-mode additions do not borrow a draft: ${phrase}`, async () => { + const events = modeDraftEvents("req-draft-add-isolation", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + events.splice(0, events.length, { type: "answer.completed", answer: "已收到" }); + harness.input.value = phrase; + await harness.sandbox.CognitiveAgentAssistant.submit(); + const request = JSON.parse(harness.requestCalls.at(-1).options.body); + assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false); + }); +} + test("shade position rows explain the target opening at semantic boundaries", async () => { const positions = [0, 30, 50, 100]; const events = [ diff --git a/tests/unit/mode_schedule_frontend.test.cjs b/tests/unit/mode_schedule_frontend.test.cjs new file mode 100644 index 0000000..0a735f8 --- /dev/null +++ b/tests/unit/mode_schedule_frontend.test.cjs @@ -0,0 +1,474 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const web = path.resolve(__dirname, "../../apps/web-console"); +const assistant = fs.readFileSync(path.join(web, "assistant-chat.js"), "utf8"); +const app = fs.readFileSync(path.join(web, "app.js"), "utf8"); + +function functionSource(source, name) { + const start = source.search(new RegExp(`(?: )?(?:async )?function ${name}\\(`)); + assert.ok(start >= 0, `missing production function ${name}`); + const rest = source.slice(start); + const next = rest.slice(1).search(/\n(?: )?(?:async )?function /); + return next < 0 ? rest : rest.slice(0, next + 1); +} + +class Element { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.dataset = {}; + this.attributes = {}; + this.listeners = {}; + this.className = ""; + this.textContent = ""; + this.classList = { add: (name) => { this.className += ` ${name}`; } }; + } + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this.children = children; } + setAttribute(name, value) { this.attributes[name] = value; } + addEventListener(name, listener) { this.listeners[name] = listener; } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + querySelectorAll(selector) { + return descendants(this).filter((item) => { + if (selector.startsWith(".")) return item.className.split(/\s+/).includes(selector.slice(1)); + const match = selector.match(/^\[data-([\w-]+)(?:="([^"]*)")?\]$/); + if (match) { + const key = match[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + return key in item.dataset && (match[2] === undefined || item.dataset[key] === match[2]); + } + return item.tagName === selector; + }); + } +} + +function descendants(element) { + return element.children.flatMap((item) => typeof item === "object" ? [item, ...descendants(item)] : []); +} + +function textOf(element) { + return [element.textContent, ...descendants(element).map((item) => item.textContent)].filter(Boolean).join("\n"); +} + +function harness() { + const body = new Element("body"); + const calls = []; + const document = { + body, + createElement: (tag) => new Element(tag), + createTextNode: (text) => Object.assign(new Element("#text"), { textContent: text }), + querySelectorAll: (selector) => body.querySelectorAll(selector), + }; + const context = { + document, window: {}, CSS: { escape: String }, + $: (selector, parent = body) => parent.querySelector(selector), + rememberModeDraftContext() {}, supersedeModeDraftCards() {}, followLatest() {}, + normalizeModeName: String, + saveModeProfileDraft: (...args) => calls.push({ kind: "save", args }), + tryModeProfileDraft: (...args) => calls.push({ kind: "trial", args }), + isN7SyncedModeProfile: () => false, + modeEditorReadableCommands: () => [], + modeSecondsLabel: (value) => String(Number(value) / 1000), + LEGACY_DYNAMIC_MODE_IDS: new Set(["rocking_horse", "dynamic_cradle"]), + }; + vm.createContext(context); + const constants = app.slice(app.indexOf("const MODE_DOMAIN_LABELS"), app.indexOf("function modeOperationParameters")) + + app.slice(app.indexOf("const CONFIG_CAPABILITY_LABELS"), app.indexOf("function makeConfigElement")); + const assistantNames = ["modeDraftRows", "modeDraftScheduleView", "appendModeDraftRows", "appendModeDraftState"]; + const appNames = ["formatConfigValue", "modeOperationParameters", "formatModeOperationValue", "modeOperationView", "modeTimelineTargetSummary", "modeTimelineStepView", "modeScheduleDurationLabel", "modeScheduleView", "modeExecutionActionViews", "modeScheduleTrialMatches", "modeScheduleExecutionView", "isUserModeProfile", "isDynamicModeProfile", "createModeGalleryCard", "renderModeActionPopover"]; + vm.runInContext(constants + assistantNames.map((name) => functionSource(assistant, name)).join("\n") + + appNames.map((name) => functionSource(app, name)).join("\n"), context); + return { context, body, calls }; +} + +function operation(capability, parameters, scope = "vehicle") { + return { domain: capability.split(".")[0], capability, target_scope: scope, operation: "set", parameters }; +} + +function schedule(overrides = {}) { + return { + schema: "cognitive-agent-mode-schedule/v1", + repeat_count: 2, + phases: [ + { id: "warm", name: "快速预热", duration_ms: 60000, operations: [ + operation("climate.set_temperature", { temperature_c: 28 }), + operation("seat.set_driver_massage_native", { action: "mode", mode: "relax" }, "driver"), + operation("seat.set_heating", { state: "on", level: 1 }, "passenger"), + ] }, + { id: "comfortable", name: "舒适保持", duration_ms: 30000, operations: [ + operation("climate.set_temperature", { temperature_c: 24 }), + operation("seat.set_driver_massage_native", { action: "off" }, "driver"), + operation("seat.set_heating", { state: "off" }, "passenger"), + ] }, + ], + ...overrides, + }; +} + +test("assistant keeps phases, relative timing, repeat count, total duration and stop guidance", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ schedule: schedule() }); + assert.equal(view.summary, "2 个阶段 · 重复 2 次 · 总时长 3分"); + assert.equal(view.operationCount, 6); + assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1分"); + assert.equal(view.phases[1].timing, "+1分 开始 · 持续 30秒"); + assert.match(view.stopHint, /停止动态模式/); + assert.match(view.stopHint, /每轮/); +}); + +test("driver and passenger scopes and changed device values are not collapsed", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ schedule: schedule() }); + assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [ + ["空调温度", "28℃"], ["主驾座椅按摩", "放松模式"], ["副驾座椅加热", "开启 · 1档"], + ]); + assert.deepEqual(Array.from(view.phases[1].rows, (row) => [row.label, row.value]), [ + ["空调温度", "24℃"], ["主驾座椅按摩", "关闭"], ["副驾座椅加热", "关闭"], + ]); +}); + +test("phase previews never apply whole-instruction zero-gravity grouping", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ instruction: "副驾零重力", schedule: schedule({ phases: [{ + duration_ms: 1500, operations: [ + operation("seat.set_longitudinal_position", { position_pct: 80 }, "passenger"), + operation("seat.set_backrest_recline", { position_pct: 60 }, "passenger"), + ], + }] }) }); + assert.equal(view.phases[0].rows.length, 2); + assert.equal(view.phases[0].name, "阶段 1"); + assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1.5秒"); +}); + +test("per-zone climate targets are explicit even within the same phase", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ schedule: schedule({ phases: [{ + duration_ms: 2000, operations: [ + operation("climate.set_temperature", { temperature_c: 26 }, "front_left"), + operation("climate.set_temperature", { temperature_c: 24 }, "front_right"), + ], + }] }) }); + assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [ + ["主驾空调温度", "26℃"], ["副驾空调温度", "24℃"], + ]); +}); + +for (const [name, value] of [ + ["absent", undefined], ["unknown schema", schedule({ schema: "other" })], + ["zero repeat", schedule({ repeat_count: 0 })], ["unbounded repeat", schedule({ repeat_count: 21 })], + ["fractional repeat", schedule({ repeat_count: 1.5 })], ["string repeat", schedule({ repeat_count: "2" })], + ["empty phases", schedule({ phases: [] })], ["non-array phases", schedule({ phases: {} })], + ["zero duration", schedule({ phases: [{ duration_ms: 0, operations: [] }] })], + ["negative duration", schedule({ phases: [{ duration_ms: -1, operations: [] }] })], + ["invalid duration", schedule({ phases: [{ duration_ms: Infinity, operations: [] }] })], + ["missing operations", schedule({ phases: [{ duration_ms: 1000 }] })], +]) { + test(`schedule formatter safely rejects ${name}`, () => { + const { context } = harness(); + assert.equal(context.modeDraftScheduleView({ schedule: value }), null); + assert.equal(context.modeScheduleView({ schedule: value }), null); + }); +} + +test("hover formatter prefers stage operations over an overlapping flat projection", () => { + const { context } = harness(); + const mode = { schedule: schedule(), operations: [operation("climate.set_temperature", { temperature_c: 24 })] }; + const rows = context.modeExecutionActionViews(mode); + assert.equal(rows.length, 6); + assert.equal(rows[0].phase, "阶段 1:快速预热"); + assert.equal(rows[3].phase, "阶段 2:舒适保持"); + assert.equal(rows[0].parameters, "目标温度:28 ℃"); + assert.equal(rows[3].parameters, "目标温度:24 ℃"); + assert.equal(rows[1].target, "主驾"); + assert.equal(rows[2].target, "副驾"); + assert.equal(context.modeScheduleView(mode).summary, context.modeDraftScheduleView(mode).summary); + assert.equal(context.isDynamicModeProfile({ ...mode, behavior: "static" }), true); +}); + +test("legacy static modes and seat ModeTimeline retain their original action semantics", () => { + const { context } = harness(); + const operations = [operation("climate.set_temperature", { temperature_c: 24 })]; + assert.equal(context.modeExecutionActionViews({ operations }).length, 1); + assert.equal(context.isDynamicModeProfile({ operations, behavior: "static" }), false); + const mode = { operations, timeline: { + entry: { targets: { backrest_recline: { position_pct: 40 } }, timeout_ms: 15000 }, + phases: [{ name: "抬起", targets: { backrest_recline: { position_pct: 50 } }, duration_ms: 2000 }], + } }; + const views = context.modeExecutionActionViews(mode); + assert.equal(views.length, 3); + assert.equal(views[1].action, "进入动态模式初始位置"); + assert.match(views[1].parameters, /保护上限 15s/); + assert.equal(views[2].action, "循环阶段 1:抬起"); + assert.match(views[2].parameters, /持续 2s/); + assert.equal(context.isDynamicModeProfile(mode), true); +}); + +test("assistant renders a stage card, preserving opaque save/trial request context", () => { + const { context, body, calls } = harness(); + const message = { body: new Element("div"), article: new Element("article") }; + body.append(message.body); + const draft = { name: "上车预热模式", source_request_id: "opaque-preview-id", schedule: schedule(), operation_count: 6 }; + const flatActions = []; + context.appendModeDraftState(message, draft, flatActions); + const card = message.body.children[0]; + assert.equal(card.querySelectorAll(".assistant-mode-schedule-phase").length, 2); + assert.equal(card.querySelectorAll(".assistant-mode-draft-row").length, 6); + assert.equal(card.querySelector(".assistant-mode-draft-badge").textContent, "6 项动作"); + assert.match(textOf(card), /28℃/); + assert.match(textOf(card), /24℃/); + assert.match(textOf(card), /重复 2 次 · 总时长 3分/); + assert.match(textOf(card), /停止动态模式/); + card.querySelector(".assistant-mode-draft-save").listeners.click(); + card.querySelector(".assistant-mode-draft-trial").listeners.click(); + assert.equal(calls[0].kind, "save"); + assert.equal(calls[0].args[2], draft); + assert.equal(calls[1].kind, "trial"); + assert.equal(calls[1].args[1], draft); + assert.equal(calls[1].args[2], flatActions); +}); + +test("management schedule details are independently expandable without executing the card", () => { + const { context } = harness(); + const card = context.createModeGalleryCard({ id: "custom-warm", name: "上车预热模式", origin: "user", schedule: schedule() }); + const details = card.querySelector(".mode-gallery-schedule-details"); + assert.ok(details); + assert.equal(details.querySelector("summary").textContent, "查看分阶段执行动作"); + assert.equal(details.querySelectorAll("li").length, 2); + assert.equal(details.querySelectorAll("button").length, 0); + assert.match(textOf(details), /快速预热/); + assert.match(textOf(details), /舒适保持/); + assert.match(textOf(details), /主驾 · 设置主驾座椅按摩:动作:切换按摩模式,工作模式:放松/); + assert.match(textOf(card.querySelector(".mode-gallery-card-meta")), /重复 2 次 · 总时长 3分/); +}); + +test("popover presents stage timings and stop guidance without interpreting text as HTML", () => { + const { context } = harness(); + const popover = new Element("aside"); + for (const [tag, key] of [["small", "modeActionEyebrow"], ["strong", "modeActionTitle"], ["b", "modeActionCount"], ["ol", "modeActionList"]]) { + const item = new Element(tag); + item.dataset[key] = "true"; + popover.append(item); + } + context.ensureModeActionPopover = () => popover; + const value = schedule(); + value.phases[0].name = ''; + context.renderModeActionPopover({ name: "测试动态模式", schedule: value }); + assert.equal(popover.querySelectorAll(".mode-chip-action-copy").length, 6); + assert.equal(popover.querySelectorAll(".mode-schedule-popover-phase").length, 6); + assert.match(textOf(popover), /阶段 1: { + const { context } = harness(); + const running = context.modeScheduleExecutionView({ status: "running", schedule_execution: { status: "running" } }); + assert.equal(running.state, "processing"); + assert.equal(running.title, "动态时间轴已启动"); + assert.doesNotMatch(running.title, /完成/); + const trial = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 90000 } }); + assert.equal(trial.title, "动态时间轴模拟试运行通过"); + assert.match(trial.detail, /未向车辆下发控制/); + const stop = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "cancelled" } }); + assert.equal(stop.title, "动态时间轴已停止"); + assert.match(stop.detail, /不会自动回滚/); + assert.equal(context.modeScheduleExecutionView({ status: "failed", schedule_execution: { status: "failed" } }), null); +}); + +test("direct saved-mode execution keeps a running schedule active and starts polling", () => { + const updates = []; + const polls = []; + const context = { + beginModeExecution() { throw new Error("an existing execution handle must be reused"); }, + setMessageContent: (_message, content, options) => updates.push({ kind: "content", content, options }), + setActionStatus: (_message, content, state) => updates.push({ kind: "status", content, state }), + appendDispatchedControls: (_message, result) => updates.push({ kind: "controls", result }), + startScheduleStatusPoll: (result, message) => polls.push({ result, message }), + persistMessages: () => updates.push({ kind: "persist" }), + }; + vm.createContext(context); + vm.runInContext(functionSource(assistant, "completeModeExecution"), context); + const message = { body: {} }; + const result = { + status: "running", + vehicle_id: "denza_n7", + schedule_execution: { status: "running", session_id: "session-direct" }, + }; + + context.completeModeExecution( + { message, modeName: "动态舒适", commandText: "进入动态舒适" }, + { id: "dynamic-comfort" }, + result, + ); + + assert.match(updates.find((item) => item.kind === "content").content, /已启动动态时间轴/); + assert.deepEqual(updates.find((item) => item.kind === "status"), { + kind: "status", + content: "动态时间轴执行中,可输入“停止动态模式”提前结束", + state: "working", + }); + assert.equal(polls.length, 1); + assert.equal(polls[0].result, result); + assert.equal(polls[0].message, message); +}); + +test("virtual trial verifies temporal structure, not just a flattened action set", () => { + const { context } = harness(); + const draft = { schedule: schedule() }; + const response = () => ({ schedule: schedule(), schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 180000 } }); + assert.equal(context.modeScheduleTrialMatches(draft, response()), true); + assert.equal(context.modeScheduleTrialMatches({}, {}), true, "legacy static drafts remain unchanged"); + for (const mutate of [ + (value) => { value.schedule.repeat_count = 1; }, + (value) => { value.schedule.phases.reverse(); }, + (value) => { value.schedule.phases[0].duration_ms = 45000; value.schedule.phases[1].duration_ms = 45000; }, + (value) => { value.schedule.phases[0].operations[0].parameters.temperature_c = 26; }, + (value) => { value.schedule.phases[0].operations[1].target_scope = "passenger"; }, + (value) => { value.schedule_execution.virtual_elapsed_ms = 90000; }, + (value) => { value.schedule_execution.completed = false; }, + (value) => { value.schedule_execution.status = "running"; }, + (value) => { delete value.schedule; }, + ]) { + const value = response(); + mutate(value); + assert.equal(context.modeScheduleTrialMatches(draft, value), false); + } +}); + +function pollHarness(statuses = []) { + const { context } = harness(); + const timers = new Map(); + const requests = []; + const updates = []; + const modeUpdates = []; + const vehicle = { value: "denza_n7" }; + let timerId = 0; + context.activeSchedulePoll = null; + context.AbortController = AbortController; + context.conversation = "conversation-1"; + context.conversationId = () => context.conversation; + context.setActionStatus = (_message, copy, state) => updates.push({ copy, state }); + const originalQuery = context.$; + context.$ = (selector, parent) => selector === "#vehicleSelect" ? vehicle : originalQuery(selector, parent); + context.window.setTimeout = (callback, delay) => { + assert.equal(delay, 2000); + timers.set(++timerId, callback); + return timerId; + }; + context.window.clearTimeout = (id) => timers.delete(id); + context.window.CognitiveAgentConsole = { updateScheduleExecutionStatus: (...args) => modeUpdates.push(args) }; + context.window.CognitiveAgentNetwork = { request: async (url, options) => { + requests.push({ url, options }); + const next = statuses.shift(); + if (next instanceof Error) throw next; + if (typeof next === "function") return next(); + return { ok: true, status: 200, json: async () => next }; + } }; + vm.runInContext(["stopScheduleStatusPoll", "startScheduleStatusPoll"].map((name) => functionSource(assistant, name)).join("\n"), context); + const start = (session = "session-1") => context.startScheduleStatusPoll({ + vehicle_id: "denza_n7", mode_profile: { id: "test-mode" }, schedule_execution: { session_id: session }, + }, {}); + const tick = async () => { + const [id, callback] = timers.entries().next().value || []; + assert.ok(callback, "poll timer must be scheduled"); + timers.delete(id); + await callback(); + }; + return { context, timers, requests, updates, modeUpdates, vehicle, start, tick }; +} + +test("polls only the active vehicle using GET and updates the original status until completion", async () => { + const h = pollHarness([ + { status: "running", session_id: "session-1", vehicle_id: "denza_n7", phase_index: 1, repeat_index: 0 }, + { status: "completed", session_id: "session-1", completed: true }, + ]); + h.start(); + assert.equal(h.requests.length, 0); + await h.tick(); + assert.equal(h.requests.length, 1); + assert.equal(h.requests[0].url, "/api/modes/schedule/status?vehicle_id=denza_n7"); + assert.equal(h.requests[0].options.method, "GET"); + assert.equal(h.requests[0].options.body, undefined); + assert.match(h.updates[0].copy, /第 1 轮 · 阶段 2/); + assert.equal(h.updates[0].state, "working"); + await h.tick(); + assert.equal(h.timers.size, 0); + assert.equal(h.context.activeSchedulePoll, null); + assert.match(h.updates[1].copy, /时间轴已结束/); + assert.deepEqual(h.modeUpdates, [["test-mode", "completed"]]); +}); + +for (const status of ["failed", "cancelled", "stopped", "idle"]) { + test(`terminal ${status} ends polling without a new request or action`, async () => { + const h = pollHarness([{ status, session_id: "session-1", error: "阶段下发失败" }]); + h.start(); + await h.tick(); + assert.equal(h.requests.length, 1); + assert.equal(h.timers.size, 0); + assert.equal(h.updates[0].state, status === "failed" ? "failed" : "success"); + if (status === "failed") assert.match(h.updates[0].copy, /阶段下发失败/); + if (["cancelled", "stopped"].includes(status)) assert.match(h.updates[0].copy, /已停止/); + }); +} + +test("a transient status failure is visible and retried, not reported as execution failure", async () => { + const h = pollHarness([new Error("offline"), { status: "completed", completed: true }]); + h.start(); + await h.tick(); + assert.match(h.updates[0].copy, /暂时无法获取时间轴状态/); + assert.equal(h.updates[0].state, "working"); + assert.equal(h.timers.size, 1); + await h.tick(); + assert.equal(h.timers.size, 0); +}); + +test("vehicle or conversation changes terminate observation before another request", async () => { + for (const kind of ["vehicle", "conversation"]) { + const h = pollHarness(); + h.start(); + if (kind === "vehicle") h.vehicle.value = "another-vehicle"; + else h.context.conversation = "conversation-2"; + await h.tick(); + assert.equal(h.requests.length, 0); + assert.equal(h.context.activeSchedulePoll, null); + assert.equal(h.timers.size, 0); + } +}); + +test("replacement session does not apply a different schedule outcome to the old message", async () => { + const h = pollHarness([{ status: "failed", session_id: "another-session" }]); + h.start(); + await h.tick(); + assert.match(h.updates[0].copy, /已被新的运行任务替换/); + assert.doesNotMatch(h.updates[0].copy, /执行失败/); + assert.equal(h.timers.size, 0); + assert.equal(h.modeUpdates.length, 0); +}); + +test("stop aborts an in-flight status read and ignores its late completion", async () => { + let resolve; + const response = new Promise((done) => { resolve = done; }); + const h = pollHarness([() => response]); + h.start(); + const pending = h.tick(); + assert.equal(h.requests.length, 1); + assert.equal(h.timers.size, 0, "an in-flight read cannot overlap another timer"); + h.context.stopScheduleStatusPoll(); + assert.equal(h.requests[0].options.signal.aborted, true); + resolve({ ok: true, status: 200, json: async () => ({ status: "completed", completed: true }) }); + await pending; + assert.equal(h.updates.length, 0); + assert.equal(h.timers.size, 0); +}); + +test("cleanup is wired to new conversation, vehicle changes and page exit", () => { + assert.match(assistant, /if \(!target\?\.closest\("#newConversation"\)\) return;\s+(?:event\.preventDefault\?\.\(\);\s+)?stopScheduleStatusPoll\(\);/); + assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/); + assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/); +}); diff --git a/tests/unit/test_assistant_capability_catalog.py b/tests/unit/test_assistant_capability_catalog.py index 1ed6108..90e8b12 100644 --- a/tests/unit/test_assistant_capability_catalog.py +++ b/tests/unit/test_assistant_capability_catalog.py @@ -298,7 +298,7 @@ def test_mode_catalog_projects_bounded_action_details_without_free_form_reasons( snapshot = _catalog(vehicle_service).snapshot() office = next(item for item in snapshot["modes"] if item["id"] == "office") - assert len(office["operations"]) == office["operation_count"] == 10 + assert len(office["operations"]) == office["operation_count"] == 12 temperature = next( item for item in office["operations"] @@ -341,7 +341,7 @@ def test_saved_mode_detail_fast_answer_is_exact_read_only_and_natural( "遮阳帘", "氛围灯", "副驾空调温度设为25℃", - "共10项动作", + "共12项动作", "本次仅查询,未下发车辆控制", ) ) @@ -372,7 +372,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries( templates = snapshot["scene_templates"] assert snapshot["source_status"]["scene_templates"] == "ready" - assert len(templates) == 12 + assert len(templates) == 16 assert {item["name"] for item in templates} >= { "观影模式", "露营模式", diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index 4877c58..5609cf6 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface(): def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8") assert ( - 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' + 'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"' in service_worker ) assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker @@ -316,7 +316,7 @@ def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_tur assert "rememberModeDraftContext(draft)" in assistant assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant assert "function isModeDraftUpdateRequest(message, context)" in assistant - assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant + assert "normalizeModeName(matched[nameIndex]) === normalizeModeName(context.name)" in assistant def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context(): diff --git a/tests/unit/test_assistant_mode_draft_rows.py b/tests/unit/test_assistant_mode_draft_rows.py new file mode 100644 index 0000000..bb83d28 --- /dev/null +++ b/tests/unit/test_assistant_mode_draft_rows.py @@ -0,0 +1,177 @@ +"""Execute the mode preview formatter against actual seat/light parameter shapes.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def render_rows(): + node = shutil.which("node") + if not node: + runtime = ( + Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" + / "dependencies" / "node" / "bin" + ) + node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None) + assert node, "Node.js is required to validate rendered mode action text" + source = (ROOT / "apps" / "web-console" / "assistant-chat.js").read_text(encoding="utf-8") + start = source.index(" function modeDraftRows(") + end = source.index("\n function ", start + 1) + script = source[start:end] + "\nconst input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));\n" + script += "console.log(JSON.stringify(modeDraftRows(input.draft, input.actions)));" + + def render(actions, instruction=""): + completed = subprocess.run( + [node, "-e", script], + input=json.dumps({"draft": {"instruction": instruction}, "actions": actions}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(completed.stdout) + + return render + + +def action(capability, parameters, zone="driver"): + return { + "domain": capability.split(".")[0], + "capability": capability, + "target": {"zone": zone}, + "parameters": parameters, + } + + +@pytest.mark.parametrize("kind", ["ventilation", "heating"]) +@pytest.mark.parametrize( + ("parameters", "expected"), + [ + ({"state": "on", "level": 1}, "开启 · 高档"), + ({"state": "on", "level": 2}, "开启 · 低档"), + ({"state": "on"}, "开启 · 低档"), + ({"state": "off", "level": 1}, "关闭"), + ], +) +def test_driver_native_comfort_uses_its_own_level_scale(render_rows, kind, parameters, expected): + rows = render_rows([action(f"seat.set_driver_{kind}_native", parameters, "front_left")]) + assert rows[0]["label"] == f"主驾座椅{'通风' if kind == 'ventilation' else '加热'}" + assert rows[0]["value"] == expected + + +@pytest.mark.parametrize("kind", ["ventilation", "heating"]) +@pytest.mark.parametrize("level", [1, 2, 3, 4, 5]) +def test_passenger_levels_are_not_translated_using_driver_can_codes(render_rows, kind, level): + rows = render_rows([action(f"seat.set_{kind}", {"state": "on", "level": level}, "passenger")]) + assert rows[0]["label"] == f"副驾座椅{'通风' if kind == 'ventilation' else '加热'}" + assert rows[0]["value"] == f"开启 · {level}档" + + +@pytest.mark.parametrize( + ("parameters", "expected"), + [ + ({"action": "off", "mode": "relax"}, "关闭"), + ({"action": "mode", "mode": "wave"}, "波浪模式"), + ({"action": "mode", "mode": "pulse"}, "脉冲模式"), + ({"action": "mode", "mode": "stretch"}, "舒展模式"), + ({"action": "mode", "mode": "slack"}, "舒缓模式"), + ({"action": "mode", "mode": "relax"}, "放松模式"), + ({"action": "strength", "strength": 1}, "轻柔力度"), + ({"action": "strength", "strength": 2}, "中等力度"), + ({"action": "strength", "strength": 3}, "较强力度"), + ({"action": "mode"}, "按摩模式待确认"), + ({"action": "strength"}, "按摩力度待确认"), + ], +) +def test_massage_preview_distinguishes_operation_mode_and_strength(render_rows, parameters, expected): + rows = render_rows([action("seat.set_driver_massage_native", parameters)]) + assert rows[0]["label"] == "主驾座椅按摩" + assert rows[0]["value"] == expected + + +def test_ambient_native_color_and_brightness_remain_distinct_rows(render_rows): + rows = render_rows([ + action("ambient_light.set_color_index", {"color_index": 127}, "vehicle"), + action("ambient_light.set_brightness_level", {"brightness_level": 2}, "vehicle"), + ]) + assert [(row["label"], row["value"]) for row in rows] == [ + ("氛围灯颜色", "色号 127"), + ("氛围灯亮度", "2档"), + ] + + +@pytest.mark.parametrize("brightness", [0, 20, 100]) +def test_rgb_illumination_keeps_exact_color_and_brightness(render_rows, brightness): + rows = render_rows([ + action("ambient_light.set_illumination", {"color_hex": "#FFB347", "brightness_pct": brightness}, "vehicle"), + ]) + assert rows[0]["value"] == f"颜色 #FFB347 · 亮度 {brightness}%" + + +def test_zero_gravity_grouping_keeps_driver_comfort_visible(render_rows): + rows = render_rows([ + action("seat.set_longitudinal_position", {"position_pct": 100}, "passenger"), + action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "front_right"), + action("seat.set_driver_massage_native", {"action": "mode", "mode": "relax"}), + ], "副驾零重力模式,并开启主驾放松按摩") + assert [(row["label"], row["value"]) for row in rows] == [ + ("副驾座椅", "零重力模式"), + ("主驾座椅按摩", "放松模式"), + ] + + +def test_position_and_power_rows_keep_existing_values(render_rows): + rows = render_rows([ + action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "passenger"), + action("ambient_light.set_power", {"state": "off"}, "vehicle"), + action("seat.set_ventilation", {"state": "off", "level": 2}, "passenger"), + ]) + assert [(row["label"], row["value"]) for row in rows] == [ + ("副驾靠背角度", "81%(150°)"), + ("氛围灯", "关闭"), + ("副驾座椅通风", "关闭"), + ] + + +@pytest.mark.parametrize( + ("capability", "mode", "label", "value"), + [ + ("climate.set_control_mode", "auto", "空调控制模式", "自动控制"), + ("climate.set_control_mode", "manual", "空调控制模式", "手动控制"), + ("climate.set_cycle_mode", "internal", "空调循环", "内循环"), + ("climate.set_cycle_mode", "external", "空调循环", "外循环"), + ("climate.set_outlet_mode", "face", "空调出风模式", "吹面"), + ("climate.set_outlet_mode", "face_feet", "空调出风模式", "吹面 / 吹脚"), + ("climate.set_outlet_mode", "feet", "空调出风模式", "吹脚"), + ("climate.set_outlet_mode", "feet_defrost", "空调出风模式", "吹脚 / 除霜"), + ("climate.set_outlet_mode", "defrost", "空调出风模式", "除霜"), + ("climate.set_outlet_mode", "face_feet_defrost", "空调出风模式", "吹面 / 吹脚 / 除霜"), + ("climate.set_outlet_mode", "face_defrost", "空调出风模式", "吹面 / 除霜"), + ], +) +def test_climate_modes_use_capability_specific_labels(render_rows, capability, mode, label, value): + rows = render_rows([action(capability, {"mode": mode}, "vehicle")]) + assert (rows[0]["label"], rows[0]["value"]) == (label, value) + + +def test_climate_control_circulation_and_outlet_are_not_conflated(render_rows): + rows = render_rows([ + action("climate.set_control_mode", {"mode": "auto"}, "vehicle"), + action("climate.set_cycle_mode", {"mode": "internal"}, "vehicle"), + action("climate.set_outlet_mode", {"mode": "face_feet"}, "vehicle"), + action("climate.set_fan_level", {"level": 2}, "vehicle"), + ]) + assert [(row["label"], row["value"]) for row in rows] == [ + ("空调控制模式", "自动控制"), + ("空调循环", "内循环"), + ("空调出风模式", "吹面 / 吹脚"), + ("空调风量", "2档"), + ] diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 959cc6a..2b47cc6 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: "/energy-monitor.js?v=energy-monitor-5-performance" ) assert ( - 'const CACHE_VERSION = "cognitive-agent-pad-v321-n7-dispatch"' + 'const CACHE_VERSION = "cognitive-agent-pad-v322-n7-firmware-merge"' in service_worker ) diff --git a/tests/unit/test_firmware_workbench.py b/tests/unit/test_firmware_workbench.py new file mode 100644 index 0000000..e902c9a --- /dev/null +++ b/tests/unit/test_firmware_workbench.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import hashlib +import json +import threading +import time +import zipfile +from pathlib import Path + +from cognitive_agent.application.firmware_workbench import ( + FINAL_RELEASE_MESSAGE, + FirmwareWorkbenchService, +) + +ROOT = Path(__file__).resolve().parents[2] + + +def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + task = service.task(task_id) + if task["status"] not in {"waiting", "running", "pending_confirmation"}: + return task + time.sleep(0.02) + raise AssertionError("firmware pipeline did not reach a terminal state") + + +def make_image(tmp_path: Path) -> Path: + image = tmp_path / "Seat_Controller_demo.hex" + image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80) + return image + + +def test_pipeline_publishes_a_downloadable_ota_package( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv( + "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", + "http://192.168.43.130:8000/api/firmware-workbench/releases", + ) + image = make_image(tmp_path) + + def build_runner( + _task_id: str, log: object, _cancel: threading.Event + ) -> Path: + log("info", "IAR build: 0 errors, 0 warnings") + return image + + service = FirmwareWorkbenchService( + ROOT, + artifact_root=tmp_path / "workbench", + ota_root=tmp_path / "ota", + build_runner=build_runner, + ) + instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能" + + first = service.start_task(instruction) + task = wait_for_terminal(service, str(first["task"]["id"])) + assert task["status"] == "success" + assert task["message"] == FINAL_RELEASE_MESSAGE + assert [stage["status"] for stage in task["stages"]] == ["success"] * 8 + assert len(task["test_results"]) == 7 + + snapshot = service.snapshot() + assert snapshot["baselines"] == [ + { + "id": "time_only", + "name": "时间控制基线", + "capabilities": ["时间控制"], + } + ] + assert len(snapshot["artifacts"]) == 2 + assert len(snapshot["releases"]) == 1 + artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"]) + baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline")) + release = snapshot["releases"][0] + assert baseline["capabilities"] == ["时间控制"] + assert baseline["protected"] is True + assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"] + assert artifact["status"] == "published" + assert release["download_url"] == ( + f"http://192.168.43.130:8000/api/firmware-workbench/releases/" + f"{release['id']}/download" + ) + assert service.latest_release()["download_url"] == release["download_url"] + latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8")) + assert latest_manifest["download_url"] == release["download_url"] + package, filename = service.resolve_release_download(str(release["id"])) + assert filename == artifact["firmware_filename"] + assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"] + with zipfile.ZipFile(package) as archive: + assert { + "Seat_Controller_demo.hex", + "manifest.json", + "requirements.json", + "validation.json", + "feature_profile.json", + } <= set(archive.namelist()) + assert (tmp_path / "ota" / "latest.json").is_file() + assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"] + service.close() + + monkeypatch.setenv( + "FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL", + "http://192.168.43.131:8000/api/firmware-workbench/releases", + ) + migrated = FirmwareWorkbenchService( + ROOT, + artifact_root=tmp_path / "workbench", + ota_root=tmp_path / "ota", + build_runner=build_runner, + ) + migrated_release = migrated.latest_release() + assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/") + migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8")) + assert migrated_latest["download_url"] == migrated_release["download_url"] + migrated.close() + + +def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None: + image = make_image(tmp_path) + + def build_runner( + _task_id: str, log: object, _cancel: threading.Event + ) -> Path: + log("info", "IAR build: 0 errors, 0 warnings") + return image + + service = FirmwareWorkbenchService( + ROOT, + artifact_root=tmp_path / "workbench", + ota_root=tmp_path / "ota", + build_runner=build_runner, + ) + first = service.start_task("增加座椅加热功能。") + first_task = wait_for_terminal(service, str(first["task"]["id"])) + second = service.start_task("增加座椅加热功能。") + + assert first["task"]["id"] != second["task"]["id"] + assert "duplicate" not in first + assert "duplicate" not in second + second_task = wait_for_terminal(service, str(second["task"]["id"])) + assert first_task["status"] == "success" + assert second_task["status"] == "success" + assert first_task["target_capabilities"] == ["时间控制", "座椅加热"] + assert second_task["target_capabilities"] == ["时间控制", "座椅加热"] + service.close() + + +def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None: + image = make_image(tmp_path) + + def build_runner( + _task_id: str, log: object, _cancel: threading.Event + ) -> Path: + log("info", "IAR build: 0 errors, 0 warnings") + return image + + service = FirmwareWorkbenchService( + ROOT, + artifact_root=tmp_path / "workbench", + ota_root=tmp_path / "ota", + build_runner=build_runner, + ) + started = service.start_task("增加座椅通风功能") + task = wait_for_terminal(service, str(started["task"]["id"])) + package_path, _ = service.resolve_artifact_download(str(task["artifact_id"])) + release_path, _ = service.resolve_release_download(str(task["release_id"])) + + result = service.delete_artifact(str(task["artifact_id"])) + assert result["deleted"] is True + assert not package_path.exists() + assert not release_path.exists() + snapshot = service.snapshot() + assert [artifact["id"] for artifact in snapshot["artifacts"]] == [ + "fw-art-time-only-baseline" + ] + assert snapshot["releases"] == [] + + try: + service.delete_artifact("fw-art-time-only-baseline") + except Exception as exc: # noqa: BLE001 - public error type is asserted below + assert getattr(exc, "code", None) == "baseline_protected" + else: + raise AssertionError("time-only baseline must not be deletable") + service.close() + + +def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None: + image = make_image(tmp_path) + + def build_runner( + _task_id: str, log: object, _cancel: threading.Event + ) -> Path: + log("info", "IAR build: profile position=1 ventilation=0 heating=0") + return image + + service = FirmwareWorkbenchService( + ROOT, + artifact_root=tmp_path / "workbench", + ota_root=tmp_path / "ota", + build_runner=build_runner, + ) + started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only") + task = wait_for_terminal(service, str(started["task"]["id"])) + + assert task["status"] == "success" + assert task["baseline_capabilities"] == ["时间控制"] + assert task["requested_capabilities"] == ["位置控制"] + assert task["target_capabilities"] == ["时间控制", "位置控制"] + artifact = service.snapshot()["artifacts"][0] + assert artifact["capabilities"] == ["时间控制", "位置控制"] + assert artifact["capability_diff"]["added"] == ["位置控制"] + assert "vent" not in artifact["firmware_filename"] + assert "heat" not in artifact["firmware_filename"] + service.close() + + +def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None: + image = make_image(tmp_path) + allow_build = threading.Event() + + def build_runner( + _task_id: str, log: object, cancel: threading.Event + ) -> Path: + log("info", "IAR build started") + while not allow_build.is_set(): + if cancel.wait(0.01): + raise InterruptedError + return image + + service = FirmwareWorkbenchService( + ROOT, + artifact_root=tmp_path / "workbench", + ota_root=tmp_path / "ota", + build_runner=build_runner, + ) + started = service.start_task("增加座椅通风功能") + task_id = str(started["task"]["id"]) + deadline = time.monotonic() + 3 + while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline: + time.sleep(0.01) + service.cancel_task(task_id) + canceled = wait_for_terminal(service, task_id) + assert canceled["status"] == "canceled" + assert any(stage["status"] == "canceled" for stage in canceled["stages"]) + + allow_build.set() + retried = service.retry_task(task_id) + assert retried["id"] == task_id + completed = wait_for_terminal(service, task_id) + assert completed["status"] == "success" + service.close() diff --git a/tests/unit/test_firmware_workbench_frontend_contract.py b/tests/unit/test_firmware_workbench_frontend_contract.py new file mode 100644 index 0000000..77a9227 --- /dev/null +++ b/tests/unit/test_firmware_workbench_frontend_contract.py @@ -0,0 +1,71 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WEB = ROOT / "apps" / "web-console" + + +def source(name: str) -> str: + return (WEB / name).read_text(encoding="utf-8") + + +def test_workbench_is_a_standalone_development_surface() -> None: + html = source("firmware-workbench.html") + script = source("firmware-workbench.js") + + assert "座椅固件开发工作台" in html + assert '