fix: compose named cockpit modes from capabilities
This commit is contained in:
@@ -7,6 +7,7 @@ import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
@@ -419,26 +420,28 @@ class ModeProfileRegistry:
|
||||
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._write_lock = RLock()
|
||||
self.reload()
|
||||
|
||||
def reload(self) -> None:
|
||||
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
|
||||
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
|
||||
|
||||
def profiles(self) -> list[ModeProfile]:
|
||||
return list(self._profiles)
|
||||
@@ -505,6 +508,61 @@ class ModeProfileRegistry:
|
||||
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,
|
||||
"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 [
|
||||
@@ -526,6 +584,8 @@ class ModeProfileRegistry:
|
||||
instruction: str = "",
|
||||
command_flow: dict[str, Any] | None = None,
|
||||
timeline: ModeTimeline | None = None,
|
||||
expected_revision: str | None = None,
|
||||
create_only: bool = False,
|
||||
) -> ModeProfile:
|
||||
clean_name = _clean_name(name)
|
||||
canonical_operations = [
|
||||
@@ -566,20 +626,64 @@ class ModeProfileRegistry:
|
||||
if vehicle_id
|
||||
else self.user_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.user_root)
|
||||
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:
|
||||
@@ -623,6 +727,14 @@ class ModeProfileRegistry:
|
||||
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:
|
||||
@@ -642,6 +754,10 @@ class ModeProfileRegistry:
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user