feat: add seat firmware workbench and OTA delivery
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
"""Conservative, local defaults for adding named devices to an existing mode.
|
||||
|
||||
Only a complete *bare device list* uses these defaults. Explicit parameters and
|
||||
actions stay in the normal parser; unrecognised list entries never disappear.
|
||||
Defaults are proposals for a fully validated preview, not executable commands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
_ADD_PREFIX = re.compile(
|
||||
r"^(?:(?:请|帮我|帮忙|再|还|也|同时|顺便|继续|并且|并)\s*)*"
|
||||
r"(?:加入|添加|增加|补充|加上|追加|新增)\s*"
|
||||
)
|
||||
_DEVICE_GROUPS = {
|
||||
"空调": "climate", "冷气": "climate",
|
||||
"氛围灯": "ambient_light", "气氛灯": "ambient_light",
|
||||
"阅读灯": "reading", "读书灯": "reading",
|
||||
"车窗": "window", "窗户": "window",
|
||||
"遮阳帘": "roof", "天幕遮阳帘": "roof", "天窗遮阳帘": "roof",
|
||||
"室内灯": "indoor", "顶灯": "indoor",
|
||||
}
|
||||
_LIST_SEPARATOR = re.compile(r"[、,,;;\s]+|以及|还有|和|与|及")
|
||||
_EXPLICIT_ACTION = re.compile(
|
||||
r"\d|[零一二三四五六七八九十百]+(?:度|档|挡|秒|分钟|%)|"
|
||||
r"打开|开启|关闭|关掉|开到|关到|调整|设置|调到|调成|变成|改成|"
|
||||
r"升高|降低|调高|调低|调亮|调暗|全开|全关|半开|一半|"
|
||||
r"红色|蓝色|绿色|白色|暖色|冷色|亮度|温度|风量|内循环|外循环"
|
||||
)
|
||||
|
||||
|
||||
def strip_mode_addition_prefix(change_text: str) -> str:
|
||||
"""Remove only the editing verb, not any device, value or negation."""
|
||||
return _ADD_PREFIX.sub("", change_text.strip(), count=1)
|
||||
|
||||
|
||||
def mode_device_additions(
|
||||
change_text: str,
|
||||
base_operations: list[dict[str, Any]],
|
||||
default_operations: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Return missing device defaults, None for non-list/explicit edits.
|
||||
|
||||
Existing groups are already configured and are left completely untouched,
|
||||
including their target zones, power state and custom parameter values.
|
||||
Reading and indoor lights are separate groups within the same domain.
|
||||
"""
|
||||
if not _ADD_PREFIX.match(change_text.strip()):
|
||||
return None
|
||||
body = strip_mode_addition_prefix(change_text).strip("。.!!吧 ")
|
||||
if _EXPLICIT_ACTION.search(body):
|
||||
return None
|
||||
body = re.sub(r"(?:等)?(?:相关)?(?:设备)?(?:的)?(?:控制)?(?:动作|能力|联动)?$", "", body)
|
||||
body = re.sub(r"等$", "", body)
|
||||
devices = [item for item in _LIST_SEPARATOR.split(body) if item]
|
||||
groups: list[str] = []
|
||||
for device in devices:
|
||||
device = re.sub(r"(?:设备)?(?:的)?(?:控制)?(?:动作|能力)$", "", device)
|
||||
group = _DEVICE_GROUPS.get(device)
|
||||
if group is None:
|
||||
raise ValueError(f"未识别或暂不支持增补设备“{device}”;未修改任何模式动作。")
|
||||
if group not in groups:
|
||||
groups.append(group)
|
||||
if not groups:
|
||||
raise ValueError("请说明要加入哪些设备,或提供明确的设备动作。")
|
||||
|
||||
def belongs(operation: dict[str, Any], group: str) -> bool:
|
||||
capability = str(operation.get("capability") or "")
|
||||
if group == "reading":
|
||||
return capability == "interior_light.set_reading_power"
|
||||
if group == "indoor":
|
||||
return capability == "interior_light.set_indoor_power"
|
||||
return operation.get("domain") == group
|
||||
|
||||
additions: list[dict[str, Any]] = []
|
||||
for group in groups:
|
||||
if any(belongs(operation, group) for operation in base_operations):
|
||||
continue
|
||||
candidates = [operation for operation in default_operations if belongs(operation, group)]
|
||||
if not candidates:
|
||||
raise ValueError(f"该设备尚无经过登记的默认编排,请补充明确目标:{group}。")
|
||||
additions.extend(deepcopy(candidates))
|
||||
return additions
|
||||
@@ -170,11 +170,12 @@ _MODE_PROFILE_DELETE_BA = re.compile(
|
||||
)
|
||||
_MODE_PROFILE_UPDATE_NEGATION = re.compile(
|
||||
r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|"
|
||||
r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存"
|
||||
r"取消(?:修改|调整|更改|更新|变更|加入|添加|增加|补充|加上|追加|新增|操作)|"
|
||||
r"撤销|算了|不执行|不下发|不保存"
|
||||
)
|
||||
_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile(
|
||||
r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)"
|
||||
r"(?:把|将|修改|调整|更改|更新|变更)"
|
||||
r"(?:把|将|在|给|为|往|向|修改|调整|更改|更新|变更)"
|
||||
)
|
||||
_MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)"
|
||||
_MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])"
|
||||
@@ -197,7 +198,19 @@ _CONTEXTUAL_NAME_FIRST_UPDATE = re.compile(
|
||||
rf"^{_LEADING_POLITE}{_QUOTED_MODE_NAME}[,,::]"
|
||||
r"(?P<change>.+?)[。.!!]?$"
|
||||
)
|
||||
_MODE_PROFILE_APPEND_VERB = r"(?:加入|添加|增加|补充|加上|追加|新增)"
|
||||
_APPEND_MODE_UPDATE = re.compile(
|
||||
rf"^{_LEADING_POLITE}(?:在|给|为|往|向)?{_QUOTED_MODE_NAME}"
|
||||
rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?[,,::]?"
|
||||
rf"(?:再|还|也|同时|另外)?(?P<change>{_MODE_PROFILE_APPEND_VERB}.+?)[。.!!]?$"
|
||||
)
|
||||
_APPEND_TO_MODE_UPDATE = re.compile(
|
||||
rf"^{_LEADING_POLITE}(?:把|将)(?P<change>.+?)"
|
||||
rf"(?P<append_verb>{_MODE_PROFILE_APPEND_VERB})(?:到|进)?{_QUOTED_MODE_NAME}"
|
||||
rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?(?:吧|呀|啊|哦|谢谢(?:你)?)?[。.!!]?$"
|
||||
)
|
||||
MODE_PROFILE_CHANGE_PREDICATE = re.compile(
|
||||
rf"{_MODE_PROFILE_APPEND_VERB}|"
|
||||
r"修改|改成|改为|更改|更新|变更|变成|变为|换成|"
|
||||
r"调整|调节|调高|调低|调到|调成|调为|调至|"
|
||||
r"设置|设为|设成|设到|设至|"
|
||||
@@ -208,7 +221,7 @@ MODE_PROFILE_CHANGE_PREDICATE = re.compile(
|
||||
r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次"
|
||||
)
|
||||
MODE_PROFILE_QUERY_CUE = re.compile(
|
||||
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|"
|
||||
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|能否|能不能|可不可以|会不会|"
|
||||
r"合适(?:吗)?|合理(?:吗)?|会(?:怎么样|怎样|如何)|"
|
||||
r"怎么|如何|列(?:出|一下)|介绍(?:一下)?|说明(?:一下)?|"
|
||||
r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]|(?:吗|么|呢)$"
|
||||
@@ -610,6 +623,8 @@ def _candidate_matches(
|
||||
) -> list[tuple[re.Match[str], bool]]:
|
||||
matches: list[tuple[re.Match[str], bool]] = []
|
||||
for pattern, predicate_required in (
|
||||
(_APPEND_MODE_UPDATE, True),
|
||||
(_APPEND_TO_MODE_UPDATE, False),
|
||||
(_PREFIX_UPDATE, False),
|
||||
(_BA_UPDATE, True),
|
||||
(_NAME_FIRST_UPDATE, True),
|
||||
@@ -629,6 +644,14 @@ def _candidate_matches(
|
||||
return matches
|
||||
|
||||
|
||||
def _mode_profile_update_change(match: re.Match[str], name: str) -> str:
|
||||
change = _strip_repeated_mode_update_subject(str(match.group("change") or ""), name)
|
||||
append_verb = str(match.groupdict().get("append_verb") or "")
|
||||
# Keep reverse-order additions on the same service contract as name-first
|
||||
# additions. Device names, explicit targets and parameters remain unchanged.
|
||||
return f"{append_verb}{change}" if append_verb else change
|
||||
|
||||
|
||||
def _strip_repeated_mode_update_subject(change: str, mode_name: str) -> str:
|
||||
"""Remove a repeated ``把<same mode>的`` wrapper from an update body."""
|
||||
|
||||
@@ -708,10 +731,7 @@ def is_mode_profile_update_shape(
|
||||
and _is_unquoted_device_property_mode(match, name)
|
||||
):
|
||||
continue
|
||||
change = _strip_repeated_mode_update_subject(
|
||||
str(match.group("change") or ""),
|
||||
name,
|
||||
)
|
||||
change = _mode_profile_update_change(match, name)
|
||||
if change and (
|
||||
not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change)
|
||||
):
|
||||
@@ -746,10 +766,9 @@ def parse_mode_profile_update(
|
||||
and _is_unquoted_device_property_mode(match, name)
|
||||
):
|
||||
continue
|
||||
change = _strip_repeated_mode_update_subject(
|
||||
str(match.group("change") or ""),
|
||||
name,
|
||||
)
|
||||
change = _mode_profile_update_change(match, name)
|
||||
if re.fullmatch(rf"(?:再|还|也|同时|另外)?{_MODE_PROFILE_APPEND_VERB}(?:一下)?", change):
|
||||
continue
|
||||
if (
|
||||
(not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change))
|
||||
and not _MODE_PROFILE_UPDATE_NEGATION.search(change)
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Read-only HTTP surface for vehicle firmware discovery and download."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from cognitive_agent.config_loader import load_local_env
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
load_local_env(REPOSITORY_ROOT / ".env", override=True)
|
||||
|
||||
RELEASE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{7,80}")
|
||||
DOWNLOAD_PATH_PATTERN = re.compile(
|
||||
r"/api/firmware-workbench/releases/(?P<release_id>[a-z0-9-]+)/download"
|
||||
)
|
||||
LATEST_PATHS = frozenset(
|
||||
{
|
||||
"/latest",
|
||||
"/api/firmware-workbench/releases/latest",
|
||||
}
|
||||
)
|
||||
RELEASES_PATHS = frozenset(
|
||||
{
|
||||
"/releases",
|
||||
"/api/firmware-workbench/releases",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FirmwareOtaRepository:
|
||||
"""Resolve atomically published OTA manifests without loading workbench state."""
|
||||
|
||||
def __init__(self, root: Path, public_base_url: str) -> None:
|
||||
self.root = Path(root).resolve()
|
||||
self.public_base_url = str(public_base_url).rstrip("/")
|
||||
|
||||
def latest(self) -> dict[str, object]:
|
||||
manifest_path = self.root / "latest.json"
|
||||
manifest = self._read_manifest(manifest_path)
|
||||
return self._public_manifest(manifest)
|
||||
|
||||
def releases(self) -> dict[str, object]:
|
||||
releases_root = self.root / "releases"
|
||||
manifests: list[dict[str, object]] = []
|
||||
if releases_root.is_dir():
|
||||
for release_root in releases_root.iterdir():
|
||||
if not release_root.is_dir():
|
||||
continue
|
||||
try:
|
||||
manifest = self._read_manifest(release_root / "release.json")
|
||||
release_id = self._release_id(manifest)
|
||||
if release_id != release_root.name:
|
||||
continue
|
||||
package, _, _ = self.release_package(release_id)
|
||||
expected_size = manifest.get("size_bytes")
|
||||
if (
|
||||
expected_size is not None
|
||||
and int(expected_size) != package.stat().st_size
|
||||
):
|
||||
continue
|
||||
if str(manifest.get("status") or "published") != "published":
|
||||
continue
|
||||
manifests.append(self._public_manifest(manifest))
|
||||
except (FileNotFoundError, OSError, TypeError, ValueError):
|
||||
continue
|
||||
manifests.sort(
|
||||
key=lambda item: str(item.get("published_at") or ""), reverse=True
|
||||
)
|
||||
latest_release_id = (
|
||||
str(manifests[0]["release_id"]) if manifests else None
|
||||
)
|
||||
return {
|
||||
"schema": "n7-ota-release-list/v1",
|
||||
"count": len(manifests),
|
||||
"latest_release_id": latest_release_id,
|
||||
"releases": manifests,
|
||||
}
|
||||
|
||||
def release_package(self, release_id: str) -> tuple[Path, str, str]:
|
||||
checked = str(release_id).strip()
|
||||
if not RELEASE_ID_PATTERN.fullmatch(checked):
|
||||
raise FileNotFoundError("release not found")
|
||||
release_root = (self.root / "releases" / checked).resolve()
|
||||
try:
|
||||
release_root.relative_to((self.root / "releases").resolve())
|
||||
except ValueError as exc:
|
||||
raise FileNotFoundError("release not found") from exc
|
||||
manifest = self._read_manifest(release_root / "release.json")
|
||||
if self._release_id(manifest) != checked:
|
||||
raise FileNotFoundError("release not found")
|
||||
filename = str(manifest.get("firmware_filename") or "")
|
||||
if not filename or Path(filename).name != filename:
|
||||
raise FileNotFoundError("firmware package not found")
|
||||
package = (release_root / filename).resolve()
|
||||
try:
|
||||
package.relative_to(release_root)
|
||||
except ValueError as exc:
|
||||
raise FileNotFoundError("firmware package not found") from exc
|
||||
if not package.is_file():
|
||||
raise FileNotFoundError("firmware package not found")
|
||||
expected_hash = str(manifest.get("sha256") or "").lower()
|
||||
return package, filename, expected_hash
|
||||
|
||||
def _public_manifest(self, manifest: dict[str, object]) -> dict[str, object]:
|
||||
payload = dict(manifest)
|
||||
release_id = self._release_id(payload)
|
||||
payload["schema"] = "n7-ota-release/v1"
|
||||
payload["release_id"] = release_id
|
||||
payload["download_url"] = f"{self.public_base_url}/{release_id}/download"
|
||||
payload.pop("package_path", None)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _read_manifest(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
raise FileNotFoundError("manifest not found") from exc
|
||||
if not isinstance(document, dict):
|
||||
raise FileNotFoundError("manifest not found")
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
def _release_id(manifest: dict[str, object]) -> str:
|
||||
release_id = str(manifest.get("release_id") or manifest.get("id") or "")
|
||||
if not RELEASE_ID_PATTERN.fullmatch(release_id):
|
||||
raise FileNotFoundError("release not found")
|
||||
return release_id
|
||||
|
||||
|
||||
class FirmwareOtaHandler(BaseHTTPRequestHandler):
|
||||
"""Expose health, release manifests, and immutable firmware packages."""
|
||||
|
||||
repository: FirmwareOtaRepository
|
||||
server_version = "N7FirmwareOta/1.0"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
||||
self._handle_request(send_body=True)
|
||||
|
||||
def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API
|
||||
self._handle_request(send_body=False)
|
||||
|
||||
def _handle_request(self, *, send_body: bool) -> None:
|
||||
path = unquote(urlparse(self.path).path)
|
||||
if path == "/healthz":
|
||||
self._json({"status": "ok", "service": "n7-firmware-ota"}, send_body)
|
||||
return
|
||||
if path in LATEST_PATHS:
|
||||
try:
|
||||
self._json(self.repository.latest(), send_body)
|
||||
except FileNotFoundError:
|
||||
self._json(
|
||||
{"error": "not_found", "message": "暂无可供车机升级的固件。"},
|
||||
send_body,
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
return
|
||||
if path in RELEASES_PATHS:
|
||||
self._json(self.repository.releases(), send_body)
|
||||
return
|
||||
match = DOWNLOAD_PATH_PATTERN.fullmatch(path)
|
||||
if match:
|
||||
try:
|
||||
package, filename, expected_hash = self.repository.release_package(
|
||||
match.group("release_id")
|
||||
)
|
||||
self._file(package, filename, expected_hash, send_body)
|
||||
except FileNotFoundError:
|
||||
self._json(
|
||||
{"error": "not_found", "message": "OTA 固件包不存在。"},
|
||||
send_body,
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
return
|
||||
self._json(
|
||||
{"error": "not_found", "message": "接口不存在。"},
|
||||
send_body,
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
def _file(
|
||||
self,
|
||||
path: Path,
|
||||
filename: str,
|
||||
expected_hash: str,
|
||||
send_body: bool,
|
||||
) -> None:
|
||||
size = path.stat().st_size
|
||||
try:
|
||||
start, end, partial = self._byte_range(size)
|
||||
except ValueError:
|
||||
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
self.send_header("Content-Range", f"bytes */{size}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
return
|
||||
content_length = end - start + 1
|
||||
self.send_response(HTTPStatus.PARTIAL_CONTENT if partial else HTTPStatus.OK)
|
||||
self.send_header(
|
||||
"Content-Type", mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
)
|
||||
self.send_header("Content-Length", str(content_length))
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
if expected_hash:
|
||||
self.send_header("X-Firmware-SHA256", expected_hash)
|
||||
if partial:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
if not send_body:
|
||||
return
|
||||
with path.open("rb") as source:
|
||||
source.seek(start)
|
||||
remaining = content_length
|
||||
while remaining:
|
||||
chunk = source.read(min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
def _byte_range(self, size: int) -> tuple[int, int, bool]:
|
||||
value = str(self.headers.get("Range") or "").strip()
|
||||
if not value:
|
||||
return 0, size - 1, False
|
||||
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value)
|
||||
if not match or not any(match.groups()):
|
||||
raise ValueError("invalid byte range")
|
||||
first, last = match.groups()
|
||||
if first:
|
||||
start = int(first)
|
||||
end = int(last) if last else size - 1
|
||||
else:
|
||||
suffix = int(last)
|
||||
if suffix <= 0:
|
||||
raise ValueError("invalid byte range")
|
||||
start = max(0, size - suffix)
|
||||
end = size - 1
|
||||
if start >= size or end < start:
|
||||
raise ValueError("invalid byte range")
|
||||
return start, min(end, size - 1), True
|
||||
|
||||
def _json(
|
||||
self,
|
||||
payload: object,
|
||||
send_body: bool,
|
||||
status: HTTPStatus = HTTPStatus.OK,
|
||||
) -> None:
|
||||
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(content)
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run the read-only vehicle OTA service")
|
||||
parser.add_argument(
|
||||
"--host", default=os.getenv("FIRMWARE_OTA_BIND_HOST", "127.0.0.1")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=int(os.getenv("FIRMWARE_OTA_PORT", "8012"))
|
||||
)
|
||||
args = parser.parse_args()
|
||||
configured_root = os.getenv("FIRMWARE_WORKBENCH_OTA_ROOT", "").strip()
|
||||
ota_root = (
|
||||
Path(configured_root)
|
||||
if configured_root
|
||||
else REPOSITORY_ROOT / "artifacts" / "server" / "firmware-ota"
|
||||
)
|
||||
public_base_url = os.getenv(
|
||||
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
||||
f"http://{args.host}:{args.port}/api/firmware-workbench/releases",
|
||||
)
|
||||
FirmwareOtaHandler.repository = FirmwareOtaRepository(ota_root, public_base_url)
|
||||
server = ThreadingHTTPServer((args.host, args.port), FirmwareOtaHandler)
|
||||
print(f"N7 firmware OTA service: http://{args.host}:{args.port}", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -43,6 +43,10 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402
|
||||
CodexAssistantService,
|
||||
CodexVehicleToolError,
|
||||
)
|
||||
from cognitive_agent.application.firmware_workbench import ( # noqa: E402
|
||||
FirmwareWorkbenchError,
|
||||
FirmwareWorkbenchService,
|
||||
)
|
||||
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
|
||||
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
|
||||
normalize_mode_profile_name,
|
||||
@@ -103,7 +107,11 @@ PUBLIC_AUTH_PATHS = {
|
||||
"/network.js",
|
||||
"/api/auth/status",
|
||||
"/api/auth/login",
|
||||
"/api/firmware-workbench/releases/latest",
|
||||
}
|
||||
PUBLIC_OTA_DOWNLOAD_PATH = re.compile(
|
||||
r"/api/firmware-workbench/releases/[a-z0-9-]+/download"
|
||||
)
|
||||
NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"})
|
||||
|
||||
|
||||
@@ -207,6 +215,7 @@ def _lan_ipv4_address() -> str | None:
|
||||
|
||||
class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
service = SeatDemoService()
|
||||
firmware_workbench = FirmwareWorkbenchService(REPOSITORY_ROOT)
|
||||
# Codex defaults to its own empty temporary workspace. A deployment may
|
||||
# opt into another isolated directory through CODEX_ASSISTANT_WORKSPACE,
|
||||
# but it must never inherit access to this application's source tree.
|
||||
@@ -317,6 +326,63 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/assistant/capabilities":
|
||||
self._json(self._assistant_capability_catalog().snapshot())
|
||||
return
|
||||
if path == "/api/firmware-workbench/state":
|
||||
self._json(self.firmware_workbench.snapshot())
|
||||
return
|
||||
if path == "/api/firmware-workbench/releases/latest":
|
||||
try:
|
||||
self._json(self.firmware_workbench.latest_release())
|
||||
except FirmwareWorkbenchError as exc:
|
||||
self._json(
|
||||
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
|
||||
)
|
||||
return
|
||||
firmware_task_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/tasks/(?P<task_id>[a-z0-9-]+)", path
|
||||
)
|
||||
if firmware_task_match:
|
||||
try:
|
||||
self._json(
|
||||
self.firmware_workbench.task(firmware_task_match.group("task_id"))
|
||||
)
|
||||
except FirmwareWorkbenchError as exc:
|
||||
self._json(
|
||||
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
|
||||
)
|
||||
return
|
||||
firmware_artifact_download_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/download",
|
||||
path,
|
||||
)
|
||||
if firmware_artifact_download_match:
|
||||
self._serve_firmware_download(
|
||||
"artifact", firmware_artifact_download_match.group("artifact_id")
|
||||
)
|
||||
return
|
||||
firmware_release_download_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/releases/(?P<release_id>[a-z0-9-]+)/download",
|
||||
path,
|
||||
)
|
||||
if firmware_release_download_match:
|
||||
self._serve_firmware_download(
|
||||
"release", firmware_release_download_match.group("release_id")
|
||||
)
|
||||
return
|
||||
firmware_artifact_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)", path
|
||||
)
|
||||
if firmware_artifact_match:
|
||||
try:
|
||||
self._json(
|
||||
self.firmware_workbench.artifact(
|
||||
firmware_artifact_match.group("artifact_id")
|
||||
)
|
||||
)
|
||||
except FirmwareWorkbenchError as exc:
|
||||
self._json(
|
||||
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
|
||||
)
|
||||
return
|
||||
if path == "/api/runtime":
|
||||
self._json(self.service.runtime_info())
|
||||
return
|
||||
@@ -510,6 +576,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._require_json_content_type():
|
||||
return
|
||||
if path == "/api/firmware-workbench/tasks" or path.startswith(
|
||||
"/api/firmware-workbench/"
|
||||
):
|
||||
self._handle_firmware_workbench_post(path)
|
||||
return
|
||||
if path in {
|
||||
"/api/hardware/access/request",
|
||||
"/api/hardware/access/release",
|
||||
@@ -2529,6 +2600,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
firmware_artifact_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)",
|
||||
path,
|
||||
)
|
||||
if firmware_artifact_match is not None:
|
||||
try:
|
||||
self._json(
|
||||
self.firmware_workbench.delete_artifact(
|
||||
firmware_artifact_match.group("artifact_id")
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except FirmwareWorkbenchError as exc:
|
||||
self._json(
|
||||
{"error": exc.code, "message": str(exc)},
|
||||
HTTPStatus(exc.status),
|
||||
)
|
||||
return
|
||||
prefix = "/api/mode-profiles/"
|
||||
if not path.startswith(prefix):
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
@@ -3259,6 +3348,70 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return payload
|
||||
|
||||
def _handle_firmware_workbench_post(self, path: str) -> None:
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=16 * 1024)
|
||||
if path == "/api/firmware-workbench/tasks":
|
||||
result = self.firmware_workbench.start_task(
|
||||
str(payload.get("instruction", "")),
|
||||
str(payload.get("baseline_id", "time_only")),
|
||||
)
|
||||
self._json(result, HTTPStatus.ACCEPTED)
|
||||
return
|
||||
task_action_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/tasks/(?P<task_id>[a-z0-9-]+)/(?P<action>cancel|retry)",
|
||||
path,
|
||||
)
|
||||
if task_action_match:
|
||||
task_id = task_action_match.group("task_id")
|
||||
if task_action_match.group("action") == "cancel":
|
||||
self._json(self.firmware_workbench.cancel_task(task_id))
|
||||
else:
|
||||
self._json(
|
||||
self.firmware_workbench.retry_task(task_id), HTTPStatus.ACCEPTED
|
||||
)
|
||||
return
|
||||
artifact_publish_match = re.fullmatch(
|
||||
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/publish",
|
||||
path,
|
||||
)
|
||||
if artifact_publish_match:
|
||||
self._json(
|
||||
self.firmware_workbench.publish_artifact(
|
||||
artifact_publish_match.group("artifact_id")
|
||||
)
|
||||
)
|
||||
return
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except FirmwareWorkbenchError as exc:
|
||||
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
|
||||
|
||||
def _serve_firmware_download(self, kind: str, item_id: str) -> None:
|
||||
try:
|
||||
if kind == "release":
|
||||
path, filename = self.firmware_workbench.resolve_release_download(item_id)
|
||||
else:
|
||||
path, filename = self.firmware_workbench.resolve_artifact_download(item_id)
|
||||
content = path.read_bytes()
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "private, no-store")
|
||||
self.send_header(
|
||||
"Content-Disposition",
|
||||
f"attachment; filename*=UTF-8''{quote(filename, safe='.-_')}",
|
||||
)
|
||||
self._send_security_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
except FirmwareWorkbenchError as exc:
|
||||
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
|
||||
|
||||
def _require_json_content_type(self) -> bool:
|
||||
if is_json_content_type(self.headers.get("Content-Type", "")):
|
||||
return True
|
||||
@@ -3505,7 +3658,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def _authorize(self, path: str) -> bool:
|
||||
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
||||
if (
|
||||
not self.auth.config.enabled
|
||||
or path in PUBLIC_AUTH_PATHS
|
||||
or PUBLIC_OTA_DOWNLOAD_PATH.fullmatch(path)
|
||||
):
|
||||
return True
|
||||
if self._session_username() is not None:
|
||||
return True
|
||||
@@ -3780,8 +3937,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
|
||||
def _send_security_headers(self) -> None:
|
||||
allow_same_origin_frame = (
|
||||
unquote(urlparse(self.path).path) == "/firmware-workbench.html"
|
||||
)
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header(
|
||||
"X-Frame-Options", "SAMEORIGIN" if allow_same_origin_frame else "DENY"
|
||||
)
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Permissions-Policy",
|
||||
@@ -3796,7 +3958,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self' http://*:3001; "
|
||||
"connect-src 'self' blob: http://172.20.10.6:8889; "
|
||||
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
"object-src 'none'; base-uri 'none'; frame-ancestors "
|
||||
+ ("'self'" if allow_same_origin_frame else "'none'"),
|
||||
)
|
||||
if self._is_https():
|
||||
self.send_header(
|
||||
@@ -3893,6 +4056,7 @@ def main() -> None:
|
||||
SeatDemoHandler.nfc.close()
|
||||
SeatDemoHandler.calibration.close()
|
||||
SeatDemoHandler.repair.close()
|
||||
SeatDemoHandler.firmware_workbench.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user