feat(firmware): support rocking-horse workbench delivery

This commit is contained in:
Miayui
2026-09-22 11:15:13 +08:00
parent ba5dfcd6da
commit 4d99702acd
13 changed files with 250 additions and 29 deletions
+10
View File
@@ -1,5 +1,15 @@
# Web Console
## 座椅固件开发工作台
“开发指令”支持输入“增加动态木马功能”,也可点击位于位置控制、座椅通风、座椅加热之后的
“+ 动态木马”快捷按钮。木马模式会自动加入位置控制依赖,基于当前时间控制基线生成
“时间控制+位置控制+动态木马”固件,并沿用代码生成、IAR 编译、验证、打包和发布流程。
可同时选择通风、加热等能力。固件使用已有木马标定参数及停止、超时保护。
发布后的产物可下载,并通过该行的“通知车端”按钮发送现有协议的 3 帧发布通知;
通知仅提示有可用固件,不会自动升级或启动座椅。未选择木马的工作台任务关闭木马编译开关。
## 宠物卡片与 NFC 上报
主页“宠物卡片”区域支持创建多张宠物卡片。`pet_name` 与 `action` 可在卡片中编辑,
+1 -1
View File
@@ -176,7 +176,7 @@ textarea {
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 { display: flex; flex-wrap: wrap; gap: 6px; }
.prompt-templates button {
min-width: 0;
padding: 5px 8px;
+3 -2
View File
@@ -8,9 +8,9 @@
<title>座椅固件开发工作台 · 511 工程</title>
<link rel="icon" href="/icons/app-icon-192.png" />
<script src="/firmware-workbench-theme.js?v=1"></script>
<link rel="stylesheet" href="/firmware-workbench.css?v=7" />
<link rel="stylesheet" href="/firmware-workbench.css?v=8" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/firmware-workbench.js?v=4" defer></script>
<script src="/firmware-workbench.js?v=5" defer></script>
</head>
<body>
<div class="shell">
@@ -48,6 +48,7 @@
<button type="button" data-prompt="增加支持座椅位置参数控制的能力">+ 位置控制</button>
<button type="button" data-prompt="增加座椅通风功能">+ 座椅通风</button>
<button type="button" data-prompt="增加座椅加热功能">+ 座椅加热</button>
<button type="button" data-prompt="增加动态木马功能">+ 动态木马</button>
</div>
<div class="capability-brief" aria-label="固件能力升级范围">
<div><span>当前基线</span><strong id="baselineCapabilities">时间控制</strong></div>
+7 -3
View File
@@ -28,7 +28,7 @@
publish: "cloud-upload",
};
const ACTIVE_STATES = new Set(["waiting", "running", "pending_confirmation"]);
const CAPABILITY_ORDER = ["时间控制", "位置控制", "座椅通风", "座椅加热"];
const CAPABILITY_ORDER = ["时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马"];
const FALLBACK_BASELINES = {
time_only: ["时间控制"],
};
@@ -112,8 +112,11 @@
"位置控制": ["位置", "position"],
"座椅通风": ["通风", "vent"],
"座椅加热": ["加热", "heater", "heat"],
"动态木马": ["木马", "rocking_horse", "rocking horse", "rocking-horse"],
};
return CAPABILITY_ORDER.filter((capability) => aliases[capability].some((alias) => lowered.includes(alias)));
const requested = CAPABILITY_ORDER.filter((capability) => aliases[capability].some((alias) => lowered.includes(alias)));
if (requested.includes("动态木马") && !requested.includes("位置控制")) requested.push("位置控制");
return CAPABILITY_ORDER.filter((capability) => requested.includes(capability));
}
function updateCapabilityPreview(task = null) {
@@ -126,6 +129,7 @@
: 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("+");
ui.targetCapabilities.title = ui.targetCapabilities.textContent;
}
function formatDate(value) {
@@ -247,7 +251,7 @@
return;
}
ui.inspector.innerHTML = task.file_changes?.length
? task.file_changes.map((change) => `<div class="change-item"><span class="change-type">${change.type === "added" ? "新增" : "修改"}</span><div class="change-copy"><strong>${escapeHtml(change.path)}</strong><span>${escapeHtml(change.summary)}</span></div></div>`).join("")
? task.file_changes.map((change) => `<div class="change-item"><span class="change-type">${change.type === "added" ? "新增" : change.type === "retained" ? "复用" : "修改"}</span><div class="change-copy"><strong>${escapeHtml(change.path)}</strong><span>${escapeHtml(change.summary)}</span></div></div>`).join("")
: `<div class="empty-inline">尚无文件变更</div>`;
}
+3 -3
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v334-video-media";
const CACHE_VERSION = "cognitive-agent-pad-v335-firmware-rocking-horse";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -27,8 +27,8 @@ const APP_SHELL = [
"/firmware-workbench-entry.js?v=3",
"/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=7",
"/firmware-workbench.js?v=4",
"/firmware-workbench.css?v=8",
"/firmware-workbench.js?v=5",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
@@ -5,6 +5,7 @@
#include "motor_control.h"
#include "seat_dynamic_mode_config.h"
#include "seat_feature_profile.h"
#include "seat_motor_manager.h"
#include "seat_move_config.h"
@@ -206,6 +207,12 @@ bool SeatDynamicMode_Start(uint8_t modeId, uint16_t commandSequence)
SeatDynamic_Definition(modeId);
uint8_t motor;
#if !SEAT_FEATURE_ROCKING_HORSE
if(modeId == SEAT_DYNAMIC_MODE_ROCKING_HORSE)
{
return false;
}
#endif
if((definition == NULL) || SeatDynamicMode_IsActive() ||
SeatMotorManager_IsAnyActive())
{
@@ -681,6 +681,13 @@ static bool SeatMove_ParametersValid(const seat_move_command_t *command,
*reject = SEAT_MOVE_REJECT_PARAM_RANGE;
return false;
}
#if !SEAT_FEATURE_ROCKING_HORSE
if(command->paramA == SEAT_DYNAMIC_MODE_ROCKING_HORSE)
{
*reject = SEAT_MOVE_REJECT_BAD_OPCODE;
return false;
}
#endif
break;
case SEAT_MOVE_OPCODE_STOP_DYNAMIC_MODE:
if((command->paramA != 0u) || (command->paramB != 0u))
@@ -9,6 +9,7 @@
#define SEAT_FEATURE_POSITION_CONTROL (1)
#define SEAT_FEATURE_VENTILATION (1)
#define SEAT_FEATURE_HEATING (1)
#define SEAT_FEATURE_ROCKING_HORSE (1)
#if ((SEAT_FEATURE_POSITION_CONTROL != 0) && \
(SEAT_FEATURE_POSITION_CONTROL != 1))
@@ -21,6 +22,13 @@
#error "SEAT_FEATURE_HEATING must be 0 or 1"
#endif
#if ((SEAT_FEATURE_ROCKING_HORSE != 0) && (SEAT_FEATURE_ROCKING_HORSE != 1))
#error "SEAT_FEATURE_ROCKING_HORSE must be 0 or 1"
#endif
#if SEAT_FEATURE_ROCKING_HORSE && !SEAT_FEATURE_POSITION_CONTROL
#error "Rocking-horse mode requires position control"
#endif
#define SEAT_FEATURE_THERMAL \
((SEAT_FEATURE_VENTILATION != 0) || (SEAT_FEATURE_HEATING != 0))
@@ -33,7 +33,7 @@ PIPELINE_STAGES = (
)
TERMINAL_TASK_STATES = frozenset({"success", "failed", "canceled", "blocked"})
ACTIVE_TASK_STATES = frozenset({"waiting", "running", "pending_confirmation"})
ALL_CAPABILITIES = ("时间控制", "位置控制", "座椅通风", "座椅加热")
ALL_CAPABILITIES = ("时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马")
BASELINE_CATALOG = {
"time_only": {
"name": "时间控制基线",
@@ -854,6 +854,7 @@ class FirmwareWorkbenchService:
"position_control": "位置控制" in target_capabilities,
"ventilation": "座椅通风" in target_capabilities,
"heating": "座椅加热" in target_capabilities,
"rocking_horse": "动态木马" in target_capabilities,
}
requirements = {
"task_id": task_id,
@@ -910,6 +911,19 @@ class FirmwareWorkbenchService:
"summary": "启用靠背加热分级控制与超时保护",
}
)
if "动态木马" in target_capabilities:
file_changes.extend([
{
"path": "App/seat_dynamic_mode.c",
"type": "modified",
"summary": "启用动态木马四轴循环运动、停止与超时保护",
},
{
"path": "Config/seat_dynamic_mode_config.h",
"type": "retained",
"summary": "复用已标定的木马中心姿态、往复端点与电机占空比",
},
])
task["file_changes"] = file_changes
task["code_diff"] = code_diff
self._save_locked()
@@ -936,7 +950,7 @@ class FirmwareWorkbenchService:
)
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。")
self._finish_stage(task_id, "build", started, "IAR 编译成功,HEX 镜像已生成。")
started = self._begin_stage(task_id, "test", "执行固件能力与工程完整性验证。")
self._check_cancel(cancel_event)
@@ -1059,11 +1073,15 @@ class FirmwareWorkbenchService:
"位置控制": ("位置", "position"),
"座椅通风": ("通风", "vent"),
"座椅加热": ("加热", "heater", "heat"),
"动态木马": ("木马", "rocking_horse", "rocking horse", "rocking-horse"),
}
for capability, aliases in patterns.items():
if any(alias in lowered for alias in aliases):
requested.append(capability)
return requested
# The ECU executes rocking-horse poses with calibrated Hall position targets.
if "动态木马" in requested and "位置控制" not in requested:
requested.append("位置控制")
return [capability for capability in ALL_CAPABILITIES if capability in requested]
def _build_log_callback(self, task_id: str) -> Callable[[str, str], None]:
def callback(level: str, line: str) -> None:
@@ -1115,6 +1133,8 @@ class FirmwareWorkbenchService:
"1" if "座椅通风" in capabilities else "0",
"-Heating",
"1" if "座椅加热" in capabilities else "0",
"-RockingHorse",
"1" if "动态木马" in capabilities else "0",
]
process = subprocess.Popen(
command,
@@ -1175,6 +1195,7 @@ class FirmwareWorkbenchService:
"position_control": "位置控制" in target_capabilities,
"ventilation": "座椅通风" in target_capabilities,
"heating": "座椅加热" in target_capabilities,
"rocking_horse": "动态木马" in target_capabilities,
}),
]
if "位置控制" in target_capabilities:
@@ -1194,6 +1215,35 @@ class FirmwareWorkbenchService:
"座椅加热控制",
"SeatBackrestHeater_SetLevel" in thermal and "MAX_ACTIVE_TIME" in heater,
))
if "动态木马" in target_capabilities:
dynamic = (self.target_root / "App" / "seat_dynamic_mode.c").read_text(
encoding="utf-8", errors="replace"
)
dynamic_config = (
self.target_root / "Config" / "seat_dynamic_mode_config.h"
).read_text(encoding="utf-8", errors="replace")
checks.extend([
("动态木马位置控制依赖", feature_profile["position_control"]),
(
"动态木马控制与停止保护",
all(token in dynamic for token in (
"SEAT_DYNAMIC_MODE_ROCKING_HORSE",
"SEAT_FEATURE_ROCKING_HORSE",
"SeatDynamicMode_Start",
"SeatDynamicMode_Stop",
"SeatDynamic_Fail",
))
and "SEAT_MOVE_OPCODE_START_DYNAMIC_MODE" in application
and "SEAT_MOVE_OPCODE_STOP_DYNAMIC_MODE" in application,
),
(
"动态木马标定与工程配置",
"SEAT_DYNAMIC_HORSE_PHASE_TIMEOUT_MS" in dynamic_config
and "SEAT_DYNAMIC_HORSE_FORWARD_M0_HALL" in dynamic_config
and "SEAT_DYNAMIC_HORSE_BACKWARD_M0_HALL" in dynamic_config
and "seat_dynamic_mode.c" in project,
),
])
checks.extend([
(
"IAR 工程模块完整性",
@@ -1245,6 +1295,7 @@ class FirmwareWorkbenchService:
"位置控制": "position",
"座椅通风": "vent",
"座椅加热": "heat",
"动态木马": "rocking-horse",
}[capability]
for capability in capabilities
)
+33 -2
View File
@@ -1,12 +1,17 @@
from __future__ import annotations
import io
import json
import tempfile
import threading
import time
import urllib.request
import zipfile
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
import pytest
from cognitive_agent.application.firmware_workbench import FirmwareWorkbenchService
from cognitive_agent.auth import AuthConfig, AuthRuntime
@@ -75,8 +80,14 @@ class TestFirmwareWorkbenchApi:
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 = "增加位置控制、座椅通风和座椅加热功能"
@pytest.mark.parametrize("instruction,capabilities", [
("增加位置控制、座椅通风和座椅加热功能", ["时间控制", "位置控制", "座椅通风", "座椅加热"]),
("增加动态木马功能", ["时间控制", "位置控制", "动态木马"]),
("增加动态木马、座椅通风和座椅加热功能", ["时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马"]),
])
def test_real_http_task_publish_detail_and_download_flow(
self, instruction: str, capabilities: list[str], monkeypatch
) -> None:
status, created = self.post(
"/api/firmware-workbench/tasks", {"instruction": instruction}
)
@@ -135,6 +146,26 @@ class TestFirmwareWorkbenchApi:
assert response.headers["Content-Type"] == "application/zip"
assert "attachment" in response.headers["Content-Disposition"]
assert len(package) == artifact["size_bytes"]
assert artifact["capabilities"] == capabilities
with zipfile.ZipFile(io.BytesIO(package)) as archive:
assert json.loads(archive.read("manifest.json"))["capabilities"] == capabilities
frames = []
def publish(frame):
frames.append(frame)
return {"status": "published"}
monkeypatch.setattr(
SeatDemoHandler, "service", SimpleNamespace(publish_firmware_notification=publish)
)
notify_status, notified = self.post(
f"/api/firmware-workbench/artifacts/{artifact_id}/notify", {}
)
assert notify_status == 200
assert notified["published_count"] == 3
assert [frame["sequence"] for frame in frames] == [1, 2, 3]
assert all(frame["firmware"]["release_id"] == release_id for frame in frames)
delete_request = urllib.request.Request(
f"{self.base_url}/api/firmware-workbench/artifacts/{artifact_id}",
+88
View File
@@ -7,10 +7,14 @@ import time
import zipfile
from pathlib import Path
import pytest
from cognitive_agent.application.firmware_workbench import (
FINAL_RELEASE_MESSAGE,
FirmwareWorkbenchError,
FirmwareWorkbenchService,
)
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
ROOT = Path(__file__).resolve().parents[2]
@@ -213,6 +217,8 @@ def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) ->
assert artifact["capability_diff"]["added"] == ["位置控制"]
assert "vent" not in artifact["firmware_filename"]
assert "heat" not in artifact["firmware_filename"]
profile_path = tmp_path / "workbench" / "tasks" / task["id"] / "feature_profile.json"
assert json.loads(profile_path.read_text("utf-8"))["rocking_horse"] is False
service.close()
@@ -251,3 +257,85 @@ def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
completed = wait_for_terminal(service, task_id)
assert completed["status"] == "success"
service.close()
@pytest.mark.parametrize("instruction", [
"增加动态木马功能",
"增加木马功能",
"Add rocking_horse",
"Add Rocking Horse",
"Add rocking-horse",
])
def test_rocking_horse_package_and_notification(tmp_path: Path, instruction: str) -> None:
image = make_image(tmp_path)
profile = {
"position_control": True, "ventilation": False,
"heating": False, "rocking_horse": True,
}
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
workspace = tmp_path / "workbench" / "tasks" / task_id
assert json.loads((workspace / "feature_profile.json").read_text("utf-8")) == profile
return image
service = FirmwareWorkbenchService(
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
build_runner=build_runner,
)
try:
started = service.start_task(instruction)
task = wait_for_terminal(service, started["task"]["id"])
assert task["status"] == "success", task.get("failure_reason")
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
assert task["target_capabilities"] == ["时间控制", "位置控制", "动态木马"]
assert any(item["name"] == "动态木马控制与停止保护" for item in task["test_results"])
artifact = service.artifact(task["artifact_id"])
assert artifact["capability_diff"]["added"] == ["位置控制", "动态木马"]
assert "rocking-horse" in artifact["firmware_filename"]
package, _ = service.resolve_release_download(task["release_id"])
with zipfile.ZipFile(package) as archive:
assert json.loads(archive.read("manifest.json"))["capabilities"] == task["target_capabilities"]
assert json.loads(archive.read("feature_profile.json")) == profile
assert json.loads(archive.read("requirements.json"))["feature_profile"] == profile
frames = []
def publish(frame):
frames.append(frame)
return {"status": "published"}
result = service.notify_artifact(artifact["id"], publish)
assert result["published_count"] == 3
assert [frame["sequence"] for frame in frames] == [1, 2, 3]
assert len({frame["message_id"] for frame in frames}) == 1
assert all(frame["firmware"]["artifact_name"] == artifact["firmware_filename"] for frame in frames)
assert all(frame["firmware"]["sha256"] == artifact["sha256"] for frame in frames)
assert all(frame["auto_upgrade"] is False for frame in frames)
def unavailable(frame):
raise CanGatewayError("broker unavailable")
with pytest.raises(FirmwareWorkbenchError, match="0/3") as error:
service.notify_artifact(artifact["id"], unavailable)
assert error.value.code == "notification_publish_failed"
assert service.artifact(artifact["id"])["status"] == "published"
finally:
service.close()
def test_rocking_horse_build_failure_does_not_publish(tmp_path: Path) -> None:
def fail_build(task_id: str, log: object, cancel: threading.Event) -> Path:
raise RuntimeError("IAR build failed")
service = FirmwareWorkbenchService(
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
build_runner=fail_build,
)
try:
started = service.start_task("增加动态木马功能")
task = wait_for_terminal(service, started["task"]["id"])
assert task["status"] == "failed"
assert task["failure_reason"] == "IAR build failed"
assert service.snapshot()["releases"] == []
assert all(item.get("is_baseline") for item in service.snapshot()["artifacts"])
finally:
service.close()
@@ -58,8 +58,8 @@ def test_workbench_exposes_artifact_actions_and_always_starts_a_new_task() -> No
"/firmware-workbench-entry.js?v=3",
"/firmware-workbench.html",
"/firmware-workbench-theme.js?v=1",
"/firmware-workbench.css?v=7",
"/firmware-workbench.js?v=4",
"/firmware-workbench.css?v=8",
"/firmware-workbench.js?v=5",
):
assert f'"{asset}"' in worker
for runtime_file in (
+27 -13
View File
@@ -11,10 +11,15 @@ param(
[ValidateSet(0, 1)]
[int]$Ventilation = 1,
[ValidateSet(0, 1)]
[int]$Heating = 1
[int]$Heating = 1,
[ValidateSet(0, 1)]
[int]$RockingHorse = $PositionControl
)
$ErrorActionPreference = 'Stop'
if ($RockingHorse -eq 1 -and $PositionControl -eq 0) {
throw 'Rocking-horse mode requires -PositionControl 1.'
}
$repositoryCandidate = if ($RepositoryRoot) {
$RepositoryRoot
} else {
@@ -142,23 +147,29 @@ try {
New-Item -ItemType Junction -Path (Join-Path $stagingRootFull 'tviibh8m') -Target $vendorDevice | Out-Null
$featureProfilePath = Join-Path $stagingProjectRoot 'Config\seat_feature_profile.h'
$featureProfile = @"
#ifndef SEAT_FEATURE_PROFILE_H
#define SEAT_FEATURE_PROFILE_H
/* Generated by tools/build_seat_firmware.ps1 for this build only. */
#define SEAT_FEATURE_POSITION_CONTROL ($PositionControl)
#define SEAT_FEATURE_VENTILATION ($Ventilation)
#define SEAT_FEATURE_HEATING ($Heating)
#endif /* SEAT_FEATURE_PROFILE_H */
"@
# Preserve dependency checks and derived switches (including THERMAL).
$featureProfile = [System.IO.File]::ReadAllText($featureProfilePath)
$featureValues = [ordered]@{
SEAT_FEATURE_POSITION_CONTROL = $PositionControl
SEAT_FEATURE_VENTILATION = $Ventilation
SEAT_FEATURE_HEATING = $Heating
SEAT_FEATURE_ROCKING_HORSE = $RockingHorse
}
foreach ($entry in $featureValues.GetEnumerator()) {
$pattern = '(?m)^#define\s+' + $entry.Key + '\s+\([01]\)'
if (-not [regex]::IsMatch($featureProfile, $pattern)) {
throw "Firmware feature switch is missing: $($entry.Key)"
}
$featureProfile = [regex]::Replace(
$featureProfile, $pattern, "#define $($entry.Key) ($($entry.Value))"
)
}
[System.IO.File]::WriteAllText(
$featureProfilePath,
$featureProfile,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host "Feature profile: position=$PositionControl ventilation=$Ventilation heating=$Heating"
Write-Host "Feature profile: position=$PositionControl ventilation=$Ventilation heating=$Heating rocking_horse=$RockingHorse"
$stagedProject = Join-Path $stagingProjectRoot 'IAR\Seat_Controller_demo.ewp'
Invoke-IarBuild -ProjectPath $stagedProject
@@ -170,6 +181,9 @@ try {
Join-Path $repositoryFirmware "IAR\$Configuration\Exe"
}
New-Item -ItemType Directory -Path $artifactOutput -Force | Out-Null
Copy-BuildArtifact -Source $featureProfilePath -Destination (
Join-Path $artifactOutput 'seat_feature_profile.h'
)
foreach ($artifact in @('Seat_Controller_demo.hex', 'Seat_Controller_demo.out')) {
$sourceArtifact = Join-Path $stagedExe $artifact
if (-not (Test-Path -LiteralPath $sourceArtifact -PathType Leaf)) {