初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.pytest_cache/
data/
@@ -0,0 +1 @@
2026-09-08T10:19+08:00
+178
View File
@@ -0,0 +1,178 @@
# 奇妙小屏幕控制器(核桃派 ZeroW)
本目录是核桃派 ZeroW/H618 的独立服务源码,控制一块 64×64、1/32 扫描、ABCDE 行寻址的 HUB75 RGB 点阵屏。生产驱动是项目自带的 `walnutpi-h618-hub75`,不依赖其他开发板的 GPIO 库。
## 路径与运行方式
下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。
- 程序:`/opt/matrix-screen-controller`
- 持久数据:`/var/lib/matrix-screen-controller`
- 运行数据:`/run/matrix-screen-controller`
- 服务:`matrix-screen-controller.service`
- 网页:`http://<设备通过 DHCP 获得的 IPv4>:8080/`
本机 mock:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
MATRIX_DRIVER=mock .venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8080
```
核桃派不具备境外网络条件。生产安装必须使用项目根目录 `发布更新相关/其他依赖/aarch64-py311/` 中已校验的 wheel,并强制 `--no-index`:
```bash
python3 -m venv .venv
.venv/bin/pip install --no-index --find-links ../发布更新相关/其他依赖/aarch64-py311 -r requirements.txt
make -C app/display/native clean all
```
## 原生驱动边界
- PIO 控制器:`allwinner,sun50i-h616-pinctrl`
- 映射基址:`0x0300B000`
- PWM 控制器:`0x0300A000` 的 PWM4,OE 使用 `PI14/PWM4`,LAT 使用 `PI15/GPIO`
- 硬件映射:`walnutpi-pi-bank-pwm-oe-v2`,不得搭配旧版 LAT/OE 接线
- PI bank:偏移 `0x120`,数据寄存器再偏移 `0x10`
- 图像:64×64 RGB888 输入、7-bit PWM、双缓冲帧边界交换
- 刷新档:15/20/30/45/60/80/100 Hz
- CPU3 绑定、实时优先级、内存锁定和 governor 会逐项报告实际结果,状态不得伪报成功
- 专用主机停用 lightdm、蓝牙、官方 `gpioc-server` 和 `map_device`;Wi-Fi、SSH、NetworkManager 不受影响。正式可刷镜像默认启用 SSH,使用配置账户密码登录并以同一密码执行完整 sudo,禁止 root 直接登录
- 退出或异常时先禁用 PWM4 并强制 OE 高、清屏,再恢复 GPIO/PWM 状态;systemd 前后都执行独立 `hub75_safeoff`
编译和纯逻辑测试:
```bash
make -C app/display/native clean all test
```
真实 `/dev/mem` 基准必须在 HUB75 和 ADC 都断开时、由 root 执行:
```bash
sudo app/display/native/hub75_benchmark --duration 60 --refresh-rate 100 --brightness 40 --dev-mem
```
接屏门槛与全部人工停顿顺序见项目上层 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。
## 闪烁修复状态
- 旧版 CPU 忙等 OE 已复现局部高亮块;当前版本使用 H618 PWM4 的硬件单脉冲模式(`PWM_MODE/PWM_PUL_START`)自动结束低有效 OE 脉冲,并在点亮当前锁存画面的同时移入下一 bitplane,以 50% 扫描槽预算恢复 `1..100%` 全范围亮度。不得用 `PER` 反复启停连续 PWM 模拟单脉冲。该实现尚须依次通过板端、实屏与摄像头验收,在这些门槛完成前不得写成“闪烁已解决”。
- 任一门槛失败必须回滚旧 `/opt`、保持持久数据不变,并在断电后恢复原 LAT/OE 接线;不得把部分亮度通过写成全部通过。
## I²C 与电压采样
Unit ADC 仅使用物理脚 3/5 的 `/dev/i2c-1`(PI8/PI7),默认地址 `0x48`。HUB75 驱动不会更改 PI7、PI8 和保留的 PI16。ADC 必须等屏幕基础画面全部通过后,在整机断电状态下连接;校准时万用表和 ADC 必须测量同一点。
## 数据安全
配置 schema 为 v8,包含设备共享的 `workspace_order`,并在 v7→v8 时把 `media-import` 插入文字与动图工作区之间。媒体源文件和严格 schema v1 任务记录位于 `/var/lib/matrix-screen-controller/media-import/jobs`;成功或取消立即清理,失败及等待设置保留 24 小时。只有数据文件不存在时才能创建默认值;损坏、未知更高版本或迁移失败时保留原文件并拒绝启动。更新程序必须先克隆 `/var/lib/matrix-screen-controller`,在副本上完成登记迁移并验证后再原子切换,不得把设备凭据、主机密钥、旧设备配置或测试数据打进部署载荷。
媒体转换依赖 Debian 的 `ffmpeg`、`ffprobe`、`heif-convert`(`libheif-examples`)以及带 `zscale`、`tonemap` 的 FFmpeg 构建。分析和转换由 `matrix-screen-converter@.service` 在 CPU0–1、150% CPU、384 MiB 内存、无交换区和私有网络限制下串行执行;主服务只负责流式上传、任务管理与结果展示。
现有设备使用原子更新脚本;它在版本化 staging 中离线安装、编译和测试,健康检查失败会自动恢复旧 `/opt`:
```bash
sudo sh scripts/update_walnutpi.sh
```
## 浏览器全量 OTA
系统设置显示 `VERSION` 中的正式软件版本和 `FEATURE_UPDATED_AT` 中固定的北京时间,并允许上传完整 `.ota` 文件。页面不展示最近 OTA 结果,但上传、安装、断线恢复和失败反馈保持可见;失败后会自动打开可滚动、可复制的完整诊断日志,关闭后仍可重新查看或复制。设备只在 `/var/lib/matrix-screen-controller/ota/last-failure.log` 原子保留最近一份不超过 1 MiB 的脱敏失败日志,下一次成功更新会将其删除;`GET /api/ota/failure-log` 仅在最近结果失败且日志存在时返回 `text/plain`,并禁止缓存。设备不连接更新服务器,不使用增量、加密或签名;包内 SHA-256 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。
从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。
普通功能开发、修复和 SSH 开发部署都保持 `VERSION` 不变;每次可部署的功能新增或优化把 `FEATURE_UPDATED_AT` 写为精确到分钟的固定 `+08:00` 时间。只有用户明确要求导出 OTA 或可刷镜像时才推进共用正式版本;单纯导出、重复部署和文档修改不得刷新功能时间。普通更新成功导出默认将补丁位加一;新增或变更系统软件必须增加 minor 并归零 patch,使用 `--version` 指定已登记节点。失败不占号,全部操作登记在项目根 `发布记录.json`。
需要交付或测试 OTA 包时才从项目根执行:
```powershell
python ".\核桃派软件源代码\scripts\export_release.py" ota --notes "本次更新说明"
```
OTA 输出为 `发布更新相关/OTA数据包/<版本>/`;镜像输出为 `发布更新相关/导出包/<版本>/`。已有版本目录不会覆盖。导出 IMG 可由用户删除以节省空间,缺失默认正常,不追补、不自动重建、不影响开发或常规测试。OTA 今后长期保留;1.0.1 缺失为已接受的历史例外,不再追补或提醒,不影响使用。后续构建不得引用历史产物,原发布记录保留。
## 可刷镜像导出与同版本修复
镜像导出只在 Linux root 构建主机上执行。入口使用标准库完成打包和镜像操作,不要求主机安装 Pillow、FontTools 或应用 venv;应用依赖由镜像中的 AArch64 离线 wheelhouse 在首次启动时安装。执行前先检查构建主机,而不是假设远端具备本机工具:
```bash
command -v python3
command -v bash
command -v losetup
command -v mount
command -v umount
command -v systemctl
command -v sha256sum
```
普通新版本镜像需要显式配置 JSON,并使用两阶段门禁。第一阶段只生成静态验证完成的候选,不修改正式版本或发布记录:
```bash
sudo python3 核桃派软件源代码/scripts/export_release.py image \
--version 1.1.1 --config /受保护临时目录/image-config.json \
--candidate-output /受保护临时目录/image-1.1.1-candidate \
--notes "完整可刷镜像 1.1.1"
```
同一字节候选完成真实 TF 卡首启、默认 Wi-Fi、SSH、sudo、内核、网页和再次重启验收后,使用不含秘密且与 IMG 摘要绑定的严格 JSON 晋升;晋升会重建期望 bundle 并重新只读验证镜像,成功后才推进版本:
```bash
sudo python3 核桃派软件源代码/scripts/export_release.py image \
--validated-candidate /受保护临时目录/image-1.1.1-candidate \
--validation-report /受保护临时目录/image-1.1.1-validation.json \
--notes "完整可刷镜像 1.1.1"
```
IMG 自带完整系统依赖,不应用 OTA 必经节点跳转规则。正式 IMG README 是公开默认账户和 Wi-Fi 值的唯一发布文档;建议用户刷写前用镜像编辑器修改。已经登记但经真实首启证明不可用的当前版本,只有获得明确授权后才使用同版本修复入口:
```bash
sudo python3 核桃派软件源代码/scripts/export_release.py image \
--repair-current \
--notes "同版本镜像修复说明"
```
修复入口从现有正式 IMG 复用完整双槽配置,从未修改官方基线重建,在隐藏目录完成全部验证后才替换正式目录和原发布记录;不追加重复版本、不修改 `VERSION`。失败会恢复旧目录和记录。旧的 `refresh_sd_image_payload.py` 原地刷新方式已停用,因为它不能安全处理 rootfs 应用载荷和 FAT 内核安装空间。
镜像格式 v3 在 FAT16 中只放配置、元数据和首启入口;应用 bundle 与候选内核分别预置到 `/opt/matrix-image-bootstrap/app` 和 `/opt/matrix-image-bootstrap/axp313a`。构建及设备安装均要求候选 boot 文件实际预算之外仍有 `32 MiB` 余量。首次启动失败保留 rootfs 压缩载荷供重试,全部成功后才统一删除。
## 测试
```bash
python -m pytest -q
node --test tests/*.mjs
```
真实硬件测试必须严格按上层测试文档逐项执行,上一项失败时不得跨过。任何接线、拔插、万用表测量或电压调整前都必须暂停并等待人工确认。
## 软件安装节点与 1.1.0
从 1.1.0 起,每个 minor 的 `.0` 是**软件安装包(必经升级版本)**。例如 `1.0.6 → 1.1.0 → 1.2.0 → 1.2.3`;不能跳过任何安装节点。同系列补丁可以跳过,例如 `1.1.0 → 1.1.3`。已预装 frp 的 1.0.6 调试设备也必须先完成 1.1.0,版本号与组件完整性分别检查。同版和降级仍不允许。
正式导出命令(项目根):
```powershell
python "核桃派软件源代码/scripts/export_release.py" ota --version 1.1.0 --notes "软件安装包:离线安装或修复 frpc,建立必经升级节点"
```
`UPGRADE_POLICY.json` 登记连续安装节点及固定依赖摘要;已发布节点不得改写。1.1.0 使用旧更新器支持的 v1 外层协议,frpc 放在 `software/system-dependencies/frpc/`。以后使用 v2,正式旧更新器会拒绝该格式;新更新器会在安装前提示必须先安装的版本。普通补丁不携带系统二进制,仍包含应用及 Python wheelhouse;系统组件缺失时先修复再更新。首装镜像仍携带完整离线依赖。
frpc 完整匹配时不替换二进制或重启服务;否则离线修复,保留 UUID 配置、选择项和启停状态。初次默认关闭。OTA 从现有 frp 非 root 账户、镜像生成的 `/etc/sudoers.d/90-matrix-screen-controller-account` 或唯一 sudo 普通账户确定运行账户,无法确定则在修改前失败。
`prepare_data_root` 仅在根权限、真实 OTA 请求、候选源码和候选数据路径全部匹配时启动组件事务,普通迁移和测试不会安装系统软件。完整 pytest 必须先通过。事务记录及原组件备份镜像保存在原数据根和候选数据根的 `ota/component-transaction/`;系统启动恢复程序临时位于 `/opt/matrix-screen-controller-component-recovery.py`,恢复 unit 为 `matrix-screen-component-recovery.service`,运行期监护为 `matrix-screen-component-watch.service`。原 worker 成功记录与目标版本共同决定提交;失败或中断恢复原组件、权限、程序和数据。无法完成恢复时保留记录并拒绝下一次更新;成功恢复/提交后删除事务文件、临时恢复程序和 unit,成功提交还删除程序目录内的安装载荷。只包含运行状态的文件仍位于 `/run/matrix-screen-controller`。
## OTA 停服运行目录保护(必须保留的防回归规则)
`RuntimeDirectoryPreserve=restart` 只保护重启,不保护分开的 `systemctl stop` / `start`。OTA 会显式停止主服务,因此主服务使用 `RuntimeDirectoryPreserve=yes`;该运行目录仍位于 `/run`,整机重启后清空。日志写入重新 mkdir 只能救回日志,不能救回已经删除的请求和进度,不能作为生命周期修复。
从旧版本安装时,候选迁移入口必须先创建 `/run/systemd/system/matrix-screen-controller.service.d/90-matrix-ota-runtime.conf`,执行 daemon-reload 并检查有效 `RuntimeDirectoryPreserve=yes`,再允许旧 worker 停服。保护一直保留到提交或回滚及清理结束;原有用户 drop-in 不覆盖。该标准 systemd 运行期目录是临时服务配置的明确例外;普通运行数据继续集中在 `/run/matrix-screen-controller`。
真实验收使用 `sudo python3 scripts/test_runtime_directory_systemd.py`,仅操作随机命名的专用测试 unit/目录,不停止生产服务。必须包含旧配置失败对照、显式 stop、restart、启动失败及保护清理,不能用 mock systemctl 代替。诊断存储故障不得中断回滚,有限内存缓冲可补写最近失败日志。
经用户明确授权修复当前 OTA 时,先构建到隔离候选目录并实机验收,再执行:
```powershell
python "核桃派软件源代码/scripts/export_release.py" ota --repair-current --validated-candidate "<已验收候选包>" --validation-report "<实机验收JSON>" --notes "修复说明"
```
此入口校验原产物及记录、候选摘要、最终源码一致性和实机验收结果;完整归档原包与记录后,按原字节晋升候选包,不再构建、不推进 VERSION、不刷新功能时间。验证或提交失败保留原产物与记录。
@@ -0,0 +1,15 @@
{
"schema_version": 1,
"checkpoints": [
{
"version": "1.1.0",
"components": {
"frpc": {
"version": "0.71.0",
"sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2",
"bundle": "frp/0.71.0/linux-arm64"
}
}
}
]
}
+1
View File
@@ -0,0 +1 @@
1.1.1
+2
View File
@@ -0,0 +1,2 @@
"""Matrix screen controller application package."""
@@ -0,0 +1,3 @@
from .store import AnimationStore
__all__ = ["AnimationStore"]
@@ -0,0 +1,3 @@
DEFAULT_DURATION_MS = 500
MIN_DURATION_MS = 50
MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1000
@@ -0,0 +1,711 @@
from __future__ import annotations
import errno
import hashlib
import json
import os
import re
import shutil
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from uuid import UUID, uuid4
from app.animations.constants import DEFAULT_DURATION_MS, MAX_DURATION_MS, MIN_DURATION_MS
from app.display.text_renderer import FontResolver
from app.persistence import atomic_write_bytes
from app.templates.store import (
TemplateConflictError, TemplateError, TemplateNotFoundError,
TemplateStorageFullError, TemplateValidationError,
render_scene_thumbnail, scene_digest, validate_scene,
)
ANIMATION_SCHEMA_VERSION = 2
NAME_LIMIT = 80
RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"})
FRAME_FIELDS = frozenset({
"id", "name", "duration_ms", "created_at", "updated_at",
"digest", "thumbnail", "scene_file",
})
V1_FRAME_FIELDS = frozenset((FRAME_FIELDS - {"scene_file"}) | {"scene"})
THUMBNAIL_RE = re.compile(r"^[0-9a-f-]{36}-[0-9a-f]{16}\.png$")
SCENE_RE = re.compile(r"^[0-9a-f-]{36}-[0-9a-f]{16}\.json$")
def _uuid(value: Any, message: str) -> str:
try:
return str(UUID(str(value)))
except (ValueError, TypeError, AttributeError) as exc:
raise TemplateNotFoundError(message) from exc
def _name(value: Any, *, optional: bool = False) -> str | None:
if optional and (value is None or value == ""):
return None
if not isinstance(value, str):
raise TemplateValidationError("name must be a string")
checked = value.strip()
if not checked or len(checked) > NAME_LIMIT:
raise TemplateValidationError(f"name must contain 1..{NAME_LIMIT} characters")
return checked
def _duration(value: Any) -> int:
if type(value) is not int or not MIN_DURATION_MS <= value <= MAX_DURATION_MS:
raise TemplateValidationError(
f"duration_ms must be in {MIN_DURATION_MS}..{MAX_DURATION_MS}"
)
return value
def _persisted(record: dict[str, Any]) -> dict[str, Any]:
return {
"id": record["id"], "name": record["name"],
"created_at": record["created_at"], "updated_at": record["updated_at"],
"frames": [{key: frame[key] for key in FRAME_FIELDS} for frame in record["frames"]],
}
def _revision(record: dict[str, Any]) -> str:
raw = json.dumps(_persisted(record), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
class AnimationStore:
"""Schema-v2 animations: compact metadata plus one strict scene per frame."""
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None,
cleanup_playback: bool = True) -> None:
self.root = Path(data_dir) / "animations"
self._font_resolver = font_resolver
self._lock = threading.RLock()
self._metadata_size_cache: dict[str, tuple[str, int]] = {}
self._list_cache: dict[str, Any] | None = None
self._list_signature: tuple[tuple[str, int, int], ...] = ()
self.root.mkdir(parents=True, exist_ok=True)
self._playback_root = self.root / ".playback"
if cleanup_playback:
shutil.rmtree(self._playback_root, ignore_errors=True)
self._playback_root.mkdir(parents=True, exist_ok=True)
records: list[dict[str, Any]] = []
with self._lock:
for child in self.root.iterdir():
if child.is_dir() and child.name.startswith(".staging-"):
if cleanup_playback and child.stat().st_mtime < time.time() - 24 * 60 * 60:
shutil.rmtree(child, ignore_errors=True)
elif child.is_dir() and child.name != ".playback":
record = self._read(child.name)
self._cleanup_orphans(record)
records.append(record)
# Startup already performed the expensive strict frame/thumbnail
# validation. Build the first listing from those same records so a
# large library is immediately usable instead of doing all I/O a
# second time on the first browser request.
self._prime_list_cache(records)
self._list_signature = self._library_signature()
@staticmethod
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _dir(self, animation_id: str) -> Path:
return self.root / animation_id
def _path(self, animation_id: str) -> Path:
return self._dir(animation_id) / "animation.json"
def _thumb_dir(self, animation_id: str) -> Path:
return self._dir(animation_id) / "thumbnails"
def _scene_dir(self, animation_id: str) -> Path:
return self._dir(animation_id) / "scenes"
@staticmethod
def _translate(exc: OSError) -> None:
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
raise TemplateStorageFullError("animation storage is full") from exc
raise TemplateError(str(exc)) from exc
def _write_at(self, record: dict[str, Any], directory_id: str) -> None:
self._dir(directory_id).mkdir(parents=True, exist_ok=True)
data = json.dumps(
{"schema_version": 2, **_persisted(record)},
ensure_ascii=False, sort_keys=True, indent=2,
).encode("utf-8") + b"\n"
try:
atomic_write_bytes(self._path(directory_id), data)
except OSError as exc:
self._translate(exc)
def _write(self, record: dict[str, Any]) -> None:
self._write_at(record, record["id"])
self._list_cache = None
def _write_scene(self, animation_id: str, frame: dict[str, Any]) -> None:
scene = validate_scene(frame.get("scene"), strict_schema=True)
self._scene_dir(animation_id).mkdir(parents=True, exist_ok=True)
data = json.dumps(scene, ensure_ascii=False, sort_keys=True, indent=2).encode("utf-8") + b"\n"
try:
atomic_write_bytes(self._scene_dir(animation_id) / frame["scene_file"], data)
except OSError as exc:
self._translate(exc)
def _load_scene(self, animation_id: str, frame: dict[str, Any]) -> dict[str, Any]:
path = self._scene_dir(animation_id) / frame["scene_file"]
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TemplateValidationError(f"animation frame {frame['id']} is unreadable") from exc
scene = validate_scene(value, strict_schema=True)
if scene_digest(scene) != frame["digest"]:
raise TemplateValidationError("animation frame digest does not match scene")
return scene
def _write_thumbnail(self, animation_id: str, frame: dict[str, Any]) -> None:
self._thumb_dir(animation_id).mkdir(parents=True, exist_ok=True)
path = self._thumb_dir(animation_id) / frame["thumbnail"]
if path.exists():
return
scene = frame.get("scene") or self._load_scene(animation_id, frame)
try:
atomic_write_bytes(path, render_scene_thumbnail(scene, font_resolver=self._font_resolver))
except OSError as exc:
self._translate(exc)
def _v1_record(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
document = dict(value)
if document.pop("schema_version", None) != 1 or set(document) != RECORD_FIELDS:
raise TemplateValidationError("animation schema_version is unsupported")
if document.get("id") != expected_id:
raise TemplateValidationError("animation id does not match its directory")
if not document.get("created_at") or not document.get("updated_at"):
raise TemplateValidationError("animation timestamps are required")
if not isinstance(document.get("frames"), list):
raise TemplateValidationError("animation frames must be an array")
frames, ids = [], set()
for raw in document["frames"]:
if not isinstance(raw, dict) or set(raw) != V1_FRAME_FIELDS:
raise TemplateValidationError("animation frame fields do not match schema v1")
frame_id = _uuid(raw.get("id"), "animation frame not found")
if frame_id in ids:
raise TemplateValidationError("animation frame ids must be unique")
ids.add(frame_id)
scene = validate_scene(raw.get("scene"), strict_schema=True)
digest = scene_digest(scene)
if raw.get("digest") != digest or raw.get("thumbnail") != f"{frame_id}-{digest}.png":
raise TemplateValidationError("animation frame digest does not match scene")
frames.append({
"id": frame_id, "name": _name(raw.get("name"), optional=True),
"duration_ms": _duration(raw.get("duration_ms")),
"created_at": str(raw.get("created_at") or ""),
"updated_at": str(raw.get("updated_at") or ""),
"digest": digest, "thumbnail": f"{frame_id}-{digest}.png",
"scene_file": f"{frame_id}-{digest}.json", "scene": scene,
})
return {
"id": expected_id, "name": _name(document.get("name")),
"created_at": str(document["created_at"]),
"updated_at": str(document["updated_at"]), "frames": frames,
}
def _migrate_v1(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
record = self._v1_record(value, expected_id)
# The entire v1 document is validated before authoritative metadata changes.
for frame in record["frames"]:
self._write_scene(expected_id, frame)
self._write_thumbnail(expected_id, frame)
self._write(record)
return {"schema_version": 2, **_persisted(record)}
def _validate_record(self, value: Any, expected_id: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise TemplateValidationError("animation record must be an object")
if value.get("schema_version") == 1:
value = self._migrate_v1(value, expected_id)
document = dict(value)
if document.pop("schema_version", None) != 2:
raise TemplateValidationError("animation schema_version is unsupported")
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
raise TemplateValidationError("animation fields do not match the current schema")
if not document.get("created_at") or not document.get("updated_at"):
raise TemplateValidationError("animation timestamps are required")
if not isinstance(document.get("frames"), list):
raise TemplateValidationError("animation frames must be an array")
frames, ids = [], set()
for raw in document["frames"]:
if not isinstance(raw, dict) or set(raw) != FRAME_FIELDS:
raise TemplateValidationError("animation frame fields do not match the current schema")
frame_id = _uuid(raw.get("id"), "animation frame not found")
if frame_id in ids:
raise TemplateValidationError("animation frame ids must be unique")
ids.add(frame_id)
digest = str(raw.get("digest") or "")
thumbnail, scene_file = f"{frame_id}-{digest}.png", f"{frame_id}-{digest}.json"
if raw.get("thumbnail") != thumbnail or raw.get("scene_file") != scene_file:
raise TemplateValidationError("animation frame paths do not match its digest")
frame = {
"id": frame_id, "name": _name(raw.get("name"), optional=True),
"duration_ms": _duration(raw.get("duration_ms")),
"created_at": str(raw.get("created_at") or ""),
"updated_at": str(raw.get("updated_at") or ""),
"digest": digest, "thumbnail": thumbnail, "scene_file": scene_file,
}
self._load_scene(expected_id, frame)
if not (self._thumb_dir(expected_id) / thumbnail).is_file():
self._write_thumbnail(expected_id, frame)
frames.append(frame)
return {
"id": expected_id, "name": _name(document.get("name")),
"created_at": str(document["created_at"]),
"updated_at": str(document["updated_at"]), "frames": frames,
}
def _read(self, animation_id: Any) -> dict[str, Any]:
normalized = _uuid(animation_id, "animation not found")
path = self._path(normalized)
if not path.is_file():
raise TemplateNotFoundError("animation not found")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TemplateValidationError(f"animation {normalized} is unreadable") from exc
return self._validate_record(value, normalized)
def _cleanup_orphans(self, record: dict[str, Any]) -> None:
for directory, expected, pattern in (
(self._thumb_dir(record["id"]), {f["thumbnail"] for f in record["frames"]}, THUMBNAIL_RE),
(self._scene_dir(record["id"]), {f["scene_file"] for f in record["frames"]}, SCENE_RE),
):
if directory.exists():
for path in directory.iterdir():
if path.is_file() and path.name not in expected and pattern.fullmatch(path.name):
path.unlink(missing_ok=True)
def _all(self) -> list[dict[str, Any]]:
return [
self._read(child.name) for child in self.root.iterdir()
if child.is_dir() and not child.name.startswith(".")
]
def _library_signature(self) -> tuple[tuple[str, int, int], ...]:
"""Cheaply identify committed library changes made by another process."""
entries: list[tuple[str, int, int]] = []
for child in self.root.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
try:
stat = self._path(child.name).stat()
entries.append((child.name, stat.st_mtime_ns, stat.st_size))
except FileNotFoundError:
# Keep the in-progress directory transition visible to the
# before/after stability check in _refresh_list_cache().
entries.append((child.name, -1, -1))
return tuple(sorted(entries))
def _refresh_list_cache(self) -> None:
# An isolated media worker commits animations with an atomic directory
# rename. Read only across a stable signature so that a concurrent
# commit or delete cannot become the new long-lived cached snapshot.
for _attempt in range(3):
before = self._library_signature()
try:
records = self._all()
except (TemplateNotFoundError, TemplateValidationError):
if before != self._library_signature():
continue
raise
after = self._library_signature()
if before == after:
self._prime_list_cache(records)
self._list_signature = after
return
# Continuous external changes are exceptional. Return a fresh scan and
# leave the signature from after that scan so the next request checks it
# again instead of trusting an older snapshot.
self._prime_list_cache(self._all())
self._list_signature = self._library_signature()
def _prime_list_cache(self, records: Iterable[dict[str, Any]]) -> None:
ordered = sorted(records, key=lambda record: record["updated_at"], reverse=True)
metadata = [self._metadata(record) for record in ordered]
usage = shutil.disk_usage(self.root)
self._list_cache = {
"animations": metadata,
"storage": {
"animations_bytes": sum(item["size_bytes"] for item in metadata),
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
},
}
def _unique(self, name: str, exclude: str | None = None) -> None:
folded = name.casefold()
if any(r["id"] != exclude and r["name"].casefold() == folded for r in self._all()):
raise TemplateConflictError("animation name already exists")
def name_available(self, name: Any) -> bool:
checked = _name(name)
with self._lock:
return all(r["name"].casefold() != checked.casefold() for r in self._all())
def thumbnail_url(self, animation_id: str, frame: dict[str, Any]) -> str:
return f"/api/animations/{animation_id}/frames/{frame['id']}/thumbnail?v={frame['digest']}"
def _metadata(self, record: dict[str, Any]) -> dict[str, Any]:
revision = _revision(record)
cached = self._metadata_size_cache.get(record["id"])
if cached is not None and cached[0] == revision:
size = cached[1]
else:
size = self._path(record["id"]).stat().st_size
for frame in record["frames"]:
for path in (
self._thumb_dir(record["id"]) / frame["thumbnail"],
self._scene_dir(record["id"]) / frame["scene_file"],
):
if path.exists():
size += path.stat().st_size
self._metadata_size_cache[record["id"]] = (revision, size)
first = record["frames"][0] if record["frames"] else None
return {
"id": record["id"], "name": record["name"],
"created_at": record["created_at"], "updated_at": record["updated_at"],
"revision": revision, "frame_count": len(record["frames"]),
"total_duration_ms": sum(f["duration_ms"] for f in record["frames"]),
"size_bytes": size,
"thumbnail_url": self.thumbnail_url(record["id"], first) if first else None,
}
def _detail(self, record: dict[str, Any], *, scenes: bool = False,
offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
if offset is None and limit is None:
start, end, page = 0, len(record["frames"]), {}
else:
start = max(0, int(offset or 0))
checked_limit = max(1, min(500, int(limit or 100)))
end = min(len(record["frames"]), start + checked_limit)
page = {
"frame_offset": start, "frame_limit": checked_limit,
"frame_total": len(record["frames"]),
}
frames = []
for index in range(start, end):
frame = record["frames"][index]
item = {
"id": frame["id"], "name": frame["name"], "position": index + 1,
"duration_ms": frame["duration_ms"], "created_at": frame["created_at"],
"updated_at": frame["updated_at"], "digest": frame["digest"],
"thumbnail_url": self.thumbnail_url(record["id"], frame),
}
if scenes:
item["scene"] = self._load_scene(record["id"], frame)
frames.append(item)
return {**self._metadata(record), **page, "frames": frames}
def list(self) -> dict[str, Any]:
with self._lock:
signature = self._library_signature()
if self._list_cache is None or signature != self._list_signature:
self._refresh_list_cache()
return {
"animations": [dict(item) for item in self._list_cache["animations"]],
"storage": dict(self._list_cache["storage"]),
}
def get(self, animation_id: Any, *, scenes: bool = False,
offset: int | None = None, limit: int | None = None) -> dict[str, Any]:
with self._lock:
return self._detail(self._read(animation_id), scenes=scenes, offset=offset, limit=limit)
def create(self, name: Any) -> dict[str, Any]:
checked = _name(name)
with self._lock:
self._unique(checked)
animation_id, now = str(uuid4()), self._now()
record = {
"id": animation_id, "name": checked,
"created_at": now, "updated_at": now, "frames": [],
}
self._write(record)
return self._detail(record)
def _new_frame(self, source: dict[str, Any], now: str) -> dict[str, Any]:
frame_id = str(uuid4())
scene = validate_scene(source.get("scene"), strict_schema=True)
digest = scene_digest(scene)
return {
"id": frame_id, "name": _name(source.get("name"), optional=True),
"duration_ms": _duration(source.get("duration_ms")),
"created_at": now, "updated_at": now, "digest": digest,
"thumbnail": f"{frame_id}-{digest}.png",
"scene_file": f"{frame_id}-{digest}.json", "scene": scene,
}
def create_from_frames(self, name: Any, frames: Iterable[dict[str, Any]]) -> dict[str, Any]:
checked = _name(name)
with self._lock:
self._unique(checked)
animation_id, now = str(uuid4()), self._now()
staging_id = ".staging-" + animation_id
record = {
"id": animation_id, "name": checked,
"created_at": now, "updated_at": now, "frames": [],
}
try:
for source in frames:
frame = self._new_frame(source, now)
self._write_scene(staging_id, frame)
self._write_thumbnail(staging_id, frame)
record["frames"].append(frame)
if not record["frames"]:
raise TemplateValidationError("animation must contain at least one frame")
self._write_at(record, staging_id)
self._dir(staging_id).replace(self._dir(animation_id))
self._list_cache = None
except Exception:
shutil.rmtree(self._dir(staging_id), ignore_errors=True)
raise
return self._detail(record)
def copy_from_frames(self, source_name: Any, frames: Any) -> dict[str, Any]:
source_name = _name(source_name)
if not isinstance(frames, list) or not frames:
raise TemplateValidationError("animation copy source must contain frames")
with self._lock:
base, sequence = f"{source_name} - 副本", 2
candidate = base
names = {r["name"].casefold() for r in self._all()}
while candidate.casefold() in names:
candidate = f"{base} {sequence}"
sequence += 1
return self.create_from_frames(candidate, frames)
def _match(self, record: dict[str, Any], expected: str) -> None:
if _revision(record) != expected:
raise TemplateConflictError("animation was modified")
def rename(self, animation_id: Any, name: Any, expected: str) -> dict[str, Any]:
checked = _name(name)
with self._lock:
record = self._read(animation_id)
self._match(record, expected); self._unique(checked, record["id"])
record["name"] = checked; record["updated_at"] = self._now(); self._write(record)
return self._detail(record)
def add_frame(self, animation_id: Any, scene: Any, expected: str, *,
name: Any = None, duration_ms: Any = DEFAULT_DURATION_MS) -> dict[str, Any]:
source = {
"scene": validate_scene(scene), "name": _name(name, optional=True),
"duration_ms": _duration(duration_ms),
}
with self._lock:
record = self._read(animation_id); self._match(record, expected); now = self._now()
frame = self._new_frame(source, now)
self._write_scene(record["id"], frame); self._write_thumbnail(record["id"], frame)
record["frames"].append(frame); record["updated_at"] = now; self._write(record)
return self._detail(record)
def _frame(self, record: dict[str, Any], frame_id: Any) -> dict[str, Any]:
normalized = _uuid(frame_id, "animation frame not found")
for frame in record["frames"]:
if frame["id"] == normalized:
return frame
raise TemplateNotFoundError("animation frame not found")
def _frames(self, record: dict[str, Any], frame_ids: Any) -> list[dict[str, Any]]:
if not isinstance(frame_ids, list) or not frame_ids:
raise TemplateValidationError("frame_ids must be a non-empty array")
normalized = [_uuid(v, "animation frame not found") for v in frame_ids]
if len(normalized) != len(set(normalized)):
raise TemplateValidationError("frame_ids must not contain duplicates")
selected = set(normalized)
ordered = [f for f in record["frames"] if f["id"] in selected]
if len(ordered) != len(normalized):
raise TemplateNotFoundError("animation frame not found")
return ordered
def get_frame(self, animation_id: Any, frame_id: Any) -> dict[str, Any]:
with self._lock:
record = self._read(animation_id); frame = self._frame(record, frame_id)
return {
"animation_id": record["id"], "animation_name": record["name"],
"animation_revision": _revision(record),
"position": record["frames"].index(frame) + 1,
"id": frame["id"], "name": frame["name"],
"duration_ms": frame["duration_ms"], "created_at": frame["created_at"],
"updated_at": frame["updated_at"], "digest": frame["digest"],
"scene": self._load_scene(record["id"], frame),
"thumbnail_url": self.thumbnail_url(record["id"], frame),
}
def update_frame(self, animation_id: Any, frame_id: Any, expected: str, *,
scene: Any | None = None, name: Any = ..., duration_ms: Any = ...) -> dict[str, Any]:
with self._lock:
record = self._read(animation_id); self._match(record, expected)
frame = self._frame(record, frame_id); now = self._now()
old_thumb, old_scene = frame["thumbnail"], frame["scene_file"]
if scene is not None:
checked, digest = validate_scene(scene), scene_digest(validate_scene(scene))
frame.update({
"scene": checked, "digest": digest,
"thumbnail": f"{frame['id']}-{digest}.png",
"scene_file": f"{frame['id']}-{digest}.json",
})
self._write_scene(record["id"], frame); self._write_thumbnail(record["id"], frame)
if name is not ...:
frame["name"] = _name(name, optional=True)
if duration_ms is not ...:
frame["duration_ms"] = _duration(duration_ms)
frame["updated_at"] = now; record["updated_at"] = now; self._write(record)
if old_thumb != frame["thumbnail"]:
(self._thumb_dir(record["id"]) / old_thumb).unlink(missing_ok=True)
(self._scene_dir(record["id"]) / old_scene).unlink(missing_ok=True)
return self._detail(record)
def reorder(self, animation_id: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
if not isinstance(frame_ids, list):
raise TemplateValidationError("frame_ids must be an array")
with self._lock:
record = self._read(animation_id); self._match(record, expected)
normalized = [_uuid(v, "animation frame not found") for v in frame_ids]
current = [f["id"] for f in record["frames"]]
if len(normalized) != len(set(normalized)) or set(normalized) != set(current):
raise TemplateValidationError("frame_ids must be an exact permutation")
by_id = {f["id"]: f for f in record["frames"]}
record["frames"] = [by_id[v] for v in normalized]
record["updated_at"] = self._now(); self._write(record)
return self._detail(record)
def move_frames(self, animation_id: Any, frame_ids: Any, expected: str, *,
before_frame_id: Any = None) -> dict[str, Any]:
with self._lock:
record = self._read(animation_id); self._match(record, expected)
moved = self._frames(record, frame_ids); moved_ids = {f["id"] for f in moved}
remaining = [f for f in record["frames"] if f["id"] not in moved_ids]
if before_frame_id is None:
insert_at = len(remaining)
else:
before = _uuid(before_frame_id, "animation frame not found")
if before in moved_ids:
raise TemplateValidationError("before_frame_id must not be moved")
try:
insert_at = next(i for i, frame in enumerate(remaining) if frame["id"] == before)
except StopIteration as exc:
raise TemplateNotFoundError("animation frame not found") from exc
remaining[insert_at:insert_at] = moved
record["frames"] = remaining; record["updated_at"] = self._now(); self._write(record)
return self._detail(record)
def set_durations(self, animation_id: Any, duration_ms: Any, expected: str, *,
frame_ids: Any = None) -> dict[str, Any]:
checked = _duration(duration_ms)
with self._lock:
record = self._read(animation_id); self._match(record, expected); now = self._now()
targets = record["frames"] if frame_ids is None else self._frames(record, frame_ids)
for frame in targets:
frame["duration_ms"] = checked; frame["updated_at"] = now
record["updated_at"] = now; self._write(record)
return self._detail(record)
def set_all_durations(self, animation_id: Any, duration_ms: Any, expected: str) -> dict[str, Any]:
return self.set_durations(animation_id, duration_ms, expected)
def copy_frames(self, source_animation_id: Any, frame_ids: Any,
source_expected: str, destination_animation_id: Any,
destination_expected: str, insert_after_frame_id: Any = None) -> dict[str, Any]:
with self._lock:
source = self._read(source_animation_id); self._match(source, source_expected)
source_frames = self._frames(source, frame_ids)
destination = source if source["id"] == str(destination_animation_id) else self._read(destination_animation_id)
self._match(destination, destination_expected)
insert_at = 0 if insert_after_frame_id is None else (
destination["frames"].index(self._frame(destination, insert_after_frame_id)) + 1
)
now, copied, created = self._now(), [], []
try:
for original in source_frames:
frame = self._new_frame({
"scene": self._load_scene(source["id"], original),
"name": original["name"], "duration_ms": original["duration_ms"],
}, now)
created += [
self._thumb_dir(destination["id"]) / frame["thumbnail"],
self._scene_dir(destination["id"]) / frame["scene_file"],
]
self._write_scene(destination["id"], frame)
self._write_thumbnail(destination["id"], frame)
copied.append(frame)
destination["frames"][insert_at:insert_at] = copied
destination["updated_at"] = now; self._write(destination)
except Exception:
for path in created:
path.unlink(missing_ok=True)
raise
return {
"animation": self._detail(destination),
"copied_frame_ids": [frame["id"] for frame in copied],
}
def delete_frames(self, animation_id: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
with self._lock:
record = self._read(animation_id); self._match(record, expected)
removed = self._frames(record, frame_ids); ids = {f["id"] for f in removed}
record["frames"] = [f for f in record["frames"] if f["id"] not in ids]
record["updated_at"] = self._now(); self._write(record)
for frame in removed:
(self._thumb_dir(record["id"]) / frame["thumbnail"]).unlink(missing_ok=True)
(self._scene_dir(record["id"]) / frame["scene_file"]).unlink(missing_ok=True)
return self._detail(record)
def delete_frame(self, animation_id: Any, frame_id: Any, expected: str) -> dict[str, Any]:
return self.delete_frames(animation_id, [frame_id], expected)
def delete(self, animation_id: Any, expected: str) -> None:
with self._lock:
record = self._read(animation_id); self._match(record, expected)
shutil.rmtree(self._dir(record["id"]))
self._list_cache = None
def thumbnail_path(self, animation_id: Any, frame_id: Any, digest: str | None = None) -> Path:
if digest is not None:
normalized_animation = _uuid(animation_id, "animation not found")
normalized_frame = _uuid(frame_id, "animation frame not found")
checked_digest = str(digest)
if re.fullmatch(r"[0-9a-f]{16}", checked_digest) is None:
raise TemplateNotFoundError("animation thumbnail not found")
path = self._thumb_dir(normalized_animation) / f"{normalized_frame}-{checked_digest}.png"
if not path.is_file():
raise TemplateNotFoundError("animation thumbnail not found")
return path
with self._lock:
record = self._read(animation_id); frame = self._frame(record, frame_id)
path = self._thumb_dir(record["id"]) / frame["thumbnail"]
if not path.is_file():
raise TemplateNotFoundError("animation thumbnail not found")
return path
def playback_snapshot(self, animation_id: Any, expected: str | None = None) -> dict[str, Any]:
"""Create immutable hard-linked PNG descriptors for lazy display playback."""
with self._lock:
record = self._read(animation_id); revision = _revision(record)
if expected is not None and revision != expected:
raise TemplateConflictError("animation was modified")
snapshot_dir = self._playback_root / f"{record['id']}-{revision}"
snapshot_dir.mkdir(parents=True, exist_ok=True)
frames = []
for frame in record["frames"]:
source = self._thumb_dir(record["id"]) / frame["thumbnail"]
target = snapshot_dir / frame["thumbnail"]
if not target.exists():
try:
os.link(source, target)
except OSError:
shutil.copyfile(source, target)
frames.append({
"id": frame["id"], "duration_ms": frame["duration_ms"], "path": target,
})
return {**self._metadata(record), "frames": frames}
@@ -0,0 +1,2 @@
"""Configuration helpers."""
@@ -0,0 +1,703 @@
from __future__ import annotations
import json
import logging
import math
import os
import re
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import UUID
from app.persistence import atomic_write_bytes
logger = logging.getLogger(__name__)
DEFAULT_COLOR_PALETTE = [
"#000000", "#FFFFFF", "#FF0000", "#FF8000", "#FFFF00", "#00FF00",
"#00FFFF", "#0000FF", "#8000FF", "#FF00FF", "#808080", "#C0C0C0",
]
COLOR_PALETTE_LIMIT = 24
DEFAULT_PREVIEW_REFRESH_INTERVAL_MS = 1000
MIN_PREVIEW_REFRESH_INTERVAL_MS = 1
MAX_PREVIEW_REFRESH_INTERVAL_MS = 60000
DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT = 2
MIN_ANIMATION_PREVIEW_MAX_CONCURRENT = 1
MAX_ANIMATION_PREVIEW_MAX_CONCURRENT = 50
DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ = 100
DEFAULT_DISPLAY = {
"type": "animation",
"id": "00000000-0000-4000-8000-000000000102",
}
DEFAULT_WORKSPACE_ORDER = [
"device",
"settings",
"canvas",
"text",
"media-import",
"animations",
"templates",
]
MAX_WORKSPACE_ORDER_ITEMS = 128
MATRIX_REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
HEX_COLOR_RE = re.compile(r"^#([0-9A-Fa-f]{6})$")
WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
DEFAULT_CONFIG: dict[str, Any] = {
"orientation": 0,
"brightness": 40,
"default_font": "default",
"default_text_size": 12,
"last_mode": "clear",
"color_palette": DEFAULT_COLOR_PALETTE,
"voltage_calibration_factor": 1.0,
"voltage_calibrated_at": None,
"voltage_calibration_reference": None,
"voltage_calibration_uncalibrated": None,
"preview_refresh_interval_ms": DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
"low_voltage_protection_enabled": False,
"matrix_refresh_rate_limit_hz": DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ,
"custom_test_color": "#40A0FF",
"default_display": DEFAULT_DISPLAY,
"workspace_order": DEFAULT_WORKSPACE_ORDER,
"performance_mode_enabled": False,
"animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
}
CONFIG_SCHEMA_VERSION = 10
CONFIG_V1_FIELDS = frozenset({
"orientation",
"brightness",
"default_font",
"default_text_size",
"last_mode",
"color_palette",
"voltage_calibration_factor",
"voltage_calibrated_at",
"voltage_calibration_reference",
"voltage_calibration_uncalibrated",
})
CONFIG_V2_FIELDS = CONFIG_V1_FIELDS | {"preview_refresh_interval_ms"}
CONFIG_V3_FIELDS = CONFIG_V2_FIELDS | {"low_voltage_protection_enabled"}
CONFIG_V4_FIELDS = CONFIG_V3_FIELDS | {"matrix_refresh_rate_limit_hz"}
CONFIG_V5_FIELDS = CONFIG_V4_FIELDS | {"custom_test_color"}
CONFIG_V6_FIELDS = CONFIG_V5_FIELDS | {"default_display"}
CONFIG_V7_FIELDS = CONFIG_V6_FIELDS | {"workspace_order"}
CONFIG_V8_FIELDS = CONFIG_V7_FIELDS
CONFIG_V9_FIELDS = CONFIG_V8_FIELDS | {"performance_mode_enabled"}
CONFIG_V10_FIELDS = CONFIG_V9_FIELDS | {"animation_preview_max_concurrent"}
CONFIG_FIELDS = CONFIG_V10_FIELDS
ORIENTATIONS = {0, 90, 180, 270}
class ConfigError(ValueError):
"""Raised when a persisted or requested configuration is invalid."""
class ConfigStore:
def __init__(self, project_root: Path | None = None) -> None:
self.project_root = (
Path(project_root)
if project_root is not None
else Path(__file__).resolve().parents[2]
)
self.data_dir = resolve_data_dir(project_root)
self.runtime_dir = resolve_runtime_dir(project_root, data_dir=self.data_dir)
self.path = self.data_dir / "config.json"
self._lock = threading.RLock()
self.data_dir.mkdir(parents=True, exist_ok=True)
self.runtime_dir.mkdir(parents=True, exist_ok=True)
self._config = self.load()
@property
def config(self) -> dict[str, Any]:
with self._lock:
return {
**self._config,
"color_palette": list(self._config["color_palette"]),
"default_display": dict(self._config["default_display"]),
"workspace_order": list(self._config["workspace_order"]),
}
def load(self) -> dict[str, Any]:
if not self.path.exists():
config = default_config()
self._write(config)
logger.info("Created default config at %s", self.path)
return config
try:
raw = json.loads(self.path.read_bytes().decode("utf-8"))
config, migrated = load_persisted_config(raw)
if migrated:
self._write(config)
logger.info("Migrated config at %s", self.path)
logger.info("Loaded config from %s", self.path)
return config
except ConfigError:
raise
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ConfigError(f"persisted config is unreadable: {self.path}") from exc
except Exception as exc:
raise ConfigError(f"persisted config is invalid: {self.path}") from exc
def update(self, values: dict[str, Any]) -> dict[str, Any]:
with self._lock:
unknown = set(values) - CONFIG_FIELDS
if unknown:
raise ConfigError(f"unknown config fields: {', '.join(sorted(unknown))}")
merged = {**self._config, **values}
config = validate_config(merged)
if config == self._config:
return {
**config,
"color_palette": list(config["color_palette"]),
"default_display": dict(config["default_display"]),
"workspace_order": list(config["workspace_order"]),
}
self._write(config)
self._config = config
logger.info("Saved config to %s", self.path)
return {
**config,
"color_palette": list(config["color_palette"]),
"default_display": dict(config["default_display"]),
"workspace_order": list(config["workspace_order"]),
}
def add_palette_color(self, color: str) -> dict[str, Any]:
normalized = normalize_hex_color(color)
with self._lock:
palette = list(self._config["color_palette"])
if normalized in palette:
return self.config
if len(palette) >= COLOR_PALETTE_LIMIT:
raise ConfigError(f"color palette is limited to {COLOR_PALETTE_LIMIT} colors")
palette.append(normalized)
return self.update({"color_palette": palette})
def remove_palette_color(self, color: str) -> dict[str, Any]:
normalized = normalize_hex_color(color)
with self._lock:
palette = [item for item in self._config["color_palette"] if item != normalized]
if len(palette) == len(self._config["color_palette"]):
return self.config
return self.update({"color_palette": palette})
def set_last_mode(self, mode: str) -> None:
self.update({"last_mode": mode})
def _write(self, config: dict[str, Any]) -> None:
persisted = {"schema_version": CONFIG_SCHEMA_VERSION, **config}
atomic_write_bytes(
self.path,
(json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
)
def _configured_path(name: str) -> Path | None:
value = os.environ.get(name)
return Path(value) if value else None
def resolve_data_dir(project_root: Path | None = None) -> Path:
if project_root is not None:
return Path(project_root) / "data"
return (
_configured_path("MATRIX_DATA_DIR")
or _configured_path("STATE_DIRECTORY")
or Path(__file__).resolve().parents[2] / "data"
)
def resolve_runtime_dir(
project_root: Path | None = None,
*,
data_dir: Path | None = None,
) -> Path:
if project_root is not None:
return Path(project_root) / "data" / "runtime"
return (
_configured_path("MATRIX_RUNTIME_DIR")
or _configured_path("RUNTIME_DIRECTORY")
or Path(__file__).resolve().parents[2] / "data" / "runtime"
)
def default_config() -> dict[str, Any]:
return {
**DEFAULT_CONFIG,
"color_palette": list(DEFAULT_COLOR_PALETTE),
"default_display": dict(DEFAULT_DISPLAY),
"workspace_order": list(DEFAULT_WORKSPACE_ORDER),
}
def _check_known_fields(
document: dict[str, Any],
allowed_fields: frozenset[str] = CONFIG_FIELDS,
) -> None:
unknown = set(document) - allowed_fields
if unknown:
raise ConfigError(f"unknown persisted config fields: {', '.join(sorted(unknown))}")
def _validate_current_config(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document)
missing = CONFIG_FIELDS - set(document)
if missing:
raise ConfigError(f"persisted config is missing fields: {', '.join(sorted(missing))}")
_validate_current_field_types(document)
try:
checked = validate_config(document)
except (TypeError, ValueError) as exc:
raise ConfigError("persisted config values are invalid") from exc
if checked != document:
raise ConfigError(
f"persisted config does not match schema version {CONFIG_SCHEMA_VERSION}"
)
return checked
def _validate_current_field_types(document: dict[str, Any]) -> None:
integer_fields = (
"orientation",
"brightness",
"default_text_size",
"preview_refresh_interval_ms",
"matrix_refresh_rate_limit_hz",
"animation_preview_max_concurrent",
)
for field in integer_fields:
if type(document[field]) is not int:
raise ConfigError(f"persisted config field {field} must be an integer")
for field in ("default_font", "last_mode"):
if not isinstance(document[field], str):
raise ConfigError(f"persisted config field {field} must be a string")
if type(document["low_voltage_protection_enabled"]) is not bool:
raise ConfigError(
"persisted config field low_voltage_protection_enabled must be a boolean"
)
if type(document["performance_mode_enabled"]) is not bool:
raise ConfigError(
"persisted config field performance_mode_enabled must be a boolean"
)
if not isinstance(document["custom_test_color"], str):
raise ConfigError("persisted config field custom_test_color must be a string")
default_display = document["default_display"]
if not isinstance(default_display, dict):
raise ConfigError("persisted config field default_display must be an object")
workspace_order = document["workspace_order"]
if not isinstance(workspace_order, list) or any(
not isinstance(value, str) for value in workspace_order
):
raise ConfigError("persisted config field workspace_order must be an array of strings")
palette = document["color_palette"]
if not isinstance(palette, list) or any(not isinstance(value, str) for value in palette):
raise ConfigError("persisted config field color_palette must be an array of strings")
factor = document["voltage_calibration_factor"]
if isinstance(factor, bool) or not isinstance(factor, (int, float)):
raise ConfigError("persisted config field voltage_calibration_factor must be a number")
calibrated_at = document["voltage_calibrated_at"]
if calibrated_at is not None and not isinstance(calibrated_at, str):
raise ConfigError("persisted config field voltage_calibrated_at must be a string or null")
for field in ("voltage_calibration_reference", "voltage_calibration_uncalibrated"):
value = document[field]
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))):
raise ConfigError(f"persisted config field {field} must be a number or null")
def _migrate_config_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V1_FIELDS)
try:
migrated = validate_config({**default_config(), **document})
except (TypeError, ValueError) as exc:
raise ConfigError("legacy config cannot be migrated safely") from exc
calibration_fields = {
"voltage_calibration_factor",
"voltage_calibrated_at",
"voltage_calibration_reference",
"voltage_calibration_uncalibrated",
}
present_calibration_fields = calibration_fields & set(document)
if present_calibration_fields:
if present_calibration_fields != calibration_fields:
raise ConfigError("legacy voltage calibration is incomplete")
if any(migrated[field] != document[field] for field in calibration_fields):
raise ConfigError("legacy voltage calibration cannot be migrated safely")
migrated.pop("preview_refresh_interval_ms")
migrated.pop("low_voltage_protection_enabled")
migrated.pop("matrix_refresh_rate_limit_hz")
migrated.pop("custom_test_color")
migrated.pop("default_display")
migrated.pop("workspace_order")
migrated.pop("performance_mode_enabled")
migrated.pop("animation_preview_max_concurrent")
return migrated
def _migrate_config_v1_to_v2(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V1_FIELDS)
missing = CONFIG_V1_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"preview_refresh_interval_ms": DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
}
def _migrate_config_v2_to_v3(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V2_FIELDS)
missing = CONFIG_V2_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"low_voltage_protection_enabled": False,
}
def _migrate_config_v3_to_v4(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V3_FIELDS)
missing = CONFIG_V3_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"matrix_refresh_rate_limit_hz": DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ,
}
def _migrate_config_v4_to_v5(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V4_FIELDS)
missing = CONFIG_V4_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"custom_test_color": DEFAULT_CONFIG["custom_test_color"],
}
def _migrate_config_v5_to_v6(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V5_FIELDS)
missing = CONFIG_V5_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"default_display": dict(DEFAULT_DISPLAY),
}
def _migrate_config_v6_to_v7(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V6_FIELDS)
missing = CONFIG_V6_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"workspace_order": [
"device", "settings", "canvas", "text", "animations", "templates",
],
}
def _migrate_config_v7_to_v8(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V7_FIELDS)
missing = CONFIG_V7_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
order = normalize_workspace_order(document["workspace_order"])
if "media-import" not in order:
insert_at = order.index("animations") if "animations" in order else len(order)
order.insert(insert_at, "media-import")
return {**document, "workspace_order": order}
def _migrate_config_v8_to_v9(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V8_FIELDS)
missing = CONFIG_V8_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {**document, "performance_mode_enabled": False}
def _migrate_config_v9_to_v10(document: dict[str, Any]) -> dict[str, Any]:
_check_known_fields(document, CONFIG_V9_FIELDS)
missing = CONFIG_V9_FIELDS - set(document)
if missing:
raise ConfigError(
f"persisted config is missing fields: {', '.join(sorted(missing))}"
)
return {
**document,
"animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
}
CONFIG_MIGRATIONS = {
0: _migrate_config_v0_to_v1,
1: _migrate_config_v1_to_v2,
2: _migrate_config_v2_to_v3,
3: _migrate_config_v3_to_v4,
4: _migrate_config_v4_to_v5,
5: _migrate_config_v5_to_v6,
6: _migrate_config_v6_to_v7,
7: _migrate_config_v7_to_v8,
8: _migrate_config_v8_to_v9,
9: _migrate_config_v9_to_v10,
}
def load_persisted_config(raw: Any) -> tuple[dict[str, Any], bool]:
if not isinstance(raw, dict):
raise ConfigError("persisted config must be a JSON object")
document = dict(raw)
if "schema_version" in document:
version = document.pop("schema_version")
if type(version) is not int:
raise ConfigError("config schema_version must be an integer")
else:
version = 0
if version > CONFIG_SCHEMA_VERSION:
raise ConfigError(
f"config schema version {version} is newer than supported version "
f"{CONFIG_SCHEMA_VERSION}"
)
if version < 0:
raise ConfigError(f"unsupported config schema version {version}")
if version == CONFIG_SCHEMA_VERSION:
return _validate_current_config(document), False
while version < CONFIG_SCHEMA_VERSION:
migrator = CONFIG_MIGRATIONS.get(version)
if migrator is None:
raise ConfigError(f"no config migration is registered for version {version}")
document = migrator(document)
version += 1
return _validate_current_config(document), True
def validate_config(config: dict[str, Any]) -> dict[str, Any]:
orientation = int(config.get("orientation", DEFAULT_CONFIG["orientation"]))
if orientation not in ORIENTATIONS:
raise ConfigError("orientation must be one of 0, 90, 180, 270")
brightness = int(config.get("brightness", DEFAULT_CONFIG["brightness"]))
if brightness < 1 or brightness > 100:
raise ConfigError("brightness must be in 1..100")
default_font = str(config.get("default_font", DEFAULT_CONFIG["default_font"])).strip()
if not default_font:
default_font = DEFAULT_CONFIG["default_font"]
default_text_size = int(config.get("default_text_size", DEFAULT_CONFIG["default_text_size"]))
if default_text_size < 1 or default_text_size > 64:
raise ConfigError("default_text_size must be in 1..64")
preview_refresh_interval = config.get(
"preview_refresh_interval_ms",
DEFAULT_PREVIEW_REFRESH_INTERVAL_MS,
)
if type(preview_refresh_interval) is not int:
raise ConfigError("preview_refresh_interval_ms must be an integer")
if not (
MIN_PREVIEW_REFRESH_INTERVAL_MS
<= preview_refresh_interval
<= MAX_PREVIEW_REFRESH_INTERVAL_MS
):
raise ConfigError(
"preview_refresh_interval_ms must be in "
f"{MIN_PREVIEW_REFRESH_INTERVAL_MS}..{MAX_PREVIEW_REFRESH_INTERVAL_MS}"
)
low_voltage_protection_enabled = config.get(
"low_voltage_protection_enabled",
DEFAULT_CONFIG["low_voltage_protection_enabled"],
)
if type(low_voltage_protection_enabled) is not bool:
raise ConfigError("low_voltage_protection_enabled must be a boolean")
performance_mode_enabled = config.get(
"performance_mode_enabled",
DEFAULT_CONFIG["performance_mode_enabled"],
)
if type(performance_mode_enabled) is not bool:
raise ConfigError("performance_mode_enabled must be a boolean")
animation_preview_max_concurrent = config.get(
"animation_preview_max_concurrent",
DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
)
if type(animation_preview_max_concurrent) is not int:
raise ConfigError("animation_preview_max_concurrent must be an integer")
if not (
MIN_ANIMATION_PREVIEW_MAX_CONCURRENT
<= animation_preview_max_concurrent
<= MAX_ANIMATION_PREVIEW_MAX_CONCURRENT
):
raise ConfigError(
"animation_preview_max_concurrent must be in "
f"{MIN_ANIMATION_PREVIEW_MAX_CONCURRENT}.."
f"{MAX_ANIMATION_PREVIEW_MAX_CONCURRENT}"
)
matrix_refresh_rate_limit = config.get(
"matrix_refresh_rate_limit_hz",
DEFAULT_CONFIG["matrix_refresh_rate_limit_hz"],
)
if type(matrix_refresh_rate_limit) is not int:
raise ConfigError("matrix_refresh_rate_limit_hz must be an integer")
if matrix_refresh_rate_limit not in MATRIX_REFRESH_RATE_LIMITS:
allowed = ", ".join(str(value) for value in sorted(MATRIX_REFRESH_RATE_LIMITS))
raise ConfigError(
f"matrix_refresh_rate_limit_hz must be one of {allowed}"
)
custom_test_color = normalize_hex_color(
config.get("custom_test_color", DEFAULT_CONFIG["custom_test_color"])
)
default_display = normalize_default_display(
config.get("default_display", DEFAULT_DISPLAY)
)
workspace_order = normalize_workspace_order(
config.get("workspace_order", DEFAULT_WORKSPACE_ORDER)
)
last_mode = str(config.get("last_mode", DEFAULT_CONFIG["last_mode"]))
raw_palette = config.get("color_palette", DEFAULT_COLOR_PALETTE)
if not isinstance(raw_palette, list):
raw_palette = DEFAULT_COLOR_PALETTE
color_palette: list[str] = []
for value in raw_palette:
try:
normalized = normalize_hex_color(value)
except ConfigError:
continue
if normalized not in color_palette:
color_palette.append(normalized)
if len(color_palette) == COLOR_PALETTE_LIMIT:
break
voltage_factor = config.get("voltage_calibration_factor", 1.0)
voltage_calibrated_at = config.get("voltage_calibrated_at")
voltage_reference = config.get("voltage_calibration_reference")
voltage_uncalibrated = config.get("voltage_calibration_uncalibrated")
try:
voltage_factor = float(voltage_factor)
factor_valid = math.isfinite(voltage_factor) and 0.8 <= voltage_factor <= 1.2
except (TypeError, ValueError):
factor_valid = False
calibration_values = (voltage_calibrated_at, voltage_reference, voltage_uncalibrated)
if all(value is None for value in calibration_values):
calibration_valid = factor_valid and voltage_factor == 1.0
elif all(value is not None for value in calibration_values) and factor_valid:
try:
datetime.fromisoformat(str(voltage_calibrated_at).replace("Z", "+00:00"))
voltage_reference = float(voltage_reference)
voltage_uncalibrated = float(voltage_uncalibrated)
calibration_valid = (
math.isfinite(voltage_reference)
and 4.5 <= voltage_reference <= 5.5
and math.isfinite(voltage_uncalibrated)
and 0.0 < voltage_uncalibrated <= 12.0
)
except (TypeError, ValueError):
calibration_valid = False
else:
calibration_valid = False
if not calibration_valid:
voltage_factor = 1.0
voltage_calibrated_at = None
voltage_reference = None
voltage_uncalibrated = None
return {
"orientation": orientation,
"brightness": brightness,
"default_font": default_font,
"default_text_size": default_text_size,
"last_mode": last_mode,
"color_palette": color_palette,
"voltage_calibration_factor": voltage_factor,
"voltage_calibrated_at": voltage_calibrated_at,
"voltage_calibration_reference": voltage_reference,
"voltage_calibration_uncalibrated": voltage_uncalibrated,
"preview_refresh_interval_ms": preview_refresh_interval,
"low_voltage_protection_enabled": low_voltage_protection_enabled,
"matrix_refresh_rate_limit_hz": matrix_refresh_rate_limit,
"custom_test_color": custom_test_color,
"default_display": default_display,
"workspace_order": workspace_order,
"performance_mode_enabled": performance_mode_enabled,
"animation_preview_max_concurrent": animation_preview_max_concurrent,
}
def normalize_hex_color(value: Any) -> str:
if not isinstance(value, str):
raise ConfigError("palette color must be #RRGGBB")
match = HEX_COLOR_RE.fullmatch(value.strip())
if not match:
raise ConfigError("palette color must be #RRGGBB")
return f"#{match.group(1).upper()}"
def normalize_default_display(value: Any) -> dict[str, str]:
if not isinstance(value, dict) or set(value) != {"type", "id"}:
raise ConfigError("default_display must contain only type and id")
display_type = value.get("type")
if display_type not in {"template", "animation"}:
raise ConfigError("default_display type must be template or animation")
raw_id = value.get("id")
if not isinstance(raw_id, str):
raise ConfigError("default_display id must be a UUID string")
try:
display_id = str(UUID(raw_id))
except (ValueError, AttributeError) as exc:
raise ConfigError("default_display id must be a UUID string") from exc
return {"type": display_type, "id": display_id}
def normalize_workspace_order(value: Any) -> list[str]:
if not isinstance(value, list):
raise ConfigError("workspace_order must be an array")
if not 1 <= len(value) <= MAX_WORKSPACE_ORDER_ITEMS:
raise ConfigError(
f"workspace_order must contain 1..{MAX_WORKSPACE_ORDER_ITEMS} items"
)
normalized: list[str] = []
seen: set[str] = set()
for workspace_id in value:
if not isinstance(workspace_id, str) or not WORKSPACE_ID_RE.fullmatch(workspace_id):
raise ConfigError("workspace_order contains an invalid workspace id")
if workspace_id in seen:
raise ConfigError("workspace_order items must be unique")
seen.add(workspace_id)
normalized.append(workspace_id)
return normalized
@@ -0,0 +1,129 @@
from __future__ import annotations
import base64
import hashlib
import json
from typing import Any
from PIL import Image
from app.display.startup_indicator import (
CLOSED_EYE_SECONDS,
OPEN_EYE_SECONDS,
render_startup_smile,
)
from app.templates.store import render_scene_thumbnail, scene_digest
DEMO_STATIC_ID = "00000000-0000-4000-8000-000000000101"
DEMO_ANIMATION_ID = "00000000-0000-4000-8000-000000000102"
DEMO_OPEN_FRAME_ID = "00000000-0000-4000-8000-000000000201"
DEMO_CLOSED_FRAME_ID = "00000000-0000-4000-8000-000000000202"
DEMO_STATIC_NAME = "演示静态图"
DEMO_ANIMATION_NAME = "演示动图"
DEMO_RESTRICTED_MESSAGE = "演示案例不得编辑,请复制"
DEMO_TIMESTAMP = "1970-01-01T00:00:00+00:00"
def _scene(image: Image.Image) -> dict[str, Any]:
rgb = image.convert("RGB")
return {
"version": 1,
"width": 64,
"height": 64,
"pixelRgb": base64.b64encode(rgb.tobytes()).decode("ascii"),
"elements": [],
}
DEMO_OPEN_SCENE = _scene(render_startup_smile(eyes_closed=False))
DEMO_CLOSED_SCENE = _scene(render_startup_smile(eyes_closed=True))
def _revision(value: Any) -> str:
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
def demo_template(*, include_scene: bool = False) -> dict[str, Any]:
digest = scene_digest(DEMO_OPEN_SCENE)
item = {
"id": DEMO_STATIC_ID,
"name": DEMO_STATIC_NAME,
"created_at": DEMO_TIMESTAMP,
"updated_at": DEMO_TIMESTAMP,
"digest": digest,
"thumbnail_url": f"/api/templates/{DEMO_STATIC_ID}/thumbnail?v={digest}",
"size_bytes": 0,
"is_demo": True,
"demo_order": 1,
"read_only": True,
}
item["revision"] = _revision({**item, "scene": DEMO_OPEN_SCENE})
if include_scene:
item["scene"] = DEMO_OPEN_SCENE
return item
def _demo_frames(*, include_scenes: bool) -> list[dict[str, Any]]:
frames = []
definitions = (
(DEMO_OPEN_FRAME_ID, "睁眼", round(OPEN_EYE_SECONDS * 1000), DEMO_OPEN_SCENE),
(DEMO_CLOSED_FRAME_ID, "闭眼", round(CLOSED_EYE_SECONDS * 1000), DEMO_CLOSED_SCENE),
)
for position, (frame_id, name, duration_ms, scene) in enumerate(definitions, start=1):
digest = scene_digest(scene)
frame = {
"id": frame_id,
"name": name,
"position": position,
"duration_ms": duration_ms,
"created_at": DEMO_TIMESTAMP,
"updated_at": DEMO_TIMESTAMP,
"digest": digest,
"thumbnail_url": (
f"/api/animations/{DEMO_ANIMATION_ID}/frames/{frame_id}/thumbnail?v={digest}"
),
}
if include_scenes:
frame["scene"] = scene
frames.append(frame)
return frames
def demo_animation(*, include_scenes: bool = False) -> dict[str, Any]:
frames_with_scenes = _demo_frames(include_scenes=True)
revision = _revision({"id": DEMO_ANIMATION_ID, "name": DEMO_ANIMATION_NAME, "frames": frames_with_scenes})
frames = frames_with_scenes if include_scenes else _demo_frames(include_scenes=False)
return {
"id": DEMO_ANIMATION_ID,
"name": DEMO_ANIMATION_NAME,
"created_at": DEMO_TIMESTAMP,
"updated_at": DEMO_TIMESTAMP,
"revision": revision,
"frame_count": len(frames),
"total_duration_ms": sum(frame["duration_ms"] for frame in frames),
"size_bytes": 0,
"thumbnail_url": frames[0]["thumbnail_url"],
"frames": frames,
"is_demo": True,
"demo_order": 2,
"read_only": True,
}
def demo_animation_frame(frame_id: str) -> dict[str, Any]:
animation = demo_animation(include_scenes=True)
for frame in animation["frames"]:
if frame["id"] == frame_id:
return {
"animation_id": DEMO_ANIMATION_ID,
"animation_name": DEMO_ANIMATION_NAME,
"animation_revision": animation["revision"],
**frame,
}
raise KeyError(frame_id)
def demo_thumbnail(scene: dict[str, Any]) -> bytes:
return render_scene_thumbnail(scene)
@@ -0,0 +1,2 @@
"""Display abstraction and drivers."""
@@ -0,0 +1,43 @@
from __future__ import annotations
import re
from typing import Any
RGB = tuple[int, int, int]
HEX_RE = re.compile(r"^#([0-9a-fA-F]{6})$")
def parse_color(value: Any) -> RGB:
if isinstance(value, str):
match = HEX_RE.match(value.strip())
if not match:
raise ValueError("color must be #RRGGBB")
raw = match.group(1)
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
if isinstance(value, dict):
try:
return validate_rgb((value["r"], value["g"], value["b"]))
except KeyError as exc:
raise ValueError("color object must contain r, g, and b") from exc
if isinstance(value, (list, tuple)) and len(value) == 3:
return validate_rgb(value)
raise ValueError("color must be #RRGGBB or {r,g,b}")
def validate_rgb(value: Any) -> RGB:
try:
channels = tuple(int(channel) for channel in value)
except Exception as exc:
raise ValueError("RGB channels must be integers") from exc
if len(channels) != 3:
raise ValueError("RGB color must have three channels")
if any(channel < 0 or channel > 255 for channel in channels):
raise ValueError("RGB channels must be in 0..255")
return channels # type: ignore[return-value]
@@ -0,0 +1,133 @@
from __future__ import annotations
from PIL import Image, ImageDraw
from .text_renderer import TextOptions, render_text
DIAGNOSTIC_MODES = {
"corners_lines",
"row_bands",
"address_check",
"address_bit_0",
"address_bit_1",
"address_bit_2",
"address_bit_3",
"address_bit_4",
"text_ok123",
"clear",
}
def render_diagnostic(mode: str, size: tuple[int, int] = (64, 64)) -> Image.Image:
if mode not in DIAGNOSTIC_MODES:
allowed = ", ".join(sorted(DIAGNOSTIC_MODES))
raise ValueError(f"diagnostic mode must be one of: {allowed}")
if mode == "clear":
return Image.new("RGB", size, (0, 0, 0))
if mode == "corners_lines":
return _render_corners_lines(size)
if mode == "row_bands":
return _render_row_bands(size)
if mode == "address_check":
return _render_address_check(size)
if mode.startswith("address_bit_"):
return _render_address_bit(int(mode.rsplit("_", 1)[1]), size)
return render_text(TextOptions(text="OK123", size=12, x=4, y=24), size=size)
def _render_corners_lines(size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
for y, color in ((0, (80, 80, 80)), (31, (255, 180, 0)), (32, (0, 180, 255)), (63, (80, 80, 80))):
if y < height:
draw.line((0, y, width - 1, y), fill=color)
for x in (0, width - 1):
draw.line((x, 0, x, height - 1), fill=(80, 80, 80))
marker = 4
draw.rectangle((0, 0, marker, marker), fill=(255, 0, 0))
draw.rectangle((width - marker - 1, 0, width - 1, marker), fill=(0, 255, 0))
draw.rectangle((0, height - marker - 1, marker, height - 1), fill=(0, 0, 255))
draw.rectangle((width - marker - 1, height - marker - 1, width - 1, height - 1), fill=(255, 255, 255))
return image
def _render_row_bands(size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
colors = (
(255, 0, 0),
(255, 120, 0),
(255, 255, 0),
(0, 255, 0),
(0, 255, 255),
(0, 0, 255),
(180, 0, 255),
(255, 255, 255),
)
band_height = max(1, height // len(colors))
for index, color in enumerate(colors):
y0 = index * band_height
y1 = height - 1 if index == len(colors) - 1 else min(height - 1, y0 + band_height - 1)
draw.rectangle((0, y0, width - 1, y1), fill=color)
draw.line((0, y0, width - 1, y0), fill=(0, 0, 0))
return image
def _render_address_check(size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
bit_colors = (
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
)
segment_width = max(1, width // len(bit_colors))
for y in range(height):
for bit, color in enumerate(bit_colors):
if y & (1 << bit):
x0 = bit * segment_width
x1 = width - 1 if bit == len(bit_colors) - 1 else min(width - 1, x0 + segment_width - 2)
draw.line((x0, y, x1, y), fill=color)
diagonal_x = min(width - 1, y)
draw.point((diagonal_x, y), fill=(255, 255, 255))
draw.point((width - 1 - diagonal_x, y), fill=(80, 80, 80))
for y in (0, 31, 32, 63):
if y < height:
draw.line((0, y, width - 1, y), fill=(255, 255, 255))
return image
def _render_address_bit(bit: int, size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
colors = (
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
)
color = colors[bit]
for y in range(height):
if y & (1 << bit):
draw.line((0, y, width - 1, y), fill=color)
for y in (0, 31, 32, 63):
if y < height:
draw.line((0, y, width - 1, y), fill=(255, 255, 255))
return image
@@ -0,0 +1,24 @@
from __future__ import annotations
from PIL import Image, ImageDraw
BACKGROUND = (0, 0, 0)
RED = (255, 0, 0)
def render_low_voltage_indicator() -> Image.Image:
"""Render the fixed 64x64 critical-battery indicator."""
image = Image.new("RGB", (64, 64), BACKGROUND)
draw = ImageDraw.Draw(image)
# A thick, empty battery outline. The terminal deliberately touches the
# body so it remains legible after the existing orientation transform.
draw.rectangle((9, 17, 49, 21), fill=RED)
draw.rectangle((9, 42, 49, 46), fill=RED)
draw.rectangle((9, 17, 13, 46), fill=RED)
draw.rectangle((45, 17, 49, 46), fill=RED)
draw.rectangle((50, 27, 56, 36), fill=RED)
# Keep the interior otherwise empty, with one short low-charge bar.
draw.rectangle((17, 34, 22, 40), fill=RED)
return image
@@ -0,0 +1,297 @@
from __future__ import annotations
import ctypes
import logging
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from PIL import Image
logger = logging.getLogger(__name__)
REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
NATIVE_LIBRARY = Path(__file__).with_name("native") / "libh618_hub75.so"
HARDWARE_MAPPING = "walnutpi-pi-bank-pwm-oe-v2"
@dataclass(frozen=True)
class MatrixDriverOptions:
rows: int = 64
cols: int = 64
scan_rows: int = 32
row_address_bits: int = 5
pwm_bits: int = 7
hardware_mapping: str = HARDWARE_MAPPING
brightness: int = 40
limit_refresh_rate_hz: int = 100
pio_base: str = "0x0300B000"
pi_bank_offset: str = "0x120"
data_offset: str = "0x10"
cpu_affinity: int = 3
realtime_priority: int = 50
def to_dict(self) -> dict:
return asdict(self)
def get_default_driver_options(
brightness: int,
limit_refresh_rate_hz: int = 100,
) -> MatrixDriverOptions:
options = MatrixDriverOptions(
brightness=brightness,
limit_refresh_rate_hz=limit_refresh_rate_hz,
)
validate_driver_options(options)
return options
def get_matrix_driver_options(
brightness: int,
limit_refresh_rate_hz: int = 100,
) -> MatrixDriverOptions:
# The panel geometry, GPIO mapping and PWM depth are hardware contracts,
# not environment-variable tuning knobs. Keeping this function preserves
# the DisplayService boundary while making every deployment deterministic.
return get_default_driver_options(brightness, limit_refresh_rate_hz)
def validate_driver_options(options: MatrixDriverOptions) -> None:
if type(options.brightness) is not int or not 1 <= options.brightness <= 100:
raise ValueError("brightness must be in 1..100")
if options.limit_refresh_rate_hz not in REFRESH_RATE_LIMITS:
raise ValueError(
"limit_refresh_rate_hz must be one of 15, 20, 30, 45, 60, 80, or 100"
)
fixed = {
"rows": (options.rows, 64),
"cols": (options.cols, 64),
"scan_rows": (options.scan_rows, 32),
"row_address_bits": (options.row_address_bits, 5),
"pwm_bits": (options.pwm_bits, 7),
"hardware_mapping": (options.hardware_mapping, HARDWARE_MAPPING),
}
for name, (actual, expected) in fixed.items():
if actual != expected:
raise ValueError(f"{name} is fixed at {expected!r} for this hardware")
class _NativeConfig(ctypes.Structure):
_fields_ = [
("brightness", ctypes.c_int),
("refresh_rate_hz", ctypes.c_int),
("cpu_affinity", ctypes.c_int),
("realtime_priority", ctypes.c_int),
("use_dev_mem", ctypes.c_int),
("fake_pwm5_mode", ctypes.c_int),
("fake_pwm4_stuck", ctypes.c_int),
]
class _NativeStats(ctypes.Structure):
_fields_ = [
("actual_refresh_rate_hz", ctypes.c_double),
("panel_scan_rate_hz", ctypes.c_double),
("completed_frames", ctypes.c_uint64),
("completed_scans", ctypes.c_uint64),
("deadline_misses", ctypes.c_uint64),
("buffer_swaps", ctypes.c_uint64),
("submitted_frames", ctypes.c_uint64),
("oe_pulse_faults", ctypes.c_uint64),
("oe_forced_blanks", ctypes.c_uint64),
("max_programmed_oe_ns", ctypes.c_uint64),
("cpu_affinity", ctypes.c_int),
("cpu_affinity_active", ctypes.c_int),
("realtime_priority", ctypes.c_int),
("realtime_priority_active", ctypes.c_int),
("memory_locked", ctypes.c_int),
("running", ctypes.c_int),
("brightness", ctypes.c_int),
("refresh_rate_hz", ctypes.c_int),
("scans_per_frame", ctypes.c_int),
("governor", ctypes.c_char * 32),
("affinity_error", ctypes.c_char * 128),
("realtime_error", ctypes.c_char * 128),
("memory_lock_error", ctypes.c_char * 128),
("oe_timing_backend", ctypes.c_char * 32),
("oe_timing_error", ctypes.c_char * 256),
("safeoff_error", ctypes.c_char * 256),
("last_error", ctypes.c_char * 256),
]
def _decode_c_string(value: bytes | ctypes.Array) -> str | None:
raw = bytes(value).split(b"\0", 1)[0]
return raw.decode("utf-8", errors="replace") or None
def _load_native_library() -> ctypes.CDLL:
if not NATIVE_LIBRARY.is_file():
raise RuntimeError(
f"WalnutPi native HUB75 library is missing: {NATIVE_LIBRARY}; "
"run app/display/native/Makefile on the AArch64 target"
)
library = ctypes.CDLL(str(NATIVE_LIBRARY))
library.hub75_hardware_mapping.argtypes = []
library.hub75_hardware_mapping.restype = ctypes.c_char_p
native_mapping = library.hub75_hardware_mapping()
if native_mapping is None or native_mapping.decode("ascii", errors="replace") != HARDWARE_MAPPING:
raise RuntimeError(
"WalnutPi native HUB75 hardware mapping ABI mismatch; output remains blank"
)
library.hub75_create.argtypes = [
ctypes.POINTER(_NativeConfig),
ctypes.c_char_p,
ctypes.c_size_t,
]
library.hub75_create.restype = ctypes.c_void_p
library.hub75_start.argtypes = [ctypes.c_void_p]
library.hub75_start.restype = ctypes.c_int
library.hub75_submit_rgb.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t]
library.hub75_submit_rgb.restype = ctypes.c_int
library.hub75_set_brightness.argtypes = [ctypes.c_void_p, ctypes.c_int]
library.hub75_set_brightness.restype = ctypes.c_int
library.hub75_set_refresh_rate.argtypes = [ctypes.c_void_p, ctypes.c_int]
library.hub75_set_refresh_rate.restype = ctypes.c_int
library.hub75_clear.argtypes = [ctypes.c_void_p]
library.hub75_clear.restype = ctypes.c_int
library.hub75_get_stats.argtypes = [ctypes.c_void_p, ctypes.POINTER(_NativeStats)]
library.hub75_get_stats.restype = ctypes.c_int
library.hub75_destroy.argtypes = [ctypes.c_void_p]
library.hub75_destroy.restype = None
return library
class MatrixDisplayDriver:
name = "walnutpi-h618-hub75"
hardware_mapping = HARDWARE_MAPPING
def __init__(self, brightness: int, limit_refresh_rate_hz: int = 100) -> None:
self.driver_options = get_matrix_driver_options(
brightness,
limit_refresh_rate_hz,
)
self._library = _load_native_library()
self._context: int | None = None
error = ctypes.create_string_buffer(512)
config = _NativeConfig(
brightness=self.driver_options.brightness,
refresh_rate_hz=self.driver_options.limit_refresh_rate_hz,
cpu_affinity=self.driver_options.cpu_affinity,
realtime_priority=self.driver_options.realtime_priority,
use_dev_mem=1,
fake_pwm5_mode=0,
fake_pwm4_stuck=0,
)
context = self._library.hub75_create(
ctypes.byref(config),
error,
ctypes.sizeof(error),
)
if not context:
reason = _decode_c_string(error) or "unknown native initialization error"
raise RuntimeError(f"WalnutPi native HUB75 initialization failed: {reason}")
self._context = context
if self._library.hub75_start(context) != 0:
reason = self._stats_error() or "native refresh thread failed to start"
self.close()
raise RuntimeError(reason)
self.available = True
self.brightness = brightness
logger.info(
"Initialized %s with options: %s",
self.name,
self.driver_options.to_dict(),
)
def _require_context(self) -> int:
if self._context is None:
raise RuntimeError("WalnutPi native HUB75 driver is closed")
return self._context
def _stats(self) -> _NativeStats:
stats = _NativeStats()
if self._library.hub75_get_stats(self._require_context(), ctypes.byref(stats)) != 0:
raise RuntimeError("Failed to read WalnutPi native HUB75 status")
return stats
def _stats_error(self) -> str | None:
try:
return _decode_c_string(self._stats().last_error)
except Exception:
return None
def set_image(self, image: Image.Image, *, synchronized: bool = False) -> None:
if image.mode != "RGB" or image.size != (64, 64):
raise ValueError("native HUB75 frame must be a 64x64 RGB image")
frame = image.tobytes()
buffer = (ctypes.c_uint8 * len(frame)).from_buffer_copy(frame)
if self._library.hub75_submit_rgb(
self._require_context(),
ctypes.cast(buffer, ctypes.c_void_p),
len(frame),
) != 0:
raise RuntimeError(self._stats_error() or "Native RGB frame submission failed")
def clear(self) -> None:
if self._library.hub75_clear(self._require_context()) != 0:
raise RuntimeError(self._stats_error() or "Native display clear failed")
def set_brightness(self, value: int) -> None:
if type(value) is not int or not 1 <= value <= 100:
raise ValueError("brightness must be in 1..100")
if self._library.hub75_set_brightness(self._require_context(), value) != 0:
raise RuntimeError(self._stats_error() or "Runtime brightness update failed")
self.brightness = value
self.driver_options = replace(self.driver_options, brightness=value)
def set_refresh_rate_limit(self, value: int) -> None:
if value not in REFRESH_RATE_LIMITS:
raise ValueError("unsupported refresh-rate limit")
if self._library.hub75_set_refresh_rate(self._require_context(), value) != 0:
raise RuntimeError(self._stats_error() or "Runtime refresh-rate update failed")
self.driver_options = replace(self.driver_options, limit_refresh_rate_hz=value)
def close(self) -> None:
context = self._context
if context is None:
return
self._context = None
self._library.hub75_destroy(context)
def get_status(self) -> dict:
stats = self._stats()
return {
"driver": self.name,
"driver_available": True,
"driver_error": _decode_c_string(stats.last_error),
"hardware_mapping": self.hardware_mapping,
"driver_options": self.driver_options.to_dict(),
"driver_status": {
"actual_refresh_rate_hz": round(stats.actual_refresh_rate_hz, 3),
"panel_scan_rate_hz": round(stats.panel_scan_rate_hz, 3),
"completed_frames": stats.completed_frames,
"completed_scans": stats.completed_scans,
"scans_per_frame": stats.scans_per_frame,
"deadline_misses": stats.deadline_misses,
"buffer_swaps": stats.buffer_swaps,
"submitted_frames": stats.submitted_frames,
"oe_timing_backend": _decode_c_string(stats.oe_timing_backend),
"oe_pulse_faults": stats.oe_pulse_faults,
"oe_forced_blanks": stats.oe_forced_blanks,
"max_programmed_oe_ns": stats.max_programmed_oe_ns,
"oe_timing_error": _decode_c_string(stats.oe_timing_error),
"safeoff_error": _decode_c_string(stats.safeoff_error),
"cpu_affinity": stats.cpu_affinity,
"cpu_affinity_active": bool(stats.cpu_affinity_active),
"cpu_affinity_error": _decode_c_string(stats.affinity_error),
"realtime_priority": stats.realtime_priority,
"realtime_priority_active": bool(stats.realtime_priority_active),
"realtime_priority_error": _decode_c_string(stats.realtime_error),
"memory_locked": bool(stats.memory_locked),
"memory_lock_error": _decode_c_string(stats.memory_lock_error),
"running": bool(stats.running),
"governor": _decode_c_string(stats.governor),
"last_error": _decode_c_string(stats.last_error),
},
}
@@ -0,0 +1,97 @@
from __future__ import annotations
import logging
from dataclasses import replace
from pathlib import Path
from PIL import Image
from .matrix_driver import MatrixDriverOptions, get_default_driver_options
logger = logging.getLogger(__name__)
class MockDisplayDriver:
name = "mock"
hardware_mapping = "walnutpi-pi-bank-pwm-oe-v2"
def __init__(
self,
data_dir: Path,
brightness: int = 40,
limit_refresh_rate_hz: int = 100,
reason: str | None = None,
driver_options: MatrixDriverOptions | None = None,
) -> None:
self.data_dir = data_dir
self.reason = reason
self.available = reason is None
self.last_frame_path = self.data_dir / "last_frame.png"
self.brightness = brightness
self.driver_options = driver_options or get_default_driver_options(
brightness,
limit_refresh_rate_hz,
)
self.data_dir.mkdir(parents=True, exist_ok=True)
if reason:
logger.warning("Using mock display driver: %s", reason)
else:
logger.info("Using mock display driver")
def set_image(self, image: Image.Image, *, synchronized: bool = False) -> None:
image.save(self.last_frame_path)
logger.debug("Saved mock frame to %s", self.last_frame_path)
def clear(self) -> None:
if self.last_frame_path.exists():
Image.new("RGB", (64, 64), (0, 0, 0)).save(self.last_frame_path)
def set_brightness(self, value: int) -> None:
self.brightness = value
self.driver_options = replace(self.driver_options, brightness=value)
def set_refresh_rate_limit(self, value: int) -> None:
self.driver_options = replace(
self.driver_options,
limit_refresh_rate_hz=value,
)
def close(self) -> None:
self.clear()
def get_status(self) -> dict:
return {
"driver": self.name,
"driver_available": self.available,
"driver_error": self.reason,
"hardware_mapping": self.driver_options.hardware_mapping,
"driver_options": self.driver_options.to_dict(),
"driver_status": {
"actual_refresh_rate_hz": None,
"panel_scan_rate_hz": None,
"completed_frames": None,
"completed_scans": None,
"scans_per_frame": None,
"deadline_misses": None,
"buffer_swaps": None,
"submitted_frames": None,
"oe_timing_backend": None,
"oe_pulse_faults": None,
"oe_forced_blanks": None,
"max_programmed_oe_ns": None,
"oe_timing_error": None,
"safeoff_error": None,
"cpu_affinity": None,
"cpu_affinity_active": False,
"cpu_affinity_error": None,
"realtime_priority": None,
"realtime_priority_active": False,
"realtime_priority_error": None,
"memory_locked": False,
"memory_lock_error": None,
"running": False,
"governor": None,
"last_error": self.reason,
},
"last_frame": str(self.last_frame_path),
}
@@ -0,0 +1,31 @@
CC ?= gcc
CFLAGS ?= -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread
LDFLAGS ?= -pthread
LIBRARY := libh618_hub75.so
BENCHMARK := hub75_benchmark
UNIT_TEST := hub75_native_test
SAFEOFF := hub75_safeoff
.PHONY: all clean test
all: $(LIBRARY) $(BENCHMARK) $(UNIT_TEST) $(SAFEOFF)
$(LIBRARY): h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) -shared h618_hub75.c -o $@ $(LDFLAGS)
$(BENCHMARK): benchmark.c h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) benchmark.c h618_hub75.c -o $@ $(LDFLAGS)
$(UNIT_TEST): native_test.c h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) native_test.c h618_hub75.c -o $@ $(LDFLAGS)
$(SAFEOFF): safeoff.c h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) safeoff.c h618_hub75.c -o $@ $(LDFLAGS)
test: $(UNIT_TEST) $(SAFEOFF)
./$(UNIT_TEST)
./$(SAFEOFF) --fake
clean:
rm -f -- $(LIBRARY) $(BENCHMARK) $(UNIT_TEST) $(SAFEOFF)
@@ -0,0 +1,30 @@
# Third-party notices
The H618 PIO register layout and WalnutPi 40-pin physical mapping used by
`h618_hub75.c` were derived from:
- Project: `walnutpi/gpioc`
- Source: https://github.com/walnutpi/gpioc
- Commit: `220a46267469cc9cb1cbc35cd72f35eb1699a6e8`
- License: MIT
- Copyright: Copyright (c) 2023 酥饼
## MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,188 @@
#define _POSIX_C_SOURCE 200809L
#include "h618_hub75.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
static double monotonic_seconds(void) {
struct timespec value;
clock_gettime(CLOCK_MONOTONIC, &value);
return (double)value.tv_sec + (double)value.tv_nsec / 1000000000.0;
}
static int integer_argument(const char *value, const char *name) {
char *end = NULL;
long parsed = strtol(value, &end, 10);
if (value[0] == '\0' || end == NULL || *end != '\0' || parsed < 0 || parsed > 1000000) {
fprintf(stderr, "invalid %s: %s\n", name, value);
exit(2);
}
return (int)parsed;
}
static int hex_nibble(char value) {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
return -1;
}
static void color_argument(const char *value, uint8_t output[3]) {
if (strlen(value) != 6) {
fprintf(stderr, "invalid color: %s (expected RRGGBB)\n", value);
exit(2);
}
for (unsigned channel = 0; channel < 3; ++channel) {
int high = hex_nibble(value[channel * 2]);
int low = hex_nibble(value[channel * 2 + 1]);
if (high < 0 || low < 0) {
fprintf(stderr, "invalid color: %s (expected RRGGBB)\n", value);
exit(2);
}
output[channel] = (uint8_t)((high << 4) | low);
}
}
int main(int argc, char **argv) {
int duration = 10;
int refresh_rate = 100;
int brightness = 40;
int use_dev_mem = 0;
int use_static_color = 0;
uint8_t static_color[3] = {0};
for (int index = 1; index < argc; ++index) {
if (strcmp(argv[index], "--duration") == 0 && index + 1 < argc) {
duration = integer_argument(argv[++index], "duration");
} else if (strcmp(argv[index], "--refresh-rate") == 0 && index + 1 < argc) {
refresh_rate = integer_argument(argv[++index], "refresh rate");
} else if (strcmp(argv[index], "--brightness") == 0 && index + 1 < argc) {
brightness = integer_argument(argv[++index], "brightness");
} else if (strcmp(argv[index], "--dev-mem") == 0) {
use_dev_mem = 1;
} else if (strcmp(argv[index], "--color") == 0 && index + 1 < argc) {
color_argument(argv[++index], static_color);
use_static_color = 1;
} else {
fprintf(stderr, "usage: %s [--duration N] [--refresh-rate N] [--brightness N] [--color RRGGBB] [--dev-mem]\n", argv[0]);
return 2;
}
}
if (duration < 1) {
fputs("duration must be at least one second\n", stderr);
return 2;
}
hub75_config config = {
.brightness = brightness,
.refresh_rate_hz = refresh_rate,
.cpu_affinity = 3,
.realtime_priority = 50,
.use_dev_mem = use_dev_mem,
};
char error[512] = "";
hub75_context *context = hub75_create(&config, error, sizeof(error));
if (context == NULL) {
fprintf(stderr, "create failed: %s\n", error);
return 1;
}
if (hub75_start(context) != 0) {
fputs("start failed\n", stderr);
hub75_destroy(context);
return 1;
}
uint8_t frame[HUB75_FRAME_BYTES];
if (use_static_color) {
for (unsigned pixel = 0; pixel < HUB75_WIDTH * HUB75_HEIGHT; ++pixel) {
memcpy(frame + pixel * 3u, static_color, sizeof(static_color));
}
if (hub75_submit_rgb(context, frame, sizeof(frame)) != 0) {
fputs("initial static submit failed\n", stderr);
hub75_destroy(context);
return 1;
}
}
double started = monotonic_seconds();
unsigned submitted = 0;
while (monotonic_seconds() - started < duration) {
if (!use_static_color) {
for (unsigned pixel = 0; pixel < HUB75_WIDTH * HUB75_HEIGHT; ++pixel) {
frame[pixel * 3] = (uint8_t)(pixel + submitted);
frame[pixel * 3 + 1] = (uint8_t)(pixel * 3u + submitted);
frame[pixel * 3 + 2] = (uint8_t)(pixel * 7u + submitted);
}
if (hub75_submit_rgb(context, frame, sizeof(frame)) != 0) {
fputs("submit failed\n", stderr);
hub75_destroy(context);
return 1;
}
++submitted;
}
struct timespec delay = {.tv_sec = 0, .tv_nsec = 20000000};
nanosleep(&delay, NULL);
}
hub75_stats stats;
if (hub75_get_stats(context, &stats) != 0) {
fputs("stats failed\n", stderr);
hub75_destroy(context);
return 1;
}
hub75_destroy(context);
int expected_scans = hub75_scans_per_frame_for_brightness(brightness);
double miss_rate = stats.completed_frames == 0 ? 100.0 :
(double)stats.deadline_misses * 100.0 / (double)stats.completed_frames;
printf(
"{\"mode\":\"%s\",\"configured_hz\":%d,\"actual_hz\":%.3f,"
"\"panel_scan_hz\":%.3f,\"completed_frames\":%llu,"
"\"completed_scans\":%llu,\"scans_per_frame\":%d,"
"\"deadline_misses\":%llu,"
"\"deadline_miss_percent\":%.6f,\"buffer_swaps\":%llu,"
"\"submitted_frames\":%llu,\"oe_timing_backend\":\"%s\","
"\"oe_pulse_faults\":%llu,\"oe_forced_blanks\":%llu,"
"\"max_programmed_oe_ns\":%llu,\"oe_timing_error\":\"%s\","
"\"cpu_affinity_active\":%s,"
"\"realtime_priority_active\":%s,\"memory_locked\":%s,"
"\"governor\":\"%s\",\"affinity_error\":\"%s\","
"\"realtime_error\":\"%s\",\"memory_lock_error\":\"%s\","
"\"last_error\":\"%s\"}\n",
use_dev_mem ? "dev-mem" : "fake-registers",
refresh_rate,
stats.actual_refresh_rate_hz,
stats.panel_scan_rate_hz,
(unsigned long long)stats.completed_frames,
(unsigned long long)stats.completed_scans,
stats.scans_per_frame,
(unsigned long long)stats.deadline_misses,
miss_rate,
(unsigned long long)stats.buffer_swaps,
(unsigned long long)stats.submitted_frames,
stats.oe_timing_backend,
(unsigned long long)stats.oe_pulse_faults,
(unsigned long long)stats.oe_forced_blanks,
(unsigned long long)stats.max_programmed_oe_ns,
stats.oe_timing_error,
stats.cpu_affinity_active ? "true" : "false",
stats.realtime_priority_active ? "true" : "false",
stats.memory_locked ? "true" : "false",
stats.governor,
stats.affinity_error,
stats.realtime_error,
stats.memory_lock_error,
stats.last_error
);
if (
stats.completed_frames == 0 ||
stats.actual_refresh_rate_hz <= 0.0 ||
stats.panel_scan_rate_hz <= 0.0 ||
stats.scans_per_frame != expected_scans ||
stats.completed_scans != stats.completed_frames * (uint64_t)expected_scans ||
stats.oe_pulse_faults != 0 ||
strcmp(stats.oe_timing_backend, HUB75_OE_TIMING_BACKEND) != 0
) return 1;
return 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
/*
* Native HUB75 refresh driver for WalnutPi ZeroW / Allwinner H618.
*
* GPIO register layout and physical-pin mapping are derived from WalnutPi's
* MIT-licensed gpioc project, commit 220a46267469cc9cb1cbc35cd72f35eb1699a6e8:
* https://github.com/walnutpi/gpioc
*
* This project keeps the upstream MIT license in THIRD_PARTY_NOTICES.md.
*/
#ifndef WALNUTPI_H618_HUB75_H
#define WALNUTPI_H618_HUB75_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define HUB75_WIDTH 64
#define HUB75_HEIGHT 64
#define HUB75_SCAN_ROWS 32
#define HUB75_PWM_BITS 7
#define HUB75_INTERNAL_PWM_BITS 11
#define HUB75_PWM_PLANE_OFFSET (HUB75_INTERNAL_PWM_BITS - HUB75_PWM_BITS)
#define HUB75_SCANS_PER_FRAME 1
#define HUB75_FRAME_BYTES (HUB75_WIDTH * HUB75_HEIGHT * 3)
#define HUB75_BITPLANE_WORDS (HUB75_SCAN_ROWS * HUB75_PWM_BITS * HUB75_WIDTH)
#define HUB75_HARDWARE_MAPPING "walnutpi-pi-bank-pwm-oe-v2"
#define HUB75_OE_TIMING_BACKEND "h618-pwm4"
#define HUB75_OE_PWM_TICK_NS 125u
typedef struct hub75_context hub75_context;
typedef struct hub75_config {
int brightness;
int refresh_rate_hz;
int cpu_affinity;
int realtime_priority;
int use_dev_mem;
int fake_pwm5_mode;
int fake_pwm4_stuck;
} hub75_config;
typedef struct hub75_stats {
double actual_refresh_rate_hz;
double panel_scan_rate_hz;
uint64_t completed_frames;
uint64_t completed_scans;
uint64_t deadline_misses;
uint64_t buffer_swaps;
uint64_t submitted_frames;
uint64_t oe_pulse_faults;
uint64_t oe_forced_blanks;
uint64_t max_programmed_oe_ns;
int cpu_affinity;
int cpu_affinity_active;
int realtime_priority;
int realtime_priority_active;
int memory_locked;
int running;
int brightness;
int refresh_rate_hz;
int scans_per_frame;
char governor[32];
char affinity_error[128];
char realtime_error[128];
char memory_lock_error[128];
char oe_timing_backend[32];
char oe_timing_error[256];
char safeoff_error[256];
char last_error[256];
} hub75_stats;
hub75_context *hub75_create(const hub75_config *config, char *error, size_t error_size);
int hub75_start(hub75_context *context);
int hub75_submit_rgb(hub75_context *context, const uint8_t *rgb, size_t length);
int hub75_set_brightness(hub75_context *context, int brightness);
int hub75_set_refresh_rate(hub75_context *context, int refresh_rate_hz);
int hub75_clear(hub75_context *context);
int hub75_get_stats(hub75_context *context, hub75_stats *stats);
void hub75_destroy(hub75_context *context);
const char *hub75_hardware_mapping(void);
int hub75_force_safeoff(int use_dev_mem, char *error, size_t error_size);
/* Pure conversion hook used by tests; output words contain PI0..PI5 colors. */
int hub75_build_bitplanes(
const uint8_t *rgb,
size_t length,
uint16_t *output,
size_t output_words
);
/* Raspberry-Pi-compatible CIE1931 brightness mapping into 11-bit color. */
int hub75_build_bitplanes_for_brightness(
const uint8_t *rgb,
size_t length,
int brightness,
uint16_t *output,
size_t output_words
);
/* Pure 8-bit RGB channel to 11-bit CIE1931 mapping hook. */
uint16_t hub75_map_channel_for_brightness(uint8_t value, int brightness);
/* Pure scan-count hook used by native tests and the benchmark. */
int hub75_scans_per_frame_for_brightness(int brightness);
/* Fixed binary OE weights matching internal bitplanes 4..10. */
uint32_t hub75_oe_ticks_for_plane(
uint64_t scan_slot_ns,
int brightness,
unsigned plane,
uint64_t *remainder
);
typedef struct hub75_fake_registers {
uint32_t mux0;
uint32_t mux1;
uint32_t data;
uint32_t pwm_pccr45;
uint32_t pwm_per;
uint32_t pwm_pcr4;
uint32_t pwm_ppr4;
uint32_t pwm_pcr5;
uint32_t pwm_ppr5;
} hub75_fake_registers;
int hub75_get_fake_registers(
hub75_context *context,
hub75_fake_registers *registers
);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,195 @@
#define _POSIX_C_SOURCE 200809L
#include "h618_hub75.h"
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
enum {
PI_R1 = 1u << 0,
PI_G1 = 1u << 1,
PI_B1 = 1u << 2,
PI_R2 = 1u << 3,
PI_G2 = 1u << 4,
PI_B2 = 1u << 5,
};
static size_t word_index(unsigned row, unsigned plane, unsigned column) {
return ((size_t)row * HUB75_PWM_BITS + plane) * HUB75_WIDTH + column;
}
int main(void) {
assert(strcmp(hub75_hardware_mapping(), HUB75_HARDWARE_MAPPING) == 0);
assert(HUB75_INTERNAL_PWM_BITS == 11);
assert(HUB75_PWM_PLANE_OFFSET == 4);
assert(HUB75_SCANS_PER_FRAME == 1);
assert(hub75_scans_per_frame_for_brightness(1) == 1);
assert(hub75_scans_per_frame_for_brightness(20) == 1);
assert(hub75_scans_per_frame_for_brightness(100) == 1);
assert(hub75_scans_per_frame_for_brightness(0) == -1);
assert(hub75_scans_per_frame_for_brightness(101) == -1);
uint16_t previous_mapped = 0;
for (int brightness = 1; brightness <= 100; ++brightness) {
uint16_t mapped = hub75_map_channel_for_brightness(255, brightness);
assert(mapped >= previous_mapped);
previous_mapped = mapped;
for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) {
uint64_t remainder = 123u;
assert(hub75_oe_ticks_for_plane(
10250000, brightness, plane, &remainder
) == (1u << (plane + HUB75_PWM_PLANE_OFFSET)));
assert(remainder == 0);
}
}
assert(previous_mapped == 2047u);
assert(hub75_map_channel_for_brightness(255, 0) == UINT16_MAX);
uint64_t invalid_remainder = 0;
assert(hub75_oe_ticks_for_plane(5000000, 0, 0, &invalid_remainder) == UINT32_MAX);
assert(hub75_oe_ticks_for_plane(5000000, 1, HUB75_PWM_BITS, &invalid_remainder) == UINT32_MAX);
uint64_t full_scale_remainder = 0;
assert(hub75_oe_ticks_for_plane(
10250000, 100, HUB75_PWM_BITS - 1, &full_scale_remainder
) == 1024u);
uint8_t *frame = calloc(1, HUB75_FRAME_BYTES);
uint16_t *planes = calloc(HUB75_BITPLANE_WORDS, sizeof(*planes));
assert(frame != NULL);
assert(planes != NULL);
/* Compare extracted planes with the Raspberry-compatible 11-bit map. */
frame[0] = 0xff;
frame[1] = 0x80;
frame[2] = 0x40;
size_t bottom = (size_t)HUB75_SCAN_ROWS * HUB75_WIDTH * 3u;
frame[bottom] = 0x20;
frame[bottom + 1] = 0x10;
frame[bottom + 2] = 0x08;
assert(hub75_build_bitplanes(
frame,
HUB75_FRAME_BYTES,
planes,
HUB75_BITPLANE_WORDS
) == 0);
const uint8_t values[] = {0xff, 0x80, 0x40, 0x20, 0x10, 0x08};
const uint16_t bits[] = {PI_R1, PI_G1, PI_B1, PI_R2, PI_G2, PI_B2};
for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) {
uint16_t expected = 0;
for (unsigned component = 0; component < 6; ++component) {
uint16_t mapped = hub75_map_channel_for_brightness(values[component], 100);
if ((mapped >> (plane + HUB75_PWM_PLANE_OFFSET)) & 1u) {
expected |= bits[component];
}
}
assert(planes[word_index(0, plane, 0)] == expected);
}
memset(frame, 0xff, HUB75_FRAME_BYTES);
assert(hub75_build_bitplanes(
frame,
HUB75_FRAME_BYTES,
planes,
HUB75_BITPLANE_WORDS
) == 0);
for (size_t index = 0; index < HUB75_BITPLANE_WORDS; ++index) {
assert(planes[index] == (PI_R1 | PI_G1 | PI_B1 | PI_R2 | PI_G2 | PI_B2));
}
/* Brightness changes bitplane data rather than OE pulse duration. */
memset(frame, 0, HUB75_FRAME_BYTES);
frame[0] = 0xff;
frame[1] = 0x4f;
frame[2] = 0xa3;
uint16_t low_planes[HUB75_BITPLANE_WORDS];
assert(hub75_build_bitplanes_for_brightness(
frame, HUB75_FRAME_BYTES, 100, planes, HUB75_BITPLANE_WORDS
) == 0);
assert(hub75_build_bitplanes_for_brightness(
frame, HUB75_FRAME_BYTES, 20, low_planes, HUB75_BITPLANE_WORDS
) == 0);
assert(memcmp(low_planes, planes, sizeof(low_planes)) != 0);
assert(hub75_build_bitplanes(frame, 1, planes, HUB75_BITPLANE_WORDS) == -1);
assert(hub75_build_bitplanes(frame, HUB75_FRAME_BYTES, planes, 1) == -1);
hub75_config config = {
.brightness = 40,
.refresh_rate_hz = 100,
.cpu_affinity = 0,
.realtime_priority = 1,
.use_dev_mem = 0,
};
char error[512] = {0};
hub75_context *context = hub75_create(&config, error, sizeof(error));
assert(context != NULL);
hub75_fake_registers registers;
assert(hub75_get_fake_registers(context, &registers) == 0);
assert(((registers.mux1 >> ((14u - 8u) * 4u)) & 0xfu) == 0x5u);
assert(((registers.mux1 >> ((15u - 8u) * 4u)) & 0xfu) == 0x1u);
assert((registers.pwm_pccr45 & (1u << 4)) != 0);
assert((registers.pwm_pccr45 & ((0xfu) | (1u << 5) | (0x3u << 7))) == 0);
assert((registers.pwm_per & (1u << 4)) != 0);
assert((registers.pwm_pcr4 & 0x1ffu) == 2u);
assert((registers.pwm_pcr4 & (1u << 9)) != 0);
assert(registers.pwm_ppr4 == (65535u << 16));
hub75_stats stats;
assert(hub75_get_stats(context, &stats) == 0);
assert(strcmp(stats.oe_timing_backend, HUB75_OE_TIMING_BACKEND) == 0);
assert(stats.oe_pulse_faults == 0);
memset(frame, 0xff, HUB75_FRAME_BYTES);
assert(hub75_submit_rgb(context, frame, HUB75_FRAME_BYTES) == 0);
assert(hub75_set_brightness(context, 100) == 0);
assert(hub75_start(context) == 0);
struct timespec pulse_test_delay = {.tv_sec = 0, .tv_nsec = 100000000};
nanosleep(&pulse_test_delay, NULL);
assert(hub75_get_stats(context, &stats) == 0);
assert(stats.max_programmed_oe_ns == 128000u);
assert(stats.oe_pulse_faults == 0);
assert(hub75_get_fake_registers(context, &registers) == 0);
assert((registers.pwm_pcr4 & (1u << 9)) != 0);
assert((registers.pwm_pcr4 & (1u << 10)) == 0);
assert((registers.pwm_per & (1u << 4)) != 0);
assert((registers.pwm_ppr4 & 0xffffu) != 0);
assert((registers.pwm_ppr4 >> 16) + 1u ==
(registers.pwm_ppr4 & 0xffffu));
hub75_destroy(context);
config.fake_pwm4_stuck = 1;
context = hub75_create(&config, error, sizeof(error));
assert(context != NULL);
assert(hub75_start(context) == 0);
struct timespec stuck_test_delay = {.tv_sec = 0, .tv_nsec = 100000000};
nanosleep(&stuck_test_delay, NULL);
assert(hub75_get_stats(context, &stats) == 0);
assert(stats.oe_pulse_faults == 1);
assert(stats.oe_forced_blanks == 1);
assert(strstr(stats.oe_timing_error, "start bit did not clear") != NULL);
assert(hub75_get_fake_registers(context, &registers) == 0);
assert((registers.pwm_per & (1u << 4)) == 0);
assert(((registers.mux1 >> ((14u - 8u) * 4u)) & 0xfu) == 0x1u);
hub75_destroy(context);
config.fake_pwm4_stuck = 0;
config.fake_pwm5_mode = 1;
context = hub75_create(&config, error, sizeof(error));
assert(context != NULL);
assert(hub75_get_fake_registers(context, &registers) == 0);
assert((registers.pwm_per & (1u << 5)) != 0);
assert((registers.pwm_pccr45 & (1u << 6)) != 0);
assert(registers.pwm_pcr5 == 0x100u);
assert(registers.pwm_ppr5 == 0x000b0006u);
hub75_destroy(context);
config.fake_pwm5_mode = 2;
context = hub75_create(&config, error, sizeof(error));
assert(context == NULL);
assert(strstr(error, "incompatible shared PWM4/5 clock") != NULL);
assert(hub75_force_safeoff(0, error, sizeof(error)) == 0);
free(planes);
free(frame);
puts("native_bitplane_tests=PASS");
return 0;
}
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Read-only H618 PI-bank snapshot for no-load safety verification."""
from __future__ import annotations
import json
import mmap
import os
import struct
from pathlib import Path
PIO_BASE = 0x0300B000
PIO_LENGTH = 0x400
PI_OFFSET = 0x120
DATA_OFFSET = 0x10
OWNED_BITS = tuple([*range(0, 7), *range(9, 16)])
RESERVED_BITS = (7, 8, 16)
def read_u32(mapped: mmap.mmap, offset: int) -> int:
return struct.unpack_from("<I", mapped, offset)[0]
def mux_mode(mapped: mmap.mmap, pin: int) -> int:
register = PI_OFFSET + (pin // 8) * 4
return (read_u32(mapped, register) >> ((pin % 8) * 4)) & 0xF
def device_tree_contract() -> dict:
node = Path("/proc/device-tree/soc/pinctrl@300b000")
compatible = (node / "compatible").read_bytes().rstrip(b"\0").decode("ascii")
values = struct.unpack(">II", (node / "reg").read_bytes())
if compatible != "allwinner,sun50i-h616-pinctrl" or values != (PIO_BASE, PIO_LENGTH):
raise RuntimeError(f"unexpected pinctrl contract: {compatible=} {values=}")
return {"compatible": compatible, "base": f"0x{values[0]:08X}", "length": values[1]}
def main() -> None:
contract = device_tree_contract()
descriptor = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
try:
with mmap.mmap(descriptor, PIO_LENGTH, access=mmap.ACCESS_READ, offset=PIO_BASE) as mapped:
data = read_u32(mapped, PI_OFFSET + DATA_OFFSET)
modes = {f"PI{pin}": mux_mode(mapped, pin) for pin in range(17)}
finally:
os.close(descriptor)
owned_mask = sum(1 << bit for bit in OWNED_BITS)
reserved_mask = sum(1 << bit for bit in RESERVED_BITS)
print(json.dumps({
"device_tree": contract,
"data_register": f"0x{data:08X}",
"owned_data_bits": f"0x{data & owned_mask:08X}",
"reserved_data_bits": f"0x{data & reserved_mask:08X}",
"modes": modes,
}, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,22 @@
#include "h618_hub75.h"
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
int use_dev_mem = 1;
if (argc == 2 && strcmp(argv[1], "--fake") == 0) {
use_dev_mem = 0;
} else if (argc != 1) {
fprintf(stderr, "usage: %s [--fake]\n", argv[0]);
return 2;
}
char error[512] = {0};
if (hub75_force_safeoff(use_dev_mem, error, sizeof(error)) != 0) {
fprintf(stderr, "HUB75 safeoff failed: %s\n", error[0] ? error : "unknown error");
return 1;
}
puts("HUB75 safeoff complete");
return 0;
}
@@ -0,0 +1,113 @@
"""Pure-Python reference model for the fixed WalnutPi HUB75 mapping.
The production refresh loop is C. This module is intentionally small and
deterministic so mapping and bitplane contracts can be tested on any host.
"""
from __future__ import annotations
from dataclasses import dataclass
WIDTH = 64
HEIGHT = 64
SCAN_ROWS = 32
PWM_BITS = 7
PIO_BASE = 0x0300B000
PI_BANK_OFFSET = 0x120
PI_DATA_OFFSET = 0x10
PWM_BASE = 0x0300A000
HARDWARE_MAPPING = "walnutpi-pi-bank-pwm-oe-v2"
OE_PWM_TICK_NS = 125
INTERNAL_PWM_BITS = 11
PWM_PLANE_OFFSET = INTERNAL_PWM_BITS - PWM_BITS
@dataclass(frozen=True)
class Hub75Pin:
signal: str
physical: int
gpio: str
bit: int
HUB75_PINS = (
Hub75Pin("R1", 29, "PI0", 0),
Hub75Pin("G1", 31, "PI1", 1),
Hub75Pin("B1", 33, "PI2", 2),
Hub75Pin("R2", 35, "PI3", 3),
Hub75Pin("G2", 37, "PI4", 4),
Hub75Pin("B2", 8, "PI5", 5),
Hub75Pin("A", 10, "PI6", 6),
Hub75Pin("B", 28, "PI9", 9),
Hub75Pin("C", 27, "PI10", 10),
Hub75Pin("D", 15, "PI11", 11),
Hub75Pin("E", 16, "PI12", 12),
Hub75Pin("CLK", 38, "PI13", 13),
Hub75Pin("OE", 40, "PI14/PWM4", 14),
Hub75Pin("LAT", 36, "PI15", 15),
)
RESERVED_PI_BITS = frozenset({7, 8, 16})
REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
def address_mask(row: int) -> int:
if type(row) is not int or not 0 <= row < SCAN_ROWS:
raise ValueError("row must be in 0..31")
mapping = (6, 9, 10, 11, 12)
return sum(1 << gpio_bit for row_bit, gpio_bit in enumerate(mapping) if row & (1 << row_bit))
def map_channel(value: int, brightness: int) -> int:
if type(value) is not int or not 0 <= value <= 255:
raise ValueError("value must be in 0..255")
if type(brightness) is not int or not 1 <= brightness <= 100:
raise ValueError("brightness must be in 1..100")
output_factor = (1 << INTERNAL_PWM_BITS) - 1
v = value * brightness / 255.0
luminance = v / 902.3 if v <= 8 else ((v + 16) / 116.0) ** 3
return int(output_factor * luminance + 0.5)
def build_bitplanes(frame: bytes, brightness: int = 100) -> tuple[int, ...]:
if len(frame) != WIDTH * HEIGHT * 3:
raise ValueError("frame must contain 64x64 RGB888 bytes")
mapped = tuple(map_channel(value, brightness) for value in range(256))
output = [0] * (SCAN_ROWS * PWM_BITS * WIDTH)
for row in range(SCAN_ROWS):
for plane in range(PWM_BITS):
for column in range(WIDTH):
top = (row * WIDTH + column) * 3
bottom = ((row + SCAN_ROWS) * WIDTH + column) * 3
word = 0
for component in range(3):
if mapped[frame[top + component]] & (1 << (plane + PWM_PLANE_OFFSET)):
word |= 1 << component
if mapped[frame[bottom + component]] & (1 << (plane + PWM_PLANE_OFFSET)):
word |= 1 << (component + 3)
index = ((row * PWM_BITS + plane) * WIDTH) + column
output[index] = word
return tuple(output)
def bitplane_index(row: int, plane: int, column: int) -> int:
if not 0 <= row < SCAN_ROWS or not 0 <= plane < PWM_BITS or not 0 <= column < WIDTH:
raise ValueError("bitplane coordinate out of range")
return ((row * PWM_BITS + plane) * WIDTH) + column
def oe_ticks_for_plane(
scan_slot_ns: int,
brightness: int,
plane: int,
remainder: int,
) -> tuple[int, int]:
if type(scan_slot_ns) is not int or scan_slot_ns <= 0:
raise ValueError("scan_slot_ns must be positive")
if type(brightness) is not int or not 1 <= brightness <= 100:
raise ValueError("brightness must be in 1..100")
if type(plane) is not int or not 0 <= plane < PWM_BITS:
raise ValueError("plane must be in 0..6")
if type(remainder) is not int or remainder < 0:
raise ValueError("remainder must be non-negative")
return 1 << (plane + PWM_PLANE_OFFSET), 0
@@ -0,0 +1,98 @@
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
OTA_INDICATOR_BRIGHTNESS = 40
OTA_CANVAS_SIZE = 64
OTA_CONTENT_SIZE = 58
OTA_CONTENT_OFFSET = (OTA_CANVAS_SIZE - OTA_CONTENT_SIZE) // 2
def _draw_binary_text(
image: Image.Image,
position: tuple[int, int],
text: str,
*,
font: ImageFont.ImageFont,
fill: tuple[int, int, int],
) -> tuple[int, int, int, int]:
probe = ImageDraw.Draw(image)
bbox = probe.textbbox((0, 0), text, font=font)
width = max(1, bbox[2] - bbox[0])
height = max(1, bbox[3] - bbox[1])
mask = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, font=font, fill=255)
# Small system labels must be LED pixels, not dim antialiasing samples.
mask = mask.point(lambda value: 255 if value >= 64 else 0)
left = position[0] + bbox[0]
top = position[1] + bbox[1]
image.paste(fill, (left, top), mask)
return left, top, left + width, top + height
def _resize_preserving_lit_pixels(image: Image.Image, size: tuple[int, int]) -> Image.Image:
"""Shrink a pixel composition without dropping one-pixel strokes."""
source = image.convert("RGB")
target = Image.new("RGB", size, (0, 0, 0))
source_pixels = source.load()
target_pixels = target.load()
source_width, source_height = source.size
target_width, target_height = size
# Forward-map every lit source pixel once. Pillow's inverse nearest-neighbor
# shrink skips some source rows/columns, which is exactly what breaks thin
# strokes such as the top of "T".
for source_y in range(source_height):
target_y = min(target_height - 1, int((source_y + 0.5) * target_height / source_height))
for source_x in range(source_width):
pixel = source_pixels[source_x, source_y]
if pixel == (0, 0, 0):
continue
target_x = min(target_width - 1, int((source_x + 0.5) * target_width / source_width))
if max(pixel) > max(target_pixels[target_x, target_y]):
target_pixels[target_x, target_y] = pixel
return target
def render_ota_indicator(percent: int = 0, stage: str = "准备更新") -> Image.Image:
value = max(0, min(100, int(percent)))
content = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
draw = ImageDraw.Draw(content)
cyan = (34, 220, 220)
dim = (24, 70, 76)
white = (245, 250, 250)
draw.rounded_rectangle((15, 4, 48, 32), radius=5, outline=cyan, width=2)
draw.polygon(((32, 8), (23, 18), (28, 18), (28, 27), (36, 27), (36, 18), (41, 18)), fill=cyan)
font = ImageFont.load_default()
# One source-pixel space prevents the coverage shrink from joining adjacent
# glyph edges into the misleading "OIA" seen on the device.
ota_label = "O T A"
label_box = draw.textbbox((0, 0), ota_label, font=font)
label_width = label_box[2] - label_box[0]
_draw_binary_text(
content,
((OTA_CANVAS_SIZE - label_width) // 2, 35),
ota_label,
font=font,
fill=white,
)
draw.rectangle((6, 48, 57, 55), outline=dim, width=1)
fill_width = round(49 * value / 100)
if fill_width:
draw.rectangle((7, 49, 6 + fill_width, 54), fill=cyan)
label = f"{value}%"
percent_box = draw.textbbox((0, 0), label, font=font)
percent_width = percent_box[2] - percent_box[0]
left = (OTA_CANVAS_SIZE - percent_width) // 2
# Position from the measured bottom so the source glyph is never clipped.
top = OTA_CANVAS_SIZE - percent_box[3]
_draw_binary_text(content, (left, top), label, font=font, fill=white)
# The stage changes a tiny activity marker even when the percentage is fixed.
marker = sum(stage.encode("utf-8")) % 4
draw.rectangle((52 + marker * 2, 37, 53 + marker * 2, 39), fill=cyan)
scaled = _resize_preserving_lit_pixels(content, (OTA_CONTENT_SIZE, OTA_CONTENT_SIZE))
image = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
image.paste(scaled, (OTA_CONTENT_OFFSET, OTA_CONTENT_OFFSET))
return image
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from uuid import uuid4
from PIL import Image, ImageDraw
from app.persistence import atomic_write_bytes
logger = logging.getLogger(__name__)
STARTUP_BRIGHTNESS = 50
OPEN_EYE_SECONDS = 1.3
CLOSED_EYE_SECONDS = 0.2
BACKGROUND = (255, 255, 255)
PINK = (255, 79, 163)
_PROCESS_BOOT_ID = f"process-{uuid4()}"
def render_startup_smile(*, eyes_closed: bool = False) -> Image.Image:
image = Image.new("RGB", (64, 64), BACKGROUND)
draw = ImageDraw.Draw(image)
if eyes_closed:
draw.rectangle((14, 23, 25, 26), fill=PINK)
draw.rectangle((38, 23, 49, 26), fill=PINK)
else:
draw.rectangle((16, 18, 23, 29), fill=PINK)
draw.rectangle((40, 18, 47, 29), fill=PINK)
for box in (
(14, 36, 17, 39),
(18, 40, 21, 43),
(22, 44, 25, 47),
(26, 48, 37, 51),
(38, 44, 41, 47),
(42, 40, 45, 43),
(46, 36, 49, 39),
):
draw.rectangle(box, fill=PINK)
return image
def read_boot_id() -> str:
try:
value = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
if value:
return value
except (OSError, UnicodeError):
logger.info("Linux boot ID is unavailable; using the current process session")
return _PROCESS_BOOT_ID
class StartupSessionStore:
def __init__(self, data_dir: Path, boot_id: str | None = None) -> None:
self.path = data_dir / "startup_indicator.json"
self.boot_id = boot_id or read_boot_id()
def should_show(self) -> bool:
if not self.path.exists():
return True
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("startup indicator state must be an object")
return raw.get("dismissed_boot_id") != self.boot_id
except (OSError, ValueError, TypeError):
logger.exception("Startup indicator state is invalid; showing the indicator")
return True
def mark_dismissed(self) -> None:
atomic_write_bytes(
self.path,
(
json.dumps(
{"dismissed_boot_id": self.boot_id},
ensure_ascii=False,
indent=2,
)
+ "\n"
).encode("utf-8"),
)
@@ -0,0 +1,322 @@
from __future__ import annotations
import os
import subprocess
import unicodedata
from functools import lru_cache
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont, features
from .colors import RGB
@dataclass(frozen=True)
class TextOptions:
text: str
font: str = "default"
size: int = 12
x: int = 0
y: int = 0
align: str = "left"
color: RGB = (255, 255, 255)
background: RGB = (0, 0, 0)
DEFAULT_FONT_CANDIDATES = (
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/msjh.ttc",
"C:/Windows/Fonts/YuGothR.ttc",
"C:/Windows/Fonts/malgun.ttf",
"C:/Windows/Fonts/LeelUIsl.ttf",
)
FONTCONFIG_LANGUAGES = (
"en", "ru", "el", "zh-cn", "ja", "ko", "ar", "he", "hi",
"bn", "ta", "te", "th",
)
PREFERRED_FONT_CANDIDATES: dict[str, tuple[tuple[str, int], ...]] = {
"en": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"ru": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"el": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"zh-cn": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), ("C:/Windows/Fonts/msyh.ttc", 0)),
"ja": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0), ("C:/Windows/Fonts/YuGothR.ttc", 0)),
"ko": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 1), ("C:/Windows/Fonts/malgun.ttf", 0)),
"ar": (("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"he": (("/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"hi": (("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", 0),),
"bn": (("/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf", 0),),
"ta": (("/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf", 0),),
"te": (("/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf", 0),),
"th": (("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", 0), ("C:/Windows/Fonts/LeelUIsl.ttf", 0)),
}
@dataclass(frozen=True)
class FontFace:
path: str
index: int = 0
FontResolver = Callable[[str], tuple[FontFace, ...]]
def render_text_layer(
options: TextOptions,
size: tuple[int, int] = (64, 64),
*,
font_resolver: FontResolver | None = None,
) -> Image.Image:
if options.size < 1 or options.size > 64:
raise ValueError("text size must be in 1..64")
if options.align not in {"left", "center", "right"}:
raise ValueError("align must be left, center, or right")
image = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
font = load_font(options.font, options.size, options.text, font_resolver=font_resolver)
x = int(options.x)
y = int(options.y)
bbox = draw.textbbox((0, 0), options.text, font=font)
text_width = bbox[2] - bbox[0]
if options.align == "center":
x = x - text_width // 2
elif options.align == "right":
x = x - text_width
draw.text((x, y), options.text, fill=(*options.color, 255), font=font)
return image
def render_text(
options: TextOptions,
size: tuple[int, int] = (64, 64),
*,
font_resolver: FontResolver | None = None,
) -> Image.Image:
background = Image.new("RGBA", size, (*options.background, 255))
layer = render_text_layer(options, size=size, font_resolver=font_resolver)
return Image.alpha_composite(background, layer).convert("RGB")
def _required_codepoints(text: str) -> frozenset[int]:
return frozenset(
ord(character)
for character in text
if not character.isspace()
and unicodedata.category(character) not in {"Cc", "Cf"}
and not 0xFE00 <= ord(character) <= 0xFE0F
and not 0xE0100 <= ord(character) <= 0xE01EF
)
def _contains_default_emoji(codepoints: frozenset[int]) -> bool:
# Default automatic text intentionally stays deterministic across Debian
# images. Some DejaVu releases contain a few monochrome emoji glyphs while
# others do not, so accepting them based on the host cmap makes the API
# change after an OS/font update. Explicitly imported fonts remain eligible.
return any(0x1F000 <= codepoint <= 0x1FAFF for codepoint in codepoints)
@lru_cache(maxsize=256)
def _font_coverage(face: FontFace) -> frozenset[int]:
try:
# Fontconfig encodes variable-font named instances above the base
# collection index. FontTools needs the base face while Pillow uses the
# complete FreeType face index.
with TTFont(face.path, fontNumber=face.index & 0xFFFF, lazy=True) as font:
cmap = font.getBestCmap() or {}
return frozenset(cmap)
except Exception:
return frozenset()
@lru_cache(maxsize=32)
def _fontconfig_face(language: str) -> FontFace | None:
try:
result = subprocess.run(
["fc-match", "--format=%{file}\n%{index}\n", f":lang={language}"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=2,
)
lines = result.stdout.splitlines()
if not lines:
return None
path = lines[0].strip()
index = int(lines[1].strip() or "0") if len(lines) > 1 else 0
if path and Path(path).is_file():
return FontFace(path, index)
except (FileNotFoundError, OSError, subprocess.SubprocessError, ValueError):
pass
return None
def _fontconfig_faces() -> list[FontFace]:
return [face for language in FONTCONFIG_LANGUAGES if (face := _fontconfig_face(language))]
def _preferred_font_face(language: str) -> FontFace | None:
for candidate, index in PREFERRED_FONT_CANDIDATES.get(language, ()):
path = Path(candidate)
if path.is_file():
return FontFace(str(path.resolve()), index)
face = _fontconfig_face(language)
if face and Path(face.path).name == "NotoSansCJK-Regular.ttc":
indexes = {"ja": 0, "ko": 1, "zh-cn": 2}
return FontFace(face.path, indexes.get(language, face.index))
return face
def _preferred_font_language(text: str) -> str | None:
codepoints = _required_codepoints(text)
script_ranges = (
("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF))),
("ja", ((0x3040, 0x30FF), (0x31F0, 0x31FF))),
("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF))),
("he", ((0x0590, 0x05FF),)),
("hi", ((0x0900, 0x097F),)),
("bn", ((0x0980, 0x09FF),)),
("ta", ((0x0B80, 0x0BFF),)),
("te", ((0x0C00, 0x0C7F),)),
("th", ((0x0E00, 0x0E7F),)),
("el", ((0x0370, 0x03FF),)),
("ru", ((0x0400, 0x052F),)),
("zh-cn", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF))),
)
for language, ranges in script_ranges:
if any(start <= codepoint <= end for codepoint in codepoints for start, end in ranges):
return language
return "en" if codepoints else None
def _configured_faces() -> list[FontFace]:
raw = os.environ.get("MATRIX_FONT_PATHS", "")
return [
FontFace(str(Path(value).expanduser()))
for value in raw.split(os.pathsep)
if value.strip() and Path(value.strip()).expanduser().is_file()
]
@lru_cache(maxsize=1)
def fallback_font_faces() -> tuple[FontFace, ...]:
faces = _configured_faces() + _fontconfig_faces()
faces.extend(
FontFace(str(path))
for candidate in DEFAULT_FONT_CANDIDATES
if (path := Path(candidate)).is_file()
)
unique: list[FontFace] = []
seen: set[tuple[str, int]] = set()
for face in faces:
key = (str(Path(face.path).resolve()), face.index)
if key not in seen:
seen.add(key)
unique.append(FontFace(key[0], face.index))
return tuple(unique)
def _custom_font_faces(font: str) -> tuple[FontFace, ...]:
if not font or font == "default":
return ()
path = Path(font).expanduser()
if not path.is_file():
return ()
# Existing requests do not carry a TTC index. Trying the indexes returned by
# Fontconfig first retains useful custom TTC behavior without changing the API.
indexes = [face.index for face in fallback_font_faces() if Path(face.path) == path.resolve()]
indexes.append(0)
return tuple(FontFace(str(path.resolve()), index) for index in dict.fromkeys(indexes))
def resolve_font_face(
font: str,
text: str,
*,
font_resolver: FontResolver | None = None,
) -> FontFace | None:
required = _required_codepoints(text)
if font == "default" and _contains_default_emoji(required):
return None
language = _preferred_font_language(text)
preferred = _preferred_font_face(language) if language else None
preferred_faces = (preferred,) if preferred is not None else ()
resolved_faces = font_resolver(font) if font_resolver and font != "default" else ()
primary_candidates = resolved_faces + _custom_font_faces(font) + preferred_faces
if not required and primary_candidates:
return primary_candidates[0]
for face in primary_candidates:
if required <= _font_coverage(face):
return face
candidates = primary_candidates + fallback_font_faces()
if not candidates:
return None
if not required:
return candidates[0]
for face in candidates[len(primary_candidates):]:
if required <= _font_coverage(face):
return face
missing = sorted(
codepoint
for codepoint in required
if not any(codepoint in _font_coverage(face) for face in candidates)
)
if not missing:
missing = sorted(required)
codes = ", ".join(f"U+{codepoint:04X}" for codepoint in missing[:12])
if len(missing) > 12:
codes += ", ..."
raise ValueError(f"no installed font covers Unicode code points: {codes}")
@lru_cache(maxsize=256)
def _load_truetype(face: FontFace, size: int) -> ImageFont.FreeTypeFont:
layout_engine = ImageFont.Layout.RAQM if features.check_feature("raqm") else ImageFont.Layout.BASIC
return ImageFont.truetype(
face.path,
size=size,
index=face.index,
layout_engine=layout_engine,
)
def load_font(
font: str,
size: int,
text: str = "",
*,
font_resolver: FontResolver | None = None,
) -> ImageFont.ImageFont:
face = resolve_font_face(font, text, font_resolver=font_resolver)
if face is not None:
return _load_truetype(face, size)
if _required_codepoints(text):
codes = ", ".join(f"U+{value:04X}" for value in sorted(_required_codepoints(text)))
raise ValueError(f"no installed font covers Unicode code points: {codes}")
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
@@ -0,0 +1,62 @@
from __future__ import annotations
import os
from PIL import Image
VALID_ORIENTATIONS = {0, 90, 180, 270}
DEFAULT_ROW_BIT_ORDER = (0, 1, 2, 3, 4)
def apply_orientation(image: Image.Image, orientation: int) -> Image.Image:
if orientation not in VALID_ORIENTATIONS:
raise ValueError("orientation must be one of 0, 90, 180, 270")
if orientation == 0:
return image.copy()
if orientation == 90:
return image.transpose(Image.Transpose.ROTATE_270)
if orientation == 180:
return image.transpose(Image.Transpose.ROTATE_180)
return image.transpose(Image.Transpose.ROTATE_90)
def get_row_bit_order() -> tuple[int, int, int, int, int]:
raw = os.environ.get("MATRIX_ROW_BIT_ORDER", "").strip()
if not raw:
return DEFAULT_ROW_BIT_ORDER
try:
values = tuple(int(part.strip()) for part in raw.split(","))
except ValueError as exc:
raise ValueError("MATRIX_ROW_BIT_ORDER must be five comma-separated integers") from exc
if len(values) != 5 or sorted(values) != [0, 1, 2, 3, 4]:
raise ValueError("MATRIX_ROW_BIT_ORDER must be a permutation of 0,1,2,3,4")
return values
def apply_row_bit_order(image: Image.Image, bit_order: tuple[int, int, int, int, int]) -> Image.Image:
if bit_order == DEFAULT_ROW_BIT_ORDER:
return image.copy()
width, height = image.size
if height % 32 != 0:
raise ValueError("row bit remap requires a display height divisible by 32")
remapped = Image.new("RGB", image.size)
for y in range(height):
scan_row = y & 31
half_offset = y - scan_row
physical_scan_row = _permute_low_five_bits(scan_row, bit_order)
source_y = half_offset + physical_scan_row
remapped.paste(image.crop((0, source_y, width, source_y + 1)), (0, y))
return remapped
def _permute_low_five_bits(value: int, bit_order: tuple[int, int, int, int, int]) -> int:
result = 0
for driver_bit, physical_bit in enumerate(bit_order):
if value & (1 << driver_bit):
result |= 1 << physical_bit
return result
@@ -0,0 +1,66 @@
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from .text_renderer import load_font
WIFI_INDICATOR_BRIGHTNESS = 50
WIFI_SCROLL_PIXELS_PER_SECOND = 16.0
WIFI_SCROLL_FPS = 20.0
WIFI_SCROLL_GAP_PIXELS = 16
WIFI_TEXT_SIZE = 10
def _draw_binary_text(
image: Image.Image,
position: tuple[int, int],
message: str,
*,
font: ImageFont.ImageFont,
) -> None:
probe = ImageDraw.Draw(image)
bbox = probe.textbbox((0, 0), message, font=font)
width = max(1, bbox[2] - bbox[0])
height = max(1, bbox[3] - bbox[1])
mask = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), message, font=font, fill=255)
# Promote every covered font sample to a complete LED pixel. At this size,
# retaining grayscale antialiasing makes thin strokes disappear at 50%.
mask = mask.point(lambda value: 255 if value >= 64 else 0)
image.paste((255, 255, 255), (position[0] + bbox[0], position[1] + bbox[1]), mask)
def text_width(message: str) -> int:
font = load_font("default", WIFI_TEXT_SIZE, message)
draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
box = draw.textbbox((0, 0), message, font=font)
return max(1, box[2] - box[0])
def _draw_disconnected_icon(draw: ImageDraw.ImageDraw) -> None:
# A compact WiFi fan with a red slash remains recognizable on 64x64.
white = (255, 255, 255)
red = (255, 48, 48)
draw.arc((19, 4, 45, 28), 205, 335, fill=white, width=2)
draw.arc((23, 10, 41, 28), 205, 335, fill=white, width=2)
draw.arc((28, 17, 36, 27), 205, 335, fill=white, width=2)
draw.rectangle((31, 27, 33, 29), fill=white)
draw.line((19, 4, 45, 30), fill=red, width=3)
def render_wifi_indicator(
message: str,
*,
offset: float,
connected: bool,
size: tuple[int, int] = (64, 64),
) -> Image.Image:
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
font = load_font("default", WIFI_TEXT_SIZE, message)
if not connected:
_draw_disconnected_icon(draw)
x = size[0] - int(offset)
y = 26 if connected else 42
_draw_binary_text(image, (x, y), message, font=font)
return image
@@ -0,0 +1,21 @@
from .store import (
ACCEPTED_FONT_EXTENSIONS,
MAX_FONT_UPLOAD_BYTES,
FontCatalog,
FontCatalogError,
FontStorageFullError,
FontTooLargeError,
FontTypeError,
FontValidationError,
)
__all__ = [
"ACCEPTED_FONT_EXTENSIONS",
"MAX_FONT_UPLOAD_BYTES",
"FontCatalog",
"FontCatalogError",
"FontStorageFullError",
"FontTooLargeError",
"FontTypeError",
"FontValidationError",
]
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
import errno
import hashlib
import logging
import os
import subprocess
import threading
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import AsyncIterable, Iterable
from uuid import uuid4
from fontTools.ttLib import TTCollection, TTFont, TTLibError
from app.display.text_renderer import DEFAULT_FONT_CANDIDATES, FontFace
logger = logging.getLogger(__name__)
ACCEPTED_FONT_EXTENSIONS = (".ttf", ".otf", ".ttc", ".otc")
MAX_FONT_UPLOAD_BYTES = 32 * 1024 * 1024
_FONTCONFIG_TIMEOUT_SECONDS = 8
_USER_FONT_NAME = "{digest}.font"
class FontCatalogError(RuntimeError):
"""Base error for font catalog operations."""
class FontTooLargeError(FontCatalogError):
pass
class FontTypeError(FontCatalogError):
pass
class FontValidationError(FontCatalogError):
pass
class FontStorageFullError(FontCatalogError):
pass
@dataclass(frozen=True)
class _DiscoveredFace:
face: FontFace
family: str
style: str
def _friendly_name(font: TTFont, fallback: str) -> tuple[str, str]:
names = font.get("name")
if names is None:
return fallback, "Regular"
family = names.getBestFamilyName() or names.getDebugName(1) or fallback
style = names.getBestSubFamilyName() or names.getDebugName(2) or "Regular"
return str(family).strip() or fallback, str(style).strip() or "Regular"
def _has_unicode_cmap(font: TTFont) -> bool:
cmap = font.get("cmap")
return bool(cmap and any(table.isUnicode() and table.cmap for table in cmap.tables))
def _inspect_font_file(path: Path, *, require_unicode: bool) -> tuple[_DiscoveredFace, ...]:
path = Path(path)
fallback = path.stem or "Unnamed font"
collection: TTCollection | None = None
fonts: list[TTFont] = []
try:
content = path.read_bytes()
try:
collection = TTCollection(BytesIO(content), lazy=False)
fonts = list(collection.fonts)
except TTLibError:
fonts = [TTFont(BytesIO(content), lazy=False)]
discovered: list[_DiscoveredFace] = []
for index, font in enumerate(fonts):
if require_unicode and not _has_unicode_cmap(font):
continue
family, style = _friendly_name(font, fallback)
discovered.append(
_DiscoveredFace(FontFace(str(path.resolve()), index), family, style)
)
if not discovered:
raise FontValidationError("font file does not contain a usable Unicode font face")
return tuple(discovered)
except FontValidationError:
raise
except (OSError, TTLibError, KeyError, ValueError) as exc:
raise FontValidationError("font file is invalid or unsupported") from exc
finally:
if collection is not None:
collection.close()
else:
for font in fonts:
font.close()
def _system_font_id(face: FontFace) -> str:
raw = f"{Path(face.path).resolve()}\0{face.index}".encode("utf-8")
return f"system:{hashlib.sha256(raw).hexdigest()}"
def _user_font_id(digest: str, index: int) -> str:
return f"user:{digest}:{index}"
def _entry(identifier: str, family: str, style: str, source: str) -> dict[str, str]:
family = family.strip() or "未命名字体"
style = style.strip() or "Regular"
label = family if style.casefold() in {"regular", "normal", "book"} else f"{family} — {style}"
return {
"id": identifier,
"label": label,
"family": family,
"style": style,
"source": source,
}
def _fontconfig_faces() -> tuple[_DiscoveredFace, ...]:
try:
result = subprocess.run(
["fc-list", "--format=%{file}\t%{index}\t%{family[0]}\t%{style[0]}\n"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=_FONTCONFIG_TIMEOUT_SECONDS,
)
except (FileNotFoundError, OSError, subprocess.SubprocessError):
return ()
discovered: list[_DiscoveredFace] = []
for line in result.stdout.splitlines():
fields = line.split("\t", 3)
if len(fields) != 4:
continue
raw_path, raw_index, family, style = fields
path = Path(raw_path.strip())
if path.suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS or not path.is_file():
continue
try:
index = int(raw_index.strip() or "0")
except ValueError:
continue
discovered.append(
_DiscoveredFace(
FontFace(str(path.resolve()), index),
family.strip() or path.stem,
style.strip() or "Regular",
)
)
return tuple(discovered)
def _configured_paths() -> Iterable[Path]:
raw = os.environ.get("MATRIX_FONT_PATHS", "")
for value in raw.split(os.pathsep):
if value.strip():
yield Path(value.strip()).expanduser()
def _sync_directory(path: Path) -> None:
if os.name == "nt":
return
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
class FontCatalog:
def __init__(self, data_dir: Path) -> None:
self.root = Path(data_dir) / "fonts"
self._lock = threading.RLock()
self._items: tuple[dict[str, str], ...] = ()
self._faces: dict[str, tuple[FontFace, ...]] = {}
self._warnings: tuple[str, ...] = ()
self.root.mkdir(parents=True, exist_ok=True)
if os.name != "nt":
self.root.chmod(0o750)
self._cleanup_upload_temporaries()
self._system_catalog = self._system_faces()
self.refresh()
def _cleanup_upload_temporaries(self) -> None:
for path in self.root.glob(".upload-*.tmp"):
if path.is_file() and path.parent == self.root:
path.unlink(missing_ok=True)
def _system_faces(self) -> tuple[_DiscoveredFace, ...]:
discovered = list(_fontconfig_faces())
known = {(str(Path(item.face.path).resolve()), item.face.index) for item in discovered}
known_paths = {path for path, _index in known}
for candidate in (*_configured_paths(), *(Path(value) for value in DEFAULT_FONT_CANDIDATES)):
if candidate.suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS or not candidate.is_file():
continue
resolved_candidate = str(candidate.resolve())
if resolved_candidate in known_paths:
continue
try:
inspected = _inspect_font_file(candidate, require_unicode=True)
except FontValidationError:
continue
for item in inspected:
key = (str(Path(item.face.path).resolve()), item.face.index)
if key not in known:
known.add(key)
known_paths.add(key[0])
discovered.append(item)
return tuple(discovered)
def refresh(self) -> None:
faces: dict[str, tuple[FontFace, ...]] = {}
system_items: list[dict[str, str]] = []
imported_items: list[dict[str, str]] = []
warnings: list[str] = []
seen_system: set[tuple[str, int]] = set()
for item in self._system_catalog:
key = (str(Path(item.face.path).resolve()), item.face.index)
if key in seen_system:
continue
seen_system.add(key)
identifier = _system_font_id(item.face)
faces[identifier] = (item.face,)
system_items.append(_entry(identifier, item.family, item.style, "system"))
for path in sorted(self.root.glob("*.font"), key=lambda item: item.name):
digest = path.stem.casefold()
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
warnings.append(f"已忽略未登记字体文件:{path.name}")
continue
try:
inspected = _inspect_font_file(path, require_unicode=True)
except FontValidationError:
warnings.append(f"已保留但忽略损坏字体:{path.name}")
logger.warning("Ignoring invalid persisted font %s", path)
continue
for item in inspected:
identifier = _user_font_id(digest, item.face.index)
faces[identifier] = (item.face,)
imported_items.append(_entry(identifier, item.family, item.style, "imported"))
sort_key = lambda value: (value["family"].casefold(), value["style"].casefold(), value["id"])
automatic = {
"id": "default",
"label": "自动多语言字体",
"family": "自动多语言字体",
"style": "自动回退",
"source": "automatic",
}
with self._lock:
self._items = (
automatic,
*sorted(imported_items, key=sort_key),
*sorted(system_items, key=sort_key),
)
self._faces = faces
self._warnings = tuple(warnings)
def document(self) -> dict:
with self._lock:
return {
"items": [dict(item) for item in self._items],
"accepted_extensions": list(ACCEPTED_FONT_EXTENSIONS),
"max_upload_bytes": MAX_FONT_UPLOAD_BYTES,
"warnings": list(self._warnings),
}
def resolve(self, identifier: str) -> tuple[FontFace, ...]:
with self._lock:
return self._faces.get(identifier, ())
async def import_font(
self,
filename: str,
chunks: AsyncIterable[bytes],
) -> tuple[bool, list[dict[str, str]]]:
if (
not filename
or len(filename) > 255
or "\0" in filename
or "/" in filename
or "\\" in filename
):
raise FontTypeError("font filename is missing or invalid")
if Path(filename).suffix.casefold() not in ACCEPTED_FONT_EXTENSIONS:
raise FontTypeError("font type must be TTF, OTF, TTC, or OTC")
temporary = self.root / f".upload-{uuid4().hex}.tmp"
digest = hashlib.sha256()
total = 0
try:
with temporary.open("xb") as handle:
if os.name != "nt":
os.chmod(temporary, 0o640)
async for chunk in chunks:
if not chunk:
continue
total += len(chunk)
if total > MAX_FONT_UPLOAD_BYTES:
raise FontTooLargeError("font file exceeds the 32 MiB limit")
digest.update(chunk)
handle.write(chunk)
handle.flush()
os.fsync(handle.fileno())
if total == 0:
raise FontValidationError("font file is empty")
inspected = _inspect_font_file(temporary, require_unicode=True)
file_digest = digest.hexdigest()
target = self.root / _USER_FONT_NAME.format(digest=file_digest)
with self._lock:
created = not target.exists()
if created:
os.replace(temporary, target)
_sync_directory(self.root)
else:
temporary.unlink(missing_ok=True)
self.refresh()
identifiers = {_user_font_id(file_digest, item.face.index) for item in inspected}
imported = [dict(item) for item in self._items if item["id"] in identifiers]
return created, imported
except FontCatalogError:
temporary.unlink(missing_ok=True)
raise
except OSError as exc:
temporary.unlink(missing_ok=True)
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
raise FontStorageFullError("not enough disk space to import font") from exc
raise FontCatalogError(str(exc)) from exc
except BaseException:
temporary.unlink(missing_ok=True)
raise
@@ -0,0 +1,19 @@
from .manager import (
FrpConflictError,
FrpController,
FrpError,
FrpServiceError,
FrpValidationError,
MockFrpBackend,
SystemdFrpBackend,
)
__all__ = [
"FrpConflictError",
"FrpController",
"FrpError",
"FrpServiceError",
"FrpValidationError",
"MockFrpBackend",
"SystemdFrpBackend",
]
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import threading
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
from uuid import UUID, uuid4
from app.persistence import atomic_write_bytes
FRP_CATALOG_SCHEMA_VERSION = 1
MAX_FRP_CONFIG_BYTES = 1024 * 1024
SUPPORTED_FRP_SUFFIXES = {".toml", ".yaml", ".yml", ".json", ".ini"}
_SAFE_NAME_RE = re.compile(r"[^\w\-. ()\u4e00-\u9fff]+", re.UNICODE)
class FrpError(RuntimeError):
"""Base error whose text is safe to return to the browser."""
class FrpValidationError(FrpError):
pass
class FrpConflictError(FrpError):
pass
class FrpServiceError(FrpError):
pass
class FrpBackend(Protocol):
def validate(self, path: Path) -> None: ...
def status(self) -> dict[str, Any]: ...
def select(self, path: Path) -> None: ...
def start(self) -> None: ...
def stop(self) -> None: ...
def restart(self) -> None: ...
def _utc_now() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _checked_uuid(value: str) -> str:
try:
checked = str(UUID(str(value)))
except (ValueError, TypeError, AttributeError) as exc:
raise FrpValidationError("FRP config id is invalid") from exc
if checked != str(value):
raise FrpValidationError("FRP config id is invalid")
return checked
def _checked_suffix(filename: str) -> str:
suffix = Path(str(filename)).suffix.casefold()
if suffix not in SUPPORTED_FRP_SUFFIXES:
raise FrpValidationError("FRP config must be TOML, YAML, JSON or INI")
return suffix
def _checked_content(content: bytes) -> bytes:
if not content:
raise FrpValidationError("FRP config must not be empty")
if len(content) > MAX_FRP_CONFIG_BYTES:
raise FrpValidationError("FRP config exceeds the 1 MiB limit")
try:
text = content.decode("utf-8")
except UnicodeDecodeError as exc:
raise FrpValidationError("FRP config must be UTF-8 text") from exc
if "\x00" in text:
raise FrpValidationError("FRP config must not contain NUL bytes")
return content
def _display_name(name: str, filename: str) -> str:
candidate = str(name or "").strip() or Path(str(filename)).stem.strip()
candidate = _SAFE_NAME_RE.sub("_", candidate).strip(" ._")
if not candidate:
candidate = "frpc"
if len(candidate) > 80:
candidate = candidate[:80].rstrip()
return candidate
def _checked_timestamp(value: str) -> str:
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError) as exc:
raise FrpValidationError("FRP config timestamp is invalid") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise FrpValidationError("FRP config timestamp is invalid")
return value
class FrpCatalog:
def __init__(self, data_root: Path, backend: FrpBackend) -> None:
self.root = Path(data_root) / "frp"
self.catalog_path = self.root / "catalog.json"
self.backend = backend
self._lock = threading.RLock()
self._error: str | None = None
self._document = {
"schema_version": FRP_CATALOG_SCHEMA_VERSION,
"selected_id": None,
"profiles": [],
}
try:
self._load()
except FrpError as exc:
self._error = str(exc)
@property
def available(self) -> bool:
return self._error is None
@property
def error(self) -> str | None:
return self._error
def _require_available(self) -> None:
if self._error is not None:
raise FrpServiceError(self._error)
def _load(self) -> None:
if not self.catalog_path.exists():
return
try:
raw = json.loads(self.catalog_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise FrpServiceError("FRP config catalog is damaged; the original file was preserved") from exc
if not isinstance(raw, dict) or set(raw) != {"schema_version", "selected_id", "profiles"}:
raise FrpServiceError("FRP config catalog fields are unsupported; the original file was preserved")
if raw["schema_version"] != FRP_CATALOG_SCHEMA_VERSION:
raise FrpServiceError("FRP config catalog version is unsupported; the original file was preserved")
if not isinstance(raw["profiles"], list):
raise FrpServiceError("FRP config catalog profiles are invalid; the original file was preserved")
seen: set[str] = set()
profiles: list[dict[str, str]] = []
for item in raw["profiles"]:
if not isinstance(item, dict) or set(item) != {"id", "name", "filename", "created_at", "updated_at"}:
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
profile_id = _checked_uuid(item["id"])
if profile_id in seen or not all(isinstance(item[key], str) and item[key] for key in item):
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
suffix = _checked_suffix(item["filename"])
if item["filename"] != f"frpc{suffix}" or _display_name(item["name"], item["filename"]) != item["name"]:
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved")
try:
_checked_timestamp(item["created_at"])
_checked_timestamp(item["updated_at"])
except FrpValidationError as exc:
raise FrpServiceError("FRP config catalog profile is invalid; the original file was preserved") from exc
path = self._profile_path(item)
if not path.is_file():
raise FrpServiceError("FRP config catalog references a missing file; the original catalog was preserved")
seen.add(profile_id)
profiles.append(dict(item))
selected = raw["selected_id"]
if selected is not None and (not isinstance(selected, str) or selected not in seen):
raise FrpServiceError("FRP selected config is invalid; the original catalog was preserved")
self._document = {"schema_version": FRP_CATALOG_SCHEMA_VERSION, "selected_id": selected, "profiles": profiles}
def _profile_path(self, profile: dict[str, str]) -> Path:
return self.root / "profiles" / profile["id"] / profile["filename"]
@staticmethod
def _verification_path(path: Path) -> Path:
return path.with_name(f".{path.stem}.verify{path.suffix}")
def _save(self) -> None:
content = (json.dumps(self._document, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
atomic_write_bytes(self.catalog_path, content)
self._protect(self.catalog_path)
@staticmethod
def _protect(path: Path) -> None:
if os.name != "nt":
path.chmod(0o640)
def _unique_name(self, requested: str, *, excluding: str | None = None) -> str:
existing = {
item["name"].casefold()
for item in self._document["profiles"]
if item["id"] != excluding
}
if requested.casefold() not in existing:
return requested
counter = 2
while f"{requested} ({counter})".casefold() in existing:
counter += 1
return f"{requested} ({counter})"
def list_document(self) -> dict[str, Any]:
with self._lock:
return {
"available": self.available,
"error": self.error,
"selected_id": self._document["selected_id"],
"profiles": deepcopy(self._document["profiles"]),
}
def get(self, profile_id: str) -> tuple[dict[str, str], Path]:
self._require_available()
checked = _checked_uuid(profile_id)
for profile in self._document["profiles"]:
if profile["id"] == checked:
return deepcopy(profile), self._profile_path(profile)
raise FrpValidationError("FRP config was not found")
def create(self, *, filename: str, name: str, content: bytes) -> dict[str, str]:
self._require_available()
checked_content = _checked_content(content)
suffix = _checked_suffix(filename)
with self._lock:
profile_id = str(uuid4())
now = _utc_now()
profile = {
"id": profile_id,
"name": self._unique_name(_display_name(name, filename)),
"filename": f"frpc{suffix}",
"created_at": now,
"updated_at": now,
}
path = self._profile_path(profile)
path.parent.mkdir(parents=True, exist_ok=False)
if os.name != "nt":
path.parent.chmod(0o2750)
try:
temporary = self._verification_path(path)
atomic_write_bytes(temporary, checked_content)
self.backend.validate(temporary)
os.replace(temporary, path)
self._protect(path)
self._document["profiles"].append(profile)
if self._document["selected_id"] is None:
self._document["selected_id"] = profile_id
self.backend.select(path)
self._save()
except BaseException:
temporary = self._verification_path(path)
temporary.unlink(missing_ok=True)
if path.exists():
path.unlink(missing_ok=True)
try:
path.parent.rmdir()
except OSError:
pass
self._document["profiles"] = [item for item in self._document["profiles"] if item["id"] != profile_id]
if self._document["selected_id"] == profile_id:
self._document["selected_id"] = None
raise
return deepcopy(profile)
def update(self, profile_id: str, *, name: str, content: bytes) -> dict[str, str]:
self._require_available()
checked_content = _checked_content(content)
with self._lock:
profile, path = self.get(profile_id)
old_content = path.read_bytes()
temporary = self._verification_path(path)
atomic_write_bytes(temporary, checked_content)
try:
self.backend.validate(temporary)
os.replace(temporary, path)
self._protect(path)
profile["name"] = self._unique_name(_display_name(name, profile["filename"]), excluding=profile_id)
profile["updated_at"] = _utc_now()
index = next(i for i, item in enumerate(self._document["profiles"]) if item["id"] == profile_id)
previous = self._document["profiles"][index]
self._document["profiles"][index] = profile
try:
self._save()
if self._document["selected_id"] == profile_id and self.backend.status()["running"]:
self.backend.restart()
except BaseException:
atomic_write_bytes(path, old_content)
self._protect(path)
self._document["profiles"][index] = previous
self._save()
if self._document["selected_id"] == profile_id and self.backend.status()["running"]:
try:
self.backend.restart()
except FrpError:
pass
raise
finally:
temporary.unlink(missing_ok=True)
return deepcopy(profile)
def select(self, profile_id: str) -> dict[str, str]:
self._require_available()
with self._lock:
profile, path = self.get(profile_id)
previous_id = self._document["selected_id"]
previous_path = self.get(previous_id)[1] if previous_id else None
was_running = bool(self.backend.status()["running"])
try:
if was_running:
self.backend.stop()
self.backend.select(path)
self._document["selected_id"] = profile_id
self._save()
if was_running:
self.backend.start()
except BaseException:
self._document["selected_id"] = previous_id
if previous_path is not None:
self.backend.select(previous_path)
self._save()
if was_running:
try:
self.backend.start()
except FrpError:
pass
raise
return profile
def delete(self, profile_id: str) -> None:
self._require_available()
with self._lock:
profile, path = self.get(profile_id)
selected = self._document["selected_id"] == profile_id
if selected and self.backend.status()["running"]:
raise FrpConflictError("stop FRP before deleting the active config")
old_content = path.read_bytes()
old_document = deepcopy(self._document)
self._document["profiles"] = [item for item in self._document["profiles"] if item["id"] != profile_id]
if selected:
self._document["selected_id"] = None
try:
self._save()
path.unlink()
path.parent.rmdir()
except BaseException:
self._document = old_document
atomic_write_bytes(path, old_content)
self._protect(path)
self._save()
raise
class SystemdFrpBackend:
def __init__(
self,
data_root: Path,
*,
binary: Path = Path("/usr/local/bin/frpc"),
unit: str = "matrix-screen-frpc.service",
runner=subprocess.run,
) -> None:
self.binary = Path(binary)
self.unit = unit
self.environment_path = Path(data_root) / "frp" / "active.env"
self.runner = runner
def _run(self, command: list[str], *, check: bool = True, timeout: int = 15) -> subprocess.CompletedProcess[str]:
try:
result = self.runner(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as exc:
raise FrpServiceError("FRP system service command failed") from exc
if check and result.returncode != 0:
raise FrpServiceError("FRP system service rejected the operation")
return result
def validate(self, path: Path) -> None:
if not self.binary.is_file():
raise FrpServiceError("frpc is not installed")
result = self._run([
str(self.binary), "verify", "-c", str(path),
"--allow-unsafe=TokenSourceExec",
], check=False)
if result.returncode != 0:
message = (result.stderr or result.stdout or "FRP config validation failed").strip()
message = re.sub(r"(?i)(token|password|secret)(\s*[=:]\s*)\S+", r"\1\2[redacted]", message)
raise FrpValidationError(message[:1000])
def status(self) -> dict[str, Any]:
version = None
if self.binary.is_file():
result = self._run([str(self.binary), "--version"], check=False, timeout=5)
if result.returncode == 0:
version = result.stdout.strip()[:80]
enabled_result = self._run(["/usr/bin/systemctl", "is-enabled", self.unit], check=False, timeout=5)
active_result = self._run(["/usr/bin/systemctl", "is-active", self.unit], check=False, timeout=5)
enabled = enabled_result.stdout.strip() == "enabled"
state = active_result.stdout.strip() or "unknown"
return {
"installed": self.binary.is_file(),
"version": version,
"enabled": enabled,
"running": state == "active",
"state": state,
"last_error": None if state in {"active", "inactive"} else "FRP service is not running normally",
}
def select(self, path: Path) -> None:
resolved = path.resolve()
content = f"FRPC_CONFIG={resolved}\n".encode("utf-8")
atomic_write_bytes(self.environment_path, content)
if os.name != "nt":
self.environment_path.chmod(0o600)
def start(self) -> None:
self._run(["/usr/bin/systemctl", "enable", "--now", self.unit])
if not self.status()["running"]:
raise FrpServiceError("FRP service did not enter the running state")
def stop(self) -> None:
self._run(["/usr/bin/systemctl", "disable", "--now", self.unit])
def restart(self) -> None:
self._run(["/usr/bin/systemctl", "restart", self.unit])
if not self.status()["running"]:
raise FrpServiceError("FRP service did not restart successfully")
class MockFrpBackend:
def __init__(self) -> None:
self.installed = True
self.enabled = False
self.running = False
self.selected: Path | None = None
self.fail_validation = False
self.fail_start = False
self.fail_restart = False
def validate(self, path: Path) -> None:
text = path.read_text(encoding="utf-8")
if self.fail_validation or "INVALID_FRP_CONFIG" in text:
raise FrpValidationError("FRP config validation failed")
def status(self) -> dict[str, Any]:
return {
"installed": self.installed,
"version": "0.71.0-mock",
"enabled": self.enabled,
"running": self.running,
"state": "active" if self.running else "inactive",
"last_error": None,
}
def select(self, path: Path) -> None:
self.selected = Path(path)
def start(self) -> None:
if self.fail_start:
raise FrpServiceError("FRP service did not enter the running state")
self.enabled = True
self.running = True
def stop(self) -> None:
self.enabled = False
self.running = False
def restart(self) -> None:
if self.fail_restart:
raise FrpServiceError("FRP service did not restart successfully")
self.running = True
class FrpController:
def __init__(self, data_root: Path, backend: FrpBackend) -> None:
self.backend = backend
self.catalog = FrpCatalog(data_root, backend)
def status(self) -> dict[str, Any]:
catalog = self.catalog.list_document()
service = self.backend.status()
if not catalog["available"]:
service = {**service, "enabled": False, "running": False, "last_error": catalog["error"]}
return {**catalog, "service": service}
def content(self, profile_id: str) -> tuple[dict[str, str], bytes]:
profile, path = self.catalog.get(profile_id)
return profile, path.read_bytes()
def start(self) -> dict[str, Any]:
state = self.catalog.list_document()
if not state["selected_id"]:
raise FrpConflictError("select an FRP config before starting")
_profile, path = self.catalog.get(state["selected_id"])
self.backend.validate(path)
self.backend.select(path)
self.backend.start()
return self.status()
def stop(self) -> dict[str, Any]:
self.backend.stop()
return self.status()
@@ -0,0 +1,138 @@
from __future__ import annotations
import errno
import hashlib
import json
import threading
from pathlib import Path
from typing import Any
from uuid import UUID
from app.persistence import atomic_write_bytes
LIBRARY_ORDER_SCHEMA_VERSION = 1
LIBRARY_ITEM_TYPES = frozenset({"template", "animation"})
LIBRARY_ORDER_FIELDS = frozenset({"schema_version", "items"})
LIBRARY_ITEM_FIELDS = frozenset({"type", "id"})
class LibraryOrderError(RuntimeError):
pass
class LibraryOrderValidationError(LibraryOrderError):
pass
class LibraryOrderConflictError(LibraryOrderError):
pass
class LibraryOrderStorageFullError(LibraryOrderError):
pass
def normalize_library_items(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
raise LibraryOrderValidationError("library order items must be an array")
normalized: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for item in value:
if not isinstance(item, dict) or set(item) != LIBRARY_ITEM_FIELDS:
raise LibraryOrderValidationError("library order item fields are invalid")
item_type = item.get("type")
item_id = item.get("id")
if item_type not in LIBRARY_ITEM_TYPES:
raise LibraryOrderValidationError("library order item type is invalid")
if not isinstance(item_id, str):
raise LibraryOrderValidationError("library order item id must be a UUID")
try:
normalized_id = str(UUID(item_id))
except (ValueError, TypeError, AttributeError) as exc:
raise LibraryOrderValidationError("library order item id must be a UUID") from exc
if normalized_id != item_id:
raise LibraryOrderValidationError("library order item id must be a canonical UUID")
key = (item_type, normalized_id)
if key in seen:
raise LibraryOrderValidationError("library order items must be unique")
seen.add(key)
normalized.append({"type": item_type, "id": normalized_id})
return normalized
def library_order_revision(items: list[dict[str, str]]) -> str:
canonical = json.dumps(items, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
class LibraryOrderStore:
def __init__(self, data_dir: Path, *, forbidden_ids: set[str] | None = None):
self.path = Path(data_dir) / "library" / "order.json"
self._forbidden_ids = set(forbidden_ids or ())
self._lock = threading.RLock()
if self.path.exists():
self._read_items()
@staticmethod
def _json_bytes(items: list[dict[str, str]]) -> bytes:
document = {
"schema_version": LIBRARY_ORDER_SCHEMA_VERSION,
"items": items,
}
return (json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
def _read_items(self) -> list[dict[str, str]] | None:
if not self.path.exists():
return None
try:
document = json.loads(self.path.read_bytes().decode("utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise LibraryOrderValidationError("library order file is unreadable") from exc
if not isinstance(document, dict) or set(document) != LIBRARY_ORDER_FIELDS:
raise LibraryOrderValidationError("library order fields do not match the current schema")
version = document.get("schema_version")
if type(version) is not int or version != LIBRARY_ORDER_SCHEMA_VERSION:
raise LibraryOrderValidationError("library order schema version is unsupported")
items = normalize_library_items(document.get("items"))
if any(item["id"] in self._forbidden_ids for item in items):
raise LibraryOrderValidationError("demo items cannot be included in library order")
return items
@staticmethod
def _resolve_items(
stored: list[dict[str, str]] | None,
defaults: list[dict[str, str]],
) -> list[dict[str, str]]:
current = {(item["type"], item["id"]) for item in defaults}
resolved = [item for item in (stored or []) if (item["type"], item["id"]) in current]
included = {(item["type"], item["id"]) for item in resolved}
resolved.extend(item for item in defaults if (item["type"], item["id"]) not in included)
return resolved
def get(self, default_items: Any) -> dict[str, Any]:
defaults = normalize_library_items(default_items)
with self._lock:
items = self._resolve_items(self._read_items(), defaults)
return {"items": items, "revision": library_order_revision(items)}
def update(self, items: Any, default_items: Any, expected_revision: str) -> dict[str, Any]:
requested = normalize_library_items(items)
if any(item["id"] in self._forbidden_ids for item in requested):
raise LibraryOrderValidationError("demo items cannot be included in library order")
defaults = normalize_library_items(default_items)
with self._lock:
current = self._resolve_items(self._read_items(), defaults)
if library_order_revision(current) != expected_revision:
raise LibraryOrderConflictError("library order was changed by another client")
requested_set = {(item["type"], item["id"]) for item in requested}
current_set = {(item["type"], item["id"]) for item in defaults}
if requested_set != current_set:
raise LibraryOrderConflictError("library contents changed; refresh before sorting")
try:
atomic_write_bytes(self.path, self._json_bytes(requested))
except OSError as exc:
if exc.errno in {errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)}:
raise LibraryOrderStorageFullError("not enough disk space to save library order") from exc
raise
return {"items": requested, "revision": library_order_revision(requested)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
"""Persistent media analysis and conversion jobs."""
from .manager import MediaImportError, MediaImportManager
__all__ = ["MediaImportError", "MediaImportManager"]
@@ -0,0 +1,402 @@
from __future__ import annotations
import base64
import json
import math
import shutil
import subprocess
from bisect import bisect_right
from pathlib import Path
from typing import Any, Iterable, Iterator
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
MAX_DURATION_SECONDS = 2 * 60 * 60
MAX_DIMENSION = 32768
MAX_PIXELS = 160_000_000
MAX_MERGED_FRAMES = 30_000
SAMPLE_MS = 50
OUTPUT_SIZE = 64
MIN_CENTER = -31.0
MAX_CENTER = 32.0
class MediaConversionError(ValueError):
pass
def rgb_scene(rgb: bytes) -> dict[str, Any]:
if len(rgb) != 64 * 64 * 3:
raise MediaConversionError("converted frame is not 64x64 RGB888")
return {
"version": 1, "width": 64, "height": 64,
"pixelRgb": base64.b64encode(rgb).decode("ascii"), "elements": [],
}
def _run(args: list[str], *, timeout: int = 120, stdout: Any = subprocess.PIPE) -> subprocess.CompletedProcess:
try:
result = subprocess.run(
args, stdin=subprocess.DEVNULL, stdout=stdout, stderr=subprocess.PIPE,
timeout=timeout, check=False,
)
except FileNotFoundError as exc:
raise MediaConversionError(f"required decoder is missing: {args[0]}") from exc
except subprocess.TimeoutExpired as exc:
raise MediaConversionError("media decoder made no progress before timeout") from exc
if result.returncode:
detail = result.stderr.decode("utf-8", "replace")[-1200:].strip()
raise MediaConversionError(detail or "media decoder failed")
return result
def _hex_color(value: Any, field: str) -> tuple[int, int, int]:
if not isinstance(value, str) or len(value) != 7 or value[0] != "#":
raise MediaConversionError(f"{field} must be #RRGGBB")
try:
return tuple(bytes.fromhex(value[1:])) # type: ignore[return-value]
except ValueError as exc:
raise MediaConversionError(f"{field} must be #RRGGBB") from exc
def validate_settings(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise MediaConversionError("settings must be an object")
fit_mode = value.get("fit_mode")
if fit_mode not in {"crop", "contain", "stretch"}:
raise MediaConversionError("fit_mode must be crop, contain, or stretch")
center_x, center_y, zoom = value.get("center_x"), value.get("center_y"), value.get("zoom")
for field, number in (("center_x", center_x), ("center_y", center_y)):
if (
isinstance(number, bool) or not isinstance(number, (int, float))
or not math.isfinite(number) or not MIN_CENTER <= number <= MAX_CENTER
):
raise MediaConversionError(f"{field} must be a finite free-framing coordinate")
if isinstance(zoom, bool) or not isinstance(zoom, (int, float)) or not 1 <= zoom <= 16:
raise MediaConversionError("zoom must be in 1..16")
_hex_color(value.get("transparency_color"), "transparency_color")
_hex_color(value.get("padding_color"), "padding_color")
return {
"fit_mode": fit_mode, "center_x": float(center_x), "center_y": float(center_y),
"zoom": float(zoom), "transparency_color": value["transparency_color"].upper(),
"padding_color": value["padding_color"].upper(),
}
def _orient_and_color(image: Image.Image) -> Image.Image:
image = ImageOps.exif_transpose(image)
rgba = image.convert("RGBA")
profile = image.info.get("icc_profile")
if profile:
alpha = rgba.getchannel("A")
try:
source = ImageCms.ImageCmsProfile(bytes(profile))
target = ImageCms.createProfile("sRGB")
rgb = ImageCms.profileToProfile(rgba.convert("RGB"), source, target, outputMode="RGB")
rgba = rgb.convert("RGBA")
rgba.putalpha(alpha)
except (ImageCms.PyCMSError, OSError, TypeError, ValueError) as exc:
raise MediaConversionError("image ICC profile cannot be converted to sRGB") from exc
return rgba
def _sample_aspect_ratio(value: Any) -> float:
if not isinstance(value, str) or ":" not in value:
return 1.0
numerator, denominator = value.split(":", 1)
try:
ratio = float(numerator) / float(denominator)
except (ValueError, ZeroDivisionError):
return 1.0
return ratio if math.isfinite(ratio) and ratio > 0 else 1.0
def _metadata_display_dimensions(metadata: dict[str, Any]) -> tuple[float, float]:
width, height = float(metadata["width"]), float(metadata["height"])
if metadata.get("decoder") == "ffmpeg":
width *= _sample_aspect_ratio(metadata.get("sample_aspect_ratio"))
return width, height
def _free_crop_geometry(
width: float, height: float, settings: dict[str, Any],
) -> tuple[int, int, int, int]:
scale = OUTPUT_SIZE * settings["zoom"] / max(width, height)
out_w = max(1, round(width * scale))
out_h = max(1, round(height * scale))
left = round(OUTPUT_SIZE / 2 - settings["center_x"] * out_w)
top = round(OUTPUT_SIZE / 2 - settings["center_y"] * out_h)
if left >= OUTPUT_SIZE or left + out_w <= 0 or top >= OUTPUT_SIZE or top + out_h <= 0:
raise MediaConversionError("free-framing position must leave at least one output pixel visible")
return out_w, out_h, left, top
def validate_settings_for_metadata(value: Any, metadata: dict[str, Any] | None) -> dict[str, Any]:
settings = validate_settings(value)
if settings["fit_mode"] == "crop" and metadata is not None:
_free_crop_geometry(*_metadata_display_dimensions(metadata), settings)
return settings
def _composite_free_crop(
image: Image.Image, settings: dict[str, Any], geometry: tuple[int, int, int, int],
) -> bytes:
out_w, out_h, left, top = geometry
transparency = _hex_color(settings["transparency_color"], "transparency_color")
padding = _hex_color(settings["padding_color"], "padding_color")
resized = image if image.size == (out_w, out_h) else image.resize((out_w, out_h), Image.Resampling.LANCZOS)
content = Image.alpha_composite(
Image.new("RGBA", resized.size, (*transparency, 255)), resized,
).convert("RGB")
output = Image.new("RGB", (OUTPUT_SIZE, OUTPUT_SIZE), padding)
output.paste(content, (left, top))
return output.tobytes()
def transform_frame(image: Image.Image, settings: dict[str, Any]) -> bytes:
settings = validate_settings(settings)
image = _orient_and_color(image)
width, height = image.size
if width < 1 or height < 1 or width > MAX_DIMENSION or height > MAX_DIMENSION or width * height > MAX_PIXELS:
raise MediaConversionError("media pixel dimensions exceed the safety limit")
transparency = _hex_color(settings["transparency_color"], "transparency_color")
padding = _hex_color(settings["padding_color"], "padding_color")
mode = settings["fit_mode"]
if mode == "crop":
return _composite_free_crop(image, settings, _free_crop_geometry(width, height, settings))
if mode == "stretch":
image = image.resize((64, 64), Image.Resampling.LANCZOS)
background = Image.new("RGBA", (64, 64), (*transparency, 255))
return Image.alpha_composite(background, image).convert("RGB").tobytes()
scale = min(64 / width, 64 / height)
size = (max(1, round(width * scale)), max(1, round(height * scale)))
image = image.resize(size, Image.Resampling.LANCZOS)
content = Image.alpha_composite(Image.new("RGBA", size, (*transparency, 255)), image).convert("RGB")
output = Image.new("RGB", (64, 64), padding)
output.paste(content, ((64 - size[0]) // 2, (64 - size[1]) // 2))
return output.tobytes()
def _check_dimensions(width: int, height: int) -> None:
if width < 1 or height < 1 or width > MAX_DIMENSION or height > MAX_DIMENSION or width * height > MAX_PIXELS:
raise MediaConversionError("media pixel dimensions exceed the safety limit")
def _pillow_probe(source: Path) -> tuple[dict[str, Any], list[Image.Image]]:
with Image.open(source) as image:
width, height = image.size
_check_dimensions(width, height)
count = int(getattr(image, "n_frames", 1))
durations, previews = [], []
preview_indexes = {
min(count - 1, round(i * (count - 1) / min(4, count - 1)))
for i in range(min(5, count))
} if count > 1 else {0}
for index in range(count):
image.seek(index)
duration = image.info.get("duration", 100 if count > 1 else 0)
if isinstance(duration, bool) or not isinstance(duration, (int, float)) or duration <= 0:
duration = 100
durations.append(int(round(duration)))
if index in preview_indexes:
previews.append(_orient_and_color(image.copy()))
total_ms = sum(durations) if count > 1 else 0
if total_ms > MAX_DURATION_SECONDS * 1000:
raise MediaConversionError("media duration exceeds 2 hours")
return {
"decoder": "pillow", "format": str(image.format or "image").lower(),
"width": previews[0].width, "height": previews[0].height, "duration_ms": total_ms,
"source_frame_count": count, "dynamic": count > 1,
"has_alpha": "A" in image.getbands() or "transparency" in image.info,
}, previews
def _ffprobe(source: Path) -> dict[str, Any]:
result = _run([
"ffprobe", "-v", "error", "-protocol_whitelist", "file,pipe",
"-show_streams", "-show_format", "-of", "json", str(source),
])
try:
value = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise MediaConversionError("ffprobe returned invalid metadata") from exc
streams = [
stream for stream in value.get("streams", [])
if stream.get("codec_type") == "video"
and not int((stream.get("disposition") or {}).get("attached_pic", 0))
]
if not streams:
raise MediaConversionError("input does not contain a supported video stream")
stream = streams[0]
width, height = int(stream.get("width") or 0), int(stream.get("height") or 0)
_check_dimensions(width, height)
duration = stream.get("duration") or (value.get("format") or {}).get("duration")
try:
duration_ms = round(float(duration) * 1000)
except (TypeError, ValueError):
raise MediaConversionError("video duration is unavailable")
if duration_ms <= 0 or duration_ms > MAX_DURATION_SECONDS * 1000:
raise MediaConversionError("media duration must be in 0..2 hours")
transfer = str(stream.get("color_transfer") or "").lower()
hdr = transfer in {"smpte2084", "arib-std-b67"}
if hdr:
filters = _run(["ffmpeg", "-hide_banner", "-filters"]).stdout.decode("utf-8", "replace")
if " zscale " not in filters or " tonemap " not in filters:
raise MediaConversionError("HDR input requires FFmpeg zscale and tonemap filters")
return {
"decoder": "ffmpeg", "format": str((value.get("format") or {}).get("format_name") or "video"),
"width": width, "height": height, "duration_ms": duration_ms,
"source_frame_count": int(stream.get("nb_frames") or 0), "dynamic": True,
"has_alpha": "a" in str(stream.get("pix_fmt") or ""),
"stream_index": int(stream.get("index") or 0), "hdr": hdr,
"sample_aspect_ratio": str(stream.get("sample_aspect_ratio") or "1:1"),
}
def _heif_probe(source: Path) -> tuple[dict[str, Any], list[Image.Image]]:
decoded = source.parent / "decoded-heif.png"
_run(["heif-convert", str(source), str(decoded)], timeout=120)
metadata, images = _pillow_probe(decoded)
metadata.update({"decoder": "heif", "format": "heif", "dynamic": False})
return metadata, images
def analyze(source: Path, preview_dir: Path) -> dict[str, Any]:
preview_dir.mkdir(parents=True, exist_ok=True)
try:
metadata, images = _pillow_probe(source)
for index, image in enumerate(images):
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
image.save(preview_dir / f"{index}.png", "PNG")
except (UnidentifiedImageError, OSError):
try:
metadata, images = _heif_probe(source)
for index, image in enumerate(images):
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
image.save(preview_dir / f"{index}.png", "PNG")
except MediaConversionError:
metadata = _ffprobe(source)
count = min(5, max(1, math.ceil(metadata["duration_ms"] / 1000)))
times = [metadata["duration_ms"] * i / max(1, count - 1) / 1000 for i in range(count)]
for index, when in enumerate(times):
_run([
"ffmpeg", "-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe",
"-ss", f"{when:.3f}", "-i", str(source), "-map", f"0:{metadata['stream_index']}",
"-frames:v", "1", "-vf",
"scale=trunc(iw*sar+0.5):ih:flags=lanczos,setsar=1,"
"scale=512:512:force_original_aspect_ratio=decrease:flags=lanczos",
"-y", str(preview_dir / f"{index}.png"),
], timeout=120)
metadata["preview_count"] = len(list(preview_dir.glob("*.png")))
return metadata
def _pillow_frames(source: Path, settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
with Image.open(source) as image:
count = int(getattr(image, "n_frames", 1))
if count == 1:
yield transform_frame(image.copy(), settings), 0
return
frames, ends, elapsed = [], [], 0
for index in range(count):
image.seek(index)
duration = image.info.get("duration", 100)
if not isinstance(duration, (int, float)) or isinstance(duration, bool) or duration <= 0:
duration = 100
elapsed += int(round(duration))
ends.append(elapsed)
frames.append(image.copy())
if elapsed > MAX_DURATION_SECONDS * 1000:
raise MediaConversionError("media duration exceeds 2 hours")
sample_times = range(0, elapsed, SAMPLE_MS)
for time_ms in sample_times:
index = min(len(frames) - 1, bisect_right(ends, time_ms))
duration = min(SAMPLE_MS, elapsed - time_ms)
yield transform_frame(frames[index], settings), duration
def _ffmpeg_frames(source: Path, metadata: dict[str, Any], settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
width, height = _metadata_display_dimensions(metadata)
mode = settings["fit_mode"]
if mode == "contain":
scale = min(64 / width, 64 / height)
out_w, out_h = max(1, round(width * scale)), max(1, round(height * scale))
geometry = f"scale={out_w}:{out_h}:flags=lanczos"
elif mode == "stretch":
out_w = out_h = 64
geometry = "scale=64:64:flags=lanczos"
else:
out_w, out_h, left, top = _free_crop_geometry(width, height, settings)
geometry = f"scale={out_w}:{out_h}:flags=lanczos"
color_filter = ""
if metadata.get("hdr"):
color_filter = "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709,"
filters = f"fps=20,{color_filter}{geometry},setsar=1,format=rgba"
try:
process = subprocess.Popen([
"ffmpeg", "-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe",
"-i", str(source), "-map", f"0:{metadata['stream_index']}", "-an", "-sn", "-dn",
"-vf", filters, "-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1",
], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except FileNotFoundError as exc:
raise MediaConversionError("required decoder is missing: ffmpeg") from exc
assert process.stdout is not None
frame_bytes = out_w * out_h * 4
count = 0
while True:
raw = process.stdout.read(frame_bytes)
if not raw:
break
if len(raw) != frame_bytes:
process.kill()
raise MediaConversionError("FFmpeg returned a truncated video frame")
rgba = Image.frombytes("RGBA", (out_w, out_h), raw)
if mode == "contain":
transparency = _hex_color(settings["transparency_color"], "transparency_color")
padding = _hex_color(settings["padding_color"], "padding_color")
content = Image.alpha_composite(Image.new("RGBA", rgba.size, (*transparency, 255)), rgba).convert("RGB")
output = Image.new("RGB", (64, 64), padding)
output.paste(content, ((64 - out_w) // 2, (64 - out_h) // 2))
rgb = output.tobytes()
elif mode == "crop":
rgb = _composite_free_crop(rgba, settings, (out_w, out_h, left, top))
else:
background = Image.new("RGBA", (64, 64), (*_hex_color(settings["transparency_color"], "transparency_color"), 255))
rgb = Image.alpha_composite(background, rgba).convert("RGB").tobytes()
count += 1
yield rgb, SAMPLE_MS
stderr = process.stderr.read().decode("utf-8", "replace") if process.stderr else ""
if process.wait(timeout=30):
raise MediaConversionError(stderr[-1200:].strip() or "FFmpeg conversion failed")
if count == 0:
raise MediaConversionError("video decoder produced no frames")
def converted_frames(source: Path, metadata: dict[str, Any], settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
settings = validate_settings_for_metadata(settings, metadata)
raw_frames: Iterable[tuple[bytes, int]]
if metadata["decoder"] in {"pillow", "heif"}:
actual_source = source if metadata["decoder"] == "pillow" else source.parent / "decoded-heif.png"
raw_frames = _pillow_frames(actual_source, settings)
else:
raw_frames = _ffmpeg_frames(source, metadata, settings)
previous: bytes | None = None
duration = 0
merged = 0
for rgb, frame_duration in raw_frames:
if previous is None:
previous, duration = rgb, frame_duration
elif rgb == previous:
duration += frame_duration
else:
merged += 1
if merged > MAX_MERGED_FRAMES:
raise MediaConversionError("converted animation exceeds 30,000 merged frames")
yield previous, max(SAMPLE_MS, duration)
previous, duration = rgb, frame_duration
if previous is None:
raise MediaConversionError("decoder produced no frames")
merged += 1
if merged > MAX_MERGED_FRAMES:
raise MediaConversionError("converted animation exceeds 30,000 merged frames")
yield previous, max(SAMPLE_MS, duration) if metadata["dynamic"] else 0
@@ -0,0 +1,524 @@
from __future__ import annotations
import json
import os
import queue
import shutil
import subprocess
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
from app.animations.store import AnimationStore
from app.persistence import atomic_write_bytes
from app.templates.store import TemplateConflictError, TemplateStore
from .converter import (
MAX_MERGED_FRAMES, MediaConversionError, analyze, converted_frames,
rgb_scene, validate_settings_for_metadata,
)
JOB_SCHEMA_VERSION = 1
MAX_SOURCE_BYTES = 4 * 1024 * 1024 * 1024
MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024
RETENTION_HOURS = 24
STATES = {
"uploading", "analyzing", "awaiting_settings", "queued", "converting", "failed",
}
JOB_FIELDS = {
"id", "filename", "name", "source_size", "state", "action", "created_at",
"updated_at", "expires_at", "progress", "metadata", "settings",
"preview_count", "error", "queue_sequence",
}
class MediaImportError(ValueError):
def __init__(self, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.status_code = status_code
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat()
def _job_id(value: Any) -> str:
try:
return str(UUID(str(value)))
except (ValueError, TypeError, AttributeError) as exc:
raise MediaImportError("media import job not found", status_code=404) from exc
def _output_name(value: Any) -> str:
if not isinstance(value, str):
raise MediaImportError("output name must be a string")
checked = value.strip()
if not checked or len(checked) > 80:
raise MediaImportError("output name must contain 1..80 characters")
return checked
def _default_output_name(filename: str) -> str:
leaf = filename.replace("\\", "/").rsplit("/", 1)[-1].strip()
stem = leaf.rsplit(".", 1)[0].strip() if "." in leaf else leaf
return (stem[:80].strip() or "未命名媒体")
class MediaImportManager:
"""Persistent single-worker media queue.
The record is authoritative, so analyzing/queued/converting work can be
restored after either service or device restart.
"""
def __init__(self, data_dir: Path, templates: TemplateStore, animations: AnimationStore) -> None:
self.root = Path(data_dir) / "media-import" / "jobs"
self.root.mkdir(parents=True, exist_ok=True)
self.templates = templates
self.animations = animations
self._lock = threading.RLock()
self._queue: queue.PriorityQueue[tuple[int, str, str]] = queue.PriorityQueue()
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._canceled: set[str] = set()
self._external_worker = os.environ.get("MATRIX_MEDIA_WORKER_MODE") == "systemd"
self._sequence = 0
with self._lock:
for child in self.root.iterdir():
if not child.is_dir():
continue
record = self._read(child.name)
self._sequence = max(self._sequence, record["queue_sequence"])
if record["state"] == "uploading":
record.update({
"state": "failed", "action": "analyze",
"error": "upload was interrupted", "progress": 0,
"updated_at": _iso(_now()),
"expires_at": _iso(_now() + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
elif record["state"] in {"analyzing", "queued", "converting"}:
action = "analyze" if record["action"] == "analyze" else "convert"
record["state"] = "analyzing" if action == "analyze" else "queued"
record["progress"] = 0
self._write(record)
self._queue.put((record["queue_sequence"], record["id"], action))
def _dir(self, job_id: str) -> Path:
return self.root / job_id
def _path(self, job_id: str) -> Path:
return self._dir(job_id) / "job.json"
def source_path(self, job_id: Any) -> Path:
return self._dir(_job_id(job_id)) / "source.bin"
def preview_path(self, job_id: Any, index: int) -> Path:
normalized = _job_id(job_id)
record = self._read(normalized)
if index < 0 or index >= record["preview_count"]:
raise MediaImportError("media preview not found", status_code=404)
path = self._dir(normalized) / "previews" / f"{index}.png"
if not path.is_file():
raise MediaImportError("media preview not found", status_code=404)
return path
def _write(self, record: dict[str, Any]) -> None:
payload = json.dumps(
{"schema_version": JOB_SCHEMA_VERSION, **record},
ensure_ascii=False, sort_keys=True, indent=2,
).encode("utf-8") + b"\n"
atomic_write_bytes(self._path(record["id"]), payload)
def _validate(self, value: Any, expected_id: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise MediaImportError("media job record is invalid", status_code=500)
document = dict(value)
if document.pop("schema_version", None) != 1 or set(document) != JOB_FIELDS:
raise MediaImportError("media job schema is unsupported", status_code=500)
if document.get("id") != expected_id or document.get("state") not in STATES:
raise MediaImportError("media job record is invalid", status_code=500)
if document.get("action") not in {"analyze", "convert"}:
raise MediaImportError("media job action is invalid", status_code=500)
if not isinstance(document.get("settings"), dict):
raise MediaImportError("media job settings are invalid", status_code=500)
return document
def _read(self, job_id: Any) -> dict[str, Any]:
normalized = _job_id(job_id)
path = self._path(normalized)
if not path.is_file():
raise MediaImportError("media import job not found", status_code=404)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise MediaImportError("media job record is unreadable", status_code=500) from exc
return self._validate(value, normalized)
def _public(self, record: dict[str, Any]) -> dict[str, Any]:
queued = []
for _, queued_id, action in list(self._queue.queue):
if action == "convert":
queued.append(queued_id)
queue_position = queued.index(record["id"]) + 1 if record["id"] in queued else None
return {
key: record[key] for key in (
"id", "filename", "name", "source_size", "state", "created_at",
"updated_at", "expires_at", "progress", "metadata", "settings",
"error",
)
} | {
"queue_position": queue_position,
"previews": [
f"/api/media-imports/{record['id']}/previews/{index}"
for index in range(record["preview_count"])
],
}
def _reconcile_external(self, record: dict[str, Any]) -> dict[str, Any]:
if not self._external_worker or record["state"] not in {"analyzing", "queued", "converting"}:
return record
result = subprocess.run(
["systemctl", "show", f"matrix-screen-converter@{record['id']}.service",
"-p", "ActiveState", "-p", "Result", "--value"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
check=False, timeout=5,
)
values = set(result.stdout.decode("utf-8", "replace").split())
if result.returncode == 0 and "failed" in values:
now = _now()
record.update({
"state": "failed", "progress": 0,
"error": "isolated converter process exited unexpectedly",
"updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
return record
def _ensure_space(self, incoming: int = 0) -> None:
free = shutil.disk_usage(self.root).free
if free - incoming < MIN_FREE_BYTES:
raise MediaImportError("device must retain at least 2 GiB free space", status_code=507)
def begin_upload(self, filename: Any, name: Any | None, size: int) -> tuple[dict[str, Any], Path]:
if not isinstance(filename, str) or not filename.strip() or len(filename) > 255:
raise MediaImportError("filename must contain 1..255 characters")
checked_name = _default_output_name(filename) if name is None else _output_name(name)
if isinstance(size, bool) or not isinstance(size, int) or size <= 0:
raise MediaImportError("Content-Length must be a positive integer", status_code=411)
if size > MAX_SOURCE_BYTES:
raise MediaImportError("source file exceeds 4 GiB", status_code=413)
with self._lock:
self._ensure_space(size)
job_id, now = str(uuid4()), _now()
self._sequence += 1
directory = self._dir(job_id)
directory.mkdir(parents=True, exist_ok=False)
record = {
"id": job_id, "filename": filename.strip(), "name": checked_name,
"source_size": size, "state": "uploading", "action": "analyze",
"created_at": _iso(now), "updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
"progress": 0, "metadata": None,
"settings": {
"fit_mode": "crop", "center_x": 0.5, "center_y": 0.5,
"zoom": 1.0, "transparency_color": "#000000",
"padding_color": "#000000",
},
"preview_count": 0, "error": None, "queue_sequence": self._sequence,
}
self._write(record)
return self._public(record), directory / "source.bin"
def finish_upload(self, job_id: Any, received: int) -> dict[str, Any]:
with self._lock:
record = self._read(job_id)
if record["state"] != "uploading":
raise MediaImportError("media upload is not active", status_code=409)
if received != record["source_size"]:
shutil.rmtree(self._dir(record["id"]), ignore_errors=True)
raise MediaImportError("uploaded byte count does not match Content-Length")
record.update({
"state": "analyzing", "action": "analyze", "progress": 0,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
self._schedule(record["id"], "analyze", record["queue_sequence"])
return self._public(record)
def fail_upload(self, job_id: Any) -> None:
with self._lock:
shutil.rmtree(self._dir(_job_id(job_id)), ignore_errors=True)
def start(self) -> None:
with self._lock:
if self._external_worker:
pending = []
while not self._queue.empty():
pending.append(self._queue.get_nowait())
for sequence, job_id, action in pending:
self._schedule(job_id, action, sequence)
return
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name="media-import-queue", daemon=True)
self._thread.start()
def stop(self) -> None:
if self._external_worker:
return
self._stop.set()
self._queue.put((2**63 - 1, "", "stop"))
if self._thread:
self._thread.join(timeout=10)
def _schedule(self, job_id: str, action: str, sequence: int) -> None:
if not self._external_worker:
self._queue.put((sequence, job_id, action))
return
result = subprocess.run(
["systemctl", "start", "--no-block", f"matrix-screen-converter@{job_id}.service"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=False, timeout=15,
)
if result.returncode:
raise MediaImportError(
"cannot start isolated converter: "
+ result.stderr.decode("utf-8", "replace")[-800:].strip(),
status_code=503,
)
def _run(self) -> None:
while not self._stop.is_set():
_, job_id, action = self._queue.get()
if not job_id or self._stop.is_set():
return
with self._lock:
if job_id in self._canceled or not self._path(job_id).is_file():
continue
try:
if action == "analyze":
self._analyze(job_id)
else:
self._convert(job_id)
except Exception as exc:
with self._lock:
if job_id in self._canceled or not self._path(job_id).is_file():
continue
record = self._read(job_id)
now = _now()
record.update({
"state": "failed", "progress": 0,
"error": str(exc)[:2000], "updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
def run_job(self, job_id: Any) -> None:
"""Run one persisted action; used by the isolated systemd instance."""
normalized = _job_id(job_id)
record = self._read(normalized)
try:
if record["action"] == "analyze":
self._analyze(normalized)
else:
self._convert(normalized)
except Exception as exc:
if not self._path(normalized).is_file():
return
record = self._read(normalized)
now = _now()
record.update({
"state": "failed", "progress": 0, "error": str(exc)[:2000],
"updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
raise
def _analyze(self, job_id: str) -> None:
metadata = analyze(self.source_path(job_id), self._dir(job_id) / "previews")
with self._lock:
if job_id in self._canceled:
return
record = self._read(job_id)
record.update({
"state": "awaiting_settings", "metadata": metadata,
"preview_count": metadata["preview_count"], "progress": 100,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
def _library_name_conflict(self, name: str) -> bool:
folded = name.casefold()
if any(item["name"].casefold() == folded for item in self.templates.list()["templates"]):
return True
return not self.animations.name_available(name)
def _reserved_name_conflict(self, name: str, *, exclude_job_id: str) -> bool:
folded = name.casefold()
for child in self.root.iterdir():
if not child.is_dir() or child.name == exclude_job_id:
continue
other = self._read(child.name)
if other["state"] in {"queued", "converting"} and other["name"].casefold() == folded:
return True
return False
def _name_conflict(self, record: dict[str, Any], *, include_reservations: bool) -> bool:
if self._library_name_conflict(record["name"]):
return True
return include_reservations and self._reserved_name_conflict(
record["name"], exclude_job_id=record["id"],
)
def _convert(self, job_id: str) -> None:
with self._lock:
self._ensure_space()
record = self._read(job_id)
if self._name_conflict(record, include_reservations=False):
raise TemplateConflictError("output name already exists")
record.update({
"state": "converting", "action": "convert", "progress": 1,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
frames = converted_frames(
self.source_path(job_id), record["metadata"], record["settings"],
)
if record["metadata"]["dynamic"]:
def scenes():
for index, (rgb, duration) in enumerate(frames):
with self._lock:
if job_id in self._canceled:
raise MediaConversionError("media conversion was canceled")
current = self._read(job_id)
current["progress"] = min(99, 1 + index * 98 // MAX_MERGED_FRAMES)
self._write(current)
yield {
"scene": rgb_scene(rgb), "duration_ms": duration,
"name": None,
}
self.animations.create_from_frames(record["name"], scenes())
else:
rgb, _ = next(iter(frames))
with self._lock:
if job_id in self._canceled:
raise MediaConversionError("media conversion was canceled")
self.templates.create(record["name"], rgb_scene(rgb))
with self._lock:
if job_id not in self._canceled:
shutil.rmtree(self._dir(job_id), ignore_errors=True)
def list(self) -> dict[str, Any]:
self.cleanup_expired()
with self._lock:
records = sorted(
(self._reconcile_external(self._read(child.name)) for child in self.root.iterdir() if child.is_dir()),
key=lambda record: record["created_at"],
)
return {"jobs": [self._public(record) for record in records]}
def get(self, job_id: Any) -> dict[str, Any]:
self.cleanup_expired()
with self._lock:
return self._public(self._reconcile_external(self._read(job_id)))
def update_settings(self, job_id: Any, value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise MediaImportError("settings must be an object")
allowed = {
"name", "fit_mode", "center_x", "center_y", "zoom",
"transparency_color", "padding_color",
}
if set(value) - allowed:
raise MediaImportError("unknown media settings field")
with self._lock:
record = self._read(job_id)
if record["state"] not in {"awaiting_settings", "failed"}:
raise MediaImportError("media settings cannot be changed in the current state", status_code=409)
settings = {**record["settings"], **{k: v for k, v in value.items() if k != "name"}}
try:
record["settings"] = validate_settings_for_metadata(settings, record["metadata"])
except MediaConversionError as exc:
raise MediaImportError(str(exc)) from exc
if "name" in value:
proposed_name = _output_name(value["name"])
proposed = {**record, "name": proposed_name}
if self._name_conflict(proposed, include_reservations=True):
raise MediaImportError("output name already exists", status_code=409)
record["name"] = proposed_name
record.update({"updated_at": _iso(_now()), "error": None})
if record["state"] == "failed" and record["metadata"] is not None:
record["state"] = "awaiting_settings"
self._write(record)
return self._public(record)
def enqueue_conversion(self, job_id: Any) -> dict[str, Any]:
with self._lock:
record = self._read(job_id)
if record["state"] != "awaiting_settings":
raise MediaImportError("media job is not ready for conversion", status_code=409)
if self._name_conflict(record, include_reservations=True):
raise MediaImportError("output name already exists", status_code=409)
self._sequence += 1
record.update({
"state": "queued", "action": "convert", "progress": 0,
"queue_sequence": self._sequence, "updated_at": _iso(_now()),
"error": None,
})
self._write(record)
self._schedule(record["id"], "convert", record["queue_sequence"])
return self._public(record)
def retry(self, job_id: Any) -> dict[str, Any]:
with self._lock:
record = self._read(job_id)
if record["state"] != "failed":
raise MediaImportError("only failed jobs can be retried", status_code=409)
action = record["action"]
if action == "convert" and record["metadata"] is not None and self._name_conflict(
record, include_reservations=True,
):
raise MediaImportError("output name already exists", status_code=409)
self._sequence += 1
record.update({
"state": "analyzing" if action == "analyze" else "queued",
"queue_sequence": self._sequence, "progress": 0,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
self._schedule(record["id"], action, record["queue_sequence"])
return self._public(record)
def cancel(self, job_id: Any) -> None:
normalized = _job_id(job_id)
with self._lock:
self._read(normalized)
self._canceled.add(normalized)
shutil.rmtree(self._dir(normalized), ignore_errors=True)
def cleanup_expired(self) -> None:
with self._lock:
now = _now()
for child in list(self.root.iterdir()):
if not child.is_dir():
continue
record = self._read(child.name)
if record["state"] in {"failed", "awaiting_settings"}:
try:
expires = datetime.fromisoformat(record["expires_at"])
except (TypeError, ValueError):
continue
if expires <= now:
self._canceled.add(record["id"])
shutil.rmtree(child, ignore_errors=True)
@@ -0,0 +1,24 @@
from __future__ import annotations
import argparse
from app.animations.store import AnimationStore
from app.config.store import ConfigStore
from app.media.manager import MediaImportManager
from app.templates.store import TemplateStore
def main() -> int:
parser = argparse.ArgumentParser(description="Run one isolated media import action")
parser.add_argument("job_id")
args = parser.parse_args()
config = ConfigStore()
templates = TemplateStore(config.data_dir)
animations = AnimationStore(config.data_dir, cleanup_playback=False)
manager = MediaImportManager(config.data_dir, templates, animations)
manager.run_job(args.job_id)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""Lightweight in-process monitoring helpers."""
@@ -0,0 +1,257 @@
from __future__ import annotations
import logging
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
logger = logging.getLogger(__name__)
POLL_INTERVAL_SECONDS = 5.0
def isoformat_utc(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _clamp_percent(value: float) -> float:
return min(100.0, max(0.0, value))
def _parse_cpu_values(fields: list[str], label: str) -> tuple[int, int]:
if len(fields) < 4:
raise ValueError(f"/proc/stat {label} 行无效")
values = [int(value) for value in fields]
total = sum(values)
idle = values[3] + (values[4] if len(values) > 4 else 0)
if total <= 0:
raise ValueError(f"/proc/stat {label} 总计无效")
return total, idle
def _parse_cpu_snapshot(text: str) -> tuple[tuple[int, int], tuple[tuple[str, int, int], ...]]:
aggregate: tuple[int, int] | None = None
cores: list[tuple[str, int, int]] = []
for line in text.splitlines():
parts = line.split()
if not parts:
continue
label = parts[0]
if label == "cpu":
aggregate = _parse_cpu_values(parts[1:], label)
elif label.startswith("cpu") and label[3:].isdigit():
total, idle = _parse_cpu_values(parts[1:], label)
cores.append((label, total, idle))
if aggregate is None or not cores:
raise ValueError("/proc/stat 缺少 CPU 汇总或逻辑核心行")
cores.sort(key=lambda item: int(item[0][3:]))
return aggregate, tuple(cores)
def _parse_process_cpu_ticks(text: str) -> int:
closing_paren = text.rfind(")")
if closing_paren < 0:
raise ValueError("/proc/self/stat 进程名无效")
fields = text[closing_paren + 1:].split()
if len(fields) < 13:
raise ValueError("/proc/self/stat 字段不足")
return int(fields[11]) + int(fields[12])
def _parse_rss_bytes(text: str, page_size: int) -> int:
fields = text.split()
if len(fields) < 2:
raise ValueError("/proc/self/statm 字段不足")
rss_pages = int(fields[1])
if rss_pages < 0:
raise ValueError("/proc/self/statm RSS 无效")
return rss_pages * page_size
def _parse_memory_bytes(text: str) -> tuple[int, int]:
values: dict[str, int] = {}
for line in text.splitlines():
key, separator, remainder = line.partition(":")
if not separator:
continue
parts = remainder.split()
if parts and parts[0].isdigit():
values[key] = int(parts[0]) * 1024
total = values.get("MemTotal", 0)
available = values.get("MemAvailable", -1)
if total <= 0 or not 0 <= available <= total:
raise ValueError("/proc/meminfo 内存汇总无效")
return total, available
class ResourceMonitor:
def __init__(
self,
proc_root: Path | str = "/proc",
poll_interval: float = POLL_INTERVAL_SECONDS,
page_size: int | None = None,
now: Callable[[], datetime] | None = None,
) -> None:
self._proc_root = Path(proc_root)
self._poll_interval = poll_interval
if page_size is None:
sysconf = getattr(os, "sysconf", None)
page_size = int(sysconf("SC_PAGE_SIZE")) if sysconf is not None else 4096
self._page_size = page_size
self._now = now or (lambda: datetime.now(timezone.utc))
self._state_lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._previous_cpu: tuple[
int,
int,
int,
tuple[tuple[str, int, int], ...],
] | None = None
self._snapshot = self._empty_snapshot("starting", None, None)
@staticmethod
def _empty_snapshot(status: str, sampled_at: str | None, error_code: str | None) -> dict[str, Any]:
return {
"status": status,
"sampled_at": sampled_at,
"cpu": {
"application_percent": None,
"total_percent": None,
"logical_cpu_count": None,
"application_core_equivalent": None,
"cores_percent": [],
},
"memory": {
"application_bytes": None,
"application_percent": None,
"total_percent": None,
},
"error_code": error_code,
}
def start(self) -> None:
with self._state_lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name="system-resource-monitor",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
with self._state_lock:
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=max(2.0, self._poll_interval + 1.0))
def get_status(self) -> dict[str, Any]:
with self._state_lock:
snapshot = self._snapshot
return {
**snapshot,
"cpu": {
**snapshot["cpu"],
"cores_percent": list(snapshot["cpu"]["cores_percent"]),
},
"memory": dict(snapshot["memory"]),
}
def sample_now(self) -> dict[str, Any]:
sampled_at = isoformat_utc(self._now())
try:
(cpu_total, cpu_idle), cpu_cores = _parse_cpu_snapshot(
(self._proc_root / "stat").read_text(encoding="ascii")
)
process_ticks = _parse_process_cpu_ticks(
(self._proc_root / "self" / "stat").read_text(encoding="ascii")
)
rss_bytes = _parse_rss_bytes(
(self._proc_root / "self" / "statm").read_text(encoding="ascii"),
self._page_size,
)
memory_total, memory_available = _parse_memory_bytes(
(self._proc_root / "meminfo").read_text(encoding="ascii")
)
application_cpu: float | None = None
application_cores: float | None = None
total_cpu: float | None = None
cores_percent: list[float | None] = [None] * len(cpu_cores)
previous = self._previous_cpu
if previous is not None:
previous_total, previous_idle, previous_process, previous_cores = previous
current_labels = tuple(item[0] for item in cpu_cores)
previous_labels = tuple(item[0] for item in previous_cores)
if current_labels == previous_labels:
total_delta = cpu_total - previous_total
idle_delta = cpu_idle - previous_idle
process_delta = process_ticks - previous_process
if total_delta > 0 and idle_delta >= 0 and process_delta >= 0:
total_cpu = _clamp_percent(
(total_delta - idle_delta) * 100.0 / total_delta
)
application_cpu = _clamp_percent(process_delta * 100.0 / total_delta)
application_cores = min(
float(len(cpu_cores)),
max(0.0, process_delta * len(cpu_cores) / total_delta),
)
for index, (current, old) in enumerate(zip(cpu_cores, previous_cores)):
_, current_total, current_idle = current
_, old_total, old_idle = old
core_delta = current_total - old_total
core_idle_delta = current_idle - old_idle
if core_delta > 0 and core_idle_delta >= 0:
cores_percent[index] = _clamp_percent(
(core_delta - core_idle_delta) * 100.0 / core_delta
)
self._previous_cpu = (cpu_total, cpu_idle, process_ticks, cpu_cores)
snapshot = {
"status": "ok",
"sampled_at": sampled_at,
"cpu": {
"application_percent": application_cpu,
"total_percent": total_cpu,
"logical_cpu_count": len(cpu_cores),
"application_core_equivalent": application_cores,
"cores_percent": cores_percent,
},
"memory": {
"application_bytes": rss_bytes,
"application_percent": _clamp_percent(rss_bytes * 100.0 / memory_total),
"total_percent": _clamp_percent(
(memory_total - memory_available) * 100.0 / memory_total
),
},
"error_code": None,
}
except (OSError, UnicodeError, ValueError, IndexError):
snapshot = self._empty_snapshot("error", sampled_at, "proc_read_error")
except Exception:
logger.exception("Unexpected system resource monitor failure")
snapshot = self._empty_snapshot("error", sampled_at, "unexpected_error")
with self._state_lock:
previous_status = self._snapshot["status"]
self._snapshot = snapshot
if previous_status != snapshot["status"]:
if snapshot["status"] == "ok":
logger.info("System resource monitor is available")
else:
logger.warning("System resource monitor is unavailable: %s", snapshot["error_code"])
return self.get_status()
def _run(self) -> None:
while not self._stop.is_set():
started = time.monotonic()
self.sample_now()
remaining = max(0.0, self._poll_interval - (time.monotonic() - started))
self._stop.wait(remaining)
@@ -0,0 +1,95 @@
from __future__ import annotations
import os
import shutil
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
DEVICE_STORAGE_CACHE_SECONDS = 300.0
def _normalized_roots(paths: Iterable[Path]) -> list[Path]:
roots: list[Path] = []
for candidate in sorted(
{Path(path).resolve() for path in paths},
key=lambda path: len(path.parts),
):
if any(candidate == root or candidate.is_relative_to(root) for root in roots):
continue
roots.append(candidate)
return roots
def directory_bytes(paths: Iterable[Path]) -> int:
total = 0
pending = _normalized_roots(paths)
while pending:
path = pending.pop()
if not path.exists():
continue
if not path.is_dir():
total += path.stat(follow_symlinks=False).st_size
continue
with os.scandir(path) as entries:
for entry in entries:
if entry.is_dir(follow_symlinks=False):
pending.append(Path(entry.path))
else:
total += entry.stat(follow_symlinks=False).st_size
return total
class DeviceStorageMonitor:
def __init__(
self,
program_root: Path,
data_root: Path,
*,
cache_seconds: float = DEVICE_STORAGE_CACHE_SECONDS,
clock: Callable[[], float] = time.monotonic,
usage_reader: Callable[[Path], shutil._ntuple_diskusage] = shutil.disk_usage,
directory_reader: Callable[[Iterable[Path]], int] = directory_bytes,
) -> None:
self.program_root = Path(program_root).resolve()
self.data_root = Path(data_root).resolve()
self.cache_seconds = float(cache_seconds)
self._clock = clock
self._usage_reader = usage_reader
self._directory_reader = directory_reader
self._lock = threading.RLock()
self._snapshot: dict | None = None
self._sampled_monotonic = float("-inf")
def invalidate(self) -> None:
with self._lock:
self._snapshot = None
self._sampled_monotonic = float("-inf")
def get_status(self) -> dict:
with self._lock:
now = self._clock()
if (
self._snapshot is not None
and now - self._sampled_monotonic < self.cache_seconds
):
return dict(self._snapshot)
usage = self._usage_reader(self.program_root)
snapshot = {
"sampled_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"device_total_bytes": usage.total,
"device_free_bytes": usage.free,
"software_bytes": self._directory_reader(
[self.program_root, self.data_root]
),
"templates_bytes": self._directory_reader(
[self.data_root / "templates", self.data_root / "animations"]
),
}
self._snapshot = snapshot
self._sampled_monotonic = now
return dict(snapshot)
@@ -0,0 +1,14 @@
"""NetworkManager-backed WiFi configuration and boot coordination."""
from .manager import MockNetworkManager, NetworkManagerError, NmcliNetworkManager
from .service import WifiNetworkService
from .store import WifiConfigError, WifiConfigStore
__all__ = [
"MockNetworkManager",
"NetworkManagerError",
"NmcliNetworkManager",
"WifiConfigError",
"WifiConfigStore",
"WifiNetworkService",
]
@@ -0,0 +1,120 @@
from __future__ import annotations
import socket
import ssl
import subprocess
import time
from copy import deepcopy
from typing import Any, Callable
class NetworkDiagnostics:
def __init__(
self,
status_provider: Callable[[], dict[str, Any]],
*,
runner=subprocess.run,
resolver=socket.getaddrinfo,
connector=socket.create_connection,
) -> None:
self.status_provider = status_provider
self.runner = runner
self.resolver = resolver
self.connector = connector
@staticmethod
def _check(code: str, ok: bool, message: str, *, warning: bool = False) -> dict[str, Any]:
return {"code": code, "ok": ok, "warning": warning, "message": message}
def _command(self, command: list[str], timeout: int = 3) -> subprocess.CompletedProcess[str] | None:
try:
return self.runner(command, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired):
return None
def run(self) -> dict[str, Any]:
started = time.monotonic()
checks: list[dict[str, Any]] = []
active = (self.status_provider().get("active") or {})
connected = active.get("connected") is True and bool(active.get("ipv4_address"))
checks.append(self._check("interface", connected, "网络接口已连接" if connected else "网卡或 WiFi 未连接"))
if not connected:
return self._result(checks, started, "failed", "网卡或 WiFi 未连接")
route = self._command(["/usr/sbin/ip", "-4", "route", "show", "default"])
route_ok = bool(route and route.returncode == 0 and route.stdout.strip())
checks.append(self._check("default_route", route_ok, "默认路由正常" if route_ok else "默认网关缺失"))
if not route_ok:
return self._result(checks, started, "failed", "默认网关缺失")
dns_ok = False
for host in ("www.baidu.com", "www.qq.com"):
try:
if self.resolver(host, 443, type=socket.SOCK_STREAM):
dns_ok = True
break
except OSError:
continue
checks.append(self._check("dns", dns_ok, "DNS 解析正常" if dns_ok else "DNS 解析失败"))
if not dns_ok:
return self._result(checks, started, "failed", "DNS 解析失败,请检查 DNS 或网关配置")
reachable = False
tls_ok = False
for host in ("www.baidu.com", "www.qq.com"):
try:
raw = self.connector((host, 443), timeout=3)
reachable = True
try:
context = ssl.create_default_context()
with context.wrap_socket(raw, server_hostname=host):
tls_ok = True
break
finally:
try:
raw.close()
except OSError:
pass
except (OSError, ssl.SSLError):
continue
checks.append(self._check("internet", reachable, "外网 TCP 连接正常" if reachable else "网关或上游网络不可达"))
checks.append(self._check("tls", tls_ok, "TLS 校验正常" if tls_ok else "TLS 或系统时间可能异常"))
ping = self._command(["/usr/bin/ping", "-c", "1", "-W", "2", "www.baidu.com"])
ping_ok = bool(ping and ping.returncode == 0)
checks.append(self._check(
"icmp", ping_ok,
"百度 ping 正常" if ping_ok else "目标未响应 ping;部分网络会屏蔽 ICMP",
warning=not ping_ok and tls_ok,
))
if tls_ok:
return self._result(checks, started, "ok", "网络连接正常")
if reachable:
return self._result(checks, started, "failed", "TLS 或系统时间可能异常")
return self._result(checks, started, "failed", "网关或上游网络不可达")
@staticmethod
def _result(checks: list[dict[str, Any]], started: float, overall: str, diagnosis: str) -> dict[str, Any]:
return {
"overall": overall,
"diagnosis": diagnosis,
"duration_ms": int((time.monotonic() - started) * 1000),
"checks": checks,
}
class MockNetworkDiagnostics:
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.result = result or {
"overall": "ok",
"diagnosis": "网络连接正常(mock)",
"duration_ms": 1,
"checks": [
{"code": "interface", "ok": True, "warning": False, "message": "网络接口已连接"},
{"code": "dns", "ok": True, "warning": False, "message": "DNS 解析正常"},
{"code": "internet", "ok": True, "warning": False, "message": "外网连接正常"},
],
}
def run(self) -> dict[str, Any]:
return deepcopy(self.result)
@@ -0,0 +1,297 @@
from __future__ import annotations
import subprocess
import threading
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Protocol, Sequence
from uuid import uuid4
class NetworkManagerError(RuntimeError):
"""A sanitized NetworkManager operation failure."""
class NetworkBackend(Protocol):
def discover_connection_uuid(self) -> str | None: ...
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ...
def read_active(self) -> dict[str, Any]: ...
def create_connection(self, settings: dict[str, Any]) -> str: ...
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: ...
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: ...
def _decode_terse(value: str) -> str:
output: list[str] = []
escaped = False
for character in value:
if escaped:
output.append(character)
escaped = False
elif character == "\\":
escaped = True
else:
output.append(character)
if escaped:
output.append("\\")
return "".join(output)
@dataclass
class NmcliNetworkManager:
executable: str = "/usr/bin/nmcli"
interface: str = "wlan0"
def _run(
self,
arguments: Sequence[str],
*,
timeout: int = 10,
) -> str:
try:
result = subprocess.run(
[self.executable, *arguments],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return result.stdout.strip()
except FileNotFoundError as exc:
raise NetworkManagerError("NetworkManager nmcli is unavailable") from exc
except subprocess.TimeoutExpired as exc:
raise NetworkManagerError("NetworkManager operation timed out") from exc
except subprocess.CalledProcessError as exc:
# stderr may echo a secret on some nmcli failures, so never include it.
raise NetworkManagerError("NetworkManager rejected the requested operation") from exc
def _connection_value(
self,
connection_uuid: str,
field: str,
*,
include_secret: bool = False,
) -> str:
arguments = []
if include_secret:
arguments.append("--show-secrets")
arguments.extend(["--get-values", field, "connection", "show", "uuid", connection_uuid])
return self._run(arguments)
def discover_connection_uuid(self) -> str | None:
active = self._run([
"--get-values", "GENERAL.CON-UUID", "device", "show", self.interface,
])
if active and active != "--":
return active.splitlines()[0].strip()
rows = self._run([
"--terse", "--fields", "UUID,TYPE,AUTOCONNECT", "connection", "show",
])
candidates = []
for row in rows.splitlines():
parts = row.split(":")
if len(parts) == 3 and parts[1] == "802-11-wireless" and parts[2] == "yes":
candidates.append(parts[0])
return candidates[0] if len(candidates) == 1 else None
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]:
ssid = self._connection_value(connection_uuid, "802-11-wireless.ssid")
method = self._connection_value(connection_uuid, "ipv4.method") or "auto"
raw_addresses = self._connection_value(connection_uuid, "ipv4.addresses")
address = raw_addresses.splitlines()[0].strip() if raw_addresses else ""
raw_dns = self._connection_value(connection_uuid, "ipv4.dns")
password = None
password_configured = False
if include_secret:
password = self._connection_value(
connection_uuid,
"802-11-wireless-security.psk",
include_secret=True,
)
password_configured = bool(password and password != "--")
else:
key_mgmt = self._connection_value(
connection_uuid,
"802-11-wireless-security.key-mgmt",
)
password_configured = bool(key_mgmt and key_mgmt != "--")
prefix = None
plain_address = address
if "/" in address:
plain_address, raw_prefix = address.rsplit("/", 1)
try:
prefix = int(raw_prefix)
except ValueError:
prefix = None
return {
"connection_uuid": connection_uuid,
"ssid": ssid,
"password": password if password_configured else None,
"password_configured": password_configured,
"ipv4_mode": "dhcp" if method == "auto" else "manual",
"address": plain_address or None,
"prefix": prefix,
"gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None,
"dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()],
}
def read_active(self) -> dict[str, Any]:
raw_state = self._run([
"--get-values", "GENERAL.STATE", "device", "show", self.interface,
])
connected = raw_state.startswith("100")
connection_uuid = self._run([
"--get-values", "GENERAL.CON-UUID", "device", "show", self.interface,
]) or None
addresses = [
item.strip() for item in self._run([
"--get-values", "IP4.ADDRESS", "device", "show", self.interface,
]).splitlines() if item.strip()
] if connected else []
ssid = None
if connected:
rows = self._run([
"--terse", "--fields", "IN-USE,SSID", "device", "wifi", "list",
"ifname", self.interface,
])
for row in rows.splitlines():
if row.startswith("*:"):
ssid = _decode_terse(row[2:])
break
ipv4 = addresses[0].split("/", 1)[0] if addresses else None
return {
"connected": bool(connected and ipv4),
"connection_uuid": connection_uuid,
"ssid": ssid,
"ipv4_address": ipv4,
"control_url": f"http://{ipv4}:8080/" if ipv4 else None,
}
@staticmethod
def _modify_arguments(settings: dict[str, Any]) -> list[str]:
arguments = [
"802-11-wireless.ssid", settings["ssid"],
"802-11-wireless-security.key-mgmt", "wpa-psk",
]
if settings.get("password") is not None:
arguments.extend(["802-11-wireless-security.psk", settings["password"]])
if settings["ipv4_mode"] == "dhcp":
arguments.extend([
"ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", "",
"ipv4.dns", "",
])
else:
dns = settings["dns_servers"] or [settings["gateway"]]
arguments.extend([
"ipv4.method", "manual",
"ipv4.addresses", f"{settings['address']}/{settings['prefix']}",
"ipv4.gateway", settings["gateway"],
"ipv4.dns", ",".join(dns),
])
return arguments
def create_connection(self, settings: dict[str, Any]) -> str:
name = "matrix-screen-controller-wifi"
self._run([
"connection", "add", "type", "wifi", "ifname", self.interface,
"con-name", name, "ssid", settings["ssid"],
])
connection_uuid = self._run([
"--get-values", "connection.uuid", "connection", "show", "id", name,
])
self.save_connection(connection_uuid, settings)
return connection_uuid
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
self._run([
"connection", "modify", "uuid", connection_uuid,
*self._modify_arguments(settings),
])
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
bounded = max(1, min(int(timeout), 30))
self._run([
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
], timeout=bounded + 2)
class MockNetworkManager:
def __init__(
self,
saved: dict[str, Any] | None = None,
active: dict[str, Any] | None = None,
) -> None:
self._lock = threading.RLock()
self.saved = deepcopy(saved)
self.active = deepcopy(active) if active is not None else {
"connected": False,
"connection_uuid": None,
"ssid": None,
"ipv4_address": None,
"control_url": None,
}
self.activations: list[str] = []
self.fail_save = False
self.fail_activate = False
def discover_connection_uuid(self) -> str | None:
with self._lock:
if self.active.get("connection_uuid"):
return self.active["connection_uuid"]
return self.saved.get("connection_uuid") if self.saved else None
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]:
with self._lock:
if not self.saved or self.saved.get("connection_uuid") != connection_uuid:
raise NetworkManagerError("managed WiFi connection is unavailable")
value = deepcopy(self.saved)
if not include_secret:
value["password"] = None
return value
def read_active(self) -> dict[str, Any]:
with self._lock:
return deepcopy(self.active)
def create_connection(self, settings: dict[str, Any]) -> str:
connection_uuid = str(uuid4())
with self._lock:
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
self.saved["password_configured"] = bool(settings.get("password"))
return connection_uuid
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
if self.fail_save:
raise NetworkManagerError("NetworkManager rejected the requested operation")
with self._lock:
previous_password = self.saved.get("password") if self.saved else None
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
if settings.get("password") is None:
self.saved["password"] = previous_password
self.saved["password_configured"] = bool(self.saved.get("password"))
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
del timeout
if self.fail_activate:
raise NetworkManagerError("NetworkManager rejected the requested operation")
with self._lock:
self.activations.append(connection_uuid)
if self.saved and self.saved.get("connection_uuid") == connection_uuid:
existing_address = None
if (
self.saved.get("ipv4_mode") == "dhcp"
and self.active.get("connected")
and self.active.get("connection_uuid") == connection_uuid
):
existing_address = self.active.get("ipv4_address")
address = self.saved.get("address") or existing_address or "192.168.1.100"
self.active = {
"connected": True,
"connection_uuid": connection_uuid,
"ssid": self.saved.get("ssid"),
"ipv4_address": address,
"control_url": f"http://{address}:8080/",
}
@@ -0,0 +1,353 @@
from __future__ import annotations
import ipaddress
import logging
import threading
import time
from copy import deepcopy
from typing import Any, Callable
from uuid import uuid4
from app.display.service import DisplayService
from .manager import NetworkBackend, NetworkManagerError
from .store import WifiConfigError, WifiConfigStore
logger = logging.getLogger(__name__)
def _utf8_length(value: str) -> int:
return len(value.encode("utf-8"))
def validate_wifi_settings(
raw: dict[str, Any],
*,
previous: dict[str, Any] | None = None,
) -> dict[str, Any]:
ssid = str(raw.get("ssid") or "").strip()
if not 1 <= _utf8_length(ssid) <= 32:
raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes")
password_value = raw.get("password")
password = None if password_value in (None, "") else str(password_value)
if password is not None and not 8 <= _utf8_length(password) <= 63:
raise WifiConfigError("password must contain 8..63 UTF-8 bytes")
if previous is None or previous.get("ssid") != ssid:
if password is None:
raise WifiConfigError("a new password is required when the SSID changes")
elif password is None and not previous.get("password_configured"):
raise WifiConfigError("the managed WiFi connection does not have a saved password")
mode = str(raw.get("ipv4_mode") or "dhcp")
if mode not in {"dhcp", "manual"}:
raise WifiConfigError("ipv4_mode must be dhcp or manual")
address = gateway = None
prefix = 24
dns_servers: list[str] = []
if mode == "manual":
try:
address = str(ipaddress.IPv4Address(str(raw.get("address") or "")))
except ipaddress.AddressValueError as exc:
raise WifiConfigError("manual IPv4 address is required") from exc
try:
gateway = str(ipaddress.IPv4Address(str(raw.get("gateway") or "")))
except ipaddress.AddressValueError as exc:
raise WifiConfigError("manual IPv4 gateway is required") from exc
prefix_value = raw.get("prefix")
if prefix_value not in (None, ""):
if type(prefix_value) is not int:
raise WifiConfigError("IPv4 prefix must be an integer")
prefix = prefix_value
if not 1 <= prefix <= 32:
raise WifiConfigError("IPv4 prefix must be in 1..32")
network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False)
if ipaddress.IPv4Address(address) in {network.network_address, network.broadcast_address}:
raise WifiConfigError("manual IPv4 address cannot be the network or broadcast address")
if ipaddress.IPv4Address(gateway) not in network:
raise WifiConfigError("manual IPv4 gateway must be in the selected subnet")
for item in raw.get("dns_servers") or []:
try:
normalized = str(ipaddress.IPv4Address(str(item).strip()))
except ipaddress.AddressValueError as exc:
raise WifiConfigError("DNS servers must be valid IPv4 addresses") from exc
if normalized not in dns_servers:
dns_servers.append(normalized)
if not dns_servers:
dns_servers = [gateway]
return {
"ssid": ssid,
"password": password,
"password_configured": bool(password or (previous or {}).get("password_configured")),
"ipv4_mode": mode,
"address": address,
"prefix": prefix if mode == "manual" else None,
"gateway": gateway,
"dns_servers": dns_servers,
}
class WifiNetworkService:
def __init__(
self,
store: WifiConfigStore,
backend: NetworkBackend,
display: DisplayService,
*,
monotonic: Callable[[], float] = time.monotonic,
poll_seconds: float = 2.0,
) -> None:
self.store = store
self.backend = backend
self.display = display
self.monotonic = monotonic
self.poll_seconds = poll_seconds
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._activation_active = False
self._status_cache: dict[str, Any] | None = None
self._ensure_managed_connection()
def _ensure_managed_connection(self) -> None:
if self.store.config["managed_connection_uuid"] is not None:
return
try:
discovered = self.backend.discover_connection_uuid()
except NetworkManagerError:
logger.warning("Unable to discover a managed WiFi connection")
return
if discovered:
self.store.update({"managed_connection_uuid": discovered})
logger.info("Bound the active NetworkManager WiFi connection")
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
# Board-side NetworkManager queries can take around a second.
# Populate once before serving requests, then refresh in the
# coordinator instead of blocking every /api/status response.
self.get_status()
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name="wifi-boot-coordinator",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.5)
self.display.dismiss_wifi_indicator()
def dismiss_for_boot(self) -> None:
try:
changed = self.store.dismiss_for_boot()
except Exception:
logger.exception("Failed to persist WiFi indicator dismissal")
changed = True
if changed or self.display.get_status()["state"].get("wifi_indicator_active"):
self.display.dismiss_wifi_indicator()
def get_status(self, *, include_secret: bool = False) -> dict[str, Any]:
managed_uuid = self.store.config["managed_connection_uuid"]
saved: dict[str, Any] | None = None
error = None
try:
if managed_uuid:
saved = self.backend.read_saved(managed_uuid, include_secret=include_secret)
active = self.backend.read_active()
except NetworkManagerError as exc:
active = {
"connected": False,
"connection_uuid": None,
"ssid": None,
"ipv4_address": None,
"control_url": None,
}
error = str(exc)
if saved is not None and not include_secret:
saved = {key: value for key, value in saved.items() if key != "password"}
session = self.store.session
result = {
"available": error is None,
"error": error,
"saved": saved,
"active": active,
"prompt_delay_seconds": self.store.config["prompt_delay_seconds"],
"prompt": {
"dismissed_for_boot": session["dismissed"],
"deadline_monotonic": session["deadline_monotonic"],
"active": self.display.get_status()["state"].get("wifi_indicator_active", False),
},
"operation": deepcopy(session["operation"]),
}
if not include_secret:
with self._lock:
self._status_cache = deepcopy(result)
return result
def get_cached_status(self) -> dict[str, Any]:
with self._lock:
cached = deepcopy(self._status_cache)
if cached is None:
return self.get_status()
session = self.store.session
cached["prompt"] = {
"dismissed_for_boot": session["dismissed"],
"deadline_monotonic": session["deadline_monotonic"],
"active": self.display.get_status()["state"].get("wifi_indicator_active", False),
}
cached["operation"] = deepcopy(session["operation"])
cached["prompt_delay_seconds"] = self.store.config["prompt_delay_seconds"]
return cached
def save_settings(self, raw: dict[str, Any]) -> dict[str, Any]:
activation = str(raw.get("activation") or "")
if activation not in {"immediate", "next_boot"}:
raise WifiConfigError("activation must be immediate or next_boot")
delay = raw.get("prompt_delay_seconds")
if delay is not None and type(delay) is not int:
raise WifiConfigError("prompt_delay_seconds must be an integer")
with self._lock:
if self._activation_active:
raise WifiConfigError("another WiFi activation is already running")
managed_uuid = self.store.config["managed_connection_uuid"]
previous = None
if managed_uuid:
previous = self.backend.read_saved(managed_uuid, include_secret=False)
settings = validate_wifi_settings(raw, previous=previous)
if managed_uuid:
rollback = self.backend.read_saved(managed_uuid, include_secret=True)
try:
self.backend.save_connection(managed_uuid, settings)
if delay is not None:
self.store.update({"prompt_delay_seconds": delay})
except Exception:
try:
self.backend.save_connection(managed_uuid, rollback)
except Exception:
logger.exception("Failed to roll back NetworkManager profile metadata")
raise
else:
managed_uuid = self.backend.create_connection(settings)
try:
store_update = {"managed_connection_uuid": managed_uuid}
if delay is not None:
store_update["prompt_delay_seconds"] = delay
self.store.update(store_update)
except Exception:
logger.exception("WiFi profile was created but its managed UUID could not be saved")
raise
operation_id = str(uuid4())
state = "scheduled" if activation == "immediate" else "succeeded"
message = (
"WiFi 配置已保存,正在准备立即切换"
if activation == "immediate"
else "WiFi 配置已保存,将在下次断电开机后生效"
)
self.store.set_operation({"id": operation_id, "state": state, "message": message})
return {
"operation_id": operation_id,
"activation": activation,
"message": message,
}
def save_prompt_delay(self, value: Any) -> dict[str, Any]:
if type(value) is not int:
raise WifiConfigError("prompt_delay_seconds must be an integer")
with self._lock:
config = self.store.update({"prompt_delay_seconds": value})
return {
"prompt_delay_seconds": config["prompt_delay_seconds"],
"message": "网络提示等待时间已保存",
}
def activate_saved(self, operation_id: str) -> None:
with self._lock:
if self._activation_active:
return
self._activation_active = True
try:
managed_uuid = self.store.config["managed_connection_uuid"]
if not managed_uuid:
raise NetworkManagerError("managed WiFi connection is unavailable")
self.store.set_operation({
"id": operation_id,
"state": "applying",
"message": "正在切换 WiFi 与 IPv4 配置",
})
self.backend.activate(managed_uuid, timeout=30)
self.store.set_operation({
"id": operation_id,
"state": "succeeded",
"message": "WiFi 与 IPv4 配置已生效",
})
except Exception as exc:
logger.warning("WiFi activation failed without exposing NetworkManager output")
self.store.set_operation({
"id": operation_id,
"state": "failed",
"message": str(exc),
})
finally:
with self._lock:
self._activation_active = False
def _run(self) -> None:
session = self.store.session
deadline = float(session["deadline_monotonic"])
managed_uuid = self.store.config["managed_connection_uuid"]
if self.store.is_new_boot_session and managed_uuid:
while not self._stop.is_set() and self.monotonic() < deadline:
try:
self.backend.activate(managed_uuid, timeout=5)
if self.backend.read_active().get("connected"):
break
except NetworkManagerError:
pass
self._stop.wait(min(self.poll_seconds, max(0.0, deadline - self.monotonic())))
remaining = deadline - self.monotonic()
if remaining > 0 and self._stop.wait(remaining):
return
while not self._stop.is_set():
self.get_status()
if self.store.session["dismissed"]:
self.display.dismiss_wifi_indicator()
else:
self._refresh_indicator()
self._stop.wait(self.poll_seconds)
def _refresh_indicator(self) -> None:
managed_uuid = self.store.config["managed_connection_uuid"]
try:
active = self.backend.read_active()
if active.get("connected") and active.get("ssid") and active.get("ipv4_address"):
message = (
f"SSID: {active['ssid']} "
f"IP: http://{active['ipv4_address']}:8080/"
)
self.display.show_wifi_indicator(connected=True, message=message)
return
if managed_uuid:
saved = self.backend.read_saved(managed_uuid, include_secret=True)
password = saved.get("password") or "未保存"
message = f"SSID: {saved.get('ssid') or '未配置'} 密码: {password}"
else:
message = "尚未配置 WiFi"
self.display.show_wifi_indicator(connected=False, message=message)
except NetworkManagerError:
self.display.show_wifi_indicator(connected=False, message="WiFi 状态不可用")
@@ -0,0 +1,184 @@
from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any, Callable
from uuid import UUID
from app.display.startup_indicator import read_boot_id
from app.persistence import atomic_write_bytes
logger = logging.getLogger(__name__)
WIFI_CONFIG_SCHEMA_VERSION = 1
DEFAULT_PROMPT_DELAY_SECONDS = 30
MIN_PROMPT_DELAY_SECONDS = 1
MAX_PROMPT_DELAY_SECONDS = 3600
WIFI_CONFIG_FIELDS = frozenset({"managed_connection_uuid", "prompt_delay_seconds"})
class WifiConfigError(ValueError):
"""Raised when persisted WiFi metadata is unsafe or invalid."""
def validate_wifi_config(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise WifiConfigError("wifi config must be a JSON object")
unknown = set(value) - WIFI_CONFIG_FIELDS
if unknown:
raise WifiConfigError(f"unknown wifi config fields: {', '.join(sorted(unknown))}")
missing = WIFI_CONFIG_FIELDS - set(value)
if missing:
raise WifiConfigError(f"wifi config is missing fields: {', '.join(sorted(missing))}")
managed_uuid = value["managed_connection_uuid"]
if managed_uuid is not None:
if not isinstance(managed_uuid, str):
raise WifiConfigError("managed_connection_uuid must be a UUID string or null")
try:
managed_uuid = str(UUID(managed_uuid))
except ValueError as exc:
raise WifiConfigError("managed_connection_uuid must be a valid UUID") from exc
delay = value["prompt_delay_seconds"]
if type(delay) is not int:
raise WifiConfigError("prompt_delay_seconds must be an integer")
if not MIN_PROMPT_DELAY_SECONDS <= delay <= MAX_PROMPT_DELAY_SECONDS:
raise WifiConfigError(
f"prompt_delay_seconds must be in {MIN_PROMPT_DELAY_SECONDS}.."
f"{MAX_PROMPT_DELAY_SECONDS}"
)
return {
"managed_connection_uuid": managed_uuid,
"prompt_delay_seconds": delay,
}
class WifiConfigStore:
def __init__(
self,
data_dir: Path,
runtime_dir: Path,
*,
boot_id: str | None = None,
monotonic: Callable[[], float] = time.monotonic,
) -> None:
self.path = Path(data_dir) / "wifi_config.json"
self.session_path = Path(runtime_dir) / "wifi_session.json"
self.boot_id = boot_id or read_boot_id()
self.monotonic = monotonic
self._lock = threading.RLock()
self.path.parent.mkdir(parents=True, exist_ok=True)
self.session_path.parent.mkdir(parents=True, exist_ok=True)
self._config = self._load_config()
self._session, self._new_boot_session = self._load_session()
@property
def config(self) -> dict[str, Any]:
with self._lock:
return dict(self._config)
@property
def is_new_boot_session(self) -> bool:
return self._new_boot_session
@property
def session(self) -> dict[str, Any]:
with self._lock:
return dict(self._session)
def update(self, values: dict[str, Any]) -> dict[str, Any]:
with self._lock:
unknown = set(values) - WIFI_CONFIG_FIELDS
if unknown:
raise WifiConfigError(f"unknown wifi config fields: {', '.join(sorted(unknown))}")
checked = validate_wifi_config({**self._config, **values})
if checked != self._config:
self._write_config(checked)
self._config = checked
return dict(self._config)
def dismiss_for_boot(self) -> bool:
with self._lock:
if self._session["dismissed"]:
return False
self._session["dismissed"] = True
self._write_session(self._session)
return True
def set_operation(self, operation: dict[str, Any]) -> None:
safe = {
"id": str(operation.get("id") or ""),
"state": str(operation.get("state") or "idle"),
"message": str(operation.get("message") or ""),
}
if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}:
raise WifiConfigError("invalid wifi operation state")
with self._lock:
self._session["operation"] = safe
self._write_session(self._session)
def _load_config(self) -> dict[str, Any]:
if not self.path.exists():
value = {
"managed_connection_uuid": None,
"prompt_delay_seconds": DEFAULT_PROMPT_DELAY_SECONDS,
}
self._write_config(value)
return value
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise WifiConfigError(f"persisted wifi config is unreadable: {self.path}") from exc
if not isinstance(raw, dict):
raise WifiConfigError("persisted wifi config must be a JSON object")
document = dict(raw)
version = document.pop("schema_version", None)
if type(version) is not int:
raise WifiConfigError("wifi config schema_version must be an integer")
if version != WIFI_CONFIG_SCHEMA_VERSION:
raise WifiConfigError(
f"unsupported wifi config schema version {version}; "
f"supported version is {WIFI_CONFIG_SCHEMA_VERSION}"
)
return validate_wifi_config(document)
def _load_session(self) -> tuple[dict[str, Any], bool]:
now = float(self.monotonic())
default = {
"boot_id": self.boot_id,
"deadline_monotonic": now + self._config["prompt_delay_seconds"],
"dismissed": False,
"operation": {"id": "", "state": "idle", "message": ""},
}
if self.session_path.exists():
try:
raw = json.loads(self.session_path.read_text(encoding="utf-8"))
if (
isinstance(raw, dict)
and raw.get("boot_id") == self.boot_id
and isinstance(raw.get("deadline_monotonic"), (int, float))
and type(raw.get("dismissed")) is bool
and isinstance(raw.get("operation"), dict)
):
return raw, False
except (OSError, UnicodeError, json.JSONDecodeError):
logger.warning("Resetting unreadable runtime WiFi session")
self._write_session(default)
return default, True
def _write_config(self, value: dict[str, Any]) -> None:
payload = {"schema_version": WIFI_CONFIG_SCHEMA_VERSION, **value}
atomic_write_bytes(
self.path,
(json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
)
def _write_session(self, value: dict[str, Any]) -> None:
atomic_write_bytes(
self.session_path,
(json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
)
@@ -0,0 +1,28 @@
"""Offline, browser-uploaded full-release updates.
Keep package initialization dependency-free: release tooling imports the
stdlib-only package builder on bare Linux hosts that do not install the
controller's Pillow/FontTools runtime.
"""
from importlib import import_module
__all__ = [
"OtaBusyError",
"OtaManager",
"OtaPackageError",
"OtaUploadError",
"SoftwareVersion",
"inspect_package",
"read_software_version",
]
def __getattr__(name: str):
if name in {"OtaBusyError", "OtaManager", "OtaUploadError"}:
return getattr(import_module(".manager", __name__), name)
if name in {"OtaPackageError", "inspect_package"}:
return getattr(import_module(".package", __name__), name)
if name in {"SoftwareVersion", "read_software_version"}:
return getattr(import_module(".versioning", __name__), name)
raise AttributeError(name)
@@ -0,0 +1,206 @@
from __future__ import annotations
from datetime import datetime, timezone
import hashlib
import os
from pathlib import Path
import platform
import shlex
import shutil
import subprocess
import sys
from typing import Any
from app.persistence import atomic_write_bytes
MAX_FAILURE_LOG_BYTES = 1024 * 1024
FAILURE_LOG_RELATIVE = Path("ota") / "last-failure.log"
_TRUNCATION_MARKER = (
"\n\n========== 日志已截断 =========="
"\n原始日志超过 1 MiB;保留开头和最新的失败尾部。"
"\n========== 继续显示最新日志 ==========\n\n"
).encode("utf-8")
_HEAD_BYTES = 128 * 1024
def _timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def bounded_failure_log(body: bytes, *, maximum: int = MAX_FAILURE_LOG_BYTES) -> bytes:
normalized = bytes(body).decode("utf-8", errors="replace").encode("utf-8")
if len(normalized) <= maximum:
return normalized
head = min(_HEAD_BYTES, maximum // 4)
tail = maximum - head - len(_TRUNCATION_MARKER)
if tail <= 0:
return normalized[-maximum:]
return normalized[:head] + _TRUNCATION_MARKER + normalized[-tail:]
def failure_log_path(data_root: Path) -> Path:
return Path(data_root) / FAILURE_LOG_RELATIVE
def persist_failure_log(data_root: Path, runtime_log: Path) -> Path:
source = Path(runtime_log)
body = bounded_failure_log(source.read_bytes())
target = failure_log_path(data_root)
atomic_write_bytes(target, body)
try:
target.chmod(0o600)
except OSError:
target.unlink(missing_ok=True)
raise
return target
def clear_failure_log(data_root: Path) -> None:
failure_log_path(data_root).unlink(missing_ok=True)
def failure_log_metadata(data_root: Path, last_result: dict[str, Any] | None) -> tuple[bool, int]:
if not isinstance(last_result, dict) or last_result.get("status") != "failed":
return False, 0
path = failure_log_path(data_root)
try:
size = path.stat().st_size
except OSError:
return False, 0
if size <= 0 or size > MAX_FAILURE_LOG_BYTES:
return False, 0
return True, size
def read_failure_log(data_root: Path, last_result: dict[str, Any] | None) -> bytes | None:
available, expected_size = failure_log_metadata(data_root, last_result)
if not available:
return None
try:
body = failure_log_path(data_root).read_bytes()
except OSError:
return None
if len(body) != expected_size:
return None
return body
class DiagnosticLog:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._recent = bytearray()
self._degraded = False
def reset(self) -> None:
self._recent.clear()
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_bytes(b"")
except OSError:
self._degraded = True
self.write("OTA 诊断日志已创建")
def write(self, message: str) -> None:
rendered = str(message).replace("\r\n", "\n").replace("\r", "\n")
entry = f"[{_timestamp()}] {rendered}\n".encode("utf-8", errors="replace")
self._recent.extend(entry)
del self._recent[:-MAX_FAILURE_LOG_BYTES]
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
restore = self._degraded or not self.path.exists()
with self.path.open("wb" if restore else "ab") as handle:
handle.write(bytes(self._recent) if restore else entry)
self._degraded = False
except OSError as exc:
self._degraded = True
try:
# Avoid printing OS messages, paths or the command's content here.
sys.stderr.write(f"OTA diagnostic storage unavailable ({type(exc).__name__}); recovery continues\n")
except (OSError, UnicodeError):
pass
def persist(self, data_root: Path) -> Path:
try:
body = self.path.read_bytes() if not self._degraded else bytes(self._recent)
except OSError:
body = bytes(self._recent)
target = failure_log_path(data_root)
atomic_write_bytes(target, bounded_failure_log(body))
target.chmod(0o600)
return target
def section(self, name: str) -> None:
self.write(f"========== {name} ==========")
def host_summary(self, *, test_root: Path) -> None:
self.section("安全主机摘要")
self.write(f"system={platform.system()} release={platform.release()} machine={platform.machine()}")
self.write(f"python={sys.version.splitlines()[0]}")
for path in (Path("/opt"), Path("/run"), Path(test_root)):
probe = path if path.exists() else path.parent
try:
usage = shutil.disk_usage(probe)
self.write(f"space path={path} free={usage.free} total={usage.total}")
except OSError as exc:
self.write(f"space path={path} unavailable={exc.__class__.__name__}")
def run(
self,
command: list[str],
*,
cwd: Path | None = None,
env: dict[str, str] | None = None,
check: bool = True,
failure_label: str | None = None,
log_output: bool = True,
) -> subprocess.CompletedProcess[bytes]:
display = (
shlex.join([str(item) for item in command])
if log_output
else f"{shlex.quote(str(command[0]))} <参数已按脱敏规则隐藏>"
)
self.section(f"命令开始:{failure_label or Path(command[0]).name}")
self.write(f"cwd={cwd if cwd is not None else os.getcwd()}")
self.write(f"command={display}")
try:
result = subprocess.run(
command,
cwd=cwd,
env=env,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
except OSError as exc:
self.write(f"command_start_failed={exc.__class__.__name__}: {exc}")
raise RuntimeError(f"{failure_label or '命令'}无法启动:{exc}") from exc
output = result.stdout.decode("utf-8", errors="replace")
if output and log_output:
self.write("stdout_stderr:\n" + output.rstrip("\n"))
try:
sys.stdout.write(output)
sys.stdout.flush()
except (UnicodeError, OSError):
pass
elif output:
self.write("stdout_stderr=<已按脱敏规则隐藏>")
self.write(f"exit_code={result.returncode}")
if check and result.returncode != 0:
tail = (
output.rstrip()[-12000:] or "命令没有输出。"
if log_output
else "命令输出已按脱敏规则隐藏。"
)
raise RuntimeError(
f"{failure_label or '命令'}失败(退出码 {result.returncode}):\n{tail}"
)
return result
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import asyncio
import os
from pathlib import Path
import re
import shutil
import subprocess
import threading
from typing import Any, AsyncIterable, Callable
from uuid import uuid4
from app.display.service import DisplayService
from .diagnostics import failure_log_metadata, read_failure_log
from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package
from .state import read_json, read_last_result, utc_now, write_json
from .versioning import SoftwareVersion
_OTA_FILENAME = re.compile(r"^matrix-screen-controller-[0-9]+\.[0-9]+\.[0-9]+\.ota$")
class OtaUploadError(RuntimeError):
def __init__(self, message: str, *, status_code: int = 422) -> None:
super().__init__(message)
self.status_code = status_code
class OtaBusyError(OtaUploadError):
def __init__(self, message: str = "an OTA upload or update is already active") -> None:
super().__init__(message, status_code=409)
class OtaManager:
def __init__(
self,
*,
code_root: Path,
data_root: Path,
runtime_root: Path,
software_version: SoftwareVersion,
display: DisplayService,
worker_starter: Callable[[], None] | None = None,
staging_root: Path | None = None,
) -> None:
self.code_root = Path(code_root)
self.data_root = Path(data_root)
self.runtime_root = Path(runtime_root)
self.software_version = software_version
self.display = display
configured_staging = os.environ.get("MATRIX_OTA_STAGING_DIR")
if staging_root is not None:
self.staging_root = Path(staging_root)
elif configured_staging:
self.staging_root = Path(configured_staging)
elif os.name != "nt" and str(self.code_root).startswith("/opt/"):
self.staging_root = Path("/opt/matrix-screen-controller-ota")
else:
self.staging_root = self.runtime_root / "ota-staging"
self.status_path = self.runtime_root / "ota-status.json"
self.request_path = self.runtime_root / "ota-request.json"
self._worker_starter = worker_starter or self._start_systemd_worker
self._upload_lock = asyncio.Lock()
self._monitor_stop = threading.Event()
self._monitor_thread: threading.Thread | None = None
self._completion_callback: Callable[[], None] = lambda: None
def set_completion_callback(self, callback: Callable[[], None]) -> None:
self._completion_callback = callback
def status(self) -> dict[str, Any]:
active_document = read_json(self.status_path)
job = active_document.get("job") if isinstance(active_document, dict) else None
component_pending = (self.data_root / "ota/component-transaction").exists()
active = component_pending or bool(active_document and active_document.get("active") is True and isinstance(job, dict))
last_result = read_last_result(self.data_root)
failure_log_available, failure_log_bytes = failure_log_metadata(self.data_root, last_result)
return {
"current_version": str(self.software_version),
"max_upload_bytes": MAX_OTA_UPLOAD_BYTES,
"active": active,
"component_recovery_pending": component_pending,
"job": dict(job) if active or isinstance(job, dict) else None,
"last_result": last_result,
"failure_log_available": failure_log_available,
"failure_log_bytes": failure_log_bytes,
}
def failure_log(self) -> bytes | None:
last_result = read_last_result(self.data_root)
return read_failure_log(self.data_root, last_result)
def is_active(self) -> bool:
return self.status()["active"] or self._upload_lock.locked()
async def accept_upload(
self,
*,
filename: str,
chunks: AsyncIterable[bytes],
content_length: int | None,
orientation: int,
) -> dict[str, Any]:
if Path(filename).name != filename or not _OTA_FILENAME.fullmatch(filename):
raise OtaUploadError(
"OTA filename must be matrix-screen-controller-MAJOR.MINOR.PATCH.ota",
status_code=400,
)
if content_length is not None and (content_length <= 0 or content_length > MAX_OTA_UPLOAD_BYTES):
raise OtaUploadError("OTA package exceeds the 256 MiB upload limit", status_code=413)
if self._upload_lock.locked() or self.status()["active"]:
raise OtaBusyError()
async with self._upload_lock:
if self.status()["active"]:
raise OtaBusyError()
upload_root = self.staging_root / "uploads"
upload_root.mkdir(parents=True, exist_ok=True)
temporary = upload_root / f".{uuid4().hex}.part"
final: Path | None = None
total = 0
try:
with temporary.open("xb") as handle:
os.chmod(temporary, 0o600)
async for chunk in chunks:
if not chunk:
continue
total += len(chunk)
if total > MAX_OTA_UPLOAD_BYTES:
raise OtaUploadError("OTA package exceeds the 256 MiB upload limit", status_code=413)
handle.write(chunk)
handle.flush()
os.fsync(handle.fileno())
if total == 0:
raise OtaUploadError("OTA package is empty", status_code=400)
if content_length is not None and total != content_length:
raise OtaUploadError("OTA upload length does not match Content-Length", status_code=400)
try:
package = inspect_package(temporary, current_version=self.software_version)
except OtaPackageError as exc:
message = str(exc)
status_code = 409 if "must be newer" in message else 422
raise OtaUploadError(message, status_code=status_code) from exc
expected_name = f"matrix-screen-controller-{package.target_version}.ota"
if filename != expected_name:
raise OtaUploadError("OTA filename version does not match its manifest", status_code=422)
job_id = uuid4().hex
final = upload_root / f"{job_id}.ota"
os.replace(temporary, final)
request = {
"schema_version": 1,
"job_id": job_id,
"package_path": str(final),
"status_path": str(self.status_path),
"request_path": str(self.request_path),
"current_version": str(self.software_version),
"target_version": str(package.target_version),
"packaged_at": package.created_at,
"release_notes": package.release_notes,
"orientation": int(orientation),
"accepted_at": utc_now(),
}
job = {
"id": job_id,
"target_version": str(package.target_version),
"packaged_at": package.created_at,
"phase": "queued",
"percent": 5,
"message": "软件安装包已校验,准备检查并安装系统组件" if package.target_version.patch == 0 else "更新包已校验,准备安装",
"started_at": request["accepted_at"],
"finished_at": None,
"error": None,
}
write_json(self.request_path, request)
write_json(self.status_path, {"schema_version": 1, "active": True, "job": job})
self.display.start_ota_indicator(self.status_path)
try:
self._worker_starter()
except Exception as exc:
failed = dict(job)
failed.update(
phase="failed",
percent=0,
message="无法启动独立 OTA 工作服务",
finished_at=utc_now(),
error=str(exc),
)
write_json(self.status_path, {"schema_version": 1, "active": False, "job": failed})
self.display.stop_ota_indicator()
raise OtaUploadError("independent OTA worker could not be started", status_code=500) from exc
self.resume_or_start_monitor(self._completion_callback)
return {"accepted": True, "job": job}
except BaseException:
temporary.unlink(missing_ok=True)
if final is not None and not self.status()["active"]:
final.unlink(missing_ok=True)
raise
def resume_or_start_monitor(self, on_finished: Callable[[], None]) -> bool:
if not self.status()["active"]:
return False
self.display.start_ota_indicator(self.status_path)
if self._monitor_thread is not None and self._monitor_thread.is_alive():
return True
self._monitor_stop.clear()
def monitor() -> None:
while not self._monitor_stop.wait(0.25):
if self.status()["active"]:
continue
try:
self.display.stop_ota_indicator()
on_finished()
except Exception:
pass
return
self._monitor_thread = threading.Thread(target=monitor, name="ota-status-monitor", daemon=True)
self._monitor_thread.start()
return True
def close(self) -> None:
self._monitor_stop.set()
thread = self._monitor_thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
@staticmethod
def _start_systemd_worker() -> None:
result = subprocess.run(
["systemctl", "start", "--no-block", "matrix-screen-controller-ota.service"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "systemctl start failed").strip())
+294
View File
@@ -0,0 +1,294 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path, PurePosixPath
import shutil
import tarfile
import tempfile
from typing import Any, BinaryIO
import zipfile
from .versioning import SoftwareVersion
from .policy import check_upgrade, package_format, release_metadata
PRODUCT_ID = "matrix-screen-controller-walnutpi"
PACKAGE_FORMAT_VERSION = 1
MAX_OTA_UPLOAD_BYTES = 256 * 1024 * 1024
MAX_OTA_PAYLOAD_BYTES = 256 * 1024 * 1024
MAX_OTA_EXPANDED_BYTES = 1024 * 1024 * 1024
_MAX_MANIFEST_BYTES = 64 * 1024
_PACKAGE_MEMBERS = {"manifest.json", "payload.tar.gz"}
_NATIVE_BUILD_OUTPUTS = {
"app/display/native/libh618_hub75.so",
"app/display/native/hub75_benchmark",
"app/display/native/hub75_native_test",
"app/display/native/hub75_safeoff",
}
_EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"}
class OtaPackageError(ValueError):
"""The uploaded archive is not a supported complete release."""
@dataclass(frozen=True)
class OtaPackageInfo:
path: Path
target_version: SoftwareVersion
created_at: str
release_notes: str
payload_sha256: str
payload_bytes: int
expanded_bytes: int
def document(self) -> dict[str, Any]:
return {
"target_version": str(self.target_version),
"created_at": self.created_at,
"release_notes": self.release_notes,
"payload_sha256": self.payload_sha256,
"payload_bytes": self.payload_bytes,
"expanded_bytes": self.expanded_bytes,
**release_metadata(self.target_version),
}
def _validated_manifest(raw: bytes) -> tuple[dict[str, Any], SoftwareVersion]:
if len(raw) > _MAX_MANIFEST_BYTES:
raise OtaPackageError("OTA manifest is too large")
try:
manifest = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise OtaPackageError("OTA manifest is not valid UTF-8 JSON") from exc
expected = {
"format_version",
"product",
"software_version",
"created_at",
"release_notes",
"payload_sha256",
"payload_bytes",
"expanded_bytes",
}
if not isinstance(manifest, dict) or set(manifest) != expected:
raise OtaPackageError("OTA manifest fields do not match format version 1")
if type(manifest["format_version"]) is not int or manifest["format_version"] not in (1, 2):
raise OtaPackageError("OTA package format is unsupported")
if manifest["product"] != PRODUCT_ID:
raise OtaPackageError("OTA package is for a different product")
try:
version = SoftwareVersion.parse(manifest["software_version"])
if manifest["format_version"] != package_format(version):
raise ValueError("OTA version requires a different package format")
except ValueError as exc:
raise OtaPackageError(str(exc)) from exc
if not isinstance(manifest["created_at"], str) or not manifest["created_at"].strip():
raise OtaPackageError("OTA package created_at is missing")
if not isinstance(manifest["release_notes"], str) or len(manifest["release_notes"]) > 4000:
raise OtaPackageError("OTA release notes are invalid")
digest = manifest["payload_sha256"]
if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise OtaPackageError("OTA payload SHA-256 is invalid")
for key, maximum in (
("payload_bytes", MAX_OTA_PAYLOAD_BYTES),
("expanded_bytes", MAX_OTA_EXPANDED_BYTES),
):
value = manifest[key]
if type(value) is not int or value <= 0 or value > maximum:
raise OtaPackageError(f"OTA {key} is outside the supported limit")
return manifest, version
def _payload_digest(handle: BinaryIO) -> tuple[str, int]:
digest = hashlib.sha256()
total = 0
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_OTA_PAYLOAD_BYTES:
raise OtaPackageError("OTA payload exceeds the supported limit")
digest.update(chunk)
return digest.hexdigest(), total
def inspect_package(path: Path, *, current_version: SoftwareVersion | None = None) -> OtaPackageInfo:
package_path = Path(path)
try:
size = package_path.stat().st_size
except OSError as exc:
raise OtaPackageError("OTA package cannot be read") from exc
if size <= 0 or size > MAX_OTA_UPLOAD_BYTES:
raise OtaPackageError("OTA package exceeds the 256 MiB upload limit")
try:
with zipfile.ZipFile(package_path, "r") as archive:
entries = archive.infolist()
if {entry.filename for entry in entries} != _PACKAGE_MEMBERS or len(entries) != 2:
raise OtaPackageError("OTA package must contain only manifest.json and payload.tar.gz")
if any(entry.is_dir() or entry.flag_bits & 0x1 for entry in entries):
raise OtaPackageError("OTA package entries must be unencrypted files")
manifest_entry = archive.getinfo("manifest.json")
payload_entry = archive.getinfo("payload.tar.gz")
if manifest_entry.file_size > _MAX_MANIFEST_BYTES:
raise OtaPackageError("OTA manifest is too large")
if payload_entry.file_size > MAX_OTA_PAYLOAD_BYTES:
raise OtaPackageError("OTA payload exceeds the supported limit")
manifest, target_version = _validated_manifest(archive.read(manifest_entry))
with archive.open(payload_entry, "r") as payload:
digest, payload_bytes = _payload_digest(payload)
except (OtaPackageError, zipfile.BadZipFile):
raise
except (KeyError, OSError, RuntimeError) as exc:
raise OtaPackageError("OTA package cannot be inspected") from exc
if payload_bytes != manifest["payload_bytes"] or digest != manifest["payload_sha256"]:
raise OtaPackageError("OTA payload checksum or size does not match the manifest")
if current_version is not None:
try:
check_upgrade(current_version, target_version)
except ValueError as exc:
raise OtaPackageError(str(exc)) from exc
return OtaPackageInfo(
path=package_path,
target_version=target_version,
created_at=manifest["created_at"],
release_notes=manifest["release_notes"],
payload_sha256=digest,
payload_bytes=payload_bytes,
expanded_bytes=manifest["expanded_bytes"],
)
def _safe_member_path(name: str) -> Path:
pure = PurePosixPath(name)
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
raise OtaPackageError(f"unsafe OTA payload path: {name}")
if pure.parts[0] not in {"software", "wheelhouse", "system-dependencies"}:
raise OtaPackageError(f"unexpected OTA payload root: {pure.parts[0]}")
return Path(*pure.parts)
def extract_payload(package: OtaPackageInfo, destination: Path) -> None:
target = Path(destination)
target.mkdir(parents=True, exist_ok=False)
expanded = 0
try:
with zipfile.ZipFile(package.path, "r") as archive:
with archive.open("payload.tar.gz", "r") as payload:
with tarfile.open(fileobj=payload, mode="r|gz") as tar:
for member in tar:
relative = _safe_member_path(member.name)
output = target / relative
if member.isdir():
output.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
raise OtaPackageError("OTA payload may contain only regular files and directories")
expanded += member.size
if expanded > MAX_OTA_EXPANDED_BYTES or expanded > package.expanded_bytes:
raise OtaPackageError("OTA payload expands beyond its declared limit")
output.parent.mkdir(parents=True, exist_ok=True)
source = tar.extractfile(member)
if source is None:
raise OtaPackageError(f"OTA payload member cannot be read: {member.name}")
with output.open("xb") as handle:
shutil.copyfileobj(source, handle, length=1024 * 1024)
output.chmod(member.mode & 0o777 or 0o644)
if expanded != package.expanded_bytes:
raise OtaPackageError("OTA expanded size does not match the manifest")
if not (target / "software" / "VERSION").is_file():
raise OtaPackageError("OTA payload does not contain software/VERSION")
if not (target / "wheelhouse" / "SHA256SUMS").is_file():
raise OtaPackageError("OTA payload does not contain the offline wheel manifest")
except BaseException:
shutil.rmtree(target, ignore_errors=True)
raise
def _source_file_allowed(path: Path, source_root: Path) -> bool:
relative = path.relative_to(source_root)
if any(part in _EXCLUDED_PARTS | {"system-dependencies", "output", ".playwright-cli"} for part in relative.parts):
return False
if relative.as_posix() in _NATIVE_BUILD_OUTPUTS:
return False
if path.suffix in {".pyc", ".pyo"}:
return False
return True
def _tar_add_file(tar: tarfile.TarFile, source: Path, archive_name: str) -> int:
info = tar.gettarinfo(str(source), arcname=archive_name)
info.uid = info.gid = 0
info.uname = info.gname = "root"
info.mtime = 0
info.mode = 0o755 if source.suffix == ".sh" else 0o644
with source.open("rb") as handle:
tar.addfile(info, handle)
return info.size
def build_package(
source_root: Path,
wheelhouse: Path,
output_path: Path,
*,
version: SoftwareVersion,
release_notes: str,
created_at: datetime | None = None,
system_dependencies: Path | None = None,
) -> OtaPackageInfo:
source_root = Path(source_root).resolve()
wheelhouse = Path(wheelhouse).resolve()
system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None
if version.patch != 0 and version >= SoftwareVersion(1, 1, 0) and system_dependencies is not None:
raise OtaPackageError("普通补丁包不得携带系统软件,请导出新的 minor .0 安装包")
if SoftwareVersion.parse((source_root / "VERSION").read_text(encoding="utf-8")) != version:
raise OtaPackageError("requested package version does not match source VERSION")
if not (wheelhouse / "SHA256SUMS").is_file():
raise OtaPackageError("offline wheelhouse SHA256SUMS is missing")
if system_dependencies is not None and not (system_dependencies / "SHA256SUMS").is_file():
raise OtaPackageError("offline system dependency SHA256SUMS is missing")
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
raise FileExistsError(output)
timestamp = (created_at or datetime.now(timezone.utc)).astimezone().isoformat(timespec="seconds")
with tempfile.TemporaryDirectory(prefix="matrix-ota-build-") as temporary:
payload_path = Path(temporary) / "payload.tar.gz"
expanded = 0
with tarfile.open(payload_path, "w:gz", format=tarfile.PAX_FORMAT) as tar:
for path in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix()):
if path.is_file() and _source_file_allowed(path, source_root):
expanded += _tar_add_file(tar, path, f"software/{path.relative_to(source_root).as_posix()}")
for path in sorted(wheelhouse.rglob("*"), key=lambda item: item.relative_to(wheelhouse).as_posix()):
if path.is_file():
expanded += _tar_add_file(tar, path, f"wheelhouse/{path.relative_to(wheelhouse).as_posix()}")
if system_dependencies is not None:
for path in sorted(system_dependencies.rglob("*"), key=lambda item: item.relative_to(system_dependencies).as_posix()):
if path.is_file():
expanded += _tar_add_file(
tar,
path,
f"software/system-dependencies/frpc/{path.relative_to(system_dependencies).as_posix()}",
)
payload_bytes = payload_path.stat().st_size
if payload_bytes > MAX_OTA_PAYLOAD_BYTES or expanded > MAX_OTA_EXPANDED_BYTES:
raise OtaPackageError("generated OTA payload exceeds the supported limit")
digest = hashlib.sha256(payload_path.read_bytes()).hexdigest()
manifest = {
"format_version": package_format(version),
"product": PRODUCT_ID,
"software_version": str(version),
"created_at": timestamp,
"release_notes": str(release_notes).strip(),
"payload_sha256": digest,
"payload_bytes": payload_bytes,
"expanded_bytes": expanded,
}
with zipfile.ZipFile(output, "x", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
archive.write(payload_path, "payload.tar.gz")
return inspect_package(output)
@@ -0,0 +1,83 @@
"""Ordered dependency checkpoints, independent of removable release artifacts."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from .versioning import SoftwareVersion
BRIDGE_VERSION = SoftwareVersion(1, 1, 0)
def check_upgrade(current: SoftwareVersion, target: SoftwareVersion) -> None:
if target <= current:
raise ValueError(f"OTA target {target} must be newer than installed version {current}")
if current.major != target.major:
raise ValueError("跨主版本升级尚未登记,请使用明确支持此路径的安装包")
if target.minor > current.minor:
required = SoftwareVersion(current.major, current.minor + 1, 0)
if target != required:
raise ValueError(f"请先安装 {required} 软件安装包,不能跳过必经升级版本")
def package_format(version: SoftwareVersion) -> int:
return 1 if version <= BRIDGE_VERSION else 2
def release_metadata(version: SoftwareVersion) -> dict:
checkpoint = version.patch == 0 and version >= BRIDGE_VERSION
predecessor = None
if version >= BRIDGE_VERSION:
predecessor = str(SoftwareVersion(version.major, version.minor - 1 if checkpoint else version.minor, 0))
return {"package_kind": "software-install" if checkpoint else "application",
"required_checkpoint": predecessor, "is_checkpoint": checkpoint}
def read_policy(source: Path) -> dict:
value = json.loads((source / "UPGRADE_POLICY.json").read_text(encoding="utf-8"))
if not isinstance(value, dict) or set(value) != {"schema_version", "checkpoints"} or value["schema_version"] != 1:
raise ValueError("invalid upgrade policy")
previous = SoftwareVersion(1, 0, 0)
for entry in value["checkpoints"]:
version = SoftwareVersion.parse(entry["version"])
if version != SoftwareVersion(previous.major, previous.minor + 1, 0) or not entry["components"]:
raise ValueError("dependency checkpoints must be consecutive minor .0 versions")
for name, component in entry["components"].items():
if name != "frpc" or set(component) != {"version", "sha256", "bundle"}:
raise ValueError("component installer is not registered")
if len(component["sha256"]) != 64 or any(c not in "0123456789abcdef" for c in component["sha256"]):
raise ValueError("invalid component digest")
bundle = Path(component["bundle"])
if bundle.is_absolute() or ".." in bundle.parts or "\\" in component["bundle"]:
raise ValueError("unsafe component bundle path")
previous = version
return value
def required_components(source: Path, version: SoftwareVersion) -> dict:
components = {}
for entry in read_policy(source)["checkpoints"]:
if SoftwareVersion.parse(entry["version"]) <= version:
components.update(entry["components"])
return components
def export_bundle(source: Path, dependency_root: Path, current: SoftwareVersion, target: SoftwareVersion) -> Path | None:
check_upgrade(current, target)
policy = read_policy(source)
if policy["checkpoints"] and target < SoftwareVersion.parse(policy["checkpoints"][-1]["version"]):
raise ValueError("源码已引入新系统依赖;必须先导出已登记的 minor .0 软件安装包")
matching = [e for e in policy["checkpoints"] if e["version"] == str(target)]
if target.patch == 0 and not matching:
raise ValueError("软件安装包必须登记本次依赖变化")
components = required_components(source, target)
# Verify the actual offline source, including on patch exports: silently replacing
# a binary at the same version must not bypass a dependency checkpoint.
for component in components.values():
binary = dependency_root / component["bundle"] / "frpc"
if hashlib.sha256(binary.read_bytes()).hexdigest() != component["sha256"]:
raise ValueError("系统依赖发生变化;请登记新的 minor .0 软件安装版本")
if not matching:
return None
return dependency_root / matching[0]["components"]["frpc"]["bundle"]
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
from app.persistence import atomic_write_bytes
OTA_STATE_SCHEMA_VERSION = 1
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def read_json(path: Path) -> dict[str, Any] | None:
try:
value = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def write_json(path: Path, document: dict[str, Any]) -> None:
atomic_write_bytes(
Path(path),
(json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8"),
)
def read_last_result(data_root: Path) -> dict[str, Any] | None:
document = read_json(Path(data_root) / "ota" / "state.json")
if not document or document.get("schema_version") != OTA_STATE_SCHEMA_VERSION:
return None
result = document.get("last_result")
return dict(result) if isinstance(result, dict) else None
def write_last_result(data_root: Path, result: dict[str, Any]) -> None:
write_json(
Path(data_root) / "ota" / "state.json",
{"schema_version": OTA_STATE_SCHEMA_VERSION, "last_result": dict(result)},
)
@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
import re
_VERSION_PATTERN = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
_FEATURE_UPDATED_AT_PATTERN = re.compile(
r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]\+08:00$"
)
@dataclass(frozen=True, order=True)
class SoftwareVersion:
major: int
minor: int
patch: int
@classmethod
def parse(cls, value: str) -> "SoftwareVersion":
if not isinstance(value, str):
raise ValueError("software version must be a string")
match = _VERSION_PATTERN.fullmatch(value.strip())
if match is None:
raise ValueError("software version must use strict MAJOR.MINOR.PATCH digits")
return cls(*(int(part) for part in match.groups()))
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
def read_software_version(code_root: Path) -> SoftwareVersion:
path = Path(code_root) / "VERSION"
try:
return SoftwareVersion.parse(path.read_text(encoding="utf-8"))
except OSError as exc:
raise RuntimeError(f"software VERSION file is unreadable: {path}") from exc
def read_feature_updated_at(code_root: Path) -> str:
path = Path(code_root) / "FEATURE_UPDATED_AT"
try:
value = path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise RuntimeError(f"feature update timestamp file is unreadable: {path}") from exc
if _FEATURE_UPDATED_AT_PATTERN.fullmatch(value) is None:
raise ValueError("feature update timestamp must use YYYY-MM-DDTHH:MM+08:00")
try:
parsed = datetime.fromisoformat(value)
except ValueError as exc:
raise ValueError("feature update timestamp is not a valid calendar time") from exc
if parsed.utcoffset() != timedelta(hours=8):
raise ValueError("feature update timestamp must use Beijing UTC+08:00")
return value
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Iterable
from uuid import uuid4
logger = logging.getLogger(__name__)
def atomic_write_bytes(path: Path, content: bytes) -> None:
"""Durably replace *path* without exposing a partially written file."""
atomic_write_many_bytes(((Path(path), content),))
def atomic_write_many_bytes(writes: Iterable[tuple[Path, bytes]]) -> None:
"""Durably replace one or more files and roll back a failed transaction."""
entries = [(Path(path), bytes(content)) for path, content in writes]
if not entries:
return
targets = [path for path, _content in entries]
if len(set(targets)) != len(targets):
raise ValueError("atomic write transaction contains duplicate paths")
originals: dict[Path, bytes | None] = {}
temporaries: dict[Path, Path] = {}
replaced: list[Path] = []
parents = sorted({target.parent for target in targets}, key=str)
try:
for target, _content in entries:
target.parent.mkdir(parents=True, exist_ok=True)
originals[target] = target.read_bytes() if target.exists() else None
for target, content in entries:
temporary = _write_temporary(target, content)
temporaries[target] = temporary
for parent in parents:
_fsync_directory(parent)
for target, _content in entries:
os.replace(temporaries[target], target)
replaced.append(target)
for parent in parents:
_fsync_directory(parent)
except BaseException as original_error:
_cleanup_temporaries(temporaries.values())
if replaced:
try:
_restore_originals(replaced, originals)
except BaseException as recovery_error:
logger.critical(
"Unable to roll back failed durable write transaction",
exc_info=recovery_error,
)
raise RuntimeError(
"durable write failed and its previous files could not be restored"
) from original_error
raise
def _write_temporary(target: Path, content: bytes) -> Path:
temporary = target.with_name(f".{target.name}.{uuid4().hex}.tmp")
try:
with temporary.open("xb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
except BaseException:
temporary.unlink(missing_ok=True)
raise
return temporary
def _cleanup_temporaries(paths: Iterable[Path]) -> None:
for path in paths:
try:
path.unlink(missing_ok=True)
except OSError:
logger.warning("Unable to clean temporary persistence file %s", path, exc_info=True)
def _restore_originals(
replaced: Iterable[Path],
originals: dict[Path, bytes | None],
) -> None:
targets = list(replaced)
recovery_files: dict[Path, Path] = {}
try:
for target in targets:
original = originals[target]
if original is not None:
recovery_files[target] = _write_temporary(target, original)
for target in targets:
recovery = recovery_files.get(target)
if recovery is None:
target.unlink(missing_ok=True)
else:
os.replace(recovery, target)
for parent in sorted({target.parent for target in targets}, key=str):
_fsync_directory(parent)
finally:
_cleanup_temporaries(recovery_files.values())
def _fsync_directory(directory: Path) -> None:
"""Sync a directory entry where the platform supports directory handles."""
if os.name == "nt":
return
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
descriptor = os.open(directory, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
@@ -0,0 +1,5 @@
"""屏幕供电监测组件。"""
from app.power.voltage import VoltageMonitor
__all__ = ["VoltageMonitor"]
@@ -0,0 +1,207 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from decimal import Decimal, ROUND_FLOOR
LOW_VOLTAGE_CRITICAL_THRESHOLD = 4.5
LOW_VOLTAGE_LIMIT_THRESHOLD = 4.8
LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT = 35
PROTECTION_MODE_DISABLED = "disabled"
PROTECTION_MODE_UNAVAILABLE = "unavailable"
PROTECTION_MODE_INACTIVE = "inactive"
PROTECTION_MODE_LIMITING = "limiting"
PROTECTION_MODE_CRITICAL = "critical"
NORMAL_SAMPLING_INTERVAL_SECONDS = 5.0
LIMITING_SAMPLING_INTERVAL_SECONDS = 1.0
FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5
MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5
MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS = 2.0
MAX_CRITICAL_SLOWDOWN_STEP_SECONDS = 0.5
ERROR_RETRY_INTERVALS_SECONDS = (1.0, 2.0, 5.0)
@dataclass(frozen=True)
class LowVoltageProtectionDirective:
sequence: int
enabled: bool
mode: str
brightness_limit_percent: int | None
reading_stale: bool
def brightness_limit_for_voltage(volts: float) -> int | None:
"""Return the output cap for a calibrated, unrounded voltage reading."""
voltage = float(volts)
if not math.isfinite(voltage):
raise ValueError("voltage must be finite")
if voltage > LOW_VOLTAGE_LIMIT_THRESHOLD:
return None
if voltage < LOW_VOLTAGE_CRITICAL_THRESHOLD:
return LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT
if voltage == LOW_VOLTAGE_LIMIT_THRESHOLD:
return 100
if voltage == LOW_VOLTAGE_CRITICAL_THRESHOLD:
return 50
decimal_voltage = Decimal(str(voltage))
limit = int(
(
Decimal(50)
+ Decimal(50)
* (decimal_voltage - Decimal("4.5"))
/ Decimal("0.3")
).to_integral_value(rounding=ROUND_FLOOR)
)
return max(50, min(100, limit))
def protection_directive_for_voltage(
sequence: int,
enabled: bool,
volts: float | None,
*,
reading_stale: bool = False,
) -> LowVoltageProtectionDirective:
if not enabled:
return LowVoltageProtectionDirective(
sequence=sequence,
enabled=False,
mode=PROTECTION_MODE_DISABLED,
brightness_limit_percent=None,
reading_stale=False,
)
if volts is None:
return LowVoltageProtectionDirective(
sequence=sequence,
enabled=True,
mode=PROTECTION_MODE_UNAVAILABLE,
brightness_limit_percent=None,
reading_stale=reading_stale,
)
limit = brightness_limit_for_voltage(volts)
if volts > LOW_VOLTAGE_LIMIT_THRESHOLD:
mode = PROTECTION_MODE_INACTIVE
limit = None
elif volts < LOW_VOLTAGE_CRITICAL_THRESHOLD:
mode = PROTECTION_MODE_CRITICAL
else:
mode = PROTECTION_MODE_LIMITING
return LowVoltageProtectionDirective(
sequence=sequence,
enabled=True,
mode=mode,
brightness_limit_percent=limit,
reading_stale=reading_stale,
)
def same_protection_level(
left: LowVoltageProtectionDirective,
right: LowVoltageProtectionDirective,
) -> bool:
return (
left.enabled == right.enabled
and left.mode == right.mode
and left.brightness_limit_percent == right.brightness_limit_percent
)
def is_more_restrictive(
candidate: LowVoltageProtectionDirective,
current: LowVoltageProtectionDirective,
) -> bool:
ranks = {
PROTECTION_MODE_DISABLED: 0,
PROTECTION_MODE_UNAVAILABLE: 0,
PROTECTION_MODE_INACTIVE: 0,
PROTECTION_MODE_LIMITING: 1,
PROTECTION_MODE_CRITICAL: 2,
}
candidate_rank = ranks[candidate.mode]
current_rank = ranks[current.mode]
if candidate_rank != current_rank:
return candidate_rank > current_rank
if candidate.mode == PROTECTION_MODE_LIMITING:
assert candidate.brightness_limit_percent is not None
assert current.brightness_limit_percent is not None
return candidate.brightness_limit_percent < current.brightness_limit_percent
return False
def more_conservative(
left: LowVoltageProtectionDirective,
right: LowVoltageProtectionDirective,
) -> LowVoltageProtectionDirective:
"""Choose the stricter directive while retaining the newest sequence on a tie."""
if is_more_restrictive(left, right):
return left
if is_more_restrictive(right, left):
return right
return right if right.sequence >= left.sequence else left
def error_retry_interval(consecutive_errors: int) -> float:
if consecutive_errors <= 0:
return NORMAL_SAMPLING_INTERVAL_SECONDS
index = min(consecutive_errors, len(ERROR_RETRY_INTERVALS_SECONDS)) - 1
return ERROR_RETRY_INTERVALS_SECONDS[index]
def successful_sampling_interval(
*,
enabled: bool,
mode: str,
volts: float,
previous_volts: float | None,
elapsed_seconds: float | None,
previous_interval_seconds: float,
first_critical_sample: bool,
) -> float:
"""Return the next group-start interval for a successful voltage sample."""
if not enabled:
return NORMAL_SAMPLING_INTERVAL_SECONDS
if mode == PROTECTION_MODE_LIMITING:
return LIMITING_SAMPLING_INTERVAL_SECONDS
if mode == PROTECTION_MODE_CRITICAL:
if first_critical_sample:
return FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS
rate = _voltage_rate(volts, previous_volts, elapsed_seconds)
target = _clamp(
MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS,
MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS,
0.02 / max(abs(rate), 0.01),
)
if target > previous_interval_seconds:
return min(
target,
previous_interval_seconds + MAX_CRITICAL_SLOWDOWN_STEP_SECONDS,
)
return target
if mode == PROTECTION_MODE_INACTIVE:
rate = _voltage_rate(volts, previous_volts, elapsed_seconds)
if rate < 0:
seconds_to_limit = (volts - LOW_VOLTAGE_LIMIT_THRESHOLD) / -rate
return _clamp(1.0, NORMAL_SAMPLING_INTERVAL_SECONDS, seconds_to_limit)
return NORMAL_SAMPLING_INTERVAL_SECONDS
def _voltage_rate(
volts: float,
previous_volts: float | None,
elapsed_seconds: float | None,
) -> float:
if (
previous_volts is None
or elapsed_seconds is None
or not math.isfinite(elapsed_seconds)
or elapsed_seconds <= 0
):
return 0.0
return (volts - previous_volts) / elapsed_seconds
def _clamp(minimum: float, maximum: float, value: float) -> float:
return max(minimum, min(maximum, value))
@@ -0,0 +1,754 @@
from __future__ import annotations
import errno
import copy
import logging
import math
import os
import statistics
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Protocol
from uuid import uuid4
from app.config.store import ConfigStore
from app.power.protection import (
NORMAL_SAMPLING_INTERVAL_SECONDS,
LowVoltageProtectionDirective,
error_retry_interval,
is_more_restrictive,
more_conservative,
protection_directive_for_voltage,
same_protection_level,
successful_sampling_interval,
)
logger = logging.getLogger(__name__)
DEFAULT_BUS = "/dev/i2c-1"
DEFAULT_ADDRESS = 0x48
I2C_SLAVE = 0x0703
CONFIG_CONTINUOUS_15SPS_PGA1 = 0x0C
CONFIG_VALUE_MASK = 0x7F
DATA_READY_MASK = 0x80
REFERENCE_VOLTS = 2.048
ADC_CODE_SCALE = 32768
BOARD_DIVIDER_RATIO = 6.1
CONVERSION_WAIT_SECONDS = (1.0 / 15.0) + 0.010
FRESH_DATA_TIMEOUT_SECONDS = 0.75
NORMAL_SAMPLE_COUNT = 5
CALIBRATION_SAMPLE_COUNT = 15
POLL_INTERVAL_SECONDS = 5.0
CALIBRATION_PROPOSAL_SECONDS = 120
CALIBRATION_REFERENCE_MIN = 4.5
CALIBRATION_REFERENCE_MAX = 5.5
CALIBRATION_FACTOR_MIN = 0.8
CALIBRATION_FACTOR_MAX = 1.2
CALIBRATION_MAX_SPREAD_VOLTS = 0.05
PROTECTION_ENFORCEMENT_ERROR_CODE = "display_enforcement_failed"
class AdcError(RuntimeError):
code = "adc_error"
class AdcDisconnectedError(AdcError):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class AdcProtocolError(AdcError):
code = "protocol_error"
class VoltageMonitorError(RuntimeError):
pass
class CalibrationError(VoltageMonitorError):
pass
class CalibrationUnavailableError(CalibrationError):
pass
class CalibrationConflictError(CalibrationError):
pass
class AdcDevice(Protocol):
def __enter__(self) -> "AdcDevice": ...
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: ...
def configure(self) -> None: ...
def read_fresh(self) -> tuple[int, int]: ...
@dataclass(frozen=True)
class SampleGroup:
raw_median: float
uncalibrated_volts: float
config: int
spread_volts: float
@dataclass(frozen=True)
class CalibrationProposal:
proposal_id: str
reference_volts: float
uncalibrated_volts: float
current_factor: float
proposed_factor: float
created_at: datetime
expires_at: datetime
def response(self) -> dict[str, Any]:
return {
"proposal_id": self.proposal_id,
"created_at": isoformat_utc(self.created_at),
"expires_at": isoformat_utc(self.expires_at),
"reference_volts": self.reference_volts,
"uncalibrated_volts": self.uncalibrated_volts,
"current_factor": self.current_factor,
"proposed_factor": self.proposed_factor,
"projected_volts": self.uncalibrated_volts * self.proposed_factor,
}
def isoformat_utc(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def voltage_from_raw(raw: int | float, calibration_factor: float = 1.0) -> float:
if not -32768 <= raw <= 32767:
raise ValueError("ADS1110 raw 必须在 -32768 到 32767 之间")
if not math.isfinite(calibration_factor) or calibration_factor <= 0:
raise ValueError("校准系数必须是正有限数")
return float(raw) * REFERENCE_VOLTS / ADC_CODE_SCALE * BOARD_DIVIDER_RATIO * calibration_factor
def validate_conversion(data: bytes) -> tuple[int, int]:
if len(data) != 3:
raise AdcProtocolError(f"ADS1110 应返回 3 字节,实际收到 {len(data)} 字节")
raw = int.from_bytes(data[:2], byteorder="big", signed=True)
config = data[2]
if (config & CONFIG_VALUE_MASK) != CONFIG_CONTINUOUS_15SPS_PGA1:
raise AdcProtocolError(
f"ADS1110 配置不符:收到 0x{config:02X},低 7 位应为 0x0C"
)
return raw, config
def disconnected_from_os_error(action: str, exc: OSError) -> AdcDisconnectedError:
remote_io = getattr(errno, "EREMOTEIO", 121)
if exc.errno in (remote_io, errno.ENXIO):
return AdcDisconnectedError("address_no_response", f"{action}:I2C 地址 0x48 没有响应")
if exc.errno == errno.EBUSY:
return AdcDisconnectedError("device_busy", f"{action}:I2C 地址 0x48 已被占用")
if exc.errno in (errno.EACCES, errno.EPERM):
return AdcDisconnectedError("permission_denied", f"{action}:没有 I2C 访问权限")
return AdcDisconnectedError("io_error", f"{action}:{exc.strerror or exc}")
class Ads1110Device:
def __init__(self, bus_path: str = DEFAULT_BUS, address: int = DEFAULT_ADDRESS) -> None:
self.bus_path = bus_path
self.address = address
self._fd: int | None = None
def __enter__(self) -> "Ads1110Device":
if os.name != "posix":
raise AdcDisconnectedError("unsupported_platform", "真实 I2C 读取只支持 Linux")
try:
import fcntl
except ImportError as exc:
raise AdcDisconnectedError("unsupported_platform", "当前 Python 缺少 Linux fcntl") from exc
try:
self._fd = os.open(self.bus_path, os.O_RDWR)
except FileNotFoundError as exc:
raise AdcDisconnectedError("bus_missing", f"没有找到 {self.bus_path}") from exc
except PermissionError as exc:
raise AdcDisconnectedError("permission_denied", f"没有权限打开 {self.bus_path}") from exc
except OSError as exc:
raise disconnected_from_os_error(f"打开 {self.bus_path} 失败", exc) from exc
try:
fcntl.ioctl(self._fd, I2C_SLAVE, self.address)
except OSError as exc:
self.close()
raise disconnected_from_os_error("选择 I2C 从设备失败", exc) from exc
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()
def close(self) -> None:
if self._fd is not None:
os.close(self._fd)
self._fd = None
def _require_fd(self) -> int:
if self._fd is None:
raise AdcDisconnectedError("device_closed", "I2C 设备尚未打开")
return self._fd
def configure(self) -> None:
try:
written = os.write(self._require_fd(), bytes([CONFIG_CONTINUOUS_15SPS_PGA1]))
except OSError as exc:
raise disconnected_from_os_error("写入 ADS1110 配置失败", exc) from exc
if written != 1:
raise AdcProtocolError(f"ADS1110 配置应写入 1 字节,实际写入 {written} 字节")
def read_fresh(self) -> tuple[int, int]:
deadline = time.monotonic() + FRESH_DATA_TIMEOUT_SECONDS
while True:
time.sleep(CONVERSION_WAIT_SECONDS)
try:
data = os.read(self._require_fd(), 3)
except OSError as exc:
raise disconnected_from_os_error("读取 ADS1110 失败", exc) from exc
raw, config = validate_conversion(data)
if (config & DATA_READY_MASK) == 0:
return raw, config
if time.monotonic() >= deadline:
raise AdcProtocolError("等待 ADS1110 新转换数据超时")
class VoltageMonitor:
def __init__(
self,
config_store: ConfigStore,
device_factory: Callable[[], AdcDevice] | None = None,
poll_interval: float | None = None,
) -> None:
if poll_interval is not None and (
isinstance(poll_interval, bool)
or not math.isfinite(float(poll_interval))
or float(poll_interval) <= 0
):
raise ValueError("poll_interval must be a positive finite number")
self.config_store = config_store
self._device_factory = device_factory or Ads1110Device
self._fixed_poll_interval = (
None if poll_interval is None else float(poll_interval)
)
self._state_lock = threading.RLock()
self._io_lock = threading.Lock()
self._listener_lock = threading.Lock()
self._stop = threading.Event()
self._reschedule = threading.Event()
self._thread: threading.Thread | None = None
self._proposal: CalibrationProposal | None = None
self._sequence = 0
self._last_published_sequence = 0
self._snapshot: dict[str, Any] = {
"status": "starting",
"volts": None,
"sampled_at": None,
"calibrated": self._is_calibrated(),
"error_code": None,
}
enabled = self.config_store.config["low_voltage_protection_enabled"]
self._protection = protection_directive_for_voltage(0, enabled, None)
self._protection_revision = 0
self._protection_listener: (
Callable[[LowVoltageProtectionDirective], None] | None
) = None
self._enforced_sequence = -1
self._enforcement_error_code: str | None = None
self._has_successful_protection_sample = False
self._relaxation_candidate: LowVoltageProtectionDirective | None = None
self._relaxation_success_count = 0
self._last_successful_voltage: float | None = None
self._last_successful_monotonic: float | None = None
self._last_sample_mode: str | None = None
self._consecutive_errors = 0
self._sampling_interval = (
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else NORMAL_SAMPLING_INTERVAL_SECONDS
)
def start(self) -> None:
with self._state_lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._reschedule.clear()
self._thread = threading.Thread(
target=self._run,
name="screen-voltage-monitor",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
self._reschedule.set()
with self._state_lock:
thread = self._thread
interval = self._sampling_interval
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=max(2.0, min(6.0, interval + 1.0)))
def get_status(self) -> dict[str, Any]:
with self._state_lock:
return copy.deepcopy(self._snapshot)
def get_protection_status(self) -> dict[str, Any]:
with self._state_lock:
status = {
"enabled": self._protection.enabled,
"mode": self._protection.mode,
"brightness_limit_percent": (
self._protection.brightness_limit_percent
),
"reading_stale": self._protection.reading_stale,
"revision": self._protection_revision,
"sampling_interval_seconds": self._sampling_interval,
"enforcement_error_code": self._enforcement_error_code,
}
return copy.deepcopy(status)
def set_protection_listener(
self,
callback: Callable[[LowVoltageProtectionDirective], None] | None,
) -> None:
if callback is not None and not callable(callback):
raise TypeError("protection listener must be callable or None")
with self._state_lock:
self._protection_listener = callback
self._enforced_sequence = -1
self._enforcement_error_code = None
self._retry_protection_listener()
def set_protection_enabled(self, enabled: bool) -> dict[str, Any]:
if type(enabled) is not bool:
raise TypeError("enabled must be a boolean")
schedule_changed = False
with self._state_lock:
if enabled != self._protection.enabled:
sequence = self._allocate_sequence_locked()
self._last_published_sequence = sequence
self._replace_protection_locked(
protection_directive_for_voltage(sequence, enabled, None)
)
self._has_successful_protection_sample = False
self._clear_relaxation_locked()
self._last_successful_voltage = None
self._last_successful_monotonic = None
self._last_sample_mode = None
self._consecutive_errors = 0
schedule_changed = self._set_sampling_interval_locked(
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else NORMAL_SAMPLING_INTERVAL_SECONDS
)
if schedule_changed:
self._request_reschedule()
self._retry_protection_listener()
return self.get_protection_status()
def sample_now(self, sample_count: int = NORMAL_SAMPLE_COUNT) -> dict[str, Any]:
sequence = self._allocate_sequence()
try:
group = self._read_group(sample_count)
return self._publish_success(sequence, group)
except AdcDisconnectedError as exc:
return self._publish_failure(sequence, "disconnected", exc.code)
except (AdcProtocolError, ValueError):
return self._publish_failure(sequence, "error", "invalid_reading")
except Exception:
return self._publish_failure(sequence, "error", "unexpected_error")
def preview_calibration(self, reference_volts: float) -> dict[str, Any]:
reference = float(reference_volts)
if not math.isfinite(reference) or not CALIBRATION_REFERENCE_MIN <= reference <= CALIBRATION_REFERENCE_MAX:
raise CalibrationError("万用表参考电压必须在 4.500V 到 5.500V 之间")
sequence = self._allocate_sequence()
try:
group = self._read_group(CALIBRATION_SAMPLE_COUNT)
except AdcDisconnectedError as exc:
self._publish_failure(sequence, "disconnected", exc.code)
raise CalibrationUnavailableError("电压传感器断开,无法校准") from exc
except (AdcProtocolError, ValueError) as exc:
self._publish_failure(sequence, "error", "invalid_reading")
raise CalibrationUnavailableError("电压读取异常,无法校准") from exc
self._publish_success(sequence, group)
if group.spread_volts > CALIBRATION_MAX_SPREAD_VOLTS:
raise CalibrationError(
f"采样波动 {group.spread_volts:.3f}V,超过 0.050V;请等待供电稳定后重试"
)
if group.uncalibrated_volts <= 0:
raise CalibrationError("未校准电压必须大于 0V")
proposed_factor = reference / group.uncalibrated_volts
if not CALIBRATION_FACTOR_MIN <= proposed_factor <= CALIBRATION_FACTOR_MAX:
raise CalibrationError(
f"建议系数 {proposed_factor:.6f} 超出 0.8 到 1.2;请检查型号、接线和共地"
)
now = datetime.now(timezone.utc)
proposal = CalibrationProposal(
proposal_id=str(uuid4()),
reference_volts=reference,
uncalibrated_volts=group.uncalibrated_volts,
current_factor=float(self.config_store.config["voltage_calibration_factor"]),
proposed_factor=proposed_factor,
created_at=now,
expires_at=now + timedelta(seconds=CALIBRATION_PROPOSAL_SECONDS),
)
with self._state_lock:
self._proposal = proposal
return proposal.response()
def confirm_calibration(self, proposal_id: str) -> dict[str, Any]:
now = datetime.now(timezone.utc)
with self._state_lock:
proposal = self._proposal
status = self._snapshot["status"]
if proposal is None or proposal.proposal_id != proposal_id:
raise CalibrationConflictError("校准提案不存在或已经失效,请重新预览")
if now > proposal.expires_at:
with self._state_lock:
self._proposal = None
raise CalibrationConflictError("校准提案已超过 120 秒,请重新预览")
if status != "ok":
raise CalibrationUnavailableError("传感器状态已经变化,请恢复后重新预览")
self.config_store.update({
"voltage_calibration_factor": proposal.proposed_factor,
"voltage_calibrated_at": isoformat_utc(now),
"voltage_calibration_reference": proposal.reference_volts,
"voltage_calibration_uncalibrated": proposal.uncalibrated_volts,
})
with self._state_lock:
self._proposal = None
return self.sample_now()
def reset_calibration(self) -> dict[str, Any]:
self.config_store.update({
"voltage_calibration_factor": 1.0,
"voltage_calibrated_at": None,
"voltage_calibration_reference": None,
"voltage_calibration_uncalibrated": None,
})
with self._state_lock:
self._proposal = None
return self.sample_now()
def _run(self) -> None:
# The application performs one synchronous startup sample. Waiting here
# avoids immediately opening the I2C device for the same information.
wait_seconds = self._current_sampling_interval()
while not self._stop.is_set():
if self._wait_until_due(wait_seconds):
return
started = time.monotonic()
self.sample_now()
wait_seconds = max(
0.0,
self._current_sampling_interval() - (time.monotonic() - started),
)
def _wait_until_due(self, seconds: float) -> bool:
deadline = time.monotonic() + seconds
while not self._stop.is_set():
remaining = max(0.0, deadline - time.monotonic())
if not self._reschedule.wait(remaining):
return False
self._reschedule.clear()
if self._stop.is_set():
return True
deadline = time.monotonic() + self._current_sampling_interval()
return True
def _read_group(self, sample_count: int) -> SampleGroup:
if sample_count <= 0:
raise ValueError("样本数量必须大于 0")
raw_values: list[int] = []
last_config = CONFIG_CONTINUOUS_15SPS_PGA1
with self._io_lock:
with self._device_factory() as device:
device.configure()
for _ in range(sample_count):
raw, last_config = device.read_fresh()
raw_values.append(raw)
raw_median = float(statistics.median(raw_values))
uncalibrated = voltage_from_raw(raw_median)
minimum = voltage_from_raw(min(raw_values))
maximum = voltage_from_raw(max(raw_values))
if not 0.0 <= uncalibrated <= 12.0:
raise AdcProtocolError(f"屏幕输入电压 {uncalibrated:.3f}V 超出 0V 到 12V")
return SampleGroup(
raw_median=raw_median,
uncalibrated_volts=uncalibrated,
config=last_config,
spread_volts=maximum - minimum,
)
def _publish_success(self, sequence: int, group: SampleGroup) -> dict[str, Any]:
now = datetime.now(timezone.utc)
sampled_monotonic = time.monotonic()
factor = float(self.config_store.config["voltage_calibration_factor"])
volts = group.uncalibrated_volts * factor
snapshot = {
"status": "ok",
"volts": volts,
"sampled_at": isoformat_utc(now),
"calibrated": self._is_calibrated(),
"error_code": None,
}
accepted = False
schedule_changed = False
previous_status = "ok"
with self._state_lock:
if sequence > self._last_published_sequence:
accepted = True
self._last_published_sequence = sequence
previous_status = self._snapshot["status"]
self._snapshot = snapshot
self._consecutive_errors = 0
elapsed = (
None
if self._last_successful_monotonic is None
else sampled_monotonic - self._last_successful_monotonic
)
candidate = protection_directive_for_voltage(
sequence,
self._protection.enabled,
volts,
)
first_critical = (
candidate.mode == "critical"
and self._last_sample_mode != "critical"
)
if self._protection.enabled:
self._accept_successful_protection_candidate_locked(candidate)
interval = (
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else successful_sampling_interval(
enabled=self._protection.enabled,
mode=candidate.mode,
volts=volts,
previous_volts=self._last_successful_voltage,
elapsed_seconds=elapsed,
previous_interval_seconds=self._sampling_interval,
first_critical_sample=first_critical,
)
)
schedule_changed = self._set_sampling_interval_locked(interval)
self._last_successful_voltage = volts
self._last_successful_monotonic = sampled_monotonic
self._last_sample_mode = candidate.mode
result = copy.deepcopy(self._snapshot)
if accepted and previous_status != "ok":
logger.info("Screen voltage sensor is available")
if schedule_changed:
self._request_reschedule()
self._retry_protection_listener()
return result
def _publish_failure(
self,
sequence: int,
status: str,
error_code: str,
) -> dict[str, Any]:
snapshot = {
"status": status,
"volts": None,
"sampled_at": None,
"calibrated": self._is_calibrated(),
"error_code": error_code,
}
accepted = False
schedule_changed = False
previous = (status, error_code)
with self._state_lock:
if sequence > self._last_published_sequence:
accepted = True
self._last_published_sequence = sequence
previous = (self._snapshot["status"], self._snapshot["error_code"])
self._snapshot = snapshot
self._proposal = None
self._clear_relaxation_locked()
self._consecutive_errors += 1
self._mark_protection_stale_locked(sequence)
interval = (
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else error_retry_interval(self._consecutive_errors)
)
schedule_changed = self._set_sampling_interval_locked(interval)
result = copy.deepcopy(self._snapshot)
if accepted and previous != (status, error_code):
logger.warning("Screen voltage sensor status=%s error_code=%s", status, error_code)
if schedule_changed:
self._request_reschedule()
self._retry_protection_listener()
return result
def _accept_successful_protection_candidate_locked(
self,
candidate: LowVoltageProtectionDirective,
) -> None:
if not self._has_successful_protection_sample:
self._has_successful_protection_sample = True
self._clear_relaxation_locked()
self._replace_protection_locked(candidate)
return
current = self._protection
if is_more_restrictive(candidate, current):
self._clear_relaxation_locked()
self._replace_protection_locked(candidate)
return
if same_protection_level(candidate, current):
self._clear_relaxation_locked()
if current.reading_stale:
self._replace_protection_locked(candidate)
return
# A successful sample makes the reading fresh immediately, while any
# output relaxation still waits for a second consecutive success.
if current.reading_stale:
self._replace_protection_locked(
LowVoltageProtectionDirective(
sequence=candidate.sequence,
enabled=current.enabled,
mode=current.mode,
brightness_limit_percent=current.brightness_limit_percent,
reading_stale=False,
)
)
self._relaxation_success_count += 1
self._relaxation_candidate = (
candidate
if self._relaxation_candidate is None
else more_conservative(self._relaxation_candidate, candidate)
)
if self._relaxation_success_count >= 2:
conservative = self._relaxation_candidate
assert conservative is not None
self._replace_protection_locked(
LowVoltageProtectionDirective(
sequence=candidate.sequence,
enabled=conservative.enabled,
mode=conservative.mode,
brightness_limit_percent=(
conservative.brightness_limit_percent
),
reading_stale=False,
)
)
self._clear_relaxation_locked()
def _mark_protection_stale_locked(self, sequence: int) -> None:
if not self._protection.enabled:
return
if self._has_successful_protection_sample:
if not self._protection.reading_stale:
self._replace_protection_locked(
LowVoltageProtectionDirective(
sequence=sequence,
enabled=True,
mode=self._protection.mode,
brightness_limit_percent=(
self._protection.brightness_limit_percent
),
reading_stale=True,
)
)
return
# There is no successful reading to become stale yet. Keep the
# non-restrictive unavailable directive and expose the ADC error via
# the sensor status instead of churning the protection revision.
def _replace_protection_locked(
self,
directive: LowVoltageProtectionDirective,
) -> None:
if directive == self._protection:
return
self._protection = directive
self._protection_revision += 1
def _clear_relaxation_locked(self) -> None:
self._relaxation_candidate = None
self._relaxation_success_count = 0
def _retry_protection_listener(self) -> None:
with self._listener_lock:
with self._state_lock:
listener = self._protection_listener
directive = self._protection
needs_enforcement = (
listener is not None
and (
self._enforced_sequence != directive.sequence
or self._enforcement_error_code is not None
)
)
if not needs_enforcement or listener is None:
return
try:
listener(directive)
except Exception:
with self._state_lock:
if self._protection == directive:
self._enforcement_error_code = (
PROTECTION_ENFORCEMENT_ERROR_CODE
)
logger.exception("Failed to enforce low-voltage display protection")
return
with self._state_lock:
if self._protection == directive:
self._enforced_sequence = directive.sequence
self._enforcement_error_code = None
def _allocate_sequence(self) -> int:
with self._state_lock:
return self._allocate_sequence_locked()
def _allocate_sequence_locked(self) -> int:
self._sequence += 1
return self._sequence
def _set_sampling_interval_locked(self, interval: float) -> bool:
normalized = float(interval)
changed = normalized != self._sampling_interval
self._sampling_interval = normalized
return changed
def _current_sampling_interval(self) -> float:
with self._state_lock:
return self._sampling_interval
def _request_reschedule(self) -> None:
with self._state_lock:
thread = self._thread
if thread is not None and threading.current_thread() is not thread:
self._reschedule.set()
def _is_calibrated(self) -> bool:
return self.config_store.config.get("voltage_calibrated_at") is not None
@@ -0,0 +1,466 @@
import { apiJson } from "./core.js";
import { animationPreviewFrameAtElapsed } from "./animation-ui-model.js";
export const ANIMATION_PREVIEW_PAGE_SIZE = 500;
export const ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT = 2;
export const ANIMATION_PREVIEW_MIN_MAX_CONCURRENT = 1;
export const ANIMATION_PREVIEW_MAX_MAX_CONCURRENT = 50;
export const ANIMATION_PREVIEW_PRELOAD_COUNT = 3;
export function normalizeAnimationPreviewMaxConcurrent(value) {
if (!Number.isInteger(value)
|| value < ANIMATION_PREVIEW_MIN_MAX_CONCURRENT
|| value > ANIMATION_PREVIEW_MAX_MAX_CONCURRENT) {
throw new Error(
`动态缩略图并发数量必须是 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数`,
);
}
return value;
}
function stalePreviewError() {
const error = new Error("动图已在预览加载期间发生变化");
error.code = "ANIMATION_PREVIEW_STALE";
return error;
}
export async function loadCompleteAnimationPreview(
animation,
{ fetchJson = apiJson, signal, pageSize = ANIMATION_PREVIEW_PAGE_SIZE } = {},
) {
const expectedRevision = String(animation?.revision || "");
const expectedCount = Number(animation?.frame_count || 0);
if (!animation?.id || !expectedRevision) throw new Error("动图预览缺少标识或修订值");
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > ANIMATION_PREVIEW_PAGE_SIZE) {
throw new Error("动图预览分页大小无效");
}
if (expectedCount === 0) return [];
const frames = [];
let offset = 0;
let total = expectedCount;
while (offset < total) {
const query = new URLSearchParams({
frame_offset: String(offset),
frame_limit: String(pageSize),
});
const page = await fetchJson(`/api/animations/${animation.id}?${query}`, { signal });
if (String(page?.revision || "") !== expectedRevision) throw stalePreviewError();
if (!Array.isArray(page.frames)) throw new Error("动图预览帧列表无效");
total = Number(page.frame_total ?? page.frame_count ?? page.frames.length);
if (!Number.isInteger(total) || total < 0 || total !== expectedCount) throw stalePreviewError();
if (!page.frames.length && offset < total) throw new Error("动图预览分页提前结束");
frames.push(...page.frames);
offset += page.frames.length;
}
if (frames.length !== expectedCount) throw stalePreviewError();
return frames;
}
class AnimationPreviewEngine {
constructor() {
this.records = new Set();
this.initialQueue = [];
this.activeInitialLoads = 0;
this.queue = [];
this.activeLoads = 0;
this.maxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT;
this.timer = null;
this.cache = new Map();
this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
this.observer = typeof IntersectionObserver === "function"
? new IntersectionObserver((entries) => this.handleIntersections(entries), { rootMargin: "0px" })
: null;
this.viewportSyncPending = false;
this.handleFallbackViewport = () => {
if (this.observer || this.viewportSyncPending) return;
this.viewportSyncPending = true;
window.requestAnimationFrame(() => {
this.viewportSyncPending = false;
this.syncAll();
});
};
this.handleVisibility = () => this.syncAll();
this.handleMotionChange = () => this.syncAll();
document.addEventListener("visibilitychange", this.handleVisibility);
this.reducedMotion.addEventListener?.("change", this.handleMotionChange);
window.addEventListener("scroll", this.handleFallbackViewport, true);
window.addEventListener("resize", this.handleFallbackViewport);
}
cacheKey(animation) {
return `${animation.id}:${animation.revision}`;
}
setMaxConcurrent(value) {
const normalized = normalizeAnimationPreviewMaxConcurrent(value);
if (normalized === this.maxConcurrent) return normalized;
this.maxConcurrent = normalized;
this.syncAll();
return normalized;
}
createGroup() {
const engine = this;
const records = new Set();
return {
active: false,
register(options) {
const record = engine.register(this, options);
records.add(record);
return () => {
records.delete(record);
engine.disposeRecord(record);
engine.syncAll();
};
},
setActive(active) {
this.active = Boolean(active);
engine.syncAll();
},
clear() {
const keys = new Set();
for (const record of records) {
keys.add(engine.cacheKey(record.animation));
engine.disposeRecord(record);
}
records.clear();
for (const key of keys) engine.cache.delete(key);
engine.scheduleTick();
},
};
}
register(group, {
animation,
image,
initialUrl,
target = image,
onFrame = null,
onLoad = null,
onInitialError = null,
}) {
const record = {
group,
animation,
image,
initialUrl,
target,
onFrame,
onLoad,
onInitialError,
visible: false,
disposed: false,
initialQueued: false,
initialLoading: false,
initialReady: false,
initialFailed: false,
queued: false,
loading: false,
loadController: null,
schedule: null,
startedAt: null,
currentIndex: -1,
preloads: new Map(),
lastSuccessfulUrl: "",
restoring: false,
};
record.handleLoad = () => {
if (record.initialLoading) this.finishInitialLoad(record, true);
record.lastSuccessfulUrl = image.currentSrc || image.src;
record.restoring = false;
onLoad?.();
};
record.handleError = () => {
if (record.initialLoading) {
this.finishInitialLoad(record, false);
record.restoring = false;
onInitialError?.();
return;
}
const failedUrl = image.currentSrc || image.src;
if (record.lastSuccessfulUrl && failedUrl !== record.lastSuccessfulUrl && !record.restoring) {
record.restoring = true;
image.src = record.lastSuccessfulUrl;
return;
}
record.restoring = false;
onInitialError?.();
};
image.addEventListener("load", record.handleLoad);
image.addEventListener("error", record.handleError);
this.records.add(record);
this.observer?.observe(target);
this.syncAll();
return record;
}
disposeRecord(record) {
if (record.disposed) return;
record.disposed = true;
record.initialQueued = false;
if (record.initialLoading) {
record.initialLoading = false;
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
record.image.removeAttribute("src");
}
record.loadController?.abort();
record.preloads.clear();
record.image.removeEventListener("load", record.handleLoad);
record.image.removeEventListener("error", record.handleError);
this.observer?.unobserve(record.target);
this.records.delete(record);
this.pumpInitialQueue();
}
canLoadInitial(record) {
return !record.disposed
&& record.group.active
&& this.isRecordVisible(record)
&& document.visibilityState === "visible"
&& Boolean(record.initialUrl);
}
isRecordVisible(record) {
if (this.observer) return record.visible;
const rect = record.target.getBoundingClientRect();
return rect.width > 0
&& rect.height > 0
&& rect.right > 0
&& rect.bottom > 0
&& rect.left < window.innerWidth
&& rect.top < window.innerHeight;
}
syncInitial(record) {
if (!this.canLoadInitial(record)
|| record.initialReady
|| record.initialFailed
|| record.initialLoading
|| record.initialQueued) return;
record.initialQueued = true;
this.initialQueue.push(record);
}
pumpInitialQueue() {
while (this.activeInitialLoads < this.maxConcurrent
&& this.initialQueue.length) {
const record = this.initialQueue.shift();
record.initialQueued = false;
if (!this.canLoadInitial(record) || record.initialReady || record.initialFailed) continue;
record.initialLoading = true;
this.activeInitialLoads += 1;
record.image.src = record.initialUrl;
}
}
finishInitialLoad(record, succeeded) {
if (!record.initialLoading) return;
record.initialLoading = false;
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
record.initialReady = succeeded;
record.initialFailed = !succeeded;
this.pumpInitialQueue();
this.syncAll();
}
handleIntersections(entries) {
for (const entry of entries) {
for (const record of this.records) {
if (record.target !== entry.target) continue;
record.visible = entry.isIntersecting;
}
}
this.syncAll();
}
canRun(record) {
return !record.disposed
&& record.group.active
&& this.isRecordVisible(record)
&& document.visibilityState === "visible"
&& !this.reducedMotion.matches
&& Number(record.animation.frame_count) > 1;
}
shouldRun(record) {
let selected = 0;
for (const candidate of this.records) {
if (!this.canRun(candidate)) continue;
if (candidate === record) return selected < this.maxConcurrent;
selected += 1;
if (selected >= this.maxConcurrent) return false;
}
return false;
}
syncRecord(record) {
if (!this.shouldRun(record)) {
record.loadController?.abort();
record.loadController = null;
record.loading = false;
record.queued = false;
record.preloads.clear();
if (record.schedule) this.cache.delete(this.cacheKey(record.animation));
record.schedule = null;
record.startedAt = null;
record.currentIndex = -1;
return;
}
if (!record.initialReady) return;
if (record.schedule) return;
const cached = this.cache.get(this.cacheKey(record.animation));
if (cached) {
record.schedule = cached;
record.startedAt = performance.now();
return;
}
if (!record.loading && !record.queued) {
record.queued = true;
this.queue.push(record);
this.pumpQueue();
}
}
syncAll() {
for (const record of this.records) this.syncInitial(record);
this.pumpInitialQueue();
for (const record of this.records) this.syncRecord(record);
this.scheduleTick();
}
pumpQueue() {
while (this.activeLoads < this.maxConcurrent && this.queue.length) {
const record = this.queue.shift();
record.queued = false;
if (!this.shouldRun(record) || record.disposed || record.schedule) continue;
this.loadRecord(record);
}
}
async loadRecord(record) {
record.loading = true;
record.loadController = new AbortController();
this.activeLoads += 1;
try {
const frames = await loadCompleteAnimationPreview(record.animation, {
signal: record.loadController.signal,
});
if (record.disposed || !this.shouldRun(record)) return;
record.schedule = frames;
record.startedAt = performance.now();
this.cache.set(this.cacheKey(record.animation), frames);
} catch (error) {
if (error?.name !== "AbortError" && error?.code !== "ANIMATION_PREVIEW_STALE") {
// The first frame remains usable; a later refresh can retry the full schedule.
}
} finally {
record.loading = false;
record.loadController = null;
this.activeLoads -= 1;
this.pumpQueue();
this.scheduleTick();
}
}
preloadAhead(record, index) {
const wanted = new Set();
const length = record.schedule.length;
for (let step = 1; step <= ANIMATION_PREVIEW_PRELOAD_COUNT && step < length; step += 1) {
const nextIndex = (index + step) % length;
wanted.add(nextIndex);
if (!record.preloads.has(nextIndex)) {
const preload = new Image();
preload.decoding = "async";
preload.src = record.schedule[nextIndex].thumbnail_url;
record.preloads.set(nextIndex, preload);
}
}
for (const existing of record.preloads.keys()) {
if (!wanted.has(existing)) record.preloads.delete(existing);
}
}
scheduleTick() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
this.timer = null;
}
let nextDelay = Infinity;
const now = performance.now();
for (const record of this.records) {
if (!this.shouldRun(record) || !record.schedule?.length) continue;
if (record.startedAt === null) record.startedAt = now;
const selected = animationPreviewFrameAtElapsed(record.schedule, now - record.startedAt);
if (!selected) continue;
if (record.currentIndex !== selected.index) {
record.currentIndex = selected.index;
record.image.src = selected.frame.thumbnail_url;
record.onFrame?.({
frame: selected.frame,
index: selected.index,
total: record.schedule.length,
});
this.preloadAhead(record, selected.index);
}
nextDelay = Math.min(nextDelay, selected.remainingMs);
}
if (Number.isFinite(nextDelay)) {
this.timer = window.setTimeout(() => {
this.timer = null;
this.scheduleTick();
}, Math.max(16, Math.ceil(nextDelay)));
}
}
}
let sharedEngine = null;
function getSharedEngine() {
if (!sharedEngine) sharedEngine = new AnimationPreviewEngine();
return sharedEngine;
}
export function createAnimationPreviewGroup() {
return getSharedEngine().createGroup();
}
export function setAnimationPreviewMaxConcurrent(value) {
return getSharedEngine().setMaxConcurrent(value);
}
export function createAnimationThumbnailBox(group, animation) {
const imageBox = document.createElement("div");
imageBox.className = "template-image-box";
if (!animation.thumbnail_url || !animation.frame_count) {
imageBox.classList.add("template-image-empty");
imageBox.setAttribute("aria-label", "空动图");
return imageBox;
}
const image = document.createElement("img");
image.className = "template-image";
image.alt = `${animation.name} 动图缩略图`;
image.width = 64;
image.height = 64;
image.draggable = false;
image.loading = "lazy";
imageBox.append(image);
group.register({
animation,
image,
initialUrl: animation.thumbnail_url,
target: imageBox,
onLoad: () => {
image.hidden = false;
imageBox.classList.remove("template-image-error");
imageBox.removeAttribute("aria-label");
},
onInitialError: () => {
image.hidden = true;
imageBox.classList.add("template-image-error");
imageBox.setAttribute("aria-label", "缩略图加载失败");
},
});
return imageBox;
}
@@ -0,0 +1,82 @@
export const MIN_FRAME_DURATION_MS = 50;
export const MAX_FRAME_DURATION_MS = 604800000;
export function effectiveFrameName(animationName, frame, position = frame?.position) {
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
if (customName) return customName;
return `${String(animationName || "动图").trim() || "动图"}-第${position}帧`;
}
export function animationEditContextName(animationName, frame, position = frame?.position) {
const normalizedAnimationName = String(animationName || "动图").trim() || "动图";
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
return `${normalizedAnimationName}-${customName || `第${position}帧`}`;
}
export function animationPreviewFrameAtElapsed(frames, elapsedMs) {
if (!Array.isArray(frames) || !frames.length) return null;
const durations = frames.map((frame) => Number(frame?.duration_ms));
if (durations.some((duration) => !Number.isFinite(duration) || duration <= 0)) return null;
const totalDurationMs = durations.reduce((total, duration) => total + duration, 0);
const normalizedElapsed = Number.isFinite(Number(elapsedMs)) ? Number(elapsedMs) : 0;
const cycleElapsedMs = ((normalizedElapsed % totalDurationMs) + totalDurationMs) % totalDurationMs;
let boundary = 0;
for (let index = 0; index < frames.length; index += 1) {
boundary += durations[index];
if (cycleElapsedMs < boundary) {
return {
frame: frames[index],
index,
remainingMs: boundary - cycleElapsedMs,
totalDurationMs,
};
}
}
return null;
}
export function formatFrameDuration(durationMs, unit = "ms") {
const duration = Number(durationMs);
if (!Number.isInteger(duration)) return "";
if (unit === "ms") return String(duration);
if (unit !== "s") throw new Error("unknown animation duration unit");
return (duration / 1000).toFixed(3).replace(/\.?0+$/, "");
}
export function validateFrameDuration(value, unit = "ms") {
const normalized = typeof value === "string" ? value.trim() : String(value ?? "").trim();
const millisecondsPattern = /^\d+$/;
const secondsPattern = /^\d+(?:\.\d{1,3})?$/;
if (!normalized || (unit === "ms" ? !millisecondsPattern.test(normalized) : !secondsPattern.test(normalized))) {
return {
valid: false,
duration: null,
error: unit === "ms"
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
};
}
const numeric = Number(normalized);
const duration = unit === "s" ? numeric * 1000 : numeric;
if (!Number.isInteger(duration) || duration < MIN_FRAME_DURATION_MS || duration > MAX_FRAME_DURATION_MS) {
return {
valid: false,
duration: null,
error: unit === "ms"
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
};
}
return { valid: true, duration, error: "" };
}
export function reorderSelectedFrameIds(frameIds, selectedFrameIds, insertAfterFrameId = null) {
if (!Array.isArray(frameIds) || !Array.isArray(selectedFrameIds) || !selectedFrameIds.length) return null;
const selected = new Set(selectedFrameIds);
if (selected.size !== selectedFrameIds.length || selectedFrameIds.some((id) => !frameIds.includes(id))) return null;
if (insertAfterFrameId !== null && (!frameIds.includes(insertAfterFrameId) || selected.has(insertAfterFrameId))) return null;
const moving = frameIds.filter((id) => selected.has(id));
const remaining = frameIds.filter((id) => !selected.has(id));
const insertAt = insertAfterFrameId === null ? 0 : remaining.indexOf(insertAfterFrameId) + 1;
return [...remaining.slice(0, insertAt), ...moving, ...remaining.slice(insertAt)];
}
+379
View File
@@ -0,0 +1,379 @@
import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js";
import "./interaction-feedback.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js";
import { setBoardWorkspace } from "./scene-board.js";
import { renderUiCopy } from "./ui-copy-runtime.js";
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
import {
ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT,
setAnimationPreviewMaxConcurrent,
} from "./animation-thumbnail-preview.js";
import "./color-picker.js";
import "./views/canvas.js";
import { refreshStatus } from "./views/device.js";
import "./views/text.js";
import "./views/media-import.js";
import "./views/animations.js";
import "./views/templates.js";
import "./views/settings.js";
const drawer = document.getElementById("workspace-drawer");
const backdrop = document.getElementById("drawer-backdrop");
const menuButton = document.getElementById("drawer-toggle");
const navigation = document.getElementById("workspace-nav");
const workspaceOrderToggle = document.getElementById("workspace-order-toggle");
const workspaceOrderFeedback = document.getElementById("workspace-order-feedback");
const currentTitle = document.getElementById("current-workspace-title");
const saveTemplateButton = document.getElementById("save-template");
const definitions = getWorkspaces();
const buttons = new Map();
let activeWorkspace = null;
let savedWorkspaceOrder = definitions.map((definition) => definition.id);
let draftWorkspaceOrder = null;
let workspaceOrderEditing = false;
let workspaceOrderBusy = false;
let workspaceOrderLoadPromise = null;
let statusPollPending = false;
const loadedAppVersion = document.querySelector('meta[name="app-version"]')?.content || null;
function setWorkspaceOrderToggleLabel() {
if (workspaceOrderEditing) {
renderUiCopy("copy.dynamic.workspace_order.save", workspaceOrderToggle, "保存");
} else {
renderUiCopy("copy.dynamic.workspace_order.edit", workspaceOrderToggle, "自定义项目位置");
}
}
function setWorkspaceOrderFeedback(copyId, defaultText, values = {}, state = "ready") {
renderUiCopy(copyId, workspaceOrderFeedback, defaultText, values);
workspaceOrderFeedback.hidden = false;
workspaceOrderFeedback.dataset.state = state;
workspaceOrderFeedback.setAttribute("role", state === "error" ? "alert" : "status");
announce(workspaceOrderFeedback.textContent, state === "error");
}
function clearWorkspaceOrderFeedback() {
workspaceOrderFeedback.hidden = true;
delete workspaceOrderFeedback.dataset.state;
}
function orderedDefinitions(orderIds) {
return resolveWorkspaceOrder(definitions, orderIds);
}
function buildNavigation() {
const orderIds = workspaceOrderEditing ? draftWorkspaceOrder : savedWorkspaceOrder;
const ordered = orderedDefinitions(orderIds);
navigation.replaceChildren();
navigation.classList.toggle("is-order-editing", workspaceOrderEditing);
buttons.clear();
ordered.forEach((definition, index) => {
const row = document.createElement("div");
row.className = "nav-item-row";
row.dataset.workspace = definition.id;
const button = document.createElement("button");
button.type = "button";
button.className = "nav-item";
button.dataset.workspace = definition.id;
button.textContent = definition.title;
if (workspaceOrderEditing) {
button.setAttribute("aria-disabled", "true");
button.dataset.unavailableReason = "请先保存项目位置";
} else {
button.addEventListener("click", () => {
navigate(definition.id);
requestCloseDrawer(true);
});
}
if (activeWorkspace?.id === definition.id) button.setAttribute("aria-current", "page");
row.appendChild(button);
buttons.set(definition.id, button);
if (workspaceOrderEditing) {
const actions = document.createElement("div");
actions.className = "nav-order-actions";
const moveUp = document.createElement("button");
moveUp.type = "button";
moveUp.className = "nav-order-action";
moveUp.dataset.orderAction = "up";
moveUp.dataset.workspace = definition.id;
moveUp.setAttribute("aria-label", `上移${definition.title}`);
renderUiCopy("copy.dynamic.workspace_order.move_up", moveUp, "上移");
moveUp.addEventListener("click", () => moveDraftWorkspace(index, -1, "up"));
const moveDown = document.createElement("button");
moveDown.type = "button";
moveDown.className = "nav-order-action";
moveDown.dataset.orderAction = "down";
moveDown.dataset.workspace = definition.id;
moveDown.setAttribute("aria-label", `下移${definition.title}`);
renderUiCopy("copy.dynamic.workspace_order.move_down", moveDown, "下移");
moveDown.addEventListener("click", () => moveDraftWorkspace(index, 1, "down"));
actions.append(moveUp, moveDown);
row.appendChild(actions);
if (index === 0) setControlUnavailable(moveUp, "当前已经是第一项,不能继续上移。");
if (index === ordered.length - 1) {
setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
}
}
navigation.appendChild(row);
});
}
function moveDraftWorkspace(index, offset, action) {
const moved = moveWorkspaceOrder(draftWorkspaceOrder, index, offset);
if (!moved) return;
const workspaceId = draftWorkspaceOrder[index];
draftWorkspaceOrder = moved;
clearWorkspaceOrderFeedback();
buildNavigation();
Array.from(navigation.querySelectorAll(`[data-order-action="${action}"]`))
.find((button) => button.dataset.workspace === workspaceId)
?.focus();
}
async function loadWorkspaceOrder() {
if (workspaceOrderLoadPromise) return workspaceOrderLoadPromise;
navigation.setAttribute("aria-busy", "true");
workspaceOrderLoadPromise = apiJson("/api/config")
.then((config) => {
try {
setAnimationPreviewMaxConcurrent(config.animation_preview_max_concurrent);
} catch {
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
}
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
if (!workspaceOrderEditing) buildNavigation();
return savedWorkspaceOrder;
})
.catch((error) => {
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
throw error;
})
.finally(() => {
navigation.removeAttribute("aria-busy");
workspaceOrderLoadPromise = null;
});
return workspaceOrderLoadPromise;
}
async function beginWorkspaceOrderEdit() {
if (workspaceOrderBusy || workspaceOrderEditing) return;
workspaceOrderBusy = true;
setControlBusy(workspaceOrderToggle);
renderUiCopy("copy.dynamic.workspace_order.loading", workspaceOrderToggle, "正在读取…");
try {
await loadWorkspaceOrder();
draftWorkspaceOrder = [...savedWorkspaceOrder];
workspaceOrderEditing = true;
clearWorkspaceOrderFeedback();
buildNavigation();
} catch (error) {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.load_failed",
"项目位置读取失败:{reason}",
{ reason: error.message },
"error",
);
} finally {
workspaceOrderBusy = false;
setControlReady(workspaceOrderToggle);
setWorkspaceOrderToggleLabel();
}
}
async function saveWorkspaceOrder() {
if (workspaceOrderBusy || !workspaceOrderEditing || !draftWorkspaceOrder) return;
workspaceOrderBusy = true;
setControlBusy(workspaceOrderToggle);
renderUiCopy("copy.dynamic.workspace_order.saving", workspaceOrderToggle, "正在保存…");
try {
const config = await apiJson("/api/config", {
method: "PUT",
body: JSON.stringify({ workspace_order: draftWorkspaceOrder }),
});
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
draftWorkspaceOrder = null;
workspaceOrderEditing = false;
buildNavigation();
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.saved",
"项目位置已保存",
{},
"success",
);
} catch (error) {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.save_failed",
"项目位置保存失败:{reason}",
{ reason: error.message },
"error",
);
} finally {
workspaceOrderBusy = false;
setControlReady(workspaceOrderToggle);
setWorkspaceOrderToggleLabel();
}
}
function remindWorkspaceOrderSave() {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.save_required",
"请先保存项目位置",
);
workspaceOrderToggle.focus();
}
function resolveWorkspaceId() {
const hashId = decodeURIComponent(window.location.hash.replace(/^#/, ""));
return definitions.some((item) => item.id === hashId) ? hashId : DEFAULT_WORKSPACE_ID;
}
function navigate(id, updateHash = true) {
const next = definitions.find((item) => item.id === id)
|| definitions.find((item) => item.id === DEFAULT_WORKSPACE_ID)
|| definitions[0];
if (!next || activeWorkspace?.id === next.id) return;
if (activeWorkspace) {
document.getElementById(activeWorkspace.elementId).hidden = true;
buttons.get(activeWorkspace.id)?.removeAttribute("aria-current");
activeWorkspace.onLeave?.();
}
activeWorkspace = next;
const element = document.getElementById(next.elementId);
element.hidden = false;
setBoardWorkspace(next.id);
saveTemplateButton.hidden = !next.capabilities.includes("scene-template-save");
currentTitle.textContent = next.title;
buttons.get(next.id)?.setAttribute("aria-current", "page");
document.title = `${next.title} · 奇妙小屏幕控制器`;
Promise.resolve(next.onEnter?.()).catch((error) => {
console.error(`工作区 ${next.id} 初始化失败`, error);
const message = `${next.title}初始化失败:${error.message}`;
const feedback = element.querySelector("[aria-live]");
if (feedback) {
feedback.textContent = message;
feedback.dataset.state = "error";
}
announce(message, true);
});
if (updateHash && window.location.hash !== `#${next.id}`) {
history.pushState(null, "", `#${next.id}`);
}
window.scrollTo({ top: 0, behavior: "auto" });
}
function openDrawer() {
drawer.classList.add("open");
drawer.removeAttribute("inert");
drawer.setAttribute("aria-hidden", "false");
backdrop.hidden = false;
document.body.classList.add("drawer-open");
menuButton.setAttribute("aria-expanded", "true");
menuButton.setAttribute("aria-label", "关闭功能菜单");
if (!workspaceOrderEditing && !workspaceOrderBusy) {
loadWorkspaceOrder().catch((error) => {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.load_failed",
"项目位置读取失败:{reason}",
{ reason: error.message },
"error",
);
});
}
}
function closeDrawer(restoreFocus = false) {
drawer.classList.remove("open");
drawer.setAttribute("inert", "");
drawer.setAttribute("aria-hidden", "true");
backdrop.hidden = true;
document.body.classList.remove("drawer-open");
menuButton.setAttribute("aria-expanded", "false");
menuButton.setAttribute("aria-label", "打开功能菜单");
if (restoreFocus) menuButton.focus();
}
function requestCloseDrawer(restoreFocus = false) {
if (workspaceOrderEditing || workspaceOrderBusy) {
remindWorkspaceOrderSave();
return false;
}
closeDrawer(restoreFocus);
return true;
}
workspaceOrderToggle.addEventListener("click", () => {
if (workspaceOrderEditing) saveWorkspaceOrder();
else beginWorkspaceOrderEdit();
});
menuButton.addEventListener("click", () => {
if (drawer.classList.contains("open")) {
requestCloseDrawer(true);
} else {
openDrawer();
}
});
backdrop.addEventListener("click", () => requestCloseDrawer(true));
window.addEventListener("hashchange", () => navigate(resolveWorkspaceId(), false));
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && drawer.classList.contains("open")) {
event.preventDefault();
requestCloseDrawer(true);
return;
}
if (event.key === "Tab" && drawer.classList.contains("open")) {
const focusable = [menuButton, ...Array.from(drawer.querySelectorAll("button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled)"))]
.filter((element) => element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
});
buildNavigation();
setWorkspaceOrderToggleLabel();
navigate(resolveWorkspaceId(), false);
loadWorkspaceOrder().catch((error) => {
setWorkspaceOrderFeedback(
"copy.dynamic.workspace_order.load_failed",
"项目位置读取失败:{reason}",
{ reason: error.message },
"error",
);
});
document.getElementById("reload-app").addEventListener("click", () => window.location.reload());
async function pollStatus() {
if (statusPollPending || document.visibilityState !== "visible") return;
statusPollPending = true;
try {
const status = await refreshStatus({ source: "poll" });
const currentVersion = status.service?.app_version;
if (loadedAppVersion && currentVersion && currentVersion !== loadedAppVersion) {
markAppStale();
}
} catch (_error) {
// The device workspace reports persistent failures when the user opens it.
} finally {
statusPollPending = false;
}
}
window.setInterval(pollStatus, 5000);
window.addEventListener("focus", pollStatus);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") pollStatus();
});
pollStatus();
@@ -0,0 +1,174 @@
export const BRUSH_MODE_NORMAL = "normal";
export const BRUSH_MODE_PIXEL = "pixel";
export const CANVAS_VIEW_MIN_SCALE = 1;
export const CANVAS_VIEW_MAX_SCALE = 8;
export const CANVAS_UNDO_LIMIT = 50;
const MODE_SPECS = Object.freeze({
[BRUSH_MODE_NORMAL]: Object.freeze({ min: 1, max: 16, presets: Object.freeze([1, 2, 4, 8]) }),
[BRUSH_MODE_PIXEL]: Object.freeze({ min: 1, max: 4, presets: Object.freeze([1, 2, 3, 4]) }),
});
function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
export function brushModeSpec(mode) {
return MODE_SPECS[mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL];
}
export function normalizeBrushSize(value, mode) {
const spec = brushModeSpec(mode);
const parsed = Number.parseInt(value, 10);
return Math.max(spec.min, Math.min(spec.max, Number.isFinite(parsed) ? parsed : spec.min));
}
export function restoreCanvasToolState(savedV2, savedV1 = null) {
const current = record(savedV2);
const legacy = record(savedV1);
const source = current || legacy || {};
return {
paintColor: typeof source.paintColor === "string" ? source.paintColor : null,
backgroundColor: typeof source.backgroundColor === "string" ? source.backgroundColor : null,
tool: source.tool === "eraser" ? "eraser" : "brush",
pixelEditEnabled: Boolean(current?.pixelEditEnabled),
normalBrushSize: normalizeBrushSize(
current?.normalBrushSize ?? source.brushSize,
BRUSH_MODE_NORMAL,
),
pixelBrushSize: normalizeBrushSize(current?.pixelBrushSize, BRUSH_MODE_PIXEL),
};
}
export function linePoints(from, to) {
const points = [];
let x = from.x;
let y = from.y;
const dx = Math.abs(to.x - from.x);
const sx = from.x < to.x ? 1 : -1;
const dy = -Math.abs(to.y - from.y);
const sy = from.y < to.y ? 1 : -1;
let error = dx + dy;
while (true) {
points.push({ x, y });
if (x === to.x && y === to.y) break;
const doubled = 2 * error;
if (doubled >= dy) {
error += dy;
x += sx;
}
if (doubled <= dx) {
error += dx;
y += sy;
}
}
return points;
}
export function brushStampPoints(centerX, centerY, size, mode, width = 64, height = 64) {
const normalizedMode = mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
const diameter = normalizeBrushSize(size, normalizedMode);
const start = -Math.floor((diameter - 1) / 2);
const radius = Math.max(0.25, (diameter - 0.5) / 2);
const shapeCenter = (diameter - 1) / 2;
const points = [];
for (let localY = 0; localY < diameter; localY += 1) {
for (let localX = 0; localX < diameter; localX += 1) {
if (normalizedMode === BRUSH_MODE_NORMAL) {
const normalizedX = (localX - shapeCenter) / radius;
const normalizedY = (localY - shapeCenter) / radius;
if ((normalizedX * normalizedX) + (normalizedY * normalizedY) > 1) continue;
}
const x = centerX + start + localX;
const y = centerY + start + localY;
if (x >= 0 && x < width && y >= 0 && y < height) points.push({ x, y });
}
}
return points;
}
export function pixelBuffersEqual(left, right) {
if (!left || !right || left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
export class PixelUndoHistory {
constructor(limit = CANVAS_UNDO_LIMIT) {
this.limit = Math.max(1, Number.isInteger(limit) ? limit : CANVAS_UNDO_LIMIT);
this.entries = [];
}
get size() {
return this.entries.length;
}
record(before, after) {
if (pixelBuffersEqual(before, after)) return false;
this.entries.push(Uint8Array.from(before));
if (this.entries.length > this.limit) this.entries.splice(0, this.entries.length - this.limit);
return true;
}
undo() {
return this.entries.pop() || null;
}
clear() {
this.entries.length = 0;
}
}
export function normalizeCanvasScale(value) {
const scale = Number(value);
if (!Number.isFinite(scale)) return CANVAS_VIEW_MIN_SCALE;
return Math.max(CANVAS_VIEW_MIN_SCALE, Math.min(CANVAS_VIEW_MAX_SCALE, scale));
}
export function clampCanvasView(
view,
{ viewportWidth, viewportHeight, baseSize, minVisible = 48 },
) {
const scale = normalizeCanvasScale(view?.scale);
const width = Math.max(1, Number(baseSize) || 1) * scale;
const height = width;
const visibleX = Math.min(Math.max(1, Number(minVisible) || 1), width, viewportWidth);
const visibleY = Math.min(Math.max(1, Number(minVisible) || 1), height, viewportHeight);
const limitX = Math.max(0, (viewportWidth + width) / 2 - visibleX);
const limitY = Math.max(0, (viewportHeight + height) / 2 - visibleY);
return {
scale,
x: Math.max(-limitX, Math.min(limitX, Number(view?.x) || 0)),
y: Math.max(-limitY, Math.min(limitY, Number(view?.y) || 0)),
};
}
export function zoomCanvasView(view, nextScale, anchor, bounds) {
const scale = normalizeCanvasScale(nextScale);
const previousScale = normalizeCanvasScale(view?.scale);
const centerX = bounds.viewportWidth / 2;
const centerY = bounds.viewportHeight / 2;
const ratio = scale / previousScale;
const x = (anchor.x - centerX) - ratio * (anchor.x - centerX - (Number(view?.x) || 0));
const y = (anchor.y - centerY) - ratio * (anchor.y - centerY - (Number(view?.y) || 0));
return clampCanvasView({ scale, x, y }, bounds);
}
export function clampFloatingButton(position, {
viewportWidth,
viewportHeight,
size = 56,
inset = 8,
}) {
const half = size / 2;
const minX = inset + half;
const maxX = Math.max(minX, viewportWidth - inset - half);
const minY = inset + half;
const maxY = Math.max(minY, viewportHeight - inset - half);
return {
x: Math.max(minX, Math.min(maxX, Number(position?.x) || minX)),
y: Math.max(minY, Math.min(maxY, Number(position?.y) || minY)),
};
}
@@ -0,0 +1,76 @@
export function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value)));
}
export function normalizeHex(value) {
const match = /^#?([0-9a-f]{6})$/i.exec(String(value).trim());
return match ? `#${match[1].toUpperCase()}` : null;
}
export function rgbToHex({ r, g, b }) {
return `#${[r, g, b]
.map((channel) => Math.round(clamp(channel, 0, 255)).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
}
export function hexToRgb(value) {
const hex = normalizeHex(value);
if (!hex) throw new TypeError("颜色必须是 #RRGGBB");
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
export function rgbToHsv({ r, g, b }) {
const red = clamp(r, 0, 255) / 255;
const green = clamp(g, 0, 255) / 255;
const blue = clamp(b, 0, 255) / 255;
const maximum = Math.max(red, green, blue);
const minimum = Math.min(red, green, blue);
const delta = maximum - minimum;
let hue = 0;
if (delta !== 0) {
if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
else hue = 60 * ((red - green) / delta + 4);
}
if (hue < 0) hue += 360;
return {
h: Math.round(hue),
s: Math.round(maximum === 0 ? 0 : (delta / maximum) * 100),
v: Math.round(maximum * 100),
};
}
export function hsvToRgb({ h, s, v }) {
const hue = ((Number(h) % 360) + 360) % 360;
const saturation = clamp(s, 0, 100) / 100;
const value = clamp(v, 0, 100) / 100;
const chroma = value * saturation;
const segment = hue / 60;
const secondary = chroma * (1 - Math.abs((segment % 2) - 1));
let channels = [0, 0, 0];
if (segment < 1) channels = [chroma, secondary, 0];
else if (segment < 2) channels = [secondary, chroma, 0];
else if (segment < 3) channels = [0, chroma, secondary];
else if (segment < 4) channels = [0, secondary, chroma];
else if (segment < 5) channels = [secondary, 0, chroma];
else channels = [chroma, 0, secondary];
const match = value - chroma;
return {
r: Math.round((channels[0] + match) * 255),
g: Math.round((channels[1] + match) * 255),
b: Math.round((channels[2] + match) * 255),
};
}
export function updateRecentColors(colors, value, limit = 10) {
const normalized = normalizeHex(value);
if (!normalized) return Array.isArray(colors) ? colors.slice(0, limit) : [];
const existing = Array.isArray(colors)
? colors.map(normalizeHex).filter(Boolean).filter((color) => color !== normalized)
: [];
return [normalized, ...existing].slice(0, limit);
}
@@ -0,0 +1,426 @@
import { announce, apiJson, createLocalState, setActiveButton } from "./core.js";
import {
clamp,
hexToRgb,
hsvToRgb,
normalizeHex,
rgbToHex,
rgbToHsv,
updateRecentColors,
} from "./color-math.js";
import { setControlBusy, setControlReady } from "./interaction-feedback.js";
const localState = createLocalState("colors", 1);
const savedState = localState.load({});
let recentColors = Array.isArray(savedState.recentColors)
? savedState.recentColors.map(normalizeHex).filter(Boolean).slice(0, 10)
: [];
let mode = savedState.mode === "rgb" ? "rgb" : "hsv";
let palette = [];
let activeTrigger = null;
let originalColor = "#000000";
let currentRgb = hexToRgb(originalColor);
let currentHsv = rgbToHsv(currentRgb);
let paletteEditing = false;
let pickerReadonly = false;
const overlay = document.createElement("div");
overlay.id = "color-picker-overlay";
overlay.className = "color-picker-overlay";
overlay.hidden = true;
overlay.innerHTML = `
<section class="color-picker-panel" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
<header class="color-picker-header">
<div><p class="eyebrow">软件自有控件</p><h2 id="color-picker-title">选择颜色</h2></div>
<button class="compact-button" type="button" data-color-action="cancel" aria-label="关闭颜色选择器">关闭</button>
</header>
<div class="color-comparison" aria-label="原颜色与新颜色对比">
<div><span>原颜色</span><i id="color-original-preview"></i></div>
<div><span>新颜色</span><i id="color-current-preview"></i></div>
</div>
<div class="color-mode-tabs segmented" role="tablist" aria-label="颜色模式">
<button type="button" role="tab" data-color-mode="hsv">HSV</button>
<button type="button" role="tab" data-color-mode="rgb">RGB</button>
</div>
<div id="color-hsv-panel" class="color-mode-panel" role="tabpanel">
<div id="color-sv-field" class="color-sv-field" role="slider" tabindex="0" aria-label="饱和度和明度" aria-valuemin="0" aria-valuemax="100">
<span id="color-sv-handle" class="color-track-handle"></span>
</div>
<div id="color-hue-track" class="color-linear-track color-hue-track" role="slider" tabindex="0" aria-label="色相" aria-valuemin="0" aria-valuemax="360">
<span id="color-hue-handle" class="color-track-handle"></span>
</div>
<div class="color-number-grid">
<label>H<input id="color-h" type="number" min="0" max="360" inputmode="numeric"></label>
<label>S<input id="color-s" type="number" min="0" max="100" inputmode="numeric"></label>
<label>V<input id="color-v" type="number" min="0" max="100" inputmode="numeric"></label>
</div>
</div>
<div id="color-rgb-panel" class="color-mode-panel" role="tabpanel" hidden>
<div class="rgb-channel"><span>R</span><div class="color-linear-track" data-rgb-track="r" role="slider" tabindex="0" aria-label="红色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-r" type="number" min="0" max="255" inputmode="numeric"></div>
<div class="rgb-channel"><span>G</span><div class="color-linear-track" data-rgb-track="g" role="slider" tabindex="0" aria-label="绿色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-g" type="number" min="0" max="255" inputmode="numeric"></div>
<div class="rgb-channel"><span>B</span><div class="color-linear-track" data-rgb-track="b" role="slider" tabindex="0" aria-label="蓝色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-b" type="number" min="0" max="255" inputmode="numeric"></div>
</div>
<label class="color-hex-field">HEX<input id="color-hex" type="text" maxlength="7" autocapitalize="characters" spellcheck="false" inputmode="text"></label>
<section class="color-collection" aria-labelledby="recent-colors-title">
<div class="color-collection-heading"><h3 id="recent-colors-title">最近颜色</h3></div>
<div id="recent-colors" class="color-swatch-grid"></div>
</section>
<section class="color-collection" aria-labelledby="palette-colors-title">
<div class="color-collection-heading"><h3 id="palette-colors-title">设备收藏色</h3><div><button type="button" class="compact-button" data-color-action="favorite">收藏当前</button><button type="button" class="compact-button" data-color-action="edit-palette">编辑</button></div></div>
<div id="palette-colors" class="color-swatch-grid"></div>
<p id="palette-status" class="hint" aria-live="polite"></p>
</section>
<footer class="color-picker-actions">
<button type="button" data-color-action="cancel">取消</button>
<button type="button" class="primary-button" data-color-action="confirm">使用此颜色</button>
</footer>
</section>`;
document.body.appendChild(overlay);
const panel = overlay.querySelector(".color-picker-panel");
const hsvPanel = document.getElementById("color-hsv-panel");
const rgbPanel = document.getElementById("color-rgb-panel");
const svField = document.getElementById("color-sv-field");
const hueTrack = document.getElementById("color-hue-track");
const modeButtons = Array.from(overlay.querySelectorAll("[data-color-mode]"));
const rgbTracks = Array.from(overlay.querySelectorAll("[data-rgb-track]"));
function persistLocalColors() {
localState.save({ recentColors, mode });
}
function renderTrigger(trigger) {
const value = normalizeHex(trigger.value) || "#000000";
trigger.value = value;
trigger.querySelector(".color-trigger-swatch").style.background = value;
trigger.querySelector(".color-trigger-value").textContent = value;
}
function setMode(nextMode) {
mode = nextMode === "rgb" ? "rgb" : "hsv";
setActiveButton(modeButtons, modeButtons.find((button) => button.dataset.colorMode === mode));
modeButtons.forEach((button) => button.setAttribute("aria-selected", String(button.dataset.colorMode === mode)));
hsvPanel.hidden = mode !== "hsv";
rgbPanel.hidden = mode !== "rgb";
persistLocalColors();
}
function setCurrentRgb(rgb) {
if (pickerReadonly) return;
currentRgb = {
r: Math.round(clamp(rgb.r, 0, 255)),
g: Math.round(clamp(rgb.g, 0, 255)),
b: Math.round(clamp(rgb.b, 0, 255)),
};
currentHsv = rgbToHsv(currentRgb);
renderPicker();
}
function setCurrentHsv(hsv) {
if (pickerReadonly) return;
currentHsv = {
h: Math.round(clamp(hsv.h, 0, 360)),
s: Math.round(clamp(hsv.s, 0, 100)),
v: Math.round(clamp(hsv.v, 0, 100)),
};
currentRgb = hsvToRgb(currentHsv);
renderPicker();
}
function renderPicker() {
const hex = rgbToHex(currentRgb);
document.getElementById("color-original-preview").style.background = originalColor;
document.getElementById("color-current-preview").style.background = hex;
document.getElementById("color-hex").value = hex;
for (const channel of ["r", "g", "b"]) document.getElementById(`color-${channel}`).value = currentRgb[channel];
for (const channel of ["h", "s", "v"]) document.getElementById(`color-${channel}`).value = currentHsv[channel];
svField.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${currentHsv.h} 100% 50%))`;
const svHandle = document.getElementById("color-sv-handle");
svHandle.style.left = `${currentHsv.s}%`;
svHandle.style.top = `${100 - currentHsv.v}%`;
svField.setAttribute("aria-valuenow", String(currentHsv.s));
svField.setAttribute("aria-valuetext", `饱和度 ${currentHsv.s}%,明度 ${currentHsv.v}%`);
document.getElementById("color-hue-handle").style.left = `${currentHsv.h / 3.6}%`;
hueTrack.setAttribute("aria-valuenow", String(currentHsv.h));
rgbTracks.forEach((track) => {
const channel = track.dataset.rgbTrack;
const start = { ...currentRgb, [channel]: 0 };
const end = { ...currentRgb, [channel]: 255 };
track.style.background = `linear-gradient(to right, ${rgbToHex(start)}, ${rgbToHex(end)})`;
track.querySelector(".color-track-handle").style.left = `${(currentRgb[channel] / 255) * 100}%`;
track.setAttribute("aria-valuenow", String(currentRgb[channel]));
});
renderSwatches();
}
function swatchButton(color, label) {
const button = document.createElement("button");
button.type = "button";
button.className = "color-swatch";
button.style.background = color;
button.title = color;
button.setAttribute("aria-label", `${label} ${color}`);
button.addEventListener("click", () => setCurrentRgb(hexToRgb(color)));
return button;
}
function renderSwatches() {
const recentContainer = document.getElementById("recent-colors");
recentContainer.replaceChildren(...recentColors.map((color) => swatchButton(color, "最近颜色")));
if (!recentColors.length) recentContainer.textContent = "还没有最近颜色";
const paletteContainer = document.getElementById("palette-colors");
paletteContainer.replaceChildren(...palette.map((color) => {
const wrapper = document.createElement("span");
wrapper.className = "palette-swatch";
wrapper.appendChild(swatchButton(color, "收藏颜色"));
if (paletteEditing) {
const remove = document.createElement("button");
remove.type = "button";
remove.className = "palette-remove";
remove.textContent = "×";
remove.setAttribute("aria-label", `删除收藏颜色 ${color}`);
remove.addEventListener("click", () => deleteFavorite(color, remove));
wrapper.appendChild(remove);
}
return wrapper;
}));
if (!palette.length) paletteContainer.textContent = "收藏色板为空";
}
async function loadPalette() {
const status = document.getElementById("palette-status");
status.textContent = "正在读取设备收藏色…";
try {
const response = await apiJson("/api/colors/palette");
palette = response.colors.map(normalizeHex).filter(Boolean);
status.textContent = `${palette.length} / ${response.limit}`;
renderSwatches();
} catch (error) {
status.textContent = `收藏色读取失败:${error.message}`;
}
}
async function addFavorite(control) {
setControlBusy(control, { label: "正在收藏…", message: "正在收藏当前颜色…" });
try {
const response = await apiJson("/api/colors/palette", {
method: "POST",
body: JSON.stringify({ color: rgbToHex(currentRgb) }),
});
palette = response.colors;
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
renderSwatches();
} catch (error) {
announce(`收藏颜色失败:${error.message}`, true);
} finally {
setControlReady(control);
}
}
async function deleteFavorite(color, control) {
setControlBusy(control, { message: `正在删除收藏颜色 ${color}…` });
try {
const response = await apiJson(`/api/colors/palette/${color.slice(1)}`, { method: "DELETE" });
palette = response.colors;
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
renderSwatches();
} catch (error) {
announce(`删除收藏颜色失败:${error.message}`, true);
} finally {
setControlReady(control);
}
}
function trackRatio(event, track) {
const rect = track.getBoundingClientRect();
return clamp((event.clientX - rect.left) / rect.width, 0, 1);
}
function bindHorizontalTrack(track, update) {
let activePointer = null;
const move = (event) => {
if (activePointer !== event.pointerId) return;
update(trackRatio(event, track));
};
track.addEventListener("pointerdown", (event) => {
activePointer = event.pointerId;
track.setPointerCapture(event.pointerId);
update(trackRatio(event, track));
});
track.addEventListener("pointermove", move);
const finish = (event) => { if (activePointer === event.pointerId) activePointer = null; };
track.addEventListener("pointerup", finish);
track.addEventListener("pointercancel", finish);
}
bindHorizontalTrack(hueTrack, (ratio) => setCurrentHsv({ ...currentHsv, h: ratio * 360 }));
rgbTracks.forEach((track) => bindHorizontalTrack(track, (ratio) => {
setCurrentRgb({ ...currentRgb, [track.dataset.rgbTrack]: ratio * 255 });
}));
let svPointer = null;
function updateSvFromPointer(event) {
if (event.pointerId !== svPointer) return;
const rect = svField.getBoundingClientRect();
setCurrentHsv({
...currentHsv,
s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
v: clamp((1 - ((event.clientY - rect.top) / rect.height)) * 100, 0, 100),
});
}
svField.addEventListener("pointerdown", (event) => {
svPointer = event.pointerId;
svField.setPointerCapture(event.pointerId);
updateSvFromPointer(event);
});
svField.addEventListener("pointermove", updateSvFromPointer);
svField.addEventListener("pointerup", () => { svPointer = null; });
svField.addEventListener("pointercancel", () => { svPointer = null; });
function sliderKeyDelta(event, large = 10) {
if (["ArrowRight", "ArrowUp"].includes(event.key)) return event.shiftKey ? large : 1;
if (["ArrowLeft", "ArrowDown"].includes(event.key)) return event.shiftKey ? -large : -1;
if (event.key === "PageUp") return large;
if (event.key === "PageDown") return -large;
return 0;
}
hueTrack.addEventListener("keydown", (event) => {
const delta = sliderKeyDelta(event, 10);
if (!delta) return;
event.preventDefault();
setCurrentHsv({ ...currentHsv, h: clamp(currentHsv.h + delta, 0, 360) });
});
rgbTracks.forEach((track) => track.addEventListener("keydown", (event) => {
const delta = sliderKeyDelta(event, 10);
if (!delta) return;
event.preventDefault();
const channel = track.dataset.rgbTrack;
setCurrentRgb({ ...currentRgb, [channel]: currentRgb[channel] + delta });
}));
svField.addEventListener("keydown", (event) => {
const delta = event.shiftKey ? 10 : 1;
const next = { ...currentHsv };
if (event.key === "ArrowLeft") next.s -= delta;
else if (event.key === "ArrowRight") next.s += delta;
else if (event.key === "ArrowDown") next.v -= delta;
else if (event.key === "ArrowUp") next.v += delta;
else return;
event.preventDefault();
setCurrentHsv(next);
});
for (const channel of ["r", "g", "b"]) {
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
setCurrentRgb({ ...currentRgb, [channel]: event.target.value });
});
}
for (const channel of ["h", "s", "v"]) {
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
setCurrentHsv({ ...currentHsv, [channel]: event.target.value });
});
}
document.getElementById("color-hex").addEventListener("input", (event) => {
const hex = normalizeHex(event.target.value);
if (hex) setCurrentRgb(hexToRgb(hex));
});
function closePicker() {
overlay.hidden = true;
document.body.classList.remove("color-picker-open");
const trigger = activeTrigger;
activeTrigger = null;
trigger?.focus();
}
function confirmPicker() {
if (!activeTrigger || pickerReadonly) return;
setColorValue(activeTrigger, rgbToHex(currentRgb), { recordHistory: true });
closePicker();
}
function openPicker(trigger) {
activeTrigger = trigger;
pickerReadonly = trigger.dataset.colorReadonly === "true";
originalColor = normalizeHex(trigger.value) || "#000000";
currentRgb = hexToRgb(originalColor);
currentHsv = rgbToHsv(currentRgb);
paletteEditing = false;
overlay.querySelector("[data-color-action='edit-palette']").classList.remove("active");
setMode(mode);
renderPicker();
panel.classList.toggle("color-picker-readonly", pickerReadonly);
document.getElementById("color-picker-title").textContent = pickerReadonly ? "查看颜色" : "选择颜色";
panel.querySelectorAll(".color-collection, .color-picker-actions").forEach((element) => {
element.hidden = pickerReadonly;
});
for (const id of ["color-h", "color-s", "color-v", "color-r", "color-g", "color-b", "color-hex"]) {
document.getElementById(id).readOnly = pickerReadonly;
}
[svField, hueTrack, ...rgbTracks].forEach((track) => {
track.setAttribute("aria-disabled", String(pickerReadonly));
track.tabIndex = pickerReadonly ? -1 : 0;
});
overlay.hidden = false;
document.body.classList.add("color-picker-open");
if (!pickerReadonly) loadPalette();
window.requestAnimationFrame(() => panel.querySelector("[data-color-action='cancel']").focus());
}
export function setColorValue(trigger, value, { recordHistory = true } = {}) {
const color = normalizeHex(value);
if (!color) throw new TypeError("颜色必须是 #RRGGBB");
trigger.value = color;
renderTrigger(trigger);
if (recordHistory) {
recentColors = updateRecentColors(recentColors, color, 10);
persistLocalColors();
}
trigger.dispatchEvent(new Event("input", { bubbles: true }));
trigger.dispatchEvent(new Event("change", { bubbles: true }));
}
export function refreshColorTrigger(trigger) {
renderTrigger(trigger);
}
modeButtons.forEach((button) => button.addEventListener("click", () => setMode(button.dataset.colorMode)));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) closePicker();
const action = event.target.closest("[data-color-action]")?.dataset.colorAction;
if (action === "cancel") closePicker();
else if (action === "confirm") confirmPicker();
else if (action === "favorite") addFavorite(event.target.closest("[data-color-action='favorite']"));
else if (action === "edit-palette") {
paletteEditing = !paletteEditing;
event.target.classList.toggle("active", paletteEditing);
renderSwatches();
}
});
window.addEventListener("keydown", (event) => {
if (overlay.hidden) return;
if (event.key === "Escape") {
event.preventDefault();
closePicker();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(panel.querySelectorAll("button:not(:disabled), input:not(:disabled), [tabindex='0']"))
.filter((element) => !element.closest("[hidden]") && element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
document.querySelectorAll("[data-color-picker]").forEach((trigger) => {
renderTrigger(trigger);
trigger.addEventListener("click", () => openPicker(trigger));
});
+198
View File
@@ -0,0 +1,198 @@
const workspaces = new Map();
const PERSISTENCE_POLICIES = new Set(["none", "local", "server"]);
let appStale = false;
export const DEFAULT_API_TIMEOUT_MS = 15000;
export const DEFAULT_WORKSPACE_ID = "device";
export function isAppStale() {
return appStale;
}
export function markAppStale() {
if (appStale) return;
appStale = true;
document.getElementById("app-version-notice")?.removeAttribute("hidden");
announce("控制程序已更新,请刷新页面后再操作设备", true);
document.dispatchEvent(new CustomEvent("matrix:app-stale"));
}
function ensureWriteAllowed(path, options) {
const method = String(options.method || "GET").toUpperCase();
const isWrite = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
if (appStale && isWrite && !path.startsWith("/api/preview/")) {
const error = new Error("页面版本已过期,请刷新后再提交此操作");
error.status = 409;
error.code = "APP_STALE";
throw error;
}
}
export function registerWorkspace(definition) {
const required = ["id", "title", "elementId", "order", "persistence"];
for (const key of required) {
if (definition[key] === undefined || definition[key] === null || definition[key] === "") {
throw new Error(`工作区缺少 ${key}`);
}
}
if (workspaces.has(definition.id)) {
throw new Error(`工作区 ${definition.id} 重复注册`);
}
if (!PERSISTENCE_POLICIES.has(definition.persistence)) {
throw new Error(`工作区 ${definition.id} 的持久化策略无效`);
}
const capabilities = definition.capabilities || [];
if (!Array.isArray(capabilities) || capabilities.some((item) => typeof item !== "string")) {
throw new Error(`工作区 ${definition.id} 的能力声明无效`);
}
workspaces.set(definition.id, Object.freeze({
...definition,
capabilities: Object.freeze([...new Set(capabilities)]),
}));
}
export function getWorkspaces() {
return Array.from(workspaces.values()).sort((left, right) => (
left.order - right.order
|| left.title.localeCompare(right.title, "zh-CN")
));
}
async function fetchWithTimeout(path, options) {
const { timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
let timedOut = false;
const forwardAbort = () => controller.abort(externalSignal?.reason);
if (externalSignal?.aborted) forwardAbort();
else externalSignal?.addEventListener("abort", forwardAbort, { once: true });
const timer = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await fetch(path, { ...fetchOptions, signal: controller.signal });
} catch (error) {
if (timedOut) {
const timeoutError = new Error("请求超时,请检查核桃派网络或服务状态后重试");
timeoutError.code = "REQUEST_TIMEOUT";
throw timeoutError;
}
throw error;
} finally {
window.clearTimeout(timer);
externalSignal?.removeEventListener("abort", forwardAbort);
}
}
export async function apiJson(path, options = {}) {
ensureWriteAllowed(path, options);
const response = await fetchWithTimeout(path, {
...options,
cache: "no-store",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.detail || response.statusText);
error.status = response.status;
error.detail = data.detail;
throw error;
}
return data;
}
export async function apiBlob(path, options = {}) {
ensureWriteAllowed(path, options);
const response = await fetchWithTimeout(path, {
...options,
cache: "no-store",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
if (!response.ok) {
let message = response.statusText;
try {
const data = await response.json();
message = data.detail || message;
} catch (_error) {
// Keep the HTTP status text when the error body is not JSON.
}
const error = new Error(message);
error.status = response.status;
throw error;
}
return response.blob();
}
export async function apiFile(path, file, options = {}) {
ensureWriteAllowed(path, options);
const response = await fetchWithTimeout(path, {
...options,
method: options.method || "POST",
body: file,
cache: "no-store",
headers: { "Content-Type": "application/octet-stream", ...(options.headers || {}) },
});
let data = null;
try {
data = await response.json();
} catch (_error) {
data = null;
}
if (!response.ok) {
const error = new Error(data?.detail || response.statusText);
error.status = response.status;
error.detail = data?.detail;
throw error;
}
return data;
}
export function announce(text, isError = false) {
const message = document.getElementById("last-message");
message.textContent = text;
message.classList.toggle("message-error", isError);
message.setAttribute("role", isError ? "alert" : "status");
}
export function createLocalState(namespace, version) {
const key = `matrixController:${namespace}:v${version}`;
return {
load(fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (_error) {
return fallback;
}
},
save(value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (_error) {
announce("浏览器无法保存当前编辑草稿", true);
}
},
};
}
export function setActiveButton(buttons, activeButton) {
buttons.forEach((button) => {
const active = button === activeButton;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
}
export function debounce(callback, wait) {
let timer = null;
const wrapped = (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => callback(...args), wait);
};
wrapped.flush = (...args) => {
window.clearTimeout(timer);
timer = null;
callback(...args);
};
return wrapped;
}
@@ -0,0 +1,78 @@
export const CURRENT_DISPLAY_SPEEDS = Object.freeze([0.5, 1, 1.5, 2]);
export function formatPlaybackTime(valueMs) {
const milliseconds = Math.max(0, Math.floor(Number(valueMs) || 0));
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor((milliseconds % 60000) / 1000);
const hundredths = Math.floor((milliseconds % 1000) / 10);
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`;
}
export function projectPlaybackPosition(playback, elapsedMs = 0) {
if (!playback?.active || !Number.isFinite(Number(playback.total_duration_ms))) return null;
const total = Math.max(1, Number(playback.total_duration_ms));
const base = Math.max(0, Number(playback.position_ms) || 0);
if (playback.paused) return Math.min(total - 1, base);
const speed = Number(playback.speed) || 1;
const projected = base + Math.max(0, Number(elapsedMs) || 0) * speed;
return Math.min(total - 1, projected % total);
}
export class LatestSeekDispatcher {
constructor(send, {
intervalMs = 100,
now = () => performance.now(),
setTimer = (callback, delay) => window.setTimeout(callback, delay),
clearTimer = (timer) => window.clearTimeout(timer),
onError = () => {},
} = {}) {
this.send = send;
this.intervalMs = intervalMs;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this.onError = onError;
this.inFlight = false;
this.lastStartedAt = Number.NEGATIVE_INFINITY;
this.queued = null;
this.timer = null;
}
enqueue(positionMs, { flush = false } = {}) {
this.queued = { positionMs, flush: flush || Boolean(this.queued?.flush) };
this.#drain();
}
clear() {
this.queued = null;
if (this.timer !== null) this.clearTimer(this.timer);
this.timer = null;
}
#drain() {
if (this.inFlight || this.queued === null) return;
const waitMs = this.intervalMs - (this.now() - this.lastStartedAt);
if (!this.queued.flush && waitMs > 0) {
if (this.timer === null) {
this.timer = this.setTimer(() => {
this.timer = null;
this.#drain();
}, waitMs);
}
return;
}
if (this.timer !== null) this.clearTimer(this.timer);
this.timer = null;
const item = this.queued;
this.queued = null;
this.inFlight = true;
this.lastStartedAt = this.now();
Promise.resolve()
.then(() => this.send(item.positionMs))
.catch((error) => this.onError(error, item.positionMs))
.finally(() => {
this.inFlight = false;
this.#drain();
});
}
}
@@ -0,0 +1,207 @@
const SOURCE_ORDER = Object.freeze(["automatic", "imported", "system"]);
const SOURCE_LABELS = Object.freeze({
automatic: "自动字体",
imported: "已导入字体",
system: "系统字体",
});
export function normalizeFontItems(items) {
const seen = new Set();
const normalized = [];
for (const raw of Array.isArray(items) ? items : []) {
if (!raw || typeof raw.id !== "string" || !raw.id || seen.has(raw.id)) continue;
if (!SOURCE_ORDER.includes(raw.source)) continue;
seen.add(raw.id);
const family = String(raw.family || "未命名字体").trim() || "未命名字体";
const style = String(raw.style || "Regular").trim() || "Regular";
normalized.push(Object.freeze({
id: raw.id,
label: String(raw.label || family).trim() || family,
family,
style,
source: raw.source,
}));
}
return normalized;
}
export function filterFontItems(items, query = "") {
const needle = String(query).trim().toLocaleLowerCase("zh-CN");
if (!needle) return [...items];
return items.filter((item) => (
`${item.label} ${item.family} ${item.style}`.toLocaleLowerCase("zh-CN").includes(needle)
));
}
export function groupFontItems(items) {
return SOURCE_ORDER
.map((source) => ({
source,
label: SOURCE_LABELS[source],
items: items.filter((item) => item.source === source),
}))
.filter((group) => group.items.length);
}
export function createFontPicker({ input, listbox, onCommit }) {
let items = [];
let selectedId = "default";
let visibleItems = [];
let activeIndex = -1;
let enabled = false;
let closeTimer = null;
function selectedItem() {
return items.find((item) => item.id === selectedId) || null;
}
function selectedLabel() {
return selectedItem()?.label || "当前字体不可用,将自动回退";
}
function restoreLabel() {
input.value = selectedLabel();
input.classList.toggle("font-reference-missing", !selectedItem());
}
function close({ restore = true } = {}) {
window.clearTimeout(closeTimer);
listbox.hidden = true;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
activeIndex = -1;
if (restore) restoreLabel();
}
function optionId(index) {
return `${listbox.id}-option-${index}`;
}
function setActive(index) {
if (!visibleItems.length) {
activeIndex = -1;
input.removeAttribute("aria-activedescendant");
return;
}
activeIndex = Math.max(0, Math.min(index, visibleItems.length - 1));
input.setAttribute("aria-activedescendant", optionId(activeIndex));
listbox.querySelectorAll('[role="option"]').forEach((option, optionIndex) => {
const active = optionIndex === activeIndex;
option.classList.toggle("active", active);
if (active) option.scrollIntoView({ block: "nearest" });
});
}
function commit(item) {
if (!item) return;
selectedId = item.id;
restoreLabel();
close({ restore: false });
onCommit?.(item);
}
function render(query = "") {
visibleItems = filterFontItems(items, query);
const groups = groupFontItems(visibleItems);
const nodes = [];
let optionIndex = 0;
for (const group of groups) {
const heading = document.createElement("div");
heading.className = "font-option-group";
heading.textContent = group.label;
heading.setAttribute("role", "presentation");
nodes.push(heading);
for (const item of group.items) {
const option = document.createElement("div");
option.id = optionId(optionIndex);
option.className = "font-option";
option.setAttribute("role", "option");
option.setAttribute("aria-selected", String(item.id === selectedId));
option.setAttribute("aria-label", item.label);
option.dataset.fontId = item.id;
const label = document.createElement("span");
label.textContent = item.family;
const source = document.createElement("small");
source.textContent = item.style;
source.setAttribute("aria-hidden", "true");
option.append(label, source);
option.addEventListener("pointerdown", (event) => event.preventDefault());
option.addEventListener("click", () => commit(item));
nodes.push(option);
optionIndex += 1;
}
}
if (!visibleItems.length) {
const empty = document.createElement("div");
empty.className = "font-options-empty";
empty.textContent = "没有匹配的字体";
nodes.push(empty);
}
listbox.replaceChildren(...nodes);
activeIndex = visibleItems.findIndex((item) => item.id === selectedId);
if (activeIndex < 0 && visibleItems.length) activeIndex = 0;
setActive(activeIndex);
}
function open(query = "") {
if (!enabled) return;
render(query);
listbox.hidden = false;
input.setAttribute("aria-expanded", "true");
}
input.addEventListener("focus", () => {
if (!enabled) return;
input.select();
open();
});
input.addEventListener("click", () => open());
input.addEventListener("input", (event) => {
event.stopPropagation();
open(input.value);
});
input.addEventListener("keydown", (event) => {
if (!enabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (listbox.hidden) open();
setActive(activeIndex + (event.key === "ArrowDown" ? 1 : -1));
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
if (listbox.hidden) open();
setActive(event.key === "Home" ? 0 : visibleItems.length - 1);
} else if (event.key === "Enter" && !listbox.hidden) {
event.preventDefault();
commit(visibleItems[activeIndex]);
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
});
input.addEventListener("blur", () => {
closeTimer = window.setTimeout(() => close(), 0);
});
restoreLabel();
return Object.freeze({
setItems(nextItems) {
items = normalizeFontItems(nextItems);
restoreLabel();
if (!listbox.hidden) render();
},
setValue(identifier) {
selectedId = typeof identifier === "string" && identifier ? identifier : "default";
restoreLabel();
},
getValue() {
return selectedId;
},
setEnabled(value) {
enabled = Boolean(value);
input.readOnly = !enabled;
if (!enabled) close();
},
open,
close,
});
}
@@ -0,0 +1,943 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#111318">
<title>奇妙小屏幕控制器</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-shell">
<button id="drawer-toggle" class="drawer-toggle" type="button" aria-controls="workspace-drawer" aria-expanded="false" aria-label="打开功能菜单">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="drawer-toggle-menu-icon" d="M4 7h16M4 12h16M4 17h16"></path>
<path class="drawer-toggle-close-icon" d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
<aside id="workspace-drawer" class="drawer" aria-label="功能选择" aria-hidden="true" inert>
<div class="drawer-header">
<div>
<p class="eyebrow" data-ui-copy-default-text="64 × 64 点阵屏"></p>
<p class="brand">奇妙小屏幕控制器</p>
</div>
</div>
<div class="workspace-order-editor">
<button id="workspace-order-toggle" class="workspace-order-toggle compact-button" type="button">自定义项目位置</button>
<p id="workspace-order-feedback" class="workspace-order-feedback" role="status" aria-live="polite" hidden></p>
</div>
<nav id="workspace-nav" class="workspace-nav" aria-label="工作区"></nav>
<p class="drawer-note" data-ui-copy-default-text="局域网设备控制台"></p>
</aside>
<button id="drawer-backdrop" class="drawer-backdrop" type="button" aria-label="关闭功能菜单" hidden></button>
<div class="app-main">
<header class="topbar">
<div class="topbar-copy">
<h1 id="current-workspace-title">设备状态和测试</h1>
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
</div>
<div class="topbar-statuses" aria-label="设备实时状态">
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
当前画面:正在读取…
</button>
<button id="topbar-cpu" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看 CPU 占用详情">
CPU -
</button>
<button id="topbar-memory" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看内存占用详情">
RAM -
</button>
<div
id="topbar-low-voltage-warning"
class="low-voltage-warning"
data-mode="disabled"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></div>
<div id="topbar-voltage" class="voltage-indicator" data-ui-copy-value data-status="starting" role="status" aria-live="polite">
正在读取电压…
</div>
</div>
</header>
<section id="app-version-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
<div>
<strong>控制程序已更新</strong>
<p>当前页面版本已过期。画板仍可编辑和导出,但刷新前不会再向设备或模板提交操作。</p>
</div>
<button id="reload-app" class="primary-button" type="button">刷新到新版本</button>
</section>
<section id="draft-conflict-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
<div>
<strong>另一标签页修改了画板草稿</strong>
<p>请选择载入另一标签页的最新草稿,或明确保留当前标签页的草稿。</p>
</div>
<div class="sync-notice-actions">
<button id="load-external-draft" type="button">载入其他标签草稿</button>
<button id="keep-current-draft" class="primary-button" type="button">保留当前草稿</button>
</div>
</section>
<section id="board-shell" class="board-shell" aria-labelledby="board-title">
<div id="standard-board-shell">
<div class="board-heading">
<div>
<p id="board-eyebrow" class="eyebrow" data-ui-copy-value>64 × 64 统一画板</p>
<h2 id="board-title" data-ui-copy-value>合成预览</h2>
</div>
<div class="board-heading-context">
<span id="board-mode" class="orientation-label" data-ui-copy-value>正向 0° · 统一画板</span>
<section id="animation-edit-notice" class="animation-edit-notice" aria-live="polite" hidden>
<strong id="animation-edit-name">正在编辑:</strong>
<span id="animation-edit-status">已保存</span>
</section>
</div>
</div>
<div id="board-stage" class="board-stage">
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
</div>
<p id="board-status" class="board-status" aria-live="polite">正在恢复画板草稿…</p>
<div class="board-actions">
<button id="apply-composition" class="primary-button" type="button">应用统一画板</button>
<button id="exit-animation-edit" type="button" hidden>退出动图编辑</button>
<button id="export-composition" type="button">导出合成 PNG</button>
<button id="save-template" type="button" hidden>保存为模板</button>
</div>
<div id="canvas-edit-assistive" class="canvas-edit-assistive" hidden>
<button id="canvas-assistive-toggle" class="canvas-assistive-toggle" type="button"
aria-controls="canvas-assistive-menu" aria-expanded="false" aria-label="打开画布编辑菜单">
<span aria-hidden="true"></span>
</button>
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden>
<p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
<button id="exit-canvas-edit" type="button" role="menuitem">退出编辑</button>
<button id="move-canvas-view" type="button" role="menuitem">移动画布视线</button>
<button id="continue-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="当前已经是绘画模式。">继续编辑</button>
</div>
</div>
</div>
<section id="animation-preview-shell" class="animation-preview-shell" aria-labelledby="animation-preview-shell-title" hidden>
<h2 id="animation-preview-shell-title" class="sr-only">动图与当前画板预览</h2>
<article class="animation-preview-card">
<p class="eyebrow">已保存内容</p>
<h3>当前动图</h3>
<div class="animation-small-preview" aria-label="当前打开动图的循环预览">
<img id="animation-preview-image" alt="" width="64" height="64" hidden>
<span id="animation-preview-placeholder">请先打开一个动图</span>
</div>
<p id="animation-preview-status" class="animation-preview-status" aria-live="polite">请先打开一个动图</p>
</article>
<article class="animation-preview-card">
<p class="eyebrow">新建帧来源</p>
<h3>当前画板</h3>
<div class="animation-small-preview">
<canvas id="animation-board-preview" width="64" height="64" aria-label="当前统一画板预览"></canvas>
</div>
<p class="animation-preview-status">“从当前画板新建帧”将复制此画面</p>
</article>
</section>
</section>
<main id="workspace-root" class="workspace-root">
<section id="workspace-device" class="workspace" aria-label="设备状态和测试" hidden>
<div class="device-refresh-row">
<button id="refresh-status" class="secondary-button" type="button">刷新状态</button>
</div>
<article class="panel status-panel">
<div class="panel-heading">
<h3>屏幕状态</h3>
<span class="status-badge">仅为程序设定值,屏幕实际情况软件无法得知</span>
</div>
<dl class="status-grid status-grid-compact">
<div><dt>屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)</dt><dd id="status-orientation" data-ui-copy-value>-</dd></div>
<div><dt>亮度(实际亮度会受到电压保护功能限制)</dt><dd id="status-brightness" data-ui-copy-value>-</dd></div>
</dl>
</article>
<article class="panel">
<h3>纯色测试</h3>
<div class="choice-grid color-choices" role="group" aria-label="纯色测试选择">
<button type="button" data-test-fill="#000000">全黑(不亮)</button>
<button type="button" data-test-fill="#FF0000">全红</button>
<button type="button" data-test-fill="#00FF00">全绿</button>
<button type="button" data-test-fill="#0000FF">全蓝</button>
<button type="button" data-test-fill="#FFFFFF">全白(高亮度会导致严重耗电)</button>
<button id="custom-test-fill" type="button">自定义纯色</button>
</div>
<div class="color-field"><span>颜色 RGB / HEX</span>
<button id="test-color" class="color-trigger" type="button" value="#40A0FF" data-color-picker data-color-readonly="true" aria-label="查看测试颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#40A0FF</span>
</button>
</div>
<div id="display-test-controls" class="display-test-controls" hidden>
<div class="panel-heading">
<label for="test-brightness">测试亮度(此亮度独立于屏幕亮度)</label>
<output id="test-brightness-value" data-ui-copy-value for="test-brightness" class="brightness-output">50%</output>
</div>
<input id="test-brightness" class="brightness-slider" type="range" min="1" max="100" value="50">
<p id="test-brightness-protection" class="brightness-protection-status" role="status" aria-live="polite" hidden></p>
<p id="display-test-feedback" class="settings-feedback" aria-live="polite" hidden></p>
<button id="exit-display-test" class="danger-button" type="button">退出测试</button>
</div>
</article>
<article class="panel device-storage-panel">
<div class="panel-heading"><h3>设备容量</h3></div>
<section class="template-storage device-storage-grid" aria-label="设备与软件空间">
<div><span>设备总空间</span><strong id="device-total-space" data-ui-copy-value>-</strong></div>
<div><span>设备可用空间</span><strong id="device-free-space" data-ui-copy-value>-</strong></div>
<div><span>当前软件总占用空间</span><strong id="device-software-size" data-ui-copy-value>-</strong></div>
<div><span>模板占用空间</span><strong id="device-template-size" data-ui-copy-value>-</strong></div>
</section>
<p id="device-storage-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</article>
</section>
<section id="workspace-canvas" class="workspace" aria-labelledby="canvas-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="canvas-title" aria-label="像素画布" data-ui-copy-default-text="像素画布"></h2>
</div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
</div>
<article class="panel canvas-workbench">
<div class="canvas-toolbar">
<div class="color-field"><span>画笔颜色</span>
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button>
</div>
<div class="color-field"><span>背景颜色</span>
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
</button>
</div>
<div class="segmented canvas-tools" role="group" aria-label="绘制工具">
<button type="button" data-canvas-tool="brush">画笔</button>
<button type="button" data-canvas-tool="eraser">橡皮</button>
<button type="button" data-canvas-tool="eyedropper">吸管</button>
</div>
<div class="brush-size-control">
<div class="brush-size-heading"><span>笔尖粗细</span><output id="brush-size-value" data-ui-copy-value for="brush-size">1 px</output></div>
<div class="brush-size-row">
<span id="brush-size-preview" class="brush-size-preview" aria-hidden="true"></span>
<input id="brush-size" type="range" min="1" max="16" value="1" aria-label="画笔和橡皮粗细">
</div>
<div class="brush-size-presets" role="group" aria-label="笔尖粗细快捷选择">
<button type="button" data-brush-size="1">1</button><button type="button" data-brush-size="2">2</button>
<button type="button" data-brush-size="4">4</button><button type="button" data-brush-size="8">8</button>
</div>
<button id="pixel-edit-toggle" class="pixel-edit-toggle" type="button" aria-pressed="false" aria-describedby="pixel-edit-status">开启单像素编辑</button>
<p id="pixel-edit-status" class="pixel-edit-status" aria-live="polite" hidden></p>
</div>
</div>
<div class="choice-grid canvas-actions">
<button id="fill-canvas" type="button">填充背景</button>
<button id="clear-canvas" type="button">清空像素层</button>
</div>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section>
<section id="workspace-text" class="workspace" aria-labelledby="text-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="text-title" aria-label="文字显示" data-ui-copy-default-text="文字显示"></h2>
</div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
</div>
<article class="panel text-elements-panel">
<div class="panel-heading">
<div>
<h3>文字元素</h3>
<p id="text-selection-status" class="subtle">新建文字或从列表选择一个元素</p>
</div>
<span class="status-badge" data-ui-copy-default-text="Pillow"></span>
</div>
<div class="text-element-actions">
<button id="create-text" class="primary-button" type="button">新建文字</button>
<button id="duplicate-text" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">复制选中</button>
<button id="delete-text" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">删除选中</button>
</div>
<div id="text-element-list" class="text-element-list" role="list" aria-label="文字元素列表"></div>
</article>
<form id="text-form" class="panel text-form" autocomplete="off">
<fieldset id="text-content-fields" disabled>
<label class="wide-field">文字内容
<textarea id="text-value" rows="2" maxlength="512"></textarea>
</label>
</fieldset>
<div class="font-field wide-field">
<label id="text-font-label" for="text-font">字体</label>
<div class="font-control-row">
<div class="font-combobox-shell">
<input id="text-font" type="search" role="combobox" value="自动多语言字体 — 自动回退"
aria-labelledby="text-font-label" aria-autocomplete="list" aria-expanded="false"
aria-controls="text-font-options" autocapitalize="off" autocomplete="off" spellcheck="false">
<div id="text-font-options" class="font-options" role="listbox" aria-label="可用字体" hidden></div>
</div>
<button id="import-font" type="button">导入字体</button>
<input id="font-file" class="sr-only" type="file" accept=".ttf,.otf,.ttc,.otc">
</div>
<span class="hint">选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件;缺字时会自动回退。</span>
<p id="font-feedback" class="font-feedback" role="status" aria-live="polite" hidden></p>
</div>
<fieldset id="text-fields" disabled>
<div class="form-grid">
<label>字号
<input id="text-size" type="number" min="1" max="64" value="12" inputmode="numeric">
</label>
<label>X 坐标
<input id="text-x" type="number" min="-128" max="128" value="4" inputmode="numeric">
</label>
<label>Y 坐标
<input id="text-y" type="number" min="-128" max="128" value="24" inputmode="numeric">
</label>
<label>对齐
<select id="text-align">
<option value="left">左对齐</option>
<option value="center">居中</option>
<option value="right">右对齐</option>
</select>
</label>
<div class="color-field"><span>文字颜色</span>
<button id="text-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择文字颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button>
</div>
</div>
</fieldset>
</form>
</section>
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="media-import-title" data-ui-copy-default-text="媒体内容转换"></h2>
</div>
<button id="refresh-media-imports" class="secondary-button" type="button" data-ui-copy-default-text="刷新任务"></button>
</div>
<form id="media-upload-form" class="panel media-upload-panel">
<div class="panel-heading">
<h3 data-ui-copy-default-text="导入图片、动图或视频"></h3>
</div>
<div class="form-grid">
<label class="media-source-field"><span data-ui-copy-default-text="源文件"></span>
<input id="media-source-file" type="file" required
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,image/heic,image/heif,image/bmp,image/tiff,image/jp2,video/*,.mkv,.m4v,.mpeg,.mpg">
</label>
</div>
<button id="media-upload-button" class="primary-button" type="submit">上传文件</button>
<progress id="media-upload-progress" max="100" value="0" hidden></progress>
</form>
<p id="media-import-feedback" class="settings-feedback" aria-live="polite"
data-ui-copy-default-text="选择文件并填写保存名称。"></p>
<div id="media-import-jobs" class="media-job-list" aria-live="polite"></div>
</section>
<section id="workspace-animations" class="workspace" aria-labelledby="animations-title" hidden>
<div class="workspace-heading">
<div><p class="eyebrow" data-ui-copy-default-text="内容"></p><h2 id="animations-title">动图管理</h2></div>
<button id="refresh-animations" class="secondary-button" type="button">刷新</button>
</div>
<div class="animation-toolbar panel">
<button id="create-animation" class="primary-button" type="button">新建动图</button>
<button id="add-animation-frame" type="button" aria-disabled="true" data-unavailable-reason="请先新建或打开一个动图,再把当前画板添加为帧。">从当前画板新建帧</button>
</div>
<p id="animation-feedback" class="settings-feedback" aria-live="polite">请选择或新建动图。</p>
<section id="animation-frame-panel" class="panel" hidden>
<div class="panel-heading"><h3 id="animation-frame-title">当前动图</h3></div>
<div id="animation-batch-toolbar" class="animation-batch-toolbar" aria-label="动图帧批量操作">
<p id="animation-selection-status" aria-live="polite">未选择帧</p>
<div class="animation-batch-actions">
<button id="animation-batch-copy" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量复制</button>
<button id="animation-batch-move" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量移动</button>
<button id="animation-batch-delete" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量删除</button>
</div>
</div>
<div id="animation-frames" class="template-grid" aria-label="动图帧"></div>
<nav id="animation-frame-pagination" class="animation-frame-pagination" aria-label="动图帧分页" hidden>
<button id="animation-page-prev" type="button">上一页</button>
<p id="animation-page-status" aria-live="polite">第 1 / 1 页 · 共 0 帧</p>
<button id="animation-page-next" type="button">下一页</button>
</nav>
</section>
<section class="animation-existing" aria-labelledby="animation-existing-title">
<div class="panel-heading"><h3 id="animation-existing-title">保存的动图</h3></div>
<div id="animation-folders" class="animation-folder-list" aria-label="已有动图"></div>
</section>
</section>
<section id="workspace-templates" class="workspace" aria-labelledby="templates-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="templates-title">模板管理</h2>
</div>
<div class="workspace-heading-actions">
<button id="select-default-content" class="secondary-button" type="button" aria-pressed="false">设置默认显示内容</button>
<button id="refresh-templates" class="secondary-button" type="button">刷新</button>
</div>
</div>
<p id="template-feedback" class="settings-feedback" aria-live="polite">正在读取模板…</p>
<div id="template-grid" class="template-grid" aria-label="已保存模板"></div>
</section>
<section id="workspace-settings" class="workspace" aria-labelledby="settings-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="设备"></p>
<h2 id="settings-title">系统设置</h2>
</div>
</div>
<article class="panel setting-card">
<div>
<div class="panel-heading">
<div>
<h3>屏幕亮度</h3>
<p class="hint">拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)</p>
</div>
<output id="brightness-value" data-ui-copy-value for="config-brightness" class="brightness-output">40%</output>
</div>
<input id="config-brightness" class="brightness-slider" type="range" min="1" max="100" value="40">
<p
id="brightness-protection-status"
class="brightness-protection-status"
data-mode="inactive"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></p>
</div>
</article>
<article class="panel setting-card">
<div>
<h3>屏幕方向</h3>
<p class="hint">角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。</p>
</div>
<div class="orientation-grid" role="group" aria-label="屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)">
<button type="button" data-orientation="0">0°</button>
<button type="button" data-orientation="90">90°</button>
<button type="button" data-orientation="180">180°</button>
<button type="button" data-orientation="270">270°</button>
</div>
</article>
<article class="panel setting-card">
<div>
<h3>屏幕扫描刷新率</h3>
<p class="hint">设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。</p>
</div>
<div id="matrix-refresh-rate-grid" class="refresh-rate-grid" role="group" aria-label="屏幕扫描刷新率上限">
<button type="button" data-matrix-refresh-rate="15">15 Hz</button>
<button type="button" data-matrix-refresh-rate="20">20 Hz</button>
<button type="button" data-matrix-refresh-rate="30">30 Hz</button>
<button type="button" data-matrix-refresh-rate="45">45 Hz</button>
<button type="button" data-matrix-refresh-rate="60">60 Hz</button>
<button type="button" data-matrix-refresh-rate="80">80 Hz</button>
<button type="button" data-matrix-refresh-rate="100">100 Hz</button>
</div>
<p class="hint" data-ui-copy-default-text="这里设置的是扫描上限,不是保证精确达到的实时测量值,也不同于下方网页预览刷新间隔。"></p>
</article>
<article class="panel setting-card voltage-setting-card">
<div>
<h3>供电电压</h3>
<p class="hint">设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。</p>
</div>
<dl class="voltage-details">
<div><dt>当前电压</dt><dd id="voltage-current" data-ui-copy-value>-</dd></div>
<div><dt>传感器状态</dt><dd id="voltage-sensor-status" data-ui-copy-value>正在读取</dd></div>
<div><dt>校准状态</dt><dd id="voltage-calibration-status" data-ui-copy-value>未校准</dd></div>
<div><dt>校准时间</dt><dd id="voltage-calibrated-at" data-ui-copy-value>-</dd></div>
</dl>
<div class="voltage-actions">
<button id="start-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="正在读取电压传感器状态,请稍候。">校准电压传感器</button>
<button id="reset-voltage-calibration" type="button">恢复标称值</button>
</div>
<div class="low-voltage-protection-setting">
<div class="low-voltage-protection-copy">
<h3>低电压亮度保护</h3>
<p id="low-voltage-protection-description" class="hint">开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度</p>
</div>
<label class="switch-control" for="low-voltage-protection-enabled">
<input
id="low-voltage-protection-enabled"
type="checkbox"
role="switch"
aria-label="低电压亮度保护"
aria-describedby="low-voltage-protection-description low-voltage-protection-feedback"
aria-disabled="true"
data-unavailable-reason="正在读取保护开关状态,请稍候。"
>
<span class="switch-track" aria-hidden="true"></span>
<span id="low-voltage-protection-switch-state" class="switch-state">关闭</span>
</label>
</div>
<p
id="low-voltage-protection-feedback"
class="low-voltage-protection-feedback"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></p>
</article>
<article class="panel setting-card">
<div>
<h3>预览刷新间隔</h3>
<p class="hint">设置设备状态和系统设置中当前屏幕画面的刷新速度。</p>
</div>
<label class="number-unit-field" for="preview-refresh-interval">
<input
id="preview-refresh-interval"
type="number"
min="1"
max="60000"
step="1"
inputmode="numeric"
value="1000"
>
<span>毫秒</span>
</label>
<p id="preview-refresh-warning" class="preview-refresh-warning" role="status" hidden>
刷新间隔过短,会增加核桃派负载,可能影响系统运行。
</p>
<p id="preview-refresh-error" class="error-text" role="alert" hidden></p>
</article>
<article class="panel setting-card wifi-setting-card">
<div>
<h3>WiFi设置</h3>
<p class="hint">设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip</p>
</div>
<div class="wifi-fields">
<label>WiFi 名称(SSID)
<input id="wifi-ssid" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-ssid-modified" required>
<span id="wifi-ssid-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>WiFi 密码
<input id="wifi-password" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-password-modified" placeholder="读取中…">
<span id="wifi-password-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<div class="wifi-dhcp-row">
<div>
<strong>自动获取 IP(DHCP)</strong>
<p id="wifi-dhcp-description" class="hint">关闭后可指定固定 IPv4 地址。</p>
</div>
<label class="switch-control" for="wifi-dhcp-enabled">
<input id="wifi-dhcp-enabled" type="checkbox" role="switch" aria-describedby="wifi-dhcp-description wifi-dhcp-modified" checked>
<span class="switch-track" aria-hidden="true"></span>
<span id="wifi-dhcp-state" class="switch-state">开启</span>
</label>
<p id="wifi-dhcp-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</p>
</div>
<div id="wifi-manual-fields" class="wifi-manual-fields" hidden>
<label>静态 IPv4 地址
<input id="wifi-address" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-address-modified" placeholder="192.168.1.50">
<span id="wifi-address-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>网关
<input id="wifi-gateway" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-gateway-modified" placeholder="192.168.1.1">
<span id="wifi-gateway-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>子网前缀(选填)
<input id="wifi-prefix" data-ui-copy-ignore type="number" min="1" max="32" step="1" inputmode="numeric" aria-describedby="wifi-prefix-modified" placeholder="24">
<span id="wifi-prefix-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
<label>DNS(选填)
<input id="wifi-dns" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-dns-modified" placeholder="留空使用网关,多个用逗号分隔">
<span id="wifi-dns-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
</label>
</div>
<p id="wifi-active-status" class="hint" role="status">正在读取当前网络…</p>
</div>
<div class="wifi-actions">
<button id="wifi-apply-immediate" type="button">立即生效(会导致网页断开)</button>
<button id="wifi-apply-next-boot" type="button">下次开机生效</button>
</div>
<p id="wifi-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</article>
<article class="panel setting-card wifi-prompt-setting-card">
<div>
<h3>网络提示显示</h3>
<p class="hint">设置设备开机后等待多少秒再显示网络连接提示。</p>
</div>
<div class="wifi-prompt-delay-row">
<label class="number-unit-field" for="wifi-prompt-delay">
<input id="wifi-prompt-delay" type="number" min="1" max="3600" step="1" inputmode="numeric" value="30">
<span>秒后显示网络提示</span>
</label>
<button id="wifi-prompt-delay-save" type="button">保存</button>
</div>
<p id="wifi-prompt-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</article>
<article class="panel setting-card ota-setting-card">
<div>
<h3>软件版本</h3>
</div>
<dl class="ota-version-details">
<div><dt>当前软件版本</dt><dd id="ota-current-version" data-ui-copy-value>读取中…</dd></div>
<div><dt>最新功能更新时间</dt><dd id="ota-feature-updated-at" data-ui-copy-value>读取中…</dd></div>
</dl>
<input id="ota-file-input" class="sr-only" type="file" accept=".ota,application/octet-stream">
<div class="ota-actions">
<button id="ota-select-file" class="primary-button" type="button">导入 OTA 更新</button>
<span class="ota-power-warning">不要在更新期间断电。</span>
</div>
<div id="ota-progress-group" class="ota-progress-group" hidden>
<div class="ota-progress-heading">
<strong id="ota-progress-stage" data-ui-copy-value>准备更新</strong>
<span id="ota-progress-value" data-ui-copy-value>0%</span>
</div>
<progress id="ota-progress" max="100" value="0">0%</progress>
</div>
<p id="ota-feedback" class="settings-feedback" role="status" aria-live="polite" aria-atomic="true" hidden></p>
<div id="ota-failure-log-actions" class="ota-failure-log-actions" hidden>
<button id="ota-view-failure-log" type="button">查看失败日志</button>
<button id="ota-copy-failure-log" type="button">复制日志</button>
</div>
</article>
<article class="panel setting-card">
<div class="low-voltage-protection-setting">
<div class="low-voltage-protection-copy">
<h3>性能模式</h3>
<p id="performance-mode-description" class="hint">如果使用出现卡顿,可以打开此选项,但会降低续航。</p>
</div>
<label class="switch-control" for="performance-mode-enabled">
<input
id="performance-mode-enabled"
type="checkbox"
role="switch"
aria-label="性能模式"
aria-describedby="performance-mode-description performance-mode-feedback"
aria-disabled="true"
data-unavailable-reason="正在读取 CPU 调频状态,请稍候。"
>
<span class="switch-track" aria-hidden="true"></span>
<span id="performance-mode-switch-state" class="switch-state">关闭</span>
</label>
</div>
<p
id="performance-mode-feedback"
class="low-voltage-protection-feedback"
role="status"
aria-live="polite"
aria-atomic="true"
>正在读取 CPU 调频状态…</p>
</article>
<article class="panel setting-card">
<div>
<h3>模板界面同时播放的动图数量</h3>
<p id="animation-preview-concurrency-warning" class="hint">不建议开启太多,否则会出现严重的性能问题。</p>
</div>
<label class="number-unit-field" for="animation-preview-max-concurrent">
<input
id="animation-preview-max-concurrent"
type="number"
min="1"
max="50"
step="1"
inputmode="numeric"
value="2"
aria-describedby="animation-preview-concurrency-warning animation-preview-concurrency-feedback"
>
<span>个</span>
</label>
<p
id="animation-preview-concurrency-feedback"
class="settings-feedback"
role="status"
aria-live="polite"
aria-atomic="true"
hidden
></p>
</article>
<article class="panel setting-card frp-setting-card">
<div>
<h3>frp配置</h3>
<p class="hint">此功能通常是远程排查使用,请保证你知道你在做什么。</p>
</div>
<dl class="frp-status-details">
<div><dt>当前配置</dt><dd id="frp-selected-name">无</dd></div>
<div><dt>运行状态</dt><dd id="frp-service-state">关闭</dd></div>
</dl>
<p id="frp-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
<div class="frp-actions">
<button id="frp-service-toggle" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先导入或新建 FRP 配置。">启动frp</button>
<button id="frp-edit-config" type="button">编辑配置文件</button>
<button id="network-diagnostics-run" type="button">网络检测</button>
</div>
</article>
<p id="settings-feedback" class="settings-feedback" aria-live="polite" hidden></p>
</section>
</main>
</div>
</div>
<dialog id="template-name-dialog" class="template-dialog">
<form method="dialog">
<div class="template-dialog-heading">
<h2 id="template-dialog-title">保存为模板</h2>
<p id="template-dialog-note" hidden></p>
</div>
<label>模板名称
<input id="template-name-input" type="text" maxlength="80" autocomplete="off" required>
</label>
<div class="template-dialog-actions">
<button type="submit" value="cancel" formnovalidate>取消</button>
<button class="primary-button" type="submit" value="save">确认</button>
</div>
</form>
</dialog>
<dialog id="template-confirm-dialog" class="template-dialog">
<form method="dialog">
<h2>确认模板操作</h2>
<p id="template-confirm-message"></p>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button class="primary-button" type="submit" value="confirm">确认</button>
</div>
</form>
</dialog>
<dialog id="template-save-choice-dialog" class="template-dialog">
<form method="dialog">
<h2>保存模板</h2>
<p id="template-save-choice-message">请选择保存方式。</p>
<div class="template-dialog-actions template-save-choice-actions">
<button type="submit" value="cancel">取消</button>
<button id="template-save-new" type="submit" value="new">另存为新模板</button>
<button id="template-save-current" class="primary-button" type="submit" value="current">保存到当前模板</button>
</div>
</form>
</dialog>
<dialog id="library-copy-dialog" class="template-dialog">
<form method="dialog">
<h2>选择复制目标</h2>
<label>复制到
<select id="library-copy-target"><option value="static">静态模板</option></select>
</label>
<div class="template-dialog-actions"><button value="cancel">取消</button><button class="primary-button" value="copy">复制</button></div>
</form>
</dialog>
<dialog id="animation-dirty-dialog" class="template-dialog">
<form method="dialog">
<h2>当前动图帧尚未保存</h2>
<p id="animation-dirty-message">是否应用当前修改到动图?</p>
<div class="template-dialog-actions"><button value="cancel">取消</button><button value="discard">否,放弃修改</button><button class="primary-button" value="save">应用修改</button></div>
</form>
</dialog>
<dialog id="animation-operation-dialog" class="template-dialog">
<form method="dialog">
<h2 id="animation-operation-title">确认动图操作</h2>
<p id="animation-operation-message"></p>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button id="animation-operation-confirm" class="primary-button" type="submit" value="confirm">确认</button>
</div>
</form>
</dialog>
<dialog id="animation-copy-destination-dialog" class="template-dialog">
<form method="dialog">
<h2>选择批量复制目标</h2>
<label>复制到动图
<select id="animation-copy-destination"></select>
</label>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button class="primary-button" type="submit" value="next">下一步</button>
</div>
</form>
</dialog>
<dialog id="animation-position-dialog" class="template-dialog animation-position-dialog">
<form method="dialog">
<h2 id="animation-position-title">选择插入位置</h2>
<p id="animation-position-hint">选择目标帧后,所选帧将插入到该帧之后;选择“插入到最前”则置于第一帧前。</p>
<label>插入位置
<select id="animation-position-target"></select>
</label>
<div class="template-dialog-actions">
<button type="submit" value="cancel">取消</button>
<button class="primary-button" type="submit" value="confirm">确认位置</button>
</div>
</form>
</dialog>
<dialog id="voltage-calibration-dialog" class="template-dialog voltage-calibration-dialog">
<form method="dialog">
<h2>校准电压传感器</h2>
<p>保持整机正常约 5V。把可靠万用表并联到 ADC 相同的屏幕输入 5V/GND 测量点,再输入万用表读数。</p>
<label>万用表参考电压(V)
<input id="voltage-reference-input" type="number" min="4.500" max="5.500" step="0.001" inputmode="decimal" value="5.000" required>
</label>
<p id="voltage-calibration-dialog-feedback" class="hint" aria-live="polite">点击“采样并预览”,约需 1 秒。</p>
<dl id="voltage-calibration-preview" class="voltage-details" hidden>
<div><dt>参考电压</dt><dd id="calibration-reference-result">-</dd></div>
<div><dt>未校准读数</dt><dd id="calibration-raw-result">-</dd></div>
<div><dt>当前系数</dt><dd id="calibration-current-factor">-</dd></div>
<div><dt>建议系数</dt><dd id="calibration-proposed-factor">-</dd></div>
<div><dt>预计结果</dt><dd id="calibration-projected-result">-</dd></div>
</dl>
<div class="voltage-calibration-actions">
<button type="submit" value="cancel">取消</button>
<button id="preview-voltage-calibration" type="button">采样并预览</button>
<button id="confirm-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先完成“采样并预览”,再保存校准。">保存校准</button>
</div>
</form>
</dialog>
<dialog id="current-display-dialog" class="template-dialog current-display-dialog" aria-labelledby="current-display-dialog-title">
<div class="current-display-dialog-body">
<div class="current-display-dialog-heading">
<div>
<h2 id="current-display-dialog-title">当前画面预览</h2>
<p id="current-display-dialog-name" class="subtle" data-ui-copy-value>正在读取…</p>
</div>
<button id="current-display-dialog-close" type="button" aria-label="关闭当前画面预览">关闭</button>
</div>
<button id="current-display-preview-surface" class="current-display-preview-surface" type="button" aria-label="当前实屏逻辑帧预览">
<img id="current-display-preview-image" alt="当前实屏逻辑帧" hidden>
<span id="current-display-preview-placeholder">正在读取当前画面…</span>
</button>
<section id="current-display-playback-controls" class="current-display-playback-controls" aria-label="动图播放控制" hidden>
<span id="current-display-seek-unavailable-reason" class="sr-only">请先完成当前进度跳转。</span>
<div class="current-display-progress-heading">
<label for="current-display-progress">播放位置</label>
<output id="current-display-progress-time" for="current-display-progress" data-ui-copy-value>0:00.00 / 0:00.00</output>
</div>
<input id="current-display-progress" type="range" min="0" max="0" step="1" value="0" aria-label="动图播放位置">
<div class="current-display-playback-actions">
<button id="current-display-play-pause" class="primary-button" type="button">暂停</button>
<div id="current-display-speed-controls" class="current-display-speed-controls" role="group" aria-label="动图播放倍速">
<button type="button" data-playback-speed="0.5">0.5×</button>
<button type="button" data-playback-speed="1">1×</button>
<button type="button" data-playback-speed="1.5">1.5×</button>
<button type="button" data-playback-speed="2">2×</button>
</div>
</div>
<p id="current-display-playback-feedback" class="hint" aria-live="polite" hidden></p>
</section>
</div>
</dialog>
<dialog id="resource-details-dialog" class="template-dialog resource-details-dialog" aria-labelledby="resource-details-title">
<form method="dialog">
<h2 id="resource-details-title">资源占用详情</h2>
<p id="resource-details-message" class="resource-details-message" role="status"></p>
<section class="resource-details-section" aria-labelledby="resource-cpu-title">
<h3 id="resource-cpu-title">CPU</h3>
<dl class="resource-details-grid">
<div><dt>应用占用核心</dt><dd id="resource-cpu-app-cores">-</dd></div>
<div><dt>折合单核占用</dt><dd id="resource-cpu-single-percent">-</dd></div>
<div><dt>占整机 CPU</dt><dd id="resource-cpu-app-percent">-</dd></div>
<div><dt>整机 CPU 总占用</dt><dd id="resource-cpu-total-percent">-</dd></div>
</dl>
<div id="resource-core-list" class="resource-core-list" aria-label="每核心 CPU 占用"></div>
</section>
<section class="resource-details-section" aria-labelledby="resource-memory-title">
<h3 id="resource-memory-title">内存</h3>
<dl class="resource-details-grid">
<div><dt>应用常驻内存</dt><dd id="resource-memory-app">-</dd></div>
<div><dt>应用占整机内存</dt><dd id="resource-memory-app-percent">-</dd></div>
<div><dt>整机内存总占用</dt><dd id="resource-memory-total-percent">-</dd></div>
</dl>
</section>
<div class="template-dialog-actions resource-dialog-actions">
<button id="resource-details-close" class="primary-button" type="submit" value="close">关闭</button>
</div>
</form>
</dialog>
<dialog id="ota-failure-log-dialog" class="template-dialog ota-failure-log-dialog" aria-labelledby="ota-failure-log-title">
<div class="ota-failure-log-dialog-body">
<div class="template-dialog-heading">
<div>
<h2 id="ota-failure-log-title">OTA 更新失败日志</h2>
<p>可将完整内容复制后提供给维护人员排查。</p>
</div>
</div>
<pre id="ota-failure-log-content" tabindex="0" aria-label="OTA 更新失败诊断日志"></pre>
<p id="ota-failure-log-feedback" class="settings-feedback" role="status" aria-live="polite" hidden></p>
<div class="template-dialog-actions">
<button id="ota-failure-log-close" type="button">关闭</button>
<button id="ota-failure-log-copy" class="primary-button" type="button">复制全部日志</button>
</div>
</div>
</dialog>
<dialog id="frp-config-dialog" class="template-dialog frp-config-dialog" aria-labelledby="frp-config-dialog-title">
<div class="frp-config-dialog-body">
<div class="template-dialog-heading">
<div>
<h2 id="frp-config-dialog-title">编辑 FRP 配置文件</h2>
<p>配置可能包含服务器地址和令牌,请只在可信网络中操作。</p>
</div>
</div>
<label>已保存配置
<select id="frp-config-select"><option value="">暂无配置</option></select>
</label>
<label>配置名称
<input id="frp-config-name" type="text" maxlength="80" autocomplete="off" placeholder="远程维护">
</label>
<label>文件格式
<select id="frp-config-format">
<option value=".toml">TOML</option>
<option value=".yaml">YAML</option>
<option value=".json">JSON</option>
<option value=".ini">INI(兼容格式)</option>
</select>
</label>
<label>配置原文
<textarea id="frp-config-content" data-ui-copy-ignore spellcheck="false" aria-describedby="frp-config-feedback"></textarea>
</label>
<input id="frp-config-file" class="sr-only" type="file" accept=".toml,.yaml,.yml,.json,.ini,text/plain,application/json">
<p id="frp-config-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
<div class="frp-config-secondary-actions">
<button id="frp-config-new" type="button">新建空白配置</button>
<button id="frp-config-upload" type="button">上传配置文件</button>
<button id="frp-config-delete" class="danger-button" type="button">删除</button>
</div>
<div class="template-dialog-actions">
<button id="frp-config-close" type="button">关闭</button>
<button id="frp-config-save" class="primary-button" type="button">保存配置</button>
</div>
</div>
</dialog>
<dialog id="network-diagnostics-dialog" class="template-dialog network-diagnostics-dialog" aria-labelledby="network-diagnostics-title">
<div class="network-diagnostics-dialog-body">
<h2 id="network-diagnostics-title">网络检测结果</h2>
<p id="network-diagnostics-summary" role="status">正在检测…</p>
<ul id="network-diagnostics-checks"></ul>
<div class="template-dialog-actions">
<button id="network-diagnostics-close" class="primary-button" type="button">关闭</button>
</div>
</div>
</dialog>
<div id="control-unavailable-tooltip" class="control-unavailable-tooltip" role="tooltip" hidden></div>
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<noscript>此控制台需要启用 JavaScript。</noscript>
<script type="module" src="/static/app.js"></script>
</body>
</html>
@@ -0,0 +1,182 @@
import { announce } from "./core.js";
const tooltip = document.getElementById("control-unavailable-tooltip");
const toast = document.getElementById("control-feedback-toast");
const controlRecords = new WeakMap();
let descriptionSequence = 0;
let toastTimer = null;
function recordFor(control) {
if (!controlRecords.has(control)) {
controlRecords.set(control, {
label: "textContent" in control ? control.textContent : "",
description: null,
describedBy: control.getAttribute("aria-describedby") || "",
});
}
return controlRecords.get(control);
}
function removeDescription(control) {
const record = recordFor(control);
record.description?.remove();
record.description = null;
if (record.describedBy) control.setAttribute("aria-describedby", record.describedBy);
else control.removeAttribute("aria-describedby");
}
function setDescription(control, reason) {
const record = recordFor(control);
if (!record.description) {
const description = document.createElement("span");
description.id = `control-unavailable-reason-${++descriptionSequence}`;
description.className = "sr-only";
control.insertAdjacentElement("afterend", description);
record.description = description;
}
record.description.textContent = reason;
const ids = [record.describedBy, record.description.id].filter(Boolean).join(" ");
control.setAttribute("aria-describedby", ids);
}
function restoreLabel(control) {
const record = recordFor(control);
if ("textContent" in control && control.dataset.busyLabelApplied === "true") {
control.textContent = record.label;
}
delete control.dataset.busyLabelApplied;
}
function clearBusy(control) {
restoreLabel(control);
if ("disabled" in control) control.disabled = false;
control.removeAttribute("aria-busy");
delete control.dataset.controlState;
}
export function setControlReady(control) {
clearBusy(control);
removeDescription(control);
control.removeAttribute("aria-disabled");
delete control.dataset.unavailableReason;
}
export function setControlUnavailable(control, reason) {
if (!reason || !String(reason).trim()) throw new Error("不可用控件必须提供原因");
clearBusy(control);
control.setAttribute("aria-disabled", "true");
control.dataset.unavailableReason = String(reason).trim();
setDescription(control, control.dataset.unavailableReason);
}
export function setControlBusy(control, { label = "", message = "" } = {}) {
const record = recordFor(control);
const wasBusy = control.dataset.controlState === "busy";
removeDescription(control);
control.removeAttribute("aria-disabled");
delete control.dataset.unavailableReason;
control.dataset.controlState = "busy";
control.setAttribute("aria-busy", "true");
if (label && "textContent" in control) {
if (control.dataset.busyLabelApplied !== "true") record.label = control.textContent;
control.textContent = label;
control.dataset.busyLabelApplied = "true";
}
if ("disabled" in control) control.disabled = true;
if (message && !wasBusy) announce(message);
}
export async function runControlAction(control, options, action) {
setControlBusy(control, options);
try {
return await action();
} finally {
setControlReady(control);
}
}
function unavailableControl(target) {
return target instanceof Element
? target.closest('[aria-disabled="true"][data-unavailable-reason]')
: null;
}
function positionTooltip(control, text = control.dataset.unavailableReason) {
if (!tooltip || !text) return;
tooltip.textContent = text;
tooltip.hidden = false;
const controlRect = control.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const margin = 10;
const left = Math.min(
window.innerWidth - tooltipRect.width - margin,
Math.max(margin, controlRect.left + (controlRect.width - tooltipRect.width) / 2),
);
let top = controlRect.top - tooltipRect.height - margin;
if (top < margin) top = controlRect.bottom + margin;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.top = `${Math.round(top)}px`;
}
function overflowTooltipTarget(target) {
return target instanceof Element ? target.closest("[data-hover-tooltip]") : null;
}
function canShowOverflowTooltip(target, event) {
return event.pointerType === "mouse"
&& window.matchMedia("(hover: hover) and (pointer: fine)").matches
&& target.scrollWidth > target.clientWidth;
}
function hideTooltip() {
if (tooltip) tooltip.hidden = true;
}
function showToast(reason) {
if (!toast) return;
window.clearTimeout(toastTimer);
toast.textContent = reason;
toast.hidden = false;
toastTimer = window.setTimeout(() => {
toast.hidden = true;
}, 4000);
}
document.addEventListener("pointerover", (event) => {
const control = unavailableControl(event.target);
if (control) {
positionTooltip(control);
return;
}
const overflowTarget = overflowTooltipTarget(event.target);
if (overflowTarget && canShowOverflowTooltip(overflowTarget, event)) {
positionTooltip(overflowTarget, overflowTarget.dataset.hoverTooltip);
}
});
document.addEventListener("pointerout", (event) => {
const control = unavailableControl(event.target);
if (control && !control.contains(event.relatedTarget)) {
hideTooltip();
return;
}
const overflowTarget = overflowTooltipTarget(event.target);
if (overflowTarget && !overflowTarget.contains(event.relatedTarget)) hideTooltip();
});
document.addEventListener("focusin", (event) => {
const control = unavailableControl(event.target);
if (control) positionTooltip(control);
});
document.addEventListener("focusout", (event) => {
const control = unavailableControl(event.target);
if (control) hideTooltip();
});
document.addEventListener("click", (event) => {
const control = unavailableControl(event.target);
if (!control) return;
event.preventDefault();
event.stopImmediatePropagation();
const reason = control.dataset.unavailableReason;
hideTooltip();
showToast(reason);
announce(reason);
}, true);
@@ -0,0 +1,56 @@
const OUTPUT_PIXELS = 64;
function positiveNumber(value, field) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
throw new TypeError(field + " must be a positive number");
}
return number;
}
export function cropGeometry({
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom, centerX, centerY,
}) {
const viewWidth = positiveNumber(viewportWidth, "viewportWidth");
const viewHeight = positiveNumber(viewportHeight, "viewportHeight");
const imageWidth = positiveNumber(sourceWidth, "sourceWidth");
const imageHeight = positiveNumber(sourceHeight, "sourceHeight");
const scaleZoom = positiveNumber(zoom, "zoom");
const x = Number(centerX), y = Number(centerY);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new TypeError("crop center must be finite");
}
const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight) * scaleZoom;
const width = imageWidth * scale;
const height = imageHeight * scale;
return {
width,
height,
left: viewWidth / 2 - x * width,
top: viewHeight / 2 - y * height,
};
}
export function cropCenterBounds({
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
}) {
const geometry = cropGeometry({
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
centerX: 0.5, centerY: 0.5,
});
const visibleX = Math.min(Number(viewportWidth) / OUTPUT_PIXELS, geometry.width);
const visibleY = Math.min(Number(viewportHeight) / OUTPUT_PIXELS, geometry.height);
return {
minX: (visibleX - Number(viewportWidth) / 2) / geometry.width,
maxX: 1 + (Number(viewportWidth) / 2 - visibleX) / geometry.width,
minY: (visibleY - Number(viewportHeight) / 2) / geometry.height,
maxY: 1 + (Number(viewportHeight) / 2 - visibleY) / geometry.height,
};
}
export function clampCropCenter(value, bounds) {
return {
centerX: Math.max(bounds.minX, Math.min(bounds.maxX, Number(value.centerX))),
centerY: Math.max(bounds.minY, Math.min(bounds.maxY, Number(value.centerY))),
};
}
@@ -0,0 +1,5 @@
export function shouldReuseMediaJobCard(rendered, incoming) {
if (!rendered || !incoming || rendered.id !== incoming.id) return false;
if (rendered.state !== "awaiting_settings" || incoming.state !== "awaiting_settings") return false;
return rendered.dirty || rendered.updatedAt === incoming.updated_at;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,577 @@
export const SCENE_VERSION = 1;
export const SCENE_WIDTH = 64;
export const SCENE_HEIGHT = 64;
export const RGB_CHANNELS = 3;
export const RGBA_CHANNELS = 4;
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS;
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS;
export const SCENE_STORAGE_KEY = "matrixController:scene";
export const LEGACY_STORAGE_KEYS = Object.freeze({
sceneV1: "matrixController:scene:v1",
canvasV3: "matrixController:canvas:v3",
canvasV2: "matrixController:canvas:v2",
canvasRgb: "matrixCanvasRgb",
textV1: "matrixController:text:v1",
});
export const DEFAULT_TEXT_ELEMENT = Object.freeze({
type: "text",
text: "ok",
font: "default",
size: 12,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
revision: 0,
});
const ALIGNMENTS = new Set(["left", "center", "right"]);
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function finiteInteger(value, fallback, minimum, maximum) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(number)));
}
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
return typeof value === "string" && HEX_COLOR.test(value.trim())
? value.trim().toUpperCase()
: fallback;
}
function normalizeId(value) {
if (typeof value !== "string") return null;
const id = value.trim();
return id && id.length <= 128 ? id : null;
}
function byteArray(value, expectedLength) {
if (value === null || value === undefined || typeof value.length !== "number") return null;
if (value.length !== expectedLength) return null;
const result = new Uint8ClampedArray(expectedLength);
for (let index = 0; index < expectedLength; index += 1) {
const channel = value[index];
if (!Number.isInteger(channel) || channel < 0 || channel > 255) return null;
result[index] = channel;
}
return result;
}
function encodeBase64(bytes) {
let encoded = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index];
const hasSecond = index + 1 < bytes.length;
const hasThird = index + 2 < bytes.length;
const second = hasSecond ? bytes[index + 1] : 0;
const third = hasThird ? bytes[index + 2] : 0;
encoded += BASE64_ALPHABET[first >> 2];
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)];
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "=";
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "=";
}
return encoded;
}
function decodeBase64(value) {
if (typeof value !== "string" || value.length % 4 !== 0) return null;
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding);
let output = 0;
for (let index = 0; index < value.length; index += 4) {
const first = BASE64_ALPHABET.indexOf(value[index]);
const second = BASE64_ALPHABET.indexOf(value[index + 1]);
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]);
const fourth = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3]);
const packed = (first << 18) | (second << 12) | (third << 6) | fourth;
if (output < result.length) result[output++] = (packed >> 16) & 255;
if (output < result.length) result[output++] = (packed >> 8) & 255;
if (output < result.length) result[output++] = packed & 255;
}
return result;
}
function parseJsonValue(value) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function sourceValue(source, alias, storageKey) {
if (!isRecord(source)) return null;
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias];
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey];
return null;
}
function copyTextElement(element) {
return {
id: element.id,
type: "text",
text: element.text,
font: element.font,
size: element.size,
x: element.x,
y: element.y,
align: element.align,
color: element.color,
revision: element.revision,
};
}
function storageTextElement(element) {
const { revision: _revision, ...stored } = copyTextElement(element);
return stored;
}
function normalizedElementList(elements) {
if (!Array.isArray(elements)) return [];
const normalized = [];
const usedIds = new Set();
for (const value of elements) {
const element = normalizeTextElement(value, { strict: true });
if (!element || usedIds.has(element.id)) continue;
usedIds.add(element.id);
normalized.push(element);
}
return normalized;
}
function assertScene(scene) {
if (!isRecord(scene)) throw new TypeError("scene must be an object");
if (scene.version !== SCENE_VERSION || scene.width !== SCENE_WIDTH || scene.height !== SCENE_HEIGHT) {
throw new RangeError("scene must be version 1 with a 64x64 frame");
}
const pixels = byteArray(scene.pixels, RGB_BYTE_LENGTH);
if (!pixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(scene.elements)) throw new TypeError("scene elements must be an array");
const elements = normalizedElementList(scene.elements);
if (elements.length !== scene.elements.length) throw new TypeError("scene contains an invalid or duplicate element");
return { pixels, elements };
}
function sceneWithElements(scene, elements) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checked.pixels,
elements: elements.map(copyTextElement),
};
}
export function createRgbPixels(color = [0, 0, 0]) {
const channels = byteArray(color, RGB_CHANNELS);
if (!channels) throw new RangeError("color must contain three RGB bytes");
const pixels = new Uint8ClampedArray(RGB_BYTE_LENGTH);
for (let offset = 0; offset < pixels.length; offset += RGB_CHANNELS) pixels.set(channels, offset);
return pixels;
}
export function createScene({ pixels = createRgbPixels(), elements = [] } = {}) {
const checkedPixels = byteArray(pixels, RGB_BYTE_LENGTH);
if (!checkedPixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checkedPixels,
elements: normalizedElementList(elements),
};
}
export function sceneToStorageValue(scene) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixelRgb: encodeBase64(checked.pixels),
elements: checked.elements.map(storageTextElement),
};
}
export function serializeScene(scene) {
return JSON.stringify(sceneToStorageValue(scene));
}
export function deserializeScene(value) {
const stored = parseJsonValue(value);
if (!isRecord(stored)) return null;
if (stored.version !== SCENE_VERSION || stored.width !== SCENE_WIDTH || stored.height !== SCENE_HEIGHT) return null;
const decoded = decodeBase64(stored.pixelRgb);
if (!decoded || decoded.length !== RGB_BYTE_LENGTH) return null;
return createScene({
pixels: decoded,
elements: normalizedElementList(stored.elements),
});
}
export function inspectSceneStorageValue(value) {
if (value === null || value === undefined) return { status: "missing", scene: null };
let stored = value;
if (typeof value === "string") {
try {
stored = JSON.parse(value);
} catch (_error) {
return { status: "damaged", scene: null };
}
}
if (!isRecord(stored)) return { status: "damaged", scene: null };
if (Number.isInteger(stored.version) && stored.version > SCENE_VERSION) {
return { status: "future", scene: null };
}
const restored = deserializeScene(stored);
return restored
? { status: "valid", scene: restored }
: { status: "damaged", scene: null };
}
export function nextTextElementId(elements = []) {
const used = new Set(
Array.isArray(elements)
? elements.map((element) => normalizeId(element?.id)).filter(Boolean)
: [],
);
let sequence = 1;
while (used.has(`text-${sequence}`)) sequence += 1;
return `text-${sequence}`;
}
export function normalizeTextElement(value = {}, { id = null, strict = false } = {}) {
if (!isRecord(value) || (value.type !== undefined && value.type !== "text")) return null;
if (strict && (typeof value.text !== "string" || !normalizeId(value.id))) return null;
const normalizedId = normalizeId(value.id) || normalizeId(id) || "text-1";
const text = typeof value.text === "string" ? value.text.slice(0, 512) : DEFAULT_TEXT_ELEMENT.text;
const font = typeof value.font === "string" && value.font.trim()
? value.font.trim().slice(0, 512)
: DEFAULT_TEXT_ELEMENT.font;
return {
id: normalizedId,
type: "text",
text,
font,
size: finiteInteger(value.size, DEFAULT_TEXT_ELEMENT.size, 1, 64),
x: finiteInteger(value.x, DEFAULT_TEXT_ELEMENT.x, -128, 128),
y: finiteInteger(value.y, DEFAULT_TEXT_ELEMENT.y, -128, 128),
align: ALIGNMENTS.has(value.align) ? value.align : DEFAULT_TEXT_ELEMENT.align,
color: normalizeColor(value.color),
revision: finiteInteger(value.revision, 0, 0, Number.MAX_SAFE_INTEGER),
};
}
export function createTextElement(existingElements = [], overrides = {}) {
const id = nextTextElementId(existingElements);
return normalizeTextElement({ ...DEFAULT_TEXT_ELEMENT, ...overrides, id }, { id });
}
export function addTextElement(scene, overrides = {}) {
const checked = assertScene(scene);
const element = createTextElement(checked.elements, overrides);
return sceneWithElements(scene, [...checked.elements, element]);
}
export function updateTextElement(scene, id, patch = {}) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
const index = checked.elements.findIndex((element) => element.id === targetId);
if (index < 0 || !isRecord(patch)) return scene;
const current = checked.elements[index];
const updated = normalizeTextElement({
...current,
...patch,
id: current.id,
type: "text",
revision: current.revision + 1,
}, { id: current.id });
const elements = checked.elements.map((element, elementIndex) => (
elementIndex === index ? updated : copyTextElement(element)
));
return sceneWithElements(scene, elements);
}
export function deleteTextElement(scene, id) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
if (!checked.elements.some((element) => element.id === targetId)) return scene;
return sceneWithElements(scene, checked.elements.filter((element) => element.id !== targetId));
}
export function duplicateTextElement(scene, id) {
const checked = assertScene(scene);
const source = checked.elements.find((element) => element.id === normalizeId(id));
if (!source) return scene;
const duplicate = createTextElement(checked.elements, {
...source,
x: source.x + 2,
y: source.y + 2,
revision: 0,
});
return sceneWithElements(scene, [...checked.elements, duplicate]);
}
function legacyCanvasPixels(value) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
return byteArray(parsed.pixels, RGB_BYTE_LENGTH);
}
function legacyCsvPixels(value) {
if (typeof value !== "string") return byteArray(value, RGB_BYTE_LENGTH);
const channels = value.split(",").map((channel) => Number(channel));
return byteArray(channels, RGB_BYTE_LENGTH);
}
function migrateTextV1(value, options) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
const text = parsed["text-value"] ?? parsed.text;
if (typeof text !== "string") return null;
return normalizeTextElement({
id: "text-1",
type: "text",
text,
font: parsed["text-font"] ?? parsed.font ?? options.defaultFont,
size: parsed["text-size"] ?? parsed.size ?? options.defaultTextSize,
x: parsed["text-x"] ?? parsed.x,
y: parsed["text-y"] ?? parsed.y,
align: parsed["text-align"] ?? parsed.align,
color: parsed["text-color"] ?? parsed.color,
}, { id: "text-1" });
}
export function migrateLegacyScene(source = {}, options = {}) {
const migrationOptions = {
defaultFont: typeof options.defaultFont === "string" && options.defaultFont.trim()
? options.defaultFont.trim()
: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: finiteInteger(options.defaultTextSize, DEFAULT_TEXT_ELEMENT.size, 1, 64),
};
const canvasCandidates = [
["canvasV3", LEGACY_STORAGE_KEYS.canvasV3],
["canvasV2", LEGACY_STORAGE_KEYS.canvasV2],
];
let pixels = null;
for (const [alias, storageKey] of canvasCandidates) {
pixels = legacyCanvasPixels(sourceValue(source, alias, storageKey));
if (pixels) break;
}
if (!pixels) {
pixels = legacyCsvPixels(sourceValue(source, "matrixCanvasRgb", LEGACY_STORAGE_KEYS.canvasRgb));
}
const text = migrateTextV1(
sourceValue(source, "textV1", LEGACY_STORAGE_KEYS.textV1),
migrationOptions,
);
if (!pixels && !text) return null;
return createScene({
pixels: pixels || createRgbPixels(),
elements: text ? [text] : [],
});
}
function draftLoadResult(scene, {
canPersist,
status,
notice = null,
} = {}) {
return { scene, canPersist, status, notice };
}
function blockedDraft(scene, status, notice) {
return draftLoadResult(scene, { canPersist: false, status, notice });
}
function readStorageSnapshot(storage) {
if (!storage || typeof storage.getItem !== "function") {
throw new TypeError("browser storage is unavailable");
}
const values = {};
values.current = storage.getItem(SCENE_STORAGE_KEY);
for (const [alias, key] of Object.entries(LEGACY_STORAGE_KEYS)) {
values[alias] = storage.getItem(key);
}
return values;
}
function invalidSplitLegacyKeys(values) {
const invalid = [];
if (values.canvasV3 !== null && !legacyCanvasPixels(values.canvasV3)) invalid.push(LEGACY_STORAGE_KEYS.canvasV3);
if (values.canvasV2 !== null && !legacyCanvasPixels(values.canvasV2)) invalid.push(LEGACY_STORAGE_KEYS.canvasV2);
if (values.canvasRgb !== null && !legacyCsvPixels(values.canvasRgb)) invalid.push(LEGACY_STORAGE_KEYS.canvasRgb);
if (values.textV1 !== null && !migrateTextV1(values.textV1, {
defaultFont: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: DEFAULT_TEXT_ELEMENT.size,
})) invalid.push(LEGACY_STORAGE_KEYS.textV1);
return invalid;
}
function writeMigratedDraft(storage, scene, hasLegacyData) {
try {
if (typeof storage.setItem !== "function") throw new TypeError("browser storage is read-only");
storage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
} catch (_error) {
return blockedDraft(
scene,
"write-failed",
hasLegacyData
? "浏览器无法写入升级后的画板草稿;旧草稿已原样保留,本页不会覆盖它。"
: "浏览器无法初始化画板草稿;本页编辑不会自动保存或应用。",
);
}
if (!hasLegacyData) {
return draftLoadResult(scene, { canPersist: true, status: "created" });
}
const cleanupFailures = [];
for (const key of Object.values(LEGACY_STORAGE_KEYS)) {
try {
if (typeof storage.removeItem !== "function") throw new TypeError("browser storage cannot remove old keys");
storage.removeItem(key);
} catch (_error) {
cleanupFailures.push(key);
}
}
if (cleanupFailures.length) {
return draftLoadResult(scene, {
canPersist: true,
status: "cleanup-warning",
notice: "画板草稿已升级,但部分旧版草稿键无法清理;旧值仍被保留。",
});
}
return draftLoadResult(scene, {
canPersist: true,
status: "migrated",
notice: "已将旧版浏览器画板草稿升级到当前格式。",
});
}
export function restoreSceneDraft(storage) {
const blankScene = createScene();
let values;
try {
values = readStorageSnapshot(storage);
} catch (_error) {
return blockedDraft(
blankScene,
"read-failed",
"浏览器草稿存储不可用;本页不会自动保存或应用空白画板。",
);
}
const current = inspectSceneStorageValue(values.current);
if (current.status === "valid") {
return draftLoadResult(current.scene, { canPersist: true, status: "current" });
}
if (current.status === "future") {
return blockedDraft(
blankScene,
"future-version",
"检测到更高版本的浏览器画板草稿;原值已保留,本页不会覆盖或应用它。",
);
}
if (current.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-current",
"浏览器画板草稿损坏或格式无法识别;原值已保留,本页不会覆盖或应用它。",
);
}
const legacyScene = inspectSceneStorageValue(values.sceneV1);
if (legacyScene.status === "future") {
return blockedDraft(
blankScene,
"future-legacy-version",
"旧版草稿键中包含更高版本数据;原值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-legacy-scene",
"旧版画板草稿损坏或无法迁移;原值已保留,本页不会覆盖或应用它。",
);
}
const hasSplitLegacyData = ["canvasV3", "canvasV2", "canvasRgb", "textV1"]
.some((alias) => values[alias] !== null);
const hasLegacyData = values.sceneV1 !== null || hasSplitLegacyData;
const invalidLegacyKeys = invalidSplitLegacyKeys(values);
const migrated = migrateLegacyScene({
canvasV3: values.canvasV3,
canvasV2: values.canvasV2,
matrixCanvasRgb: values.canvasRgb,
textV1: values.textV1,
});
if (invalidLegacyKeys.length) {
return blockedDraft(
legacyScene.scene || migrated || blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "valid") {
return writeMigratedDraft(storage, legacyScene.scene, hasLegacyData);
}
if (hasSplitLegacyData && !migrated) {
return blockedDraft(
blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
return writeMigratedDraft(storage, migrated || blankScene, hasLegacyData);
}
function compositeInto(target, rgba) {
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < rgba.length; sourceOffset += 4, targetOffset += 3) {
const alpha = rgba[sourceOffset + 3];
if (alpha === 0) continue;
if (alpha === 255) {
target[targetOffset] = rgba[sourceOffset];
target[targetOffset + 1] = rgba[sourceOffset + 1];
target[targetOffset + 2] = rgba[sourceOffset + 2];
continue;
}
const inverse = 255 - alpha;
target[targetOffset] = Math.round((rgba[sourceOffset] * alpha + target[targetOffset] * inverse) / 255);
target[targetOffset + 1] = Math.round((rgba[sourceOffset + 1] * alpha + target[targetOffset + 1] * inverse) / 255);
target[targetOffset + 2] = Math.round((rgba[sourceOffset + 2] * alpha + target[targetOffset + 2] * inverse) / 255);
}
}
export function compositeRgbaLayer(pixels, rgba) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
const layer = byteArray(rgba, RGBA_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!layer) throw new RangeError("layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
return result;
}
export function compositeRgbaLayers(pixels, layers = []) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(layers)) throw new TypeError("layers must be an array");
for (const value of layers) {
const layer = byteArray(value, RGBA_BYTE_LENGTH);
if (!layer) throw new RangeError("each layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
}
return result;
}
@@ -0,0 +1,79 @@
export const DRAG_START_GUARD_PX = 8;
const INTERACTIVE_SELECTOR = "button, input, select, textarea, a[href], [contenteditable]:not([contenteditable='false'])";
export function pointInsideExpandedRect(x, y, rect, padding = DRAG_START_GUARD_PX) {
return x >= rect.left - padding
&& x <= rect.right + padding
&& y >= rect.top - padding
&& y <= rect.bottom + padding;
}
export function isProtectedDragStart(card, event, padding = DRAG_START_GUARD_PX) {
const target = typeof Element !== "undefined" && event.target instanceof Element ? event.target : null;
if (target?.closest(INTERACTIVE_SELECTOR)) return true;
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false;
return [...card.querySelectorAll(INTERACTIVE_SELECTOR)].some((control) => (
pointInsideExpandedRect(event.clientX, event.clientY, control.getBoundingClientRect(), padding)
));
}
export function protectCardDragStart(card, { locked = false } = {}) {
let dragging = false;
let restoreListeners = null;
const restore = () => {
restoreListeners?.();
restoreListeners = null;
if (!locked && !dragging) card.draggable = true;
};
card.draggable = !locked;
card.addEventListener("pointerdown", (event) => {
if (locked || dragging) return;
restore();
if (!isProtectedDragStart(card, event)) {
card.draggable = true;
return;
}
card.draggable = false;
const pointerId = event.pointerId;
const finish = (finishEvent) => {
if (finishEvent.pointerId !== pointerId) return;
queueMicrotask(restore);
};
window.addEventListener("pointerup", finish, true);
window.addEventListener("pointercancel", finish, true);
restoreListeners = () => {
window.removeEventListener("pointerup", finish, true);
window.removeEventListener("pointercancel", finish, true);
};
}, true);
card.addEventListener("dragstart", (event) => {
if (locked || !card.draggable || isProtectedDragStart(card, event)) {
event.preventDefault();
restore();
return;
}
dragging = true;
restoreListeners?.();
restoreListeners = null;
card.classList.add("is-dragging");
});
card.addEventListener("dragend", () => {
dragging = false;
card.classList.remove("is-dragging");
restore();
});
return { restore };
}
export function reorderByKey(items, sourceKey, targetKey) {
const order = [...items];
const from = order.indexOf(sourceKey);
const target = order.indexOf(targetKey);
if (from < 0 || target < 0 || from === target) return null;
order.splice(target, 0, order.splice(from, 1)[0]);
return order;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
export function createTemplateEditContext(template, baseline) {
return {
id: template.id,
name: template.name,
revision: template.revision,
readOnly: Boolean(template.read_only ?? template.readOnly),
baseline,
};
}
export function templateEditSnapshot(context, serializedScene) {
if (!context) return null;
return {
id: context.id,
name: context.name,
revision: context.revision,
readOnly: Boolean(context.readOnly),
dirty: serializedScene !== context.baseline,
};
}
export function sceneNeedsTemplateSave(context, serializedScene) {
return !context || serializedScene !== context.baseline;
}
@@ -0,0 +1,279 @@
{
"copy.dynamic.template.save_bound": {
"text": "保存模板",
"scope": "templates",
"source": "views/templates.js::save_bound"
},
"copy.dynamic.template.save_new": {
"text": "保存为模板",
"scope": "templates",
"source": "views/templates.js::save_new"
},
"copy.dynamic.template.save_choice": {
"text": "请选择“{name}”的保存方式。",
"scope": "templates",
"source": "views/templates.js::save_choice"
},
"copy.dynamic.template.unsaved_replace": {
"text": "当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
"scope": "templates",
"source": "views/templates.js::unsaved_replace"
},
"copy.dynamic.animation.page_status": {
"text": "第 {page} / {pages} 页 · 共 {total} 帧",
"scope": "animations",
"source": "views/animations.js::page_status"
},
"copy.dynamic.workspace_order.edit": {
"text": "自定义项目位置",
"scope": "drawer",
"source": "app.js::workspace_order_edit"
},
"copy.dynamic.workspace_order.save": {
"text": "保存",
"scope": "drawer",
"source": "app.js::workspace_order_save"
},
"copy.dynamic.workspace_order.loading": {
"text": "正在读取…",
"scope": "drawer",
"source": "app.js::workspace_order_loading"
},
"copy.dynamic.workspace_order.move_up": {
"text": "上移",
"scope": "drawer",
"source": "app.js::workspace_order_move_up"
},
"copy.dynamic.workspace_order.move_down": {
"text": "下移",
"scope": "drawer",
"source": "app.js::workspace_order_move_down"
},
"copy.dynamic.workspace_order.first_item": {
"text": "当前已经是第一项,不能继续上移。",
"scope": "drawer",
"source": "app.js::workspace_order_first_item"
},
"copy.dynamic.workspace_order.last_item": {
"text": "当前已经是最后一项,不能继续下移。",
"scope": "drawer",
"source": "app.js::workspace_order_last_item"
},
"copy.dynamic.workspace_order.saving": {
"text": "正在保存…",
"scope": "drawer",
"source": "app.js::workspace_order_saving"
},
"copy.dynamic.workspace_order.saved": {
"text": "项目位置已保存",
"scope": "drawer",
"source": "app.js::workspace_order_saved"
},
"copy.dynamic.workspace_order.save_required": {
"text": "请先保存项目位置",
"scope": "drawer",
"source": "app.js::workspace_order_save_required"
},
"copy.dynamic.workspace_order.load_failed": {
"text": "项目位置读取失败:{reason}",
"scope": "drawer",
"source": "app.js::workspace_order_load_failed"
},
"copy.dynamic.workspace_order.save_failed": {
"text": "项目位置保存失败:{reason}",
"scope": "drawer",
"source": "app.js::workspace_order_save_failed"
},
"copy.dynamic.board.monitor_eyebrow": {
"text": "64 × 64 屏幕监视器",
"scope": "scene-board",
"source": "scene-board.js::monitor_eyebrow",
"default_hidden": true
},
"copy.dynamic.board.monitor_title": {
"text": "当前屏幕画面",
"scope": "scene-board",
"source": "scene-board.js::monitor_title"
},
"copy.dynamic.board.monitor_mode": {
"text": "预览当前显示屏幕画面",
"scope": "scene-board",
"source": "scene-board.js::monitor_mode",
"default_hidden": true
},
"copy.dynamic.board.composition_eyebrow": {
"text": "64 × 64 统一画板",
"scope": "scene-board",
"source": "scene-board.js::composition_eyebrow",
"default_hidden": true
},
"copy.dynamic.board.composition_title": {
"text": "合成预览",
"scope": "scene-board",
"source": "scene-board.js::composition_title",
"default_hidden": true
},
"copy.dynamic.board.composition_mode": {
"text": "正向 0° · 统一画板",
"scope": "scene-board",
"source": "scene-board.js::composition_mode",
"default_hidden": true
},
"copy.dynamic.board.animation_edit_mode": {
"text": "动图帧编辑 · 不会直接更新实屏",
"scope": "scene-board",
"source": "scene-board.js::animation_edit_mode"
},
"copy.dynamic.device.current_content": {
"text": "当前画面:{name}",
"scope": "topbar",
"source": "views/device.js::current_content"
},
"copy.dynamic.device.voltage_ok": {
"text": "屏幕输入电压 {volts} V",
"scope": "device",
"source": "views/device.js::voltage_ok"
},
"copy.dynamic.device.voltage_disconnected": {
"text": "电压传感器断开",
"scope": "device",
"source": "views/device.js::voltage_disconnected"
},
"copy.dynamic.device.voltage_error": {
"text": "电压读取异常",
"scope": "device",
"source": "views/device.js::voltage_error"
},
"copy.dynamic.device.voltage_loading": {
"text": "正在读取电压…",
"scope": "device",
"source": "views/device.js::voltage_loading"
},
"copy.dynamic.text.font_selected": {
"text": "已选择字体“{name}”",
"scope": "text",
"source": "views/text.js::font_selected"
},
"copy.dynamic.text.font_type_error": {
"text": "只支持 TTF、OTF、TTC、OTC 字体文件。",
"scope": "text",
"source": "views/text.js::font_type_error"
},
"copy.dynamic.text.font_size_error": {
"text": "字体文件不能超过 32 MiB。",
"scope": "text",
"source": "views/text.js::font_size_error"
},
"copy.dynamic.text.font_importing": {
"text": "正在导入字体“{name}”…",
"scope": "text",
"source": "views/text.js::font_importing"
},
"copy.dynamic.text.font_imported": {
"text": "字体“{name}”已导入",
"scope": "text",
"source": "views/text.js::font_imported"
},
"copy.dynamic.text.font_imported_applied": {
"text": "字体“{name}”已导入并应用到当前文字",
"scope": "text",
"source": "views/text.js::font_imported_applied"
},
"copy.dynamic.text.font_exists": {
"text": "字体“{name}”已经存在",
"scope": "text",
"source": "views/text.js::font_exists"
},
"copy.dynamic.text.font_exists_applied": {
"text": "字体“{name}”已经存在,已应用到当前文字",
"scope": "text",
"source": "views/text.js::font_exists_applied"
},
"copy.dynamic.text.font_error_stale": {
"text": "页面版本已过期,请刷新后再导入字体。",
"scope": "text",
"source": "views/text.js::font_error_stale"
},
"copy.dynamic.text.font_error_timeout": {
"text": "字体导入超时,请检查核桃派网络或服务状态后重试。",
"scope": "text",
"source": "views/text.js::font_error_timeout"
},
"copy.dynamic.text.font_error_too_large": {
"text": "字体导入失败:文件超过 32 MiB。",
"scope": "text",
"source": "views/text.js::font_error_too_large"
},
"copy.dynamic.text.font_error_type": {
"text": "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。",
"scope": "text",
"source": "views/text.js::font_error_type"
},
"copy.dynamic.text.font_error_invalid": {
"text": "字体导入失败:文件损坏或不包含可用字体。",
"scope": "text",
"source": "views/text.js::font_error_invalid"
},
"copy.dynamic.text.font_error_storage": {
"text": "字体导入失败:设备存储空间不足。",
"scope": "text",
"source": "views/text.js::font_error_storage"
},
"copy.dynamic.text.font_error_network": {
"text": "字体导入失败:无法连接设备,请检查网络后重试。",
"scope": "text",
"source": "views/text.js::font_error_network"
},
"copy.dynamic.text.font_error_unknown": {
"text": "字体导入失败:服务器未能完成导入,请稍后重试。",
"scope": "text",
"source": "views/text.js::font_error_unknown"
},
"copy.dynamic.text.font_catalog_failed": {
"text": "字体目录读取失败,当前字体值已保留:{error}",
"scope": "text",
"source": "views/text.js::font_catalog_failed"
},
"copy.dynamic.media.state_uploading": {"text": "上传中", "scope": "media-import", "source": "views/media-import.js::state_uploading"},
"copy.dynamic.media.state_analyzing": {"text": "分析中", "scope": "media-import", "source": "views/media-import.js::state_analyzing"},
"copy.dynamic.media.state_awaiting": {"text": "等待设置", "scope": "media-import", "source": "views/media-import.js::state_awaiting"},
"copy.dynamic.media.state_queued": {"text": "排队中", "scope": "media-import", "source": "views/media-import.js::state_queued"},
"copy.dynamic.media.state_converting": {"text": "转换中", "scope": "media-import", "source": "views/media-import.js::state_converting"},
"copy.dynamic.media.state_failed": {"text": "失败", "scope": "media-import", "source": "views/media-import.js::state_failed"},
"copy.dynamic.media.queue_position": {"text": "{state} · 队列第 {position} 位", "scope": "media-import", "source": "views/media-import.js::queue_position"},
"copy.dynamic.media.preview": {"text": "代表帧 {index}", "scope": "media-import", "source": "views/media-import.js::preview"},
"copy.dynamic.media.output_name": {"text": "保存名称", "scope": "media-import", "source": "views/media-import.js::output_name"},
"copy.dynamic.media.fit_mode": {"text": "1:1 适配", "scope": "media-import", "source": "views/media-import.js::fit_mode"},
"copy.dynamic.media.mode_crop": {"text": "自由取景", "scope": "media-import", "source": "views/media-import.js::mode_crop"},
"copy.dynamic.media.mode_contain": {"text": "完整包含", "scope": "media-import", "source": "views/media-import.js::mode_contain"},
"copy.dynamic.media.mode_stretch": {"text": "拉伸", "scope": "media-import", "source": "views/media-import.js::mode_stretch"},
"copy.dynamic.media.crop_zoom": {"text": "取景缩放", "scope": "media-import", "source": "views/media-import.js::crop_zoom"},
"copy.dynamic.media.transparency": {"text": "透明像素填充色", "scope": "media-import", "source": "views/media-import.js::transparency"},
"copy.dynamic.media.padding": {"text": "留白颜色", "scope": "media-import", "source": "views/media-import.js::padding"},
"copy.dynamic.media.convert": {"text": "保存设置并开始转换", "scope": "media-import", "source": "views/media-import.js::convert"},
"copy.dynamic.media.cancel_source": {"text": "取消并删除源文件", "scope": "media-import", "source": "views/media-import.js::cancel_source"},
"copy.dynamic.media.retry": {"text": "重试", "scope": "media-import", "source": "views/media-import.js::retry"},
"copy.dynamic.media.cancel": {"text": "取消并删除", "scope": "media-import", "source": "views/media-import.js::cancel"},
"copy.dynamic.media.deleted": {"text": "任务和源文件已删除。", "scope": "media-import", "source": "views/media-import.js::deleted"},
"copy.dynamic.media.delete_failed": {"text": "删除任务失败:{error}", "scope": "media-import", "source": "views/media-import.js::delete_failed"},
"copy.dynamic.media.queued": {"text": "任务已加入转换队列。", "scope": "media-import", "source": "views/media-import.js::queued"},
"copy.dynamic.media.name_conflict": {"text": "保存名称已存在,请更换名称。", "scope": "media-import", "source": "views/media-import.js::name_conflict"},
"copy.dynamic.media.convert_failed": {"text": "无法开始转换:{error}", "scope": "media-import", "source": "views/media-import.js::convert_failed"},
"copy.dynamic.media.retry_failed": {"text": "重试失败:{error}", "scope": "media-import", "source": "views/media-import.js::retry_failed"},
"copy.dynamic.media.completed": {"text": "“{name}”转换完成,已加入内容库。", "scope": "media-import", "source": "views/media-import.js::completed"},
"copy.dynamic.media.empty": {"text": "当前没有等待或转换中的任务。", "scope": "media-import", "source": "views/media-import.js::empty"},
"copy.dynamic.media.list_failed": {"text": "任务列表读取失败:{error}", "scope": "media-import", "source": "views/media-import.js::list_failed"},
"copy.dynamic.media.uploaded": {"text": "上传完成,设备正在分析媒体。", "scope": "media-import", "source": "views/media-import.js::uploaded"},
"copy.dynamic.media.upload_failed": {"text": "上传失败:{error}", "scope": "media-import", "source": "views/media-import.js::upload_failed"},
"copy.dynamic.current_display.loading": {"text": "正在读取当前画面…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_loading"},
"copy.dynamic.current_display.load_failed": {"text": "当前画面读取失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_load_failed"},
"copy.dynamic.current_display.pause": {"text": "暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pause"},
"copy.dynamic.current_display.resume": {"text": "继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resume"},
"copy.dynamic.current_display.pausing": {"text": "正在暂停…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pausing"},
"copy.dynamic.current_display.resuming": {"text": "正在继续播放…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resuming"},
"copy.dynamic.current_display.paused": {"text": "动图已暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_paused"},
"copy.dynamic.current_display.resumed": {"text": "动图继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resumed"},
"copy.dynamic.current_display.speed_changing": {"text": "正在切换到 {speed}×…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changing"},
"copy.dynamic.current_display.speed_changed": {"text": "已切换到 {speed}×", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changed"},
"copy.dynamic.current_display.control_failed": {"text": "动图播放控制失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_control_failed"}
}
@@ -0,0 +1,129 @@
const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__ || null;
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
const targetRegistry = new Map();
const accessibleNameState = new WeakMap();
let activeDraft = bootstrap?.initial_draft || { overrides: {} };
function formatTemplate(text, values = {}) {
return String(text).replace(placeholderPattern, (_match, name) => values[name] ?? `{${name}}`);
}
function preserveAccessibleName(record, text) {
const host = record.host;
if (!host) return;
if (!accessibleNameState.has(host)) accessibleNameState.set(host, host.getAttribute("aria-label"));
host.setAttribute("aria-label", text || record.defaultText);
}
function restoreAccessibleName(record) {
if (!record.host || !accessibleNameState.has(record.host)) return;
const original = accessibleNameState.get(record.host);
if (original === null) record.host.removeAttribute("aria-label");
else record.host.setAttribute("aria-label", original);
accessibleNameState.delete(record.host);
}
function applyRecord(record) {
if (!record.target.isConnected) return;
const override = activeDraft.overrides?.[record.copyId] || {};
const sourceText = override.text ?? record.defaultText;
const renderedText = formatTemplate(sourceText, record.values);
if (record.attribute) {
record.target.setAttribute(record.attribute, renderedText);
return;
}
const hidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(record.defaultHidden);
record.target.textContent = hidden ? "" : renderedText;
if (hidden) preserveAccessibleName(record, renderedText);
else restoreAccessibleName(record);
if (override.font_size_px) {
if (!record.fontCaptured) {
record.originalFontSize = record.target.style.fontSize;
record.fontCaptured = true;
}
record.target.style.fontSize = `${override.font_size_px}px`;
} else if (record.fontCaptured) {
record.target.style.fontSize = record.originalFontSize;
record.fontCaptured = false;
}
record.target.classList.toggle("ui-copy-has-override", Boolean(activeDraft.overrides?.[record.copyId]));
}
function registerRecord(record) {
const previous = targetRegistry.get(record.target);
if (previous) {
record.originalFontSize = previous.originalFontSize;
record.fontCaptured = previous.fontCaptured;
}
targetRegistry.set(record.target, record);
if (bootstrap) record.target.dataset.uiCopyId = record.copyId;
applyRecord(record);
return record;
}
export function renderUiCopy(copyId, element, defaultText, values = {}, options = {}) {
if (!(element instanceof Element)) return;
if (!bootstrap) {
const renderedText = formatTemplate(defaultText, values);
const record = { host: element, defaultText };
element.textContent = options.defaultHidden ? "" : renderedText;
if (options.defaultHidden) preserveAccessibleName(record, renderedText);
else restoreAccessibleName(record);
return;
}
registerRecord({
copyId,
target: element,
host: element,
defaultText,
values,
kind: "dynamic_template",
attribute: null,
defaultHidden: Boolean(options.defaultHidden),
});
}
export function renderUiValue(element, value) {
if (!(element instanceof Element)) return;
const priorRecord = targetRegistry.get(element);
restoreAccessibleName(priorRecord || { host: element });
targetRegistry.delete(element);
delete element.dataset.uiCopyId;
element.textContent = String(value ?? "");
}
export function registerStaticUiCopy(copyId, target, host, defaultText, kind, attribute = null, defaultHidden = false) {
if (!bootstrap || !(target instanceof Element)) return null;
return registerRecord({
copyId,
target,
host: host || target,
defaultText,
values: {},
kind,
attribute,
defaultHidden: Boolean(defaultHidden),
});
}
export function setUiCopyDraft(nextDraft) {
activeDraft = nextDraft;
for (const record of targetRegistry.values()) applyRecord(record);
}
export function applyUiCopyId(copyId) {
for (const record of targetRegistry.values()) {
if (record.copyId === copyId) applyRecord(record);
}
}
export function uiCopyRecords(copyId = null) {
const records = Array.from(targetRegistry.values()).filter((record) => record.target.isConnected);
return copyId === null ? records : records.filter((record) => record.copyId === copyId);
}
export function unregisterUiCopySubtree(root) {
for (const [target] of targetRegistry) {
if (!target.isConnected || target === root || root?.contains?.(target)) targetRegistry.delete(target);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,585 @@
import {
announce,
createLocalState,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
import { hexToRgb, rgbToHex } from "../color-math.js";
import {
BRUSH_MODE_NORMAL,
BRUSH_MODE_PIXEL,
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
clampCanvasView,
clampFloatingButton,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
zoomCanvasView,
} from "../canvas-tools-model.js";
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import {
boardPoint,
commitPixelChanges,
fillPixelLayer,
getBoardOverlay,
getInteractionMode,
getPixelBuffer,
renderPixelDraft,
} from "../scene-board.js";
const canvas = getBoardOverlay();
const localState = createLocalState("canvas-tools", 2);
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
const legacyState = createLocalState("canvas", 3).load(null)
|| createLocalState("canvas", 2).load(null)
|| {};
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
const paintColor = document.getElementById("paint-color");
const backgroundColor = document.getElementById("background-color");
const brushSizeInput = document.getElementById("brush-size");
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
const pixelEditStatus = document.getElementById("pixel-edit-status");
const pixelGrid = document.getElementById("pixel-grid");
const boardStage = document.getElementById("board-stage");
const canvasWorkbench = document.querySelector(".canvas-workbench");
const startEditButton = document.getElementById("start-canvas-edit");
const assistive = document.getElementById("canvas-edit-assistive");
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
const assistiveMenu = document.getElementById("canvas-assistive-menu");
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
const undoButton = document.getElementById("undo-canvas-edit");
const exitEditButton = document.getElementById("exit-canvas-edit");
const moveViewButton = document.getElementById("move-canvas-view");
const continueEditButton = document.getElementById("continue-canvas-edit");
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
let tool = savedTools.tool;
let previousDrawingTool = tool;
let pixelEditEnabled = savedTools.pixelEditEnabled;
let normalBrushSize = savedTools.normalBrushSize;
let pixelBrushSize = savedTools.pixelBrushSize;
let drawing = false;
let drawingPointerId = null;
let lastPoint = null;
let drawingBefore = null;
let editorActive = false;
let editorMode = "draw";
let view = { scale: 1, x: 0, y: 0 };
let viewGesture = null;
let assistiveDrag = null;
let suppressAssistiveClick = false;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
const undoHistory = new PixelUndoHistory();
paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
function brushMode() {
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
}
function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
function setPixel(x, y, rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return;
const offset = (y * 64 + x) * 3;
const pixels = getPixelBuffer();
pixels[offset] = rgb.r;
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
}
function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
.forEach((point) => setPixel(point.x, point.y, rgb));
}
function paintLine(from, to) {
const rgb = activeRgb();
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
}
function paintEvent(event) {
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
for (const sample of samples.length ? samples : [event]) {
const point = boardPoint(sample);
paintLine(lastPoint || point, point);
lastPoint = point;
}
renderPixelDraft();
}
function pixelSnapshot() {
return Uint8Array.from(getPixelBuffer());
}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer());
updateUndoControl();
return recorded;
}
function viewportBounds() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
baseSize: Math.max(1, boardStage.offsetWidth),
minVisible: 48,
};
}
function applyCanvasView(next = view) {
view = clampCanvasView(next, viewportBounds());
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
}
function resetCanvasView() {
view = { scale: 1, x: 0, y: 0 };
applyCanvasView();
}
function applyAssistivePosition(next = assistivePosition) {
assistivePosition = clampFloatingButton(next, {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
size: 56,
inset: 8,
});
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
}
function resetAssistivePosition() {
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
}
function closeAssistiveMenu({ restoreFocus = false } = {}) {
assistiveMenu.hidden = true;
assistiveToggle.setAttribute("aria-expanded", "false");
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
if (restoreFocus && editorActive) assistiveToggle.focus();
}
function openAssistiveMenu() {
applyAssistivePosition();
assistiveMenu.hidden = false;
assistiveToggle.setAttribute("aria-expanded", "true");
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
firstAvailable?.focus();
}
function toggleAssistiveMenu() {
if (assistiveMenu.hidden) openAssistiveMenu();
else closeAssistiveMenu({ restoreFocus: true });
}
function updateEditorModeControls() {
const moving = editorMode === "move";
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
}
updateUndoControl();
}
function resetViewGesture() {
const points = Array.from(viewPointers.values());
if (points.length >= 2) {
const [first, second] = points;
viewGesture = {
kind: "pinch",
startView: { ...view },
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
};
} else if (points.length === 1) {
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
} else {
viewGesture = null;
}
}
function setEditorMode(mode) {
if (!editorActive) return;
if (mode === "move") finishDrawing();
editorMode = mode === "move" ? "move" : "draw";
viewPointers.clear();
viewGesture = null;
updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布");
}
function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return;
editorActive = true;
editorMode = "draw";
canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active");
assistive.hidden = false;
closeAssistiveMenu();
updateEditorModeControls();
window.requestAnimationFrame(() => {
resetCanvasView();
resetAssistivePosition();
assistiveToggle.focus();
});
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
}
function exitCanvasEditor({ returnToTools = true } = {}) {
if (!editorActive) return;
finishDrawing();
editorActive = false;
editorMode = "draw";
viewPointers.clear();
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
delete document.body.dataset.canvasEditorMode;
boardStage.style.removeProperty("--canvas-view-scale");
boardStage.style.removeProperty("--canvas-view-x");
boardStage.style.removeProperty("--canvas-view-y");
view = { scale: 1, x: 0, y: 0 };
if (returnToTools) {
window.requestAnimationFrame(() => {
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
startEditButton.focus({ preventScroll: true });
});
}
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function persistTools() {
localState.save({
paintColor: paintColor.value,
backgroundColor: backgroundColor.value,
tool,
pixelEditEnabled,
normalBrushSize,
pixelBrushSize,
});
}
function renderBrushSize() {
const mode = brushMode();
const brushSize = currentBrushSize();
const spec = brushModeSpec(mode);
brushSizeInput.min = String(spec.min);
brushSizeInput.max = String(spec.max);
brushSizeInput.value = String(brushSize);
document.getElementById("brush-size-value").textContent = pixelEditEnabled
? `${brushSize} × ${brushSize}`
: `${brushSize} px`;
const preview = document.getElementById("brush-size-preview");
const previewSize = Math.max(4, brushSize * 2);
preview.style.width = `${previewSize}px`;
preview.style.height = `${previewSize}px`;
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
brushSizePresets.forEach((button, index) => {
const value = spec.presets[index];
button.dataset.brushSize = String(value);
button.textContent = String(value);
});
setActiveButton(
brushSizePresets,
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
);
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
pixelEditStatus.hidden = !pixelEditEnabled;
pixelEditStatus.textContent = pixelEditEnabled
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
: "";
}
function setBrushSize(value) {
const normalized = normalizeBrushSize(value, brushMode());
if (pixelEditEnabled) pixelBrushSize = normalized;
else normalBrushSize = normalized;
renderBrushSize();
persistTools();
}
function updatePixelGridVisibility() {
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
}
function togglePixelEditMode() {
pixelEditEnabled = !pixelEditEnabled;
renderBrushSize();
updatePixelGridVisibility();
persistTools();
announce(pixelEditEnabled
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
}
function selectTool(button) {
tool = button.dataset.canvasTool;
if (tool !== "eyedropper") previousDrawingTool = tool;
setActiveButton(toolButtons, button);
persistTools();
}
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return;
if (editorMode === "move") {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture(event.pointerId);
resetViewGesture();
return;
}
if (drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3;
const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({
r: pixels[offset],
g: pixels[offset + 1],
b: pixels[offset + 2],
}), { recordHistory: true });
tool = previousDrawingTool;
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
persistTools();
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
return;
}
event.preventDefault();
drawing = true;
drawingPointerId = event.pointerId;
drawingBefore = pixelSnapshot();
lastPoint = boardPoint(event);
canvas.setPointerCapture(event.pointerId);
paintEvent(event);
});
canvas.addEventListener("pointermove", (event) => {
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
const points = Array.from(viewPointers.values());
if (viewGesture?.kind === "pinch" && points.length >= 2) {
const [first, second] = points;
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
const scaled = zoomCanvasView(
viewGesture.startView,
viewGesture.startView.scale * (distance / viewGesture.startDistance),
viewGesture.startMidpoint,
viewportBounds(),
);
applyCanvasView({
...scaled,
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
});
} else if (viewGesture?.kind === "pan" && points.length === 1) {
applyCanvasView({
...viewGesture.startView,
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
});
}
return;
}
if (drawing && drawingPointerId === event.pointerId && editorActive
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
});
function finishDrawing() {
if (!drawing) return;
drawing = false;
drawingPointerId = null;
lastPoint = null;
const changed = recordPixelOperation(drawingBefore);
drawingBefore = null;
if (changed) commitPixelChanges("pixel-draw");
persistTools();
}
function finishCanvasPointer(event) {
if (viewPointers.delete(event.pointerId)) {
resetViewGesture();
return;
}
if (drawingPointerId === event.pointerId) finishDrawing();
}
canvas.addEventListener("pointerup", finishCanvasPointer);
canvas.addEventListener("pointercancel", finishCanvasPointer);
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
canvas.addEventListener("wheel", (event) => {
if (!editorActive || editorMode !== "move") return;
event.preventDefault();
const factor = Math.exp(-event.deltaY * 0.002);
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
x: event.clientX,
y: event.clientY,
}, viewportBounds()));
}, { passive: false });
paintColor.addEventListener("input", persistTools);
backgroundColor.addEventListener("input", persistTools);
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
pixelEditToggle.addEventListener("click", togglePixelEditMode);
document.getElementById("fill-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]);
recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return;
event.preventDefault();
event.stopPropagation();
assistiveDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
origin: { ...assistivePosition },
moved: false,
};
assistiveToggle.setPointerCapture(event.pointerId);
});
assistiveToggle.addEventListener("pointermove", (event) => {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const dx = event.clientX - assistiveDrag.startX;
const dy = event.clientY - assistiveDrag.startY;
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
if (assistiveDrag.moved) {
closeAssistiveMenu();
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
}
});
function finishAssistiveDrag(event) {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
suppressAssistiveClick = assistiveDrag.moved;
assistiveDrag = null;
event.stopPropagation();
}
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
assistiveToggle.addEventListener("click", () => {
if (suppressAssistiveClick) {
suppressAssistiveClick = false;
return;
}
toggleAssistiveMenu();
});
document.addEventListener("keydown", (event) => {
if (!editorActive || event.key !== "Escape") return;
event.preventDefault();
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
else exitCanvasEditor();
});
window.addEventListener("resize", () => {
if (!editorActive) return;
applyCanvasView();
applyAssistivePosition();
});
document.addEventListener("matrix:composition-applied", clearUndoHistory);
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
refreshColorTrigger(paintColor);
refreshColorTrigger(backgroundColor);
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
renderBrushSize();
persistTools();
updateEditorModeControls();
registerWorkspace({
id: "canvas",
title: "像素画布",
elementId: "workspace-canvas",
order: 30,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter() {
renderPixelDraft();
updatePixelGridVisibility();
},
onLeave() {
exitCanvasEditor({ returnToTools: false });
finishDrawing();
pixelGrid.hidden = true;
},
});
@@ -0,0 +1,960 @@
import {
announce,
apiBlob,
apiJson,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshCurrentDisplayPreview } from "../scene-board.js";
import { refreshColorTrigger } from "../color-picker.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { renderUiCopy, renderUiValue } from "../ui-copy-runtime.js";
import {
LatestSeekDispatcher,
formatPlaybackTime,
projectPlaybackPosition,
} from "../current-display-playback.js";
const statusEls = {
orientation: document.getElementById("status-orientation"),
brightness: document.getElementById("status-brightness"),
currentContent: document.getElementById("topbar-current-content"),
cpu: document.getElementById("topbar-cpu"),
memory: document.getElementById("topbar-memory"),
lowVoltageWarning: document.getElementById("topbar-low-voltage-warning"),
voltage: document.getElementById("topbar-voltage"),
};
const fillButtons = Array.from(document.querySelectorAll("[data-test-fill]"));
const customFillButton = document.getElementById("custom-test-fill");
const customColor = document.getElementById("test-color");
const refreshStatusButton = document.getElementById("refresh-status");
const displayTestControls = document.getElementById("display-test-controls");
const testBrightness = document.getElementById("test-brightness");
const testBrightnessValue = document.getElementById("test-brightness-value");
const testBrightnessProtection = document.getElementById("test-brightness-protection");
const displayTestFeedback = document.getElementById("display-test-feedback");
const exitDisplayTest = document.getElementById("exit-display-test");
const storageEls = {
total: document.getElementById("device-total-space"),
free: document.getElementById("device-free-space"),
software: document.getElementById("device-software-size"),
templates: document.getElementById("device-template-size"),
feedback: document.getElementById("device-storage-feedback"),
};
const resourceDialog = document.getElementById("resource-details-dialog");
const resourceDialogClose = document.getElementById("resource-details-close");
const resourceDetailsMessage = document.getElementById("resource-details-message");
const resourceCoreList = document.getElementById("resource-core-list");
const resourceDetailEls = {
cpuAppCores: document.getElementById("resource-cpu-app-cores"),
cpuSinglePercent: document.getElementById("resource-cpu-single-percent"),
cpuAppPercent: document.getElementById("resource-cpu-app-percent"),
cpuTotalPercent: document.getElementById("resource-cpu-total-percent"),
memoryApp: document.getElementById("resource-memory-app"),
memoryAppPercent: document.getElementById("resource-memory-app-percent"),
memoryTotalPercent: document.getElementById("resource-memory-total-percent"),
};
const currentDisplayDialog = document.getElementById("current-display-dialog");
const currentDisplayDialogName = document.getElementById("current-display-dialog-name");
const currentDisplayDialogClose = document.getElementById("current-display-dialog-close");
const currentDisplayPreviewSurface = document.getElementById("current-display-preview-surface");
const currentDisplayPreviewImage = document.getElementById("current-display-preview-image");
const currentDisplayPreviewPlaceholder = document.getElementById("current-display-preview-placeholder");
const currentDisplayPlaybackControls = document.getElementById("current-display-playback-controls");
const currentDisplayProgress = document.getElementById("current-display-progress");
const currentDisplayProgressTime = document.getElementById("current-display-progress-time");
const currentDisplayPlayPause = document.getElementById("current-display-play-pause");
const currentDisplaySpeedButtons = Array.from(document.querySelectorAll("[data-playback-speed]"));
const currentDisplayPlaybackFeedback = document.getElementById("current-display-playback-feedback");
const currentDisplaySeekUnavailableReason = document.getElementById("current-display-seek-unavailable-reason");
let loadedOnce = false;
let lastStatusIdentity = null;
let latestResources = null;
let latestProtection = null;
let latestStatus = null;
let currentDisplayOpenIdentity = null;
let currentDisplayPlayback = null;
let currentDisplayPlaybackSampledAt = 0;
let currentDisplayPreviewTimer = null;
let currentDisplayPreviewPending = false;
let currentDisplayPreviewRequested = false;
let currentDisplayPreviewSequence = 0;
let currentDisplayPreviewUrl = null;
let currentDisplayAnimationFrame = null;
let currentDisplayDragging = false;
let currentDisplayFinalSeekPosition = null;
let currentDisplayControlPending = false;
let customTestColor = "#40A0FF";
let testActionPending = false;
let desiredTestBrightness = null;
let applyingTestBrightness = false;
let storageTimer = null;
const STORAGE_REFRESH_INTERVAL_MS = 300000;
const LOW_VOLTAGE_WARNING_TEXT = Object.freeze({
limiting: "电压过低,屏幕亮度限制",
critical: "电压过低,屏幕已经禁用,请充电",
});
function finiteNumber(value) {
if (value === null || value === undefined || value === "") return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function formatPercent(value) {
const number = finiteNumber(value);
return number === null ? "-" : `${number.toFixed(1)}%`;
}
function formatMemoryDetailed(bytes) {
const number = finiteNumber(bytes);
if (number === null || number < 0) return "-";
const mebibytes = number / (1024 * 1024);
return `${mebibytes.toFixed(1)} MiB`;
}
function formatMemoryCompact(bytes) {
const number = finiteNumber(bytes);
if (number === null || number < 0) return "-";
return `${Math.round(number / (1024 * 1024))}M`;
}
function formatCoreEquivalent(value) {
const number = finiteNumber(value);
return number === null ? "-" : `${number.toFixed(1)}核`;
}
function renderCoreMeters(cpu) {
resourceCoreList.replaceChildren();
const count = Number.isInteger(Number(cpu.logical_cpu_count))
? Math.max(0, Number(cpu.logical_cpu_count))
: 0;
const values = Array.isArray(cpu.cores_percent) ? cpu.cores_percent : [];
for (let index = 0; index < count; index += 1) {
const value = finiteNumber(values[index]);
const row = document.createElement("div");
row.className = "resource-core-row";
const label = document.createElement("span");
label.textContent = `CPU ${index + 1}`;
const progress = document.createElement("progress");
progress.max = 100;
progress.value = value === null ? 0 : Math.max(0, Math.min(100, value));
progress.setAttribute("aria-label", `CPU ${index + 1} 占用`);
const output = document.createElement("output");
output.textContent = formatPercent(value);
row.append(label, progress, output);
resourceCoreList.appendChild(row);
}
}
function updateResourceDetails(resources) {
const status = resources?.status || "starting";
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const coreCount = finiteNumber(cpu.logical_cpu_count);
const coreEquivalent = finiteNumber(cpu.application_core_equivalent);
resourceDetailEls.cpuAppCores.textContent = coreEquivalent === null
? "-"
: `${coreEquivalent.toFixed(2)} / ${coreCount ?? "-"} 核`;
resourceDetailEls.cpuSinglePercent.textContent = coreEquivalent === null
? "-"
: formatPercent(coreEquivalent * 100);
resourceDetailEls.cpuAppPercent.textContent = formatPercent(cpu.application_percent);
resourceDetailEls.cpuTotalPercent.textContent = formatPercent(cpu.total_percent);
resourceDetailEls.memoryApp.textContent = formatMemoryDetailed(memory.application_bytes);
resourceDetailEls.memoryAppPercent.textContent = formatPercent(memory.application_percent);
resourceDetailEls.memoryTotalPercent.textContent = formatPercent(memory.total_percent);
renderCoreMeters(cpu);
if (status === "error") {
resourceDetailsMessage.textContent = "资源读取异常,屏幕、电压和其他控制功能不受影响。";
} else if (status !== "ok") {
resourceDetailsMessage.textContent = "正在收集资源样本…";
} else if (coreEquivalent === null) {
resourceDetailsMessage.textContent = "正在等待第二个 CPU 差分样本;内存数据已可用。";
} else {
resourceDetailsMessage.textContent = "";
}
resourceDetailsMessage.hidden = resourceDetailsMessage.textContent === "";
}
function showTopbarResources(resources) {
latestResources = resources || null;
const status = resources?.status || "starting";
statusEls.cpu.dataset.status = status;
statusEls.memory.dataset.status = status;
if (status === "error") {
statusEls.cpu.textContent = "CPU !";
statusEls.memory.textContent = "RAM !";
statusEls.cpu.setAttribute("aria-label", "CPU 读取异常,查看资源占用详情");
statusEls.memory.setAttribute("aria-label", "内存读取异常,查看资源占用详情");
updateResourceDetails(resources);
return;
}
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const cpuCompact = finiteNumber(cpu.application_core_equivalent) === null
? "CPU -"
: `CPU ${formatCoreEquivalent(cpu.application_core_equivalent)} · ${formatPercent(cpu.total_percent)}`;
const memoryCompact = finiteNumber(memory.application_bytes) === null
? "RAM -"
: `RAM ${formatMemoryCompact(memory.application_bytes)} · ${formatPercent(memory.total_percent)}`;
statusEls.cpu.textContent = cpuCompact;
statusEls.memory.textContent = memoryCompact;
statusEls.cpu.setAttribute("aria-label", `${cpuCompact},查看 CPU 占用详情`);
statusEls.memory.setAttribute("aria-label", `${memoryCompact},查看内存占用详情`);
updateResourceDetails(resources);
}
function openResourceDetails() {
updateResourceDetails(latestResources);
if (!resourceDialog.open) resourceDialog.showModal();
resourceDialogClose.focus();
}
statusEls.cpu.addEventListener("click", openResourceDetails);
statusEls.memory.addEventListener("click", openResourceDetails);
function activeCurrentDisplayPlayback(status = latestStatus) {
const content = status?.state?.current_content;
const playback = status?.state?.animation_playback;
if (
content?.category !== "animation"
|| playback?.active !== true
|| typeof playback.session_id !== "string"
|| playback.animation_id !== content.id
) return null;
return playback;
}
function currentDisplayIdentity(status = latestStatus) {
const content = status?.state?.current_content || {};
const playback = activeCurrentDisplayPlayback(status);
return [
content.category || "unsaved",
content.id || "",
content.name || "未保存内容",
playback?.session_id || "",
].join(":");
}
function setCurrentDisplayPlaybackFeedback(copyId = "", defaultText = "", values = {}, state = "idle") {
if (copyId) renderUiCopy(copyId, currentDisplayPlaybackFeedback, defaultText, values);
else renderUiValue(currentDisplayPlaybackFeedback, "");
currentDisplayPlaybackFeedback.dataset.state = state;
currentDisplayPlaybackFeedback.hidden = !defaultText;
}
function syncCurrentDisplayControlAvailability() {
const controls = [currentDisplayPlayPause, ...currentDisplaySpeedButtons];
if (currentDisplayControlPending) {
[currentDisplayProgress, ...controls].forEach((control) => setControlBusy(control));
return;
}
setControlReady(currentDisplayProgress);
if (currentDisplayDragging) {
const reason = currentDisplaySeekUnavailableReason.textContent || "请先完成当前进度跳转。";
controls.forEach((control) => setControlUnavailable(control, reason));
} else {
controls.forEach((control) => setControlReady(control));
}
}
function renderCurrentDisplayPosition(positionMs, { force = false } = {}) {
if (!currentDisplayPlayback) return;
const total = Math.max(1, Number(currentDisplayPlayback.total_duration_ms) || 1);
const position = Math.max(0, Math.min(total - 1, Number(positionMs) || 0));
if (force || !currentDisplayDragging) currentDisplayProgress.value = String(Math.floor(position));
currentDisplayProgressTime.textContent = `${formatPlaybackTime(position)} / ${formatPlaybackTime(total)}`;
}
function applyCurrentDisplayPlayback(playback, { preservePosition = currentDisplayDragging } = {}) {
if (!playback?.active) return;
currentDisplayPlayback = { ...playback };
currentDisplayPlaybackSampledAt = performance.now();
const total = Math.max(1, Number(playback.total_duration_ms) || 1);
currentDisplayProgress.max = String(total - 1);
renderUiCopy(
playback.paused ? "copy.dynamic.current_display.resume" : "copy.dynamic.current_display.pause",
currentDisplayPlayPause,
playback.paused ? "继续播放" : "暂停",
);
currentDisplayPlayPause.setAttribute("aria-pressed", String(Boolean(playback.paused)));
currentDisplaySpeedButtons.forEach((button) => {
button.setAttribute("aria-pressed", String(Number(button.dataset.playbackSpeed) === Number(playback.speed)));
});
if (!preservePosition) renderCurrentDisplayPosition(playback.position_ms, { force: true });
syncCurrentDisplayControlAvailability();
if (currentDisplayAnimationFrame === null) {
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
}
}
function animateCurrentDisplayProgress(now) {
currentDisplayAnimationFrame = null;
if (!currentDisplayDialog.open || currentDisplayPlaybackControls.hidden || !currentDisplayPlayback) return;
if (!currentDisplayDragging) {
renderCurrentDisplayPosition(
projectPlaybackPosition(currentDisplayPlayback, now - currentDisplayPlaybackSampledAt),
);
}
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
}
function scheduleCurrentDisplayPreview(delay = null) {
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
const configured = Number(latestStatus?.ui?.preview_refresh_interval_ms);
const wait = delay === null ? Math.max(1, Number.isFinite(configured) ? configured : 1000) : delay;
currentDisplayPreviewTimer = window.setTimeout(() => {
currentDisplayPreviewTimer = null;
refreshCurrentDisplayDialogFrame();
}, wait);
}
async function refreshCurrentDisplayDialogFrame() {
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
if (currentDisplayPreviewPending) {
currentDisplayPreviewRequested = true;
return;
}
currentDisplayPreviewPending = true;
currentDisplayPreviewRequested = false;
const sequence = currentDisplayPreviewSequence;
try {
const blob = await apiBlob("/api/display/current-frame");
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
const nextUrl = URL.createObjectURL(blob);
const previousUrl = currentDisplayPreviewUrl;
currentDisplayPreviewUrl = nextUrl;
currentDisplayPreviewImage.src = nextUrl;
currentDisplayPreviewImage.hidden = false;
currentDisplayPreviewPlaceholder.hidden = true;
if (previousUrl !== null) URL.revokeObjectURL(previousUrl);
} catch (error) {
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
currentDisplayPreviewImage.hidden = true;
currentDisplayPreviewPlaceholder.hidden = false;
renderUiCopy(
"copy.dynamic.current_display.load_failed",
currentDisplayPreviewPlaceholder,
"当前画面读取失败:{reason}",
{ reason: error.message },
);
} finally {
currentDisplayPreviewPending = false;
if (!currentDisplayDialog.open) return;
if (sequence !== currentDisplayPreviewSequence) {
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
return;
}
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
else scheduleCurrentDisplayPreview();
}
}
function requestCurrentDisplayPreviewRefresh() {
if (currentDisplayPreviewPending) {
currentDisplayPreviewRequested = true;
return;
}
scheduleCurrentDisplayPreview(0);
}
function cleanupCurrentDisplayDialog() {
currentDisplayOpenIdentity = null;
currentDisplayPlayback = null;
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
currentDisplayControlPending = false;
currentDisplayPreviewSequence += 1;
currentDisplayPreviewRequested = false;
seekDispatcher.clear();
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
currentDisplayPreviewTimer = null;
if (currentDisplayAnimationFrame !== null) window.cancelAnimationFrame(currentDisplayAnimationFrame);
currentDisplayAnimationFrame = null;
if (currentDisplayPreviewUrl !== null) URL.revokeObjectURL(currentDisplayPreviewUrl);
currentDisplayPreviewUrl = null;
currentDisplayPreviewImage.removeAttribute("src");
currentDisplayPreviewImage.hidden = true;
currentDisplayPreviewPlaceholder.hidden = false;
renderUiCopy(
"copy.dynamic.current_display.loading",
currentDisplayPreviewPlaceholder,
"正在读取当前画面…",
);
setCurrentDisplayPlaybackFeedback();
}
function closeCurrentDisplayDialog() {
if (currentDisplayDialog.open) currentDisplayDialog.close();
else cleanupCurrentDisplayDialog();
}
async function openCurrentDisplayDialog() {
if (currentDisplayDialog.open) return;
setControlBusy(statusEls.currentContent);
try {
await refreshStatus({ source: "manual" });
} catch (_error) {
if (!latestStatus) return;
} finally {
setControlReady(statusEls.currentContent);
}
const content = latestStatus?.state?.current_content || { name: "未保存内容" };
const playback = activeCurrentDisplayPlayback();
currentDisplayOpenIdentity = currentDisplayIdentity();
currentDisplayDialogName.textContent = content.name || "未保存内容";
currentDisplayPlaybackControls.hidden = !playback;
currentDisplayDialog.showModal();
currentDisplayPreviewSequence += 1;
if (playback) applyCurrentDisplayPlayback(playback, { preservePosition: false });
refreshCurrentDisplayDialogFrame();
if (playback) currentDisplayProgress.focus();
else currentDisplayPreviewSurface.focus();
}
function syncCurrentDisplayDialogStatus(status) {
latestStatus = status;
if (!currentDisplayDialog.open) return;
const identity = currentDisplayIdentity(status);
if (identity !== currentDisplayOpenIdentity) {
closeCurrentDisplayDialog();
return;
}
const playback = activeCurrentDisplayPlayback(status);
if (playback) applyCurrentDisplayPlayback(playback);
}
async function sendCurrentDisplayPlaybackPatch(fields) {
const sessionId = currentDisplayPlayback?.session_id;
if (!sessionId) throw new Error("当前动图播放会话已经结束");
const result = await apiJson("/api/display/animation-playback", {
method: "PATCH",
body: JSON.stringify({ session_id: sessionId, ...fields }),
});
const playback = result.animation_playback;
if (
currentDisplayDialog.open
&& currentDisplayPlayback?.session_id === sessionId
&& playback?.session_id === sessionId
) {
if (latestStatus?.state) latestStatus.state.animation_playback = playback;
if (Number.isInteger(result.state_revision) && latestStatus?.service?.instance_id) {
lastStatusIdentity = `${latestStatus.service.instance_id}:${result.state_revision}`;
}
applyCurrentDisplayPlayback(playback);
requestCurrentDisplayPreviewRefresh();
}
return playback;
}
function handleCurrentDisplayPlaybackError(error) {
const defaultText = "动图播放控制失败:{reason}";
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.control_failed",
defaultText,
{ reason: error.message },
"error",
);
announce(`动图播放控制失败:${error.message}`, true);
if (error.status === 409) {
closeCurrentDisplayDialog();
refreshStatus({ source: "manual" }).catch(() => {});
}
}
async function sendCurrentDisplaySeek(positionMs) {
const playback = await sendCurrentDisplayPlaybackPatch({ position_ms: Math.round(positionMs) });
if (currentDisplayFinalSeekPosition === positionMs) {
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
applyCurrentDisplayPlayback(playback, { preservePosition: false });
syncCurrentDisplayControlAvailability();
}
}
const seekDispatcher = new LatestSeekDispatcher(sendCurrentDisplaySeek, {
intervalMs: 100,
onError: (error, positionMs) => {
if (currentDisplayFinalSeekPosition === positionMs) {
currentDisplayDragging = false;
currentDisplayFinalSeekPosition = null;
syncCurrentDisplayControlAvailability();
}
handleCurrentDisplayPlaybackError(error);
},
});
statusEls.currentContent.addEventListener("click", openCurrentDisplayDialog);
currentDisplayDialogClose.addEventListener("click", closeCurrentDisplayDialog);
currentDisplayDialog.addEventListener("close", cleanupCurrentDisplayDialog);
currentDisplayDialog.addEventListener("click", (event) => {
if (currentDisplayPlaybackControls.hidden || event.target === currentDisplayDialog) {
closeCurrentDisplayDialog();
}
});
currentDisplayProgress.addEventListener("pointerdown", () => {
currentDisplayDragging = true;
currentDisplayFinalSeekPosition = null;
syncCurrentDisplayControlAvailability();
});
currentDisplayProgress.addEventListener("input", () => {
currentDisplayDragging = true;
currentDisplayFinalSeekPosition = null;
const position = Number(currentDisplayProgress.value);
renderCurrentDisplayPosition(position, { force: true });
syncCurrentDisplayControlAvailability();
seekDispatcher.enqueue(position);
});
function flushCurrentDisplaySeek() {
if (!currentDisplayDragging) return;
const position = Number(currentDisplayProgress.value);
currentDisplayFinalSeekPosition = position;
seekDispatcher.enqueue(position, { flush: true });
}
currentDisplayProgress.addEventListener("change", flushCurrentDisplaySeek);
currentDisplayProgress.addEventListener("pointerup", flushCurrentDisplaySeek);
currentDisplayProgress.addEventListener("pointercancel", flushCurrentDisplaySeek);
currentDisplayPlayPause.addEventListener("click", async () => {
if (!currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging) return;
currentDisplayControlPending = true;
syncCurrentDisplayControlAvailability();
setCurrentDisplayPlaybackFeedback(
currentDisplayPlayback.paused
? "copy.dynamic.current_display.resuming"
: "copy.dynamic.current_display.pausing",
currentDisplayPlayback.paused ? "正在继续播放…" : "正在暂停…",
{},
"pending",
);
try {
const playback = await sendCurrentDisplayPlaybackPatch({ paused: !currentDisplayPlayback.paused });
setCurrentDisplayPlaybackFeedback(
playback.paused
? "copy.dynamic.current_display.paused"
: "copy.dynamic.current_display.resumed",
playback.paused ? "动图已暂停" : "动图继续播放",
{},
"success",
);
} catch (error) {
handleCurrentDisplayPlaybackError(error);
} finally {
currentDisplayControlPending = false;
syncCurrentDisplayControlAvailability();
}
});
currentDisplaySpeedButtons.forEach((button) => {
button.addEventListener("click", async () => {
const speed = Number(button.dataset.playbackSpeed);
if (
!currentDisplayPlayback
|| currentDisplayControlPending
|| currentDisplayDragging
|| speed === Number(currentDisplayPlayback.speed)
) return;
currentDisplayControlPending = true;
syncCurrentDisplayControlAvailability();
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.speed_changing",
"正在切换到 {speed}×…",
{ speed },
"pending",
);
try {
await sendCurrentDisplayPlaybackPatch({ speed });
setCurrentDisplayPlaybackFeedback(
"copy.dynamic.current_display.speed_changed",
"已切换到 {speed}×",
{ speed },
"success",
);
} catch (error) {
handleCurrentDisplayPlaybackError(error);
} finally {
currentDisplayControlPending = false;
syncCurrentDisplayControlAvailability();
}
});
});
document.addEventListener("visibilitychange", () => {
if (!currentDisplayDialog.open) return;
if (document.visibilityState === "visible") requestCurrentDisplayPreviewRefresh();
else if (currentDisplayPreviewTimer !== null) {
window.clearTimeout(currentDisplayPreviewTimer);
currentDisplayPreviewTimer = null;
}
});
function showTopbarVoltage(voltage) {
const status = voltage?.status || "starting";
statusEls.voltage.dataset.status = status;
if (status === "ok" && Number.isFinite(Number(voltage.volts))) {
renderUiCopy(
"copy.dynamic.device.voltage_ok",
statusEls.voltage,
"屏幕输入电压 {volts} V",
{ volts: Number(voltage.volts).toFixed(2) },
);
} else if (status === "disconnected") {
renderUiCopy("copy.dynamic.device.voltage_disconnected", statusEls.voltage, "电压传感器断开");
} else if (status === "error") {
renderUiCopy("copy.dynamic.device.voltage_error", statusEls.voltage, "电压读取异常");
} else {
renderUiCopy("copy.dynamic.device.voltage_loading", statusEls.voltage, "正在读取电压…");
}
}
function showCurrentContent(state) {
const name = typeof state?.current_content?.name === "string"
? state.current_content.name
: "未保存内容";
renderUiCopy(
"copy.dynamic.device.current_content",
statusEls.currentContent,
"当前画面:{name}",
{ name },
);
}
function showTopbarLowVoltageProtection(protection) {
const requestedMode = typeof protection?.mode === "string" ? protection.mode : "unavailable";
const warningMode = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, requestedMode);
const mode = warningMode
? requestedMode
: ["disabled", "unavailable", "inactive"].includes(requestedMode)
? requestedMode
: "unavailable";
const previousMode = statusEls.lowVoltageWarning.dataset.mode;
const visible = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, mode);
statusEls.lowVoltageWarning.dataset.mode = mode;
if (!visible) {
statusEls.lowVoltageWarning.hidden = true;
if (previousMode !== mode) statusEls.lowVoltageWarning.textContent = "";
return;
}
if (previousMode !== mode) {
statusEls.lowVoltageWarning.textContent = LOW_VOLTAGE_WARNING_TEXT[mode];
}
statusEls.lowVoltageWarning.hidden = false;
}
function showDeviceBrightness(state) {
const userBrightness = finiteNumber(state?.brightness);
const effectiveBrightness = finiteNumber(state?.effective_brightness);
if (userBrightness === null) {
statusEls.brightness.textContent = "-";
return;
}
if (effectiveBrightness === null) {
statusEls.brightness.textContent = `${userBrightness}%`;
return;
}
statusEls.brightness.textContent = `用户 ${userBrightness}% · 实际 ${effectiveBrightness}%`;
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && size >= 1024; index += 1) {
size /= 1024;
unit = units[index];
}
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
}
function normalizedHex(value) {
const match = /^#([0-9A-Fa-f]{6})$/.exec(String(value || "").trim());
return match ? `#${match[1].toUpperCase()}` : null;
}
function showTestColor(value, { editable = false } = {}) {
const color = normalizedHex(value) || customTestColor;
customColor.value = color;
customColor.dataset.colorReadonly = String(!editable);
customColor.setAttribute("aria-label", editable ? "编辑自定义测试颜色" : "查看测试颜色");
refreshColorTrigger(customColor);
}
function setDisplayTestFeedback(text = "", state = "idle") {
displayTestFeedback.textContent = text;
displayTestFeedback.dataset.state = state;
displayTestFeedback.hidden = !text;
}
function showTestProtection(testValue, effectiveValue) {
const mode = latestProtection?.mode;
if (mode !== "limiting" && mode !== "critical") {
testBrightnessProtection.hidden = true;
testBrightnessProtection.textContent = "";
return;
}
testBrightnessProtection.dataset.mode = mode;
if (mode === "critical") {
testBrightnessProtection.textContent = "电压过低,纯色测试暂时由 35% 亮度的低电图标覆盖;退出或恢复电压前,测试颜色和亮度会保留。";
} else {
const limit = finiteNumber(latestProtection?.brightness_limit_percent);
const effective = finiteNumber(effectiveValue);
testBrightnessProtection.textContent = `电压过低,当前允许的最高亮度为 ${limit ?? "-"}%;测试设置 ${testValue}%,实际 ${effective ?? "-"}%。`;
}
testBrightnessProtection.hidden = false;
}
function syncDisplayTest(state, protection = latestProtection) {
latestProtection = protection || null;
const displayTest = state?.display_test || {};
const active = displayTest.active === true;
displayTestControls.hidden = !active;
if (!active) {
setActiveButton(fillButtons, null);
customFillButton.classList.remove("active");
customFillButton.setAttribute("aria-pressed", "false");
showTestColor(customTestColor);
showTestProtection(50, state?.effective_brightness);
return;
}
const color = normalizedHex(displayTest.color) || customTestColor;
const brightness = Math.max(1, Math.min(100, Number(displayTest.brightness) || 50));
const matchingFixed = fillButtons.find((button) => button.dataset.testFill === color) || null;
setActiveButton(fillButtons, matchingFixed);
customFillButton.classList.toggle("active", !matchingFixed);
customFillButton.setAttribute("aria-pressed", String(!matchingFixed));
testBrightness.value = String(brightness);
testBrightnessValue.textContent = `${brightness}%`;
showTestColor(color, { editable: !matchingFixed });
showTestProtection(brightness, state?.effective_brightness);
}
export async function refreshDeviceStorage() {
storageEls.feedback.textContent = "正在读取设备容量…";
storageEls.feedback.dataset.state = "pending";
storageEls.feedback.hidden = false;
try {
const data = await apiJson("/api/device-storage");
storageEls.total.textContent = formatBytes(data.device_total_bytes);
storageEls.free.textContent = formatBytes(data.device_free_bytes);
storageEls.software.textContent = formatBytes(data.software_bytes);
storageEls.templates.textContent = formatBytes(data.templates_bytes);
storageEls.feedback.textContent = "";
storageEls.feedback.hidden = true;
return data;
} catch (error) {
storageEls.feedback.textContent = `容量读取失败:${error.message}`;
storageEls.feedback.dataset.state = "error";
announce(storageEls.feedback.textContent, true);
throw error;
}
}
export async function refreshStatus({ source = "manual" } = {}) {
try {
const data = await apiJson("/api/status");
syncCurrentDisplayDialogStatus(data);
statusEls.orientation.textContent = `${data.state.orientation}°`;
showDeviceBrightness(data.state);
showCurrentContent(data.state);
showTopbarResources(data.resources);
showTopbarLowVoltageProtection(data.power?.low_voltage_protection);
showTopbarVoltage(data.power?.screen_input_voltage);
syncDisplayTest(data.state, data.power?.low_voltage_protection);
const statusIdentity = `${data.service?.instance_id || "legacy"}:${data.state.revision ?? "legacy"}`;
const externalChange = source === "poll" && lastStatusIdentity !== null && statusIdentity !== lastStatusIdentity;
lastStatusIdentity = statusIdentity;
document.dispatchEvent(new CustomEvent("matrix:status", {
detail: { ...data, externalChange },
}));
if (externalChange) {
announce("设备状态已由其他页面更新;本页画板未被自动应用");
}
return data;
} catch (error) {
if (source !== "poll") announce(`状态读取失败:${error.message}`, true);
throw error;
}
}
fillButtons.forEach((button) => {
button.addEventListener("click", () => startDisplayTest(
button,
button.dataset.testFill,
button.textContent.trim(),
));
});
async function startDisplayTest(button, color, label, { openEditor = false } = {}) {
if (testActionPending) return;
testActionPending = true;
[...fillButtons, customFillButton].forEach((control) => setControlBusy(control));
setDisplayTestFeedback(`正在显示${label}…`, "pending");
try {
await apiJson("/api/display/test/fill", {
method: "POST",
body: JSON.stringify({ color }),
});
showTestColor(color, { editable: openEditor });
setDisplayTestFeedback(`${label}测试已开始`, "success");
announce(`${label}测试已开始`);
refreshCurrentDisplayPreview();
await refreshStatus();
if (openEditor) {
showTestColor(color, { editable: true });
customColor.click();
}
} catch (error) {
setDisplayTestFeedback(`纯色测试失败:${error.message}`, "error");
announce(`纯色测试失败:${error.message}`, true);
} finally {
testActionPending = false;
[...fillButtons, customFillButton].forEach((control) => setControlReady(control));
}
}
customFillButton.addEventListener("click", () => startDisplayTest(
customFillButton,
customTestColor,
"自定义纯色",
{ openEditor: true },
));
customColor.addEventListener("change", async () => {
if (customColor.dataset.colorReadonly === "true") return;
const requested = normalizedHex(customColor.value);
if (!requested || requested === customTestColor) return;
setControlBusy(customColor, { message: "正在保存并应用自定义纯色…" });
setDisplayTestFeedback("正在保存并应用自定义纯色…", "pending");
try {
const config = await apiJson("/api/config", {
method: "PUT",
body: JSON.stringify({ custom_test_color: requested }),
});
customTestColor = normalizedHex(config.custom_test_color) || requested;
await apiJson("/api/display/test/fill", {
method: "POST",
body: JSON.stringify({ color: customTestColor }),
});
setDisplayTestFeedback(`自定义纯色 ${customTestColor} 已保存并应用`, "success");
announce(`自定义纯色 ${customTestColor} 已保存并应用`);
refreshCurrentDisplayPreview();
await refreshStatus();
} catch (error) {
setDisplayTestFeedback(`自定义纯色保存或应用失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
await refreshStatus().catch(() => {});
} finally {
setControlReady(customColor);
}
});
async function processTestBrightnessQueue() {
if (applyingTestBrightness) return;
applyingTestBrightness = true;
while (desiredTestBrightness !== null) {
const requested = desiredTestBrightness;
desiredTestBrightness = null;
setDisplayTestFeedback(`正在应用测试亮度 ${requested}%…`, "pending");
try {
const response = await apiJson("/api/display/test/brightness", {
method: "PUT",
body: JSON.stringify({ brightness: requested }),
});
const applied = Number(response.display_test?.brightness) || requested;
testBrightness.value = String(applied);
testBrightnessValue.textContent = `${applied}%`;
showTestProtection(applied, response.effective_brightness);
setDisplayTestFeedback(`测试亮度 ${applied}% 已生效,不会修改用户亮度`, "success");
} catch (error) {
setDisplayTestFeedback(`测试亮度应用失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
if (desiredTestBrightness === null) await refreshStatus().catch(() => {});
}
}
applyingTestBrightness = false;
refreshCurrentDisplayPreview();
await refreshStatus().catch(() => {});
}
function queueTestBrightness(value) {
desiredTestBrightness = Math.max(1, Math.min(100, Number(value)));
void processTestBrightnessQueue();
}
testBrightness.addEventListener("input", () => {
testBrightnessValue.textContent = `${testBrightness.value}%`;
showTestProtection(testBrightness.value, null);
queueTestBrightness(testBrightness.value);
});
testBrightness.addEventListener("change", () => queueTestBrightness(testBrightness.value));
exitDisplayTest.addEventListener("click", async () => {
setControlBusy(exitDisplayTest, { label: "正在退出…", message: "正在退出纯色测试…" });
setDisplayTestFeedback("正在退出纯色测试…", "pending");
try {
await apiJson("/api/display/test", { method: "DELETE" });
setDisplayTestFeedback();
announce("已退出纯色测试,恢复进入测试前的设备状态");
refreshCurrentDisplayPreview();
await refreshStatus();
} catch (error) {
setDisplayTestFeedback(`退出测试失败:${error.message}`, "error");
announce(displayTestFeedback.textContent, true);
} finally {
setControlReady(exitDisplayTest);
}
});
refreshStatusButton.addEventListener("click", async () => {
setControlBusy(refreshStatusButton, { label: "正在刷新…" });
announce("正在刷新设备状态…");
try {
await refreshStatus();
announce("设备状态已刷新");
} catch (_error) {
// refreshStatus already reports the failure.
} finally {
setControlReady(refreshStatusButton);
}
});
registerWorkspace({
id: "device",
title: "设备状态和测试",
elementId: "workspace-device",
order: 10,
persistence: "none",
async onEnter() {
if (!loadedOnce) {
loadedOnce = true;
try {
const config = await apiJson("/api/config");
customTestColor = normalizedHex(config.custom_test_color) || customTestColor;
showTestColor(customTestColor);
} catch (error) {
announce(`自定义纯色读取失败:${error.message}`, true);
}
}
await refreshStatus();
await refreshDeviceStorage().catch(() => {});
window.clearInterval(storageTimer);
storageTimer = window.setInterval(() => refreshDeviceStorage().catch(() => {}), STORAGE_REFRESH_INTERVAL_MS);
},
onLeave() {
window.clearInterval(storageTimer);
storageTimer = null;
},
});
document.addEventListener("matrix:composition-applied", () => refreshStatus().catch(() => {}));
@@ -0,0 +1,322 @@
import { announce, apiJson } from "../core.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
const selectedName = document.getElementById("frp-selected-name");
const serviceState = document.getElementById("frp-service-state");
const feedback = document.getElementById("frp-feedback");
const serviceToggle = document.getElementById("frp-service-toggle");
const editButton = document.getElementById("frp-edit-config");
const diagnosticsButton = document.getElementById("network-diagnostics-run");
const dialog = document.getElementById("frp-config-dialog");
const configSelect = document.getElementById("frp-config-select");
const configName = document.getElementById("frp-config-name");
const configFormat = document.getElementById("frp-config-format");
const configContent = document.getElementById("frp-config-content");
const configFile = document.getElementById("frp-config-file");
const configFeedback = document.getElementById("frp-config-feedback");
const newButton = document.getElementById("frp-config-new");
const uploadButton = document.getElementById("frp-config-upload");
const deleteButton = document.getElementById("frp-config-delete");
const closeButton = document.getElementById("frp-config-close");
const saveButton = document.getElementById("frp-config-save");
const diagnosticsDialog = document.getElementById("network-diagnostics-dialog");
const diagnosticsSummary = document.getElementById("network-diagnostics-summary");
const diagnosticsChecks = document.getElementById("network-diagnostics-checks");
const diagnosticsClose = document.getElementById("network-diagnostics-close");
const SUPPORTED_SUFFIXES = new Set([".toml", ".yaml", ".yml", ".json", ".ini"]);
const MAX_CONFIG_BYTES = 1024 * 1024;
let state = null;
let editingId = null;
let generation = 0;
function setMessage(element, text, kind = "idle") {
element.textContent = text;
element.dataset.state = kind;
element.hidden = !text;
}
function profileById(id) {
return state?.profiles?.find((profile) => profile.id === id) || null;
}
function serviceLabel(service) {
if (!service?.installed) return "frpc 未安装";
if (service.running) return service.enabled ? "运行中(开机自动启动)" : "运行中";
if (service.state === "failed") return "启动失败";
return service.enabled ? "等待启动" : "关闭";
}
function renderState(next) {
state = next;
const selected = profileById(next?.selected_id);
selectedName.textContent = selected?.name || "无";
serviceState.textContent = serviceLabel(next?.service);
const running = next?.service?.running === true;
serviceToggle.textContent = running || next?.service?.enabled ? "关闭frp" : "启动frp";
if (!next?.available) {
setControlUnavailable(serviceToggle, next?.error || "FRP 配置目录不可用。");
setMessage(feedback, next?.error || "FRP 配置目录不可用。", "error");
} else if (!next?.service?.installed) {
setControlUnavailable(serviceToggle, "frpc 系统软件尚未安装。");
setMessage(feedback, "frpc 系统软件尚未安装。", "error");
} else if (!selected && !running) {
setControlUnavailable(serviceToggle, "请先导入或新建 FRP 配置。");
setMessage(feedback, "尚未保存 FRP 配置。", "idle");
} else {
setControlReady(serviceToggle);
setMessage(feedback, next?.service?.last_error || "", next?.service?.last_error ? "error" : "idle");
}
}
function renderProfileOptions() {
configSelect.replaceChildren();
if (!state?.profiles?.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "暂无配置";
configSelect.append(option);
configSelect.disabled = true;
} else {
configSelect.disabled = false;
state.profiles.forEach((profile) => {
const option = document.createElement("option");
option.value = profile.id;
option.textContent = profile.name;
configSelect.append(option);
});
configSelect.value = editingId || state.selected_id || state.profiles[0].id;
}
deleteButton.disabled = !editingId;
}
async function requestText(path, options = {}) {
const response = await fetch(path, { ...options, cache: "no-store" });
if (!response.ok) {
let message = response.statusText;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
return response.text();
}
async function loadProfile(id) {
const currentGeneration = generation;
const profile = profileById(id);
if (!profile) {
editingId = null;
configName.value = "";
configContent.value = "";
renderProfileOptions();
return;
}
setMessage(configFeedback, "正在读取配置…");
const content = await requestText(`/api/frp/configs/${encodeURIComponent(id)}`);
if (generation !== currentGeneration) return;
editingId = id;
configName.value = profile.name;
const suffix = profile.filename.slice(profile.filename.lastIndexOf(".")).toLowerCase();
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
configContent.value = content;
renderProfileOptions();
setMessage(configFeedback, "");
}
export async function loadFrpSettings() {
const currentGeneration = ++generation;
try {
const next = await apiJson("/api/frp");
if (generation !== currentGeneration) return;
renderState(next);
} catch (error) {
setMessage(feedback, `FRP 状态读取失败:${error.message}`, "error");
}
}
export function discardFrpDrafts() {
generation += 1;
editingId = null;
configName.value = "";
configContent.value = "";
configFile.value = "";
setMessage(configFeedback, "");
if (dialog.open) dialog.close();
if (diagnosticsDialog.open) diagnosticsDialog.close();
}
editButton.addEventListener("click", async () => {
try {
await loadFrpSettings();
editingId = state?.selected_id || state?.profiles?.[0]?.id || null;
renderProfileOptions();
if (editingId) await loadProfile(editingId);
else {
configName.value = "";
configFormat.value = ".toml";
configContent.value = "";
}
dialog.showModal();
(editingId ? configContent : configName).focus();
} catch (error) {
announce(`打开 FRP 配置失败:${error.message}`, true);
}
});
closeButton.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => {
configFile.value = "";
setMessage(configFeedback, "");
});
newButton.addEventListener("click", () => {
editingId = null;
configSelect.value = "";
configName.value = "";
configFormat.value = ".toml";
configContent.value = "";
deleteButton.disabled = true;
setMessage(configFeedback, "请输入完整配置;保存前会使用 frpc 官方校验。", "idle");
configName.focus();
});
uploadButton.addEventListener("click", () => configFile.click());
configFile.addEventListener("change", async () => {
const file = configFile.files?.[0];
if (!file) return;
const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (!SUPPORTED_SUFFIXES.has(suffix)) {
setMessage(configFeedback, "请选择 TOML、YAML、JSON 或 INI 配置文件。", "error");
return;
}
if (file.size > MAX_CONFIG_BYTES) {
setMessage(configFeedback, "配置文件不能超过 1 MiB。", "error");
return;
}
try {
const content = await file.text();
editingId = null;
configName.value = file.name.slice(0, -suffix.length);
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
configContent.value = content;
deleteButton.disabled = true;
setMessage(configFeedback, "文件已读取,点击保存后执行官方校验。", "idle");
} catch (error) {
setMessage(configFeedback, `读取文件失败:${error.message}`, "error");
} finally {
configFile.value = "";
}
});
configSelect.addEventListener("change", async () => {
const id = configSelect.value;
if (!id) return;
try {
if (id !== state?.selected_id) {
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(id)}/select`, { method: "POST" });
renderState(result.frp);
}
await loadProfile(id);
} catch (error) {
setMessage(configFeedback, `切换配置失败:${error.message}`, "error");
renderProfileOptions();
}
});
saveButton.addEventListener("click", async () => {
const name = configName.value.trim();
const content = configContent.value;
if (!name || !content.trim()) {
setMessage(configFeedback, "请输入配置名称和完整配置内容。", "error");
return;
}
setControlBusy(saveButton, { label: "正在校验…", message: "正在使用 frpc 校验配置" });
try {
const suffix = configFormat.value;
const path = editingId
? `/api/frp/configs/${encodeURIComponent(editingId)}?name=${encodeURIComponent(name)}`
: `/api/frp/configs?filename=${encodeURIComponent(`frpc${suffix}`)}&name=${encodeURIComponent(name)}`;
const response = await fetch(path, {
method: editingId ? "PUT" : "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: content,
cache: "no-store",
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || response.statusText);
editingId = result.profile.id;
renderState(result.frp);
renderProfileOptions();
setMessage(configFeedback, result.frp.service.running ? "配置已保存并重启 FRP。" : "配置已保存并通过校验。", "success");
announce("FRP 配置已保存");
} catch (error) {
setMessage(configFeedback, `配置保存失败:${error.message}`, "error");
} finally {
setControlReady(saveButton);
}
});
deleteButton.addEventListener("click", async () => {
if (!editingId) return;
const profile = profileById(editingId);
if (!window.confirm(`确认删除 FRP 配置“${profile?.name || "未命名"}”?`)) return;
setControlBusy(deleteButton, { label: "正在删除…" });
try {
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(editingId)}`, { method: "DELETE" });
renderState(result.frp);
editingId = result.frp.selected_id || result.frp.profiles?.[0]?.id || null;
renderProfileOptions();
if (editingId) await loadProfile(editingId);
else {
configName.value = "";
configContent.value = "";
}
announce("FRP 配置已删除");
} catch (error) {
setMessage(configFeedback, `删除失败:${error.message}`, "error");
} finally {
setControlReady(deleteButton);
}
});
serviceToggle.addEventListener("click", async () => {
const stopping = state?.service?.running || state?.service?.enabled;
setControlBusy(serviceToggle, { label: stopping ? "正在关闭…" : "正在启动…" });
try {
const result = await apiJson(`/api/frp/service/${stopping ? "stop" : "start"}`, { method: "POST" });
renderState(result.frp);
announce(stopping ? "FRP 已关闭" : "FRP 已启动");
} catch (error) {
setMessage(feedback, `${stopping ? "关闭" : "启动"} FRP 失败:${error.message}`, "error");
await loadFrpSettings();
}
});
diagnosticsButton.addEventListener("click", async () => {
diagnosticsSummary.textContent = "正在检测网络,请稍候…";
diagnosticsSummary.dataset.state = "idle";
diagnosticsChecks.replaceChildren();
diagnosticsDialog.showModal();
setControlBusy(diagnosticsButton, { label: "正在检测…" });
try {
const result = await apiJson("/api/network/diagnostics", { method: "POST" });
diagnosticsSummary.textContent = result.diagnosis;
diagnosticsSummary.dataset.state = result.overall === "ok" ? "success" : "error";
result.checks.forEach((check) => {
const item = document.createElement("li");
item.dataset.state = check.ok ? "success" : (check.warning ? "warning" : "error");
item.textContent = `${check.ok ? "通过" : (check.warning ? "提示" : "失败")}:${check.message}`;
diagnosticsChecks.append(item);
});
} catch (error) {
diagnosticsSummary.textContent = `网络检测失败:${error.message}`;
diagnosticsSummary.dataset.state = "error";
} finally {
setControlReady(diagnosticsButton);
}
});
diagnosticsClose.addEventListener("click", () => diagnosticsDialog.close());
document.addEventListener("matrix:status", (event) => {
if (event.detail?.frp) renderState(event.detail.frp);
});
@@ -0,0 +1,410 @@
import { announce, apiJson, registerWorkspace } from "../core.js";
import { clampCropCenter, cropCenterBounds, cropGeometry } from "../media-crop-geometry.js";
import { shouldReuseMediaJobCard } from "../media-import-ui-model.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
const form = document.getElementById("media-upload-form");
const fileInput = document.getElementById("media-source-file");
const uploadButton = document.getElementById("media-upload-button");
const uploadProgress = document.getElementById("media-upload-progress");
const refreshButton = document.getElementById("refresh-media-imports");
const feedback = document.getElementById("media-import-feedback");
const jobsRoot = document.getElementById("media-import-jobs");
const stateNames = {
uploading: ["copy.dynamic.media.state_uploading", "上传中"],
analyzing: ["copy.dynamic.media.state_analyzing", "分析中"],
awaiting_settings: ["copy.dynamic.media.state_awaiting", "等待设置"],
queued: ["copy.dynamic.media.state_queued", "排队中"],
converting: ["copy.dynamic.media.state_converting", "转换中"],
failed: ["copy.dynamic.media.state_failed", "失败"],
};
let initialized = false;
let pollingTimer = 0;
let priorJobs = new Map();
let emptyState = null;
function setFeedback(copyId, defaultText, values = {}, error = false) {
renderUiCopy(copyId, feedback, defaultText, values);
feedback.dataset.state = error ? "error" : "success";
}
function makeButton(copyId, text, className = "") {
const element = document.createElement("button");
element.type = "button";
renderUiCopy(copyId, element, text);
if (className) element.className = className;
return element;
}
function labelControl(copyId, text, control) {
const label = document.createElement("label");
const span = document.createElement("span");
renderUiCopy(copyId, span, text);
label.append(span, control);
return label;
}
async function cancelJob(id) {
try {
await apiJson("/api/media-imports/" + id, { method: "DELETE" });
setFeedback("copy.dynamic.media.deleted", "任务和源文件已删除。");
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.delete_failed", "删除任务失败:{error}", { error: error.message }, true);
}
}
function settingsCard(job, card) {
const markDirty = () => { card.dataset.localDraft = "true"; };
const stage = document.createElement("div");
stage.className = "media-crop-stage";
stage.dataset.fit = job.settings.fit_mode;
const sourcePlane = document.createElement("div");
sourcePlane.className = "media-crop-source";
const image = document.createElement("img");
image.alt = job.filename + " 代表帧";
image.src = job.previews[0] || "";
sourcePlane.append(image);
stage.append(sourcePlane);
const tabs = document.createElement("div");
tabs.className = "media-preview-tabs";
job.previews.forEach((url, index) => {
const tab = makeButton("copy.dynamic.media.preview", "代表帧 " + (index + 1));
renderUiCopy("copy.dynamic.media.preview", tab, "代表帧 {index}", { index: index + 1 });
tab.setAttribute("aria-pressed", index === 0 ? "true" : "false");
tab.addEventListener("click", () => {
markDirty();
image.src = url;
tabs.querySelectorAll("button").forEach((item) => {
item.setAttribute("aria-pressed", item === tab ? "true" : "false");
});
});
tabs.append(tab);
});
const grid = document.createElement("div");
grid.className = "media-settings-grid";
const name = document.createElement("input");
name.type = "text"; name.maxLength = 80; name.value = job.name;
const fit = document.createElement("select");
[
["crop", "copy.dynamic.media.mode_crop", "自由取景"],
["contain", "copy.dynamic.media.mode_contain", "完整包含"],
["stretch", "copy.dynamic.media.mode_stretch", "拉伸"],
].forEach((pair) => {
const option = document.createElement("option");
option.value = pair[0];
renderUiCopy(pair[1], option, pair[2]);
option.selected = pair[0] === job.settings.fit_mode;
fit.append(option);
});
const zoom = document.createElement("input");
zoom.type = "range"; zoom.min = "1"; zoom.max = "16";
zoom.step = "0.05"; zoom.value = job.settings.zoom;
const transparency = document.createElement("input");
transparency.type = "color"; transparency.value = job.settings.transparency_color;
const padding = document.createElement("input");
padding.type = "color"; padding.value = job.settings.padding_color;
grid.append(
labelControl("copy.dynamic.media.output_name", "保存名称", name),
labelControl("copy.dynamic.media.fit_mode", "1:1 适配", fit),
labelControl("copy.dynamic.media.crop_zoom", "取景缩放", zoom),
labelControl("copy.dynamic.media.transparency", "透明像素填充色", transparency),
labelControl("copy.dynamic.media.padding", "留白颜色", padding),
);
let centerX = Number(job.settings.center_x);
let centerY = Number(job.settings.center_y);
function sourceSize() {
return {
width: image.naturalWidth || 1,
height: image.naturalHeight || 1,
};
}
function cropBounds() {
const source = sourceSize();
return cropCenterBounds({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: Number(zoom.value),
});
}
function clampCenter() {
const clamped = clampCropCenter({ centerX, centerY }, cropBounds());
centerX = clamped.centerX;
centerY = clamped.centerY;
}
function updateCropVisual() {
stage.dataset.fit = fit.value;
stage.style.backgroundColor = padding.value;
sourcePlane.style.backgroundColor = transparency.value;
const source = sourceSize();
if (fit.value === "stretch") {
sourcePlane.style.width = "100%";
sourcePlane.style.height = "100%";
sourcePlane.style.left = "0";
sourcePlane.style.top = "0";
return;
}
if (fit.value === "crop") clampCenter();
const geometry = cropGeometry({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: fit.value === "crop" ? Number(zoom.value) : 1,
centerX: fit.value === "crop" ? centerX : 0.5,
centerY: fit.value === "crop" ? centerY : 0.5,
});
sourcePlane.style.width = geometry.width + "px";
sourcePlane.style.height = geometry.height + "px";
sourcePlane.style.left = geometry.left + "px";
sourcePlane.style.top = geometry.top + "px";
}
name.addEventListener("input", markDirty);
fit.addEventListener("change", () => { markDirty(); updateCropVisual(); });
zoom.addEventListener("input", () => { markDirty(); updateCropVisual(); });
transparency.addEventListener("input", () => { markDirty(); updateCropVisual(); });
padding.addEventListener("input", () => { markDirty(); updateCropVisual(); });
let drag = null;
stage.addEventListener("pointerdown", (event) => {
if (fit.value !== "crop") return;
drag = { x: event.clientX, y: event.clientY, centerX, centerY };
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener("pointermove", (event) => {
if (!drag) return;
markDirty();
const source = sourceSize();
const geometry = cropGeometry({
viewportWidth: stage.clientWidth || 1,
viewportHeight: stage.clientHeight || 1,
sourceWidth: source.width,
sourceHeight: source.height,
zoom: Number(zoom.value), centerX: drag.centerX, centerY: drag.centerY,
});
centerX = drag.centerX - (event.clientX - drag.x) / geometry.width;
centerY = drag.centerY - (event.clientY - drag.y) / geometry.height;
clampCenter();
updateCropVisual();
});
stage.addEventListener("pointerup", () => { drag = null; });
stage.addEventListener("pointercancel", () => { drag = null; });
image.addEventListener("load", updateCropVisual);
const cropResizeObserver = new ResizeObserver(updateCropVisual);
cropResizeObserver.observe(stage);
card.mediaCropDispose = () => cropResizeObserver.disconnect();
updateCropVisual();
const actions = document.createElement("div");
actions.className = "media-job-actions";
const convert = makeButton("copy.dynamic.media.convert", "保存设置并开始转换", "primary-button");
const cancel = makeButton("copy.dynamic.media.cancel_source", "取消并删除源文件", "danger-button");
actions.append(convert, cancel);
convert.addEventListener("click", async () => {
convert.disabled = true;
try {
await apiJson("/api/media-imports/" + job.id + "/settings", {
method: "PUT",
body: JSON.stringify({
name: name.value, fit_mode: fit.value, center_x: centerX, center_y: centerY,
zoom: Number(zoom.value), transparency_color: transparency.value,
padding_color: padding.value,
}),
});
await apiJson("/api/media-imports/" + job.id + "/convert", { method: "POST", body: "{}" });
setFeedback("copy.dynamic.media.queued", "任务已加入转换队列。");
await refreshJobs();
} catch (error) {
if (error.message === "output name already exists") {
setFeedback("copy.dynamic.media.name_conflict", "保存名称已存在,请更换名称。", {}, true);
} else {
setFeedback("copy.dynamic.media.convert_failed", "无法开始转换:{error}", { error: error.message }, true);
}
convert.disabled = false;
}
});
cancel.addEventListener("click", () => cancelJob(job.id));
card.append(tabs, stage, grid, actions);
}
function renderJob(job) {
const card = document.createElement("article");
card.className = "media-job-card";
card.dataset.jobId = job.id;
card.dataset.jobState = job.state;
card.dataset.serverUpdatedAt = job.updated_at;
card.dataset.localDraft = "false";
const heading = document.createElement("div");
heading.className = "media-job-heading";
const title = document.createElement("h3");
title.textContent = job.name;
const state = document.createElement("p");
const stateCopy = stateNames[job.state];
if (job.queue_position) {
renderUiCopy("copy.dynamic.media.queue_position", state, "{state} · 队列第 {position} 位", {
state: stateCopy ? stateCopy[1] : job.state, position: job.queue_position,
});
} else if (stateCopy) {
renderUiCopy(stateCopy[0], state, stateCopy[1]);
} else {
state.textContent = job.state;
}
heading.append(title, state);
const source = document.createElement("p");
source.textContent = job.filename + " · " + (job.source_size / 1024 / 1024).toFixed(1) + " MiB";
card.append(heading, source);
if (job.state === "awaiting_settings") {
settingsCard(job, card);
return card;
}
const progress = document.createElement("progress");
progress.max = 100; progress.value = job.progress || 0;
card.append(progress);
if (job.error) {
const error = document.createElement("p");
error.textContent = job.error; error.className = "settings-feedback";
error.dataset.state = "error"; card.append(error);
}
const actions = document.createElement("div");
actions.className = "media-job-actions";
if (job.state === "failed") {
const retry = makeButton("copy.dynamic.media.retry", "重试", "primary-button");
retry.addEventListener("click", async () => {
try {
await apiJson("/api/media-imports/" + job.id + "/retry", { method: "POST", body: "{}" });
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.retry_failed", "重试失败:{error}", { error: error.message }, true);
}
});
actions.append(retry);
}
const cancel = makeButton("copy.dynamic.media.cancel", "取消并删除", "danger-button");
cancel.addEventListener("click", () => cancelJob(job.id));
actions.append(cancel); card.append(actions);
return card;
}
function renderedCardState(card) {
if (!card) return null;
return {
id: card.dataset.jobId,
state: card.dataset.jobState,
updatedAt: card.dataset.serverUpdatedAt,
dirty: card.dataset.localDraft === "true",
};
}
function reconcileJobCards(jobs) {
const oldCards = new Map(
Array.from(jobsRoot.querySelectorAll(":scope > .media-job-card"))
.map((card) => [card.dataset.jobId, card]),
);
if (jobs.length) {
emptyState?.remove();
jobs.forEach((job, index) => {
const oldCard = oldCards.get(job.id) || null;
const card = shouldReuseMediaJobCard(renderedCardState(oldCard), job)
? oldCard
: renderJob(job);
if (oldCard && card !== oldCard) {
oldCard.mediaCropDispose?.();
oldCard.replaceWith(card);
}
const position = jobsRoot.children[index] || null;
if (position !== card) jobsRoot.insertBefore(card, position);
oldCards.delete(job.id);
});
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
return;
}
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
if (!emptyState) {
emptyState = document.createElement("p");
emptyState.className = "media-job-empty";
renderUiCopy("copy.dynamic.media.empty", emptyState, "当前没有等待或转换中的任务。");
}
if (!emptyState.isConnected) jobsRoot.append(emptyState);
}
async function refreshJobs() {
if (document.hidden) return;
try {
const result = await apiJson("/api/media-imports");
const next = new Map(result.jobs.map((job) => [job.id, job]));
for (const pair of priorJobs) {
const id = pair[0], job = pair[1];
if (!next.has(id) && ["queued", "converting"].includes(job.state)) {
const message = "“" + job.name + "”转换完成,已加入内容库。";
setFeedback("copy.dynamic.media.completed", "“{name}”转换完成,已加入内容库。", { name: job.name });
announce(message);
document.getElementById("refresh-templates")?.click();
document.getElementById("refresh-animations")?.click();
}
}
priorJobs = next;
reconcileJobCards(result.jobs);
} catch (error) {
setFeedback("copy.dynamic.media.list_failed", "任务列表读取失败:{error}", { error: error.message }, true);
}
}
function upload(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/media-imports?filename=" + encodeURIComponent(file.name));
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.responseType = "json";
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) uploadProgress.value = event.loaded * 100 / event.total;
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject(new Error(xhr.response?.detail || xhr.statusText || "上传失败"));
});
xhr.addEventListener("error", () => reject(new Error("上传连接中断")));
xhr.send(file);
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) return;
uploadButton.disabled = true; uploadProgress.hidden = false; uploadProgress.value = 0;
try {
await upload(file);
form.reset();
setFeedback("copy.dynamic.media.uploaded", "上传完成,设备正在分析媒体。");
await refreshJobs();
} catch (error) {
setFeedback("copy.dynamic.media.upload_failed", "上传失败:{error}", { error: error.message }, true);
} finally {
uploadButton.disabled = false; uploadProgress.hidden = true;
}
});
refreshButton.addEventListener("click", refreshJobs);
document.addEventListener("visibilitychange", () => {
window.clearInterval(pollingTimer);
if (!document.hidden && initialized) {
refreshJobs(); pollingTimer = window.setInterval(refreshJobs, 2000);
}
});
async function initialize() {
if (!initialized) {
initialized = true; pollingTimer = window.setInterval(refreshJobs, 2000);
}
await refreshJobs();
}
registerWorkspace({
id: "media-import", title: "媒体内容转换", elementId: "workspace-media-import",
order: 45, persistence: "server",
capabilities: ["media-upload", "background-conversion"], onEnter: initialize,
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,627 @@
import { announce, apiJson, registerWorkspace } from "../core.js";
import {
animationEditState,
enterTemplateEdit,
getScene,
markTemplateSaved,
ordinarySceneNeedsTemplateSave,
refreshCurrentDisplayPreview,
templateEditState,
} from "../scene-board.js";
import { sceneToStorageValue } from "../scene-model.js";
import { protectCardDragStart, reorderByKey } from "../sortable-card.js";
import { resolveAnimationEditBeforeNavigation } from "./animations.js";
import { refreshStatus } from "./device.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
createAnimationPreviewGroup,
createAnimationThumbnailBox,
} from "../animation-thumbnail-preview.js";
const grid = document.getElementById("template-grid");
const feedback = document.getElementById("template-feedback");
const refreshButton = document.getElementById("refresh-templates");
const selectDefaultButton = document.getElementById("select-default-content");
const saveButton = document.getElementById("save-template");
const nameDialog = document.getElementById("template-name-dialog");
const nameDialogTitle = document.getElementById("template-dialog-title");
const nameDialogNote = document.getElementById("template-dialog-note");
const nameInput = document.getElementById("template-name-input");
const confirmDialog = document.getElementById("template-confirm-dialog");
const confirmMessage = document.getElementById("template-confirm-message");
const saveChoiceDialog = document.getElementById("template-save-choice-dialog");
const saveChoiceMessage = document.getElementById("template-save-choice-message");
const saveCurrentButton = document.getElementById("template-save-current");
const copyDialog = document.getElementById("library-copy-dialog");
const copyTarget = document.getElementById("library-copy-target");
let loading = false;
let animationList = [];
let activeAnimation = null;
let libraryOrder = { items: [], revision: "" };
let draggingLibraryKey = null;
let defaultContent = null;
let selectingDefault = false;
const demoRestrictedMessage = "演示案例不得编辑,请复制";
const libraryDragType = "application/x-matrix-library-item";
const templateAnimationPreviewGroup = createAnimationPreviewGroup();
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "-";
if (bytes < 1024) return `${bytes} B`;
const units = ["KiB", "MiB", "GiB", "TiB"];
let size = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && size >= 1024; index += 1) {
size /= 1024;
unit = units[index];
}
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
}
function askName(title, initial = "", { note = "" } = {}) {
nameDialogTitle.textContent = title;
nameDialogNote.textContent = note;
nameDialogNote.hidden = !note;
nameInput.value = initial;
nameInput.required = true;
nameDialog.returnValue = "";
nameDialog.showModal();
window.setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 0);
return new Promise((resolve) => {
nameDialog.addEventListener("close", () => {
resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null);
}, { once: true });
});
}
function askConfirmation(message, { copyId = "", values = {} } = {}) {
if (copyId) renderUiCopy(copyId, confirmMessage, message, values);
else confirmMessage.textContent = message;
confirmDialog.returnValue = "";
confirmDialog.showModal();
return new Promise((resolve) => {
confirmDialog.addEventListener("close", () => {
resolve(confirmDialog.returnValue === "confirm");
}, { once: true });
});
}
function actionButton(label, action, className = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
if (className) button.className = className;
button.addEventListener("click", () => action(button));
return button;
}
function restrictDemoAction(control, message = demoRestrictedMessage) {
control.classList.add("demo-restricted-action");
setControlUnavailable(control, message);
return control;
}
function syncSaveButton() {
if (!animationEditState() && templateEditState()) {
renderUiCopy("copy.dynamic.template.save_bound", saveButton, "保存模板");
} else {
renderUiCopy("copy.dynamic.template.save_new", saveButton, "保存为模板");
}
}
function askSaveChoice(context) {
renderUiCopy(
"copy.dynamic.template.save_choice",
saveChoiceMessage,
"请选择“{name}”的保存方式。",
{ name: context.name },
);
saveCurrentButton.classList.remove("demo-restricted-action");
setControlReady(saveCurrentButton);
if (context.readOnly) {
restrictDemoAction(saveCurrentButton, "演示案例不得覆盖,请另存为新模板");
}
saveChoiceDialog.returnValue = "";
saveChoiceDialog.showModal();
return new Promise((resolve) => {
saveChoiceDialog.addEventListener("close", () => {
resolve(["current", "new"].includes(saveChoiceDialog.returnValue)
? saveChoiceDialog.returnValue
: "cancel");
}, { once: true });
});
}
async function confirmOrdinarySceneReplacement(name) {
if (!ordinarySceneNeedsTemplateSave()) return true;
return askConfirmation(
"当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
{
copyId: "copy.dynamic.template.unsaved_replace",
values: { name },
},
);
}
function setBusy(value) {
loading = value;
if (value) {
setControlBusy(refreshButton);
setControlBusy(selectDefaultButton);
setControlBusy(saveButton);
grid.setAttribute("aria-busy", "true");
} else {
setControlReady(refreshButton);
setControlReady(selectDefaultButton);
setControlReady(saveButton);
grid.removeAttribute("aria-busy");
}
grid.toggleAttribute("inert", value);
}
function isDefaultContent(type, id) {
return defaultContent?.type === type && defaultContent?.id === id;
}
function syncDefaultSelectionButton() {
selectDefaultButton.setAttribute("aria-pressed", String(selectingDefault));
selectDefaultButton.classList.toggle("active", selectingDefault);
selectDefaultButton.textContent = selectingDefault ? "取消设置默认内容" : "设置默认显示内容";
}
async function chooseDefaultContent(type, item) {
await mutate(async () => {
defaultContent = await apiJson("/api/display/default-content", {
method: "PUT",
body: JSON.stringify({ type, id: item.id }),
});
selectingDefault = false;
syncDefaultSelectionButton();
await refreshStatus();
}, `已将“${item.name}”设为默认显示内容并立即显示`);
}
function decorateDefaultContent(card, type, item, { available = true } = {}) {
if (isDefaultContent(type, item.id)) {
card.classList.add("template-default-content");
const badge = document.createElement("span");
badge.className = "template-default-badge";
badge.textContent = "当前默认";
card.insertBefore(badge, card.querySelector(".template-card-content-actions"));
}
if (!selectingDefault) return card;
const actions = document.createElement("div");
actions.className = "template-card-actions template-card-default-actions";
const choose = actionButton(
isDefaultContent(type, item.id) ? "当前默认" : "设为默认",
() => chooseDefaultContent(type, item),
"primary-button",
);
if (isDefaultContent(type, item.id)) {
setControlUnavailable(choose, "这项已经是当前默认显示内容。");
} else if (!available) {
setControlUnavailable(choose, "空动图不能设为默认,请先添加至少一帧。");
}
actions.append(choose);
card.append(actions);
return card;
}
async function mutate(action, successMessage) {
setBusy(true);
feedback.textContent = "正在处理模板操作…";
feedback.dataset.state = "pending";
try {
await action();
announce(successMessage);
setBusy(false);
await refreshTemplates();
} catch (error) {
const conflict = error.code !== "APP_STALE" && (error.status === 409 || error.status === 428);
feedback.textContent = conflict
? "模板已被其他页面修改,列表已刷新;请检查最新内容后重新操作。"
: error.message;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
if (conflict) {
setBusy(false);
await refreshTemplates();
}
} finally {
setBusy(false);
}
}
function appendContentActions(card, { play, edit, copy, rename, remove }) {
const actions = document.createElement("div");
actions.className = "template-card-actions template-card-content-actions";
remove.classList.add("template-card-delete-action");
actions.append(play, edit, copy, rename, remove);
card.append(actions);
}
async function playStaticTemplate(template) {
await mutate(async () => {
await apiJson(`/api/templates/${template.id}/play`, {
method: "POST",
headers: { "If-Match": `"${template.revision}"` },
body: "{}",
});
refreshCurrentDisplayPreview();
await refreshStatus();
}, `开始播放“${template.name}”`);
}
async function editStaticTemplate(template) {
if (!await resolveAnimationEditBeforeNavigation()) return;
if (!await confirmOrdinarySceneReplacement(template.name)) return;
setBusy(true);
feedback.textContent = `正在读取“${template.name}”…`;
feedback.dataset.state = "pending";
try {
const full = await apiJson(`/api/templates/${template.id}`);
enterTemplateEdit(full);
window.location.hash = "#canvas";
announce(`正在编辑模板“${template.name}”`);
} catch (error) {
feedback.textContent = `模板读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setBusy(false);
}
}
function renderCard(template) {
const card = document.createElement("article");
card.className = "template-card";
const imageBox = document.createElement("div");
imageBox.className = "template-image-box";
const image = document.createElement("img");
image.className = "template-image";
image.src = template.thumbnail_url;
image.alt = `${template.name} 模板缩略图`;
image.width = 64;
image.height = 64;
image.draggable = false;
image.loading = "lazy";
image.addEventListener("error", () => {
image.hidden = true;
imageBox.classList.add("template-image-error");
imageBox.setAttribute("aria-label", "缩略图加载失败");
});
imageBox.appendChild(image);
const name = document.createElement("h3");
name.className = "template-card-name";
name.textContent = template.name;
const badge = document.createElement("span");
badge.className = "content-type-badge";
badge.textContent = "静态";
const size = document.createElement("p");
size.className = "template-card-size";
size.textContent = `占用 ${formatBytes(template.size_bytes)}`;
const play = actionButton("播放", () => playStaticTemplate(template), "primary-button");
const edit = actionButton("编辑", () => editStaticTemplate(template));
const copy = actionButton("复制", (control) => copyStaticTemplate(template, control));
const rename = actionButton("重命名", async () => {
const nextName = await askName("重命名模板", template.name);
if (!nextName || nextName === template.name) return;
mutate(() => apiJson(`/api/templates/${template.id}`, {
method: "PATCH",
headers: { "If-Match": `"${template.revision}"` },
body: JSON.stringify({ name: nextName }),
}), `模板已重命名为“${nextName}”`);
});
const remove = actionButton("删除", async () => {
if (!await askConfirmation(`永久删除模板“${template.name}”及其缩略图吗?`)) return;
mutate(() => apiJson(`/api/templates/${template.id}`, {
method: "DELETE",
headers: { "If-Match": `"${template.revision}"` },
}), `模板“${template.name}”已删除`);
}, "danger-button");
if (template.read_only) {
restrictDemoAction(rename);
restrictDemoAction(remove);
}
card.append(imageBox, badge, name, size);
appendContentActions(card, { play, edit, copy, rename, remove });
return decorateDefaultContent(card, "template", template);
}
async function chooseCopyTarget() {
const data = await apiJson("/api/animations");
copyTarget.replaceChildren(new Option("静态模板", "static"), ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}:${item.revision}`)));
copyDialog.returnValue = ""; copyDialog.showModal();
await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true }));
if (copyDialog.returnValue !== "copy") return null;
const [type, animationId, revision] = copyTarget.value.split(":");
return { type, animationId, revision };
}
async function copyStaticTemplate(template, control) {
setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" });
try {
const target = await chooseCopyTarget(); if (!target) return;
await mutate(() => apiJson("/api/library/copy", { method: "POST", body: JSON.stringify({
source_type: "template", source_id: template.id, source_revision: template.revision,
destination_type: target.type, destination_animation_id: target.animationId || null, destination_revision: target.revision || null,
}) }), `模板“${template.name}”已复制`);
} catch (error) {
feedback.textContent = `复制目标读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(control);
}
}
function renderAnimationCard(animation) {
const card = document.createElement("article"); card.className = "template-card";
const imageBox = createAnimationThumbnailBox(templateAnimationPreviewGroup, animation);
const badge = document.createElement("span"); badge.className = "content-type-badge content-type-animation"; badge.textContent = "动图";
const name = document.createElement("h3"); name.className = "template-card-name animation-folder-name"; name.textContent = animation.name;
name.dataset.hoverTooltip = animation.name;
const active = activeAnimation?.id === animation.id;
const meta = document.createElement("p"); meta.className = "template-card-size"; meta.textContent = `${animation.frame_count} 帧 · ${(animation.total_duration_ms / 1000).toFixed(2)} 秒 · ${formatBytes(animation.size_bytes)}${active ? activeAnimation.revision === animation.revision ? " · 正在播放" : " · 正在播放旧版本" : ""}`;
const play = actionButton("播放", () => mutate(async () => {
await apiJson(`/api/animations/${animation.id}/play`, {
method: "POST",
headers: { "If-Match": `"${animation.revision}"` },
body: "{}",
});
refreshCurrentDisplayPreview();
await refreshStatus();
}, `开始播放“${animation.name}”`), "primary-button");
if (!animation.frame_count) setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。");
const copy = actionButton("复制", () => mutate(() => apiJson(`/api/animations/${animation.id}/copy`, { method: "POST", body: "{}" }), `动图“${animation.name}”已复制`));
const edit = actionButton("编辑", () => { window.location.hash = "#animations"; document.dispatchEvent(new CustomEvent("matrix:open-animation", { detail: { id: animation.id } })); });
const rename = actionButton("重命名", async () => { const next = await askName("重命名动图", animation.name); if (next) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "PATCH", headers: { "If-Match": `"${animation.revision}"` }, body: JSON.stringify({ name: next }) }), "动图已重命名"); });
const remove = actionButton("删除", async () => { if (await askConfirmation(`永久删除动图“${animation.name}”及全部帧吗?`)) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "DELETE", headers: { "If-Match": `"${animation.revision}"` } }), "动图已删除"); }, "danger-button");
if (animation.read_only) {
restrictDemoAction(edit);
restrictDemoAction(rename);
restrictDemoAction(remove);
}
card.append(imageBox, badge, name, meta);
appendContentActions(card, { play, edit, copy, rename, remove });
return decorateDefaultContent(card, "animation", animation, { available: animation.frame_count > 0 });
}
function libraryItemKey(item) {
return `${item.type}:${item.id}`;
}
function clearLibraryDragIndicators() {
grid.querySelectorAll(".library-drop-target").forEach((card) => card.classList.remove("library-drop-target"));
}
function saveLibraryOrder(keys) {
const byKey = new Map(libraryOrder.items.map((item) => [libraryItemKey(item), item]));
const items = keys.map((key) => byKey.get(key)).filter(Boolean);
if (items.length !== libraryOrder.items.length) return;
mutate(() => apiJson("/api/library/order", {
method: "PUT",
headers: { "If-Match": `"${libraryOrder.revision}"` },
body: JSON.stringify({ items }),
}), "模板顺序已更新");
}
function moveLibraryItem(index, offset) {
const keys = libraryOrder.items.map(libraryItemKey);
const target = index + offset;
if (target < 0 || target >= keys.length) return;
[keys[index], keys[target]] = [keys[target], keys[index]];
saveLibraryOrder(keys);
}
function prepareDemoCard(card) {
card.classList.add("library-demo-fixed");
card.querySelectorAll("img").forEach((image) => { image.draggable = false; });
protectCardDragStart(card, { locked: true });
const orderActions = document.createElement("div");
orderActions.className = "template-card-actions template-card-order-actions";
const moveUp = restrictDemoAction(
actionButton("上移", () => {}),
"演示案例固定在最前,不能移动。",
);
const moveDown = restrictDemoAction(
actionButton("下移", () => {}),
"演示案例固定在最前,不能移动。",
);
orderActions.append(moveUp, moveDown);
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
return card;
}
function prepareSortableLibraryCard(card, item, index, total) {
const key = libraryItemKey(item);
card.classList.add("sortable-card", "library-sortable-card");
card.dataset.libraryKey = key;
protectCardDragStart(card);
const orderActions = document.createElement("div");
orderActions.className = "template-card-actions template-card-order-actions";
const moveUp = actionButton("上移", () => moveLibraryItem(index, -1));
const moveDown = actionButton("下移", () => moveLibraryItem(index, 1));
if (index === 0) setControlUnavailable(moveUp, "两个演示案例固定在最前,当前已经是第一项。");
if (index === total - 1) setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
orderActions.append(moveUp, moveDown);
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
card.addEventListener("dragstart", (event) => {
if (event.defaultPrevented) return;
draggingLibraryKey = key;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(libraryDragType, key);
event.dataTransfer.setData("text/plain", key);
});
card.addEventListener("dragover", (event) => {
if (!draggingLibraryKey || draggingLibraryKey === key) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
clearLibraryDragIndicators();
card.classList.add("library-drop-target");
});
card.addEventListener("dragleave", (event) => {
if (!card.contains(event.relatedTarget)) card.classList.remove("library-drop-target");
});
card.addEventListener("drop", (event) => {
if (!draggingLibraryKey) return;
event.preventDefault();
const source = event.dataTransfer.getData(libraryDragType) || draggingLibraryKey;
const keys = libraryOrder.items.map(libraryItemKey);
const reordered = reorderByKey(keys, source, key);
clearLibraryDragIndicators();
if (reordered) saveLibraryOrder(reordered);
});
card.addEventListener("dragend", () => {
draggingLibraryKey = null;
clearLibraryDragIndicators();
});
return card;
}
export async function refreshTemplates() {
if (loading) return;
setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取模板和动图…" });
feedback.textContent = "正在读取模板…";
feedback.dataset.state = "pending";
try {
const [data, animations, order, selectedDefault] = await Promise.all([
apiJson("/api/templates?include_demo=true"),
apiJson("/api/animations?include_demo=true"),
apiJson("/api/library/order"),
apiJson("/api/display/default-content"),
]);
defaultContent = selectedDefault;
animationList = animations.animations;
libraryOrder = order;
const demoCards = [
...data.templates.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "static" })),
...animationList.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "animation" })),
].sort((left, right) => left.demo_order - right.demo_order);
const userItems = [
...data.templates.filter((item) => !item.is_demo).map((item) => ({ type: "template", id: item.id, value: item })),
...animationList.filter((item) => !item.is_demo).map((item) => ({ type: "animation", id: item.id, value: item })),
];
const userByKey = new Map(userItems.map((item) => [libraryItemKey(item), item]));
const orderedUsers = order.items.map((item) => userByKey.get(libraryItemKey(item))).filter(Boolean);
templateAnimationPreviewGroup.clear();
const renderedDemos = demoCards.map((item) => prepareDemoCard(item.content_type === "static" ? renderCard(item) : renderAnimationCard(item)));
const renderedUsers = orderedUsers.map((item, index) => prepareSortableLibraryCard(
item.type === "template" ? renderCard(item.value) : renderAnimationCard(item.value),
item,
index,
orderedUsers.length,
));
grid.replaceChildren(...renderedDemos, ...renderedUsers);
if (!userItems.length) {
const empty = document.createElement("p");
empty.className = "template-empty panel";
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
grid.appendChild(empty);
}
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
feedback.dataset.state = "ready";
announce(feedback.textContent);
} catch (error) {
feedback.textContent = `模板读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(refreshButton);
}
}
async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
const name = await askName("保存为模板", initial, { note });
if (!name) return;
await mutate(async () => {
const result = await apiJson("/api/templates", {
method: "POST",
body: JSON.stringify({ name, scene: sceneToStorageValue(getScene()) }),
});
if (bind) markTemplateSaved(result);
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-save" } }));
return result;
}, `模板“${name}”已保存`);
}
async function saveSceneToCurrentTemplate(context) {
await mutate(async () => {
const result = await apiJson(`/api/templates/${context.id}`, {
method: "PUT",
headers: { "If-Match": `"${context.revision}"` },
body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }),
});
markTemplateSaved(result);
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-update" } }));
return result;
}, `模板“${context.name}”已保存`);
}
saveButton.addEventListener("click", async () => {
if (animationEditState()) {
await saveSceneAsNew({
note: "当前帧将被保存为一个静态图模板",
bind: false,
});
return;
}
const context = templateEditState();
if (!context) {
await saveSceneAsNew();
return;
}
const choice = await askSaveChoice(context);
if (choice === "current") {
await saveSceneToCurrentTemplate(context);
} else if (choice === "new") {
await saveSceneAsNew({ initial: `${context.name} - 副本` });
}
});
refreshButton.addEventListener("click", refreshTemplates);
selectDefaultButton.addEventListener("click", async () => {
if (loading) return;
selectingDefault = !selectingDefault;
syncDefaultSelectionButton();
announce(selectingDefault ? "请选择下方内容设为默认显示" : "已取消设置默认显示内容");
await refreshTemplates();
});
document.addEventListener("matrix:library-change", refreshTemplates);
document.addEventListener("matrix:template-edit-change", syncSaveButton);
document.addEventListener("matrix:animation-edit-change", syncSaveButton);
document.addEventListener("matrix:status", (event) => {
const state = event.detail?.state;
activeAnimation = state?.animation_active ? { id: state.animation_id, revision: state.animation_revision } : null;
});
registerWorkspace({
id: "templates",
title: "模板管理",
elementId: "workspace-templates",
order: 60,
persistence: "server",
onEnter: async () => {
templateAnimationPreviewGroup.setActive(true);
await refreshTemplates();
},
onLeave: () => {
templateAnimationPreviewGroup.setActive(false);
templateAnimationPreviewGroup.clear();
},
});
syncDefaultSelectionButton();
syncSaveButton();
@@ -0,0 +1,349 @@
import { announce, apiFile, apiJson, registerWorkspace } from "../core.js";
import { refreshColorTrigger } from "../color-picker.js";
import { createFontPicker } from "../font-picker.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
runControlAction,
setControlReady,
setControlUnavailable,
} from "../interaction-feedback.js";
import {
addTextElement,
deleteTextElement,
duplicateTextElement,
getScene,
getSelectedTextElement,
selectTextElement,
subscribeScene,
updateTextElement,
} from "../scene-board.js";
const form = document.getElementById("text-form");
const fields = document.getElementById("text-fields");
const contentFields = document.getElementById("text-content-fields");
const list = document.getElementById("text-element-list");
const selectionStatus = document.getElementById("text-selection-status");
const duplicateButton = document.getElementById("duplicate-text");
const deleteButton = document.getElementById("delete-text");
const colorTrigger = document.getElementById("text-color");
const fontInput = document.getElementById("text-font");
const fontOptions = document.getElementById("text-font-options");
const importFontButton = document.getElementById("import-font");
const fontFileInput = document.getElementById("font-file");
const fontFeedback = document.getElementById("font-feedback");
let initialized = false;
let suppressForm = false;
let renderedSelectionId = null;
let defaults = { font: "default", size: 12 };
let fontCatalogReady = false;
let fontMaxUploadBytes = 32 * 1024 * 1024;
let acceptedFontExtensions = [".ttf", ".otf", ".ttc", ".otc"];
const fontPicker = createFontPicker({
input: fontInput,
listbox: fontOptions,
onCommit(item) {
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, { font: item.id }, "form");
setFontFeedbackTemplate(
"copy.dynamic.text.font_selected",
"已选择字体“{name}”",
{ name: item.label },
);
},
});
function field(id) {
return document.getElementById(id);
}
function setFontFeedback(message = "", isError = false) {
fontFeedback.textContent = message;
fontFeedback.hidden = !message;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function setFontFeedbackTemplate(copyId, defaultText, values = {}, isError = false) {
renderUiCopy(copyId, fontFeedback, defaultText, values);
fontFeedback.hidden = false;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function updateFontPickerAvailability(element = getSelectedTextElement()) {
if (!fontCatalogReady) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "字体目录尚未加载完成。");
} else if (!element) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "请先新建或选择一个文字元素;字体导入仍可使用。");
} else {
setControlReady(fontInput);
fontPicker.setEnabled(true);
}
}
function applyElementToFields(element) {
suppressForm = true;
if (element) {
field("text-value").value = element.text;
fontPicker.setValue(element.font);
field("text-size").value = String(element.size);
field("text-x").value = String(element.x);
field("text-y").value = String(element.y);
field("text-align").value = element.align;
colorTrigger.value = element.color;
} else {
field("text-value").value = "";
fontPicker.setValue(defaults.font);
field("text-size").value = String(defaults.size);
field("text-x").value = "32";
field("text-y").value = "26";
field("text-align").value = "center";
colorTrigger.value = "#FFFFFF";
}
refreshColorTrigger(colorTrigger);
contentFields.toggleAttribute("disabled", !element);
fields.toggleAttribute("disabled", !element);
updateFontPickerAvailability(element);
renderedSelectionId = element?.id || null;
suppressForm = false;
}
function displayName(element, index) {
const text = element.text.trim() || "(空文字)";
return `${index + 1}. ${text.replace(/\s+/g, " ").slice(0, 28)}`;
}
function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "text-element-item";
button.textContent = displayName(element, index);
button.title = `${element.text || "空文字"} · ${element.size}px · ${element.color}`;
const selected = element.id === selectedId;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
button.addEventListener("click", () => selectTextElement(element.id));
return button;
});
list.replaceChildren(...items);
}
function renderSelection({ scene, selectedTextId, reason }) {
const selected = scene.elements.find((element) => element.id === selectedTextId) || null;
renderElementList(scene, selectedTextId);
if (selected) {
setControlReady(duplicateButton);
setControlReady(deleteButton);
} else {
setControlUnavailable(duplicateButton, "请先新建或选择一个文字元素。");
setControlUnavailable(deleteButton, "请先新建或选择一个文字元素。");
}
selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected);
}
function numericValue(id, fallback) {
const raw = field(id).value.trim();
const value = Number(raw);
return raw && Number.isFinite(value) ? value : fallback;
}
function formPatch(element) {
return {
text: field("text-value").value,
font: fontPicker.getValue() || "default",
size: numericValue("text-size", element.size),
x: numericValue("text-x", element.x),
y: numericValue("text-y", element.y),
align: field("text-align").value,
color: colorTrigger.value,
};
}
form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form");
});
document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({
text: "ok",
font: defaults.font,
size: defaults.size,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
});
announce(`已创建文字“${created.text}”,编辑完成后再应用统一画板`);
});
duplicateButton.addEventListener("click", () => {
const duplicate = duplicateTextElement();
if (duplicate) announce(`已复制文字“${duplicate.text}”并置于最上层`);
});
deleteButton.addEventListener("click", () => {
const selected = getSelectedTextElement();
if (selected && deleteTextElement(selected.id)) announce(`已删除文字“${selected.text || "空文字"}”`);
});
subscribeScene(renderSelection);
async function loadFontCatalog() {
const catalog = await apiJson("/api/fonts");
fontPicker.setItems(catalog.items);
acceptedFontExtensions = Array.isArray(catalog.accepted_extensions)
? catalog.accepted_extensions.map((value) => String(value).toLowerCase())
: acceptedFontExtensions;
fontMaxUploadBytes = Number(catalog.max_upload_bytes) || fontMaxUploadBytes;
fontCatalogReady = true;
updateFontPickerAvailability();
if (catalog.warnings?.length) {
setFontFeedback(catalog.warnings.join(";"), true);
}
return catalog;
}
function validateFontFile(file) {
const extension = file.name.includes(".")
? `.${file.name.split(".").at(-1).toLowerCase()}`
: "";
if (!acceptedFontExtensions.includes(extension)) {
return "type";
}
if (file.size > fontMaxUploadBytes) {
return "size";
}
return "";
}
function describeFontImportError(error) {
if (error?.code === "APP_STALE") {
return ["copy.dynamic.text.font_error_stale", "页面版本已过期,请刷新后再导入字体。"];
}
if (error?.code === "REQUEST_TIMEOUT") {
return ["copy.dynamic.text.font_error_timeout", "字体导入超时,请检查核桃派网络或服务状态后重试。"];
}
const messages = {
413: ["copy.dynamic.text.font_error_too_large", "字体导入失败:文件超过 32 MiB。"],
415: ["copy.dynamic.text.font_error_type", "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。"],
422: ["copy.dynamic.text.font_error_invalid", "字体导入失败:文件损坏或不包含可用字体。"],
507: ["copy.dynamic.text.font_error_storage", "字体导入失败:设备存储空间不足。"],
};
if (messages[error?.status]) return messages[error.status];
if (error instanceof TypeError) {
return ["copy.dynamic.text.font_error_network", "字体导入失败:无法连接设备,请检查网络后重试。"];
}
return ["copy.dynamic.text.font_error_unknown", "字体导入失败:服务器未能完成导入,请稍后重试。"];
}
importFontButton.addEventListener("click", () => fontFileInput.click());
fontFileInput.addEventListener("change", async () => {
const file = fontFileInput.files?.[0];
fontFileInput.value = "";
if (!file) return;
const validationError = validateFontFile(file);
if (validationError) {
const copy = validationError === "type"
? ["copy.dynamic.text.font_type_error", "只支持 TTF、OTF、TTC、OTC 字体文件。"]
: ["copy.dynamic.text.font_size_error", "字体文件不能超过 32 MiB。"];
setFontFeedbackTemplate(copy[0], copy[1], {}, true);
announce(fontFeedback.textContent, true);
return;
}
try {
setFontFeedbackTemplate(
"copy.dynamic.text.font_importing",
"正在导入字体“{name}”…",
{ name: file.name },
);
announce(fontFeedback.textContent);
const result = await runControlAction(
importFontButton,
{ label: "正在导入…" },
async () => apiFile(
`/api/fonts/import?filename=${encodeURIComponent(file.name)}`,
file,
{ method: "POST" },
),
);
await loadFontCatalog();
const selected = getSelectedTextElement();
const imported = result.fonts?.[0] || null;
if (selected && imported) {
fontPicker.setValue(imported.id);
updateTextElement(selected.id, { font: imported.id }, "form");
}
const name = imported?.label || file.name;
const feedback = result.created
? (selected
? ["copy.dynamic.text.font_imported_applied", "字体“{name}”已导入并应用到当前文字"]
: ["copy.dynamic.text.font_imported", "字体“{name}”已导入"])
: (selected
? ["copy.dynamic.text.font_exists_applied", "字体“{name}”已经存在,已应用到当前文字"]
: ["copy.dynamic.text.font_exists", "字体“{name}”已经存在"]);
setFontFeedbackTemplate(feedback[0], feedback[1], { name });
announce(fontFeedback.textContent);
} catch (error) {
const feedback = describeFontImportError(error);
setFontFeedbackTemplate(feedback[0], feedback[1], {}, true);
announce(fontFeedback.textContent, true);
}
});
async function initializeTextWorkspace() {
if (!initialized) {
initialized = true;
const [configResult, fontsResult] = await Promise.allSettled([
apiJson("/api/config"),
loadFontCatalog(),
]);
if (configResult.status === "fulfilled") {
const config = configResult.value;
defaults = {
font: config.default_font || "default",
size: Number(config.default_text_size) || 12,
};
} else {
announce(`默认文字设置读取失败,已使用内置默认值:${configResult.reason.message}`, true);
}
if (fontsResult.status === "rejected") {
fontCatalogReady = false;
updateFontPickerAvailability();
setFontFeedbackTemplate(
"copy.dynamic.text.font_catalog_failed",
"字体目录读取失败,当前字体值已保留:{error}",
{ error: fontsResult.reason.message },
true,
);
announce(fontFeedback.textContent, true);
}
}
if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
registerWorkspace({
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});
@@ -0,0 +1,345 @@
import { announce, apiJson } from "../core.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { createLatchedDraftState } from "../wifi-draft-state.js";
const ssidInput = document.getElementById("wifi-ssid");
const passwordInput = document.getElementById("wifi-password");
const dhcpToggle = document.getElementById("wifi-dhcp-enabled");
const dhcpState = document.getElementById("wifi-dhcp-state");
const dhcpRow = dhcpToggle.closest(".wifi-dhcp-row");
const manualFields = document.getElementById("wifi-manual-fields");
const addressInput = document.getElementById("wifi-address");
const gatewayInput = document.getElementById("wifi-gateway");
const prefixInput = document.getElementById("wifi-prefix");
const dnsInput = document.getElementById("wifi-dns");
const immediateButton = document.getElementById("wifi-apply-immediate");
const nextBootButton = document.getElementById("wifi-apply-next-boot");
const activeStatus = document.getElementById("wifi-active-status");
const feedback = document.getElementById("wifi-feedback");
const delayInput = document.getElementById("wifi-prompt-delay");
const delaySaveButton = document.getElementById("wifi-prompt-delay-save");
const delayFeedback = document.getElementById("wifi-prompt-feedback");
const networkFields = new Map([
["ssid", { control: ssidInput, marker: document.getElementById("wifi-ssid-modified"), highlight: ssidInput }],
["password", { control: passwordInput, marker: document.getElementById("wifi-password-modified"), highlight: passwordInput }],
["dhcp", { control: dhcpToggle, marker: document.getElementById("wifi-dhcp-modified"), highlight: dhcpRow }],
["address", { control: addressInput, marker: document.getElementById("wifi-address-modified"), highlight: addressInput }],
["gateway", { control: gatewayInput, marker: document.getElementById("wifi-gateway-modified"), highlight: gatewayInput }],
["prefix", { control: prefixInput, marker: document.getElementById("wifi-prefix-modified"), highlight: prefixInput }],
["dns", { control: dnsInput, marker: document.getElementById("wifi-dns-modified"), highlight: dnsInput }],
]);
const networkDraft = createLatchedDraftState(networkFields.keys());
const delayDraft = createLatchedDraftState(["prompt_delay_seconds"]);
let wifiPending = false;
let delayPending = false;
let settingsLoaded = false;
let loadGeneration = 0;
let baseline = null;
function setFeedback(message, state = "idle") {
feedback.textContent = message;
feedback.dataset.state = state;
feedback.hidden = !message;
}
function setDelayFeedback(message, state = "idle") {
delayFeedback.textContent = message;
delayFeedback.dataset.state = state;
delayFeedback.hidden = !message;
}
function showDhcpMode() {
const enabled = dhcpToggle.checked;
dhcpState.textContent = enabled ? "开启" : "关闭";
manualFields.hidden = enabled;
addressInput.required = !enabled;
gatewayInput.required = !enabled;
}
function splitDns(value) {
return value.split(/[,,\s]+/u).map((item) => item.trim()).filter(Boolean);
}
function showStatus(data) {
const active = data.active || {};
if (active.connected) {
activeStatus.textContent = `当前已连接:${active.ssid || "未知 WiFi"} · ${active.ipv4_address || "等待 IPv4"}`;
} else if (data.available === false) {
activeStatus.textContent = `网络状态不可用:${data.error || "NetworkManager 无响应"}`;
} else {
activeStatus.textContent = "当前未连接 WiFi";
}
}
function formStateFromResponse(data) {
const saved = data.saved || {};
return {
ssid: saved.ssid || "",
password: saved.password || "",
passwordConfigured: Boolean(saved.password_configured),
dhcp: (saved.ipv4_mode || "dhcp") === "dhcp",
address: saved.address || "",
gateway: saved.gateway || "",
prefix: saved.prefix ?? "",
dns: (saved.dns_servers || []).join(", "),
promptDelay: data.prompt_delay_seconds ?? 30,
};
}
function applyFormState(state) {
if (!state) return;
ssidInput.value = state.ssid;
passwordInput.value = state.password;
passwordInput.placeholder = state.passwordConfigured ? "" : "请输入 8..63 字节密码";
dhcpToggle.checked = state.dhcp;
addressInput.value = state.address;
gatewayInput.value = state.gateway;
prefixInput.value = state.prefix;
dnsInput.value = state.dns;
delayInput.value = String(state.promptDelay);
showDhcpMode();
}
function setNetworkMarker(key, modified) {
const field = networkFields.get(key);
field.marker.hidden = !modified;
field.highlight.classList.toggle("wifi-field-modified", modified);
}
function clearNetworkDraft() {
networkDraft.clear();
networkFields.forEach((_field, key) => setNetworkMarker(key, false));
updateNetworkButtons();
}
function clearDelayDraft() {
delayDraft.clear();
updateDelayButton();
}
function updateNetworkButtons() {
if (wifiPending) return;
const ready = settingsLoaded && networkDraft.dirty;
immediateButton.classList.toggle("wifi-action-ready", ready);
nextBootButton.classList.toggle("wifi-action-ready", ready);
if (ready) {
setControlReady(immediateButton);
setControlReady(nextBootButton);
return;
}
const reason = settingsLoaded
? "请先修改 WiFi 或 DHCP 设置,再选择生效方式。"
: "请等待当前 WiFi 设置读取完成。";
setControlUnavailable(immediateButton, reason);
setControlUnavailable(nextBootButton, reason);
}
function updateDelayButton() {
if (delayPending) return;
const ready = settingsLoaded && delayDraft.dirty;
delaySaveButton.classList.toggle("wifi-action-ready", ready);
if (ready) {
setControlReady(delaySaveButton);
return;
}
setControlUnavailable(
delaySaveButton,
settingsLoaded ? "请先修改网络提示等待时间。" : "请等待当前网络提示设置读取完成。",
);
}
function markNetworkField(key) {
if (!settingsLoaded) return;
networkDraft.mark(key);
setNetworkMarker(key, true);
updateNetworkButtons();
}
function markDelayField() {
if (!settingsLoaded) return;
delayDraft.mark("prompt_delay_seconds");
updateDelayButton();
}
function syncForm(data) {
baseline = formStateFromResponse(data);
applyFormState(baseline);
settingsLoaded = true;
clearNetworkDraft();
clearDelayDraft();
showStatus(data);
}
export async function loadWifiSettings() {
const generation = ++loadGeneration;
settingsLoaded = false;
clearNetworkDraft();
clearDelayDraft();
try {
const data = await apiJson("/api/network/wifi");
if (generation !== loadGeneration) return;
syncForm(data);
setFeedback("");
setDelayFeedback("");
} catch (error) {
if (generation !== loadGeneration) return;
setFeedback(`WiFi 设置读取失败:${error.message}`, "error");
announce(`WiFi 设置读取失败:${error.message}`, true);
updateNetworkButtons();
updateDelayButton();
}
}
function buildRequest(activation) {
const ssid = ssidInput.value.trim();
if (!ssid) throw new Error("请输入 WiFi 名称(SSID)");
const password = passwordInput.value;
if (ssid !== baseline.ssid && !password) throw new Error("修改 WiFi 名称时必须填写新密码");
if (!password && !baseline.passwordConfigured) throw new Error("当前没有已保存密码,请填写 WiFi 密码");
const manual = !dhcpToggle.checked;
if (manual && !addressInput.value.trim()) throw new Error("关闭 DHCP 后必须填写静态 IPv4 地址");
if (manual && !gatewayInput.value.trim()) throw new Error("关闭 DHCP 后必须填写网关");
const rawPrefix = prefixInput.value.trim();
const prefix = rawPrefix ? Number(rawPrefix) : null;
if (rawPrefix && (!Number.isInteger(prefix) || prefix < 1 || prefix > 32)) {
throw new Error("子网前缀必须是 1..32 的整数");
}
return {
ssid,
password: password || null,
ipv4_mode: manual ? "manual" : "dhcp",
address: manual ? addressInput.value.trim() : null,
gateway: manual ? gatewayInput.value.trim() : null,
prefix,
dns_servers: manual ? splitDns(dnsInput.value) : [],
activation,
};
}
function captureNetworkBaseline(request) {
baseline = {
...baseline,
ssid: request.ssid,
password: request.password || baseline.password,
passwordConfigured: Boolean(request.password || baseline.passwordConfigured),
dhcp: request.ipv4_mode === "dhcp",
address: request.address || "",
gateway: request.gateway || "",
prefix: request.prefix ?? "",
dns: (request.dns_servers || []).join(", "),
};
}
async function applyWifi(activation) {
if (wifiPending || !networkDraft.dirty) return;
let request;
try {
request = buildRequest(activation);
} catch (error) {
setFeedback(error.message, "error");
announce(error.message, true);
return;
}
wifiPending = true;
setControlBusy(immediateButton, {
label: activation === "immediate" ? "正在保存…" : undefined,
message: "正在安全保存 WiFi 与 IPv4 配置…",
});
setControlBusy(nextBootButton, {
label: activation === "next_boot" ? "正在保存…" : undefined,
});
setFeedback(
activation === "immediate"
? "正在保存;设备随后会切换网络,当前页面可能断开。"
: "正在保存下次断电开机使用的网络配置…",
"pending",
);
try {
const result = await apiJson("/api/network/wifi", {
method: "PUT",
body: JSON.stringify(request),
});
captureNetworkBaseline(request);
clearNetworkDraft();
setFeedback(result.message, "success");
announce(result.message);
if (result.network) showStatus(result.network);
} catch (error) {
setFeedback(`WiFi 设置保存失败:${error.message}`, "error");
announce(`WiFi 设置保存失败:${error.message}`, true);
} finally {
wifiPending = false;
updateNetworkButtons();
}
}
function validatedPromptDelay() {
const delay = Number(delayInput.value);
if (!Number.isInteger(delay) || delay < 1 || delay > 3600) {
throw new Error("网络提示等待时间必须是 1..3600 秒的整数");
}
return delay;
}
async function savePromptDelay() {
if (delayPending || !delayDraft.dirty) return;
let delay;
try {
delay = validatedPromptDelay();
} catch (error) {
setDelayFeedback(error.message, "error");
announce(error.message, true);
return;
}
delayPending = true;
setControlBusy(delaySaveButton, {
label: "正在保存…",
message: "正在保存网络提示等待时间…",
});
setDelayFeedback("正在保存网络提示等待时间…", "pending");
try {
const result = await apiJson("/api/network/wifi/prompt-delay", {
method: "PUT",
body: JSON.stringify({ prompt_delay_seconds: delay }),
});
baseline = { ...baseline, promptDelay: result.prompt_delay_seconds };
delayInput.value = String(result.prompt_delay_seconds);
clearDelayDraft();
setDelayFeedback(result.message, "success");
announce(result.message);
} catch (error) {
setDelayFeedback(`网络提示等待时间保存失败:${error.message}`, "error");
announce(`网络提示等待时间保存失败:${error.message}`, true);
} finally {
delayPending = false;
updateDelayButton();
}
}
export function discardWifiSettingsDrafts() {
loadGeneration += 1;
applyFormState(baseline);
settingsLoaded = false;
clearNetworkDraft();
clearDelayDraft();
setFeedback("");
setDelayFeedback("");
}
for (const [key, field] of networkFields) {
if (key === "dhcp") continue;
field.control.addEventListener("input", () => markNetworkField(key));
}
dhcpToggle.addEventListener("change", () => {
showDhcpMode();
markNetworkField("dhcp");
});
delayInput.addEventListener("input", markDelayField);
immediateButton.addEventListener("click", () => applyWifi("immediate"));
nextBootButton.addEventListener("click", () => applyWifi("next_boot"));
delaySaveButton.addEventListener("click", savePromptDelay);
updateNetworkButtons();
updateDelayButton();
@@ -0,0 +1,25 @@
export function createLatchedDraftState(allowedKeys) {
const allowed = new Set(allowedKeys);
const modified = new Set();
return Object.freeze({
mark(key) {
if (!allowed.has(key)) throw new Error(`Unknown draft field: ${key}`);
const wasModified = modified.has(key);
modified.add(key);
return !wasModified;
},
clear() {
modified.clear();
},
has(key) {
return modified.has(key);
},
get dirty() {
return modified.size > 0;
},
keys() {
return [...modified];
},
});
}
@@ -0,0 +1,34 @@
export function resolveWorkspaceOrder(definitions, savedOrder) {
const defaults = Array.from(definitions || []);
const byId = new Map(defaults.map((definition) => [definition.id, definition]));
const resolved = [];
const seen = new Set();
if (Array.isArray(savedOrder)) {
savedOrder.forEach((workspaceId) => {
if (seen.has(workspaceId) || !byId.has(workspaceId)) return;
seen.add(workspaceId);
resolved.push(byId.get(workspaceId));
});
}
defaults.forEach((definition) => {
if (seen.has(definition.id)) return;
seen.add(definition.id);
resolved.push(definition);
});
return resolved;
}
export function moveWorkspaceOrder(workspaceIds, index, offset) {
if (!Array.isArray(workspaceIds) || !Number.isInteger(index) || !Number.isInteger(offset)) {
return null;
}
const target = index + offset;
if (index < 0 || index >= workspaceIds.length || target < 0 || target >= workspaceIds.length) {
return null;
}
const moved = [...workspaceIds];
[moved[index], moved[target]] = [moved[target], moved[index]];
return moved;
}
@@ -0,0 +1 @@
"""Host-system controls used by the matrix service."""
@@ -0,0 +1,257 @@
from __future__ import annotations
import argparse
import json
import os
import threading
from collections.abc import Callable
from pathlib import Path
from typing import Any
class PerformanceModeError(RuntimeError):
"""Raised when cpufreq policies cannot be changed safely."""
class PerformanceModeManager:
RECORD_NAME = "cpufreq-governors.original.json"
def __init__(
self,
runtime_root: Path,
*,
sysfs_root: Path | None = None,
) -> None:
configured_root = os.environ.get("MATRIX_CPUFREQ_SYSFS_ROOT")
self.sysfs_root = Path(
sysfs_root
if sysfs_root is not None
else configured_root or "/sys/devices/system/cpu/cpufreq"
)
self.runtime_root = Path(runtime_root)
self.record_path = self.runtime_root / self.RECORD_NAME
self._lock = threading.RLock()
self._restore_governors: dict[str, str] = {}
self._requested = False
self._last_error: str | None = None
self._load_or_capture_baseline()
def _policy_paths(self) -> dict[str, Path]:
if not self.sysfs_root.is_dir():
return {}
return {
path.name: path
for path in sorted(self.sysfs_root.glob("policy*"), key=lambda item: item.name)
if path.is_dir()
}
@staticmethod
def _read_text(path: Path) -> str:
return path.read_text(encoding="ascii").strip()
@staticmethod
def _write_text(path: Path, value: str) -> None:
path.write_text(f"{value}\n", encoding="ascii")
def _read_governors(self) -> dict[str, str]:
return {
name: self._read_text(path / "scaling_governor")
for name, path in self._policy_paths().items()
}
def _available_governors(self) -> dict[str, set[str]]:
result: dict[str, set[str]] = {}
for name, path in self._policy_paths().items():
available_path = path / "scaling_available_governors"
try:
result[name] = set(self._read_text(available_path).split())
except OSError:
result[name] = set()
return result
def _write_record(self, governors: dict[str, str]) -> None:
self.runtime_root.mkdir(parents=True, exist_ok=True)
temporary = self.record_path.with_suffix(".tmp")
payload = {
"schema_version": 1,
"governors": governors,
}
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, self.record_path)
def _read_record(self) -> dict[str, str] | None:
if not self.record_path.is_file():
return None
try:
payload = json.loads(self.record_path.read_text(encoding="utf-8"))
governors = payload.get("governors")
if payload.get("schema_version") != 1 or not isinstance(governors, dict):
raise ValueError("invalid governor record")
checked = {
str(name): str(value)
for name, value in governors.items()
if str(name).startswith("policy") and str(value).strip()
}
if checked != governors:
raise ValueError("invalid governor record fields")
return checked
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
self._last_error = f"saved governor record is invalid: {exc}"
return None
def _load_or_capture_baseline(self) -> None:
with self._lock:
policies = self._policy_paths()
if not policies:
return
saved = self._read_record()
if saved is not None:
if set(saved) == set(policies):
self._restore_governors = saved
return
self._last_error = "saved governor policies do not match current cpufreq policies"
return
try:
current = self._read_governors()
self._write_record(current)
self._restore_governors = current
except OSError as exc:
self._last_error = f"could not capture original governors: {exc}"
def _set_governors(self, requested: dict[str, str]) -> None:
policies = self._policy_paths()
if set(requested) != set(policies):
raise PerformanceModeError("cpufreq policies changed while applying governor")
before = self._read_governors()
changed: list[str] = []
try:
for name, value in requested.items():
self._write_text(policies[name] / "scaling_governor", value)
changed.append(name)
actual = self._read_text(policies[name] / "scaling_governor")
if actual != value:
raise PerformanceModeError(
f"{name} governor is {actual!r} after requesting {value!r}"
)
except Exception as exc:
rollback_errors: list[str] = []
for name in reversed(changed):
try:
self._write_text(policies[name] / "scaling_governor", before[name])
except OSError as rollback_exc:
rollback_errors.append(f"{name}: {rollback_exc}")
suffix = f"; rollback failed for {', '.join(rollback_errors)}" if rollback_errors else ""
raise PerformanceModeError(f"could not apply cpufreq governor: {exc}{suffix}") from exc
def available(self) -> bool:
policies = self._policy_paths()
if not policies or set(self._restore_governors) != set(policies):
return False
available = self._available_governors()
return all("performance" in values for values in available.values())
def start(self, requested: bool) -> None:
with self._lock:
self._requested = requested
if not requested:
if self._restore_governors and self._read_governors() != self._restore_governors:
try:
self._set_governors(self._restore_governors)
self._last_error = None
except PerformanceModeError as exc:
self._last_error = str(exc)
return
try:
self._apply_requested(True)
self._last_error = None
except PerformanceModeError as exc:
self._last_error = str(exc)
def _apply_requested(self, requested: bool) -> None:
if requested:
if not self.available():
raise PerformanceModeError("performance governor is unavailable")
target = {name: "performance" for name in self._policy_paths()}
else:
if not self._restore_governors:
raise PerformanceModeError("original governors are unavailable")
target = dict(self._restore_governors)
self._set_governors(target)
def transact(self, requested: bool, persist: Callable[[bool], Any]) -> None:
with self._lock:
previous_requested = self._requested
previous_governors = self._read_governors()
try:
self._apply_requested(requested)
persist(requested)
except Exception as exc:
try:
if previous_governors:
self._set_governors(previous_governors)
except Exception as rollback_exc:
self._last_error = f"{exc}; governor rollback failed: {rollback_exc}"
raise PerformanceModeError(self._last_error) from exc
self._requested = previous_requested
self._last_error = str(exc)
if isinstance(exc, PerformanceModeError):
raise
raise PerformanceModeError(f"performance mode transaction failed: {exc}") from exc
self._requested = requested
self._last_error = None
def restore(self, *, remove_record: bool = False) -> None:
with self._lock:
if self._restore_governors:
try:
self._set_governors(self._restore_governors)
self._last_error = None
except PerformanceModeError as exc:
self._last_error = str(exc)
raise
if remove_record and self.record_path.exists():
self.record_path.unlink()
def status(self, requested: bool | None = None) -> dict[str, Any]:
with self._lock:
current: dict[str, str] = {}
try:
current = self._read_governors()
except OSError as exc:
self._last_error = f"could not read current governors: {exc}"
effective_requested = self._requested if requested is None else requested
effective = bool(
effective_requested
and current
and all(value == "performance" for value in current.values())
)
return {
"requested": bool(effective_requested),
"available": self.available(),
"effective": effective,
"current_governors": current,
"restore_governors": dict(self._restore_governors),
"last_error": self._last_error,
}
def _restore_cli() -> int:
parser = argparse.ArgumentParser(description="Restore matrix service cpufreq governors")
parser.add_argument("restore", nargs="?")
parser.add_argument("--runtime-root", type=Path, required=True)
parser.add_argument("--sysfs-root", type=Path)
args = parser.parse_args()
manager = PerformanceModeManager(args.runtime_root, sysfs_root=args.sysfs_root)
try:
manager.restore(remove_record=True)
except PerformanceModeError as exc:
parser.error(str(exc))
return 0
if __name__ == "__main__":
raise SystemExit(_restore_cli())
@@ -0,0 +1,2 @@
"""Editable scene template persistence and thumbnail rendering."""
@@ -0,0 +1,631 @@
from __future__ import annotations
import base64
import binascii
import errno
import hashlib
import json
import logging
import re
import shutil
import threading
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from typing import Any, Callable
from uuid import UUID, uuid4
from PIL import Image
from app.display.colors import parse_color
from app.display.text_renderer import FontResolver, TextOptions, render_text_layer
from app.persistence import atomic_write_bytes, atomic_write_many_bytes
logger = logging.getLogger(__name__)
SCENE_WIDTH = 64
SCENE_HEIGHT = 64
RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3
NAME_LIMIT = 80
TEMPLATE_SCHEMA_VERSION = 1
TEMPLATE_RECORD_FIELDS = frozenset({
"id",
"name",
"created_at",
"updated_at",
"digest",
"thumbnail",
"scene",
})
SCENE_FIELDS = frozenset({"version", "width", "height", "pixelRgb", "elements"})
TEXT_ELEMENT_FIELDS = frozenset({
"id",
"type",
"text",
"font",
"size",
"x",
"y",
"align",
"color",
})
HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
THUMBNAIL_RE = re.compile(
r"^(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<digest>[0-9a-f]{16})\.png$"
)
class TemplateError(RuntimeError):
"""Base error for template operations."""
class TemplateValidationError(TemplateError):
pass
class TemplateConflictError(TemplateError):
pass
class TemplateNotFoundError(TemplateError):
pass
class TemplateStorageFullError(TemplateError):
pass
def _migrate_template_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
return dict(document)
TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1}
def _is_record(value: Any) -> bool:
return isinstance(value, dict)
def normalize_name(value: Any) -> str:
if not isinstance(value, str):
raise TemplateValidationError("template name must be a string")
name = value.strip()
if not name or len(name) > NAME_LIMIT:
raise TemplateValidationError(f"template name must contain 1..{NAME_LIMIT} characters")
return name
def normalize_template_id(value: Any) -> str:
try:
return str(UUID(str(value)))
except (ValueError, TypeError, AttributeError) as exc:
raise TemplateNotFoundError("template not found") from exc
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
if not _is_record(value):
raise TemplateValidationError("scene must be an object")
if strict_schema:
unknown = set(value) - SCENE_FIELDS
missing = SCENE_FIELDS - set(value)
if unknown or missing:
raise TemplateValidationError("scene fields do not match the current schema")
for field in ("version", "width", "height"):
if type(value[field]) is not int:
raise TemplateValidationError(f"scene {field} must be an integer")
if value.get("version") != 1 or value.get("width") != 64 or value.get("height") != 64:
raise TemplateValidationError("scene must be version 1 with a 64x64 frame")
pixel_rgb = value.get("pixelRgb")
if not isinstance(pixel_rgb, str):
raise TemplateValidationError("scene pixelRgb must be base64 RGB888")
try:
decoded = base64.b64decode(pixel_rgb, validate=True)
except (ValueError, binascii.Error) as exc:
raise TemplateValidationError("scene pixelRgb must be valid base64 RGB888") from exc
if len(decoded) != RGB_BYTE_LENGTH:
raise TemplateValidationError("scene pixelRgb must contain 64x64 RGB888 bytes")
raw_elements = value.get("elements")
if not isinstance(raw_elements, list):
raise TemplateValidationError("scene elements must be an array")
elements: list[dict[str, Any]] = []
ids: set[str] = set()
for raw in raw_elements:
if not _is_record(raw) or raw.get("type") != "text":
raise TemplateValidationError("scene contains an unsupported element type")
if strict_schema:
unknown = set(raw) - TEXT_ELEMENT_FIELDS
missing = TEXT_ELEMENT_FIELDS - set(raw)
if unknown or missing:
raise TemplateValidationError("text element fields do not match the current schema")
for field in ("size", "x", "y"):
if type(raw[field]) is not int:
raise TemplateValidationError(f"text element {field} must be an integer")
element_id = raw.get("id")
if not isinstance(element_id, str) or not element_id.strip() or len(element_id.strip()) > 128:
raise TemplateValidationError("scene element id must contain 1..128 characters")
element_id = element_id.strip()
if element_id in ids:
raise TemplateValidationError("scene element ids must be unique")
ids.add(element_id)
text = raw.get("text")
font = raw.get("font")
align = raw.get("align")
color = raw.get("color")
if not isinstance(text, str) or len(text) > 512:
raise TemplateValidationError("text element text must contain at most 512 characters")
if not isinstance(font, str) or not font.strip() or len(font.strip()) > 512:
raise TemplateValidationError("text element font must contain 1..512 characters")
if align not in {"left", "center", "right"}:
raise TemplateValidationError("text element align must be left, center, or right")
if not isinstance(color, str) or not HEX_COLOR_RE.fullmatch(color.strip()):
raise TemplateValidationError("text element color must be #RRGGBB")
try:
size = int(raw.get("size"))
x = int(raw.get("x"))
y = int(raw.get("y"))
except (TypeError, ValueError) as exc:
raise TemplateValidationError("text element size and coordinates must be integers") from exc
if not 1 <= size <= 64:
raise TemplateValidationError("text element size must be in 1..64")
if not -128 <= x <= 128 or not -128 <= y <= 128:
raise TemplateValidationError("text element coordinates must be in -128..128")
elements.append({
"id": element_id,
"type": "text",
"text": text,
"font": font.strip(),
"size": size,
"x": x,
"y": y,
"align": align,
"color": color.strip().upper(),
})
return {
"version": 1,
"width": 64,
"height": 64,
"pixelRgb": base64.b64encode(decoded).decode("ascii"),
"elements": elements,
}
def scene_digest(scene: dict[str, Any]) -> str:
canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def record_revision(record: dict[str, Any]) -> str:
canonical = json.dumps(
{
"id": record["id"],
"name": record["name"],
"created_at": record["created_at"],
"updated_at": record["updated_at"],
"digest": record["digest"],
"scene": record["scene"],
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
ThumbnailRenderer = Callable[[Image.Image, dict[str, Any], FontResolver | None], Image.Image]
def _render_text(
base: Image.Image,
element: dict[str, Any],
font_resolver: FontResolver | None = None,
) -> Image.Image:
layer = render_text_layer(TextOptions(
text=element["text"],
font=element["font"],
size=element["size"],
x=element["x"],
y=element["y"],
align=element["align"],
color=parse_color(element["color"]),
), size=(SCENE_WIDTH, SCENE_HEIGHT), font_resolver=font_resolver)
return Image.alpha_composite(base, layer)
ELEMENT_RENDERERS: dict[str, ThumbnailRenderer] = {"text": _render_text}
def render_scene_image(
scene: dict[str, Any],
*,
font_resolver: FontResolver | None = None,
) -> Image.Image:
checked = validate_scene(scene)
pixels = base64.b64decode(checked["pixelRgb"], validate=True)
image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA")
for element in checked["elements"]:
renderer = ELEMENT_RENDERERS.get(element["type"])
if renderer is None:
raise TemplateValidationError(f"no thumbnail renderer for {element['type']}")
image = renderer(image, element, font_resolver)
return image.convert("RGB")
def render_scene_thumbnail(
scene: dict[str, Any],
*,
font_resolver: FontResolver | None = None,
) -> bytes:
image = render_scene_image(scene, font_resolver=font_resolver)
output = BytesIO()
image.save(output, format="PNG", optimize=True)
return output.getvalue()
class TemplateStore:
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None) -> None:
self.root = Path(data_dir) / "templates"
self._font_resolver = font_resolver
self.records_dir = self.root / "records"
self.thumbnails_dir = self.root / "thumbnails"
self._lock = threading.RLock()
self.records_dir.mkdir(parents=True, exist_ok=True)
self.thumbnails_dir.mkdir(parents=True, exist_ok=True)
with self._lock:
records, migrations = self._preflight_records()
self._write_migrations_atomically(migrations)
self._reconcile(records)
def _record_path(self, template_id: str) -> Path:
return self.records_dir / f"{template_id}.json"
def _thumbnail_path(self, template_id: str, digest: str) -> Path:
return self.thumbnails_dir / f"{template_id}-{digest}.png"
@staticmethod
def _timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
@staticmethod
def _json_bytes(record: dict[str, Any]) -> bytes:
persisted = {"schema_version": TEMPLATE_SCHEMA_VERSION, **record}
return (json.dumps(persisted, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
@staticmethod
def _translate_write_error(exc: OSError) -> None:
if exc.errno == errno.ENOSPC:
raise TemplateStorageFullError("not enough disk space to save template") from exc
raise TemplateError(str(exc)) from exc
def _write_bytes_atomic(self, path: Path, content: bytes) -> None:
try:
atomic_write_bytes(path, content)
except OSError as exc:
self._translate_write_error(exc)
def _write_migrations_atomically(
self,
migrations: list[tuple[Path, dict[str, Any]]],
) -> None:
if not migrations:
return
try:
atomic_write_many_bytes(
(path, self._json_bytes(record))
for path, record in migrations
)
except OSError as exc:
self._translate_write_error(exc)
def _write_thumbnail(self, template_id: str, digest: str, scene: dict[str, Any]) -> Path:
path = self._thumbnail_path(template_id, digest)
if path.exists():
return path
content = render_scene_thumbnail(scene, font_resolver=self._font_resolver)
self._write_bytes_atomic(path, content)
try:
with Image.open(path) as image:
image.load()
if image.size != (64, 64) or image.format != "PNG":
raise TemplateError("generated thumbnail is invalid")
except Exception:
path.unlink(missing_ok=True)
raise
return path
def _parse_record_document(
self,
path: Path,
raw: Any,
) -> tuple[dict[str, Any], bool]:
if not _is_record(raw):
raise TemplateValidationError("template record must be an object")
document = dict(raw)
if "schema_version" in document:
version = document.pop("schema_version")
if type(version) is not int:
raise TemplateValidationError("template schema_version must be an integer")
else:
version = 0
if version > TEMPLATE_SCHEMA_VERSION:
raise TemplateValidationError(
f"template schema version {version} is newer than supported version "
f"{TEMPLATE_SCHEMA_VERSION}"
)
if version < 0:
raise TemplateValidationError(f"unsupported template schema version {version}")
source_version = version
while version < TEMPLATE_SCHEMA_VERSION:
migrator = TEMPLATE_MIGRATIONS.get(version)
if migrator is None:
raise TemplateValidationError(
f"no template migration is registered for version {version}"
)
document = migrator(document)
version += 1
unknown = set(document) - TEMPLATE_RECORD_FIELDS
if unknown:
raise TemplateValidationError(
f"unknown template fields: {', '.join(sorted(unknown))}"
)
missing = TEMPLATE_RECORD_FIELDS - set(document)
if missing:
raise TemplateValidationError(
f"template record is missing fields: {', '.join(sorted(missing))}"
)
try:
template_id = normalize_template_id(document.get("id"))
if path.stem != template_id:
raise TemplateValidationError("template id does not match filename")
name = normalize_name(document.get("name"))
scene = validate_scene(
document.get("scene"),
strict_schema=source_version == TEMPLATE_SCHEMA_VERSION,
)
digest = scene_digest(scene)
if (
document.get("digest") != digest
or document.get("thumbnail") != f"{template_id}-{digest}.png"
):
raise TemplateValidationError("template digest is invalid")
created_at = str(document.get("created_at", ""))
updated_at = str(document.get("updated_at", ""))
if not created_at or not updated_at:
raise TemplateValidationError("template timestamps are missing")
record = {
"id": template_id,
"name": name,
"created_at": created_at,
"updated_at": updated_at,
"digest": digest,
"thumbnail": document["thumbnail"],
"scene": scene,
}
except TemplateValidationError:
raise
except TemplateError as exc:
raise TemplateValidationError(str(exc)) from exc
except Exception as exc:
raise TemplateValidationError("template record is invalid") from exc
if source_version == TEMPLATE_SCHEMA_VERSION and record != document:
raise TemplateValidationError(
f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}"
)
return record, source_version != TEMPLATE_SCHEMA_VERSION
def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]:
try:
raw = json.loads(path.read_bytes().decode("utf-8"))
return self._parse_record_document(path, raw)
except TemplateValidationError as exc:
raise TemplateValidationError(f"invalid template record {path.name}: {exc}") from exc
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise TemplateValidationError(f"invalid template record {path.name}: unreadable JSON") from exc
def _read_record_path(self, path: Path) -> dict[str, Any] | None:
if not path.exists():
return None
record, migrated = self._load_record_path(path)
if migrated:
self._write_bytes_atomic(path, self._json_bytes(record))
return record
def _preflight_records(
self,
) -> tuple[list[dict[str, Any]], list[tuple[Path, dict[str, Any]]]]:
records: list[dict[str, Any]] = []
migrations: list[tuple[Path, dict[str, Any]]] = []
for path in sorted(self.records_dir.glob("*.json")):
record, migrated = self._load_record_path(path)
records.append(record)
if migrated:
migrations.append((path, record))
return records, migrations
def _records(self) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for path in sorted(self.records_dir.glob("*.json")):
record = self._read_record_path(path)
if record is not None:
records.append(record)
return records
def _reconcile(self, records: list[dict[str, Any]] | None = None) -> None:
if records is None:
records = self._records()
expected: set[str] = set()
for record in records:
expected.add(record["thumbnail"])
path = self.thumbnails_dir / record["thumbnail"]
if not path.exists():
try:
self._write_thumbnail(record["id"], record["digest"], record["scene"])
except Exception:
logger.exception("Failed to rebuild template thumbnail: %s", record["id"])
for path in self.thumbnails_dir.iterdir():
if not path.is_file():
continue
if path.name.startswith(".") and path.name.endswith(".tmp"):
path.unlink(missing_ok=True)
elif THUMBNAIL_RE.fullmatch(path.name) and path.name not in expected:
path.unlink(missing_ok=True)
def _ensure_unique_name(self, name: str, exclude_id: str | None = None) -> None:
key = name.casefold()
if any(record["id"] != exclude_id and record["name"].casefold() == key for record in self._records()):
raise TemplateConflictError("template name already exists")
def _metadata(self, record: dict[str, Any]) -> dict[str, Any]:
record_path = self._record_path(record["id"])
thumbnail_path = self.thumbnails_dir / record["thumbnail"]
size_bytes = (record_path.stat().st_size if record_path.exists() else 0)
size_bytes += thumbnail_path.stat().st_size if thumbnail_path.exists() else 0
return {
"id": record["id"],
"name": record["name"],
"created_at": record["created_at"],
"updated_at": record["updated_at"],
"digest": record["digest"],
"revision": record_revision(record),
"thumbnail_url": f"/api/templates/{record['id']}/thumbnail?v={record['digest']}",
"size_bytes": size_bytes,
}
def list(self) -> dict[str, Any]:
with self._lock:
records = sorted(self._records(), key=lambda item: item["updated_at"], reverse=True)
templates = [self._metadata(record) for record in records]
usage = shutil.disk_usage(self.root)
return {
"templates": templates,
"storage": {
"templates_bytes": sum(item["size_bytes"] for item in templates),
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
},
}
def get(self, template_id: Any) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id)
with self._lock:
record = self._read_record_path(self._record_path(normalized_id))
if record is None:
raise TemplateNotFoundError("template not found")
return {**self._metadata(record), "scene": record["scene"]}
def create(self, name: Any, scene: Any) -> dict[str, Any]:
checked_name = normalize_name(name)
checked_scene = validate_scene(scene)
with self._lock:
self._ensure_unique_name(checked_name)
template_id = str(uuid4())
timestamp = self._timestamp()
digest = scene_digest(checked_scene)
record = {
"id": template_id,
"name": checked_name,
"created_at": timestamp,
"updated_at": timestamp,
"digest": digest,
"thumbnail": f"{template_id}-{digest}.png",
"scene": checked_scene,
}
try:
self._write_thumbnail(template_id, digest, checked_scene)
self._write_bytes_atomic(self._record_path(template_id), self._json_bytes(record))
except Exception:
self._thumbnail_path(template_id, digest).unlink(missing_ok=True)
raise
self._reconcile()
return {**self._metadata(record), "scene": record["scene"]}
@staticmethod
def _ensure_revision(record: dict[str, Any], expected_revision: str) -> None:
if record_revision(record) != expected_revision:
raise TemplateConflictError("template was changed by another client")
def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id)
checked_scene = validate_scene(scene)
with self._lock:
current = self._read_record_path(self._record_path(normalized_id))
if current is None:
raise TemplateNotFoundError("template not found")
self._ensure_revision(current, expected_revision)
digest = scene_digest(checked_scene)
updated = {
**current,
"updated_at": self._timestamp(),
"digest": digest,
"thumbnail": f"{normalized_id}-{digest}.png",
"scene": checked_scene,
}
self._write_thumbnail(normalized_id, digest, checked_scene)
try:
self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated))
except Exception:
if digest != current["digest"]:
self._thumbnail_path(normalized_id, digest).unlink(missing_ok=True)
raise
self._reconcile()
return {**self._metadata(updated), "scene": updated["scene"]}
def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id)
checked_name = normalize_name(name)
with self._lock:
current = self._read_record_path(self._record_path(normalized_id))
if current is None:
raise TemplateNotFoundError("template not found")
self._ensure_revision(current, expected_revision)
self._ensure_unique_name(checked_name, exclude_id=normalized_id)
updated = {**current, "name": checked_name, "updated_at": self._timestamp()}
self._write_bytes_atomic(self._record_path(normalized_id), self._json_bytes(updated))
self._reconcile()
return {**self._metadata(updated), "scene": updated["scene"]}
def copy(self, template_id: Any) -> dict[str, Any]:
current = self.get(template_id)
with self._lock:
base = f"{current['name']} - 副本"
candidate = base
sequence = 2
names = {record["name"].casefold() for record in self._records()}
while candidate.casefold() in names:
candidate = f"{base} {sequence}"
sequence += 1
return self.create(candidate, current["scene"])
def delete(self, template_id: Any, expected_revision: str) -> None:
normalized_id = normalize_template_id(template_id)
with self._lock:
path = self._record_path(normalized_id)
current = self._read_record_path(path)
if current is None:
raise TemplateNotFoundError("template not found")
self._ensure_revision(current, expected_revision)
try:
path.unlink()
for thumbnail in self.thumbnails_dir.glob(f"{normalized_id}-*.png"):
if THUMBNAIL_RE.fullmatch(thumbnail.name):
thumbnail.unlink(missing_ok=True)
except OSError as exc:
self._translate_write_error(exc)
self._reconcile()
def thumbnail_path(self, template_id: Any, digest: str | None = None) -> Path:
normalized_id = normalize_template_id(template_id)
with self._lock:
record = self._read_record_path(self._record_path(normalized_id))
if record is None:
raise TemplateNotFoundError("template not found")
if digest is not None and digest != record["digest"]:
raise TemplateNotFoundError("template thumbnail not found")
path = self.thumbnails_dir / record["thumbnail"]
if not path.exists():
raise TemplateNotFoundError("template thumbnail not found")
return path
@@ -0,0 +1,2 @@
"""Build-gated UI copy editor support."""
@@ -0,0 +1,3 @@
"""This flag is changed only when producing a dedicated editing build."""
UI_COPY_EDITOR_BUILD_ENABLED = False

Some files were not shown because too many files have changed in this diff Show More