This commit is contained in:
orin
2026-07-30 14:09:04 +08:00
parent 25b24ac4cd
commit 49f5c49c53
35 changed files with 4078 additions and 48 deletions
@@ -339,6 +339,55 @@ class ConversationStateStore:
self._persist_presets(conversation_id, state["presets"])
return deepcopy(state)
def save_mode_profile(
self,
conversation_id: str,
utterance: str,
mode_profile: dict[str, Any],
source_actions: list[dict[str, Any]],
decision_summary: list[str],
) -> dict[str, Any]:
with self._lock:
state = self._state(conversation_id)
state["turn_count"] += 1
state["updated_at"] = _now()
state["recent_turns"].append(
{
"utterance": utterance,
"actions": deepcopy(source_actions),
"decision_summary": list(decision_summary),
"control_event": "mode_profile_save",
"mode_profile": deepcopy(mode_profile),
}
)
state["recent_turns"] = state["recent_turns"][-6:]
return deepcopy(state)
def record_mode_profile_event(
self,
conversation_id: str,
utterance: str,
mode_profile: dict[str, Any],
source_actions: list[dict[str, Any]],
decision_summary: list[str],
control_event: str,
) -> dict[str, Any]:
with self._lock:
state = self._state(conversation_id)
state["turn_count"] += 1
state["updated_at"] = _now()
state["recent_turns"].append(
{
"utterance": utterance,
"actions": deepcopy(source_actions),
"decision_summary": list(decision_summary),
"control_event": control_event,
"mode_profile": deepcopy(mode_profile),
}
)
state["recent_turns"] = state["recent_turns"][-6:]
return deepcopy(state)
def reset(self, conversation_id: str) -> dict[str, Any]:
with self._lock:
state = _new_state(conversation_id, self._load_presets(conversation_id))
@@ -0,0 +1,435 @@
"""Repository-backed cabin mode profiles."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
_WHITESPACE = re.compile(r"\s+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
def _normalize_match_text(value: str) -> str:
return _WHITESPACE.sub("", value.casefold())
def _clean_name(value: str) -> str:
cleaned = value.strip().strip(",。,.!!")
if not cleaned:
raise ValueError("mode name must not be empty")
if len(cleaned) > 64:
raise ValueError("mode name must be at most 64 characters")
return cleaned
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
values = [name, *(aliases or [])]
if name.endswith("模式") and len(name) > 2:
values.append(name.removesuffix("模式"))
else:
values.append(f"{name}模式")
normalized: set[str] = set()
unique = []
for value in values:
candidate = str(value).strip()
key = _normalize_match_text(candidate)
if not candidate or key in normalized:
continue
normalized.add(key)
unique.append(candidate)
return unique
def _slugify_mode_id(name: str) -> str:
ascii_slug = _NON_ASCII_SLUG.sub("-", name.casefold()).strip("-")
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:10]
if ascii_slug:
return ascii_slug if len(ascii_slug) >= 3 else f"{ascii_slug}-{digest[:6]}"
return f"mode-{digest}"
@dataclass(frozen=True)
class ModeOperation:
domain: str
capability: str
target_scope: str
parameters: dict[str, Any]
operation: str = "set"
reason: str = ""
@classmethod
def from_dict(cls, document: dict[str, Any]) -> "ModeOperation":
parameters = document.get("parameters", {})
if not isinstance(parameters, dict):
raise ValueError("mode operation parameters must be an object")
return cls(
domain=str(document["domain"]),
capability=str(document["capability"]),
target_scope=str(document["target_scope"]),
operation=str(document.get("operation", "set")),
parameters=dict(parameters),
reason=str(document.get("reason", "")),
)
def to_request(self, mode_name: str) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"operation": self.operation,
"parameters": [
{"name": name, "value": value}
for name, value in self.parameters.items()
],
"reason": self.reason or f"Matched mode profile {mode_name}.",
}
def to_yaml(self) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"operation": self.operation,
"parameters": dict(self.parameters),
"reason": self.reason,
}
@dataclass(frozen=True)
class ModeProfile:
id: str
name: str
aliases: tuple[str, ...]
description: str
icon: str
operations: tuple[ModeOperation, ...]
source: str
common: bool = True
vehicle_ids: tuple[str, ...] = ()
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
raise ValueError(f"{path} must contain a YAML object")
operations = document.get("operations", [])
if not isinstance(operations, list) or not operations:
raise ValueError(f"{path} must contain at least one operation")
name = _clean_name(str(document["name"]))
common, vehicle_ids = _mode_scope(document, path, profile_root)
aliases = _mode_aliases(
name,
[str(alias) for alias in document.get("aliases", [])],
)
return cls(
id=str(document["id"]),
name=name,
aliases=tuple(aliases),
description=str(document.get("description", "")),
icon=str(document.get("icon", "bookmark")),
operations=tuple(ModeOperation.from_dict(item) for item in operations),
source=str(path),
common=common,
vehicle_ids=vehicle_ids,
)
def public_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"aliases": list(self.aliases),
"description": self.description,
"icon": self.icon,
"domains": sorted({operation.domain for operation in self.operations}),
"operation_count": len(self.operations),
"scope": {
"common": self.common,
"vehicle_ids": list(self.vehicle_ids),
},
}
class ModeProfileRegistry:
"""Load, match, and persist long-term cabin mode profiles."""
def __init__(self, profile_root: Path):
self.profile_root = Path(profile_root)
self._profiles: list[ModeProfile] = []
self.reload()
def reload(self) -> None:
if not self.profile_root.exists():
self._profiles = []
return
self._profiles = [
ModeProfile.from_yaml(path, self.profile_root)
for path in sorted(self.profile_root.rglob("*.yaml"))
if path.is_file()
]
def profiles(self) -> list[ModeProfile]:
return list(self._profiles)
def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
return [
profile.public_dict()
for profile in self._profiles_for_vehicle(vehicle_id)
]
def match_text(
self,
utterance: str,
vehicle_id: str | None = None,
) -> list[ModeProfile]:
haystack = _normalize_match_text(utterance)
selected = {
profile.id: profile
for profile in self._profiles_for_vehicle(vehicle_id)
}
matches = []
matched_ids: set[str] = set()
for profile in self._visible_candidates_for_vehicle(vehicle_id):
candidates = [profile.id, profile.name, *profile.aliases]
if any(
candidate
and _normalize_match_text(str(candidate)) in haystack
for candidate in candidates
):
if profile.id in matched_ids:
continue
matched_ids.add(profile.id)
matches.append(selected[profile.id])
return matches
@staticmethod
def requests_for_profiles(profiles: list[ModeProfile]) -> list[dict[str, Any]]:
return [
operation.to_request(profile.name)
for profile in profiles
for operation in profile.operations
]
def save_mode(
self,
name: str,
operations: list[dict[str, Any] | ModeOperation],
mode_id: str | None = None,
aliases: list[str] | None = None,
vehicle_id: str | None = None,
) -> ModeProfile:
clean_name = _clean_name(name)
canonical_operations = [
operation
if isinstance(operation, ModeOperation)
else ModeOperation.from_dict(operation)
for operation in operations
]
if not canonical_operations:
raise ValueError("mode profile must contain at least one operation")
profile_id = mode_id or _slugify_mode_id(clean_name)
scope = (
{"vehicle_ids": [vehicle_id]}
if vehicle_id
else {"common": True, "vehicle_ids": []}
)
document = {
"id": profile_id,
"name": clean_name,
"aliases": _mode_aliases(clean_name, aliases),
"description": "用户固化的长期模式配置。",
"icon": "bookmark",
"scope": scope,
"operations": [
operation.to_yaml() for operation in canonical_operations
],
}
path = (
self.profile_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
else self.profile_root / "common" / "user" / f"{profile_id}.yaml"
)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def update_mode(
self,
profile: ModeProfile,
operations: list[dict[str, Any] | ModeOperation],
) -> ModeProfile:
canonical_updates = _canonical_operations(operations)
if not canonical_updates:
raise ValueError("mode profile update must contain at least one operation")
path = Path(profile.source)
if not path.is_file():
raise ValueError(f"mode profile source does not exist: {path}")
backup_path = _initial_backup_path(path)
if not backup_path.exists():
backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
merged_operations = _merge_operations(profile.operations, canonical_updates)
document = _mode_profile_document(profile, merged_operations)
_replace_yaml(path, document)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile:
path = Path(profile.source)
backup_path = _initial_backup_path(path)
if not backup_path.is_file():
raise ValueError(f"no initial mode backup found: {backup_path}")
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(backup_path.read_text(encoding="utf-8"), encoding="utf-8")
temporary.replace(path)
self.reload()
return ModeProfile.from_yaml(path, self.profile_root)
def _profiles_for_vehicle(self, vehicle_id: str | None) -> list[ModeProfile]:
selected: dict[str, ModeProfile] = {}
order: list[str] = []
for profile in self._visible_candidates_for_vehicle(vehicle_id):
if profile.id not in selected:
order.append(profile.id)
selected[profile.id] = profile
continue
current = selected[profile.id]
if _profile_rank(profile, vehicle_id) >= _profile_rank(current, vehicle_id):
selected[profile.id] = profile
return [selected[profile_id] for profile_id in order]
def _visible_candidates_for_vehicle(
self,
vehicle_id: str | None,
) -> list[ModeProfile]:
return [
profile
for profile in self._profiles
if _is_visible_for_vehicle(profile, vehicle_id)
]
def _canonical_operations(
operations: list[dict[str, Any] | ModeOperation],
) -> list[ModeOperation]:
return [
operation
if isinstance(operation, ModeOperation)
else ModeOperation.from_dict(operation)
for operation in operations
]
def _operation_signature(operation: ModeOperation) -> tuple[str, str, str]:
return (operation.domain, operation.capability, operation.target_scope)
def _merge_operations(
existing: tuple[ModeOperation, ...],
updates: list[ModeOperation],
) -> list[ModeOperation]:
merged = list(existing)
positions = {
_operation_signature(operation): index
for index, operation in enumerate(merged)
}
for operation in updates:
signature = _operation_signature(operation)
index = positions.get(signature)
if index is None:
positions[signature] = len(merged)
merged.append(operation)
continue
merged[index] = operation
return merged
def _initial_backup_path(path: Path) -> Path:
return path.with_suffix(path.suffix + ".initial.bak")
def _mode_profile_document(
profile: ModeProfile,
operations: list[ModeOperation],
) -> dict[str, Any]:
scope: dict[str, Any] = {
"common": profile.common,
"vehicle_ids": list(profile.vehicle_ids),
}
return {
"id": profile.id,
"name": profile.name,
"aliases": list(profile.aliases),
"description": profile.description,
"icon": profile.icon,
"scope": scope,
"operations": [operation.to_yaml() for operation in operations],
}
def _replace_yaml(path: Path, document: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temporary.replace(path)
def _mode_scope(
document: dict[str, Any],
path: Path,
profile_root: Path | None,
) -> tuple[bool, tuple[str, ...]]:
scope = document.get("scope")
if isinstance(scope, dict):
vehicle_ids = tuple(
str(vehicle_id).strip()
for vehicle_id in scope.get("vehicle_ids", [])
if str(vehicle_id).strip()
)
common = bool(scope.get("common", not vehicle_ids))
return common, vehicle_ids
if profile_root is not None:
try:
parts = path.relative_to(profile_root).parts
except ValueError:
parts = ()
if len(parts) >= 3 and parts[0] == "vehicles":
return False, (parts[1],)
if parts and parts[0] == "common":
return True, ()
return True, ()
def _is_visible_for_vehicle(
profile: ModeProfile,
vehicle_id: str | None,
) -> bool:
if vehicle_id is None:
return True
return profile.common or vehicle_id in profile.vehicle_ids
def _profile_rank(profile: ModeProfile, vehicle_id: str | None) -> int:
if vehicle_id is not None and vehicle_id in profile.vehicle_ids:
return 2
if profile.common:
return 1
return 0
@@ -10,6 +10,7 @@ from cognitive_agent.application.seat_calibration import (
ANGLE_CALIBRATIONS,
angle_to_position_pct,
)
from cognitive_agent.application.vehicle_profile import VehicleProfile
SEAT_CAPABILITIES = {
@@ -23,6 +24,7 @@ SEAT_MOTION_CAPABILITIES = {
"seat.set_legrest_height": "legrest_height",
"seat.set_legrest_extension": "legrest_extension",
}
SEAT_RELATIVE_PHYSICAL_PARAMETERS = {"distance_mm", "angle_delta_deg"}
SAVE_PRESET_CAPABILITY = "seat.save_posture_preset"
APPLY_PRESET_CAPABILITY = "seat.apply_posture_preset"
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
@@ -45,6 +47,7 @@ class ModelPlanAdapter:
self,
plan: dict[str, Any],
context: dict[str, Any] | None = None,
vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any]:
actions: list[dict[str, Any]] = []
domain_actions: list[dict[str, Any]] = []
@@ -73,6 +76,11 @@ class ModelPlanAdapter:
}
allowed_parameters = {item["name"] for item in capability.get("parameters", [])}
unknown_parameters = set(parameters) - allowed_parameters
if (
domain == "seat"
and capability_name in SEAT_MOTION_CAPABILITIES
):
unknown_parameters -= SEAT_RELATIVE_PHYSICAL_PARAMETERS
if unknown_parameters:
ambiguities.append(
{
@@ -162,6 +170,7 @@ class ModelPlanAdapter:
operation_parameters,
ambiguities,
context,
vehicle_profile,
)
if action is not None:
actions.append(action)
@@ -174,7 +183,7 @@ class ModelPlanAdapter:
)
continue
action = self._seat_action(
index, request, parameters, ambiguities, context
index, request, parameters, ambiguities, context, vehicle_profile
)
if action is not None:
actions.append(action)
@@ -367,6 +376,7 @@ class ModelPlanAdapter:
parameters: dict[str, Any],
ambiguities: list[dict[str, str]],
context: dict[str, Any],
vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any] | None:
target = request.get("target_scope")
if target not in {"driver", "passenger"}:
@@ -381,21 +391,68 @@ class ModelPlanAdapter:
capability = request["capability"]
if capability in SEAT_MOTION_CAPABILITIES:
axis = SEAT_MOTION_CAPABILITIES[capability]
physical_action = ModelPlanAdapter._relative_physical_motion_action(
index,
request,
parameters,
ambiguities,
context,
target,
axis,
vehicle_profile,
)
if physical_action is not None:
return physical_action
angle = parameters.get("angle_deg")
if angle is not None and capability in ANGLE_CALIBRATIONS:
calibration = ANGLE_CALIBRATIONS[capability]
if (
not isinstance(angle, (int, float))
or isinstance(angle, bool)
or not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg
):
if angle is not None:
if not isinstance(angle, (int, float)) or isinstance(angle, bool):
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
"question": (
f"{capability} 的夹角必须在 "
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
),
"question": f"{capability} 的夹角必须是数字。",
}
)
return None
profile_motor = (
vehicle_profile.seat_motors.get(axis)
if vehicle_profile is not None
else None
)
if profile_motor is not None and profile_motor.display_angle:
if not profile_motor.contains_display_angle(float(angle)):
display_angle = profile_motor.display_angle
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
"question": (
f"{capability} 的夹角必须在 "
f"{display_angle['zero_deg']:g}-{display_angle['max_deg']:g} 度之间。"
),
}
)
return None
position_pct = profile_motor.position_pct_for_display_angle(
float(angle)
)
elif capability in ANGLE_CALIBRATIONS:
calibration = ANGLE_CALIBRATIONS[capability]
if not calibration.minimum_deg <= float(angle) <= calibration.maximum_deg:
ambiguities.append(
{
"field": f"requests[{index}].parameters.angle_deg",
"question": (
f"{capability} 的夹角必须在 "
f"{calibration.minimum_deg:g}-{calibration.maximum_deg:g} 度之间。"
),
}
)
return None
position_pct = angle_to_position_pct(capability, float(angle))
else:
ambiguities.append(
{
"field": f"requests[{index}].parameters.position_pct",
"question": "当前车型未给出该座椅轴的角度标定,需要 0-100 的绝对百分比。",
}
)
return None
@@ -406,7 +463,7 @@ class ModelPlanAdapter:
"action_type": "position",
"target": target,
"axis": axis,
"position_pct": angle_to_position_pct(capability, float(angle)),
"position_pct": position_pct,
"angle_deg": exact_angle,
}
return ModelPlanAdapter._preserve_relative_motion(
@@ -460,6 +517,157 @@ class ModelPlanAdapter:
),
}
@staticmethod
def _relative_physical_motion_action(
index: int,
request: dict[str, Any],
parameters: dict[str, Any],
ambiguities: list[dict[str, str]],
context: dict[str, Any],
target: str,
axis: str,
vehicle_profile: VehicleProfile | None,
) -> dict[str, Any] | None:
physical_parameters = [
name
for name in ("distance_mm", "angle_delta_deg")
if parameters.get(name) is not None
]
if not physical_parameters:
return None
if len(physical_parameters) > 1:
ambiguities.append(
{
"field": f"requests[{index}].parameters",
"question": "单个座椅轴相对移动只能提供一种物理增量。",
}
)
return None
if vehicle_profile is None:
ambiguities.append(
{
"field": f"requests[{index}].parameters.position_pct",
"question": "缺少车型座椅量程,无法把相对物理移动换算为绝对百分比。",
}
)
return None
parameter_name = physical_parameters[0]
delta = parameters[parameter_name]
if (
not isinstance(delta, (int, float))
or isinstance(delta, bool)
or float(delta) <= 0
):
ambiguities.append(
{
"field": f"requests[{index}].parameters.{parameter_name}",
"question": "座椅相对物理移动量必须是正数。",
}
)
return None
motor = vehicle_profile.seat_motors.get(axis)
if motor is None:
ambiguities.append(
{
"field": f"requests[{index}].capability",
"question": f"车型 {vehicle_profile.name} 未配置 {axis} 轴量程。",
}
)
return None
expected_unit = "mm" if parameter_name == "distance_mm" else "degree"
if motor.unit != expected_unit:
ambiguities.append(
{
"field": f"requests[{index}].parameters.{parameter_name}",
"question": (
f"{axis} 轴使用 {motor.unit} 标定,不能用 {expected_unit} 增量换算。"
),
}
)
return None
direction = str(request.get("operation", "set"))
if direction not in {"increase", "decrease"}:
ambiguities.append(
{
"field": f"requests[{index}].operation",
"question": "相对物理移动需要 increase 或 decrease 方向。",
}
)
return None
previous = (
context.get("devices", {})
.get(target, {})
.get("position", {})
.get(axis)
)
if not isinstance(previous, dict):
ambiguities.append(
{
"field": f"requests[{index}].target_scope",
"question": "缺少当前座椅姿态,无法安全执行相对物理移动。",
}
)
return None
current_pct = previous.get("position_pct")
if (
not isinstance(current_pct, (int, float))
or isinstance(current_pct, bool)
):
ambiguities.append(
{
"field": f"requests[{index}].target_scope",
"question": "当前座椅百分比位置无效,无法安全执行相对物理移动。",
}
)
return None
position_pct = vehicle_profile.relative_target_pct(
axis,
current_pct=current_pct,
delta=float(delta),
direction=direction,
)
action: dict[str, Any] = {
"action_type": "position",
"target": target,
"axis": axis,
"position_pct": position_pct,
}
if parameter_name == "angle_delta_deg":
action["angle_deg"] = ModelPlanAdapter._relative_target_angle(
previous,
motor,
position_pct,
float(delta),
direction,
)
return ModelPlanAdapter._preserve_relative_motion(
index, request, action, context, ambiguities
)
@staticmethod
def _relative_target_angle(
previous: dict[str, Any],
motor: Any,
position_pct: int,
delta_deg: float,
direction: str,
) -> int | float:
start_angle = previous.get("angle_deg")
if (
isinstance(start_angle, (int, float))
and not isinstance(start_angle, bool)
):
signed_delta = delta_deg if direction == "increase" else -delta_deg
return _clean_number(float(start_angle) + signed_delta)
display_angle = motor.display_angle_for_pct(position_pct)
if display_angle is None:
return _clean_number(delta_deg)
return _clean_number(display_angle)
@staticmethod
def _preserve_relative_motion(
index: int,
@@ -530,3 +738,7 @@ class ModelPlanAdapter:
)
action["motion_intent"] = motion_intent
return action
def _clean_number(value: float) -> int | float:
return int(value) if float(value).is_integer() else round(float(value), 1)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
"""Vehicle-specific seat calibration profiles."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True)
class SeatMotorProfile:
id: str
name: str
unit: str
range: float
reference_zero: str
positive_direction: str
hall_total_pulses: int
display_angle: dict[str, Any] | None = None
def delta_to_pct(self, delta: float) -> float:
if self.range <= 0:
raise ValueError(f"motor {self.id} range must be positive")
return float(delta) / self.range * 100
def display_angle_for_pct(self, position_pct: int) -> float | None:
if not self.display_angle:
return None
zero = float(self.display_angle["zero_deg"])
maximum = float(self.display_angle["max_deg"])
return zero + (maximum - zero) * _clamp_pct(position_pct) / 100
def position_pct_for_display_angle(self, angle_deg: int | float) -> int:
if not self.display_angle:
raise ValueError(f"motor {self.id} does not define a display angle range")
zero = float(self.display_angle["zero_deg"])
maximum = float(self.display_angle["max_deg"])
if maximum <= zero:
raise ValueError(f"motor {self.id} display angle range must be positive")
normalized = (float(angle_deg) - zero) / (maximum - zero)
return _clamp_pct(round(normalized * 100))
def contains_display_angle(self, angle_deg: int | float) -> bool:
if not self.display_angle:
return False
zero = float(self.display_angle["zero_deg"])
maximum = float(self.display_angle["max_deg"])
return zero <= float(angle_deg) <= maximum
def public_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": self.id,
"name": self.name,
"unit": self.unit,
"range": _public_number(self.range),
"reference_zero": self.reference_zero,
"positive_direction": self.positive_direction,
"hall_total_pulses": self.hall_total_pulses,
}
if self.display_angle is not None:
payload["display_angle"] = dict(self.display_angle)
return payload
@dataclass(frozen=True)
class VehicleProfile:
id: str
name: str
seat_motors: dict[str, SeatMotorProfile]
def relative_target_pct(
self,
axis: str,
*,
current_pct: int | float,
delta: int | float,
direction: str,
) -> int:
motor = self.seat_motors[axis]
delta_pct = motor.delta_to_pct(float(delta))
if direction == "decrease":
delta_pct = -delta_pct
elif direction != "increase":
raise ValueError(f"unsupported relative direction: {direction}")
return _clamp_pct(round(float(current_pct) + delta_pct))
def display_angle_for_pct(self, axis: str, position_pct: int) -> float | None:
return self.seat_motors[axis].display_angle_for_pct(position_pct)
def public_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"seat": {
"motors": {
key: motor.public_dict()
for key, motor in self.seat_motors.items()
}
},
}
class VehicleProfileRegistry:
def __init__(self, profile_root: Path) -> None:
self.profile_root = profile_root
self._profiles = self._load_profiles(profile_root)
def get(self, vehicle_id: str | None) -> VehicleProfile:
resolved = vehicle_id or self.default().id
try:
return self._profiles[resolved]
except KeyError as exc:
raise ValueError(f"unknown vehicle profile: {resolved}") from exc
def default(self) -> VehicleProfile:
if "denza_n7" in self._profiles:
return self._profiles["denza_n7"]
try:
return next(iter(self._profiles.values()))
except StopIteration as exc:
raise ValueError(f"no vehicle profiles found in {self.profile_root}") from exc
def public_profiles(self) -> list[dict[str, Any]]:
return [profile.public_dict() for profile in self._profiles.values()]
@staticmethod
def _load_profiles(profile_root: Path) -> dict[str, VehicleProfile]:
profiles: dict[str, VehicleProfile] = {}
for path in sorted(profile_root.glob("*.yaml")):
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
profile = _parse_vehicle_profile(data)
profiles[profile.id] = profile
return profiles
def _parse_vehicle_profile(data: dict[str, Any]) -> VehicleProfile:
seat = data.get("seat") or {}
motors = seat.get("motors") or {}
seat_motors = {
str(axis): SeatMotorProfile(
id=str(axis),
name=str(payload["name"]),
unit=str(payload["unit"]),
range=float(payload["range"]),
reference_zero=str(payload["reference_zero"]),
positive_direction=str(payload["positive_direction"]),
hall_total_pulses=int(payload["hall_total_pulses"]),
display_angle=payload.get("display_angle"),
)
for axis, payload in motors.items()
}
return VehicleProfile(
id=str(data["id"]),
name=str(data["name"]),
seat_motors=seat_motors,
)
def _clamp_pct(value: int | float) -> int:
return max(0, min(100, int(value)))
def _public_number(value: float) -> int | float:
return int(value) if value.is_integer() else value