feat: integrate cabin CAN and verified dynamic seat modes

This commit is contained in:
vee
2026-08-04 17:51:38 +08:00
parent 155fbb59ba
commit dc7c91800e
99 changed files with 51897 additions and 3618 deletions
+207 -2
View File
@@ -102,6 +102,195 @@ class ModeOperation:
}
@dataclass(frozen=True)
class ModeTimelinePhase:
id: str
name: str
duration_ms: int | None
transition: str
targets: dict[str, Any]
effects: dict[str, Any]
completion: str
timeout_ms: int | None
motion: dict[str, Any]
arrival_grace_ms: int = 0
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeTimelinePhase":
phase_id = str(document.get("id", "")).strip()
if not phase_id:
raise ValueError("mode timeline phase id must not be empty")
duration_ms = document.get("duration_ms")
completion = str(
document.get(
"completion",
"elapsed_time" if duration_ms is not None else "all_targets_reached",
)
).strip()
if completion not in {"elapsed_time", "all_targets_reached"}:
raise ValueError(
f"mode timeline phase {phase_id} completion must be elapsed_time or all_targets_reached"
)
if completion == "elapsed_time":
if (
not isinstance(duration_ms, int)
or isinstance(duration_ms, bool)
or duration_ms <= 0
):
raise ValueError(
f"mode timeline phase {phase_id} duration_ms must be a positive integer"
)
elif duration_ms is not None:
raise ValueError(
f"mode timeline phase {phase_id} duration_ms must be omitted for all_targets_reached"
)
timeout_ms = document.get("timeout_ms")
if timeout_ms is not None and (
not isinstance(timeout_ms, int)
or isinstance(timeout_ms, bool)
or timeout_ms <= 0
):
raise ValueError(
f"mode timeline phase {phase_id} timeout_ms must be a positive integer"
)
if completion == "all_targets_reached" and timeout_ms is None:
raise ValueError(
f"mode timeline phase {phase_id} timeout_ms is required for all_targets_reached"
)
arrival_grace_ms = document.get("arrival_grace_ms", 0)
if (
not isinstance(arrival_grace_ms, int)
or isinstance(arrival_grace_ms, bool)
or arrival_grace_ms < 0
):
raise ValueError(
f"mode timeline phase {phase_id} arrival_grace_ms must be a non-negative integer"
)
targets = document.get("targets", {})
effects = document.get("effects", {})
motion = document.get("motion", {})
if (
not isinstance(targets, dict)
or not isinstance(effects, dict)
or not isinstance(motion, dict)
):
raise ValueError(
f"mode timeline phase {phase_id} targets/effects/motion must be objects"
)
return cls(
id=phase_id,
name=str(document.get("name", phase_id)),
duration_ms=duration_ms,
transition=str(document.get("transition", "hold")),
targets=dict(targets),
effects=dict(effects),
completion=completion,
timeout_ms=timeout_ms,
motion=dict(motion),
arrival_grace_ms=arrival_grace_ms,
)
def to_yaml(self) -> dict[str, Any]:
result = {
"id": self.id,
"name": self.name,
"transition": self.transition,
"targets": dict(self.targets),
"effects": dict(self.effects),
"completion": self.completion,
}
if self.duration_ms is not None:
result["duration_ms"] = self.duration_ms
if self.timeout_ms is not None:
result["timeout_ms"] = self.timeout_ms
if self.motion:
result["motion"] = dict(self.motion)
if self.arrival_grace_ms:
result["arrival_grace_ms"] = self.arrival_grace_ms
return result
@dataclass(frozen=True)
class ModeTimeline:
repeat: str
cycle_duration_ms: int | None
entry: dict[str, Any]
phases: tuple[ModeTimelinePhase, ...]
max_runtime_ms: int | None = None
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeTimeline":
repeat = str(document.get("repeat", "")).strip()
if repeat not in {"once", "infinite", "until_stopped_or_timeout"}:
raise ValueError(
"mode timeline repeat must be once, infinite, or until_stopped_or_timeout"
)
max_runtime_ms = document.get("max_runtime_ms")
if max_runtime_ms is not None and (
not isinstance(max_runtime_ms, int)
or isinstance(max_runtime_ms, bool)
or max_runtime_ms <= 0
):
raise ValueError("mode timeline max_runtime_ms must be a positive integer")
if repeat == "until_stopped_or_timeout" and max_runtime_ms is None:
raise ValueError(
"mode timeline max_runtime_ms is required for until_stopped_or_timeout"
)
duration_ms = document.get("cycle_duration_ms")
if duration_ms is not None and (
not isinstance(duration_ms, int)
or isinstance(duration_ms, bool)
or duration_ms <= 0
):
raise ValueError("mode timeline cycle_duration_ms must be positive")
raw_entry = document.get("entry", {})
raw_phases = document.get("phases", [])
if not isinstance(raw_entry, dict):
raise ValueError("mode timeline entry must be an object")
if not isinstance(raw_phases, list) or not raw_phases:
raise ValueError("mode timeline must contain at least one phase")
phases = tuple(ModeTimelinePhase.from_dict(item) for item in raw_phases)
phase_ids = [phase.id for phase in phases]
if len(set(phase_ids)) != len(phase_ids):
raise ValueError("mode timeline phase ids must be unique")
timed_phases = all(phase.duration_ms is not None for phase in phases)
if timed_phases:
calculated_duration = sum(
int(phase.duration_ms or 0) for phase in phases
)
if calculated_duration != duration_ms:
raise ValueError(
"mode timeline cycle_duration_ms does not equal the sum of phases: "
f"declared={duration_ms}, calculated={calculated_duration}"
)
elif duration_ms is not None:
raise ValueError(
"mode timeline cycle_duration_ms must be omitted when a phase completes by target arrival"
)
return cls(
repeat=repeat,
cycle_duration_ms=duration_ms,
entry=dict(raw_entry),
phases=phases,
max_runtime_ms=max_runtime_ms,
)
def to_yaml(self) -> dict[str, Any]:
result = {
"repeat": self.repeat,
"entry": dict(self.entry),
"phases": [phase.to_yaml() for phase in self.phases],
}
if self.cycle_duration_ms is not None:
result["cycle_duration_ms"] = self.cycle_duration_ms
if self.max_runtime_ms is not None:
result["max_runtime_ms"] = self.max_runtime_ms
return result
def public_dict(self) -> dict[str, Any]:
return self.to_yaml()
@dataclass(frozen=True)
class ModeProfile:
id: str
@@ -113,6 +302,7 @@ class ModeProfile:
source: str
common: bool = True
vehicle_ids: tuple[str, ...] = ()
timeline: ModeTimeline | None = None
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
@@ -128,6 +318,9 @@ class ModeProfile:
name,
[str(alias) for alias in document.get("aliases", [])],
)
raw_timeline = document.get("timeline")
if raw_timeline is not None and not isinstance(raw_timeline, dict):
raise ValueError(f"{path} timeline must be an object")
return cls(
id=str(document["id"]),
name=name,
@@ -138,10 +331,15 @@ class ModeProfile:
source=str(path),
common=common,
vehicle_ids=vehicle_ids,
timeline=(
ModeTimeline.from_dict(raw_timeline)
if isinstance(raw_timeline, dict)
else None
),
)
def public_dict(self) -> dict[str, Any]:
return {
result = {
"id": self.id,
"name": self.name,
"aliases": list(self.aliases),
@@ -153,7 +351,11 @@ class ModeProfile:
"common": self.common,
"vehicle_ids": list(self.vehicle_ids),
},
"behavior": "cyclic" if self.timeline is not None else "static",
}
if self.timeline is not None:
result["timeline"] = self.timeline.public_dict()
return result
class ModeProfileRegistry:
@@ -370,7 +572,7 @@ def _mode_profile_document(
"common": profile.common,
"vehicle_ids": list(profile.vehicle_ids),
}
return {
document = {
"id": profile.id,
"name": profile.name,
"aliases": list(profile.aliases),
@@ -379,6 +581,9 @@ def _mode_profile_document(
"scope": scope,
"operations": [operation.to_yaml() for operation in operations],
}
if profile.timeline is not None:
document["timeline"] = profile.timeline.to_yaml()
return document
def _replace_yaml(path: Path, document: dict[str, Any]) -> None: