"""Repository-backed cabin mode profiles.""" from __future__ import annotations import hashlib import json import re from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from threading import RLock from typing import Any import yaml from cognitive_agent.application.mode_schedule import ModeSchedule _MATCH_FORMATTING = re.compile(r"[\s()()]+") _NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+") _MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _MODE_REFERENCE_VERBS = ( "切换进入", "切换到", "切换为", "帮我进入", "开始执行", "进入", "启动", "开启", "打开", "启用", "执行", "运行", "调用", "应用", "使用", "恢复", "切换", "选择", "退出", "停止", "关闭", "取消", "结束", "来一个", "来个", ) _MODE_REFERENCE_TAILS = ( "吧", "呀", "啊", "哦", "呢", "一下", "看看", "试试", ",", ",", "。", ".", "!", "!", "?", "?", ";", ";", ":", ":", "然后", "接着", "同时", "并且", ) def _normalize_match_text(value: str) -> str: # Parentheses in display names are presentation-only. For example, # ``女王副驾(行驶)`` is naturally spoken as ``女王副驾行驶模式``. # Removing those separators lets the complete name match while the # short-alias boundary guard below still rejects device compounds. return _MATCH_FORMATTING.sub("", value.casefold()) def _mode_candidate_matches(utterance: str, candidate: object) -> bool: """Match a mode reference without treating device nouns as mode aliases. Names that explicitly contain ``模式`` are unambiguous enough for normal substring matching. Short aliases are different: a user-created ``阅读模式`` automatically has the alias ``阅读``, but ``打开阅读灯`` is a light command, not a request to run that mode. A short alias therefore requires an invocation verb and a bounded tail, or an explicit ``模式`` suffix. This retains natural requests such as ``启动阅读`` and ``进入阅读模式`` while failing closed for compound device names. """ needle = _normalize_match_text(str(candidate).strip()) if not needle: return False if "模式" in needle: return needle in utterance start = 0 while True: index = utterance.find(needle, start) if index < 0: return False tail = utterance[index + len(needle) :] if tail.startswith("模式"): return True prefix = utterance[:index] tail_is_bounded = not tail or any( tail.startswith(boundary) for boundary in _MODE_REFERENCE_TAILS ) if any( prefix.endswith(verb) for verb in _MODE_REFERENCE_VERBS ) and tail_is_bounded: return True start = index + 1 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 _clean_mode_id(value: str) -> str: cleaned = str(value).strip() if not _MODE_ID.fullmatch(cleaned): raise ValueError( "mode id must start with a lowercase letter or number and contain " "only lowercase letters, numbers, underscores, or hyphens" ) return cleaned def _clean_instruction(value: str) -> str: cleaned = str(value).strip() if len(cleaned) > 1000: raise ValueError("mode instruction must be at most 1000 characters") return cleaned def _clean_command_flow(value: Any) -> dict[str, Any] | None: if value is None: return None if not isinstance(value, dict): raise ValueError("mode command_flow must be an object") try: encoded = json.dumps(value, ensure_ascii=False, sort_keys=True) except (TypeError, ValueError) as exc: raise ValueError("mode command_flow must be JSON serializable") from exc if len(encoded.encode("utf-8")) > 1024 * 1024: raise ValueError("mode command_flow must be at most 1 MiB") return json.loads(encoded) 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]: target_scope = self.target_scope if self.domain == "seat" and target_scope == "front_right": # Compiled seat operations use the physical zone name, while the # model-plan adapter exposes the logical passenger target. Normalize # persisted cards so a validated target is not queried again. target_scope = "passenger" return { "domain": self.domain, "capability": self.capability, "target_scope": 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, } def public_dict(self) -> dict[str, Any]: """Return the executable fields that are safe to expose as UI metadata.""" return { "domain": self.domain, "capability": self.capability, "target_scope": self.target_scope, "operation": self.operation, "parameters": dict(self.parameters), } @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 name: str aliases: tuple[str, ...] description: str icon: str operations: tuple[ModeOperation, ...] source: str common: bool = True vehicle_ids: tuple[str, ...] = () timeline: ModeTimeline | None = None user_defined: bool = False instruction: str = "" command_flow: dict[str, Any] | None = None schedule: ModeSchedule | None = None @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", [])], ) 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, 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, timeline=( ModeTimeline.from_dict(raw_timeline) if isinstance(raw_timeline, dict) else None ), user_defined=_is_user_profile_path(path, profile_root), instruction=_clean_instruction(document.get("instruction", "")), command_flow=_clean_command_flow(document.get("command_flow")), 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.schedule is not None or self.timeline is not None or any( operation.capability == "seat.start_dynamic_mode" for operation in self.operations ) result = { "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), "operations": [operation.public_dict() for operation in self.operations], "scope": { "common": self.common, "vehicle_ids": list(self.vehicle_ids), }, "behavior": "cyclic" if is_cyclic else "static", "origin": "user" if self.user_defined else "preset", "deletable": self.user_defined, "instruction": self.instruction, "command_flow": self.command_flow, } 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 class ModeProfileRegistry: """Load preset profiles and optionally keep user profiles in a session overlay.""" def __init__(self, profile_root: Path, user_root: Path | None = None): self.profile_root = Path(profile_root) self.user_root = Path(user_root) if user_root is not None else self.profile_root self.session_user_profiles = user_root is not None self._profiles: list[ModeProfile] = [] self._change_listeners: list[Callable[["ModeProfileRegistry"], None]] = [] self._write_lock = RLock() self.reload() def reload(self) -> None: with self._write_lock: profiles: list[ModeProfile] = [] if self.profile_root.exists(): for path in sorted(self.profile_root.rglob("*.yaml")): if not path.is_file(): continue if self.session_user_profiles and _is_user_profile_path( path, self.profile_root ): continue profiles.append(ModeProfile.from_yaml(path, self.profile_root)) if self.session_user_profiles and self.user_root.exists(): profiles.extend( ModeProfile.from_yaml(path, self.user_root) for path in sorted(self.user_root.rglob("*.yaml")) if path.is_file() ) self._profiles = profiles self._notify_change_listeners() def add_change_listener( self, listener: Callable[["ModeProfileRegistry"], None], *, notify: bool = True, ) -> None: """Run ``listener`` after every successful registry reload. Mode profiles remain YAML-backed. Listeners are intended for derived, read-only projections such as the LAN scene catalog; they do not replace or alter the existing persistence path. """ with self._write_lock: if listener not in self._change_listeners: self._change_listeners.append(listener) if notify: listener(self) def _notify_change_listeners(self) -> None: for listener in tuple(self._change_listeners): listener(self) def profiles(self) -> list[ModeProfile]: return list(self._profiles) def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]: profiles = self._profiles_for_vehicle(vehicle_id) profiles.sort(key=lambda profile: 0 if profile.user_defined else 1) preset_ids = { profile.id for profile in self._visible_candidates_for_vehicle(vehicle_id) if not profile.user_defined } public = [] for profile in profiles: item = profile.public_dict() if profile.user_defined and profile.id in preset_ids: item["origin"] = "customized" item["preset_override"] = True public.append(item) return public def get_profile( self, mode_id: str, vehicle_id: str | None = None, ) -> ModeProfile: """Resolve the effective preset or user override by its stable ID.""" clean_mode_id = str(mode_id).strip() profile = next( ( item for item in self._profiles_for_vehicle(vehicle_id) if item.id == clean_mode_id ), None, ) if profile is None: raise KeyError(clean_mode_id) return profile 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 _mode_candidate_matches(haystack, candidate) for candidate in candidates ): if profile.id in matched_ids: continue matched_ids.add(profile.id) matches.append(selected[profile.id]) return matches def resolve_exact_name( self, value: str, vehicle_id: str | None = None, ) -> ModeProfile | None: """Resolve one visible mode by an exact ID, name, or alias. Scene creation must not use the substring semantics of :meth:`match_text`: a name can contain another mode alias and accidentally combine two profiles. Returning ``None`` for both no match and an ambiguous alias keeps that path fail-closed so callers can choose an explicit fallback without combining modes. """ needle = _normalize_match_text(str(value).strip()) if not needle: return None matches = [ profile for profile in self._profiles_for_vehicle(vehicle_id) if needle in { _normalize_match_text(str(candidate)) for candidate in (profile.id, profile.name, *profile.aliases) if str(candidate).strip() } ] return matches[0] if len(matches) == 1 else None @staticmethod def profile_revision(profile: ModeProfile) -> str: """Return a stable semantic revision for optimistic mode updates.""" document = { "id": profile.id, "name": profile.name, "aliases": list(profile.aliases), "description": profile.description, "icon": profile.icon, "scope": { "common": profile.common, "vehicle_ids": list(profile.vehicle_ids), }, "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, } canonical = json.dumps( document, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] @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, *, description: str | None = None, icon: str = "bookmark", instruction: str = "", command_flow: dict[str, Any] | None = None, timeline: ModeTimeline | None = None, schedule: ModeSchedule | None = None, expected_revision: str | None = None, create_only: bool = False, ) -> 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 = _clean_mode_id(mode_id or _slugify_mode_id(clean_name)) clean_instruction = _clean_instruction(instruction) clean_command_flow = _clean_command_flow(command_flow) 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": str(description or "用户固化的长期模式配置。")[:500], "icon": str(icon or "bookmark")[:64], "scope": scope, "operations": [ operation.to_yaml() for operation in canonical_operations ], } if clean_instruction: document["instruction"] = clean_instruction if clean_command_flow is not None: document["command_flow"] = clean_command_flow if timeline is not None: document["timeline"] = timeline.to_yaml() 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 else self.user_root / "common" / "user" / f"{profile_id}.yaml" ) with self._write_lock: # Refresh inside the same critical section as the compare-and-swap. # This makes concurrent assistant saves one-winner within the server # process instead of letting two stale previews overwrite each other. self.reload() visible_profiles = self._profiles_for_vehicle(vehicle_id) current = next( (profile for profile in visible_profiles if profile.id == profile_id), None, ) normalized_name = _normalize_match_text(clean_name) name_collision = any( profile.id != profile_id and normalized_name in { _normalize_match_text(str(candidate)) for candidate in (profile.id, profile.name, *profile.aliases) if str(candidate).strip() } for profile in visible_profiles ) if name_collision: raise ValueError("该模式名称已被其他模式使用。") if create_only and current is not None: raise ValueError("该模式已由其他会话创建,请刷新后重试。") if expected_revision is not None: expected = str(expected_revision).strip() if ( not expected or current is None or self.profile_revision(current) != expected ): raise ValueError( "功能模式已被其他会话更新,请重新生成预览后再保存。" ) 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.user_root) def delete_user_mode( self, mode_id: str, vehicle_id: str | None = None, ) -> ModeProfile: with self._write_lock: return self._delete_user_mode_locked(mode_id, vehicle_id) def _delete_user_mode_locked( self, mode_id: str, vehicle_id: str | None = None, ) -> ModeProfile: clean_id = str(mode_id).strip() if not clean_id: raise ValueError("mode id must not be empty") profile = next( ( item for item in self._profiles_for_vehicle(vehicle_id) if item.id == clean_id ), None, ) if profile is None: raise KeyError(clean_id) if not profile.user_defined: raise PermissionError("preset mode profiles cannot be deleted") path = Path(profile.source).resolve() relative_parts = None for root in {self.profile_root.resolve(), self.user_root.resolve()}: try: relative_parts = path.relative_to(root).parts break except ValueError: continue if relative_parts is None: raise PermissionError("mode profile is outside the configured registry") if "user" not in relative_parts or path.suffix.lower() != ".yaml": raise PermissionError("only user-defined mode profiles can be deleted") if not path.is_file(): raise KeyError(clean_id) path.unlink() backup_path = _initial_backup_path(path) if backup_path.is_file(): backup_path.unlink() self.reload() return profile def update_mode( self, profile: ModeProfile, operations: list[dict[str, Any] | ModeOperation], ) -> ModeProfile: with self._write_lock: return self._update_mode_locked(profile, operations) def _update_mode_locked( 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") merged_operations = _merge_operations(profile.operations, canonical_updates) if self.session_user_profiles and not profile.user_defined: # Runtime edits must never rewrite repository-owned presets. Save # a same-id user overlay instead; normal registry ranking makes it # the effective profile, while deleting the overlay reveals the # untouched preset again. updated = self.save_mode( profile.name, merged_operations, mode_id=profile.id, aliases=list(profile.aliases), vehicle_id=( profile.vehicle_ids[0] if len(profile.vehicle_ids) == 1 else None ), description=profile.description, icon=profile.icon, instruction=profile.instruction, command_flow=profile.command_flow, timeline=profile.timeline, ) backup_path = _initial_backup_path(Path(updated.source)) if not backup_path.exists(): _replace_yaml( backup_path, _mode_profile_document(profile, list(profile.operations)), ) return updated 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") document = _mode_profile_document(profile, merged_operations) _replace_yaml(path, document) self.reload() return ModeProfile.from_yaml(path, self._root_for_path(path)) def restore_initial_mode(self, profile: ModeProfile) -> ModeProfile: with self._write_lock: return self._restore_initial_mode_locked(profile) def _restore_initial_mode_locked(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._root_for_path(path)) def _root_for_path(self, path: Path) -> Path: resolved = path.resolve() for root in (self.user_root.resolve(), self.profile_root.resolve()): try: resolved.relative_to(root) return root except ValueError: continue return 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), } document = { "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], } 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: document["command_flow"] = profile.command_flow return document 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_user_profile_path(path: Path, profile_root: Path | None) -> bool: if profile_root is None: return path.parent.name == "user" try: relative_parts = path.resolve().relative_to(profile_root.resolve()).parts except ValueError: return False return "user" in relative_parts 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