feat: integrate cabin CAN and verified dynamic seat modes
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user