commit 8d368de3b5c73b5d474f11954a80ba45568891d8 Author: TIAN <1846195334@qq.com> Date: Tue Sep 8 22:56:52 2026 +0800 初始化奇妙小屏幕控制器项目 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f6ef4f0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +* text=auto +*.sh text eol=lf +*.service text eol=lf +*.conf text eol=lf +*.py text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.json text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.tsv text eol=lf +*.ps1 text eol=crlf +*.img filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.gz -text +*.ota filter=lfs diff=lfs merge=lfs -text +*.exe filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.deb filter=lfs diff=lfs merge=lfs -text +*.whl filter=lfs diff=lfs merge=lfs -text +*.pdf -text +*.docx -text +*.png -text +*.jpg -text +*.jpeg -text +*.gif -text +*.ttf -text +*.otf -text +*.woff -text +*.woff2 -text +*.tar.gz filter=lfs diff=lfs merge=lfs -text diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100644 index 0000000..5abf8ed --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100644 index 0000000..b8b76c2 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100644 index 0000000..726f909 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..99c3b47 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +exec python "$repo_root/核桃派软件源代码/scripts/check_repository_hygiene.py" --staged diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..5f26dc4 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..797d52f --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Private device and workstation credentials. Keep the example next to it tracked. +/测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt + +# Local secrets and private keys. +.env +.env.* +!.env.example +*.local +*.local.* +!*.example +!*.example.* +*.pem +*.key +id_rsa +id_rsa.* +id_ed25519 +id_ed25519.* +known_hosts + +# Editors and operating systems. +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +Desktop.ini + +# Python, Node.js and test caches. +**/__pycache__/ +*.py[cod] +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ +**/.coverage +**/coverage.xml +**/node_modules/ + +# Repository-local environments and reproducible runtime data. +/核桃派软件源代码/.venv/ +/核桃派软件源代码/data/ +/测试相关资料/如何测试/本机测试环境/.venv/ +/测试相关资料/如何测试/本机测试环境/data/ +/测试相关资料/如何测试/本机测试环境/work/ +/.playwright-cli/ +/output/playwright/ + +# Image editor build caches. Released executables remain tracked elsewhere. +/发布更新相关/镜像编辑器/编辑器源代码/.build-venv/ +/发布更新相关/镜像编辑器/编辑器源代码/.build/ +/发布更新相关/镜像编辑器/编辑器源代码/build/ +/发布更新相关/镜像编辑器/编辑器源代码/dist/ +/发布更新相关/镜像编辑器/编辑器源代码/*.spec + +# Intentionally not ignored: 发布更新相关/核桃派镜像, 导出包, OTA数据包, +# 其他依赖, 镜像编辑器/编辑器程序, and 各种归档. This private repository +# stores those release and historical artifacts as ordinary Git objects. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c6fff6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# 奇妙小屏幕控制器(核桃派 ZeroW)协作指南 + +本目录是可以单独拿走、独立开发和部署的核桃派项目。处理任务前先读本文件,再读 `整体开发需求/`、`测试相关资料/如何测试/` 和与任务有关的硬件资料。本项目不得依赖外层移植目录、原树莓派工作区、旧设备配置或旧设备凭据。 + +## 1. 固定硬件和边界 + +- 主控:核桃派 ZeroW,H618,Debian 12 Server。 +- 屏幕:单块 `RGB-Matrix-P3-64x64-F`,HUB75,64×64,1/32 扫描,ABCDE 行地址。 +- 真实驱动:项目自带 `walnutpi-h618-hub75` C 驱动,通过 `/dev/mem` 访问 H618 PI bank;不得回退或依赖 `rpi-rgb-led-matrix`。 +- ADC:M5Stack Unit ADC v1.1 / ADS1110,`/dev/i2c-1`,地址 `0x48`。 +- 生产程序、持久数据和运行数据分别位于 `/opt/matrix-screen-controller`、`/var/lib/matrix-screen-controller`、`/run/matrix-screen-controller`。 +- 当前设备和开发机凭据只保存在 `测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt`,不得复制到源码、日志、普通文档或回复。正式 IMG 的发布目录 `README.md` 是唯一例外:它必须写明该未修改镜像内故意公开的默认账户与 Wi-Fi 四项值,并警告用户修改;不得把当前设备或开发机凭据误写为镜像默认值。 + +### 1.1 私有调试凭据门禁 + +- `测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt` 是可提交示例;同目录 `用户名密码ip.txt` 是被根 `.gitignore` 精确排除的唯一真实调试记录。不得把真实值写回示例。 +- 凡 SSH、远端命令、实机部署、镜像定制、网络登录或其他需要设备身份的任务,先从仓库根目录运行 `python "测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py"`。不要先读取、回显或自行猜测凭据。 +- 私有文件不存在时,准备脚本只从示例创建副本并停止。agent 必须暂停当前远端步骤,明确让用户编辑仓库相对路径 `测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt`,等待用户确认后重新运行检查;不得在示例值未替换时继续。 +- 私有文件已存在时不得覆盖、重建或格式化。检查失败只报告缺失字段名或格式问题,不输出字段值;聊天、命令行、日志、测试夹具、补丁和提交摘要中均不得出现真实值。 +- 提交前运行 `python "核桃派软件源代码/scripts/check_repository_hygiene.py"`。发现真实凭据进入 Git 候选、开发机绝对路径、私钥或高可信秘密时必须停止,不得用扩大忽略范围掩盖本应修正的公开文件。 + +## 2. 需求与测试编号 + +- `WEB-*`:网页、REST、WebSocket、交互和安全入口。 +- `DISPLAY-*`:显示服务、帧、方向、字体、动画、mock 与 H618 真实驱动。 +- `CONFIG-*`:schema、默认值、迁移、持久数据和损坏保护。 +- `DEPLOY-*`:编译、systemd、权限、离线部署、更新和清理。 +- `HW-*`:核桃派排针、HUB75、ADC、供电和扫描参数。 +- `TEST-*`:本机、板端无负载、实屏、ADC、重启、清理和独立性验收。 + +新增或改变行为时,先更新 `整体开发需求/` 的稳定编号,再改 `核桃派软件源代码/`,最后同步 `测试相关资料/如何测试/` 的映射和合格标准。 + +## 3. 分层验证 + +- 本机 mock 验证确定性业务、REST、WebSocket、配置、字体、模板、动图和前端测试。 +- 核桃派无负载验证编译、寄存器映射、线程调度、双缓冲、截止统计和异常清屏;在该门槛通过前禁止接屏。 +- HUB75 实屏测试按全黑、红、绿、蓝、白、四角、关键行、方向、文字、图片、动画逐项执行;上一项失败就停止,只排查对应线组。 +- ADC 测试按总线、地址、原始读取、同点万用表校准、正常档、单个低压档、恢复逐项执行,不合并人工动作。 +- 有清晰摄像头时由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时必须等待用户确认。 + +## 4. 人工停顿与电气安全 + +任何拔卡、插卡、断电接线、上电、万用表读数或调压都必须分阶段暂停。暂停时明确当前完成项、是否断电、用户只做什么、回复什么、下一步测什么。没有用户确认,不把物理结果写成通过。 + +- 接线前核桃派和屏幕都必须断电。 +- HUB75 屏幕使用独立 5V 供电并与核桃派共地;不得用 GPIO 供电。 +- ADC 在实屏基础画面通过后才连接;不得带电插拔。 +- 低压阶梯一次只要求一个安全档位,读完软件状态和实屏结果后才给下一档。 + +## 5. 网络和依赖 + +核桃派没有 VPN。板端不得默认访问 GitHub、PyPI 或其他境外服务并长时间重试。 + +1. Debian 包优先使用已验证可达的国内镜像;修改源前记录原值和回退方法。 +2. Python/C 源码或 wheel 若只有境外来源,由有 VPN 的电脑下载并校验,再上传离线安装。 +3. 每个离线包保存 SHA-256 清单;板端安装使用 `--no-index --find-links` 或明确的本地路径。 +4. 不关闭电脑 VPN,不把本机工具目录或盘符固化进项目文档和部署脚本。 +5. SSH 后先在远端用 `command -v` 和版本命令检查工具,不能因电脑有工具就假设板端也有。 +6. 普通功能开发不读取或重建大型镜像;只有用户明确要求导出镜像、维护离线材料或更新编辑器时才进入 `发布更新相关/` 的发布流程。 +7. 新增或删除板端 Python、Debian 或原生命令依赖时属于功能修改的一部分:必须同步 `发布更新相关/其他依赖/` 的文件、SHA-256 和生命周期清单。停用依赖删除二进制并登记原因,不能等待下次镜像导出再补。 +8. OTA 和可刷镜像共用 `核桃派软件源代码/VERSION` 与根目录 `发布记录.json`;只有明确导出成功才推进版本,失败不得占号或留下半成品目录。 +9. 可部署的功能新增或优化必须同步更新 `核桃派软件源代码/FEATURE_UPDATED_AT`,格式固定为精确到分钟的北京时间 `YYYY-MM-DDTHH:MM+08:00`。单纯 OTA/镜像导出、重复部署和文档修改不得改写该时间。 + +## 6. 数据生命周期 + +- OTA 等跨主服务停启存活的任务必须显式保护共享运行目录。`RuntimeDirectoryPreserve=restart` 不保留单独 `systemctl stop` 的目录;主服务使用 `yes`,旧版本在停服前通过事务临时 drop-in 保护并核验。不得只改新包内尚未生效的 unit,也不得只用 mkdir 掩盖请求/进度已丢失。运行目录仍随整机重启清空。 +- 涉及 systemd RuntimeDirectory、停启、回滚或独立任务的修改,必须补真实 systemd 生命周期测试。模拟 systemctl 通过不能写成真实 OTA 通过。日志写入失败不能阻止恢复动作;一次性故障证据写归档,稳定测试要求写需求与测试流程。 + +- 持久数据:配置、校准、模板、动图、字体和用户资源,统一保留在 `/var/lib/matrix-screen-controller`。 +- 可再生数据:缩略图等专用缓存,随持久源保存,只按登记规则清理。 +- 运行数据:帧快照、锁、boot 标记和临时状态,统一放 `/run/matrix-screen-controller`。 +- `/opt/matrix-screen-controller` 只含可替换程序、venv、原生驱动和部署文件。 +- schema 逐级单向迁移;损坏、未知字段、未来版本或不安全迁移必须保留原文件并拒绝启动,只有文件不存在才创建默认值。 + +## 7. 真实驱动规则 + +- 14 根 HUB75 信号集中使用 PI bank;物理脚 3/5 留给 I²C,物理脚 32 保留。 +- C 刷新线程必须双缓冲并只在完整帧边界交换;帧格式固定 RGB888,bitplane 固定 7 bit。 +- 默认 100 Hz,允许 15、20、30、45、60、80、100 Hz;状态必须报告实际刷新率、帧数、截止丢失数和最后错误。 +- 刷新线程尝试 CPU3 绑定、实时优先级、内存锁定和 performance governor;能力不足必须明确报告,不能伪造优化已生效。 +- 启动先保持 OE 禁用并清黑;异常、关闭、重建和进程退出都必须先禁用 OE、清空输出、恢复 GPIO 安全输入状态并释放映射。 +- 正常 WebSocket 断开不得记为显示异常或在 close 后再次发送。 + +## 8. 清理和独立性 + +- 不提交 `.venv`、`__pycache__`、`.pytest_cache`、`node_modules`、运行数据、测试数据、主机密钥或历史移植临时文件。 +- 临时部署、构建目录、wheelhouse 和临时 systemd unit 在验证后按明确绝对路径清理。 +- 最终把本目录复制到隔离临时位置,从文档命令重新构建和运行测试;任何引用目录外文件的路径都视为失败。 +- 一次性结果写在交付或 `各种归档/<时间戳>_用途/README.md`,不写进可重复测试流程。 +- `发布更新相关/导出包/<版本>/` 是可随时删除的大型 IMG 发布产物。发现历史导出 IMG 缺失时,默认视为用户为节省空间主动删除,属于正常情况;不报缺失故障、不追补、不自动重建,也不得因此阻塞开发、常规测试或后续构建。源码、文档命令和后续构建不得引用其中任何文件。 +- `发布更新相关/OTA数据包/` 中的 OTA 今后长期保留,不按可删除缓存清理;已缺失的 OTA 1.0.1 是用户接受的历史例外,不再追补、提醒或阻塞开发及使用。保留原发布记录,后续构建仍不得依赖历史包。 +- 普通开发不检查历史 IMG 是否齐全;只有用户明确要求处理某个镜像时,才核对该任务实际需要的输入文件。官方基线和其他离线依赖按各自规则管理。 +- 仓库内描述工作区文件时使用相对路径或 `<仓库根目录>`、`<受保护临时目录>` 等文字占位符,不固化盘符、Windows 用户目录、macOS 用户目录或开发者 home。核桃派上的 `/opt`、`/var/lib`、`/run`、`/boot`、`/usr/local` 等固定部署路径不属于此限制。 +- 私有 Git 仓库保留正式 IMG、OTA、离线依赖、编辑器程序和历史归档;大体积二进制允许使用 Git LFS,不能通过扩大 `.gitignore` 省略应保留内容。只有秘密、本机状态、运行数据和可再生缓存由根 `.gitignore` 排除。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f0fd81 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# 奇妙小屏幕控制器(核桃派 ZeroW) + +这是一个可单独克隆、开发、测试和离线部署的核桃派 ZeroW/H618 项目,用于驱动单块 64×64 HUB75 RGB 点阵屏,并提供局域网页面、模板、动图、网络、OTA 和电压监控功能。 + +## 仓库结构 + +- `核桃派软件源代码/`:FastAPI 服务、网页、H618 HUB75 原生驱动、部署与发布脚本。 +- `整体开发需求/`:带稳定编号的功能和部署需求。 +- `测试相关资料/如何测试/`:本机、板端、实屏与 ADC 的分层测试流程。 +- `硬件相关资料和硬件的连接/`:核桃派、HUB75 和 ADS1110 接线资料。 +- `发布更新相关/`:官方基线 IMG、正式导出包、OTA、离线依赖和镜像编辑器。 +- `各种归档/`:一次性验收证据和历史实现材料。 + +本私有仓库保留发布产物和离线依赖,并使用 Git LFS 管理已登记的大体积二进制。当前完整工作区超过 10 GiB;首次克隆和拉取需要安装 Git LFS,并为普通 Git 数据与 LFS 对象预留足够的磁盘空间和时间。 + +## 首次使用 + +1. 完整阅读 `AGENTS.md`、`整体开发需求/` 和与任务相关的测试、硬件资料。 +2. 需要 SSH、实机部署或镜像定制时,运行: + + ```powershell + python ".\测试相关资料\核桃派的用户名和密码和ip\prepare_credentials.py" + ``` + +3. 如果脚本创建了 `用户名密码ip.txt`,只编辑该文件并填完全部字段,然后重新运行检查。该私有文件被 Git 精确忽略;不要把内容复制到聊天、日志、源码或普通文档。 +4. 运行仓库卫生检查: + + ```powershell + python ".\核桃派软件源代码\scripts\check_repository_hygiene.py" + ``` + +5. 本机完整测试: + + ```powershell + python ".\测试相关资料\如何测试\本机测试环境\local_test.py" test --suite all + ``` + +## Git 安全边界 + +根 `.gitignore` 是统一入口。真实设备凭据、私钥、本机环境、运行数据和可再生缓存不得提交;示例文件、源码、需求、测试、正式 IMG、OTA、离线依赖、编辑器程序及历史归档需要保留。提交前运行卫生检查,确认没有开发电脑绝对路径、私钥或高可信明文秘密。 + +仓库内描述源码位置时使用仓库相对路径或 `<仓库根目录>` 等文字占位符。核桃派生产路径 `/opt/matrix-screen-controller`、`/var/lib/matrix-screen-controller` 和 `/run/matrix-screen-controller` 是部署契约,不属于开发机路径。 + +可选的版本库 hook 位于 `.githooks/pre-commit`。当前 checkout 可执行以下命令启用: + +```powershell +git config core.hooksPath .githooks +``` + +## 许可证 + +项目自有源码以 GNU General Public License v3.0 发布,见 `LICENSE`。仓库中随附的系统镜像、Debian 包、Python wheel、字体、FRP、硬件资料和其他第三方内容仍由各自许可证、版权声明或来源条款约束;GPLv3 不会替这些第三方材料重新授权。 diff --git a/发布更新相关/OTA数据包/1.0.4/README.md b/发布更新相关/OTA数据包/1.0.4/README.md new file mode 100644 index 0000000..12d2f27 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.4/README.md @@ -0,0 +1,8 @@ +# 奇妙小屏幕控制器 OTA 1.0.4 + +- 软件版本:`1.0.4` +- 导出时间:`2026-09-04T13:14:05+08:00` +- 说明:发布当前工作区已完成并通过本机全套测试的软件版本。 +- 产物:`matrix-screen-controller-1.0.4.ota` + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 diff --git a/发布更新相关/OTA数据包/1.0.4/manifest.json b/发布更新相关/OTA数据包/1.0.4/manifest.json new file mode 100644 index 0000000..81622e4 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.4/manifest.json @@ -0,0 +1,10 @@ +{ + "format_version": 1, + "artifact_type": "ota", + "software_version": "1.0.4", + "created_at": "2026-09-04T13:14:05+08:00", + "notes": "发布当前工作区已完成并通过本机全套测试的软件版本。", + "artifact": "matrix-screen-controller-1.0.4.ota", + "artifact_bytes": 22372978, + "artifact_sha256": "ed15498e2d66758343c874d00176e2525e67a69fea5cc8f4578f139dd2b0f766" +} diff --git a/发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota b/发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota new file mode 100644 index 0000000..03cf3ac --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed15498e2d66758343c874d00176e2525e67a69fea5cc8f4578f139dd2b0f766 +size 22372978 diff --git a/发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota.sha256 b/发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota.sha256 new file mode 100644 index 0000000..19a8f44 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota.sha256 @@ -0,0 +1 @@ +ed15498e2d66758343c874d00176e2525e67a69fea5cc8f4578f139dd2b0f766 matrix-screen-controller-1.0.4.ota diff --git a/发布更新相关/OTA数据包/1.0.5/README.md b/发布更新相关/OTA数据包/1.0.5/README.md new file mode 100644 index 0000000..89e06e3 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.5/README.md @@ -0,0 +1,8 @@ +# 奇妙小屏幕控制器 OTA 1.0.5 + +- 软件版本:`1.0.5` +- 导出时间:`2026-09-06T21:10:58+08:00` +- 说明:修复 OTA 板端测试目录隔离,并新增可查看、复制的详细失败日志。 +- 产物:`matrix-screen-controller-1.0.5.ota` + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 diff --git a/发布更新相关/OTA数据包/1.0.5/manifest.json b/发布更新相关/OTA数据包/1.0.5/manifest.json new file mode 100644 index 0000000..c763eaa --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.5/manifest.json @@ -0,0 +1,10 @@ +{ + "format_version": 1, + "artifact_type": "ota", + "software_version": "1.0.5", + "created_at": "2026-09-06T21:10:58+08:00", + "notes": "修复 OTA 板端测试目录隔离,并新增可查看、复制的详细失败日志。", + "artifact": "matrix-screen-controller-1.0.5.ota", + "artifact_bytes": 22381616, + "artifact_sha256": "ec245d2ba2eefdf415f6745aefb1dfc66631e027d6820a2b6bd04bd6306d4152" +} diff --git a/发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota b/发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota new file mode 100644 index 0000000..c31c80e --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec245d2ba2eefdf415f6745aefb1dfc66631e027d6820a2b6bd04bd6306d4152 +size 22381616 diff --git a/发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota.sha256 b/发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota.sha256 new file mode 100644 index 0000000..3800658 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota.sha256 @@ -0,0 +1 @@ +ec245d2ba2eefdf415f6745aefb1dfc66631e027d6820a2b6bd04bd6306d4152 matrix-screen-controller-1.0.5.ota diff --git a/发布更新相关/OTA数据包/1.0.6/README.md b/发布更新相关/OTA数据包/1.0.6/README.md new file mode 100644 index 0000000..3a9323e --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.6/README.md @@ -0,0 +1,8 @@ +# 奇妙小屏幕控制器 OTA 1.0.6 + +- 软件版本:`1.0.6` +- 导出时间:`2026-09-06T21:39:45+08:00` +- 说明:诊断引导包:经设备持有者授权,仅在 1.0.3 旧 worker 中跳过 pytest,以先安装可查看、复制的 OTA 失败日志;后续更新恢复严格测试。 +- 产物:`matrix-screen-controller-1.0.6.ota` + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 diff --git a/发布更新相关/OTA数据包/1.0.6/manifest.json b/发布更新相关/OTA数据包/1.0.6/manifest.json new file mode 100644 index 0000000..445d208 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.6/manifest.json @@ -0,0 +1,10 @@ +{ + "format_version": 1, + "artifact_type": "ota", + "software_version": "1.0.6", + "created_at": "2026-09-06T21:39:45+08:00", + "notes": "诊断引导包:经设备持有者授权,仅在 1.0.3 旧 worker 中跳过 pytest,以先安装可查看、复制的 OTA 失败日志;后续更新恢复严格测试。", + "artifact": "matrix-screen-controller-1.0.6.ota", + "artifact_bytes": 22382190, + "artifact_sha256": "8e804dcfb1f2f9194f3c57b88b5d4b7308509270fba536059e528ab21fdfa0b9" +} diff --git a/发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota b/发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota new file mode 100644 index 0000000..9b376bd --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e804dcfb1f2f9194f3c57b88b5d4b7308509270fba536059e528ab21fdfa0b9 +size 22382190 diff --git a/发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota.sha256 b/发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota.sha256 new file mode 100644 index 0000000..61b5dc7 --- /dev/null +++ b/发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota.sha256 @@ -0,0 +1 @@ +8e804dcfb1f2f9194f3c57b88b5d4b7308509270fba536059e528ab21fdfa0b9 matrix-screen-controller-1.0.6.ota diff --git a/发布更新相关/OTA数据包/1.1.0/README.md b/发布更新相关/OTA数据包/1.1.0/README.md new file mode 100644 index 0000000..f593361 --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.0/README.md @@ -0,0 +1,11 @@ +# OTA 1.1.0 软件安装包(修复版) + +修复 OTA 显式停止旧服务时运行目录被删除导致升级和回滚中断;增加停服前事务保护、日志降级及真实 systemd 回归。已在测试设备完成 1.0.6 → 1.1.0,用户数据和 frp 状态保持。 + +- SHA-256:`7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8` +- 修复时间:`2026-09-08T10:31:33+08:00` +- 通过系统设置上传 `.ota`,无需解压。1.0.x 必须先安装本节点,再安装后续补丁。 +- 已通过真实 systemd 停启测试及测试设备 OTA;正式包与实机验收包字节一致。 +- 已安装 frp 时保留配置和启停状态,完整组件跳过,缺失或损坏时离线修复。 +- 本包修复停止旧服务时运行目录被删除的问题;仍为 1.1.0,不是 1.1.1。 +- 原失败包与旧记录:`各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f`。 diff --git a/发布更新相关/OTA数据包/1.1.0/manifest.json b/发布更新相关/OTA数据包/1.1.0/manifest.json new file mode 100644 index 0000000..342b5bb --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.0/manifest.json @@ -0,0 +1,15 @@ +{ + "format_version": 1, + "artifact_type": "ota", + "software_version": "1.1.0", + "created_at": "2026-09-08T10:19:47+08:00", + "notes": "修复 OTA 显式停止旧服务时运行目录被删除导致升级和回滚中断;增加停服前事务保护、日志降级及真实 systemd 回归。已在测试设备完成 1.0.6 → 1.1.0,用户数据和 frp 状态保持。", + "artifact": "matrix-screen-controller-1.1.0.ota", + "artifact_bytes": 27998979, + "artifact_sha256": "7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8", + "package_kind": "software-install", + "required_checkpoint": "1.0.0", + "is_checkpoint": true, + "repaired_at": "2026-09-08T10:31:33+08:00", + "replaces_sha256": "379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5" +} diff --git a/发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota b/发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota new file mode 100644 index 0000000..e92ceb3 --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8 +size 27998979 diff --git a/发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota.sha256 b/发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota.sha256 new file mode 100644 index 0000000..c03c71a --- /dev/null +++ b/发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota.sha256 @@ -0,0 +1 @@ +7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8 matrix-screen-controller-1.1.0.ota diff --git a/发布更新相关/README.md b/发布更新相关/README.md new file mode 100644 index 0000000..56010f2 --- /dev/null +++ b/发布更新相关/README.md @@ -0,0 +1,23 @@ +# 发布更新材料与产物 + +本目录用于没有境外网络的核桃派从官方系统镜像离线安装当前软件,并保存按用户明确指令导出的可刷镜像。 + +| 目录 | 内容 | 生命周期 | +|---|---|---| +| `核桃派镜像/` | 未修改的官方 RAR、IMG 和摘要 | 稳定基线,不得原地修改 | +| `导出包/<版本>/` | Rufus 可写入的版本化 IMG | 用户可随时删除以节省空间;缺失默认正常,不影响开发,项目不得引用 | +| `其他依赖/` | AArch64 wheel、Debian 包、预编译内核、固定内核源码和依赖生命周期 | 随软件依赖同步维护 | +| `OTA数据包/<版本>/` | 版本化 OTA 更新包 | 今后长期保留,不作为后续构建输入;1.0.1 缺失为已接受的历史例外 | +| `镜像编辑器/` | Windows 编辑器源码和绿色程序 | 编辑器使用独立版本并覆盖旧程序 | + +导出 IMG 缺失默认视为用户主动清理,不报故障、不追补、不自动重建,也不阻塞开发、常规测试或后续构建。OTA 今后保留;已缺失的 1.0.1 不再追补或重复提醒,原发布记录保留。 + +普通开发不需要读取大型镜像或导出目录。只有用户明确要求导出镜像、维护离线依赖或更新编辑器时才进入本流程;但新增或删除软件运行依赖时,必须在同一次功能修改中同步 `其他依赖/`。 + +镜像中的首次启动配置包含账户和 Wi-Fi 明文。未修改正式 IMG 的发布目录 README 必须写明该镜像故意公开的默认账户与 Wi-Fi 四项值并提示立即修改;这是唯一允许记录这些默认值的发布文档。当前设备或开发机凭据仍不得进入正式导出说明、发布记录、源码或镜像。定制镜像应按秘密文件保护;写卡成功后,首启程序会尽力覆盖并删除卡上的明文配置。 + +镜像格式 v3 分两层完成:标准库构建器从未修改官方 IMG 复制件生成应用离线 bundle,FAT16 只写固定双槽配置、v3 元数据和首启入口;随后 Linux root bootstrap 向复制件根分区写入应用 bundle、已登记的预编译内核载荷、一次性 root systemd 服务和受管 SSH 策略,同时清除继承的网络和设备身份。rootfs 注入前必须保留“两类压缩载荷 + 256 MiB”;FAT 必须在候选内核五个 boot 文件、启动脚本临时/回滚文件和状态文件实际预算之外再保留 `32 MiB`,并在构建端和设备安装端各检查一次。SSH 在首启 oneshot 完成前受 systemd 排序阻止,成功自动重启后才使用镜像配置账户开放。内核固定源码不进入镜像。两层及最终只读挂载验证全部成功后才可登记为正式产物。 + +正式镜像必须通过 `核桃派软件源代码/scripts/export_release.py image` 构建,禁止直接调用旧的 FAT 原地刷新脚本。新版本先用 `--candidate-output` 在正式目录外构建;同一字节候选通过摘要绑定的真实 TF 卡正向验收后,再用 `--validated-candidate` 和 `--validation-report` 晋升,晋升前会重建期望 bundle 并重新只读验证。候选阶段不得修改 `VERSION`、发布记录或正式目录。当前版本坏包的授权修复继续使用 `--repair-current`。构建主机只需 Python 标准库与已检查的 Linux 镜像工具,不得用安装 Pillow/FontTools 的临时 venv 掩盖导出器导入错误;构建不访问网络。 + +Windows 与 Linux 构建机之间传输时,先在 Codex 临时目录生成不含凭据的项目快照及 IMG 压缩副本,并在解压前后分别校验 SHA-256。PuTTY SCP 对中文远端路径不可靠时,只在明确的远端临时根建立 ASCII 下载目录;同文件系统可使用硬链接避免复制第二份大型 IMG,启用 `protected_hardlinks` 时仅用 sudo 创建这些明确硬链接。官方 `SHA256SUMS` 使用相对文件名,必须在清单所在目录校验。传输、解压、loop、挂载和 ASCII 下载目录在产物回传并复核后按明确绝对路径清理。 diff --git a/发布更新相关/其他依赖/DEPENDENCIES.json b/发布更新相关/其他依赖/DEPENDENCIES.json new file mode 100644 index 0000000..8002f3b --- /dev/null +++ b/发布更新相关/其他依赖/DEPENDENCIES.json @@ -0,0 +1,51 @@ +{ + "schema_version": 1, + "platform": "Debian 12 / AArch64 / Python 3.11", + "active": [ + { + "id": "python-wheelhouse", + "path": "aarch64-py311", + "reason": "FastAPI 服务、媒体、字体和本机/板端验证", + "introduced_in": "1.0.1" + }, + { + "id": "ffmpeg-libheif-debian", + "path": "debian12-aarch64", + "reason": "媒体导入需要 ffmpeg、ffprobe、zscale、tonemap 和 heif-convert", + "source": "清华 TUNA 的 Debian bookworm、bookworm-updates 与 bookworm-security AArch64 镜像;仓库索引和包均校验 SHA-256", + "roots": ["ffmpeg", "libheif-examples", "python3.11-venv"], + "package_count": 84, + "baseline": "debian12-aarch64/BASE_IMAGE_PACKAGES.tsv", + "resolution": "debian12-aarch64/RESOLUTION.json", + "introduced_in": "1.0.2" + }, + { + "id": "axp313a-kernel-runtime", + "path": "aarch64-kernel/6.1.31-matrix-axp313a1", + "reason": "新刷 IMG 首次启动时离线安装已验证的 AXP313A/cpufreq 修复内核,不在导出时重新编译", + "architecture": "aarch64", + "kernel_release": "6.1.31-matrix-axp313a1", + "introduced_in": "1.0.2" + }, + { + "id": "walnutpi-linux-kernel-source", + "path": "kernel-source/walnutpi-linux-6.1.31-30ff3fd5", + "reason": "完全离线重建 6.1.31-matrix-axp313a1 的固定源码输入;不进入 IMG 或 OTA", + "source_commit": "30ff3fd5cf45417622b447a12e7a947402ffe34d", + "introduced_in": "1.0.2" + }, + { + "id": "frpc-system-client", + "path": "frp/0.71.0/linux-arm64", + "reason": "网页远程维护功能使用的 FRP 客户端系统组件", + "source": "fatedier/frp 官方 v0.71.0 Linux ARM64 Release,并校验官方归档和提取文件 SHA-256", + "architecture": "aarch64", + "version": "0.71.0", + "installed_path": "/usr/local/bin/frpc", + "introduced_in": "1.1.0", + "development_baseline": "1.0.6 调试设备已预装;正式旧版本不包含此组件", + "ota_lifecycle": "仅软件安装节点携带二进制,普通补丁校验已安装组件;事务失败恢复原文件与状态" + } + ], + "retired": [] +} diff --git a/发布更新相关/其他依赖/README.md b/发布更新相关/其他依赖/README.md new file mode 100644 index 0000000..d9bc81d --- /dev/null +++ b/发布更新相关/其他依赖/README.md @@ -0,0 +1,15 @@ +# 其他离线依赖 + +- `aarch64-py311/`:Debian 12、Python 3.11、AArch64 的 Python wheelhouse。 +- `debian12-aarch64/`:官方基础镜像缺少或版本不足的 FFmpeg、libheif、Python 3.11 venv/ensurepip 及完整递归闭包。`BASE_IMAGE_PACKAGES.tsv` 是官方基础镜像的正常安装包基准,`RESOLUTION.json` 记录解析根、仓库索引摘要和选中版本;构建器会在生成 IMG 前重新验证闭包。 +- `aarch64-kernel/6.1.31-matrix-axp313a1/`:新刷 IMG 使用的已验证预编译内核载荷;导出只复制和校验,不重新编译。 +- `kernel-source/walnutpi-linux-6.1.31-30ff3fd5/`:固定源码归档,只用于完全离线重建内核,不进入 IMG 或 OTA。 +- `frp/0.71.0/linux-arm64/`:FRP 官方 Linux ARM64 `frpc` 系统组件、许可证和 SHA-256 清单;部署到 `/usr/local/bin/frpc`,不包含 `frps`。 + +两个目录都必须先执行各自的 `sha256sum -c SHA256SUMS`。Debian 目录当前包含 84 个 `.deb`,来源为清华 TUNA 的 Debian Bookworm、Updates 与 Security AArch64 镜像,文件逐个按仓库 SHA-256 校验。板端安装不得访问 PyPI、GitHub 或境外软件源。 + +内核二进制和源码目录同样必须校验各自的 `SHA256SUMS` 与 `METADATA.json`。正式 IMG 只把压缩后的预编译载荷注入复制镜像的 Linux 根分区;源码归档始终留在构建电脑上。 + +依赖生命周期以 `DEPENDENCIES.json` 为准。新增运行依赖必须同时加入文件、摘要和用途;删除依赖时删除二进制,并在 `retired` 中记录停用版本和原因。 + +frpc 正式引入版本为 1.1.0;此前 1.0.6 仅有开发设备预装。OTA 安装节点按源码 `UPGRADE_POLICY.json` 选择离线材料,普通补丁不重复打入 frpc。组件事务使用 Debian 基础镜像已包含的 `systemd`(systemctl/systemd-run)、`coreutils`(stat/sha256sum/install)与 `diffutils`(cmp),版本基线见 `debian12-aarch64/BASE_IMAGE_PACKAGES.tsv`,不增加板端下载。 diff --git a/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/METADATA.json b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/METADATA.json new file mode 100644 index 0000000..496786d --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/METADATA.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "architecture": "aarch64", + "kernel_release": "6.1.31-matrix-axp313a1", + "source_commit": "30ff3fd5cf45417622b447a12e7a947402ffe34d", + "source_archive_sha256": "8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f", + "artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", + "artifact_bytes": 48665765, + "artifact_sha256": "f2b557ce0874aca11aacaa4496ff1f3edfdb84d67ed537132f47d8f4432b8f6d", + "unpacked_file_bytes": 160051604, + "regular_file_count": 3056, + "module_file_count": 3048, + "provenance": "Exact files captured read-only from the validated production WalnutPi kernel on 2026-08-25 after browser and real HUB75 GPIO acceptance." +} diff --git a/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/README.md b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/README.md new file mode 100644 index 0000000..5ebe05b --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/README.md @@ -0,0 +1,7 @@ +# 6.1.31-matrix-axp313a1 预编译载荷 + +- 架构:AArch64。 +- 用途:未来可刷 IMG 首次启动时安装已经通过真实 HUB75 GPIO 验证的 AXP313A/cpufreq 修复内核。 +- 载荷只包含候选 Image、两个 DTB、config、System.map、模块、版本标记和内部摘要,不包含设备身份、凭据、日志或用户数据。 +- 模块目录中的 `build`、`source` 符号链接已排除;安装完成后使用目标系统的 `depmod` 重建索引。 +- 正式构建必须先校验本目录 `SHA256SUMS` 和 `METADATA.json`,不得从历史 `导出包/` 取文件。 diff --git a/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/SHA256SUMS b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/SHA256SUMS new file mode 100644 index 0000000..15a3814 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/SHA256SUMS @@ -0,0 +1 @@ +f2b557ce0874aca11aacaa4496ff1f3edfdb84d67ed537132f47d8f4432b8f6d axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz diff --git a/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz new file mode 100644 index 0000000..58dfa14 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2b557ce0874aca11aacaa4496ff1f3edfdb84d67ed537132f47d8f4432b8f6d +size 48665765 diff --git a/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS b/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS new file mode 100644 index 0000000..8b0c7db --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/SHA256SUMS @@ -0,0 +1,29 @@ +f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 annotated_types-0.8.0-py3-none-any.whl +9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 anyio-4.14.2-py3-none-any.whl +62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 certifi-2026.7.22-py3-none-any.whl +e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 click-8.4.2-py3-none-any.whl +4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 colorama-0.4.6-py2.py3-none-any.whl +c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 fastapi-0.116.1-py3-none-any.whl +2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 h11-0.16.0-py3-none-any.whl +2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 httpcore-1.0.9-py3-none-any.whl +bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad httpx-0.28.1-py3-none-any.whl +7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 idna-3.18-py3-none-any.whl +f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 iniconfig-2.3.0-py3-none-any.whl +d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c packaging-26.3-py3-none-any.whl +bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl +e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 pluggy-1.6.0-py3-none-any.whl +7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl +45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba pydantic-2.13.4-py3-none-any.whl +81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 pygments-2.20.0-py3-none-any.whl +539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 pytest-8.4.1-py3-none-any.whl +1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a python_dotenv-1.2.2-py3-none-any.whl +10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51 starlette-0.47.3-py3-none-any.whl +481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 typing_extensions-4.16.0-py3-none-any.whl +4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 typing_inspection-0.4.2-py3-none-any.whl +197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a uvicorn-0.35.0-py3-none-any.whl +53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl +d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1 websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl diff --git a/发布更新相关/其他依赖/aarch64-py311/annotated_types-0.8.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/annotated_types-0.8.0-py3-none-any.whl new file mode 100644 index 0000000..d2263d7 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/annotated_types-0.8.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +size 13427 diff --git a/发布更新相关/其他依赖/aarch64-py311/anyio-4.14.2-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/anyio-4.14.2-py3-none-any.whl new file mode 100644 index 0000000..ff05acb --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/anyio-4.14.2-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 +size 125813 diff --git a/发布更新相关/其他依赖/aarch64-py311/certifi-2026.7.22-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/certifi-2026.7.22-py3-none-any.whl new file mode 100644 index 0000000..21a4bf3 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/certifi-2026.7.22-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 +size 136983 diff --git a/发布更新相关/其他依赖/aarch64-py311/click-8.4.2-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/click-8.4.2-py3-none-any.whl new file mode 100644 index 0000000..018d074 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/click-8.4.2-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 +size 119243 diff --git a/发布更新相关/其他依赖/aarch64-py311/colorama-0.4.6-py2.py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/colorama-0.4.6-py2.py3-none-any.whl new file mode 100644 index 0000000..4e8f20a --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/colorama-0.4.6-py2.py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +size 25335 diff --git a/发布更新相关/其他依赖/aarch64-py311/fastapi-0.116.1-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/fastapi-0.116.1-py3-none-any.whl new file mode 100644 index 0000000..bbb7aa6 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/fastapi-0.116.1-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 +size 95631 diff --git a/发布更新相关/其他依赖/aarch64-py311/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl new file mode 100644 index 0000000..ca002dd --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b +size 5111952 diff --git a/发布更新相关/其他依赖/aarch64-py311/h11-0.16.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/h11-0.16.0-py3-none-any.whl new file mode 100644 index 0000000..43e545e --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/h11-0.16.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +size 37515 diff --git a/发布更新相关/其他依赖/aarch64-py311/httpcore-1.0.9-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/httpcore-1.0.9-py3-none-any.whl new file mode 100644 index 0000000..140b7c6 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/httpcore-1.0.9-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +size 78784 diff --git a/发布更新相关/其他依赖/aarch64-py311/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl new file mode 100644 index 0000000..9e2d46d --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d +size 464235 diff --git a/发布更新相关/其他依赖/aarch64-py311/httpx-0.28.1-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/httpx-0.28.1-py3-none-any.whl new file mode 100644 index 0000000..6621ee6 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/httpx-0.28.1-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +size 73517 diff --git a/发布更新相关/其他依赖/aarch64-py311/idna-3.18-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/idna-3.18-py3-none-any.whl new file mode 100644 index 0000000..b901293 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/idna-3.18-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 +size 65455 diff --git a/发布更新相关/其他依赖/aarch64-py311/iniconfig-2.3.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/iniconfig-2.3.0-py3-none-any.whl new file mode 100644 index 0000000..c188248 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/iniconfig-2.3.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +size 7484 diff --git a/发布更新相关/其他依赖/aarch64-py311/packaging-26.3-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/packaging-26.3-py3-none-any.whl new file mode 100644 index 0000000..5bf95b6 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/packaging-26.3-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +size 129956 diff --git a/发布更新相关/其他依赖/aarch64-py311/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl new file mode 100644 index 0000000..24aa42a --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd +size 6263814 diff --git a/发布更新相关/其他依赖/aarch64-py311/pluggy-1.6.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/pluggy-1.6.0-py3-none-any.whl new file mode 100644 index 0000000..ea07463 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pluggy-1.6.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +size 20538 diff --git a/发布更新相关/其他依赖/aarch64-py311/pydantic-2.13.4-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/pydantic-2.13.4-py3-none-any.whl new file mode 100644 index 0000000..ed94d12 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pydantic-2.13.4-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba +size 472262 diff --git a/发布更新相关/其他依赖/aarch64-py311/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl new file mode 100644 index 0000000..ef95539 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 +size 1972827 diff --git a/发布更新相关/其他依赖/aarch64-py311/pygments-2.20.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/pygments-2.20.0-py3-none-any.whl new file mode 100644 index 0000000..668c4d5 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pygments-2.20.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +size 1231151 diff --git a/发布更新相关/其他依赖/aarch64-py311/pytest-8.4.1-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/pytest-8.4.1-py3-none-any.whl new file mode 100644 index 0000000..7ee6820 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pytest-8.4.1-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 +size 365474 diff --git a/发布更新相关/其他依赖/aarch64-py311/python_dotenv-1.2.2-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/python_dotenv-1.2.2-py3-none-any.whl new file mode 100644 index 0000000..00f478f --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/python_dotenv-1.2.2-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a +size 22101 diff --git a/发布更新相关/其他依赖/aarch64-py311/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl new file mode 100644 index 0000000..209efd5 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c +size 775556 diff --git a/发布更新相关/其他依赖/aarch64-py311/starlette-0.47.3-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/starlette-0.47.3-py3-none-any.whl new file mode 100644 index 0000000..99b5e47 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/starlette-0.47.3-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51 +size 72991 diff --git a/发布更新相关/其他依赖/aarch64-py311/typing_extensions-4.16.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/typing_extensions-4.16.0-py3-none-any.whl new file mode 100644 index 0000000..e7f774b --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/typing_extensions-4.16.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +size 45571 diff --git a/发布更新相关/其他依赖/aarch64-py311/typing_inspection-0.4.2-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/typing_inspection-0.4.2-py3-none-any.whl new file mode 100644 index 0000000..c4071f4 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/typing_inspection-0.4.2-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 +size 14611 diff --git a/发布更新相关/其他依赖/aarch64-py311/uvicorn-0.35.0-py3-none-any.whl b/发布更新相关/其他依赖/aarch64-py311/uvicorn-0.35.0-py3-none-any.whl new file mode 100644 index 0000000..1e2dbf2 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/uvicorn-0.35.0-py3-none-any.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a +size 66406 diff --git a/发布更新相关/其他依赖/aarch64-py311/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl new file mode 100644 index 0000000..26c5ba6 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 +size 3753819 diff --git a/发布更新相关/其他依赖/aarch64-py311/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl new file mode 100644 index 0000000..1fbd4ae --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a +size 456611 diff --git a/发布更新相关/其他依赖/aarch64-py311/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl b/发布更新相关/其他依赖/aarch64-py311/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl new file mode 100644 index 0000000..7019600 --- /dev/null +++ b/发布更新相关/其他依赖/aarch64-py311/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1 +size 221108 diff --git a/发布更新相关/其他依赖/debian12-aarch64/BASE_IMAGE_PACKAGES.tsv b/发布更新相关/其他依赖/debian12-aarch64/BASE_IMAGE_PACKAGES.tsv new file mode 100644 index 0000000..500fe80 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/BASE_IMAGE_PACKAGES.tsv @@ -0,0 +1,790 @@ +ii adduser 3.134 +ii adwaita-icon-theme 43-1 +ii alsa-topology-conf 1.2.5.1-2 +ii alsa-ucm-conf 1.2.8-1 +ii alsa-utils 1.2.8-1 +ii apt 2.6.1 +ii apt-utils 2.6.1 +ii at-spi2-common 2.46.0-5 +ii at-spi2-core 2.46.0-5 +ii avahi-daemon 0.8-10 +ii base-files 12.4+deb12u7 +ii base-passwd 3.6.1 +ii bash 5.2.15-2+b7 +ii bash-completion 1:2.11-6 +ii bc 1.07.1-3 +ii binutils 2.40-2 +ii binutils-aarch64-linux-gnu 2.40-2 +ii binutils-common 2.40-2 +ii bison 2:3.8.2+dfsg-1+b1 +ii bluez 666.6.66-1+deb12u1 +ii bootbin-walnutpi-1b-v2024.01 1.0 +ii bsdextrautils 2.38.1-5+deb12u1 +ii bsdutils 1:2.38.1-5+deb12u1 +ii btop 1.2.13-1 +ii build-essential 12.9 +ii busybox 1:1.35.0-4+b3 +ii bzip2 1.0.8-5+b1 +ii ca-certificates 20230311 +ii conf-h616-audio 1.0.0 +ii conf-qtenv 1.0.0 +ii configtxt-walnutpi-1b-v2024.01 1.0 +ii coreutils 9.1-1 +ii cpio 2.13+dfsg-7.1 +ii cpp 4:12.2.0-3 +ii cpp-12 12.2.0-14 +ii cron 3.0pl1-162 +ii cron-daemon-common 3.0pl1-162 +ii curl 7.88.1-10+deb12u7 +ii dash 0.5.12-2 +ii dbus 1.14.10-1~deb12u1 +ii dbus-bin 1.14.10-1~deb12u1 +ii dbus-daemon 1.14.10-1~deb12u1 +ii dbus-session-bus-common 1.14.10-1~deb12u1 +ii dbus-system-bus-common 1.14.10-1~deb12u1 +ii dbus-user-session 1.14.10-1~deb12u1 +ii dbus-x11 1.14.10-1~deb12u1 +ii dconf-gsettings-backend 0.40.0-4 +ii dconf-service 0.40.0-4 +ii debconf 1.5.82 +ii debconf-i18n 1.5.82 +ii debian-archive-keyring 2023.3+deb12u1 +ii debianutils 5.7-0.5~deb12u1 +ii desktop-base 12.0.6+nmu1~deb12u1 +ii device-tree-compiler 1.6.1-4+b1 +ii diffutils 1:3.8-4 +ii dirmngr 2.2.40-1.1 +ii disable-wifi-random 1.0.0 +ii dmidecode 3.4-1 +ii dmsetup 2:1.02.185-2 +ii dns-root-data 2024041801~deb12u1 +ii dnsmasq-base 2.89-1 +ii dpkg 1.21.22 +ii dpkg-dev 1.21.22 +ii e2fsprogs 1.47.0-2 +ii enable-ssh-root 1.0.0 +ii evemu-tools 2.7.0-3 +ii evtest 1:1.35-1 +ii fakeroot 1.31-1.2 +ii fbset 2.1-33 +ii fbterm 1.7-5 +ii fdisk 2.38.1-5+deb12u1 +ii findutils 4.9.0-4 +ii flex 2.6.4-8.2 +ii fontconfig 2.14.1-4 +ii fontconfig-config 2.14.1-4 +ii fonts-cantarell 0.303.1-1 +ii fonts-dejavu-core 2.37-6 +ii fonts-quicksand 0.2016-2.1 +ii fonts-wqy-zenhei 0.9.45-8 +ii g++ 4:12.2.0-3 +ii g++-12 12.2.0-14 +ii gcc 4:12.2.0-3 +ii gcc-12 12.2.0-14 +ii gcc-12-base 12.2.0-14 +ii geoclue-2.0 2.6.0-2 +ii git 1:2.39.2-1.1 +ii git-man 1:2.39.2-1.1 +ii glib-networking 2.74.0-4 +ii glib-networking-common 2.74.0-4 +ii glib-networking-services 2.74.0-4 +ii gnome-accessibility-themes 3.28-2 +ii gnome-themes-extra 3.28-2 +ii gnome-themes-extra-data 3.28-2 +ii gnupg 2.2.40-1.1 +ii gnupg-l10n 2.2.40-1.1 +ii gnupg-utils 2.2.40-1.1 +ii gpg 2.2.40-1.1 +ii gpg-agent 2.2.40-1.1 +ii gpg-wks-client 2.2.40-1.1 +ii gpg-wks-server 2.2.40-1.1 +ii gpgconf 2.2.40-1.1 +ii gpgsm 2.2.40-1.1 +ii gpgv 2.2.40-1.1 +ii grep 3.8-5 +ii groff-base 1.22.4-10 +ii gsettings-desktop-schemas 43.0-1 +ii gstreamer1.0-plugins-base 1.22.0-3+deb12u2 +ii gtk-update-icon-cache 3.24.38-2~deb12u2 +ii gtk2-engines-pixbuf 2.24.33-2+deb12u1 +ii gzip 1.12-1 +ii hcitools 1.0.1 +ii hicolor-icon-theme 0.17-2 +ii hostname 3.23+nmu1 +ii i2c-tools 4.3-2+b3 +ii ifupdown 0.8.41 +ii iio-sensor-proxy 3.0-2 +ii init 1.65.2 +ii init-system-helpers 1.65.2 +ii initramfs-tools 0.142+deb12u1 +ii initramfs-tools-core 0.142+deb12u1 +ii initramfs-tools-hook 1.0.0 +ii iproute2 6.1.0-3 +ii iputils-ping 3:20221126-1 +ii ir-keytable 1.22.1-5+b2 +ii isc-dhcp-client 4.4.3-P1-2 +ii isc-dhcp-common 4.4.3-P1-2 +ii iso-codes 4.15.0-1 +ii iw 5.19-1 +ii javascript-common 11+nmu1 +ii kernel-dtb-walnutpi-1b-6.1.31 1.36.0 +ii kernel-headers-walnutpi-1b-6.1.31 1.36.0 +ii kernel-image-walnutpi-1b-6.1.31 1.36.0 +ii kernel-modules-walnutpi-1b-6.1.31 1.36.0 +ii keyboard-configuration 1.221 +ii keyutils 1.6.3-2 +ii klibc-utils 2.0.12-1 +ii kmod 30+20221128-1 +ii less 590-2.1~deb12u2 +ii libabsl20220623 20220623.1-1 +ii libacl1 2.3.1-3 +ii libalgorithm-diff-perl 1.201-1 +ii libalgorithm-diff-xs-perl 0.04-8+b1 +ii libalgorithm-merge-perl 0.08-5 +ii libaom3 3.6.0-1+deb12u1 +ii libapparmor1 3.0.8-3 +ii libapt-pkg6.0 2.6.1 +ii libargon2-1 0~20171227-0.3+deb12u1 +ii libasan8 12.2.0-14 +ii libasound2 1.2.8-1+b1 +ii libasound2-data 1.2.8-1 +ii libassuan0 2.5.5-5 +ii libasyncns0 0.8-6+b3 +ii libatk-bridge2.0-0 2.46.0-5 +ii libatk1.0-0 2.46.0-5 +ii libatomic1 12.2.0-14 +ii libatopology2 1.2.8-1+b1 +ii libatspi2.0-0 2.46.0-5 +ii libattr1 1:2.5.1-4 +ii libaudit-common 1:3.0.9-1 +ii libaudit1 1:3.0.9-1 +ii libavahi-client3 0.8-10 +ii libavahi-common-data 0.8-10 +ii libavahi-common3 0.8-10 +ii libavahi-core7 0.8-10 +ii libavahi-glib1 0.8-10 +ii libavif15 0.11.1-1 +ii libayatana-ido3-0.4-0 0.9.3-1 +ii libayatana-indicator3-7 0.9.3-1 +ii libbinutils 2.40-2 +ii libblkid1 2.38.1-5+deb12u1 +ii libbluetooth3 5.66-1+deb12u2 +ii libbpf1 1:1.1.0-1 +ii libbrotli1 1.0.9-2+b6 +ii libbsd0 0.11.7-2 +ii libbz2-1.0 1.0.8-5+b1 +ii libc-bin 2.36-9+deb12u8 +ii libc-dev-bin 2.36-9+deb12u8 +ii libc-devtools 2.36-9+deb12u8 +ii libc-l10n 2.36-9+deb12u8 +ii libc6 2.36-9+deb12u8 +ii libc6-dev 2.36-9+deb12u8 +ii libcaca0 0.99.beta20-3 +ii libcairo-gobject2 1.16.0-7 +ii libcairo2 1.16.0-7 +ii libcap-ng0 0.8.3-1+b3 +ii libcap2 1:2.66-4 +ii libcap2-bin 1:2.66-4 +ii libcbor0.8 0.8.0-2+b1 +ii libcc1-0 12.2.0-14 +ii libcdparanoia0 3.10.2+debian-14 +ii libcolord2 1.4.6-2.2 +ii libcom-err2 1.47.0-2 +ii libcrypt-dev 1:4.4.33-2 +ii libcrypt1 1:4.4.33-2 +ii libcryptsetup12 2:2.6.1-4~deb12u2 +ii libctf-nobfd0 2.40-2 +ii libctf0 2.40-2 +ii libcups2 2.4.2-3+deb12u7 +ii libcurl3-gnutls 7.88.1-10+deb12u7 +ii libcurl4 7.88.1-10+deb12u7 +ii libdaemon0 0.14-7.1 +ii libdatrie1 0.2.13-2+b1 +ii libdav1d6 1.0.0-2+deb12u1 +ii libdb5.3 5.3.28+dfsg2-1 +ii libdbus-1-3 1.14.10-1~deb12u1 +ii libdconf1 0.40.0-4 +ii libde265-0 1.0.11-1+deb12u2 +ii libdebconfclient0 0.270 +ii libdeflate0 1.14-1 +ii libdevmapper1.02.1 2:1.02.185-2 +ii libdouble-conversion3 3.2.1-1 +ii libdpkg-perl 1.21.22 +ii libdrm-amdgpu1 2.4.114-1+b1 +ii libdrm-common 2.4.114-1 +ii libdrm-nouveau2 2.4.114-1+b1 +ii libdrm-radeon1 2.4.114-1+b1 +ii libdrm2 2.4.114-1+b1 +ii libduktape207 2.7.0-2 +ii libdw1 0.188-2.1 +ii libedit2 3.1-20221030-2 +ii libegl-mesa0 22.3.6-1+deb12u1 +ii libegl1 1.6.0-1 +ii libelf1 0.188-2.1 +ii libepoxy0 1.5.10-1 +ii liberror-perl 0.17029-2 +ii libevdev2 1.13.0+dfsg-1 +ii libevemu3 2.7.0-3 +ii libevent-2.1-7 2.1.12-stable-8 +ii libevent-core-2.1-7 2.1.12-stable-8 +ii libexpat1 2.5.0-1 +ii libexpat1-dev 2.5.0-1 +ii libext2fs2 1.47.0-2 +ii libfakeroot 1.31-1.2 +ii libfdisk1 2.38.1-5+deb12u1 +ii libfdt1 1.6.1-4+b1 +ii libffi8 3.4.4-1 +ii libfftw3-single3 3.3.10-1 +ii libfido2-1 1.12.0-2+b1 +ii libfile-fcntllock-perl 0.22-4+b1 +ii libfl-dev 2.6.4-8.2 +ii libfl2 2.6.4-8.2 +ii libflac12 1.4.2+ds-2 +ii libfontconfig1 2.14.1-4 +ii libfontenc1 1:1.1.4-1 +ii libfreetype6 2.12.1+dfsg-5+deb12u3 +ii libfribidi0 1.0.8-2.1 +ii libgav1-1 0.18.0-1+b1 +ii libgbm1 22.3.6-1+deb12u1 +ii libgcc-12-dev 12.2.0-14 +ii libgcc-s1 12.2.0-14 +ii libgcrypt20 1.10.1-3 +ii libgd3 2.3.3-9 +ii libgdbm-compat4 1.23-3 +ii libgdbm6 1.23-3 +ii libgdk-pixbuf-2.0-0 2.42.10+dfsg-1+deb12u1 +ii libgdk-pixbuf2.0-bin 2.42.10+dfsg-1+deb12u1 +ii libgdk-pixbuf2.0-common 2.42.10+dfsg-1+deb12u1 +ii libgl1 1.6.0-1 +ii libgl1-mesa-dri 22.3.6-1+deb12u1 +ii libglapi-mesa 22.3.6-1+deb12u1 +ii libglib2.0-0 2.74.6-2+deb12u3 +ii libglib2.0-data 2.74.6-2+deb12u3 +ii libglu1-mesa 9.0.2-1.1 +ii libglvnd0 1.6.0-1 +ii libglx-mesa0 22.3.6-1+deb12u1 +ii libglx0 1.6.0-1 +ii libgmp10 2:6.2.1+dfsg1-1.1 +ii libgnutls30 3.7.9-2+deb12u3 +ii libgomp1 12.2.0-14 +ii libgpg-error0 1.46-1 +ii libgpm2 1.20.7-10+b1 +ii libgprofng0 2.40-2 +ii libgraphite2-3 1.3.14-1 +ii libgssapi-krb5-2 1.20.1-2+deb12u2 +ii libgstreamer-plugins-base1.0-0 1.22.0-3+deb12u2 +ii libgstreamer1.0-0 1.22.0-2 +ii libgtk-3-0 3.24.38-2~deb12u2 +ii libgtk-3-bin 3.24.38-2~deb12u2 +ii libgtk-3-common 3.24.38-2~deb12u2 +ii libgudev-1.0-0 237-2 +ii libharfbuzz-subset0 6.0.0+dfsg-3 +ii libharfbuzz0b 6.0.0+dfsg-3 +ii libhogweed6 3.8.1-2 +ii libhwasan0 12.2.0-14 +ii libhyphen0 2.8.8-7 +ii libi2c0 4.3-2+b3 +ii libice6 2:1.0.10-1 +ii libicu72 72.1-3 +ii libidn2-0 2.3.3-1+b1 +ii libinput-bin 1.22.1-1 +ii libinput10 1.22.1-1 +ii libip4tc2 1.8.9-2 +ii libisl23 0.25-1.1 +ii libitm1 12.2.0-14 +ii libiw30 30~pre9-14 +ii libjansson4 2.14-2 +ii libjbig0 2.1-6.1 +ii libjim0.81 0.81+dfsg0-2 +ii libjpeg62-turbo 1:2.1.5-2 +ii libjs-jquery 3.6.1+dfsg+~3.5.14-1 +ii libjs-sphinxdoc 5.3.0-4 +ii libjs-underscore 1.13.4~dfsg+~1.11.4-3 +ii libjson-c5 0.16-2 +ii libjson-glib-1.0-0 1.6.6-1 +ii libjson-glib-1.0-common 1.6.6-1 +ii libk5crypto3 1.20.1-2+deb12u2 +ii libkeyutils1 1.6.3-2 +ii libklibc 2.0.12-1 +ii libkmod2 30+20221128-1 +ii libkrb5-3 1.20.1-2+deb12u2 +ii libkrb5support0 1.20.1-2+deb12u2 +ii libksba8 1.6.3-2 +ii liblcms2-2 2.14-2 +ii libldap-2.5-0 2.5.13+dfsg-5 +ii libldap-common 2.5.13+dfsg-5 +ii liblerc4 4.0.0+ds-2 +ii liblightdm-gobject-1-0 1.26.0-8 +ii libllvm15 1:15.0.6-4+b1 +ii liblocale-gettext-perl 1.07-5 +ii liblsan0 12.2.0-14 +ii liblz4-1 1.9.4-1 +ii liblzma5 5.4.1-0.2 +ii libmbim-glib4 1.28.2-1 +ii libmbim-proxy 1.28.2-1 +ii libmbim-utils 1.28.2-1 +ii libmd0 1.0.4-2 +ii libmd4c0 0.4.8-1 +ii libminizip1 1.1-8+deb12u1 +ii libmm-glib0 1.20.4-1 +ii libmnl0 1.0.4-3 +ii libmount1 2.38.1-5+deb12u1 +ii libmp3lame0 3.100-6 +ii libmpc3 1.3.1-1 +ii libmpfr6 4.2.0-1 +ii libmpg123-0 1.31.2-1 +ii libmtdev1 1.1.6-1 +ii libncursesw6 6.4-4 +ii libndp0 1.8-1+deb12u1 +ii libnetfilter-conntrack3 1.0.9-3 +ii libnettle8 3.8.1-2 +ii libnewt0.52 0.52.23-1+b1 +ii libnfnetlink0 1.0.2-2 +ii libnfsidmap1 1:2.6.2-4 +ii libnftables1 1.0.6-2+deb12u2 +ii libnftnl11 1.2.4-2 +ii libnghttp2-14 1.52.0-1+deb12u1 +ii libnl-3-200 3.7.0-0.2+b1 +ii libnl-genl-3-200 3.7.0-0.2+b1 +ii libnl-route-3-200 3.7.0-0.2+b1 +ii libnm0 1.42.4-1 +ii libnotify4 0.8.1-1 +ii libnpth0 1.6-3 +ii libnsl-dev 1.3.0-2 +ii libnsl2 1.3.0-2 +ii libnspr4 2:4.35-1 +ii libnss-mdns 0.15.1-3 +ii libnss3 2:3.87.1-1 +ii libnuma1 2.0.16-1 +ii libogg0 1.3.5-3 +ii libopengl0 1.6.0-1 +ii libopenjp2-7 2.5.0-2 +ii libopus0 1.3.1-3 +ii liborc-0.4-0 1:0.4.33-2 +ii libp11-kit0 0.24.1-2 +ii libpam-modules 1.5.2-6+deb12u1 +ii libpam-modules-bin 1.5.2-6+deb12u1 +ii libpam-runtime 1.5.2-6+deb12u1 +ii libpam-systemd 252.30-1~deb12u2 +ii libpam0g 1.5.2-6+deb12u1 +ii libpango-1.0-0 1.50.12+ds-1 +ii libpangocairo-1.0-0 1.50.12+ds-1 +ii libpangoft2-1.0-0 1.50.12+ds-1 +ii libparted2 3.5-3 +ii libpcap0.8 1.10.3-1 +ii libpciaccess0 0.17-2 +ii libpcre2-16-0 10.42-1 +ii libpcre2-8-0 10.42-1 +ii libpcsclite1 1.9.9-2 +ii libperl5.36 5.36.0-7+deb12u1 +ii libpipeline1 1.5.7-1 +ii libpixman-1-0 0.42.2-1 +ii libplymouth5 22.02.122-3 +ii libpng16-16 1.6.39-2 +ii libpolkit-agent-1-0 122-3 +ii libpolkit-gobject-1-0 122-3 +ii libpopt0 1.19+dfsg-1 +ii libproc2-0 2:4.0.2-3 +ii libproxy1v5 0.4.18-1.2 +ii libpsl5 0.21.2-1 +ii libpulse0 16.1+dfsg1-2+b1 +ii libpython3-dev 3.11.2-1+b1 +ii libpython3-stdlib 3.11.2-1+b1 +ii libpython3.11 3.11.2-6+deb12u2 +ii libpython3.11-dev 3.11.2-6+deb12u2 +ii libpython3.11-minimal 3.11.2-6+deb12u2 +ii libpython3.11-stdlib 3.11.2-6+deb12u2 +ii libqmi-glib5 1.32.2-1 +ii libqmi-proxy 1.32.2-1 +ii libqmi-utils 1.32.2-1 +ii libqrtr-glib0 1.2.2-1 +ii libqscintilla2-qt5-15 2.13.3+dfsg-3 +ii libqscintilla2-qt5-l10n 2.13.3+dfsg-3 +ii libqt5bluetooth5 5.15.8-2 +ii libqt5bluetooth5-bin 5.15.8-2 +ii libqt5charts5 5.15.8-2 +ii libqt5core5a 5.15.8+dfsg-11+deb12u2 +ii libqt5dbus5 5.15.8+dfsg-11+deb12u2 +ii libqt5designer5 5.15.8-2 +ii libqt5gui5 5.15.8+dfsg-11+deb12u2 +ii libqt5help5 5.15.8-2 +ii libqt5location5 5.15.8+dfsg-3+deb12u1 +ii libqt5multimedia5 5.15.8-2 +ii libqt5multimediawidgets5 5.15.8-2 +ii libqt5network5 5.15.8+dfsg-11+deb12u2 +ii libqt5nfc5 5.15.8-2 +ii libqt5opengl5 5.15.8+dfsg-11+deb12u2 +ii libqt5positioning5 5.15.8+dfsg-3+deb12u1 +ii libqt5positioningquick5 5.15.8+dfsg-3+deb12u1 +ii libqt5printsupport5 5.15.8+dfsg-11+deb12u2 +ii libqt5qml5 5.15.8+dfsg-3 +ii libqt5qmlmodels5 5.15.8+dfsg-3 +ii libqt5quick5 5.15.8+dfsg-3 +ii libqt5quickwidgets5 5.15.8+dfsg-3 +ii libqt5remoteobjects5 5.15.8-2 +ii libqt5sensors5 5.15.8-2 +ii libqt5serialport5 5.15.8-2 +ii libqt5sql5 5.15.8+dfsg-11+deb12u2 +ii libqt5sql5-sqlite 5.15.8+dfsg-11+deb12u2 +ii libqt5svg5 5.15.8-3 +ii libqt5test5 5.15.8+dfsg-11+deb12u2 +ii libqt5texttospeech5 5.15.8-2 +ii libqt5waylandclient5 5.15.8-2 +ii libqt5waylandcompositor5 5.15.8-2 +ii libqt5webchannel5 5.15.8-2 +ii libqt5webengine-data 5.15.13+dfsg-1~deb12u1 +ii libqt5webengine5 5.15.13+dfsg-1~deb12u1 +ii libqt5webenginecore5 5.15.13+dfsg-1~deb12u1 +ii libqt5webenginewidgets5 5.15.13+dfsg-1~deb12u1 +ii libqt5webkit5 5.212.0~alpha4-30 +ii libqt5websockets5 5.15.8-2 +ii libqt5widgets5 5.15.8+dfsg-11+deb12u2 +ii libqt5x11extras5 5.15.8-2 +ii libqt5xml5 5.15.8+dfsg-11+deb12u2 +ii libqt5xmlpatterns5 5.15.8-2 +ii libqwt-qt5-6 6.1.4-2 +ii librav1e0 0.5.1-6 +ii libre2-9 20220601+dfsg-1+b1 +ii libreadline8 8.2-1.3 +ii librsvg2-2 2.54.7+dfsg-1~deb12u1 +ii librsvg2-common 2.54.7+dfsg-1~deb12u1 +ii librtmp1 2.4+20151223.gitfa8646d.1-2+b2 +ii libsamplerate0 0.2.2-3 +ii libsasl2-2 2.1.28+dfsg-10 +ii libsasl2-modules 2.1.28+dfsg-10 +ii libsasl2-modules-db 2.1.28+dfsg-10 +ii libseccomp2 2.5.4-1+deb12u1 +ii libselinux1 3.4-1+b6 +ii libsemanage-common 3.4-1 +ii libsemanage2 3.4-1+b5 +ii libsensors-config 1:3.6.0-7.1 +ii libsensors5 1:3.6.0-7.1 +ii libsepol2 3.4-2.1 +ii libslang2 2.3.3-3 +ii libsm6 2:1.2.3-1 +ii libsmartcols1 2.38.1-5+deb12u1 +ii libsnappy1v5 1.1.9-3 +ii libsndfile1 1.2.0-1 +ii libsodium23 1.0.18-1 +ii libsoup-3.0-0 3.2.2-2 +ii libsoup-3.0-common 3.2.2-2 +ii libspeechd2 0.11.4-2 +ii libsqlite3-0 3.40.1-2 +ii libss2 1.47.0-2 +ii libssh2-1 1.10.0-3+b1 +ii libssl-dev 3.0.14-1~deb12u1 +ii libssl3 3.0.14-1~deb12u1 +ii libstartup-notification0 0.12-6+b1 +ii libstdc++-12-dev 12.2.0-14 +ii libstdc++6 12.2.0-14 +ii libsvtav1enc1 1.4.1+dfsg-1 +ii libsystemd-shared 252.30-1~deb12u2 +ii libsystemd0 252.30-1~deb12u2 +ii libtasn1-6 4.19.0-2 +ii libteamdctl0 1.31-1 +ii libtext-charwidth-perl 0.04-11 +ii libtext-iconv-perl 1.7-8 +ii libtext-wrapi18n-perl 0.06-10 +ii libthai-data 0.1.29-1 +ii libthai0 0.1.29-1 +ii libtheora0 1.1.1+dfsg.1-16.1+b1 +ii libtiff6 4.5.0-6+deb12u1 +ii libtinfo6 6.4-4 +ii libtirpc-common 1.3.3+ds-1 +ii libtirpc-dev 1.3.3+ds-1 +ii libtirpc3 1.3.3+ds-1 +ii libts-bin 1.22-1+b1 +ii libts0 1.22-1+b1 +ii libtsan2 12.2.0-14 +ii libubootenv-tool 0.3.2-1 +ii libubootenv0.1 0.3.2-1 +ii libubsan1 12.2.0-14 +ii libuchardet0 0.0.7-1 +ii libudev1 252.30-1~deb12u2 +ii libunistring2 1.0-2 +ii libunwind8 1.6.2-3 +ii libusb-1.0-0 2:1.0.26-1 +ii libutempter0 1.2.1-3 +ii libuuid1 2.38.1-5+deb12u1 +ii libvisual-0.4-0 0.4.0-19 +ii libvorbis0a 1.3.7-1 +ii libvorbisenc2 1.3.7-1 +ii libvpx7 1.12.0-1+deb12u3 +ii libwacom-common 2.6.0-1 +ii libwacom9 2.6.0-1 +ii libwayland-client0 1.21.0-1 +ii libwayland-cursor0 1.21.0-1 +ii libwayland-egl1 1.21.0-1 +ii libwayland-server0 1.21.0-1 +ii libwebp7 1.2.4-0.2+deb12u1 +ii libwebpdemux2 1.2.4-0.2+deb12u1 +ii libwebpmux3 1.2.4-0.2+deb12u1 +ii libwnck-3-0 43.0-3 +ii libwnck-3-common 43.0-3 +ii libwoff1 1.0.2-2 +ii libwrap0 7.6.q-32 +ii libx11-6 2:1.8.4-2+deb12u2 +ii libx11-data 2:1.8.4-2+deb12u2 +ii libx11-xcb1 2:1.8.4-2+deb12u2 +ii libx265-199 3.5-2+b1 +ii libxau6 1:1.0.9-1 +ii libxaw7 2:1.0.14-1 +ii libxcb-damage0 1.15-1 +ii libxcb-dri2-0 1.15-1 +ii libxcb-dri3-0 1.15-1 +ii libxcb-glx0 1.15-1 +ii libxcb-icccm4 0.4.1-1.1 +ii libxcb-image0 0.4.0-2 +ii libxcb-keysyms1 0.4.0-1+b2 +ii libxcb-present0 1.15-1 +ii libxcb-randr0 1.15-1 +ii libxcb-render-util0 0.3.9-1+b1 +ii libxcb-render0 1.15-1 +ii libxcb-shape0 1.15-1 +ii libxcb-shm0 1.15-1 +ii libxcb-sync1 1.15-1 +ii libxcb-util1 0.4.0-1+b1 +ii libxcb-xfixes0 1.15-1 +ii libxcb-xinerama0 1.15-1 +ii libxcb-xinput0 1.15-1 +ii libxcb-xkb1 1.15-1 +ii libxcb1 1.15-1 +ii libxcomposite1 1:0.4.5-1 +ii libxcursor1 1:1.2.1-1 +ii libxcvt0 0.1.2-1 +ii libxdamage1 1:1.1.6-1 +ii libxdmcp6 1:1.1.2-3 +ii libxext6 2:1.3.4-1+b1 +ii libxfce4ui-2-0 4.18.2-2 +ii libxfce4ui-common 4.18.2-2 +ii libxfce4util-bin 4.18.1-2 +ii libxfce4util-common 4.18.1-2 +ii libxfce4util7 4.18.1-2 +ii libxfconf-0-3 4.18.0-2 +ii libxfixes3 1:6.0.0-2 +ii libxfont2 1:2.0.6-1 +ii libxft2 2.3.6-1 +ii libxi6 2:1.8-1+b1 +ii libxinerama1 2:1.1.4-3 +ii libxkbcommon-x11-0 1.5.0-1 +ii libxkbcommon0 1.5.0-1 +ii libxkbfile1 1:1.1.0-1 +ii libxklavier16 5.4-4 +ii libxml2 2.9.14+dfsg-1.3~deb12u1 +ii libxmu6 2:1.1.3-3 +ii libxmuu1 2:1.1.3-3 +ii libxpm4 1:3.5.12-1.1+deb12u1 +ii libxpresent1 1.0.0-2+b10 +ii libxrandr2 2:1.5.2-2+b1 +ii libxrender1 1:0.9.10-1.1 +ii libxres1 2:1.2.1-1 +ii libxshmfence1 1.3-1 +ii libxslt1.1 1.1.35-1 +ii libxt6 1:1.2.1-1.1 +ii libxtables12 1.8.9-2 +ii libxtst6 2:1.2.3-1.1 +ii libxv1 2:1.0.11-1.1 +ii libxxf86dga1 2:1.1.5-1 +ii libxxf86vm1 1:1.1.4-1+b2 +ii libxxhash0 0.8.1-1 +ii libyaml-0-2 0.2.5-1 +ii libyuv0 0.0~git20230123.b2528b0-1 +ii libz3-4 4.8.12-3.1 +ii libzstd1 1.5.4+dfsg2-5 +ii lightdm 1.26.0-8 +ii lightdm-gtk-greeter 2.0.8-2+b1 +ii linux-base 4.9 +ii linux-libc-dev 6.1.106-3 +ii locales 2.36-9+deb12u8 +ii login 1:4.13+dfsg1-1+b1 +ii logrotate 3.21.0-1 +ii logsave 1.47.0-2 +ii m4 1.4.19-3 +ii make 4.3-4.1 +ii man-db 2.11.2-2 +ii manpages 6.03-2 +ii manpages-dev 6.03-2 +ii map-device 0.0.1 +ii mawk 1.3.4.20200120-3.1 +ii media-types 10.0.0 +ii modemmanager 1.20.4-1 +ii mount 2.38.1-5+deb12u1 +ii nano 7.2-1+deb12u1 +ii ncurses-base 6.4-4 +ii ncurses-bin 6.4-4 +ii ncurses-term 6.4-4 +ii net-tools 2.10-0.1 +ii netbase 6.4 +ii network-manager 1.42.4-1 +ii nfs-common 1:2.6.2-4 +ii nftables 1.0.6-2+deb12u2 +ii ntpdate 1:4.2.8p15+dfsg-2~1.2.2+dfsg1-1+deb12u1 +ii ntpsec-ntpdate 1.2.2+dfsg1-1+deb12u1 +ii ntpsec-ntpdig 1.2.2+dfsg1-1+deb12u1 +ii openssh-client 1:9.2p1-2+deb12u3 +ii openssh-server 1:9.2p1-2+deb12u3 +ii openssh-sftp-server 1:9.2p1-2+deb12u3 +ii openssl 3.0.14-1~deb12u1 +ii parted 3.5-3 +ii passwd 1:4.13+dfsg1-1+b1 +ii patch 2.7.6-7 +ii perl 5.36.0-7+deb12u1 +ii perl-base 5.36.0-7+deb12u1 +ii perl-modules-5.36 5.36.0-7+deb12u1 +ii pinentry-curses 1.2.1-1 +ii pkexec 122-3 +ii plymouth 22.02.122-3 +ii plymouth-label 22.02.122-3 +ii plymouth-themes 22.02.122-3 +ii plymouth-themes-walnutpi 1.0.0 +ii policykit-1 122-3 +ii polkitd 122-3 +ii polkitd-pkla 122-3 +ii ppp 2.4.9-1+1.1+b1 +ii procps 2:4.0.2-3 +ii publicsuffix 20230209.2326-1 +ii python-dev-is-python3 3.11.2-1+deb12u1 +ii python-is-python3 3.11.2-1+deb12u1 +ii python3 3.11.2-1+b1 +ii python3-dev 3.11.2-1+b1 +ii python3-distutils 3.11.2-3 +ii python3-lib2to3 3.11.2-3 +ii python3-minimal 3.11.2-1+b1 +ii python3-ntp 1.2.2+dfsg1-1+deb12u1 +ii python3-pip 23.0.1+dfsg-1 +ii python3-pkg-resources 66.1.1-1 +ii python3-pyqt5 5.15.9+dfsg-1 +ii python3-pyqt5.qsci 2.13.3+dfsg-3 +ii python3-pyqt5.qtbluetooth 5.15.9+dfsg-1 +ii python3-pyqt5.qtchart 5.15.6+dfsg-1 +ii python3-pyqt5.qtmultimedia 5.15.9+dfsg-1 +ii python3-pyqt5.qtnfc 5.15.9+dfsg-1 +ii python3-pyqt5.qtopengl 5.15.9+dfsg-1 +ii python3-pyqt5.qtpositioning 5.15.9+dfsg-1 +ii python3-pyqt5.qtquick 5.15.9+dfsg-1 +ii python3-pyqt5.qtremoteobjects 5.15.9+dfsg-1 +ii python3-pyqt5.qtsensors 5.15.9+dfsg-1 +ii python3-pyqt5.qtserialport 5.15.9+dfsg-1 +ii python3-pyqt5.qtsql 5.15.9+dfsg-1 +ii python3-pyqt5.qtsvg 5.15.9+dfsg-1 +ii python3-pyqt5.qttexttospeech 5.15.9+dfsg-1 +ii python3-pyqt5.qtwebchannel 5.15.9+dfsg-1 +ii python3-pyqt5.qtwebengine 5.15.6-1 +ii python3-pyqt5.qtwebkit 5.15.9+dfsg-1 +ii python3-pyqt5.qtwebsockets 5.15.9+dfsg-1 +ii python3-pyqt5.qtx11extras 5.15.9+dfsg-1 +ii python3-pyqt5.qtxmlpatterns 5.15.9+dfsg-1 +ii python3-pyqt5.qwt 1.02.02-2+b4 +ii python3-pyqt5.sip 12.11.1-1 +ii python3-setuptools 66.1.1-1 +ii python3-wheel 0.38.4-2 +ii python3.11 3.11.2-6+deb12u2 +ii python3.11-dev 3.11.2-6+deb12u2 +ii python3.11-minimal 3.11.2-6+deb12u2 +ii pyuic 1.0.0 +ii qt5-gtk-platformtheme 5.15.8+dfsg-11+deb12u2 +ii qtspeech5-speechd-plugin 5.15.8-2 +ii qttranslations5-l10n 5.15.8-2 +ii qtwayland5 5.15.8-2 +ii read-edid 3.0.2-1.1 +ii readline-common 8.2-1.3 +ii rfkill 2.38.1-5+deb12u1 +ii rpcbind 1.2.6-6+b1 +ii rpcsvc-proto 1.4.3-1 +ii runit-helper 2.15.2 +ii sed 4.9-1 +ii sensible-utils 0.0.17+nmu1 +ii service-aw859-bluetooth 1.0.0 +ii service-boot-script 1.2.0 +ii service-disable-fbcon 1.0.0 +ii service-h616-conf-thermal-trip 1.0. +ii service-h616-mac-detect 1.0.1 +ii service-resize-part 1.0.2 +ii service-w1b-dis-mmc2 1.0.0 +ii set-device 1.1.1 +ii set-emmc 1.1.0 +ii set-language 1.0.1 +ii set-lcd 1.1.4 +ii sgml-base 1.31 +ii shared-mime-info 2.2-1 +ii ssh 1:9.2p1-2+deb12u3 +ii sudo 1.9.13p3-1+deb12u1 +ii systemd 252.30-1~deb12u2 +ii systemd-sysv 252.30-1~deb12u2 +ii systemd-timesyncd 252.30-1~deb12u2 +ii sysvinit-utils 3.06-4 +ii tar 1.34+dfsg-1.2+deb12u1 +ii tasksel 3.73 +ii tasksel-data 3.73 +ii toilet 0.3-1.4 +ii toilet-fonts 0.3-1.4 +ii tree 2.1.0-1 +ii tzdata 2024a-0+deb12u1 +ii u-boot-tools 2023.01+dfsg-2+deb12u1 +ii ucf 3.0043+nmu1 +ii udev 252.30-1~deb12u2 +ii usb-modeswitch 2.6.1-3+b1 +ii usb-modeswitch-data 20191128-5 +ii usbutils 1:014-1+deb12u1 +ii usr-is-merged 37~deb12u1 +ii util-linux 2.38.1-5+deb12u1 +ii util-linux-extra 2.38.1-5+deb12u1 +ii vim 2:9.0.1378-2 +ii vim-common 2:9.0.1378-2 +ii vim-runtime 2:9.0.1378-2 +ii vim-tiny 2:9.0.1378-2 +ii walnutpi-lib 1.1.0 +ii wget 1.21.3-1+b1 +ii whiptail 0.52.23-1+b1 +ii wireless-regdb 2022.06.06-1 +ii wireless-tools 30~pre9-14 +ii wpasupplicant 2:2.10-12+deb12u2 +ii wpi-host-name 1.0.0 +ii wpi-lightdm 1.0.0 +ii wpi-user 1.0.2 +ii wpi-welcome-info 1.0.0 +ii x11-apps 7.7+9 +ii x11-common 1:7.7+23 +ii x11-session-utils 7.7+5 +ii x11-utils 7.7+5 +ii x11-xkb-utils 7.7+7 +ii x11-xserver-utils 7.7+9+b1 +ii xauth 1:1.1.2-1 +ii xbitmaps 1.1.1-2.2 +ii xcvt 0.1.2-1 +ii xdg-user-dirs 0.18-1 +ii xfconf 4.18.0-2 +ii xfonts-100dpi 1:1.0.5 +ii xfonts-75dpi 1:1.0.5 +ii xfonts-base 1:1.0.5+nmu1 +ii xfonts-encodings 1:1.0.4-2.2 +ii xfonts-scalable 1:1.0.3-1.3 +ii xfonts-utils 1:7.7+6 +ii xfwm4 4.18.0-1 +ii xinit 1.4.0-1 +ii xinput-calibrator 0.7.5+git20140201-1+b2 +ii xkb-data 2.35.1-1 +ii xml-core 0.18+nmu1 +ii xorg 1:7.7+23 +ii xorg-docs-core 1:1.7.1-1.2 +ii xserver-common 2:21.1.7-3+deb12u7 +ii xserver-xorg 1:7.7+23 +ii xserver-xorg-core 2:21.1.7-3+deb12u7 +ii xserver-xorg-input-all 1:7.7+23 +ii xserver-xorg-input-evdev 1:2.10.6-2+b1 +ii xserver-xorg-input-libinput 1.2.1-1+b1 +ii xserver-xorg-input-synaptics 1.9.2-1+b1 +ii xserver-xorg-input-wacom 1.1.0-1 +ii xserver-xorg-legacy 2:21.1.7-3+deb12u7 +ii xserver-xorg-video-all 1:7.7+23 +ii xserver-xorg-video-amdgpu 23.0.0-1 +ii xserver-xorg-video-ati 1:19.1.0-3 +ii xserver-xorg-video-fbdev 1:0.5.0-2 +ii xserver-xorg-video-nouveau 1:1.0.17-2 +ii xserver-xorg-video-radeon 1:19.1.0-3 +ii xserver-xorg-video-vesa 1:2.5.0-1+b1 +ii xterm 379-1 +ii xz-utils 5.4.1-0.2 +ii zlib1g 1:1.2.13.dfsg-1 +ii zlib1g-dev 1:1.2.13.dfsg-1 +ii zstd 1.5.4+dfsg2-5 diff --git a/发布更新相关/其他依赖/debian12-aarch64/RESOLUTION.json b/发布更新相关/其他依赖/debian12-aarch64/RESOLUTION.json new file mode 100644 index 0000000..7114134 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/RESOLUTION.json @@ -0,0 +1,627 @@ +{ + "schema_version": 1, + "architecture": "arm64", + "roots": [ + "ffmpeg", + "libheif-examples", + "python3.11-venv" + ], + "inventory_sha256": "28ee670eb0aaf53dbfcbd06259c752dcf767c1e33e26225aea31f65f2bade35c", + "indices": [ + { + "file": "bookworm-security-main-Packages.xz", + "sha256": "51b56de7a84be578bb2baf0197787cf9f671ed1c3b436787c13b9a9c04871612", + "base": "https://mirrors.tuna.tsinghua.edu.cn/debian-security/" + }, + { + "file": "bookworm-updates-main-Packages.xz", + "sha256": "5dfd52317071685ad6e58c4ef0720128b7db68624079f83be02bdf55f07ac070", + "base": "https://mirrors.tuna.tsinghua.edu.cn/debian/" + }, + { + "file": "bookworm-main-Packages.xz", + "sha256": "2ddb1737692e8c45c53e8d57c0ce4cd21c78c5703b830c3226b1423566a06c00", + "base": "https://mirrors.tuna.tsinghua.edu.cn/debian/" + } + ], + "selected": [ + { + "package": "ffmpeg", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "7890aeb19a94d73e5d9281afa5ca07005804c1fca4f10791f4f2ce0df741b610", + "size": 1807012 + }, + { + "package": "gcc-12-base", + "version": "12.2.0-14+deb12u1", + "source": "local", + "sha256": "674cf6cba6d432bd200c45fe866c1652c7a53523cc2e7a613e05bc4abf7b5440", + "size": 37624 + }, + { + "package": "libass9", + "version": "1:0.17.1-1", + "source": "local", + "sha256": "feb4cef72caf849fae2a13e1f757f9cc9fbfe3ddfb877e42cc888a7d313e298c", + "size": 91112 + }, + { + "package": "libavc1394-0", + "version": "0.5.4-5", + "source": "local", + "sha256": "5a6ad45da10d69ca6ca7757882fe696ace8af98fccac781697ef280ea13e770f", + "size": 18368 + }, + { + "package": "libavcodec59", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "74468871b1b95f28476a3cb63deafe21c97e57289ac286e760384273b0c39fc1", + "size": 4811760 + }, + { + "package": "libavdevice59", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "46c574e3ca73624d88af636e89703820b1c57657090bd89eb02cef727076ed1b", + "size": 119244 + }, + { + "package": "libavfilter8", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "6156525ccfd083bca35545a2b227839b2978f6d6c02cd10a1fe4691989e80a4e", + "size": 3321836 + }, + { + "package": "libavformat59", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "600ddadab359b552bc890323c9ca603bd0f08f4e324378e4530321c0f1900990", + "size": 1054596 + }, + { + "package": "libavutil57", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "9c5bd3feecf46c9eab1d6b4e65830acaf8e2d6caee008bd4f94e1adf22a63f31", + "size": 341460 + }, + { + "package": "libblas3", + "version": "3.11.0-2", + "source": "local", + "sha256": "054aec63b247d157b4b475ca26763538d5dcbd32894734db6b5cae6c7aa335f2", + "size": 91444 + }, + { + "package": "libbluray2", + "version": "1:1.3.4-1", + "source": "local", + "sha256": "f8f28e8e099f6c872053abf79755514a83d1cdc035b3774c29b0d75ebc7f0d11", + "size": 123524 + }, + { + "package": "libbs2b0", + "version": "3.1.0+dfsg-7", + "source": "local", + "sha256": "6145ea38842a1cc5d2f49e1a00bfb65426024c8c8fe48edbe59d3f8366ef565b", + "size": 10656 + }, + { + "package": "libcdio-cdda2", + "version": "10.2+2.0.1-1", + "source": "local", + "sha256": "20515c1a5df7fce5c3ff41b5a5abf10f0294b485577ea4d461186ebc4e79c014", + "size": 20112 + }, + { + "package": "libcdio-paranoia2", + "version": "10.2+2.0.1-1", + "source": "local", + "sha256": "f960dc3ab9f6bd6aee925350a19736a86d44ef8326177ce8127ae8df443490b6", + "size": 19568 + }, + { + "package": "libcdio19", + "version": "2.1.0-4", + "source": "local", + "sha256": "f1b9c80716ba707e46ced45b454246b4b03f93ef9772e92af0e3b86ab0d56e2d", + "size": 201476 + }, + { + "package": "libchromaprint1", + "version": "1.5.1-2+b1", + "source": "local", + "sha256": "6f73d7b541247024746730858ebd5729acf6c017e189c2be25b13c6e7d92853f", + "size": 37404 + }, + { + "package": "libcjson1", + "version": "1.7.15-1+deb12u4", + "source": "local", + "sha256": "67b9f4d780a401b28bcce3d23a0a26efbdf040d7a012f1d73f8142a641925e2e", + "size": 22724 + }, + { + "package": "libcodec2-1.0", + "version": "1.0.5-1", + "source": "local", + "sha256": "13c5182958d011333d30e1459e5e72acb24a2b3433ac4dd027d35f1afdcb6338", + "size": 8163248 + }, + { + "package": "libdc1394-25", + "version": "2.2.6-4", + "source": "local", + "sha256": "82c939e239dc710675344c25f792341d00b9c4329c0c62b4146e06ffe928310a", + "size": 105424 + }, + { + "package": "libdecor-0-0", + "version": "0.1.1-2", + "source": "local", + "sha256": "e88d3c2dd3336a97b8cf10ef270a264388fce81cedf8032b18f2a36b021bc6a3", + "size": 14152 + }, + { + "package": "libflite1", + "version": "2.2-5", + "source": "local", + "sha256": "a32d8cfe4eda5e98dc4332ffb2520d3fb65565265bb869eb101f6b8b21f0115d", + "size": 12814836 + }, + { + "package": "libgfortran5", + "version": "12.2.0-14+deb12u1", + "source": "local", + "sha256": "583497cf5a95960c11f2006586050375c4adbc4b0c569a7e7df346f2bf380cbc", + "size": 339792 + }, + { + "package": "libgme0", + "version": "0.6.3-6", + "source": "local", + "sha256": "561758595dc64d42e3ef48da13f87025af713a6dd240631eb68334bfcfcf8c97", + "size": 117960 + }, + { + "package": "libgsm1", + "version": "1.0.22-1", + "source": "local", + "sha256": "4c0041b45a3e2535dfcdb91222fbd9cb2a79c9ba0e38d6d44ad727424b457775", + "size": 28816 + }, + { + "package": "libheif-examples", + "version": "1.15.1-1+deb12u1", + "source": "local", + "sha256": "32c76d827664fe5c90b8a3af74e6b333f03d4a3dda1ade43fd0c12e97370a02f", + "size": 41724 + }, + { + "package": "libheif1", + "version": "1.15.1-1+deb12u1", + "source": "local", + "sha256": "92dddfa77e4281997af385cb2bc9e8ffa13b0876f889a3d6d6ad177ac968a2bc", + "size": 190616 + }, + { + "package": "libhwy1", + "version": "1.0.3-3+deb12u1", + "source": "local", + "sha256": "bfd492bb0c5c5072c67ea053c3ca8863df67d14d066b4dddd519eeae8cbcd92f", + "size": 225744 + }, + { + "package": "libiec61883-0", + "version": "1.2.0-6+b1", + "source": "local", + "sha256": "55dbcc899d6f647b0a6d0299f8430e3737059185c72f7d51ba8798ce797a140c", + "size": 29032 + }, + { + "package": "libjack-jackd2-0", + "version": "1.9.21~dfsg-3", + "source": "local", + "sha256": "9b4bef2006edefa4d015918b954b315a91829a3c5a34ca5f9ca1a752b90f4ea8", + "size": 261068 + }, + { + "package": "libjxl0.7", + "version": "0.7.0-10+deb12u1", + "source": "local", + "sha256": "fc46d9d24c22e16726cc0fe131f52c4b3cc08db7ba408ef15173841bd3933f2e", + "size": 643892 + }, + { + "package": "liblapack3", + "version": "3.11.0-2", + "source": "local", + "sha256": "7f6310fbd910326a63dedd87da046cd6eff3f644e9c00173f3afce20b7b2a93e", + "size": 1664496 + }, + { + "package": "liblilv-0-0", + "version": "0.24.14-1", + "source": "local", + "sha256": "e3c0ebc8374617e26fa81e87714dbcbf7a04002cd3a11470ec4240692bf29ced", + "size": 46808 + }, + { + "package": "libmbedcrypto7", + "version": "2.28.3-1", + "source": "local", + "sha256": "018c421355f3fbbf11a5132a2d5e3b2c2f6b057ead517002da5547be2817a731", + "size": 262868 + }, + { + "package": "libmysofa1", + "version": "1.3.1~dfsg0-1", + "source": "local", + "sha256": "ec983f89355ac7f22fc486eaabc10c5c6192c56f02aec0c0ff50a7ce8e86a9ea", + "size": 1155764 + }, + { + "package": "libnorm1", + "version": "1.5.9+dfsg-2", + "source": "local", + "sha256": "3974ddba57ed9bbfff10a144baae64d647adbb38ebce5fa122e4f0dd16b77866", + "size": 202432 + }, + { + "package": "libopenal-data", + "version": "1:1.19.1-2", + "source": "local", + "sha256": "695a650803f885459994bb921132d956c2b693759572005351a5b13773c754cd", + "size": 170228 + }, + { + "package": "libopenal1", + "version": "1:1.19.1-2", + "source": "local", + "sha256": "848f7cb93823c780ab58c0da67535316ef797666934d06ed6d64e902d4e2df11", + "size": 485416 + }, + { + "package": "libopenmpt0", + "version": "0.6.9-1", + "source": "local", + "sha256": "168a759cc2cfec251b757527fae13989b2306a8f778627aedf61000378149a52", + "size": 621956 + }, + { + "package": "libpgm-5.3-0", + "version": "5.3.128~dfsg-2", + "source": "local", + "sha256": "6a632aa13cafe154d6212a57710f058405dd895fb62d464e16961b28d7325cbb", + "size": 151640 + }, + { + "package": "libplacebo208", + "version": "4.208.0-3", + "source": "local", + "sha256": "ea1d0e24255b74e94155daaba32f4467d6a746447ce2dc4e6d122a670d600f6c", + "size": 2003668 + }, + { + "package": "libpocketsphinx3", + "version": "0.8+5prealpha+1-15", + "source": "local", + "sha256": "5792065cf9dc3e1c5413f1f87ba95845b3daf90ef542ed2786bf64c36b846a60", + "size": 114020 + }, + { + "package": "libpostproc56", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "ffafb4b6fc84108c2113386fe2d9a7f8ab2c1bcb0dca1b8d84f2ccf4ecba6177", + "size": 82604 + }, + { + "package": "libpython3.11-minimal", + "version": "3.11.2-6+deb12u3", + "source": "pool/updates/main/p/python3.11/libpython3.11-minimal_3.11.2-6+deb12u3_arm64.deb", + "sha256": "77b244e4ed2ce3ec3f23cb20ed1613c0cc0caa78bd6a5d305772b3a234cb227b", + "size": 808228 + }, + { + "package": "libpython3.11-stdlib", + "version": "3.11.2-6+deb12u3", + "source": "pool/updates/main/p/python3.11/libpython3.11-stdlib_3.11.2-6+deb12u3_arm64.deb", + "sha256": "f5adb88b46dcccdd1acfea3a5eb6ff26a665a7c41f4530e7e4af70e68f67d59c", + "size": 1746676 + }, + { + "package": "librabbitmq4", + "version": "0.11.0-1+deb12u2", + "source": "local", + "sha256": "6e4cdedc6de5514b867df1b7cf7cc075fdb95e59ab84b439ea47f5f75d01c1e5", + "size": 39872 + }, + { + "package": "libraw1394-11", + "version": "2.1.2-2", + "source": "local", + "sha256": "81a1e9bd2b790aa7ce7c426dd1742d3310894b01f124bc41962331b834da6cc3", + "size": 40316 + }, + { + "package": "librist4", + "version": "0.2.7+dfsg-1", + "source": "local", + "sha256": "35df536e53aed81b4da5c4568ad7f98375318e6611ebaae4c4140e9a5c4ca2a9", + "size": 69620 + }, + { + "package": "librubberband2", + "version": "3.1.2+dfsg0-1", + "source": "local", + "sha256": "fb97c395697ab03ce4d06d82ef293a6f1fef62ea79299b686d0f498eb6839d7e", + "size": 117612 + }, + { + "package": "libsdl2-2.0-0", + "version": "2.26.5+dfsg-1", + "source": "local", + "sha256": "7d875b119108a240015e7739c7a0de402616975281bcd8169cb434326fa93a66", + "size": 565580 + }, + { + "package": "libserd-0-0", + "version": "0.30.16-1", + "source": "local", + "sha256": "cf26049564c27b60e1de39c3207743ca05d9ee34e25d8ed4027033d1afa22852", + "size": 44708 + }, + { + "package": "libshine3", + "version": "3.1.1-2", + "source": "local", + "sha256": "d64982ddfbdfcef714dc35a760a752ab1f3739aaee8011bc5c3f03afa73f465f", + "size": 22980 + }, + { + "package": "libsndio7.0", + "version": "1.9.0-0.3+b2", + "source": "local", + "sha256": "e7c787895eacf8363a4dea6a0cb3237a78857691d8d53f60a1a3ffeea3819808", + "size": 25716 + }, + { + "package": "libsord-0-0", + "version": "0.16.14+git221008-1", + "source": "local", + "sha256": "fbfcbb5115fc85c8e659ef283f1eb588f08c77d89f762feaa96589ad9143e58c", + "size": 20004 + }, + { + "package": "libsoxr0", + "version": "0.1.3-4", + "source": "local", + "sha256": "8f1b1780a5ced7a0821a8040d0f3ab5815acd5d2f38b12cca915a9a9d0ee28b7", + "size": 54376 + }, + { + "package": "libspeex1", + "version": "1.2.1-2", + "source": "local", + "sha256": "5fc81ad2d0ead5b811381fbb47ef98c1bf063c9283470d795541f3c1cb95991a", + "size": 48304 + }, + { + "package": "libsphinxbase3", + "version": "0.8+5prealpha+1-16", + "source": "local", + "sha256": "bd42e9aaf59b23e3c3d9014875b148e5cf056f0582d22ba9f882c09f57507060", + "size": 108904 + }, + { + "package": "libsratom-0-0", + "version": "0.6.14-1", + "source": "local", + "sha256": "a2f9d014bf2acaf67499b1cae7dd7f1a857a676d99849219b23ae2f4980a9b20", + "size": 15868 + }, + { + "package": "libsrt1.5-gnutls", + "version": "1.5.1-1+deb12u1", + "source": "local", + "sha256": "3cfb9d8d26459e3538473683a8e297429d0662eea4c51a01c736bf06608354c5", + "size": 272080 + }, + { + "package": "libssh-gcrypt-4", + "version": "0.10.6-0+deb12u1", + "source": "local", + "sha256": "bd6bf5ad88a9cf8fb8e43667e27420ed68be6d6b03a2bdfdf111cff48a1d5b6d", + "size": 206548 + }, + { + "package": "libswresample4", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "c723d38ce00cdcbf779add81d90d3510525424dd174ce6880520a0147453ddeb", + "size": 98904 + }, + { + "package": "libswscale6", + "version": "7:5.1.9-0+deb12u1", + "source": "local", + "sha256": "deb1abe7e40fdc9f38f54d7a87d1d2601f54447198221c4e1bb98da64a60d1ef", + "size": 190152 + }, + { + "package": "libtwolame0", + "version": "0.4.0-2", + "source": "local", + "sha256": "73780c555796569a252ec5cf1ea919e59ef4031298bc4be1516a6a0e83f06b5a", + "size": 48548 + }, + { + "package": "libudfread0", + "version": "1.1.2-1", + "source": "local", + "sha256": "693e1e62806759c6f55585cd1884cc851ac9a1b0a3ea7df55c51176b0b450fd4", + "size": 15700 + }, + { + "package": "libva-drm2", + "version": "2.17.0-1", + "source": "local", + "sha256": "dd1c3981a81171f3886f14b56e2c13cb879111109e5b1e3e786c26641ab2cc2f", + "size": 16328 + }, + { + "package": "libva-x11-2", + "version": "2.17.0-1", + "source": "local", + "sha256": "326f1a6d764adae4ccdc606913ba60b3aecc62788c509444751e7fea75a13584", + "size": 20808 + }, + { + "package": "libva2", + "version": "2.17.0-1", + "source": "local", + "sha256": "b737136a783067f8f202115ed820d955aea16b95a6f06c406cc4305f99d20d3e", + "size": 65168 + }, + { + "package": "libvdpau1", + "version": "1.5-2", + "source": "local", + "sha256": "2f13f0bce02b7c5c5caecf5522e22852e296d93b2275c6a445dbf5427605b93c", + "size": 23712 + }, + { + "package": "libvidstab1.1", + "version": "1.1.0-2+b1", + "source": "local", + "sha256": "39a88b11d1c727f43a4301d8408dfcbde2d6980e4d6295fe9f9f0447c9436971", + "size": 36440 + }, + { + "package": "libvorbisfile3", + "version": "1.3.7-1", + "source": "local", + "sha256": "f8f418e15f99905d4a2d532617511a11d700e814f8ead1a883deea2f7241970c", + "size": 25376 + }, + { + "package": "libvulkan1", + "version": "1.3.239.0-1", + "source": "local", + "sha256": "3a21d49fef3a9799a21ffd5faa5bd7a1bd35604417cd9f5cf74155afd3b5282e", + "size": 111080 + }, + { + "package": "libx264-164", + "version": "2:0.164.3095+gitbaee400-3", + "source": "local", + "sha256": "95475856611f971affe2dfaccc85253d1c6bb2fcb1d67c3147b32021cf304b8e", + "size": 442908 + }, + { + "package": "libxss1", + "version": "1:1.2.3-1", + "source": "local", + "sha256": "e0ff80e309eacda5face68b9a3bd56718fed2750af429324c35d7c9491c335f4", + "size": 17764 + }, + { + "package": "libxvidcore4", + "version": "2:1.3.7-1", + "source": "local", + "sha256": "b950edcb42803f158f94cd2ee44850082c98c92d54e20982ad933936f5f1d181", + "size": 202152 + }, + { + "package": "libzimg2", + "version": "3.0.4+ds1-1", + "source": "local", + "sha256": "407502547272e72490f7434c51a2c5c69fedcbd627ff06d17b7ffedea7e9c310", + "size": 110352 + }, + { + "package": "libzmq5", + "version": "4.3.4-6", + "source": "local", + "sha256": "f5e6f10a2e01b016369f2d1687d39ab5ed5e5ecdb6ca5fb248b6100a3a7069e9", + "size": 244304 + }, + { + "package": "libzvbi-common", + "version": "0.2.41-1+deb12u1", + "source": "local", + "sha256": "2115a125736144bc46bec2c185a24e4fd67b392f1b069950f4f45f11633de0e1", + "size": 69944 + }, + { + "package": "libzvbi0", + "version": "0.2.41-1+deb12u1", + "source": "local", + "sha256": "b6fb4091ca76c31d5ea1113e6ff9c482227a516508a7bff1d9e4baa723f3b49e", + "size": 259548 + }, + { + "package": "ocl-icd-libopencl1", + "version": "2.3.1-1", + "source": "local", + "sha256": "a021fd47d5c0d802e4f688694dae4c1e3601cd8b651294df3d327a5e6fa72a72", + "size": 42068 + }, + { + "package": "python3-distutils", + "version": "3.11.2-3", + "source": "pool/main/p/python3-stdlib-extensions/python3-distutils_3.11.2-3_all.deb", + "sha256": "a620b555f301860a08e30534c7e6f7d79818e5e1977bfec39a612e7003074318", + "size": 130936 + }, + { + "package": "python3-pip-whl", + "version": "23.0.1+dfsg-1", + "source": "pool/main/p/python-pip/python3-pip-whl_23.0.1+dfsg-1_all.deb", + "sha256": "cc373690a1cb469fb301b9fe0125048e3102f2e365d1b2d27cab091ec89fccdc", + "size": 1717296 + }, + { + "package": "python3-setuptools-whl", + "version": "66.1.1-1+deb12u2", + "source": "pool/main/s/setuptools/python3-setuptools-whl_66.1.1-1+deb12u2_all.deb", + "sha256": "0eb54da9f3d47e42a1be5dd8285def8bbc6e2556131b9c4ce3bd781dfc4b425a", + "size": 1112076 + }, + { + "package": "python3.11", + "version": "3.11.2-6+deb12u3", + "source": "pool/updates/main/p/python3.11/python3.11_3.11.2-6+deb12u3_arm64.deb", + "sha256": "1544e9ea7babdf5f46a8d8f088c36e07f662ea195fc1e1466d6df2643e2d7be6", + "size": 572804 + }, + { + "package": "python3.11-minimal", + "version": "3.11.2-6+deb12u3", + "source": "pool/updates/main/p/python3.11/python3.11-minimal_3.11.2-6+deb12u3_arm64.deb", + "sha256": "f0e014db594ef4cb9cdb64cbe1760d51cb4d8d9988defb305b38a31ab0e8e9ae", + "size": 1857036 + }, + { + "package": "python3.11-venv", + "version": "3.11.2-6+deb12u3", + "source": "pool/updates/main/p/python3.11/python3.11-venv_3.11.2-6+deb12u3_arm64.deb", + "sha256": "b86e8a1a2521053fff6dc5ff873ae48e74b1989e525543267d570775d0589591", + "size": 5896 + } + ], + "downloaded_files": [ + "libpython3.11-minimal_3.11.2-6+deb12u3_arm64.deb", + "libpython3.11-stdlib_3.11.2-6+deb12u3_arm64.deb", + "python3-distutils_3.11.2-3_all.deb", + "python3-pip-whl_23.0.1+dfsg-1_all.deb", + "python3-setuptools-whl_66.1.1-1+deb12u2_all.deb", + "python3.11_3.11.2-6+deb12u3_arm64.deb", + "python3.11-minimal_3.11.2-6+deb12u3_arm64.deb", + "python3.11-venv_3.11.2-6+deb12u3_arm64.deb" + ] +} diff --git a/发布更新相关/其他依赖/debian12-aarch64/SHA256SUMS b/发布更新相关/其他依赖/debian12-aarch64/SHA256SUMS new file mode 100644 index 0000000..3105225 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/SHA256SUMS @@ -0,0 +1,86 @@ +28ee670eb0aaf53dbfcbd06259c752dcf767c1e33e26225aea31f65f2bade35c BASE_IMAGE_PACKAGES.tsv +7890aeb19a94d73e5d9281afa5ca07005804c1fca4f10791f4f2ce0df741b610 ffmpeg_7%3a5.1.9-0+deb12u1_arm64.deb +674cf6cba6d432bd200c45fe866c1652c7a53523cc2e7a613e05bc4abf7b5440 gcc-12-base_12.2.0-14+deb12u1_arm64.deb +feb4cef72caf849fae2a13e1f757f9cc9fbfe3ddfb877e42cc888a7d313e298c libass9_0.17.1-1_arm64.deb +5a6ad45da10d69ca6ca7757882fe696ace8af98fccac781697ef280ea13e770f libavc1394-0_0.5.4-5_arm64.deb +74468871b1b95f28476a3cb63deafe21c97e57289ac286e760384273b0c39fc1 libavcodec59_7%3a5.1.9-0+deb12u1_arm64.deb +46c574e3ca73624d88af636e89703820b1c57657090bd89eb02cef727076ed1b libavdevice59_7%3a5.1.9-0+deb12u1_arm64.deb +6156525ccfd083bca35545a2b227839b2978f6d6c02cd10a1fe4691989e80a4e libavfilter8_7%3a5.1.9-0+deb12u1_arm64.deb +600ddadab359b552bc890323c9ca603bd0f08f4e324378e4530321c0f1900990 libavformat59_7%3a5.1.9-0+deb12u1_arm64.deb +9c5bd3feecf46c9eab1d6b4e65830acaf8e2d6caee008bd4f94e1adf22a63f31 libavutil57_7%3a5.1.9-0+deb12u1_arm64.deb +054aec63b247d157b4b475ca26763538d5dcbd32894734db6b5cae6c7aa335f2 libblas3_3.11.0-2_arm64.deb +f8f28e8e099f6c872053abf79755514a83d1cdc035b3774c29b0d75ebc7f0d11 libbluray2_1.3.4-1_arm64.deb +6145ea38842a1cc5d2f49e1a00bfb65426024c8c8fe48edbe59d3f8366ef565b libbs2b0_3.1.0+dfsg-7_arm64.deb +20515c1a5df7fce5c3ff41b5a5abf10f0294b485577ea4d461186ebc4e79c014 libcdio-cdda2_10.2+2.0.1-1_arm64.deb +f960dc3ab9f6bd6aee925350a19736a86d44ef8326177ce8127ae8df443490b6 libcdio-paranoia2_10.2+2.0.1-1_arm64.deb +f1b9c80716ba707e46ced45b454246b4b03f93ef9772e92af0e3b86ab0d56e2d libcdio19_2.1.0-4_arm64.deb +6f73d7b541247024746730858ebd5729acf6c017e189c2be25b13c6e7d92853f libchromaprint1_1.5.1-2+b1_arm64.deb +67b9f4d780a401b28bcce3d23a0a26efbdf040d7a012f1d73f8142a641925e2e libcjson1_1.7.15-1+deb12u4_arm64.deb +13c5182958d011333d30e1459e5e72acb24a2b3433ac4dd027d35f1afdcb6338 libcodec2-1.0_1.0.5-1_arm64.deb +82c939e239dc710675344c25f792341d00b9c4329c0c62b4146e06ffe928310a libdc1394-25_2.2.6-4_arm64.deb +e88d3c2dd3336a97b8cf10ef270a264388fce81cedf8032b18f2a36b021bc6a3 libdecor-0-0_0.1.1-2_arm64.deb +a32d8cfe4eda5e98dc4332ffb2520d3fb65565265bb869eb101f6b8b21f0115d libflite1_2.2-5_arm64.deb +583497cf5a95960c11f2006586050375c4adbc4b0c569a7e7df346f2bf380cbc libgfortran5_12.2.0-14+deb12u1_arm64.deb +561758595dc64d42e3ef48da13f87025af713a6dd240631eb68334bfcfcf8c97 libgme0_0.6.3-6_arm64.deb +4c0041b45a3e2535dfcdb91222fbd9cb2a79c9ba0e38d6d44ad727424b457775 libgsm1_1.0.22-1_arm64.deb +32c76d827664fe5c90b8a3af74e6b333f03d4a3dda1ade43fd0c12e97370a02f libheif-examples_1.15.1-1+deb12u1_arm64.deb +92dddfa77e4281997af385cb2bc9e8ffa13b0876f889a3d6d6ad177ac968a2bc libheif1_1.15.1-1+deb12u1_arm64.deb +bfd492bb0c5c5072c67ea053c3ca8863df67d14d066b4dddd519eeae8cbcd92f libhwy1_1.0.3-3+deb12u1_arm64.deb +55dbcc899d6f647b0a6d0299f8430e3737059185c72f7d51ba8798ce797a140c libiec61883-0_1.2.0-6+b1_arm64.deb +9b4bef2006edefa4d015918b954b315a91829a3c5a34ca5f9ca1a752b90f4ea8 libjack-jackd2-0_1.9.21~dfsg-3_arm64.deb +fc46d9d24c22e16726cc0fe131f52c4b3cc08db7ba408ef15173841bd3933f2e libjxl0.7_0.7.0-10+deb12u1_arm64.deb +7f6310fbd910326a63dedd87da046cd6eff3f644e9c00173f3afce20b7b2a93e liblapack3_3.11.0-2_arm64.deb +e3c0ebc8374617e26fa81e87714dbcbf7a04002cd3a11470ec4240692bf29ced liblilv-0-0_0.24.14-1_arm64.deb +018c421355f3fbbf11a5132a2d5e3b2c2f6b057ead517002da5547be2817a731 libmbedcrypto7_2.28.3-1_arm64.deb +ec983f89355ac7f22fc486eaabc10c5c6192c56f02aec0c0ff50a7ce8e86a9ea libmysofa1_1.3.1~dfsg0-1_arm64.deb +3974ddba57ed9bbfff10a144baae64d647adbb38ebce5fa122e4f0dd16b77866 libnorm1_1.5.9+dfsg-2_arm64.deb +695a650803f885459994bb921132d956c2b693759572005351a5b13773c754cd libopenal-data_1.19.1-2_all.deb +848f7cb93823c780ab58c0da67535316ef797666934d06ed6d64e902d4e2df11 libopenal1_1.19.1-2_arm64.deb +168a759cc2cfec251b757527fae13989b2306a8f778627aedf61000378149a52 libopenmpt0_0.6.9-1_arm64.deb +6a632aa13cafe154d6212a57710f058405dd895fb62d464e16961b28d7325cbb libpgm-5.3-0_5.3.128~dfsg-2_arm64.deb +ea1d0e24255b74e94155daaba32f4467d6a746447ce2dc4e6d122a670d600f6c libplacebo208_4.208.0-3_arm64.deb +5792065cf9dc3e1c5413f1f87ba95845b3daf90ef542ed2786bf64c36b846a60 libpocketsphinx3_0.8+5prealpha+1-15_arm64.deb +ffafb4b6fc84108c2113386fe2d9a7f8ab2c1bcb0dca1b8d84f2ccf4ecba6177 libpostproc56_7%3a5.1.9-0+deb12u1_arm64.deb +77b244e4ed2ce3ec3f23cb20ed1613c0cc0caa78bd6a5d305772b3a234cb227b libpython3.11-minimal_3.11.2-6+deb12u3_arm64.deb +f5adb88b46dcccdd1acfea3a5eb6ff26a665a7c41f4530e7e4af70e68f67d59c libpython3.11-stdlib_3.11.2-6+deb12u3_arm64.deb +6e4cdedc6de5514b867df1b7cf7cc075fdb95e59ab84b439ea47f5f75d01c1e5 librabbitmq4_0.11.0-1+deb12u2_arm64.deb +81a1e9bd2b790aa7ce7c426dd1742d3310894b01f124bc41962331b834da6cc3 libraw1394-11_2.1.2-2_arm64.deb +35df536e53aed81b4da5c4568ad7f98375318e6611ebaae4c4140e9a5c4ca2a9 librist4_0.2.7+dfsg-1_arm64.deb +fb97c395697ab03ce4d06d82ef293a6f1fef62ea79299b686d0f498eb6839d7e librubberband2_3.1.2+dfsg0-1_arm64.deb +7d875b119108a240015e7739c7a0de402616975281bcd8169cb434326fa93a66 libsdl2-2.0-0_2.26.5+dfsg-1_arm64.deb +cf26049564c27b60e1de39c3207743ca05d9ee34e25d8ed4027033d1afa22852 libserd-0-0_0.30.16-1_arm64.deb +d64982ddfbdfcef714dc35a760a752ab1f3739aaee8011bc5c3f03afa73f465f libshine3_3.1.1-2_arm64.deb +e7c787895eacf8363a4dea6a0cb3237a78857691d8d53f60a1a3ffeea3819808 libsndio7.0_1.9.0-0.3+b2_arm64.deb +fbfcbb5115fc85c8e659ef283f1eb588f08c77d89f762feaa96589ad9143e58c libsord-0-0_0.16.14+git221008-1_arm64.deb +8f1b1780a5ced7a0821a8040d0f3ab5815acd5d2f38b12cca915a9a9d0ee28b7 libsoxr0_0.1.3-4_arm64.deb +5fc81ad2d0ead5b811381fbb47ef98c1bf063c9283470d795541f3c1cb95991a libspeex1_1.2.1-2_arm64.deb +bd42e9aaf59b23e3c3d9014875b148e5cf056f0582d22ba9f882c09f57507060 libsphinxbase3_0.8+5prealpha+1-16_arm64.deb +a2f9d014bf2acaf67499b1cae7dd7f1a857a676d99849219b23ae2f4980a9b20 libsratom-0-0_0.6.14-1_arm64.deb +3cfb9d8d26459e3538473683a8e297429d0662eea4c51a01c736bf06608354c5 libsrt1.5-gnutls_1.5.1-1+deb12u1_arm64.deb +bd6bf5ad88a9cf8fb8e43667e27420ed68be6d6b03a2bdfdf111cff48a1d5b6d libssh-gcrypt-4_0.10.6-0+deb12u1_arm64.deb +c723d38ce00cdcbf779add81d90d3510525424dd174ce6880520a0147453ddeb libswresample4_7%3a5.1.9-0+deb12u1_arm64.deb +deb1abe7e40fdc9f38f54d7a87d1d2601f54447198221c4e1bb98da64a60d1ef libswscale6_7%3a5.1.9-0+deb12u1_arm64.deb +73780c555796569a252ec5cf1ea919e59ef4031298bc4be1516a6a0e83f06b5a libtwolame0_0.4.0-2_arm64.deb +693e1e62806759c6f55585cd1884cc851ac9a1b0a3ea7df55c51176b0b450fd4 libudfread0_1.1.2-1_arm64.deb +dd1c3981a81171f3886f14b56e2c13cb879111109e5b1e3e786c26641ab2cc2f libva-drm2_2.17.0-1_arm64.deb +326f1a6d764adae4ccdc606913ba60b3aecc62788c509444751e7fea75a13584 libva-x11-2_2.17.0-1_arm64.deb +b737136a783067f8f202115ed820d955aea16b95a6f06c406cc4305f99d20d3e libva2_2.17.0-1_arm64.deb +2f13f0bce02b7c5c5caecf5522e22852e296d93b2275c6a445dbf5427605b93c libvdpau1_1.5-2_arm64.deb +39a88b11d1c727f43a4301d8408dfcbde2d6980e4d6295fe9f9f0447c9436971 libvidstab1.1_1.1.0-2+b1_arm64.deb +f8f418e15f99905d4a2d532617511a11d700e814f8ead1a883deea2f7241970c libvorbisfile3_1.3.7-1_arm64.deb +3a21d49fef3a9799a21ffd5faa5bd7a1bd35604417cd9f5cf74155afd3b5282e libvulkan1_1.3.239.0-1_arm64.deb +95475856611f971affe2dfaccc85253d1c6bb2fcb1d67c3147b32021cf304b8e libx264-164_0.164.3095+gitbaee400-3_arm64.deb +e0ff80e309eacda5face68b9a3bd56718fed2750af429324c35d7c9491c335f4 libxss1_1.2.3-1_arm64.deb +b950edcb42803f158f94cd2ee44850082c98c92d54e20982ad933936f5f1d181 libxvidcore4_1.3.7-1_arm64.deb +407502547272e72490f7434c51a2c5c69fedcbd627ff06d17b7ffedea7e9c310 libzimg2_3.0.4+ds1-1_arm64.deb +f5e6f10a2e01b016369f2d1687d39ab5ed5e5ecdb6ca5fb248b6100a3a7069e9 libzmq5_4.3.4-6_arm64.deb +2115a125736144bc46bec2c185a24e4fd67b392f1b069950f4f45f11633de0e1 libzvbi-common_0.2.41-1+deb12u1_all.deb +b6fb4091ca76c31d5ea1113e6ff9c482227a516508a7bff1d9e4baa723f3b49e libzvbi0_0.2.41-1+deb12u1_arm64.deb +a021fd47d5c0d802e4f688694dae4c1e3601cd8b651294df3d327a5e6fa72a72 ocl-icd-libopencl1_2.3.1-1_arm64.deb +a620b555f301860a08e30534c7e6f7d79818e5e1977bfec39a612e7003074318 python3-distutils_3.11.2-3_all.deb +cc373690a1cb469fb301b9fe0125048e3102f2e365d1b2d27cab091ec89fccdc python3-pip-whl_23.0.1+dfsg-1_all.deb +0eb54da9f3d47e42a1be5dd8285def8bbc6e2556131b9c4ce3bd781dfc4b425a python3-setuptools-whl_66.1.1-1+deb12u2_all.deb +1544e9ea7babdf5f46a8d8f088c36e07f662ea195fc1e1466d6df2643e2d7be6 python3.11_3.11.2-6+deb12u3_arm64.deb +f0e014db594ef4cb9cdb64cbe1760d51cb4d8d9988defb305b38a31ab0e8e9ae python3.11-minimal_3.11.2-6+deb12u3_arm64.deb +b86e8a1a2521053fff6dc5ff873ae48e74b1989e525543267d570775d0589591 python3.11-venv_3.11.2-6+deb12u3_arm64.deb +331367d7a6e60a4053efcf6bb611993267315b52316b47c0f3cb17ac150c6d94 RESOLUTION.json diff --git a/发布更新相关/其他依赖/debian12-aarch64/ffmpeg_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/ffmpeg_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..d67ecb3 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/ffmpeg_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7890aeb19a94d73e5d9281afa5ca07005804c1fca4f10791f4f2ce0df741b610 +size 1807012 diff --git a/发布更新相关/其他依赖/debian12-aarch64/gcc-12-base_12.2.0-14+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/gcc-12-base_12.2.0-14+deb12u1_arm64.deb new file mode 100644 index 0000000..b4dfda9 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/gcc-12-base_12.2.0-14+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:674cf6cba6d432bd200c45fe866c1652c7a53523cc2e7a613e05bc4abf7b5440 +size 37624 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libass9_0.17.1-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libass9_0.17.1-1_arm64.deb new file mode 100644 index 0000000..eff106c --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libass9_0.17.1-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:feb4cef72caf849fae2a13e1f757f9cc9fbfe3ddfb877e42cc888a7d313e298c +size 91112 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libavc1394-0_0.5.4-5_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libavc1394-0_0.5.4-5_arm64.deb new file mode 100644 index 0000000..cab8ffe --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libavc1394-0_0.5.4-5_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a6ad45da10d69ca6ca7757882fe696ace8af98fccac781697ef280ea13e770f +size 18368 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libavcodec59_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libavcodec59_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..ed7b99d --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libavcodec59_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74468871b1b95f28476a3cb63deafe21c97e57289ac286e760384273b0c39fc1 +size 4811760 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libavdevice59_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libavdevice59_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..a5c1dcb --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libavdevice59_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46c574e3ca73624d88af636e89703820b1c57657090bd89eb02cef727076ed1b +size 119244 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libavfilter8_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libavfilter8_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..618dc15 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libavfilter8_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6156525ccfd083bca35545a2b227839b2978f6d6c02cd10a1fe4691989e80a4e +size 3321836 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libavformat59_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libavformat59_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..bb8baf0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libavformat59_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:600ddadab359b552bc890323c9ca603bd0f08f4e324378e4530321c0f1900990 +size 1054596 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libavutil57_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libavutil57_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..619fd04 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libavutil57_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c5bd3feecf46c9eab1d6b4e65830acaf8e2d6caee008bd4f94e1adf22a63f31 +size 341460 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libblas3_3.11.0-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libblas3_3.11.0-2_arm64.deb new file mode 100644 index 0000000..b66fab3 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libblas3_3.11.0-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:054aec63b247d157b4b475ca26763538d5dcbd32894734db6b5cae6c7aa335f2 +size 91444 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libbluray2_1.3.4-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libbluray2_1.3.4-1_arm64.deb new file mode 100644 index 0000000..355f51f --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libbluray2_1.3.4-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8f28e8e099f6c872053abf79755514a83d1cdc035b3774c29b0d75ebc7f0d11 +size 123524 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libbs2b0_3.1.0+dfsg-7_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libbs2b0_3.1.0+dfsg-7_arm64.deb new file mode 100644 index 0000000..e733381 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libbs2b0_3.1.0+dfsg-7_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6145ea38842a1cc5d2f49e1a00bfb65426024c8c8fe48edbe59d3f8366ef565b +size 10656 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libcdio-cdda2_10.2+2.0.1-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libcdio-cdda2_10.2+2.0.1-1_arm64.deb new file mode 100644 index 0000000..d37e82f --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libcdio-cdda2_10.2+2.0.1-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20515c1a5df7fce5c3ff41b5a5abf10f0294b485577ea4d461186ebc4e79c014 +size 20112 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libcdio-paranoia2_10.2+2.0.1-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libcdio-paranoia2_10.2+2.0.1-1_arm64.deb new file mode 100644 index 0000000..383cc90 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libcdio-paranoia2_10.2+2.0.1-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f960dc3ab9f6bd6aee925350a19736a86d44ef8326177ce8127ae8df443490b6 +size 19568 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libcdio19_2.1.0-4_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libcdio19_2.1.0-4_arm64.deb new file mode 100644 index 0000000..2ff848a --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libcdio19_2.1.0-4_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1b9c80716ba707e46ced45b454246b4b03f93ef9772e92af0e3b86ab0d56e2d +size 201476 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libchromaprint1_1.5.1-2+b1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libchromaprint1_1.5.1-2+b1_arm64.deb new file mode 100644 index 0000000..8fca85b --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libchromaprint1_1.5.1-2+b1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f73d7b541247024746730858ebd5729acf6c017e189c2be25b13c6e7d92853f +size 37404 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libcjson1_1.7.15-1+deb12u4_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libcjson1_1.7.15-1+deb12u4_arm64.deb new file mode 100644 index 0000000..818d6ec --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libcjson1_1.7.15-1+deb12u4_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67b9f4d780a401b28bcce3d23a0a26efbdf040d7a012f1d73f8142a641925e2e +size 22724 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libcodec2-1.0_1.0.5-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libcodec2-1.0_1.0.5-1_arm64.deb new file mode 100644 index 0000000..863d6f0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libcodec2-1.0_1.0.5-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13c5182958d011333d30e1459e5e72acb24a2b3433ac4dd027d35f1afdcb6338 +size 8163248 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libdc1394-25_2.2.6-4_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libdc1394-25_2.2.6-4_arm64.deb new file mode 100644 index 0000000..918117b --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libdc1394-25_2.2.6-4_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82c939e239dc710675344c25f792341d00b9c4329c0c62b4146e06ffe928310a +size 105424 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libdecor-0-0_0.1.1-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libdecor-0-0_0.1.1-2_arm64.deb new file mode 100644 index 0000000..65d1219 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libdecor-0-0_0.1.1-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e88d3c2dd3336a97b8cf10ef270a264388fce81cedf8032b18f2a36b021bc6a3 +size 14152 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libflite1_2.2-5_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libflite1_2.2-5_arm64.deb new file mode 100644 index 0000000..e1371c7 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libflite1_2.2-5_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a32d8cfe4eda5e98dc4332ffb2520d3fb65565265bb869eb101f6b8b21f0115d +size 12814836 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libgfortran5_12.2.0-14+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libgfortran5_12.2.0-14+deb12u1_arm64.deb new file mode 100644 index 0000000..2cf80af --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libgfortran5_12.2.0-14+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:583497cf5a95960c11f2006586050375c4adbc4b0c569a7e7df346f2bf380cbc +size 339792 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libgme0_0.6.3-6_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libgme0_0.6.3-6_arm64.deb new file mode 100644 index 0000000..0c6c2b6 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libgme0_0.6.3-6_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:561758595dc64d42e3ef48da13f87025af713a6dd240631eb68334bfcfcf8c97 +size 117960 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libgsm1_1.0.22-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libgsm1_1.0.22-1_arm64.deb new file mode 100644 index 0000000..90cd848 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libgsm1_1.0.22-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c0041b45a3e2535dfcdb91222fbd9cb2a79c9ba0e38d6d44ad727424b457775 +size 28816 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libheif-examples_1.15.1-1+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libheif-examples_1.15.1-1+deb12u1_arm64.deb new file mode 100644 index 0000000..3b60b90 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libheif-examples_1.15.1-1+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32c76d827664fe5c90b8a3af74e6b333f03d4a3dda1ade43fd0c12e97370a02f +size 41724 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libheif1_1.15.1-1+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libheif1_1.15.1-1+deb12u1_arm64.deb new file mode 100644 index 0000000..6a1925a --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libheif1_1.15.1-1+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92dddfa77e4281997af385cb2bc9e8ffa13b0876f889a3d6d6ad177ac968a2bc +size 190616 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libhwy1_1.0.3-3+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libhwy1_1.0.3-3+deb12u1_arm64.deb new file mode 100644 index 0000000..f656b82 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libhwy1_1.0.3-3+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfd492bb0c5c5072c67ea053c3ca8863df67d14d066b4dddd519eeae8cbcd92f +size 225744 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libiec61883-0_1.2.0-6+b1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libiec61883-0_1.2.0-6+b1_arm64.deb new file mode 100644 index 0000000..f042174 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libiec61883-0_1.2.0-6+b1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55dbcc899d6f647b0a6d0299f8430e3737059185c72f7d51ba8798ce797a140c +size 29032 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libjack-jackd2-0_1.9.21~dfsg-3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libjack-jackd2-0_1.9.21~dfsg-3_arm64.deb new file mode 100644 index 0000000..dcf3774 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libjack-jackd2-0_1.9.21~dfsg-3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b4bef2006edefa4d015918b954b315a91829a3c5a34ca5f9ca1a752b90f4ea8 +size 261068 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libjxl0.7_0.7.0-10+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libjxl0.7_0.7.0-10+deb12u1_arm64.deb new file mode 100644 index 0000000..3e1e110 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libjxl0.7_0.7.0-10+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc46d9d24c22e16726cc0fe131f52c4b3cc08db7ba408ef15173841bd3933f2e +size 643892 diff --git a/发布更新相关/其他依赖/debian12-aarch64/liblapack3_3.11.0-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/liblapack3_3.11.0-2_arm64.deb new file mode 100644 index 0000000..d5cab6a --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/liblapack3_3.11.0-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f6310fbd910326a63dedd87da046cd6eff3f644e9c00173f3afce20b7b2a93e +size 1664496 diff --git a/发布更新相关/其他依赖/debian12-aarch64/liblilv-0-0_0.24.14-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/liblilv-0-0_0.24.14-1_arm64.deb new file mode 100644 index 0000000..cd22fe0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/liblilv-0-0_0.24.14-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3c0ebc8374617e26fa81e87714dbcbf7a04002cd3a11470ec4240692bf29ced +size 46808 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libmbedcrypto7_2.28.3-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libmbedcrypto7_2.28.3-1_arm64.deb new file mode 100644 index 0000000..783bb49 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libmbedcrypto7_2.28.3-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:018c421355f3fbbf11a5132a2d5e3b2c2f6b057ead517002da5547be2817a731 +size 262868 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libmysofa1_1.3.1~dfsg0-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libmysofa1_1.3.1~dfsg0-1_arm64.deb new file mode 100644 index 0000000..071ccb0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libmysofa1_1.3.1~dfsg0-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec983f89355ac7f22fc486eaabc10c5c6192c56f02aec0c0ff50a7ce8e86a9ea +size 1155764 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libnorm1_1.5.9+dfsg-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libnorm1_1.5.9+dfsg-2_arm64.deb new file mode 100644 index 0000000..7589a29 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libnorm1_1.5.9+dfsg-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3974ddba57ed9bbfff10a144baae64d647adbb38ebce5fa122e4f0dd16b77866 +size 202432 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libopenal-data_1.19.1-2_all.deb b/发布更新相关/其他依赖/debian12-aarch64/libopenal-data_1.19.1-2_all.deb new file mode 100644 index 0000000..9ac5cfa --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libopenal-data_1.19.1-2_all.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:695a650803f885459994bb921132d956c2b693759572005351a5b13773c754cd +size 170228 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libopenal1_1.19.1-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libopenal1_1.19.1-2_arm64.deb new file mode 100644 index 0000000..587cea0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libopenal1_1.19.1-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:848f7cb93823c780ab58c0da67535316ef797666934d06ed6d64e902d4e2df11 +size 485416 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libopenmpt0_0.6.9-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libopenmpt0_0.6.9-1_arm64.deb new file mode 100644 index 0000000..f47dcb6 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libopenmpt0_0.6.9-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:168a759cc2cfec251b757527fae13989b2306a8f778627aedf61000378149a52 +size 621956 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libpgm-5.3-0_5.3.128~dfsg-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libpgm-5.3-0_5.3.128~dfsg-2_arm64.deb new file mode 100644 index 0000000..e7e8ac2 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libpgm-5.3-0_5.3.128~dfsg-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a632aa13cafe154d6212a57710f058405dd895fb62d464e16961b28d7325cbb +size 151640 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libplacebo208_4.208.0-3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libplacebo208_4.208.0-3_arm64.deb new file mode 100644 index 0000000..24eb8b8 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libplacebo208_4.208.0-3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea1d0e24255b74e94155daaba32f4467d6a746447ce2dc4e6d122a670d600f6c +size 2003668 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libpocketsphinx3_0.8+5prealpha+1-15_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libpocketsphinx3_0.8+5prealpha+1-15_arm64.deb new file mode 100644 index 0000000..70af370 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libpocketsphinx3_0.8+5prealpha+1-15_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5792065cf9dc3e1c5413f1f87ba95845b3daf90ef542ed2786bf64c36b846a60 +size 114020 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libpostproc56_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libpostproc56_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..9e3a902 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libpostproc56_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffafb4b6fc84108c2113386fe2d9a7f8ab2c1bcb0dca1b8d84f2ccf4ecba6177 +size 82604 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libpython3.11-minimal_3.11.2-6+deb12u3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libpython3.11-minimal_3.11.2-6+deb12u3_arm64.deb new file mode 100644 index 0000000..8f5b70b --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libpython3.11-minimal_3.11.2-6+deb12u3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77b244e4ed2ce3ec3f23cb20ed1613c0cc0caa78bd6a5d305772b3a234cb227b +size 808228 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libpython3.11-stdlib_3.11.2-6+deb12u3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libpython3.11-stdlib_3.11.2-6+deb12u3_arm64.deb new file mode 100644 index 0000000..de8c59e --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libpython3.11-stdlib_3.11.2-6+deb12u3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5adb88b46dcccdd1acfea3a5eb6ff26a665a7c41f4530e7e4af70e68f67d59c +size 1746676 diff --git a/发布更新相关/其他依赖/debian12-aarch64/librabbitmq4_0.11.0-1+deb12u2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/librabbitmq4_0.11.0-1+deb12u2_arm64.deb new file mode 100644 index 0000000..cad5176 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/librabbitmq4_0.11.0-1+deb12u2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e4cdedc6de5514b867df1b7cf7cc075fdb95e59ab84b439ea47f5f75d01c1e5 +size 39872 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libraw1394-11_2.1.2-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libraw1394-11_2.1.2-2_arm64.deb new file mode 100644 index 0000000..b7a5298 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libraw1394-11_2.1.2-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81a1e9bd2b790aa7ce7c426dd1742d3310894b01f124bc41962331b834da6cc3 +size 40316 diff --git a/发布更新相关/其他依赖/debian12-aarch64/librist4_0.2.7+dfsg-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/librist4_0.2.7+dfsg-1_arm64.deb new file mode 100644 index 0000000..3807f66 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/librist4_0.2.7+dfsg-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35df536e53aed81b4da5c4568ad7f98375318e6611ebaae4c4140e9a5c4ca2a9 +size 69620 diff --git a/发布更新相关/其他依赖/debian12-aarch64/librubberband2_3.1.2+dfsg0-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/librubberband2_3.1.2+dfsg0-1_arm64.deb new file mode 100644 index 0000000..0fabf8d --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/librubberband2_3.1.2+dfsg0-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb97c395697ab03ce4d06d82ef293a6f1fef62ea79299b686d0f498eb6839d7e +size 117612 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsdl2-2.0-0_2.26.5+dfsg-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsdl2-2.0-0_2.26.5+dfsg-1_arm64.deb new file mode 100644 index 0000000..a288314 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsdl2-2.0-0_2.26.5+dfsg-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d875b119108a240015e7739c7a0de402616975281bcd8169cb434326fa93a66 +size 565580 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libserd-0-0_0.30.16-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libserd-0-0_0.30.16-1_arm64.deb new file mode 100644 index 0000000..9505244 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libserd-0-0_0.30.16-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf26049564c27b60e1de39c3207743ca05d9ee34e25d8ed4027033d1afa22852 +size 44708 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libshine3_3.1.1-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libshine3_3.1.1-2_arm64.deb new file mode 100644 index 0000000..26b6792 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libshine3_3.1.1-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d64982ddfbdfcef714dc35a760a752ab1f3739aaee8011bc5c3f03afa73f465f +size 22980 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsndio7.0_1.9.0-0.3+b2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsndio7.0_1.9.0-0.3+b2_arm64.deb new file mode 100644 index 0000000..93ab6e1 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsndio7.0_1.9.0-0.3+b2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7c787895eacf8363a4dea6a0cb3237a78857691d8d53f60a1a3ffeea3819808 +size 25716 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsord-0-0_0.16.14+git221008-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsord-0-0_0.16.14+git221008-1_arm64.deb new file mode 100644 index 0000000..a6c14cc --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsord-0-0_0.16.14+git221008-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbfcbb5115fc85c8e659ef283f1eb588f08c77d89f762feaa96589ad9143e58c +size 20004 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsoxr0_0.1.3-4_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsoxr0_0.1.3-4_arm64.deb new file mode 100644 index 0000000..989bcb6 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsoxr0_0.1.3-4_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f1b1780a5ced7a0821a8040d0f3ab5815acd5d2f38b12cca915a9a9d0ee28b7 +size 54376 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libspeex1_1.2.1-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libspeex1_1.2.1-2_arm64.deb new file mode 100644 index 0000000..0b33555 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libspeex1_1.2.1-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fc81ad2d0ead5b811381fbb47ef98c1bf063c9283470d795541f3c1cb95991a +size 48304 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsphinxbase3_0.8+5prealpha+1-16_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsphinxbase3_0.8+5prealpha+1-16_arm64.deb new file mode 100644 index 0000000..973c216 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsphinxbase3_0.8+5prealpha+1-16_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd42e9aaf59b23e3c3d9014875b148e5cf056f0582d22ba9f882c09f57507060 +size 108904 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsratom-0-0_0.6.14-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsratom-0-0_0.6.14-1_arm64.deb new file mode 100644 index 0000000..dd5186a --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsratom-0-0_0.6.14-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2f9d014bf2acaf67499b1cae7dd7f1a857a676d99849219b23ae2f4980a9b20 +size 15868 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libsrt1.5-gnutls_1.5.1-1+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libsrt1.5-gnutls_1.5.1-1+deb12u1_arm64.deb new file mode 100644 index 0000000..a085429 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libsrt1.5-gnutls_1.5.1-1+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cfb9d8d26459e3538473683a8e297429d0662eea4c51a01c736bf06608354c5 +size 272080 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libssh-gcrypt-4_0.10.6-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libssh-gcrypt-4_0.10.6-0+deb12u1_arm64.deb new file mode 100644 index 0000000..cbdc2e1 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libssh-gcrypt-4_0.10.6-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd6bf5ad88a9cf8fb8e43667e27420ed68be6d6b03a2bdfdf111cff48a1d5b6d +size 206548 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libswresample4_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libswresample4_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..1160d4e --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libswresample4_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c723d38ce00cdcbf779add81d90d3510525424dd174ce6880520a0147453ddeb +size 98904 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libswscale6_7%3a5.1.9-0+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libswscale6_7%3a5.1.9-0+deb12u1_arm64.deb new file mode 100644 index 0000000..c1477ec --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libswscale6_7%3a5.1.9-0+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:deb1abe7e40fdc9f38f54d7a87d1d2601f54447198221c4e1bb98da64a60d1ef +size 190152 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libtwolame0_0.4.0-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libtwolame0_0.4.0-2_arm64.deb new file mode 100644 index 0000000..cfeb33c --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libtwolame0_0.4.0-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73780c555796569a252ec5cf1ea919e59ef4031298bc4be1516a6a0e83f06b5a +size 48548 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libudfread0_1.1.2-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libudfread0_1.1.2-1_arm64.deb new file mode 100644 index 0000000..32bc1ee --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libudfread0_1.1.2-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:693e1e62806759c6f55585cd1884cc851ac9a1b0a3ea7df55c51176b0b450fd4 +size 15700 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libva-drm2_2.17.0-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libva-drm2_2.17.0-1_arm64.deb new file mode 100644 index 0000000..2f05729 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libva-drm2_2.17.0-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd1c3981a81171f3886f14b56e2c13cb879111109e5b1e3e786c26641ab2cc2f +size 16328 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libva-x11-2_2.17.0-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libva-x11-2_2.17.0-1_arm64.deb new file mode 100644 index 0000000..7f23b69 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libva-x11-2_2.17.0-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:326f1a6d764adae4ccdc606913ba60b3aecc62788c509444751e7fea75a13584 +size 20808 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libva2_2.17.0-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libva2_2.17.0-1_arm64.deb new file mode 100644 index 0000000..a359a9a --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libva2_2.17.0-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b737136a783067f8f202115ed820d955aea16b95a6f06c406cc4305f99d20d3e +size 65168 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libvdpau1_1.5-2_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libvdpau1_1.5-2_arm64.deb new file mode 100644 index 0000000..9c550db --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libvdpau1_1.5-2_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f13f0bce02b7c5c5caecf5522e22852e296d93b2275c6a445dbf5427605b93c +size 23712 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libvidstab1.1_1.1.0-2+b1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libvidstab1.1_1.1.0-2+b1_arm64.deb new file mode 100644 index 0000000..ee6232c --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libvidstab1.1_1.1.0-2+b1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39a88b11d1c727f43a4301d8408dfcbde2d6980e4d6295fe9f9f0447c9436971 +size 36440 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libvorbisfile3_1.3.7-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libvorbisfile3_1.3.7-1_arm64.deb new file mode 100644 index 0000000..c73f502 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libvorbisfile3_1.3.7-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8f418e15f99905d4a2d532617511a11d700e814f8ead1a883deea2f7241970c +size 25376 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libvulkan1_1.3.239.0-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libvulkan1_1.3.239.0-1_arm64.deb new file mode 100644 index 0000000..d8c94c3 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libvulkan1_1.3.239.0-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a21d49fef3a9799a21ffd5faa5bd7a1bd35604417cd9f5cf74155afd3b5282e +size 111080 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libx264-164_0.164.3095+gitbaee400-3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libx264-164_0.164.3095+gitbaee400-3_arm64.deb new file mode 100644 index 0000000..1a2851d --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libx264-164_0.164.3095+gitbaee400-3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95475856611f971affe2dfaccc85253d1c6bb2fcb1d67c3147b32021cf304b8e +size 442908 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libxss1_1.2.3-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libxss1_1.2.3-1_arm64.deb new file mode 100644 index 0000000..e6c36da --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libxss1_1.2.3-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0ff80e309eacda5face68b9a3bd56718fed2750af429324c35d7c9491c335f4 +size 17764 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libxvidcore4_1.3.7-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libxvidcore4_1.3.7-1_arm64.deb new file mode 100644 index 0000000..c0feac0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libxvidcore4_1.3.7-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b950edcb42803f158f94cd2ee44850082c98c92d54e20982ad933936f5f1d181 +size 202152 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libzimg2_3.0.4+ds1-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libzimg2_3.0.4+ds1-1_arm64.deb new file mode 100644 index 0000000..94f37bd --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libzimg2_3.0.4+ds1-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:407502547272e72490f7434c51a2c5c69fedcbd627ff06d17b7ffedea7e9c310 +size 110352 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libzmq5_4.3.4-6_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libzmq5_4.3.4-6_arm64.deb new file mode 100644 index 0000000..66068a0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libzmq5_4.3.4-6_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5e6f10a2e01b016369f2d1687d39ab5ed5e5ecdb6ca5fb248b6100a3a7069e9 +size 244304 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libzvbi-common_0.2.41-1+deb12u1_all.deb b/发布更新相关/其他依赖/debian12-aarch64/libzvbi-common_0.2.41-1+deb12u1_all.deb new file mode 100644 index 0000000..346166c --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libzvbi-common_0.2.41-1+deb12u1_all.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2115a125736144bc46bec2c185a24e4fd67b392f1b069950f4f45f11633de0e1 +size 69944 diff --git a/发布更新相关/其他依赖/debian12-aarch64/libzvbi0_0.2.41-1+deb12u1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/libzvbi0_0.2.41-1+deb12u1_arm64.deb new file mode 100644 index 0000000..9ca6f66 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/libzvbi0_0.2.41-1+deb12u1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6fb4091ca76c31d5ea1113e6ff9c482227a516508a7bff1d9e4baa723f3b49e +size 259548 diff --git a/发布更新相关/其他依赖/debian12-aarch64/ocl-icd-libopencl1_2.3.1-1_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/ocl-icd-libopencl1_2.3.1-1_arm64.deb new file mode 100644 index 0000000..9f0bd80 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/ocl-icd-libopencl1_2.3.1-1_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a021fd47d5c0d802e4f688694dae4c1e3601cd8b651294df3d327a5e6fa72a72 +size 42068 diff --git a/发布更新相关/其他依赖/debian12-aarch64/python3-distutils_3.11.2-3_all.deb b/发布更新相关/其他依赖/debian12-aarch64/python3-distutils_3.11.2-3_all.deb new file mode 100644 index 0000000..a234085 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/python3-distutils_3.11.2-3_all.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a620b555f301860a08e30534c7e6f7d79818e5e1977bfec39a612e7003074318 +size 130936 diff --git a/发布更新相关/其他依赖/debian12-aarch64/python3-pip-whl_23.0.1+dfsg-1_all.deb b/发布更新相关/其他依赖/debian12-aarch64/python3-pip-whl_23.0.1+dfsg-1_all.deb new file mode 100644 index 0000000..75b73a6 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/python3-pip-whl_23.0.1+dfsg-1_all.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc373690a1cb469fb301b9fe0125048e3102f2e365d1b2d27cab091ec89fccdc +size 1717296 diff --git a/发布更新相关/其他依赖/debian12-aarch64/python3-setuptools-whl_66.1.1-1+deb12u2_all.deb b/发布更新相关/其他依赖/debian12-aarch64/python3-setuptools-whl_66.1.1-1+deb12u2_all.deb new file mode 100644 index 0000000..91cd5f1 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/python3-setuptools-whl_66.1.1-1+deb12u2_all.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eb54da9f3d47e42a1be5dd8285def8bbc6e2556131b9c4ce3bd781dfc4b425a +size 1112076 diff --git a/发布更新相关/其他依赖/debian12-aarch64/python3.11-minimal_3.11.2-6+deb12u3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/python3.11-minimal_3.11.2-6+deb12u3_arm64.deb new file mode 100644 index 0000000..e21347f --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/python3.11-minimal_3.11.2-6+deb12u3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0e014db594ef4cb9cdb64cbe1760d51cb4d8d9988defb305b38a31ab0e8e9ae +size 1857036 diff --git a/发布更新相关/其他依赖/debian12-aarch64/python3.11-venv_3.11.2-6+deb12u3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/python3.11-venv_3.11.2-6+deb12u3_arm64.deb new file mode 100644 index 0000000..ddfdfc0 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/python3.11-venv_3.11.2-6+deb12u3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b86e8a1a2521053fff6dc5ff873ae48e74b1989e525543267d570775d0589591 +size 5896 diff --git a/发布更新相关/其他依赖/debian12-aarch64/python3.11_3.11.2-6+deb12u3_arm64.deb b/发布更新相关/其他依赖/debian12-aarch64/python3.11_3.11.2-6+deb12u3_arm64.deb new file mode 100644 index 0000000..16ee144 --- /dev/null +++ b/发布更新相关/其他依赖/debian12-aarch64/python3.11_3.11.2-6+deb12u3_arm64.deb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1544e9ea7babdf5f46a8d8f088c36e07f662ea195fc1e1466d6df2643e2d7be6 +size 572804 diff --git a/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/LICENSE b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/LICENSE new file mode 100644 index 0000000..8f71f43 --- /dev/null +++ b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/METADATA.json b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/METADATA.json new file mode 100644 index 0000000..674ed53 --- /dev/null +++ b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/METADATA.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "name": "frpc", + "version": "0.71.0", + "platform": "linux-arm64", + "source": "https://github.com/fatedier/frp/releases/tag/v0.71.0", + "archive": "frp_0.71.0_linux_arm64.tar.gz", + "archive_sha256": "f33c293c275d8fc68c654b6fba8f10b2551d6463d09a9fc9cffb7227eae82266", + "installed_path": "/usr/local/bin/frpc", + "license_file": "LICENSE" +} diff --git a/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/SHA256SUMS b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/SHA256SUMS new file mode 100644 index 0000000..8dbca61 --- /dev/null +++ b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/SHA256SUMS @@ -0,0 +1,2 @@ +6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2 frpc +c6596eb7be8581c18be736c846fb9173b69eccf6ef94c5135893ec56bd92ba08 LICENSE diff --git a/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/frpc b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/frpc new file mode 100644 index 0000000..2b44fdf Binary files /dev/null and b/发布更新相关/其他依赖/frp/0.71.0/linux-arm64/frpc differ diff --git a/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/METADATA.json b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/METADATA.json new file mode 100644 index 0000000..19b94cd --- /dev/null +++ b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/METADATA.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "source": "WalnutPi Linux 6.1.31", + "source_commit": "30ff3fd5cf45417622b447a12e7a947402ffe34d", + "archive": "walnutpi-linux-6.1.31-30ff3fd5.tar.gz", + "archive_bytes": 249095239, + "archive_sha256": "8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f", + "purpose": "Offline reproducible build input for 6.1.31-matrix-axp313a1" +} diff --git a/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/README.md b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/README.md new file mode 100644 index 0000000..5616e38 --- /dev/null +++ b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/README.md @@ -0,0 +1,3 @@ +# 核桃派 Linux 固定源码归档 + +本目录保存构建 `6.1.31-matrix-axp313a1` 所需的核桃派官方 Linux `6.1.31` 固定提交源码。它只用于完全离线重建,不复制进 OTA 或可刷 IMG。构建时还必须使用项目 `kernel/patches/SHA256SUMS` 中登记的补丁。 diff --git a/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/SHA256SUMS b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/SHA256SUMS new file mode 100644 index 0000000..e215fd9 --- /dev/null +++ b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/SHA256SUMS @@ -0,0 +1 @@ +8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f walnutpi-linux-6.1.31-30ff3fd5.tar.gz diff --git a/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/walnutpi-linux-6.1.31-30ff3fd5.tar.gz b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/walnutpi-linux-6.1.31-30ff3fd5.tar.gz new file mode 100644 index 0000000..74597c0 --- /dev/null +++ b/发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/walnutpi-linux-6.1.31-30ff3fd5.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f +size 249095239 diff --git a/发布更新相关/导出包/1.0.3/README.md b/发布更新相关/导出包/1.0.3/README.md new file mode 100644 index 0000000..1e71fe7 --- /dev/null +++ b/发布更新相关/导出包/1.0.3/README.md @@ -0,0 +1,8 @@ +# 奇妙小屏幕控制器 IMAGE 1.0.3 + +- 软件版本:`1.0.3` +- 导出时间:`2026-08-25T08:17:48+00:00` +- 说明:修复 1.0.3 首次启动 FAT 空间不足:应用离线载荷迁入 rootfs,增加候选内核 boot 文件预算与 32 MiB 安全余量双重校验,并修复导出入口误加载 Pillow/FontTools。 +- 产物:`matrix-screen-controller-1.0.3.img` + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 diff --git a/发布更新相关/导出包/1.0.3/manifest.json b/发布更新相关/导出包/1.0.3/manifest.json new file mode 100644 index 0000000..1cb821d --- /dev/null +++ b/发布更新相关/导出包/1.0.3/manifest.json @@ -0,0 +1,21 @@ +{ + "format_version": 3, + "product": "matrix-screen-controller-walnutpi", + "software_version": "1.0.3", + "bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ", + "bundle_bytes": 76123924, + "bundle_sha256": "74e90c7b93d3ffc55226d52bfb5cee214255704341ac40564cd9afc823b19616", + "kernel_release": "6.1.31-matrix-axp313a1", + "kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", + "kernel_artifact_bytes": 48665765, + "kernel_artifact_sha256": "f2b557ce0874aca11aacaa4496ff1f3edfdb84d67ed537132f47d8f4432b8f6d", + "kernel_unpacked_file_bytes": 160051604, + "boot_required_bytes": 26279936, + "boot_safety_bytes": 33554432, + "created_at": "2026-08-25T08:17:48+00:00", + "image_bytes": 3307208704, + "image_sha256": "a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b", + "artifact_type": "image", + "notes": "修复 1.0.3 首次启动 FAT 空间不足:应用离线载荷迁入 rootfs,增加候选内核 boot 文件预算与 32 MiB 安全余量双重校验,并修复导出入口误加载 Pillow/FontTools。", + "artifact": "matrix-screen-controller-1.0.3.img" +} diff --git a/发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img b/发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img new file mode 100644 index 0000000..4c06c21 --- /dev/null +++ b/发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b +size 3307208704 diff --git a/发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img.sha256 b/发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img.sha256 new file mode 100644 index 0000000..2da2b97 --- /dev/null +++ b/发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img.sha256 @@ -0,0 +1 @@ +a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b matrix-screen-controller-1.0.3.img diff --git a/发布更新相关/导出包/1.1.1/README.md b/发布更新相关/导出包/1.1.1/README.md new file mode 100644 index 0000000..aa1d42c --- /dev/null +++ b/发布更新相关/导出包/1.1.1/README.md @@ -0,0 +1,21 @@ +# 奇妙小屏幕控制器 IMAGE 1.1.1 + +- 软件版本:`1.1.1` +- 导出时间:`2026-09-08T07:24:53+00:00` +- 说明:完整可刷镜像 1.1.1:默认账户与 Wi-Fi,可直接首启使用 +- 产物:`matrix-screen-controller-1.1.1.img` +- SHA-256:`8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d` + +## 未修改镜像的默认设置 + +- Linux 用户名:`matrix` +- Linux 密码:`bvGfabduLw9KmDVZ6EAk` +- Wi-Fi SSID:`MatrixScreen-Setup` +- Wi-Fi 密码:`ibB5dzomfrrA6XjFmMKF` +- IPv4:`DHCP` + +> 这些是公开的默认凭据。建议刷写前使用镜像编辑器修改;直接使用未修改镜像时,请按上述 SSID 和密码开启热点,并在首次登录后立即更换账户及 Wi-Fi 设置。 + +首次启动会离线安装全部软件并自动重启一次。请等待设备重新连接热点后再通过 SSH 或网页访问。 + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 diff --git a/发布更新相关/导出包/1.1.1/manifest.json b/发布更新相关/导出包/1.1.1/manifest.json new file mode 100644 index 0000000..f138056 --- /dev/null +++ b/发布更新相关/导出包/1.1.1/manifest.json @@ -0,0 +1,22 @@ +{ + "format_version": 3, + "product": "matrix-screen-controller-walnutpi", + "software_version": "1.1.1", + "bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ", + "bundle_bytes": 81763335, + "bundle_sha256": "fea32f83b63d5db9941f1dba9b6f1ed744ffebb4244ec0ff03fb439f42a670e8", + "kernel_release": "6.1.31-matrix-axp313a1", + "kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", + "kernel_artifact_bytes": 48665765, + "kernel_artifact_sha256": "f2b557ce0874aca11aacaa4496ff1f3edfdb84d67ed537132f47d8f4432b8f6d", + "kernel_unpacked_file_bytes": 160051604, + "boot_required_bytes": 26279936, + "boot_safety_bytes": 33554432, + "created_at": "2026-09-08T07:24:53+00:00", + "image_bytes": 3307208704, + "image_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d", + "artifact_type": "image", + "notes": "完整可刷镜像 1.1.1:默认账户与 Wi-Fi,可直接首启使用", + "artifact": "matrix-screen-controller-1.1.1.img", + "config_sha256": "d30933707ae73aee50c7928b0ad1cbed3cb42b76fdbbf25e3ec6a742add340ec" +} diff --git a/发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img b/发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img new file mode 100644 index 0000000..08eba92 --- /dev/null +++ b/发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d +size 3307208704 diff --git a/发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img.sha256 b/发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img.sha256 new file mode 100644 index 0000000..b6ecc03 --- /dev/null +++ b/发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img.sha256 @@ -0,0 +1 @@ +8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d matrix-screen-controller-1.1.1.img diff --git a/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img b/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img new file mode 100644 index 0000000..0e61aac --- /dev/null +++ b/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04a27c5da2244eded7bbc3dfb967582957a9540c7d61ef51cfdbaebeaa70d789 +size 3307208704 diff --git a/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar b/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar new file mode 100644 index 0000000..69b87d2 --- /dev/null +++ b/发布更新相关/核桃派镜像/2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78a54e0e04a28bb7247c68101a61defb9a40f666a3813138a22a35f97fb226cd +size 630053154 diff --git a/发布更新相关/核桃派镜像/README.md b/发布更新相关/核桃派镜像/README.md new file mode 100644 index 0000000..8945701 --- /dev/null +++ b/发布更新相关/核桃派镜像/README.md @@ -0,0 +1,8 @@ +# 核桃派官方原始镜像 + +本目录只保存未经项目修改的 WalnutPi Debian 12 V2.5.1 官方归档和解包 IMG。镜像导出器必须先复制 IMG,再修改副本;不得在这里直接写入软件、网络、账户或首次启动配置。 + +- RAR 是官方原始归档,不直接写卡。 +- IMG 是构建可刷项目镜像的只读基线。 +- 搬运或构建前使用 `SHA256SUMS` 校验。 +- ZeroW 与 1B 使用同一官方镜像;项目真实硬件仍固定为 ZeroW/H618。 diff --git a/发布更新相关/核桃派镜像/SHA256SUMS b/发布更新相关/核桃派镜像/SHA256SUMS new file mode 100644 index 0000000..1bde003 --- /dev/null +++ b/发布更新相关/核桃派镜像/SHA256SUMS @@ -0,0 +1,2 @@ +04a27c5da2244eded7bbc3dfb967582957a9540c7d61ef51cfdbaebeaa70d789 2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img +78a54e0e04a28bb7247c68101a61defb9a40f666a3813138a22a35f97fb226cd 2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.rar diff --git a/发布更新相关/镜像编辑器/README.md b/发布更新相关/镜像编辑器/README.md new file mode 100644 index 0000000..d9737a7 --- /dev/null +++ b/发布更新相关/镜像编辑器/README.md @@ -0,0 +1,9 @@ +# 核桃派镜像配置编辑器 + +`2.0.0` 起使用 Python 3 + PySide6 重写。`编辑器程序/` 保存 Windows 10/11 x64 可直接运行的绿色单 EXE;`编辑器源代码/` 是不依赖本项目其他目录的跨平台源码,可在 macOS 上本地重新打包。 + +打开版本化 IMG 后可查看软件版本、账户名、Wi-Fi 和 IPv4 配置,密码默认遮挡。支持二次确认后修改原镜像,以及使用宿主系统合法的新名称另存为定制副本。中文、空格和长路径受支持;保存完成会生成无 BOM UTF-8 的同名 `.sha256` 并重新验证。损坏、截断、产品错误、未知 schema、配置摘要错误或现有镜像摘要不符时禁止保存。 + +编辑器只修改 IMG 内固定的双槽配置区,不负责向 TF 卡写入镜像。写卡仍使用 Rufus 等原始磁盘写入工具。 + +1.0.1 C#/.NET WinForms 版已完整归档到 `各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/`。 diff --git a/发布更新相关/镜像编辑器/编辑器源代码/.gitignore b/发布更新相关/镜像编辑器/编辑器源代码/.gitignore new file mode 100644 index 0000000..54bc820 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/.gitignore @@ -0,0 +1,7 @@ +/.build-venv/ +/.build/ +/dist/ +/build/ +/*.spec +__pycache__/ +*.py[cod] diff --git a/发布更新相关/镜像编辑器/编辑器源代码/README.md b/发布更新相关/镜像编辑器/编辑器源代码/README.md new file mode 100644 index 0000000..b1999a0 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/README.md @@ -0,0 +1,39 @@ +# 构建镜像配置编辑器 2.0.0 + +源码使用 Python 3 和 PySide6,不依赖本项目其他目录。支持 Python 3.10–3.14,构建依赖已在 `requirements-build.txt` 锁定。 + +以下命令均在本文件所在的 `发布更新相关/镜像编辑器/编辑器源代码/` 目录执行。 + +## Windows 10/11 x64 + +```powershell +.\build_windows.ps1 +``` + +脚本创建隔离构建环境、运行单元测试,再使用 PyInstaller 生成单 EXE,并更新 `../编辑器程序/` 的版本和 SHA-256。发布后的电脑不需要安装 Python、.NET 或 Qt。 +已有锁定依赖的 venv 时可传入 `-BuildEnvironment ""`,脚本会拒绝错误的 PySide6/PyInstaller 版本。 + +## macOS + +必须在 macOS 上本地构建,PyInstaller 不是跨平台编译器: + +```sh +chmod +x ./build_macos.sh +./build_macos.sh +``` + +本项目未交付或验收 macOS 产物;对外分发 `.app` 时还应由分发者完成 Apple 签名和公证。命令行功能位于同一源码入口;若需在终端直接查看标准输出,将 macOS 构建参数的 `--windowed` 改为 `--console`。 + +## 源码运行与命令行 + +```powershell +$env:PYTHONPATH = ".\src" +python .\editor_main.py +python .\editor_main.py --validate "<镜像.img>" +python .\editor_main.py --apply "<原镜像.img>" "<配置.json>" "<新镜像.img>" +python -m unittest discover -s .\tests -v +``` + +编辑器只修改 IMG 内 FAT16 启动分区的固定双槽配置区,不负责向 TF 卡写入镜像。源码、构建物和日志不得内置发布密码或读取项目凭据文件。 + +第三方软件声明见 `THIRD_PARTY_NOTICES.md`。 diff --git a/发布更新相关/镜像编辑器/编辑器源代码/THIRD_PARTY_NOTICES.md b/发布更新相关/镜像编辑器/编辑器源代码/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..7421475 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/THIRD_PARTY_NOTICES.md @@ -0,0 +1,10 @@ +# 第三方软件声明 + +本编辑器的发布包包含 Python、Qt for Python/PySide6、Qt 及 PyInstaller 的必要组件。 + +- Python 使用 Python Software Foundation License: +- Qt for Python/PySide6 社区版使用 LGPLv3/GPLv3 及其所含第三方许可: +- Qt 的开源许可和模块说明: +- PyInstaller 使用 GPLv2-or-later,其 bootloader 带有允许发布生成程序的例外: + +构建时只引入 Qt Core/Gui/Widgets 及平台插件等本程序实际使用的组件。再分发者应按实际发布地区和用途核对并遵守相应许可条款。 diff --git a/发布更新相关/镜像编辑器/编辑器源代码/VERSION b/发布更新相关/镜像编辑器/编辑器源代码/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/发布更新相关/镜像编辑器/编辑器源代码/build_macos.sh b/发布更新相关/镜像编辑器/编辑器源代码/build_macos.sh new file mode 100644 index 0000000..a00c147 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/build_macos.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")" +python3 -m venv .build-venv +. .build-venv/bin/activate +python -m pip install --requirement requirements-build.txt +PYTHONPATH=src python -m unittest discover -s tests -v +python -m PyInstaller --noconfirm --clean --onefile --windowed \ + --name "核桃派镜像配置编辑器" \ + --paths src editor_main.py diff --git a/发布更新相关/镜像编辑器/编辑器源代码/build_windows.ps1 b/发布更新相关/镜像编辑器/编辑器源代码/build_windows.ps1 new file mode 100644 index 0000000..8bbd1fc --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/build_windows.ps1 @@ -0,0 +1,69 @@ +param( + [string]$Python = "python", + [string]$BuildEnvironment = "" +) + +$ErrorActionPreference = "Stop" +$sourceRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$buildRoot = Join-Path $sourceRoot ".build" +$programRoot = [System.IO.Path]::GetFullPath((Join-Path $sourceRoot "..\编辑器程序")) + +if ($BuildEnvironment) { + $pythonExe = [System.IO.Path]::GetFullPath((Join-Path $BuildEnvironment "Scripts\python.exe")) + if (-not (Test-Path -LiteralPath $pythonExe)) { + throw "指定的构建环境不存在:$BuildEnvironment" + } +} else { + $venvRoot = Join-Path $sourceRoot ".build-venv" + $pythonExe = Join-Path $venvRoot "Scripts\python.exe" + if (-not (Test-Path -LiteralPath $pythonExe)) { + & $Python -m venv $venvRoot + } + & $pythonExe -m pip install --disable-pip-version-check --requirement (Join-Path $sourceRoot "requirements-build.txt") +} + +& $pythonExe -c "import PyInstaller, PySide6; assert PyInstaller.__version__ == '6.22.2'; assert PySide6.__version__ == '6.11.2'" +if ($LASTEXITCODE -ne 0) { throw "构建依赖版本校验失败" } + +$env:PYTHONPATH = Join-Path $sourceRoot "src" +& $pythonExe -m unittest discover -s (Join-Path $sourceRoot "tests") -v +if ($LASTEXITCODE -ne 0) { throw "镜像编辑器单元测试失败" } + +New-Item -ItemType Directory -Path $buildRoot -Force | Out-Null +$distRoot = Join-Path $buildRoot "dist" +$workRoot = Join-Path $buildRoot "work" +$specRoot = Join-Path $buildRoot "spec" +New-Item -ItemType Directory -Path $distRoot,$workRoot,$specRoot -Force | Out-Null + +$originalPath = $env:PATH +try { + # Avoid collecting unrelated DLLs from the caller's PATH (for example another app's ICU build). + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + & $pythonExe -m PyInstaller ` + --noconfirm ` + --clean ` + --onefile ` + --console ` + --hide-console=hide-early ` + --name "核桃派镜像配置编辑器" ` + --version-file (Join-Path $sourceRoot "windows_version_info.txt") ` + --paths (Join-Path $sourceRoot "src") ` + --distpath $distRoot ` + --workpath $workRoot ` + --specpath $specRoot ` + (Join-Path $sourceRoot "editor_main.py") + if ($LASTEXITCODE -ne 0) { throw "PyInstaller 构建失败" } +} finally { + $env:PATH = $originalPath +} + +$artifact = Join-Path $distRoot "核桃派镜像配置编辑器.exe" +if (-not (Test-Path -LiteralPath $artifact)) { + throw "PyInstaller 未生成目标 EXE" +} +New-Item -ItemType Directory -Path $programRoot -Force | Out-Null +Copy-Item -LiteralPath $artifact -Destination (Join-Path $programRoot "核桃派镜像配置编辑器.exe") -Force +[System.IO.File]::WriteAllText((Join-Path $programRoot "VERSION"), "2.0.0`n", [System.Text.UTF8Encoding]::new($false)) +$hash = (Get-FileHash -LiteralPath (Join-Path $programRoot "核桃派镜像配置编辑器.exe") -Algorithm SHA256).Hash.ToLowerInvariant() +[System.IO.File]::WriteAllText((Join-Path $programRoot "SHA256SUMS"), "$hash 核桃派镜像配置编辑器.exe`n", [System.Text.UTF8Encoding]::new($false)) +Write-Output "已生成:$programRoot" diff --git a/发布更新相关/镜像编辑器/编辑器源代码/editor_main.py b/发布更新相关/镜像编辑器/编辑器源代码/editor_main.py new file mode 100644 index 0000000..e99b36e --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/editor_main.py @@ -0,0 +1,5 @@ +from matrix_image_editor.__main__ import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/发布更新相关/镜像编辑器/编辑器源代码/pyproject.toml b/发布更新相关/镜像编辑器/编辑器源代码/pyproject.toml new file mode 100644 index 0000000..cd61b78 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "matrix-image-editor" +version = "2.0.0" +description = "WalnutPi matrix screen image configuration editor" +requires-python = ">=3.10,<3.15" +dependencies = ["PySide6-Essentials==6.11.2"] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/发布更新相关/镜像编辑器/编辑器源代码/requirements-build.txt b/发布更新相关/镜像编辑器/编辑器源代码/requirements-build.txt new file mode 100644 index 0000000..de2eace --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/requirements-build.txt @@ -0,0 +1,2 @@ +PyInstaller==6.22.2 +PySide6-Essentials==6.11.2 diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/__init__.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/__init__.py new file mode 100644 index 0000000..758f501 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/__init__.py @@ -0,0 +1,4 @@ +"""核桃派镜像配置编辑器。""" + +__version__ = "2.0.0" + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/__main__.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/__main__.py new file mode 100644 index 0000000..94b2200 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/__main__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import sys + +from .cli import run_command + + +def main() -> int: + if len(sys.argv) > 1: + return run_command(sys.argv[1:]) + from .gui import run_gui + + return run_gui() + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/cli.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/cli.py new file mode 100644 index 0000000..b079934 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/cli.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys + +from .config import deserialize_config +from .operations import copy_write_and_verify, read_config + + +USAGE = "用法:镜像配置编辑器.exe [--validate 镜像 | --apply 原镜像 配置.json 新镜像]" + + +def run_command(arguments: list[str]) -> int: + try: + if len(arguments) == 2 and arguments[0] == "--validate": + config = read_config(arguments[1]) + public = { + "valid": True, + "SoftwareVersion": config["software_version"], + "Username": config["account"]["username"], + "Ssid": config["wifi"]["ssid"], + "Mode": config["ipv4"]["mode"], + "Address": config["ipv4"]["address"], + } + # ASCII escapes match the 1.0.1 System.Text.Json output and remain stable in every Windows console code page. + print(json.dumps(public, ensure_ascii=True, separators=(",", ":"))) + return 0 + if len(arguments) == 4 and arguments[0] == "--apply": + try: + text = Path(arguments[2]).read_text(encoding="utf-8-sig") + except (OSError, UnicodeError) as exception: + raise ValueError(f"无法读取配置 JSON:{exception}") from exception + config = deserialize_config(text) + destination, _verified = copy_write_and_verify(arguments[1], arguments[3], config) + print(destination) + return 0 + print(USAGE, file=sys.stderr) + return 2 + except Exception as exception: + print(str(exception), file=sys.stderr) + return 1 diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/config.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/config.py new file mode 100644 index 0000000..fb242de --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/config.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import hashlib +import ipaddress +import json +import re +import struct +from typing import Any + + +PRODUCT_ID = "matrix-screen-controller-walnutpi" +CONFIG_FILE_BYTES = 64 * 1024 +HEADER_BYTES = 4096 +SLOT_BYTES = (CONFIG_FILE_BYTES - HEADER_BYTES) // 2 +FILE_MAGIC = b"MSCCFG2\0" +SLOT_MAGIC = b"MSCSLOT\0" +FORMAT_VERSION = 1 +_HEADER = struct.Struct("<8sII") +_SLOT_HEADER = struct.Struct("<8sQI32s") +_USERNAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}", re.ASCII) +_VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", re.ASCII) + + +class ImageConfigError(ValueError): + pass + + +def _exact_object(value: Any, fields: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise ImageConfigError(f"{label}字段无效") + return value + + +def _utf16_units(value: str) -> int: + # .NET String.Length counts UTF-16 code units; retain 1.0.1 validation semantics. + return len(value.encode("utf-16-le")) // 2 + + +def _validate_secret(value: Any, label: str, minimum: int, maximum: int) -> str: + if not isinstance(value, str) or not minimum <= _utf16_units(value) <= maximum: + raise ImageConfigError(f"{label}长度或字符无效") + if any(character in value for character in ("\0", "\r", "\n")): + raise ImageConfigError(f"{label}长度或字符无效") + return value + + +def _validate_text(value: Any, label: str, minimum: int, maximum_bytes: int) -> str: + if not isinstance(value, str) or _utf16_units(value) < minimum or len(value.encode("utf-8")) > maximum_bytes: + raise ImageConfigError(f"{label}长度或字符无效") + if any(character in value for character in ("\0", "\r", "\n")): + raise ImageConfigError(f"{label}长度或字符无效") + return value + + +def validate_config(value: Any) -> dict[str, Any]: + source = _exact_object( + value, + {"schema_version", "product", "software_version", "account", "wifi", "ipv4"}, + "镜像配置", + ) + schema = source["schema_version"] + if type(schema) is not int or schema != FORMAT_VERSION or source["product"] != PRODUCT_ID: + raise ImageConfigError("镜像产品或配置版本不受支持") + software_version = source["software_version"] + if not isinstance(software_version, str) or _VERSION.fullmatch(software_version) is None: + raise ImageConfigError("软件版本格式无效") + + account = _exact_object(source["account"], {"username", "password"}, "账户配置") + username = account["username"] + if not isinstance(username, str) or _USERNAME.fullmatch(username) is None: + raise ImageConfigError("用户名只能使用小写字母、数字、下划线和连字符,且最长 32 个字符") + account_password = _validate_secret(account["password"], "账户密码", 8, 128) + + wifi = _exact_object(source["wifi"], {"ssid", "password"}, "Wi-Fi 配置") + ssid = _validate_text(wifi["ssid"], "Wi-Fi 名称", 1, 32) + wifi_password = _validate_secret(wifi["password"], "Wi-Fi 密码", 8, 63) + + ipv4 = _exact_object(source["ipv4"], {"mode", "address", "prefix", "gateway", "dns"}, "IPv4 配置") + mode = ipv4["mode"] + if mode == "dhcp": + normalized_ipv4: dict[str, Any] = {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []} + elif mode == "static": + try: + address = ipaddress.IPv4Address(ipv4["address"]) + gateway = ipaddress.IPv4Address(ipv4["gateway"]) + except (ipaddress.AddressValueError, TypeError) as exception: + raise ImageConfigError("静态 IPv4 地址或网关无效") from exception + prefix = ipv4["prefix"] + if type(prefix) is not int or not 1 <= prefix <= 32: + raise ImageConfigError("IPv4 前缀必须是 1 到 32") + if gateway not in ipaddress.IPv4Network(f"{address}/{prefix}", strict=False): + raise ImageConfigError("静态 IPv4 网关必须与地址处于同一子网") + dns_source = ipv4["dns"] + if not isinstance(dns_source, list) or not 1 <= len(dns_source) <= 4: + raise ImageConfigError("DNS 必须包含 1 到 4 个 IPv4 地址") + try: + dns = [str(ipaddress.IPv4Address(item)) for item in dns_source] + except (ipaddress.AddressValueError, TypeError) as exception: + raise ImageConfigError("DNS 必须包含 1 到 4 个 IPv4 地址") from exception + normalized_ipv4 = { + "mode": "static", + "address": str(ipv4["address"]), + "prefix": prefix, + "gateway": str(ipv4["gateway"]), + "dns": dns, + } + else: + raise ImageConfigError("IPv4 模式无效") + + return { + "schema_version": FORMAT_VERSION, + "product": PRODUCT_ID, + "software_version": software_version, + "account": {"username": username, "password": account_password}, + "wifi": {"ssid": ssid, "password": wifi_password}, + "ipv4": normalized_ipv4, + } + + +def serialize_config(value: Any) -> str: + return json.dumps(validate_config(value), ensure_ascii=False, separators=(",", ":")) + + +def deserialize_config(text: str) -> dict[str, Any]: + try: + value = json.loads(text) + except (UnicodeError, json.JSONDecodeError) as exception: + raise ImageConfigError("配置 JSON 无效") from exception + return validate_config(value) + + +def _slot_offset(index: int) -> int: + return HEADER_BYTES + index * SLOT_BYTES + + +def _encode_slot(config: Any, generation: int) -> bytes: + payload = (serialize_config(config) + "\n").encode("utf-8") + if len(payload) > SLOT_BYTES - _SLOT_HEADER.size: + raise ImageConfigError("配置内容过大") + header = _SLOT_HEADER.pack(SLOT_MAGIC, generation, len(payload), hashlib.sha256(payload).digest()) + return header + payload + bytes(SLOT_BYTES - len(header) - len(payload)) + + +def create_config_file(config: Any) -> bytes: + result = bytearray(CONFIG_FILE_BYTES) + _HEADER.pack_into(result, 0, FILE_MAGIC, FORMAT_VERSION, CONFIG_FILE_BYTES) + result[_slot_offset(0) : _slot_offset(0) + SLOT_BYTES] = _encode_slot(config, 1) + return bytes(result) + + +def read_config_file(data: bytes) -> tuple[dict[str, Any], int, int]: + if len(data) != CONFIG_FILE_BYTES: + raise ImageConfigError("镜像配置文件大小无效") + try: + magic, version, declared_size = _HEADER.unpack_from(data, 0) + except struct.error as exception: + raise ImageConfigError("镜像配置区头部无效") from exception + if magic != FILE_MAGIC or version != FORMAT_VERSION or declared_size != CONFIG_FILE_BYTES: + raise ImageConfigError("镜像配置区头部无效") + + valid: list[tuple[int, int, dict[str, Any]]] = [] + for index in range(2): + offset = _slot_offset(index) + slot_magic, generation, payload_bytes, digest = _SLOT_HEADER.unpack_from(data, offset) + if slot_magic != SLOT_MAGIC or payload_bytes <= 0 or payload_bytes > SLOT_BYTES - _SLOT_HEADER.size: + continue + payload_start = offset + _SLOT_HEADER.size + payload = data[payload_start : payload_start + payload_bytes] + if hashlib.sha256(payload).digest() != digest: + continue + try: + document = deserialize_config(payload.decode("utf-8")) + except (UnicodeError, ImageConfigError): + continue + valid.append((generation, index, document)) + if not valid: + raise ImageConfigError("镜像配置区没有可恢复的有效副本") + generation, index, document = max(valid, key=lambda item: (item[0], item[1])) + return document, generation, index + + +def encode_update(data: bytes, config: Any) -> tuple[bytes, int]: + _current, generation, active = read_config_file(data) + target = 1 - active + return _encode_slot(config, generation + 1), target + + +def update_config_file(data: bytes, config: Any) -> bytes: + slot_data, target = encode_update(data, config) + result = bytearray(data) + offset = _slot_offset(target) + result[offset : offset + SLOT_BYTES] = slot_data + return bytes(result) + + +def slot_offset(index: int) -> int: + if index not in (0, 1): + raise ValueError("配置槽索引无效") + return _slot_offset(index) + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/fat16.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/fat16.py new file mode 100644 index 0000000..0f24fa1 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/fat16.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import struct +from typing import BinaryIO, Iterator + + +class Fat16Error(ValueError): + pass + + +@dataclass(frozen=True) +class FatEntry: + first_cluster: int + size: int + + +class Fat16Disk: + _SUPPORTED_PARTITION_TYPES = {0x04, 0x06, 0x0B, 0x0C, 0x0E} + + def __init__(self, path: str | os.PathLike[str], *, writable: bool) -> None: + self.path = Path(path) + self._stream: BinaryIO = self.path.open("r+b" if writable else "rb", buffering=1024 * 1024) + try: + self._initialize() + except Exception: + self._stream.close() + raise + + def _initialize(self) -> None: + self._stream.seek(0, os.SEEK_END) + self._image_bytes = self._stream.tell() + mbr = self._read_at(0, 512) + if mbr[510:512] != b"\x55\xaa": + raise Fat16Error("文件没有有效的 MBR 分区表") + partition_type = mbr[450] + partition_lba, partition_sectors = struct.unpack_from(" self._image_bytes: + raise Fat16Error("镜像分区表超出文件边界") + + bpb = self._read_at(self._partition_offset, 512) + bytes_per_sector = struct.unpack_from(" self._partition_end or self._fat_offset + self._fat_bytes > self._partition_end: + raise Fat16Error("FAT16 布局超出分区边界") + + def __enter__(self) -> "Fat16Disk": + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + def close(self) -> None: + self._stream.close() + + def _read_at(self, offset: int, count: int) -> bytes: + if offset < 0 or count < 0 or offset + count > self._image_bytes: + raise Fat16Error("镜像数据超出文件边界") + self._stream.seek(offset) + value = self._stream.read(count) + if len(value) != count: + raise Fat16Error("镜像数据被截断") + return value + + @staticmethod + def _name83(name: str) -> bytes: + parts = name.upper().split(".", 1) + if not 1 <= len(parts[0]) <= 8 or (len(parts) == 2 and len(parts[1]) > 3): + raise ValueError("文件名不是 8.3 格式") + try: + return (parts[0].ljust(8) + (parts[1] if len(parts) == 2 else "").ljust(3)).encode("ascii") + except UnicodeEncodeError as exception: + raise ValueError("文件名不是 ASCII 8.3 格式") from exception + + def _find(self, name: str) -> FatEntry: + expected = self._name83(name) + root = self._read_at(self._root_offset, self._root_entries * 32) + for index in range(self._root_entries): + entry = root[index * 32 : (index + 1) * 32] + if entry[0] == 0x00: + break + if entry[0] == 0xE5 or entry[11] == 0x0F: + continue + if entry[:11] == expected: + first_cluster = struct.unpack_from(" Iterator[int]: + seen: set[int] = set() + cluster = first_cluster + while 2 <= cluster < 0xFFF8: + if cluster in seen: + raise Fat16Error("FAT16 簇链循环") + if cluster * 2 + 2 > len(fat): + raise Fat16Error("FAT16 簇号超出 FAT 边界") + offset = self._data_offset + (cluster - 2) * self._cluster_bytes + if offset + self._cluster_bytes > self._partition_end: + raise Fat16Error("FAT16 簇超出分区边界") + seen.add(cluster) + yield cluster + cluster = struct.unpack_from(" int: + return self._data_offset + (cluster - 2) * self._cluster_bytes + + def read_file(self, name: str) -> bytes: + entry = self._find(name) + if entry.size == 0: + return b"" + fat = self._read_at(self._fat_offset, self._fat_bytes) + result = bytearray(entry.size) + written = 0 + for cluster in self._chain(entry.first_cluster, fat): + count = min(self._cluster_bytes, entry.size - written) + result[written : written + count] = self._read_at(self._cluster_offset(cluster), count) + written += count + if written == entry.size: + break + if written != entry.size: + raise Fat16Error("镜像配置文件被截断") + return bytes(result) + + def write_file_range(self, name: str, file_offset: int, data: bytes) -> None: + entry = self._find(name) + if file_offset < 0 or file_offset + len(data) > entry.size: + raise ValueError("写入范围超出配置文件") + fat = self._read_at(self._fat_offset, self._fat_bytes) + chain = list(self._chain(entry.first_cluster, fat)) + remaining = len(data) + source_offset = 0 + position = file_offset + while remaining: + chain_index, within = divmod(position, self._cluster_bytes) + if chain_index >= len(chain): + raise Fat16Error("镜像配置文件簇链不足") + count = min(remaining, self._cluster_bytes - within) + absolute = self._cluster_offset(chain[chain_index]) + within + if absolute + count > self._partition_end: + raise Fat16Error("写入范围超出 FAT16 分区") + self._stream.seek(absolute) + self._stream.write(data[source_offset : source_offset + count]) + position += count + source_offset += count + remaining -= count + self._stream.flush() + os.fsync(self._stream.fileno()) + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/gui.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/gui.py new file mode 100644 index 0000000..733f584 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/gui.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys +from typing import Any, Callable + +from PySide6.QtCore import QObject, QRunnable, QThreadPool, Qt, Signal +from PySide6.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent, QFont +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QFrame, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPlainTextEdit, + QProgressBar, + QPushButton, + QScrollArea, + QSpinBox, + QStyle, + QVBoxLayout, + QWidget, +) + +from . import __version__ +from .config import validate_config +from .operations import copy_write_and_verify, read_config, write_and_verify +from .paths import validate_new_destination + + +class AppDialog(QDialog): + def __init__(self, parent: QWidget | None, message: str, title: str, icon: QStyle.StandardPixmap, confirm: bool) -> None: + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumSize(520, 260) + self.resize(640, 320) + + icon_label = QLabel() + icon_label.setPixmap(self.style().standardIcon(icon).pixmap(48, 48)) + icon_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + body = QPlainTextEdit(message) + body.setReadOnly(True) + body.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No + if confirm + else QDialogButtonBox.StandardButton.Ok + ) + if confirm: + buttons.button(QDialogButtonBox.StandardButton.Yes).setText("是") + buttons.button(QDialogButtonBox.StandardButton.No).setText("否") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + else: + buttons.button(QDialogButtonBox.StandardButton.Ok).setText("确定") + buttons.accepted.connect(self.accept) + + top = QHBoxLayout() + top.addWidget(icon_label) + top.addWidget(body, 1) + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(20) + layout.addLayout(top, 1) + layout.addWidget(buttons) + + @classmethod + def information(cls, parent: QWidget, message: str, title: str) -> None: + cls(parent, message, title, QStyle.StandardPixmap.SP_MessageBoxInformation, False).exec() + + @classmethod + def error(cls, parent: QWidget | None, message: str, title: str) -> None: + cls(parent, message, title, QStyle.StandardPixmap.SP_MessageBoxCritical, False).exec() + + @classmethod + def confirm(cls, parent: QWidget, message: str, title: str) -> bool: + return cls(parent, message, title, QStyle.StandardPixmap.SP_MessageBoxWarning, True).exec() == QDialog.DialogCode.Accepted + + +class WorkerSignals(QObject): + progress = Signal(int) + succeeded = Signal(object) + failed = Signal(str) + + +class Worker(QRunnable): + def __init__(self, operation: Callable[[Callable[[int], None]], Any]) -> None: + super().__init__() + self.operation = operation + self.signals = WorkerSignals() + + def run(self) -> None: + try: + result = self.operation(self.signals.progress.emit) + except Exception as exception: + self.signals.failed.emit(str(exception)) + else: + self.signals.succeeded.emit(result) + + +class MainWindow(QMainWindow): + PREFERRED_SIZE = (960, 760) + MINIMUM_SIZE = (760, 600) + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle(f"核桃派镜像配置编辑器 {__version__}") + self.setAcceptDrops(True) + self._image_path: Path | None = None + self._loaded: dict[str, Any] | None = None + self._busy = False + self._workers: set[Worker] = set() + self._thread_pool = QThreadPool.globalInstance() + self._screen_signal_connected = False + + self.path = QLineEdit() + self.path.setReadOnly(True) + self.version = QLabel("尚未打开镜像") + self.username = QLineEdit() + self.account_password = QLineEdit() + self.ssid = QLineEdit() + self.wifi_password = QLineEdit() + self.mode = QComboBox() + self.mode.addItems(["自动获取(DHCP)", "静态 IPv4"]) + self.address = QLineEdit() + self.prefix = QSpinBox() + self.prefix.setRange(1, 32) + self.prefix.setValue(24) + self.gateway = QLineEdit() + self.dns = QLineEdit() + self.open_button = QPushButton("打开镜像…") + self.save_button = QPushButton("修改原镜像") + self.save_as_button = QPushButton("另存为…") + self.progress = QProgressBar() + self.progress.setRange(0, 100) + self.progress.hide() + self.save_button.setEnabled(False) + self.save_as_button.setEnabled(False) + + self.account_password.setEchoMode(QLineEdit.EchoMode.Password) + self.wifi_password.setEchoMode(QLineEdit.EchoMode.Password) + self.mode.currentIndexChanged.connect(self._update_network_fields) + self.open_button.clicked.connect(self._open_dialog) + self.save_button.clicked.connect(self._save_original) + self.save_as_button.clicked.connect(self._save_as) + self.setCentralWidget(self._build_ui()) + self._update_network_fields() + + def _build_ui(self) -> QWidget: + content = QWidget() + outer = QVBoxLayout(content) + outer.setContentsMargins(24, 24, 24, 24) + outer.setSpacing(12) + + header = QHBoxLayout() + header.addWidget(self.path, 1) + self.open_button.setMinimumSize(130, 38) + header.addWidget(self.open_button) + outer.addLayout(header) + outer.addWidget(self.version) + outer.addWidget(self._group("登录账户", [("用户名", self.username), ("密码", self._password_row(self.account_password))])) + outer.addWidget(self._group("Wi-Fi", [("SSID", self.ssid), ("密码", self._password_row(self.wifi_password))])) + outer.addWidget( + self._group( + "IPv4", + [ + ("方式", self.mode), + ("地址", self.address), + ("前缀", self.prefix), + ("网关", self.gateway), + ("DNS(逗号分隔)", self.dns), + ], + ) + ) + + footer = QHBoxLayout() + footer.addWidget(self.progress, 1) + footer.addStretch(1) + self.save_button.setMinimumSize(150, 40) + self.save_as_button.setMinimumSize(130, 40) + footer.addWidget(self.save_button) + footer.addWidget(self.save_as_button) + outer.addLayout(footer) + + scroll = QScrollArea() + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setWidgetResizable(True) + scroll.setWidget(content) + return scroll + + @staticmethod + def _group(title: str, rows: list[tuple[str, QWidget]]) -> QGroupBox: + group = QGroupBox(title) + form = QFormLayout(group) + form.setContentsMargins(16, 20, 16, 16) + form.setHorizontalSpacing(18) + form.setVerticalSpacing(10) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + for label, widget in rows: + widget.setMinimumWidth(320) + form.addRow(label, widget) + return group + + @staticmethod + def _password_row(field: QLineEdit) -> QWidget: + wrapper = QWidget() + layout = QHBoxLayout(wrapper) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(12) + show = QCheckBox("显示") + show.toggled.connect(lambda checked: field.setEchoMode(QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password)) + layout.addWidget(field, 1) + layout.addWidget(show) + return wrapper + + def showEvent(self, event: Any) -> None: + super().showEvent(event) + self._fit_to_screen() + handle = self.windowHandle() + if handle is not None and not self._screen_signal_connected: + handle.screenChanged.connect(self._fit_to_screen) + self._screen_signal_connected = True + + def _fit_to_screen(self, *_args: object) -> None: + screen = self.screen() or QApplication.primaryScreen() + if screen is None: + return + area = screen.availableGeometry() + maximum_width = max(320, int(area.width() * 0.9)) + maximum_height = max(240, int(area.height() * 0.9)) + minimum_width = min(self.MINIMUM_SIZE[0], maximum_width) + minimum_height = min(self.MINIMUM_SIZE[1], maximum_height) + self.setMinimumSize(minimum_width, minimum_height) + width = max(minimum_width, min(self.PREFERRED_SIZE[0], maximum_width)) + height = max(minimum_height, min(self.PREFERRED_SIZE[1], maximum_height)) + self.resize(width, height) + self.move(area.left() + max(0, (area.width() - width) // 2), area.top() + max(0, (area.height() - height) // 2)) + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + urls = event.mimeData().urls() if event.mimeData().hasUrls() else [] + if not self._busy and len(urls) == 1 and urls[0].isLocalFile(): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event: QDropEvent) -> None: + urls = event.mimeData().urls() if event.mimeData().hasUrls() else [] + if not self._busy and len(urls) == 1 and urls[0].isLocalFile(): + event.acceptProposedAction() + self._open_image(urls[0].toLocalFile()) + else: + event.ignore() + + def closeEvent(self, event: QCloseEvent) -> None: + if self._busy: + event.ignore() + return + super().closeEvent(event) + + def _open_dialog(self) -> None: + filename, _filter = QFileDialog.getOpenFileName(self, "打开核桃派镜像", "", "核桃派镜像 (*.img)") + if filename: + self._open_image(filename) + + def _open_image(self, filename: str) -> None: + try: + config = read_config(filename) + self._apply_loaded(Path(filename).resolve(), config) + except Exception as exception: + AppDialog.error(self, str(exception), "无法打开镜像") + + def _apply_loaded(self, filename: Path, config: dict[str, Any]) -> None: + self._image_path = filename + self._loaded = config + self.path.setText(str(filename)) + self.version.setText(f"软件版本:{config['software_version']} 配置校验:正常") + self.username.setText(config["account"]["username"]) + self.account_password.setText(config["account"]["password"]) + self.ssid.setText(config["wifi"]["ssid"]) + self.wifi_password.setText(config["wifi"]["password"]) + ipv4 = config["ipv4"] + self.mode.setCurrentIndex(1 if ipv4["mode"] == "static" else 0) + self.address.setText(ipv4["address"]) + self.prefix.setValue(ipv4["prefix"] or 24) + self.gateway.setText(ipv4["gateway"]) + self.dns.setText(",".join(ipv4["dns"])) + self.save_button.setEnabled(True) + self.save_as_button.setEnabled(True) + self._update_network_fields() + + def _collect(self) -> dict[str, Any]: + if self._loaded is None: + raise ValueError("尚未打开镜像") + ipv4: dict[str, Any] + if self.mode.currentIndex() == 0: + ipv4 = {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []} + else: + ipv4 = { + "mode": "static", + "address": self.address.text().strip(), + "prefix": self.prefix.value(), + "gateway": self.gateway.text().strip(), + "dns": [item.strip() for item in self.dns.text().split(",") if item.strip()], + } + return validate_config( + { + "schema_version": self._loaded["schema_version"], + "product": self._loaded["product"], + "software_version": self._loaded["software_version"], + "account": {"username": self.username.text().strip(), "password": self.account_password.text()}, + "wifi": {"ssid": self.ssid.text(), "password": self.wifi_password.text()}, + "ipv4": ipv4, + } + ) + + def _save_original(self) -> None: + if self._image_path is None: + return + try: + config = self._collect() + except Exception as exception: + AppDialog.error(self, str(exception), "保存失败") + return + if not AppDialog.confirm( + self, + "这会直接修改当前镜像。建议日常使用“另存为”保留原始包。\n\n确定继续吗?", + "确认修改原镜像", + ): + return + target = self._image_path + self._start_worker( + lambda progress: write_and_verify(target, config, progress), + lambda verified: self._save_succeeded(target, verified, "配置已保存,镜像与 SHA-256 摘要均已完成写后校验。", "保存成功"), + ) + + def _save_as(self) -> None: + if self._image_path is None: + return + try: + config = self._collect() + except Exception as exception: + AppDialog.error(self, str(exception), "另存为失败") + return + dialog = QFileDialog(self, "请重命名,以便区分设备或使用地点", str(self._image_path.parent), "核桃派镜像 (*.img)") + dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) + dialog.setFileMode(QFileDialog.FileMode.AnyFile) + dialog.setOption(QFileDialog.Option.DontConfirmOverwrite, True) + dialog.selectFile(f"{self._image_path.stem}-自定义.img") + if dialog.exec() != QDialog.DialogCode.Accepted or not dialog.selectedFiles(): + return + selected = dialog.selectedFiles()[0] + if Path(selected).suffix == "": + selected += ".img" + try: + destination = validate_new_destination(self._image_path, selected) + except Exception as exception: + AppDialog.error(self, str(exception), "另存为失败") + return + source = self._image_path + self._start_worker( + lambda progress: copy_write_and_verify(source, destination, config, progress), + lambda result: self._save_succeeded( + result[0], result[1], "自定义镜像已保存并重新打开,镜像与 SHA-256 摘要均已完成写后校验。", "另存为成功" + ), + ) + + def _start_worker(self, operation: Callable[[Callable[[int], None]], Any], on_success: Callable[[Any], None]) -> None: + self._set_busy(True) + worker = Worker(operation) + self._workers.add(worker) + worker.signals.progress.connect(self.progress.setValue) + + def succeeded(result: Any) -> None: + self._workers.discard(worker) + self._set_busy(False) + on_success(result) + + def failed(message: str) -> None: + self._workers.discard(worker) + self._set_busy(False) + AppDialog.error(self, message, "保存失败") + + worker.signals.succeeded.connect(succeeded) + worker.signals.failed.connect(failed) + self._thread_pool.start(worker) + + def _save_succeeded(self, target: Path, verified: dict[str, Any], message: str, title: str) -> None: + self._apply_loaded(target, verified) + AppDialog.information(self, message, title) + + def _set_busy(self, busy: bool) -> None: + self._busy = busy + self.open_button.setEnabled(not busy) + self.save_button.setEnabled(not busy and self._loaded is not None) + self.save_as_button.setEnabled(not busy and self._loaded is not None) + self.progress.setVisible(busy) + self.progress.setValue(0) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) if busy else QApplication.restoreOverrideCursor() + + def _update_network_fields(self) -> None: + enabled = self.mode.currentIndex() == 1 + for widget in (self.address, self.prefix, self.gateway, self.dns): + widget.setEnabled(enabled) + + +def run_gui() -> int: + app = QApplication(sys.argv) + app.setApplicationName("核桃派镜像配置编辑器") + app.setApplicationVersion(__version__) + if os.name == "nt": + app.setFont(QFont("Microsoft YaHei UI", 10)) + window = MainWindow() + window.show() + return app.exec() diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/operations.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/operations.py new file mode 100644 index 0000000..6a2849a --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/operations.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import shutil +from typing import Any, Callable +import uuid + +from .config import encode_update, read_config_file, serialize_config, slot_offset, validate_config +from .fat16 import Fat16Disk +from .paths import ( + cleanup_new_destination, + ensure_sidecar_writable, + sidecar_path, + validate_existing_image, + validate_new_destination, +) + + +Progress = Callable[[int], None] +_CHUNK_BYTES = 4 * 1024 * 1024 + + +class ImageOperationError(ValueError): + pass + + +def _hash_file(path: Path, progress: Progress | None = None, *, start: int = 0, span: int = 100) -> str: + digest = hashlib.sha256() + size = path.stat().st_size + completed = 0 + with path.open("rb", buffering=_CHUNK_BYTES) as stream: + for chunk in iter(lambda: stream.read(_CHUNK_BYTES), b""): + digest.update(chunk) + completed += len(chunk) + if progress is not None: + fraction = completed / size if size else 1.0 + progress(min(100, start + int(fraction * span))) + if progress is not None: + progress(min(100, start + span)) + return digest.hexdigest() + + +def _read_sidecar_digest(path: Path, expected_filename: str) -> str: + try: + if path.stat().st_size > 128 * 1024: + raise ImageOperationError("镜像 SHA-256 摘要文件过大") + raw = path.read_bytes() + except OSError as exception: + raise ImageOperationError(f"无法读取镜像 SHA-256 摘要:{exception}") from exception + if raw.startswith(b"\xef\xbb\xbf"): + raise ImageOperationError("镜像 SHA-256 摘要必须使用无 BOM UTF-8 格式") + try: + content = raw.decode("utf-8", errors="strict") + except UnicodeDecodeError as exception: + raise ImageOperationError("镜像 SHA-256 摘要不是有效的 UTF-8 文本") from exception + if len(content) < 68 or not content.endswith("\n") or "\r" in content or "\n" in content[:-1]: + raise ImageOperationError("镜像 SHA-256 摘要文件必须是以 LF 结尾的单行规范格式") + line = content[:-1] + digest = line[:64] + if len(line) < 67 or any(character not in "0123456789abcdef" for character in digest) or line[64:66] != " ": + raise ImageOperationError("镜像 SHA-256 摘要文件格式无效") + if line[66:] != expected_filename: + raise ImageOperationError("镜像 SHA-256 摘要中的文件名与当前镜像不一致") + return digest + + +def verify_sidecar_if_present(image_path: Path, progress: Progress | None = None) -> None: + sidecar = sidecar_path(image_path) + if not sidecar.exists(): + return + expected = _read_sidecar_digest(sidecar, image_path.name) + actual = _hash_file(image_path, progress) + if actual != expected: + raise ImageOperationError("镜像内容与 SHA-256 摘要不符,禁止编辑") + + +def read_config(image: str | os.PathLike[str], progress: Progress | None = None) -> dict[str, Any]: + image_path = validate_existing_image(image) + verify_sidecar_if_present(image_path, progress) + with Fat16Disk(image_path, writable=False) as disk: + config, _generation, _slot = read_config_file(disk.read_file("MSCCFG.BIN")) + return config + + +def write_config(image: str | os.PathLike[str], config: Any) -> None: + image_path = validate_existing_image(image) + ensure_sidecar_writable(image_path) + verify_sidecar_if_present(image_path) + normalized = validate_config(config) + with Fat16Disk(image_path, writable=True) as disk: + current = disk.read_file("MSCCFG.BIN") + active, _generation, _slot = read_config_file(current) + if active["software_version"] != normalized["software_version"]: + raise ImageOperationError("禁止通过编辑器改变软件版本") + slot_data, target = encode_update(current, normalized) + disk.write_file_range("MSCCFG.BIN", slot_offset(target), slot_data) + verified, _generation, _slot = read_config_file(disk.read_file("MSCCFG.BIN")) + if serialize_config(verified) != serialize_config(normalized): + raise ImageOperationError("配置写后校验失败") + + +def write_sha256(image: str | os.PathLike[str], progress: Progress | None = None) -> None: + image_path = validate_existing_image(image) + ensure_sidecar_writable(image_path) + digest = _hash_file(image_path, progress) + sidecar = sidecar_path(image_path) + temporary = Path(str(sidecar) + f".{uuid.uuid4().hex}.tmp") + try: + payload = f"{digest} {image_path.name}\n".encode("utf-8") + with temporary.open("xb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, sidecar) + stored = _read_sidecar_digest(sidecar, image_path.name) + if stored != digest: + raise ImageOperationError("镜像 SHA-256 摘要写后校验失败") + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +def write_and_verify(image: str | os.PathLike[str], config: Any, progress: Progress | None = None) -> dict[str, Any]: + write_config(image, config) + write_sha256(image, progress) + return read_config(image) + + +def _copy_file(source: Path, destination: Path, progress: Progress | None = None) -> None: + total = source.stat().st_size + completed = 0 + with source.open("rb", buffering=_CHUNK_BYTES) as incoming, destination.open("xb", buffering=_CHUNK_BYTES) as outgoing: + for chunk in iter(lambda: incoming.read(_CHUNK_BYTES), b""): + outgoing.write(chunk) + completed += len(chunk) + if progress is not None: + progress(int((completed / total if total else 1.0) * 35)) + outgoing.flush() + os.fsync(outgoing.fileno()) + try: + shutil.copystat(source, destination) + except OSError: + pass + + +def copy_write_and_verify( + source: str | os.PathLike[str], + destination: str | os.PathLike[str], + config: Any, + progress: Progress | None = None, +) -> tuple[Path, dict[str, Any]]: + source_path = validate_existing_image(source) + target = validate_new_destination(source_path, destination) + normalized = validate_config(config) + read_config(source_path) + try: + _copy_file(source_path, target, progress) + write_config(target, normalized) + write_sha256(target, None if progress is None else lambda value: progress(35 + value * 65 // 100)) + verified = read_config(target) + return target, verified + except Exception as exception: + cleanup_error = cleanup_new_destination(target) + if cleanup_error is not None: + raise ImageOperationError(f"{exception}\n清理失败:{cleanup_error}") from exception + raise + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/paths.py b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/paths.py new file mode 100644 index 0000000..664cafe --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/src/matrix_image_editor/paths.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import os +from pathlib import Path +import re +import uuid + + +class ImagePathError(ValueError): + pass + + +_WINDOWS_RESERVED = re.compile(r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$", re.IGNORECASE | re.ASCII) +_WINDOWS_INVALID = set('<>:"/\\|?*') + + +def _absolute(path: str | os.PathLike[str]) -> Path: + if not str(path).strip(): + raise ImagePathError("镜像路径不能为空") + try: + return Path(path).expanduser().resolve(strict=False) + except (OSError, RuntimeError, ValueError) as exception: + raise ImagePathError("镜像路径格式无效") from exception + + +def _validate_name_and_directory(path: str | os.PathLike[str]) -> Path: + full = _absolute(path) + name = full.name + if not name.strip(): + raise ImagePathError("镜像文件名不能为空") + if full.suffix.lower() != ".img": + raise ImagePathError("镜像文件名必须以 .img 结尾") + if os.name == "nt": + if name != name.rstrip(" .") or any(character in _WINDOWS_INVALID or ord(character) < 32 for character in name): + raise ImagePathError("镜像文件名包含 Windows 不允许的字符,或以空格、句点结尾") + if _WINDOWS_RESERVED.fullmatch(full.stem): + raise ImagePathError("镜像文件名使用了 Windows 保留名称,请更换名称") + if not full.parent.is_dir(): + raise ImagePathError("镜像目标文件夹不存在") + return full + + +def sidecar_path(image_path: Path) -> Path: + return Path(str(image_path) + ".sha256") + + +def validate_existing_image(path: str | os.PathLike[str]) -> Path: + full = _validate_name_and_directory(path) + if not full.is_file(): + raise ImagePathError(f"镜像文件不存在:{full}") + sidecar = sidecar_path(full) + if sidecar.is_dir(): + raise ImagePathError("同名 SHA-256 摘要路径被文件夹占用") + return full + + +def validate_new_destination(source: str | os.PathLike[str], destination: str | os.PathLike[str]) -> Path: + source_path = validate_existing_image(source) + target = _validate_name_and_directory(destination) + if os.path.normcase(str(target)) == os.path.normcase(str(source_path)): + raise ImagePathError("另存为必须使用与原镜像不同的文件名") + if target.exists(): + raise ImagePathError("目标镜像已经存在,请使用新的文件名") + sidecar = sidecar_path(target) + if sidecar.exists(): + raise ImagePathError("目标镜像的同名 SHA-256 摘要已经存在,请使用新的文件名") + _probe_directory(target.parent) + return target + + +def ensure_sidecar_writable(image_path: Path) -> None: + sidecar = sidecar_path(image_path) + if sidecar.exists(): + if not sidecar.is_file(): + raise ImagePathError("同名 SHA-256 摘要不是普通文件") + try: + with sidecar.open("ab"): + pass + except OSError as exception: + raise ImagePathError("同名 SHA-256 摘要不可写") from exception + _probe_directory(image_path.parent) + + +def _probe_directory(directory: Path) -> None: + probe = directory / f".matrix-image-editor-{uuid.uuid4().hex}.tmp" + try: + with probe.open("xb") as stream: + stream.flush() + os.fsync(stream.fileno()) + except OSError as exception: + raise ImagePathError("镜像目标文件夹不可写") from exception + finally: + try: + probe.unlink(missing_ok=True) + except OSError: + pass + + +def cleanup_new_destination(destination: str | os.PathLike[str]) -> str | None: + target = Path(destination) + failures: list[str] = [] + for item in (sidecar_path(target), target): + try: + if item.is_file() or item.is_symlink(): + item.unlink() + except OSError as exception: + failures.append(f"{item.name}:{exception}") + return ";".join(failures) if failures else None + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/tests/fixture_image.py b/发布更新相关/镜像编辑器/编辑器源代码/tests/fixture_image.py new file mode 100644 index 0000000..47fd368 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/tests/fixture_image.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +import struct + + +SECTOR_BYTES = 512 +PARTITION_LBA = 1 +PARTITION_SECTORS = 8192 +RESERVED_SECTORS = 1 +FAT_COUNT = 2 +FAT_SECTORS = 32 +ROOT_ENTRIES = 512 +ROOT_SECTORS = ROOT_ENTRIES * 32 // SECTOR_BYTES +DATA_RELATIVE_SECTOR = RESERVED_SECTORS + FAT_COUNT * FAT_SECTORS + ROOT_SECTORS + + +def build_fixture(path: Path, config_file: bytes) -> Path: + if len(config_file) != 64 * 1024: + raise ValueError("配置文件必须为 64 KiB") + image = bytearray((PARTITION_LBA + PARTITION_SECTORS) * SECTOR_BYTES) + image[510:512] = b"\x55\xaa" + partition_entry = 446 + image[partition_entry + 4] = 0x06 + struct.pack_into(" int: + return (PARTITION_LBA + RESERVED_SECTORS) * SECTOR_BYTES + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/tests/test_config.py b/发布更新相关/镜像编辑器/编辑器源代码/tests/test_config.py new file mode 100644 index 0000000..b3d9a6a --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/tests/test_config.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import copy +import importlib.util +from pathlib import Path +import sys +import unittest + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SOURCE_ROOT / "src")) + +from matrix_image_editor.config import ( # noqa: E402 + HEADER_BYTES, + SLOT_BYTES, + ImageConfigError, + create_config_file, + read_config_file, + update_config_file, + validate_config, +) + + +def sample_config() -> dict: + return { + "schema_version": 1, + "product": "matrix-screen-controller-walnutpi", + "software_version": "1.0.3", + "account": {"username": "matrix", "password": "password123"}, + "wifi": {"ssid": "测试 WiFi", "password": "wifi-pass-123"}, + "ipv4": {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}, + } + + +class ConfigTests(unittest.TestCase): + def test_round_trip_and_update_inactive_slot(self) -> None: + original = create_config_file(sample_config()) + config, generation, slot = read_config_file(original) + self.assertEqual((generation, slot), (1, 0)) + config["wifi"]["ssid"] = "新网络" + updated = update_config_file(original, config) + verified, generation, slot = read_config_file(updated) + self.assertEqual((generation, slot, verified["wifi"]["ssid"]), (2, 1, "新网络")) + self.assertEqual(updated[HEADER_BYTES : HEADER_BYTES + SLOT_BYTES], original[HEADER_BYTES : HEADER_BYTES + SLOT_BYTES]) + + def test_corrupt_active_slot_falls_back_and_both_corrupt_fail(self) -> None: + original = create_config_file(sample_config()) + updated = bytearray(update_config_file(original, sample_config())) + updated[HEADER_BYTES + SLOT_BYTES + 60] ^= 0x01 + _config, generation, slot = read_config_file(bytes(updated)) + self.assertEqual((generation, slot), (1, 0)) + updated[HEADER_BYTES + 60] ^= 0x01 + with self.assertRaises(ImageConfigError): + read_config_file(bytes(updated)) + + def test_rejects_unknown_product_schema_and_fields(self) -> None: + for field, value in (("product", "wrong"), ("schema_version", 2)): + config = sample_config() + config[field] = value + with self.assertRaises(ImageConfigError): + validate_config(config) + config = sample_config() + config["unexpected"] = True + with self.assertRaises(ImageConfigError): + validate_config(config) + + def test_utf8_ssid_and_utf16_secret_lengths_match_contract(self) -> None: + config = sample_config() + config["wifi"]["ssid"] = "中" * 10 + validate_config(config) + config["wifi"]["ssid"] = "中" * 11 + with self.assertRaises(ImageConfigError): + validate_config(config) + config = sample_config() + config["account"]["password"] = "😀" * 4 + validate_config(config) + + def test_static_ipv4_requires_same_subnet_and_valid_dns(self) -> None: + config = sample_config() + config["ipv4"] = { + "mode": "static", + "address": "192.168.10.20", + "prefix": 24, + "gateway": "192.168.10.1", + "dns": ["1.1.1.1", "8.8.8.8"], + } + validate_config(config) + config["ipv4"]["gateway"] = "192.168.11.1" + with self.assertRaises(ImageConfigError): + validate_config(config) + + def test_project_codec_cross_compatibility_when_repository_is_present(self) -> None: + project_codec = SOURCE_ROOT.parents[2] / "核桃派软件源代码" / "scripts" / "image_config.py" + if not project_codec.is_file(): + self.skipTest("独立源码副本中没有主工程交叉校验器") + spec = importlib.util.spec_from_file_location("project_image_config", project_codec) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + ours = create_config_file(copy.deepcopy(sample_config())) + theirs = module.create_config_file(copy.deepcopy(sample_config())) + self.assertEqual(module.read_config_file(ours)[0], read_config_file(theirs)[0]) + + +if __name__ == "__main__": + unittest.main() + diff --git a/发布更新相关/镜像编辑器/编辑器源代码/tests/test_gui.py b/发布更新相关/镜像编辑器/编辑器源代码/tests/test_gui.py new file mode 100644 index 0000000..2155c94 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/tests/test_gui.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +SOURCE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SOURCE_ROOT / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from PySide6.QtWidgets import QApplication, QLineEdit # noqa: E402 +from matrix_image_editor.gui import MainWindow # noqa: E402 +from test_config import sample_config # noqa: E402 + + +class GuiTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self) -> None: + self.window = MainWindow() + + def tearDown(self) -> None: + self.window.close() + + def test_passwords_are_hidden_and_dhcp_disables_static_fields(self) -> None: + self.assertEqual(self.window.account_password.echoMode(), QLineEdit.EchoMode.Password) + self.assertEqual(self.window.wifi_password.echoMode(), QLineEdit.EchoMode.Password) + for widget in (self.window.address, self.window.prefix, self.window.gateway, self.window.dns): + self.assertFalse(widget.isEnabled()) + self.window.mode.setCurrentIndex(1) + for widget in (self.window.address, self.window.prefix, self.window.gateway, self.window.dns): + self.assertTrue(widget.isEnabled()) + + def test_loaded_values_collect_without_changing_image_version(self) -> None: + config = sample_config() + self.window._apply_loaded(Path("sample.img"), config) + self.window.username.setText("new_user") + collected = self.window._collect() + self.assertEqual(collected["software_version"], config["software_version"]) + self.assertEqual(collected["account"]["username"], "new_user") + + +if __name__ == "__main__": + unittest.main() diff --git a/发布更新相关/镜像编辑器/编辑器源代码/tests/test_image_operations.py b/发布更新相关/镜像编辑器/编辑器源代码/tests/test_image_operations.py new file mode 100644 index 0000000..7bba26f --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/tests/test_image_operations.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import struct +import sys +import tempfile +import unittest + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SOURCE_ROOT / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fixture_image import build_fixture, first_fat_offset # noqa: E402 +from test_config import sample_config # noqa: E402 +from matrix_image_editor.config import create_config_file # noqa: E402 +from matrix_image_editor.cli import run_command # noqa: E402 +from matrix_image_editor.fat16 import Fat16Disk, Fat16Error # noqa: E402 +from matrix_image_editor.operations import ( # noqa: E402 + ImageOperationError, + copy_write_and_verify, + read_config, + write_and_verify, +) +from matrix_image_editor.paths import ImagePathError, cleanup_new_destination, validate_new_destination # noqa: E402 + + +class ImageOperationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="镜像编辑器-") + self.root = Path(self.temporary.name) + self.image = build_fixture(self.root / "原 镜像.img", create_config_file(sample_config())) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_read_write_sidecar_and_reopen(self) -> None: + config = read_config(self.image) + config["wifi"]["ssid"] = "中文 新 WiFi" + progress: list[int] = [] + verified = write_and_verify(self.image, config, progress.append) + self.assertEqual(verified["wifi"]["ssid"], "中文 新 WiFi") + sidecar = Path(str(self.image) + ".sha256") + raw = sidecar.read_bytes() + self.assertFalse(raw.startswith(b"\xef\xbb\xbf")) + self.assertEqual(raw, f"{hashlib.sha256(self.image.read_bytes()).hexdigest()} {self.image.name}\n".encode("utf-8")) + self.assertEqual(progress[-1], 100) + + def test_rejects_bad_sidecar_forms_and_content(self) -> None: + sidecar = Path(str(self.image) + ".sha256") + digest = hashlib.sha256(self.image.read_bytes()).hexdigest() + invalid = [ + b"\xef\xbb\xbf" + f"{digest} {self.image.name}\n".encode("utf-8"), + f"{digest.upper()} {self.image.name}\n".encode("utf-8"), + f"{digest} {self.image.name}\n".encode("utf-8"), + f"{digest} wrong.img\n".encode("utf-8"), + f"{'0' * 64} {self.image.name}\n".encode("utf-8"), + f"{digest} {self.image.name}\r\n".encode("utf-8"), + ] + for payload in invalid: + with self.subTest(payload=payload[:12]): + sidecar.write_bytes(payload) + with self.assertRaises(ImageOperationError): + read_config(self.image) + + def test_copy_apply_preserves_source_and_cleans_failure(self) -> None: + original = self.image.read_bytes() + config = sample_config() + config["account"]["username"] = "new_user" + target, verified = copy_write_and_verify(self.image, self.root / "新 镜像.img", config) + self.assertEqual(verified["account"]["username"], "new_user") + self.assertEqual(self.image.read_bytes(), original) + self.assertTrue(Path(str(target) + ".sha256").is_file()) + + failed = self.root / "失败.img" + wrong_version = sample_config() + wrong_version["software_version"] = "9.9.9" + with self.assertRaises(ImageOperationError): + copy_write_and_verify(self.image, failed, wrong_version) + self.assertFalse(failed.exists()) + self.assertFalse(Path(str(failed) + ".sha256").exists()) + + def test_path_rejections(self) -> None: + with self.assertRaises(ImagePathError): + validate_new_destination(self.image, self.image) + existing = self.root / "existing.img" + existing.write_bytes(b"") + with self.assertRaises(ImagePathError): + validate_new_destination(self.image, existing) + orphan = self.root / "orphan.img" + Path(str(orphan) + ".sha256").write_text("orphan", encoding="utf-8") + with self.assertRaises(ImagePathError): + validate_new_destination(self.image, orphan) + with self.assertRaises(ImagePathError): + validate_new_destination(self.image, self.root / "wrong.txt") + if os.name == "nt": + with self.assertRaises(ImagePathError): + validate_new_destination(self.image, self.root / "CON.img") + + def test_fat_cycle_and_partition_bounds_are_rejected(self) -> None: + cyclic = self.root / "cycle.img" + cyclic.write_bytes(self.image.read_bytes()) + with cyclic.open("r+b") as stream: + stream.seek(first_fat_offset() + 2 * 2) + stream.write(struct.pack(" None: + import contextlib + import io + + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(run_command(["--validate", str(self.image)]), 0) + document = json.loads(output.getvalue()) + self.assertEqual( + set(document), + {"valid", "SoftwareVersion", "Username", "Ssid", "Mode", "Address"}, + ) + config_path = self.root / "config.json" + config_path.write_text(json.dumps(sample_config(), ensure_ascii=False), encoding="utf-8-sig") + target = self.root / "cli-new.img" + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(run_command(["--apply", str(self.image), str(config_path), str(target)]), 0) + self.assertTrue(target.is_file()) + self.assertTrue(Path(str(target) + ".sha256").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/发布更新相关/镜像编辑器/编辑器源代码/windows_version_info.txt b/发布更新相关/镜像编辑器/编辑器源代码/windows_version_info.txt new file mode 100644 index 0000000..2d54991 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器源代码/windows_version_info.txt @@ -0,0 +1,26 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(2, 0, 0, 0), + prodvers=(2, 0, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo([ + StringTable('080404b0', [ + StringStruct('CompanyName', '奇妙小屏幕控制器'), + StringStruct('FileDescription', '核桃派镜像配置编辑器'), + StringStruct('FileVersion', '2.0.0.0'), + StringStruct('InternalName', 'matrix_image_editor'), + StringStruct('OriginalFilename', '核桃派镜像配置编辑器.exe'), + StringStruct('ProductName', '核桃派镜像配置编辑器'), + StringStruct('ProductVersion', '2.0.0') + ]) + ]), + VarFileInfo([VarStruct('Translation', [2052, 1200])]) + ] +) diff --git a/发布更新相关/镜像编辑器/编辑器程序/README.md b/发布更新相关/镜像编辑器/编辑器程序/README.md new file mode 100644 index 0000000..c6f7cca --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器程序/README.md @@ -0,0 +1,16 @@ +# 镜像配置编辑器 2.0.0 + +双击 `核桃派镜像配置编辑器.exe` 即可运行,不需要安装 Python、.NET、Qt,也不需要复制任何 DLL。支持 Windows 10/11 x64,不写注册表或安装系统服务。单 EXE 首次启动会将内置运行库解压到系统临时目录,退出后自动清理。 + +打开 IMG 后可查看账户、Wi-Fi 和 IPv4 设置,密码默认隐藏。“修改原镜像”会先二次确认;“另存为”会保留原文件,并要求新的合法 `.img` 文件名。中文、空格和长路径受支持;保存后会生成无 BOM UTF-8 的 `.img.sha256` 并重新校验镜像。 + +本程序不负责写 TF 卡;完成配置后请使用 Rufus 写入 IMG。日常优先使用“另存为”,不要直接修改正式发布镜像。 + +命令行接口: + +```text +核桃派镜像配置编辑器.exe --validate <镜像.img> +核桃派镜像配置编辑器.exe --apply <原镜像.img> <配置.json> <新镜像.img> +``` + +源码、macOS 构建说明和第三方许可声明位于 `../编辑器源代码/`。 diff --git a/发布更新相关/镜像编辑器/编辑器程序/SHA256SUMS b/发布更新相关/镜像编辑器/编辑器程序/SHA256SUMS new file mode 100644 index 0000000..b1b9056 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器程序/SHA256SUMS @@ -0,0 +1 @@ +44d6662770af14284df9926b788c651bfd732571b3f8e7665a0112e1622731df 核桃派镜像配置编辑器.exe diff --git a/发布更新相关/镜像编辑器/编辑器程序/VERSION b/发布更新相关/镜像编辑器/编辑器程序/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器程序/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/发布更新相关/镜像编辑器/编辑器程序/核桃派镜像配置编辑器.exe b/发布更新相关/镜像编辑器/编辑器程序/核桃派镜像配置编辑器.exe new file mode 100644 index 0000000..a5e2a32 --- /dev/null +++ b/发布更新相关/镜像编辑器/编辑器程序/核桃派镜像配置编辑器.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44d6662770af14284df9926b788c651bfd732571b3f8e7665a0112e1622731df +size 37696354 diff --git a/发布记录.json b/发布记录.json new file mode 100644 index 0000000..f59a50d --- /dev/null +++ b/发布记录.json @@ -0,0 +1,94 @@ +{ + "schema_version": 1, + "releases": [ + { + "version": "1.0.1", + "artifact_type": "ota", + "created_at": "2026-08-13T15:31:23+08:00", + "notes": "新增浏览器全量 OTA。", + "artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", + "artifact_sha256": "1ae8544e2072a014dc76e07a930375c80e85b5331957716a456c8123dd204325" + }, + { + "version": "1.0.2", + "artifact_type": "image", + "created_at": "2026-08-19T15:42:35+08:00", + "notes": "建立可刷镜像、离线首次启动和 Windows 镜像编辑能力;修复并真实验证 SSH 默认启用、配置账户密码登录、需同密码完整 sudo 权限、root 登录禁用、sshd 易失运行目录、首启 unit 超时与假成功,以及基础镜像 rootpw、旧 pi 免密规则和主机名解析延迟。", + "artifact_path": "发布更新相关/导出包/1.0.2/matrix-screen-controller-1.0.2.img", + "artifact_sha256": "0397b2abd974cc293a472789027b02bf469434d358578fd43aa9fdb218944aeb" + }, + { + "version": "1.0.3", + "artifact_type": "image", + "created_at": "2026-08-25T08:17:48+00:00", + "notes": "修复 1.0.3 首次启动 FAT 空间不足:应用离线载荷迁入 rootfs,增加候选内核 boot 文件预算与 32 MiB 安全余量双重校验,并修复导出入口误加载 Pillow/FontTools。", + "artifact_path": "发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img", + "artifact_sha256": "a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b" + }, + { + "version": "1.0.4", + "artifact_type": "ota", + "created_at": "2026-09-04T13:14:05+08:00", + "notes": "发布当前工作区已完成并通过本机全套测试的软件版本。", + "artifact_path": "发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota", + "artifact_sha256": "ed15498e2d66758343c874d00176e2525e67a69fea5cc8f4578f139dd2b0f766" + }, + { + "version": "1.0.5", + "artifact_type": "ota", + "created_at": "2026-09-06T21:10:58+08:00", + "notes": "修复 OTA 板端测试目录隔离,并新增可查看、复制的详细失败日志。", + "artifact_path": "发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota", + "artifact_sha256": "ec245d2ba2eefdf415f6745aefb1dfc66631e027d6820a2b6bd04bd6306d4152" + }, + { + "version": "1.0.6", + "artifact_type": "ota", + "created_at": "2026-09-06T21:39:45+08:00", + "notes": "诊断引导包:经设备持有者授权,仅在 1.0.3 旧 worker 中跳过 pytest,以先安装可查看、复制的 OTA 失败日志;后续更新恢复严格测试。", + "artifact_path": "发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota", + "artifact_sha256": "8e804dcfb1f2f9194f3c57b88b5d4b7308509270fba536059e528ab21fdfa0b9" + }, + { + "version": "1.1.0", + "artifact_type": "ota", + "created_at": "2026-09-08T10:19:47+08:00", + "notes": "修复 OTA 显式停止旧服务时运行目录被删除导致升级和回滚中断;增加停服前事务保护、日志降级及真实 systemd 回归。已在测试设备完成 1.0.6 → 1.1.0,用户数据和 frp 状态保持。", + "artifact_path": "发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota", + "artifact_sha256": "7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8", + "package_kind": "software-install", + "required_checkpoint": "1.0.0", + "is_checkpoint": true, + "component_checkpoints": [ + { + "version": "1.1.0", + "components": { + "frpc": { + "version": "0.71.0", + "sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2", + "bundle": "frp/0.71.0/linux-arm64" + } + } + } + ], + "repairs": [ + { + "at": "2026-09-08T10:31:33+08:00", + "reason": "修复 OTA 显式停止旧服务时运行目录被删除导致升级和回滚中断;增加停服前事务保护、日志降级及真实 systemd 回归。已在测试设备完成 1.0.6 → 1.1.0,用户数据和 frp 状态保持。", + "previous_sha256": "379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5", + "archive_path": "各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f" + } + ] + }, + { + "version": "1.1.1", + "artifact_type": "image", + "created_at": "2026-09-08T07:24:53+00:00", + "notes": "完整可刷镜像 1.1.1:默认账户与 Wi-Fi,可直接首启使用", + "artifact_path": "发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img", + "artifact_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d", + "validation_path": "各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json", + "validated_at": "2026-09-08T09:09:53+00:00" + } + ] +} diff --git a/各种归档/20260819_离线镜像1.0.2_SSH与sudo真实验收修复/README.md b/各种归档/20260819_离线镜像1.0.2_SSH与sudo真实验收修复/README.md new file mode 100644 index 0000000..a9a5d31 --- /dev/null +++ b/各种归档/20260819_离线镜像1.0.2_SSH与sudo真实验收修复/README.md @@ -0,0 +1,26 @@ +# 1.0.2 SSH 与 sudo 真实验收修复 + +## 真实设备发现 + +- 配置账户关闭 SSH agent 后可用镜像密码实际登录,SSH、网页和控制服务均已运行。 +- `sudo -n` 正确拒绝免密,但输入配置账户密码仍被拒绝,并出现主机名解析延迟。 +- 最终 IMG 的 rootfs 只读检查确认供应商 `/etc/sudoers` 在 `@includedir` 之后设置了 `Defaults rootpw`,并保留旧 `pi ALL=(ALL) NOPASSWD:ALL`;因此此前仅增加 sudo 组和 drop-in 规则不足以满足“同一账户密码、不得免密”。 + +## 修复 + +- 首启账户阶段原子移除供应商 `Defaults rootpw` 和旧 `pi NOPASSWD` 行。 +- 继续创建模式 `0440` 的配置账户 `ALL=(ALL:ALL) ALL` drop-in,并在替换前后运行 `visudo` 校验。 +- 身份阶段同步 `/etc/hosts` 中 `127.0.1.1` 的主机名,消除 sudo 解析警告和等待。 +- 当前真实设备使用供应商官方公开的基础镜像 root 凭据执行一次受控迁移;未开启 root SSH、未改变配置账户密码,临时脚本随后删除。 + +## 验收结果 + +- 禁用本机 SSH agent 后,配置账户密码登录通过。 +- `sudo -n` 被拒绝;输入同一配置账户密码后取得 UID 0,完整 root 命令通过。 +- `visudo -c` 通过,活动 sudoers 不含 `rootpw` 或 `NOPASSWD`,主机名可立即解析。 +- `sshd -T` 的密码认证、空密码、键盘交互和 root 登录策略全部符合要求;使用供应商 root 密码的实际 root SSH 登录被拒绝。 +- SSH、控制服务、网页、真实 H618 驱动和网络状态正常;再次重启后启动时长、SSH、控制服务和主机名解析复验通过。 +- 聚焦 Python 测试 13 项通过;完整 Python 回归 306 项通过、8 项跳过;Node 回归 61 项通过;shell 语法通过。 +- 最终 IMG 仅刷新 FAT 载荷,配置双槽逐字节保留,ext4 分区 SHA-256 保持不变,bundle 敏感路径扫描通过。 + +本归档不记录账户、密码、IP、主机密钥或设备身份值。此前 1.0.2 哈希均已被最终正式哈希取代,不得继续发布。 diff --git a/各种归档/20260819_离线镜像1.0.2_SSH首启失败修复/README.md b/各种归档/20260819_离线镜像1.0.2_SSH首启失败修复/README.md new file mode 100644 index 0000000..8d6db95 --- /dev/null +++ b/各种归档/20260819_离线镜像1.0.2_SSH首启失败修复/README.md @@ -0,0 +1,23 @@ +# 离线镜像 1.0.2 SSH 首启失败修复记录 + +## 现场结果 + +- 设备供电后网络间歇可达,但 SSH 22 和控制服务 8080 在 20 分钟门槛内始终未开放。 +- FAT 状态文件报告 `stage=ssh` 失败;账户、sudoers、受管 SSH 配置、服务启用链接、machine-id 和新 SSH 主机密钥均已写入。 +- ext4 systemd journal 的直接证据为:`sshd -t` 报告缺少 privilege separation 运行目录 `/run/sshd`,随后首启服务以退出码 1 失败。 + +## 根因与修复 + +- 首启 unit 通过 `Before=ssh.service` 正确阻止 SSH 提前监听,因此 systemd 尚未为 `ssh.service` 创建易失的 `/run/sshd`。 +- 首启程序却在服务启动前直接执行 `sshd -t/-T`,错误依赖了该目录已经存在。 +- `configure_ssh` 现于策略校验前创建 `/run/sshd` 并设置模式 `0755`;需求和测试映射同步增加该门槛。 +- 同版本正式 IMG 只刷新 FAT bundle,第二分区保持核桃派已验证的 SHA-256 `a3207843b6c405df3abb827c80ffd60285788541e62fa6fdd8335498084a9f95` 不变。失败卡上的三个 FAT 载荷文件已逐字更新并读回,配置区摘要写前写后一致。 + +## 自动验证 + +- SSH/镜像聚焦测试:11 项通过。 +- 完整 Python:304 项通过、8 项跳过。 +- 前端:55 项通过,全部 JavaScript 与相关 shell 语法检查通过。 +- 新正式 IMG 与发布记录摘要一致;旧摘要已失效,不得继续使用。 + +本记录不包含账户、密码、Wi-Fi、IP、主机密钥内容或其他设备身份值。修复后的真实首次启动、SSH、sudo、网页和再次重启仍需重新验收。 diff --git a/各种归档/20260819_离线镜像1.0.2_SSH首启自动重启超时修复/README.md b/各种归档/20260819_离线镜像1.0.2_SSH首启自动重启超时修复/README.md new file mode 100644 index 0000000..35c8b37 --- /dev/null +++ b/各种归档/20260819_离线镜像1.0.2_SSH首启自动重启超时修复/README.md @@ -0,0 +1,28 @@ +# 1.0.2 SSH 首启自动重启超时修复 + +## 现象与证据 + +- 真实 TF 卡的 FAT 状态文件曾显示首启成功,但设备没有按预期自动重启,SSH 端口也未监听。 +- Linux 分区 journal 显示 SSH 配置校验、主机密钥生成和 `ssh.service` 启用已经完成,随后 `matrix-image-firstboot.service` 因启动超时被终止。 +- 首启脚本在自身仍运行时删除 unit 并执行 `systemctl daemon-reload`,导致 systemd 丢失该运行中 unit 的显式超时定义并回落到默认启动超时;成功状态又在清理和同步之前写入,因此形成假成功。 + +## 修复 + +- `matrix-image-firstboot.service` 改用 `TimeoutStartSec=infinity`,避免不同 TF 卡速度导致合法的离线部署被中止。 +- 首启脚本不再在自身运行时执行 `systemctl daemon-reload`;unit 文件仍在重启前删除,新的 systemd 实例启动后自然不再加载它。 +- 成功状态延后到秘密和临时文件清理、首启 unit 禁用、工作目录删除及全盘同步之后,仅在请求重启前写入。 +- 保留先前 `/run/sshd`、受管 SSH 策略、需密码完整 sudo 权限及禁止 root SSH 的修复。 + +## 验证结果 + +- 聚焦 Python 测试:12 项通过。 +- 完整 Python 回归:305 项通过,8 项跳过。 +- Node 前端回归:61 项通过。 +- shell 语法、远端 rootfs 安装及只读验证通过。 +- 最终 IMG 从官方基础镜像重新复制构建;官方基础镜像 SHA-256 保持不变。 +- 最终 IMG 的 FAT 配置双槽与旧 1.0.2 工厂配置逐字节一致;bundle 与当前源码一致。 +- rootfs 中首启 unit、受管 SSH 配置和服务启用状态正确,且不含 SSH 主机密钥、NetworkManager 连接、项目凭据或设备数据。 + +旧验收哈希已被新的 1.0.2 正式哈希取代,不得继续作为可发布镜像使用。真实设备的 SSH 登录、sudo、root 拒绝和再次重启验证继续以本轮 TF 卡验收为准。 + +后续真实 sudo 验收又发现基础镜像的 `Defaults rootpw` 和旧 `pi NOPASSWD`,本归档对应哈希再次被最终 sudo 策略修复哈希取代。 diff --git a/各种归档/20260819_离线镜像1.0.2真实TF卡验收/README.md b/各种归档/20260819_离线镜像1.0.2真实TF卡验收/README.md new file mode 100644 index 0000000..e6de1c8 --- /dev/null +++ b/各种归档/20260819_离线镜像1.0.2真实TF卡验收/README.md @@ -0,0 +1,45 @@ +# 离线镜像 1.0.2 真实 TF 卡验收记录(旧产物,已失效) + +## 失效说明 + +- 本记录对应 SHA-256 `3a28d9b5200ed22824a628a46ce8d36851cc8366bbf3b8cbd22a2b9a7c4b9128` 的旧 IMG。该镜像最终没有监听 SSH 22 端口,缺少正式导出包的必要功能,因此不得再作为正式包使用。 +- 旧 IMG 已删除,并由同版本的新 IMG 原子替换;新产物摘要以根目录 `发布记录.json` 和 `发布更新相关/导出包/1.0.2/manifest.json` 为准。 +- 以下内容只保留当时失败定位和已完成硬件检查的历史证据,不代表新产物已通过真实 TF 卡首启、SSH 或重启验收。新产物须在用户完成镜像编辑、写卡和上电后另行验证。 + +## 旧产物当时的结论 + +- 用户于 2026-08-19 现场确认控制界面没有问题,同意按当前结果完成本次任务验收。 +- 当时测试的镜像软件版本为 `1.0.2`,旧 SHA-256 为 `3a28d9b5200ed22824a628a46ce8d36851cc8366bbf3b8cbd22a2b9a7c4b9128`;该摘要现已失效。 +- 浏览器真实打开控制界面成功,首页和七项侧栏工作区入口正常渲染,控制台无 warning/error。 +- `/api/status` 返回 `software_version=1.0.2`;真实 `walnutpi-h618-hub75` 驱动、PI bank 映射、H618 PWM4 OE、CPU3 绑定、实时优先级和内存锁定均生效,采样时截止丢失、OE fault、强制黑屏和 `last_error` 均为 0。 +- 未连接 HUB75 屏幕和 ADC;ADC 被正确报告为断开,此项不算失败。 +- 设备 Ping 与 HTTP 8080 持续可达。SSH 22 端口在最终复查时仍未监听,因此没有完成 SSH 登录;该项明确记为未通过/未验证,不伪写为通过。用户接受此例外并要求收口。 +- 没有进行验收后的额外一次显式重启测试;首次启动过程中的自动重启无法从 5 秒网络采样中单独证明,持久重启项保留为未单独验证。 +- 本文件不包含账户、密码、Wi-Fi、配置槽内容或设备身份值。 + +## 真实失败与修复过程 + +1. 首次启动在 `packages` 阶段失败。FAT 状态文件证明 FFmpeg 的 Debian AArch64 离线材料只有 13 个包,不是完整闭包。 +2. 从基础镜像导出 790 个正常安装包作为 `BASE_IMAGE_PACKAGES.tsv`,使用 Debian Bookworm、Updates、Security AArch64 索引递归解析并补齐 FFmpeg/libheif 闭包。 +3. 第二次启动越过包阶段后在 `deploy` 阶段失败。`MSCDEPLOY.TXT` 证明基础镜像缺少 `python3.11-venv/ensurepip`。 +4. 将 `python3.11-venv` 加入正式根依赖并补齐 8 个包;最终 Debian 离线集合为 84 个 `.deb`。 +5. venv、全部 wheel、H618 原生驱动编译、原生测试和专用主机检查随后全部通过,但部署停在 `systemctl enable --now`。 +6. 根因是 `matrix-image-firstboot.service` 声明 `Before=matrix-screen-controller.service`,却在 oneshot 内同步等待控制服务启动,形成 systemd 排序死锁。 +7. 镜像首启改为只启用控制服务并延迟到自动重启后启动;增加同版本中断恢复门禁,只有版本、venv、原生文件、unit 和专用主机检查全部通过时才接管已有 `/opt`。 +8. 最终控制网页和真实驱动正常运行,用户现场确认界面没有问题。 + +## 自动验证 + +- Python:`297 passed, 8 skipped`。 +- Node:`61 passed`。 +- Debian:84 个 `.deb`,86 个摘要条目全部通过;三个根依赖的本地闭包解析共选择 84 个包。 +- 发布相关 shell 脚本通过语法检查。 +- 正式镜像 FAT 载荷每次均读回验证;修复前后第二分区 SHA-256 始终为 `9ea9f86fa39fbc481a6a99623c39cdbbdf1cf86fc642add49acdf02f8f26744a`,证明同版本修复没有改变已验收的 rootfs bootstrap 分区。 +- 官方基础镜像保持只读,正式镜像只在复制件上修改。 + +## 防回归规则 + +- 镜像构建前必须以 `BASE_IMAGE_PACKAGES.tsv` 验证 `ffmpeg`、`libheif-examples`、`python3.11-venv` 的完整递归闭包;缺根包、传递包、基准清单或摘要时拒绝导出。 +- Debian 安装失败保留无配置凭据的 `MSCPKG.TXT`/`MSCPKGS.TSV`;部署失败保留无配置凭据的 `MSCDEPLOY.TXT`;成功时删除这些诊断文件。 +- 首启 oneshot 不得同步启动受其 `Before=` 排序约束的控制服务。 +- 本归档是一次性验收记录,不得作为后续构建输入;后续构建不得引用 `发布更新相关/导出包/<版本>/`。 diff --git a/各种归档/20260825_离线镜像1.0.3启动分区空间修复/README.md b/各种归档/20260825_离线镜像1.0.3启动分区空间修复/README.md new file mode 100644 index 0000000..48a5f94 --- /dev/null +++ b/各种归档/20260825_离线镜像1.0.3启动分区空间修复/README.md @@ -0,0 +1,52 @@ +# 离线镜像 1.0.3 启动分区空间修复记录 + +## 结论与产物身份 + +- 旧 1.0.3 IMG 的 SHA-256 为 `de3c9bc88b25cd11b0450e50f219398d973dd78d997a5c6e3c91c0f4d23d9f64`,已确认不可用并从正式目录删除,不得再写卡或发布。 +- 修复后仍使用软件版本和文件名 `1.0.3`,新 IMG SHA-256 为 `a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b`。 +- `核桃派软件源代码/VERSION` 始终为 `1.0.3`;`发布记录.json` 中仍只有一条 1.0.3 记录,已原子更新到新摘要。 +- 原 65,536 字节双槽配置区逐字节复用,新镜像未写入账户、密码、IP 或 Wi-Fi 等现场值。 +- 本轮未写 TF 卡。真实首次启动、SSH、sudo、网页、候选内核和再次重启须由用户写卡后继续验收,本文不把这些项目写成通过。 + +## 首次启动失败根因 + +- 现场 FAT 日志为 `install: error writing '/boot/Image-matrix-axp313a1': No space left on device`,随后回滚并留下 `FAILED: stage=kernel; code=1; line=150`。 +- 现场工具报告 FAT 容量 `149,778,432` 字节、剩余 `17,678,336` 字节;独立 FAT16 簇解析得到分区字节数 `149,946,368`、可分配空间 `17,694,720`。两种口径有 BPB/簇取整差异,但都小于新内核单文件 `22,468,616` 字节。 +- 旧 `/boot/MSCBOOT.TGZ` 占 `76,117,351` 字节。即使不计双份 DTB、System.map、kernel config、启动脚本临时文件和原始脚本回滚副本,只写候选 Image 也至少短缺约 4.8 MB。 +- 基础 Debian 和网络已先完成,因此设备可 ping;控制服务、网页和成功重启位于失败点之后,不能由 ping 可达推断为已安装。 + +## 设计修复 + +- 镜像元数据从 v2 升级为 v3。FAT 只保存 `MSCCFG.BIN`、`MSCMETA.JSN` 和 `MSCINIT`;应用离线包迁入 `/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ`,内核载荷继续位于相邻的 `axp313a/`。 +- rootfs 注入前检查“应用压缩载荷 + 内核压缩载荷 + 256 MiB”。首次启动失败保留两类压缩载荷供完整重试,全部成功后才删除整个 bootstrap 根。 +- 构建端和内核安装器共用同一 FAT 预算算法,按 4,096 字节簇计入五个候选 boot 文件、原始回滚脚本、临时/最终受管脚本、摘要和健康标记,再额外保留固定 `32 MiB`。 +- 新镜像实测 FAT 空闲 `93,814,784` 字节,实际文件预算 `26,279,936`,安全余量 `33,554,432`,总门槛 `59,834,368`,门槛之外仍有 `33,980,416` 字节。 +- 新应用 bundle 为 `76,123,924` 字节、SHA-256 `74e90c7b93d3ffc55226d52bfb5cee214255704341ac40564cd9afc823b19616`;最终 FAT 明确不存在 `MSCBOOT.TGZ`。 + +## 导出入口报错与修复 + +- 先前正式脚本在创建发布锁和版本目录之前报远端系统 Python 缺少 FontTools;本机 bare Python 复现时先报缺少 Pillow。 +- 根因不是镜像构建需要字体包,而是导出器导入 `app.ota.package` 时执行了 `app.ota.__init__`,后者提前加载 OTA manager、显示服务、Pillow 和 FontTools。 +- 当时用已校验 wheelhouse 创建临时 Python 3.11 venv 可以绕过报错,但这不是正确的长期依赖边界。 +- `app.ota` 现改为惰性导出。`python3 -S scripts/export_release.py --help` 在没有 site packages 的 Linux 系统 Python 上通过;正式镜像导出不再创建应用 venv 或访问网络。 +- 旧 `refresh_sd_image_payload.py` 原地刷新方式已停用。同版本坏包只能通过 `image --repair-current` 从官方基线完整重建,验证后替换原目录和原记录。 + +## 构建和传输过程中的防坑记录 + +- Windows 端只创建不含凭据的最小项目快照,并对项目快照、官方基线压缩副本和旧 IMG 压缩副本分别做传输前后 SHA-256;三项全部一致后才解压。 +- Windows Git tar 不能直接把带盘符的输出路径交给其 gzip 子进程;本轮改用 Windows 自带 bsdtar 生成项目快照。以后仍应把传输产物放在 Codex 临时目录,不放进项目。 +- 官方镜像 `SHA256SUMS` 使用相对文件名,校验必须在清单所在目录执行或显式筛选 IMG 条目;不能在项目根直接执行整份清单。 +- PuTTY SCP 对远端中文路径发生过编码失败。下载阶段使用同文件系统、ASCII 名称的临时硬链接目录;启用 `protected_hardlinks` 时由 sudo 只创建硬链接,不复制第二份 3.3 GB IMG。下载后仍以正式侧车摘要校验。 +- 远端工具先按远端 PATH 检查;普通账户 PATH 不含 `/usr/sbin/losetup`,root 构建使用明确的系统 PATH。没有因本机具备工具而假设远端具备。 + +## 自动验证结果 + +- 本机聚焦镜像/内核测试:`27 passed`。 +- 本机完整 Python:`356 passed, 8 skipped`;警告均为既有 FastAPI/Pillow 弃用警告。 +- Node 前端:`72 passed`。 +- 四个相关 shell 脚本通过 `bash -n`;远端 bare Python 参数入口通过。 +- 官方基线 IMG 在解压后、构建后分别按项目清单复核为 `OK`。 +- 正式构建输出 `rootfs bootstrap installed` 与 `rootfs bootstrap verified`;提交后又从 IMG 复制应用 bundle 为独立文件,再次运行只读 rootfs/FAT 验证并通过。 +- 新 manifest、侧车摘要、实际整镜像摘要和唯一发布记录四者一致;发布锁、`.building` 和 `.invalid-backup` 均不得残留。 + +本记录是一次性故障和发布证据,不得作为后续构建输入;后续构建不得引用 `发布更新相关/导出包/1.0.3` 中的任何文件。 diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/ota_worker.py b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/ota_worker.py new file mode 100644 index 0000000..79edbc3 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/ota_worker.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Privileged one-shot worker for a previously validated browser OTA package.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time +from typing import Any + +from app.ota.package import extract_payload, inspect_package +from app.ota.state import read_json, utc_now, write_json, write_last_result +from app.ota.versioning import SoftwareVersion + +TARGET = Path("/opt/matrix-screen-controller") +RELEASES = Path("/opt/matrix-screen-controller.releases") +DATA_ROOT = Path("/var/lib/matrix-screen-controller") +RUNTIME_ROOT = Path("/run/matrix-screen-controller") +UNIT_PATH = Path("/etc/systemd/system/matrix-screen-controller.service") +SERVICE = "matrix-screen-controller.service" +REQUEST_PATH = RUNTIME_ROOT / "ota-request.json" + + +class UpdateFailed(RuntimeError): + pass + + +def run(command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> None: + subprocess.run(command, cwd=cwd, env=env, check=True) + + +def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]: + records: dict[str, tuple[int, str]] = {} + ignored = ignored or set() + if not root.exists(): + return records + for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.as_posix()): + relative = path.relative_to(root).as_posix() + if relative in ignored: + continue + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + records[relative] = (path.stat().st_size, digest.hexdigest()) + return records + + +class Transaction: + def __init__(self, request: dict[str, Any]) -> None: + self.request = request + self.job_id = checked_token(request.get("job_id"), "job_id") + self.target_version = SoftwareVersion.parse(str(request.get("target_version"))) + self.current_version = SoftwareVersion.parse(str(request.get("current_version"))) + if self.target_version <= self.current_version: + raise UpdateFailed("target version is not newer than the installed version") + self.package_path = Path(str(request.get("package_path"))) + self.status_path = Path(str(request.get("status_path"))) + if self.status_path != RUNTIME_ROOT / "ota-status.json": + raise UpdateFailed("unexpected OTA status path") + self.work_root = Path(f"/opt/matrix-screen-controller-ota/work.{self.job_id}") + self.extract_root = self.work_root / "extracted" + self.release = RELEASES / f"{self.target_version}-{self.job_id}" + self.data_candidate = DATA_ROOT.with_name(f"matrix-screen-controller.ota.{self.job_id}") + self.data_backup = DATA_ROOT.with_name(f"matrix-screen-controller.rollback.{self.job_id}") + self.unit_backup = self.work_root / "matrix-screen-controller.service.old" + self.previous_target: Path | None = None + self.previous_directory: Path | None = None + self.program_swapped = False + self.data_swapped = False + self.visual: subprocess.Popen[bytes] | None = None + self.job = { + "id": self.job_id, + "target_version": str(self.target_version), + "packaged_at": str(request.get("packaged_at") or ""), + "phase": "preparing", + "percent": 8, + "message": "正在准备更新", + "started_at": str(request.get("accepted_at") or utc_now()), + "finished_at": None, + "error": None, + } + + def update(self, phase: str, percent: int, message: str) -> None: + self.job.update(phase=phase, percent=max(0, min(100, percent)), message=message) + write_json(self.status_path, {"schema_version": 1, "active": True, "job": self.job}) + + def prepare(self) -> None: + if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)): + raise UpdateFailed("OTA transaction paths already exist") + self.work_root.mkdir(parents=True, mode=0o700) + package = inspect_package(self.package_path, current_version=self.current_version) + if package.target_version != self.target_version: + raise UpdateFailed("request and package versions do not match") + stat = shutil.disk_usage("/opt") + required = max(package.expanded_bytes * 4, 512 * 1024 * 1024) + if stat.free < required: + raise UpdateFailed("not enough free space to stage and validate the OTA release") + + self.update("extracting", 12, "正在解压全量更新包") + extract_payload(package, self.extract_root) + software = self.extract_root / "software" + wheelhouse = self.extract_root / "wheelhouse" + if SoftwareVersion.parse((software / "VERSION").read_text(encoding="utf-8")) != self.target_version: + raise UpdateFailed("extracted software VERSION does not match the package") + + self.update("dependencies", 22, "正在校验离线依赖并创建运行环境") + run(["sha256sum", "-c", "SHA256SUMS"], cwd=wheelhouse) + shutil.move(str(software), self.release) + run(["python3", "-m", "venv", str(self.release / ".venv")]) + run([ + str(self.release / ".venv/bin/pip"), + "install", + "--no-index", + "--disable-pip-version-check", + "--find-links", + str(wheelhouse), + "-r", + str(self.release / "requirements-dev.txt"), + ]) + + self.update("building", 42, "正在编译并测试屏幕驱动") + run(["make", "-C", str(self.release / "app/display/native"), "clean", "all", "test"]) + run([str(self.release / ".venv/bin/python"), "-m", "compileall", "-q", str(self.release / "app")]) + + self.update("testing", 55, "正在运行新版本自动化测试") + test_env = os.environ.copy() + test_env.update( + MATRIX_DRIVER="mock", + MATRIX_DATA_DIR=str(self.work_root / "test-data"), + MATRIX_RUNTIME_DIR=str(self.work_root / "test-runtime"), + MATRIX_SOURCE_ONLY_UPDATE_TESTS="1", + ) + run([str(self.release / ".venv/bin/python"), "-m", "pytest", "-q"], cwd=self.release, env=test_env) + run([str(self.release / ".venv/bin/python"), str(self.release / "scripts/dedicated_host.py"), "check"]) + + self.update("migrating", 68, "正在迁移用户数据副本") + shutil.copytree(DATA_ROOT, self.data_candidate, symlinks=True) + runtime_candidate = self.work_root / "migration-runtime" + migration_env = os.environ.copy() + migration_env["PYTHONPATH"] = str(self.release) + run([ + str(self.release / ".venv/bin/python"), + "-m", + "scripts.prepare_data_root", + "--data-root", + str(self.data_candidate), + "--runtime-root", + str(runtime_candidate), + ], cwd=self.release, env=migration_env) + + def switch(self) -> None: + self.update("switching", 78, "正在切换到新版本") + shutil.copy2(UNIT_PATH, self.unit_backup) + run(["systemctl", "stop", SERVICE]) + self.start_visual() + + RELEASES.mkdir(parents=True, exist_ok=True) + if TARGET.is_symlink(): + self.previous_target = Path(os.readlink(TARGET)) + TARGET.unlink() + elif TARGET.is_dir(): + self.previous_directory = RELEASES / f"{self.current_version}-pre-ota-{self.job_id}" + TARGET.rename(self.previous_directory) + else: + raise UpdateFailed("production target is neither a release symlink nor a directory") + os.symlink(self.release, TARGET, target_is_directory=True) + self.program_swapped = True + + DATA_ROOT.rename(self.data_backup) + self.data_candidate.rename(DATA_ROOT) + self.data_swapped = True + shutil.copy2(self.release / "systemd/matrix-screen-controller.service", UNIT_PATH) + shutil.copy2(self.release / "systemd/matrix-screen-controller-ota.service", Path("/etc/systemd/system/matrix-screen-controller-ota.service")) + run(["systemctl", "daemon-reload"]) + self.stop_visual() + run(["systemctl", "start", SERVICE]) + + def verify(self) -> None: + self.update("verifying", 90, "正在验证新版本和真实屏幕驱动") + deadline = time.monotonic() + 45 + last_error = "service did not respond" + while time.monotonic() < deadline: + try: + output = subprocess.check_output( + ["curl", "--fail", "--silent", "http://127.0.0.1:8080/api/status"], + timeout=5, + ) + status = json.loads(output.decode("utf-8")) + screen = status.get("screen", {}) + driver = screen.get("driver_status") or {} + if status.get("service", {}).get("software_version") != str(self.target_version): + raise UpdateFailed("new service reports the wrong software version") + if screen.get("driver") != "walnutpi-h618-hub75": + raise UpdateFailed("new service did not start the production HUB75 driver") + if screen.get("hardware_mapping") != "walnutpi-pi-bank-pwm-oe-v2": + raise UpdateFailed("new service reports the wrong hardware mapping") + if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0: + raise UpdateFailed("new service did not pass the PWM4 OE health check") + return + except (subprocess.SubprocessError, OSError, UnicodeError, json.JSONDecodeError) as exc: + last_error = str(exc) + time.sleep(1) + raise UpdateFailed(f"new release health check timed out: {last_error}") + + def succeed(self) -> None: + result = { + "status": "success", + "target_version": str(self.target_version), + "packaged_at": str(self.request.get("packaged_at") or ""), + "installed_at": utc_now(), + "release_notes": str(self.request.get("release_notes") or ""), + "error": None, + } + write_last_result(DATA_ROOT, result) + self.job.update( + phase="complete", + percent=100, + message="更新完成", + finished_at=result["installed_at"], + error=None, + ) + write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job}) + self.package_path.unlink(missing_ok=True) + self.request_path().unlink(missing_ok=True) + shutil.rmtree(self.data_backup, ignore_errors=True) + if self.previous_directory is not None: + shutil.rmtree(self.previous_directory, ignore_errors=True) + if self.previous_target is not None: + previous_release = self.previous_target if self.previous_target.is_absolute() else TARGET.parent / self.previous_target + if previous_release.parent == RELEASES: + shutil.rmtree(previous_release, ignore_errors=True) + shutil.rmtree(self.work_root, ignore_errors=True) + prune_empty(self.package_path.parent) + + def rollback(self, error: BaseException) -> None: + self.stop_visual() + if self.program_swapped or self.data_swapped: + subprocess.run(["systemctl", "stop", SERVICE], check=False) + if self.data_swapped: + failed_data = DATA_ROOT.with_name(f"matrix-screen-controller.failed.{self.job_id}") + if DATA_ROOT.exists(): + DATA_ROOT.rename(failed_data) + if self.data_backup.exists(): + self.data_backup.rename(DATA_ROOT) + shutil.rmtree(failed_data, ignore_errors=True) + if self.program_swapped: + if TARGET.is_symlink(): + TARGET.unlink() + if self.previous_directory is not None and self.previous_directory.exists(): + self.previous_directory.rename(TARGET) + elif self.previous_target is not None: + os.symlink(self.previous_target, TARGET, target_is_directory=True) + if self.unit_backup.exists(): + shutil.copy2(self.unit_backup, UNIT_PATH) + subprocess.run(["systemctl", "daemon-reload"], check=False) + if self.program_swapped or self.data_swapped: + subprocess.run(["systemctl", "start", SERVICE], check=False) + shutil.rmtree(self.release, ignore_errors=True) + shutil.rmtree(self.data_candidate, ignore_errors=True) + result = { + "status": "failed", + "target_version": str(self.target_version), + "packaged_at": str(self.request.get("packaged_at") or ""), + "installed_at": utc_now(), + "release_notes": str(self.request.get("release_notes") or ""), + "error": str(error), + } + try: + write_last_result(DATA_ROOT, result) + except OSError: + pass + self.job.update( + phase="failed", + percent=0, + message="更新失败,已恢复原版本", + finished_at=result["installed_at"], + error=str(error), + ) + write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job}) + self.package_path.unlink(missing_ok=True) + self.request_path().unlink(missing_ok=True) + shutil.rmtree(self.work_root, ignore_errors=True) + + def start_visual(self) -> None: + python = TARGET / ".venv/bin/python" + self.visual = subprocess.Popen([ + str(python), "-m", "scripts.hub75_visual_hold", + "--mode", "ota", + "--brightness", "40", + "--progress-file", str(self.status_path), + "--orientation", str(int(self.request.get("orientation", 0))), + ]) + time.sleep(1) + if self.visual.poll() is not None: + raise UpdateFailed("independent OTA display process failed to start") + + def stop_visual(self) -> None: + if self.visual is None: + return + if self.visual.poll() is None: + self.visual.send_signal(signal.SIGTERM) + try: + self.visual.wait(timeout=5) + except subprocess.TimeoutExpired: + self.visual.kill() + self.visual.wait(timeout=2) + self.visual = None + + def request_path(self) -> Path: + return Path(str(self.request.get("request_path") or REQUEST_PATH)) + + +def checked_token(value: Any, name: str) -> str: + candidate = str(value or "") + if not candidate or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for character in candidate): + raise UpdateFailed(f"invalid {name}") + return candidate + + +def prune_empty(path: Path) -> None: + try: + path.rmdir() + except OSError: + pass + + +def main() -> int: + if os.geteuid() != 0 or os.uname().machine != "aarch64": + print("OTA worker must run as root on the WalnutPi AArch64 host", file=sys.stderr) + return 2 + request = read_json(REQUEST_PATH) + if request is None or request.get("schema_version") != 1: + print("OTA request is missing or invalid", file=sys.stderr) + return 2 + transaction: Transaction | None = None + try: + transaction = Transaction(request) + transaction.prepare() + transaction.switch() + transaction.verify() + transaction.succeed() + return 0 + except BaseException as exc: + if transaction is not None: + transaction.rollback(exc) + print(f"OTA update failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/package.py b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/package.py new file mode 100644 index 0000000..10fcc92 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/package.py @@ -0,0 +1,275 @@ +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 + +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, + } + + +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 manifest["format_version"] != PACKAGE_FORMAT_VERSION: + 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"]) + 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 and target_version <= current_version: + raise OtaPackageError( + f"OTA target {target_version} must be newer than installed version {current_version}" + ) + 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"}: + 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 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, +) -> OtaPackageInfo: + source_root = Path(source_root).resolve() + wheelhouse = Path(wheelhouse).resolve() + 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") + 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()}") + 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) diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/state.py b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/state.py new file mode 100644 index 0000000..add23e4 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/state.py @@ -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)}, + ) diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/versioning.py b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/versioning.py new file mode 100644 index 0000000..db92ddd --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/1.0.3旧更新器/versioning.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +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]*)$") + + +@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 diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/README.md b/各种归档/20260907_OTA1.1.0软件安装节点验证/README.md new file mode 100644 index 0000000..eaa8b69 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/README.md @@ -0,0 +1,40 @@ +# OTA 1.1.0 交付与一次性验证记录 + +## 交付 + +- 正式包:`发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota` +- 字节数:27993930(约 26.7 MiB)。 +- SHA-256:`379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5`。 +- 功能时间:`2026-09-07T22:00+08:00`。导出成功后 VERSION 推进到 1.1.0,发布记录追加一次。 +- 软件安装节点:1.1.0;1.0.x 可直接安装本节点,同系列补丁可跳过,跨多个 minor 必须逐个安装 .0。1.1.0 包协议 v1,后续包协议 v2。 +- 固定系统组件:官方 Linux ARM64 frpc 0.71.0,摘要见源码 UPGRADE_POLICY.json;首次默认关闭,已有完整二进制跳过,缺失或损坏自动修复并保留配置和启停状态。 + +## 验证证据 + +- 本机全套 Python/前端与静态 JavaScript 检查通过。 +- 复制源码、需求、测试流程及完整其他离线依赖至独立目录,在目标版本 1.1.0 下运行:406 passed、9 skipped。跳过为已登记平台条件。 +- 最后相关回归:48 passed(版本/组件事务、日志、发布导出)。 +- 从现有 1.0.3 IMG 的 rootfs 内只读提取真实 package.py 与 ota_worker.py;不修改镜像、不刷写设备。worker SHA-256:`cabac4403146a2f2482a2cba5a9599b2d7542617aed949458dc2a241edfbdb56`。 +- AArch64 私有 mount namespace 隔离 `/opt`、`/var/lib`、`/run`、`/usr/local/bin`、systemd unit 和 sudoers 目录;真实旧 worker 创建离线 venv、安装 wheel、编译驱动、执行 pytest:413 passed、2 skipped。原生 bitplane 与 fake safeoff 通过。 +- 真实旧 worker 从 1.0.3 完成候选安装、数据迁移、frpc 安装、程序和数据切换;systemd 命令与最终 HTTP/显示健康结果使用测试替身。专用主机检查在 namespace 外只读完成。 +- 首轮暴露账户识别遗漏,改为优先读取既有 FRP 账户、镜像首启创建的专用 sudoers,再使用唯一 sudo 普通账户。随后发现当前开发设备并非镜像首启账户布局,隔离夹具补齐与 1.0.3 首启一致的账户策略;同一候选包已通过的完整测试不重复,从失败的迁移入口继续完成验证。 +- 验证无 frp 安装、已有二进制不重写、损坏二进制和缺 drop-in 修复、开启/启用状态保留。 +- 注入安装后的失败、切换后的中断、数据两次 rename 之间的中断、服务启动失败、健康检查失败,恢复原程序、用户内容和系统组件。 +- 真实 1.0.3 包解析器确认拒绝 v2 普通补丁,防止绕过 1.1.0。 +- 最终补充脚本验证真实 ARM64 安装器保持 active.env 的 0600 权限、完整二进制 mtime 不变、启停状态保留;已有损坏组件先修复,失败后精确恢复原字节及权限。最后仅补充保留原 worker 失败简报的条件判断,并通过本机相关回归。 +- 正式包已重新解包逐文件核对与最终源码相同,含 246 个文件,只有 software/wheelhouse 顶层,恰好一个已校验 frpc;无 venv、缓存、浏览器产物、持久数据或旧镜像。包摘要、sidecar、manifest、发布记录一致。 + +## 调试设备 + +- 用户确认设备 SSH 指纹后连接;凭据未归档。 +- 仅兼容修正旧 worker 的依赖目录查找和组件安装分支,未部署整个应用、未执行正式 OTA、未重启服务。 +- 原 worker 摘要:`3eec0eab7b2ce2bd43f11cccb0ba3063b2c820532d78abc2e06ba3823f8c33e5`。 +- 修正后摘要:`219cb5eea3121e2b6b54ff06319900e3fa88199ef43945434d5af13f7aef6bc0`。 +- 实际开发包解析器成功接收并解压 1.1.0,设备仍报告 1.0.6,主服务 active,frpc 摘要保持原值。 +- 原 worker 备份保存在随附原始记录归档中。必要时可恢复该单文件;不涉及用户数据。 + +## 边界与清理 + +真实浏览器安装、真实 systemd 组件监护、整机断电/重启与实屏结果尚未验收;上述模拟结果不代表这些项目通过。用户后续自行上传正式 1.1.0。 + +本目录是一次性证据,不得成为源码、日常测试或未来导出的依赖。板端临时验证目录与本机候选包/隔离副本在收集记录后清理;持久测试环境与其他任务文件保留。 diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_compat_fix.sh b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_compat_fix.sh new file mode 100644 index 0000000..47c287b --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_compat_fix.sh @@ -0,0 +1,35 @@ +set -eu +python3 - <<'PY' +from pathlib import Path +import hashlib,os,ast,json +root=Path('/opt/matrix-screen-controller') +p=root/'scripts/ota_worker.py' +original=p.read_bytes() +assert hashlib.sha256(original).hexdigest()=='3eec0eab7b2ce2bd43f11cccb0ba3063b2c820532d78abc2e06ba3823f8c33e5', 'worker changed; re-inspect before patching' +status=Path('/run/matrix-screen-controller/ota-status.json') +assert not status.exists() or not json.loads(status.read_text()).get('active'), 'OTA currently active' +s=original.decode('utf-8') +old=' frpc_bundle = self.extract_root / "system-dependencies/frpc"' +new=' frpc_bundle = software / "system-dependencies/frpc"\n if not frpc_bundle.is_dir():\n frpc_bundle = self.extract_root / "system-dependencies/frpc"' +assert s.count(old)==1 +s=s.replace(old,new) +start=s.index(' self.backup_frpc_system()') +end=s.index(' self.run(["systemctl", "daemon-reload"]',start) +legacy=s[start:end] +replacement=' if (self.release / "scripts/ota_components.py").is_file():\n # Candidate migration has already prepared the durable transaction.\n pass\n else:\n' +replacement += ''.join(' '+line+'\n' for line in legacy.splitlines()) +s=s[:start]+replacement+s[end:] +ast.parse(s) +backup=Path('/var/tmp/matrix-ota110-validation/development-worker-before.py') +backup.write_bytes(original) +body=s.encode('utf-8') +temp=p.with_name('.ota_worker.compat.tmp') +with temp.open('wb') as f: + f.write(body);f.flush();os.fsync(f.fileno()) +os.chmod(temp,p.stat().st_mode & 0o777) +os.replace(temp,p) +print('development_worker_compatibility_patched=true') +print('software_version='+ (root/'VERSION').read_text().strip()) +print('worker_sha256='+hashlib.sha256(p.read_bytes()).hexdigest()) +print('FRP binary/config and running services unchanged') +PY diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_integration.py b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_integration.py new file mode 100644 index 0000000..6e45d40 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_integration.py @@ -0,0 +1,139 @@ +from pathlib import Path +import importlib.util, json, os, shutil, subprocess, sys, tarfile, zipfile + +ROOT = Path(__file__).resolve().parent +SEED = ROOT/'seed' +SEED.mkdir(exist_ok=True) +with zipfile.ZipFile(ROOT/'candidate.ota') as z: + with z.open('payload.tar.gz') as p, tarfile.open(fileobj=p, mode='r|gz') as t: + for m in t: + assert m.isfile() and not m.name.startswith('/') and '..' not in Path(m.name).parts + out=SEED/m.name;out.parent.mkdir(parents=True,exist_ok=True) + out.write_bytes(t.extractfile(m).read());out.chmod(m.mode) +sys.path.insert(0,str(SEED/'software')) + +def module(name,path): + spec=importlib.util.spec_from_file_location(name,path) + m=importlib.util.module_from_spec(spec);sys.modules[name]=m;spec.loader.exec_module(m);return m + +oldpackage=module('app.ota.legacy_package',ROOT/'baseline/package.py') +worker=module('legacy_worker',ROOT/'baseline/ota_worker.py') +worker.inspect_package=oldpackage.inspect_package +worker.extract_payload=oldpackage.extract_payload +from scripts import ota_components as c + +def reset(): + for p in [Path('/opt'),Path('/var/lib'),Path('/run'),Path('/usr/local/bin'),Path('/etc/systemd/system')]: + for entry in p.iterdir(): + if entry.is_dir() and not entry.is_symlink(): shutil.rmtree(entry) + else: entry.unlink() + c.DATA.mkdir() + c.TARGET.mkdir() + (c.TARGET/'VERSION').write_text('1.0.3',encoding='utf-8') + c.RELEASES.mkdir() + (c.DATA/'keep.txt').write_bytes(b'user resource preserved') + for name in ('app-unit','ota-unit'): + c.FILES[name].write_bytes(('old '+name).encode()) + +if '--resume' not in sys.argv: + reset() +else: + assert '413 passed' in (ROOT/'validation.log').read_text(encoding='utf-8',errors='replace') +request={'schema_version':1,'job_id':'baseline103','target_version':'1.1.0','current_version':'1.0.3', + 'package_path':'/opt/candidate.ota','status_path':str(c.RUNTIME/'ota-status.json'), + 'accepted_at':'2026-09-07T00:00:00Z'} +c.RUNTIME.mkdir(parents=True,exist_ok=True) +(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8') +shutil.copy2(ROOT/'candidate.ota',request['package_path']) +tx=worker.Transaction(request) +tx.start_visual=lambda: None +tx.stop_visual=lambda: None +actual_run=worker.run +def run(command,**kwargs): + if any('dedicated_host.py' in p for p in command): + print('Dedicated host policy: separately checked outside namespace',flush=True) + return + actual_run(command,**kwargs) +worker.run=run +if '--resume' in sys.argv: + actual_run([str(tx.release/'.venv/bin/python'),'-m','scripts.prepare_data_root','--data-root',str(tx.data_candidate),'--runtime-root',str(tx.work_root/'migration-runtime')],cwd=tx.release,env={**os.environ,'PYTHONPATH':str(tx.release)}) +else: + tx.prepare() +assert c.FILES['frpc'].is_file() +assert (c.DATA/c.JOURNAL/'state.json').is_file() +tx.switch() +# Hardware/HTTP health is intentionally simulated in this isolated filesystem. +# The real worker ordering, venv, compile, pytest, migration and installation run. +tx.succeed() +c.finish() +assert (c.TARGET/'VERSION').read_text().strip()=='1.1.0' +assert (c.DATA/'keep.txt').read_bytes()==b'user resource preserved' +assert not (c.DATA/c.JOURNAL).exists() +c.check_installed(c.TARGET,'1.1.0') +print('PASS: real 1.0.3 worker -> 1.1.0, actual offline venv/tests/native compile/frpc; simulated service health',flush=True) + +# Exercise idempotence and repair with the actual ARM64 shell installer. +bundle=SEED/'software/system-dependencies/frpc' +env=os.environ.copy();env['FRPC_BUNDLE']=str(bundle);env['FRPC_RUN_USER']=c.account() +installer=['/bin/sh',str(c.TARGET/'scripts/install_frpc_system.sh')] +state_path=Path('/run/fake-systemctl.json') +state_path.write_text(json.dumps({c.FRP:{'active':True,'enabled':True}})) +before=c.FILES['frpc'].stat().st_mtime_ns +subprocess.run(installer,env=env,check=True) +assert c.FILES['frpc'].stat().st_mtime_ns==before +c.FILES['frpc'].write_bytes(b'corrupt') +subprocess.run(installer,env=env,check=True) +c.check_installed(c.TARGET,'1.1.0') +c.FILES['frpc-dropin'].unlink() +subprocess.run(installer,env=env,check=True) +c.check_installed(c.TARGET,'1.1.0') +assert json.loads(state_path.read_text())[c.FRP]=={'active':True,'enabled':True} +print('PASS: existing binary skipped; damaged binary and missing drop-in repaired; enabled/active preserved',flush=True) + +# Test independent rollback after the legacy worker switches program and data. +for stage in ('installed','switched','data-gap','service-failure','health-failure'): + reset() + source=c.RELEASES/'1.1.0-baseline103' + shutil.copytree(SEED/'software',source) + candidate=c.DATA.with_name('matrix-screen-controller.ota.baseline103') + shutil.copytree(c.DATA,candidate) + c.RUNTIME.mkdir(parents=True,exist_ok=True) + (c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8') + c.begin(source,candidate) + assert c.FILES['frpc'].exists() + if stage in ('service-failure','health-failure'): + failed_tx=worker.Transaction(request) + failed_tx.work_root.mkdir(parents=True) + failed_tx.start_visual=lambda: None + failed_tx.stop_visual=lambda: None + if stage=='service-failure': + state_path.write_text(json.dumps({'start_fail_once':c.SERVICE})) + try: + failed_tx.switch() + raise RuntimeError('injected health failure') + except Exception as error: + failed_tx.rollback(error) + if stage in ('switched','data-gap'): + c.TARGET.rename(c.RELEASES/'1.0.3-pre-ota-baseline103') + c.TARGET.symlink_to(source,target_is_directory=True) + c.DATA.rename(c.DATA.with_name('matrix-screen-controller.rollback.baseline103')) + if stage=='switched': candidate.rename(c.DATA) + c.finish(boot=True) + assert (c.TARGET/'VERSION').read_text().strip()=='1.0.3' + assert (c.DATA/'keep.txt').read_bytes()==b'user resource preserved' + assert not c.FILES['frpc'].exists() + assert not (c.DATA/c.JOURNAL).exists() + print('PASS: component/application interruption recovery '+stage,flush=True) + +# v2 patch must fail in the real old parser, even without its own path policy. +from app.ota.package import build_package +from app.ota.versioning import SoftwareVersion +mini=ROOT/'patch-source';mini.mkdir(exist_ok=True) +(mini/'VERSION').write_text('1.1.1',encoding='utf-8') +patch=ROOT/'patch.ota';patch.unlink(missing_ok=True) +build_package(mini,SEED/'wheelhouse',patch,version=SoftwareVersion.parse('1.1.1'),release_notes='gate test') +try: + oldpackage.inspect_package(patch,current_version=SoftwareVersion.parse('1.0.3')) +except oldpackage.OtaPackageError: + print('PASS: real 1.0.3 parser rejects v2 patch before installation',flush=True) +else: raise AssertionError('legacy updater accepted patch') diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_namespace.sh b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_namespace.sh new file mode 100644 index 0000000..8240446 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_namespace.sh @@ -0,0 +1,44 @@ +set -eu +ROOT=/var/tmp/matrix-ota110-validation +export ROOT +mkdir -p "$ROOT/fs/sudoers" +python3 - "$ROOT/fs/sudoers/90-matrix-screen-controller-account" <<'PY' +from pathlib import Path +import re,pwd,sys +text=Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf').read_text(encoding='utf-8') +users=re.findall(r'^User=([a-zA-Z0-9_-]+)$',text,re.M) +assert len(users)==1 and pwd.getpwnam(users[0]).pw_uid!=0 +Path(sys.argv[1]).write_text(users[0]+' ALL=(ALL:ALL) ALL'+chr(10),encoding='utf-8') +PY +mkdir -p "$ROOT/fs/opt" "$ROOT/fs/varlib" "$ROOT/fs/run" "$ROOT/fs/bin" "$ROOT/fs/units" "$ROOT/fake-bin" +cat > "$ROOT/fake-bin/systemctl" <<'PY' +#!/usr/bin/python3 +import sys,json +from pathlib import Path +if Path(sys.argv[0]).name=='systemd-run': sys.exit(0) +args=sys.argv[1:];state_path=Path('/run/fake-systemctl.json') +state=json.loads(state_path.read_text()) if state_path.exists() else {} +cmd=args[0] if args else '';unit=args[-1] if args else '' +if cmd in ('is-active','is-enabled'): sys.exit(0 if state.get(unit,{}).get('active' if cmd=='is-active' else 'enabled',False) else 1) +if cmd=='show': print('inactive') +if cmd in ('enable','disable','start','stop'): + if cmd=='start' and state.get('start_fail_once')==unit: + del state['start_fail_once'];state_path.write_text(json.dumps(state));sys.exit(1) + entry=state.setdefault(unit,{}) + entry['active' if cmd in ('start','stop') else 'enabled']=cmd in ('start','enable') + state_path.write_text(json.dumps(state)) +sys.exit(0) +PY +cp "$ROOT/fake-bin/systemctl" "$ROOT/fake-bin/systemd-run" +chmod 755 "$ROOT/fake-bin/systemctl" "$ROOT/fake-bin/systemd-run" +export PATH="$ROOT/fake-bin:$PATH" +unshare --mount --propagation private /bin/sh -c ' +set -eu +mount --bind "$ROOT/fs/opt" /opt +mount --bind "$ROOT/fs/varlib" /var/lib +mount --bind "$ROOT/fs/run" /run +mount --bind "$ROOT/fs/bin" /usr/local/bin +mount --bind "$ROOT/fs/units" /etc/systemd/system +mount --bind "$ROOT/fs/sudoers" /etc/sudoers.d +python3 "$ROOT/${TEST_SCRIPT:-ota110_integration.py}" ${TEST_ARG:-} +' diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_supplement.py b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_supplement.py new file mode 100644 index 0000000..130adee --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/ota110_supplement.py @@ -0,0 +1,44 @@ +from pathlib import Path +import importlib.util, json, os, shutil, subprocess, sys +ROOT=Path(__file__).resolve().parent +seed=ROOT/'seed/software' +sys.path.insert(0,str(seed)) +spec=importlib.util.spec_from_file_location('latest_components',ROOT/'latest/ota_components.py') +c=importlib.util.module_from_spec(spec);spec.loader.exec_module(c) +for root in (Path('/opt'),Path('/var/lib'),Path('/run'),Path('/usr/local/bin'),Path('/etc/systemd/system')): + for path in root.iterdir(): + if path.is_dir() and not path.is_symlink(): shutil.rmtree(path) + else: path.unlink() +c.DATA.mkdir();c.TARGET.mkdir();c.RELEASES.mkdir();c.RUNTIME.mkdir(parents=True) +(c.TARGET/'VERSION').write_text('1.0.6',encoding='utf-8') +for name in ('app-unit','ota-unit'): c.FILES[name].write_bytes(('old '+name).encode()) +source=c.RELEASES/'1.1.0-supplement' +shutil.copytree(seed,source) +for name in ('ota_components.py','install_frpc_system.sh'): + shutil.copy2(ROOT/'latest'/name,source/'scripts'/name) +env=os.environ.copy();env['FRPC_RUN_USER']=c.account();env['FRPC_BUNDLE']=str(source/'system-dependencies/frpc') +installer=['/bin/sh',str(source/'scripts/install_frpc_system.sh')] +subprocess.run(installer,env=env,check=True,capture_output=True) +active=c.DATA/'frp/active.env';active.write_bytes(b'# isolated fixture\n');active.chmod(0o600) +state=Path('/run/fake-systemctl.json');state.write_text(json.dumps({c.FRP:{'active':True,'enabled':True}}),encoding='utf-8') +before=c.FILES['frpc'].stat().st_mtime_ns +subprocess.run(installer,env=env,check=True,capture_output=True) +assert before==c.FILES['frpc'].stat().st_mtime_ns +c.check_installed(source,'1.1.0') +assert active.stat().st_mode & 0o777 == 0o600 +assert json.loads(state.read_text(encoding='utf-8'))[c.FRP]=={'active':True,'enabled':True} +print('PASS: final installer idempotence, active.env 0600, active/enabled state preserved') +original=c.FILES['frpc'].read_bytes() +c.FILES['frpc'].write_bytes(b'corrupt-before-OTA') +candidate=c.DATA.with_name('matrix-screen-controller.ota.supplement');shutil.copytree(c.DATA,candidate) +request={'schema_version':1,'job_id':'supplement','current_version':'1.0.6','target_version':'1.1.0','accepted_at':'2026-09-07T00:00:00Z'} +(c.RUNTIME/'ota-request.json').write_text(json.dumps(request),encoding='utf-8') +c.begin(source,candidate) +assert c.FILES['frpc'].read_bytes()==original +c.finish(boot=True) +assert c.FILES['frpc'].read_bytes()==b'corrupt-before-OTA' +assert active.read_bytes()==b'# isolated fixture\n' +assert active.stat().st_mode & 0o777 == 0o600 +assert json.loads(state.read_text(encoding='utf-8'))[c.FRP]=={'active':True,'enabled':True} +assert not (c.DATA/c.JOURNAL).exists() +print('PASS: final component transaction repairs then exactly restores preinstalled FRP and permissions on failure') diff --git a/各种归档/20260907_OTA1.1.0软件安装节点验证/板端隔离验证原始记录.tar.gz b/各种归档/20260907_OTA1.1.0软件安装节点验证/板端隔离验证原始记录.tar.gz new file mode 100644 index 0000000..1722be9 --- /dev/null +++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/板端隔离验证原始记录.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb4f776e95f4e4deb9373e4bbca029604d399a932ccfb66ff113d7744d8fb6e3 +size 9843 diff --git a/各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json b/各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json new file mode 100644 index 0000000..0d578f1 --- /dev/null +++ b/各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "artifact_type": "image", + "image_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d", + "software_version": "1.1.1", + "status": "success", + "firstboot_completed": true, + "automatic_reboot_passed": true, + "default_wifi_connected": true, + "ssh_password_login_passed": true, + "sudo_password_passed": true, + "root_login_rejected": true, + "networkmanager_passed": true, + "controller_service_passed": true, + "kernel_health_passed": true, + "h618_mapping_passed": true, + "web_ui_passed": true, + "plaintext_config_removed": true, + "machine_identity_regenerated": true, + "second_reboot_passed": true, + "validated_at": "2026-09-08T16:32:38+08:00" +} diff --git a/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/README.md b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/README.md new file mode 100644 index 0000000..2f94efe --- /dev/null +++ b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/README.md @@ -0,0 +1,22 @@ +# 奇妙小屏幕控制器 OTA 1.1.0 + +- 软件版本:`1.1.0` +- 导出时间:`2026-09-07T22:02:12+08:00` +- 说明:软件安装包(必经升级版本):离线安装或修复 frpc 0.71.0,保留已有配置和启停状态;建立逐 minor .0 升级门槛、普通补丁跳版本及组件事务恢复。 +- 产物:`matrix-screen-controller-1.1.0.ota` + +- 包类型:软件安装包(必经升级版本) +- 前置系列基线:`1.0.0` + +在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。初次安装默认关闭。禁止跳过失败测试;失败自动恢复。 + +本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。 + +## 本次安装 + +1. 正常 1.0.x 旧版本先安装本包,不要直接安装未来的 1.1.x 补丁。当前已预装 frp 的调试设备也安装同一份包;其旧 OTA worker 已完成必要兼容修正,设备版本仍为 1.0.6。 +2. 打开设备网页的“系统设置 → 软件更新”,选择 `matrix-screen-controller-1.1.0.ota`,无需解压。 +3. 安装会执行完整测试并检查 frp;已有完整组件跳过,缺失或损坏自动离线修复。等待页面恢复后确认软件版本为 1.1.0。 +4. 完成 1.1.0 后可以跳过同系列补丁,例如直接安装 1.1.3;未来跨到 1.2 系列时必须先安装 1.2.0。 + +本机测试、板端 AArch64 隔离安装/回滚和真实旧包解析器兼容检查已通过。真实网页 OTA、真实 systemd 监护、整机断电/重启与实屏尚未验收;未自动替你安装本正式包。一次性验证记录保存在项目的 `各种归档/20260907_OTA1.1.0软件安装节点验证/`,不作为后续构建依赖。 diff --git a/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/manifest.json b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/manifest.json new file mode 100644 index 0000000..e959d0f --- /dev/null +++ b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/manifest.json @@ -0,0 +1,13 @@ +{ + "format_version": 1, + "artifact_type": "ota", + "software_version": "1.1.0", + "created_at": "2026-09-07T22:02:12+08:00", + "notes": "软件安装包(必经升级版本):离线安装或修复 frpc 0.71.0,保留已有配置和启停状态;建立逐 minor .0 升级门槛、普通补丁跳版本及组件事务恢复。", + "artifact": "matrix-screen-controller-1.1.0.ota", + "artifact_bytes": 27993930, + "artifact_sha256": "379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5", + "package_kind": "software-install", + "required_checkpoint": "1.0.0", + "is_checkpoint": true +} diff --git a/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/matrix-screen-controller-1.1.0.ota b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/matrix-screen-controller-1.1.0.ota new file mode 100644 index 0000000..a33787e --- /dev/null +++ b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/matrix-screen-controller-1.1.0.ota @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5 +size 27993930 diff --git a/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/matrix-screen-controller-1.1.0.ota.sha256 b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/matrix-screen-controller-1.1.0.ota.sha256 new file mode 100644 index 0000000..ce7666c --- /dev/null +++ b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/原安装包/matrix-screen-controller-1.1.0.ota.sha256 @@ -0,0 +1 @@ +379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5 matrix-screen-controller-1.1.0.ota diff --git a/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/发布记录_修复前.json b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/发布记录_修复前.json new file mode 100644 index 0000000..2967f4b --- /dev/null +++ b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/发布记录_修复前.json @@ -0,0 +1,76 @@ +{ + "schema_version": 1, + "releases": [ + { + "version": "1.0.1", + "artifact_type": "ota", + "created_at": "2026-08-13T15:31:23+08:00", + "notes": "新增浏览器全量 OTA。", + "artifact_path": "OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", + "artifact_sha256": "1ae8544e2072a014dc76e07a930375c80e85b5331957716a456c8123dd204325" + }, + { + "version": "1.0.2", + "artifact_type": "image", + "created_at": "2026-08-19T15:42:35+08:00", + "notes": "建立可刷镜像、离线首次启动和 Windows 镜像编辑能力;修复并真实验证 SSH 默认启用、配置账户密码登录、需同密码完整 sudo 权限、root 登录禁用、sshd 易失运行目录、首启 unit 超时与假成功,以及基础镜像 rootpw、旧 pi 免密规则和主机名解析延迟。", + "artifact_path": "离线依赖/导出包/1.0.2/matrix-screen-controller-1.0.2.img", + "artifact_sha256": "0397b2abd974cc293a472789027b02bf469434d358578fd43aa9fdb218944aeb" + }, + { + "version": "1.0.3", + "artifact_type": "image", + "created_at": "2026-08-25T08:17:48+00:00", + "notes": "修复 1.0.3 首次启动 FAT 空间不足:应用离线载荷迁入 rootfs,增加候选内核 boot 文件预算与 32 MiB 安全余量双重校验,并修复导出入口误加载 Pillow/FontTools。", + "artifact_path": "离线依赖/导出包/1.0.3/matrix-screen-controller-1.0.3.img", + "artifact_sha256": "a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b" + }, + { + "version": "1.0.4", + "artifact_type": "ota", + "created_at": "2026-09-04T13:14:05+08:00", + "notes": "发布当前工作区已完成并通过本机全套测试的软件版本。", + "artifact_path": "OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota", + "artifact_sha256": "ed15498e2d66758343c874d00176e2525e67a69fea5cc8f4578f139dd2b0f766" + }, + { + "version": "1.0.5", + "artifact_type": "ota", + "created_at": "2026-09-06T21:10:58+08:00", + "notes": "修复 OTA 板端测试目录隔离,并新增可查看、复制的详细失败日志。", + "artifact_path": "OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota", + "artifact_sha256": "ec245d2ba2eefdf415f6745aefb1dfc66631e027d6820a2b6bd04bd6306d4152" + }, + { + "version": "1.0.6", + "artifact_type": "ota", + "created_at": "2026-09-06T21:39:45+08:00", + "notes": "诊断引导包:经设备持有者授权,仅在 1.0.3 旧 worker 中跳过 pytest,以先安装可查看、复制的 OTA 失败日志;后续更新恢复严格测试。", + "artifact_path": "OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota", + "artifact_sha256": "8e804dcfb1f2f9194f3c57b88b5d4b7308509270fba536059e528ab21fdfa0b9" + }, + { + "version": "1.1.0", + "artifact_type": "ota", + "created_at": "2026-09-07T22:02:12+08:00", + "notes": "软件安装包(必经升级版本):离线安装或修复 frpc 0.71.0,保留已有配置和启停状态;建立逐 minor .0 升级门槛、普通补丁跳版本及组件事务恢复。", + "artifact_path": "OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota", + "artifact_sha256": "379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5", + "package_kind": "software-install", + "required_checkpoint": "1.0.0", + "is_checkpoint": true, + "component_checkpoints": [ + { + "version": "1.1.0", + "components": { + "frpc": { + "version": "0.71.0", + "sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2", + "bundle": "frp/0.71.0/linux-arm64" + } + } + } + ] + } + ] +} diff --git a/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/实机验收.json b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/实机验收.json new file mode 100644 index 0000000..78af5d0 --- /dev/null +++ b/各种归档/20260908_103133_OTA1.1.0修复前_1d2a8f/实机验收.json @@ -0,0 +1,18 @@ +{ + "package_sha256": "7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8", + "installed_version": "1.1.0", + "status": "success", + "runtime_lifecycle_passed": true, + "user_data_preserved": true, + "frp_state_preserved": true, + "transaction_cleanup_passed": true, + "user_file_count": 421, + "job_id": "86cbcfe541894bb6b07c52b17fb5f3f3", + "verified_at": "2026-09-08T02:31:22.720144+00:00", + "frp_active": "inactive", + "frp_enabled": "disabled", + "disk_version": "1.1.0", + "api_version": "1.1.0", + "feature_updated_at": "2026-09-08T10:19+08:00", + "screen_visual_acceptance": "not_performed" +} diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/README.md b/各种归档/20260908_OTA1.1.0运行目录修复验收/README.md new file mode 100644 index 0000000..0957ba0 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/README.md @@ -0,0 +1,27 @@ +# 1.1.0 OTA 运行目录修复验收 + +2026-09-08 已在测试设备实际完成 1.0.6 → 1.1.0。磁盘 VERSION、运行 API 均为 1.1.0,OTA worker Result=success,用户数据 421 个非 OTA 文件摘要完全一致,frpc 二进制摘要保持,服务仍 inactive / disabled。组件完整性校验通过,恢复事务、临时保护、恢复 unit/helper、上传包与工作目录均已清理。 + +正式产物为 `发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota`,27998979 字节,SHA-256 为 `7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8`。发布时直接复制实机验收候选,逐字节确认一致,未重新构建。VERSION 保持 1.1.0,FEATURE_UPDATED_AT 为 `2026-09-08T10:19+08:00`。旧包摘要 `379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5`,完整旧产物和旧发布记录归档位置见根发布记录的 repairs 字段。 + +## 故障与修复 + +原 1.1.0 安装过程中,旧 unit 的 RuntimeDirectoryPreserve=restart 在独立 stop 时删除共享 /run 目录,升级日志和回滚日志连续 FileNotFoundError;独立组件监护恢复了 1.0.6。因此显示 1.0.6 代表升级失败恢复,不能宣称升级成功。 + +主服务改为 Preserve=yes。候选迁移入口在停止旧服务之前创建标准运行期 drop-in、daemon-reload 并核验有效策略;保护直到提交或恢复完成,随后清理。日志目录丢失会重建,写入失败保留有限缓冲,避免日志异常阻止恢复。该约束已写入 AGENTS.md、源码 README、稳定需求和测试流程。 + +首轮修复候选发现保护残留检查位置错误,安装后校验误拒绝本次事务保护。该候选未发布;完整板端测试 420 passed / 2 skipped 后进入迁移失败恢复,421 个用户文件和 frp 状态保持,原始失败原因和日志保留,临时事务清理通过。随后将残留检查移至事务开始之前,并新增组件校验允许本事务保护的回归测试。 + +## 验证及证据 + +- 最终候选隔离目录完整 pytest:414 passed、9 skipped;完整其他离线依赖复制后验证,未引用历史镜像导出目录。最初仅解包 OTA 的隔离目录缺少镜像构建材料,已补全离线依赖后重新完整测试通过,无跳过失败用例。 +- 最终候选前端测试:76 passed;本机完整前端和 JavaScript 语法、文案目录检查通过。隔离目录重新打包通过,其测试构建不用于发布。 +- 最终实机 OTA 的完整 pytest:421 passed、2 skipped,358.28 秒。跳过为源码 OTA 不含镜像材料及已登记平台条件;保留完整测试门槛。 +- 真实 systemd 专用临时 unit 复现 restart 在独立 stop 时删除目录,生产保护函数验证 stop、restart、启动失败后请求/进度/日志字节不变,测试 unit 清理通过。该项未使用 systemctl 替身。 +- 实际 OTA 采样 7 次观察到临时保护存在、有效 Preserve=yes,请求/状态/日志均存在;最终保护清理成功。见 ota-evidence.json。 +- 从原 1.0.3 镜像已只读提取的真实解析器再次校验和解包最终候选通过,仅 software / wheelhouse 顶层,恰好一份 frpc 离线二进制。 +- 原有组件失败恢复、升级门槛、日志故障、同版本发布与发布失败原产物保留回归均包含在完整 pytest 中。原 1.0.3 worker 的隔离故障矩阵证据仍保留在 20260907 归档,该矩阵使用过 systemctl 替身,不替代此次真实 systemd 与真实 OTA 验收。 + +本次没有执行人工实屏视觉、断电或整机重启验收,不把软件成功等同于这些结果通过。升级前完整持久数据备份仅在设备受限临时目录保存,验收及发布完成后清理,不复制用户配置或凭据到普通归档。 + +设备临时验证目录及受限备份已清理。本机 `如何测试/本机测试环境/work/ota-runtime-repair/` 的递归清理被自动安全策略拒绝(blocked by policy,未提供更具体原因),因此仍保留候选和隔离测试文件;它们不属于正式产物,不被后续构建引用。 diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/final-build.log b/各种归档/20260908_OTA1.1.0运行目录修复验收/final-build.log new file mode 100644 index 0000000..1ebbd9e --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/final-build.log @@ -0,0 +1 @@ +<仓库根目录>\测试相关资料\如何测试\本机测试环境\work\ota-runtime-repair\final-isolated-build\1.1.0 diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/final-frontend.log b/各种归档/20260908_OTA1.1.0运行目录修复验收/final-frontend.log new file mode 100644 index 0000000..2383059 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/final-frontend.log @@ -0,0 +1,84 @@ +✔ derives automatic frame names from current animation name and position (0.6403ms) +✔ keeps custom frame names and restores automatic names when cleared (0.0937ms) +✔ builds non-repeating animation edit context names (0.0948ms) +✔ selects saved animation preview frames by ordered durations (0.6116ms) +✔ loads every preview frame through revision-checked pagination (1.3323ms) +✔ validates configurable animation preview concurrency (0.2673ms) +✔ rejects mixed revisions instead of showing a partial animation (0.3507ms) +✔ validates frame duration only when explicitly called (0.3759ms) +✔ moves selected frames as one ordered group (0.2669ms) +✔ protects an eight pixel ring around interactive drag controls (0.1664ms) +✔ reorders mixed library keys without mutating the source list (0.1138ms) +✔ restores drag eligibility after protected pointer cancellation and drag end (0.3556ms) +✔ animation workspace exposes two previews and the simplified control order (6.8075ms) +✔ normal and pixel modes expose independent ranges and presets (1.1726ms) +✔ pixel stamps are exact squares from 1x1 through 4x4 (0.2138ms) +✔ pixel stamps clip at every canvas edge (0.1787ms) +✔ normal circle geometry remains unchanged (0.0899ms) +✔ line interpolation includes endpoints without coordinate gaps (0.1821ms) +✔ v1 tools migrate into normal mode while v2 restores both sizes (0.2555ms) +✔ pixel mode markup and grid stay accessible and non-interactive (6.9068ms) +✔ pixel undo history stores changed snapshots, caps entries, and clears (0.5017ms) +✔ canvas view clamps scale and keeps a visible section in the viewport (0.4704ms) +✔ zooming around an anchor preserves its logical position (0.2555ms) +✔ floating editor button stays inside its safe viewport bounds (0.1233ms) +✔ canvas editor markup, guarded input, and checkpoint events stay wired (1.9429ms) +✔ normalizes and converts hexadecimal colors (1.1363ms) +✔ converts RGB and HSV boundaries (0.2535ms) +✔ round trips representative colors within 8-bit rounding (0.9763ms) +✔ recent colors deduplicate, normalize, and evict the oldest (0.2321ms) +✔ formats and projects looping playback without moving paused content (1.0057ms) +✔ seek dispatcher keeps one request in flight and flushes the newest position (2.948ms) +✔ topbar current display dialog exposes static close and animation controls (3.2173ms) +✔ device workspace exposes the compact immediate pure-color test flow (5.2123ms) +✔ font catalog normalization, grouping, and search are stable (1.7571ms) +✔ font picker implements commit-only searchable combobox keyboard behavior (2.9703ms) +✔ settings places unrestricted FRP maintenance at the bottom (4.612ms) +✔ FRP editor uses raw server storage and official validation errors (1.0223ms) +✔ network diagnostics renders layered evidence (1.448ms) +✔ unavailable controls never masquerade as indefinite loading (5.6542ms) +✔ action controls do not bypass the shared state module (6.0127ms) +✔ HTTP requests time out with a stable user-facing error (18.0299ms) +✔ demo library actions stay black, explain restrictions, and preserve copy (1.1572ms) +✔ sortable cards protect controls and keep demo cards fixed (1.0358ms) +✔ media import workspace exposes streaming upload, crop controls, and queue polling (1.1099ms) +✔ free framing starts from the complete source and keeps one pixel visible (0.6589ms) +✔ free framing copy is stable and catalogued (0.0881ms) +✔ upload action has visible ordinary-build copy (0.193ms) +✔ media naming happens after analysis and conflicts keep the draft (0.1455ms) +✔ awaiting-settings cards survive polling without hiding server transitions (0.1182ms) +✔ creates a fixed 64x64 RGB scene (1.9085ms) +✔ serializes pixelRgb as base64 and round trips an independent scene (6.1668ms) +✔ moves a valid v1 draft to the stable key before removing exact legacy keys (9.1938ms) +✔ keeps the v1 draft and all legacy keys when stable-key migration cannot write (2.3183ms) +✔ preserves and blocks a future stable draft instead of falling back or writing blank (1.224ms) +✔ preserves and blocks a damaged stable draft (0.4343ms) +✔ does not discard a damaged higher-priority legacy key during fallback migration (1.1675ms) +✔ rejects damaged scene roots while skipping only damaged elements (0.6392ms) +✔ normalizes text fields and creates a centered lowercase ok element (0.2164ms) +✔ migrates the first valid canvas by v3, v2, legacy priority and merges text v1 (8.2449ms) +✔ supports independent add, update, and delete operations for multiple text elements (0.5292ms) +✔ duplicates with a deep copy, new id, 2px offset, and topmost order (0.3276ms) +✔ alpha-composites full RGBA layers in array order without mutating inputs (1.1463ms) +✔ settings brightness explains an active temporary display-test override (5.305ms) +✔ settings exposes full OTA upload progress and service recovery polling (2.9085ms) +✔ settings automatically opens and safely copies a detailed OTA failure log (1.5356ms) +✔ settings exposes a transactional performance-mode switch with the fixed warning (1.3568ms) +✔ settings exposes persisted animation preview concurrency below performance mode (1.3258ms) +✔ template edit baseline distinguishes clean, dirty, and unbound scenes (0.9501ms) +✔ template manager uses a monitor and unified four-row card actions (6.2269ms) +✔ animation editing backs up and restores template edit ownership (1.0747ms) +✔ special editor exposes the agreed modes and guarded editing flow (6.7948ms) +✔ latched WiFi draft state remains dirty until explicitly cleared (1.022ms) +✔ WiFi settings expose independent network and prompt-delay controls (2.1695ms) +✔ workspace order resolves saved ids and appends new registrations (1.0454ms) +✔ workspace order movement is immutable and respects both boundaries (0.2573ms) +✔ drawer exposes one editable list and commits only from the save action (5.2391ms) +ℹ tests 76 +ℹ suites 0 +ℹ pass 76 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 502.5316 diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/final-pytest.log b/各种归档/20260908_OTA1.1.0运行目录修复验收/final-pytest.log new file mode 100644 index 0000000..1deeae1 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/final-pytest.log @@ -0,0 +1,91 @@ +........................................................................ [ 17%] +........................................................................ [ 34%] +........................................................................ [ 51%] +.............................sssssss.......s............................ [ 68%] +...............s........................................................ [ 85%] +............................................................... [100%] +============================== warnings summary =============================== +..\..\..\..\.venv\Lib\site-packages\fastapi\routing.py:234: 81 warnings +tests/test_animations.py: 891 warnings +tests/test_api.py: 2025 warnings +tests/test_fonts.py: 243 warnings +tests/test_frp.py: 324 warnings +tests/test_library_order.py: 324 warnings +tests/test_media_conversion.py: 810 warnings +tests/test_ota.py: 243 warnings +tests/test_performance_mode.py: 81 warnings +tests/test_templates.py: 648 warnings +tests/test_ui_copy_editor.py: 585 warnings + <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\routing.py:234: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead + is_coroutine = asyncio.iscoroutinefunction(dependant.call) + +app\main.py:2024: 1 warning +tests/test_animations.py: 11 warnings +tests/test_api.py: 25 warnings +tests/test_fonts.py: 3 warnings +tests/test_frp.py: 4 warnings +tests/test_library_order.py: 4 warnings +tests/test_media_conversion.py: 10 warnings +tests/test_ota.py: 3 warnings +tests/test_performance_mode.py: 1 warning +tests/test_templates.py: 8 warnings +tests/test_ui_copy_editor.py: 7 warnings + <仓库根目录>\测试相关资料\如何测试\本机测试环境\work\ota-runtime-repair\isolated-final\software\app\main.py:2024: DeprecationWarning: + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + + @app.on_event("startup") + +..\..\..\..\.venv\Lib\site-packages\fastapi\applications.py:4495: 2 warnings +tests/test_animations.py: 22 warnings +tests/test_api.py: 50 warnings +tests/test_fonts.py: 6 warnings +tests/test_frp.py: 8 warnings +tests/test_library_order.py: 8 warnings +tests/test_media_conversion.py: 20 warnings +tests/test_ota.py: 6 warnings +tests/test_performance_mode.py: 2 warnings +tests/test_templates.py: 16 warnings +tests/test_ui_copy_editor.py: 14 warnings + <仓库根目录>\测试相关资料\如何测试\本机测试环境\.venv\Lib\site-packages\fastapi\applications.py:4495: DeprecationWarning: + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + + return self.router.on_event(event_type) + +app\main.py:2047: 1 warning +tests/test_animations.py: 11 warnings +tests/test_api.py: 25 warnings +tests/test_fonts.py: 3 warnings +tests/test_frp.py: 4 warnings +tests/test_library_order.py: 4 warnings +tests/test_media_conversion.py: 10 warnings +tests/test_ota.py: 3 warnings +tests/test_performance_mode.py: 1 warning +tests/test_templates.py: 8 warnings +tests/test_ui_copy_editor.py: 7 warnings + <仓库根目录>\测试相关资料\如何测试\本机测试环境\work\ota-runtime-repair\isolated-final\software\app\main.py:2047: DeprecationWarning: + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + + @app.on_event("shutdown") + +tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner +tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner +tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner +tests/test_media_conversion.py::test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner + <仓库根目录>\测试相关资料\如何测试\本机测试环境\work\ota-runtime-repair\isolated-final\software\tests\test_media_conversion.py:134: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead. + assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1 + +tests/test_templates.py::test_template_crud_copy_persistence_and_thumbnail_cleanup + <仓库根目录>\测试相关资料\如何测试\本机测试环境\work\ota-runtime-repair\isolated-final\software\tests\test_templates.py:165: DeprecationWarning: Image.Image.getdata is deprecated and will be removed in Pillow 14 (2027-10-15). Use get_flattened_data instead. + assert len(set(image.getdata())) > 1 + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +414 passed, 9 skipped, 6568 warnings in 65.73s (0:01:05) diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/original-failure.json b/各种归档/20260908_OTA1.1.0运行目录修复验收/original-failure.json new file mode 100644 index 0000000..bdca7e3 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/original-failure.json @@ -0,0 +1,7 @@ +{ + "original_failure_matches": [ + "FileNotFoundError: [Errno 2] No such file or directory: '/run/matrix-screen-controller/ota-worker.log'", + "FileNotFoundError: [Errno 2] No such file or directory: '/run/matrix-screen-controller/ota-worker.log'" + ], + "window_utc": "2026-09-07T14:20:00Z/2026-09-07T14:40:00Z" +} diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/ota-evidence.json b/各种归档/20260908_OTA1.1.0运行目录修复验收/ota-evidence.json new file mode 100644 index 0000000..47f8a08 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/ota-evidence.json @@ -0,0 +1,58 @@ +{ + "pytest_summary": [ + "421 passed, 2 skipped, 313 warnings in 358.28s (0:05:58)" + ], + "guard_observations": [ + { + "guard_exists": true, + "request_exists": true, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + }, + { + "guard_exists": true, + "request_exists": true, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + }, + { + "guard_exists": true, + "request_exists": true, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + }, + { + "guard_exists": true, + "request_exists": true, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + }, + { + "guard_exists": true, + "request_exists": true, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + }, + { + "guard_exists": true, + "request_exists": true, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + }, + { + "guard_exists": true, + "request_exists": false, + "status_exists": true, + "log_exists": true, + "preserve": "yes" + } + ], + "service_state": "active", + "ota_worker_result": "success" +} diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/package-validation.json b/各种归档/20260908_OTA1.1.0运行目录修复验收/package-validation.json new file mode 100644 index 0000000..328b109 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/package-validation.json @@ -0,0 +1,10 @@ +{ + "legacy_103_parser_and_extraction_passed": true, + "top_level": [ + "software", + "wheelhouse" + ], + "frpc_count": 1, + "package_sha256": "7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8", + "file_count": 249 +} \ No newline at end of file diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/real-systemd.log b/各种归档/20260908_OTA1.1.0运行目录修复验收/real-systemd.log new file mode 100644 index 0000000..318b767 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/real-systemd.log @@ -0,0 +1,9 @@ +/usr/bin/python3 +/usr/bin/systemctl +/usr/bin/tar +/usr/bin/sha256sum +/usr/bin/curl +Python 3.11.2 +PASS: real systemd Preserve=restart deletes runtime directory on explicit stop +PASS: real stop/restart/start-failure preserve OTA request/status/log; temporary protection cleaned +{"runtime_lifecycle_passed": true} diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/recovery-validation.json b/各种归档/20260908_OTA1.1.0运行目录修复验收/recovery-validation.json new file mode 100644 index 0000000..b60b412 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/recovery-validation.json @@ -0,0 +1,12 @@ +{ + "recovery_passed": true, + "disk_version": "1.0.6", + "user_files_preserved": 421, + "frp_preserved": true, + "cleanup_passed": true, + "failure_log_preserved": true, + "pytest_summary": [ + "420 passed, 2 skipped, 313 warnings in 359.96s (0:05:59)" + ], + "original_failure_preserved": true +} diff --git a/各种归档/20260908_OTA1.1.0运行目录修复验收/validation.json b/各种归档/20260908_OTA1.1.0运行目录修复验收/validation.json new file mode 100644 index 0000000..78af5d0 --- /dev/null +++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/validation.json @@ -0,0 +1,18 @@ +{ + "package_sha256": "7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8", + "installed_version": "1.1.0", + "status": "success", + "runtime_lifecycle_passed": true, + "user_data_preserved": true, + "frp_state_preserved": true, + "transaction_cleanup_passed": true, + "user_file_count": 421, + "job_id": "86cbcfe541894bb6b07c52b17fb5f3f3", + "verified_at": "2026-09-08T02:31:22.720144+00:00", + "frp_active": "inactive", + "frp_enabled": "disabled", + "disk_version": "1.1.0", + "api_version": "1.1.0", + "feature_updated_at": "2026-09-08T10:19+08:00", + "screen_visual_acceptance": "not_performed" +} diff --git a/各种归档/20260908_工作区目录适配验收/README.md b/各种归档/20260908_工作区目录适配验收/README.md new file mode 100644 index 0000000..a9a6a70 --- /dev/null +++ b/各种归档/20260908_工作区目录适配验收/README.md @@ -0,0 +1,71 @@ +# 工作区目录适配验收 + +2026-09-08,本次只适配工作区整理后的目录引用和定位逻辑。以下是一次性验证记录,稳定要求见 `DEPLOY-WORKSPACE-LAYOUT` 与 `TEST-WORKSPACE-LAYOUT`。 + +## 完成内容 + +- 更新协作指南、需求、测试说明、源码与发布 README、ADC 示例命令和资料校验命令。 +- 修复本机测试入口的工作区根定位;更新部署、手工更新、frpc 安装、OTA 构建、发布、晋升和修复工具的工作区路径。 +- 发布记录只改 `artifact_path`,逐条比较确认其他字段不变;当前 VERSION 为 1.1.1,FEATURE_UPDATED_AT 为 2026-09-08T10:19+08:00,均保持原值。本次不是可部署业务功能新增或优化。 +- 保留镜像内部 `project/离线依赖/` 协议及设备路径;既有发布二进制未修改。归档只修正当前导航,历史代码块和清理记录保持原文。 +- 编辑器的相对层级仍正确,无需修改定位代码。接线 DOCX 未发现本次搬迁涉及的旧路径,无需重新生成;官方 PDF 未修改。 +- 所有当前维护文档的 Markdown 本地链接校验通过;两篇已不存在的屏幕控制说明/连接简表引用,改指现有 HUB75 接线与首次上电检查文档。 + +## 验证结果 + +| 验证 | 结果 | +|---|---| +| 原工作区测试入口 | Python 418 passed、9 skipped;前端 70 passed;静态 JavaScript 与文案目录检查通过 | +| 最终隔离副本完整测试 | Python 421 passed、9 skipped;前端 70 passed;静态检查通过 | +| 新增路径回归 | 不同当前目录下定位中文空格路径;清理保留环境与人工数据;越界清理在删除前拒绝,3 项通过 | +| 镜像编辑器 | 原工作区与隔离副本各 14 项通过,包含主工程配置编码交叉校验与无窗口 GUI 测试 | +| mock 服务 | 隔离副本从测试环境启动,页面与 `/api/status` 均 HTTP 200,正常关闭并清屏 | +| 清理 | 隔离副本执行 `clean` 后配置、人工标记、venv 和依赖指纹保留,work/runtime 已删除 | +| ADC 标准库示例 | 新位置 `--self-test` 通过,未访问硬件 | +| Shell 脚本 | 修改的三个安装/更新脚本语法检查通过,未执行部署 | +| 发布工具 | 完整 Python 套件覆盖新目录下的临时 OTA 产物与发布记录、失败回退、同版修复和镜像候选;未导出正式产物 | + +隔离副本包含独立源码、测试入口、需求、完整其他依赖、编辑器源码和复制的测试环境,位于含中文及空格的目录;测试命令从副本外调用,未引用原工作区源码或历史导出产物。9 项跳过为现有工具/平台条件(媒体编码工具及特定环境用例),并非路径失败。未运行板端、真实 systemd、真实 OTA、TF 卡或实屏验收。 + +## 历史材料状态(用户已确认,无待办) + +- `发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota` +- `发布更新相关/导出包/1.0.2/matrix-screen-controller-1.0.2.img` + +以上为本次检查时的历史状态。用户随后明确:历史导出 IMG 缺失默认视为主动清理空间,属于正常情况,不影响开发;OTA 今后保留,但 1.0.1 不再追补、提醒或要求验收,不影响使用。两项均不属于待修复问题,原发布记录保留。其余六个登记产物在当时检查的新路径存在(只检查存在性,未读取大型 IMG)。 + +## 修改清单 + +可复核的文本差异见 [路径调整.patch](路径调整.patch)。本次修改以下 28 个已有或新增工作文件,另新增本验收目录: + +- `AGENTS.md` +- `发布更新相关/README.md` +- `发布更新相关/镜像编辑器/编辑器源代码/README.md` +- `发布记录.json` +- `各种归档/20260819_离线镜像1.0.2真实TF卡验收/README.md` +- `各种归档/20260825_离线镜像1.0.3启动分区空间修复/README.md` +- `各种归档/20260907_OTA1.1.0软件安装节点验证/README.md` +- `各种归档/20260908_OTA1.1.0运行目录修复验收/README.md` +- `整体开发需求/01_网页配置端需求.md` +- `整体开发需求/02_屏幕底层控制接口需求.md` +- `核桃派软件源代码/README.md` +- `核桃派软件源代码/kernel/README.md` +- `核桃派软件源代码/scripts/build_ota_package.py` +- `核桃派软件源代码/scripts/deploy_walnutpi.sh` +- `核桃派软件源代码/scripts/export_release.py` +- `核桃派软件源代码/scripts/install_frpc_system.sh` +- `核桃派软件源代码/scripts/promote_image_release.py` +- `核桃派软件源代码/scripts/repair_ota_release.py` +- `核桃派软件源代码/scripts/update_walnutpi.sh` +- `核桃派软件源代码/tests/test_kernel_artifact_dependency.py` +- `核桃派软件源代码/tests/test_ota_checkpoints.py` +- `核桃派软件源代码/tests/test_release_image.py` +- `测试相关资料/如何测试/本机测试环境/README.md` +- `测试相关资料/如何测试/本机测试环境/local_test.py` +- `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md` +- `硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md` +- `硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/来源索引.md` +- `核桃派软件源代码/tests/test_workspace_layout.py` +## 临时副本清理状态 + +隔离工作区内部的 `clean` 验收通过,但随后删除整个本次隔离副本的 PowerShell 操作被自动审批拒绝,仅返回 `blocked by policy`,没有更具体原因。副本及验证日志仍保留在 Codex 专用临时目录的 `workspace-path-adaptation-20260908/` 下;该目录不是项目依赖,不应被后续构建引用。未绕过拒绝执行其他删除方式。 diff --git a/各种归档/20260908_工作区目录适配验收/路径调整.patch b/各种归档/20260908_工作区目录适配验收/路径调整.patch new file mode 100644 index 0000000..11732c6 --- /dev/null +++ b/各种归档/20260908_工作区目录适配验收/路径调整.patch @@ -0,0 +1,809 @@ +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -1,6 +1,6 @@ + # 奇妙小屏幕控制器(核桃派 ZeroW)协作指南 + +-本目录是可以单独拿走、独立开发和部署的核桃派项目。处理任务前先读本文件,再读 `整体开发需求/`、`如何测试/` 和与任务有关的硬件资料。本项目不得依赖外层移植目录、原树莓派工作区、旧设备配置或旧设备凭据。 ++本目录是可以单独拿走、独立开发和部署的核桃派项目。处理任务前先读本文件,再读 `整体开发需求/`、`测试相关资料/如何测试/` 和与任务有关的硬件资料。本项目不得依赖外层移植目录、原树莓派工作区、旧设备配置或旧设备凭据。 + + ## 1. 固定硬件和边界 + +@@ -9,7 +9,7 @@ + - 真实驱动:项目自带 `walnutpi-h618-hub75` C 驱动,通过 `/dev/mem` 访问 H618 PI bank;不得回退或依赖 `rpi-rgb-led-matrix`。 + - ADC:M5Stack Unit ADC v1.1 / ADS1110,`/dev/i2c-1`,地址 `0x48`。 + - 生产程序、持久数据和运行数据分别位于 `/opt/matrix-screen-controller`、`/var/lib/matrix-screen-controller`、`/run/matrix-screen-controller`。 +-- 当前设备和开发机凭据只保存在 `核桃派的用户名和密码和ip/用户名密码ip.txt`,不得复制到源码、日志、普通文档或回复。正式 IMG 的发布目录 `README.md` 是唯一例外:它必须写明该未修改镜像内故意公开的默认账户与 Wi-Fi 四项值,并警告用户修改;不得把当前设备或开发机凭据误写为镜像默认值。 ++- 当前设备和开发机凭据只保存在 `测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt`,不得复制到源码、日志、普通文档或回复。正式 IMG 的发布目录 `README.md` 是唯一例外:它必须写明该未修改镜像内故意公开的默认账户与 Wi-Fi 四项值,并警告用户修改;不得把当前设备或开发机凭据误写为镜像默认值。 + + ## 2. 需求与测试编号 + +@@ -20,7 +20,7 @@ + - `HW-*`:核桃派排针、HUB75、ADC、供电和扫描参数。 + - `TEST-*`:本机、板端无负载、实屏、ADC、重启、清理和独立性验收。 + +-新增或改变行为时,先更新 `整体开发需求/` 的稳定编号,再改 `核桃派软件源代码/`,最后同步 `如何测试/` 的映射和合格标准。 ++新增或改变行为时,先更新 `整体开发需求/` 的稳定编号,再改 `核桃派软件源代码/`,最后同步 `测试相关资料/如何测试/` 的映射和合格标准。 + + ## 3. 分层验证 + +@@ -48,8 +48,8 @@ + 3. 每个离线包保存 SHA-256 清单;板端安装使用 `--no-index --find-links` 或明确的本地路径。 + 4. 不关闭电脑 VPN,不把本机工具目录或盘符固化进项目文档和部署脚本。 + 5. SSH 后先在远端用 `command -v` 和版本命令检查工具,不能因电脑有工具就假设板端也有。 +-6. 普通功能开发不读取或重建大型镜像;只有用户明确要求导出镜像、维护离线材料或更新编辑器时才进入 `离线依赖/` 的发布流程。 +-7. 新增或删除板端 Python、Debian 或原生命令依赖时属于功能修改的一部分:必须同步 `离线依赖/其他依赖/` 的文件、SHA-256 和生命周期清单。停用依赖删除二进制并登记原因,不能等待下次镜像导出再补。 ++6. 普通功能开发不读取或重建大型镜像;只有用户明确要求导出镜像、维护离线材料或更新编辑器时才进入 `发布更新相关/` 的发布流程。 ++7. 新增或删除板端 Python、Debian 或原生命令依赖时属于功能修改的一部分:必须同步 `发布更新相关/其他依赖/` 的文件、SHA-256 和生命周期清单。停用依赖删除二进制并登记原因,不能等待下次镜像导出再补。 + 8. OTA 和可刷镜像共用 `核桃派软件源代码/VERSION` 与根目录 `发布记录.json`;只有明确导出成功才推进版本,失败不得占号或留下半成品目录。 + 9. 可部署的功能新增或优化必须同步更新 `核桃派软件源代码/FEATURE_UPDATED_AT`,格式固定为精确到分钟的北京时间 `YYYY-MM-DDTHH:MM+08:00`。单纯 OTA/镜像导出、重复部署和文档修改不得改写该时间。 + +@@ -79,4 +79,4 @@ + - 临时部署、构建目录、wheelhouse 和临时 systemd unit 在验证后按明确绝对路径清理。 + - 最终把本目录复制到隔离临时位置,从文档命令重新构建和运行测试;任何引用目录外文件的路径都视为失败。 + - 一次性结果写在交付或 `各种归档/<时间戳>_用途/README.md`,不写进可重复测试流程。 +-- `离线依赖/导出包/<版本>/` 是可随时删除的大型发布产物,源码、文档命令和后续构建不得引用其中任何文件。 ++- `发布更新相关/导出包/<版本>/` 是可随时删除的大型发布产物,源码、文档命令和后续构建不得引用其中任何文件。 +--- a/发布更新相关/README.md ++++ b/发布更新相关/README.md +@@ -1,4 +1,4 @@ +-# 离线依赖与发布产物 ++# 发布更新材料与产物 + + 本目录用于没有境外网络的核桃派从官方系统镜像离线安装当前软件,并保存按用户明确指令导出的可刷镜像。 + +@@ -7,6 +7,7 @@ + | `核桃派镜像/` | 未修改的官方 RAR、IMG 和摘要 | 稳定基线,不得原地修改 | + | `导出包/<版本>/` | Rufus 可写入的版本化 IMG | 可随时删除,项目不得引用 | + | `其他依赖/` | AArch64 wheel、Debian 包、预编译内核、固定内核源码和依赖生命周期 | 随软件依赖同步维护 | ++| `OTA数据包/<版本>/` | 版本化 OTA 更新包 | 可删除的历史发布产物,不作为后续构建输入 | + | `镜像编辑器/` | Windows 编辑器源码和绿色程序 | 编辑器使用独立版本并覆盖旧程序 | + + 普通开发不需要读取大型镜像或导出目录。只有用户明确要求导出镜像、维护离线依赖或更新编辑器时才进入本流程;但新增或删除软件运行依赖时,必须在同一次功能修改中同步 `其他依赖/`。 +--- a/发布更新相关/镜像编辑器/编辑器源代码/README.md ++++ b/发布更新相关/镜像编辑器/编辑器源代码/README.md +@@ -1,6 +1,8 @@ + # 构建镜像配置编辑器 2.0.0 + + 源码使用 Python 3 和 PySide6,不依赖本项目其他目录。支持 Python 3.10–3.14,构建依赖已在 `requirements-build.txt` 锁定。 ++ ++以下命令均在本文件所在的 `发布更新相关/镜像编辑器/编辑器源代码/` 目录执行。 + + ## Windows 10/11 x64 + +--- a/发布记录.json ++++ b/发布记录.json +@@ -6,7 +6,7 @@ + "artifact_type": "ota", + "created_at": "2026-08-13T15:31:23+08:00", + "notes": "新增浏览器全量 OTA。", +- "artifact_path": "OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", ++ "artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", + "artifact_sha256": "1ae8544e2072a014dc76e07a930375c80e85b5331957716a456c8123dd204325" + }, + { +@@ -14,7 +14,7 @@ + "artifact_type": "image", + "created_at": "2026-08-19T15:42:35+08:00", + "notes": "建立可刷镜像、离线首次启动和 Windows 镜像编辑能力;修复并真实验证 SSH 默认启用、配置账户密码登录、需同密码完整 sudo 权限、root 登录禁用、sshd 易失运行目录、首启 unit 超时与假成功,以及基础镜像 rootpw、旧 pi 免密规则和主机名解析延迟。", +- "artifact_path": "离线依赖/导出包/1.0.2/matrix-screen-controller-1.0.2.img", ++ "artifact_path": "发布更新相关/导出包/1.0.2/matrix-screen-controller-1.0.2.img", + "artifact_sha256": "0397b2abd974cc293a472789027b02bf469434d358578fd43aa9fdb218944aeb" + }, + { +@@ -22,7 +22,7 @@ + "artifact_type": "image", + "created_at": "2026-08-25T08:17:48+00:00", + "notes": "修复 1.0.3 首次启动 FAT 空间不足:应用离线载荷迁入 rootfs,增加候选内核 boot 文件预算与 32 MiB 安全余量双重校验,并修复导出入口误加载 Pillow/FontTools。", +- "artifact_path": "离线依赖/导出包/1.0.3/matrix-screen-controller-1.0.3.img", ++ "artifact_path": "发布更新相关/导出包/1.0.3/matrix-screen-controller-1.0.3.img", + "artifact_sha256": "a5676576fb63a913ed9e40a02ce206e5ef71f47b1005a98a8758479b51413e0b" + }, + { +@@ -30,7 +30,7 @@ + "artifact_type": "ota", + "created_at": "2026-09-04T13:14:05+08:00", + "notes": "发布当前工作区已完成并通过本机全套测试的软件版本。", +- "artifact_path": "OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota", ++ "artifact_path": "发布更新相关/OTA数据包/1.0.4/matrix-screen-controller-1.0.4.ota", + "artifact_sha256": "ed15498e2d66758343c874d00176e2525e67a69fea5cc8f4578f139dd2b0f766" + }, + { +@@ -38,7 +38,7 @@ + "artifact_type": "ota", + "created_at": "2026-09-06T21:10:58+08:00", + "notes": "修复 OTA 板端测试目录隔离,并新增可查看、复制的详细失败日志。", +- "artifact_path": "OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota", ++ "artifact_path": "发布更新相关/OTA数据包/1.0.5/matrix-screen-controller-1.0.5.ota", + "artifact_sha256": "ec245d2ba2eefdf415f6745aefb1dfc66631e027d6820a2b6bd04bd6306d4152" + }, + { +@@ -46,7 +46,7 @@ + "artifact_type": "ota", + "created_at": "2026-09-06T21:39:45+08:00", + "notes": "诊断引导包:经设备持有者授权,仅在 1.0.3 旧 worker 中跳过 pytest,以先安装可查看、复制的 OTA 失败日志;后续更新恢复严格测试。", +- "artifact_path": "OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota", ++ "artifact_path": "发布更新相关/OTA数据包/1.0.6/matrix-screen-controller-1.0.6.ota", + "artifact_sha256": "8e804dcfb1f2f9194f3c57b88b5d4b7308509270fba536059e528ab21fdfa0b9" + }, + { +@@ -54,7 +54,7 @@ + "artifact_type": "ota", + "created_at": "2026-09-08T10:19:47+08:00", + "notes": "修复 OTA 显式停止旧服务时运行目录被删除导致升级和回滚中断;增加停服前事务保护、日志降级及真实 systemd 回归。已在测试设备完成 1.0.6 → 1.1.0,用户数据和 frp 状态保持。", +- "artifact_path": "OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota", ++ "artifact_path": "发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota", + "artifact_sha256": "7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8", + "package_kind": "software-install", + "required_checkpoint": "1.0.0", +@@ -85,7 +85,7 @@ + "artifact_type": "image", + "created_at": "2026-09-08T07:24:53+00:00", + "notes": "完整可刷镜像 1.1.1:默认账户与 Wi-Fi,可直接首启使用", +- "artifact_path": "离线依赖/导出包/1.1.1/matrix-screen-controller-1.1.1.img", ++ "artifact_path": "发布更新相关/导出包/1.1.1/matrix-screen-controller-1.1.1.img", + "artifact_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d", + "validation_path": "各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json", + "validated_at": "2026-09-08T09:09:53+00:00" +--- a/各种归档/20260819_离线镜像1.0.2真实TF卡验收/README.md ++++ b/各种归档/20260819_离线镜像1.0.2真实TF卡验收/README.md +@@ -3,7 +3,7 @@ + ## 失效说明 + + - 本记录对应 SHA-256 `3a28d9b5200ed22824a628a46ce8d36851cc8366bbf3b8cbd22a2b9a7c4b9128` 的旧 IMG。该镜像最终没有监听 SSH 22 端口,缺少正式导出包的必要功能,因此不得再作为正式包使用。 +-- 旧 IMG 已删除,并由同版本的新 IMG 原子替换;新产物摘要以根目录 `发布记录.json` 和 `离线依赖/导出包/1.0.2/manifest.json` 为准。 ++- 旧 IMG 已删除,并由同版本的新 IMG 原子替换;新产物摘要以根目录 `发布记录.json` 和 `发布更新相关/导出包/1.0.2/manifest.json` 为准。 + - 以下内容只保留当时失败定位和已完成硬件检查的历史证据,不代表新产物已通过真实 TF 卡首启、SSH 或重启验收。新产物须在用户完成镜像编辑、写卡和上电后另行验证。 + + ## 旧产物当时的结论 +@@ -42,4 +42,4 @@ + - 镜像构建前必须以 `BASE_IMAGE_PACKAGES.tsv` 验证 `ffmpeg`、`libheif-examples`、`python3.11-venv` 的完整递归闭包;缺根包、传递包、基准清单或摘要时拒绝导出。 + - Debian 安装失败保留无配置凭据的 `MSCPKG.TXT`/`MSCPKGS.TSV`;部署失败保留无配置凭据的 `MSCDEPLOY.TXT`;成功时删除这些诊断文件。 + - 首启 oneshot 不得同步启动受其 `Before=` 排序约束的控制服务。 +-- 本归档是一次性验收记录,不得作为后续构建输入;后续构建不得引用 `离线依赖/导出包/<版本>/`。 ++- 本归档是一次性验收记录,不得作为后续构建输入;后续构建不得引用 `发布更新相关/导出包/<版本>/`。 +--- a/各种归档/20260825_离线镜像1.0.3启动分区空间修复/README.md ++++ b/各种归档/20260825_离线镜像1.0.3启动分区空间修复/README.md +@@ -49,4 +49,4 @@ + - 正式构建输出 `rootfs bootstrap installed` 与 `rootfs bootstrap verified`;提交后又从 IMG 复制应用 bundle 为独立文件,再次运行只读 rootfs/FAT 验证并通过。 + - 新 manifest、侧车摘要、实际整镜像摘要和唯一发布记录四者一致;发布锁、`.building` 和 `.invalid-backup` 均不得残留。 + +-本记录是一次性故障和发布证据,不得作为后续构建输入;后续构建不得引用 `离线依赖/导出包/1.0.3` 中的任何文件。 ++本记录是一次性故障和发布证据,不得作为后续构建输入;后续构建不得引用 `发布更新相关/导出包/1.0.3` 中的任何文件。 +--- a/各种归档/20260907_OTA1.1.0软件安装节点验证/README.md ++++ b/各种归档/20260907_OTA1.1.0软件安装节点验证/README.md +@@ -2,7 +2,7 @@ + + ## 交付 + +-- 正式包:`OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota` ++- 正式包:`发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota` + - 字节数:27993930(约 26.7 MiB)。 + - SHA-256:`379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5`。 + - 功能时间:`2026-09-07T22:00+08:00`。导出成功后 VERSION 推进到 1.1.0,发布记录追加一次。 +--- a/各种归档/20260908_OTA1.1.0运行目录修复验收/README.md ++++ b/各种归档/20260908_OTA1.1.0运行目录修复验收/README.md +@@ -2,7 +2,7 @@ + + 2026-09-08 已在测试设备实际完成 1.0.6 → 1.1.0。磁盘 VERSION、运行 API 均为 1.1.0,OTA worker Result=success,用户数据 421 个非 OTA 文件摘要完全一致,frpc 二进制摘要保持,服务仍 inactive / disabled。组件完整性校验通过,恢复事务、临时保护、恢复 unit/helper、上传包与工作目录均已清理。 + +-正式产物为 `OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota`,27998979 字节,SHA-256 为 `7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8`。发布时直接复制实机验收候选,逐字节确认一致,未重新构建。VERSION 保持 1.1.0,FEATURE_UPDATED_AT 为 `2026-09-08T10:19+08:00`。旧包摘要 `379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5`,完整旧产物和旧发布记录归档位置见根发布记录的 repairs 字段。 ++正式产物为 `发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota`,27998979 字节,SHA-256 为 `7f33a417181633e1f4c77f48a3030d33f6c6e9578002e22f3b0487aff535eef8`。发布时直接复制实机验收候选,逐字节确认一致,未重新构建。VERSION 保持 1.1.0,FEATURE_UPDATED_AT 为 `2026-09-08T10:19+08:00`。旧包摘要 `379f80a30157e23d717157005ea246fa822529010c0f0295dcad5db3ceff42c5`,完整旧产物和旧发布记录归档位置见根发布记录的 repairs 字段。 + + ## 故障与修复 + +--- a/整体开发需求/01_网页配置端需求.md ++++ b/整体开发需求/01_网页配置端需求.md +@@ -6,7 +6,7 @@ + + ## 0. 需求编号索引 + +-本文件使用 `WEB-*`、`CONFIG-*` 和 `DEPLOY-*` 编号描述网页端、接口、配置和运行入口。测试覆盖关系维护在 `如何测试/核桃派点阵屏控制服务测试流程.md`,不要在本文件复制完整测试命令。 ++本文件使用 `WEB-*`、`CONFIG-*` 和 `DEPLOY-*` 编号描述网页端、接口、配置和运行入口。测试覆盖关系维护在 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`,不要在本文件复制完整测试命令。 + + | 编号 | 范围 | 当前阶段要求 | + |---|---|---| +@@ -34,6 +34,7 @@ + | `DEPLOY-IMAGE-EXPORT` | Rufus 可刷镜像 | 从未修改官方 IMG 复制生成;FAT16 只携带双槽配置、元数据和首启程序,Linux 根分区携带应用与已验证预编译内核离线载荷,元数据格式固定为 v3。 | + | `DEPLOY-IMAGE-FIRSTBOOT` | 无外设自动首启 | 只连接核桃派本体即可离线安装应用与双内核候选、配置身份和网络、清理秘密并自动重启;SSID、认证、DHCP 或默认路由不可用不阻断安装,真实安装失败恢复原 boot 文件且不循环重启。 | + | `DEPLOY-OFFLINE-DEPS` | 离线依赖生命周期 | Python wheel、Debian AArch64 包、预编译内核及固定内核源码均有摘要与用途;代码依赖增删必须同次同步。 | ++| `DEPLOY-WORKSPACE-LAYOUT` | 工作区路径与独立性 | 适配分类目录,支持中文、空格及不同调用目录;清理不越界,包内路径与设备路径保持兼容。 | + | `DEPLOY-FRP` | FRP 系统组件 | 固定、校验并离线交付官方 Linux ARM64 `frpc`,安装到 `/usr/local/bin`,由维护账户运行且纳入首装、手工更新和 OTA 回滚。 | + | `DEPLOY-IMAGE-EDITOR` | 便携镜像编辑器 | Python/PySide6 跨平台源码读取、修改或另存账户、WiFi 和 IPv4;Windows 交付绿色单 EXE,编辑器版本独立于软件版本。 | + | `DEPLOY-FONTS` | 多语言字体运行依赖 | 部署环境提供 Fontconfig、Noto Core 和 Noto CJK,服务启动后能解析主流现代文字体系。 | +@@ -288,12 +289,12 @@ + - OTA 与 SSH 手工更新运行 Python 门槛时必须把应用数据、运行数据、pytest `tmp_path` 和通用临时文件全部限制在本次事务目录,显式设置 `MATRIX_TEST_ROOT`、`TMPDIR` 与 pytest `--basetemp`,并禁用 pytest cache;不得依赖或污染系统默认 `/tmp`。新源码的 conftest 还必须兼容旧更新器只传入 `MATRIX_DATA_DIR`、`MATRIX_RUNTIME_DIR` 和 `MATRIX_SOURCE_ONLY_UPDATE_TESTS=1` 的调用方式。除发布记录中已经固化且不得覆盖的 1.0.6 一次性诊断引导包外,所有当前源码和后续 OTA 都必须实际执行完整 pytest,任一失败继续阻止切换并触发原子回滚;不得保留诊断标记、成功早退或其他测试绕过入口。 + - OTA worker 必须把包校验、离线依赖、venv、原生编译、pytest、专用主机检查、迁移、切换、健康检查和回滚的阶段时间、安全主机摘要、命令、退出码及 stdout/stderr 写入单个运行期日志。日志不得读取或记录账户密码、IP、SSID、配置内容、用户资源内容或完整环境变量。失败时以耐久原子写入只保留最近一份、最多 `1 MiB` 的日志,超限时保留头部和最新尾部并标明截断;下一次成功更新删除旧失败日志。 + - `GET /api/ota/status` 返回最近失败日志是否可用及字节数;`GET /api/ota/failure-log` 只在最近结果为失败且日志有效时以无缓存 UTF-8 纯文本返回,否则 `404`。设置页检测到带日志的失败后自动打开“OTA 更新失败日志”弹窗,以纯文本等宽滚动区显示,支持 Clipboard API 和非安全局域网 HTTP 下的 textarea 回退复制;关闭后仍保留查看和复制入口,日志读取失败时继续显示原简短错误且不得打开空弹窗。 +-- 活动状态原子写入 `/run/matrix-screen-controller/ota-status.json`;持久根 `ota/state.json` 只保留 schema v1 的最近结果和未完成事务恢复信息。不得积累历史包或索引工作区 `OTA数据包/`。 ++- 活动状态原子写入 `/run/matrix-screen-controller/ota-status.json`;持久根 `ota/state.json` 只保留 schema v1 的最近结果和未完成事务恢复信息。不得积累历史包或索引工作区 `发布更新相关/OTA数据包/`。 + - 新配置字段必须通过逐级迁移增加预设值。删除功能时,迁移只能删除已登记归属该功能的字段或路径;其余配置、字体、模板、动图和未知文件完整复制。成功后删除旧 release、上传包、数据备份和 staging;失败或中途重启按事务记录恢复旧程序、旧 unit 与旧数据。 + + ### 1.3.3 可刷镜像和离线材料(`DEPLOY-IMAGE-EXPORT`、`DEPLOY-IMAGE-FIRSTBOOT`、`DEPLOY-OFFLINE-DEPS`、`DEPLOY-IMAGE-EDITOR`) + +-- `离线依赖/核桃派镜像` 只保存未修改官方镜像。导出器复制后向 FAT16 启动分区只写入摘要元数据、一次性 root 首启程序和固定大小双槽配置区;应用源码、AArch64 wheel 与 Debian 包组成的 `MSCBOOT.TGZ` 由 Linux bootstrap 写入复制镜像根分区 `/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ`,预编译内核写入相邻的 `/opt/matrix-image-bootstrap/axp313a`。固定内核源码只留在电脑端,禁止进入 IMG 或 OTA。 ++- `发布更新相关/核桃派镜像` 只保存未修改官方镜像。导出器复制后向 FAT16 启动分区只写入摘要元数据、一次性 root 首启程序和固定大小双槽配置区;应用源码、AArch64 wheel 与 Debian 包组成的 `MSCBOOT.TGZ` 由 Linux bootstrap 写入复制镜像根分区 `/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ`,预编译内核写入相邻的 `/opt/matrix-image-bootstrap/axp313a`。固定内核源码只留在电脑端,禁止进入 IMG 或 OTA。 + - 镜像元数据格式固定为 v3,记录应用载荷位置、压缩字节数和 SHA-256,以及候选内核 release、压缩/展开字节数、SHA-256、FAT 安装预算和安全余量。IMG 导出必须同时验证预编译载荷及固定源码归档;根分区注入前至少保留“应用压缩载荷 + 内核压缩载荷 + 256 MiB”,注入后重新只读挂载并逐字节核对 FAT v3 元数据、根分区两类载荷和离线依赖元数据。FAT 必须明确不存在 `MSCBOOT.TGZ`。旧 v1/v2 镜像不得原地刷新或迁移,必须从官方基线重建。 + - 构建器必须在最终 FAT 布局上按簇取整计算首次安装新增的候选 Image、双份 DTB、System.map、kernel config、受管启动脚本、原始启动脚本回滚副本、临时文件和状态文件,并在这些实际预算之外保留固定 `32 MiB` 安全余量;不满足时在发布前拒绝。内核安装器在写入第一个候选 boot 文件前使用同一算法再次检查当前 `/boot`,不足时保留 rootfs 离线载荷并失败,禁止依靠清除应用载荷换取不可恢复的空间。 + - 首启不依赖显示器、键盘、HUB75、ADC 或当前网络在线。它校验并离线安装载荷、编译真实驱动、配置账户和 NetworkManager、生成唯一 machine-id 与 SSH 主机密钥、部署服务,最后安装预编译双内核候选并自动重启一次。镜像配置仍必须包含有效的 WiFi 与 IPv4 字段;首启只写入启用自动连接的受管配置并重启 NetworkManager,不等待 SSID、认证、DHCP 或默认路由,以上任一未就绪都继续离线安装,安装完成后由正常运行服务继续连接并按既有逻辑显示断网提示。NetworkManager 未启动、配置写入失败以及载荷、硬件、权限、SSH 或内核错误仍必须令首启失败。安装内核前根分区至少保留“两倍展开字节数 + 256 MiB”,解包只进入明确的 `/var/tmp/matrix-axp313a-image-install`;成功后删除压缩载荷和展开目录,失败由安装器恢复原 boot 文件、清除部分候选并保留无秘密状态,不联网补包或循环重启。候选启动继续使用一次性健康标记,内核、AXP313A、cpufreq、应用或 GPIO 健康失败时下一次自动回原内核。首启完成时若受管连接或默认路由尚未就绪,FAT 状态仍写成功,但必须明确说明 WiFi 尚未连接且运行系统会继续处理,不得包含 SSID、密码、用户名或地址。 +@@ -302,8 +303,14 @@ + - 配置 schema v1 固定包含产品、软件版本、账户、WiFi 和 DHCP/静态 IPv4。双槽分别带单调代数、长度和 SHA-256;读取选择最高有效代,保存先完整写入非活动槽并刷新,写入中断必须仍能读取旧槽。 + - 编辑器使用不依赖项目外部目录的 Python/PySide6 跨平台源码;Windows 10/11 x64 必须交付内置 Python、Qt 和运行库的唯一绿色单 EXE,其他电脑不得要求安装 Python、.NET、Qt 或补充 DLL。同一源码允许用户在 macOS 本地构建,Windows 发布不得宣称已交付或验收 macOS 产物。编辑器显示镜像版本和当前配置,密码默认遮挡;“修改原镜像”必须二次确认,“另存为”必须使用不同且尚不存在的宿主系统合法 `.img` 文件名,并提示按设备或地点命名。中文、空格和长路径必须受支持;保留名、错误扩展名、同路径、既有目标或无法创建同名摘要时必须在复制或修改前拒绝。未知产品、版本、损坏槽或摘要失败时禁止保存;每次保存以无 BOM UTF-8 生成规范的 `<64 位小写 SHA-256><两个空格><完整文件名>` 侧车文件,按固定字段边界解析并严格复核文件名和镜像内容。摘要必须通过同目录临时文件原子替换,另存失败不得留下 IMG、摘要或临时摘要。 + - 编辑器必须声明 Per-Monitor V2 DPI 感知;主窗口、输入区域以及应用内错误、警告、确认和成功提示均按当前显示器 DPI 与工作区自适应缩放,分辨率或显示器变化后保持完整可达。密码输入框与同列普通输入框等宽;低分辨率时允许纵向滚动但不得裁切字段、正文或操作按钮。Windows 原生打开和保存选择器继续使用系统界面。 +-- `离线依赖/其他依赖` 是当前软件所需的唯一离线材料清单。新增依赖必须同时提供文件、架构、来源、用途和摘要;停用时删除二进制并记录停用版本与原因。AXP313A 运行载荷必须来自已经通过真实 GPIO 验收的 `6.1.31-matrix-axp313a1`,使用确定性 `tar.gz`,拒绝路径穿越、符号链接、特殊文件、错误 release/commit 和未登记文件;模块的 `build`、`source` 链接不进入载荷,由目标 `depmod` 重建索引。固定源码归档必须与 vendor commit 和补丁摘要同时可离线校验。Debian 包必须相对于登记的官方基础镜像包清单形成可递归解析的完整闭包,镜像构建前自动验证,缺少直接依赖、传递依赖、根包或基准清单时必须拒绝导出。首启 Debian 安装失败时,FAT 状态文件指向不含配置凭据的包诊断与基准包清单,失败后不得继续网络和部署阶段。 +-- `离线依赖/导出包/<版本>` 只保存可删除产物,任何源码、测试或后续导出不得引用其中内容。 ++- `发布更新相关/其他依赖` 是当前软件所需的唯一离线材料清单。新增依赖必须同时提供文件、架构、来源、用途和摘要;停用时删除二进制并记录停用版本与原因。AXP313A 运行载荷必须来自已经通过真实 GPIO 验收的 `6.1.31-matrix-axp313a1`,使用确定性 `tar.gz`,拒绝路径穿越、符号链接、特殊文件、错误 release/commit 和未登记文件;模块的 `build`、`source` 链接不进入载荷,由目标 `depmod` 重建索引。固定源码归档必须与 vendor commit 和补丁摘要同时可离线校验。Debian 包必须相对于登记的官方基础镜像包清单形成可递归解析的完整闭包,镜像构建前自动验证,缺少直接依赖、传递依赖、根包或基准清单时必须拒绝导出。首启 Debian 安装失败时,FAT 状态文件指向不含配置凭据的包诊断与基准包清单,失败后不得继续网络和部署阶段。 ++- `发布更新相关/导出包/<版本>` 只保存可删除产物,任何源码、测试或后续导出不得引用其中内容。 ++ ++### 工作区路径约定(`DEPLOY-WORKSPACE-LAYOUT`) ++ ++- 测试入口位于 `测试相关资料/如何测试/本机测试环境/`,发布工具从 `发布更新相关/` 定位离线材料和产物;源码仍位于根目录 `核桃派软件源代码/`。路径须由脚本位置或显式参数解析,支持中文、空格及不同调用目录。 ++- 工作区搬迁不改变设备安装路径、镜像内部载荷目录或历史包协议;已有发布记录仅同步工作区产物路径,不改版本、摘要及历史验收结果。 ++- 本机测试与清理必须在隔离工作区验证:能够定位本地源码,保留 `.venv` 和人工持久数据,并拒绝清理越界路径。覆盖见 `TEST-WORKSPACE-LAYOUT`。 + + ## 2. 页面功能 + +@@ -1209,8 +1216,7 @@ + + ## 9. 参考资料 + +-- 本地资料:`点阵屏幕相关资料/RGB-Matrix-P3-64x64-F_核桃派ZeroW控制说明.md` +-- 本地资料:`点阵屏幕相关资料/点阵屏幕_GPIO连接简表_开发用.md` ++- 本地接线资料:`硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md` + - 核桃派官方 `gpioc`(MIT):https://github.com/walnutpi/gpioc + - FastAPI WebSockets:https://fastapi.tiangolo.com/advanced/websockets/ + - FastAPI StaticFiles:https://fastapi.tiangolo.com/tutorial/static-files/ +--- a/整体开发需求/02_屏幕底层控制接口需求.md ++++ b/整体开发需求/02_屏幕底层控制接口需求.md +@@ -16,7 +16,7 @@ + + ## 0. 需求编号索引 + +-本文件使用 `DISPLAY-*`、`CONFIG-*` 和 `HW-*` 编号描述显示边界、驱动、配置和硬件契约。网页/API 入口见 `01_网页配置端需求.md`,测试覆盖关系见 `如何测试/核桃派点阵屏控制服务测试流程.md`。 ++本文件使用 `DISPLAY-*`、`CONFIG-*` 和 `HW-*` 编号描述显示边界、驱动、配置和硬件契约。网页/API 入口见 `01_网页配置端需求.md`,测试覆盖关系见 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。 + + | 编号 | 范围 | 当前阶段要求 | + |---|---|---| +@@ -604,7 +604,7 @@ + - FastAPI 进程内的独立电压监测组件负责持续读取和缓存,并在锁外把保护指令交给显示输出仲裁器;`DisplayService` 不直接访问 ADC。ADC 故障不得终止显示或驱动生命周期,取得过成功样本后须保持最后保护而不是误解除。 + - 每台设备通过 `CONFIG-SCREEN-VOLTAGE` 独立保存一次比例校准;校准只补偿当前 ADC 与测量点的比例误差,不能替代接线、共地和型号检查。 + - 当前实现 `DISPLAY-LOW-VOLTAGE-PROTECTION`:校准后电压在 `4.5..4.8V` 线性限亮,严格低于 `4.5V` 覆盖 35% 低电图标,严格高于 `4.8V` 经恢复确认撤销。它不实现过压保护、物理关断、BMS 或核桃派关机。 +-- 完整接线、ADS1110 协议、换算公式和官方资料见 `M5Stack Unit ADC v1.1相关内容/`;真实硬件验证映射到 `TEST-ADC-HARDWARE`。 ++- 完整接线、ADS1110 协议、换算公式和官方资料见 `硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/`;真实硬件验证映射到 `TEST-ADC-HARDWARE`。 + + ## 11. 后续实现验收清单 + +@@ -641,10 +641,9 @@ + + ## 13. 参考资料 + +-- 本地资料:`点阵屏幕相关资料/RGB-Matrix-P3-64x64-F_核桃派ZeroW控制说明.md` +-- 本地资料:`点阵屏幕相关资料/点阵屏幕_GPIO连接简表_开发用.md` +-- 本地资料:`M5Stack Unit ADC v1.1相关内容/README.md` +-- 本地资料:`M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md` ++- 本地接线资料:`硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md` ++- 本地资料:`硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/README.md` ++- 本地资料:`硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md` + - 核桃派官方 `gpioc`(MIT):https://github.com/walnutpi/gpioc + - 核桃派 ZeroW 参数:https://wiki.walnutpi.com/docs/walnutpi_1/intro/hw-parameter/ + - H616/H618 PIO 寄存器依据:板端 device tree `allwinner,sun50i-h616-pinctrl` 与项目内上游来源记录 +--- a/核桃派软件源代码/README.md ++++ b/核桃派软件源代码/README.md +@@ -3,6 +3,8 @@ + 本目录是核桃派 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` +@@ -18,11 +20,11 @@ + MATRIX_DRIVER=mock .venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8080 + ``` + +-核桃派不具备境外网络条件。生产安装必须使用项目根目录 `离线依赖/其他依赖/aarch64-py311/` 中已校验的 wheel,并强制 `--no-index`: ++核桃派不具备境外网络条件。生产安装必须使用项目根目录 `发布更新相关/其他依赖/aarch64-py311/` 中已校验的 wheel,并强制 `--no-index`: + + ```bash + python3 -m venv .venv +-.venv/bin/pip install --no-index --find-links ../离线依赖/其他依赖/aarch64-py311 -r requirements.txt ++.venv/bin/pip install --no-index --find-links ../发布更新相关/其他依赖/aarch64-py311 -r requirements.txt + make -C app/display/native clean all + ``` + +@@ -51,7 +53,7 @@ + sudo app/display/native/hub75_benchmark --duration 60 --refresh-rate 100 --brightness 40 --dev-mem + ``` + +-接屏门槛与全部人工停顿顺序见项目上层 `如何测试/核桃派点阵屏控制服务测试流程.md`。 ++接屏门槛与全部人工停顿顺序见项目上层 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。 + + ## 闪烁修复状态 + +@@ -88,7 +90,7 @@ + python ".\核桃派软件源代码\scripts\export_release.py" ota --notes "本次更新说明" + ``` + +-OTA 输出为 `OTA数据包/<版本>/`;镜像输出为 `离线依赖/导出包/<版本>/`。已有版本目录不会覆盖;删除任一旧版本目录不影响项目或设备,后续构建不得引用历史产物。 ++OTA 输出为 `发布更新相关/OTA数据包/<版本>/`;镜像输出为 `发布更新相关/导出包/<版本>/`。已有版本目录不会覆盖;删除任一旧版本目录不影响项目或设备,后续构建不得引用历史产物。 + + ## 可刷镜像导出与同版本修复 + +--- a/核桃派软件源代码/kernel/README.md ++++ b/核桃派软件源代码/kernel/README.md +@@ -14,11 +14,11 @@ + `walnutpi-linux-6.1.31-30ff3fd5.tar.gz`; its measured SHA-256 is + `8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f`. + The fixed archive is registered at +-`离线依赖/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/` so the kernel can be ++`发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/` so the kernel can be + rebuilt without network access. It is a build input and is never copied into an IMG or OTA. + + The exact production files accepted by the browser and real HUB75 GPIO tests are registered at +-`离线依赖/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/`. Future IMG exports install this ++`发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/`. Future IMG exports install this + precompiled payload and do not rebuild the kernel unless patches, DTS, or kernel configuration + change. + +--- a/核桃派软件源代码/scripts/build_ota_package.py ++++ b/核桃派软件源代码/scripts/build_ota_package.py +@@ -25,15 +25,15 @@ + args = parser.parse_args() + source_root = Path(__file__).resolve().parents[1] + project_root = source_root.parent +- wheelhouse = project_root / "离线依赖" / "其他依赖" / "aarch64-py311" +- frpc_bundle = project_root / "离线依赖" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64" ++ wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311" ++ frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64" + version = read_software_version(source_root) + policy = read_policy(source_root) + if policy["checkpoints"] and version < type(version).parse(policy["checkpoints"][-1]["version"]): + parser.error("源码包含未发布的软件依赖;请使用 export_release.py ota --version 导出安装节点") + if version.patch != 0: + frpc_bundle = None +- output_root = (args.output_root or project_root / "OTA数据包").resolve() ++ output_root = (args.output_root or project_root / "发布更新相关" / "OTA数据包").resolve() + version_dir = output_root / str(version) + if version_dir.exists(): + parser.error(f"version output already exists and will not be overwritten: {version_dir}") +--- a/核桃派软件源代码/scripts/deploy_walnutpi.sh ++++ b/核桃派软件源代码/scripts/deploy_walnutpi.sh +@@ -8,8 +8,8 @@ + + SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd) +-WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/离线依赖/其他依赖/aarch64-py311} +-FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/离线依赖/其他依赖/frp/0.71.0/linux-arm64} ++WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311} ++FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64} + DEFER_SERVICE_START=${DEFER_SERVICE_START:-0} + MANIFEST=$WHEELHOUSE/SHA256SUMS + TARGET=/opt/matrix-screen-controller +--- a/核桃派软件源代码/scripts/export_release.py ++++ b/核桃派软件源代码/scripts/export_release.py +@@ -87,8 +87,8 @@ + "artifact_type": "ota", + "created_at": "2026-08-13T15:31:23+08:00", + "notes": "新增浏览器全量 OTA。", +- "artifact_path": "OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", +- "artifact_sha256": sha256_file(path.parent / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"), ++ "artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", ++ "artifact_sha256": sha256_file(path.parent / "发布更新相关" / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"), + } + ], + } +@@ -146,7 +146,7 @@ + if len(matches) != 1: + raise ValueError("current version must have exactly one release record before repair") + index, record = matches[0] +- expected_path = f"离线依赖/导出包/{version}/{artifact_name}" ++ expected_path = f"发布更新相关/导出包/{version}/{artifact_name}" + if ( + record.get("artifact_type") != "image" + or record.get("artifact_path") != expected_path +@@ -246,10 +246,10 @@ + target = current if args.repair_current else (args.version or next_patch(current)) + dependency_bundle = None + if not args.repair_current and args.kind == "ota": +- dependency_bundle = export_bundle(source, project / "离线依赖" / "其他依赖", current, target) ++ dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target) + if args.kind == "image" and not args.repair_current and target <= current: + parser.error("image candidate version must be newer than the current version") +- output_root = project / "OTA数据包" if args.kind == "ota" else project / "离线依赖" / "导出包" ++ output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包" + official_directory = output_root / str(target) + final_directory = args.candidate_output.resolve() if args.candidate_output is not None else official_directory + if args.candidate_output is not None: +@@ -299,7 +299,7 @@ + artifact = temporary_directory / f"matrix-screen-controller-{target}.ota" + info = build_package( + staged_source, +- project / "离线依赖" / "其他依赖" / "aarch64-py311", ++ project / "发布更新相关" / "其他依赖" / "aarch64-py311", + artifact, + version=target, + release_notes=args.notes, +@@ -334,29 +334,29 @@ + bootstrap_bundle = Path(staging_text) / "MSCBOOT.TGZ" + kernel_dependency = ( + project +- / "离线依赖" ++ / "发布更新相关" + / "其他依赖" + / "aarch64-kernel" + / "6.1.31-matrix-axp313a1" + ) + kernel_source = ( + project +- / "离线依赖" ++ / "发布更新相关" + / "其他依赖" + / "kernel-source" + / "walnutpi-linux-6.1.31-30ff3fd5" + ) + manifest = build_image( +- project / "离线依赖" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img", ++ project / "发布更新相关" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img", + staged_source, +- project / "离线依赖" / "其他依赖" / "aarch64-py311", +- project / "离线依赖" / "其他依赖" / "debian12-aarch64", ++ project / "发布更新相关" / "其他依赖" / "aarch64-py311", ++ project / "发布更新相关" / "其他依赖" / "debian12-aarch64", + kernel_dependency, + kernel_source, + config, + artifact, + bootstrap_bundle, +- project / "离线依赖" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64", ++ project / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64", + ) + subprocess.run( + [ +@@ -419,7 +419,7 @@ + "artifact_type": args.kind, + "created_at": manifest["created_at"], + "notes": args.notes.strip(), +- "artifact_path": f"{'OTA数据包' if args.kind == 'ota' else '离线依赖/导出包'}/{target}/{artifact.name}", ++ "artifact_path": f"{'发布更新相关/OTA数据包' if args.kind == 'ota' else '发布更新相关/导出包'}/{target}/{artifact.name}", + "artifact_sha256": manifest["image_sha256"] if args.kind == "image" else manifest["artifact_sha256"], + **(release_metadata(target) if args.kind == "ota" else {}), + **({"component_checkpoints": policy["checkpoints"]} if args.kind == "ota" else {}), +--- a/核桃派软件源代码/scripts/install_frpc_system.sh ++++ b/核桃派软件源代码/scripts/install_frpc_system.sh +@@ -12,7 +12,7 @@ + + SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd) +-BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/离线依赖/其他依赖/frp/0.71.0/linux-arm64} ++BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64} + UNIT_SOURCE=$SOURCE_ROOT/systemd/matrix-screen-frpc.service + UNIT=/etc/systemd/system/matrix-screen-frpc.service + DROPIN_DIR=/etc/systemd/system/matrix-screen-frpc.service.d +--- a/核桃派软件源代码/scripts/promote_image_release.py ++++ b/核桃派软件源代码/scripts/promote_image_release.py +@@ -106,7 +106,7 @@ + raise ValueError("image candidate README does not match its embedded defaults") + _validate_report(validation, digest, version) + +- dependency_root = project / "离线依赖" / "其他依赖" ++ dependency_root = project / "发布更新相关" / "其他依赖" + kernel = dependency_root / "aarch64-kernel" / "6.1.31-matrix-axp313a1" + with tempfile.TemporaryDirectory(prefix="matrix-image-promotion-") as temporary_text: + temporary = Path(temporary_text) +@@ -133,7 +133,7 @@ + history = _history(history_path) + if any(record.get("version") == str(version) for record in history["releases"]): + raise ValueError("validated image version is already registered") +- final = project / "离线依赖" / "导出包" / str(version) ++ final = project / "发布更新相关" / "导出包" / str(version) + if final.exists(): + raise ValueError("formal image release directory already exists") + stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") +@@ -149,7 +149,7 @@ + "artifact_type": "image", + "created_at": manifest["created_at"], + "notes": notes.strip(), +- "artifact_path": f"离线依赖/导出包/{version}/{artifact_name}", ++ "artifact_path": f"发布更新相关/导出包/{version}/{artifact_name}", + "artifact_sha256": digest, + "validation_path": f"{archive.relative_to(project).as_posix()}/实机验收.json", + "validated_at": stamp, +--- a/核桃派软件源代码/scripts/repair_ota_release.py ++++ b/核桃派软件源代码/scripts/repair_ota_release.py +@@ -23,7 +23,7 @@ + os.close(fd) + try: + version = read_software_version(source) +- current = project / 'OTA数据包' / str(version) ++ current = project / '发布更新相关' / 'OTA数据包' / str(version) + name = f'matrix-screen-controller-{version}.ota' + history_path = project / '发布记录.json' + history = _history(history_path) +--- a/核桃派软件源代码/scripts/update_walnutpi.sh ++++ b/核桃派软件源代码/scripts/update_walnutpi.sh +@@ -12,8 +12,8 @@ + + SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd) +-WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/离线依赖/其他依赖/aarch64-py311} +-FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/离线依赖/其他依赖/frp/0.71.0/linux-arm64} ++WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311} ++FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64} + MANIFEST=$WHEELHOUSE/SHA256SUMS + TARGET=/opt/matrix-screen-controller + DATA_ROOT=/var/lib/matrix-screen-controller +--- a/核桃派软件源代码/tests/test_kernel_artifact_dependency.py ++++ b/核桃派软件源代码/tests/test_kernel_artifact_dependency.py +@@ -96,8 +96,8 @@ + + + def test_registered_kernel_and_source_dependencies_are_complete(): +- kernel = PROJECT_ROOT / "离线依赖/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1" +- source = PROJECT_ROOT / "离线依赖/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5" ++ kernel = PROJECT_ROOT / "发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1" ++ source = PROJECT_ROOT / "发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5" + if os.environ.get("MATRIX_SOURCE_ONLY_UPDATE_TESTS") == "1": + if not kernel.exists() and not source.exists(): + pytest.skip("source-only update payload intentionally omits image build dependencies") +@@ -106,7 +106,7 @@ + assert info.regular_file_count == 3056 + assert info.unpacked_file_bytes == 160051604 + assert verify_source_dependency(source)["archive_bytes"] == 249095239 +- registry = json.loads((PROJECT_ROOT / "离线依赖/其他依赖/DEPENDENCIES.json").read_text(encoding="utf-8")) ++ registry = json.loads((PROJECT_ROOT / "发布更新相关/其他依赖/DEPENDENCIES.json").read_text(encoding="utf-8")) + active = {item["id"]: item for item in registry["active"]} + assert active["axp313a-kernel-runtime"]["path"] == "aarch64-kernel/6.1.31-matrix-axp313a1" + assert active["walnutpi-linux-kernel-source"]["path"] == ( +--- a/核桃派软件源代码/tests/test_ota_checkpoints.py ++++ b/核桃派软件源代码/tests/test_ota_checkpoints.py +@@ -187,7 +187,7 @@ + source.mkdir() + (source / 'VERSION').write_text('1.0.6\n', encoding='utf-8') + (source / 'FEATURE_UPDATED_AT').write_text('2026-09-07T20:00+08:00\n', encoding='utf-8') +- deps = tmp_path / '离线依赖/其他依赖' ++ deps = tmp_path / '发布更新相关/其他依赖' + binary = deps / 'frp/v/frpc' + binary.parent.mkdir(parents=True) + binary.write_bytes(b'frpc') +@@ -213,11 +213,13 @@ + assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0' + monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch']) + assert exporter.main() == 0 +- artifact = next((tmp_path/'OTA数据包/1.1.1').glob('*.ota')) ++ artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota')) + with zipfile.ZipFile(artifact) as z, z.open('payload.tar.gz') as f, tarfile.open(fileobj=f, mode='r|gz') as t: + assert not any('system-dependencies' in item.name for item in t) + records = json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8'))['releases'] + assert [r['package_kind'] for r in records] == ['software-install', 'application'] ++ assert all(r['artifact_path'].startswith('发布更新相关/OTA数据包/') for r in records) ++ assert all((tmp_path / r['artifact_path']).is_file() for r in records) + assert (source/'FEATURE_UPDATED_AT').read_text(encoding='utf-8') == '2026-09-07T20:00+08:00\n' + + +@@ -234,7 +236,7 @@ + exporter.main() + assert (source/'VERSION').read_bytes() == original_version + assert (tmp_path/'发布记录.json').read_bytes() == original +- assert not list((tmp_path/'OTA数据包').iterdir()) ++ assert not list((tmp_path/'发布更新相关/OTA数据包').iterdir()) + assert not (tmp_path/'.release-export.lock').exists() + + +@@ -268,13 +270,13 @@ + exporter, source = release_project(tmp_path, monkeypatch) + monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original']) + exporter.main() +- artifact=tmp_path/'OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota' ++ artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota' + old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes() + (source/'fix.py').write_text('FIXED = True\n',encoding='utf-8') + candidate=tmp_path/'candidate.ota' +- build_package(source,tmp_path/'离线依赖/其他依赖/aarch64-py311',candidate, ++ build_package(source,tmp_path/'发布更新相关/其他依赖/aarch64-py311',candidate, + version=V.parse('1.1.0'),release_notes='repair', +- system_dependencies=tmp_path/'离线依赖/其他依赖/frp/v') ++ system_dependencies=tmp_path/'发布更新相关/其他依赖/frp/v') + report=tmp_path/'validation.json' + report.write_text(json.dumps({'package_sha256':hashlib.sha256(candidate.read_bytes()).hexdigest(), + 'installed_version':'1.1.0','status':'success','runtime_lifecycle_passed':True, +--- a/核桃派软件源代码/tests/test_release_image.py ++++ b/核桃派软件源代码/tests/test_release_image.py +@@ -133,7 +133,7 @@ + assert export_release.main() == 0 + assert (source / "VERSION").read_text(encoding="utf-8") == "1.1.0\n" + assert history.read_text(encoding="utf-8") == '{"schema_version":1,"releases":[]}' +- assert not (tmp_path / "离线依赖" / "导出包" / "1.1.1").exists() ++ assert not (tmp_path / "发布更新相关" / "导出包" / "1.1.1").exists() + assert {path.name for path in candidate.iterdir()} == { + "README.md", "manifest.json", "matrix-screen-controller-1.1.1.img", + "matrix-screen-controller-1.1.1.img.sha256", +--- a/测试相关资料/如何测试/本机测试环境/README.md ++++ b/测试相关资料/如何测试/本机测试环境/README.md +@@ -5,10 +5,10 @@ + ## 常用命令 + + ```powershell +-python ".\如何测试\本机测试环境\local_test.py" setup +-python ".\如何测试\本机测试环境\local_test.py" test --suite all +-python ".\如何测试\本机测试环境\local_test.py" serve --port 8765 +-python ".\如何测试\本机测试环境\local_test.py" clean ++python ".\测试相关资料\如何测试\本机测试环境\local_test.py" setup ++python ".\测试相关资料\如何测试\本机测试环境\local_test.py" test --suite all ++python ".\测试相关资料\如何测试\本机测试环境\local_test.py" serve --port 8765 ++python ".\测试相关资料\如何测试\本机测试环境\local_test.py" clean + ``` + + 测试套件: +--- a/测试相关资料/如何测试/本机测试环境/local_test.py ++++ b/测试相关资料/如何测试/本机测试环境/local_test.py +@@ -11,7 +11,7 @@ + + + HARNESS_DIR = Path(__file__).resolve().parent +-WORKSPACE_ROOT = HARNESS_DIR.parent.parent ++WORKSPACE_ROOT = HARNESS_DIR.parents[2] + SOURCE_DIR = WORKSPACE_ROOT / "核桃派软件源代码" + VENV_DIR = HARNESS_DIR / ".venv" + WORK_DIR = HARNESS_DIR / "work" +--- a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md ++++ b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md +@@ -6,6 +6,7 @@ + + | 测试编号 | 主要覆盖 | 执行位置 | 人工停顿 | + |---|---|---|---| ++| `TEST-WORKSPACE-LAYOUT` | `DEPLOY-WORKSPACE-LAYOUT`、测试与发布路径、隔离和清理 | 本机中文及空格路径 | 否 | + | `TEST-LOCAL-MOCK` | `WEB-*`、`DISPLAY-MOCK`、`CONFIG-*` | Windows 或隔离 Linux | 否 | + | `TEST-NATIVE-UNIT` | `DISPLAY-DRIVER`、bitplane、映射、亮度、双缓冲 | 本机,不访问 `/dev/mem` | 否 | + | `TEST-REMOTE-HEALTH` | `DEPLOY-*`、系统、网络、磁盘、工具 | 核桃派,无屏 | 否 | +@@ -42,8 +43,8 @@ + 从独立项目根执行: + + ```powershell +-python ".\如何测试\本机测试环境\local_test.py" setup +-python ".\如何测试\本机测试环境\local_test.py" test ++python ".\测试相关资料\如何测试\本机测试环境\local_test.py" setup ++python ".\测试相关资料\如何测试\本机测试环境\local_test.py" test + ``` + + 前端测试: +@@ -97,7 +98,9 @@ + + 候选晋升的最低真实硬件门槛是同一摘要 IMG 在默认热点从上电起可用时完成一次完整正向首启、自动重启、SSH/sudo/root 拒绝、服务、内核、H618、网页和再次重启复验。SSID 不存在、认证失败和 DHCP 失败分支仍须通过自动化;没有另行执行真实故障网络矩阵时,在验收记录中明确写为“本次未重复实卡验证”,不得写成实卡通过。 + +-`TEST-OTA-REAL` 固定走 `1.0.0 → 1.0.1`:先用手工更新入口部署 1.0.0,再从系统设置选择工作区 `OTA数据包/1.0.1/` 中的真实包。更新前后对持久根做文件清单,除 schema 迁移和单条 OTA 状态外保持一致;确认版本、实例 ID、真实驱动、硬件映射、PWM4 OE 后端和 fault 计数。再次上传同一包必须在停服前拒绝。成功后不得残留旧 release、上传包、staging、数据备份或活动 OTA unit。另用受控失败包确认 1.0.5 及后续旧版本服务恢复后自动弹出最近失败日志,日志包含失败用例与回滚结论且可在局域网 HTTP 页面完整复制;成功更新清除旧日志。1.0.6 是发布记录中唯一经授权跳过旧 worker pytest 的历史诊断引导包,只用于先安装日志能力,其成品不得覆盖、实现不得复制到当前源码或任何后续包;1.0.7 及以后全部恢复严格 pytest 门槛。实屏应在更新期间以 40% 亮度显示整体缩放为 58×58、位于 `(3,3)` 的居中 OTA 进度,四边均有黑色空白,并在成功后恢复默认内容;摄像头画面不清晰时必须等待用户肉眼确认。 ++历史 OTA 验收的前提是备齐对应版本的原始包及其摘要;历史产物允许按生命周期删除。缺少所需版本时应报告“验收材料缺失”,停止该历史版本组合的验收,不把路径存在性失败记为更新失败,也不自动导出或替换成其他版本。 ++ ++`TEST-OTA-REAL` 固定走 `1.0.0 → 1.0.1`:先用手工更新入口部署 1.0.0,再从系统设置选择工作区 `发布更新相关/OTA数据包/1.0.1/` 中的真实包。更新前后对持久根做文件清单,除 schema 迁移和单条 OTA 状态外保持一致;确认版本、实例 ID、真实驱动、硬件映射、PWM4 OE 后端和 fault 计数。再次上传同一包必须在停服前拒绝。成功后不得残留旧 release、上传包、staging、数据备份或活动 OTA unit。另用受控失败包确认 1.0.5 及后续旧版本服务恢复后自动弹出最近失败日志,日志包含失败用例与回滚结论且可在局域网 HTTP 页面完整复制;成功更新清除旧日志。1.0.6 是发布记录中唯一经授权跳过旧 worker pytest 的历史诊断引导包,只用于先安装日志能力,其成品不得覆盖、实现不得复制到当前源码或任何后续包;1.0.7 及以后全部恢复严格 pytest 门槛。实屏应在更新期间以 40% 亮度显示整体缩放为 58×58、位于 `(3,3)` 的居中 OTA 进度,四边均有黑色空白,并在成功后恢复默认内容;摄像头画面不清晰时必须等待用户肉眼确认。 + + ## 2. 原生驱动单元测试 + +@@ -240,3 +243,9 @@ + 完整包仍执行全部 pytest。涉及主服务停启的 OTA 修复必须走真实设备 OTA,核对磁盘与 API 版本、成功状态、持久数据、frp 启停和事务清理。systemctl 替身仅能验证业务分支,不能作为真实目录生命周期或 OTA 通过证据。显示效果与软件验收分开记录,缺少人工/摄像头证据时不能记为实屏通过。 + + 同版本修复覆盖验证报告不匹配、候选源码不匹配、历史摘要损坏、提交失败回退及原始包完整归档;正式产物必须与实机验收候选包摘要一致,不得在实机成功后重新构建替换。 ++ ++## 工作区目录适配验收(`TEST-WORKSPACE-LAYOUT`) ++ ++从工作区根目录及其他调用目录分别通过测试入口绝对路径执行测试;复制源码、测试入口、当前需求和必要离线材料到含中文及空格的隔离目录,按本文件命令运行 Python、前端与 mock 健康检查。复制件不得依赖原工作区或历史导出产物。发布定位用临时夹具测试,确认依赖来自 `发布更新相关/其他依赖/`,OTA 与 IMG 输出分别位于 `发布更新相关/` 下的 `OTA数据包/`、`导出包/`;不为路径验证推进正式版本。 ++ ++在隔离目录执行 `clean`,确认 `.venv`、依赖指纹和人工持久数据保持不变,仅删除登记的临时目标;越界路径必须拒绝。核对现存文档链接和发布记录中的产物路径,已删除的历史产物允许缺失,须明确其历史性质,不重新生成。镜像内部 `project/离线依赖/` 属于载荷协议,构建器与首启脚本应继续保持一致。 +--- a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md ++++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md +@@ -113,16 +113,16 @@ + + ```bash + # 本机纯计算/协议自测,不访问 I²C,Windows 也可以运行 +-python "M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --self-test ++python "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --self-test + + # 核桃派单次读数 +-python3 "M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" ++python3 "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" + + # 连续读数 +-python3 "M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --watch ++python3 "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --watch + + # 使用一次比例校正 +-python3 "M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --calibration-factor 1.00604 --watch ++python3 "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --calibration-factor 1.00604 --watch + ``` + + 额外参数通过 `--help` 查看。独立工具必须保持 Python 标准库实现并与主服务监测器分开;不能把独立无限循环直接放进 FastAPI 进程,也不能与主服务同时长期读取同一地址。 +--- a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/来源索引.md ++++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/来源索引.md +@@ -15,7 +15,7 @@ + 重新核验哈希时,在 PowerShell 中运行: + + ```powershell +-Get-ChildItem -LiteralPath ".\M5Stack Unit ADC v1.1相关内容\官方资料" -Filter *.pdf | ++Get-ChildItem -LiteralPath ".\硬件相关资料和硬件的连接\M5Stack Unit ADC v1.1相关内容\官方资料" -Filter *.pdf | + Get-FileHash -Algorithm SHA256 + ``` + +--- /dev/null ++++ b/核桃派软件源代码/tests/test_workspace_layout.py +@@ -0,0 +1,83 @@ ++from __future__ import annotations ++ ++import importlib.util ++import os ++from pathlib import Path ++import shutil ++import subprocess ++import sys ++ ++import pytest ++ ++ ++PROJECT_ROOT = Path(__file__).resolve().parents[2] ++HARNESS_RELATIVE = Path("测试相关资料/如何测试/本机测试环境/local_test.py") ++ ++ ++@pytest.fixture ++def relocated_harness(tmp_path): ++ original = PROJECT_ROOT / HARNESS_RELATIVE ++ if not original.is_file() and os.environ.get("MATRIX_SOURCE_ONLY_UPDATE_TESTS") == "1": ++ pytest.skip("source-only update payload intentionally omits the workstation harness") ++ root = tmp_path / "独立 工作区" ++ script = root / HARNESS_RELATIVE ++ script.parent.mkdir(parents=True) ++ shutil.copyfile(original, script) ++ source = root / "核桃派软件源代码" ++ (source / "app").mkdir(parents=True) ++ for name in ("requirements.txt", "requirements-dev.txt"): ++ (source / name).write_text("", encoding="utf-8") ++ spec = importlib.util.spec_from_file_location("relocated_local_test", script) ++ module = importlib.util.module_from_spec(spec) ++ spec.loader.exec_module(module) ++ return root, module ++ ++ ++def test_harness_locates_relocated_source_from_unrelated_cwd(relocated_harness, tmp_path): ++ root, harness = relocated_harness ++ probe = ( ++ "import runpy, sys; from pathlib import Path; " ++ "h = runpy.run_path(sys.argv[1]); h['_validate_layout'](); " ++ "assert h['WORKSPACE_ROOT'] == Path(sys.argv[2]); " ++ "assert h['SOURCE_DIR'] == Path(sys.argv[2]) / '核桃派软件源代码'" ++ ) ++ subprocess.run( ++ [sys.executable, "-c", probe, str(root / HARNESS_RELATIVE), str(root)], ++ cwd=tmp_path, ++ check=True, ++ ) ++ ++ ++def test_clean_keeps_environment_and_manual_data_in_relocated_workspace(relocated_harness): ++ root, harness = relocated_harness ++ keep = { ++ harness.VENV_PYTHON: b"interpreter sentinel", ++ harness.REQUIREMENTS_STAMP: b"requirements sentinel", ++ harness.DATA_DIR / "config.json": b"manual config sentinel", ++ harness.DATA_DIR / "templates" / "example.json": b"manual template sentinel", ++ } ++ for path, content in keep.items(): ++ path.parent.mkdir(parents=True, exist_ok=True) ++ path.write_bytes(content) ++ for directory in (harness.WORK_DIR, harness.DATA_DIR / "runtime", harness.SOURCE_DIR / "app/__pycache__"): ++ directory.mkdir(parents=True) ++ (directory / "temporary").write_bytes(b"temporary") ++ harness.clean() ++ assert all(path.read_bytes() == content for path, content in keep.items()) ++ assert not harness.WORK_DIR.exists() ++ assert not (harness.DATA_DIR / "runtime").exists() ++ assert not (harness.SOURCE_DIR / "app/__pycache__").exists() ++ with pytest.raises(harness.HarnessError): ++ harness._assert_within(root.parent / "outside", root) ++ ++ ++def test_clean_rejects_outside_target_before_deleting(relocated_harness, tmp_path, monkeypatch): ++ _, harness = relocated_harness ++ outside = tmp_path / "outside" ++ outside.mkdir() ++ marker = outside / "preserve" ++ marker.write_bytes(b"unchanged") ++ monkeypatch.setattr(harness, "WORK_DIR", outside) ++ with pytest.raises(harness.HarnessError): ++ harness.clean() ++ assert marker.read_bytes() == b"unchanged" diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/README.md b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/README.md new file mode 100644 index 0000000..76f7aee --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/README.md @@ -0,0 +1,9 @@ +# 核桃派镜像配置编辑器 + +`编辑器程序/` 保存 Windows 10/11 x64 可直接运行的绿色单 EXE;`编辑器源代码/` 保存 .NET 8 WinForms 源码。编辑器版本独立于核桃派软件版本,当前为 `1.0.1`,新版本发布时直接覆盖程序目录中的旧版本。 + +打开版本化 IMG 后可查看软件版本、账户名、Wi-Fi 和 IPv4 配置,密码默认遮挡。支持二次确认后修改原镜像,以及用 Windows 合法的新名称另存为定制副本;中文、空格和长路径均受支持。保存完成会生成无 BOM UTF-8 的同名 `.sha256` 并重新验证。损坏、截断、产品错误、未知 schema、配置摘要错误或现有镜像摘要不符时禁止保存。 + +1.0.1 修复了中文另存文件名被 ASCII 替换成 `?` 后无法重新打开的问题,并将主窗口、输入区域和应用内提示框改为随系统 DPI、显示器和分辨率自适应的大尺寸布局。 + +编辑器只修改 IMG 内固定的双槽配置区,不负责向 TF 卡写入镜像。写卡仍使用 Rufus 等原始磁盘写入工具。 diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/归档说明.md b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/归档说明.md new file mode 100644 index 0000000..8dc1a92 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/归档说明.md @@ -0,0 +1,7 @@ +# 镜像配置编辑器 1.0.1 C# 旧版归档 + +本目录是 2026-09-08 将镜像编辑器重写为 Python/PySide6 2.0.0 前的完整快照。内含原 `.NET 8 WinForms` 源码、绿色程序、版本、摘要和原说明。后续构建、测试和镜像发布不得引用本归档。 + +- 旧编辑器版本:`1.0.1` +- 旧 EXE SHA-256:`bcee5a87b654999b5649948bbed49b9071ebb09e5e9caef5cc8f22331ee482c3` +- 原程序文件:`编辑器程序/核桃派镜像配置编辑器.exe` diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/AppDialog.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/AppDialog.cs new file mode 100644 index 0000000..c1448e9 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/AppDialog.cs @@ -0,0 +1,131 @@ +namespace MatrixImageEditor; + +internal enum AppDialogIcon +{ + Information, + Warning, + Error, +} + +internal sealed class AppDialog : Form +{ + private static readonly Size PreferredLogicalSize = new(640, 320); + private static readonly Size MinimumLogicalSize = new(520, 260); + private readonly Screen initialScreen; + + private AppDialog(IWin32Window? owner, string message, string title, MessageBoxButtons buttons, AppDialogIcon icon) + { + initialScreen = owner is null ? Screen.PrimaryScreen! : Screen.FromHandle(owner.Handle); + Text = title; + AutoScaleMode = AutoScaleMode.Dpi; + Font = new Font("Microsoft YaHei UI", 10F); + FormBorderStyle = FormBorderStyle.Sizable; + StartPosition = FormStartPosition.Manual; + ShowInTaskbar = false; + MinimizeBox = false; + MaximizeBox = false; + + var body = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(24), + ColumnCount = 2, + RowCount = 2, + }; + body.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + body.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + body.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var picture = new PictureBox + { + Image = GetIcon(icon).ToBitmap(), + SizeMode = PictureBoxSizeMode.CenterImage, + Size = new Size(64, 64), + Margin = new Padding(0, 0, 20, 0), + Anchor = AnchorStyles.Top, + }; + var text = new TextBox + { + Text = message, + ReadOnly = true, + Multiline = true, + WordWrap = true, + ScrollBars = ScrollBars.Vertical, + Dock = DockStyle.Fill, + BackColor = SystemColors.Window, + BorderStyle = BorderStyle.FixedSingle, + Margin = new Padding(0), + ShortcutsEnabled = true, + }; + var actions = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + FlowDirection = FlowDirection.RightToLeft, + WrapContents = false, + Margin = new Padding(0, 20, 0, 0), + }; + + if (buttons == MessageBoxButtons.YesNo) + { + var no = Button("否", DialogResult.No); + var yes = Button("是", DialogResult.Yes); + actions.Controls.Add(no); + actions.Controls.Add(yes); + AcceptButton = yes; + CancelButton = no; + } + else + { + var ok = Button("确定", DialogResult.OK); + actions.Controls.Add(ok); + AcceptButton = ok; + CancelButton = ok; + } + + body.Controls.Add(picture, 0, 0); + body.Controls.Add(text, 1, 0); + body.Controls.Add(actions, 0, 1); + body.SetColumnSpan(actions, 2); + Controls.Add(body); + } + + public static DialogResult Show(IWin32Window? owner, string message, string title, MessageBoxButtons buttons, AppDialogIcon icon) + { + using var dialog = new AppDialog(owner, message, title, buttons, icon); + return owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner); + } + + public static void Information(IWin32Window? owner, string message, string title) => Show(owner, message, title, MessageBoxButtons.OK, AppDialogIcon.Information); + public static void Error(IWin32Window? owner, string message, string title) => Show(owner, message, title, MessageBoxButtons.OK, AppDialogIcon.Error); + public static DialogResult Confirm(IWin32Window? owner, string message, string title) => Show(owner, message, title, MessageBoxButtons.YesNo, AppDialogIcon.Warning); + + protected override void OnShown(EventArgs eventArgs) + { + base.OnShown(eventArgs); + ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.8, initialScreen); + } + + protected override void OnDpiChanged(DpiChangedEventArgs eventArgs) + { + base.OnDpiChanged(eventArgs); + BeginInvoke(() => ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.8)); + } + + private static Button Button(string text, DialogResult result) => new() + { + Text = text, + DialogResult = result, + AutoSize = true, + MinimumSize = new Size(110, 38), + Margin = new Padding(10, 0, 0, 0), + }; + + private static Icon GetIcon(AppDialogIcon icon) => icon switch + { + AppDialogIcon.Error => SystemIcons.Error, + AppDialogIcon.Warning => SystemIcons.Warning, + _ => SystemIcons.Information, + }; +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/Fat16Disk.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/Fat16Disk.cs new file mode 100644 index 0000000..c54988f --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/Fat16Disk.cs @@ -0,0 +1,124 @@ +using System.Buffers.Binary; +using System.Text; + +namespace MatrixImageEditor; + +public sealed class Fat16Disk : IDisposable +{ + private readonly FileStream stream; + private readonly long fatOffset; + private readonly long rootOffset; + private readonly int rootEntries; + private readonly int fatBytes; + private readonly long dataOffset; + private readonly int clusterBytes; + + public Fat16Disk(string path, bool writable) + { + stream = new FileStream(path, writable ? FileMode.Open : FileMode.Open, writable ? FileAccess.ReadWrite : FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.RandomAccess); + var mbr = ReadAt(0, 512); + if (mbr[510] != 0x55 || mbr[511] != 0xAA) throw new InvalidDataException("文件没有有效的 MBR 分区表"); + var partitionLba = BinaryPrimitives.ReadUInt32LittleEndian(mbr.AsSpan(454, 4)); + var partitionSectors = BinaryPrimitives.ReadUInt32LittleEndian(mbr.AsSpan(458, 4)); + var partitionType = mbr[450]; + // WalnutPi labels this BPB-compatible FAT16 volume as 0x0C in the MBR. + if (partitionLba == 0 || partitionSectors == 0 || partitionType is not (0x04 or 0x06 or 0x0B or 0x0C or 0x0E)) throw new InvalidDataException("镜像第一分区不是受支持的 FAT 启动分区"); + var partitionOffset = partitionLba * 512L; + if (partitionOffset + partitionSectors * 512L > stream.Length) throw new InvalidDataException("镜像分区表超出文件边界"); + var bpb = ReadAt(partitionOffset, 512); + var bytesPerSector = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(11, 2)); + var sectorsPerCluster = bpb[13]; + var reserved = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(14, 2)); + var fatCount = bpb[16]; + rootEntries = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(17, 2)); + var fatSectors = BinaryPrimitives.ReadUInt16LittleEndian(bpb.AsSpan(22, 2)); + if (bytesPerSector != 512 || sectorsPerCluster == 0 || fatSectors == 0 || rootEntries == 0) throw new InvalidDataException("镜像第一分区不是受支持的 FAT16"); + clusterBytes = bytesPerSector * sectorsPerCluster; + fatBytes = fatSectors * bytesPerSector; + fatOffset = partitionOffset + reserved * bytesPerSector; + rootOffset = fatOffset + fatCount * fatBytes; + var rootSectors = (rootEntries * 32L + bytesPerSector - 1) / bytesPerSector; + dataOffset = rootOffset + rootSectors * bytesPerSector; + } + + public byte[] ReadFile(string name) + { + var entry = Find(name); + var fat = ReadAt(fatOffset, fatBytes); + var result = new byte[entry.Size]; + var written = 0; + foreach (var cluster in Chain(entry.FirstCluster, fat)) + { + var count = Math.Min(clusterBytes, result.Length - written); + ReadAt(ClusterOffset(cluster), result.AsSpan(written, count)); + written += count; + if (written == result.Length) break; + } + if (written != result.Length) throw new InvalidDataException("镜像配置文件被截断"); + return result; + } + + public void WriteFileRange(string name, int fileOffset, byte[] data) + { + var entry = Find(name); + if (fileOffset < 0 || fileOffset + data.Length > entry.Size) throw new ArgumentOutOfRangeException(nameof(fileOffset)); + var fat = ReadAt(fatOffset, fatBytes); + var chain = Chain(entry.FirstCluster, fat).ToArray(); + var remaining = data.Length; + var sourceOffset = 0; + var position = fileOffset; + while (remaining > 0) + { + var chainIndex = position / clusterBytes; + var within = position % clusterBytes; + if (chainIndex >= chain.Length) throw new InvalidDataException("镜像配置文件簇链不足"); + var count = Math.Min(remaining, clusterBytes - within); + stream.Position = ClusterOffset(chain[chainIndex]) + within; + stream.Write(data, sourceOffset, count); + position += count; sourceOffset += count; remaining -= count; + } + stream.Flush(flushToDisk: true); + } + + private (ushort FirstCluster, int Size) Find(string name) + { + var expected = Name83(name); + var root = ReadAt(rootOffset, rootEntries * 32); + for (var index = 0; index < rootEntries; index++) + { + var entry = root.AsSpan(index * 32, 32); + if (entry[0] == 0x00) break; + if (entry[0] == 0xE5 || entry[11] == 0x0F) continue; + if (entry[..11].SequenceEqual(expected)) + { + return (BinaryPrimitives.ReadUInt16LittleEndian(entry.Slice(26, 2)), BinaryPrimitives.ReadInt32LittleEndian(entry.Slice(28, 4))); + } + } + throw new InvalidDataException($"镜像缺少 {name}"); + } + + private IEnumerable Chain(ushort first, byte[] fat) + { + var seen = new HashSet(); + var cluster = first; + while (cluster is >= 2 and < 0xFFF8) + { + if (!seen.Add(cluster)) throw new InvalidDataException("FAT16 簇链循环"); + yield return cluster; + cluster = BinaryPrimitives.ReadUInt16LittleEndian(fat.AsSpan(cluster * 2, 2)); + } + } + + private long ClusterOffset(ushort cluster) => dataOffset + (cluster - 2L) * clusterBytes; + private byte[] ReadAt(long offset, int count) { var value = new byte[count]; ReadAt(offset, value); return value; } + private void ReadAt(long offset, Span destination) { stream.Position = offset; stream.ReadExactly(destination); } + + private static byte[] Name83(string name) + { + var parts = name.ToUpperInvariant().Split('.', 2); + if (parts[0].Length is < 1 or > 8 || (parts.Length == 2 && parts[1].Length > 3)) throw new ArgumentException("文件名不是 8.3 格式"); + return Encoding.ASCII.GetBytes(parts[0].PadRight(8) + (parts.Length == 2 ? parts[1] : "").PadRight(3)); + } + + public void Dispose() => stream.Dispose(); +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImageConfig.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImageConfig.cs new file mode 100644 index 0000000..9adff9c --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImageConfig.cs @@ -0,0 +1,160 @@ +using System.Buffers.Binary; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace MatrixImageEditor; + +public sealed class ImageConfig +{ + [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; } = 1; + [JsonPropertyName("product")] public string Product { get; set; } = ""; + [JsonPropertyName("software_version")] public string SoftwareVersion { get; set; } = ""; + [JsonPropertyName("account")] public AccountConfig Account { get; set; } = new(); + [JsonPropertyName("wifi")] public WifiConfig Wifi { get; set; } = new(); + [JsonPropertyName("ipv4")] public Ipv4Config Ipv4 { get; set; } = new(); +} + +public sealed class AccountConfig +{ + [JsonPropertyName("username")] public string Username { get; set; } = ""; + [JsonPropertyName("password")] public string Password { get; set; } = ""; +} + +public sealed class WifiConfig +{ + [JsonPropertyName("ssid")] public string Ssid { get; set; } = ""; + [JsonPropertyName("password")] public string Password { get; set; } = ""; +} + +public sealed class Ipv4Config +{ + [JsonPropertyName("mode")] public string Mode { get; set; } = "dhcp"; + [JsonPropertyName("address")] public string Address { get; set; } = ""; + [JsonPropertyName("prefix")] public int Prefix { get; set; } + [JsonPropertyName("gateway")] public string Gateway { get; set; } = ""; + [JsonPropertyName("dns")] public List Dns { get; set; } = new(); +} + +public static class ImageConfigCodec +{ + public const int FileBytes = 64 * 1024; + private const int HeaderBytes = 4096; + private const int SlotBytes = (FileBytes - HeaderBytes) / 2; + private const int SlotHeaderBytes = 52; + private static readonly byte[] FileMagic = Encoding.ASCII.GetBytes("MSCCFG2\0"); + private static readonly byte[] SlotMagic = Encoding.ASCII.GetBytes("MSCSLOT\0"); + private static readonly Regex UsernamePattern = new("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant); + private static readonly Regex VersionPattern = new("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", RegexOptions.CultureInvariant); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = null, + WriteIndented = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + public static ImageConfig Deserialize(string json) + { + var config = JsonSerializer.Deserialize(json, JsonOptions) ?? throw new InvalidDataException("配置 JSON 为空"); + Validate(config); + return config; + } + + public static string Serialize(ImageConfig config) + { + Validate(config); + return JsonSerializer.Serialize(config, JsonOptions); + } + + public static ImageConfig Read(byte[] data) => ReadDetailed(data).Config; + + public static (ImageConfig Config, ulong Generation, int Slot) ReadDetailed(byte[] data) + { + if (data.Length != FileBytes || !data.AsSpan(0, 8).SequenceEqual(FileMagic) || BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(8, 4)) != 1 || BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(12, 4)) != FileBytes) + { + throw new InvalidDataException("镜像配置区头部无效"); + } + var values = new List<(ImageConfig Config, ulong Generation, int Slot)>(); + for (var slot = 0; slot < 2; slot++) + { + var offset = HeaderBytes + slot * SlotBytes; + var span = data.AsSpan(offset, SlotBytes); + if (!span[..8].SequenceEqual(SlotMagic)) continue; + var generation = BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(8, 8)); + var length = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(16, 4)); + if (length <= 0 || length > SlotBytes - SlotHeaderBytes) continue; + var payload = span.Slice(SlotHeaderBytes, length).ToArray(); + if (!SHA256.HashData(payload).AsSpan().SequenceEqual(span.Slice(20, 32))) continue; + try + { + values.Add((Deserialize(Encoding.UTF8.GetString(payload)), generation, slot)); + } + catch { } + } + if (values.Count == 0) throw new InvalidDataException("镜像配置区没有可恢复的有效副本"); + return values.OrderByDescending(value => value.Generation).ThenByDescending(value => value.Slot).First(); + } + + public static (byte[] SlotData, int TargetSlot) EncodeUpdate(byte[] current, ImageConfig config) + { + Validate(config); + var active = ReadDetailed(current); + var target = 1 - active.Slot; + var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(config, JsonOptions) + "\n"); + if (payload.Length > SlotBytes - SlotHeaderBytes) throw new InvalidDataException("配置内容过大"); + var result = new byte[SlotBytes]; + SlotMagic.CopyTo(result, 0); + BinaryPrimitives.WriteUInt64LittleEndian(result.AsSpan(8, 8), active.Generation + 1); + BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(16, 4), payload.Length); + SHA256.HashData(payload).CopyTo(result, 20); + payload.CopyTo(result, SlotHeaderBytes); + return (result, target); + } + + public static int SlotOffset(int slot) => HeaderBytes + slot * SlotBytes; + + public static void Validate(ImageConfig config) + { + if (config.Account is null || config.Wifi is null || config.Ipv4 is null) throw new InvalidDataException("镜像配置缺少必要部分"); + if (config.SchemaVersion != 1 || config.Product != "matrix-screen-controller-walnutpi") throw new InvalidDataException("镜像产品或配置版本不受支持"); + if (!VersionPattern.IsMatch(config.SoftwareVersion)) throw new InvalidDataException("软件版本格式无效"); + if (!UsernamePattern.IsMatch(config.Account.Username)) throw new InvalidDataException("用户名只能使用小写字母、数字、下划线和连字符,且最长 32 个字符"); + ValidateSecret(config.Account.Password, "账户密码", 8, 128); + ValidateText(config.Wifi.Ssid, "Wi-Fi 名称", 1, 32); + ValidateSecret(config.Wifi.Password, "Wi-Fi 密码", 8, 63); + if (config.Ipv4.Mode == "dhcp") + { + config.Ipv4.Address = ""; config.Ipv4.Prefix = 0; config.Ipv4.Gateway = ""; config.Ipv4.Dns = new(); + } + else if (config.Ipv4.Mode == "static") + { + if (!IPAddress.TryParse(config.Ipv4.Address, out var address) || address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) throw new InvalidDataException("静态 IPv4 地址无效"); + if (!IPAddress.TryParse(config.Ipv4.Gateway, out var gateway) || gateway.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) throw new InvalidDataException("静态 IPv4 网关无效"); + if (config.Ipv4.Prefix is < 1 or > 32) throw new InvalidDataException("IPv4 前缀必须是 1 到 32"); + if (!SameSubnet(address, gateway, config.Ipv4.Prefix)) throw new InvalidDataException("静态 IPv4 网关必须与地址处于同一子网"); + if (config.Ipv4.Dns is null || config.Ipv4.Dns.Count is < 1 or > 4 || config.Ipv4.Dns.Any(item => !IPAddress.TryParse(item, out var parsed) || parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)) throw new InvalidDataException("DNS 必须包含 1 到 4 个 IPv4 地址"); + } + else throw new InvalidDataException("IPv4 模式无效"); + } + + private static void ValidateSecret(string value, string label, int minimum, int maximum) + { + if (value is null || value.Length < minimum || value.Length > maximum || value.IndexOfAny(['\0', '\r', '\n']) >= 0) throw new InvalidDataException($"{label}长度或字符无效"); + } + + private static void ValidateText(string value, string label, int minimum, int maximumBytes) + { + if (value is null || value.Length < minimum || Encoding.UTF8.GetByteCount(value) > maximumBytes || value.IndexOfAny(['\0', '\r', '\n']) >= 0) throw new InvalidDataException($"{label}长度或字符无效"); + } + + private static bool SameSubnet(IPAddress address, IPAddress gateway, int prefix) + { + var addressValue = BinaryPrimitives.ReadUInt32BigEndian(address.GetAddressBytes()); + var gatewayValue = BinaryPrimitives.ReadUInt32BigEndian(gateway.GetAddressBytes()); + var mask = prefix == 32 ? uint.MaxValue : uint.MaxValue << (32 - prefix); + return (addressValue & mask) == (gatewayValue & mask); + } +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImageOperations.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImageOperations.cs new file mode 100644 index 0000000..90430f8 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImageOperations.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MatrixImageEditor; + +public static class ImageOperations +{ + private static readonly UTF8Encoding Utf8NoBomStrict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + public static ImageConfig ReadConfig(string imagePath) + { + imagePath = ImagePathRules.ValidateExistingImage(imagePath); + VerifySidecarIfPresent(imagePath); + using var disk = new Fat16Disk(imagePath, writable: false); + return ImageConfigCodec.Read(disk.ReadFile("MSCCFG.BIN")); + } + + public static void WriteConfig(string imagePath, ImageConfig config) + { + imagePath = ImagePathRules.ValidateExistingImage(imagePath); + VerifySidecarIfPresent(imagePath); + using var disk = new Fat16Disk(imagePath, writable: true); + var current = disk.ReadFile("MSCCFG.BIN"); + if (ImageConfigCodec.Read(current).SoftwareVersion != config.SoftwareVersion) throw new InvalidDataException("禁止通过编辑器改变软件版本"); + var update = ImageConfigCodec.EncodeUpdate(current, config); + disk.WriteFileRange("MSCCFG.BIN", ImageConfigCodec.SlotOffset(update.TargetSlot), update.SlotData); + var verified = ImageConfigCodec.Read(disk.ReadFile("MSCCFG.BIN")); + if (!ImageConfigCodec.Serialize(verified).Equals(ImageConfigCodec.Serialize(config), StringComparison.Ordinal)) throw new IOException("配置写后校验失败"); + } + + public static void WriteSha256(string imagePath, IProgress? progress) + { + imagePath = ImagePathRules.ValidateExistingImage(imagePath); + using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4 * 1024 * 1024, FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[4 * 1024 * 1024]; + long completed = 0; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + hash.AppendData(buffer, 0, read); + completed += read; + progress?.Report((int)(completed * 100 / stream.Length)); + } + var digest = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + progress?.Report(100); + + var sidecar = imagePath + ".sha256"; + var temporary = sidecar + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var bytes = Utf8NoBomStrict.GetBytes($"{digest} {Path.GetFileName(imagePath)}\n"); + using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + output.Write(bytes); + output.Flush(flushToDisk: true); + } + File.Move(temporary, sidecar, overwrite: true); + var stored = ReadSidecarDigest(sidecar, Path.GetFileName(imagePath)); + if (!stored.Equals(digest, StringComparison.Ordinal)) throw new IOException("镜像 SHA-256 摘要写后校验失败"); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static void VerifySidecarIfPresent(string imagePath) + { + var sidecar = imagePath + ".sha256"; + if (!File.Exists(sidecar)) return; + var expected = ReadSidecarDigest(sidecar, Path.GetFileName(imagePath)); + using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4 * 1024 * 1024, FileOptions.SequentialScan); + var actual = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + if (!actual.Equals(expected, StringComparison.Ordinal)) throw new InvalidDataException("镜像内容与 SHA-256 摘要不符,禁止编辑"); + } + + private static string ReadSidecarDigest(string sidecarPath, string expectedFileName) + { + if (new FileInfo(sidecarPath).Length > 128 * 1024) throw new InvalidDataException("镜像 SHA-256 摘要文件过大"); + var bytes = File.ReadAllBytes(sidecarPath); + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) throw new InvalidDataException("镜像 SHA-256 摘要必须使用无 BOM UTF-8 格式"); + + string content; + try + { + content = Utf8NoBomStrict.GetString(bytes); + } + catch (DecoderFallbackException exception) + { + throw new InvalidDataException("镜像 SHA-256 摘要不是有效的 UTF-8 文本", exception); + } + + if (content.Length < 68 || content[^1] != '\n' || content.AsSpan(0, content.Length - 1).ContainsAny('\r', '\n')) + { + throw new InvalidDataException("镜像 SHA-256 摘要文件必须是以 LF 结尾的单行规范格式"); + } + + var line = content.AsSpan(0, content.Length - 1); + var digest = line[..64]; + if (digest.ContainsAnyExcept("0123456789abcdef") || line[64] != ' ' || line[65] != ' ') + { + throw new InvalidDataException("镜像 SHA-256 摘要文件格式无效"); + } + + var storedFileName = line[66..].ToString(); + if (!storedFileName.Equals(expectedFileName, StringComparison.Ordinal)) + { + throw new InvalidDataException("镜像 SHA-256 摘要中的文件名与当前镜像不一致"); + } + return digest.ToString(); + } +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImagePathRules.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImagePathRules.cs new file mode 100644 index 0000000..40fb435 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ImagePathRules.cs @@ -0,0 +1,75 @@ +using System.Text.RegularExpressions; + +namespace MatrixImageEditor; + +internal static partial class ImagePathRules +{ + public static string ValidateExistingImage(string imagePath) + { + var fullPath = ValidateImageNameAndDirectory(imagePath); + if (!File.Exists(fullPath)) throw new FileNotFoundException("镜像文件不存在", fullPath); + if (Directory.Exists(fullPath + ".sha256")) throw new IOException("同名 SHA-256 摘要路径被文件夹占用"); + return fullPath; + } + + public static string ValidateNewDestination(string sourcePath, string destinationPath) + { + var source = ValidateExistingImage(sourcePath); + var destination = ValidateImageNameAndDirectory(destinationPath); + if (destination.Equals(source, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("另存为必须使用与原镜像不同的文件名"); + if (File.Exists(destination) || Directory.Exists(destination)) throw new IOException("目标镜像已经存在,请使用新的文件名"); + + var sidecar = destination + ".sha256"; + if (File.Exists(sidecar) || Directory.Exists(sidecar)) throw new IOException("目标镜像的同名 SHA-256 摘要已经存在,请使用新的文件名"); + return destination; + } + + public static string? CleanupNewDestination(string destinationPath) + { + List? failures = null; + foreach (var target in new[] { destinationPath + ".sha256", destinationPath }) + { + try + { + if (File.Exists(target)) File.Delete(target); + } + catch (Exception exception) + { + failures ??= new List(); + failures.Add($"{Path.GetFileName(target)}:{exception.Message}"); + } + } + return failures is null ? null : string.Join(";", failures); + } + + private static string ValidateImageNameAndDirectory(string imagePath) + { + if (string.IsNullOrWhiteSpace(imagePath)) throw new ArgumentException("镜像路径不能为空"); + + string fullPath; + try + { + fullPath = Path.GetFullPath(imagePath); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) + { + throw new ArgumentException("镜像路径格式无效", exception); + } + + var fileName = Path.GetFileName(fullPath); + if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("镜像文件名不能为空"); + if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || fileName != fileName.TrimEnd(' ', '.')) + { + throw new ArgumentException("镜像文件名包含 Windows 不允许的字符,或以空格、句点结尾"); + } + if (!Path.GetExtension(fileName).Equals(".img", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("镜像文件名必须以 .img 结尾"); + if (ReservedWindowsName().IsMatch(Path.GetFileNameWithoutExtension(fileName))) throw new ArgumentException("镜像文件名使用了 Windows 保留名称,请更换名称"); + + var directory = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) throw new DirectoryNotFoundException("镜像目标文件夹不存在"); + return fullPath; + } + + [GeneratedRegex("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\\..*)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ReservedWindowsName(); +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/MainForm.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/MainForm.cs new file mode 100644 index 0000000..d81902f --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/MainForm.cs @@ -0,0 +1,253 @@ +using Microsoft.Win32; + +namespace MatrixImageEditor; + +public sealed class MainForm : Form +{ + private readonly TextBox path = new() { ReadOnly = true, Dock = DockStyle.Fill }; + private readonly Label version = new() { AutoSize = true, Text = "尚未打开镜像" }; + private readonly TextBox username = new(); + private readonly TextBox accountPassword = new() { UseSystemPasswordChar = true }; + private readonly TextBox ssid = new(); + private readonly TextBox wifiPassword = new() { UseSystemPasswordChar = true }; + private readonly ComboBox mode = new() { DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly TextBox address = new(); + private readonly NumericUpDown prefix = new() { Minimum = 1, Maximum = 32, Value = 24 }; + private readonly TextBox gateway = new(); + private readonly TextBox dns = new(); + private readonly Button open = new() { Text = "打开镜像…", AutoSize = true, MinimumSize = new Size(130, 38) }; + private readonly Button save = new() { Text = "修改原镜像", Enabled = false, AutoSize = true }; + private readonly Button saveAs = new() { Text = "另存为…", Enabled = false, AutoSize = true }; + private readonly ProgressBar progress = new() { Dock = DockStyle.Fill, Visible = false }; + private string? imagePath; + private ImageConfig? loaded; + private bool isBusy; + private bool watchingDisplaySettings; + + private static readonly Size PreferredLogicalSize = new(960, 760); + private static readonly Size MinimumLogicalSize = new(760, 600); + + public MainForm() + { + var assemblyVersion = typeof(MainForm).Assembly.GetName().Version; + Text = $"核桃派镜像配置编辑器 {assemblyVersion?.Major}.{assemblyVersion?.Minor}.{assemblyVersion?.Build}"; + AutoScaleMode = AutoScaleMode.Dpi; + StartPosition = FormStartPosition.Manual; + Font = new Font("Microsoft YaHei UI", 10F); + AllowDrop = true; + mode.Items.AddRange(["自动获取(DHCP)", "静态 IPv4"]); + mode.SelectedIndexChanged += (_, _) => UpdateNetworkFields(); + save.Click += async (_, _) => await SaveOriginalAsync(); + saveAs.Click += async (_, _) => await SaveAsAsync(); + open.Click += (_, _) => OpenImageFromDialog(); + DragEnter += (_, eventArgs) => { if (!isBusy && eventArgs.Data?.GetDataPresent(DataFormats.FileDrop) == true) eventArgs.Effect = DragDropEffects.Copy; }; + DragDrop += (_, eventArgs) => { if (!isBusy && eventArgs.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length == 1) OpenImage(files[0]); }; + Controls.Add(BuildLayout()); + } + + private Control BuildLayout() + { + var outer = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(24), ColumnCount = 1, RowCount = 6, AutoScroll = true }; + for (var index = 0; index < 6; index++) outer.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + var header = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 2, Margin = new Padding(0, 0, 0, 12) }; + header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + path.Margin = new Padding(0, 4, 12, 4); + open.Margin = new Padding(0); + header.Controls.Add(path, 0, 0); header.Controls.Add(open, 1, 0); + outer.Controls.Add(header); + version.Margin = new Padding(0, 0, 0, 12); + outer.Controls.Add(version); + outer.Controls.Add(Group("登录账户", [("用户名", username), ("密码", PasswordPanel(accountPassword))])); + outer.Controls.Add(Group("Wi-Fi", [("SSID", ssid), ("密码", PasswordPanel(wifiPassword))])); + outer.Controls.Add(Group("IPv4", [("方式", mode), ("地址", address), ("前缀", prefix), ("网关", gateway), ("DNS(逗号分隔)", dns)])); + var footer = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 3, Margin = new Padding(0, 16, 0, 0) }; + footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + progress.MinimumSize = new Size(200, 28); + save.MinimumSize = new Size(150, 40); save.Margin = new Padding(12, 0, 0, 0); + saveAs.MinimumSize = new Size(130, 40); saveAs.Margin = new Padding(12, 0, 0, 0); + footer.Controls.Add(progress, 0, 0); footer.Controls.Add(save, 1, 0); footer.Controls.Add(saveAs, 2, 0); + outer.Controls.Add(footer); + return outer; + } + + private static GroupBox Group(string title, (string Label, Control Control)[] rows) + { + var box = new GroupBox { Text = title, Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(16), Margin = new Padding(0, 0, 0, 12) }; + var table = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 2, GrowStyle = TableLayoutPanelGrowStyle.AddRows }; + table.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + foreach (var row in rows) + { + row.Control.Dock = DockStyle.Fill; + row.Control.MinimumSize = new Size(320, 0); + row.Control.Margin = new Padding(12, 5, 0, 5); + table.Controls.Add(new Label { Text = row.Label, AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 7, 12, 7) }, 0, table.RowCount); + table.Controls.Add(row.Control, 1, table.RowCount++); + } + box.Controls.Add(table); return box; + } + + private static Control PasswordPanel(TextBox password) + { + var panel = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, ColumnCount = 2 }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + var show = new CheckBox { Text = "显示", AutoSize = true }; + show.CheckedChanged += (_, _) => password.UseSystemPasswordChar = !show.Checked; + password.Dock = DockStyle.Fill; + password.MinimumSize = new Size(260, 0); + password.Margin = new Padding(0, 0, 12, 0); + show.Anchor = AnchorStyles.Left; + show.Margin = new Padding(0); + panel.Controls.Add(password); panel.Controls.Add(show); return panel; + } + + private void OpenImageFromDialog() + { + using var dialog = new OpenFileDialog + { + Filter = "核桃派镜像 (*.img)|*.img", + CheckFileExists = true, + CheckPathExists = true, + Multiselect = false, + ValidateNames = true, + }; + if (dialog.ShowDialog(this) == DialogResult.OK) OpenImage(dialog.FileName); + } + + private void OpenImage(string filename) + { + try + { + var config = ImageOperations.ReadConfig(filename); + ApplyLoadedConfig(Path.GetFullPath(filename), config); + } + catch (Exception exception) { AppDialog.Error(this, exception.Message, "无法打开镜像"); } + } + + private void ApplyLoadedConfig(string filename, ImageConfig config) + { + imagePath = filename; loaded = config; path.Text = imagePath; version.Text = $"软件版本:{config.SoftwareVersion} 配置校验:正常"; + username.Text = config.Account.Username; accountPassword.Text = config.Account.Password; ssid.Text = config.Wifi.Ssid; wifiPassword.Text = config.Wifi.Password; + mode.SelectedIndex = config.Ipv4.Mode == "static" ? 1 : 0; address.Text = config.Ipv4.Address; prefix.Value = config.Ipv4.Prefix == 0 ? 24 : config.Ipv4.Prefix; gateway.Text = config.Ipv4.Gateway; dns.Text = string.Join(",", config.Ipv4.Dns); + save.Enabled = saveAs.Enabled = true; UpdateNetworkFields(); + } + + private ImageConfig Collect() + { + if (loaded is null) throw new InvalidOperationException("尚未打开镜像"); + var config = new ImageConfig + { + SchemaVersion = loaded.SchemaVersion, Product = loaded.Product, SoftwareVersion = loaded.SoftwareVersion, + Account = new AccountConfig { Username = username.Text.Trim(), Password = accountPassword.Text }, + Wifi = new WifiConfig { Ssid = ssid.Text, Password = wifiPassword.Text }, + Ipv4 = mode.SelectedIndex == 0 + ? new Ipv4Config { Mode = "dhcp" } + : new Ipv4Config { Mode = "static", Address = address.Text.Trim(), Prefix = (int)prefix.Value, Gateway = gateway.Text.Trim(), Dns = dns.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() }, + }; + ImageConfigCodec.Validate(config); return config; + } + + private async Task SaveOriginalAsync() + { + if (imagePath is null) return; + try + { + var config = Collect(); + if (AppDialog.Confirm(this, "这会直接修改当前镜像。建议日常使用“另存为”保留原始包。\n\n确定继续吗?", "确认修改原镜像") != DialogResult.Yes) return; + var verified = await WriteAndVerifyAsync(imagePath, config); + ApplyLoadedConfig(imagePath, verified); + AppDialog.Information(this, "配置已保存,镜像与 SHA-256 摘要均已完成写后校验。", "保存成功"); + } + catch (Exception exception) + { + AppDialog.Error(this, exception.Message, "保存失败"); + } + } + + private async Task SaveAsAsync() + { + if (imagePath is null) return; + using var dialog = new SaveFileDialog + { + Filter = "核桃派镜像 (*.img)|*.img", + DefaultExt = "img", + AddExtension = true, + ValidateNames = true, + CheckPathExists = true, + OverwritePrompt = false, + FileName = Path.GetFileNameWithoutExtension(imagePath) + "-自定义.img", + InitialDirectory = Path.GetDirectoryName(imagePath), + Title = "请重命名,以便区分设备或使用地点", + }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + + string? destination = null; + try + { + var config = Collect(); + destination = ImagePathRules.ValidateNewDestination(imagePath, dialog.FileName); + SetBusy(true); + await Task.Run(() => File.Copy(imagePath, destination, overwrite: false)); + var verified = await WriteAndVerifyAsync(destination, config, alreadyBusy: true); + ApplyLoadedConfig(destination, verified); + AppDialog.Information(this, "自定义镜像已保存并重新打开,镜像与 SHA-256 摘要均已完成写后校验。", "另存为成功"); + } + catch (Exception exception) + { + var cleanupError = destination is null ? null : ImagePathRules.CleanupNewDestination(destination); + var message = cleanupError is null ? exception.Message : $"{exception.Message}\n\n清理未完成:{cleanupError}"; + AppDialog.Error(this, message, "另存为失败"); + } + finally { SetBusy(false); } + } + + private async Task WriteAndVerifyAsync(string target, ImageConfig config, bool alreadyBusy = false) + { + if (!alreadyBusy) SetBusy(true); + try + { + var reporter = new Progress(value => progress.Value = Math.Clamp(value, 0, 100)); + return await Task.Run(() => + { + ImageOperations.WriteConfig(target, config); + ImageOperations.WriteSha256(target, reporter); + return ImageOperations.ReadConfig(target); + }); + } + finally { if (!alreadyBusy) SetBusy(false); } + } + + private void SetBusy(bool value) { isBusy = value; open.Enabled = !value; save.Enabled = saveAs.Enabled = !value && loaded is not null; progress.Visible = value; progress.Value = 0; UseWaitCursor = value; } + private void UpdateNetworkFields() { var enabled = mode.SelectedIndex == 1; address.Enabled = prefix.Enabled = gateway.Enabled = dns.Enabled = enabled; } + + protected override void OnShown(EventArgs eventArgs) + { + base.OnShown(eventArgs); + ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.9); + if (!watchingDisplaySettings) + { + SystemEvents.DisplaySettingsChanged += DisplaySettingsChanged; + watchingDisplaySettings = true; + } + } + + protected override void OnDpiChanged(DpiChangedEventArgs eventArgs) + { + base.OnDpiChanged(eventArgs); + BeginInvoke(() => ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.9)); + } + + protected override void OnFormClosed(FormClosedEventArgs eventArgs) + { + if (watchingDisplaySettings) + { + SystemEvents.DisplaySettingsChanged -= DisplaySettingsChanged; + watchingDisplaySettings = false; + } + base.OnFormClosed(eventArgs); + } + + private void DisplaySettingsChanged(object? sender, EventArgs eventArgs) + { + if (!IsDisposed && IsHandleCreated) BeginInvoke(() => ResponsiveLayout.FitAndCenter(this, PreferredLogicalSize, MinimumLogicalSize, 0.9)); + } +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/MatrixImageEditor.csproj b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/MatrixImageEditor.csproj new file mode 100644 index 0000000..b49cd13 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/MatrixImageEditor.csproj @@ -0,0 +1,23 @@ + + + WinExe + net8.0-windows + true + PerMonitorV2 + enable + enable + 核桃派镜像配置编辑器 + MatrixImageEditor + 1.0.1 + 1.0.1.0 + 1.0.1.0 + true + true + win-x64 + 8.0.29 + true + true + none + false + + diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/Program.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/Program.cs new file mode 100644 index 0000000..c2d4879 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/Program.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace MatrixImageEditor; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + try + { + if (args.Length > 0) + { + return RunCommand(args); + } + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + return 0; + } + catch (Exception exception) + { + if (Environment.UserInteractive && args.Length == 0) + { + AppDialog.Error(null, exception.Message, "镜像配置编辑器"); + } + else + { + Console.Error.WriteLine(exception.Message); + } + return 1; + } + } + + private static int RunCommand(string[] args) + { + if (args is ["--validate", var image]) + { + var config = ImageOperations.ReadConfig(image); + var publicInfo = new + { + valid = true, + config.SoftwareVersion, + config.Account.Username, + config.Wifi.Ssid, + config.Ipv4.Mode, + config.Ipv4.Address, + }; + Console.WriteLine(JsonSerializer.Serialize(publicInfo)); + return 0; + } + if (args is ["--apply", var source, var configPath, var destination]) + { + var sourcePath = ImagePathRules.ValidateExistingImage(source); + var destinationPath = ImagePathRules.ValidateNewDestination(sourcePath, destination); + var config = ImageConfigCodec.Deserialize(File.ReadAllText(configPath)); + _ = ImageOperations.ReadConfig(sourcePath); + try + { + File.Copy(sourcePath, destinationPath, overwrite: false); + ImageOperations.WriteConfig(destinationPath, config); + ImageOperations.WriteSha256(destinationPath, null); + _ = ImageOperations.ReadConfig(destinationPath); + } + catch (Exception exception) + { + var cleanupError = ImagePathRules.CleanupNewDestination(destinationPath); + if (cleanupError is not null) throw new IOException($"{exception.Message}\n清理失败:{cleanupError}", exception); + throw; + } + Console.WriteLine(destinationPath); + return 0; + } + Console.Error.WriteLine("用法:镜像配置编辑器.exe [--validate 镜像 | --apply 原镜像 配置.json 新镜像]"); + return 2; + } +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/README.md b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/README.md new file mode 100644 index 0000000..cd5d5ec --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/README.md @@ -0,0 +1,9 @@ +# 构建镜像配置编辑器 + +目标为 Windows 10/11 x64、.NET 8、自包含单文件。版本同时记录在 `VERSION` 与 `MatrixImageEditor.csproj`。 + +```powershell +dotnet publish .\MatrixImageEditor.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true +``` + +发布后必须用正式 IMG 覆盖读取、另存为、双槽回退、写后 SHA-256、错误产品、未知 schema、截断和错误摘要;另存名称还必须覆盖中文、空格、长路径、Windows 保留名、既有目标和孤立同名摘要。界面在 100%、150%、200% DPI 及低分辨率、1080p、4K 工作区检查输入框、滚动区和全部应用内提示框。全部通过后再将唯一 EXE、版本与摘要放入 `../编辑器程序/`。源码与程序均不得内置发布默认密码,也不得读取项目凭据文件。 diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ResponsiveLayout.cs b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ResponsiveLayout.cs new file mode 100644 index 0000000..068d331 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/ResponsiveLayout.cs @@ -0,0 +1,28 @@ +namespace MatrixImageEditor; + +internal static class ResponsiveLayout +{ + public static void FitAndCenter(Form form, Size preferredLogical, Size minimumLogical, double workingAreaFraction, Screen? screen = null) + { + screen ??= Screen.FromControl(form); + var workingArea = screen.WorkingArea; + var scale = Math.Max(form.DeviceDpi, 96) / 96F; + var preferred = Scale(preferredLogical, scale); + var minimum = Scale(minimumLogical, scale); + var maximum = new Size( + Math.Max(320, (int)Math.Floor(workingArea.Width * workingAreaFraction)), + Math.Max(240, (int)Math.Floor(workingArea.Height * workingAreaFraction))); + + var width = Math.Min(preferred.Width, maximum.Width); + var height = Math.Min(preferred.Height, maximum.Height); + form.MinimumSize = new Size(Math.Min(minimum.Width, maximum.Width), Math.Min(minimum.Height, maximum.Height)); + form.Size = new Size(Math.Max(width, form.MinimumSize.Width), Math.Max(height, form.MinimumSize.Height)); + form.Location = new Point( + workingArea.Left + Math.Max(0, (workingArea.Width - form.Width) / 2), + workingArea.Top + Math.Max(0, (workingArea.Height - form.Height) / 2)); + } + + private static Size Scale(Size logical, float scale) => new( + Math.Max(1, (int)Math.Round(logical.Width * scale)), + Math.Max(1, (int)Math.Round(logical.Height * scale))); +} diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/VERSION b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/VERSION new file mode 100644 index 0000000..7dea76e --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器源代码/VERSION @@ -0,0 +1 @@ +1.0.1 diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/README.md b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/README.md new file mode 100644 index 0000000..3b21363 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/README.md @@ -0,0 +1,9 @@ +# 镜像配置编辑器 1.0.1 + +双击 `核桃派镜像配置编辑器.exe` 即可运行,不需要安装 .NET、写注册表或复制 DLL。支持 Windows 10/11 x64。 + +打开 IMG 后可查看账户、Wi-Fi 和 IPv4 设置;密码默认隐藏。“修改原镜像”会先二次确认,“另存为”会提示使用 Windows 合法的新名称并保留原文件。中文、空格和长路径均受支持;保存后会在 IMG 旁生成无 BOM UTF-8 的对应 `.sha256` 并重新验证。主窗口和应用内提示框会随系统缩放、显示器和分辨率自动调整。 + +1.0.1 修复了中文另存文件名导致摘要格式错误的问题,并放大、修复了密码输入框和全部应用内对话框。 + +本程序不负责写 TF 卡;完成配置后请使用 Rufus 写入 IMG。不要删除或改写正式发布 IMG,日常优先使用“另存为”。 diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/SHA256SUMS b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/SHA256SUMS new file mode 100644 index 0000000..b527376 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/SHA256SUMS @@ -0,0 +1 @@ +bcee5a87b654999b5649948bbed49b9071ebb09e5e9caef5cc8f22331ee482c3 核桃派镜像配置编辑器.exe diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/VERSION b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/VERSION new file mode 100644 index 0000000..7dea76e --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/VERSION @@ -0,0 +1 @@ +1.0.1 diff --git a/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/核桃派镜像配置编辑器.exe b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/核桃派镜像配置编辑器.exe new file mode 100644 index 0000000..5061a12 --- /dev/null +++ b/各种归档/20260908_镜像编辑器1.0.1_CSharp旧版/编辑器程序/核桃派镜像配置编辑器.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcee5a87b654999b5649948bbed49b9071ebb09e5e9caef5cc8f22331ee482c3 +size 71605504 diff --git a/整体开发需求/01_网页配置端需求.md b/整体开发需求/01_网页配置端需求.md new file mode 100644 index 0000000..0d06def --- /dev/null +++ b/整体开发需求/01_网页配置端需求.md @@ -0,0 +1,1234 @@ +# 01 网页配置端需求 + +本文描述核桃派 ZeroW 在局域网内启动的网页配置端。这个网页是用户后续操作点阵屏幕的主要入口,当前阶段提供可叠加的像素与多文字内容编辑,以及纯色、诊断、清屏等独占设备测试能力。 + +项目默认一台核桃派只做这一件事:开机后自动启动网页服务和屏幕控制服务,同一局域网内的手机、电脑或平板通过 `http://核桃派IP:8080` 访问。 + +## 0. 需求编号索引 + +本文件使用 `WEB-*`、`CONFIG-*` 和 `DEPLOY-*` 编号描述网页端、接口、配置和运行入口。测试覆盖关系维护在 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`,不要在本文件复制完整测试命令。 + +| 编号 | 范围 | 当前阶段要求 | +|---|---|---| +| `WEB-ENTRY` | 网页入口和整体页面 | 局域网设备访问移动端优先的控制面板,首页直接进入可操作界面。 | +| `WEB-WORKSPACES` | 可扩展工作区和导航 | 工作区通过注册信息生成单层侧栏和 URL hash;用户可在内存草稿中上移、下移并显式保存设备共享顺序。 | +| `WEB-PREVIEW` | 共享正向预览与设备监视器 | 普通编辑工作区共用页面顶部的逻辑 `0°` 组合画板;设备状态、系统设置和模板管理在同一位置显示当前设备逻辑帧的只读监视画面。 | +| `WEB-COMPOSITION` | 统一场景与图层合成 | 像素层固定在最底部,有序元素依次叠加;设备测试独占覆盖但不得销毁场景草稿。 | +| `WEB-BACKEND` | FastAPI、REST、WebSocket、静态文件托管 | 服务监听 `0.0.0.0:8080`,页面通过 API/WS 调用显示服务。 | +| `WEB-STARTUP-INDICATOR` | 开机运行指示兼容资源 | 旧开机笑脸不再作为独立启动覆盖层;其两态像素资源继续供只读演示案例和默认演示动图复用。 | +| `WEB-DEFAULT-DISPLAY` | 默认显示内容 | 模板管理可把演示案例、用户静态模板或非空用户动图设为默认;开机和启动提示结束后显示该内容,失效时回退演示动图。 | +| `WEB-CURRENT-DISPLAY` | 顶栏当前画面 | 顶栏持续显示当前实际可见内容并可打开实屏帧预览;活动动图还提供整段跳转、暂停/继续和固定倍速控制,控制状态只属于当前播放会话。 | +| `WEB-WIFI-SETTINGS` | WiFi、IPv4 与网络提示设置 | 系统设置可修改受管 WiFi、明文显示密码、DHCP/静态 IPv4,并分别选择立即切换或下次断电开机生效;网络提示延迟使用下方独立设置卡保存。 | +| `WEB-NETWORK-DIAGNOSTICS` | 分层网络检测 | 系统设置可检测接口/IP、默认路由、DNS、外网 TCP/TLS 和辅助 ping,以证据区分未连接、网关、DNS、时间/TLS 和目标站点异常。 | +| `WEB-FRP-MAINTENANCE` | FRP 远程维护 | 系统设置底部管理多个 FRP 原文配置,选择一个由独立 systemd 服务启停,保存前必须通过官方 `frpc verify`。 | +| `WEB-ASSET-VERSION` | 前端版本和缓存一致性 | 首页和动态 API 不缓存;同一次页面加载只使用同一内容摘要版本的静态资源,旧页面能发现服务升级。 | +| `WEB-MULTI-CLIENT` | 多标签和多设备一致性 | 浏览器草稿保持本地边界,共享设备状态及时刷新,草稿和模板冲突不得静默覆盖。 | +| `WEB-INTERACTION-FEEDBACK` | 控件可用性、处理中与失败反馈 | 所有操作区分可用、条件未满足、处理中、成功和失败;桌面、键盘与触屏都能获知不可用原因,任何网络操作都必须有界结束并恢复控件。 | +| `WEB-SYSTEM-RESOURCES` | 应用与整机资源概览 | 顶栏低频实时显示当前服务进程及整机的 CPU、内存占用,不记录历史、不提供配置开关。 | +| `WEB-OTA` | 软件版本与浏览器全量更新 | 系统设置显示正式软件版本和固定功能更新时间,并允许上传单个全量 `.ota` 包;页面仅在上传、安装或失败时显示临时进度,不展示最近 OTA 结果,切换服务期间允许短暂断线后自动恢复。 | +| `DEPLOY-SYSTEMD` | systemd 自启动 | 核桃派开机后自动启动服务,具体远端验证见测试文档。 | +| `DEPLOY-NETWORK-MANAGER` | 无线网络运行依赖 | 服务和镜像首启依赖 NetworkManager 而不依赖网络已在线;通过参数化 `nmcli` 管理 `wlan0` 的受管连接。 | +| `DEPLOY-UPDATE` | 手工部署与浏览器 OTA | 手工部署和全量 OTA 共用离线构建、原子切换、健康检查与失败回滚边界;拒绝同版和旧版,成功后删除旧程序与暂存。 | +| `DEPLOY-RELEASE-VERSION` | OTA 与镜像共用版本 | 只有明确导出成功才推进同一三段版本并登记发布记录;失败不占号;导出 IMG 可删除,OTA 长期保留(1.0.1 为历史例外)。 | +| `DEPLOY-FEATURE-TIMESTAMP` | 功能更新时间 | 每次可部署的功能新增或优化写入源码固定北京时间;单纯导出 OTA/镜像、重复部署和文档修改不得改写。 | +| `DEPLOY-IMAGE-EXPORT` | Rufus 可刷镜像 | 从未修改官方 IMG 复制生成;FAT16 只携带双槽配置、元数据和首启程序,Linux 根分区携带应用与已验证预编译内核离线载荷,元数据格式固定为 v3。 | +| `DEPLOY-IMAGE-FIRSTBOOT` | 无外设自动首启 | 只连接核桃派本体即可离线安装应用与双内核候选、配置身份和网络、清理秘密并自动重启;SSID、认证、DHCP 或默认路由不可用不阻断安装,真实安装失败恢复原 boot 文件且不循环重启。 | +| `DEPLOY-OFFLINE-DEPS` | 离线依赖生命周期 | Python wheel、Debian AArch64 包、预编译内核及固定内核源码均有摘要与用途;代码依赖增删必须同次同步。 | +| `DEPLOY-WORKSPACE-LAYOUT` | 工作区路径与独立性 | 适配分类目录,支持中文、空格及不同调用目录;清理不越界,包内路径与设备路径保持兼容。 | +| `DEPLOY-REPOSITORY-HYGIENE` | Git 仓库安全与可移植性 | 私有凭据与示例分离,阻止秘密和开发机路径提交,并以普通 Git 保留正式产物、离线依赖和历史归档。 | +| `DEPLOY-FRP` | FRP 系统组件 | 固定、校验并离线交付官方 Linux ARM64 `frpc`,安装到 `/usr/local/bin`,由维护账户运行且纳入首装、手工更新和 OTA 回滚。 | +| `DEPLOY-IMAGE-EDITOR` | 便携镜像编辑器 | Python/PySide6 跨平台源码读取、修改或另存账户、WiFi 和 IPv4;Windows 交付绿色单 EXE,编辑器版本独立于软件版本。 | +| `DEPLOY-FONTS` | 多语言字体运行依赖 | 部署环境提供 Fontconfig、Noto Core 和 Noto CJK,服务启动后能解析主流现代文字体系。 | +| `WEB-FILL` | 全屏纯色测试 | 支持黑、红、绿、蓝、白和持久自定义颜色;测试使用可恢复的临时亮度会话。 | +| `WEB-DEVICE-STORAGE` | 设备与软件容量概览 | 设备状态页显示设备总量、可用量、软件总占用和模板内容占用,使用有界缓存而非随状态轮询反复扫描。 | +| `WEB-DIAGNOSTIC` | 硬件诊断图案 | 支持发送四角横线、行带、地址检查和文字复现图案,用于排查重复/错位显示。 | +| `WEB-CANVAS` | 像素底层和 WebSocket 帧 | 维护 `64 x 64 x RGB` 像素底层,合成后通过 `WS /ws/canvas` 发送最终整帧。 | +| `WEB-CANVAS-EDIT` | 防误触全屏编辑与画布视角 | 像素画布默认只读,经明确入口进入全屏绘画;编辑期提供可移动悬浮菜单、会话撤销以及独立的平移缩放模式。 | +| `WEB-COLOR-UI` | 统一颜色选择与绘画工具 | 使用软件自有的 RGB/HSV 取色面板、最近颜色、共享色板和触摸友好的画笔工具,不调用系统颜色选择器。 | +| `WEB-ORIENTATION` | 页面方向设置 | 支持 `0/90/180/270`,保存后重启仍生效。 | +| `WEB-BRIGHTNESS` | 页面实时亮度设置 | 拖动亮度条时持续应用并保存最新值,当前画面立即反馈且不需要再次发送。 | +| `WEB-MATRIX-REFRESH` | 屏幕扫描刷新率挡位 | 系统设置提供固定 HUB75 扫描上限挡位,选择后在网页服务不断线的情况下立即应用并保存。 | +| `WEB-PERFORMANCE-MODE` | 可选 CPU 性能模式 | 系统设置提供默认关闭的性能模式;只有实际 cpufreq policy 可切换时才允许开启,应用、持久化和回滚必须组成事务,服务停止时恢复启动前 governor。 | +| `WEB-SCREEN-VOLTAGE` | 屏幕输入电压状态与校准 | 顶栏显示当前屏幕输入电压,设置页提供每台设备独立的软件校准流程,传感器故障不得拖垮显示服务。 | +| `WEB-LOW-VOLTAGE-PROTECTION` | 低电压提示与保护开关 | 可选保护按校准后的屏幕输入电压限制实际亮度或覆盖低电图标,页面显示当前限制且不改写用户显示设置。 | +| `WEB-TEXT` | 多文字元素 | 每组文字是可独立选择、编辑、拖动、等比缩放、复制和删除的透明元素。 | +| `WEB-TEXT-I18N` | 多语言静态文字 | `default` 自动选择能覆盖全文的字体,支持常见现代语言且不以缺字方框代替失败。 | +| `WEB-TEXT-FONTS` | 字体选择与导入 | 文字编辑器使用可搜索的设备字体目录,允许导入受校验的字体文件且不要求用户输入服务器路径。 | +| `WEB-TEMPLATES` | 可编辑场景模板 | 内容工作区可保存或更新完整场景模板;模板管理统一提供播放、编辑、复制、重命名、删除和混合排序,动图卡片按完整帧序列动态预览,播放与编辑草稿互不影响;动态预览与动图管理共享设备配置的并发上限。 | +| `WEB-ANIMATIONS` | 动图文件夹与逐帧编辑 | 把有序 scene v1 帧按独立间隔循环播放;网页负责文件夹、帧顺序、逐帧编辑、复制目标和播放入口,卡片名称单行省略并提供非触摸悬停全名,缩略图按完整帧序列动态预览;动态预览与模板管理共享设备配置的并发上限。 | +| `WEB-MEDIA-IMPORT` | 媒体内容转换 | 流式导入常见图片、动图和视频,在设备后台统一裁切、缩放、透明合成和逐帧转换为静态模板或动图。 | +| `WEB-API` | REST/WS 契约 | 定义 `/api/status`、`/api/config`、`/api/display/*`、`/api/preview/*`、`/ws/canvas`,包括带会话防护的活动动图运行时控制。 | +| `CONFIG-DATA-LIFECYCLE` | 持久、可再生、运行期与更新暂存数据 | 生产持久根固定为 `/var/lib/matrix-screen-controller`,运行根固定为 `/run/matrix-screen-controller`;格式只向唯一当前 schema 迁移,失败时保留原件并拒绝启动。 | +| `CONFIG-OTA-STATE` | OTA 活动状态和最近结果 | 活动任务只写运行根;持久根只保存一条有 schema 的最近结果和未完成事务恢复信息,不保存包历史或更新日志索引。 | +| `CONFIG-BASE` | 配置文件和默认值 | 配置保存在持久根的 `config.json`;仅文件缺失时创建默认值,现有文件损坏或无法迁移时不得静默回落。 | +| `CONFIG-WIFI` | WiFi 连接身份与开机提示延迟 | 持久根的 `wifi_config.json` v1 保存受管连接 UUID 和 `prompt_delay_seconds`;SSID、密码与 IPv4 仍由 NetworkManager 保存。 | +| `CONFIG-FRP` | FRP 配置目录 | 持久根 `frp/` 以严格 v1 目录保存多个 UUID 配置和唯一选中项;默认无配置、服务关闭,损坏时保留原件并只关闭 FRP 子系统。 | +| `CONFIG-PREVIEW-REFRESH` | 当前帧监视刷新间隔 | 按设备保存 `preview_refresh_interval_ms`,默认 `1000ms`;允许 `1..60000ms`,低于 `100ms` 时提示负载风险但不阻止保存。 | +| `CONFIG-UI-STATE` | 浏览器场景草稿与编辑工具状态 | 场景使用稳定键 `matrixController:scene`;编辑工具使用独立带版本键,并兼容迁移已有画布、文字和工具状态。 | +| `CONFIG-TEMPLATES` | 核桃派模板持久化 | 完整 scene v1 和有效后端缩略图保存在持久根的 `templates/`;部署、重启和一般清理不得删除。 | +| `CONFIG-ANIMATIONS` | 核桃派动图持久化 | 动图 v2 元数据、独立 scene v1 帧和有效缩略图保存在持久根的 `animations/`;播放按不可变快照懒加载,升级不得删除。 | +| `CONFIG-MEDIA-JOBS` | 媒体转换任务 | 持久根保存活动任务、源文件、预览和暂存输出;成功或取消立即清理,失败与待设置任务最多保留 24 小时。 | +| `CONFIG-FONTS` | 用户导入字体持久化 | 导入字体按内容摘要保存在持久根的 `fonts/`,重启、更新、回滚和一般清理不得删除。 | +| `CONFIG-LIBRARY-ORDER` | 模板管理混合顺序 | 用户静态模板和用户动图的统一顺序保存在持久根的 `library/order.json` v1;演示案例不进入记录。 | +| `CONFIG-COLOR-PALETTE` | 设备共享收藏色 | 按 `CONFIG-DATA-LIFECYCLE` 在设备配置中持久化最多 24 个规范化的 `#RRGGBB` 收藏色,供所有浏览器共享。 | +| `CONFIG-SCREEN-VOLTAGE` | 电压传感器校准 | 按 `CONFIG-DATA-LIFECYCLE` 为每台设备独立保存校准系数、参考值、未校准值和校准时间;新设备默认使用标称系数 `1.0`。 | +| `CONFIG-LOW-VOLTAGE-PROTECTION` | 低电压保护开关 | 配置 v3 严格保存布尔字段 `low_voltage_protection_enabled`,新建和升级设备均默认关闭。 | +| `CONFIG-MATRIX-REFRESH` | 屏幕扫描刷新率上限 | 配置 v4 严格保存 `matrix_refresh_rate_limit_hz`,只允许 15、20、30、45、60、80、100,默认 100。 | +| `CONFIG-TEST-COLOR` | 自定义纯色测试颜色 | 配置 v5 严格保存规范 `#RRGGBB` 字段 `custom_test_color`,v4 升级默认 `#40A0FF`。 | +| `CONFIG-DEFAULT-CONTENT` | 默认显示内容引用 | 配置 v6 严格保存 `{type,id}`,只引用静态模板或非空动图;v5 升级默认引用只读“演示动图”。 | +| `CONFIG-WORKSPACE-ORDER` | 设备共享工作区顺序 | 配置 v7 严格保存唯一工作区 ID 数组;保存后跨浏览器和重启生效,未保存编辑不持久化。 | +| `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 | +| `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v10 严格保存整数 `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 | +| `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 | +| `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 | +| `WEB-SECURITY` | 局域网安全边界 | 第一阶段默认可信局域网,保留访问密码和上传限制扩展入口。 | +| `WEB-ERRORS` | 日志和错误展示 | 后端记录关键事件,前端显示屏幕可用性和最近操作结果。 | +| `WEB-UI-COPY-CATALOG` | 网页程序文案目录 | 为固定文字、属性文字和动态状态模板分配稳定文案键,动态值使用受保护占位符。 | +| `WEB-UI-COPY-EDITOR` | 特殊构建文案编辑器 | 仅后端构建标识开启时提供全局可视化文案编辑、字号预览、隐藏和响应式新增提示。 | +| `CONFIG-UI-COPY-DRAFT` | 文案编辑草稿 | 特殊构建把结构化变更集原子保存到持久数据根,并用目录摘要和修订号阻止错误覆盖。 | +| `DEPLOY-UI-COPY-EDITOR` | 文案编辑特殊版本 | 编辑能力只能由后端源码构建标识启用;正式版本不注册接口、不挂载资源且不生成前端入口。 | + +## 1. 技术路线 + +### 1.1 后端服务(`WEB-BACKEND`) + +- 使用 `FastAPI + Python` 做常驻服务。 +- 服务监听 `0.0.0.0:8080`,允许局域网其他设备访问。 +- 静态前端文件由 FastAPI 托管。 +- 普通配置和按钮操作走 REST API。 +- 透明文字层预览走 REST API,统一画板最终帧走 WebSocket。 +- 页面在 `DisplayService` 上层维护无 DOM 场景模型与合成控制器,底层只接收最终 RGB 帧。 +- 服务内部调用屏幕底层控制接口,不让网页前端直接接触 GPIO 或 HUB75 细节。 +- 服务启动时按静态目录中排序后的相对路径和文件内容计算稳定摘要版本;任一前端文件变化都必须产生新的版本路径。 +- 首页和动态 `/api/*` 返回 `Cache-Control: no-store`;带内容摘要查询参数的模板缩略图继续使用长期缓存。当前版本静态资源使用 `/static//...` 和长期 `immutable` 缓存;旧 `/static/...` 路径继续提供但不得缓存,用于兼容升级前页面。 + +推荐进程形态: + +```text +浏览器页面 + -> REST API / WebSocket + -> FastAPI 常驻服务 + -> DisplayService 屏幕抽象接口 + -> walnutpi-h618-hub75 + -> HUB75 点阵屏 +``` + +### 1.2 前端页面(`WEB-ENTRY`) + +第一阶段不需要复杂前端框架,可以先用原生 HTML/CSS/JavaScript 实现。后续如果界面变复杂,再迁移到 React/Vue 等框架。 + +前端必须围绕 `64 x 64` 像素屏幕设计,不要把它当普通高分辨率显示器: + +- 页面只有一个共享画板,用浏览器 Canvas 放大显示最终 `64 x 64` 组合场景。 +- 前端预览和后端实际显示都以同一套坐标为准。 +- 所有发送到屏幕的画面最终都要变成 `64x64 RGB` 帧。 +- 像素底层和文字等元素由场景模型管理,不能依靠工作区 DOM 的显示/隐藏状态决定合成结果。 +- JavaScript 模块使用相对导入并从同一版本目录加载,禁止新 HTML、旧 CSS 或不同版本 JavaScript 模块混装。 + +### 1.2.1 控件状态与操作反馈(`WEB-INTERACTION-FEEDBACK`、`WEB-ERRORS`) + +- 每个可触发操作的按钮、开关或等价控件必须明确处于“可用、条件未满足、处理中”之一;成功和失败是操作结束结果,不得与处理中共用视觉状态。 +- 条件未满足不等于处理中。此类控件使用 `aria-disabled="true"` 保留键盘焦点和解释入口,鼠标使用不可用光标,不得显示忙碌圆环、进度光标或无法结束的加载动画。 +- 条件未满足必须提供具体、可执行的中文原因。桌面端在鼠标悬停或键盘聚焦时显示锚定说明;触屏点击时显示页面内固定提示条并同步无障碍播报。提示不得只依赖 hover,也不得使用阻塞式系统对话框。 +- 真正的异步处理中才允许原生禁用控件,并同时设置 `aria-busy="true"`、明确的“正在……”文案或邻近状态;成功、失败、超时和异常都必须在 `finally` 等确定路径恢复控件。 +- JSON、Blob 等普通 HTTP 请求默认 `15s` 超时,超时统一提示“请求超时,请检查核桃派网络或服务状态后重试”。画板 WebSocket 保持独立 `10s` 超时。用户主动打开的对话框和持续编辑不属于网络等待,不自动超时。 +- 页面初始化、工作区进入、列表刷新和操作回调的异常必须显示在当前页面或顶栏状态区;不得只写浏览器控制台、静默吞掉异常或留下永久禁用控件。 +- 新增控件时必须通过共享状态模块声明不可用原因和忙碌状态,并补充 `TEST-INTERACTION-FEEDBACK` 映射;禁止直接新增无解释的静态 `disabled` 操作按钮。 +- 可拖动卡片只能从非交互区域开始拖动。按钮、输入框、选择框、链接、可编辑元素及其外围 `8 CSS px` 均为起拖保护区;从保护区按下不得阻止控件本身的选择、点击或键盘行为。拖动已经开始后经过或落到保护区不取消拖动。 +- 单像素编辑模式开关属于始终可用的同步切换控件,使用 `aria-pressed` 表达状态;切换完成后同时更新邻近持久说明和顶栏无障碍播报,不伪装成异步忙碌操作。 + +### 1.2.1.1 程序文案目录与特殊编辑器(`WEB-UI-COPY-CATALOG`、`WEB-UI-COPY-EDITOR`) + +- 网页中由程序编写的标题、按钮、标签、说明、占位符、不可用原因、状态和错误模板必须显式登记稳定 `copy_id`;不得按当前 DOM 文字猜测或全页扫描文案。实时数值、功能数值、用户输入或命名、SSID/IP、日期、容量以及后端返回的原始错误内容不属于可编辑文案,也不得显示选择轮廓。 +- 动态程序文案只能通过统一文案渲染入口绑定目标;使用 `{name}` 占位符时,编辑后的占位符名称和出现次数必须与基线完全相同。变量值只读,文字始终按纯文本渲染,不接受 HTML。 +- 特殊构建在任意工作区右下角提供固定呼出按钮。编辑器支持浏览、选择文字和添加提示三种模式;选择模式不得触发被选中的原操作,浏览模式保持应用正常可用。 +- 现有文案可修改内容、设置统一适用于全部视口的 `8..64 CSS px` 字号或通过醒目的“删除这行文字”二次确认仅隐藏视觉文字。隐藏按钮或字段文字时必须保留可靠的无障碍名称,不得删除关联功能;新增提示提供独立的二次确认删除入口。 +- 新提示只能相对已登记的页面锚点在上方或下方插入,进入正常响应式文档流;不得使用绝对坐标遮挡页面控件。 +- 文字和字号输入只修改编辑面板内的暂存值,不得同时修改正文或发送请求。每个项目由用户点击“确定修改”后先以当前 `If-Match` 保存完整草稿,保存成功才一次性注入正文;失败或冲突时正文保持原样并保留表单内容。输入框清空后确认必须显示行内错误、恢复本次编辑前文字且不发送请求。 +- 恢复默认、当前会话撤销、新增提示和二次确认删除同样遵守“先保存、成功后注入”;界面必须区分未确认、保存中、已保存、失败和冲突。编辑器自身的目录或状态更新不得触发应用区域的 MutationObserver 循环。 +- 构建标识关闭时,首页不包含编辑器引导信息或按钮,编辑 API 与编辑资源均不存在;任何前端操作都不能改变构建标识。 + +`WEB-UI-COPY-CATALOG` 当前目录 schema 为 v2,每项明确属于静态文字、属性文字或显式动态模板;功能数值和数据值不得进入目录。正式合并后的隐藏文案继续以 `default_hidden` 和原始纯文本保留在目录中,普通构建不渲染可见文字但保留可靠无障碍名称;以后再次开启特殊构建时仍可搜索并恢复显示,不能因定稿隐藏而丢失文案键。`CONFIG-UI-COPY-DRAFT` 继续使用独立 schema v1,至少包含目录摘要、修订号、更新时间、按 `copy_id` 的覆盖项和响应式插入项。文件只在特殊构建中读取和写入,使用严格字段校验和原子替换;损坏、未来版本、未知键、非法字号、非法锚点或目录摘要不一致时保留原件并拒绝编辑,不得静默重建。目录升级必须先导出旧草稿,再用显式迁移报告记录保留和排除项并原子更新摘要;失败时同时回滚程序和草稿。 + +### 1.2.2 页面版本和多客户端状态(`WEB-ASSET-VERSION`、`WEB-MULTI-CLIENT`) + +- `GET /api/status` 提供应用版本、当前服务实例标识和设备状态修订号;成功改变显示帧、方向或亮度时修订号递增,只读与预览请求不得递增。 +- 页面可见时每 5 秒读取一次状态,重新获得焦点时立即读取;检测到另一页面改变实屏状态时更新模式、方向和亮度展示并提示,但不得自动覆盖或发送本地画板。 +- 检测到应用版本变化后持续提示刷新;本地画板编辑和导出仍可用,但旧页面不得继续提交设备、配置、色板、模板或 WebSocket 写操作。 +- 不同浏览器或设备的 scene v1 草稿继续相互独立。相同来源的多个标签页写入同一草稿时,收到 `storage` 变化的标签页必须暂停继续持久化,并让用户明确选择载入外部草稿或以当前草稿覆盖。 +- 共享物理屏幕仍采用最后一次成功命令生效,不引入用户账户、编辑锁或实时协作画板。 + +`WEB-CURRENT-DISPLAY` 把顶栏当前画面改为可聚焦按钮。点击后使用原生对话框读取 `GET /api/display/current-frame`:静态模板、未保存内容和系统覆盖只显示当前逻辑帧,点击预览任意位置、遮罩或按 `Escape` 关闭;活动动图显示同一预览、整段循环进度、播放/暂停以及 `0.5x/1x/1.5x/2x` 四档倍速。动图对话框只允许遮罩、关闭按钮或 `Escape` 关闭,操作控件不得误触关闭。 + +- 动图进度按全部帧 `duration_ms` 之和计算,播放期间网页平滑外推位置,并按已有 `preview_refresh_interval_ms` 单请求递归刷新当前逻辑帧。拖动期间不得被状态轮询抢回旧位置;网页最多每 `100ms` 提交一次跳转、同一时刻最多一个控制请求,始终保留并最终提交最新位置。 +- 暂停、跳转和倍速直接控制实屏后台播放;跳转保持原暂停状态,关闭对话框不得自动继续。播放任何静态内容、新动图或重新播放同一动图都创建新的运行时会话,位置归零、恢复播放和 `1x`,并使旧页面尚未完成的控制请求失效。 +- 播放会话、位置、暂停和倍速只保存在显示服务内存,不写入设备配置或用户资源。当前内容或播放会话改变时,已打开页面立即关闭旧对话框并丢弃局部控制状态;其他浏览器最迟在既有 `5s` 状态轮询内同步。 + +### 1.2.2 应用与整机资源概览(`WEB-SYSTEM-RESOURCES`) + +- 顶栏按 `CPU、RAM、低电压保护警告、电压` 排列;保护未活动时警告元素隐藏。CPU 和内存仍是两个可点击的紧凑按钮,外层固定显示 `CPU 0.7核 · 16%` 和 `RAM 60M · 48%` 这种“应用值 · 整机值”格式,不在顶栏重复“应用”“总计”等说明文字;首次采样显示 `CPU -`、`RAM -`,异常显示 `CPU !`、`RAM !`。 +- CPU 的应用值表示当前 FastAPI 服务进程折合占用的逻辑核心数,可在 `0..逻辑核心总数` 间变化;整机值表示全部逻辑核心综合后的非空闲百分比。内存应用值显示当前服务进程常驻内存(RSS)的整数 MiB 简写,整机值表示按 `MemTotal - MemAvailable` 计算的整机内存百分比。 +- 点击 CPU 或 RAM 均打开同一个“资源占用详情”原生对话框,不能依赖鼠标悬停。弹层显示应用 CPU 的核心数、折合单核百分比、占整机百分比、整机总占用,以及从 `CPU 1` 开始编号的每核心进度条;内存区显示应用 RSS 的准确 MiB、应用百分比和整机总占用。弹层提供明确关闭按钮,支持触控、键盘焦点和 Escape。 +- 常驻后端监测组件每 `5s` 读取一次 Linux `/proc` 并缓存最新快照;API 请求只复制缓存,不能为每个浏览器、每次状态请求重复计算或启动采样。首次 CPU 样本允许只显示占位符,取得时间差分后再显示百分比。 +- 顶栏沿用页面已有的 `5s` 状态轮询,不新增前端定时器或接口;页面隐藏时停止状态请求,重新可见时读取最新缓存。窄屏允许 CPU、RAM、保护警告和电压自然换行,但不得产生横向滚动或把完整说明重新塞回顶栏。 +- `GET /api/status` 返回独立的 `resources` 对象,继续保留 `cpu.application_percent`、`cpu.total_percent`、`memory.application_bytes`、`memory.application_percent` 和 `memory.total_percent`,并增加 `cpu.logical_cpu_count`、`cpu.application_core_equivalent` 和按 `/proc/stat` 的 `cpu0..cpuN` 顺序排列的 `cpu.cores_percent`。首次差分样本的 CPU 百分比和核心数允许为空;采样失败时返回稳定错误状态和空值,弹层解释读取失败,不得影响屏幕、电压、配置或其他 API。 +- 资源快照只存在当前进程内存中,不写入配置、持久数据根、运行数据根或日志历史;当前阶段不提供开关、刷新频率设置、告警阈值、历史曲线或导出功能。 + +### 1.2.3 屏幕输入电压状态(`WEB-SCREEN-VOLTAGE`) + +- FastAPI 进程内只有一个电压监测组件持续访问 ADS1110,其他组件和请求只读取其线程安全缓存;`DisplayService` 不得直接访问 ADC,监测组件只在自身锁和 I²C 锁外发布不可变保护指令。 +- 每组固定取 `5` 个 15 SPS 新样本的中位数,保护判定使用校准后、未舍入的组中位数。组起始间隔在保护关闭或稳定高于 `4.8V` 时为 `5s`;保护开启且高于 `4.8V` 但持续下降时,按预计到达 `4.8V` 的时间缩短到 `1..5s`;`4.5..4.8V` 为 `1s`;首次低于 `4.5V` 为 `0.5s`,之后使用 `clamp(0.5, 2.0, 0.02 / max(|dV/dt|, 0.01))`,放慢每轮最多增加 `0.5s`、加速立即生效。连续读取错误按 `1s、2s、5s` 退避。 +- 每次 I²C 尝试分配单调序号,晚返回的旧结果不得覆盖新结果。传感器不可用、读取失败、保护回调失败或重连不得阻塞网页和其他 API;回调失败与 ADC 读取状态分开记录并在后续周期重试。 +- 顶栏标题右侧显示校准后的当前电压并固定保留两位小数;首次读取、断开和异常分别显示“正在读取电压…”“电压传感器断开”“电压读取异常”。 +- `GET /api/status` 返回独立的 `power.screen_input_voltage` 对象,包含 `status`、有效时的 `volts` 和 `sampled_at`、`calibrated` 以及稳定的 `error_code`。失败不得伪装成 `0V`。 +- 状态缓存内部保留未舍入电压、raw 中位数、采样时间和错误类别,供低电压保护使用;保护只做软件降载与提示,不是 BMS、物理断电、充电管理或核桃派关机保护。 + +### 1.2.4 电压传感器软件校准(`WEB-SCREEN-VOLTAGE`、`CONFIG-SCREEN-VOLTAGE`) + +- 系统设置页显示当前电压、传感器状态、是否已校准、校准时间,并提供“开始校准”和“恢复标称值”。 +- 校准时整机保持正常约 `5V`,用户把可靠万用表并联到 ADC 相同的屏幕输入 `5V/GND` 测量点并输入 `4.500..5.500V` 参考值;页面不得引导用户调高或调低整机电源。 +- 预览操作通过唯一监测组件取得 `15` 个新样本的中位数,按 `参考电压 / 未校准电压` 计算建议系数;不得与旧系数连乘。建议系数必须在 `0.8..1.2`,否则拒绝生成可保存提案。 +- 预览只返回参考值、未校准值、当前系数、建议系数和预计值,不修改配置。确认接口只接受同一服务实例最近生成且尚未过期的提案;传感器状态变化或提案超过 `120s` 时拒绝。 +- 确认后持久化本机校准记录并触发一次新采样;恢复标称值将系数设回 `1.0` 并清空校准元数据。两者都不得改变屏幕帧、模式、方向、亮度或设备修订号。 +- 校准记录只属于当前核桃派、当前 ADC 和当前测量点。正常部署、更新包和未来 OTA 必须按 `CONFIG-DATA-LIFECYCLE` 保留持久根的 `config.json`;更换模块或测量接线后必须恢复标称值并重新校准,不得自动套用其他机器的系数。 + +### 1.2.5 低电压亮度保护(`WEB-LOW-VOLTAGE-PROTECTION`、`CONFIG-LOW-VOLTAGE-PROTECTION`) + +- “供电电压”设置卡在电压详情与校准按钮下方提供保护开关;默认关闭。开启后立即请求一组新样本,关闭后立即撤销限制和覆盖,不等待恢复确认,也不改变用户亮度、方向、画面或 `last_mode`。 +- 保护模式固定为 `disabled / unavailable / inactive / limiting / critical`。开启但本进程尚无成功样本时为 `unavailable` 且不凭空锁屏;取得过成功样本后 ADC 故障保持最后一次保护并标记 `reading_stale=true`。 +- 电压严格大于 `4.8V` 为 `inactive`;`4.5V..4.8V` 为 `limiting`,亮度上限为 `floor(50 + 50 × (V - 4.5) / 0.3)`;严格低于 `4.5V` 为 `critical`。因此 `4.8V` 仍警告且上限 `100%`,`4.5V` 上限 `50%`。 +- 更严格的模式或更低上限由一组成功样本立即应用;放宽上限、退出临界或完全解除需要连续两组成功样本确认。读取失败取消待确认的放宽并保持最后保护,不另加电压滞回。 +- `limiting` 时实际亮度为 `min(用户亮度或开机笑脸的 50%, 亮度上限)`。顶栏显示“电压过低,屏幕亮度限制”,亮度设置下方始终显示“当前允许的最高亮度:N%”;即使用户值已更低,该提示仍保留到保护恢复。 +- `critical` 时用户内容仍可更新和保存,但输出由黑底红色低电图标覆盖并固定为 `35%`;图标继承用户方向。顶栏显示“电压过低,屏幕已经禁用,请充电”,亮度区说明用户画面已禁用且图标固定 `35%`。恢复后自动显示临界期间保存的最新用户内容。该亮度来自实屏保护膜下红色可视性复测,不能回写用户亮度。 +- 页面仍以 `1..100` 保存用户亮度,不修改滑块 `max`。保护状态渲染必须先于亮度请求的重复值提前返回;告警只在模式或文案变化时进行一次无障碍播报,不在每轮状态轮询重复朗读。 +- 临时纯色测试活动时,系统设置页仍允许保存用户亮度,但成功反馈必须明确显示“用户亮度已保存,但当前由测试亮度覆盖”;不得把临时测试亮度回写用户配置,也不得用看似普通的“已生效”文案造成设置失效的误解。 + +### 1.3 自启动(`DEPLOY-SYSTEMD`) + +核桃派部署时用 systemd 启动服务: + +- 服务名建议:`matrix-screen-controller.service` +- 启动目标:`multi-user.target` +- 失败策略:`Restart=on-failure` +- 启动命令调用项目虚拟环境里的 Python 或 uvicorn。 + +服务启动时在 HTTP 服务进入可用状态前解析并显示 `CONFIG-DEFAULT-CONTENT`。默认静态模板按当前 scene 渲染,默认动图以当前记录快照无限播放;两者继承用户方向和亮度,不算用户改屏,也不取消开机 WiFi 提示。旧开机笑脸不再作为独立覆盖层启动;未配置的 v5 设备迁移后默认引用内容完全相同的“演示动图”。同一 boot ID 内 systemd 重启也必须重新恢复默认内容,因为动画播放只属于进程内快照。 + +服务与 `NetworkManager.service` 排序但不得等待 `network-online.target`。`ExecStartPre` 的 `check --service` 只要求 NetworkManager 已启动,不得因为冷启动时默认路由尚未生成而失败或依赖 systemd 重试;人工专用主机检查和服务就绪后的远端健康检查仍必须要求默认路由存在。HTTP 服务启动后在后台尝试连接受管 WiFi,开机提示截止时间和“本次开机已取消”状态保存到运行根;systemd 重启沿用同一 boot ID 的原截止时间,断电重启才创建新会话。成功访问首页或成功改屏会取消本次开机的 WiFi 提示,之后断网也不得重新弹出。WiFi 提示只覆盖默认或用户内容,不停止其动图线程;首页仅在该提示活动时撤销覆盖并恢复底层内容,不得把其他客户端刚设置的画面强制重置为默认。 + +示例结构仅作为后续实现参考: + +```ini +[Unit] +Description=Matrix Screen Controller +After=NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=simple +WorkingDirectory=/opt/matrix-screen-controller +ExecStart=/opt/matrix-screen-controller/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8080 +StateDirectory=matrix-screen-controller +StateDirectoryMode=0711 +RuntimeDirectory=matrix-screen-controller +RuntimeDirectoryMode=0750 +Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller +Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +由于 `walnutpi-h618-hub75` 需要访问 `/dev/mem`、实时调度与内存锁定,实际部署必须验证 systemd 的 root 运行边界、设备白名单、capability 限制和状态目录写权限。权限优化不得破坏异常禁用 OE 与安全清屏。 + +### 1.3.1 专用核桃派运行模式(`DEPLOY-DEDICATED-HOST`) + +生产核桃派只运行本控制器及其必需的网络、监测和系统服务,必须使用可重复、可检查的专用主机配置: + +- 默认启动目标为 `multi-user.target`;LightDM、`display-manager`、Xorg、桌面会话和蓝牙服务必须停用并屏蔽,不得在控制器运行时自动恢复。 +- 板载音频必须通过 boot 配置关闭,`snd_bcm2835` 必须加入模块黑名单且在控制器启动前确认未加载;WirePlumber、PipeWire 及其用户级 socket 必须全局屏蔽,SSH 登录不得重新拉起音频会话。不得通过关闭矩阵硬件脉冲来兼容板载音频。 +- Wi-Fi、SSH、Avahi、`/dev/i2c-1`、Unit ADC、电压保护和 swap 必须保留;专用化不得修改无线网络、SSH 凭据、持久数据根或用户配置。 +- `scripts/dedicated_host.py check` 只读报告全部专用条件;`apply` 以幂等方式更新 boot 配置、模块黑名单和 systemd 启用状态,但不得自动重启。boot 配置更新必须原子替换、保留无关内容,不创建多代备份。 +- systemd 在启动应用前执行只读专用主机检查。任一硬件脉冲前置条件不满足时服务必须明确失败并记录原因,不能静默进入画面不稳定的软件脉冲模式。 +- 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;未来确需桌面或蓝牙时,必须先修改本需求和专用主机脚本,再重新执行刷新率、资源负载和完整实屏验收。不得临时取消屏蔽后直接交付。 + +多语言文字部署还必须满足 `DEPLOY-FONTS`: + +- Debian/核桃派安装 `fontconfig`、`fonts-noto-core` 和 `fonts-noto-cjk`;默认字体目录排除彩色 Emoji 与未登记的符号字体,并对允许的 Unicode 范围做确定性校验。 +- Python 环境安装与 `requirements.txt` 一致的 FontTools,用于读取字体 cmap 覆盖范围。 +- 部署、systemd 权限收紧和清理不得破坏 `/usr/share/fonts` 的读取或删除系统字体包。 +- 服务代码或字体依赖更新后必须重启服务,并在远端用一个多语言透明层请求确认 Pillow、Raqm、FontTools 和 Fontconfig 链路可用。 + +### 1.3.1 更新与数据根迁移(`DEPLOY-UPDATE`、`CONFIG-DATA-LIFECYCLE`) + +- `/opt/matrix-screen-controller` 只保存可以由同版本更新包完整替换的程序、虚拟环境和部署文件;生产配置、校准、模板、上传图片、预设和未来用户资源只能位于 `/var/lib/matrix-screen-controller`。 +- 手工部署和浏览器 OTA 使用同一保护边界:载荷不得包含持久根,也不得以包内默认文件覆盖设备现有数据。更新器先克隆当前持久根,在副本上执行新版本登记的单向迁移;只有迁移、构建和测试全部成功后才切换。失败时恢复更新前原件。 +- 从旧 `/opt/matrix-screen-controller/data` 首次迁移时,必须先停服并运行 `scripts/migrate_state_root.py`。工具把除已登记运行期产物和共享写入器精确命名的事务临时文件之外的全部文件复制到目标旁暂存目录,按相对路径、大小和 SHA-256 复核后再切换到目标;未知持久文件即使名称含 `.tmp` 也必须保留。工具永不删除旧源目录,旧源与目标都没有可核验持久文件时必须报错,不能把空目录当成迁移成功。 +- 旧数据根和新数据根同时包含数据时,仅当两边清单完全相同才视为已经准备完成;任一差异都必须中止,禁止静默合并、覆盖或选择较新文件。 +- 部署固定顺序为:远端健康检查和持久数据基线 → 停止服务 → 准备并核验新数据根 → 部署程序与 systemd → 启动并完成 schema 迁移 → 冒烟和业务数据逐项比对 → 再次重启验证 → 删除已确认无用的旧数据根、迁移暂存和单份事务备份。 +- 任一步失败都要停止新服务,保持旧源和未修改的持久原件可用于回退;不能通过删除损坏文件、生成默认配置或减少模板数量让服务勉强启动。 +- `scripts/update_walnutpi.sh` 必须保留为日常开发的 SSH 手工更新入口;它与 OTA 工作服务共用版本化 release、离线 wheel、原生编译、自动化测试、数据迁移副本、原子 current 指针和健康检查实现。普通源码更新载荷不得为运行与测试重新携带预编译内核、固定内核源码或镜像;更新器明确标记源码轻量测试且这两类材料均不存在时,只跳过已登记大型镜像材料的完整性用例,本机完整项目仍必须执行该用例,材料只缺一类时仍失败。 +- 新版本启动后必须轮询真实 `/api/status`,确认服务、硬件映射和生产 OE 后端均就绪;启动或健康检查失败时自动停止新版本、恢复旧 `/opt` 与旧 unit、重新启动旧服务并报告回滚结果。成功后只清理本次明确创建的 staging 和旧程序备份,不清理持久根。 + +### 1.3.2 浏览器全量 OTA(`WEB-OTA`、`CONFIG-OTA-STATE`、`DEPLOY-RELEASE-VERSION`) + +- `DEPLOY-OTA-RUNTIME-LIFECYCLE`:主服务必须使用 `RuntimeDirectoryPreserve=yes`;`restart` 不能保护 OTA 的单独 stop。旧更新器升级时,候选迁移入口必须在停旧服务前安装、重载并核验运行期保护 drop-in,保护请求、日志和进度直到成功或回滚结束。运行数据仍由整机重启清空;临时 drop-in 按事务登记清理,不覆盖用户配置。必须使用真实 systemd unit 复现和验证生命周期,mock 不能代替验收。 +- `TEST-OTA-LOG-RESILIENCE`:诊断目录意外丢失时尝试恢复日志;写入失败保留有限缓冲并输出不含配置或凭据的降级提示,不能阻止回滚或覆盖原始异常。恢复完成不代表升级成功,失败状态与原因必须保留。 +- OTA 当前版本修复必须经用户明确授权,归档旧产物和记录;实际验收的候选包按摘要原样晋升,原子替换当前产物与记录,失败不改写原件、不推进版本。本次 1.1.0 必经节点修复适用此流程。 + +- `DEPLOY-OTA-CHECKPOINT`:从 1.1.0 起,新增或变更系统软件依赖须增加 minor 并归零 patch;每个 `.0` 是软件安装包且逐个必经。同系列补丁允许跳跃;跨多个系列必须先安装下一个 `.0`,即使调试设备已装依赖也不能跳过版本节点。升级策略与依赖摘要保存在源码,不能引用历史导出目录。 +- `DEPLOY-OTA-COMPONENT-TRANSACTION`:1.1.0 兼容旧 v1 包协议,依赖放在 software 内;后续包用 v2 阻止正式旧更新器越级。旧 worker 的候选迁移入口在实际 pytest 通过后执行组件事务,独立监护与开机恢复处理失败/中断。frpc 完整匹配则跳过,否则离线修复;保留配置、选择项、权限和启停状态,首次默认关闭。普通补丁不携带系统二进制,缺失依赖时拒绝更新并提示修复。维护账户必须可靠确定,不依赖 OTA 环境的 SUDO_USER。 + +- 正式版本使用源码根 `VERSION` 中严格的三段数字版本;最新功能更新时间使用源码根 `FEATURE_UPDATED_AT` 中精确到分钟且固定为 `+08:00` 的时间。`GET /api/status` 的 `service` 同时返回 `software_version`、`feature_updated_at` 与前端内容摘要 `app_version`,`GET /api/ota/status` 也返回相同的 `feature_updated_at`。目标 OTA 版本必须严格大于当前版本,自动失败回滚不算用户降级。 +- 普通功能开发、修复以及通过 SSH 进行的开发部署不得改动 `VERSION`;每次可部署的功能新增或优化必须把 `FEATURE_UPDATED_AT` 更新为固定北京时间,同一份源码部署到不同设备时值必须相同。只有用户明确要求导出 OTA 或可刷镜像时才推进两者共用的正式软件版本;单纯导出、重复部署和文档修改不得改写功能时间。OTA 继续遵守必经 minor `.0` 软件安装节点;IMG 自带完整系统软件与离线依赖,不应用 OTA 跳转门禁,但新版本仍须严格大于当前版本且默认只增加补丁位。新 IMG 必须先构建到正式目录外的候选目录,静态校验和摘要绑定的真实 TF 卡正向验收全部成功后才允许把同一字节候选原子晋升、登记并推进 `VERSION`;候选失败不占号。已经登记但经真实首启证明不可用的当前版本镜像,只有在用户明确授权同版本修复时才能从未修改官方基线完整重建;修复必须复用原配置区,完整校验后原子替换目录和原发布记录,不追加重复版本、不修改 `VERSION`,失败时旧目录、记录和版本逐字节不变。 +- 更新包是 `matrix-screen-controller-.ota`,只含 `manifest.json` 与完整载荷。载荷包含程序、部署文件、测试和 AArch64 离线 wheelhouse;包不联网、不做增量、不加密、不签名。SHA-256 只用于传输和内容完整性校验。 +- 包必须校验产品标识、格式版本、版本、文件名、摘要、压缩前后大小和归档路径;上传上限 `256 MiB`,载荷展开上限 `1 GiB`。路径穿越、未知条目、错误产品、摘要不符、同版、旧版或空间不足均在停服前拒绝。 +- `POST /api/ota/update?filename=...` 只接受 `application/octet-stream` 流式上传并返回 `202` 任务;`GET /api/ota/status` 返回当前版本、固定功能更新时间、上限、活动任务阶段/百分比和最近结果。一次只允许一个上传或更新任务。 +- 包完成校验后立即冻结普通写接口且不允许取消;独立 systemd oneshot 工作服务继续处理,浏览器关闭不影响更新。页面分别显示上传百分比和安装阶段,服务切换期间的请求失败按预期断线处理,恢复后自动刷新到新静态资源。设置页常态只显示正式版本、格式化为 `YYYY-MM-DD HH:MM(北京时间)` 的功能更新时间、导入按钮和断电警告;最近结果继续由后端保存但不在页面展示。更新成功后隐藏完成进度条和完成提示,失败进度与错误原因继续显示。 +- OTA 与 SSH 手工更新运行 Python 门槛时必须把应用数据、运行数据、pytest `tmp_path` 和通用临时文件全部限制在本次事务目录,显式设置 `MATRIX_TEST_ROOT`、`TMPDIR` 与 pytest `--basetemp`,并禁用 pytest cache;不得依赖或污染系统默认 `/tmp`。新源码的 conftest 还必须兼容旧更新器只传入 `MATRIX_DATA_DIR`、`MATRIX_RUNTIME_DIR` 和 `MATRIX_SOURCE_ONLY_UPDATE_TESTS=1` 的调用方式。除发布记录中已经固化且不得覆盖的 1.0.6 一次性诊断引导包外,所有当前源码和后续 OTA 都必须实际执行完整 pytest,任一失败继续阻止切换并触发原子回滚;不得保留诊断标记、成功早退或其他测试绕过入口。 +- OTA worker 必须把包校验、离线依赖、venv、原生编译、pytest、专用主机检查、迁移、切换、健康检查和回滚的阶段时间、安全主机摘要、命令、退出码及 stdout/stderr 写入单个运行期日志。日志不得读取或记录账户密码、IP、SSID、配置内容、用户资源内容或完整环境变量。失败时以耐久原子写入只保留最近一份、最多 `1 MiB` 的日志,超限时保留头部和最新尾部并标明截断;下一次成功更新删除旧失败日志。 +- `GET /api/ota/status` 返回最近失败日志是否可用及字节数;`GET /api/ota/failure-log` 只在最近结果为失败且日志有效时以无缓存 UTF-8 纯文本返回,否则 `404`。设置页检测到带日志的失败后自动打开“OTA 更新失败日志”弹窗,以纯文本等宽滚动区显示,支持 Clipboard API 和非安全局域网 HTTP 下的 textarea 回退复制;关闭后仍保留查看和复制入口,日志读取失败时继续显示原简短错误且不得打开空弹窗。 +- 活动状态原子写入 `/run/matrix-screen-controller/ota-status.json`;持久根 `ota/state.json` 只保留 schema v1 的最近结果和未完成事务恢复信息。不得积累历史包或索引工作区 `发布更新相关/OTA数据包/`。 +- 新配置字段必须通过逐级迁移增加预设值。删除功能时,迁移只能删除已登记归属该功能的字段或路径;其余配置、字体、模板、动图和未知文件完整复制。成功后删除旧 release、上传包、数据备份和 staging;失败或中途重启按事务记录恢复旧程序、旧 unit 与旧数据。 + +### 1.3.3 可刷镜像和离线材料(`DEPLOY-IMAGE-EXPORT`、`DEPLOY-IMAGE-FIRSTBOOT`、`DEPLOY-OFFLINE-DEPS`、`DEPLOY-IMAGE-EDITOR`) + +- `发布更新相关/核桃派镜像` 只保存未修改官方镜像。导出器复制后向 FAT16 启动分区只写入摘要元数据、一次性 root 首启程序和固定大小双槽配置区;应用源码、AArch64 wheel 与 Debian 包组成的 `MSCBOOT.TGZ` 由 Linux bootstrap 写入复制镜像根分区 `/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ`,预编译内核写入相邻的 `/opt/matrix-image-bootstrap/axp313a`。固定内核源码只留在电脑端,禁止进入 IMG 或 OTA。 +- 镜像元数据格式固定为 v3,记录应用载荷位置、压缩字节数和 SHA-256,以及候选内核 release、压缩/展开字节数、SHA-256、FAT 安装预算和安全余量。IMG 导出必须同时验证预编译载荷及固定源码归档;根分区注入前至少保留“应用压缩载荷 + 内核压缩载荷 + 256 MiB”,注入后重新只读挂载并逐字节核对 FAT v3 元数据、根分区两类载荷和离线依赖元数据。FAT 必须明确不存在 `MSCBOOT.TGZ`。旧 v1/v2 镜像不得原地刷新或迁移,必须从官方基线重建。 +- 构建器必须在最终 FAT 布局上按簇取整计算首次安装新增的候选 Image、双份 DTB、System.map、kernel config、受管启动脚本、原始启动脚本回滚副本、临时文件和状态文件,并在这些实际预算之外保留固定 `32 MiB` 安全余量;不满足时在发布前拒绝。内核安装器在写入第一个候选 boot 文件前使用同一算法再次检查当前 `/boot`,不足时保留 rootfs 离线载荷并失败,禁止依靠清除应用载荷换取不可恢复的空间。 +- 首启不依赖显示器、键盘、HUB75、ADC 或当前网络在线。它校验并离线安装载荷、编译真实驱动、配置账户和 NetworkManager、生成唯一 machine-id 与 SSH 主机密钥、部署服务,最后安装预编译双内核候选并自动重启一次。镜像配置仍必须包含有效的 WiFi 与 IPv4 字段;首启只写入启用自动连接的受管配置并重启 NetworkManager,不等待 SSID、认证、DHCP 或默认路由,以上任一未就绪都继续离线安装,安装完成后由正常运行服务继续连接并按既有逻辑显示断网提示。NetworkManager 未启动、配置写入失败以及载荷、硬件、权限、SSH 或内核错误仍必须令首启失败。安装内核前根分区至少保留“两倍展开字节数 + 256 MiB”,解包只进入明确的 `/var/tmp/matrix-axp313a-image-install`;成功后删除压缩载荷和展开目录,失败由安装器恢复原 boot 文件、清除部分候选并保留无秘密状态,不联网补包或循环重启。候选启动继续使用一次性健康标记,内核、AXP313A、cpufreq、应用或 GPIO 健康失败时下一次自动回原内核。首启完成时若受管连接或默认路由尚未就绪,FAT 状态仍写成功,但必须明确说明 WiFi 尚未连接且运行系统会继续处理,不得包含 SSID、密码、用户名或地址。 +- SSH 是每个正式导出镜像的必需功能:复制镜像的 rootfs 必须已启用 `ssh.service`,首启期间受 `Before=ssh.service` 阻止而不得提前监听,成功重启后使用配置区的账户和密码登录。该账户必须属于 `sudo` 组并通过经 `visudo` 校验的 `ALL=(ALL:ALL) ALL` 规则获得需再次输入同一账户密码的完整权限;必须原子移除基础镜像的 `Defaults rootpw` 和旧 `pi` 免密规则,不得保留或配置 `NOPASSWD`。主机名变更必须同步 `/etc/hosts`,避免 sudo 因本机名解析失败而延迟。受管 sshd 策略固定为允许密码认证、关闭键盘交互和空密码、禁止 root 直接登录;直接运行 `sshd -t/-T` 前必须创建并校验模式为 `0755` 的易失目录 `/run/sshd`,不能依赖尚未启动的 `ssh.service` 代为创建。`sshd -t/-T`、sudoers 或服务启用校验失败都必须令首启失败。首启 unit 排序在控制服务之前,因此部署只能启用控制服务、不得在同一 oneshot 内同步等待其启动;该 unit 不得使用有限启动超时。控制服务和 SSH 必须在首启清理完成后的重启中启动;成功状态只能在秘密、内核载荷和临时文件清理、禁用首启 unit 及全盘同步完成后写入,随后必须成功请求重启。同版本首启若在完整安装移动到 `/opt` 后中断,只能在版本、venv、原生文件、unit 和专用主机检查全部通过时恢复。成功后覆盖并删除卡上明文配置。 +- 镜像不得包含当前设备持久数据、网络状态、用户资源、校准、运行数据、主机密钥或项目凭据文件。未修改正式 IMG 故意包含一组可直接使用、通过 schema 校验的默认账户和 DHCP Wi-Fi 配置;正式发布目录 README 必须逐字写明这四项公开默认值、SHA-256 和修改警告,其他 manifest、发布记录、日志及归档不得记录这些值。当前设备和开发机凭据始终不得进入镜像或发布目录。 +- 配置 schema v1 固定包含产品、软件版本、账户、WiFi 和 DHCP/静态 IPv4。双槽分别带单调代数、长度和 SHA-256;读取选择最高有效代,保存先完整写入非活动槽并刷新,写入中断必须仍能读取旧槽。 +- 编辑器使用不依赖项目外部目录的 Python/PySide6 跨平台源码;Windows 10/11 x64 必须交付内置 Python、Qt 和运行库的唯一绿色单 EXE,其他电脑不得要求安装 Python、.NET、Qt 或补充 DLL。同一源码允许用户在 macOS 本地构建,Windows 发布不得宣称已交付或验收 macOS 产物。编辑器显示镜像版本和当前配置,密码默认遮挡;“修改原镜像”必须二次确认,“另存为”必须使用不同且尚不存在的宿主系统合法 `.img` 文件名,并提示按设备或地点命名。中文、空格和长路径必须受支持;保留名、错误扩展名、同路径、既有目标或无法创建同名摘要时必须在复制或修改前拒绝。未知产品、版本、损坏槽或摘要失败时禁止保存;每次保存以无 BOM UTF-8 生成规范的 `<64 位小写 SHA-256><两个空格><完整文件名>` 侧车文件,按固定字段边界解析并严格复核文件名和镜像内容。摘要必须通过同目录临时文件原子替换,另存失败不得留下 IMG、摘要或临时摘要。 +- 编辑器必须声明 Per-Monitor V2 DPI 感知;主窗口、输入区域以及应用内错误、警告、确认和成功提示均按当前显示器 DPI 与工作区自适应缩放,分辨率或显示器变化后保持完整可达。密码输入框与同列普通输入框等宽;低分辨率时允许纵向滚动但不得裁切字段、正文或操作按钮。Windows 原生打开和保存选择器继续使用系统界面。 +- `发布更新相关/其他依赖` 是当前软件所需的唯一离线材料清单。新增依赖必须同时提供文件、架构、来源、用途和摘要;停用时删除二进制并记录停用版本与原因。AXP313A 运行载荷必须来自已经通过真实 GPIO 验收的 `6.1.31-matrix-axp313a1`,使用确定性 `tar.gz`,拒绝路径穿越、符号链接、特殊文件、错误 release/commit 和未登记文件;模块的 `build`、`source` 链接不进入载荷,由目标 `depmod` 重建索引。固定源码归档必须与 vendor commit 和补丁摘要同时可离线校验。Debian 包必须相对于登记的官方基础镜像包清单形成可递归解析的完整闭包,镜像构建前自动验证,缺少直接依赖、传递依赖、根包或基准清单时必须拒绝导出。首启 Debian 安装失败时,FAT 状态文件指向不含配置凭据的包诊断与基准包清单,失败后不得继续网络和部署阶段。 +- `发布更新相关/导出包/<版本>` 只保存可删除 IMG。历史导出 IMG 缺失默认视为用户主动清理空间,不属于故障,不追补、不自动重建、不影响开发、常规测试或后续导出;任何源码、测试或后续导出不得引用其中内容。工作区 OTA 今后长期保留,不作为缓存清理;已缺失的 1.0.1 是用户接受的历史例外,不再追补、提醒或阻塞使用,原发布记录保留。 + +### 工作区路径约定(`DEPLOY-WORKSPACE-LAYOUT`) + +- 测试入口位于 `测试相关资料/如何测试/本机测试环境/`,发布工具从 `发布更新相关/` 定位离线材料和产物;源码仍位于根目录 `核桃派软件源代码/`。路径须由脚本位置或显式参数解析,支持中文、空格及不同调用目录。 +- 工作区搬迁不改变设备安装路径、镜像内部载荷目录或历史包协议;已有发布记录仅同步工作区产物路径,不改版本、摘要及历史验收结果。 +- 本机测试与清理必须在隔离工作区验证:能够定位本地源码,保留 `.venv` 和人工持久数据,并拒绝清理越界路径。覆盖见 `TEST-WORKSPACE-LAYOUT`。 + +### Git 仓库安全约定(`DEPLOY-REPOSITORY-HYGIENE`) + +- 根 `.gitignore` 必须精确排除真实设备凭据、私钥、本机环境、运行数据和可再生缓存;凭据示例、源码、需求、测试、正式 IMG、OTA、离线依赖、编辑器程序和历史归档仍属于仓库内容。IMG、OTA、离线依赖、编辑器程序及其他已登记的大体积二进制允许通过 Git LFS 保存;LFS 不得改变工作区文件内容或扩大忽略范围。 +- 真实调试文件固定为 `测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt`,可提交示例为同目录 `用户名密码ip.example.txt`。私有文件缺失时只允许从示例创建副本并停止;字段缺失、为空或仍是占位符时,不得继续 SSH、部署或镜像定制。 +- 仓库文本不得固化开发电脑盘符、用户目录、用户名、主机名或旧工作区根;改用仓库相对路径或文字占位符。设备固定部署路径、镜像内部协议路径和测试显式构造的安全夹具不受影响。 +- 提交前必须按 Git 实际候选集合检查:真实凭据未入列、示例已入列、无私钥、无高可信明文秘密、无开发机路径。Git LFS 属性属于允许的仓库配置;二进制仍须按 ASCII、UTF-8 和 UTF-16LE 检查开发机标识,命中时从中性路径重建并同步摘要,不得直接改写有摘要保护的产物。 +- 项目自有源码使用 GPLv3;第三方镜像、软件包、字体、工具和资料继续服从各自许可与来源条款。仓库根 README 必须说明完整仓库及 Git LFS 对象的克隆成本、客户端要求和第三方许可边界。 + +## 2. 页面功能 + +### 2.1 首页布局(`WEB-ENTRY`) + +首页直接是控制面板,不做营销式首页。当前提供设备状态和测试、系统设置、像素画布、文字显示、动图管理、模板管理六个工作区,但实现不得假定工作区数量固定。 + +- 每个工作区声明稳定 `id`、标题、默认排序、持久化策略和进入/退出钩子;侧栏和 URL hash 由注册信息生成。侧栏固定为不带分类标题的单层列表。 +- 手机、平板和电脑默认都只显示当前工作区;功能侧栏默认收起,展开时以带遮罩的抽屉覆盖在内容上,不挤压或移动主内容。 +- 页面左上角始终保留同一个菜单图标按钮:收起时用于展开侧栏,展开后视觉上位于侧栏左上角并用于关闭;页面刷新或重新进入时仍默认收起,不持久化开合状态。 +- 叉号正下方提供紧凑的“自定义项目位置”按钮。点击后按钮变为“保存”,每个项目显示“上移/下移”,首项不能上移、末项不能下移;移动只修改当前页面内存草稿,不发送请求。 +- 排序编辑期间工作区项目不得导航,叉号、遮罩和 `Escape` 都不得关闭侧栏,只提示“请先保存项目位置”。不得注册关闭页面自动保存;刷新、关闭浏览器或崩溃直接丢弃本次未保存草稿。 +- 保存按钮一次提交完整当前工作区顺序;成功后退出编辑态、保持侧栏打开并显示已保存顺序,失败或页面版本过期时保留草稿和编辑态并允许重试。非编辑态在任意屏幕尺寸选择工作区后侧栏自动关闭,点击遮罩或按 `Escape` 也能关闭。 +- 默认工作区为“设备状态和测试”;无效 hash 回落默认工作区,不能导致白屏。 +- 已保存顺序中不存在于当前版本的 ID 在渲染时忽略;后续新注册的工作区按默认注册顺序追加,下一次保存时用当前完整序列整理记录。后续新增图片、时钟等功能时,只注册新工作区,不改写现有导航分支。 +- 手机控件的主要点击区域建议不小于 `48x48 CSS px`,相邻控件间距不小于 `8px`;页面不得出现非必要横向滚动。 +- 抽屉必须维护 `aria-expanded`、`aria-hidden`、当前项状态和键盘焦点;关闭时侧栏内容不可聚焦并把焦点归还菜单按钮,展开时键盘焦点限制在菜单按钮和侧栏可操作项之间。 +- 共享画板始终位于当前工作区最上方,脱离左右分栏、带内边距卡片和设置控件;其左右不得放置会压缩或遮挡画板的元素,所有工具和字段排列在画板下方。 +- 画板在不引起页面横向滚动的前提下,按内容可用宽度和当前视口可用高度取最大正方形;约 `320px`、手机、平板和桌面视口都必须完整可见且保持 `1:1`。 + +页面使用移动端单列作为默认布局,较宽屏幕只增强画板下方的设置区域;共享画板仍单独占据一行并保持正方形。 + +### 2.1.1 正向预览(`WEB-PREVIEW`) + +- 普通工作区始终预览完整组合场景,固定使用逻辑方向 `0°`,左上角为 `(0,0)`,不应用配置中的物理方向和底层行映射。 +- 编辑变化只更新网页预览;用户点击“应用统一画板”后才把完整场景提交到真实屏幕。 +- 像素底层在浏览器本地精确预览;每个文字元素通过 `POST /api/preview/text-layer` 获取透明 RGBA 图层,再由浏览器按场景顺序合成。 +- 设备状态、系统设置和模板管理工作区把同一画板切换为只读监视器,通过 `GET /api/display/current-frame` 显示当前设备经过输出仲裁后的逻辑 RGB 帧;临界低电压时返回低电图标而不是被覆盖的用户帧。监视器不得显示或提交浏览器编辑草稿,固定使用逻辑 `0°`,不应用物理方向和底层行映射,最大边长为 `160 CSS px`,并隐藏应用、导出、模板和画板交互操作。设备状态工作区只在画面正上方居中显示小标题“当前屏幕画面”;系统设置和模板管理保留完整监视器上下文文案。 +- 设备状态工作区内部不重复显示“设备 / 设备状态和测试”标题;刷新状态位于监视器正下方。屏幕状态只展示方向和亮度,其中亮度区分用户保存值与实际有效值。 +- 进入设备状态、系统设置或模板管理时立即读取当前帧,并按 `preview_refresh_interval_ms` 递归刷新;同一页面最多存在一个当前帧请求,较晚返回的旧请求不得覆盖新工作区或较新的画面。页面隐藏或离开全部监视工作区时停止定时器,重新可见时立即刷新。 +- 当前帧读取成功后不显示额外状态文字;加载或读取失败时可以临时显示对应状态。离开监视工作区时恢复其他工作区原有预览。 +- 预览请求不得更新当前模式、当前逻辑帧、驱动或配置。 +- 动图管理工作区把页面顶部切换为两个只读小预览:左侧“当前动图”按当前已打开动图的已保存帧顺序和各帧 `duration_ms` 在浏览器内循环,右侧“当前画板”显示当前统一场景。两者最大边长均为 `160 CSS px`,宽屏并排、窄屏上下排列,保持 `1:1` 且不得产生横向滚动。 +- 动图管理预览不得调用播放或改屏接口;未保存的动图帧修改只反映在右侧当前画板,保存并刷新后才进入左侧循环。未打开动图、空动图或缩略图失败时显示黑色占位和明确说明;切换、刷新、删除、离开工作区或页面隐藏时必须终止旧预览计时器。 +- 动图管理顶部不显示“应用统一画板”“导出合成 PNG”、保存模板或画板交互层;离开该工作区后按目标工作区恢复原有画板能力。 + +### 2.1.2 统一场景合成(`WEB-COMPOSITION`) + +- 场景模型不得依赖 DOM,固定包含一个 `64x64 RGB888` 像素底层和一个有序 `elements` 数组;数组从前到后绘制,后面的元素覆盖前面的元素。 +- 像素底层始终位于最底部。文字元素使用独立 `64x64 RGBA` 图层,透明区域必须保留下方像素和其他元素。 +- 新建或复制的元素追加到数组末尾并置顶;当前阶段不提供手动前移或后移。 +- 每个元素具有稳定 `id`,运行时另有递增修订号。透明层缓存以元素 `id + revision` 识别,旧请求晚于新请求返回时必须丢弃,不能覆盖当前元素;修订号是缓存状态,不属于 scene v1 持久化字段。 +- 元素存在待渲染或渲染失败状态时,页面必须明确提示并禁用“应用统一画板”,不得提交缺少该元素的不完整帧。 +- 场景最终合成为单张 `64x64 RGB` 帧后再交给 `DisplayService`;显示底层不保存、选择或编辑单个元素。 +- 后续动画、图片、时钟、天气等普通内容功能必须注册为新的元素或图层提供者,并参与同一有序合成;不得新增直接覆盖屏幕且无法与现有内容叠加的普通显示路径。 +- 纯色、诊断图案和清屏属于设备测试例外:它们独占覆盖完整帧,但不得修改或清空浏览器场景草稿。 + +### 2.1.3 模板管理(`WEB-TEMPLATES`、`WEB-DEMO-TEMPLATES`) + +- 工作区注册信息可以声明“支持保存场景模板”;当前像素画布和文字显示声明该能力,后续内容工作区声明后自动出现同一个“保存为模板”入口。 +- 保存内容必须是完整、可编辑的 scene v1,保留像素底层、有序元素、稳定元素 ID 和层叠顺序;不得只保存合成 PNG,也不得保存运行时 revision、选择、请求状态或图层缓存。 +- 保存时输入 `1..80` 个字符的模板名称;忽略首尾空白并按 Unicode 大小写不敏感规则保持唯一,重名不得覆盖已有模板。 +- 从动图帧编辑上下文点击“保存为模板”时,名称弹窗必须在“保存为模板”标题右侧显示“当前帧将被保存为一个静态图模板”;普通场景保存以及共享弹窗用于新建、重命名时不得显示或残留该提示。 +- 左侧栏新增“模板管理”。模板使用购物软件式响应式卡片网格:正方形缩略图在上、名称在下,并显示占用空间;手机默认两列,宽屏自动增加列数。静态模板和动图统一使用两列四行操作:第一行“播放、编辑”,第二行“复制、重命名”,第三行横跨两列显示“删除”,第四行“上移、下移”。默认内容选择模式可以在上述常态操作后额外显示单列“设为默认”。 +- 静态模板缩略图必须由核桃派后端按场景顺序生成原生 `64x64 PNG`;动图卡片复用各帧同规格 PNG,按完整帧顺序和原始 `duration_ms` 在普通 `` 中切换,不得抽帧、截断或在浏览器重新合成场景。动态缩略图只在当前可见工作区和视口内运行,页面隐藏、离开视口、启用减少动态效果或加载失败时必须安全暂停或回退,不得影响真实屏幕输出。 +- 静态模板“播放”直接显示该模板并结束活动动图,但不得替换当前浏览器草稿或修改默认显示引用;动图“播放”保持相同的编辑解耦语义。静态模板“编辑”才读取完整 scene 并进入像素画布,且不得自动更新真实屏幕。 +- 普通画布记录当前绑定模板、修订值和最近保存的 scene 基线。未绑定的新草稿,或绑定模板后相对基线发生修改,均视为未保存;静态编辑将替换该画布时必须提示“当前内容将会丢失”,取消不得读取或切换目标。干净的已保存模板可以直接切换。动图帧编辑继续使用独立的未保存修改守卫,并完整保存、恢复普通画布及其模板编辑状态。 +- 编辑普通静态模板时,页面顶部提供单个“保存模板”入口,点击后选择“保存到当前模板”“另存为新模板”或取消。保存当前模板保留 ID、名称和创建时间并更新修改时间、内容摘要、缩略图及修订值;另存生成独立 ID 和唯一名称并把编辑上下文绑定到新模板。普通未绑定草稿沿用“保存为模板”;演示静态图的当前模板保存受限,只能另存。保存成功更新基线,失败或冲突保留当前画布和未保存状态。 +- 删除需要确认,并删除模板及其所有有效、过期缩略图。页面同时显示单模板占用、模板总占用和模板目录所在文件系统的总/已用/可用空间。 +- 模板管理固定把两个随程序发布的只读演示案例放在所有用户内容之前:第一个为“演示静态图”,内容与开机笑脸睁眼帧完全相同;第二个为“演示动图”,按开机笑脸的睁眼 `1300ms`、闭眼 `200ms` 两帧无限循环。二者来自可替换程序资源,不写入、不迁移也不计入用户模板持久空间。 +- 两个演示案例永久锁定为模板管理第 1、2 项,不可作为拖动来源或放置目标,也不进入用户顺序记录,但仍显示黑色受限态的“上移、下移”以保持卡片布局一致。其后的用户静态模板和用户动图组成一个可跨类型混排的序列,支持拖动及“上移/下移”;第一张用户卡不得移动到演示案例之前。新建或复制的用户内容追加到已有自定义顺序末尾;没有自定义顺序时保持“用户静态模板、用户动图”及各自当前 API 顺序。 +- 演示静态图允许播放、进入像素画布编辑和复制,但不得保存回演示原件;演示动图允许播放和深复制,但编辑不可用。两者的重命名、删除和移动均不可用,也不得把演示动图作为追加帧的目标。上述不可用按钮保持可聚焦/可点击并显示黑色受限态,桌面悬停或键盘聚焦展示原因,手机点击及桌面点击统一提示“演示案例不得编辑,请复制”;移动按钮使用固定顺序原因。前端隐藏或限制不是安全边界,相关写 API 也必须拒绝修改演示案例。 +- 从任一演示案例复制出的静态模板或动图必须取得新的普通 UUID、独立 scene/帧记录和唯一副本名称;复制品与用户新建内容完全相同,可以编辑、重命名、再次复制和删除,后续开机笑脸变化也不得改写既有复制品。 +- 标题右侧把“设置默认显示内容”放在“刷新”左边。按钮以 `aria-pressed` 进入选择模式;所有演示案例、用户静态模板和非空用户动图提供“设为默认”入口,空动图保留可触发的不可用说明。当前默认卡始终有醒目标记;保存时使用明确忙碌状态,成功后立即显示所选内容、退出选择模式并刷新标记,失败时保留原默认和原画面。 +- 默认引用按稳定 ID 保存。重命名不改变引用,内容更新后下一次默认激活读取最新版。删除当前默认模板或把当前默认动图删空时,必须先切换并持久保存“演示动图”再完成删除;删除失败恢复原默认。服务启动发现引用不存在、类型不匹配或动图为空时同样原子修复为演示动图。 + +### 2.1.4 动图管理(`WEB-ANIMATIONS`、`CONFIG-ANIMATIONS`) + +- 单层侧栏中动图管理的默认位置在文字显示之后、模板管理之前;保存自定义工作区顺序后允许改变该位置。一个动图是带稳定 UUID、名称和修订值的文件夹,内部保存按顺序排列的完整 scene v1 帧。 +- 动图管理页面内容顺序固定为“新建动图及从当前画板新建帧操作行、当前打开动图的帧卡片和单帧时长区、已有动图列表”。用户界面统一使用“动图”称呼;持久实现仍可把一个动图保存为带稳定 UUID、名称和修订值的文件夹。 +- 已有动图卡片在名称上方显示正方形动态缩略图。名称固定单行尾部省略,任何中文、英文或无空格长名称都不得撑宽网格;完整名称只在重命名输入框以及确实发生截断时的细指针鼠标悬停提示中显示,触摸、点击和长按不得展开名称。 +- 服务启动时对动图元数据、scene 与缩略图完成严格校验后,必须复用同一批已校验记录建立首次列表缓存;首次 `GET /api/animations` 不得再次遍历和校验全部帧文件。任一动图写入后缓存失效并按当前持久数据重建,REST 响应字段和错误语义不变。 +- 新建帧复制当前统一画板;每帧保存稳定 UUID、可选自定义名称和 `50..604800000ms` 播放时长,默认 `500ms`,最长七天。名称为空时页面按当前顺序派生“动图名称-第N帧”,因此动图重命名或帧排序后自动名称随之更新;用户设置的 `1..80` 字符自定义名称保持不变,清空名称恢复自动名称。 +- 帧卡片从上到下按“双色当前/总帧数与多选框、有效名称、重命名、显示时间与复制到已选帧、时长输入与单位切换、正方形缩略图、编辑此帧、次级操作”排列。每张卡独立在当前页面会话内切换秒或毫秒;毫秒只接受 `50..604800000` 的整数,秒只接受 `0.050..604800` 且最多三位小数,单位切换只精确换算显示值、不提交或持久化。缩略图固定 `1:1`,不能拉伸卡片或遮蔽操作。 +- 时长输入在用户键入过程中不校验、不提交,只在失焦、按 Enter 或 `change` 时校验;非法值保留并显示字段错误,不调用 API。时长数字必须能用鼠标或触摸长按选择且不得触发拖动,全部交互控件及其 `8 CSS px` 保护区遵守 `WEB-INTERACTION-FEEDBACK`。 +- 帧列表上方固定提供批量复制、批量移动和批量删除。勾选任意帧即进入选择模式:复选框、单位、时长、复制到已选帧和三个批量入口保持可用,拖动、编辑、重命名、上下移、单帧复制/删除以及切换动图或上下文的操作显示明确不可用原因。修改任意时长或复制时长到已选帧前必须二次确认;取消不发送请求并恢复发起输入,确认后原子应用到全部已选帧。批量时长成功后保留选择;复制、移动或删除成功后清空选择。 +- 批量移动仅限当前动图,保持所选帧当前相对顺序,只能在弹窗中选择“插入到最前”或某个未选帧之后;已选帧在目标下拉中以黑色不可用项显示。批量复制先选择当前或其他普通动图,再复用相同位置选择,但复制目标不因来源已选而禁用。普通单帧复制为动图时也必须选择插入位置,复制为静态模板时保持现有流程。批量删除二次确认后允许留下空动图。 +- 点击“编辑此帧”进入独立编辑上下文,普通浏览器 scene 草稿保持不变。像素画布和文字显示共同编辑该帧,并在“合成预览”右侧只显示一处编辑注释和已保存/尚未保存状态;自动帧为“动图名-第N帧”,自定义帧为“动图名-自定义帧名”,不得重复动图名。该注释不得出现在其他工作区;正在编辑的帧卡片同步高亮,顶部主按钮改为“保存当前动图帧”,保存不改变实屏,保存后继续编辑并刷新缩略图。 +- 切换帧、打开其他动图、退出动图编辑、载入模板或外部浏览器草稿以及其他会替换当前画板的操作,必须先经过同一个未保存修改守卫。存在修改时提供“应用修改、否并放弃修改、取消”;保存失败或修订冲突必须取消后续操作并保留本页画面。刷新或关闭页面时仍有未保存帧修改则触发浏览器离开提示。 +- 模板管理同时显示带“静态”或“动图”标记的卡片。动图卡片先显示第一帧,再在完整时间表就绪后按全部帧和原时长循环,并显示帧数、总时长和空间;空动图显示明确占位且不可播放。静态模板和单帧复制必须弹出目标选择,可深复制为静态模板或追加到指定动图。 +- 动图卡片提供整项深复制;普通动图与演示动图复制后都生成包含全体帧、顺序、名称和时长的独立普通动图。 +- `POST /api/animations/{id}/play` 成功后由核桃派后台无限循环,关闭网页不停止。播放使用请求时的不可变快照,后续保存需重新播放才生效;页面显示正在播放及旧版本提示。不提供独立停止按钮,其他成功改屏内容或另一动图负责替换播放。 +- 删除正在播放的动图返回冲突;动图重命名和编辑不热更新现有播放快照。动图名称只在动图类型内按 Unicode 大小写不敏感唯一。 + +### 2.2 全屏纯色测试(`WEB-FILL`) + +目标:快速确认屏幕供电、颜色通道、方向和底层刷新是否正常。 + +页面功能: + +- 固定按钮为全黑、全红、全绿、全蓝、全白,另有“自定义纯色”;页面初始不选中任何颜色。 +- 点击任意颜色立即启动设备级测试会话;新会话名义亮度固定为 `50%`,同一会话切换颜色保留用户刚调节的测试亮度,退出后再次进入才重置为 `50%`。 +- 固定色同步更新颜色框;颜色框可打开 RGB/HSV/HEX 只读详情,但不得修改、收藏或确认新颜色。自定义纯色打开完整颜色面板,确认后立即应用并把规范颜色保存到 `custom_test_color`,取消时继续使用原保存值。 +- 测试活动时显示独立亮度条、低电压保护说明和“退出测试”;测试亮度限制 `1..100`,仅影响当前测试,不写入用户亮度。临界低电压图标和限亮策略始终高于测试覆盖层。 +- 测试会话跨页面切换、刷新和重新打开网页保持;手动退出恢复底层用户内容、动画、方向和保存亮度。其他成功改屏操作自动结束测试,避免新内容被覆盖。 +- 纯色测试不得修改像素底层、场景元素、用户逻辑帧、`last_mode` 或用户保存亮度。诊断后端继续保留,但设备状态网页不展示诊断、运行所选测试或清屏入口。 + +后端行为: + +- 接收颜色。 +- 校验颜色格式必须是 `#RRGGBB` 或 `{r,g,b}`。 +- 生成 `64x64 RGB` 纯色帧。 +- 经过当前方向变换后提交给屏幕底层。 +- 更新 `last_mode = "fill"`。 + +验收标准: + +- 点击红绿蓝时,屏幕颜色通道和按钮含义一致。 +- 全白不能默认长时间满亮度压力测试,第一版可以限制亮度或提示只短时间测试。 +- 如果底层屏幕不可用,接口返回明确错误,前端显示失败状态。 + +### 2.3 像素画布(`WEB-CANVAS`) + +目标:允许用户像画画一样编辑统一场景最底部的 `64 x 64` 像素层。 + +前端功能: + +- 使用页面顶部唯一的共享 `64 x 64` Canvas,不在像素工作区另建预览。 +- 每个逻辑像素对应屏幕一个 LED 像素。 +- 支持画笔、橡皮、一次性吸管、清空像素层、填充像素层背景色;这些操作只改变像素层,不改变文字或其他元素。 +- 普通模式下,画笔和橡皮共用 `1..16` 个逻辑像素的圆形笔尖,提供连续调节、`1/2/4/8` 快捷档和圆形笔尖预览。 +- “笔尖粗细”下提供“单像素编辑”同步切换按钮。开启后画笔和橡皮改为 `1x1..4x4` 逻辑像素的正方形笔尖,连续调节范围和四个快捷档均为 `1/2/3/4`,预览与数值按 `N × N` 显示;关闭后恢复普通模式上次使用的尺寸。 +- 单像素编辑模式开启时,共享画板上方显示精确 `64x64` 的低透明度对比网格。网格是 `pointer-events:none` 的网页辅助层,仅在像素画布工作区显示,不得进入场景像素、模板、导出 PNG 或 WebSocket 帧;进入其他工作区时隐藏,返回像素画布时按已保存模式恢复。 +- 模式开关使用 `aria-pressed`,进入后在按钮附近持续提示“画布已按 64×64 方形像素分格,当前笔尖为 N×N”,并通过全局状态区播报进入或退出结果。 +- 支持颜色选择。 +- 支持鼠标拖拽绘制。 +- 支持触摸绘制。 +- 快速触摸移动时连接相邻采样点,避免手指移动过快产生断线。 +- 共享画板在像素工作区默认只读,触摸不会落笔或阻止页面滚动;颜色和画笔工具区提供明确的“开始编辑”按钮,点击后使用现有共享画板进入 CSS 全视口专注编辑,不调用浏览器 Fullscreen API。 +- 专注编辑默认进入绘画模式并锁定页面滚动;退出时完成已改变的当前笔画、复位视角并回到颜色和画笔工具区。离开像素工作区也必须先安全退出。文字工作区的画板选择、拖动和缩放行为不受该状态影响。 +- 专注编辑内持续显示类似 AssistiveTouch 的圆形悬浮按钮。按钮初始位于左侧中部,可在安全区内拖动且不得产生画笔输入;点击展开当前模式提示、撤销、退出编辑、移动画布视线和继续编辑,菜单按剩余空间调整展开方向并维护键盘焦点、`aria-expanded` 和不可用原因。 +- 移动画布视线时禁止落笔,单指平移、双指以中点为锚在 `1x..8x` 内缩放,桌面滚轮按指针位置缩放;切回继续编辑后保留视角并按变换后的画板矩形换算逻辑坐标。视口变化后重新约束位置,画布不得完全移出可见区。 +- 撤销历史按每一笔画笔/橡皮、填充背景和清空像素层分组,只在像素确实变化时记录操作前的 RGB 快照;最多 50 步,只存在当前页面内存。撤销仅恢复像素层并保留文字。退出再进入专注编辑不清空历史;页面刷新、场景替换,以及成功应用统一画板、保存或更新模板、保存或新建动图帧后清空,失败提交不得清空。 +- 支持“应用统一画板”和“保存 PNG”;PNG 保存导出最终组合场景,不只导出像素底层。 +- 背景填充只属于像素工作区;文字和未来透明元素不得各自提供全帧背景填充。 +- 后续可增加撤销、重做和导入图片。 + +画布坐标规则: + +- 前端场景模型维护一个 `64 x 64 x RGB` 像素数组作为固定底层。 +- 左上角是 `(0, 0)`。 +- 右下角是 `(63, 63)`。 +- 前端预览不直接旋转物理屏幕方向,方向统一由后端显示层处理。 + +实时预览策略: + +- 编辑过程只更新网页组合预览;点击“应用统一画板”时使用 `WS /ws/canvas` 发送最终合成帧。 +- WebSocket 发送全帧时,载荷为 `64 * 64 * 3 = 12288` 字节 RGB 数据的 base64,并固定声明 `"source": "composition"`。 +- 服务端只保留最新帧,避免网络或浏览器发送过快导致堆积。 + +WebSocket 消息示例: + +```json +{ + "type": "frame_rgb", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "source": "composition", + "data": "" +} +``` + +服务端响应示例: + +```json +{ + "type": "ack", + "mode": "composition", + "applied": true +} +``` + +验收标准: + +- 像素层点亮 `(0,0)` 时,按当前方向规则显示在对应屏幕角落,已存在文字仍保留并叠加。 +- 清空像素层后只有像素层变黑,文字和其他元素仍保留;只有场景没有其他可见元素时最终画面才全黑。 +- 颜色不会因为前后端 RGB 顺序问题错乱。 +- 画布快速拖动时服务不崩溃,最多丢弃旧帧。 +- 单像素模式的 `1x1/2x2/3x3/4x4` 笔尖均为完整正方形,快速拖动不留采样断线,边缘按 `0..63` 精确裁剪;网格在深浅像素上可辨认且不改变导出或发送的 RGB。 +- 约 `320px` 和常见手机视口下,未进入编辑时在画板上滑动页面不会改变像素;进入专注编辑后悬浮按钮拖动不落笔,移动模式可平移缩放,继续编辑后的落点仍与视觉像素一致。 +- 撤销可连续恢复所有未提交像素操作;成功提交当前场景后撤销立即不可用,失败提交仍可撤销。历史不写入浏览器持久存储、设备配置、模板或动图记录。 + +### 2.3.1 统一颜色选择器(`WEB-COLOR-UI`、`CONFIG-COLOR-PALETTE`) + +- 纯色测试、画笔、画布背景和文字四个颜色入口共用同一个软件自有颜色面板;页面不得包含或动态创建 `input[type="color"]`。 +- 小于 `40rem` 的设备使用底部抽屉,宽屏使用居中对话框。打开时记录原颜色,面板内拖动只更新新颜色预览,点击“使用此颜色”才提交;取消、遮罩点击或 `Escape` 不修改目标值。 +- HSV 模式提供色相轨道、饱和度/明度二维区域及数值输入;RGB 模式提供 R/G/B 自绘渐变轨道及数值输入;两种模式始终与大写 `#RRGGBB` 输入同步。 +- 颜色轨道使用 Pointer Events 并禁用轨道区域的浏览器默认触摸手势,同时支持键盘方向键和 `aria-valuenow`。主要触控区域不小于 `48x48 CSS px`。 +- 当前浏览器保存最近 10 个已提交或吸取的颜色,去重并按最近使用排序;面板模式和最近颜色不写入设备配置。 +- 设备共享收藏色最多 24 个,允许删除至空。首次或旧配置缺少字段时使用:`#000000`、`#FFFFFF`、`#FF0000`、`#FF8000`、`#FFFF00`、`#00FF00`、`#00FFFF`、`#0000FF`、`#8000FF`、`#FF00FF`、`#808080`、`#C0C0C0`。 +- 收藏色接口为 `GET /api/colors/palette`、`POST /api/colors/palette` 和 `DELETE /api/colors/palette/{RRGGBB}`。添加重复颜色幂等;达到 24 色后添加新颜色返回 `409`;删除不存在颜色幂等。 +- 吸管点击画布后读取该逻辑像素的精确 RGB,更新画笔色和最近颜色,然后恢复吸管之前的画笔或橡皮。 + +验收标准: + +- Windows 与 Android 打开四个颜色入口时显示同一套自有 UI,不出现系统颜色选择器,连续开关和拖动不导致页面卡死。 +- HSV、RGB 和 HEX 往返转换在舍入到 8 位 RGB 后一致;取消操作保持目标颜色不变。 +- 不同浏览器读取同一设备收藏色,最近颜色仍各自独立;配置重启后收藏色保留。 +- `1/2/4/8/16` 像素笔尖快速绘制无断线,画布边缘正确裁剪。 + +### 2.4 屏幕方向保存(`WEB-ORIENTATION`、`CONFIG-BASE`) + +目标:屏幕实际安装方向不确定,因此软件必须能锁定显示方向。 + +支持方向: + +- `0` +- `90` +- `180` +- `270` + +页面功能: + +- 使用四个方向按钮或下拉框。 +- 点击方向后立即应用到当前屏幕内容并写入配置文件,不设置单独保存按钮。 +- 修改方向时重新提交当前逻辑帧,不能先清屏或替换显示内容,当前模式保持不变。 +- 页面重新打开时读取当前保存方向。 + +后端行为: + +- `PUT /api/config` 保存方向。 +- 所有后续显示内容统一经过方向变换。 +- 方向变换属于底层显示接口的一部分,不要求每个业务功能自己旋转图像。 + +验收标准: + +- 保存 `180` 后重启服务,方向仍为 `180`。 +- 纯色测试不受方向影响。 +- 画布和文字都受同一方向规则影响。 +- 后续新增任何显示功能,都不需要重新实现方向逻辑。 + +### 2.5 文字显示(`WEB-TEXT`、`WEB-TEXT-I18N`、`WEB-TEXT-FONTS`) + +目标:允许用户在统一场景中创建多组互相独立、可叠加的静态文字元素。 + +页面功能: + +- 文字工作区提供元素列表,以及“新建文字”“复制”“删除”按钮;点击画板文字或列表项后切换当前选中元素。 +- 现有文本、字体、字号、文字颜色、`x` / `y` 坐标和左/中/右对齐编辑器始终绑定当前选中元素,选中变化时立即显示该元素的值。 +- “新建文字”创建内容为小写 `ok` 的元素,使用配置中的默认字体和字号、白色文字并默认位于画板中央;新元素追加到场景顶层并自动选中。 +- “复制”创建新 `id`,复制当前元素属性,将 `x` 和 `y` 各偏移 `2` 个逻辑像素,追加到顶层并选中新副本;超出边界的部分沿用裁切规则。 +- “删除”只删除当前元素,不影响像素底层或其他元素;没有选中元素时复制和删除按钮禁用。 +- 每个文字元素只包含文字本身,不提供背景颜色或全帧背景字段。 +- 画板上的文字可用 Pointer Events 拖动;选中框角点提供类似演示文稿文本框的等比缩放,缩放只改变字号,不增加自由宽高或自动换行。 +- 拖动和缩放过程中使用已缓存的透明层做连续视觉变换;交互结束后提交整数 `x`、`y` 和 `1..64` 的整数字号,再调用后端 Pillow 刷新精确图层。 +- 数值字段必须保留,作为键盘操作和精确定位的替代入口;画板选择、拖动、缩放均不得阻止触摸页面的必要操作。 +- 参数变化后防抖请求与实屏一致的透明文字图层;当前元素渲染失败时保留错误状态并禁止应用不完整场景。 +- 字体字段必须是可搜索的 ARIA combobox,不再提供服务器字体路径的自由文本输入。目录按“自动字体、已导入字体、系统字体”分组并显示字体家族与样式;输入只筛选目录,只有确认选项才修改当前元素。 +- combobox 支持鼠标、触摸和 `ArrowUp`/`ArrowDown`、`Home`/`End`、`Enter`、`Escape`;旧场景中的路径或已失效字体 ID 必须原样保留并显示“当前字体不可用,将自动回退”,不得因目录读取失败把场景改成 `default`。 +- 字体控件最右侧提供始终可用的“导入字体”按钮。导入成功且存在选中文字时自动选择返回的第一个字体面;没有选中文字时只更新目录,不隐式创建或修改文字元素。 +- 导入按钮使用共享控件状态区分真正上传、成功、重复文件、格式错误、超限、超时与网络失败;下拉框尚未加载或没有选中文字时也必须给出明确原因,不能留下永久忙碌状态。 + +第一版先实现静态文字,不要求滚动动画。滚动文字后续作为动画模式扩展。 + +后端行为: + +- `POST /api/preview/text-layer` 用 Pillow 生成一张 `64x64 RGBA` 透明图层,不调用显示驱动。 +- 使用 `ImageDraw.text()` 绘制文字,透明区域 alpha 为 `0`,文字颜色区域保留有效 alpha。 +- 使用 `ImageFont.truetype()` 加载字体;没有指定字体时使用默认字体。 +- `font:"default"` 表示自动多语言字体,不再表示某一个固定英文字体。渲染器必须选择一张能覆盖当前完整文本的字体,以保留复杂文字塑形和双向排版。 +- 用户指定的字体路径仍兼容;该字体不存在或不能覆盖全文时自动使用多语言后备字体。 +- 当前范围覆盖中日韩、拉丁/西里尔/希腊、阿拉伯/希伯来、常见南亚文字和泰文;Emoji、历史文字和 Noto Extra 中的罕见文字不属于当前保证范围。 +- 如果没有任何候选字体覆盖文本中的有效字符,接口返回明确的 Unicode 码位错误,不得静默画出 `.notdef` 方框。 +- 字号、颜色、坐标和对齐由请求参数控制;最终由浏览器与像素底层及其他元素合成,再把 RGB 帧交给 `DisplayService`。 + +请求示例: + +```json +{ + "text": "ok", + "font": "default", + "size": 12, + "x": 4, + "y": 24, + "align": "left", + "color": "#FFFFFF" +} +``` + +验收标准: + +- 两组以上文字能同时叠加在像素画上,各自内容、字体、字号、颜色、坐标和对齐互不影响。 +- 通过元素列表或画板选择后,编辑器只修改选中元素;拖动与缩放结束后的精确渲染位置和字号与数值字段一致。 +- 新建、复制和删除只改变目标元素,层叠顺序符合 `elements` 数组顺序。 +- 默认字体可直接显示上述常见语言;中文、日文、韩文、阿拉伯文、希伯来文、印地语、泰文、俄文和希腊文必须分别进入自动化覆盖。 +- 超出屏幕边界的文字允许被裁切,不应导致接口报错。 + +## 3. API 设计(`WEB-API`) + +### 3.1 `GET /api/status` + +用途:获取服务和屏幕状态。 + +响应示例: + +```json +{ + "ok": true, + "service": { + "app_version": "", + "instance_id": "" + }, + "screen": { + "width": 64, + "height": 64, + "driver": "walnutpi-h618-hub75", + "hardware_mapping": "walnutpi-pi-bank-pwm-oe-v2", + "driver_options": { + "rows": 64, + "cols": 64, + "scan_rows": 32, + "row_address_bits": 5, + "pwm_bits": 7, + "brightness": 40, + "limit_refresh_rate_hz": 100, + "pio_base": "0x0300B000", + "pi_bank_offset": "0x120" + }, + "driver_status": { + "actual_refresh_rate_hz": 99.7, + "panel_scan_rate_hz": 199.4, + "completed_frames": 123456, + "completed_scans": 246912, + "scans_per_frame": 2, + "deadline_misses": 0, + "buffer_swaps": 42, + "oe_timing_backend": "h618-pwm4", + "oe_pulse_faults": 0, + "oe_forced_blanks": 0, + "max_programmed_oe_ns": 8000, + "oe_timing_error": null, + "cpu_affinity": 3, + "realtime_priority_active": true, + "memory_locked": true, + "governor": "performance", + "last_error": null + } + }, + "state": { + "mode": "animation", + "orientation": 0, + "brightness": 40, + "effective_brightness": 40, + "startup_indicator_active": false, + "current_content": { + "category": "animation", + "id": "00000000-0000-4000-8000-000000000102", + "name": "演示动图" + }, + "animation_playback": { + "active": true, + "session_id": "4dd167e8-7924-49f7-af08-df1541af9f2d", + "animation_id": "00000000-0000-4000-8000-000000000102", + "animation_revision": "demo-v1", + "frame_id": "frame-1", + "frame_index": 1, + "frame_count": 2, + "position_ms": 120, + "total_duration_ms": 1000, + "paused": false, + "speed": 1.0, + "supported_speeds": [0.5, 1.0, 1.5, 2.0] + }, + "revision": 12 + } +} +``` + +`brightness` 始终表示用户保存的亮度。默认内容使用该亮度;低电压限亮活动时 `effective_brightness` 再取保护上限。自动动画帧和自动保护变化都不增加用户 `revision`,也不覆盖用户最后模式;用户成功暂停、继续、跳转或改变倍速时 `revision` 只增加一次。`current_content` 表示实际可见画面的来源:静态模板和动图分别使用 `template`、`animation`,WiFi、低电压和纯色测试等覆盖使用 `system`,无法关联已保存内容时使用 `unsaved` 和固定名称“未保存内容”。`animation_playback` 始终存在;无活动动图时 `active=false`、运行期字段为 `null`,`supported_speeds` 仍返回固定四档。 + +真实核桃派驱动必须返回 `driver_status`。`actual_refresh_rate_hz` 是最近完整测量窗口的逻辑帧边界实际值而非配置回显;`panel_scan_rate_hz` 是同一窗口内完整 32 行 × 7 bitplane 物理扫描的实际值。`completed_frames`、`completed_scans` 和 `deadline_misses` 为单调累计计数;所有亮度每逻辑帧只执行一次完整扫描,`scans_per_frame` 固定为 1,完成帧和完成扫描保持 1:1。生产 `oe_timing_backend` 固定为 `h618-pwm4`,OE fault、强制黑屏、最大脉宽和时序错误必须真实报告。CPU 绑定、实时优先级、内存锁定和 governor 分项报告实际状态。mock 驱动使用相同字段但值为 `null` 或明确的 `false`,不得伪造硬件优化。 + +`WEB-PERFORMANCE-MODE` 通过 `/api/status.system.performance_mode` 报告 `requested`、`available`、`effective`、`current_governors`、`restore_governors` 和 `last_error`。设置页标题固定为“性能模式”,提示固定为“如果使用出现卡顿,可以打开此选项,但会降低续航。”;不可用时保留可聚焦控件并给出原因。开启、配置保存和失败回滚必须是同一事务;关闭或服务停止恢复启动前 governor,最终部署默认保持关闭。 + +响应还包含: + +```json +{ + "resources": { + "status": "ok", + "sampled_at": "2026-07-21T08:00:00.000Z", + "cpu": { + "application_percent": 1.2, + "total_percent": 18.4, + "logical_cpu_count": 4, + "application_core_equivalent": 0.048, + "cores_percent": [12.1, 18.5, 21.0, 22.0] + }, + "memory": { + "application_bytes": 52428800, + "application_percent": 4.9, + "total_percent": 36.7 + }, + "error_code": null + }, + "power": { + "screen_input_voltage": { + "status": "ok", + "volts": 5.01, + "sampled_at": "2026-07-20T12:34:56.000Z", + "calibrated": true, + "error_code": null + }, + "low_voltage_protection": { + "enabled": true, + "mode": "limiting", + "brightness_limit_percent": 75, + "reading_stale": false, + "revision": 3, + "sampling_interval_seconds": 1.0, + "enforcement_error_code": null + } + } +} +``` + +`brightness` 始终是用户保存值,`effective_brightness` 是当前驱动实际值。临界保护时 `state.mode="low_voltage_indicator"`、`effective_brightness=35`,但用户 `brightness`、持久 `last_mode` 和缓存用户帧不变;限亮及临界自动切换不增加用户 `state.revision`,只增加独立的保护 `revision`。`enforcement_error_code` 只报告保护指令应用错误,不得冒充 ADC 读取错误。 + +### 3.1.1 电压校准 API + +- `POST /api/power/voltage/calibration/preview` 接收 `{ "reference_volts": 5.000 }`,同步等待唯一监测组件完成一组专用采样,成功时返回 `proposal_id`、`expires_at`、参考值、未校准值、当前系数、建议系数和预计校准后值。 +- `POST /api/power/voltage/calibration/confirm` 接收 `{ "proposal_id": "..." }`。只保存当前服务实例内仍有效且采样状态未变化的提案;不存在或过期返回 `409`,传感器不可用返回 `503`。 +- `POST /api/power/voltage/calibration/reset` 不接收校准值,将本机恢复到标称系数 `1.0` 并清空校准元数据。 +- 非法参考值或建议系数返回 `400`;预览、确认和恢复均不得修改显示状态修订号。 + +### 3.2 `GET /api/config` + +用途:读取当前保存配置。 + +响应示例: + +```json +{ + "orientation": 0, + "brightness": 40, + "default_font": "default", + "default_text_size": 12, + "last_mode": "clear", + "low_voltage_protection_enabled": false, + "voltage_calibration_factor": 1.0, + "voltage_calibrated_at": null, + "voltage_calibration_reference": null, + "voltage_calibration_uncalibrated": null, + "workspace_order": ["device", "settings", "canvas", "text", "animations", "templates"] +} +``` + +### 3.3 `PUT /api/config` + +用途:保存配置。 + +请求示例: + +```json +{ + "orientation": 90, + "brightness": 35, + "default_font": "default", + "default_text_size": 12, + "low_voltage_protection_enabled": true, + "workspace_order": ["device", "settings", "canvas", "text", "animations", "templates"] +} +``` + +规则: + +- `orientation` 只能是 `0/90/180/270`。 +- `brightness` 第一阶段限制在 `1..100`,但建议页面默认使用 `30..60`。 +- `low_voltage_protection_enabled` 必须是真正的 JSON 布尔值,数值、字符串和 `null` 均拒绝;开启后立即触发一组新采样,关闭后立即撤销保护。 +- `workspace_order` 必须提交完整的唯一合法工作区 ID 数组;保存使用现有原子配置写入,失败时不得改变原顺序。 +- 保存成功后立即影响后续显示。 + +### 3.4 `POST /api/display/fill` 与临时纯色测试 + +用途:独占显示纯色设备测试帧。该接口不修改浏览器统一场景草稿。 + +请求示例: + +```json +{ + "color": "#00FF00" +} +``` + +响应示例: + +```json +{ + "ok": true, + "mode": "fill" +} +``` + +旧 `POST /api/display/fill` 继续作为兼容独占改屏接口,并会结束活动测试会话。新网页使用以下设备测试接口: + +- `POST /api/display/test/fill` 接收 `{ "color": "#RRGGBB" }`。无活动会话时以 `50%` 测试亮度启动;已有会话时只换色并保留测试亮度。 +- `PUT /api/display/test/brightness` 接收 `{ "brightness": 1..100 }`,只更新临时亮度;无活动测试返回 `409`。 +- `DELETE /api/display/test` 幂等退出测试并恢复底层用户输出。 +- `GET /api/status.state.display_test` 返回 `active`、`color` 和 `brightness`;`state.effective_brightness` 继续表示低电压仲裁后的真实驱动亮度。 + +### 3.5 `POST /api/display/text` + +用途:兼容旧客户端的整帧文字显示。请求继续包含 `background` 并生成 `64x64 RGB` 帧;新网页不得用它显示场景文字,而应请求透明文字层并提交组合帧。 + +兼容请求示例: + +```json +{ + "text": "OK", + "font": "default", + "size": 12, + "x": 4, + "y": 24, + "align": "left", + "color": "#FFFFFF", + "background": "#000000" +} +``` + +响应示例: + +```json +{ + "ok": true, + "mode": "text" +} +``` + +### 3.6 `POST /api/display/clear` + +用途:独占清屏。该接口不等同于“清空像素层”,也不得修改浏览器统一场景草稿。 + +响应示例: + +```json +{ + "ok": true, + "mode": "clear" +} +``` + +### 3.7 `POST /api/display/diagnostic` + +用途:独占发送硬件诊断图案,用于排查屏幕重复、错位、扫描参数或接触不良问题。该接口只生成标准 `64x64 RGB` 帧,仍然通过底层显示服务提交,且不得修改浏览器统一场景草稿。 + +请求示例: + +```json +{ + "mode": "corners_lines" +} +``` + +允许的 `mode`: + +- `corners_lines` +- `row_bands` +- `address_check` +- `text_ok123` +- `clear` + +响应示例: + +```json +{ + "ok": true, + "mode": "diagnostic:corners_lines" +} +``` + +页面要求: + +- 诊断按钮放在快速测试区域。 +- 发送后只更新最近操作结果和屏幕状态,不自动连续执行多个需要目测的图案。 +- 需要目测的诊断步骤按测试文档暂停等待用户反馈。 +- 是否启用摄像头辅助由测试流程和用户确认决定;页面不默认要求摄像头,也不自动触发摄像头相关操作。 + +### 3.8 `POST /api/preview/text`、`POST /api/preview/diagnostic` + +用途:生成网页正向预览,不操作真实或模拟驱动。 + +- 文字预览复用 `POST /api/display/text` 的请求结构和 Pillow 渲染路径。 +- 诊断预览复用 `POST /api/display/diagnostic` 的请求结构和诊断渲染器。 +- 成功响应为 `image/png`,尺寸固定 `64x64`;参数错误仍返回明确的 `400` JSON 错误。 +- 预览始终是逻辑 `0°`,不得应用物理方向或行位映射,也不得修改 `last_mode`、当前逻辑帧和配置文件。 + +`POST /api/preview/text` 及 `POST /api/display/text` 的 `background` 字段为旧客户端兼容契约,继续保留;新网页不再调用这两个整帧文字接口。 + +### 3.9 `POST /api/preview/text-layer` + +用途:为统一场景中的单个文字元素生成无副作用的透明图层。 + +请求示例: + +```json +{ + "text": "ok", + "font": "default", + "size": 12, + "x": 4, + "y": 24, + "align": "left", + "color": "#FFFFFF" +} +``` + +规则: + +- 成功响应为 `image/png`,图像模式为 `RGBA`,尺寸固定 `64x64`;没有文字的区域必须透明,不能用黑色或其他背景填满。 +- 请求不接受场景背景语义;背景只由像素底层决定。 +- 字体、字号、坐标、对齐、颜色和边界裁切必须复用旧文字接口的 Pillow 渲染逻辑,避免网页预览与最终实屏分叉。 +- 参数错误返回明确的 `400` JSON 错误。 +- 调用前后 `last_mode`、当前逻辑帧、方向、亮度、真实或模拟驱动和配置文件必须保持不变。 + +### 3.10 `WS /ws/canvas` + +用途:接收已扁平化的统一场景最终帧,同时兼容旧画布客户端。 + +规则: + +- 继续支持整帧 `frame_rgb`。新网页额外发送固定字段 `"source":"composition"`,服务端成功后返回 `{"type":"ack","mode":"composition","applied":true}` 并把当前模式更新为 `composition`。 +- 旧客户端省略 `source` 时仍按原画布消息处理,成功响应保持 `mode:"canvas"`,不能因新增字段要求而失效。 +- 每次收到合法帧,后端立即提交最新帧。 +- 如果上一帧还没处理完,新帧可以覆盖旧帧。 +- 错误消息返回 JSON,不能让连接无声断开。 +- 客户端正常 close、网络断开或页面刷新必须由断开分支直接结束循环并记录普通信息日志;不得被显示异常分支捕获,不得在 WebSocket 已 close 后再次发送错误消息。 + +错误响应示例: + +```json +{ + "type": "error", + "message": "frame size must be 64x64 RGB888" +} +``` + +### 3.11 模板 API(`WEB-TEMPLATES`、`WEB-DEMO-TEMPLATES`、`CONFIG-TEMPLATES`) + +- `GET /api/templates` 返回按更新时间倒序的模板元数据、记录 `revision`、带内容摘要查询参数的缩略图 URL、单模板字节数、模板总字节数及模板目录所在分区的总/已用/可用字节数。`revision` 由完整记录计算,不写入模板 JSON。 +- `GET /api/templates?include_demo=true` 在普通结果外返回“演示静态图”,并以 `is_demo=true`、`demo_order=1`、`read_only=true` 明确其展示和权限;不带参数时只列出用户持久模板,便于普通选择器避免把演示项当作写入目标。 +- `POST /api/templates` 接收 `{ "name": string, "scene": sceneV1 }` 并创建模板。 +- `GET /api/templates/{id}` 返回完整模板;`GET /api/templates/{id}/thumbnail?v=` 返回后端缓存的 `image/png`。 +- `PUT /api/templates/{id}` 接收 `{ "scene": sceneV1 }` 并更新内容;`PATCH /api/templates/{id}` 接收 `{ "name": string }` 并只重命名;两者必须使用 `If-Match: ""` 提交读取时的记录修订值。 +- `POST /api/templates/{id}/copy` 复制完整场景;名称依次使用“原名 - 副本”“原名 - 副本 2”等第一个可用名称。 +- `POST /api/templates/{id}/play` 使用 `If-Match` 临时显示该修订的完整场景并结束活动动图,成功返回 `ok`、`template_id` 和 `revision`;演示静态图同样允许播放。该接口不得修改默认显示引用、模板记录或浏览器草稿。 +- `DELETE /api/templates/{id}` 使用 `If-Match: ""` 删除模板及其缩略图。 +- 演示静态图的详情和缩略图沿用模板读取接口;更新、重命名和删除统一返回 `409`,复制返回独立普通模板。 +- 缺少 `If-Match` 返回 `428`;修订值已过期或名称冲突返回 `409`;非法 scene 返回 `422`,不存在或非法 UUID 返回 `404`,磁盘空间不足返回 `507`。任何失败都不得改写或删除当前模板。 + +### 3.11.1 默认内容 API(`WEB-DEFAULT-DISPLAY`、`CONFIG-DEFAULT-CONTENT`) + +- `GET /api/display/default-content` 返回当前有效引用的 `type`、`id`、`name`、`revision` 和 `is_demo`。读取时发现持久引用失效必须先原子修复到演示动图再返回,不得留下悬空配置。 +- `PUT /api/display/default-content` 接收 `{ "type": "template" | "animation", "id": UUID }`。目标必须是存在的演示或用户静态模板,或至少含一帧的演示或用户动图;成功必须同时完成配置原子写入和即时显示,任一步失败都恢复旧引用及旧画面。 +- 删除当前默认内容或删除默认动图最后一帧时先切换演示动图。当前默认且正在播放的动图因此允许删除;其他正在播放的非默认动图仍返回 `409`。 +- `/ws/canvas` 的 `source="composition"` 消息可选携带 `content_source:{type:"template",id,revision}`。前端只在模板载入后未发生任何编辑时发送;合法但过期或已失效的来源不阻止帧显示,只把 `current_content` 降级为“未保存内容”,格式非法仍返回协议错误。 + +### 3.12 动图与内容库 API(`WEB-ANIMATIONS`、`CONFIG-ANIMATIONS`、`CONFIG-LIBRARY-ORDER`) + +- `GET/POST /api/animations` 列出或创建动图;`GET/PATCH/DELETE /api/animations/{id}` 读取、重命名或删除。删除活动动图返回 `409`。 +- `GET /api/animations?include_demo=true` 在普通结果外返回“演示动图”,并以 `is_demo=true`、`demo_order=2`、`read_only=true` 声明其展示和权限;不带参数时不把演示动图暴露给动图编辑器或复制目标选择器。 +- `/api/animations/{id}/frames` 及其 `/{frame_id}` 子资源提供帧创建、读取、场景覆盖、名称/时长修改和删除;摘要缩略图使用 `/{frame_id}/thumbnail?v=`。 +- `PUT /api/animations/{id}/frame-order` 只接受全部现有帧 UUID 的准确排列。`PUT /api/animations/{id}/frame-durations` 接受时长和可选 `frame_ids`;省略 ID 时兼容原有全部帧更新,提供时要求非空、无重复且全部属于当前动图,并原子更新所选帧。 +- `POST /api/animations/{source_id}/frames/copy` 使用来源 `If-Match`,接收非空无重复的 `frame_ids`、目标动图及其修订和可空 `insert_after_frame_id`;空位置表示插入最前。接口支持同动图或跨普通动图复制,保持来源顺序、名称、时长和 scene,生成独立 UUID 与缩略图,任何校验、缩略图或写入失败都不得留下部分结果。 +- `POST /api/animations/{id}/frames/batch-delete` 使用 `If-Match` 原子删除非空无重复的现有帧集合,成功后只清理对应缩略图;删除全部帧合法。演示动图不得作为上述写接口的目标。 +- `POST /api/library/copy` 明确提交静态模板或动图帧来源、静态或动图目标及相关修订值,生成完全独立的 scene 副本。 +- `GET /api/library/order` 返回全部当前用户内容的统一顺序 `items: [{ "type": "template" | "animation", "id": UUID }]` 和计算修订值。演示案例不在结果中;不存在顺序文件时按模板列表后接动图列表派生默认顺序且不落盘,已有记录中的已删除引用在响应中被过滤,新内容追加在有效记录末尾。 +- `PUT /api/library/order` 接收当前全部用户内容的准确排列并要求 `If-Match`。缺少修订值返回 `428`,过期修订或请求集合与当前用户内容不一致返回 `409`,格式错误、重复引用或包含演示 UUID 返回 `422`;成功后原子写入并返回新顺序和修订值。 +- `POST /api/animations/{id}/copy` 深复制完整动图并生成唯一副本名称;演示动图也只通过该接口复制为普通动图。演示动图的帧、排序、时长、名称和删除写接口统一返回 `409`。 +- `POST /api/animations/{id}/play` 使用 `If-Match` 启动该修订的预渲染快照;空动图和过期修订返回 `409`。所有写接口沿用模板 API 的 `428/409/422/404/507` 语义。 +- `PATCH /api/display/animation-playback` 只控制当前活动快照,必须携带 `/api/status.state.animation_playback.session_id`,并至少提供 `position_ms`、`paused`、`speed` 之一。`position_ms` 是严格整数且范围为 `0..total_duration_ms-1`;`paused` 是严格布尔值;`speed` 只接受 `0.5/1/1.5/2`。成功返回最新 `animation_playback`,无活动动图或会话不匹配返回 `409`,空更新、错误类型、非法档位或越界位置返回 `422`。同一次请求可以原子修改多个字段,跳转立即更新当前逻辑帧。 + +### 3.12.1 媒体内容转换(`WEB-MEDIA-IMPORT`、`CONFIG-MEDIA-JOBS`) + +- 侧栏新增稳定工作区 ID `media-import`。配置 v7→v8 在保持已有项目相对顺序的前提下,把它插入 `text` 之后、`animations` 之前。 +- 用户上传时只选择本地文件,不提前填写输出名称。服务以 `application/octet-stream` 流式接收,要求可验证的 `Content-Length`,单文件上限 `4 GiB`;每次上传都创建独立任务 UUID 和独立 `source.bin`,相同文件名或相同字节内容不得复用、覆盖或拒绝。用户文件名只作显示元数据,并在未显式提供兼容 `name` 参数时去掉最后一个扩展名、截取至 80 个字符,作为分析完成后“保存名称”的可编辑预填值;无有效文件名时使用“未命名媒体”。 +- 上传完成后后台分析媒体类型、尺寸、时长、方向、透明能力和最多五张代表帧。页面提供 `crop`、`contain`、`stretch` 三种模式;`crop` 在界面中显示为“自由取景”,以固定 1:1 输出视窗覆盖完整原图,不得先按短边居中裁切。`zoom=1` 时方向及像素宽高比归一后的完整素材居中可见,用户再缩放、拖动素材;整段媒体共用同一组 `center_x`、`center_y` 和 `zoom`,不得逐帧保存裁切。 +- `crop` 的 `center_x`、`center_y` 表示映射到输出视窗中心的归一化素材坐标,允许超出 `0..1` 以把素材拖出视窗,但最终至少有一个 64x64 输出逻辑像素与素材相交。透明像素填充色与几何留白色是两个独立 `#RRGGBB` 值:素材自身透明像素使用透明填充色,`contain` 留白及 `crop` 视窗落在素材外的虚空像素使用留白色。输出先完成方向和像素宽高比归一,再缩放完整 RGBA 素材、定位并合成两种颜色,最终固定为 `64x64 RGB888`。 +- 动态输入以 `20 fps` 为上限采样;最终 RGB 字节完全相同的相邻帧合并并累加持续时间。图片输出静态模板;视频或多帧图片输出动图,即使合并后只剩一帧也保持动图类型。 +- 设备只允许一个分析或转换工作进程。任务状态固定为 `uploading`、`analyzing`、`awaiting_settings`、`queued`、`converting`、`failed`;队列跨服务和整机重启恢复,运行中断任务重新排队。 +- 成功原子提交内容库后立即删除任务源文件、预览与暂存目录,转换卡片消失并立即刷新对应的静态模板或动图内容库;独立转换进程提交的动图必须在主服务不重启的情况下可见,不自动改变当前屏幕。取消同样立即清理;失败和等待设置任务保留 24 小时供重试,过期后清理。 +- 任务列表每 `2s` 轮询时必须按任务 ID 增量更新。处于 `awaiting_settings` 的卡片在服务端状态未改变时复用原 DOM,自动轮询和手动刷新均不得使名称输入失焦,也不得覆盖尚未提交的适配模式、裁切焦点、缩放、颜色或代表帧选择;任务进入排队、转换、失败、完成、取消或过期状态时才按服务端状态替换或移除卡片。未提交设置只保留在当前页面内存中,整页刷新后允许恢复服务端值。 +- 保存名称去除首尾空白后按 Unicode 大小写不敏感规则在静态模板、动图及其他 `queued`/`converting` 媒体任务之间保持唯一。两个 `awaiting_settings` 任务可暂时同名,先排队者原子占用名称;后提交者必须在保存设置或排队时收到 `409` 并保持任务、源文件和本地草稿可继续修改。失败、取消、完成或删除任务释放活动名称占用;转换开始前再次检查内容库,任何冲突不得留下部分内容。 +- 护栏固定为媒体时长不超过 2 小时、合并后不超过 30,000 帧,并始终为设备保留至少 2 GiB 可用空间;异常像素尺寸、无进展超时、总处理超时、磁盘不足或解码失败均不得留下部分模板。 +- Pillow 优先解码其支持的常见图片和多帧图片,HEIC/HEIF 使用 `heif-convert`,视频及其他可探测动态媒体使用 FFmpeg/ffprobe。音频流忽略,音频文件、网络 URL、播放列表、SVG、PDF、文档、压缩包和相机 RAW 拒绝。 +- `POST /api/media-imports?filename=` 创建流式任务,并继续兼容旧客户端可选的 `name=`;`GET /api/media-imports` 与 `GET /api/media-imports/{id}` 返回公开状态;`GET /api/media-imports/{id}/previews/{index}` 返回预览;`PUT /api/media-imports/{id}/settings` 保存名称和转换参数;`POST .../convert`、`POST .../retry`、`DELETE .../{id}` 分别排队、重试和取消。 +- API 不返回持久根、源文件、预览或暂存的绝对路径。输入按内容探测;FFmpeg 子进程只允许本地 `file` 与 `pipe` 协议,转换 unit 禁止网络和设备访问。 + +### 3.13 WiFi 与 IPv4 API(`WEB-WIFI-SETTINGS`、`CONFIG-WIFI`) + +- `GET /api/network/wifi` 返回 `saved`、`active`、`prompt_delay_seconds` 和最近 `operation`;仅该专用设置接口在 `saved.password` 中返回 NetworkManager 保存的明文密码或 `null`,同时保留 `password_configured`。`/api/status.network`、WiFi 写接口响应和业务日志继续脱敏。 +- `PUT /api/network/wifi` 接收 `ssid`、可空 `password`、`ipv4_mode`、静态地址参数和 `activation`。`password` 为空表示保留当前密码,SSID 改变时必须提交新密码;旧调用方可继续携带可选 `prompt_delay_seconds`,新页面不得通过该入口保存提示延迟。 +- `PUT /api/network/wifi/prompt-delay` 只接收 `prompt_delay_seconds`,原子保存 `1..3600s` 的网络提示延迟并返回当前值。它不读取或修改 NetworkManager、不触发网络激活,也不重算当前 boot ID 已建立的运行期提示截止时间。 +- `ipv4_mode="dhcp"` 清除静态地址;`manual` 必须提供 IPv4 地址和网关,子网前缀空值使用 `/24`,DNS 空值使用网关。 +- `activation="next_boot"` 只更新持久 NetworkManager 配置并返回 `200`;`immediate` 先保存、返回 `202`,再异步激活连接,避免浏览器在响应发出前被切断。 +- SSID 按 UTF-8 字节限制为 `1..32`,个人热点密码限制为 `8..63` UTF-8 字节;IPv4、前缀 `1..32` 和 DNS 必须严格校验。除 `GET /api/network/wifi` 的 `saved.password` 外,任何响应、错误和日志不得回显密码。 +- `/api/status.network` 暴露同一脱敏活动状态与提示状态;读取状态、静态资源和预览不取消开机提示,只有成功首页访问或成功改屏才取消。 + +### 3.13.1 FRP 与网络诊断 API(`WEB-FRP-MAINTENANCE`、`CONFIG-FRP`、`WEB-NETWORK-DIAGNOSTICS`) + +- `GET /api/frp` 只返回安装版本、目录元数据、唯一选中项、systemd 启用/运行状态和脱敏错误,不返回配置正文、服务器地址或令牌。 +- `POST /api/frp/configs`、`GET|PUT|DELETE /api/frp/configs/{id}` 以不超过 `1 MiB` 的 UTF-8 原文管理 TOML/YAML/JSON/INI;名称不超过 80 字符,路径只由服务生成 UUID。 +- `POST /api/frp/configs/{id}/select` 切换唯一活动配置;`POST /api/frp/service/start|stop` 分别对独立 unit 执行 `enable --now` 与 `disable --now`。没有选中配置时拒绝启动,活动运行配置在停止前拒绝删除。 +- 新建和保存都先在同目录临时文件执行官方 `frpc verify -c`;活动配置保存成功后自动重启,重启失败恢复旧字节和旧进程。不检查或改写任意 FRP 服务器、端口、代理类型、插件、`includes`、证书、令牌源或环境变量模板。 +- `POST /api/network/diagnostics` 依次返回接口/IP、默认路由、DNS、中国大陆多站点 TCP/TLS 及辅助 ICMP 证据。TLS 已通过时 ping 失败只作提示,不误判断网。 + +### 3.14 设备容量 API(`WEB-DEVICE-STORAGE`) + +- `GET /api/device-storage` 返回 `sampled_at`、`device_total_bytes`、`device_free_bytes`、`software_bytes` 和 `templates_bytes`。 +- 设备总量与可用量取程序根所在文件系统;`software_bytes` 为程序根与持久数据根去重后的文件占用总和,不包含运行根;`templates_bytes` 为静态模板、有效缩略图和动图内容占用。 +- 服务使用线程安全进程内缓存,最多保留 `300s`;配置、WiFi、模板、动图或内容库成功写入后立即使缓存失效。目录统计不得跟随符号链接,也不得扫描持久根、程序根以外的位置。 +- 模板页不再展示容量卡;设备页进入时读取容量,停留时每 `300s` 刷新,同页内容变更后立即刷新。读取失败只影响容量卡并给出明确提示,不阻塞设备状态和显示控制。 + +### 3.15 字体目录与导入 API(`WEB-TEXT-FONTS`、`CONFIG-FONTS`、`WEB-SECURITY`) + +- `GET /api/fonts` 返回 `items`、`accepted_extensions`、`max_upload_bytes` 和 `warnings`。首项固定为 `id:"default"`;每个目录项只暴露 `id`、`label`、`family`、`style` 和 `source`,不得暴露服务器文件路径。 +- `source` 只允许 `automatic`、`imported`、`system`。系统字体 ID 由规范路径与 Fontconfig face index 的 SHA-256 生成;导入字体 ID 为 `user:<文件SHA-256>:`,服务重启后保持稳定。 +- `POST /api/fonts/import?filename=` 接收 `application/octet-stream` 原始请求体,只接受 `.ttf`、`.otf`、`.ttc`、`.otc`,最大 `32 MiB`。服务必须同时检查扩展名、流式字节上限、实际字体结构和至少一个带 Unicode cmap 的可用字体面。 +- 新内容返回 `201` 和 `created:true`;重复内容不重复落盘,返回 `200` 和 `created:false`。两种响应都返回文件中的全部可用字体面。超限、类型不支持、内容无效和空间不足分别返回 `413`、`415`、`422`、`507`。 +- 上传先写入 `fonts/` 内明确命名的事务临时文件,完成校验、文件同步、原子替换和目录同步后才成功;失败只清理本次临时文件。不得把用户字体安装到系统目录或运行 `fc-cache`。 + +## 4. 配置和数据(`CONFIG-DATA-LIFECYCLE`、`CONFIG-BASE`) + +生产环境的数据边界固定如下: + +| 类别 | 位置 | 内容与处理 | +|---|---|---| +| 持久数据 | `/var/lib/matrix-screen-controller` | `config.json`、`wifi_config.json`、`frp/catalog.json` 与各 UUID 原文配置、模板 JSON、动图记录、`library/order.json`、`fonts/.font`、用户导入图片、预设及未来用户资源;更新、回滚和清理不得删除、覆盖或以默认值重建。NetworkManager 自身的连接配置由其系统目录管理。 | +| 可再生数据 | 持久根下各模块的专用缓存目录 | 当前包括模板和动图帧缩略图;升级时保留,只有模块能按严格命名和引用关系删除已证明无效的临时、孤立或过期项。 | +| 运行期数据 | `/run/matrix-screen-controller` | 开机标记、WiFi 提示会话与操作结果、mock 最新帧、锁和进程级临时状态;整机重启可丢失,systemd restart 可按登记语义保留,不进入迁移清单或更新备份。 | +| 更新暂存与事务备份 | 更新工具声明的专用暂存位置 | 成功后删除,不长期累计多代 `.bak`、旧包或解压目录;失败时不得牵连原持久文件。 | + +数据根和运行根分别按以下优先级解析:测试构造时显式传入的目录 → `MATRIX_DATA_DIR`/`MATRIX_RUNTIME_DIR` → systemd 的 `STATE_DIRECTORY`/`RUNTIME_DIRECTORY` → 本机源码隔离目录 `data/` 与 `data/runtime/`。生产 systemd 必须显式使用 `/var/lib/matrix-screen-controller` 和 `/run/matrix-screen-controller`,不得回落到 `/opt`。 + +设备配置位于持久根的 `config.json`。落盘的当前格式含仅供存储层使用的 `schema_version`: + +```json +{ + "schema_version": 7, + "orientation": 0, + "brightness": 40, + "default_font": "default", + "default_text_size": 12, + "last_mode": "clear", + "low_voltage_protection_enabled": false, + "color_palette": ["#000000", "#FFFFFF"], + "voltage_calibration_factor": 1.0, + "voltage_calibrated_at": null, + "voltage_calibration_reference": null, + "voltage_calibration_uncalibrated": null, + "preview_refresh_interval_ms": 1000, + "matrix_refresh_rate_limit_hz": 100, + "custom_test_color": "#40A0FF", + "default_display": { + "type": "animation", + "id": "00000000-0000-4000-8000-000000000102" + }, + "workspace_order": ["device", "settings", "canvas", "text", "animations", "templates"] +} +``` + +保存要求: + +- 写入 JSON 时使用 UTF-8。 +- 配置文件确实不存在时才创建当前 schema 的默认配置;已存在文件不能因读取、迁移或校验失败被默认值替换。 +- 缺少 `schema_version` 的现有格式只按 legacy v0 处理;存储层通过登记的 `vN → vN+1` 函数逐级迁移,全部步骤和当前 schema 校验成功后才写回。业务层只接收当前格式,不保留多版本分支。 +- 配置字段集必须按版本显式维护:v1 是原有方向、亮度、字体、模式、色板与四个电压校准字段;v2 增加 `preview_refresh_interval_ms`;v3 增加严格布尔字段 `low_voltage_protection_enabled`;v4 增加 `matrix_refresh_rate_limit_hz`;v5 增加严格大写 `#RRGGBB` 字段 `custom_test_color`;v6 增加 `default_display`;当前 v7 增加 `workspace_order`。逐级迁移分别只增加对应默认值并完整保留已有业务值;模板当前 schema 仍为 v1,不随配置版本同步升级。 +- 损坏 JSON、布尔值冒充数值、未知字段、未知更高版本或无法安全迁移时,保持原文件字节不变、记录明确错误并阻止服务启动,不得跳过字段、猜测修复或自动另建配置。 +- 写入前校验当前 schema 的完整字段集合和范围。电压校准系数必须是 `0.8..1.2` 的有限数值;四个校准字段作为一组校验,缺失或不一致时属于无效现有配置,不能静默回落为未校准状态。 +- 所有配置和模板写入共用耐久原子写入:在目标同目录创建临时文件,写完后刷新并 `fsync` 文件,使用 `os.replace` 替换,再同步父目录;任一步失败都清理本次临时文件并保留旧文件,不累计滚动备份。 +- `schema_version` 只存在于落盘记录,不出现在 `/api/config`、模板列表或模板详情响应中;`/api/config` 暴露当前业务字段(包括 `workspace_order`),其他 REST、WebSocket 和模板 API 的请求响应形状不因存储迁移改变。 + +系统设置页面按“屏幕亮度”“屏幕方向”“屏幕扫描刷新率”“供电电压”“预览刷新间隔”排列。亮度保留“拖动时自动应用,松手后立即保存”的说明,并按保护状态显示允许上限或临界覆盖提示;方向说明固定为“设置屏幕方向后会一直保存在软件内部,请使用带有方向性的文字进行设置。”;供电电压说明固定为“正常情况下,输入电压应该是5V左右,如果差距较大,请充电或者降低亮度。”,保护开关位于电压详情与校准按钮下方。页面不显示概括性的自动保存说明,也不显示更换 ADC 或测量接线后的提示;电压校准与恢复标称值功能保持不变。 + +“WiFi设置”位于系统设置末尾区域,页面提供 SSID、始终明文显示且读取后不清空的密码、默认开启的 DHCP 开关;关闭 DHCP 后显示必填 IPv4/网关及选填前缀/DNS。SSID、密码、DHCP、IPv4、网关、前缀或 DNS 发生过实际输入、删除或切换后,对应控件显示醒目边框和“对应内容已经修改”;只聚焦不算修改,改回服务端原值仍保持已修改。提示持续到网络配置成功应用或离开系统设置,失败时保留。 + +“立即生效”和“下次开机生效”在没有网络字段草稿时不点亮,并以 `aria-disabled`、桌面悬停/键盘聚焦说明和触屏提示解释“请先修改 WiFi 或 DHCP 设置”。任一网络字段被修改后两按钮同时点亮;处理中使用共享忙碌状态,成功后清除全部网络字段提示并恢复未点亮状态,失败或校验不通过时恢复为可点击且继续点亮。立即切换前明确告知当前页面可能断开。 + +“网络提示显示”是紧接在 WiFi 设置下方的独立设置卡,提供 `1..3600s` 输入和右侧“保存”按钮。该输入实际编辑后只点亮自己的保存按钮,不显示“对应内容已经修改”,也不点亮或禁用两个网络生效按钮;网络生效操作同样不得保存或清除该草稿。成功保存后按钮恢复未点亮,失败时保留草稿和可重试状态。 + +进入系统设置时读取服务端当前值作为 WiFi 与网络提示的共同基线;离开工作区时立即恢复该基线、清除所有未应用提示和按钮状态,并忽略尚未返回的旧读取结果。已经点击并发出的保存请求不因离开而取消;浏览器不在本地持久化这些草稿,关闭或重新打开后重新读取设备值。 + +`wifi_config.json` 当前 schema 为 v1,只包含 `managed_connection_uuid`(可空 UUID)和 `prompt_delay_seconds`。文件不存在时才创建;损坏、未知字段、未来版本或不安全迁移保持原件并阻止服务启动。NetworkManager 保存的密码不得复制到该 JSON 或业务日志;只允许物理断网提示和 `GET /api/network/wifi` 在进程内按需读取。 + +`matrix_refresh_rate_limit_hz` 是持久设备配置和 HUB75 逻辑帧边界的最高频率目标,不是保证最低或精确频率;面板物理扫描由当前有效亮度自适应为该值的三倍(`1..20%`)或两倍(`21..100%`),并由 `panel_scan_rate_hz` 和 `scans_per_frame` 单独报告。设置页只提供 `15、20、30、45、60、80、100 Hz` 七个按钮,不提供数字输入或“不限速”;点击不同挡位后整组按钮进入可感知的处理中状态,C 刷新线程在帧边界应用新上限并保持当前画面或当前动图位置,网页服务不得重启。成功反馈“Hz 上限已生效并保存”;失败时恢复原选中挡位、原驱动参数和原画面并显示原因,不得把真实驱动创建失败的 mock 回退报告为成功。页面固定说明该值是逻辑帧上限、低于 `80Hz` 可能明显闪烁、降低后可能降低屏幕功耗并使画面变暗,但不承诺核桃派 CPU 或整机功耗按比例下降。 + +`preview_refresh_interval_ms` 是持久设备配置,当前 schema 默认值为 `1000`,只允许 `1..60000` 的整数。设置页使用毫秒数字输入;编辑过程不提交、不校验负载提示,只有输入完成并触发 `change`、失焦或按 Enter 后才保存。最终值小于 `100` 时在输入下方显示“刷新间隔过短,会增加核桃派负载,可能影响系统运行。”,但仍保存并立即应用;`100` 不显示该提示。无效、空值、非整数或超范围值不保存,并恢复最近一次有效值。 + +当前帧定时器只控制活动监视器的 PNG 请求,不改变固定的设备状态轮询、电压采样或 `matrix_refresh_rate_limit_hz`。定时器使用单请求递归调度,请求耗时超过配置间隔时允许实际刷新变慢,不得并发堆积。`GET /api/status` 通过 `ui.preview_refresh_interval_ms` 暴露当前值,使其他已打开页面在既有状态轮询内采用新间隔;屏幕扫描上限通过完整 `screen.driver_options.limit_refresh_rate_hz` 暴露,不虚构实时测量值。 + +亮度条的 `input` 变化必须立即进入设备更新队列,队列同一时间最多发送一个请求并在连续拖动时只保留尚未发送的最新值;松手后必须保证最终值已应用并保存。实屏应在拖动过程中持续反馈亮度变化,不得等待下一次画面更新。设置反馈默认隐藏,只在读取失败、提交中、成功或失败时显示。`default_font` 和 `default_text_size` 为兼容旧配置继续保留,文字工作区可以将它们作为首次打开时的回落值,但不在系统设置中展示。 + +浏览器编辑草稿(`CONFIG-UI-STATE`)使用稳定 `localStorage` 键 `matrixController:scene`,版本只保存在 JSON 内容中: + +```json +{ + "version": 1, + "width": 64, + "height": 64, + "pixelRgb": "", + "elements": [ + { + "id": "", + "type": "text", + "text": "ok", + "font": "default", + "size": 12, + "x": 4, + "y": 24, + "align": "left", + "color": "#FFFFFF" + } + ] +} +``` + +- `pixelRgb` 解码后必须正好是 `64 * 64 * 3` 字节 RGB888;`elements` 数组顺序就是从底到顶的绘制顺序。文字元素只持久化示例所列字段,不得保存 `background`、运行时 `revision`、渲染状态或图层缓存。 +- 选中元素、渲染中/失败状态、透明 PNG 缓存、状态消息、设备状态、测试选择和错误不持久化。 +- 稳定键不存在时,先读取并验证所有实际存在的旧键;任一旧键损坏或无法识别都必须保留全部原值并停止迁移,不能跳过高优先级损坏值后拿低优先级草稿覆盖。全部验证通过后,先迁移现有 `matrixController:scene:v1`;没有该键时再按 `matrixController:canvas:v3`、`matrixController:canvas:v2`、旧 `matrixCanvasRgb` 的优先顺序选择像素草稿,同时独立读取 `matrixController:text:v1` 并合并为文字元素。迁移保留能验证的旧像素、画笔/背景颜色、画笔粗细及文字属性,丢弃旧文字背景。 +- 旧文字草稿迁移为一个稳定 `id` 的 `type:"text"` 元素;损坏文字元素单独跳过,不能连带清空有效像素或其他元素。缺少画笔粗细时回落为 `1`。 +- 只有新内容已完整校验并成功写入稳定键后,才精确删除本次已迁移的旧键;写入失败时旧键和值必须保留。 +- 稳定键内容损坏、出现未知更高版本或迁移失败时,页面保留原值和内存草稿、停止自动写回并明确提示用户;不得用空白场景覆盖,不得自动上传到核桃派。场景只保存在当前浏览器,不写入设备 `config.json`,不要求跨浏览器共享,也不保证核桃派重启后自动恢复实屏。 +- 同一来源的其他标签页修改稳定场景键时不得直接替换当前内存草稿;冲突解决前当前标签页的后续编辑继续保留在内存但暂停写回 `localStorage`。用户选择“载入其他标签草稿”后采用最后收到的有效外部值,选择“保留当前草稿”后才显式覆盖共享键。 +- 画笔颜色、像素层背景颜色、画笔模式、两种模式各自的笔尖粗细和当前画笔工具使用独立带版本号的本地 UI 状态;它们不是场景元素,也不改变 scene v1 的最终像素和元素契约。当前键为 `matrixController:canvas-tools:v2`:刷新后恢复模式与两套尺寸;首次从 v1 读取时把旧 `brushSize` 迁入普通模式,像素模式默认 `1x1` 且默认关闭,损坏或缺失值安全回落到默认值。 +- 每个新工作区显式选择 `none`、`local` 或 `server`;普通显示功能必须说明怎样接入 scene v1,不得把未来场景大对象无条件塞入服务端配置。 + +核桃派模板(`CONFIG-TEMPLATES`)使用持久根下独立的 `templates/` 数据区: + +- 每个模板 JSON 使用不可变 UUID 文件名,用户名称不参与路径拼接;落盘记录包含仅供存储层使用的 `schema_version: 1`,API 中的模板 ID、名称、创建/修改时间、场景摘要、内容和修订语义保持不变。 +- 缺少版本的现有模板按 legacy v0 逐级迁移到唯一当前 schema。服务启动必须先迁移并校验全部模板记录,再执行任何缩略图协调或清理;任一模板损坏、含未知字段、版本过高或迁移失败时,所有原件保持不变并阻止服务启动。 +- 缩略图文件名包含模板 UUID 和规范场景内容摘要。更新时先生成并验证新图,再提交模板 JSON,最后删除旧摘要图片;重命名不重复生成内容未变化的缩略图。 +- 服务启动和每次模板变更后只在专用缩略图目录内按严格 UUID/摘要规则清理临时文件、孤立图片和过期图片;读取或迁移阶段存在任何不可读记录时不得开始清理,也不得扫描或删除配置、运行期最新帧、模板 JSON 或其他用户数据。 +- 场景缩略图渲染按元素类型注册;新增元素类型时必须同时提供后端缩略图渲染器和模板往返测试。 +- 单模板空间等于其 JSON 与当前有效缩略图的实际文件大小;模板总空间为所有有效模板之和。磁盘空间取模板目录所在文件系统,不使用浏览器估算。 +- 模板更新、重命名和删除在存储锁内比较请求修订值;过期客户端只能收到冲突并重新读取,不得以最后提交者静默覆盖。 + +设备默认内容引用(`CONFIG-DEFAULT-CONTENT`)保存在同一持久 `config.json` 的 v6 字段 `default_display` 中,格式固定为 `{ "type": "template" | "animation", "id": UUID }`。v5→v6 只增加演示动图引用并保留所有业务值;配置层只校验结构,应用层负责确认目标存在和动图非空。引用失效时应用层必须原子写回演示动图,不能只在内存中临时回落。 + +工作区顺序(`CONFIG-WORKSPACE-ORDER`)保存在同一持久 `config.json` 的 v8 字段 `workspace_order` 中。v6→v7 增加原六个稳定工作区 ID;v7→v8 只把 `media-import` 插入 `text` 之后、`animations` 之前,已有项目相对顺序不变。字段必须是 `1..128` 个唯一、合法 ID 的数组;前端读取后过滤当前版本不存在的 ID,并按注册默认顺序追加缺失工作区。只有用户点击“保存”才通过 `PUT /api/config` 原子替换该字段,编辑中的顺序不得写入本地存储、运行根或服务端。 + +媒体转换任务(`CONFIG-MEDIA-JOBS`)使用持久根下的 `media-import/jobs//`。每个 `job.json` 使用严格 schema v1,源文件固定命名,预览和暂存只允许位于同一任务目录。服务启动逐个验证任务;损坏或未来版本任务保持原件、禁用媒体转换子系统并报告错误,但不得阻止显示、配置和已有内容库启动。上传中断只删除严格匹配的 `.part` 文件;活动任务以原子状态写入恢复,成功内容提交后才删除源文件。 + +模板管理顺序(`CONFIG-LIBRARY-ORDER`)使用持久根下的 `library/order.json`: + +- 当前格式为 v1,只持久化 `schema_version` 和用户静态模板/用户动图的类型与 UUID,不复制名称、scene、帧、演示案例或缩略图。文件不存在表示尚未自定义排序,读取不得因此创建文件。 +- 顺序修订值由过滤已删除引用并追加当前缺失内容后的完整用户序列计算。首次排序及后续更新使用同目录临时文件、文件和目录同步及原子替换;资源新增或删除不要求联动改写顺序文件。 +- 文件损坏、字段不严格、重复引用或未知版本时保留原件并阻止服务启动;记录中的已删除 UUID 不是损坏,读取时过滤,下一次成功排序用当前完整序列替换旧记录。 + +## 5. 安全和局域网边界(`WEB-SECURITY`) + +第一阶段默认只在可信局域网使用,不实现登录。字体上传仍必须使用严格的扩展名白名单、`32 MiB` 服务端流式上限、FontTools 内容校验、内容摘要文件名和持久根路径隔离;媒体上传按 `WEB-MEDIA-IMPORT` 使用 `4 GiB` 流式上限、内容探测、协议白名单和独立资源隔离。查询与响应不得暴露系统或持久根绝对路径。 + +必须在文档和页面中保留后续安全升级位置: + +- 简单访问密码。 +- 只允许局域网地址访问。 +- 禁止公网端口转发。 +- 图片、动图和视频上传已按 `WEB-MEDIA-IMPORT` 定义大小、类型、任务生命周期和失败提示;后续新增文档、矢量或网络媒体必须另行扩展白名单和隔离要求。 + +## 6. 日志和错误处理(`WEB-ERRORS`) + +服务至少记录: + +- 服务启动和监听地址。 +- 配置读取和保存。 +- 屏幕驱动初始化成功或失败。 +- API 参数错误。 +- WebSocket 连接、断开和帧格式错误。 + +前端至少显示: + +- 当前屏幕是否可用。 +- 最近一次操作是否成功。 +- 如果屏幕驱动初始化失败,说明网页仍可打开但无法实际点亮屏幕。 + +## 7. 后续实现验收清单 + +- 核桃派开机后服务自动启动。 +- 开机笑脸按用户保存方向持续眨眼,独立使用 50% 有效亮度;首个成功改屏指令后同一开机内不再出现。 +- 开机延迟到期且未被用户操作时,成功连接滚动实际 SSID 与控制 URL;断网显示静态 WiFi 图标并滚动保存的 SSID/密码,恢复连接后自动切换。提示固定 50% 名义亮度且不污染用户状态。 +- WiFi 设置能明文读取并编辑保存的密码,保存 DHCP 或完整静态 IPv4,立即切换先返回响应,下次开机模式不改变当前活动连接;只有专用 WiFi 设置 GET 返回密码,状态、写响应和日志不泄露密码。网络提示延迟由下方独立按钮保存且不触发网络配置。 +- 局域网设备可以访问 `http://核桃派IP:8080`。 +- 纯色测试能显示黑、红、绿、蓝、白。 +- 共享画板在所有工作区顶部无遮挡显示,像素底层与多组文字能同时叠加、独立编辑并作为一个组合帧应用到屏幕。 +- 清空或填充像素层不影响文字;文字新建、复制、删除、拖动和等比缩放不影响像素层或其他文字。 +- 设备测试独占覆盖实屏但保留场景草稿,离开测试页只恢复网页预览,点击“恢复/应用统一画板”后才恢复实屏组合场景。 +- 保存方向后重启服务仍生效。 +- 透明文字层预览无显示副作用,渲染待定或失败时不能应用不完整场景。 +- WebSocket 快速发送画布帧时服务不崩溃。 +- 屏幕底层不可用时网页仍能打开并给出明确错误。 +- 低电压保护默认关闭且不改写用户设置;开启后按 `4.8V/4.5V` 精确边界限亮或覆盖 35% 低电图标,电压恢复后自动回到最新用户画面。 + +## 8. 未来扩展入口 + +以下内容只定义扩展落点和边界,不代表当前阶段必须实现。新增功能时先在本节或对应章节补充需求编号,再更新测试矩阵。 + +- 动画、滚动文字、时钟、天气、传感器数据展示:归入新的 `WEB-MODE-*` 或扩展 `WEB-TEXT`,必须说明页面入口和元素/图层提供者,并复用 `WEB-COMPOSITION` 与最终 `64x64 RGB` 帧边界。 +- 把媒体作为可移动场景元素、逐帧独立裁切或网络媒体导入:在现有 `WEB-MEDIA-IMPORT` 基础上另行扩展;当前转换结果只生成像素底层 scene,不新增图片元素。 +- 字体删除、重命名、全局默认和系统级安装:继续扩展 `WEB-TEXT-FONTS` 与 `CONFIG-FONTS`;当前只提供目录、选择和持久导入。 +- 预设场景、启动默认画面、收藏画布:扩展 `CONFIG-BASE` 和 `CONFIG-DATA-LIFECYCLE`,必须说明持久位置、当前 schema、单向迁移和失败保全。 +- 访问密码、局域网白名单、禁止公网转发提示:扩展 `WEB-SECURITY`,并在测试文档增加允许/拒绝场景。 +- 多屏、新尺寸屏幕或新硬件映射:网页端只能消费底层暴露的尺寸和能力,不直接写死 GPIO 或 HUB75 细节;先更新硬件资料和 `02_屏幕底层控制接口需求.md`。 + +## 9. 参考资料 + +- 本地接线资料:`硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md` +- 核桃派官方 `gpioc`(MIT):https://github.com/walnutpi/gpioc +- FastAPI WebSockets:https://fastapi.tiangolo.com/advanced/websockets/ +- FastAPI StaticFiles:https://fastapi.tiangolo.com/tutorial/static-files/ +- MDN Canvas API:https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API +- Pillow ImageDraw:https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html +- systemd service:https://manpages.debian.org/bookworm/systemd/systemd.service.5.en.html diff --git a/整体开发需求/02_屏幕底层控制接口需求.md b/整体开发需求/02_屏幕底层控制接口需求.md new file mode 100644 index 0000000..63385f1 --- /dev/null +++ b/整体开发需求/02_屏幕底层控制接口需求.md @@ -0,0 +1,650 @@ +# 02 屏幕底层控制接口需求 + +本文描述点阵屏幕底层控制部分的需求。目标是把 HUB75 点阵屏控制抽象成稳定的显示接口,让后续网页端和各种显示功能只关心“生成一张 `64x64 RGB` 图像”,不关心 GPIO 时序、扫描方式、亮度 PWM 或硬件映射。 + +当前硬件范围固定为: + +- 主控:`核桃派 ZeroW`(Allwinner H618) +- 屏幕:`RGB-Matrix-P3-64x64-F` +- 分辨率:`64 x 64` +- 接口:`HUB75` +- 扫描方式:`1/32` +- 驱动:项目自带 `walnutpi-h618-hub75` C 原生驱动 +- GPIO:H618 `/dev/mem` 寄存器访问,14 根信号集中在 `PI` bank +- 屏幕数量:单块屏,不级联,不并行多链路 +- 屏幕输入电压检测:`M5Stack Unit ADC v1.1`(`U013-V11`,`ADS1110`,I²C 地址 `0x48`) + +## 0. 需求编号索引 + +本文件使用 `DISPLAY-*`、`CONFIG-*` 和 `HW-*` 编号描述显示边界、驱动、配置和硬件契约。网页/API 入口见 `01_网页配置端需求.md`,测试覆盖关系见 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。 + +| 编号 | 范围 | 当前阶段要求 | +|---|---|---| +| `HW-MATRIX` | 当前屏幕、HUB75、GPIO、硬件映射 | 单块 `RGB-Matrix-P3-64x64-F`,`64 x 64`,1/32 扫描、ABCDE 行地址;信号固定映射到核桃派 PI bank。 | +| `HW-SCREEN-VOLTAGE` | 屏幕输入端电压检测、Unit ADC 接线和 I²C 占用 | 使用 `M5Stack Unit ADC v1.1` 并联测量屏幕输入端电压,占用物理脚 3/5 的 `PI8/SDA1`、`PI7/SCL1`;独立监测组件向主服务提供缓存状态和每台设备的校准能力。 | +| `HW-CPUFREQ-AXP313A` | CPU 电源与 cpufreq | H618 板载 PMIC 按真实 `x-powers,axp313a` 绑定,DCDC2 作为 CPU 电源;保持既有 OPP 与 `810000..1080000µV` 约束,不超频、不提高电压。ALDO1 维持 1.8V、DLDO1 维持常开 3.3V、DCDC3 维持引导器实际设置的 1.10V,禁止因错误设备树把非 CPU 电源轨提高或关闭。 | +| `DISPLAY-FRAME` | 项目内部显示边界 | 统一场景在上层合成,底层只接收最终 `64x64 RGB` 帧;透明 RGBA 层不得直接提交驱动。 | +| `DISPLAY-SERVICE` | `DisplayService` 对上层接口 | 提交最终场景或独占测试帧,并兼容清屏、纯色、旧文字、方向、亮度和状态能力。 | +| `DISPLAY-STARTUP-INDICATOR` | 开机动态笑脸 | 白底粉色像素笑脸持续眨眼,固定使用 50% 有效亮度、继承用户方向,并在首个成功改屏指令后退出。 | +| `DISPLAY-WIFI-INDICATOR` | 开机 WiFi 状态提示 | 等待窗口到期后以固定 50% 名义亮度滚动连接信息;断网时叠加静态 WiFi 图标,且不污染用户画面和模式。 | +| `DISPLAY-DEFAULT-CONTENT` | 默认静态或动图输出 | 服务启动先显示配置引用的模板内容;提示覆盖结束后恢复,不把启动显示算作用户操作。 | +| `DISPLAY-CURRENT-CONTENT` | 当前内容来源状态 | 显示服务随实际仲裁输出报告模板、动图、系统覆盖或未保存内容,供顶栏展示。 | +| `DISPLAY-DRIVER` | 真实 `walnutpi-h618-hub75` 封装 | C 双缓冲刷新线程直接控制 H618 PI bank,初始化、提交、调亮度、调刷新率、统计和异常关闭均可验证。 | +| `DISPLAY-DRIVER-STATUS` | 原生线程运行状态 | 报告实际刷新率、完成帧、帧边界交换、截止丢失、CPU 绑定、实时优先级、内存锁定、governor 和最后错误。 | +| `DISPLAY-REALTIME` | 刷新线程实时隔离 | CPU3 绑定、`SCHED_FIFO 50`、内存锁定和 RT throttling 管理均报告实际结果;性能模式是独立、默认关闭且可恢复的系统设置,不能把请求值冒充实际 governor。 | +| `DISPLAY-MOCK` | 模拟驱动 | 无真实屏幕时仍能开发、测试 API 和保存最新帧。 | +| `DISPLAY-ORIENTATION` | 方向变换 | `0/90/180/270` 统一在底层处理;运行时切换方向重新提交当前逻辑帧且不清屏。 | +| `DISPLAY-BRIGHTNESS` | 运行时亮度 | 修改亮度后按当前方向和行映射原样重提当前逻辑帧,使现有画面立即采用新亮度。 | +| `DISPLAY-LOW-VOLTAGE-PROTECTION` | 低电压输出仲裁 | 在不改写用户帧、亮度和模式的前提下限制实际亮度,临界时以 35% 黑底红色低电图标覆盖输出。 | +| `DISPLAY-TEXT-FONTS` | 多语言字体目录、解析与塑形 | 解析稳定字体 ID 与兼容路径,检查全文字形覆盖,优先指定字体并自动回退,使用 Pillow/Raqm 统一渲染常见现代语言。 | +| `CONFIG-PERSIST` | 显示侧配置消费边界 | 配置模块向显示层提供已经迁移、校验的唯一当前格式;生产路径、schema 和更新保护统一由 `CONFIG-DATA-LIFECYCLE` 定义。 | +| `DISPLAY-ERRORS` | 参数、驱动和显示错误 | 错误返回明确,服务不因显示失败退出。 | +| `DISPLAY-PERF` | 性能和降载策略 | 缓存元素透明层,交互结束后精确渲染,驱动只接收最新最终整帧。 | +| `DISPLAY-ANIMATION` | scene 帧序列播放 | 预渲染不可变 RGB 帧快照,后台按帧时长循环,并继续服从方向、亮度和低电压输出仲裁。 | +| `DISPLAY-TEST-SESSION` | 临时纯色测试覆盖层 | 以独立颜色和临时亮度覆盖当前输出,不改写用户帧、动画、模式或持久亮度,退出后恢复底层输出。 | +| `DISPLAY-OTA-INDICATOR` | 软件更新覆盖层 | OTA 安装期间以 40% 名义亮度显示整体缩放至 90% 并居中的 OTA 图标、阶段和进度;主服务停启时由独立维护进程连续接管,完成或回滚后恢复用户内容。 | + +## 1. 设计原则 + +### 1.1 固定帧接口(`DISPLAY-FRAME`) + +项目内部提交到 `DisplayService` 和真实/模拟驱动时,统一使用 `64x64 RGB` 帧作为显示边界。网页场景中的透明文字等 `RGBA` 图层属于上层合成中间产物,必须先按顺序扁平化到 RGB,不能直接交给驱动。 + +上层功能只允许提交这些内容: + +- 一张 `PIL.Image`,模式为 `RGB`,尺寸为 `64 x 64`。 +- 或者一段长度为 `64 * 64 * 3` 的 RGB888 字节数据。 +- 或者能被底层转换成上述帧的兼容高层参数,例如纯色、诊断图案和旧文字参数。 + +上层功能不允许直接做这些事: + +- 直接操作 GPIO。 +- 直接调用 HUB75 行扫描逻辑。 +- 在业务代码里处理 `A/B/C/D/E`、`CLK`、`LAT`、`OE`。 +- 每个功能各自实现屏幕旋转。 +- 把像素层、文字层或元素选择状态交给 `DisplayService` 管理。 +- 让普通新功能绕过统一场景,直接用自己的完整帧覆盖其他普通内容。 + +### 1.2 底层职责(`DISPLAY-SERVICE`) + +底层显示接口负责: + +- 初始化并管理 `walnutpi-h618-hub75` 原生驱动。 +- 管理屏幕尺寸、7-bit PWM、运行时亮度、完整刷新率上限和双缓冲帧边界交换。 +- 接收标准帧。 +- 统一应用屏幕方向变换。 +- 把帧提交给物理屏幕。 +- 保存最近一次未应用物理方向的最终逻辑 RGB 帧,用于方向或亮度调整时原内容重提。 +- 统一仲裁默认/用户帧、兼容开机笑脸、WiFi 提示与临界低电图标,并把用户亮度、提示固定亮度、保护上限和临界固定亮度合成为唯一实际输出;任何显示入口不得绕过仲裁。 +- 处理统一场景最终帧,以及清屏、纯色、诊断和旧文字等兼容/设备测试命令。 +- 在驱动不可用时提供模拟/空实现,方便网页开发。 + +底层不得理解或修改统一场景的 `elements`、元素顺序、选中元素、文字透明层缓存和浏览器草稿。纯色、诊断与清屏作为设备测试可以独占提交完整帧,但不得反向清空上层场景。 + +### 1.3 上层职责(`DISPLAY-FRAME`) + +网页场景控制器、业务功能和后续动画模块负责: + +- 决定要显示什么。 +- 维护固定在最底部的像素层与有序元素列表。 +- 获取或生成单个元素图层,按列表顺序完成透明合成。 +- 只在所有当前元素图层都已就绪时生成最终 `64x64 RGB` 内容并通过 `DisplayService` 提交。 +- 把未来普通显示功能接入统一场景;只有设备测试可以使用独占完整帧。 + +它们不需要知道: + +- HUB75 是怎么扫描的。 +- 核桃派物理脚、PI bank bit 与 HUB75 信号如何对应。 +- 刷新率和 PWM 是怎么实现的。 + +## 2. 推荐模块结构(`DISPLAY-SERVICE`) + +后续实现时,在 `核桃派软件源代码/` 下按类似结构组织: + +```text +app/ + main.py + api/ + display_routes.py + config_routes.py + websocket_routes.py + display/ + service.py + matrix_driver.py + native/ + h618_hub75.c + h618_hub75.h + Makefile + mock_driver.py + transforms.py + text_renderer.py + config/ + store.py + static/ + index.html + app.js + style.css +data/ + config.json +``` + +其中核心边界是: + +- `service.py`:对上层暴露 `DisplayService`。 +- `matrix_driver.py`:用 `ctypes` 封装项目自带的 H618 C 共享库,不导入 `rgbmatrix`。 +- `native/`:实现 PI bank 寄存器映射、bitplane、双缓冲、刷新线程、安全清屏和状态统计。 +- `mock_driver.py`:没有核桃派或没有屏幕时使用,方便在电脑上开发网页。 +- `transforms.py`:统一处理 `0/90/180/270` 度方向。 +- `text_renderer.py`:用 Pillow 生成兼容整帧文字和无副作用的透明文字层;渲染函数本身不得调用驱动。 + +## 3. `DisplayService` 接口(`DISPLAY-SERVICE`) + +### 3.1 接口形态 + +推荐暴露一个常驻对象: + +```python +class DisplayService: + width: int = 64 + height: int = 64 + + def clear(self) -> None: + ... + + def fill(self, color: tuple[int, int, int]) -> None: + ... + + def show_image(self, image: Image.Image, mode: str = "image") -> None: + ... + + def show_rgb_bytes(self, data: bytes, mode: str = "canvas") -> None: + ... + + def show_text(self, options: TextOptions) -> None: + ... + + def set_orientation(self, value: int) -> None: + ... + + def set_brightness(self, value: int) -> None: + ... + + def get_current_frame(self) -> Image.Image: + ... + + def get_status(self) -> dict: + ... +``` + +`get_current_frame()` 在线程锁内复制经过仲裁、当前正在显示的逻辑 RGB 帧:临界保护时返回低电图标,其次为 WiFi 提示、当前笑脸帧,否则返回缓存的用户逻辑帧。该快照固定为逻辑 `0°`,不得应用物理方向或底层行映射,也不得修改驱动、模式、配置或状态修订号。网页只读接口 `GET /api/display/current-frame` 使用该快照生成 `64x64 RGB PNG`。 + +### 3.1.1 开机动态笑脸(`DISPLAY-STARTUP-INDICATOR`、`CONFIG-PERSIST`) + +- 逻辑帧固定为 `64x64 RGB`:全屏白色 `#FFFFFF`,粉色 `#FF4FA3` 绘制两只眼睛和一个 U 形嘴巴;睁眼约 `1.3s`、闭眼约 `0.2s` 循环。 +- 动画使用用户保存的 `orientation` 和既有行映射,因此实屏方向与用户设置一致;动画名义亮度为 `50`,实际亮度仍受低电压上限约束,不得读取、覆盖或保存为用户亮度。 +- 动画提交不得替换当前逻辑帧、`last_mode` 或状态修订号。状态接口必须同时区分用户亮度、当前有效亮度和动画是否活动。 +- 纯色、清屏、文字、诊断、最终 RGB 帧、方向或亮度的首个成功修改原子退出动画;先恢复用户亮度,再提交用户结果,退出后后台线程不得再次写入动画帧。 +- 改屏指令在渲染、驱动提交或配置保存阶段失败时保持或恢复笑脸动画。只读、预览、色板和模板操作没有显示副作用,不得退出。 +- 使用 Linux boot ID 和独立的原子状态文件记录本次开机已经退出;同一次整机开机内服务重启不得重现,boot ID 改变后重新显示。状态文件缺失或损坏时按尚未退出处理。 + +### 3.1.1.1 开机 WiFi 提示(`DISPLAY-WIFI-INDICATOR`、`CONFIG-WIFI`) + +- WiFi 提示是独立输出覆盖层,不写入当前用户帧、`last_mode`、用户亮度、方向或用户状态修订号;提示结束后恢复最新用户动图帧或静态帧。 +- 仲裁顺序固定为 `临界低电图标 > WiFi 提示 > 开机笑脸 > 用户动图 > 用户静态画面`。WiFi 提示开始时结束当前开机笑脸,但不把提示帧当作用户改屏。 +- 连接成功时在黑底上连续左移 `SSID: <实际SSID> IP: http://:8080/`;断开时顶部为静态 WiFi 断开图标,底部连续左移 `SSID: <保存SSID> 密码: <保存密码>`。未配置时显示明确无配置文案,不虚构密码。 +- 连接成功和断开两种滚动文字都必须把字体栅格化结果转成全亮或全灭的单色像素,不保留小字号抗锯齿灰阶;字体覆盖到的每个像素必须完整保留,避免局域网 IP、SSID、密码提示在网页当前帧和实屏上出现断笔或缺块。 +- WiFi 提示覆盖期间默认或用户动图线程继续运行;访问首页撤销提示后直接恢复最新底层帧。默认内容启动不得触发用户活动监听器或提前取消提示。 + +### 3.1.1.2 默认内容与来源(`DISPLAY-DEFAULT-CONTENT`、`DISPLAY-CURRENT-CONTENT`) + +- 默认静态模板通过同一 scene 渲染器生成 `64x64 RGB` 后提交;默认动图通过现有快照播放路径启动。二者继承用户方向和亮度,不使用旧笑脸固定亮度,也不修改用户修订号。 +- 显示服务为底层用户输出保存可空来源 `{category,id,name}`。明确显示已保存静态模板或动图时分别记录 `template`、`animation`;任何普通画板编辑、文字、填色、清屏或诊断显示清除来源并报告 `unsaved/未保存内容`。 +- 临界低电、WiFi 和临时纯色测试可见时,状态以 `system` 和对应名称覆盖底层来源;覆盖结束后恢复底层来源。来源状态只描述实际画面,不进入持久配置或改变显示优先级。 +- 速度目标为 `16px/s`、约 `20fps`,循环尾部保留 `16px` 空隙;文字使用统一字体解析,帧固定为 `64x64 RGB` 并继承用户方向。 +- 名义亮度为 `50`;低电 `limiting` 时使用 `min(50, 上限)`,`critical` 时仍由 35% 临界低电图标完全覆盖。 +- 网络状态改变时可以原地切换成功/失败滚动内容;结束、服务退出和驱动失败均须停止线程且不把密码写入日志。 + +### 3.1.2 低电压输出仲裁(`DISPLAY-LOW-VOLTAGE-PROTECTION`) + +- 输出优先级固定为 `临界低电图标 > WiFi 提示 > 开机笑脸 > 用户动图 > 用户画面`。保护指令只改变当前输出约束,不替换缓存用户逻辑帧、不修改用户 `brightness`、方向、`last_mode` 或用户状态修订号。 +- 限亮时实际亮度为用户亮度或笑脸名义 `50` 与保护上限的较小值;临界时实际亮度固定为 `1`,模式报告 `low_voltage_indicator`。临界期间仍接受并保存画面、亮度和方向,方向变化立即旋转图标,恢复后输出最新用户内容。 +- 低电图标是确定性 `64x64 RGB` 帧:除图标外全为 `#000000`,使用 `#FF0000` 绘制粗线空电池轮廓、小端子和低电量短条,并统一经过现有 `0/90/180/270` 方向变换。 +- 收紧保护时先降低驱动亮度再重提仲裁帧;放宽时先以旧的较低亮度提交目标帧,再提高亮度并重提,避免高亮瞬态。真实驱动无法确认运行时亮度更新时必须返回错误,不能只记录 warning。 +- 电压组件在自身锁和 I²C 锁外传入不可变保护指令。指令应用失败不得修改 ADC 样本状态,应保留保守输出、设置独立错误码并在后续周期重试;自动保护只增加保护修订号。 + +### 3.1.3 用户动图播放(`DISPLAY-ANIMATION`) + +- 播放前必须完整校验动图元数据、全部独立 scene v1 和对应 `64x64 RGB` PNG 缓存;空动图或任一帧失败时保持当前输出不变。播放快照固定帧顺序、时长和内容摘要,但只把当前帧和下一帧解码到内存,不能按总帧数线性占用 RAM。 +- 活动快照引用的摘要 PNG 在播放结束前不得被编辑清理;修改动图生成新摘要,旧修订继续播放,停止后再按当前记录协调孤立缓存。长动图详情允许分页读取,网页固定每页渲染 100 帧;跨页顺序调整使用按帧 ID 和目标位置移动接口,不把当前页误当成完整帧序列。播放顺序始终使用完整的已校验元数据快照。 +- 每帧时长统一使用整数毫秒,允许 `50..604800000ms`;七天上限是防误输与浏览器/线程计时安全边界,不改变 scene、方向、亮度或帧提交格式。服务关闭、替换内容或停止事件必须能立即中断长时长等待。 +- 输出优先级为 `临界低电图标 > 用户动图帧 > 用户静态帧`;启动动图会成功退出开机笑脸。低电限亮和临界覆盖不得停止播放线程或改写动图数据。 +- 自动换帧不得替换缓存用户静态帧、持久 `last_mode` 或逐帧增加用户状态修订号。开始播放增加一次修订;方向和亮度重提当前动图帧但不中止。 +- 清屏、纯色、诊断、最终组合帧或另一动图只有在新输出成功后才停止旧动图;失败时旧播放继续。服务关闭时有界停止线程,服务重启不自动恢复播放。 +- 真实驱动的动画帧和静态帧都提交完整 RGB888 back buffer,并由 C 刷新线程在完整帧边界交换;每个内容帧间隔从成功提交后开始计算。 + +### 3.2 `clear()` + +行为: + +- 生成全黑帧。 +- 提交到屏幕。 +- 更新当前模式为 `clear`。 + +要求: + +- 服务退出前也应该调用清屏,避免屏幕停在不可预期状态。 +- 如果底层驱动支持 `matrix.Clear()`,可以直接调用;为了保持状态一致,也可以走全黑帧路径。 + +### 3.3 `fill(color)` + +行为: + +- 接收 `(r, g, b)`,每个通道范围 `0..255`。 +- 生成 `64x64 RGB` 纯色帧。 +- 提交到屏幕。 +- 更新当前模式为 `fill`。 + +要求: + +- 参数超出范围时抛出明确的参数错误。 +- 全白测试建议结合亮度限制,不鼓励长时间满亮度运行。 +- 兼容 `fill(color)` 仍属于普通改屏操作,并会结束活动测试会话。网页纯色测试使用 `DISPLAY-TEST-SESSION`:首次启动以 `50%` 临时亮度提交纯色帧,同一会话换色保留临时亮度,亮度范围为 `1..100`。 +- 测试帧和临时亮度只存在于内存,不更新用户逻辑帧、用户亮度、`last_mode` 或动画快照;退出后重新选择当前底层用户输出。 +- 输出优先级固定为临界低电图标、低电压亮度上限、临时纯色测试、WiFi 提示、开机笑脸、用户动画或静态用户帧。普通成功改屏负责撤销测试;方向和用户持久亮度仍可在测试期间更新,退出后显示更新后的底层状态。 + +### 3.4 `show_image(image, mode="image")` + +行为: + +- 接收 `PIL.Image`。 +- 转换为 `RGB`。 +- 尺寸必须是 `64 x 64`;如后续允许其他尺寸,必须由明确策略缩放或裁切,不能静默拉伸。 +- 应用当前方向。 +- 提交到真实驱动。 +- 更新当前模式,统一场景由调用方指定为 `composition`,兼容或设备测试模式使用各自名称。 + +要求: + +- 这是所有复杂显示内容的主入口。 +- 统一场景合成帧、设备测试帧、后续动画帧和图片导入最终都应该走这个入口。 +- 该接口只能看到最终帧,不能拆解或重新排序上层元素。 + +### 3.5 `show_rgb_bytes(data, mode="canvas")` + +行为: + +- 接收 RGB888 字节数据。 +- 长度必须正好是 `12288`。 +- 按行优先顺序转换为 `64x64 RGB` 图像。 +- 调用 `show_image(image, mode=mode)`;WebSocket 新消息传入 `composition`,旧消息沿用默认 `canvas`。 + +用途: + +- 主要给 WebSocket 统一场景最终帧及旧画布帧使用;`source:"composition"` 或旧消息的兼容判断由 WebSocket/API 层完成。 + +### 3.6 `show_text(options)`(`DISPLAY-TEXT-FONTS`) + +行为: + +- 接收文字显示参数。 +- 调用 `text_renderer.py` 用 Pillow 生成 `64x64 RGB` 图像。 +- 调用 `show_image()`。 +- 该路径只为现有 `POST /api/display/text` 和 `POST /api/preview/text` 兼容保留,新网页中的文字元素不得调用它直接覆盖统一场景。 + +`TextOptions` 建议字段: + +```python +@dataclass +class TextOptions: + text: str + font: str = "default" + size: int = 12 + x: int = 0 + y: int = 0 + align: str = "left" + color: tuple[int, int, int] = (255, 255, 255) + background: tuple[int, int, int] = (0, 0, 0) +``` + +要求: + +- 字体加载失败时使用自动多语言默认字体;如果候选字体均不能覆盖全文,返回包含缺失 Unicode 码位的明确错误。 +- 字体覆盖由 FontTools cmap 判断并缓存;不得通过渲染结果猜测 `.notdef` 方框。 +- 自定义字体覆盖全文时优先使用;不覆盖全文时选择一张覆盖全文的后备字体,避免把同一个复杂文字序列拆成破坏塑形的多个字体片段。 +- 文字渲染入口通过同一个字体目录解析 `system:` 和 `user::`;预览、兼容整帧文字、模板缩略图和显示服务不得各自维护字体 ID 映射。 +- 系统字体从 Fontconfig、显式 `MATRIX_FONT_PATHS` 和已登记默认候选发现,只接受 Pillow 可加载的 TrueType/OpenType 文件。Fontconfig 的完整 FreeType face index 用于 Pillow,FontTools 覆盖检查使用对应基础字体面,不能把可变字体命名实例误判为缺字。 +- 用户字体只能解析到当前持久根的 `fonts/.font`,不能由 ID 拼接任意路径。旧请求中的直接路径继续兼容;未知 ID、丢失路径或指定字体缺字时保持现有整段回退语义。 +- 导入字体目录刷新后,新字体必须立即可用于透明预览、后端缩略图和兼容文字显示;服务重启后同一内容和 face index 得到同一字体 ID。 +- Linux 部署通过 Fontconfig 解析 Noto Core/CJK 字体文件和 TTC face index;Pillow 使用 Raqm 完成复杂塑形及双向排版。 +- 保证中日韩、拉丁/西里尔/希腊、阿拉伯/希伯来、常见南亚文字和泰文;Emoji 和历史文字不属于当前范围。 +- 超出边界的文字允许被裁切。 + +同一个 `text_renderer.py` 还必须提供透明文字层渲染能力,供 `POST /api/preview/text-layer` 调用: + +- 输入为 `text`、`font`、`size`、`x`、`y`、`align` 和 `color`,不包含 `background`。 +- 输出固定为 `64x64 RGBA`;空白区域 alpha 为 `0`,文字区域保留有效颜色与 alpha。 +- 复用兼容整帧文字的字体加载、排版、对齐和裁切逻辑,不能维护第二套文字度量规则。 +- 渲染透明层不得调用 `show_image()`、修改当前逻辑帧/模式、访问真实或模拟驱动或写配置。 + +### 3.7 `set_orientation(value)`(`DISPLAY-ORIENTATION`、`CONFIG-PERSIST`) + +行为: + +- 只允许 `0/90/180/270`。 +- 更新运行时方向。 +- 调用配置模块保存方向。 +- 保存最近一次未应用方向和行映射的最终逻辑 `64x64 RGB` 帧;该帧可能是组合场景,也可能是独占设备测试。方向变化时使用新方向重新转换并提交该帧。 +- 临界低电图标活动时仍保存新的用户方向,并用新方向立即重提图标;被覆盖的用户逻辑帧保持不变,恢复后按最新方向显示。 +- 重新提交时不得清屏、替换内容或修改 `last_mode`。 +- 后续所有帧统一应用该方向。 + +要求: + +- 方向逻辑只在显示服务里做一次。 +- 不允许画布、文字、动画各自实现一套旋转逻辑。 + +### 3.8 `set_brightness(value)`(`DISPLAY-BRIGHTNESS`、`CONFIG-PERSIST`) + +行为: + +- 更新并保存的是用户亮度;保护活动时实际驱动亮度由输出仲裁器计算,不能把保护上限或临界 `35%` 回写配置。 +- 保存配置。 +- 真实驱动的运行时亮度只影响之后写入的像素;设置新亮度后,必须按当前方向和行映射重新转换并提交缓存的当前逻辑帧,使当前画面立即采用新亮度。 +- 调整亮度时不得清屏、重建或替换逻辑帧、修改 `last_mode`,也不得要求上层再次发送画面。 +- 亮度、原帧重提和配置保存作为一次状态变更;失败时恢复旧亮度并以旧亮度重提当前帧,配置、状态修订号和 `last_mode` 保持原值。 +- 临界期间调整用户亮度仍保存成功但继续输出 `35%` 图标;限亮期间滑块仍允许 `1..100`,实际亮度不超过保护上限。 + +## 4. 真实驱动封装(`DISPLAY-DRIVER`、`HW-MATRIX`) + +### 4.1 H618 原生驱动固定参数 + +`walnutpi-h618-hub75` 固定使用: + +```text +width=64, height=64, scan_rows=32, row_address_bits=5 +pwm_bits=7, rgb_sequence=RGB, brightness=40 +limit_refresh_rate_hz=100 +pio_base=0x0300B000, pi_bank_offset=0x120, data_offset=0x10 +pwm_base=0x0300A000, oe_pwm_channel=4, oe_pwm_tick_ns=125 +driver_id=walnutpi-h618-hub75 +hardware_mapping=walnutpi-pi-bank-pwm-oe-v2 +``` + +- 物理尺寸、扫描方式、bitplane 数和 GPIO 映射不是运行时可变探测项;改变面板或接线必须先更新 `HW-*`、测试和驱动常量。 +- C 层按树莓派参考实现把 RGB888 与有效亮度共同映射为 11-bit CIE1931 亮度值,只输出内部 bitplane `4..10` 的七个高位;禁止再用所有像素共享的 OE 脉冲裁剪或跨扫描固定点抖动表示亮度。`1..100` 的通道映射必须非递减,白色在 100% 映射到 `2047`。上半屏像素 `y=0..31` 与下半屏像素 `y=32..63` 同时移位,ABCDE 选择当前行对。 +- RGB、地址、CLK 和 LAT 在 PI bank 的同一个 32-bit data register 上批量写入;OE 固定复用 `PI14/PWM4`,由 H618 PWM 硬件结束低有效点亮脉冲。不得逐针调用官方 socket 服务或 Python GPIO API,也不得回退到 CPU 忙等拉高 OE。 +- 启动时先把 `PI14` 作为 GPIO 拉高 OE,再初始化 PWM4;PWM5 活动时只允许其共享的 4/5 时钟字段已经与 PWM4 所需的 `24MHz/div1` 完全兼容,并必须逐次保留 PWM5 enable、bypass、PCR5 和 PPR5。共享时钟不兼容、PWM5 状态或自有寄存器漂移、寄存器回读不一致、PWM4 已占用或硬件映射 ABI 不匹配时必须保持全黑并拒绝真实驱动启动。所有数据、地址、CLK 和 LAT 清零后才启动刷新。 +- 异常、关闭、重建和进程退出均先禁用 PWM4、把 `PI14` 切回 GPIO 并强制 OE 高,再清零输出、恢复 PWM 寄存器和 GPIO 安全输入。systemd 必须用独立 `hub75_safeoff` 在 `ExecStartPre` 与 `ExecStopPost` 覆盖主进程崩溃路径;辅助程序失败必须导致启动失败或在停止日志中明确报错。 +- 上游寄存器和排针依据为核桃派官方 MIT `walnutpi/gpioc`,项目必须保留许可证、来源 URL 与精确 commit,不能把其来源误写为本项目原创。 + +`DISPLAY-REFRESH-LIMIT` 规定: + +- `limit_refresh_rate_hz` 只允许 `15、20、30、45、60、80、100`,生产默认 `100`;它同时限制逻辑帧边界、back/front buffer 交换和完整面板扫描频率。每个逻辑帧只执行一次 32 行 × 7 bitplane 物理扫描,禁止按亮度切换两/三次扫描而改变时序形态。 +- 100 Hz 是上限而非必须伪装成 100.000 Hz 的目标;该档允许在每个逻辑周期增加固定 `0.25ms` MMIO/调度保护间隔,名义目标约为 `97.56 Hz`。不得通过放宽 deadline 判定掩盖超时;参考树莓派在约 80 Hz 已稳定,本实现不得把提高扫描率当作修复闪烁的替代方案。 +- 刷新线程在帧边界读取新的刷新率和亮度,不重建 `/dev/mem` 映射,也不撕裂当前帧;配置原子写入失败时恢复旧值。 +- 亮度范围为 `1..100`,通过提交帧时的 11-bit CIE1931 通道映射改变 bitplane 数据;OE 的七个物理位权在所有亮度下固定不变。设置亮度后上层必须立即以新亮度重新提交当前帧,不能用同步省略脉冲或共享余数制造整屏时间抖动。 +- 每个 row/bitplane 在 LAT 完成后重新装载 PWM4 的周期和有效计数。PWM4 必须使用低有效极性、`125ns` 计数分辨率及 H618 `PCR4.PWM_MODE=1` 单脉冲模式;七个输出位平面对应 11-bit 内部位 `4..10`,固定脉宽为 `2/4/8/16/32/64/128us`。每次令总周期等于有效周期,再用写一清零的 `PCR4.PWM_PUL_START` 触发一次脉冲,并等待 `PWM_PERIOD_RDY/PWM_PUL_START` 回到可重装状态。不得用 `PER` 反复启停连续 PWM 模拟 one-shot,也不得依赖只读 `PCNTR4` 自动归零。单脉冲结束后硬件保持 OE 高,因此线程延迟只能延长黑屏时间,不能延长或重复当次点亮脉冲。 +- 逻辑刷新率是上限而非伪造的固定值;状态必须返回测量窗口内的 `actual_refresh_rate_hz`、`completed_frames`、`deadline_misses` 与 `buffer_swaps`,并另外返回真实的 `panel_scan_rate_hz`、`completed_scans` 和当前 `scans_per_frame=1`。还必须返回 `oe_timing_backend=h618-pwm4`、`oe_pulse_faults`、`oe_forced_blanks`、`max_programmed_oe_ns` 和 PWM4 初始化、占用或安全关闭错误;生产不得用软件忙等后端或逻辑帧率冒充硬件状态。 + +`DISPLAY-REALTIME` 规定: + +- 原生线程启动后尝试绑定 CPU3、设置 `SCHED_FIFO 50` 实时优先级并执行 `mlockall`。在启用 `CONFIG_RT_GROUP_SCHED` 的 cgroup v2 板端,显示服务启动时保存并临时关闭全局 RT throttling,使固定在 CPU3 的刷新线程能够进入实时调度;服务退出时恢复原始内核值,设置失败必须阻止服务启动。 +- governor 不再由部署脚本永久强制。服务首次启动时记录每个实际 cpufreq policy 的原 governor;配置请求开启时把全部 policy 事务切换为 `performance`,关闭或服务停止时恢复记录值。systemd 异常重启沿用同一份记录,不得把遗留的 `performance` 误记为原值。 +- `/api/status` 必须分别报告性能模式的请求状态、可用性、实际生效状态、当前/恢复 governor 和最后错误;任一 policy 写入失败时回滚已写 policy,配置不得显示为已开启。 +- 每项优化都必须在状态中单独报告是否生效及失败原因。权限或内核不支持时允许继续做无屏基准,但不能谎报成功;接屏门槛必须结合实际刷新率和截止丢失判断。 +- 时序使用 `CLOCK_MONOTONIC_RAW` 或等价单调时钟。HUB75 输出锁存与移位寄存器隔离,当前 bitplane 的硬件单脉冲点亮期间必须并行移入下一 bitplane,OE 自动回高后才改变行地址并 LAT;位平面固定保持内部二进制权重,截止已过时立即记录 miss,不允许负数睡眠或无限追赶。 +- 无屏基准在逻辑 100 Hz、64×64、7 个输出 bitplane 下分别以亮度 5 和 40 连续至少 60 秒:两档都必须为 `scans_per_frame=1`,逻辑与物理扫描率均不得低于 95 Hz,最大编程 OE 脉宽必须为 `128000ns`;截止丢失率不得高于 0.1%,RSS 不得持续增长,停止后 GPIO 必须回到安全状态。任一档未达标不得部署到实屏。 +- `MATRIX_MAINTENANCE_BLACK=1` 只用于接屏诊断或部署维护:服务初始化真实驱动后必须保持 C 层初始全黑 front buffer,不恢复默认内容,也不启动开机 WiFi 覆盖层。该变量不得写入生产 unit、配置或持久数据,移除后恢复正常启动行为。 + +`DISPLAY-OTA-INDICATOR` 规定: + +- 包校验完成并接受任务后,显示服务立即以 40% 名义亮度显示黑底 OTA 图标、阶段文字和进度条;全部元素必须作为同一个 64×64 合成帧整体缩放至 58×58(约 90%),使用保留所有点亮源像素的覆盖缩小方式放在 `(3,3)` 居中,四边保留黑色空白,不单独改变元素间距。OTA 与百分比文字必须先二值化为完整像素字形,百分比不得在缩放前越出画布;方向沿用当前持久方向,不改写用户帧、动画、模式或亮度。 +- 临界低电压图标优先于 OTA 覆盖;限亮状态对 40% 继续取较低值。主服务停止后,OTA oneshot 必须等待 `hub75_safeoff` 完成,再由独立维护显示进程读取同一进度文件接管真实驱动,两个进程不得同时访问 `/dev/mem`。 +- 新服务健康检查期间继续显示 OTA 覆盖,不提前恢复默认内容。工作服务写入成功或回滚结果后才恢复默认内容;维护显示进程任何退出路径都必须清屏、禁用 OE 并释放映射。 + +### 4.2 提交帧方式 + +- Python 只把完整、已应用方向的 `64×64 RGB888` 数据一次提交给 C 层,不执行逐像素 GPIO 操作。 +- C 层持有 front/back 两个 RGB 帧和预计算 bitplane;提交只写 back buffer 并发布待交换标记,刷新线程只在完整帧边界交换。 +- 连续多次提交允许覆盖尚未展示的 back buffer,只展示最新完整帧;不得出现半帧、行内交换或积压无界队列。 +- 静态图、画布和动画统一走同一帧提交接口;动画时钟仍由 `DisplayService` 管理,底层扫描时钟不随内容帧率改变。 + +### 4.3 诊断图案 + +底层服务必须提供诊断图案入口,用于区分软件参数、HUB75 行地址线和接触不良问题。诊断图案仍然生成标准 `64x64 RGB` 帧,并走 `DisplayService.show_image()`,不能绕过方向、mock 或真实驱动边界。诊断帧是独占设备测试,不与统一场景叠加,也不得销毁上层场景草稿。 + +第一版诊断模式: + +- `corners_lines`:四角颜色、`y=0/31/32/63` 横线、`x=0/63` 竖线。 +- `row_bands`:按固定行带显示不同颜色,观察是否固定间隔复制。 +- `address_check`:按 `A/B/C/D/E` 行地址敏感的行号/条纹模式显示,观察行顺序和 `E` 线。 +- `text_ok123`:显示 `OK123`,复现网页文字路径。 +- `clear`:清屏。 + +如果全色正常但诊断图固定出现 32 行重复或上下半屏镜像,优先判断为 `E` 线未接、接错或接触不良;如果行顺序乱,优先检查 `A/B/C/D/E`。固定面板不提供映射试错覆盖,软件不得用错误常量掩盖已确认的硬件接线问题。 + +### 4.4 权限 + +真实驱动需要打开 `/dev/mem`、设置实时调度和锁定内存。生产 systemd 单元使用受控的 root 服务进程,限制可写路径为状态目录和运行目录,并通过 `CapabilityBoundingSet`、设备白名单和文件系统保护缩小权限。网页只监听局域网,任何上传和路径输入仍按 `WEB-SECURITY` 校验。 + +如果后续拆成特权刷新 helper 与非特权 Web 进程,必须先定义消息认证、共享内存生命周期、崩溃清屏和升级兼容边界;当前阶段不实现未经测试的半套降权方案。 + +## 5. 模拟驱动(`DISPLAY-MOCK`) + +为了不依赖真实核桃派和屏幕开发网页,必须准备 `MockDisplayDriver`。 + +模拟驱动行为: + +- 接收所有和真实驱动相同的帧。 +- 不访问 GPIO。 +- 可以把最新最终帧保存为 `data/last_frame.png`;组合场景和独占测试使用与真实驱动相同的帧边界。 +- 可以在日志中记录当前模式、方向和亮度。 + +用途: + +- Windows 开发机上开发前端页面。 +- CI 或普通单元测试中验证 API。 +- 屏幕硬件未接好时仍能测试网页逻辑。 + +驱动选择策略: + +- 如果环境变量 `MATRIX_DRIVER=mock`,强制使用模拟驱动。 +- 如果 C 共享库缺失、架构不匹配或 `/dev/mem` 初始化失败,`auto` 模式可以退回模拟驱动,但必须在状态接口标记 `driver_available=false` 并保留精确错误;生产健康检查不能把该回退视为真实驱动成功。 +- 在核桃派部署时默认尝试 `walnutpi-h618-hub75`,显式 `MATRIX_DRIVER=mock` 只用于本机和阶段化无屏业务验证。 + +## 6. 方向变换(`DISPLAY-ORIENTATION`) + +方向变换在底层显示接口里统一处理。 + +规则: + +- `0`:不旋转。 +- `90`:顺时针旋转 90 度。 +- `180`:旋转 180 度。 +- `270`:顺时针旋转 270 度。 + +使用 Pillow 时可以通过 `Image.transpose()` 或 `Image.rotate()` 实现,但必须确认旋转后尺寸仍为 `64 x 64`。 + +方向变换测试图建议: + +- 点亮四角: + - `(0,0)` 红 + - `(63,0)` 绿 + - `(0,63)` 蓝 + - `(63,63)` 白 +- 画水平线: + - `y=0` + - `y=31` + - `y=32` + - `y=63` +- 显示文字 `UP`,确认安装方向。 + +验收标准: + +- 修改方向后,所有显示模式使用同一结果。 +- 显示非对称画面后依次切换方向,物理帧立即旋转且内容和当前模式保持。 +- 保存方向后重启服务仍生效。 +- 不同业务模块不出现互相矛盾的坐标规则。 + +## 7. 配置持久化(`CONFIG-PERSIST`) + +配置由独立模块管理,不写死在显示服务里。生产数据根、运行根、落盘 `schema_version`、单向迁移、耐久原子写入和更新保护均以 `01_网页配置端需求.md` 的 `CONFIG-DATA-LIFECYCLE`、`CONFIG-BASE` 为唯一来源,本节只定义显示侧消费职责。 + +显示层取得的当前配置形态: + +```json +{ + "orientation": 0, + "brightness": 40, + "default_font": "default", + "default_text_size": 12, + "last_mode": "clear", + "low_voltage_protection_enabled": false +} +``` + +要求: + +- 配置模块必须在初始化显示服务前完成缺失文件创建,或完成全部 legacy 迁移和当前 schema 校验;`DisplayService` 只消费去掉落盘元数据后的当前业务字段。 +- 文件缺失时由配置模块创建默认值;现有文件损坏、含未知字段、版本过高或迁移失败时,配置模块保留原件并阻止服务启动,显示层不得自行回落默认值。 +- API 层和 `DisplayService` 都通过同一配置模块读写,不各自维护文件,不感知生产路径,不解析旧 schema,也不自行创建 `.bak`。 +- 方向、亮度、默认字体和模式的业务默认值与接口语义保持不变;落盘 `schema_version` 不进入显示状态或 API 响应。 +- `low_voltage_protection_enabled` 由当前配置 v3 以严格布尔值提供,显示层只消费当前值,不负责迁移或把自动保护状态写回配置。 + +## 8. 错误处理(`DISPLAY-ERRORS`) + +### 8.1 参数错误 + +必须明确报错: + +- 颜色格式错误。 +- RGB 通道不在 `0..255`。 +- 方向不是 `0/90/180/270`。 +- 画布帧尺寸不是 `64x64`。 +- RGB 字节长度不是 `12288`。 +- 兼容整帧文字或透明文字层请求的字号不在 `1..64`。 + +### 8.2 驱动错误 + +真实驱动初始化失败时: + +- 服务仍然启动。 +- 自动进入模拟驱动或不可用状态。 +- `GET /api/status` 必须能反映错误。 +- 前端显示“屏幕驱动不可用”,但页面不能白屏。 + +### 8.3 显示错误 + +显示操作失败时: + +- API 返回错误 JSON。 +- 日志记录异常堆栈。 +- 不让异常导致整个 FastAPI 服务退出。 + +## 9. 性能要求(`DISPLAY-PERF`) + +### 9.1 基本策略 + +- 避免 Python 层逐点调用 GPIO 或 C ABI。 +- 尽量在内存中生成完整 `PIL.Image`。 +- 使用一次 RGB888 整帧提交,并由 C 层预计算 bitplane。 +- 统一场景 WebSocket 和旧画布 WebSocket 都只保留最新帧,允许丢弃过时帧。 +- 浏览器按元素 `id + revision` 缓存透明层;旧修订响应必须丢弃,不能触发回退合成。 +- 拖动或缩放过程中复用缓存层做视觉变换,结束后再请求一次 Pillow 精确层,不能在每个 Pointer Event 上发起渲染请求。 +- 任一当前元素层仍待定或失败时不提交最终帧,避免用缺层画面覆盖实屏。 +- 复杂动画优先预渲染为逐帧无损 PNG 缓存;播放按不可变摘要路径懒加载,不得把长视频全部展开为 PIL 图像列表。 + +### 9.2 核桃派 ZeroW 降载策略 + +如果出现明显闪烁、刷新慢或 CPU 占用过高: + +- 7-bit PWM 是当前面板的固定验收值;不能为了掩盖驱动未达标而静默降低。只有新增明确配置、状态和实屏对比测试后才允许提供 6-bit 模式。 +- 先区分逻辑帧率与 `panel_scan_rate_hz`;默认 100 Hz 逻辑档在有效亮度不高于 20% 时必须达到约 300 Hz 物理扫描,较高亮度保持约 200 Hz。摄像头验证必须在同一次连续连接内等待自动曝光稳定,再检查固定裁剪区是否出现整屏同步黑场、整体周期性亮度调制、固定坏行或固定错色。25 fps 等滚动快门可能把面板扫描拍成随帧移动的窄亮带或暗带;当条带数量随物理扫描率变化、稳定段整屏均值没有同步跳变、低亮度 `panel_scan_rate_hz >= 285`(较高亮度不低于 190)且截止丢失不增长时,这类拍频条带不能单独判为面板闪烁。只有摄像头拍频无法消除歧义时才暂停,请用户直接观察一次是否仍有肉眼可见闪烁。 +- 降低 `brightness`。 +- 优先确认 CPU3 绑定、实时优先级、内存锁定和 performance governor 是否实际生效。 +- 检查批量 PI register 写、bitplane 预计算和帧边界交换是否引入多余锁或系统调用。 +- 减少 WebSocket 帧率。 +- 增大文字层请求防抖间隔并复用未变更元素缓存。 +- 动画从实时计算改为预渲染。 +- 高频显示循环必须从一开始就在 C 中;Python 降载不能替代 C 无负载基准门槛。 + +## 10. 与 HUB75 接线资料的一致性(`HW-MATRIX`) + +底层真实驱动必须与本地接线资料一致:64×64、1/32 扫描、ABCDE 行地址、单块不级联。全部 14 根信号集中在 H618 PI bank;其中 13 根由同一个 32-bit data register 批量写入,OE 在同 bank 的 PI14 上复用 PWM4。 + +| HUB75 信号 | 核桃派物理脚 | H618 GPIO | PI data bit | +|---|---:|---|---:| +| `R1` | 29 | `PI0` | 0 | +| `G1` | 31 | `PI1` | 1 | +| `B1` | 33 | `PI2` | 2 | +| `R2` | 35 | `PI3` | 3 | +| `G2` | 37 | `PI4` | 4 | +| `B2` | 8 | `PI5` | 5 | +| `A` | 10 | `PI6` | 6 | +| `B` | 28 | `PI9` | 9 | +| `C` | 27 | `PI10` | 10 | +| `D` | 15 | `PI11` | 11 | +| `E` | 16 | `PI12` | 12 | +| `CLK` | 38 | `PI13` | 13 | +| `OE` | 40 | `PI14/PWM4` | 14 | +| `LAT` | 36 | `PI15/GPIO` | 15 | + +物理脚 3/5 的 `PI8/PI7` 专供 `/dev/i2c-1` ADC,物理脚 32 的 `PI16` 保留,不得被显示驱动改为输出。 + +该接线只允许与 `walnutpi-pi-bank-pwm-oe-v2` 驱动配套。旧版 `LAT=PI14/OE=PI15` 接线不得启动新驱动;以后再次改线也不能只改物理接线,必须同步升级硬件映射标识、原生 ABI、测试和本项目文档。 + +### 10.1 屏幕输入电压检测边界(`HW-SCREEN-VOLTAGE`) + +屏幕输入端电压使用 `M5Stack Unit ADC v1.1`(SKU `U013-V11`、芯片 `ADS1110`)检测。当前阶段固定边界如下: + +- 整个系统已经提供可靠、共地的 `5V/GND`;本需求不设计稳压、开关、保险、供电时序或由核桃派控制模块供电。 +- Unit ADC 的红线接系统 `5V`,黑线接系统 `GND`,黄线 `SDA` 接核桃派物理脚 3 / `PI8`,白线 `SCL` 接核桃派物理脚 5 / `PI7`。 +- 测量端 `VIN+` 并联到屏幕输入端预留的 `5V` 测试点,`VIN-/GND` 接同一输入端的系统 `GND`;屏幕负载电流不得经过 Unit ADC 或测量线。 +- 该模块只提供屏幕输入端电压数据,不能直接证明屏幕实际亮度、画面是否正常或负载电流大小。 +- FastAPI 进程内的独立电压监测组件负责持续读取和缓存,并在锁外把保护指令交给显示输出仲裁器;`DisplayService` 不直接访问 ADC。ADC 故障不得终止显示或驱动生命周期,取得过成功样本后须保持最后保护而不是误解除。 +- 每台设备通过 `CONFIG-SCREEN-VOLTAGE` 独立保存一次比例校准;校准只补偿当前 ADC 与测量点的比例误差,不能替代接线、共地和型号检查。 +- 当前实现 `DISPLAY-LOW-VOLTAGE-PROTECTION`:校准后电压在 `4.5..4.8V` 线性限亮,严格低于 `4.5V` 覆盖 35% 低电图标,严格高于 `4.8V` 经恢复确认撤销。它不实现过压保护、物理关断、BMS 或核桃派关机。 +- 完整接线、ADS1110 协议、换算公式和官方资料见 `硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/`;真实硬件验证映射到 `TEST-ADC-HARDWARE`。 + +## 11. 后续实现验收清单 + +底层接口完成后,需要验证: + +- `clear()` 能清屏。 +- `fill((255,0,0))`、`fill((0,255,0))`、`fill((0,0,255))` 显示颜色正确。 +- `show_rgb_bytes()` 能显示最终 RGB 帧,并由调用层区分、保存 `composition` 与旧 `canvas` 模式。 +- `show_text()` 继续兼容整帧静态文字;透明文字层渲染为 RGBA 且不产生显示副作用。 +- 像素底层与至少两组透明文字合成后只向 `DisplayService` 提交一张最终 RGB 帧,底层不感知元素结构。 +- 纯色、诊断和清屏能独占覆盖当前组合帧,之后重新提交统一场景即可完整恢复。 +- `set_orientation()` 对纯色以外所有内容生效。 +- 开机笑脸继承用户方向但不继承用户亮度,动画期间不污染逻辑帧、模式、配置或修订号,退出后恢复用户亮度。 +- WiFi 提示能以连续可读速度滚动成功 URL 或断网凭据,断网图标位置稳定;成功/失败切换、取消和关闭均恢复最新用户内容,密码不进入日志。 +- 低电压仲裁优先于开机笑脸和用户帧,临界图标为纯黑底红色图形并以 35% 亮度继承方向;限亮、临界和恢复均不污染用户设置或用户修订号。35% 是覆盖实屏保护膜后仍能可靠辨识红色的验收值。 +- 同一 boot ID 成功退出后重启服务不重现,下一次整机开机重新显示;无摄像头实屏验收时停下等待用户确认。 +- 重启服务后方向和亮度配置仍存在。 +- 没有真实屏幕时模拟驱动能运行。 +- 真实驱动异常不会导致网页服务无法启动。 +- 高频画布更新不会导致帧积压或内存持续上涨。 +- Unit ADC 独立脚本的离线自测能验证 ADS1110 配置、符号转换和标称电压换算;未连接实物时不得把硬件读取标记为通过。 +- ADC 断开或返回异常时网页服务和 `DisplayService` 继续工作,状态值为空而不是伪造 `0V`;恢复连接后监测组件自动重试。 + +## 12. 未来扩展入口 + +以下内容只定义扩展落点和底层边界,不代表当前阶段必须实现。新增能力时先确认是否仍然服从 `DISPLAY-FRAME`,再决定是否需要新的驱动或配置编号。 + +- 滚动文字、动画、时钟、天气、传感器数据等普通内容:优先作为上层元素或图层提供者参与统一合成,底层只负责提交最新最终 `64x64 RGB` 帧;只有出现刷新率或同步问题时才扩展 VSync/动画路径。 +- 图片导入、缩放、裁切、调色板和抖动:属于帧生成策略,必须明确缩放/裁切规则,不能在 `show_image()` 中静默拉伸未知尺寸。 +- 字体管理和中文字体:扩展 `show_text()` 或 `text_renderer.py`,必须保持字体加载失败可解释,并通过配置模块保存默认字体。 +- 启动默认画面、预设场景、亮度曲线:归入 `CONFIG-PERSIST` 和 `CONFIG-DATA-LIFECYCLE`,必须说明默认值、持久数据类别、唯一当前 schema、单向迁移和失败保全。 +- 多块屏、不同分辨率、链路级联或新硬件映射:先更新 `HW-*` 和硬件资料,再决定 `DISPLAY-FRAME` 是否从固定 `64x64` 扩展为可配置尺寸;网页层不得直接处理 GPIO 细节。 +- 权限收紧、capabilities、非 root 运行、Docker 或反向代理:属于 `DEPLOY-*`,但必须验证不会破坏真实驱动初始化、配置读写和字体资源访问。 + +## 13. 参考资料 + +- 本地接线资料:`硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md` +- 本地资料:`硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/README.md` +- 本地资料:`硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md` +- 核桃派官方 `gpioc`(MIT):https://github.com/walnutpi/gpioc +- 核桃派 ZeroW 参数:https://wiki.walnutpi.com/docs/walnutpi_1/intro/hw-parameter/ +- H616/H618 PIO 寄存器依据:板端 device tree `allwinner,sun50i-h616-pinctrl` 与项目内上游来源记录 +- Pillow ImageDraw:https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html diff --git a/核桃派软件源代码/.gitignore b/核桃派软件源代码/.gitignore new file mode 100644 index 0000000..118d4fb --- /dev/null +++ b/核桃派软件源代码/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +data/ + diff --git a/核桃派软件源代码/FEATURE_UPDATED_AT b/核桃派软件源代码/FEATURE_UPDATED_AT new file mode 100644 index 0000000..a4c75e5 --- /dev/null +++ b/核桃派软件源代码/FEATURE_UPDATED_AT @@ -0,0 +1 @@ +2026-09-08T10:19+08:00 diff --git a/核桃派软件源代码/README.md b/核桃派软件源代码/README.md new file mode 100644 index 0000000..d1e89ba --- /dev/null +++ b/核桃派软件源代码/README.md @@ -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、不刷新功能时间。验证或提交失败保留原产物与记录。 diff --git a/核桃派软件源代码/UPGRADE_POLICY.json b/核桃派软件源代码/UPGRADE_POLICY.json new file mode 100644 index 0000000..6c42bf9 --- /dev/null +++ b/核桃派软件源代码/UPGRADE_POLICY.json @@ -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" + } + } + } + ] +} diff --git a/核桃派软件源代码/VERSION b/核桃派软件源代码/VERSION new file mode 100644 index 0000000..524cb55 --- /dev/null +++ b/核桃派软件源代码/VERSION @@ -0,0 +1 @@ +1.1.1 diff --git a/核桃派软件源代码/app/__init__.py b/核桃派软件源代码/app/__init__.py new file mode 100644 index 0000000..926f835 --- /dev/null +++ b/核桃派软件源代码/app/__init__.py @@ -0,0 +1,2 @@ +"""Matrix screen controller application package.""" + diff --git a/核桃派软件源代码/app/animations/__init__.py b/核桃派软件源代码/app/animations/__init__.py new file mode 100644 index 0000000..023a2e0 --- /dev/null +++ b/核桃派软件源代码/app/animations/__init__.py @@ -0,0 +1,3 @@ +from .store import AnimationStore + +__all__ = ["AnimationStore"] diff --git a/核桃派软件源代码/app/animations/constants.py b/核桃派软件源代码/app/animations/constants.py new file mode 100644 index 0000000..e2f6b0f --- /dev/null +++ b/核桃派软件源代码/app/animations/constants.py @@ -0,0 +1,3 @@ +DEFAULT_DURATION_MS = 500 +MIN_DURATION_MS = 50 +MAX_DURATION_MS = 7 * 24 * 60 * 60 * 1000 diff --git a/核桃派软件源代码/app/animations/store.py b/核桃派软件源代码/app/animations/store.py new file mode 100644 index 0000000..6bdcfaf --- /dev/null +++ b/核桃派软件源代码/app/animations/store.py @@ -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} diff --git a/核桃派软件源代码/app/config/__init__.py b/核桃派软件源代码/app/config/__init__.py new file mode 100644 index 0000000..1fdd157 --- /dev/null +++ b/核桃派软件源代码/app/config/__init__.py @@ -0,0 +1,2 @@ +"""Configuration helpers.""" + diff --git a/核桃派软件源代码/app/config/store.py b/核桃派软件源代码/app/config/store.py new file mode 100644 index 0000000..0019fc8 --- /dev/null +++ b/核桃派软件源代码/app/config/store.py @@ -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 diff --git a/核桃派软件源代码/app/demo_library.py b/核桃派软件源代码/app/demo_library.py new file mode 100644 index 0000000..17220cc --- /dev/null +++ b/核桃派软件源代码/app/demo_library.py @@ -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) diff --git a/核桃派软件源代码/app/display/__init__.py b/核桃派软件源代码/app/display/__init__.py new file mode 100644 index 0000000..1dbfcbc --- /dev/null +++ b/核桃派软件源代码/app/display/__init__.py @@ -0,0 +1,2 @@ +"""Display abstraction and drivers.""" + diff --git a/核桃派软件源代码/app/display/colors.py b/核桃派软件源代码/app/display/colors.py new file mode 100644 index 0000000..191b8cc --- /dev/null +++ b/核桃派软件源代码/app/display/colors.py @@ -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] + diff --git a/核桃派软件源代码/app/display/diagnostics.py b/核桃派软件源代码/app/display/diagnostics.py new file mode 100644 index 0000000..95872c9 --- /dev/null +++ b/核桃派软件源代码/app/display/diagnostics.py @@ -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 diff --git a/核桃派软件源代码/app/display/low_voltage_indicator.py b/核桃派软件源代码/app/display/low_voltage_indicator.py new file mode 100644 index 0000000..1dac2f9 --- /dev/null +++ b/核桃派软件源代码/app/display/low_voltage_indicator.py @@ -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 diff --git a/核桃派软件源代码/app/display/matrix_driver.py b/核桃派软件源代码/app/display/matrix_driver.py new file mode 100644 index 0000000..94ea68f --- /dev/null +++ b/核桃派软件源代码/app/display/matrix_driver.py @@ -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), + }, + } diff --git a/核桃派软件源代码/app/display/mock_driver.py b/核桃派软件源代码/app/display/mock_driver.py new file mode 100644 index 0000000..e3ad042 --- /dev/null +++ b/核桃派软件源代码/app/display/mock_driver.py @@ -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), + } diff --git a/核桃派软件源代码/app/display/native/Makefile b/核桃派软件源代码/app/display/native/Makefile new file mode 100644 index 0000000..ff749f4 --- /dev/null +++ b/核桃派软件源代码/app/display/native/Makefile @@ -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) diff --git a/核桃派软件源代码/app/display/native/THIRD_PARTY_NOTICES.md b/核桃派软件源代码/app/display/native/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..29ec5ca --- /dev/null +++ b/核桃派软件源代码/app/display/native/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/核桃派软件源代码/app/display/native/benchmark.c b/核桃派软件源代码/app/display/native/benchmark.c new file mode 100644 index 0000000..08004c1 --- /dev/null +++ b/核桃派软件源代码/app/display/native/benchmark.c @@ -0,0 +1,188 @@ +#define _POSIX_C_SOURCE 200809L +#include "h618_hub75.h" + +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/核桃派软件源代码/app/display/native/h618_hub75.c b/核桃派软件源代码/app/display/native/h618_hub75.c new file mode 100644 index 0000000..9e75f00 --- /dev/null +++ b/核桃派软件源代码/app/display/native/h618_hub75.c @@ -0,0 +1,1200 @@ +#define _GNU_SOURCE +#include "h618_hub75.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define H618_PIO_BASE ((off_t)0x0300B000) +#define H618_PIO_MAP_BYTES ((size_t)0x1000) +#define H618_PWM_BASE ((off_t)0x0300A000) +#define H618_PWM_MAP_BYTES ((size_t)0x1000) +#define H618_PI_BANK_OFFSET ((size_t)0x120) +#define SUNXI_MUX0_OFFSET ((size_t)0x00) +#define SUNXI_MUX1_OFFSET ((size_t)0x04) +#define SUNXI_DATA_OFFSET ((size_t)0x10) + +#define PWM_PCCR45_OFFSET ((size_t)0x28) +#define PWM_PER_OFFSET ((size_t)0x40) +#define PWM_CHANNEL_STRIDE ((size_t)0x20) +#define PWM_PCR_OFFSET(channel) ((size_t)0x60 + PWM_CHANNEL_STRIDE * (channel)) +#define PWM_PPR_OFFSET(channel) (PWM_PCR_OFFSET(channel) + (size_t)0x04) +#define PWM_PCNTR_OFFSET(channel) (PWM_PCR_OFFSET(channel) + (size_t)0x08) +#define PWM_CHANNEL 4u +#define PWM_CHANNEL_ENABLE BIT(PWM_CHANNEL) +#define PWM5_CHANNEL_ENABLE BIT(5) +#define PWM_PCCR_DIV_M_MASK 0x0fu +#define PWM_PCCR_GATE BIT(4) +#define PWM_PCCR_PWM4_BYPASS BIT(5) +#define PWM_PCCR_PWM5_BYPASS BIT(6) +#define PWM_PCCR_CLOCK_SOURCE_MASK (0x3u << 7) +#define PWM_PCR_PRESCALE_MASK 0xffu +#define PWM_PCR_ACTIVE_STATE BIT(8) +#define PWM_PCR_SINGLE_PULSE_MODE BIT(9) +#define PWM_PCR_PULSE_START BIT(10) +#define PWM_PCR_PERIOD_BUSY BIT(11) +#define PWM_PRESCALE_125NS 2u +#define PWM_PERIOD_CYCLES 65536u +#define PWM_PERIOD_NS ((uint64_t)PWM_PERIOD_CYCLES * HUB75_OE_PWM_TICK_NS) +#define PI14_MUX_PWM4 0x5u + +#define BIT(pin) (1u << (pin)) +#define PI_R1 BIT(0) +#define PI_G1 BIT(1) +#define PI_B1 BIT(2) +#define PI_R2 BIT(3) +#define PI_G2 BIT(4) +#define PI_B2 BIT(5) +#define PI_A BIT(6) +#define PI_B BIT(9) +#define PI_C BIT(10) +#define PI_D BIT(11) +#define PI_E BIT(12) +#define PI_CLK BIT(13) +#define PI_OE BIT(14) +#define PI_LAT BIT(15) + +#define COLOR_MASK (PI_R1 | PI_G1 | PI_B1 | PI_R2 | PI_G2 | PI_B2) +#define ADDRESS_MASK (PI_A | PI_B | PI_C | PI_D | PI_E) +#define CONTROL_MASK (PI_CLK | PI_LAT | PI_OE) +#define OWNED_MASK (COLOR_MASK | ADDRESS_MASK | CONTROL_MASK) +#define STATS_WINDOW_NS 5000000000ull +#define HUNDRED_HZ_GUARD_NS 250000ull + +struct hub75_context { + pthread_mutex_t lock; + pthread_t thread; + int thread_created; + atomic_int stop_requested; + + int mem_fd; + uint8_t *mapped; + int mapped_is_heap; + uint8_t *pwm_mapped; + int pwm_mapped_is_heap; + int fake_pwm4_stuck; + volatile uint32_t *mux0; + volatile uint32_t *mux1; + volatile uint32_t *data; + volatile uint32_t *pwm_pccr45; + volatile uint32_t *pwm_per; + volatile uint32_t *pwm_pcr4; + volatile uint32_t *pwm_ppr4; + volatile uint32_t *pwm_pcntr4; + int gpio_configured; + int pwm_configured; + uint32_t saved_mux0_owned; + uint32_t saved_mux1_owned; + uint32_t saved_data_owned; + uint32_t preserved_data_unowned; + uint32_t saved_pwm_pccr45; + uint32_t saved_pwm_per; + uint32_t saved_pwm_pcr4; + uint32_t saved_pwm_ppr4; + uint32_t saved_pwm_pcr5; + uint32_t saved_pwm_ppr5; + uint8_t frames[2][HUB75_FRAME_BYTES]; + uint16_t bitplanes[2][HUB75_BITPLANE_WORDS]; + int front_index; + int back_index; + int swap_pending; + + int brightness; + int refresh_rate_hz; + int cpu_affinity; + int realtime_priority; + hub75_stats stats; +}; + +static int refresh_rate_valid(int value) { + switch (value) { + case 15: + case 20: + case 30: + case 45: + case 60: + case 80: + case 100: + return 1; + default: + return 0; + } +} + +int hub75_scans_per_frame_for_brightness(int brightness) { + if (brightness < 1 || brightness > 100) return -1; + return HUB75_SCANS_PER_FRAME; +} + +const char *hub75_hardware_mapping(void) { + return HUB75_HARDWARE_MAPPING; +} + +uint32_t hub75_oe_ticks_for_plane( + uint64_t scan_slot_ns, + int brightness, + unsigned plane, + uint64_t *remainder +) { + if (brightness < 1 || brightness > 100 || plane >= HUB75_PWM_BITS || + remainder == NULL) { + return UINT32_MAX; + } + if (scan_slot_ns == 0) return UINT32_MAX; + /* + * Match rpi-rgb-led-matrix with kBitPlanes=11, pwm_bits=7 and an LSB + * pulse near 130 ns. The seven transmitted planes are internal planes + * 4..10, so their fixed 125 ns PWM4 weights are 2..128 us. Brightness is + * encoded into pixel bitplanes and never temporally dithered through OE. + */ + (void)brightness; + *remainder = 0; + return 1u << (plane + HUB75_PWM_PLANE_OFFSET); +} + +static void copy_error(char *destination, size_t size, const char *format, ...) { + if (destination == NULL || size == 0) { + return; + } + va_list args; + va_start(args, format); + vsnprintf(destination, size, format, args); + va_end(args); + destination[size - 1] = '\0'; +} + +static void set_last_error(hub75_context *context, const char *format, ...) { + va_list args; + pthread_mutex_lock(&context->lock); + va_start(args, format); + vsnprintf( + context->stats.last_error, + sizeof(context->stats.last_error), + format, + args + ); + va_end(args); + context->stats.last_error[sizeof(context->stats.last_error) - 1] = '\0'; + pthread_mutex_unlock(&context->lock); +} + +static uint64_t timespec_ns(const struct timespec *value) { + return (uint64_t)value->tv_sec * 1000000000ull + (uint64_t)value->tv_nsec; +} + +static struct timespec ns_timespec(uint64_t value) { + struct timespec result; + result.tv_sec = (time_t)(value / 1000000000ull); + result.tv_nsec = (long)(value % 1000000000ull); + return result; +} + +static uint64_t monotonic_ns(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return timespec_ns(&now); +} + +static void busy_wait_ns(uint64_t duration_ns) { + if (duration_ns == 0) { + return; + } + uint64_t end = monotonic_ns() + duration_ns; + while (monotonic_ns() < end) { + __asm__ __volatile__("" ::: "memory"); + } +} + +static void sleep_until_ns(uint64_t target_ns) { + struct timespec target = ns_timespec(target_ns); + while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &target, NULL) == EINTR) { + } +} + +static uint32_t address_bits(unsigned row) { + uint32_t value = 0; + if (row & 0x01u) value |= PI_A; + if (row & 0x02u) value |= PI_B; + if (row & 0x04u) value |= PI_C; + if (row & 0x08u) value |= PI_D; + if (row & 0x10u) value |= PI_E; + return value; +} + +static inline void write_owned(hub75_context *context, uint32_t value) { + /* + * The dedicated-host profile stops every other userspace GPIO writer. + * Cache all non-HUB75 bits once so each clock edge needs one MMIO write + * instead of an MMIO read-modify-write. PI7/PI8/PI16 remain unchanged. + */ + *context->data = context->preserved_data_unowned | (value & OWNED_MASK); + atomic_signal_fence(memory_order_seq_cst); +} + +static uint16_t cie1931_channel(uint8_t value, int brightness) { + const double output_factor = (1u << HUB75_INTERNAL_PWM_BITS) - 1u; + const double v = (double)value * (double)brightness / 255.0; + double luminance; + if (v <= 8.0) { + luminance = v / 902.3; + } else { + const double ratio = (v + 16.0) / 116.0; + luminance = ratio * ratio * ratio; + } + return (uint16_t)(output_factor * luminance + 0.5); +} + +uint16_t hub75_map_channel_for_brightness(uint8_t value, int brightness) { + if (brightness < 1 || brightness > 100) return UINT16_MAX; + return cie1931_channel(value, brightness); +} + +static uint32_t mux_mask(unsigned first, unsigned last, unsigned register_base) { + uint32_t result = 0; + for (unsigned pin = first; pin <= last; ++pin) { + result |= 0xFu << ((pin - register_base) * 4u); + } + return result; +} + +static uint32_t mux_output_value(unsigned first, unsigned last, unsigned register_base) { + uint32_t result = 0; + for (unsigned pin = first; pin <= last; ++pin) { + result |= 0x1u << ((pin - register_base) * 4u); + } + return result; +} + +static uint32_t mux_set_pin(uint32_t value, unsigned pin, unsigned function) { + unsigned shift = (pin - 8u) * 4u; + return (value & ~(0xfu << shift)) | ((function & 0xfu) << shift); +} + +static void configure_gpio(hub75_context *context) { + const uint32_t mux0_owned = mux_mask(0, 6, 0); + const uint32_t mux1_owned = mux_mask(9, 15, 8); + context->saved_mux0_owned = *context->mux0 & mux0_owned; + context->saved_mux1_owned = *context->mux1 & mux1_owned; + context->saved_data_owned = *context->data & OWNED_MASK; + context->preserved_data_unowned = *context->data & ~OWNED_MASK; + write_owned(context, PI_OE); + *context->mux0 = (*context->mux0 & ~mux0_owned) | + mux_output_value(0, 6, 0); + *context->mux1 = (*context->mux1 & ~mux1_owned) | + mux_output_value(9, 15, 8); + atomic_thread_fence(memory_order_seq_cst); + write_owned(context, PI_OE); + context->gpio_configured = 1; +} + +static void switch_oe_to_pwm4(hub75_context *context) { + write_owned(context, PI_OE); + *context->mux1 = mux_set_pin(*context->mux1, 14, PI14_MUX_PWM4); + atomic_thread_fence(memory_order_seq_cst); +} + +static void switch_oe_to_gpio_high(hub75_context *context) { + *context->mux1 = mux_set_pin(*context->mux1, 14, 0x1u); + atomic_thread_fence(memory_order_seq_cst); + write_owned(context, PI_OE); +} + +static inline void pwm4_disable(hub75_context *context) { + uint32_t enabled = *context->pwm_per; + *context->pwm_per = enabled & ~PWM_CHANNEL_ENABLE; + atomic_thread_fence(memory_order_seq_cst); +} + +static void pwm_force_blank(hub75_context *context, int count_forced) { + if (context->pwm_per == NULL) { + return; + } + pwm4_disable(context); + switch_oe_to_gpio_high(context); + if (count_forced) { + pthread_mutex_lock(&context->lock); + context->stats.oe_forced_blanks += 1; + pthread_mutex_unlock(&context->lock); + } +} + +static int pwm4_wait_period_ready(hub75_context *context) { + uint64_t started = monotonic_ns(); + while ((*context->pwm_pcr4 & PWM_PCR_PERIOD_BUSY) != 0) { + if (context->pwm_mapped_is_heap) { + *context->pwm_pcr4 &= ~PWM_PCR_PERIOD_BUSY; + continue; + } + if (monotonic_ns() - started >= PWM_PERIOD_NS) { + return -1; + } + } + return 0; +} + +static void pwm4_fake_begin_period_update(hub75_context *context) { + if (context->pwm_mapped_is_heap) { + *context->pwm_pcr4 |= PWM_PCR_PERIOD_BUSY; + } +} + +static int configure_pwm4(hub75_context *context, char *error, size_t error_size) { + uint32_t per = *context->pwm_per; + if (per & PWM_CHANNEL_ENABLE) { + copy_error(error, error_size, "PWM4 is already active; hardware timing resource is occupied"); + return -1; + } + + context->saved_pwm_pccr45 = *context->pwm_pccr45; + context->saved_pwm_per = per; + context->saved_pwm_pcr4 = *context->pwm_pcr4; + context->saved_pwm_ppr4 = *context->pwm_ppr4; + context->saved_pwm_pcr5 = *(volatile uint32_t *)( + context->pwm_mapped + PWM_PCR_OFFSET(5u) + ); + context->saved_pwm_ppr5 = *(volatile uint32_t *)( + context->pwm_mapped + PWM_PPR_OFFSET(5u) + ); + context->pwm_configured = 1; + + uint32_t clock = context->saved_pwm_pccr45; + clock &= ~(PWM_PCCR_DIV_M_MASK | PWM_PCCR_CLOCK_SOURCE_MASK | + PWM_PCCR_PWM4_BYPASS); + clock |= PWM_PCCR_GATE; + uint32_t shared_mask = PWM_PCCR_DIV_M_MASK | + PWM_PCCR_CLOCK_SOURCE_MASK | PWM_PCCR_GATE; + if ((per & PWM5_CHANNEL_ENABLE) && + (context->saved_pwm_pccr45 & shared_mask) != (clock & shared_mask)) { + copy_error( + error, + error_size, + "active PWM5 uses an incompatible shared PWM4/5 clock" + ); + return -1; + } + *context->pwm_pccr45 = clock; + + uint32_t control = context->saved_pwm_pcr4; + control &= ~(PWM_PCR_PRESCALE_MASK | PWM_PCR_ACTIVE_STATE | + PWM_PCR_PULSE_START | PWM_PCR_PERIOD_BUSY); + control |= PWM_PRESCALE_125NS | PWM_PCR_SINGLE_PULSE_MODE; + *context->pwm_pcr4 = control; + *context->pwm_ppr4 = (PWM_PERIOD_CYCLES - 1u) << 16; + pwm4_fake_begin_period_update(context); + atomic_thread_fence(memory_order_seq_cst); + busy_wait_ns(1000u); + if (pwm4_wait_period_ready(context) != 0) { + copy_error(error, error_size, "PWM4 initial period register stayed busy"); + return -1; + } + *context->pwm_per = per | PWM_CHANNEL_ENABLE; + atomic_thread_fence(memory_order_seq_cst); + + uint32_t clock_readback = *context->pwm_pccr45; + uint32_t control_readback = *context->pwm_pcr4; + uint32_t period_readback = *context->pwm_ppr4; + if ((clock_readback & (PWM_PCCR_DIV_M_MASK | PWM_PCCR_CLOCK_SOURCE_MASK | + PWM_PCCR_GATE | PWM_PCCR_PWM4_BYPASS | PWM_PCCR_PWM5_BYPASS)) != + (clock & (PWM_PCCR_DIV_M_MASK | PWM_PCCR_CLOCK_SOURCE_MASK | + PWM_PCCR_GATE | PWM_PCCR_PWM4_BYPASS | PWM_PCCR_PWM5_BYPASS)) || + (control_readback & (PWM_PCR_PRESCALE_MASK | PWM_PCR_ACTIVE_STATE | + PWM_PCR_SINGLE_PULSE_MODE)) != + (PWM_PRESCALE_125NS | PWM_PCR_SINGLE_PULSE_MODE) || + period_readback != ((PWM_PERIOD_CYCLES - 1u) << 16) || + (*context->pwm_per & PWM_CHANNEL_ENABLE) == 0) { + copy_error(error, error_size, "PWM4 initialization register readback mismatch"); + return -1; + } + snprintf( + context->stats.oe_timing_backend, + sizeof(context->stats.oe_timing_backend), + "%s", + HUB75_OE_TIMING_BACKEND + ); + return 0; +} + +static int pwm4_shared_clock_valid(hub75_context *context) { + uint32_t expected = context->saved_pwm_pccr45; + expected &= ~(PWM_PCCR_DIV_M_MASK | PWM_PCCR_CLOCK_SOURCE_MASK | + PWM_PCCR_PWM4_BYPASS); + expected |= PWM_PCCR_GATE; + uint32_t mask = PWM_PCCR_DIV_M_MASK | PWM_PCCR_CLOCK_SOURCE_MASK | + PWM_PCCR_GATE | PWM_PCCR_PWM4_BYPASS | PWM_PCCR_PWM5_BYPASS; + return ((*context->pwm_pccr45 & mask) == (expected & mask)) && + ((*context->pwm_per & PWM_CHANNEL_ENABLE) != 0) && + ((*context->pwm_per & PWM5_CHANNEL_ENABLE) == + (context->saved_pwm_per & PWM5_CHANNEL_ENABLE)) && + (*(volatile uint32_t *)(context->pwm_mapped + PWM_PCR_OFFSET(5u)) == + context->saved_pwm_pcr5) && + (*(volatile uint32_t *)(context->pwm_mapped + PWM_PPR_OFFSET(5u)) == + context->saved_pwm_ppr5); +} + +static int pwm4_program_pulse(hub75_context *context, uint32_t ticks) { + if (ticks == 0 || ticks >= PWM_PERIOD_CYCLES) { + return -1; + } + uint64_t ready_started = monotonic_ns(); + while ((*context->pwm_pcr4 & + (PWM_PCR_PULSE_START | PWM_PCR_PERIOD_BUSY)) != 0) { + if (monotonic_ns() - ready_started >= PWM_PERIOD_NS) { + return -1; + } + } + uint32_t programmed = ((ticks - 1u) << 16) | ticks; + /* + * H618 single-pulse mode reloads its read-only counter for every PUL_START + * command and clears PUL_START automatically at the end. Equal active and + * entire cycle counts remove the leading inactive portion, yielding one + * immediate low-active OE pulse with no periodic repeat after a stall. + */ + *context->pwm_ppr4 = programmed; + pwm4_fake_begin_period_update(context); + atomic_thread_fence(memory_order_seq_cst); + if (*context->pwm_ppr4 != programmed) { + return -1; + } + /* + * PERIOD_RDY is read-only: 1 means the period register is still busy. + * Allow the APB write to reach the PWM clock domain, then wait for the + * new active-cycle count to be committed before issuing PUL_START. + */ + busy_wait_ns(1000u); + if (pwm4_wait_period_ready(context) != 0) { + return -1; + } + uint32_t control = *context->pwm_pcr4; + if ((control & (PWM_PCR_SINGLE_PULSE_MODE | PWM_PCR_ACTIVE_STATE)) != + PWM_PCR_SINGLE_PULSE_MODE) { + return -1; + } + *context->pwm_pcr4 = control | PWM_PCR_PULSE_START; + atomic_thread_fence(memory_order_seq_cst); + if (context->pwm_mapped_is_heap && !context->fake_pwm4_stuck) { + *context->pwm_pcr4 = control; + } + return (*context->pwm_per & PWM_CHANNEL_ENABLE) ? 0 : -1; +} + +static void restore_pwm4(hub75_context *context) { + if (!context->pwm_configured || context->pwm_per == NULL) { + return; + } + pwm4_disable(context); + *context->pwm_ppr4 = context->saved_pwm_ppr4; + *context->pwm_pcr4 = context->saved_pwm_pcr4; + if ((*context->pwm_per & PWM5_CHANNEL_ENABLE) == + (context->saved_pwm_per & PWM5_CHANNEL_ENABLE)) { + *context->pwm_pccr45 = context->saved_pwm_pccr45; + } + atomic_thread_fence(memory_order_seq_cst); + context->pwm_configured = 0; +} + +static void restore_gpio_safe(hub75_context *context) { + if (!context->gpio_configured || context->data == NULL) { + return; + } + const uint32_t mux0_owned = mux_mask(0, 6, 0); + const uint32_t mux1_owned = mux_mask(9, 15, 8); + switch_oe_to_gpio_high(context); + write_owned(context, PI_OE); + *context->mux0 = (*context->mux0 & ~mux0_owned) | context->saved_mux0_owned; + *context->mux1 = (*context->mux1 & ~mux1_owned) | context->saved_mux1_owned; + atomic_thread_fence(memory_order_seq_cst); + write_owned(context, context->saved_data_owned); + context->gpio_configured = 0; +} + +static int map_registers(hub75_context *context, int use_dev_mem, char *error, size_t error_size) { + if (use_dev_mem) { + context->mem_fd = open("/dev/mem", O_RDWR | O_SYNC | O_CLOEXEC); + if (context->mem_fd < 0) { + copy_error(error, error_size, "open /dev/mem failed: %s", strerror(errno)); + return -1; + } + context->mapped = mmap( + NULL, + H618_PIO_MAP_BYTES, + PROT_READ | PROT_WRITE, + MAP_SHARED, + context->mem_fd, + H618_PIO_BASE + ); + if (context->mapped == MAP_FAILED) { + context->mapped = NULL; + copy_error(error, error_size, "mmap H618 PIO failed: %s", strerror(errno)); + return -1; + } + context->pwm_mapped = mmap( + NULL, + H618_PWM_MAP_BYTES, + PROT_READ | PROT_WRITE, + MAP_SHARED, + context->mem_fd, + H618_PWM_BASE + ); + if (context->pwm_mapped == MAP_FAILED) { + context->pwm_mapped = NULL; + copy_error(error, error_size, "mmap H618 PWM failed: %s", strerror(errno)); + return -1; + } + } else { + context->mapped = calloc(1, H618_PIO_MAP_BYTES); + if (context->mapped == NULL) { + copy_error(error, error_size, "allocating fake PIO registers failed"); + return -1; + } + context->mapped_is_heap = 1; + context->pwm_mapped = calloc(1, H618_PWM_MAP_BYTES); + if (context->pwm_mapped == NULL) { + copy_error(error, error_size, "allocating fake PWM registers failed"); + return -1; + } + context->pwm_mapped_is_heap = 1; + } + + uint8_t *pi = context->mapped + H618_PI_BANK_OFFSET; + context->mux0 = (volatile uint32_t *)(pi + SUNXI_MUX0_OFFSET); + context->mux1 = (volatile uint32_t *)(pi + SUNXI_MUX1_OFFSET); + context->data = (volatile uint32_t *)(pi + SUNXI_DATA_OFFSET); + context->pwm_pccr45 = (volatile uint32_t *)(context->pwm_mapped + PWM_PCCR45_OFFSET); + context->pwm_per = (volatile uint32_t *)(context->pwm_mapped + PWM_PER_OFFSET); + context->pwm_pcr4 = (volatile uint32_t *)(context->pwm_mapped + PWM_PCR_OFFSET(PWM_CHANNEL)); + context->pwm_ppr4 = (volatile uint32_t *)(context->pwm_mapped + PWM_PPR_OFFSET(PWM_CHANNEL)); + context->pwm_pcntr4 = (volatile uint32_t *)(context->pwm_mapped + PWM_PCNTR_OFFSET(PWM_CHANNEL)); + return 0; +} + +static void unmap_registers(hub75_context *context) { + if (context->mapped != NULL) { + if (context->mapped_is_heap) { + free(context->mapped); + } else { + munmap(context->mapped, H618_PIO_MAP_BYTES); + } + context->mapped = NULL; + } + if (context->pwm_mapped != NULL) { + if (context->pwm_mapped_is_heap) { + free(context->pwm_mapped); + } else { + munmap(context->pwm_mapped, H618_PWM_MAP_BYTES); + } + context->pwm_mapped = NULL; + } + if (context->mem_fd >= 0) { + close(context->mem_fd); + context->mem_fd = -1; + } + context->mux0 = NULL; + context->mux1 = NULL; + context->data = NULL; + context->pwm_pccr45 = NULL; + context->pwm_per = NULL; + context->pwm_pcr4 = NULL; + context->pwm_ppr4 = NULL; + context->pwm_pcntr4 = NULL; +} + +int hub75_build_bitplanes_for_brightness( + const uint8_t *rgb, + size_t length, + int brightness, + uint16_t *output, + size_t output_words +) { + if (rgb == NULL || output == NULL || length != HUB75_FRAME_BYTES || + output_words != HUB75_BITPLANE_WORDS || brightness < 1 || + brightness > 100) { + return -1; + } + uint16_t mapped[256]; + for (unsigned value = 0; value < 256; ++value) { + mapped[value] = cie1931_channel((uint8_t)value, brightness); + } + for (unsigned row = 0; row < HUB75_SCAN_ROWS; ++row) { + for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) { + const unsigned internal_plane = plane + HUB75_PWM_PLANE_OFFSET; + for (unsigned column = 0; column < HUB75_WIDTH; ++column) { + size_t top = ((size_t)row * HUB75_WIDTH + column) * 3u; + size_t bottom = ((size_t)(row + HUB75_SCAN_ROWS) * HUB75_WIDTH + column) * 3u; + uint16_t word = 0; + if ((mapped[rgb[top]] >> internal_plane) & 1u) word |= PI_R1; + if ((mapped[rgb[top + 1]] >> internal_plane) & 1u) word |= PI_G1; + if ((mapped[rgb[top + 2]] >> internal_plane) & 1u) word |= PI_B1; + if ((mapped[rgb[bottom]] >> internal_plane) & 1u) word |= PI_R2; + if ((mapped[rgb[bottom + 1]] >> internal_plane) & 1u) word |= PI_G2; + if ((mapped[rgb[bottom + 2]] >> internal_plane) & 1u) word |= PI_B2; + size_t index = ((size_t)row * HUB75_PWM_BITS + plane) * HUB75_WIDTH + column; + output[index] = word; + } + } + } + return 0; +} + +int hub75_build_bitplanes( + const uint8_t *rgb, + size_t length, + uint16_t *output, + size_t output_words +) { + return hub75_build_bitplanes_for_brightness( + rgb, length, 100, output, output_words + ); +} + +static void read_governor(hub75_context *context) { + const char *path = "/sys/devices/system/cpu/cpu3/cpufreq/scaling_governor"; + FILE *file = fopen(path, "r"); + char value[sizeof(context->stats.governor)] = "unavailable"; + if (file != NULL) { + if (fgets(value, sizeof(value), file) == NULL) { + snprintf(value, sizeof(value), "unavailable"); + } + fclose(file); + value[strcspn(value, "\r\n")] = '\0'; + } + pthread_mutex_lock(&context->lock); + snprintf(context->stats.governor, sizeof(context->stats.governor), "%s", value); + pthread_mutex_unlock(&context->lock); +} + +static void apply_thread_optimizations(hub75_context *context) { + cpu_set_t cpu_set; + CPU_ZERO(&cpu_set); + CPU_SET(context->cpu_affinity, &cpu_set); + int code = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set), &cpu_set); + pthread_mutex_lock(&context->lock); + if (code == 0) { + context->stats.cpu_affinity_active = 1; + } else { + snprintf( + context->stats.affinity_error, + sizeof(context->stats.affinity_error), + "%s", + strerror(code) + ); + } + pthread_mutex_unlock(&context->lock); + + struct sched_param parameter; + memset(¶meter, 0, sizeof(parameter)); + parameter.sched_priority = context->realtime_priority; + code = pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶meter); + pthread_mutex_lock(&context->lock); + if (code == 0) { + context->stats.realtime_priority_active = 1; + } else { + snprintf( + context->stats.realtime_error, + sizeof(context->stats.realtime_error), + "%s", + strerror(code) + ); + } + pthread_mutex_unlock(&context->lock); + + if (mlockall(MCL_CURRENT | MCL_FUTURE) == 0) { + pthread_mutex_lock(&context->lock); + context->stats.memory_locked = 1; + pthread_mutex_unlock(&context->lock); + } else { + pthread_mutex_lock(&context->lock); + snprintf( + context->stats.memory_lock_error, + sizeof(context->stats.memory_lock_error), + "%s", + strerror(errno) + ); + pthread_mutex_unlock(&context->lock); + } + read_governor(context); +} + +static int scan_frame( + hub75_context *context, + const uint16_t *planes, + uint64_t period_ns +) { + uint64_t local_max_programmed_ns = 0; + if (!pwm4_shared_clock_valid(context)) { + pwm_force_blank(context, 1); + pthread_mutex_lock(&context->lock); + context->stats.oe_pulse_faults += 1; + snprintf( + context->stats.oe_timing_error, + sizeof(context->stats.oe_timing_error), + "PWM4/5 shared clock changed or PWM5 became active" + ); + pthread_mutex_unlock(&context->lock); + return -1; + } + + /* Prime the shift register and latch row 0 / plane 0 while OE is blank. */ + uint32_t output_address = address_bits(0); + write_owned(context, output_address); + for (unsigned column = 0; column < HUB75_WIDTH; ++column) { + uint32_t value = output_address | planes[column]; + write_owned(context, value); + write_owned(context, value | PI_CLK); + } + write_owned(context, output_address); + write_owned(context, output_address | PI_LAT); + write_owned(context, output_address); + + const unsigned total_planes = HUB75_SCAN_ROWS * HUB75_PWM_BITS; + for (unsigned item = 0; item < total_planes; ++item) { + unsigned plane = item % HUB75_PWM_BITS; + uint32_t ticks = hub75_oe_ticks_for_plane( + period_ns, + 100, + plane, + &(uint64_t){0} + ); + uint64_t pulse_enabled_at = 0; + if (ticks == UINT32_MAX || + (ticks != 0 && pwm4_program_pulse(context, ticks) != 0)) { + pwm_force_blank(context, 1); + pthread_mutex_lock(&context->lock); + context->stats.oe_pulse_faults += 1; + snprintf( + context->stats.oe_timing_error, + sizeof(context->stats.oe_timing_error), + "PWM4 pulse programming or readback failed" + ); + pthread_mutex_unlock(&context->lock); + return -1; + } + if (ticks != 0) { + /* Timestamp after the enable readback so the wait is conservative. */ + pulse_enabled_at = monotonic_ns(); + } + uint64_t programmed_ns = (uint64_t)ticks * HUB75_OE_PWM_TICK_NS; + if (programmed_ns > local_max_programmed_ns) { + local_max_programmed_ns = programmed_ns; + } + + /* + * While hardware bounds OE for the current latch, prepare the next + * bitplane. The row address remains unchanged until OE is inactive. + */ + if (item + 1u < total_planes) { + unsigned next_item = item + 1u; + size_t next_start = (size_t)next_item * HUB75_WIDTH; + for (unsigned column = 0; column < HUB75_WIDTH; ++column) { + uint32_t value = output_address | planes[next_start + column]; + write_owned(context, value); + write_owned(context, value | PI_CLK); + } + } + + if (ticks != 0) { + uint64_t pulse_deadline = pulse_enabled_at + programmed_ns + + HUB75_OE_PWM_TICK_NS / 2u; + uint64_t now = monotonic_ns(); + if (now < pulse_deadline) { + busy_wait_ns(pulse_deadline - now); + } + uint64_t idle_started = monotonic_ns(); + while ((*context->pwm_pcr4 & PWM_PCR_PULSE_START) != 0) { + if (monotonic_ns() - idle_started >= PWM_PERIOD_NS) { + pwm_force_blank(context, 1); + pthread_mutex_lock(&context->lock); + context->stats.oe_pulse_faults += 1; + snprintf( + context->stats.oe_timing_error, + sizeof(context->stats.oe_timing_error), + "PWM4 single-pulse start bit did not clear" + ); + pthread_mutex_unlock(&context->lock); + return -1; + } + } + } + + if (item + 1u < total_planes) { + unsigned next_row = (item + 1u) / HUB75_PWM_BITS; + output_address = address_bits(next_row); + write_owned(context, output_address); + write_owned(context, output_address | PI_LAT); + write_owned(context, output_address); + } + } + pthread_mutex_lock(&context->lock); + if (local_max_programmed_ns > context->stats.max_programmed_oe_ns) { + context->stats.max_programmed_oe_ns = local_max_programmed_ns; + } + pthread_mutex_unlock(&context->lock); + return 0; +} + +static void *refresh_thread(void *argument) { + hub75_context *context = argument; + apply_thread_optimizations(context); + uint64_t window_start = monotonic_ns(); + uint64_t window_frames = 0; + uint64_t window_scans = 0; + + pthread_mutex_lock(&context->lock); + context->stats.running = 1; + pthread_mutex_unlock(&context->lock); + + while (!atomic_load_explicit(&context->stop_requested, memory_order_acquire)) { + uint64_t frame_start = monotonic_ns(); + int front; + int brightness; + int refresh_rate; + + pthread_mutex_lock(&context->lock); + if (context->swap_pending) { + int previous_front = context->front_index; + context->front_index = context->back_index; + context->back_index = previous_front; + context->swap_pending = 0; + context->stats.buffer_swaps += 1; + } + front = context->front_index; + brightness = context->brightness; + refresh_rate = context->refresh_rate_hz; + pthread_mutex_unlock(&context->lock); + + uint64_t period_ns = 1000000000ull / (uint64_t)refresh_rate; + if (refresh_rate == 100) { + /* 100 Hz is an upper limit; reserve bounded MMIO/jitter headroom. */ + period_ns += HUNDRED_HZ_GUARD_NS; + } + int scans_per_frame = hub75_scans_per_frame_for_brightness(brightness); + uint64_t scan_slot_ns = period_ns / (uint64_t)scans_per_frame; + uint64_t frame_scans = 0; + for (int scan = 0; scan < scans_per_frame; ++scan) { + uint64_t scan_start = frame_start + + period_ns * (uint64_t)scan / (uint64_t)scans_per_frame; + uint64_t before_scan = monotonic_ns(); + if (before_scan < scan_start) { + sleep_until_ns(scan_start); + } + if (scan_frame( + context, + context->bitplanes[front], + scan_slot_ns + ) != 0) { + atomic_store_explicit(&context->stop_requested, 1, memory_order_release); + set_last_error(context, "PWM4 OE timing fault; output forced blank"); + break; + } + ++frame_scans; + } + uint64_t deadline = frame_start + period_ns; + uint64_t after_scan = monotonic_ns(); + int missed = after_scan > deadline; + if (!missed) { + sleep_until_ns(deadline); + } + + uint64_t now = monotonic_ns(); + ++window_frames; + window_scans += frame_scans; + pthread_mutex_lock(&context->lock); + context->stats.completed_frames += 1; + context->stats.completed_scans += frame_scans; + context->stats.scans_per_frame = scans_per_frame; + if (missed) { + context->stats.deadline_misses += 1; + } + if (now - window_start >= STATS_WINDOW_NS) { + context->stats.actual_refresh_rate_hz = + (double)window_frames * 1000000000.0 / + (double)(now - window_start); + context->stats.panel_scan_rate_hz = + (double)window_scans * 1000000000.0 / + (double)(now - window_start); + window_start = now; + window_frames = 0; + window_scans = 0; + } + pthread_mutex_unlock(&context->lock); + } + + pwm_force_blank(context, 0); + pthread_mutex_lock(&context->lock); + context->stats.running = 0; + pthread_mutex_unlock(&context->lock); + return NULL; +} + +hub75_context *hub75_create(const hub75_config *config, char *error, size_t error_size) { + if (config == NULL) { + copy_error(error, error_size, "config is required"); + return NULL; + } + if (config->brightness < 1 || config->brightness > 100) { + copy_error(error, error_size, "brightness must be in 1..100"); + return NULL; + } + if (!refresh_rate_valid(config->refresh_rate_hz)) { + copy_error(error, error_size, "unsupported refresh rate: %d", config->refresh_rate_hz); + return NULL; + } + if (config->cpu_affinity < 0 || config->realtime_priority < 1 || + config->realtime_priority > 99 || config->fake_pwm5_mode < 0 || + config->fake_pwm5_mode > 2 || config->fake_pwm4_stuck < 0 || + config->fake_pwm4_stuck > 1 || + (config->use_dev_mem && config->fake_pwm4_stuck != 0)) { + copy_error(error, error_size, "invalid CPU affinity or realtime priority"); + return NULL; + } + + hub75_context *context = calloc(1, sizeof(*context)); + if (context == NULL) { + copy_error(error, error_size, "allocating driver context failed"); + return NULL; + } + context->mem_fd = -1; + context->fake_pwm4_stuck = config->fake_pwm4_stuck; + pthread_mutexattr_t lock_attributes; + int lock_code = pthread_mutexattr_init(&lock_attributes); + int lock_attributes_ready = lock_code == 0; + if (lock_code == 0) { + lock_code = pthread_mutexattr_setprotocol( + &lock_attributes, + PTHREAD_PRIO_INHERIT + ); + } + if (lock_code == 0) { + lock_code = pthread_mutex_init(&context->lock, &lock_attributes); + } + if (lock_attributes_ready) { + pthread_mutexattr_destroy(&lock_attributes); + } + if (lock_code != 0) { + copy_error(error, error_size, "initializing driver lock failed"); + free(context); + return NULL; + } + context->brightness = config->brightness; + context->refresh_rate_hz = config->refresh_rate_hz; + context->cpu_affinity = config->cpu_affinity; + context->realtime_priority = config->realtime_priority; + context->front_index = 0; + context->back_index = 1; + context->stats.cpu_affinity = config->cpu_affinity; + context->stats.realtime_priority = config->realtime_priority; + context->stats.brightness = config->brightness; + context->stats.refresh_rate_hz = config->refresh_rate_hz; + context->stats.scans_per_frame = + hub75_scans_per_frame_for_brightness(config->brightness); + snprintf(context->stats.governor, sizeof(context->stats.governor), "unknown"); + snprintf( + context->stats.oe_timing_backend, + sizeof(context->stats.oe_timing_backend), + "%s", + HUB75_OE_TIMING_BACKEND + ); + + if (map_registers(context, config->use_dev_mem, error, error_size) != 0) { + unmap_registers(context); + pthread_mutex_destroy(&context->lock); + free(context); + return NULL; + } + if (!config->use_dev_mem && config->fake_pwm5_mode != 0) { + *context->pwm_pccr45 = config->fake_pwm5_mode == 1 ? 0x50u : 0xd0u; + *context->pwm_per = PWM5_CHANNEL_ENABLE; + *(volatile uint32_t *)(context->pwm_mapped + PWM_PCR_OFFSET(5u)) = 0x100u; + *(volatile uint32_t *)(context->pwm_mapped + PWM_PPR_OFFSET(5u)) = 0x000b0006u; + } + configure_gpio(context); + if (configure_pwm4(context, error, error_size) != 0) { + snprintf( + context->stats.oe_timing_error, + sizeof(context->stats.oe_timing_error), + "%s", + error != NULL ? error : "PWM4 initialization failed" + ); + restore_pwm4(context); + restore_gpio_safe(context); + unmap_registers(context); + pthread_mutex_destroy(&context->lock); + free(context); + return NULL; + } + switch_oe_to_pwm4(context); + memset(context->frames, 0, sizeof(context->frames)); + memset(context->bitplanes, 0, sizeof(context->bitplanes)); + return context; +} + +int hub75_start(hub75_context *context) { + if (context == NULL) return -1; + pthread_mutex_lock(&context->lock); + if (context->thread_created) { + pthread_mutex_unlock(&context->lock); + return 0; + } + atomic_store_explicit(&context->stop_requested, 0, memory_order_release); + int code = pthread_create(&context->thread, NULL, refresh_thread, context); + if (code != 0) { + snprintf( + context->stats.last_error, + sizeof(context->stats.last_error), + "pthread_create failed: %s", + strerror(code) + ); + pthread_mutex_unlock(&context->lock); + return -1; + } + context->thread_created = 1; + pthread_mutex_unlock(&context->lock); + return 0; +} + +int hub75_submit_rgb(hub75_context *context, const uint8_t *rgb, size_t length) { + if (context == NULL || rgb == NULL || length != HUB75_FRAME_BYTES) { + if (context != NULL) set_last_error(context, "RGB frame must contain %u bytes", HUB75_FRAME_BYTES); + return -1; + } + uint16_t prepared[HUB75_BITPLANE_WORDS]; + int brightness; + pthread_mutex_lock(&context->lock); + brightness = context->brightness; + pthread_mutex_unlock(&context->lock); + int result = hub75_build_bitplanes_for_brightness( + rgb, + HUB75_FRAME_BYTES, + brightness, + prepared, + HUB75_BITPLANE_WORDS + ); + if (result == 0) { + pthread_mutex_lock(&context->lock); + int back = context->back_index; + memcpy(context->frames[back], rgb, HUB75_FRAME_BYTES); + memcpy(context->bitplanes[back], prepared, sizeof(prepared)); + context->swap_pending = 1; + context->stats.submitted_frames += 1; + pthread_mutex_unlock(&context->lock); + } + return result; +} + +int hub75_set_brightness(hub75_context *context, int brightness) { + if (context == NULL || brightness < 1 || brightness > 100) return -1; + pthread_mutex_lock(&context->lock); + context->brightness = brightness; + context->stats.brightness = brightness; + pthread_mutex_unlock(&context->lock); + return 0; +} + +int hub75_set_refresh_rate(hub75_context *context, int refresh_rate_hz) { + if (context == NULL || !refresh_rate_valid(refresh_rate_hz)) return -1; + pthread_mutex_lock(&context->lock); + context->refresh_rate_hz = refresh_rate_hz; + context->stats.refresh_rate_hz = refresh_rate_hz; + pthread_mutex_unlock(&context->lock); + return 0; +} + +int hub75_clear(hub75_context *context) { + if (context == NULL) return -1; + uint8_t black[HUB75_FRAME_BYTES] = {0}; + return hub75_submit_rgb(context, black, sizeof(black)); +} + +int hub75_get_stats(hub75_context *context, hub75_stats *stats) { + if (context == NULL || stats == NULL) return -1; + pthread_mutex_lock(&context->lock); + *stats = context->stats; + pthread_mutex_unlock(&context->lock); + return 0; +} + +void hub75_destroy(hub75_context *context) { + if (context == NULL) return; + atomic_store_explicit(&context->stop_requested, 1, memory_order_release); + if (context->thread_created) { + pthread_join(context->thread, NULL); + context->thread_created = 0; + } + pwm_force_blank(context, 0); + restore_pwm4(context); + restore_gpio_safe(context); + if (context->stats.memory_locked) { + munlockall(); + } + unmap_registers(context); + pthread_mutex_destroy(&context->lock); + free(context); +} + +int hub75_get_fake_registers( + hub75_context *context, + hub75_fake_registers *registers +) { + if (context == NULL || registers == NULL || !context->mapped_is_heap || + !context->pwm_mapped_is_heap) { + return -1; + } + registers->mux0 = *context->mux0; + registers->mux1 = *context->mux1; + registers->data = *context->data; + registers->pwm_pccr45 = *context->pwm_pccr45; + registers->pwm_per = *context->pwm_per; + registers->pwm_pcr4 = *context->pwm_pcr4; + registers->pwm_ppr4 = *context->pwm_ppr4; + registers->pwm_pcr5 = *(volatile uint32_t *)( + context->pwm_mapped + PWM_PCR_OFFSET(5u) + ); + registers->pwm_ppr5 = *(volatile uint32_t *)( + context->pwm_mapped + PWM_PPR_OFFSET(5u) + ); + return 0; +} + +static void force_all_owned_gpio_inputs(hub75_context *context) { + const uint32_t mux0_owned = mux_mask(0, 6, 0); + const uint32_t mux1_owned = mux_mask(9, 15, 8); + switch_oe_to_gpio_high(context); + write_owned(context, PI_OE); + *context->mux0 &= ~mux0_owned; + *context->mux1 &= ~mux1_owned; + atomic_thread_fence(memory_order_seq_cst); + context->gpio_configured = 0; +} + +int hub75_force_safeoff(int use_dev_mem, char *error, size_t error_size) { + hub75_context context; + memset(&context, 0, sizeof(context)); + context.mem_fd = -1; + if (pthread_mutex_init(&context.lock, NULL) != 0) { + copy_error(error, error_size, "initializing safeoff lock failed"); + return -1; + } + if (map_registers(&context, use_dev_mem, error, error_size) != 0) { + unmap_registers(&context); + pthread_mutex_destroy(&context.lock); + return -1; + } + configure_gpio(&context); + pwm4_disable(&context); + force_all_owned_gpio_inputs(&context); + int failed = (*context.pwm_per & PWM_CHANNEL_ENABLE) != 0; + if (failed) { + copy_error(error, error_size, "safeoff could not disable PWM4"); + } + unmap_registers(&context); + pthread_mutex_destroy(&context.lock); + return failed ? -1 : 0; +} diff --git a/核桃派软件源代码/app/display/native/h618_hub75.h b/核桃派软件源代码/app/display/native/h618_hub75.h new file mode 100644 index 0000000..cdac00b --- /dev/null +++ b/核桃派软件源代码/app/display/native/h618_hub75.h @@ -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 +#include + +#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 diff --git a/核桃派软件源代码/app/display/native/native_test.c b/核桃派软件源代码/app/display/native/native_test.c new file mode 100644 index 0000000..9274022 --- /dev/null +++ b/核桃派软件源代码/app/display/native/native_test.c @@ -0,0 +1,195 @@ +#define _POSIX_C_SOURCE 200809L +#include "h618_hub75.h" + +#include +#include +#include +#include +#include +#include + +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, ®isters) == 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, ®isters) == 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, ®isters) == 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, ®isters) == 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; +} diff --git a/核桃派软件源代码/app/display/native/register_snapshot.py b/核桃派软件源代码/app/display/native/register_snapshot.py new file mode 100644 index 0000000..1d3ab9d --- /dev/null +++ b/核桃派软件源代码/app/display/native/register_snapshot.py @@ -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(" 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() diff --git a/核桃派软件源代码/app/display/native/safeoff.c b/核桃派软件源代码/app/display/native/safeoff.c new file mode 100644 index 0000000..b67e6ed --- /dev/null +++ b/核桃派软件源代码/app/display/native/safeoff.c @@ -0,0 +1,22 @@ +#include "h618_hub75.h" + +#include +#include + +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; +} diff --git a/核桃派软件源代码/app/display/native_model.py b/核桃派软件源代码/app/display/native_model.py new file mode 100644 index 0000000..65c14d2 --- /dev/null +++ b/核桃派软件源代码/app/display/native_model.py @@ -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 diff --git a/核桃派软件源代码/app/display/ota_indicator.py b/核桃派软件源代码/app/display/ota_indicator.py new file mode 100644 index 0000000..d9c94a3 --- /dev/null +++ b/核桃派软件源代码/app/display/ota_indicator.py @@ -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 diff --git a/核桃派软件源代码/app/display/service.py b/核桃派软件源代码/app/display/service.py new file mode 100644 index 0000000..88e570c --- /dev/null +++ b/核桃派软件源代码/app/display/service.py @@ -0,0 +1,1281 @@ +from __future__ import annotations + +import logging +import os +import json +from bisect import bisect_right +import threading +import time +from uuid import uuid4 +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from PIL import Image + +from app.animations.constants import MAX_DURATION_MS, MIN_DURATION_MS + +from app.config.store import ConfigStore, MATRIX_REFRESH_RATE_LIMITS +from app.power.protection import ( + LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT, + LowVoltageProtectionDirective, +) + +from .colors import RGB, validate_rgb +from .diagnostics import render_diagnostic +from .low_voltage_indicator import render_low_voltage_indicator +from .matrix_driver import MatrixDisplayDriver, get_default_driver_options +from .mock_driver import MockDisplayDriver +from .ota_indicator import OTA_INDICATOR_BRIGHTNESS, render_ota_indicator +from .startup_indicator import ( + CLOSED_EYE_SECONDS, + OPEN_EYE_SECONDS, + STARTUP_BRIGHTNESS, + StartupSessionStore, + render_startup_smile, +) +from .text_renderer import FontResolver, TextOptions, render_text +from .transforms import apply_orientation, apply_row_bit_order, get_row_bit_order +from .wifi_indicator import ( + WIFI_INDICATOR_BRIGHTNESS, + WIFI_SCROLL_FPS, + WIFI_SCROLL_GAP_PIXELS, + WIFI_SCROLL_PIXELS_PER_SECOND, + render_wifi_indicator, + text_width, +) + +logger = logging.getLogger(__name__) + +_PROTECTION_MODES = {"disabled", "unavailable", "inactive", "limiting", "critical"} +DEFAULT_DISPLAY_TEST_BRIGHTNESS = 50 +UNSAVED_CONTENT = {"category": "unsaved", "id": None, "name": "未保存内容"} +ANIMATION_PLAYBACK_SPEEDS = (0.5, 1.0, 1.5, 2.0) + + +class DisplayTestInactiveError(RuntimeError): + """Raised when a temporary test-only control has no active session.""" + + +class AnimationPlaybackConflictError(RuntimeError): + """Raised when a playback command does not target the active session.""" + + +@dataclass(frozen=True) +class _SelectedOutput: + frame: Image.Image + brightness: int + mode: str + + +class DisplayService: + width = 64 + height = 64 + frame_bytes = width * height * 3 + + def __init__( + self, + config_store: ConfigStore, + driver_kind: str | None = None, + startup_boot_id: str | None = None, + font_resolver: FontResolver | None = None, + ) -> None: + self.config_store = config_store + self._font_resolver = font_resolver + self._lock = threading.RLock() + self._state_revision = 0 + self._orientation = int(self.config_store.config["orientation"]) + self._brightness = int(self.config_store.config["brightness"]) + self._current_frame = Image.new("RGB", (self.width, self.height), (0, 0, 0)) + self._current_content = dict(UNSAVED_CONTENT) + self._row_bit_order = get_row_bit_order() + self._startup_session = StartupSessionStore( + self.config_store.runtime_dir, + boot_id=startup_boot_id, + ) + self._startup_active = False + self._startup_frame = render_startup_smile() + self._startup_stop = threading.Event() + self._startup_thread: threading.Thread | None = None + + self._wifi_active = False + self._wifi_connected = False + self._wifi_message = "" + self._wifi_frame = Image.new("RGB", (self.width, self.height), (0, 0, 0)) + self._wifi_stop = threading.Event() + self._wifi_thread: threading.Thread | None = None + self._wifi_generation = 0 + self._activity_listener: Callable[[], None] | None = None + + self._animation_active = False + self._animation_id: str | None = None + self._animation_revision: str | None = None + self._animation_frames: tuple[dict, ...] = () + self._animation_index = 0 + self._animation_generation = 0 + self._animation_session_id: str | None = None + self._animation_boundaries_ms: tuple[int, ...] = () + self._animation_total_duration_ms = 0 + self._animation_position_anchor_ms = 0.0 + self._animation_anchor_monotonic = time.monotonic() + self._animation_paused = False + self._animation_speed = 1.0 + self._animation_condition = threading.Condition(self._lock) + self._animation_thread: threading.Thread | None = None + + self._display_test_active = False + self._display_test_color: RGB | None = None + self._display_test_frame = Image.new("RGB", (self.width, self.height), (0, 0, 0)) + self._display_test_brightness = DEFAULT_DISPLAY_TEST_BRIGHTNESS + + self._ota_active = False + self._ota_percent = 0 + self._ota_stage = "准备更新" + self._ota_frame = render_ota_indicator() + self._ota_status_path: Path | None = None + self._ota_stop = threading.Event() + self._ota_thread: threading.Thread | None = None + + self._low_voltage_frame = render_low_voltage_indicator() + self._protection_sequence = -1 + self._protection_enforced_sequence = -1 + self._protection_enabled = False + self._protection_mode = "disabled" + self._protection_brightness_limit: int | None = None + self._protection_reading_stale = False + + self._effective_brightness = self._brightness + self._visible_frame = self._current_frame.copy() + self._visible_orientation = self._orientation + self._output_dirty = False + self._driver_kind = ( + driver_kind or os.environ.get("MATRIX_DRIVER") or "auto" + ).lower() + self.driver = self._create_driver( + int(self.config_store.config["matrix_refresh_rate_limit_hz"]) + ) + + def set_activity_listener(self, listener: Callable[[], None] | None) -> None: + self._activity_listener = listener + + def _notify_user_activity(self) -> None: + listener = self._activity_listener + if listener is None: + return + try: + listener() + except Exception: + logger.exception("Failed to notify the user-activity listener") + + def start_startup_indicator(self) -> bool: + with self._lock: + if self._startup_active or not self._startup_session.should_show(): + return False + + frame = render_startup_smile() + target = self._select_output_locked( + startup_active=True, + startup_frame=frame, + ) + try: + self._submit_output_locked(target.frame, target.brightness) + except Exception: + logger.exception("Failed to start the startup indicator") + self._try_reconcile_output_locked( + "Failed to restore the display after startup indicator error" + ) + return False + + self._startup_frame = frame + self._startup_active = True + self._startup_stop.clear() + self._startup_thread = threading.Thread( + target=self._animate_startup_indicator, + name="startup-smile", + daemon=True, + ) + self._startup_thread.start() + logger.info( + "Started the startup smile at %s%% effective brightness", + target.brightness, + ) + return True + + def clear(self) -> None: + image = Image.new("RGB", (self.width, self.height), (0, 0, 0)) + self.show_image(image, mode="clear") + + def fill(self, color: RGB) -> None: + color = validate_rgb(color) + image = Image.new("RGB", (self.width, self.height), color) + self.show_image(image, mode="fill") + + def show_image( + self, + image: Image.Image, + mode: str = "image", + *, + content_source: dict | None = None, + notify_activity: bool = True, + ) -> None: + rgb_image = image.convert("RGB") + if rgb_image.size != (self.width, self.height): + raise ValueError("image size must be 64x64") + + with self._lock: + startup_was_active = self._startup_active + wifi_was_active = self._wifi_active + target = self._select_output_locked( + current_frame=rgb_image, + startup_active=False, + wifi_active=False, + animation_active=False, + display_test_active=False, + ) + try: + self._submit_output_locked(target.frame, target.brightness) + self.config_store.set_last_mode(mode) + except Exception: + self._try_reconcile_output_locked( + "Failed to restore the display after image update error" + ) + raise + + self._current_frame = rgb_image.copy() + self._current_content = self._checked_content_source(content_source) + self._display_test_active = False + self._display_test_color = None + self._stop_animation_locked() + if startup_was_active: + self._finish_startup_indicator_locked() + if wifi_was_active: + self._finish_wifi_indicator_locked() + self._state_revision += 1 + if notify_activity: + self._notify_user_activity() + + def start_animation( + self, + animation_id: str, + revision: str, + frames: list[dict], + *, + content_source: dict | None = None, + notify_activity: bool = True, + ) -> None: + if not frames: + raise ValueError("animation must contain at least one frame") + checked: list[dict] = [] + boundaries: list[int] = [] + total_duration_ms = 0 + for item in frames: + image = item.get("image") + path = item.get("path") + duration_ms = item.get("duration_ms") + if image is not None: + if not isinstance(image, Image.Image) or image.size != (self.width, self.height): + raise ValueError("animation frame must be a 64x64 image") + descriptor = {"image": image.convert("RGB").copy()} + elif path is not None: + checked_path = Path(path) + if not checked_path.is_file(): + raise ValueError("animation frame cache is missing") + descriptor = {"path": checked_path} + else: + raise ValueError("animation frame must provide image or path") + if type(duration_ms) is not int or not MIN_DURATION_MS <= duration_ms <= MAX_DURATION_MS: + raise ValueError( + f"animation duration_ms must be in {MIN_DURATION_MS}..{MAX_DURATION_MS}" + ) + checked.append({"id": str(item["id"]), **descriptor, "duration_ms": duration_ms}) + total_duration_ms += duration_ms + boundaries.append(total_duration_ms) + + with self._lock: + first = checked[0] + first_image = self._animation_frame_image(first) + startup_was_active = self._startup_active + wifi_was_active = self._wifi_active + target = self._select_output_locked( + startup_active=False, + wifi_active=False, + animation_active=True, + animation_frame=first_image, + display_test_active=False, + ) + try: + self._submit_output_locked(target.frame, target.brightness, synchronized=True) + except Exception: + self._try_reconcile_output_locked("Failed to restore display after animation start error") + raise + self._stop_animation_locked() + self._display_test_active = False + self._display_test_color = None + self._animation_active = True + self._animation_id = str(animation_id) + self._animation_revision = str(revision) + self._animation_frames = tuple(checked) + self._animation_session_id = str(uuid4()) + self._animation_boundaries_ms = tuple(boundaries) + self._animation_total_duration_ms = total_duration_ms + self._animation_position_anchor_ms = 0.0 + self._animation_anchor_monotonic = time.monotonic() + self._animation_paused = False + self._animation_speed = 1.0 + self._current_content = self._checked_content_source( + content_source or { + "category": "animation", + "id": str(animation_id), + "name": "动图", + } + ) + self._animation_index = 0 + self._animation_generation += 1 + generation = self._animation_generation + self._animation_condition.notify_all() + if startup_was_active: + self._finish_startup_indicator_locked() + if wifi_was_active: + self._finish_wifi_indicator_locked() + self._state_revision += 1 + self._animation_thread = threading.Thread( + target=self._animate_user_animation, + args=(generation,), + name="user-animation", + daemon=True, + ) + self._animation_thread.start() + if notify_activity: + self._notify_user_activity() + + def is_animation_active(self, animation_id: str | None = None) -> bool: + with self._lock: + return self._animation_active and (animation_id is None or self._animation_id == animation_id) + + def control_animation_playback( + self, + session_id: str, + *, + position_ms: int | None = None, + paused: bool | None = None, + speed: float | None = None, + ) -> dict: + if position_ms is None and paused is None and speed is None: + raise ValueError("at least one animation playback control is required") + if not isinstance(session_id, str) or not session_id: + raise ValueError("session_id must not be empty") + if position_ms is not None and (isinstance(position_ms, bool) or not isinstance(position_ms, int)): + raise ValueError("position_ms must be an integer") + if paused is not None and type(paused) is not bool: + raise ValueError("paused must be a boolean") + if speed is not None: + if isinstance(speed, bool) or not isinstance(speed, (int, float)): + raise ValueError("speed must be a supported number") + speed = float(speed) + if speed not in ANIMATION_PLAYBACK_SPEEDS: + raise ValueError("speed must be one of 0.5, 1, 1.5 or 2") + + with self._lock: + if not self._animation_active or session_id != self._animation_session_id: + raise AnimationPlaybackConflictError("animation playback session is no longer active") + if position_ms is not None and not 0 <= position_ms < self._animation_total_duration_ms: + raise ValueError( + f"position_ms must be in 0..{self._animation_total_duration_ms - 1}" + ) + + now = time.monotonic() + current_position = self._animation_position_locked(now) + next_position = float(position_ms) if position_ms is not None else current_position + next_paused = self._animation_paused if paused is None else paused + next_speed = self._animation_speed if speed is None else speed + next_index = self._animation_index_for_position_locked(next_position) + + if position_ms is not None or next_index != self._animation_index: + frame = self._animation_frame_image(self._animation_frames[next_index]) + target = self._select_output_locked(animation_frame=frame) + self._submit_output_locked(target.frame, target.brightness, synchronized=True) + + self._animation_position_anchor_ms = next_position + self._animation_anchor_monotonic = now + self._animation_paused = next_paused + self._animation_speed = next_speed + self._animation_index = next_index + self._state_revision += 1 + self._animation_condition.notify_all() + return self._animation_playback_status_locked(now) + + def show_rgb_bytes( + self, + data: bytes, + mode: str = "canvas", + *, + content_source: dict | None = None, + ) -> None: + if len(data) != self.frame_bytes: + raise ValueError("frame size must be 64x64 RGB888") + image = Image.frombytes("RGB", (self.width, self.height), data) + self.show_image(image, mode=mode, content_source=content_source) + + def show_text(self, options: TextOptions) -> None: + image = render_text( + options, + size=(self.width, self.height), + font_resolver=self._font_resolver, + ) + self.show_image(image, mode="text") + + def show_diagnostic(self, diagnostic_mode: str) -> None: + image = render_diagnostic(diagnostic_mode, size=(self.width, self.height)) + self.show_image(image, mode=f"diagnostic:{diagnostic_mode}") + + def start_display_test_fill(self, color: RGB) -> dict: + checked = validate_rgb(color) + frame = Image.new("RGB", (self.width, self.height), checked) + with self._lock: + brightness = ( + self._display_test_brightness + if self._display_test_active + else DEFAULT_DISPLAY_TEST_BRIGHTNESS + ) + target = self._select_output_locked( + display_test_active=True, + display_test_frame=frame, + display_test_brightness=brightness, + ) + self._submit_output_locked(target.frame, target.brightness) + self._display_test_active = True + self._display_test_color = checked + self._display_test_frame = frame + self._display_test_brightness = brightness + self._state_revision += 1 + result = self._display_test_status_locked() + self._notify_user_activity() + return result + + def set_display_test_brightness(self, value: int) -> dict: + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 100: + raise ValueError("brightness must be in 1..100") + with self._lock: + if not self._display_test_active: + raise DisplayTestInactiveError("display test is not active") + target = self._select_output_locked(display_test_brightness=value) + self._submit_output_locked(target.frame, target.brightness) + self._display_test_brightness = value + self._state_revision += 1 + return self._display_test_status_locked() + + def stop_display_test(self) -> dict: + with self._lock: + if not self._display_test_active: + return self._display_test_status_locked() + target = self._select_output_locked(display_test_active=False) + self._submit_output_locked( + target.frame, + target.brightness, + synchronized=self._animation_active, + ) + self._display_test_active = False + self._display_test_color = None + self._state_revision += 1 + return self._display_test_status_locked() + + def start_ota_indicator(self, status_path: Path) -> None: + path = Path(status_path) + percent, stage = self._read_ota_progress(path) + frame = render_ota_indicator(percent, stage) + with self._lock: + target = self._select_output_locked( + ota_active=True, + ota_frame=frame, + ) + self._submit_output_locked(target.frame, target.brightness) + self._ota_active = True + self._ota_percent = percent + self._ota_stage = stage + self._ota_frame = frame + self._ota_status_path = path + if self._ota_thread is None or not self._ota_thread.is_alive(): + self._ota_stop.clear() + self._ota_thread = threading.Thread( + target=self._animate_ota_indicator, + name="ota-indicator", + daemon=True, + ) + self._ota_thread.start() + self._state_revision += 1 + + def stop_ota_indicator(self) -> None: + thread = None + with self._lock: + if not self._ota_active: + return + target = self._select_output_locked(ota_active=False) + self._submit_output_locked( + target.frame, + target.brightness, + synchronized=self._animation_active, + ) + self._ota_active = False + self._ota_status_path = None + self._ota_stop.set() + thread = self._ota_thread + self._state_revision += 1 + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=2.0) + + @staticmethod + def _read_ota_progress(path: Path) -> tuple[int, str]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + job = document.get("job") if isinstance(document, dict) else None + percent = int(job.get("percent", 0)) if isinstance(job, dict) else 0 + stage = str(job.get("message") or job.get("phase") or "准备更新") if isinstance(job, dict) else "准备更新" + return max(0, min(100, percent)), stage[:80] + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + return 0, "准备更新" + + def _animate_ota_indicator(self) -> None: + pulse = 0 + while not self._ota_stop.wait(0.25): + with self._lock: + if not self._ota_active or self._ota_status_path is None: + return + status_path = self._ota_status_path + percent, stage = self._read_ota_progress(status_path) + pulse = (pulse + 1) % 4 + frame = render_ota_indicator(percent, f"{stage}:{pulse}") + with self._lock: + if not self._ota_active: + return + target = self._select_output_locked(ota_frame=frame) + try: + self._submit_output_locked(target.frame, target.brightness) + self._ota_percent = percent + self._ota_stage = stage + self._ota_frame = frame + except Exception: + logger.exception("Failed to submit an OTA indicator frame") + + def set_orientation(self, value: int) -> None: + if value not in {0, 90, 180, 270}: + raise ValueError("orientation must be one of 0, 90, 180, 270") + with self._lock: + startup_was_active = self._startup_active + wifi_was_active = self._wifi_active + if value == self._orientation and not startup_was_active and not wifi_was_active: + if self._output_dirty: + self._reconcile_output_locked() + return + + previous = self._orientation + target = self._select_output_locked(startup_active=False, wifi_active=False) + try: + self._submit_output_locked( + target.frame, + target.brightness, + orientation=value, + ) + if value != previous: + self.config_store.update({"orientation": value}) + except Exception: + self._try_reconcile_output_locked( + "Failed to restore the display after orientation update error" + ) + raise + + self._orientation = value + if startup_was_active: + self._finish_startup_indicator_locked() + if wifi_was_active: + self._finish_wifi_indicator_locked() + self._state_revision += 1 + self._notify_user_activity() + + def set_brightness(self, value: int) -> None: + if value < 1 or value > 100: + raise ValueError("brightness must be in 1..100") + with self._lock: + startup_was_active = self._startup_active + wifi_was_active = self._wifi_active + if value == self._brightness and not startup_was_active and not wifi_was_active: + if self._output_dirty: + self._reconcile_output_locked() + return + + previous = self._brightness + target = self._select_output_locked( + brightness=value, + startup_active=False, + wifi_active=False, + ) + try: + self._submit_output_locked(target.frame, target.brightness) + if value != previous: + self.config_store.update({"brightness": value}) + except Exception: + self._try_reconcile_output_locked( + "Failed to restore display after brightness update error" + ) + raise + + self._brightness = value + if startup_was_active: + self._finish_startup_indicator_locked() + if wifi_was_active: + self._finish_wifi_indicator_locked() + self._state_revision += 1 + self._notify_user_activity() + + def show_wifi_indicator(self, *, connected: bool, message: str) -> None: + if not isinstance(message, str) or not message: + raise ValueError("WiFi indicator message must not be empty") + with self._lock: + if ( + self._wifi_active + and self._wifi_connected == connected + and self._wifi_message == message + ): + return + frame = render_wifi_indicator(message, offset=0.0, connected=connected) + target = self._select_output_locked( + wifi_active=True, + wifi_frame=frame, + startup_active=False, + ) + self._submit_output_locked(target.frame, target.brightness) + if self._startup_active: + self._finish_startup_indicator_locked() + self._wifi_active = True + self._wifi_connected = connected + self._wifi_message = message + self._wifi_frame = frame + self._wifi_generation += 1 + generation = self._wifi_generation + self._wifi_stop.clear() + self._wifi_thread = threading.Thread( + target=self._animate_wifi_indicator, + args=(generation,), + name="wifi-indicator", + daemon=True, + ) + self._wifi_thread.start() + + def dismiss_wifi_indicator(self) -> bool: + with self._lock: + if not self._wifi_active: + return False + self._finish_wifi_indicator_locked() + self._reconcile_output_locked() + return True + + def set_refresh_rate_limit(self, value: int) -> None: + if type(value) is not int or value not in MATRIX_REFRESH_RATE_LIMITS: + allowed = ", ".join(str(item) for item in sorted(MATRIX_REFRESH_RATE_LIMITS)) + raise ValueError(f"refresh rate limit must be one of {allowed}") + + with self._lock: + previous = int( + self.config_store.config["matrix_refresh_rate_limit_hz"] + ) + if value == previous: + return + try: + self.driver.set_refresh_rate_limit(value) + self.config_store.update( + {"matrix_refresh_rate_limit_hz": value} + ) + except Exception as exc: + try: + self.driver.set_refresh_rate_limit(previous) + except Exception as rollback_exc: + logger.exception( + "Failed to restore native refresh rate after update error" + ) + raise RuntimeError( + "Refresh-rate update failed and native rollback also failed: " + f"{rollback_exc}" + ) from exc + raise RuntimeError( + f"Failed to apply refresh-rate limit {value}Hz; " + "the saved setting was not changed" + ) from exc + + self._state_revision += 1 + + def apply_power_protection(self, directive: LowVoltageProtectionDirective) -> None: + """Apply the newest voltage-protection output overlay. + + The directive becomes the desired state before driver I/O starts. If + enforcement partially fails, later user activity and an equal-sequence + monitor retry both continue toward that same safe target. + """ + sequence = directive.sequence + enabled = directive.enabled + mode = directive.mode + brightness_limit = directive.brightness_limit_percent + reading_stale = directive.reading_stale + + if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence < 0: + raise ValueError("protection sequence must be a non-negative integer") + if not isinstance(enabled, bool): + raise ValueError("protection enabled must be a boolean") + if mode not in _PROTECTION_MODES: + raise ValueError(f"unsupported protection mode: {mode}") + if brightness_limit is not None and ( + isinstance(brightness_limit, bool) + or not isinstance(brightness_limit, int) + or brightness_limit < 1 + or brightness_limit > 100 + ): + raise ValueError("protection brightness limit must be in 1..100 or null") + if not isinstance(reading_stale, bool): + raise ValueError("protection reading_stale must be a boolean") + + incoming = (enabled, mode, brightness_limit, reading_stale) + with self._lock: + current = ( + self._protection_enabled, + self._protection_mode, + self._protection_brightness_limit, + self._protection_reading_stale, + ) + if sequence < self._protection_sequence: + return + if sequence == self._protection_sequence and incoming != current: + logger.warning( + "Ignoring conflicting low-voltage directive sequence %s", + sequence, + ) + return + if ( + sequence == self._protection_enforced_sequence + and not self._output_dirty + ): + return + + if sequence > self._protection_sequence: + self._protection_sequence = sequence + self._protection_enabled = enabled + self._protection_mode = mode + self._protection_brightness_limit = brightness_limit + self._protection_reading_stale = reading_stale + + self._reconcile_output_locked() + self._protection_enforced_sequence = sequence + + def get_current_frame(self) -> Image.Image: + with self._lock: + return self._visible_frame.copy() + + def get_state_revision(self) -> int: + with self._lock: + return self._state_revision + + def get_status(self) -> dict: + with self._lock: + config = self.config_store.config + driver_status = self.driver.get_status() + selected = self._select_output_locked() + playback_status = self._animation_playback_status_locked(time.monotonic()) + return { + "ok": True, + "screen": { + "width": self.width, + "height": self.height, + "driver": driver_status["driver"], + "driver_available": driver_status["driver_available"], + "driver_error": driver_status.get("driver_error"), + "hardware_mapping": driver_status["hardware_mapping"], + "driver_options": driver_status.get("driver_options", {}), + "driver_status": driver_status.get("driver_status", {}), + }, + "state": { + "mode": selected.mode, + "orientation": config["orientation"], + "brightness": config["brightness"], + "effective_brightness": self._effective_brightness, + "startup_indicator_active": self._startup_active, + "wifi_indicator_active": self._wifi_active, + "wifi_indicator_connected": self._wifi_connected if self._wifi_active else None, + "animation_active": self._animation_active, + "animation_id": self._animation_id if self._animation_active else None, + "animation_revision": self._animation_revision if self._animation_active else None, + "animation_frame_id": self._animation_frames[self._animation_index]["id"] if self._animation_active else None, + "animation_frame_index": self._animation_index + 1 if self._animation_active else None, + "animation_playback": playback_status, + "current_content": self._current_content_status_locked(selected), + "display_test": self._display_test_status_locked(), + "ota_update": { + "active": self._ota_active, + "percent": self._ota_percent if self._ota_active else None, + "stage": self._ota_stage if self._ota_active else None, + }, + "revision": self._state_revision, + }, + } + + def close(self) -> None: + thread = None + wifi_thread = None + animation_thread = None + ota_thread = None + with self._lock: + self._startup_active = False + self._startup_stop.set() + thread = self._startup_thread + self._wifi_active = False + self._wifi_stop.set() + self._wifi_generation += 1 + wifi_thread = self._wifi_thread + self._stop_animation_locked() + self._display_test_active = False + self._display_test_color = None + self._ota_active = False + self._ota_stop.set() + ota_thread = self._ota_thread + animation_thread = self._animation_thread + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=2.0) + if wifi_thread is not None and wifi_thread is not threading.current_thread(): + wifi_thread.join(timeout=2.0) + if animation_thread is not None and animation_thread is not threading.current_thread(): + animation_thread.join(timeout=2.0) + if ota_thread is not None and ota_thread is not threading.current_thread(): + ota_thread.join(timeout=2.0) + with self._lock: + self.driver.close() + + def _transform(self, image: Image.Image, orientation: int | None = None) -> Image.Image: + oriented = apply_orientation( + image, + self._orientation if orientation is None else orientation, + ) + return apply_row_bit_order(oriented, self._row_bit_order) + + def _animate_startup_indicator(self) -> None: + eyes_closed = False + delay = OPEN_EYE_SECONDS + while not self._startup_stop.wait(delay): + with self._lock: + if not self._startup_active: + return + eyes_closed = not eyes_closed + frame = render_startup_smile(eyes_closed=eyes_closed) + if self._display_test_active: + self._startup_frame = frame + delay = CLOSED_EYE_SECONDS if eyes_closed else OPEN_EYE_SECONDS + continue + target = self._select_output_locked(startup_frame=frame) + try: + self._submit_output_locked(target.frame, target.brightness) + self._startup_frame = frame + except Exception: + logger.exception("Failed to submit a startup smile frame") + delay = CLOSED_EYE_SECONDS if eyes_closed else OPEN_EYE_SECONDS + + def _animation_frame_image(self, frame: dict) -> Image.Image: + image = frame.get("image") + if isinstance(image, Image.Image): + return image + path = frame.get("path") + try: + with Image.open(path) as cached: + image = cached.convert("RGB").copy() + except (OSError, ValueError, TypeError) as exc: + raise RuntimeError("animation frame cache is unreadable") from exc + if image.size != (self.width, self.height): + raise RuntimeError("animation frame cache must be 64x64") + return image + + def _animation_position_locked(self, now: float | None = None) -> float: + if not self._animation_active or self._animation_total_duration_ms <= 0: + return 0.0 + position = self._animation_position_anchor_ms + if not self._animation_paused: + sampled_at = time.monotonic() if now is None else now + position += max(0.0, sampled_at - self._animation_anchor_monotonic) * 1000 * self._animation_speed + return position % self._animation_total_duration_ms + + def _animation_index_for_position_locked(self, position_ms: float) -> int: + if not self._animation_boundaries_ms: + return 0 + normalized = position_ms % self._animation_total_duration_ms + return min( + len(self._animation_boundaries_ms) - 1, + bisect_right(self._animation_boundaries_ms, normalized), + ) + + def _animation_playback_status_locked(self, now: float | None = None) -> dict: + if not self._animation_active: + return { + "active": False, + "session_id": None, + "animation_id": None, + "animation_revision": None, + "frame_id": None, + "frame_index": None, + "frame_count": None, + "position_ms": None, + "total_duration_ms": None, + "paused": None, + "speed": None, + "supported_speeds": list(ANIMATION_PLAYBACK_SPEEDS), + } + position = self._animation_position_locked(now) + return { + "active": True, + "session_id": self._animation_session_id, + "animation_id": self._animation_id, + "animation_revision": self._animation_revision, + "frame_id": self._animation_frames[self._animation_index]["id"], + "frame_index": self._animation_index + 1, + "frame_count": len(self._animation_frames), + "position_ms": min( + self._animation_total_duration_ms - 1, + max(0, int(position)), + ), + "total_duration_ms": self._animation_total_duration_ms, + "paused": self._animation_paused, + "speed": self._animation_speed, + "supported_speeds": list(ANIMATION_PLAYBACK_SPEEDS), + } + + def _animate_user_animation(self, generation: int) -> None: + while True: + with self._animation_condition: + if not self._animation_active or generation != self._animation_generation: + return + if self._animation_paused: + self._animation_condition.wait() + continue + + now = time.monotonic() + position = self._animation_position_locked(now) + next_index = self._animation_index_for_position_locked(position) + if next_index != self._animation_index: + try: + frame = self._animation_frame_image(self._animation_frames[next_index]) + if not self._display_test_active: + target = self._select_output_locked(animation_frame=frame) + self._submit_output_locked(target.frame, target.brightness, synchronized=True) + self._animation_index = next_index + except Exception: + logger.exception("Failed to submit an animation frame") + + frame_end_ms = self._animation_boundaries_ms[next_index] + remaining_ms = frame_end_ms - position + if remaining_ms <= 0: + remaining_ms = 1 + self._animation_condition.wait( + timeout=max(0.001, remaining_ms / (1000 * self._animation_speed)) + ) + + def _animate_wifi_indicator(self, generation: int) -> None: + offset = 0.0 + revision_message = None + frame_seconds = 1.0 / WIFI_SCROLL_FPS + step = WIFI_SCROLL_PIXELS_PER_SECOND / WIFI_SCROLL_FPS + while not self._wifi_stop.wait(frame_seconds): + with self._lock: + if not self._wifi_active or generation != self._wifi_generation: + return + if revision_message != self._wifi_message: + revision_message = self._wifi_message + offset = 0.0 + offset += step + width = text_width(self._wifi_message) + if offset > width + self.width + WIFI_SCROLL_GAP_PIXELS: + offset = 0.0 + frame = render_wifi_indicator( + self._wifi_message, + offset=offset, + connected=self._wifi_connected, + ) + if self._display_test_active: + self._wifi_frame = frame + continue + target = self._select_output_locked(wifi_frame=frame) + try: + self._submit_output_locked(target.frame, target.brightness) + self._wifi_frame = frame + except Exception: + logger.exception("Failed to submit a WiFi indicator frame") + + def _stop_animation_locked(self) -> None: + if not self._animation_active: + return + self._animation_active = False + self._animation_generation += 1 + self._animation_session_id = None + self._animation_id = None + self._animation_revision = None + self._animation_frames = () + self._animation_boundaries_ms = () + self._animation_total_duration_ms = 0 + self._animation_position_anchor_ms = 0.0 + self._animation_anchor_monotonic = time.monotonic() + self._animation_paused = False + self._animation_speed = 1.0 + self._animation_index = 0 + self._animation_condition.notify_all() + + def _finish_startup_indicator_locked(self) -> None: + self._startup_active = False + self._startup_stop.set() + try: + self._startup_session.mark_dismissed() + except Exception: + logger.exception("Failed to persist startup indicator dismissal") + logger.info("Dismissed the startup smile until the next system boot") + + def _finish_wifi_indicator_locked(self) -> None: + self._wifi_active = False + self._wifi_stop.set() + self._wifi_generation += 1 + self._wifi_message = "" + + def _select_output_locked( + self, + *, + current_frame: Image.Image | None = None, + startup_active: bool | None = None, + startup_frame: Image.Image | None = None, + wifi_active: bool | None = None, + wifi_frame: Image.Image | None = None, + animation_active: bool | None = None, + animation_frame: Image.Image | None = None, + brightness: int | None = None, + display_test_active: bool | None = None, + display_test_frame: Image.Image | None = None, + display_test_brightness: int | None = None, + ota_active: bool | None = None, + ota_frame: Image.Image | None = None, + ) -> _SelectedOutput: + user_frame = self._current_frame if current_frame is None else current_frame + user_brightness = self._brightness if brightness is None else brightness + show_startup = self._startup_active if startup_active is None else startup_active + smile = self._startup_frame if startup_frame is None else startup_frame + show_wifi = self._wifi_active if wifi_active is None else wifi_active + wifi = self._wifi_frame if wifi_frame is None else wifi_frame + show_animation = self._animation_active if animation_active is None else animation_active + if animation_frame is None and self._animation_frames: + animation_frame = self._animation_frame_image(self._animation_frames[self._animation_index]) + show_display_test = ( + self._display_test_active + if display_test_active is None + else display_test_active + ) + test_frame = self._display_test_frame if display_test_frame is None else display_test_frame + test_brightness = ( + self._display_test_brightness + if display_test_brightness is None + else display_test_brightness + ) + show_ota = self._ota_active if ota_active is None else ota_active + update_frame = self._ota_frame if ota_frame is None else ota_frame + + if self._protection_enabled and self._protection_mode == "critical": + return _SelectedOutput( + frame=self._low_voltage_frame, + brightness=LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT, + mode="low_voltage_indicator", + ) + + cap = 100 + if ( + self._protection_enabled + and self._protection_mode == "limiting" + and self._protection_brightness_limit is not None + ): + cap = self._protection_brightness_limit + + if show_display_test: + return _SelectedOutput( + frame=test_frame, + brightness=min(test_brightness, cap), + mode="display_test:fill", + ) + + if show_ota: + return _SelectedOutput( + frame=update_frame, + brightness=min(OTA_INDICATOR_BRIGHTNESS, cap), + mode="ota_update_indicator", + ) + + if show_wifi: + return _SelectedOutput( + frame=wifi, + brightness=min(WIFI_INDICATOR_BRIGHTNESS, cap), + mode="wifi_connected_indicator" if self._wifi_connected else "wifi_disconnected_indicator", + ) + + if show_startup: + return _SelectedOutput( + frame=smile, + brightness=min(STARTUP_BRIGHTNESS, cap), + mode="startup_indicator", + ) + if show_animation and animation_frame is not None: + return _SelectedOutput( + frame=animation_frame, + brightness=min(user_brightness, cap), + mode="animation", + ) + return _SelectedOutput( + frame=user_frame, + brightness=min(user_brightness, cap), + mode=self.config_store.config["last_mode"], + ) + + def _display_test_status_locked(self) -> dict: + color = None + if self._display_test_color is not None: + color = "#{:02X}{:02X}{:02X}".format(*self._display_test_color) + return { + "active": self._display_test_active, + "color": color, + "brightness": self._display_test_brightness if self._display_test_active else None, + } + + @staticmethod + def _checked_content_source(value: dict | None) -> dict: + if value is None: + return dict(UNSAVED_CONTENT) + if not isinstance(value, dict) or set(value) != {"category", "id", "name"}: + raise ValueError("content source must contain category, id and name") + category = value.get("category") + content_id = value.get("id") + name = value.get("name") + if category not in {"template", "animation"}: + raise ValueError("content source category must be template or animation") + if not isinstance(content_id, str) or not content_id: + raise ValueError("content source id must not be empty") + if not isinstance(name, str) or not name.strip(): + raise ValueError("content source name must not be empty") + return {"category": category, "id": content_id, "name": name.strip()} + + def _current_content_status_locked(self, selected: _SelectedOutput) -> dict: + system_names = { + "low_voltage_indicator": "低电压提示", + "wifi_connected_indicator": "WiFi 提示", + "wifi_disconnected_indicator": "WiFi 提示", + "startup_indicator": "开机提示", + "display_test:fill": "纯色测试", + "ota_update_indicator": "软件更新", + } + system_name = system_names.get(selected.mode) + if system_name is not None: + return {"category": "system", "id": None, "name": system_name} + return dict(self._current_content) + + def _reconcile_output_locked(self) -> None: + target = self._select_output_locked() + self._submit_output_locked(target.frame, target.brightness) + + def _try_reconcile_output_locked(self, message: str) -> None: + try: + self._reconcile_output_locked() + except Exception: + logger.exception(message) + + def _submit_output_locked( + self, + frame: Image.Image, + brightness: int, + *, + orientation: int | None = None, + synchronized: bool = False, + ) -> None: + """Submit one selected output while avoiding unsafe bright transients.""" + if brightness < 1 or brightness > 100: + raise ValueError("effective brightness must be in 1..100") + target_orientation = self._orientation if orientation is None else orientation + frame_changed = ( + target_orientation != self._visible_orientation + or frame.tobytes() != self._visible_frame.tobytes() + ) + if ( + not self._output_dirty + and not frame_changed + and brightness == self._effective_brightness + ): + return + + transformed = self._transform(frame, orientation=target_orientation) + + def submit_frame() -> None: + try: + if synchronized: + self.driver.set_image(transformed, synchronized=True) + else: + self.driver.set_image(transformed) + except Exception: + self._output_dirty = True + raise + self._visible_frame = frame.copy() + self._visible_orientation = target_orientation + self._output_dirty = False + + if brightness < self._effective_brightness: + try: + self.driver.set_brightness(brightness) + except Exception: + self._output_dirty = True + raise + self._effective_brightness = brightness + submit_frame() + return + + if brightness > self._effective_brightness: + # Put the target frame on the panel while it is still dim, then + # raise brightness and submit it once more at the final setting. + submit_frame() + try: + self.driver.set_brightness(brightness) + except Exception: + self._output_dirty = True + raise + self._effective_brightness = brightness + try: + submit_frame() + except Exception: + # The correct target frame was already accepted at the safer + # brightness. Keep that successful work and force a retry. + self._output_dirty = True + raise + return + + submit_frame() + + def _create_driver( + self, + limit_refresh_rate_hz: int, + *, + allow_fallback: bool = True, + ): + if self._driver_kind == "mock": + return MockDisplayDriver( + self.config_store.runtime_dir, + brightness=self._brightness, + limit_refresh_rate_hz=limit_refresh_rate_hz, + ) + + try: + return MatrixDisplayDriver( + brightness=self._brightness, + limit_refresh_rate_hz=limit_refresh_rate_hz, + ) + except Exception as exc: + if not allow_fallback: + raise + logger.exception("Falling back to mock display driver") + return MockDisplayDriver( + self.config_store.runtime_dir, + brightness=self._brightness, + reason=str(exc), + driver_options=get_default_driver_options( + self._brightness, + limit_refresh_rate_hz, + ), + ) diff --git a/核桃派软件源代码/app/display/startup_indicator.py b/核桃派软件源代码/app/display/startup_indicator.py new file mode 100644 index 0000000..348fd3f --- /dev/null +++ b/核桃派软件源代码/app/display/startup_indicator.py @@ -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"), + ) diff --git a/核桃派软件源代码/app/display/text_renderer.py b/核桃派软件源代码/app/display/text_renderer.py new file mode 100644 index 0000000..77a09f8 --- /dev/null +++ b/核桃派软件源代码/app/display/text_renderer.py @@ -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() diff --git a/核桃派软件源代码/app/display/transforms.py b/核桃派软件源代码/app/display/transforms.py new file mode 100644 index 0000000..61b7c15 --- /dev/null +++ b/核桃派软件源代码/app/display/transforms.py @@ -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 diff --git a/核桃派软件源代码/app/display/wifi_indicator.py b/核桃派软件源代码/app/display/wifi_indicator.py new file mode 100644 index 0000000..cc0004e --- /dev/null +++ b/核桃派软件源代码/app/display/wifi_indicator.py @@ -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 diff --git a/核桃派软件源代码/app/fonts/__init__.py b/核桃派软件源代码/app/fonts/__init__.py new file mode 100644 index 0000000..ecfebc5 --- /dev/null +++ b/核桃派软件源代码/app/fonts/__init__.py @@ -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", +] diff --git a/核桃派软件源代码/app/fonts/store.py b/核桃派软件源代码/app/fonts/store.py new file mode 100644 index 0000000..b9b7dd6 --- /dev/null +++ b/核桃派软件源代码/app/fonts/store.py @@ -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 diff --git a/核桃派软件源代码/app/frp/__init__.py b/核桃派软件源代码/app/frp/__init__.py new file mode 100644 index 0000000..6d3c390 --- /dev/null +++ b/核桃派软件源代码/app/frp/__init__.py @@ -0,0 +1,19 @@ +from .manager import ( + FrpConflictError, + FrpController, + FrpError, + FrpServiceError, + FrpValidationError, + MockFrpBackend, + SystemdFrpBackend, +) + +__all__ = [ + "FrpConflictError", + "FrpController", + "FrpError", + "FrpServiceError", + "FrpValidationError", + "MockFrpBackend", + "SystemdFrpBackend", +] diff --git a/核桃派软件源代码/app/frp/manager.py b/核桃派软件源代码/app/frp/manager.py new file mode 100644 index 0000000..0a4f53f --- /dev/null +++ b/核桃派软件源代码/app/frp/manager.py @@ -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() diff --git a/核桃派软件源代码/app/library_order.py b/核桃派软件源代码/app/library_order.py new file mode 100644 index 0000000..1512de0 --- /dev/null +++ b/核桃派软件源代码/app/library_order.py @@ -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)} diff --git a/核桃派软件源代码/app/main.py b/核桃派软件源代码/app/main.py new file mode 100644 index 0000000..576186b --- /dev/null +++ b/核桃派软件源代码/app/main.py @@ -0,0 +1,2067 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import os +from io import BytesIO +from pathlib import Path +from typing import Any, Callable, Literal +from uuid import UUID, uuid4 + +from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator + +from app.config.store import ( + COLOR_PALETTE_LIMIT, + DEFAULT_DISPLAY, + ConfigError, + ConfigStore, + normalize_default_display, + validate_config, +) +from app.animations.store import AnimationStore, DEFAULT_DURATION_MS +from app.display.colors import parse_color +from app.display.diagnostics import render_diagnostic +from app.display.service import ( + ANIMATION_PLAYBACK_SPEEDS, + AnimationPlaybackConflictError, + DisplayService, + DisplayTestInactiveError, +) +from app.display.text_renderer import TextOptions, render_text, render_text_layer +from app.fonts.store import ( + MAX_FONT_UPLOAD_BYTES, + FontCatalog, + FontCatalogError, + FontStorageFullError, + FontTooLargeError, + FontTypeError, + FontValidationError, +) +from app.frp import ( + FrpConflictError, + FrpController, + FrpError, + FrpValidationError, + MockFrpBackend, + SystemdFrpBackend, +) +from app.frp.manager import MAX_FRP_CONFIG_BYTES +from app.demo_library import ( + DEMO_ANIMATION_ID, + DEMO_RESTRICTED_MESSAGE, + DEMO_STATIC_ID, + demo_animation, + demo_animation_frame, + demo_template, + demo_thumbnail, +) +from app.monitoring.resources import ResourceMonitor +from app.monitoring.storage import DeviceStorageMonitor +from app.library_order import ( + LibraryOrderConflictError, + LibraryOrderError, + LibraryOrderStorageFullError, + LibraryOrderStore, + LibraryOrderValidationError, +) +from app.media import MediaImportError, MediaImportManager +from app.network.manager import MockNetworkManager, NetworkBackend, NetworkManagerError, NmcliNetworkManager +from app.network.diagnostics import MockNetworkDiagnostics, NetworkDiagnostics +from app.network.service import WifiNetworkService +from app.network.store import WifiConfigError, WifiConfigStore +from app.ota.manager import OtaManager, OtaUploadError +from app.ota.versioning import read_feature_updated_at, read_software_version +from app.system.performance import PerformanceModeError, PerformanceModeManager +from app.power.voltage import ( + CalibrationConflictError, + CalibrationError, + CalibrationUnavailableError, + VoltageMonitor, +) +from app.templates.store import ( + TemplateConflictError, + TemplateError, + TemplateNotFoundError, + TemplateStorageFullError, + TemplateStore, + TemplateValidationError, + render_scene_image, +) +from app.ui_copy_editor.settings import UI_COPY_EDITOR_BUILD_ENABLED +from app.ui_copy_editor.store import ( + UiCopyDraftConflictError, + UiCopyDraftStore, + UiCopyDraftValidationError, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +def static_content_version(static_dir: Path) -> str: + digest = hashlib.sha256() + for path in sorted(item for item in static_dir.rglob("*") if item.is_file()): + digest.update(path.relative_to(static_dir).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest()[:16] + + +def normalize_if_match(value: str | None) -> str: + if value is None: + raise HTTPException(status_code=428, detail="If-Match is required") + candidate = value.strip() + if candidate.startswith('W/'): + candidate = candidate[2:].strip() + if len(candidate) >= 2 and candidate[0] == candidate[-1] == '"': + candidate = candidate[1:-1] + if not candidate or "," in candidate or candidate == "*": + raise HTTPException(status_code=428, detail="If-Match must contain one record revision") + return candidate + + +class ConfigUpdate(BaseModel): + orientation: int | None = None + brightness: int | None = None + low_voltage_protection_enabled: StrictBool | None = None + default_font: str | None = None + default_text_size: int | None = None + preview_refresh_interval_ms: StrictInt | None = None + matrix_refresh_rate_limit_hz: StrictInt | None = None + custom_test_color: str | None = None + workspace_order: list[StrictStr] | None = None + performance_mode_enabled: StrictBool | None = None + animation_preview_max_concurrent: StrictInt | None = None + + @field_validator("low_voltage_protection_enabled", mode="before") + @classmethod + def reject_null_low_voltage_protection_enabled(cls, value: Any) -> Any: + if value is None: + raise ValueError("low_voltage_protection_enabled must be a boolean") + return value + + @field_validator("performance_mode_enabled", mode="before") + @classmethod + def reject_null_performance_mode_enabled(cls, value: Any) -> Any: + if value is None: + raise ValueError("performance_mode_enabled must be a boolean") + return value + + @field_validator("animation_preview_max_concurrent", mode="before") + @classmethod + def reject_null_animation_preview_max_concurrent(cls, value: Any) -> Any: + if value is None: + raise ValueError("animation_preview_max_concurrent must be an integer") + return value + + @field_validator("custom_test_color", mode="before") + @classmethod + def reject_null_custom_test_color(cls, value: Any) -> Any: + if value is None: + raise ValueError("custom_test_color must be #RRGGBB") + return value + + @field_validator("workspace_order", mode="before") + @classmethod + def reject_null_workspace_order(cls, value: Any) -> Any: + if value is None: + raise ValueError("workspace_order must be an array") + return value + + +class WifiUpdate(BaseModel): + ssid: str + password: str | None = None + ipv4_mode: Literal["dhcp", "manual"] = "dhcp" + address: str | None = None + gateway: str | None = None + prefix: StrictInt | None = None + dns_servers: list[str] = Field(default_factory=list) + prompt_delay_seconds: StrictInt | None = None + activation: Literal["immediate", "next_boot"] + + +class WifiPromptDelayUpdate(BaseModel): + prompt_delay_seconds: StrictInt + + +class DefaultContentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["template", "animation"] + id: str + + +class FillRequest(BaseModel): + color: Any + + +class DisplayTestBrightnessRequest(BaseModel): + brightness: StrictInt + + +class AnimationPlaybackUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: StrictStr + position_ms: StrictInt | None = None + paused: StrictBool | None = None + speed: float | None = None + + @field_validator("position_ms", "paused", "speed", mode="before") + @classmethod + def reject_null_playback_controls(cls, value: Any) -> Any: + if value is None: + raise ValueError("animation playback controls must not be null") + return value + + @field_validator("speed", mode="before") + @classmethod + def validate_playback_speed(cls, value: Any) -> float: + if isinstance(value, bool) or type(value) not in {int, float}: + raise ValueError("speed must be a supported number") + checked = float(value) + if checked not in ANIMATION_PLAYBACK_SPEEDS: + raise ValueError("speed must be one of 0.5, 1, 1.5 or 2") + return checked + + @model_validator(mode="after") + def require_playback_control(self) -> "AnimationPlaybackUpdateRequest": + if not self.model_fields_set.intersection({"position_ms", "paused", "speed"}): + raise ValueError("at least one animation playback control is required") + return self + + +class PaletteColorRequest(BaseModel): + color: str + + +class TemplateCreateRequest(BaseModel): + name: str + scene: dict[str, Any] + + +class TemplateSceneRequest(BaseModel): + scene: dict[str, Any] + + +class TemplateNameRequest(BaseModel): + name: str + + +class AnimationCreateRequest(BaseModel): + name: str + + +class AnimationFrameCreateRequest(BaseModel): + scene: dict[str, Any] + name: str | None = None + duration_ms: StrictInt = DEFAULT_DURATION_MS + + +class AnimationFramePutRequest(BaseModel): + scene: dict[str, Any] + + +class AnimationFramePatchRequest(BaseModel): + name: str | None = None + duration_ms: StrictInt | None = None + + +class AnimationOrderRequest(BaseModel): + frame_ids: list[str] + + +class AnimationMoveRequest(BaseModel): + frame_ids: list[str] + before_frame_id: str | None = None + + +class AnimationDurationsRequest(BaseModel): + duration_ms: StrictInt + frame_ids: list[str] | None = None + + +class AnimationFramesCopyRequest(BaseModel): + frame_ids: list[str] + destination_animation_id: str + destination_revision: str + insert_after_frame_id: str | None = None + + +class AnimationFramesDeleteRequest(BaseModel): + frame_ids: list[str] + + +class LibraryCopyRequest(BaseModel): + source_type: Literal["template", "animation_frame"] + source_id: str + source_animation_id: str | None = None + source_revision: str | None = None + destination_type: Literal["static", "animation"] + destination_animation_id: str | None = None + destination_revision: str | None = None + + +class LibraryOrderItemRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["template", "animation"] + id: str + + +class LibraryOrderRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + items: list[LibraryOrderItemRequest] + + +class MediaSettingsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = None + fit_mode: Literal["crop", "contain", "stretch"] | None = None + center_x: float | None = None + center_y: float | None = None + zoom: float | None = None + transparency_color: str | None = None + padding_color: str | None = None + + +class TextRequest(BaseModel): + text: str = Field(min_length=0, max_length=512) + font: str = "default" + size: int = 12 + x: int = 0 + y: int = 0 + align: str = "left" + color: Any = "#FFFFFF" + background: Any = "#000000" + + +class TextLayerRequest(BaseModel): + text: str = Field(min_length=0, max_length=512) + font: str = "default" + size: int = 12 + x: int = 0 + y: int = 0 + align: str = "left" + color: Any = "#FFFFFF" + + +class DiagnosticRequest(BaseModel): + mode: Literal[ + "corners_lines", + "row_bands", + "address_check", + "address_bit_0", + "address_bit_1", + "address_bit_2", + "address_bit_3", + "address_bit_4", + "text_ok123", + "clear", + ] + + +class VoltageCalibrationPreviewRequest(BaseModel): + reference_volts: float + + +class VoltageCalibrationConfirmRequest(BaseModel): + proposal_id: str = Field(min_length=1, max_length=128) + + +def text_options_from_request(request: TextRequest) -> TextOptions: + return TextOptions( + text=request.text, + font=request.font, + size=request.size, + x=request.x, + y=request.y, + align=request.align, + color=parse_color(request.color), + background=parse_color(request.background), + ) + + +def text_layer_options_from_request(request: TextLayerRequest) -> TextOptions: + return TextOptions( + text=request.text, + font=request.font, + size=request.size, + x=request.x, + y=request.y, + align=request.align, + color=parse_color(request.color), + ) + + +def image_png_response(image) -> Response: + output = BytesIO() + image.save(output, format="PNG") + return Response(content=output.getvalue(), media_type="image/png") + + +def create_app( + project_root: Path | None = None, + driver_kind: str | None = None, + startup_boot_id: str | None = None, + voltage_monitor_factory: Callable[[ConfigStore], VoltageMonitor] | None = None, + resource_monitor_factory: Callable[[], ResourceMonitor] | None = None, + storage_monitor_factory: Callable[[Path, Path], DeviceStorageMonitor] | None = None, + performance_manager_factory: Callable[[Path], PerformanceModeManager] | None = None, + network_backend: NetworkBackend | None = None, + frp_backend: Any | None = None, + network_diagnostics: Any | None = None, + ui_copy_editor_build_enabled: bool = False, + maintenance_black: bool | None = None, + ota_worker_starter: Callable[[], None] | None = None, + ota_staging_root: Path | None = None, +) -> FastAPI: + if maintenance_black is None: + maintenance_black = os.environ.get("MATRIX_MAINTENANCE_BLACK") == "1" + code_root = Path(__file__).resolve().parents[1] + software_version = read_software_version(code_root) + feature_updated_at = read_feature_updated_at(code_root) + static_dir = code_root / "app" / "static" + editor_static_dir = code_root / "app" / "ui_copy_editor" / "static" + base_static_version = static_content_version(static_dir) + editor_asset_version = None + if ui_copy_editor_build_enabled: + editor_asset_version = static_content_version(editor_static_dir) + app_version = hashlib.sha256( + f"{base_static_version}\0ui-copy-editor\0{editor_asset_version}".encode("ascii") + ).hexdigest()[:16] + else: + app_version = base_static_version + static_version_prefix = f"/static/{app_version}" + index_html = (static_dir / "index.html").read_text(encoding="utf-8") + index_html = index_html.replace( + "", + f'<meta name="app-version" content="{app_version}">\n <title>', + 1, + ) + index_html = index_html.replace('/static/style.css', f'{static_version_prefix}/style.css') + index_html = index_html.replace('/static/app.js', f'{static_version_prefix}/app.js') + store = ConfigStore(project_root=project_root) + configured_driver = (driver_kind or os.environ.get("MATRIX_DRIVER") or "auto").lower() + performance_mode = ( + performance_manager_factory(store.runtime_dir) + if performance_manager_factory + else PerformanceModeManager( + store.runtime_dir, + # Mock/fake-register runs must never inspect or change the host's + # real cpufreq policies. Tests that exercise governors inject an + # explicit fake sysfs through performance_manager_factory. + sysfs_root=(store.runtime_dir / "mock-cpufreq") if configured_driver == "mock" else None, + ) + ) + ui_copy_store = ( + UiCopyDraftStore(store.data_dir, static_dir / "ui-copy.json") + if ui_copy_editor_build_enabled + else None + ) + editor_asset_prefix = ( + f"/__ui-copy-editor/{editor_asset_version}" + if editor_asset_version is not None + else None + ) + if ui_copy_store is not None and editor_asset_prefix is not None: + bootstrap = json.dumps( + { + "api_url": "/api/dev/ui-copy", + "export_url": "/api/dev/ui-copy/export", + "catalog_url": f"{static_version_prefix}/ui-copy.json", + "runtime_url": f"{static_version_prefix}/ui-copy-runtime.js", + "app_version": app_version, + "initial_draft": ui_copy_store.status_document(), + }, + ensure_ascii=False, + separators=(",", ":"), + ).replace("<", "\\u003c") + index_html = index_html.replace( + "</head>", + f' <link rel="stylesheet" href="{editor_asset_prefix}/editor.css">\n </head>', + 1, + ) + index_html = index_html.replace( + f'<script type="module" src="{static_version_prefix}/app.js"></script>', + " <script>globalThis.__UI_COPY_EDITOR_BUILD__ = " + f"Object.freeze({bootstrap});</script>\n" + f' <script type="module" src="{static_version_prefix}/app.js"></script>', + 1, + ) + index_html = index_html.replace( + "</body>", + f' <script type="module" src="{editor_asset_prefix}/editor.js"></script>\n' + " </body>", + 1, + ) + fonts = FontCatalog(store.data_dir) + templates = TemplateStore(store.data_dir, font_resolver=fonts.resolve) + animations = AnimationStore(store.data_dir, font_resolver=fonts.resolve) + media_imports = MediaImportManager(store.data_dir, templates, animations) + library_order = LibraryOrderStore( + store.data_dir, + forbidden_ids={DEMO_STATIC_ID, DEMO_ANIMATION_ID}, + ) + display = DisplayService( + store, + driver_kind=driver_kind, + startup_boot_id=startup_boot_id, + font_resolver=fonts.resolve, + ) + power_monitor = voltage_monitor_factory(store) if voltage_monitor_factory else VoltageMonitor(store) + power_monitor.set_protection_listener(display.apply_power_protection) + resource_monitor = resource_monitor_factory() if resource_monitor_factory else ResourceMonitor() + storage_monitor = ( + storage_monitor_factory(code_root, store.data_dir) + if storage_monitor_factory + else DeviceStorageMonitor(code_root, store.data_dir) + ) + wifi_store = WifiConfigStore( + store.data_dir, + store.runtime_dir, + boot_id=startup_boot_id, + ) + if network_backend is None: + network_backend = MockNetworkManager() if configured_driver == "mock" else NmcliNetworkManager() + network = WifiNetworkService(wifi_store, network_backend, display) + if frp_backend is None: + frp_backend = ( + MockFrpBackend() + if configured_driver == "mock" + else SystemdFrpBackend(store.data_dir) + ) + frp = FrpController(store.data_dir, frp_backend) + if network_diagnostics is None: + network_diagnostics = ( + MockNetworkDiagnostics() + if configured_driver == "mock" + else NetworkDiagnostics(lambda: network.get_status()) + ) + display.set_activity_listener(network.dismiss_for_boot) + ota = OtaManager( + code_root=code_root, + data_root=store.data_dir, + runtime_root=store.runtime_dir, + software_version=software_version, + display=display, + worker_starter=ota_worker_starter, + staging_root=ota_staging_root, + ) + + def resolve_content(reference: dict[str, str]) -> dict[str, Any]: + checked = normalize_default_display(reference) + content_type = checked["type"] + content_id = checked["id"] + if content_type == "template": + detail = ( + demo_template(include_scene=True) + if content_id == DEMO_STATIC_ID + else templates.get(content_id) + ) + return { + "type": content_type, + "id": content_id, + "name": detail["name"], + "revision": detail["revision"], + "is_demo": content_id == DEMO_STATIC_ID, + "image": render_scene_image(detail["scene"], font_resolver=fonts.resolve), + } + + detail = ( + demo_animation(include_scenes=True) + if content_id == DEMO_ANIMATION_ID + else animations.playback_snapshot(content_id) + ) + if not detail["frames"]: + raise TemplateConflictError("animation has no frames") + frames = detail["frames"] if content_id != DEMO_ANIMATION_ID else [ + { + "id": frame["id"], + "duration_ms": frame["duration_ms"], + "image": render_scene_image(frame["scene"], font_resolver=fonts.resolve), + } + for frame in detail["frames"] + ] + return { + "type": content_type, + "id": content_id, + "name": detail["name"], + "revision": detail["revision"], + "is_demo": content_id == DEMO_ANIMATION_ID, + "frames": frames, + } + + def content_metadata(resolved: dict[str, Any]) -> dict[str, Any]: + return { + key: resolved[key] + for key in ("type", "id", "name", "revision", "is_demo") + } + + def apply_resolved_content( + resolved: dict[str, Any], + *, + notify_activity: bool, + ) -> None: + source = { + "category": resolved["type"], + "id": resolved["id"], + "name": resolved["name"], + } + if resolved["type"] == "template": + display.show_image( + resolved["image"], + mode="template", + content_source=source, + notify_activity=notify_activity, + ) + return + display.start_animation( + resolved["id"], + resolved["revision"], + resolved["frames"], + content_source=source, + notify_activity=notify_activity, + ) + + def effective_default_content(*, repair: bool = True) -> dict[str, Any]: + reference = store.config["default_display"] + try: + return resolve_content(reference) + except (TemplateError, ConfigError, ValueError): + if not repair or reference == DEFAULT_DISPLAY: + raise + logger.warning("Default display reference is invalid; falling back to demo animation") + store.update({"default_display": DEFAULT_DISPLAY}) + return resolve_content(DEFAULT_DISPLAY) + + def set_default_content( + reference: dict[str, str], + *, + notify_activity: bool = True, + ) -> dict[str, Any]: + checked = normalize_default_display(reference) + resolved = resolve_content(checked) + previous = store.config["default_display"] + store.update({"default_display": checked}) + try: + apply_resolved_content(resolved, notify_activity=notify_activity) + except Exception: + try: + store.update({"default_display": previous}) + except Exception: + logger.exception("Failed to roll back the default display reference") + raise + return content_metadata(resolved) + + def activate_default_content() -> dict[str, Any]: + try: + resolved = effective_default_content() + apply_resolved_content(resolved, notify_activity=False) + return content_metadata(resolved) + except Exception: + if store.config["default_display"] == DEFAULT_DISPLAY: + raise + logger.exception("Failed to activate configured default content; using demo animation") + return set_default_content(DEFAULT_DISPLAY, notify_activity=False) + + def default_matches(content_type: str, content_id: str) -> bool: + reference = store.config["default_display"] + return reference["type"] == content_type and reference["id"] == content_id + + def switch_default_before_delete(content_type: str, content_id: str) -> dict[str, str] | None: + if not default_matches(content_type, content_id): + return None + previous = store.config["default_display"] + set_default_content(DEFAULT_DISPLAY) + return previous + + def restore_default_after_failed_delete(previous: dict[str, str] | None) -> None: + if previous is None: + return + try: + set_default_content(previous) + except Exception: + logger.exception("Failed to restore the default display after a delete error") + + app = FastAPI(title="Matrix Screen Controller") + app.state.config_store = store + app.state.font_catalog = fonts + app.state.template_store = templates + app.state.animation_store = animations + app.state.media_import_manager = media_imports + app.state.library_order_store = library_order + app.state.display_service = display + app.state.voltage_monitor = power_monitor + app.state.resource_monitor = resource_monitor + app.state.storage_monitor = storage_monitor + app.state.wifi_store = wifi_store + app.state.network_service = network + app.state.frp_controller = frp + app.state.network_diagnostics = network_diagnostics + app.state.ui_copy_store = ui_copy_store + app.state.ui_copy_editor_build_enabled = ui_copy_editor_build_enabled + app.state.maintenance_black = maintenance_black + app.state.app_version = app_version + app.state.software_version = str(software_version) + app.state.feature_updated_at = feature_updated_at + app.state.ota_manager = ota + app.state.performance_mode = performance_mode + app.state.instance_id = str(uuid4()) + app.mount(static_version_prefix, StaticFiles(directory=static_dir), name="versioned-static") + app.mount("/static", StaticFiles(directory=static_dir), name="static") + if editor_asset_prefix is not None: + app.mount( + editor_asset_prefix, + StaticFiles(directory=editor_static_dir), + name="ui-copy-editor-assets", + ) + + @app.middleware("http") + async def cache_policy(request, call_next): + if ( + request.method in {"POST", "PUT", "PATCH", "DELETE"} + and request.url.path != "/api/ota/update" + and ota.is_active() + ): + return JSONResponse( + status_code=423, + content={"detail": "software update is active; device writes are temporarily locked"}, + headers={"Cache-Control": "no-store"}, + ) + response = await call_next(request) + path = request.url.path + if ( + request.method in {"POST", "PUT", "PATCH", "DELETE"} + and response.status_code < 400 + and not path.startswith("/api/display/test") + and path.startswith(( + "/api/config", + "/api/network/wifi", + "/api/frp", + "/api/power/voltage/calibration", + "/api/colors/palette", + "/api/library", + "/api/templates", + "/api/animations", + "/api/fonts", + "/api/display", + )) + ): + storage_monitor.invalidate() + is_digest_thumbnail = ( + path.startswith("/api/templates/") + and path.endswith("/thumbnail") + and bool(request.query_params.get("v")) + ) or ( + path.startswith("/api/animations/") + and path.endswith("/thumbnail") + and bool(request.query_params.get("v")) + ) + if is_digest_thumbnail: + pass + elif path == "/" or path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store" + elif path.startswith(f"{static_version_prefix}/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + elif editor_asset_prefix is not None and path.startswith(f"{editor_asset_prefix}/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + elif path.startswith("/static/"): + response.headers["Cache-Control"] = "no-store" + return response + + @app.get("/") + def index() -> HTMLResponse: + network.dismiss_for_boot() + return HTMLResponse(index_html) + + if ui_copy_store is not None: + @app.get("/api/dev/ui-copy") + def get_ui_copy_draft() -> Response: + document = ui_copy_store.status_document() + return Response( + content=json.dumps(document, ensure_ascii=False, separators=(",", ":")), + media_type="application/json", + headers={"ETag": f'"{document["revision"]}"'}, + ) + + @app.put("/api/dev/ui-copy") + def put_ui_copy_draft( + document: dict[str, Any], + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> Response: + revision_text = normalize_if_match(if_match) + try: + expected_revision = int(revision_text) + except ValueError as exc: + raise HTTPException(status_code=428, detail="If-Match must be an integer revision") from exc + try: + saved = ui_copy_store.replace(document, expected_revision=expected_revision) + except UiCopyDraftConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except UiCopyDraftValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return Response( + content=json.dumps(saved, ensure_ascii=False, separators=(",", ":")), + media_type="application/json", + headers={"ETag": f'"{saved["revision"]}"'}, + ) + + @app.get("/api/dev/ui-copy/export") + def export_ui_copy_draft() -> Response: + document = ui_copy_store.status_document() + return Response( + content=json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + media_type="application/json", + headers={ + "Content-Disposition": 'attachment; filename="ui-copy-draft.json"', + "ETag": f'"{document["revision"]}"', + }, + ) + + @app.get("/api/status") + def get_status() -> dict: + status = display.get_status() + status["service"] = { + "app_version": app.state.app_version, + "software_version": app.state.software_version, + "feature_updated_at": app.state.feature_updated_at, + "instance_id": app.state.instance_id, + } + status["power"] = { + "screen_input_voltage": power_monitor.get_status(), + "low_voltage_protection": power_monitor.get_protection_status(), + } + status["resources"] = resource_monitor.get_status() + status["network"] = network.get_cached_status() + status["frp"] = frp.status() + status["ui"] = { + "preview_refresh_interval_ms": store.config["preview_refresh_interval_ms"], + } + status["system"] = { + "performance_mode": performance_mode.status( + store.config["performance_mode_enabled"] + ), + } + return status + + @app.get("/api/ota/status") + def get_ota_status() -> dict: + return { + **ota.status(), + "feature_updated_at": app.state.feature_updated_at, + } + + @app.get("/api/ota/failure-log") + def get_ota_failure_log() -> Response: + content = ota.failure_log() + if content is None: + raise HTTPException(status_code=404, detail="no OTA failure log is available") + return Response( + content=content, + media_type="text/plain; charset=utf-8", + headers={"Cache-Control": "no-store"}, + ) + + @app.post("/api/ota/update", status_code=202) + async def upload_ota_update(request: Request, filename: str = "") -> dict: + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() + if content_type != "application/octet-stream": + raise HTTPException(status_code=415, detail="OTA upload Content-Type must be application/octet-stream") + content_length = request.headers.get("content-length") + parsed_length: int | None = None + if content_length is not None: + try: + parsed_length = int(content_length) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Content-Length must be an integer") from exc + try: + return await ota.accept_upload( + filename=filename, + chunks=request.stream(), + content_length=parsed_length, + orientation=int(store.config["orientation"]), + ) + except OtaUploadError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + @app.get("/api/display/current-frame") + def get_current_display_frame() -> Response: + return image_png_response(display.get_current_frame()) + + @app.patch("/api/display/animation-playback") + def patch_animation_playback(request: AnimationPlaybackUpdateRequest) -> dict: + try: + playback = display.control_animation_playback( + request.session_id, + position_ms=request.position_ms, + paused=request.paused, + speed=request.speed, + ) + return { + "ok": True, + "animation_playback": playback, + "state_revision": display.get_state_revision(), + } + except AnimationPlaybackConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @app.get("/api/display/default-content") + def get_default_content() -> dict: + try: + return content_metadata(effective_default_content()) + except (TemplateError, ConfigError, ValueError) as exc: + raise template_error(exc) if isinstance(exc, TemplateError) else HTTPException(status_code=400, detail=str(exc)) + + @app.put("/api/display/default-content") + def put_default_content(request: DefaultContentRequest) -> dict: + try: + return set_default_content(request.model_dump()) + except TemplateError as exc: + raise template_error(exc) from exc + except (ConfigError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Failed to save and display default content") + raise HTTPException(status_code=500, detail="default content could not be applied") from exc + + @app.get("/api/config") + def get_config() -> dict: + return store.config + + @app.get("/api/fonts") + def get_fonts() -> dict: + return fonts.document() + + @app.post("/api/fonts/import") + async def import_font(request: Request, response: Response, filename: str = "") -> dict: + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() + if content_type != "application/octet-stream": + raise HTTPException( + status_code=415, + detail="font upload Content-Type must be application/octet-stream", + ) + content_length = request.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > MAX_FONT_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="font file exceeds the 32 MiB limit") + except ValueError as exc: + raise HTTPException(status_code=400, detail="Content-Length must be an integer") from exc + try: + created, imported = await fonts.import_font(filename, request.stream()) + response.status_code = 201 if created else 200 + return {"created": created, "fonts": imported} + except FontTooLargeError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc + except FontTypeError as exc: + raise HTTPException(status_code=415, detail=str(exc)) from exc + except FontValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except FontStorageFullError as exc: + raise HTTPException(status_code=507, detail=str(exc)) from exc + except FontCatalogError as exc: + logger.exception("Font import failed") + raise HTTPException(status_code=500, detail="font could not be imported") from exc + + @app.get("/api/device-storage") + def get_device_storage() -> dict: + try: + return storage_monitor.get_status() + except OSError as exc: + logger.exception("Device storage scan failed") + raise HTTPException(status_code=500, detail="device storage could not be read") from exc + + @app.get("/api/network/wifi") + def get_wifi_config() -> dict: + return network.get_status(include_secret=True) + + @app.post("/api/network/diagnostics") + def run_network_diagnostics() -> dict: + return network_diagnostics.run() + + def frp_http_error(exc: FrpError) -> HTTPException: + if isinstance(exc, FrpConflictError): + return HTTPException(status_code=409, detail=str(exc)) + if isinstance(exc, FrpValidationError): + return HTTPException(status_code=422, detail=str(exc)) + return HTTPException(status_code=503, detail=str(exc)) + + async def read_frp_body(request: Request) -> bytes: + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() + if content_type not in {"application/octet-stream", "text/plain"}: + raise HTTPException(status_code=415, detail="FRP config Content-Type must be text/plain or application/octet-stream") + content_length = request.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > MAX_FRP_CONFIG_BYTES: + raise HTTPException(status_code=413, detail="FRP config exceeds the 1 MiB limit") + except ValueError as exc: + raise HTTPException(status_code=400, detail="Content-Length must be an integer") from exc + body = await request.body() + if len(body) > MAX_FRP_CONFIG_BYTES: + raise HTTPException(status_code=413, detail="FRP config exceeds the 1 MiB limit") + return body + + @app.get("/api/frp") + def get_frp_status() -> dict: + return frp.status() + + @app.post("/api/frp/configs", status_code=201) + async def create_frp_config(request: Request, filename: str = "", name: str = "") -> dict: + try: + profile = frp.catalog.create( + filename=filename, + name=name, + content=await read_frp_body(request), + ) + return {"ok": True, "profile": profile, "frp": frp.status()} + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.get("/api/frp/configs/{profile_id}") + def get_frp_config(profile_id: str) -> Response: + try: + profile, content = frp.content(profile_id) + return Response( + content=content, + media_type="text/plain; charset=utf-8", + headers={ + "X-Frp-Config-Name": profile["name"].encode("ascii", "ignore").decode("ascii"), + "Cache-Control": "no-store", + }, + ) + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.put("/api/frp/configs/{profile_id}") + async def update_frp_config(profile_id: str, request: Request, name: str = "") -> dict: + try: + profile = frp.catalog.update( + profile_id, + name=name, + content=await read_frp_body(request), + ) + return {"ok": True, "profile": profile, "frp": frp.status()} + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.delete("/api/frp/configs/{profile_id}") + def delete_frp_config(profile_id: str) -> dict: + try: + frp.catalog.delete(profile_id) + return {"ok": True, "frp": frp.status()} + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.post("/api/frp/configs/{profile_id}/select") + def select_frp_config(profile_id: str) -> dict: + try: + frp.catalog.select(profile_id) + return {"ok": True, "frp": frp.status()} + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.post("/api/frp/service/start") + def start_frp_service() -> dict: + try: + return {"ok": True, "frp": frp.start()} + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.post("/api/frp/service/stop") + def stop_frp_service() -> dict: + try: + return {"ok": True, "frp": frp.stop()} + except FrpError as exc: + raise frp_http_error(exc) from exc + + @app.put("/api/network/wifi") + def put_wifi_config( + update: WifiUpdate, + background_tasks: BackgroundTasks, + response: Response, + ) -> dict: + try: + result = network.save_settings(update.model_dump()) + if result["activation"] == "immediate": + response.status_code = 202 + background_tasks.add_task(network.activate_saved, result["operation_id"]) + return {"ok": True, **result, "network": network.get_status()} + except (WifiConfigError, NetworkManagerError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Failed to save WiFi configuration") + raise HTTPException(status_code=500, detail="WiFi configuration could not be saved") from exc + + @app.put("/api/network/wifi/prompt-delay") + def put_wifi_prompt_delay(update: WifiPromptDelayUpdate) -> dict: + try: + result = network.save_prompt_delay(update.prompt_delay_seconds) + return {"ok": True, **result} + except (WifiConfigError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Failed to save WiFi prompt delay") + raise HTTPException(status_code=500, detail="WiFi prompt delay could not be saved") from exc + + @app.put("/api/config") + def put_config(update: ConfigUpdate) -> dict: + values = update.model_dump(exclude_none=True) + try: + config = validate_config({**store.config, **values}) + if "low_voltage_protection_enabled" in values: + enabled = config["low_voltage_protection_enabled"] + store.update({"low_voltage_protection_enabled": enabled}) + power_monitor.set_protection_enabled(enabled) + if enabled: + power_monitor.sample_now() + if "orientation" in values: + display.set_orientation(config["orientation"]) + if "brightness" in values: + display.set_brightness(config["brightness"]) + if "matrix_refresh_rate_limit_hz" in values: + display.set_refresh_rate_limit( + config["matrix_refresh_rate_limit_hz"] + ) + if "performance_mode_enabled" in values: + performance_mode.transact( + config["performance_mode_enabled"], + lambda enabled: store.update({"performance_mode_enabled": enabled}), + ) + remaining = { + key: config[key] + for key in ( + "default_font", + "default_text_size", + "preview_refresh_interval_ms", + "custom_test_color", + "workspace_order", + "animation_preview_max_concurrent", + ) + if key in values + } + if remaining: + store.update(remaining) + return store.config + except (ConfigError, PerformanceModeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Failed to save config") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/power/voltage/calibration/preview") + def preview_voltage_calibration(request: VoltageCalibrationPreviewRequest) -> dict: + try: + return power_monitor.preview_calibration(request.reference_volts) + except CalibrationUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except CalibrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/power/voltage/calibration/confirm") + def confirm_voltage_calibration(request: VoltageCalibrationConfirmRequest) -> dict: + try: + status = power_monitor.confirm_calibration(request.proposal_id) + return { + "ok": True, + "screen_input_voltage": status, + "config": store.config, + } + except CalibrationConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except CalibrationUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + @app.post("/api/power/voltage/calibration/reset") + def reset_voltage_calibration() -> dict: + status = power_monitor.reset_calibration() + return { + "ok": True, + "screen_input_voltage": status, + "config": store.config, + } + + def palette_response() -> dict: + return { + "colors": store.config["color_palette"], + "limit": COLOR_PALETTE_LIMIT, + } + + @app.get("/api/colors/palette") + def get_color_palette() -> dict: + return palette_response() + + @app.post("/api/colors/palette") + def add_color_palette(request: PaletteColorRequest) -> dict: + try: + store.add_palette_color(request.color) + return palette_response() + except ConfigError as exc: + status_code = 409 if "limited" in str(exc) else 400 + raise HTTPException(status_code=status_code, detail=str(exc)) from exc + + @app.delete("/api/colors/palette/{hex_color}") + def delete_color_palette(hex_color: str) -> dict: + try: + store.remove_palette_color(f"#{hex_color}") + return palette_response() + except ConfigError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + def template_error(exc: TemplateError) -> HTTPException: + if isinstance(exc, TemplateValidationError): + status_code = 422 + elif isinstance(exc, TemplateConflictError): + status_code = 409 + elif isinstance(exc, TemplateNotFoundError): + status_code = 404 + elif isinstance(exc, TemplateStorageFullError): + status_code = 507 + else: + status_code = 500 + logger.exception("Template operation failed", exc_info=exc) + return HTTPException(status_code=status_code, detail=str(exc)) + + def reject_demo_template(template_id: str) -> None: + if template_id == DEMO_STATIC_ID: + raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE) + + def reject_demo_animation(animation_id: str) -> None: + if animation_id == DEMO_ANIMATION_ID: + raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE) + + def current_library_items() -> list[dict[str, str]]: + return [ + *({"type": "template", "id": item["id"]} for item in templates.list()["templates"]), + *({"type": "animation", "id": item["id"]} for item in animations.list()["animations"]), + ] + + def library_order_error(exc: LibraryOrderError) -> HTTPException: + if isinstance(exc, LibraryOrderValidationError): + status_code = 422 + elif isinstance(exc, LibraryOrderConflictError): + status_code = 409 + elif isinstance(exc, LibraryOrderStorageFullError): + status_code = 507 + else: + status_code = 500 + logger.exception("Library order operation failed", exc_info=exc) + return HTTPException(status_code=status_code, detail=str(exc)) + + def copy_scene_as_template(name: str, scene: dict[str, Any]) -> dict: + candidate = f"{name} - 副本" + sequence = 2 + while True: + try: + return templates.create(candidate, scene) + except TemplateConflictError: + candidate = f"{name} - 副本 {sequence}" + sequence += 1 + + @app.get("/api/templates") + def list_templates(include_demo: bool = False) -> dict: + result = templates.list() + if include_demo: + result["templates"] = [demo_template(), *result["templates"]] + return result + + @app.get("/api/library/order") + def get_library_order() -> dict: + try: + return library_order.get(current_library_items()) + except LibraryOrderError as exc: + raise library_order_error(exc) from exc + + @app.put("/api/library/order") + def put_library_order( + request: LibraryOrderRequest, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + items = [item.model_dump() for item in request.items] + expected_revision = normalize_if_match(if_match) + demo_ids = {DEMO_STATIC_ID, DEMO_ANIMATION_ID} + if any(item["id"] in demo_ids for item in items): + raise HTTPException(status_code=422, detail="demo items cannot be included in library order") + try: + return library_order.update( + items, + current_library_items(), + expected_revision, + ) + except LibraryOrderError as exc: + raise library_order_error(exc) from exc + + @app.post("/api/templates", status_code=201) + def create_template(request: TemplateCreateRequest) -> dict: + try: + return templates.create(request.name, request.scene) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/templates/{template_id}") + def get_template(template_id: str) -> dict: + try: + if template_id == DEMO_STATIC_ID: + return demo_template(include_scene=True) + return templates.get(template_id) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/templates/{template_id}/thumbnail") + def get_template_thumbnail(template_id: str, v: str | None = None) -> FileResponse: + try: + if template_id == DEMO_STATIC_ID: + item = demo_template(include_scene=True) + if v is not None and v != item["digest"]: + raise TemplateNotFoundError("template thumbnail not found") + return Response( + content=demo_thumbnail(item["scene"]), + media_type="image/png", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + return FileResponse( + templates.thumbnail_path(template_id, digest=v), + media_type="image/png", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.put("/api/templates/{template_id}") + def update_template( + template_id: str, + request: TemplateSceneRequest, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + try: + reject_demo_template(template_id) + return templates.update_scene(template_id, request.scene, normalize_if_match(if_match)) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.patch("/api/templates/{template_id}") + def rename_template( + template_id: str, + request: TemplateNameRequest, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + try: + reject_demo_template(template_id) + return templates.rename(template_id, request.name, normalize_if_match(if_match)) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/templates/{template_id}/copy", status_code=201) + def copy_template(template_id: str) -> dict: + try: + if template_id == DEMO_STATIC_ID: + item = demo_template(include_scene=True) + return copy_scene_as_template(item["name"], item["scene"]) + return templates.copy(template_id) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/templates/{template_id}/play") + def play_template( + template_id: str, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + try: + detail = ( + demo_template(include_scene=True) + if template_id == DEMO_STATIC_ID + else templates.get(template_id) + ) + expected = normalize_if_match(if_match) + if detail["revision"] != expected: + raise TemplateConflictError("template was modified") + resolved = { + "type": "template", + "id": template_id, + "name": detail["name"], + "revision": detail["revision"], + "is_demo": template_id == DEMO_STATIC_ID, + "image": render_scene_image(detail["scene"], font_resolver=fonts.resolve), + } + apply_resolved_content(resolved, notify_activity=True) + return { + "ok": True, + "template_id": template_id, + "revision": expected, + } + except TemplateError as exc: + raise template_error(exc) from exc + + @app.delete("/api/templates/{template_id}") + def delete_template( + template_id: str, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + try: + reject_demo_template(template_id) + expected = normalize_if_match(if_match) + current = templates.get(template_id) + if current["revision"] != expected: + raise TemplateConflictError("template was changed by another client") + previous = switch_default_before_delete("template", template_id) + try: + templates.delete(template_id, expected) + except Exception: + restore_default_after_failed_delete(previous) + raise + return {"ok": True} + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/animations") + def list_animations(include_demo: bool = False) -> dict: + result = animations.list() + if include_demo: + result["animations"] = [demo_animation(), *result["animations"]] + return result + + def media_error(exc: MediaImportError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail=str(exc)) + + @app.post("/api/media-imports", status_code=202) + async def create_media_import(request: Request, filename: str, name: str | None = None) -> dict: + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type != "application/octet-stream": + raise HTTPException(status_code=415, detail="Content-Type must be application/octet-stream") + raw_length = request.headers.get("content-length") + if raw_length is None: + raise HTTPException(status_code=411, detail="Content-Length is required") + try: + length = int(raw_length) + except ValueError as exc: + raise HTTPException(status_code=411, detail="Content-Length must be an integer") from exc + job_id = None + try: + job, source = media_imports.begin_upload(filename, name, length) + job_id = job["id"] + received = 0 + with source.open("xb") as output: + async for chunk in request.stream(): + received += len(chunk) + if received > length: + raise MediaImportError("uploaded data exceeds Content-Length") + output.write(chunk) + return media_imports.finish_upload(job_id, received) + except MediaImportError as exc: + if job_id is not None: + media_imports.fail_upload(job_id) + raise media_error(exc) from exc + except OSError as exc: + if job_id is not None: + media_imports.fail_upload(job_id) + raise HTTPException(status_code=507, detail=f"media upload failed: {exc}") from exc + + @app.get("/api/media-imports") + def list_media_imports() -> dict: + try: + return media_imports.list() + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.get("/api/media-imports/{job_id}") + def get_media_import(job_id: str) -> dict: + try: + return media_imports.get(job_id) + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.get("/api/media-imports/{job_id}/previews/{index}") + def get_media_preview(job_id: str, index: int) -> FileResponse: + try: + return FileResponse( + media_imports.preview_path(job_id, index), + media_type="image/png", + headers={"Cache-Control": "no-store"}, + ) + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.put("/api/media-imports/{job_id}/settings") + def update_media_settings(job_id: str, request: MediaSettingsRequest) -> dict: + try: + return media_imports.update_settings( + job_id, request.model_dump(exclude_unset=True, exclude_none=True), + ) + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.post("/api/media-imports/{job_id}/convert", status_code=202) + def convert_media_import(job_id: str) -> dict: + try: + return media_imports.enqueue_conversion(job_id) + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.post("/api/media-imports/{job_id}/retry", status_code=202) + def retry_media_import(job_id: str) -> dict: + try: + return media_imports.retry(job_id) + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.delete("/api/media-imports/{job_id}") + def delete_media_import(job_id: str) -> dict: + try: + media_imports.cancel(job_id) + return {"ok": True} + except MediaImportError as exc: + raise media_error(exc) from exc + + @app.post("/api/animations", status_code=201) + def create_animation(request: AnimationCreateRequest) -> dict: + try: + return animations.create(request.name) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/animations/{animation_id}") + def get_animation(animation_id: str, frame_offset: int | None = None, frame_limit: int | None = None) -> dict: + try: + if animation_id == DEMO_ANIMATION_ID: + return demo_animation() + return animations.get(animation_id, offset=frame_offset, limit=frame_limit) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.patch("/api/animations/{animation_id}") + def rename_animation(animation_id: str, request: TemplateNameRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + return animations.rename(animation_id, request.name, normalize_if_match(if_match)) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.delete("/api/animations/{animation_id}") + def delete_animation(animation_id: str, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + expected = normalize_if_match(if_match) + current = animations.get(animation_id) + if current["revision"] != expected: + raise TemplateConflictError("animation was modified") + selected_default = default_matches("animation", animation_id) + if display.is_animation_active(animation_id) and not selected_default: + raise TemplateConflictError("apply other content before deleting the active animation") + previous = switch_default_before_delete("animation", animation_id) + try: + animations.delete(animation_id, expected) + except Exception: + restore_default_after_failed_delete(previous) + raise + return {"ok": True} + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/animations/{animation_id}/frames") + def list_animation_frames(animation_id: str) -> dict: + try: + if animation_id == DEMO_ANIMATION_ID: + return demo_animation() + return animations.get(animation_id) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/animations/{animation_id}/frames", status_code=201) + def create_animation_frame(animation_id: str, request: AnimationFrameCreateRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + return animations.add_frame(animation_id, request.scene, normalize_if_match(if_match), name=request.name, duration_ms=request.duration_ms) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/animations/{animation_id}/frames/copy", status_code=201) + def copy_animation_frames( + animation_id: str, + request: AnimationFramesCopyRequest, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + try: + reject_demo_animation(animation_id) + reject_demo_animation(request.destination_animation_id) + return animations.copy_frames( + animation_id, + request.frame_ids, + normalize_if_match(if_match), + request.destination_animation_id, + request.destination_revision, + request.insert_after_frame_id, + ) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/animations/{animation_id}/frames/batch-delete") + def delete_animation_frames( + animation_id: str, + request: AnimationFramesDeleteRequest, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict: + try: + reject_demo_animation(animation_id) + expected = normalize_if_match(if_match) + current = animations.get(animation_id) + selected_ids = set(request.frame_ids) + all_ids = {frame["id"] for frame in current["frames"]} + will_empty = bool(all_ids) and selected_ids == all_ids + previous = ( + switch_default_before_delete("animation", animation_id) + if will_empty + else None + ) + try: + return animations.delete_frames( + animation_id, + request.frame_ids, + expected, + ) + except Exception: + restore_default_after_failed_delete(previous) + raise + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/animations/{animation_id}/frames/{frame_id}") + def get_animation_frame(animation_id: str, frame_id: str) -> dict: + try: + if animation_id == DEMO_ANIMATION_ID: + try: + return demo_animation_frame(frame_id) + except KeyError as exc: + raise TemplateNotFoundError("animation frame not found") from exc + return animations.get_frame(animation_id, frame_id) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.put("/api/animations/{animation_id}/frames/{frame_id}") + def put_animation_frame(animation_id: str, frame_id: str, request: AnimationFramePutRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + return animations.update_frame(animation_id, frame_id, normalize_if_match(if_match), scene=request.scene) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.patch("/api/animations/{animation_id}/frames/{frame_id}") + def patch_animation_frame(animation_id: str, frame_id: str, request: AnimationFramePatchRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + fields = request.model_fields_set + return animations.update_frame( + animation_id, frame_id, normalize_if_match(if_match), + name=request.name if "name" in fields else ..., + duration_ms=request.duration_ms if "duration_ms" in fields else ..., + ) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.delete("/api/animations/{animation_id}/frames/{frame_id}") + def delete_animation_frame(animation_id: str, frame_id: str, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + expected = normalize_if_match(if_match) + current = animations.get(animation_id) + will_empty = len(current["frames"]) == 1 and current["frames"][0]["id"] == frame_id + previous = ( + switch_default_before_delete("animation", animation_id) + if will_empty + else None + ) + try: + return animations.delete_frame(animation_id, frame_id, expected) + except Exception: + restore_default_after_failed_delete(previous) + raise + except TemplateError as exc: + raise template_error(exc) from exc + + @app.get("/api/animations/{animation_id}/frames/{frame_id}/thumbnail") + def get_animation_thumbnail(animation_id: str, frame_id: str, v: str | None = None) -> FileResponse: + try: + if animation_id == DEMO_ANIMATION_ID: + try: + frame = demo_animation_frame(frame_id) + except KeyError as exc: + raise TemplateNotFoundError("animation thumbnail not found") from exc + if v is not None and v != frame["digest"]: + raise TemplateNotFoundError("animation thumbnail not found") + return Response( + content=demo_thumbnail(frame["scene"]), + media_type="image/png", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + return FileResponse(animations.thumbnail_path(animation_id, frame_id, v), media_type="image/png", headers={"Cache-Control": "public, max-age=31536000, immutable"}) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.put("/api/animations/{animation_id}/frame-order") + def reorder_animation(animation_id: str, request: AnimationOrderRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + return animations.reorder(animation_id, request.frame_ids, normalize_if_match(if_match)) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/animations/{animation_id}/frame-move") + def move_animation_frames(animation_id: str, request: AnimationMoveRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + return animations.move_frames( + animation_id, + request.frame_ids, + normalize_if_match(if_match), + before_frame_id=request.before_frame_id, + ) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.put("/api/animations/{animation_id}/frame-durations") + def set_animation_durations(animation_id: str, request: AnimationDurationsRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + reject_demo_animation(animation_id) + return animations.set_durations( + animation_id, + request.duration_ms, + normalize_if_match(if_match), + frame_ids=request.frame_ids, + ) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/animations/{animation_id}/play") + def play_animation(animation_id: str, if_match: str | None = Header(default=None, alias="If-Match")) -> dict: + try: + detail = demo_animation(include_scenes=True) if animation_id == DEMO_ANIMATION_ID else animations.playback_snapshot(animation_id) + expected = normalize_if_match(if_match) + if detail["revision"] != expected: + raise TemplateConflictError("animation was modified") + if not detail["frames"]: + raise TemplateConflictError("animation has no frames") + frames = detail["frames"] if animation_id != DEMO_ANIMATION_ID else [ + { + "id": frame["id"], + "duration_ms": frame["duration_ms"], + "image": render_scene_image(frame["scene"], font_resolver=fonts.resolve), + } + for frame in detail["frames"] + ] + display.start_animation( + animation_id, + expected, + frames, + content_source={ + "category": "animation", + "id": animation_id, + "name": detail["name"], + }, + ) + return {"ok": True, "animation_id": animation_id, "revision": expected, "frame_count": len(frames)} + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/animations/{animation_id}/copy", status_code=201) + def copy_animation(animation_id: str) -> dict: + try: + source = demo_animation(include_scenes=True) if animation_id == DEMO_ANIMATION_ID else animations.get(animation_id, scenes=True) + return animations.copy_from_frames(source["name"], source["frames"]) + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/library/copy", status_code=201) + def copy_library_item(request: LibraryCopyRequest) -> dict: + try: + if request.source_type == "template": + source = demo_template(include_scene=True) if request.source_id == DEMO_STATIC_ID else templates.get(request.source_id) + if request.source_revision and source["revision"] != request.source_revision: + raise TemplateConflictError("source template was modified") + source_name = source["name"] + scene = source["scene"] + duration = DEFAULT_DURATION_MS + if request.destination_type == "static": + return {"type": "static", "item": copy_scene_as_template(source_name, scene)} + else: + if not request.source_animation_id: + raise TemplateValidationError("source_animation_id is required") + source = animations.get_frame(request.source_animation_id, request.source_id) + if request.source_revision and source["animation_revision"] != request.source_revision: + raise TemplateConflictError("source animation was modified") + source_name = source["name"] or f"{source['animation_name']} - 第{source['position']}帧" + scene = source["scene"] + duration = source["duration_ms"] + if request.destination_type == "animation": + if not request.destination_animation_id or not request.destination_revision: + raise TemplateValidationError("destination animation and revision are required") + item = animations.add_frame(request.destination_animation_id, scene, request.destination_revision, name=source_name, duration_ms=duration) + return {"type": "animation", "item": item} + candidate = source_name + suffix = 1 + while True: + try: + item = templates.create(candidate, scene) + return {"type": "static", "item": item} + except TemplateConflictError: + suffix += 1 + candidate = f"{source_name} - 副本{'' if suffix == 2 else f' {suffix}'}" + except TemplateError as exc: + raise template_error(exc) from exc + + @app.post("/api/display/fill") + def display_fill(request: FillRequest) -> dict: + try: + display.fill(parse_color(request.color)) + return {"ok": True, "mode": "fill"} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Fill display operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + def display_test_response(display_test: dict) -> dict: + return { + "ok": True, + "display_test": display_test, + "effective_brightness": display.get_status()["state"]["effective_brightness"], + } + + @app.post("/api/display/test/fill") + def start_display_test_fill(request: FillRequest) -> dict: + try: + return display_test_response( + display.start_display_test_fill(parse_color(request.color)) + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Display test fill operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.put("/api/display/test/brightness") + def set_display_test_brightness(request: DisplayTestBrightnessRequest) -> dict: + try: + return display_test_response( + display.set_display_test_brightness(request.brightness) + ) + except DisplayTestInactiveError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Display test brightness operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.delete("/api/display/test") + def stop_display_test() -> dict: + try: + return display_test_response(display.stop_display_test()) + except Exception as exc: + logger.exception("Display test exit operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/display/clear") + def display_clear() -> dict: + try: + display.clear() + return {"ok": True, "mode": "clear"} + except Exception as exc: + logger.exception("Clear display operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/display/text") + def display_text(request: TextRequest) -> dict: + try: + display.show_text(text_options_from_request(request)) + return {"ok": True, "mode": "text"} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Text display operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/preview/text") + def preview_text(request: TextRequest) -> Response: + try: + image = render_text( + text_options_from_request(request), + size=(display.width, display.height), + font_resolver=fonts.resolve, + ) + return image_png_response(image) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Text preview operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/preview/text-layer") + def preview_text_layer(request: TextLayerRequest) -> Response: + try: + image = render_text_layer( + text_layer_options_from_request(request), + size=(display.width, display.height), + font_resolver=fonts.resolve, + ) + return image_png_response(image) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Text layer preview operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/display/diagnostic") + def display_diagnostic(request: DiagnosticRequest) -> dict: + try: + display.show_diagnostic(request.mode) + return {"ok": True, "mode": f"diagnostic:{request.mode}"} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Diagnostic display operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.post("/api/preview/diagnostic") + def preview_diagnostic(request: DiagnosticRequest) -> Response: + try: + image = render_diagnostic(request.mode, size=(display.width, display.height)) + return image_png_response(image) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + logger.exception("Diagnostic preview operation failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @app.websocket("/ws/canvas") + async def canvas_ws(websocket: WebSocket) -> None: + await websocket.accept() + logger.info("Canvas WebSocket connected") + try: + while True: + try: + message = await websocket.receive_json() + if message.get("type") != "frame_rgb": + raise ValueError("message type must be frame_rgb") + if message.get("width") != display.width or message.get("height") != display.height: + raise ValueError("frame size must be 64x64 RGB888") + if message.get("encoding") != "base64_rgb888": + raise ValueError("encoding must be base64_rgb888") + if "source" not in message: + mode = "canvas" + elif message.get("source") == "composition": + mode = "composition" + else: + raise ValueError("source must be composition when provided") + data = base64.b64decode(str(message.get("data", "")), validate=True) + content_source = None + raw_source = message.get("content_source") + if raw_source is not None: + if mode != "composition": + raise ValueError("content_source is only valid for composition frames") + if not isinstance(raw_source, dict) or set(raw_source) != {"type", "id", "revision"}: + raise ValueError("content_source must contain type, id and revision") + if raw_source.get("type") != "template": + raise ValueError("content_source type must be template") + source_id = raw_source.get("id") + source_revision = raw_source.get("revision") + if not isinstance(source_id, str) or not isinstance(source_revision, str): + raise ValueError("content_source id and revision must be strings") + try: + if str(UUID(source_id)) != source_id: + raise ValueError + except (ValueError, AttributeError) as exc: + raise ValueError("content_source id must be a canonical UUID") from exc + if not source_revision: + raise ValueError("content_source revision must not be empty") + try: + source_detail = ( + demo_template(include_scene=True) + if source_id == DEMO_STATIC_ID + else templates.get(source_id) + ) + source_image = render_scene_image( + source_detail["scene"], + font_resolver=fonts.resolve, + ) + if ( + source_detail["revision"] == source_revision + and source_image.tobytes() == data + ): + content_source = { + "category": "template", + "id": source_id, + "name": source_detail["name"], + } + except TemplateError: + content_source = None + display.show_rgb_bytes(data, mode=mode, content_source=content_source) + await websocket.send_json({"type": "ack", "mode": mode, "applied": True}) + except WebSocketDisconnect: + raise + except ValueError as exc: + logger.warning("Canvas WebSocket frame error: %s", exc) + await websocket.send_json({"type": "error", "message": str(exc)}) + except Exception as exc: + logger.exception("Canvas WebSocket display error") + await websocket.send_json({"type": "error", "message": str(exc)}) + except WebSocketDisconnect: + logger.info("Canvas WebSocket disconnected") + + @app.on_event("startup") + def startup() -> None: + performance_mode.start(store.config["performance_mode_enabled"]) + media_imports.start() + power_monitor.sample_now() + power_monitor.start() + resource_monitor.start() + def restore_after_ota() -> None: + try: + activate_default_content() + network.start() + except Exception: + logger.exception("Failed to restore normal content after OTA completion") + + ota.set_completion_callback(restore_after_ota) + if ota.resume_or_start_monitor(restore_after_ota): + logger.warning("OTA transaction is active; keeping the update indicator visible") + elif maintenance_black: + logger.warning("Maintenance-black startup is active; leaving the initial black frame") + else: + activate_default_content() + network.start() + + @app.on_event("shutdown") + def shutdown() -> None: + logger.info("Shutting down; clearing display") + media_imports.stop() + power_monitor.close() + resource_monitor.close() + network.close() + ota.close() + try: + performance_mode.restore(remove_record=True) + except PerformanceModeError: + logger.exception("Failed to restore CPU governors on shutdown") + try: + display.close() + except Exception: + logger.exception("Failed to clear display on shutdown") + + return app + + +app = create_app(ui_copy_editor_build_enabled=UI_COPY_EDITOR_BUILD_ENABLED) diff --git a/核桃派软件源代码/app/media/__init__.py b/核桃派软件源代码/app/media/__init__.py new file mode 100644 index 0000000..d151d1f --- /dev/null +++ b/核桃派软件源代码/app/media/__init__.py @@ -0,0 +1,5 @@ +"""Persistent media analysis and conversion jobs.""" + +from .manager import MediaImportError, MediaImportManager + +__all__ = ["MediaImportError", "MediaImportManager"] diff --git a/核桃派软件源代码/app/media/converter.py b/核桃派软件源代码/app/media/converter.py new file mode 100644 index 0000000..328e20f --- /dev/null +++ b/核桃派软件源代码/app/media/converter.py @@ -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 diff --git a/核桃派软件源代码/app/media/manager.py b/核桃派软件源代码/app/media/manager.py new file mode 100644 index 0000000..626ca45 --- /dev/null +++ b/核桃派软件源代码/app/media/manager.py @@ -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) diff --git a/核桃派软件源代码/app/media/worker.py b/核桃派软件源代码/app/media/worker.py new file mode 100644 index 0000000..38515f4 --- /dev/null +++ b/核桃派软件源代码/app/media/worker.py @@ -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()) diff --git a/核桃派软件源代码/app/monitoring/__init__.py b/核桃派软件源代码/app/monitoring/__init__.py new file mode 100644 index 0000000..0230d35 --- /dev/null +++ b/核桃派软件源代码/app/monitoring/__init__.py @@ -0,0 +1 @@ +"""Lightweight in-process monitoring helpers.""" diff --git a/核桃派软件源代码/app/monitoring/resources.py b/核桃派软件源代码/app/monitoring/resources.py new file mode 100644 index 0000000..1552bd1 --- /dev/null +++ b/核桃派软件源代码/app/monitoring/resources.py @@ -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) diff --git a/核桃派软件源代码/app/monitoring/storage.py b/核桃派软件源代码/app/monitoring/storage.py new file mode 100644 index 0000000..bc3bec7 --- /dev/null +++ b/核桃派软件源代码/app/monitoring/storage.py @@ -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) diff --git a/核桃派软件源代码/app/network/__init__.py b/核桃派软件源代码/app/network/__init__.py new file mode 100644 index 0000000..2a3cc8f --- /dev/null +++ b/核桃派软件源代码/app/network/__init__.py @@ -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", +] diff --git a/核桃派软件源代码/app/network/diagnostics.py b/核桃派软件源代码/app/network/diagnostics.py new file mode 100644 index 0000000..8f8bea1 --- /dev/null +++ b/核桃派软件源代码/app/network/diagnostics.py @@ -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) diff --git a/核桃派软件源代码/app/network/manager.py b/核桃派软件源代码/app/network/manager.py new file mode 100644 index 0000000..e158b91 --- /dev/null +++ b/核桃派软件源代码/app/network/manager.py @@ -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/", + } diff --git a/核桃派软件源代码/app/network/service.py b/核桃派软件源代码/app/network/service.py new file mode 100644 index 0000000..5651885 --- /dev/null +++ b/核桃派软件源代码/app/network/service.py @@ -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 状态不可用") diff --git a/核桃派软件源代码/app/network/store.py b/核桃派软件源代码/app/network/store.py new file mode 100644 index 0000000..063a1fc --- /dev/null +++ b/核桃派软件源代码/app/network/store.py @@ -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"), + ) diff --git a/核桃派软件源代码/app/ota/__init__.py b/核桃派软件源代码/app/ota/__init__.py new file mode 100644 index 0000000..04f2c8d --- /dev/null +++ b/核桃派软件源代码/app/ota/__init__.py @@ -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) diff --git a/核桃派软件源代码/app/ota/diagnostics.py b/核桃派软件源代码/app/ota/diagnostics.py new file mode 100644 index 0000000..776e89a --- /dev/null +++ b/核桃派软件源代码/app/ota/diagnostics.py @@ -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 diff --git a/核桃派软件源代码/app/ota/manager.py b/核桃派软件源代码/app/ota/manager.py new file mode 100644 index 0000000..d4dc44a --- /dev/null +++ b/核桃派软件源代码/app/ota/manager.py @@ -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()) diff --git a/核桃派软件源代码/app/ota/package.py b/核桃派软件源代码/app/ota/package.py new file mode 100644 index 0000000..6eea90f --- /dev/null +++ b/核桃派软件源代码/app/ota/package.py @@ -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) diff --git a/核桃派软件源代码/app/ota/policy.py b/核桃派软件源代码/app/ota/policy.py new file mode 100644 index 0000000..1776e3f --- /dev/null +++ b/核桃派软件源代码/app/ota/policy.py @@ -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"] diff --git a/核桃派软件源代码/app/ota/state.py b/核桃派软件源代码/app/ota/state.py new file mode 100644 index 0000000..add23e4 --- /dev/null +++ b/核桃派软件源代码/app/ota/state.py @@ -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)}, + ) diff --git a/核桃派软件源代码/app/ota/versioning.py b/核桃派软件源代码/app/ota/versioning.py new file mode 100644 index 0000000..56028bb --- /dev/null +++ b/核桃派软件源代码/app/ota/versioning.py @@ -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 diff --git a/核桃派软件源代码/app/persistence.py b/核桃派软件源代码/app/persistence.py new file mode 100644 index 0000000..e3b374e --- /dev/null +++ b/核桃派软件源代码/app/persistence.py @@ -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) diff --git a/核桃派软件源代码/app/power/__init__.py b/核桃派软件源代码/app/power/__init__.py new file mode 100644 index 0000000..4f40e65 --- /dev/null +++ b/核桃派软件源代码/app/power/__init__.py @@ -0,0 +1,5 @@ +"""屏幕供电监测组件。""" + +from app.power.voltage import VoltageMonitor + +__all__ = ["VoltageMonitor"] diff --git a/核桃派软件源代码/app/power/protection.py b/核桃派软件源代码/app/power/protection.py new file mode 100644 index 0000000..5a3ab66 --- /dev/null +++ b/核桃派软件源代码/app/power/protection.py @@ -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)) diff --git a/核桃派软件源代码/app/power/voltage.py b/核桃派软件源代码/app/power/voltage.py new file mode 100644 index 0000000..4147638 --- /dev/null +++ b/核桃派软件源代码/app/power/voltage.py @@ -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 diff --git a/核桃派软件源代码/app/static/animation-thumbnail-preview.js b/核桃派软件源代码/app/static/animation-thumbnail-preview.js new file mode 100644 index 0000000..07b0c90 --- /dev/null +++ b/核桃派软件源代码/app/static/animation-thumbnail-preview.js @@ -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; +} diff --git a/核桃派软件源代码/app/static/animation-ui-model.js b/核桃派软件源代码/app/static/animation-ui-model.js new file mode 100644 index 0000000..50024da --- /dev/null +++ b/核桃派软件源代码/app/static/animation-ui-model.js @@ -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)]; +} diff --git a/核桃派软件源代码/app/static/app.js b/核桃派软件源代码/app/static/app.js new file mode 100644 index 0000000..a150042 --- /dev/null +++ b/核桃派软件源代码/app/static/app.js @@ -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(); diff --git a/核桃派软件源代码/app/static/canvas-tools-model.js b/核桃派软件源代码/app/static/canvas-tools-model.js new file mode 100644 index 0000000..b86bdf0 --- /dev/null +++ b/核桃派软件源代码/app/static/canvas-tools-model.js @@ -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)), + }; +} diff --git a/核桃派软件源代码/app/static/color-math.js b/核桃派软件源代码/app/static/color-math.js new file mode 100644 index 0000000..59b8ff8 --- /dev/null +++ b/核桃派软件源代码/app/static/color-math.js @@ -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); +} diff --git a/核桃派软件源代码/app/static/color-picker.js b/核桃派软件源代码/app/static/color-picker.js new file mode 100644 index 0000000..42a2ec3 --- /dev/null +++ b/核桃派软件源代码/app/static/color-picker.js @@ -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)); +}); diff --git a/核桃派软件源代码/app/static/core.js b/核桃派软件源代码/app/static/core.js new file mode 100644 index 0000000..67c72d0 --- /dev/null +++ b/核桃派软件源代码/app/static/core.js @@ -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; +} diff --git a/核桃派软件源代码/app/static/current-display-playback.js b/核桃派软件源代码/app/static/current-display-playback.js new file mode 100644 index 0000000..263a6bf --- /dev/null +++ b/核桃派软件源代码/app/static/current-display-playback.js @@ -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(); + }); + } +} diff --git a/核桃派软件源代码/app/static/font-picker.js b/核桃派软件源代码/app/static/font-picker.js new file mode 100644 index 0000000..5802cc9 --- /dev/null +++ b/核桃派软件源代码/app/static/font-picker.js @@ -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, + }); +} diff --git a/核桃派软件源代码/app/static/index.html b/核桃派软件源代码/app/static/index.html new file mode 100644 index 0000000..aeb33fb --- /dev/null +++ b/核桃派软件源代码/app/static/index.html @@ -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>奇妙小屏幕控制器 + + + + +
+ + + + + + +
+
+
+

设备状态和测试

+

控制台已就绪

+
+
+ + + + +
+ 正在读取电压… +
+
+
+ + + + + +
+
+
+
+

64 × 64 统一画板

+

合成预览

+
+
+ 正向 0° · 统一画板 + +
+
+
+ + + +
+

正在恢复画板草稿…

+
+ + + + +
+ +
+ +
+ +
+ + + + + + + + + + + + + +
+
+
+ +
+
+

保存为模板

+ +
+ +
+ + +
+
+
+ +
+

确认模板操作

+

+
+ + +
+
+
+ +
+

保存模板

+

请选择保存方式。

+
+ + + +
+
+
+ +
+

选择复制目标

+ +
+
+
+ +
+

当前动图帧尚未保存

+

是否应用当前修改到动图?

+
+
+
+ +
+

确认动图操作

+

+
+ + +
+
+
+ +
+

选择批量复制目标

+ +
+ + +
+
+
+ +
+

选择插入位置

+

选择目标帧后,所选帧将插入到该帧之后;选择“插入到最前”则置于第一帧前。

+ +
+ + +
+
+
+ +
+

校准电压传感器

+

保持整机正常约 5V。把可靠万用表并联到 ADC 相同的屏幕输入 5V/GND 测量点,再输入万用表读数。

+ +

点击“采样并预览”,约需 1 秒。

+ +
+ + + +
+
+
+ +
+
+
+

当前画面预览

+

正在读取…

+
+ +
+ + +
+
+ +
+

资源占用详情

+

+
+

CPU

+
+
应用占用核心
-
+
折合单核占用
-
+
占整机 CPU
-
+
整机 CPU 总占用
-
+
+
+
+
+

内存

+
+
应用常驻内存
-
+
应用占整机内存
-
+
整机内存总占用
-
+
+
+
+ +
+
+
+ +
+
+
+

OTA 更新失败日志

+

可将完整内容复制后提供给维护人员排查。

+
+
+

+        
+        
+ + +
+
+
+ +
+
+
+

编辑 FRP 配置文件

+

配置可能包含服务器地址和令牌,请只在可信网络中操作。

+
+
+ + + + + +

+
+ + + +
+
+ + +
+
+
+ +
+

网络检测结果

+

正在检测…

+
    +
    + +
    +
    +
    + + + + + + diff --git a/核桃派软件源代码/app/static/interaction-feedback.js b/核桃派软件源代码/app/static/interaction-feedback.js new file mode 100644 index 0000000..d55a026 --- /dev/null +++ b/核桃派软件源代码/app/static/interaction-feedback.js @@ -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); diff --git a/核桃派软件源代码/app/static/media-crop-geometry.js b/核桃派软件源代码/app/static/media-crop-geometry.js new file mode 100644 index 0000000..0d17e2f --- /dev/null +++ b/核桃派软件源代码/app/static/media-crop-geometry.js @@ -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))), + }; +} diff --git a/核桃派软件源代码/app/static/media-import-ui-model.js b/核桃派软件源代码/app/static/media-import-ui-model.js new file mode 100644 index 0000000..794003e --- /dev/null +++ b/核桃派软件源代码/app/static/media-import-ui-model.js @@ -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; +} diff --git a/核桃派软件源代码/app/static/scene-board.js b/核桃派软件源代码/app/static/scene-board.js new file mode 100644 index 0000000..7c16445 --- /dev/null +++ b/核桃派软件源代码/app/static/scene-board.js @@ -0,0 +1,1192 @@ +import { announce, apiBlob, isAppStale } from "./core.js"; +import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js"; +import { + RGB_BYTE_LENGTH, + SCENE_HEIGHT, + SCENE_STORAGE_KEY, + SCENE_WIDTH, + addTextElement as addTextToScene, + compositeRgbaLayers, + createRgbPixels, + createScene, + deleteTextElement as deleteTextFromScene, + deserializeScene, + duplicateTextElement as duplicateTextInScene, + inspectSceneStorageValue, + restoreSceneDraft, + serializeScene, + updateTextElement as updateTextInScene, +} from "./scene-model.js"; +import { animationEditContextName } from "./animation-ui-model.js"; +import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js"; +import { + createTemplateEditContext, + sceneNeedsTemplateSave, + templateEditSnapshot, +} from "./template-edit-model.js"; + +const visibleCanvas = document.getElementById("scene-canvas"); +const overlayCanvas = document.getElementById("scene-overlay"); +const visibleContext = visibleCanvas.getContext("2d"); +const overlayContext = overlayCanvas.getContext("2d"); +const compositionCanvas = document.createElement("canvas"); +compositionCanvas.width = SCENE_WIDTH; +compositionCanvas.height = SCENE_HEIGHT; +const compositionContext = compositionCanvas.getContext("2d", { willReadFrequently: true }); +const scratchCanvas = document.createElement("canvas"); +scratchCanvas.width = SCENE_WIDTH; +scratchCanvas.height = SCENE_HEIGHT; +const scratchContext = scratchCanvas.getContext("2d", { willReadFrequently: true }); +const boardTitle = document.getElementById("board-title"); +const boardEyebrow = document.getElementById("board-eyebrow"); +const boardMode = document.getElementById("board-mode"); +const boardStatus = document.getElementById("board-status"); +const boardShell = document.getElementById("board-shell"); +const standardBoardShell = document.getElementById("standard-board-shell"); +const animationPreviewShell = document.getElementById("animation-preview-shell"); +const animationBoardPreview = document.getElementById("animation-board-preview"); +const animationBoardPreviewContext = animationBoardPreview.getContext("2d"); +const applyButton = document.getElementById("apply-composition"); +const exportButton = document.getElementById("export-composition"); +const exitAnimationEditButton = document.getElementById("exit-animation-edit"); +const animationEditNotice = document.getElementById("animation-edit-notice"); +const animationEditName = document.getElementById("animation-edit-name"); +const animationEditStatus = document.getElementById("animation-edit-status"); +const MONITOR_WORKSPACES = new Set(["device", "settings", "templates"]); +const DEFAULT_PREVIEW_REFRESH_INTERVAL_MS = 1000; +const MIN_PREVIEW_REFRESH_INTERVAL_MS = 1; +const MAX_PREVIEW_REFRESH_INTERVAL_MS = 60000; + +visibleContext.imageSmoothingEnabled = false; +compositionContext.imageSmoothingEnabled = false; +scratchContext.imageSmoothingEnabled = false; +animationBoardPreviewContext.imageSmoothingEnabled = false; + +const listeners = new Set(); +const textLayers = new Map(); +function browserStorage() { + try { + return window.localStorage; + } catch (_error) { + return null; + } +} + +const draftStorage = browserStorage(); +const draftLoad = restoreSceneDraft(draftStorage); +let scene = draftLoad.scene; +let selectedTextId = null; +let interactionMode = "readonly"; +let activeWorkspace = "device"; +let previewKind = "scene"; +let previewLabel = ""; +let previewSequence = 0; +let previewRefreshIntervalMs = DEFAULT_PREVIEW_REFRESH_INTERVAL_MS; +let monitorTimer = null; +let monitorRequestPending = false; +let monitorRefreshQueued = false; +let activeTransform = null; +let activeGesture = null; +let applying = false; +let animationEdit = null; +let ordinarySceneBackup = null; +let templateEdit = null; +let sceneReplacementGuard = async () => true; +let lastExactRgb = null; +let sceneSource = null; +let draftConflict = false; +let externalDraftValue = null; +let draftPersistenceBlocked = !draftLoad.canPersist; +let draftProtectionNotice = ( + draftPersistenceBlocked || draftLoad.status === "cleanup-warning" +) ? draftLoad.notice : ""; + +const draftConflictNotice = document.getElementById("draft-conflict-notice"); +const loadExternalDraftButton = document.getElementById("load-external-draft"); +const keepCurrentDraftButton = document.getElementById("keep-current-draft"); + +function blockDraftPersistence(message) { + draftPersistenceBlocked = true; + draftProtectionNotice = message; + externalDraftValue = null; + draftConflict = false; + draftConflictNotice.hidden = true; + updateBoardUi(); + announce(message, true); +} + +export function persistScene() { + if (animationEdit) { + animationEdit.dirty = serializeScene(scene) !== animationEdit.baseline; + updateBoardUi(); + document.dispatchEvent(new CustomEvent("matrix:animation-edit-change", { detail: animationEditState() })); + return true; + } + if (draftConflict || draftPersistenceBlocked) return false; + try { + if (!draftStorage) throw new Error("browser storage unavailable"); + draftStorage.setItem(SCENE_STORAGE_KEY, serializeScene(scene)); + return true; + } catch (_error) { + blockDraftPersistence("浏览器无法保存统一画板草稿;原草稿已保留,本页不会继续覆盖或应用它。"); + return false; + } +} + +function setDraftConflict(value) { + draftConflict = value; + draftConflictNotice.hidden = !value; + updateBoardUi(); +} + +window.addEventListener("storage", (event) => { + if (!draftStorage || event.storageArea !== draftStorage || event.key !== SCENE_STORAGE_KEY) return; + const external = inspectSceneStorageValue(event.newValue); + if (external.status === "future") { + blockDraftPersistence("另一标签页写入了更高版本画板草稿;原值已保留,本页不会覆盖或应用它。"); + return; + } + if (external.status === "damaged") { + blockDraftPersistence("另一标签页写入了无法识别的画板草稿;原值已保留,本页不会覆盖或应用它。"); + return; + } + if (external.status !== "valid") return; + externalDraftValue = event.newValue; + setDraftConflict(true); + announce("另一标签页修改了画板草稿,请选择要保留的版本", true); +}); + +loadExternalDraftButton.addEventListener("click", async () => { + const value = externalDraftValue; + if (!value) return; + if (animationEdit && !await sceneReplacementGuard()) return; + externalDraftValue = null; + setDraftConflict(false); + replaceSceneFromStorage(value, { persist: false, reason: "tab-sync" }); + announce("已载入另一标签页的最新画板草稿"); +}); + +keepCurrentDraftButton.addEventListener("click", () => { + externalDraftValue = null; + setDraftConflict(false); + persistScene(); + announce("已保留当前标签页草稿并覆盖共享草稿"); +}); + +function elementSignature(element) { + return JSON.stringify({ + text: element.text, + font: element.font, + size: element.size, + x: element.x, + y: element.y, + align: element.align, + color: element.color, + }); +} + +function textPayload(element) { + return { + text: element.text, + font: element.font, + size: element.size, + x: element.x, + y: element.y, + align: element.align, + color: element.color, + }; +} + +function elementById(id) { + return scene.elements.find((element) => element.id === id) || null; +} + +function closeImage(image) { + if (image && typeof image.close === "function") image.close(); +} + +async function imageFromBlob(blob) { + if (typeof createImageBitmap === "function") return createImageBitmap(blob); + const url = URL.createObjectURL(blob); + try { + return await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("透明文字图层解码失败")); + image.src = url; + }); + } finally { + URL.revokeObjectURL(url); + } +} + +function alphaBounds(rgba) { + let left = SCENE_WIDTH; + let top = SCENE_HEIGHT; + let right = -1; + let bottom = -1; + for (let y = 0; y < SCENE_HEIGHT; y += 1) { + for (let x = 0; x < SCENE_WIDTH; x += 1) { + if (rgba[(y * SCENE_WIDTH + x) * 4 + 3] === 0) continue; + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + } + } + if (right < left || bottom < top) return null; + return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 }; +} + +function layerEntry(id) { + if (!textLayers.has(id)) { + textLayers.set(id, { + controller: null, + error: null, + image: null, + rgba: null, + bounds: null, + signature: null, + revision: null, + targetSignature: null, + targetRevision: null, + status: "idle", + timer: null, + }); + } + return textLayers.get(id); +} + +function removeUnusedLayers() { + const ids = new Set(scene.elements.map((element) => element.id)); + textLayers.forEach((entry, id) => { + if (ids.has(id)) return; + window.clearTimeout(entry.timer); + entry.controller?.abort(); + closeImage(entry.image); + textLayers.delete(id); + }); +} + +function scheduleTextLayer(element, immediate = false) { + const signature = elementSignature(element); + const entry = layerEntry(element.id); + if (entry.status === "ready" && entry.signature === signature) { + entry.revision = element.revision; + entry.targetRevision = element.revision; + return; + } + entry.targetSignature = signature; + entry.targetRevision = element.revision; + entry.status = "pending"; + entry.error = null; + window.clearTimeout(entry.timer); + entry.timer = window.setTimeout( + () => loadTextLayer(element.id, signature, element.revision), + immediate ? 0 : 120, + ); + renderComposition(); +} + +async function loadTextLayer(id, requestedSignature, requestedRevision) { + const element = elementById(id); + const entry = textLayers.get(id); + if ( + !element + || !entry + || elementSignature(element) !== requestedSignature + || element.revision !== requestedRevision + ) return; + entry.controller?.abort(); + const controller = new AbortController(); + entry.controller = controller; + try { + const blob = await apiBlob("/api/preview/text-layer", { + method: "POST", + body: JSON.stringify(textPayload(element)), + signal: controller.signal, + }); + const image = await imageFromBlob(blob); + const current = elementById(id); + if ( + !current + || elementSignature(current) !== requestedSignature + || current.revision !== requestedRevision + || entry.targetSignature !== requestedSignature + || entry.targetRevision !== requestedRevision + ) { + closeImage(image); + return; + } + scratchContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); + scratchContext.drawImage(image, 0, 0); + const rgba = new Uint8ClampedArray( + scratchContext.getImageData(0, 0, SCENE_WIDTH, SCENE_HEIGHT).data, + ); + closeImage(entry.image); + entry.image = image; + entry.rgba = rgba; + entry.bounds = alphaBounds(rgba); + entry.signature = requestedSignature; + entry.revision = requestedRevision; + entry.targetSignature = requestedSignature; + entry.targetRevision = requestedRevision; + entry.status = "ready"; + entry.error = null; + renderComposition(); + emitSceneChange("layer"); + } catch (error) { + if (error.name === "AbortError") return; + entry.status = "error"; + entry.error = error.message; + renderComposition(); + announce(`文字图层渲染失败:${error.message}`, true); + emitSceneChange("layer-error"); + } finally { + if (entry.controller === controller) entry.controller = null; + } +} + +function layerState() { + let pending = 0; + let errors = 0; + scene.elements.forEach((element) => { + const entry = textLayers.get(element.id); + const signature = elementSignature(element); + if ( + !entry + || entry.status === "pending" + || entry.signature !== signature + || entry.revision !== element.revision + ) pending += 1; + if ( + entry?.status === "error" + && entry.targetSignature === signature + && entry.targetRevision === element.revision + ) errors += 1; + }); + return { pending, errors, ready: pending === 0 && errors === 0 }; +} + +function putRgb(context, rgb) { + const image = context.createImageData(SCENE_WIDTH, SCENE_HEIGHT); + for (let source = 0, target = 0; source < rgb.length; source += 3, target += 4) { + image.data[target] = rgb[source]; + image.data[target + 1] = rgb[source + 1]; + image.data[target + 2] = rgb[source + 2]; + image.data[target + 3] = 255; + } + context.putImageData(image, 0, 0); +} + +function drawTransformedLayer(context, element, entry) { + if (!entry?.image) return; + if (!activeTransform || activeTransform.id !== element.id) { + context.drawImage(entry.image, 0, 0); + return; + } + context.save(); + if (activeTransform.kind === "drag") { + context.translate(activeTransform.dx, activeTransform.dy); + } else { + context.translate(element.x, element.y); + context.scale(activeTransform.scale, activeTransform.scale); + context.translate(-element.x, -element.y); + } + context.drawImage(entry.image, 0, 0); + context.restore(); +} + +function renderComposition() { + removeUnusedLayers(); + scene.elements.forEach((element) => scheduleTextLayerIfMissing(element)); + const state = layerState(); + if (activeTransform) { + putRgb(compositionContext, scene.pixels); + scene.elements.forEach((element) => drawTransformedLayer( + compositionContext, + element, + textLayers.get(element.id), + )); + lastExactRgb = null; + } else { + const rgbaLayers = scene.elements + .map((element) => textLayers.get(element.id)?.rgba) + .filter(Boolean); + const rgb = compositeRgbaLayers(scene.pixels, rgbaLayers); + putRgb(compositionContext, rgb); + lastExactRgb = state.ready ? rgb : null; + } + if (previewKind === "scene") { + visibleContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); + visibleContext.drawImage(compositionCanvas, 0, 0); + } + animationBoardPreviewContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); + animationBoardPreviewContext.drawImage(compositionCanvas, 0, 0); + drawSelectionOverlay(); + updateBoardUi(state); +} + +function scheduleTextLayerIfMissing(element) { + const entry = textLayers.get(element.id); + if (!entry || (entry.status === "idle" && !entry.timer)) scheduleTextLayer(element, true); +} + +function updateBoardUi(state = layerState()) { + applyButton.textContent = animationEdit ? "保存当前动图帧" : activeWorkspace === "device" ? "恢复统一画板" : "应用统一画板"; + exitAnimationEditButton.hidden = !animationEdit; + const showAnimationEditNotice = Boolean(animationEdit && ["canvas", "text"].includes(activeWorkspace)); + animationEditNotice.hidden = !showAnimationEditNotice; + boardMode.hidden = showAnimationEditNotice; + if (animationEdit) { + const displayName = animationEditContextName(animationEdit.animationName, { name: animationEdit.frameName }, animationEdit.position); + animationEditName.textContent = displayName; + animationEditStatus.textContent = animationEdit.dirty ? "尚未保存" : "已保存"; + animationEditNotice.dataset.state = animationEdit.dirty ? "dirty" : "saved"; + } + if (applying) { + setControlBusy(applyButton, { + label: animationEdit ? "正在保存…" : "正在应用…", + message: animationEdit ? "正在保存当前动图帧…" : "正在把统一画板应用到屏幕…", + }); + } else if (isAppStale()) { + setControlUnavailable(applyButton, "控制程序已更新,请先刷新页面再提交画板。"); + } else if (draftPersistenceBlocked) { + setControlUnavailable(applyButton, "浏览器无法安全保存当前草稿,请先处理页面提示后再应用。"); + } else if (draftConflict) { + setControlUnavailable(applyButton, "请先解决另一标签页的草稿冲突,再应用画板。"); + } else if (activeTransform) { + setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。"); + } else if (!state.ready) { + setControlUnavailable(applyButton, state.errors + ? "有文字图层渲染失败,请修正后再应用完整画面。" + : "请等待文字图层生成完成后再应用画板。"); + } else { + setControlReady(applyButton); + } + if (activeTransform) { + setControlUnavailable(exportButton, "请先完成当前文字拖动或缩放操作。"); + } else if (!state.ready) { + setControlUnavailable(exportButton, state.errors + ? "有文字图层渲染失败,请修正后再导出。" + : "请等待文字图层生成完成后再导出。"); + } else { + setControlReady(exportButton); + } + if (previewKind === "monitor") { + boardStatus.textContent = previewLabel; + boardStatus.hidden = !previewLabel; + boardStatus.dataset.state = previewLabel.includes("失败") ? "error" : "pending"; + return; + } + boardStatus.hidden = false; + if (draftPersistenceBlocked) { + boardStatus.textContent = draftProtectionNotice; + boardStatus.dataset.state = "error"; + return; + } else if (draftProtectionNotice) { + boardStatus.textContent = draftProtectionNotice; + boardStatus.dataset.state = "error"; + return; + } else if (previewKind === "test") { + boardStatus.textContent = `独占测试预览:${previewLabel}。测试不会修改统一画板草稿。`; + boardStatus.dataset.state = "ready"; + return; + } + if (draftConflict) { + boardStatus.textContent = "另一标签页已修改共享草稿;当前编辑暂存在本页内存,解决冲突前不会覆盖或应用。"; + boardStatus.dataset.state = "error"; + } else if (state.errors) { + boardStatus.textContent = `${state.errors} 个文字图层渲染失败,修正前不会应用不完整画面。`; + boardStatus.dataset.state = "error"; + } else if (state.pending) { + boardStatus.textContent = `正在生成 ${state.pending} 个精确文字图层…`; + boardStatus.dataset.state = "pending"; + } else { + boardStatus.textContent = animationEdit + ? `像素底层 + ${scene.elements.length} 个文字元素 · 动图帧编辑中` + : `像素底层 + ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`; + boardStatus.dataset.state = "ready"; + } +} + +function fallbackBounds(element) { + const estimatedWidth = Math.max(4, Math.min(64, Math.ceil(Math.max(1, element.text.length) * element.size * 0.58))); + const estimatedHeight = Math.max(4, Math.min(64, Math.ceil(element.size * 1.2))); + let x = element.x; + if (element.align === "center") x -= estimatedWidth / 2; + else if (element.align === "right") x -= estimatedWidth; + return { x, y: element.y, width: estimatedWidth, height: estimatedHeight }; +} + +function transformedBounds(element) { + const source = textLayers.get(element.id)?.bounds || fallbackBounds(element); + if (!activeTransform || activeTransform.id !== element.id) return source; + if (activeTransform.kind === "drag") { + return { + ...source, + x: source.x + activeTransform.dx, + y: source.y + activeTransform.dy, + }; + } + return { + x: element.x + (source.x - element.x) * activeTransform.scale, + y: element.y + (source.y - element.y) * activeTransform.scale, + width: source.width * activeTransform.scale, + height: source.height * activeTransform.scale, + }; +} + +function drawSelectionOverlay() { + overlayContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); + if (interactionMode !== "text" || previewKind !== "scene" || !selectedTextId) return; + const element = elementById(selectedTextId); + if (!element) return; + const bounds = transformedBounds(element); + overlayContext.save(); + overlayContext.strokeStyle = "#72E0C5"; + overlayContext.fillStyle = "#0D1015"; + overlayContext.lineWidth = 0.55; + overlayContext.setLineDash([1.2, 0.8]); + overlayContext.strokeRect(bounds.x - 0.4, bounds.y - 0.4, bounds.width + 0.8, bounds.height + 0.8); + overlayContext.setLineDash([]); + const handleX = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width)); + const handleY = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height)); + overlayContext.fillRect(handleX - 1.5, handleY - 1.5, 3, 3); + overlayContext.strokeRect(handleX - 1.5, handleY - 1.5, 3, 3); + overlayContext.restore(); +} + +function emitSceneChange(reason) { + const detail = { scene, selectedTextId, reason }; + listeners.forEach((listener) => listener(detail)); + document.dispatchEvent(new CustomEvent("matrix:scene-change", { detail })); +} + +export function subscribeScene(listener) { + listeners.add(listener); + listener({ scene, selectedTextId, reason: "subscribe" }); + return () => listeners.delete(listener); +} + +export function getScene() { + return scene; +} + +export function animationEditState() { + return animationEdit ? { ...animationEdit } : null; +} + +export function templateEditState() { + if (animationEdit && ordinarySceneBackup) { + return templateEditSnapshot(ordinarySceneBackup.templateEdit, ordinarySceneBackup.scene); + } + return templateEditSnapshot(templateEdit, serializeScene(scene)); +} + +export function ordinarySceneNeedsTemplateSave() { + if (animationEdit && ordinarySceneBackup) { + return sceneNeedsTemplateSave(ordinarySceneBackup.templateEdit, ordinarySceneBackup.scene); + } + return sceneNeedsTemplateSave(templateEdit, serializeScene(scene)); +} + +export function registerSceneReplacementGuard(guard) { + sceneReplacementGuard = typeof guard === "function" ? guard : async () => true; +} + +export function enterAnimationEdit(frame) { + if (!animationEdit) { + ordinarySceneBackup = { + scene: serializeScene(scene), + source: sceneSource ? { ...sceneSource } : null, + templateEdit: templateEdit ? { ...templateEdit } : null, + }; + } + animationEdit = { + animationId: frame.animation_id, + animationName: frame.animation_name, + revision: frame.animation_revision, + frameId: frame.id, + frameName: frame.name, + position: frame.position, + dirty: false, + baseline: "", + }; + replaceSceneFromStorage(frame.scene, { persist: false, reason: "animation-frame-load" }); + animationEdit.baseline = serializeScene(scene); + updateBoardUi(); + document.dispatchEvent(new CustomEvent("matrix:animation-edit-change", { detail: animationEditState() })); +} + +export function markAnimationFrameSaved(detail) { + if (!animationEdit) return; + animationEdit.revision = detail.revision; + if (Object.hasOwn(detail, "name")) animationEdit.frameName = detail.name; + if (detail.position) animationEdit.position = detail.position; + animationEdit.dirty = false; + animationEdit.baseline = serializeScene(scene); + updateBoardUi(); +} + +export function syncAnimationEditMetadata(animation) { + if (!animationEdit || animation?.id !== animationEdit.animationId || !Array.isArray(animation.frames)) return; + const frame = animation.frames.find((item) => item.id === animationEdit.frameId); + if (!frame) return; + animationEdit.animationName = animation.name; + animationEdit.revision = animation.revision; + animationEdit.frameName = frame.name; + animationEdit.position = frame.position; + updateBoardUi(); +} + +export function leaveAnimationEdit() { + if (!animationEdit) return; + const backup = ordinarySceneBackup; + animationEdit = null; + ordinarySceneBackup = null; + if (backup) { + replaceSceneFromStorage(backup.scene, { + persist: false, + reason: "animation-edit-exit", + source: backup.source, + templateEditContext: backup.templateEdit, + }); + } + updateBoardUi(); + document.dispatchEvent(new CustomEvent("matrix:animation-edit-change", { detail: null })); +} + +export function replaceSceneFromStorage( + value, + { + persist = true, + reason = "template-load", + source = null, + templateEditContext = null, + } = {}, +) { + const restored = deserializeScene(value); + if (!restored) throw new Error("模板场景无效,无法载入"); + textLayers.forEach((entry) => { + window.clearTimeout(entry.timer); + entry.controller?.abort(); + closeImage(entry.image); + }); + textLayers.clear(); + activeTransform = null; + activeGesture = null; + scene = restored; + sceneSource = source; + templateEdit = templateEditContext + ? createTemplateEditContext( + templateEditContext, + templateEditContext.baseline || serializeScene(restored), + ) + : null; + selectedTextId = scene.elements.at(-1)?.id || null; + if (persist) persistScene(); + scene.elements.forEach((element) => scheduleTextLayer(element, true)); + showCompositionPreview(); + emitSceneChange(reason); + document.dispatchEvent(new CustomEvent("matrix:scene-replaced", { detail: { reason } })); + document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() })); + return scene; +} + +export function enterTemplateEdit(template) { + const source = { type: "template", id: template.id, revision: template.revision }; + return replaceSceneFromStorage(template.scene, { + source, + reason: "template-edit-load", + templateEditContext: { + id: template.id, + name: template.name, + revision: template.revision, + readOnly: Boolean(template.read_only), + }, + }); +} + +export function markTemplateSaved(template) { + if (animationEdit) return false; + templateEdit = createTemplateEditContext(template, serializeScene(scene)); + sceneSource = { type: "template", id: template.id, revision: template.revision }; + updateBoardUi(); + document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() })); + return true; +} + +export function getPixelBuffer() { + return scene.pixels; +} + +export function getInteractionMode() { + return interactionMode; +} + +export function boardPoint(event, { round = true } = {}) { + const rect = overlayCanvas.getBoundingClientRect(); + const x = ((event.clientX - rect.left) / rect.width) * SCENE_WIDTH; + const y = ((event.clientY - rect.top) / rect.height) * SCENE_HEIGHT; + return { + x: round ? Math.max(0, Math.min(63, Math.floor(x))) : Math.max(0, Math.min(64, x)), + y: round ? Math.max(0, Math.min(63, Math.floor(y))) : Math.max(0, Math.min(64, y)), + }; +} + +export function getBoardOverlay() { + return overlayCanvas; +} + +export function renderPixelDraft() { + renderComposition(); +} + +export function commitPixelChanges(reason = "pixels") { + sceneSource = null; + persistScene(); + renderComposition(); + emitSceneChange(reason); +} + +export function fillPixelLayer(rgb) { + scene = createScene({ pixels: createRgbPixels(rgb), elements: scene.elements }); + commitPixelChanges("pixel-fill"); +} + +export function selectTextElement(id, reason = "selection") { + selectedTextId = elementById(id)?.id || null; + drawSelectionOverlay(); + emitSceneChange(reason); + return selectedTextId; +} + +export function getSelectedTextElement() { + return elementById(selectedTextId); +} + +export function addTextElement(overrides = {}) { + sceneSource = null; + scene = addTextToScene(scene, overrides); + const created = scene.elements.at(-1); + selectedTextId = created.id; + persistScene(); + scheduleTextLayer(created, true); + renderComposition(); + emitSceneChange("create"); + return created; +} + +export function updateTextElement(id, patch, reason = "update") { + const previous = scene; + scene = updateTextInScene(scene, id, patch); + if (scene === previous) return null; + sceneSource = null; + const updated = elementById(id); + persistScene(); + if (updated) scheduleTextLayer(updated); + renderComposition(); + emitSceneChange(reason); + return updated; +} + +export function duplicateTextElement(id = selectedTextId) { + const previousLength = scene.elements.length; + scene = duplicateTextInScene(scene, id); + if (scene.elements.length === previousLength) return null; + sceneSource = null; + const duplicate = scene.elements.at(-1); + selectedTextId = duplicate.id; + persistScene(); + scheduleTextLayer(duplicate, true); + renderComposition(); + emitSceneChange("duplicate"); + return duplicate; +} + +export function deleteTextElement(id = selectedTextId) { + const previousLength = scene.elements.length; + scene = deleteTextFromScene(scene, id); + if (scene.elements.length === previousLength) return false; + sceneSource = null; + selectedTextId = scene.elements.at(-1)?.id || null; + persistScene(); + renderComposition(); + emitSceneChange("delete"); + return true; +} + +function hitTestText(point) { + for (let index = scene.elements.length - 1; index >= 0; index -= 1) { + const element = scene.elements[index]; + const bounds = transformedBounds(element); + if ( + point.x >= bounds.x - 1 + && point.x <= bounds.x + bounds.width + 1 + && point.y >= bounds.y - 1 + && point.y <= bounds.y + bounds.height + 1 + ) return element.id; + } + return null; +} + +function resizeHandleHit(point, element) { + const bounds = transformedBounds(element); + const x = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width)); + const y = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height)); + return Math.abs(point.x - x) <= 3.5 && Math.abs(point.y - y) <= 3.5; +} + +function startTextGesture(event) { + if (interactionMode !== "text" || previewKind !== "scene") return; + const point = boardPoint(event, { round: false }); + let element = getSelectedTextElement(); + if (element && resizeHandleHit(point, element)) { + const distance = Math.hypot(point.x - element.x, point.y - element.y); + activeGesture = { + kind: "resize", + id: element.id, + pointerId: event.pointerId, + startDistance: Math.max(1, distance), + startSize: element.size, + }; + } else { + const hit = hitTestText(point); + if (!hit) { + selectTextElement(null); + return; + } + if (hit !== selectedTextId) selectTextElement(hit); + element = elementById(hit); + activeGesture = { + kind: "drag", + id: element.id, + pointerId: event.pointerId, + startPoint: point, + startX: element.x, + startY: element.y, + }; + } + event.preventDefault(); + overlayCanvas.setPointerCapture(event.pointerId); +} + +function moveTextGesture(event) { + if (!activeGesture || activeGesture.pointerId !== event.pointerId) return; + const point = boardPoint(event, { round: false }); + const element = elementById(activeGesture.id); + if (!element) return; + if (activeGesture.kind === "drag") { + activeTransform = { + id: element.id, + kind: "drag", + dx: point.x - activeGesture.startPoint.x, + dy: point.y - activeGesture.startPoint.y, + }; + } else { + const distance = Math.hypot(point.x - element.x, point.y - element.y); + const minimum = 1 / activeGesture.startSize; + const maximum = 64 / activeGesture.startSize; + activeTransform = { + id: element.id, + kind: "resize", + scale: Math.max(minimum, Math.min(maximum, distance / activeGesture.startDistance)), + }; + } + event.preventDefault(); + renderComposition(); +} + +function finishTextGesture(event, commit = true) { + if (!activeGesture || activeGesture.pointerId !== event.pointerId) return; + const gesture = activeGesture; + const transform = activeTransform; + activeGesture = null; + activeTransform = null; + if (commit && transform) { + if (gesture.kind === "drag") { + updateTextElement(gesture.id, { + x: gesture.startX + transform.dx, + y: gesture.startY + transform.dy, + }, "gesture"); + } else { + updateTextElement(gesture.id, { + size: gesture.startSize * transform.scale, + }, "gesture"); + } + } else { + renderComposition(); + } +} + +overlayCanvas.addEventListener("pointerdown", startTextGesture); +overlayCanvas.addEventListener("pointermove", moveTextGesture); +overlayCanvas.addEventListener("pointerup", (event) => finishTextGesture(event, true)); +overlayCanvas.addEventListener("pointercancel", (event) => finishTextGesture(event, false)); + +export function setBoardWorkspace(workspaceId) { + const wasMonitorWorkspace = MONITOR_WORKSPACES.has(activeWorkspace); + activeWorkspace = workspaceId; + interactionMode = workspaceId === "canvas" ? "pixels" : workspaceId === "text" ? "text" : "readonly"; + overlayCanvas.dataset.interaction = interactionMode; + const isMonitor = MONITOR_WORKSPACES.has(workspaceId); + const isAnimationWorkspace = workspaceId === "animations"; + boardShell.classList.toggle("board-shell-monitor", isMonitor); + boardShell.classList.toggle("board-shell-device-monitor", workspaceId === "device"); + boardShell.classList.toggle("board-shell-animation", isAnimationWorkspace); + standardBoardShell.hidden = isAnimationWorkspace; + animationPreviewShell.hidden = !isAnimationWorkspace; + boardShell.setAttribute("aria-labelledby", isAnimationWorkspace ? "animation-preview-shell-title" : "board-title"); + if (isMonitor) { + if (!wasMonitorWorkspace) previewLabel = "正在读取当前屏幕画面…"; + refreshCurrentDisplayPreview(); + } else { + stopCurrentDisplayMonitor(); + showCompositionPreview(); + } + updateBoardUi(); + drawSelectionOverlay(); +} + +function clearMonitorTimer() { + if (monitorTimer !== null) { + window.clearTimeout(monitorTimer); + monitorTimer = null; + } +} + +function stopCurrentDisplayMonitor() { + clearMonitorTimer(); + monitorRefreshQueued = false; + previewSequence += 1; +} + +function scheduleCurrentDisplayRefresh(delay = previewRefreshIntervalMs) { + clearMonitorTimer(); + if (!MONITOR_WORKSPACES.has(activeWorkspace) || document.visibilityState !== "visible") return; + monitorTimer = window.setTimeout(() => { + monitorTimer = null; + refreshCurrentDisplayPreview(); + }, delay); +} + +export function setPreviewRefreshInterval(value) { + const parsed = Number(value); + if ( + !Number.isInteger(parsed) + || parsed < MIN_PREVIEW_REFRESH_INTERVAL_MS + || parsed > MAX_PREVIEW_REFRESH_INTERVAL_MS + ) return false; + const changed = parsed !== previewRefreshIntervalMs; + previewRefreshIntervalMs = parsed; + if ( + changed + && MONITOR_WORKSPACES.has(activeWorkspace) + && document.visibilityState === "visible" + ) { + refreshCurrentDisplayPreview(); + } + return true; +} + +export function refreshCurrentDisplayPreview() { + clearMonitorTimer(); + if (!MONITOR_WORKSPACES.has(activeWorkspace) || document.visibilityState !== "visible") return; + if (monitorRequestPending) { + monitorRefreshQueued = true; + return; + } + void showCurrentDisplayPreview(); +} + +async function showCurrentDisplayPreview() { + const sequence = ++previewSequence; + monitorRequestPending = true; + previewKind = "monitor"; + const compactDeviceMonitor = activeWorkspace === "device"; + boardEyebrow.hidden = compactDeviceMonitor; + boardMode.hidden = compactDeviceMonitor; + if (compactDeviceMonitor) { + renderUiValue(boardEyebrow, ""); + renderUiValue(boardMode, ""); + } else { + renderUiCopy("copy.dynamic.board.monitor_eyebrow", boardEyebrow, "64 × 64 屏幕监视器", {}, { defaultHidden: true }); + renderUiCopy("copy.dynamic.board.monitor_mode", boardMode, "预览当前显示屏幕画面", {}, { defaultHidden: true }); + } + renderUiCopy("copy.dynamic.board.monitor_title", boardTitle, "当前屏幕画面"); + overlayContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); + updateBoardUi(); + try { + const blob = await apiBlob("/api/display/current-frame"); + const image = await imageFromBlob(blob); + if (sequence !== previewSequence || !MONITOR_WORKSPACES.has(activeWorkspace)) { + closeImage(image); + return; + } + visibleContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); + visibleContext.drawImage(image, 0, 0); + closeImage(image); + previewLabel = ""; + updateBoardUi(); + } catch (error) { + if (sequence !== previewSequence || !MONITOR_WORKSPACES.has(activeWorkspace)) return; + previewLabel = `当前屏幕画面读取失败:${error.message}`; + updateBoardUi(); + } finally { + monitorRequestPending = false; + if (!MONITOR_WORKSPACES.has(activeWorkspace) || document.visibilityState !== "visible") return; + if (monitorRefreshQueued) { + monitorRefreshQueued = false; + refreshCurrentDisplayPreview(); + } else { + scheduleCurrentDisplayRefresh(); + } + } +} + +export function showCompositionPreview() { + clearMonitorTimer(); + previewSequence += 1; + previewKind = "scene"; + previewLabel = ""; + boardEyebrow.hidden = false; + boardMode.hidden = false; + renderUiCopy("copy.dynamic.board.composition_eyebrow", boardEyebrow, "64 × 64 统一画板", {}, { defaultHidden: true }); + renderUiCopy("copy.dynamic.board.composition_title", boardTitle, "合成预览", {}, { defaultHidden: true }); + renderUiCopy( + animationEdit ? "copy.dynamic.board.animation_edit_mode" : "copy.dynamic.board.composition_mode", + boardMode, + animationEdit ? "动图帧编辑 · 不会直接更新实屏" : "正向 0° · 统一画板", + {}, + animationEdit ? {} : { defaultHidden: true }, + ); + renderComposition(); +} + +function rgbToBase64(rgb) { + let binary = ""; + for (let index = 0; index < rgb.length; index += 1) binary += String.fromCharCode(rgb[index]); + return btoa(binary); +} + +function compositionRgb() { + const state = layerState(); + if (!state.ready || !lastExactRgb) throw new Error("文字图层尚未全部就绪"); + if (lastExactRgb.length !== RGB_BYTE_LENGTH) throw new Error("合成帧尺寸无效"); + return lastExactRgb; +} + +export async function applyComposition() { + if (applying) return; + if (isAppStale()) { + throw new Error("页面版本已过期,请刷新后再应用画板"); + } + const rgb = compositionRgb(); + applying = true; + updateBoardUi(); + boardStatus.textContent = "正在把统一画板应用到屏幕…"; + boardStatus.dataset.state = "pending"; + try { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + await new Promise((resolve, reject) => { + const socket = new WebSocket(`${protocol}//${window.location.host}/ws/canvas`); + let settled = false; + const timeout = window.setTimeout(() => { + if (settled) return; + settled = true; + socket.close(); + reject(new Error("画板应用超时")); + }, 10000); + const finish = (callback, value) => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + socket.close(); + callback(value); + }; + socket.onopen = () => socket.send(JSON.stringify({ + type: "frame_rgb", + source: "composition", + ...(sceneSource ? { content_source: sceneSource } : {}), + width: SCENE_WIDTH, + height: SCENE_HEIGHT, + encoding: "base64_rgb888", + data: rgbToBase64(rgb), + })); + socket.onmessage = (event) => { + const response = JSON.parse(event.data); + if (response.type === "ack" && response.mode === "composition") finish(resolve, response); + else if (response.type === "error") finish(reject, new Error(response.message)); + }; + socket.onerror = () => finish(reject, new Error("画板连接失败,请检查设备状态")); + socket.onclose = () => { + if (!settled) finish(reject, new Error("画板连接已断开")); + }; + }); + showCompositionPreview(); + boardStatus.textContent = "统一画板已应用到屏幕"; + boardStatus.dataset.state = "ready"; + announce("统一画板已应用到屏幕"); + document.dispatchEvent(new CustomEvent("matrix:composition-applied")); + } catch (error) { + boardStatus.textContent = `画板应用失败:${error.message}`; + boardStatus.dataset.state = "error"; + announce(`画板应用失败:${error.message}`, true); + throw error; + } finally { + applying = false; + updateBoardUi(); + } +} + +export function exportCompositionPng() { + const state = layerState(); + if (!state.ready || activeTransform) throw new Error("请等待文字图层完成后再导出"); + compositionCanvas.toBlob((blob) => { + if (!blob) { + announce("合成 PNG 生成失败", true); + return; + } + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "matrix-composition.png"; + link.click(); + URL.revokeObjectURL(url); + announce("合成 PNG 已导出"); + }, "image/png"); +} + +applyButton.addEventListener("click", () => { + if (animationEdit) document.dispatchEvent(new CustomEvent("matrix:save-animation-frame")); + else applyComposition().catch(() => {}); +}); +exitAnimationEditButton.addEventListener("click", () => document.dispatchEvent(new CustomEvent("matrix:request-exit-animation-edit"))); +document.addEventListener("matrix:app-stale", () => { + updateBoardUi(); +}); +exportButton.addEventListener("click", () => { + try { + exportCompositionPng(); + } catch (error) { + announce(error.message, true); + } +}); +document.addEventListener("matrix:status", (event) => { + const interval = event.detail?.ui?.preview_refresh_interval_ms; + if (Number.isInteger(interval)) setPreviewRefreshInterval(interval); +}); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + refreshCurrentDisplayPreview(); + } else { + clearMonitorTimer(); + monitorRefreshQueued = false; + previewSequence += 1; + } +}); + +scene.elements.forEach((element) => scheduleTextLayer(element, true)); +renderComposition(); +if (draftLoad.notice) announce(draftLoad.notice, !draftLoad.canPersist || draftLoad.status === "cleanup-warning"); diff --git a/核桃派软件源代码/app/static/scene-model.js b/核桃派软件源代码/app/static/scene-model.js new file mode 100644 index 0000000..b7cfe75 --- /dev/null +++ b/核桃派软件源代码/app/static/scene-model.js @@ -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; +} diff --git a/核桃派软件源代码/app/static/sortable-card.js b/核桃派软件源代码/app/static/sortable-card.js new file mode 100644 index 0000000..01f9fa1 --- /dev/null +++ b/核桃派软件源代码/app/static/sortable-card.js @@ -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; +} diff --git a/核桃派软件源代码/app/static/style.css b/核桃派软件源代码/app/static/style.css new file mode 100644 index 0000000..227fc3a --- /dev/null +++ b/核桃派软件源代码/app/static/style.css @@ -0,0 +1,3103 @@ +:root { + color-scheme: dark; + --bg: #0d1015; + --surface: #171c24; + --surface-raised: #1d2430; + --surface-strong: #252f3d; + --text: #f4f7fb; + --muted: #aab5c5; + --border: #364356; + --accent: #35c2a0; + --accent-strong: #72e0c5; + --accent-ink: #061511; + --danger: #ff7777; + --danger-bg: #3a2027; + --focus: #8ddcff; + --radius: 14px; + --shadow: 0 20px 60px rgb(0 0 0 / 38%); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html { + min-width: 0; + background: var(--bg); + scrollbar-gutter: stable; +} + +body { + min-height: 100vh; + min-height: 100dvh; + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.45; +} + +body.drawer-open, +body.color-picker-open { + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select, +textarea, +summary { + -webkit-tap-highlight-color: transparent; +} + +button { + min-height: 48px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-strong); + color: var(--text); + cursor: pointer; + padding: 10px 14px; + touch-action: manipulation; +} + +button:hover { + border-color: var(--accent); +} + +button:active { + transform: translateY(1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +button[aria-disabled="true"], +input[aria-disabled="true"] + .switch-track, +input[aria-disabled="true"] ~ .switch-state { + cursor: not-allowed; + opacity: 0.6; +} + +button[data-control-state="busy"]:disabled, +input[data-control-state="busy"]:disabled + .switch-track { + cursor: progress; +} + +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + clip-path: inset(50%) !important; +} + +.control-unavailable-tooltip, +.control-feedback-toast { + z-index: 1200; + max-width: min(30rem, calc(100vw - 24px)); + border: 1px solid rgb(80 220 190 / 72%); + border-radius: 10px; + background: #172129; + color: var(--text); + box-shadow: 0 12px 30px rgb(0 0 0 / 38%); + padding: 10px 12px; + font-size: 0.92rem; + line-height: 1.45; +} + +.control-unavailable-tooltip { + position: fixed; + pointer-events: none; +} + +.control-feedback-toast { + position: fixed; + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + left: max(12px, env(safe-area-inset-left)); + width: fit-content; + margin-inline: auto; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +h1, +h2, +h3, +p, +dl, +dd { + margin: 0; +} + +h1 { + font-size: clamp(1.05rem, 4vw, 1.35rem); +} + +h2 { + font-size: clamp(1.5rem, 7vw, 2rem); + line-height: 1.15; +} + +h3 { + font-size: 1.05rem; +} + +.app-shell { + min-height: 100vh; + min-height: 100dvh; +} + +.drawer { + position: fixed; + z-index: 30; + inset: 0 auto 0 0; + display: flex; + width: min(84vw, 20rem); + flex-direction: column; + overflow-y: auto; + border-right: 1px solid var(--border); + background: #121720; + box-shadow: var(--shadow); + padding: calc(18px + env(safe-area-inset-top)) 16px calc(18px + env(safe-area-inset-bottom)); + transform: translateX(-105%); + transition: transform 180ms ease; +} + +.drawer-toggle { + position: fixed; + z-index: 40; + top: calc(12px + env(safe-area-inset-top)); + left: calc(12px + env(safe-area-inset-left)); + display: grid; + width: 48px; + min-width: 48px; + height: 48px; + min-height: 48px; + place-items: center; + padding: 11px; + box-shadow: 0 6px 20px rgb(0 0 0 / 28%); +} + +.drawer-toggle svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentcolor; + stroke-linecap: round; + stroke-width: 2; +} + +.drawer-toggle-close-icon, +.drawer-toggle[aria-expanded="true"] .drawer-toggle-menu-icon { + display: none; +} + +.drawer-toggle[aria-expanded="true"] .drawer-toggle-close-icon { + display: block; +} + +.drawer.open { + transform: translateX(0); +} + +.drawer-header, +.topbar, +.board-heading, +.workspace-heading, +.panel-heading, +.primary-actions, +.board-actions, +.text-element-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.drawer-header { + min-height: 48px; + padding-left: 60px; +} + +.brand { + margin-top: 3px; + font-size: 1.05rem; + font-weight: 720; +} + +.eyebrow { + color: var(--accent-strong); + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.compact-button { + min-height: 44px; + padding-inline: 12px; +} + +.workspace-nav { + display: grid; + gap: 8px; + margin-top: 16px; +} + +.workspace-order-editor { + display: grid; + gap: 8px; + margin-top: 14px; + padding-inline: 10px; +} + +.workspace-order-toggle { + width: 100%; + min-height: 40px; + padding-block: 7px; + font-size: 0.82rem; +} + +.workspace-order-feedback { + margin: 0; + color: var(--muted); + font-size: 0.78rem; +} + +.workspace-order-feedback[data-state="error"] { + color: var(--danger); +} + +.workspace-order-feedback[data-state="success"] { + color: var(--accent-strong); +} + +.nav-item-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} + +.workspace-nav.is-order-editing .nav-item-row { + grid-template-columns: minmax(0, 1fr) auto; +} + +.nav-order-actions { + display: grid; + grid-template-columns: repeat(2, minmax(48px, auto)); + gap: 6px; +} + +.nav-order-action { + min-height: 48px; + padding-inline: 10px; + font-size: 0.82rem; +} + +.nav-item { + width: 100%; + min-height: 52px; + border-color: transparent; + background: transparent; + text-align: left; +} + +.nav-item[aria-current="page"] { + border-color: rgb(53 194 160 / 45%); + background: rgb(53 194 160 / 14%); + color: var(--accent-strong); +} + +.nav-item[aria-disabled="true"] { + cursor: default; +} + +.drawer-note { + margin-top: auto; + padding: 24px 10px 0; + color: var(--muted); + font-size: 0.78rem; +} + +.drawer-backdrop { + position: fixed; + z-index: 20; + inset: 0; + width: 100%; + min-height: 100%; + border: 0; + border-radius: 0; + background: rgb(0 0 0 / 62%); + padding: 0; +} + +.topbar { + position: sticky; + z-index: 10; + top: 0; + min-height: 76px; + justify-content: space-between; + flex-wrap: wrap; + border-bottom: 1px solid var(--border); + background: rgb(13 16 21 / 92%); + padding: calc(10px + env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) 10px calc(74px + env(safe-area-inset-left)); + backdrop-filter: blur(14px); +} + +.topbar-copy { + min-width: 0; +} + +.topbar-statuses { + display: flex; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-end; + gap: 8px; +} + +.current-content-indicator { + display: flex; + flex: 1 1 13rem; + min-width: 0; + min-height: 44px; + align-items: center; + justify-content: center; + overflow-wrap: anywhere; + border: 1px solid rgb(114 224 197 / 38%); + border-radius: 10px; + background: rgb(114 224 197 / 8%); + padding: 8px 12px; + color: var(--accent-strong); + font-size: 0.82rem; + font-weight: 700; + text-align: center; + cursor: pointer; +} + +.current-content-indicator:hover, +.current-content-indicator:focus-visible { + border-color: var(--accent); + background: rgb(114 224 197 / 14%); +} + +.resource-indicator { + display: flex; + flex: 0 1 auto; + align-items: center; + justify-content: center; + min-height: 44px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + padding: 8px 10px; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + font-weight: 650; + white-space: nowrap; +} + +.resource-indicator[data-status="starting"] { + color: var(--muted); +} + +.resource-indicator[data-status="error"] { + border-color: rgb(255 119 119 / 50%); + background: rgb(255 119 119 / 8%); + color: var(--danger); +} + +.low-voltage-warning { + display: flex; + flex: 0 1 auto; + min-width: 16rem; + align-items: center; + justify-content: center; + border: 1px solid rgb(255 190 92 / 58%); + border-radius: 10px; + background: rgb(255 190 92 / 12%); + color: #ffd27f; + padding: 8px 12px; + font-size: 0.82rem; + font-weight: 760; + line-height: 1.3; + overflow-wrap: anywhere; + text-align: center; +} + +.low-voltage-warning[data-mode="critical"] { + border-color: rgb(255 119 119 / 62%); + background: rgb(255 119 119 / 12%); + color: var(--danger); +} + +.resource-details-dialog { + width: min(calc(100% - 28px), 36rem); +} + +.current-display-dialog { + width: min(calc(100% - 28px), 34rem); +} + +.current-display-dialog-body { + display: grid; + gap: 16px; + padding: 20px; +} + +.current-display-dialog-heading { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.current-display-dialog-heading h2, +.current-display-dialog-heading p { + margin: 0; +} + +.current-display-dialog-heading p { + margin-top: 4px; + overflow-wrap: anywhere; +} + +.current-display-preview-surface { + display: grid; + width: min(100%, 20rem); + aspect-ratio: 1; + place-items: center; + justify-self: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: #000; + color: var(--muted); + padding: 0; +} + +.current-display-preview-surface img { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; +} + +.current-display-preview-surface span { + padding: 16px; +} + +.current-display-playback-controls { + display: grid; + gap: 12px; + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.current-display-playback-controls[hidden] { + display: none; +} + +.current-display-progress-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.current-display-progress-heading output { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +#current-display-progress { + width: 100%; + accent-color: var(--accent); +} + +.current-display-playback-actions { + display: grid; + grid-template-columns: minmax(7rem, auto) minmax(0, 1fr); + gap: 10px; +} + +.current-display-speed-controls { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.current-display-speed-controls button[aria-pressed="true"] { + border-color: var(--accent); + background: rgb(114 224 197 / 14%); + color: var(--accent-strong); +} + +.resource-details-message { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--muted); + padding: 10px 12px; +} + +.resource-details-section { + display: grid; + gap: 12px; + border-top: 1px solid var(--border); + padding-top: 14px; +} + +.resource-details-section h3 { + margin: 0; +} + +.resource-details-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.resource-details-grid > div { + min-width: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + padding: 10px 12px; +} + +.resource-details-grid dt { + color: var(--muted); + font-size: 0.75rem; +} + +.resource-details-grid dd { + overflow-wrap: anywhere; + margin: 4px 0 0; + font-variant-numeric: tabular-nums; + font-weight: 720; +} + +.resource-core-list { + display: grid; + gap: 8px; +} + +.resource-core-row { + display: grid; + grid-template-columns: 3.5rem minmax(0, 1fr) 3.2rem; + align-items: center; + gap: 8px; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.resource-core-row progress { + width: 100%; + height: 10px; + accent-color: var(--accent); +} + +.resource-core-row output { + text-align: right; +} + +.resource-dialog-actions { + grid-template-columns: minmax(0, 1fr); +} + +.voltage-indicator { + flex: 0 0 auto; + min-width: 12rem; + border: 1px solid rgb(53 194 160 / 38%); + border-radius: 12px; + background: rgb(53 194 160 / 10%); + color: var(--accent-strong); + padding: 10px 14px; + font-size: 1.05rem; + font-weight: 720; + text-align: center; +} + +.voltage-indicator[data-status="starting"] { + border-color: var(--border); + background: var(--surface); + color: var(--muted); +} + +.voltage-indicator[data-status="disconnected"], +.voltage-indicator[data-status="error"] { + border-color: rgb(255 119 119 / 50%); + background: rgb(255 119 119 / 8%); + color: var(--danger); +} + +.last-message { + max-width: 70vw; + overflow: hidden; + margin-top: 2px; + color: var(--muted); + font-size: 0.78rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.last-message.message-error { + color: var(--danger); +} + +.sync-notice { + display: flex; + width: min(100% - 20px, 72rem); + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 12px auto 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-raised); + padding: 14px; +} + +.sync-notice-warning { + border-color: rgb(255 190 92 / 65%); + background: #332919; +} + +.sync-notice p { + margin-top: 4px; + color: var(--muted); +} + +.sync-notice-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +@media (max-width: 42rem) { + .sync-notice { + align-items: stretch; + flex-direction: column; + } + + .sync-notice-actions { + display: grid; + } +} + +.board-shell { + width: 100%; + padding: 18px max(10px, env(safe-area-inset-right)) 4px max(10px, env(safe-area-inset-left)); +} + +.board-heading, +.board-status, +.board-actions { + width: min(100%, max(16rem, calc(100dvh - 10rem))); + margin-inline: auto; +} + +.board-heading { + margin-bottom: 10px; +} + +.board-heading h2 { + margin-top: 2px; + font-size: clamp(1.15rem, 4vw, 1.55rem); +} + +.board-heading-context { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; +} + +.animation-edit-notice { + display: inline-flex; + min-width: 0; + max-width: min(28rem, 58vw); + align-items: center; + gap: 8px; + border: 1px solid rgb(63 211 172 / 55%); + border-radius: 999px; + background: rgb(63 211 172 / 10%); + padding: 6px 10px; +} + +.animation-edit-notice[hidden] { + display: none; +} + +.animation-edit-notice strong { + min-width: 0; + color: var(--text); + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.animation-edit-notice > span:last-child { + align-self: center; + color: var(--accent-strong); + font-size: 0.8rem; + font-weight: 700; +} + +.animation-edit-notice[data-state="dirty"] { + border-color: rgb(255 190 92 / 65%); + background: rgb(255 190 92 / 11%); +} + +.animation-edit-notice[data-state="dirty"] > span:last-child { + color: #ffbe5c; +} + +.animation-preview-shell { + width: min(100%, 25rem); + margin-inline: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.animation-preview-shell[hidden] { + display: none; +} + +.animation-preview-card { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + padding: 12px; + text-align: center; +} + +.animation-preview-card h3 { + margin-top: 2px; + font-size: 1rem; +} + +.animation-small-preview { + position: relative; + display: grid; + width: min(100%, 160px); + aspect-ratio: 1; + place-items: center; + overflow: hidden; + margin: 10px auto 0; + border: 1px solid var(--border); + border-radius: 8px; + background: #000; + color: var(--muted); + font-size: 0.75rem; + isolation: isolate; +} + +.animation-small-preview img, +.animation-small-preview canvas { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.animation-small-preview > span { + max-width: 9rem; + padding: 10px; +} + +.animation-preview-status { + min-height: 2.5em; + margin-top: 8px; + color: var(--muted); + font-size: 0.75rem; +} + +.board-stage { + position: relative; + width: min(100%, max(16rem, calc(100dvh - 10rem))); + aspect-ratio: 1; + overflow: hidden; + margin-inline: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: #000; + box-shadow: 0 18px 50px rgb(0 0 0 / 26%); + isolation: isolate; +} + +.board-stage canvas { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +#scene-canvas { + z-index: 1; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +#scene-overlay { + z-index: 2; + user-select: none; +} + +#scene-overlay[data-interaction="text"], +#scene-overlay[data-canvas-editing="true"] { + touch-action: none; +} + +#scene-overlay[data-interaction="pixels"]:not([data-canvas-editing="true"]) { + pointer-events: none; + touch-action: auto; +} + +.pixel-grid { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgb(255 255 255 / 28%) 1px, transparent 1px), + linear-gradient(to bottom, rgb(255 255 255 / 28%) 1px, transparent 1px); + background-size: calc(100% / 64) calc(100% / 64); + mix-blend-mode: difference; +} + +.pixel-grid[hidden] { + display: none; +} + +#scene-overlay[data-interaction="readonly"] { + pointer-events: none; +} + +body.canvas-edit-active { + overflow: hidden; + overscroll-behavior: none; +} + +body.canvas-edit-active #board-shell { + position: fixed; + z-index: 1000; + inset: 0; + overflow: hidden; + width: 100%; + height: 100dvh; + background: #05080d; + padding: 0; +} + +body.canvas-edit-active #standard-board-shell { + position: relative; + display: grid; + width: 100%; + height: 100%; + place-items: center; + overflow: hidden; +} + +body.canvas-edit-active .board-heading, +body.canvas-edit-active .board-status, +body.canvas-edit-active .board-actions { + display: none; +} + +body.canvas-edit-active .board-stage { + width: min(calc(100vw - 24px), calc(100dvh - 24px)); + max-width: none; + margin: 0; + transform: translate3d(var(--canvas-view-x, 0), var(--canvas-view-y, 0), 0) + scale(var(--canvas-view-scale, 1)); + transform-origin: center; + will-change: transform; +} + +body.canvas-edit-active[data-canvas-editor-mode="move"] .board-stage { + cursor: grab; +} + +body.canvas-edit-active[data-canvas-editor-mode="move"] .board-stage:active { + cursor: grabbing; +} + +.canvas-edit-assistive { + position: absolute; + z-index: 1010; + top: 0; + left: 0; + width: 0; + height: 0; + transform: translate3d(var(--assistive-x, 36px), var(--assistive-y, 50dvh), 0); + touch-action: none; +} + +.canvas-edit-assistive[hidden], +.canvas-assistive-menu[hidden] { + display: none; +} + +.canvas-assistive-toggle { + position: absolute; + top: -28px; + left: -28px; + display: grid; + width: 56px; + min-width: 56px; + min-height: 56px; + place-items: center; + border: 1px solid rgb(255 255 255 / 58%); + border-radius: 50%; + background: rgb(35 43 54 / 82%); + padding: 0; + box-shadow: 0 8px 24px rgb(0 0 0 / 48%); + backdrop-filter: blur(12px); + cursor: grab; +} + +.canvas-assistive-toggle:active { + cursor: grabbing; +} + +.canvas-assistive-toggle span { + width: 30px; + height: 30px; + border: 2px solid rgb(255 255 255 / 90%); + border-radius: 50%; + box-shadow: inset 0 0 0 5px rgb(255 255 255 / 20%); +} + +.canvas-assistive-menu { + position: absolute; + top: -28px; + left: 38px; + display: grid; + width: min(14rem, calc(100vw - 88px)); + gap: 8px; + border: 1px solid rgb(255 255 255 / 22%); + border-radius: 16px; + background: rgb(16 23 33 / 96%); + padding: 12px; + box-shadow: 0 16px 40px rgb(0 0 0 / 55%); +} + +.canvas-edit-assistive[data-menu-horizontal="left"] .canvas-assistive-menu { + right: 38px; + left: auto; +} + +.canvas-edit-assistive[data-menu-vertical="up"] .canvas-assistive-menu { + top: auto; + bottom: -28px; +} + +.canvas-assistive-menu p { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; +} + +.canvas-assistive-menu button { + min-height: 48px; + text-align: left; +} + +.canvas-assistive-menu button[aria-disabled="true"] { + opacity: 0.48; +} + +.board-status { + min-height: 1.4rem; + margin-top: 8px; + color: var(--muted); + font-size: 0.78rem; +} + +.board-status[data-state="error"] { + color: var(--danger); +} + +.board-status[data-state="pending"] { + color: var(--accent-strong); +} + +.board-actions { + align-items: stretch; + margin-top: 10px; +} + +.board-actions > * { + flex: 1 1 0; +} + +.board-shell-monitor .board-stage { + width: min(100%, 160px); +} + +.board-shell-monitor .board-heading, +.board-shell-monitor .board-status { + width: min(100%, 24rem); +} + +.board-shell-monitor .board-actions, +.board-shell-monitor #scene-overlay { + display: none; +} + +.board-shell-device-monitor .board-heading { + justify-content: center; + text-align: center; +} + +.board-shell-device-monitor .board-heading-context { + display: none; +} + +.workspace-root { + width: min(100%, 62rem); + margin: 0 auto; + padding: 20px max(14px, env(safe-area-inset-right)) calc(36px + env(safe-area-inset-bottom)) max(14px, env(safe-area-inset-left)); +} + +.workspace { + display: grid; + gap: 14px; +} + +.device-refresh-row { + display: flex; + justify-content: center; +} + +.device-refresh-row > button { + min-width: min(100%, 12rem); +} + +.workspace-heading { + min-height: 58px; +} + +.workspace-heading-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.workspace-intro, +.subtle, +.hint, +dt, +.orientation-label { + color: var(--muted); +} + +.workspace-intro { + margin-top: -4px; +} + +.orientation-label, +.status-badge { + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + padding: 5px 9px; + font-size: 0.75rem; +} + +.status-badge { + flex: 0 1 auto; + max-width: 100%; + border-color: rgb(53 194 160 / 38%); + color: var(--accent-strong); + overflow-wrap: anywhere; + text-align: right; +} + +.panel { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + padding: 16px; +} + +.panel > h3 + * { + margin-top: 14px; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 14px; +} + +.status-grid div { + min-width: 0; + border-radius: 10px; + background: var(--surface-raised); + padding: 11px; +} + +.status-grid-compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +dt { + font-size: 0.72rem; +} + +dd { + margin-top: 4px; + overflow-wrap: anywhere; + font-weight: 650; +} + +.driver-details { + margin-top: 12px; + color: var(--muted); + font-size: 0.8rem; +} + +.driver-details summary { + min-height: 44px; + cursor: pointer; + padding-block: 11px; +} + +.driver-details p { + overflow-wrap: anywhere; + border-radius: 8px; + background: var(--bg); + padding: 10px; + font-family: ui-monospace, "Cascadia Mono", monospace; +} + +.error-text { + margin-top: 10px; + color: var(--danger); + overflow-wrap: anywhere; +} + +.template-storage { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.device-storage-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.display-test-controls { + display: grid; + gap: 10px; + margin-top: 16px; + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.display-test-controls > .settings-feedback { + min-height: 0; +} + +.display-test-controls > .danger-button { + width: min(100%, 18rem); + justify-self: center; +} + +.template-storage div { + display: grid; + gap: 4px; + min-width: 0; + border-radius: 10px; + background: var(--surface-raised); + padding: 11px; +} + +.template-storage span, +.template-card-size { + color: var(--muted); + font-size: 0.76rem; +} + +.content-type-badge { + justify-self: start; + display: inline-flex; + padding: 0.2rem 0.55rem; + border-radius: 999px; + background: rgba(114, 224, 197, 0.14); + color: var(--accent-strong, #72e0c5); + font-size: 0.75rem; + font-weight: 700; +} + +.content-type-animation { + background: rgba(255, 166, 92, 0.16); + color: #ffb475; +} + +.animation-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.animation-folder-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: 0.75rem; + margin-top: 0.75rem; +} + +.animation-existing { + min-width: 0; +} + +.animation-folder-card.active { + outline: 2px solid var(--accent-strong, #72e0c5); +} + +.animation-folder-card { + min-width: 0; + overflow: hidden; +} + +.animation-folder-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.animation-folder-card h3, +.animation-folder-card p { + margin: 0 0 0.65rem; +} + +#animation-frames input[type="number"] { + width: 100%; + min-width: 0; +} + +.animation-frame-card { + display: flex; + flex-direction: column; +} + +.animation-batch-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0 0 12px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgb(255 255 255 / 3%); +} + +.animation-batch-toolbar p { + margin: 0; + color: var(--muted); + font-size: 0.78rem; +} + +.animation-frame-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: 1rem; +} + +.animation-frame-pagination[hidden] { + display: none; +} + +.animation-frame-pagination p { + margin: 0; + min-width: 10rem; + text-align: center; +} + +.animation-batch-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.animation-frame-card.selected { + outline: 2px solid #6ea8ff; + outline-offset: -2px; +} + +.animation-frame-position-row { + display: flex; + min-height: 28px; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--muted); + font-size: 0.72rem; +} + +.animation-frame-position-current { + color: #72e0c5; + font-weight: 800; +} + +.animation-frame-position-total { + color: #ffb475; + font-weight: 800; +} + +.animation-frame-select { + width: 22px; + height: 22px; + margin: 0; + accent-color: #6ea8ff; +} + +.animation-frame-card.editing { + outline: 2px solid var(--accent-strong, #72e0c5); + outline-offset: -2px; +} + +.animation-frame-name { + margin: 0; + min-height: 2.5em; +} + +.animation-frame-editing-badge { + align-self: flex-start; + margin-top: 6px; + border-radius: 999px; + background: rgb(63 211 172 / 16%); + color: var(--accent-strong); + padding: 3px 7px; + font-size: 0.72rem; + font-weight: 700; +} + +.animation-frame-editing-badge[hidden] { + display: none; +} + +.animation-frame-rename { + align-self: stretch; + margin-top: 7px; +} + +.animation-duration-field { + display: grid; + gap: 5px; + margin-top: 10px; + color: var(--muted); + font-size: 0.78rem; +} + +.animation-duration-heading, +.animation-duration-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.animation-duration-heading button { + min-height: 32px; + padding: 5px 8px; + font-size: 0.72rem; +} + +.animation-duration-controls input { + flex: 1 1 auto; +} + +.animation-duration-unit { + flex: 0 0 auto; + min-width: 4.4rem; + min-height: 42px; + padding-inline: 9px; +} + +.animation-duration-field input[aria-invalid="true"] { + border-color: var(--danger); +} + +.animation-duration-error { + color: var(--danger); + font-size: 0.72rem; + line-height: 1.35; +} + +.animation-duration-error[hidden] { + display: none; +} + +.animation-frame-image-box { + flex: 0 0 auto; + margin-top: 10px; +} + +.animation-frame-edit { + width: 100%; + margin-top: 9px; +} + +.animation-frame-secondary-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.template-storage strong { + overflow-wrap: anywhere; +} + +.template-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.template-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + padding: 10px; +} + +.sortable-card { + cursor: grab; +} + +.sortable-card.is-dragging { + cursor: grabbing; + opacity: 0.58; +} + +.library-sortable-card.library-drop-target { + outline: 2px solid var(--accent-strong, #72e0c5); + outline-offset: -2px; +} + +.library-demo-fixed { + cursor: default; +} + +.template-card.template-default-content { + border-color: var(--accent-strong); + box-shadow: inset 0 0 0 1px var(--accent-strong); +} + +.template-default-badge { + display: inline-flex; + width: fit-content; + margin-top: 8px; + border-radius: 999px; + background: var(--accent-strong); + padding: 3px 8px; + color: #07100e; + font-size: 0.72rem; + font-weight: 800; +} + +.template-card-default-actions { + grid-template-columns: minmax(0, 1fr); +} + +.template-image-box { + position: relative; + display: grid; + width: 100%; + aspect-ratio: 1; + place-items: center; + overflow: hidden; + border-radius: 10px; + background: + linear-gradient(45deg, #222a36 25%, transparent 25%, transparent 75%, #222a36 75%), + linear-gradient(45deg, #222a36 25%, #151a22 25%, #151a22 75%, #222a36 75%); + background-position: 0 0, 8px 8px; + background-size: 16px 16px; +} + +.template-image-box.template-image-error::after { + content: "缩略图加载失败"; + padding: 12px; + color: var(--danger); + font-size: 0.75rem; + text-align: center; +} + +.template-image-box.template-image-empty::after { + content: "空动图"; + padding: 12px; + color: var(--muted); + font-size: 0.75rem; + text-align: center; +} + +.template-image { + width: 100%; + height: 100%; + object-fit: contain; + image-rendering: pixelated; +} + +.template-card-name { + margin-top: 10px; + overflow-wrap: anywhere; + font-size: 0.96rem; + line-height: 1.3; +} + +.template-card-size { + margin-top: 4px; +} + +.template-card-actions { + display: grid; + gap: 7px; + margin-top: 9px; +} + +.template-card-content-actions, +.template-card-order-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.template-card-order-actions { + margin-top: 7px; +} + +.template-card-actions button { + min-width: 0; + padding-inline: 6px; + font-size: 0.8rem; +} + +.template-card-actions .demo-restricted-action[aria-disabled="true"] { + border-color: #000; + background: #050505; + color: #9aa1ab; + opacity: 1; +} + +.template-card-delete-action { + grid-column: 1 / -1; +} + +.template-empty { + grid-column: 1 / -1; + color: var(--muted); + text-align: center; +} + +.template-dialog { + width: min(calc(100% - 28px), 26rem); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + padding: 0; + box-shadow: var(--shadow); +} + +.template-dialog::backdrop { + background: rgb(0 0 0 / 70%); +} + +.template-dialog form { + display: grid; + gap: 16px; + padding: 20px; +} + +.template-dialog-heading { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.template-dialog-heading p { + max-width: 14rem; + color: #ffbe5c; + font-size: 0.78rem; + text-align: right; +} + +.template-dialog label { + display: grid; + gap: 7px; +} + +.template-dialog input, +.template-dialog select { + min-height: 48px; + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + padding: 10px 12px; +} + +.animation-position-dialog option:disabled { + background: #050505; + color: #fff; +} + +.template-dialog-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +@media (max-width: 28rem) { + .template-storage { + grid-template-columns: 1fr; + } + + .animation-preview-shell { + width: min(100%, 186px); + grid-template-columns: 1fr; + } + + .template-dialog-heading { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + + .template-dialog-heading p { + text-align: left; + } +} + +@media (max-width: 36rem) { + .board-heading { + align-items: flex-start; + flex-direction: column; + } + + .board-heading-context, + .animation-edit-notice { + width: 100%; + max-width: none; + } + + .animation-edit-notice { + justify-content: space-between; + border-radius: 12px; + } +} + +@media (min-width: 46rem) { + .template-grid { + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + } +} + +.choice-grid, +.orientation-grid, +.refresh-rate-grid, +.form-grid, +.canvas-toolbar, +.segmented { + display: grid; + gap: 8px; +} + +.choice-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.color-choices { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.choice-grid button.active, +.segmented button.active, +.orientation-grid button.active, +.refresh-rate-grid button.active { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 750; +} + +label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: 0.82rem; +} + +input, +select, +textarea { + width: 100%; + min-height: 48px; + border: 1px solid var(--border); + border-radius: 10px; + background: #0c1118; + color: var(--text); + padding: 10px 12px; +} + +textarea { + min-height: 76px; + resize: vertical; +} + +.color-field { + display: grid; + align-items: center; + grid-template-columns: 1fr auto; + margin-top: 12px; +} + +.color-trigger { + display: inline-grid; + width: auto; + min-width: 9.5rem; + grid-template-columns: 34px 1fr; + align-items: center; + gap: 9px; + padding: 6px 10px; +} + +.color-trigger-swatch { + width: 34px; + height: 34px; + border: 2px solid rgb(255 255 255 / 65%); + border-radius: 8px; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 45%); +} + +.color-trigger-value { + font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 0.86rem; +} + +.hint { + margin-top: 10px; + font-size: 0.78rem; +} + +.primary-actions { + align-items: stretch; + margin-top: 16px; +} + +.primary-actions > * { + flex: 1 1 0; +} + +.primary-button { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 780; +} + +.secondary-button { + background: transparent; +} + +.danger-button { + border-color: rgb(255 119 119 / 55%); + background: var(--danger-bg); + color: #ffd7d7; +} + +.full-width { + width: 100%; + margin-top: 12px; +} + +.preview-panel { + text-align: center; +} + +.preview-panel .panel-heading { + text-align: left; +} + +.matrix-preview { + display: block; + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 10px; + background: #000; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.compact-preview { + max-width: 22rem; + margin: 14px auto 0; +} + +.canvas-preview { + max-width: 42rem; + margin: 14px auto 0; + border-radius: 6px; + touch-action: none; + user-select: none; +} + +.canvas-toolbar { + grid-template-columns: minmax(0, 1fr); +} + +.canvas-toolbar .color-field { + margin-top: 0; +} + +.segmented { + grid-column: 1 / -1; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.canvas-tools { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.brush-size-control { + grid-column: 1 / -1; + border: 1px solid var(--border); + border-radius: 12px; + background: #101721; + padding: 12px; +} + +.brush-size-heading, +.brush-size-row, +.brush-size-presets { + display: flex; + align-items: center; + gap: 10px; +} + +.brush-size-heading { + justify-content: space-between; + margin-bottom: 8px; +} + +.brush-size-heading output { + color: var(--accent-strong); + font-weight: 750; +} + +.brush-size-row input { + flex: 1; + min-height: 48px; + padding: 0; + accent-color: var(--accent); +} + +.brush-size-preview { + display: block; + flex: 0 0 auto; + max-width: 32px; + max-height: 32px; + border-radius: 50%; + background: var(--text); + box-shadow: 0 0 0 1px var(--border); +} + +.brush-size-presets { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 8px; +} + +.pixel-edit-toggle { + width: 100%; + min-height: 48px; + margin-top: 12px; +} + +.pixel-edit-toggle[aria-pressed="true"] { + border-color: var(--accent); + background: rgb(53 194 160 / 16%); + color: var(--accent-strong); +} + +.pixel-edit-status { + margin: 10px 0 0; + color: var(--accent-strong); + font-size: 0.8rem; + line-height: 1.45; +} + +.pixel-edit-status[hidden] { + display: none; +} + +.brush-size-preview[data-shape="square"] { + border-radius: 2px; +} + +.color-picker-overlay { + position: fixed; + z-index: 100; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgb(2 6 11 / 76%); + padding-top: max(12px, env(safe-area-inset-top)); +} + +.color-picker-panel { + width: 100%; + max-height: calc(100dvh - 12px); + overflow-y: auto; + overscroll-behavior: contain; + border: 1px solid var(--border); + border-radius: 20px 20px 0 0; + background: var(--surface); + box-shadow: var(--shadow); + padding: 18px max(16px, env(safe-area-inset-right)) max(18px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); +} + +.color-picker-header, +.color-collection-heading, +.color-picker-actions, +.color-comparison { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.color-picker-header { + margin-bottom: 14px; +} + +.color-comparison > div { + display: grid; + flex: 1; + grid-template-columns: 1fr 52px; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.82rem; +} + +.color-comparison i { + display: block; + height: 48px; + border: 2px solid rgb(255 255 255 / 65%); + border-radius: 10px; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 50%); +} + +.color-mode-tabs { + display: grid; + margin: 14px 0; +} + +.color-sv-field { + position: relative; + width: 100%; + min-height: 190px; + border: 1px solid rgb(255 255 255 / 35%); + border-radius: 12px; + cursor: crosshair; + touch-action: none; +} + +.color-linear-track { + position: relative; + height: 48px; + border: 1px solid rgb(255 255 255 / 35%); + border-radius: 12px; + cursor: pointer; + touch-action: none; +} + +.color-hue-track { + margin-top: 12px; + background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); +} + +.color-track-handle { + position: absolute; + top: 50%; + width: 26px; + height: 26px; + border: 3px solid #fff; + border-radius: 50%; + box-shadow: 0 1px 5px rgb(0 0 0 / 80%); + pointer-events: none; + transform: translate(-50%, -50%); +} + +.color-number-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.color-number-grid label, +.color-hex-field { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 8px; +} + +.color-number-grid input { + min-width: 0; +} + +.rgb-channel { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) 72px; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.rgb-channel input { + min-width: 0; + padding-inline: 7px; + text-align: center; +} + +.color-hex-field { + margin-top: 12px; +} + +.color-hex-field input { + font-family: ui-monospace, "Cascadia Mono", monospace; + text-transform: uppercase; +} + +.color-collection { + margin-top: 16px; +} + +.color-collection-heading > div { + display: flex; + gap: 6px; +} + +.color-swatch-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); + gap: 8px; + min-height: 48px; + margin-top: 9px; + color: var(--muted); + font-size: 0.82rem; +} + +.color-swatch, +.palette-swatch { + position: relative; + width: 100%; + min-width: 48px; + height: 48px; +} + +.palette-swatch { + display: block; +} + +.color-swatch { + border: 2px solid rgb(255 255 255 / 58%); + padding: 0; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 52%); +} + +.palette-remove { + position: absolute; + top: 0; + right: 0; + width: 48px; + min-width: 48px; + min-height: 48px; + border-color: #fff; + border-radius: 10px; + background: rgb(58 32 39 / 88%); + color: var(--danger); + line-height: 1; + padding: 0; +} + +.color-picker-actions { + position: sticky; + bottom: calc(-1 * max(18px, env(safe-area-inset-bottom))); + margin: 18px -4px 0; + background: linear-gradient(to top, var(--surface) 75%, transparent); + padding: 14px 4px max(4px, env(safe-area-inset-bottom)); +} + +.color-picker-actions > * { + flex: 1; +} + +.color-picker-readonly .color-sv-field, +.color-picker-readonly .color-linear-track, +.color-picker-readonly input { + cursor: default; +} + +.color-picker-readonly .color-track-handle { + pointer-events: none; +} + +.canvas-actions { + margin-top: 12px; +} + +.text-layout, +.device-layout { + display: grid; + gap: 14px; +} + +.text-form { + display: grid; + gap: 12px; +} + +.text-form fieldset { + display: grid; + min-width: 0; + gap: 12px; + margin: 0; + border: 0; + padding: 0; +} + +.text-form fieldset:disabled { + opacity: 0.62; +} + +.font-field { + position: relative; + display: grid; + min-width: 0; + gap: 7px; +} + +.font-control-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} + +.font-combobox-shell { + position: relative; + min-width: 0; +} + +.font-combobox-shell::after { + content: "⌄"; + position: absolute; + top: 24px; + right: 13px; + z-index: 1; + color: var(--muted); + pointer-events: none; + transform: translateY(-50%); +} + +#text-font { + padding-right: 34px; + text-overflow: ellipsis; +} + +#text-font.font-reference-missing { + border-color: rgb(255 186 92 / 70%); + color: #ffd59a; +} + +#import-font { + min-height: 48px; + white-space: nowrap; +} + +.font-options { + position: absolute; + top: calc(100% + 6px); + right: 0; + left: 0; + z-index: 45; + max-height: min(22rem, 55vh); + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: #111923; + box-shadow: 0 16px 34px rgb(0 0 0 / 45%); + padding: 6px; +} + +.font-option-group { + position: sticky; + top: -6px; + z-index: 1; + background: #111923; + color: var(--muted); + padding: 8px 10px 5px; + font-size: 0.72rem; + font-weight: 750; +} + +.font-option { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 10px; + border-radius: 8px; + color: var(--text); + cursor: pointer; + padding: 8px 10px; +} + +.font-option span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.font-option small { + flex: 0 0 auto; + color: var(--muted); +} + +.font-option:hover, +.font-option.active { + background: rgb(53 194 160 / 16%); + color: var(--accent-strong); +} + +.font-option[aria-selected="true"] { + box-shadow: inset 3px 0 0 var(--accent); +} + +.font-options-empty { + color: var(--muted); + padding: 14px 10px; + text-align: center; +} + +.font-feedback { + margin: 0; + border-radius: 8px; + background: rgb(53 194 160 / 9%); + color: var(--accent-strong); + padding: 8px 10px; + font-size: 0.8rem; +} + +.font-feedback[data-state="error"] { + background: rgb(255 119 119 / 9%); + color: var(--danger); +} + +.text-element-actions { + flex-wrap: wrap; + align-items: stretch; + margin-top: 14px; +} + +.text-element-actions > * { + flex: 1 1 9rem; +} + +.text-element-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.text-element-list:empty::before { + content: "还没有文字元素"; + grid-column: 1 / -1; + border: 1px dashed var(--border); + border-radius: 10px; + color: var(--muted); + padding: 14px; + text-align: center; +} + +.text-element-item { + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-element-item.active { + border-color: var(--accent); + background: rgb(53 194 160 / 14%); + color: var(--accent-strong); +} + +.form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-grid .color-field { + grid-column: 1 / -1; + margin-top: 0; +} + +.setting-card { + display: grid; + gap: 16px; +} + +.ota-version-details, +.ota-version-details div { + margin: 0; +} + +.ota-version-details div, +.ota-progress-heading, +.ota-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.ota-version-details dt { + color: var(--muted); +} + +.ota-version-details dd { + margin: 0; + font-variant-numeric: tabular-nums; +} + +.ota-version-details { + display: grid; + gap: 8px; +} + +.ota-power-warning { + color: var(--text); +} + +.ota-progress-group { + display: grid; + gap: 8px; +} + +.ota-progress-group progress { + width: 100%; + height: 14px; + accent-color: var(--accent); +} + +.ota-failure-log-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.ota-failure-log-actions[hidden] { + display: none; +} + +.ota-failure-log-dialog { + width: min(calc(100% - 28px), 64rem); + max-width: 64rem; + max-height: 88vh; +} + +.ota-failure-log-dialog-body { + display: grid; + gap: 14px; + min-width: 0; +} + +.ota-failure-log-dialog pre { + box-sizing: border-box; + width: 100%; + max-height: 60vh; + margin: 0; + padding: 14px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: #0b1018; + color: #e8edf5; + font: 0.82rem/1.5 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} + +.wifi-fields, +.wifi-manual-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.wifi-dhcp-row, +.wifi-fields > .number-unit-field, +.wifi-active-status { + grid-column: 1 / -1; +} + +.wifi-dhcp-row { + display: flex; + flex-wrap: wrap; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + padding: 12px; +} + +.wifi-field-modified { + border-color: #ffd166; + box-shadow: 0 0 0 3px rgb(255 209 102 / 18%); +} + +.wifi-modified-note { + margin: 0; + color: #ffd166; + font-size: 0.78rem; + font-weight: 750; +} + +.wifi-dhcp-row .wifi-modified-note { + flex: 1 0 100%; +} + +.wifi-manual-fields { + grid-column: 1 / -1; +} + +.wifi-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.wifi-action-ready { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 780; +} + +.wifi-prompt-delay-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 10px; +} + +.wifi-prompt-delay-row > button { + min-width: 72px; +} + +.number-unit-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} + +.number-unit-field span { + color: var(--text); + font-weight: 700; +} + +.preview-refresh-warning { + color: #ffcc66; + font-size: 0.84rem; +} + +.voltage-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.voltage-details > div { + min-width: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + padding: 10px 12px; +} + +.voltage-details dt { + color: var(--muted); + font-size: 0.82rem; +} + +.voltage-details dd { + margin: 4px 0 0; + overflow-wrap: anywhere; + font-weight: 700; +} + +.voltage-actions, +.voltage-calibration-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.voltage-calibration-actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.voltage-calibration-dialog { + width: min(calc(100% - 28px), 34rem); +} + +.voltage-calibration-dialog .voltage-details { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.low-voltage-protection-setting { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.low-voltage-protection-copy { + min-width: 0; +} + +.low-voltage-protection-copy .hint { + margin-top: 4px; +} + +.switch-control { + position: relative; + display: inline-flex; + flex: 0 0 auto; + min-height: 48px; + align-items: center; + gap: 10px; + border-radius: 12px; + cursor: pointer; + padding: 6px; + user-select: none; +} + +.switch-control input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.switch-track { + position: relative; + width: 48px; + height: 28px; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-strong); + transition: background 150ms ease, border-color 150ms ease; +} + +.switch-track::after { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--muted); + content: ""; + transition: background 150ms ease, transform 150ms ease; +} + +.switch-control input:checked + .switch-track { + border-color: var(--accent); + background: rgb(53 194 160 / 28%); +} + +.switch-control input:checked + .switch-track::after { + background: var(--accent-strong); + transform: translateX(20px); +} + +.switch-control input:focus-visible + .switch-track { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.switch-control input:disabled + .switch-track, +.switch-control input:disabled ~ .switch-state { + opacity: 0.6; +} + +.switch-state { + min-width: 3.5rem; + color: var(--text); + font-weight: 720; + text-align: center; +} + +.low-voltage-protection-feedback, +.brightness-protection-status { + border: 1px solid rgb(255 190 92 / 48%); + border-radius: 10px; + background: rgb(255 190 92 / 9%); + color: #ffd27f; + padding: 10px 12px; + overflow-wrap: anywhere; +} + +.low-voltage-protection-feedback[data-state="success"] { + border-color: rgb(53 194 160 / 45%); + background: rgb(53 194 160 / 9%); + color: var(--accent-strong); +} + +.low-voltage-protection-feedback[data-state="error"], +.brightness-protection-status[data-mode="critical"] { + border-color: rgb(255 119 119 / 50%); + background: rgb(255 119 119 / 9%); + color: var(--danger); +} + +.brightness-protection-status { + margin-top: 10px; + font-size: 0.86rem; +} + +@media (max-width: 30rem) { + .topbar-copy, + .topbar-statuses, + .current-content-indicator, + .low-voltage-warning, + .voltage-indicator { + width: 100%; + } + + .current-display-dialog-body { + padding: 14px; + } + + .current-display-playback-actions { + grid-template-columns: 1fr; + } + + .current-display-progress-heading { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .topbar-statuses { + display: flex; + } + + .workspace-heading { + align-items: flex-start; + flex-direction: column; + } + + .workspace-heading-actions, + .workspace-heading-actions > button { + width: 100%; + } + + .resource-indicator { + flex: 1 1 calc(50% - 4px); + min-width: 0; + justify-content: center; + font-size: 0.72rem; + } + + .low-voltage-warning, + .voltage-indicator { + min-width: 0; + } + + .voltage-details, + .voltage-actions, + .voltage-calibration-dialog .voltage-details, + .voltage-calibration-actions { + grid-template-columns: 1fr; + } + + .wifi-fields, + .wifi-manual-fields, + .wifi-actions { + grid-template-columns: 1fr; + } + + .font-control-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + #import-font { + padding-inline: 10px; + } + + .wifi-dhcp-row { + align-items: stretch; + flex-direction: column; + } + + .low-voltage-protection-setting { + align-items: stretch; + flex-direction: column; + } + + .switch-control { + width: 100%; + justify-content: space-between; + } + + .resource-details-grid { + grid-template-columns: 1fr; + } +} + +.orientation-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.refresh-rate-grid { + grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); +} + +.brightness-output { + color: var(--accent-strong); + font-size: 1.25rem; + font-weight: 800; +} + +.brightness-slider { + min-height: 48px; + padding-inline: 0; + accent-color: var(--accent); +} + +.settings-feedback { + min-height: 48px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--muted); + padding: 13px 14px; +} + +.settings-feedback[data-state="success"] { + border-color: rgb(53 194 160 / 45%); + color: var(--accent-strong); +} + +.settings-feedback[data-state="error"] { + border-color: rgb(255 119 119 / 50%); + color: var(--danger); +} + +.frp-status-details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 14px 0 0; +} + +.frp-status-details > div { + border: 1px solid var(--border); + border-radius: 10px; + background: #0c1118; + padding: 10px 12px; +} + +.frp-status-details dt { + color: var(--muted); + font-size: 0.75rem; +} + +.frp-status-details dd { + margin: 4px 0 0; + overflow-wrap: anywhere; +} + +.frp-actions, +.frp-config-secondary-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +.frp-config-dialog { + width: min(760px, calc(100% - 24px)); +} + +.frp-config-dialog-body, +.network-diagnostics-dialog-body { + display: grid; + gap: 12px; + padding: 20px; +} + +.frp-config-dialog textarea { + min-height: 42vh; + font-family: ui-monospace, "Cascadia Mono", "Noto Sans Mono", monospace; + line-height: 1.5; + tab-size: 2; +} + +.network-diagnostics-dialog { + width: min(560px, calc(100% - 24px)); +} + +#network-diagnostics-summary[data-state="success"], +#network-diagnostics-checks li[data-state="success"] { + color: var(--accent-strong); +} + +#network-diagnostics-summary[data-state="error"], +#network-diagnostics-checks li[data-state="error"] { + color: #ffb3b3; +} + +#network-diagnostics-checks li[data-state="warning"] { + color: #ffd58a; +} + +@media (max-width: 560px) { + .frp-actions, + .frp-config-secondary-actions, + .frp-status-details { + grid-template-columns: 1fr; + } +} + +noscript { + display: block; + padding: 24px; + color: var(--danger); +} + +.media-upload-panel, +.media-job-card { + display: grid; + gap: 14px; +} + +.media-upload-panel progress { + width: 100%; +} + +.media-upload-panel .media-source-field { + grid-column: 1 / -1; +} + +.media-job-list { + display: grid; + gap: 16px; +} + +.media-job-card { + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + padding: 16px; +} + +.media-job-heading, +.media-preview-tabs, +.media-job-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.media-job-heading h3, +.media-job-heading p { + margin: 0; +} + +.media-crop-stage { + position: relative; + width: min(100%, 28rem); + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background-color: #000; + touch-action: none; +} + +.media-crop-source { + position: absolute; + inset: 0; +} + +.media-crop-source img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; +} + +.media-crop-stage[data-fit="crop"] .media-crop-source img, +.media-crop-stage[data-fit="contain"] .media-crop-source img { + object-fit: fill; +} + +.media-crop-stage[data-fit="stretch"] .media-crop-source img { + object-fit: fill; +} + +.media-settings-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.media-settings-grid label { + display: grid; + gap: 6px; +} + +.media-preview-tabs { + justify-content: flex-start; +} + +.media-preview-tabs button[aria-pressed="true"] { + border-color: var(--accent); +} + +@media (min-width: 40rem) { + .workspace-root { + padding: 26px 24px 48px; + } + + .device-storage-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .panel { + padding: 20px; + } + + .choice-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .canvas-toolbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .orientation-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .color-picker-overlay { + align-items: center; + padding: 24px; + } + + .color-picker-panel { + width: min(36rem, 100%); + max-height: calc(100dvh - 48px); + border-radius: 20px; + padding: 22px; + } +} + +@media (min-width: 60rem) { + .topbar { + padding-right: 28px; + padding-left: calc(82px + env(safe-area-inset-left)); + } + + .last-message { + max-width: 60vw; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/核桃派软件源代码/app/static/template-edit-model.js b/核桃派软件源代码/app/static/template-edit-model.js new file mode 100644 index 0000000..e005f8d --- /dev/null +++ b/核桃派软件源代码/app/static/template-edit-model.js @@ -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; +} diff --git a/核桃派软件源代码/app/static/ui-copy-dynamic.json b/核桃派软件源代码/app/static/ui-copy-dynamic.json new file mode 100644 index 0000000..36eb874 --- /dev/null +++ b/核桃派软件源代码/app/static/ui-copy-dynamic.json @@ -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"} +} diff --git a/核桃派软件源代码/app/static/ui-copy-runtime.js b/核桃派软件源代码/app/static/ui-copy-runtime.js new file mode 100644 index 0000000..b55771b --- /dev/null +++ b/核桃派软件源代码/app/static/ui-copy-runtime.js @@ -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); + } +} diff --git a/核桃派软件源代码/app/static/ui-copy.json b/核桃派软件源代码/app/static/ui-copy.json new file mode 100644 index 0000000..3ce715c --- /dev/null +++ b/核桃派软件源代码/app/static/ui-copy.json @@ -0,0 +1,6030 @@ +{ + "anchors": { + "anchor.0114063f6e7f8c84": { + "scope": "global", + "selector": "html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.016b37484bfb22d3": { + "scope": "animations", + "selector": "#animation-batch-toolbar", + "tag": "div" + }, + "anchor.016fdc90895c1811": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.02e01920bcb4e6a8": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3)", + "tag": "article" + }, + "anchor.038953681e94f9b7": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(11) > dl:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.0497d1913283121a": { + "scope": "global", + "selector": "#draft-conflict-notice > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.0570eaee03d0e54b": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(6)", + "tag": "article" + }, + "anchor.059ddd1d86e91186": { + "scope": "animations", + "selector": "#workspace-animations > section:nth-of-type(2) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.06f3d5518bec3a8e": { + "scope": "animations", + "selector": "#animation-frame-panel > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.07a0ea9ee780b4d0": { + "scope": "dialog:frp-config-dialog", + "selector": "#frp-config-dialog > div:nth-of-type(1) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.0822661cdb30ce8e": { + "scope": "text", + "selector": "#text-form", + "tag": "form" + }, + "anchor.08540c0b8746ec3c": { + "scope": "text", + "selector": "#workspace-text > article:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.08b90403b4eb7aca": { + "scope": "global", + "selector": "html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.0bb764ac40bb9417": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(5) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.0dcdef2de2f55b5d": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(2)", + "tag": "article" + }, + "anchor.0fd1270d35f240a7": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(3) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.104d5bc58574ac7c": { + "scope": "dialog:frp-config-dialog", + "selector": "#frp-config-dialog > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.163cccfd79df04aa": { + "scope": "text", + "selector": "#workspace-text", + "tag": "section" + }, + "anchor.1ad473586a5f2177": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4)", + "tag": "article" + }, + "anchor.1d2941dc9322d9a3": { + "scope": "dialog:template-name-dialog", + "selector": "#template-name-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.1d4531ec9208dcba": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(4)", + "tag": "div" + }, + "anchor.1e9fdf633bd61c3e": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-speed-controls", + "tag": "div" + }, + "anchor.20c8e0235988c9b0": { + "scope": "global", + "selector": "html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > header:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.2114293c52e551f4": { + "scope": "global", + "selector": "#standard-board-shell > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.21698f130aa6a3f0": { + "scope": "canvas", + "selector": "#workspace-canvas > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.22651ba922ba4d45": { + "scope": "global", + "selector": "#canvas-assistive-menu", + "tag": "div" + }, + "anchor.22d8635af118417c": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.22da97c67e347112": { + "scope": "text", + "selector": "#workspace-text > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.23b36f5f58457305": { + "scope": "dialog:library-copy-dialog", + "selector": "#library-copy-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.2409d9b8bbe37313": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-playback-controls > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.25749c6b934cb811": { + "scope": "text", + "selector": "#text-fields > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.26fc413aa931b9cc": { + "scope": "text", + "selector": "#workspace-text > article:nth-of-type(1)", + "tag": "article" + }, + "anchor.27611c528ac90111": { + "scope": "global", + "selector": "#animation-preview-shell > article:nth-of-type(1)", + "tag": "article" + }, + "anchor.28b7168658713605": { + "scope": "media-import", + "selector": "#workspace-media-import > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.2b397c76eca26ec2": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(11) > dl:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.2b8e101f4283dfaa": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(11) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.331b75c580a5f47d": { + "scope": "templates", + "selector": "#workspace-templates", + "tag": "section" + }, + "anchor.340925d0ba377a4b": { + "scope": "templates", + "selector": "#workspace-templates > div:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.3583780951966f89": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(8) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.35c22b2f525b3e92": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-playback-controls", + "tag": "section" + }, + "anchor.383d9e24406bcc86": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(9) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.3908719fa02c64c5": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(2) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.3914725f73dd969b": { + "scope": "global", + "selector": "#board-shell", + "tag": "section" + }, + "anchor.3abcf928a8dbf883": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.3b467c503f1f49b0": { + "scope": "text", + "selector": "#text-fields", + "tag": "fieldset" + }, + "anchor.3cab6526b2a0c6c1": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-preview > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.3dfdf0feb14990e8": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.3e4f6391159d1c32": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.3ed78887db7f06dd": { + "scope": "settings", + "selector": "#workspace-settings", + "tag": "section" + }, + "anchor.40afa23868c37772": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.415a046d4e8c318a": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.41d1343cebebbf64": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(2)", + "tag": "article" + }, + "anchor.42cb74bd41359d98": { + "scope": "device", + "selector": "#display-test-controls", + "tag": "div" + }, + "anchor.42da685589fe31f7": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(8) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.43a66b8e647c6d5a": { + "scope": "settings", + "selector": "#workspace-settings > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.43e54be8daa7b257": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.455e763f5956339d": { + "scope": "global", + "selector": "#canvas-edit-assistive", + "tag": "div" + }, + "anchor.489f19ca2bc24c02": { + "scope": "global", + "selector": "#standard-board-shell", + "tag": "div" + }, + "anchor.4a534bc38f5eea5e": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.4ad40b4317440dac": { + "scope": "dialog:animation-operation-dialog", + "selector": "#animation-operation-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.4c2ef8ee6f52d8ec": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(7)", + "tag": "article" + }, + "anchor.4c86b24cf4355d1b": { + "scope": "dialog:frp-config-dialog", + "selector": "#frp-config-dialog > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.4d96c43678064ebc": { + "scope": "text", + "selector": "#text-form > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.5282f6b6989af189": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(2) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.555efad87f0cc4d9": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(8) > dl:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.556caf4311aa0223": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.5639dc95e3e42e34": { + "scope": "dialog:animation-dirty-dialog", + "selector": "#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.56e9fe5c9483afd3": { + "scope": "media-import", + "selector": "#media-upload-form", + "tag": "form" + }, + "anchor.58712f590cbaf3a7": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1)", + "tag": "article" + }, + "anchor.5968dc15c128d52d": { + "scope": "text", + "selector": "#workspace-text > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.5ab63c82df6915c4": { + "scope": "device", + "selector": "#workspace-device", + "tag": "section" + }, + "anchor.5bbedb82dc65baf1": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-dialog > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.5bf4431bbf9037df": { + "scope": "animations", + "selector": "#animation-folders", + "tag": "div" + }, + "anchor.5e230c9ee959190e": { + "scope": "media-import", + "selector": "#media-upload-form > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.5fa429bc3228e4a1": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.6073f80efcbd4470": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(8) > dl:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.61a8e54d36ef9fea": { + "scope": "templates", + "selector": "#template-grid", + "tag": "div" + }, + "anchor.633cc921aacc6857": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(1)", + "tag": "article" + }, + "anchor.63cd54c6adc99d94": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(7) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.64f8947ff9e9133a": { + "scope": "global", + "selector": "#draft-conflict-notice", + "tag": "section" + }, + "anchor.65ea430cca9d489e": { + "scope": "templates", + "selector": "#workspace-templates > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.65f4eb267b96df47": { + "scope": "global", + "selector": "#draft-conflict-notice > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.669b2a7977bc1d61": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(3) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.689ce5680d7bd981": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-preview > div:nth-of-type(5)", + "tag": "div" + }, + "anchor.69e7ed7608d451a0": { + "scope": "dialog:ota-failure-log-dialog", + "selector": "#ota-failure-log-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.6adaa4635045cd76": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(4)", + "tag": "div" + }, + "anchor.6b9a1ae6e88f8c89": { + "scope": "media-import", + "selector": "#workspace-media-import > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.6d02afe42b8cdba3": { + "scope": "animations", + "selector": "#workspace-animations > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.6db4136c029a5583": { + "scope": "dialog:network-diagnostics-dialog", + "selector": "#network-diagnostics-dialog > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.700e4d49ba64cdf6": { + "scope": "text", + "selector": "#text-form > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.72a3146b7dd53a7f": { + "scope": "global", + "selector": "#standard-board-shell > div:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.778ff3b3c78d30b2": { + "scope": "settings", + "selector": "#matrix-refresh-rate-grid", + "tag": "div" + }, + "anchor.786b2fed31a08462": { + "scope": "text", + "selector": "#text-font-options", + "tag": "div" + }, + "anchor.7964f731fdd48337": { + "scope": "global", + "selector": "#standard-board-shell > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.7b27055018a9d94b": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.7b572e4bda033fd9": { + "scope": "global", + "selector": "html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > header:nth-of-type(1)", + "tag": "header" + }, + "anchor.7e252d782c29e1b0": { + "scope": "global", + "selector": "#animation-preview-shell > article:nth-of-type(2) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.7e8db092be815ba8": { + "scope": "drawer", + "selector": "#workspace-drawer > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.7f1728128cefef44": { + "scope": "dialog:frp-config-dialog", + "selector": "#frp-config-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.7f18793b7efb43c0": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(11) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.7fb28baef5269731": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.81aa0caba21e9a06": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.82ed7298c06f50c5": { + "scope": "animations", + "selector": "#animation-batch-toolbar > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.83f6e7c5f8faa67f": { + "scope": "global", + "selector": "#animation-preview-shell > article:nth-of-type(2)", + "tag": "article" + }, + "anchor.84c09888954c1f13": { + "scope": "device", + "selector": "#display-test-controls > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.8558b888154e4648": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.88d1e2de634bc893": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.895fb0ced81769cf": { + "scope": "dialog:animation-dirty-dialog", + "selector": "#animation-dirty-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.89b6f41138d36788": { + "scope": "text", + "selector": "#text-fields > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.8a2950bc67c1be6e": { + "scope": "media-import", + "selector": "#media-import-jobs", + "tag": "div" + }, + "anchor.8a42e36ce9097b6a": { + "scope": "global", + "selector": "#control-feedback-toast", + "tag": "div" + }, + "anchor.8f9ec62879968132": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.8fda536b33fefc20": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.9208b9bcce4a8aab": { + "scope": "text", + "selector": "#workspace-text > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.92332380dad484af": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.9349c76f4682b51f": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.966153aff21642e4": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.9723b53118c25912": { + "scope": "canvas", + "selector": "#workspace-canvas > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.980a5ec1072ae80e": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.99def0a011f6b41d": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(3)", + "tag": "article" + }, + "anchor.9c374a9b002f090f": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(11)", + "tag": "article" + }, + "anchor.9ccb02ac372b1ebd": { + "scope": "dialog:animation-position-dialog", + "selector": "#animation-position-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.9f88495f3174f7c6": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.a1c9f6e81ce02333": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-dialog > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.a1ee38b5ca47b735": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(9) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.a2018ecfe46b9b9a": { + "scope": "global", + "selector": "#animation-preview-shell", + "tag": "section" + }, + "anchor.a20f15783064bf7f": { + "scope": "dialog:network-diagnostics-dialog", + "selector": "#network-diagnostics-dialog > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.a4767cc4c478e862": { + "scope": "settings", + "selector": "#ota-progress-group > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.a5ac48970022453f": { + "scope": "canvas", + "selector": "#workspace-canvas", + "tag": "section" + }, + "anchor.a902c9c0ae99988e": { + "scope": "dialog:template-confirm-dialog", + "selector": "#template-confirm-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.a94c576d6a9cc5c9": { + "scope": "settings", + "selector": "#ota-progress-group", + "tag": "div" + }, + "anchor.ab385fc1ffc5506b": { + "scope": "settings", + "selector": "#workspace-settings > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.ab520e00973eafbc": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.abb0d2c055f12f5f": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.abdd30d1a9b1e54c": { + "scope": "dialog:ota-failure-log-dialog", + "selector": "#ota-failure-log-dialog > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.abf340b7d2c7f8f3": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.ac03b3df490f0810": { + "scope": "dialog:template-save-choice-dialog", + "selector": "#template-save-choice-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.aca2ddf7da4bb525": { + "scope": "dialog:frp-config-dialog", + "selector": "#frp-config-dialog > div:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.ad3c8aff5b4f10ff": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1)", + "tag": "section" + }, + "anchor.adc122f9f19faf4a": { + "scope": "settings", + "selector": "#ota-failure-log-actions", + "tag": "div" + }, + "anchor.b22f195a3699cbb4": { + "scope": "global", + "selector": "#control-unavailable-tooltip", + "tag": "div" + }, + "anchor.b2db9cf70bfb9d95": { + "scope": "global", + "selector": "#standard-board-shell > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.b3f182665d46edbc": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-preview > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.b4d98fd4c0029e8b": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(10) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.b6475c48e2678d6d": { + "scope": "dialog:ota-failure-log-dialog", + "selector": "#ota-failure-log-dialog > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.b64e40c1a4e901b4": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(9)", + "tag": "article" + }, + "anchor.b6f965a426bf7e05": { + "scope": "animations", + "selector": "#workspace-animations", + "tag": "section" + }, + "anchor.b751d4c78194dd03": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-core-list", + "tag": "div" + }, + "anchor.b9da829b67eacd85": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(8)", + "tag": "article" + }, + "anchor.bc250c26b69d11ab": { + "scope": "dialog:template-name-dialog", + "selector": "#template-name-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.bde2c3b603b696e1": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(7) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.be6ce87ecaacace7": { + "scope": "drawer", + "selector": "#workspace-drawer > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.bf04ae5d251b1663": { + "scope": "media-import", + "selector": "#workspace-media-import", + "tag": "section" + }, + "anchor.bf5e873ea348e3a6": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.bfa31771500b1857": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.c1a4ac2928430180": { + "scope": "dialog:ota-failure-log-dialog", + "selector": "#ota-failure-log-dialog > div:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.c24e69afbe87be69": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(10)", + "tag": "article" + }, + "anchor.c33130b139428259": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.c44a6a8bef32f472": { + "scope": "dialog:template-confirm-dialog", + "selector": "#template-confirm-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.c468315092a5d1ec": { + "scope": "dialog:animation-copy-destination-dialog", + "selector": "#animation-copy-destination-dialog > form:nth-of-type(1)", + "tag": "form" + }, + "anchor.c61d15f9b5fbd0b6": { + "scope": "global", + "selector": "#animation-edit-notice", + "tag": "section" + }, + "anchor.c7197a296052c7d5": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(4)", + "tag": "div" + }, + "anchor.c85d90b5c6a35a71": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2)", + "tag": "section" + }, + "anchor.c8ed714e5a9dab28": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.c9d6f882cb011292": { + "scope": "animations", + "selector": "#animation-frame-panel", + "tag": "section" + }, + "anchor.cbf0b4465a6ab0c1": { + "scope": "media-import", + "selector": "#media-upload-form > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.cc67ba821baf757b": { + "scope": "dialog:current-display-dialog", + "selector": "#current-display-playback-controls > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.cca9238f6b60e24c": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.ccebe66d017d30ab": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.ceb59551aa94ba2e": { + "scope": "device", + "selector": "#workspace-device > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.d0c0ae4bf4be9a74": { + "scope": "animations", + "selector": "#workspace-animations > section:nth-of-type(2)", + "tag": "section" + }, + "anchor.d101f5ee5081d6d4": { + "scope": "root", + "selector": "#workspace-root", + "tag": "main" + }, + "anchor.d2122138a0fc7849": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1)", + "tag": "section" + }, + "anchor.d37db195c3505491": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(5)", + "tag": "article" + }, + "anchor.d3ae735809cfec81": { + "scope": "settings", + "selector": "#wifi-manual-fields", + "tag": "div" + }, + "anchor.d50dc9f49cc40550": { + "scope": "global", + "selector": "#pixel-grid", + "tag": "div" + }, + "anchor.d6780d37d1d6c49a": { + "scope": "animations", + "selector": "#workspace-animations > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.d727912cd398a52e": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-preview > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.d72d0f46a5f7ff5e": { + "scope": "global", + "selector": "#app-version-notice", + "tag": "section" + }, + "anchor.d8c761ec98278997": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.d8e3c458b7f53aeb": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.dab18b72c4adf1ef": { + "scope": "dialog:animation-operation-dialog", + "selector": "#animation-operation-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.db5867ddb871d530": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(1)", + "tag": "article" + }, + "anchor.dd5e685ac5fbad17": { + "scope": "templates", + "selector": "#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.e052488d75f96beb": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.e0859aebd834ea7f": { + "scope": "animations", + "selector": "#workspace-animations > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.e14cb0ef2898cc23": { + "scope": "text", + "selector": "#workspace-text > article:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.e30b41c2b57d61de": { + "scope": "dialog:template-name-dialog", + "selector": "#template-name-dialog > form:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.e4c3dc609be71f22": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.e67df94d560a7770": { + "scope": "dialog:resource-details-dialog", + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(3)", + "tag": "div" + }, + "anchor.e72888c4cecd61a0": { + "scope": "global", + "selector": "#board-stage", + "tag": "div" + }, + "anchor.e801f6afa5933e61": { + "scope": "text", + "selector": "#text-element-list", + "tag": "div" + }, + "anchor.e8fb84570327abb6": { + "scope": "global", + "selector": "html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > header:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.ea9bed8dd248fd22": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.eae410d5a7bd5d62": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.ec27ad4ea6179405": { + "scope": "device", + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.ee620b326b1d71e3": { + "scope": "global", + "selector": "#animation-preview-shell > article:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.ef6fde5bfd4df0d4": { + "scope": "dialog:template-save-choice-dialog", + "selector": "#template-save-choice-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.f030b7fa4bde57e7": { + "scope": "text", + "selector": "#text-form > div:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.f1b50461944816f8": { + "scope": "dialog:animation-copy-destination-dialog", + "selector": "#animation-copy-destination-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.f1c40783d4e81438": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.f2c435a6bdec5544": { + "scope": "text", + "selector": "#text-content-fields", + "tag": "fieldset" + }, + "anchor.f465a8f60c302ba5": { + "scope": "dialog:library-copy-dialog", + "selector": "#library-copy-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.f4ef9e14da054dc5": { + "scope": "settings", + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.f501b612032768df": { + "scope": "dialog:voltage-calibration-dialog", + "selector": "#voltage-calibration-preview > div:nth-of-type(4)", + "tag": "div" + }, + "anchor.f62909ba07373523": { + "scope": "animations", + "selector": "#animation-frames", + "tag": "div" + }, + "anchor.f71afe334988c888": { + "scope": "global", + "selector": "#app-version-notice > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.f9a98e712b2f5bd9": { + "scope": "drawer", + "selector": "#workspace-drawer > div:nth-of-type(2)", + "tag": "div" + }, + "anchor.fd0fe349263d0544": { + "scope": "dialog:animation-position-dialog", + "selector": "#animation-position-dialog > form:nth-of-type(1) > div:nth-of-type(1)", + "tag": "div" + }, + "anchor.fdd4c209a877d124": { + "scope": "canvas", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4)", + "tag": "div" + }, + "anchor.fe23d702c01024bc": { + "scope": "global", + "selector": "#topbar-low-voltage-warning", + "tag": "div" + } + }, + "insertions": [], + "items": { + "copy.00adffedb22d93a5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-address-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-address-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.02530b6a92eeaf5e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#duplicate-text", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#duplicate-text::text[0]", + "text": "复制选中" + }, + "copy.029d86bb3248f1ca": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#template-grid", + "type": "attribute" + }, + "scope": "templates", + "source": "index.html::#template-grid::@aria-label", + "text": "已保存模板" + }, + "copy.03a52bb8b2e4c91e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。" + }, + "copy.03b3a0f2dabecdd0": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-fields > div:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-fields > div:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "字号" + }, + "copy.03bffee596c9e41d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-name-dialog > form:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-name-dialog", + "source": "index.html::#template-name-dialog > form:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "模板名称" + }, + "copy.03d84760b4e0dbf7": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(9) > div:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(9) > div:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "性能模式" + }, + "copy.03e0588345287c06": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-page-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-page-status::text[0]", + "text": "第 1 / 1 页 · 共 0 帧" + }, + "copy.0475a2ea3a93df47": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#media-upload-button", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#media-upload-button::text[0]", + "text": "上传文件" + }, + "copy.04e050d9ac14b6f0": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-edit-name", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-edit-name::text[0]", + "text": "正在编辑:" + }, + "copy.059802fc5a87065d": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#matrix-refresh-rate-grid", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#matrix-refresh-rate-grid::@aria-label", + "text": "屏幕扫描刷新率上限" + }, + "copy.05f352b529a82949": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(3) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(3) > span:nth-of-type(1)::text[0]", + "text": "当前软件总占用空间" + }, + "copy.0645bf9664f84e9b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-copy-destination-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-copy-destination-dialog", + "source": "index.html::#animation-copy-destination-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.06f2d02ebd1c2e6c": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#performance-mode-enabled", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#performance-mode-enabled::@aria-label", + "text": "性能模式" + }, + "copy.08f8989cc47bb4bd": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-preview > div:nth-of-type(3) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-preview > div:nth-of-type(3) > dt:nth-of-type(1)::text[0]", + "text": "当前系数" + }, + "copy.09d70d7dda4624fe": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-save-choice-message", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-save-choice-dialog", + "source": "index.html::#template-save-choice-message::text[0]", + "text": "请选择保存方式。" + }, + "copy.0a2bacf6cf154355": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-canvas > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "内容" + }, + "copy.0a5626eccce25bdf": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3)", + "type": "attribute" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3)::@aria-label", + "text": "绘制工具" + }, + "copy.0b6dfc2784eb7cb7": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#export-composition", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#export-composition::text[0]", + "text": "导出合成 PNG" + }, + "copy.0b94fa9c917543b3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-batch-delete", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-batch-delete::text[0]", + "text": "批量删除" + }, + "copy.0bcfee07f7ea6b0c": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#text-font-options", + "type": "attribute" + }, + "scope": "text", + "source": "index.html::#text-font-options::@aria-label", + "text": "可用字体" + }, + "copy.0c4edad800ff6b25": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-memory-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-memory-title::text[0]", + "text": "内存" + }, + "copy.0d4f9d3e7b74dd7a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(3) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(3) > dt:nth-of-type(1)::text[0]", + "text": "校准状态" + }, + "copy.0d58b8e30aa1f0e3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-save-new", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-save-choice-dialog", + "source": "index.html::#template-save-new::text[0]", + "text": "另存为新模板" + }, + "copy.0d92e04290486e58": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-close", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-close::text[0]", + "text": "关闭" + }, + "copy.0e2ecc5b317aff50": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-format > option:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-format > option:nth-of-type(3)::text[0]", + "text": "JSON" + }, + "copy.0e6a8a62ac959776": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "应用占整机内存" + }, + "copy.10496f2011279d36": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#brush-size", + "type": "attribute" + }, + "scope": "canvas", + "source": "index.html::#brush-size::@aria-label", + "text": "画笔和橡皮粗细" + }, + "copy.108fd5c4989641f9": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#media-import-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#media-import-title::text[0]", + "text": "媒体内容转换" + }, + "copy.10d133b3b087ab39": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#settings-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#settings-title::text[0]", + "text": "系统设置" + }, + "copy.11ecdc44d6a6207e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-dns-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-dns-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.12de5929c70f804d": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#current-display-preview-surface", + "type": "attribute" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-preview-surface::@aria-label", + "text": "当前实屏逻辑帧预览" + }, + "copy.14b54d17eef350f9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#library-copy-target > option:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:library-copy-dialog", + "source": "index.html::#library-copy-target > option:nth-of-type(1)::text[0]", + "text": "静态模板" + }, + "copy.14c489896e26ac77": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-device", + "type": "attribute" + }, + "scope": "device", + "source": "index.html::#workspace-device::@aria-label", + "text": "设备状态和测试" + }, + "copy.1505c2b60d2d4066": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-confirm-dialog > form:nth-of-type(1) > h2:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-confirm-dialog", + "source": "index.html::#template-confirm-dialog > form:nth-of-type(1) > h2:nth-of-type(1)::text[0]", + "text": "确认模板操作" + }, + "copy.165242cdfffb5e56": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-selection-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-selection-status::text[0]", + "text": "新建文字或从列表选择一个元素" + }, + "copy.16d12c6a06b4d8a6": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-batch-copy", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-batch-copy::text[0]", + "text": "批量复制" + }, + "copy.18561d0d89aa0ff4": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(3) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(3) > dt:nth-of-type(1)::text[0]", + "text": "整机内存总占用" + }, + "copy.18abf73b22c104c1": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#canvas-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#canvas-title::text[0]", + "text": "像素画布" + }, + "copy.18e908dd7123e6e3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "设备总空间" + }, + "copy.1936d0dd3ebc7f74": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-shell > article:nth-of-type(2) > p:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell > article:nth-of-type(2) > p:nth-of-type(2)::text[0]", + "text": "“从当前画板新建帧”将复制此画面" + }, + "copy.1afe1b354995303b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(3)::text[0]", + "text": "文件格式" + }, + "copy.1b1d3b5aee5c421b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-manual-fields > label:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-manual-fields > label:nth-of-type(3)::text[0]", + "text": "子网前缀(选填)" + }, + "copy.1b43bfc995b1e115": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#custom-test-fill", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#custom-test-fill::text[0]", + "text": "自定义纯色" + }, + "copy.1b5dd54af645720f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#network-diagnostics-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:network-diagnostics-dialog", + "source": "index.html::#network-diagnostics-title::text[0]", + "text": "网络检测结果" + }, + "copy.1b6e79bdc3a2ef2f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-new", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-new::text[0]", + "text": "新建空白配置" + }, + "copy.1bb459d8fcb41de4": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-operation-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-operation-dialog", + "source": "index.html::#animation-operation-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.1ced63dbb2f54c2b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-view-failure-log", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#ota-view-failure-log::text[0]", + "text": "查看失败日志" + }, + "copy.1d81dcc19552c4c3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#reload-app", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#reload-app::text[0]", + "text": "刷新到新版本" + }, + "copy.1dff9701a02d53b2": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#undo-canvas-edit", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#undo-canvas-edit::@data-unavailable-reason", + "text": "还没有可以撤销的像素操作。" + }, + "copy.1e5c96517e769c2f": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#drawer-toggle", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#drawer-toggle::@aria-label", + "text": "打开功能菜单" + }, + "copy.1e7239352037a750": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-gateway-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-gateway-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.1e7b45a25d612992": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#app-version-notice > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#app-version-notice > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "当前页面版本已过期。画板仍可编辑和导出,但刷新前不会再向设备或模板提交操作。" + }, + "copy.1fd800b8cf8b0452": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(11) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(11) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "运行状态" + }, + "copy.2027bfa631cfa1e9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-shell-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell-title::text[0]", + "text": "动图与当前画板预览" + }, + "copy.209c8a51007fadfb": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-text > article:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#workspace-text > article:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "Pillow" + }, + "copy.20b39138b667d12c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#select-default-content", + "text_index": 0, + "type": "static_text" + }, + "scope": "templates", + "source": "index.html::#select-default-content::text[0]", + "text": "设置默认显示内容" + }, + "copy.20d30c80341dd625": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-copy-destination-dialog > form:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-copy-destination-dialog", + "source": "index.html::#animation-copy-destination-dialog > form:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "复制到动图" + }, + "copy.21210199cb26caf2": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-preview > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-preview > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "参考电压" + }, + "copy.21b081f4374ecf16": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-order-toggle", + "text_index": 0, + "type": "static_text" + }, + "scope": "order-toggle", + "source": "index.html::#workspace-order-toggle::text[0]", + "text": "自定义项目位置" + }, + "copy.21df222b7e2915ef": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#undo-canvas-edit", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#undo-canvas-edit::text[0]", + "text": "撤销" + }, + "copy.22967a4b52deaa28": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-fields > div:nth-of-type(1) > label:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-fields > div:nth-of-type(1) > label:nth-of-type(2)::text[0]", + "text": "X 坐标" + }, + "copy.23c6e8b2d7a9c68c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-save", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-save::text[0]", + "text": "保存配置" + }, + "copy.23ea488815bb0c69": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-select > option:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-select > option:nth-of-type(1)::text[0]", + "text": "暂无配置" + }, + "copy.24493655c0cb1754": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-position-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-position-dialog", + "source": "index.html::#animation-position-title::text[0]", + "text": "选择插入位置" + }, + "copy.24bea9106d3f75f7": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-selected-name", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#frp-selected-name::text[0]", + "text": "无" + }, + "copy.255bbe5e8f7739d3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "笔尖粗细" + }, + "copy.25aa347187bf1916": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#text-element-list", + "type": "attribute" + }, + "scope": "text", + "source": "index.html::#text-element-list::@aria-label", + "text": "文字元素列表" + }, + "copy.26691ca3a24a5a00": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#exit-display-test", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#exit-display-test::text[0]", + "text": "退出测试" + }, + "copy.267697d7d5b31a20": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(6) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "WiFi设置" + }, + "copy.27d60e6ce20ea5b9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > h3:nth-of-type(1)::text[0]", + "text": "纯色测试" + }, + "copy.28aea19695fd4df3": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#refresh-media-imports", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#refresh-media-imports::text[0]", + "text": "刷新任务" + }, + "copy.2998517fac99a806": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-align > option:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-align > option:nth-of-type(3)::text[0]", + "text": "右对齐" + }, + "copy.2ab278628b9419da": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-drawer", + "type": "attribute" + }, + "scope": "drawer", + "source": "index.html::#workspace-drawer::@aria-label", + "text": "功能选择" + }, + "copy.2c3a2e139aee3174": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-failure-log-copy", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:ota-failure-log-dialog", + "source": "index.html::#ota-failure-log-copy::text[0]", + "text": "复制全部日志" + }, + "copy.2c783f3068fc5ac0": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#display-test-controls > div:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#display-test-controls > div:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "测试亮度(此亮度独立于屏幕亮度)" + }, + "copy.2d6b4a29feadb5e2": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(5) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(5) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "预览刷新间隔" + }, + "copy.2e9eddfdbe010ba1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-failure-log-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:ota-failure-log-dialog", + "source": "index.html::#ota-failure-log-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "可将完整内容复制后提供给维护人员排查。" + }, + "copy.2f2248f76a8ce3bf": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(1)::text[0]", + "text": "背景颜色" + }, + "copy.3371be91a3541597": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-dirty-dialog", + "source": "index.html::#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.33c51e7f58d8421e": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#animation-folders", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-folders::@aria-label", + "text": "已有动图" + }, + "copy.33c527f33f0b36e2": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#animation-frames", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-frames::@aria-label", + "text": "动图帧" + }, + "copy.344f8ccd56c92493": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-text > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#workspace-text > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "文字元素" + }, + "copy.3487c74829f9e01c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#reset-voltage-calibration", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#reset-voltage-calibration::text[0]", + "text": "恢复标称值" + }, + "copy.350ea6501fc072fa": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > button:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > button:nth-of-type(3)::text[0]", + "text": "吸管" + }, + "copy.3664bcf4c3469364": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-selection-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-selection-status::text[0]", + "text": "未选择帧" + }, + "copy.3796c77532d109a8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(2) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(2) > span:nth-of-type(1)::text[0]", + "text": "颜色 RGB / HEX" + }, + "copy.383b59131da1e5ca": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-fields > div:nth-of-type(1) > label:nth-of-type(4)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-fields > div:nth-of-type(1) > label:nth-of-type(4)::text[0]", + "text": "对齐" + }, + "copy.3a05098f2bf9a119": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#library-copy-dialog > form:nth-of-type(1) > h2:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:library-copy-dialog", + "source": "index.html::#library-copy-dialog > form:nth-of-type(1) > h2:nth-of-type(1)::text[0]", + "text": "选择复制目标" + }, + "copy.3a0f640533e6b611": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "当前电压" + }, + "copy.3a19c784c127085c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "供电电压" + }, + "copy.3c957547c2ea15fe": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-media-import > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#workspace-media-import > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "内容" + }, + "copy.3cd4fd0fe57b6a44": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-dialog > form:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-dialog > form:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "万用表参考电压(V)" + }, + "copy.3d043cc2dc3a24d2": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#current-display-progress", + "type": "attribute" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-progress::@aria-label", + "text": "动图播放位置" + }, + "copy.3d326c05561b6910": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-position-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-position-dialog", + "source": "index.html::#animation-position-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)::text[0]", + "text": "确认位置" + }, + "copy.3d641efe6b46db74": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(5) > label:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(5) > label:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "毫秒" + }, + "copy.3d8114e372897789": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#current-display-playback-controls", + "type": "attribute" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-playback-controls::@aria-label", + "text": "动图播放控制" + }, + "copy.3ec33c8f400aba24": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#exit-animation-edit", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#exit-animation-edit::text[0]", + "text": "退出动图编辑" + }, + "copy.3f40f75e85b28913": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#library-copy-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:library-copy-dialog", + "source": "index.html::#library-copy-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.3ff93800835c3124": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#test-color", + "type": "attribute" + }, + "scope": "device", + "source": "index.html::#test-color::@aria-label", + "text": "查看测试颜色" + }, + "copy.402188f66dd52c3c": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#canvas-assistive-toggle", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#canvas-assistive-toggle::@aria-label", + "text": "打开画布编辑菜单" + }, + "copy.406f27483aa5f15c": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#media-import-feedback", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#media-import-feedback::text[0]", + "text": "选择文件并填写保存名称。" + }, + "copy.4094191dc72fcb18": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#start-voltage-calibration", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#start-voltage-calibration::text[0]", + "text": "校准电压传感器" + }, + "copy.41610c76b26efd03": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-format > option:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-format > option:nth-of-type(2)::text[0]", + "text": "YAML" + }, + "copy.419fca8fba3f75f9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-speed-controls > button:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-speed-controls > button:nth-of-type(3)::text[0]", + "text": "1.5×" + }, + "copy.4319bf4398926360": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-dialog > form:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-dialog > form:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "保持整机正常约 5V。把可靠万用表并联到 ADC 相同的屏幕输入 5V/GND 测量点,再输入万用表读数。" + }, + "copy.43946981f7324068": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-manual-fields > label:nth-of-type(4)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-manual-fields > label:nth-of-type(4)::text[0]", + "text": "DNS(选填)" + }, + "copy.443c920db29b3cd8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-dialog-feedback", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-dialog-feedback::text[0]", + "text": "点击“采样并预览”,约需 1 秒。" + }, + "copy.44be89470efeea1b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-confirm-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-confirm-dialog", + "source": "index.html::#template-confirm-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.458a77b4daf36673": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#animation-board-preview", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#animation-board-preview::@aria-label", + "text": "当前统一画板预览" + }, + "copy.45992794966490b9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-title::text[0]", + "text": "资源占用详情" + }, + "copy.46f630d471e6d66f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#fill-canvas", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#fill-canvas::text[0]", + "text": "填充背景" + }, + "copy.47da2056b7f26ce1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#templates-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "templates", + "source": "index.html::#templates-title::text[0]", + "text": "模板管理" + }, + "copy.482551f0557b3db5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-password-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-password-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.48a96fc9d695f5d1": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#animation-frame-pagination", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-frame-pagination::@aria-label", + "text": "动图帧分页" + }, + "copy.493fa69c164ac111": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-format > option:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-format > option:nth-of-type(1)::text[0]", + "text": "TOML" + }, + "copy.493fb0f4a64c0020": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#load-external-draft", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#load-external-draft::text[0]", + "text": "载入其他标签草稿" + }, + "copy.4a2ff24bc62e26ef": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-text > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#workspace-text > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "内容" + }, + "copy.4a9b6b0d583997cc": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-feedback", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-feedback::text[0]", + "text": "请选择或新建动图。" + }, + "copy.4b5c6679d0c83d20": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > div:nth-of-type(1) > div:nth-of-type(1) > strong:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > div:nth-of-type(1) > div:nth-of-type(1) > strong:nth-of-type(1)::text[0]", + "text": "自动获取 IP(DHCP)" + }, + "copy.4b78df5f2aefa944": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-preview-placeholder", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-preview-placeholder::text[0]", + "text": "正在读取当前画面…" + }, + "copy.4c77e1a44745fb2f": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#frp-service-toggle", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#frp-service-toggle::@data-unavailable-reason", + "text": "请先导入或新建 FRP 配置。" + }, + "copy.4e05e5e60ee78cac": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-position-hint", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-position-dialog", + "source": "index.html::#animation-position-hint::text[0]", + "text": "选择目标帧后,所选帧将插入到该帧之后;选择“插入到最前”则置于第一帧前。" + }, + "copy.4e751b968be549e8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "屏幕亮度" + }, + "copy.4fe36b8411123474": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#import-font", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#import-font::text[0]", + "text": "导入字体" + }, + "copy.5063d1a70ca2545f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-page-next", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-page-next::text[0]", + "text": "下一页" + }, + "copy.5085f7b141e15d48": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#performance-mode-description", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#performance-mode-description::text[0]", + "text": "如果使用出现卡顿,可以打开此选项,但会降低续航。" + }, + "copy.50c47bf8855afbdb": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-frame-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-frame-title::text[0]", + "text": "当前动图" + }, + "copy.512f271688bd7139": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#clear-canvas", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#clear-canvas::text[0]", + "text": "清空像素层" + }, + "copy.52342cfa0f4d1967": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-feedback", + "text_index": 0, + "type": "static_text" + }, + "scope": "templates", + "source": "index.html::#template-feedback::text[0]", + "text": "正在读取模板…" + }, + "copy.5378fd8ef58a2368": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)" + }, + "copy.55db93bad76b7e74": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-drawer > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "drawer", + "source": "index.html::#workspace-drawer > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "64 × 64 点阵屏" + }, + "copy.56f1193ee4a7192d": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-settings > article:nth-of-type(3) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(3) > p:nth-of-type(1)::text[0]", + "text": "这里设置的是扫描上限,不是保证精确达到的实时测量值,也不同于下方网页预览刷新间隔。" + }, + "copy.58493e332274a2cc": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#pixel-edit-toggle", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#pixel-edit-toggle::text[0]", + "text": "开启单像素编辑" + }, + "copy.586622b0e4739a7f": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#confirm-voltage-calibration", + "type": "attribute" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#confirm-voltage-calibration::@data-unavailable-reason", + "text": "请先完成“采样并预览”,再保存校准。" + }, + "copy.59c9c4a1a8c5d33d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-seek-unavailable-reason", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-seek-unavailable-reason::text[0]", + "text": "请先完成当前进度跳转。" + }, + "copy.5b8069a106f33b3c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(11) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(11) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "frp配置" + }, + "copy.5c2bbfd764e110f9": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#text-title", + "type": "attribute" + }, + "scope": "text", + "source": "index.html::#text-title::@aria-label", + "text": "文字显示" + }, + "copy.5d3f13cfbe64bf8e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-shell > article:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell > article:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "当前动图" + }, + "copy.5e1ac9555b12a59f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-edit-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-edit-status::text[0]", + "text": "已保存" + }, + "copy.5e26a038197f59b8": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-text > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#workspace-text > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "正向 0°" + }, + "copy.5f8049bbe00d707e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-copy-failure-log", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#ota-copy-failure-log::text[0]", + "text": "复制日志" + }, + "copy.6080bbc04065b13f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-content-fields > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-content-fields > label:nth-of-type(1)::text[0]", + "text": "文字内容" + }, + "copy.62029c969ffb9c9d": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-drawer > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "drawer", + "source": "index.html::#workspace-drawer > p:nth-of-type(1)::text[0]", + "text": "局域网设备控制台" + }, + "copy.622494755fac8041": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-position-dialog > form:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-position-dialog", + "source": "index.html::#animation-position-dialog > form:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "插入位置" + }, + "copy.623419ad30e1d25e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "亮度(实际亮度会受到电压保护功能限制)" + }, + "copy.625ce2428665f370": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-delete", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-delete::text[0]", + "text": "删除" + }, + "copy.6288c9354cae976b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#create-animation", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#create-animation::text[0]", + "text": "新建动图" + }, + "copy.62a4eebd1eed0e90": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-format > option:nth-of-type(4)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-format > option:nth-of-type(4)::text[0]", + "text": "INI(兼容格式)" + }, + "copy.62de776ffe9070b5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#refresh-templates", + "text_index": 0, + "type": "static_text" + }, + "scope": "templates", + "source": "index.html::#refresh-templates::text[0]", + "text": "刷新" + }, + "copy.6370aa1d0515708d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-copy-destination-dialog > form:nth-of-type(1) > h2:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-copy-destination-dialog", + "source": "index.html::#animation-copy-destination-dialog > form:nth-of-type(1) > h2:nth-of-type(1)::text[0]", + "text": "选择批量复制目标" + }, + "copy.63f6d37bb7d3df10": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(7) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(7) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "设置设备开机后等待多少秒再显示网络连接提示。" + }, + "copy.6483ab3cbcda83f6": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(8) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(8) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "最新功能更新时间" + }, + "copy.66b5d66b0c5d8268": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-save-choice-dialog > form:nth-of-type(1) > h2:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-save-choice-dialog", + "source": "index.html::#template-save-choice-dialog > form:nth-of-type(1) > h2:nth-of-type(1)::text[0]", + "text": "保存模板" + }, + "copy.670c95eddb9bac43": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-canvas > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "正向 0°" + }, + "copy.6771f7b5da3b64b9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-preview > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-preview > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "未校准读数" + }, + "copy.67902b3db0483468": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#media-upload-form > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#media-upload-form > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "导入图片、动图或视频" + }, + "copy.67a5d8e186cc1705": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-failure-log-close", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:ota-failure-log-dialog", + "source": "index.html::#ota-failure-log-close::text[0]", + "text": "关闭" + }, + "copy.6810df30a70f9f9c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "html:nth-of-type(1) > head:nth-of-type(1) > title:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::html:nth-of-type(1) > head:nth-of-type(1) > title:nth-of-type(1)::text[0]", + "text": "奇妙小屏幕控制器" + }, + "copy.68778fd3c16d7d67": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.6a4a7785bf6d9659": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#animation-batch-move", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-batch-move::@data-unavailable-reason", + "text": "请先勾选至少一帧。" + }, + "copy.6a586209fd708812": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#start-voltage-calibration", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#start-voltage-calibration::@data-unavailable-reason", + "text": "正在读取电压传感器状态,请稍候。" + }, + "copy.6b9f7e4b06fedebe": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-batch-move", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-batch-move::text[0]", + "text": "批量移动" + }, + "copy.6c55c7881bd85689": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-dhcp-state", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-dhcp-state::text[0]", + "text": "开启" + }, + "copy.6c6942be57743650": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > button:nth-of-type(2)::text[0]", + "text": "橡皮" + }, + "copy.6d0f2bc93792eb3e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-dialog-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-dialog-title::text[0]", + "text": "当前画面预览" + }, + "copy.6da7a87e7c173f8d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(3) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(3) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。" + }, + "copy.6dfda93ddf4256c6": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-align > option:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-align > option:nth-of-type(1)::text[0]", + "text": "左对齐" + }, + "copy.6ee064bf76619e74": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-service-toggle", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#frp-service-toggle::text[0]", + "text": "启动frp" + }, + "copy.7002f34d6b3b8ea9": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(4) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(4) > dt:nth-of-type(1)::text[0]", + "text": "整机 CPU 总占用" + }, + "copy.709bed4144c0f313": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#preview-voltage-calibration", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#preview-voltage-calibration::text[0]", + "text": "采样并预览" + }, + "copy.711e6ff804b23d41": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-speed-controls > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-speed-controls > button:nth-of-type(2)::text[0]", + "text": "1×" + }, + "copy.71922de473a7c23f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-existing-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-existing-title::text[0]", + "text": "保存的动图" + }, + "copy.7306f94dec53830c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-dirty-message", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-dirty-dialog", + "source": "index.html::#animation-dirty-message::text[0]", + "text": "是否应用当前修改到动图?" + }, + "copy.764583466a0897f1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-save-current", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-save-choice-dialog", + "source": "index.html::#template-save-current::text[0]", + "text": "保存到当前模板" + }, + "copy.76a8f19e6219b9d8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#exit-canvas-edit", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#exit-canvas-edit::text[0]", + "text": "退出编辑" + }, + "copy.78c7f66d86ff03e8": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#media-upload-form > div:nth-of-type(2) > label:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "media-import", + "source": "index.html::#media-upload-form > div:nth-of-type(2) > label:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "源文件" + }, + "copy.7a40c35fb8eaacf7": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-operation-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-operation-dialog", + "source": "index.html::#animation-operation-title::text[0]", + "text": "确认动图操作" + }, + "copy.7bc669aeaf1fcf8a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-status::text[0]", + "text": "请先打开一个动图" + }, + "copy.7d34cf4291911bde": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-save-choice-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-save-choice-dialog", + "source": "index.html::#template-save-choice-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.7e30194af1e92f7c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#low-voltage-protection-switch-state", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#low-voltage-protection-switch-state::text[0]", + "text": "关闭" + }, + "copy.7e4896dfcc553faf": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#current-display-dialog-close", + "type": "attribute" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-dialog-close::@aria-label", + "text": "关闭当前画面预览" + }, + "copy.7e8c0ffadaaff275": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-dialog-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-name-dialog", + "source": "index.html::#template-dialog-title::text[0]", + "text": "保存为模板" + }, + "copy.7ff9080a8ecae476": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-dirty-dialog", + "source": "index.html::#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(3)::text[0]", + "text": "应用修改" + }, + "copy.810ade65b04f8e0d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(2)::text[0]", + "text": "配置名称" + }, + "copy.81b7dd59d220db17": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "placeholder", + "selector": "#frp-config-name", + "type": "attribute" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-name::@placeholder", + "text": "远程维护" + }, + "copy.82c8f3ac951918d5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "设备容量" + }, + "copy.83e4a810c846111b": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-preview > div:nth-of-type(4) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-preview > div:nth-of-type(4) > dt:nth-of-type(1)::text[0]", + "text": "建议系数" + }, + "copy.84e208be1b52fa14": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(10) > label:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(10) > label:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "个" + }, + "copy.8556f6cbbb523cd9": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-settings > article:nth-of-type(2) > div:nth-of-type(2)", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(2) > div:nth-of-type(2)::@aria-label", + "text": "屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)" + }, + "copy.866e69def85a651e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-font-label", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-font-label::text[0]", + "text": "字体" + }, + "copy.88894795de930e6e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-form > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-form > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件;缺字时会自动回退。" + }, + "copy.8893fa0c14d61760": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#canvas-assistive-menu", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#canvas-assistive-menu::@aria-label", + "text": "画布编辑操作" + }, + "copy.88cba02728c0b882": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#animation-preview-shell > article:nth-of-type(1) > div:nth-of-type(1)", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell > article:nth-of-type(1) > div:nth-of-type(1)::@aria-label", + "text": "当前打开动图的循环预览" + }, + "copy.8a7f4ace8292af9c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-play-pause", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-play-pause::text[0]", + "text": "暂停" + }, + "copy.8b156b7db0ea157e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(2) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "应用常驻内存" + }, + "copy.8b601efe42c3f0c3": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#ota-failure-log-content", + "type": "attribute" + }, + "scope": "dialog:ota-failure-log-dialog", + "source": "index.html::#ota-failure-log-content::@aria-label", + "text": "OTA 更新失败诊断日志" + }, + "copy.8b91c091682fe0f2": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(8) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(8) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "软件版本" + }, + "copy.8bbe382b2cdba4f7": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#scene-overlay", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#scene-overlay::@aria-label", + "text": "统一画板交互层" + }, + "copy.8bc27d2a600017a3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#apply-composition", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#apply-composition::text[0]", + "text": "应用统一画板" + }, + "copy.8c255b925d2ea026": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#confirm-voltage-calibration", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#confirm-voltage-calibration::text[0]", + "text": "保存校准" + }, + "copy.8c71c7dcee677639": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#duplicate-text", + "type": "attribute" + }, + "scope": "text", + "source": "index.html::#duplicate-text::@data-unavailable-reason", + "text": "请先新建或选择一个文字元素。" + }, + "copy.8f99c70f3be7044a": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#paint-color", + "type": "attribute" + }, + "scope": "canvas", + "source": "index.html::#paint-color::@aria-label", + "text": "选择画笔颜色" + }, + "copy.9027bf604f441978": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#resource-core-list", + "type": "attribute" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-core-list::@aria-label", + "text": "每核心 CPU 占用" + }, + "copy.9034d25212d334f3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#save-template", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#save-template::text[0]", + "text": "保存为模板" + }, + "copy.91952dd66c3d604c": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#delete-text", + "type": "attribute" + }, + "scope": "text", + "source": "index.html::#delete-text::@data-unavailable-reason", + "text": "请先新建或选择一个文字元素。" + }, + "copy.9227665c51cc539d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#start-canvas-edit", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#start-canvas-edit::text[0]", + "text": "开始编辑" + }, + "copy.94f52d8b062b1d32": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#performance-mode-feedback", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#performance-mode-feedback::text[0]", + "text": "正在读取 CPU 调频状态…" + }, + "copy.9513d602ed9ed3fc": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#text-color", + "type": "attribute" + }, + "scope": "text", + "source": "index.html::#text-color::@aria-label", + "text": "选择文字颜色" + }, + "copy.98877d7632f882b3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-service-state", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#frp-service-state::text[0]", + "text": "关闭" + }, + "copy.99050fd7437476f6": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "templates", + "source": "index.html::#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "内容" + }, + "copy.9a395662799ecd7a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(8) > div:nth-of-type(2) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(8) > div:nth-of-type(2) > span:nth-of-type(1)::text[0]", + "text": "不要在更新期间断电。" + }, + "copy.9b0d9a1d7cdce8ec": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-close", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-close::text[0]", + "text": "关闭" + }, + "copy.9c32507104ea1e8a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-align > option:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-align > option:nth-of-type(2)::text[0]", + "text": "居中" + }, + "copy.9c9eba99ccf5a8b5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-operation-confirm", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-operation-dialog", + "source": "index.html::#animation-operation-confirm::text[0]", + "text": "确认" + }, + "copy.9d15d538a54ea519": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#add-animation-frame", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#add-animation-frame::@data-unavailable-reason", + "text": "请先新建或打开一个动图,再把当前画板添加为帧。" + }, + "copy.9e8801c363b6c9ae": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-fields > div:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-fields > div:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "文字颜色" + }, + "copy.a05691fe09601a27": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#app-version-notice > div:nth-of-type(1) > strong:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#app-version-notice > div:nth-of-type(1) > strong:nth-of-type(1)::text[0]", + "text": "控制程序已更新" + }, + "copy.a0b1c239258c58be": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-concurrency-warning", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#animation-preview-concurrency-warning::text[0]", + "text": "不建议开启太多,否则会出现严重的性能问题。" + }, + "copy.a3579321c7de6c32": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > label:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > label:nth-of-type(2)::text[0]", + "text": "WiFi 密码" + }, + "copy.a3b67463611fa873": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-prompt-delay-save", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-prompt-delay-save::text[0]", + "text": "保存" + }, + "copy.a3dfc743ae246edc": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(10) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(10) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "模板界面同时播放的动图数量" + }, + "copy.a3eb89a8a0d67261": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#add-animation-frame", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#add-animation-frame::text[0]", + "text": "从当前画板新建帧" + }, + "copy.a4d3c5147beeb767": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-dhcp-description", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-dhcp-description::text[0]", + "text": "关闭后可指定固定 IPv4 地址。" + }, + "copy.a625910880c97cb0": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > header:nth-of-type(1) > div:nth-of-type(2)", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::html:nth-of-type(1) > body:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > header:nth-of-type(1) > div:nth-of-type(2)::@aria-label", + "text": "设备实时状态" + }, + "copy.a97a7e7145b79fc4": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-dialog > form:nth-of-type(1) > h2:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-dialog > form:nth-of-type(1) > h2:nth-of-type(1)::text[0]", + "text": "校准电压传感器" + }, + "copy.aba5ebdb61929e6e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "应用占用核心" + }, + "copy.abd374e115c65edb": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-upload", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-upload::text[0]", + "text": "上传配置文件" + }, + "copy.ad6f68e460e3321a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(6) > div:nth-of-type(2) > label:nth-of-type(1)::text[0]", + "text": "WiFi 名称(SSID)" + }, + "copy.add381d51897ace0": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-edit-config", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#frp-edit-config::text[0]", + "text": "编辑配置文件" + }, + "copy.ae49aa74f659c1b4": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#performance-mode-enabled", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#performance-mode-enabled::@data-unavailable-reason", + "text": "正在读取 CPU 调频状态,请稍候。" + }, + "copy.af9e7cfbedb4ff6c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-manual-fields > label:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-manual-fields > label:nth-of-type(2)::text[0]", + "text": "网关" + }, + "copy.afbfae2e5b3d5c49": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#board-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#board-status::text[0]", + "text": "正在恢复画板草稿…" + }, + "copy.aff9861fc5abb079": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(3) > button:nth-of-type(1)::text[0]", + "text": "画笔" + }, + "copy.b0e7bd7e96cbe4d3": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-settings > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "设备" + }, + "copy.b2d6c9b9aaff9163": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-page-prev", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animation-page-prev::text[0]", + "text": "上一页" + }, + "copy.b384a126ed4b7ac4": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > div:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "低电压亮度保护" + }, + "copy.b48485942cf17753": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(11) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(11) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "当前配置" + }, + "copy.b484c279413f7702": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-select-file", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#ota-select-file::text[0]", + "text": "导入 OTA 更新" + }, + "copy.b53c7f755d29b2f7": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animations-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#animations-title::text[0]", + "text": "动图管理" + }, + "copy.b5cd458b77fa3b56": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#create-text", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#create-text::text[0]", + "text": "新建文字" + }, + "copy.b8aeabe524848619": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)" + }, + "copy.ba277c18d29e0db1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#draft-conflict-notice > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#draft-conflict-notice > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "请选择载入另一标签页的最新草稿,或明确保留当前标签页的草稿。" + }, + "copy.bad52fefd609d6ef": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-shell > article:nth-of-type(2) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell > article:nth-of-type(2) > h3:nth-of-type(1)::text[0]", + "text": "当前画板" + }, + "copy.baf5c7251f1da0e9": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#continue-canvas-edit", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#continue-canvas-edit::@data-unavailable-reason", + "text": "当前已经是绘画模式。" + }, + "copy.bc1387b9107d6c7d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "仅为程序设定值,屏幕实际情况软件无法得知" + }, + "copy.bc16a89ad82996eb": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-confirm-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-confirm-dialog", + "source": "index.html::#template-confirm-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)::text[0]", + "text": "确认" + }, + "copy.bdc5984f6c26ac1d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#text-fields > div:nth-of-type(1) > label:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-fields > div:nth-of-type(1) > label:nth-of-type(3)::text[0]", + "text": "Y 坐标" + }, + "copy.be74020a7afaea88": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(5) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(5) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "设置设备状态和系统设置中当前屏幕画面的刷新速度。" + }, + "copy.bee5eb07b7df7f6f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "传感器状态" + }, + "copy.bf1504c7fbebaa61": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#scene-canvas", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#scene-canvas::@aria-label", + "text": "统一画板的正向合成预览" + }, + "copy.bf1b336305972f12": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#network-diagnostics-summary", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:network-diagnostics-dialog", + "source": "index.html::#network-diagnostics-summary::text[0]", + "text": "正在检测…" + }, + "copy.c006db457a8d740c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(2) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(2) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "屏幕方向" + }, + "copy.c2ad69137457c860": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-shell > article:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell > article:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "已保存内容" + }, + "copy.c35fe9f06d25c653": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#canvas-edit-mode-hint", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#canvas-edit-mode-hint::text[0]", + "text": "绘画模式:直接在画布上绘制" + }, + "copy.c456918cd4c65bb1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#delete-text", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#delete-text::text[0]", + "text": "删除选中" + }, + "copy.c49e020f2fc2d2e1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "屏幕扫描刷新率" + }, + "copy.c57164a9de08acb6": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-dialog > div:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "配置可能包含服务器地址和令牌,请只在可信网络中操作。" + }, + "copy.c69fc2f42b30c741": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-speed-controls > button:nth-of-type(4)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-speed-controls > button:nth-of-type(4)::text[0]", + "text": "2×" + }, + "copy.c76272cdb22503ee": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#network-diagnostics-run", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#network-diagnostics-run::text[0]", + "text": "网络检测" + }, + "copy.c810a846db36946d": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-dirty-dialog", + "source": "index.html::#animation-dirty-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)::text[0]", + "text": "否,放弃修改" + }, + "copy.c98610e89a7fbfa8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#keep-current-draft", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#keep-current-draft::text[0]", + "text": "保留当前草稿" + }, + "copy.ca90ae096639dae6": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(3) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(3) > dt:nth-of-type(1)::text[0]", + "text": "占整机 CPU" + }, + "copy.cb8baab29aeca22c": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1)", + "type": "attribute" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(1)::@aria-label", + "text": "纯色测试选择" + }, + "copy.cbbefb9a87ea11e0": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-nav", + "type": "attribute" + }, + "scope": "nav", + "source": "index.html::#workspace-nav::@aria-label", + "text": "工作区" + }, + "copy.cbc47b6f018987d0": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-prefix-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-prefix-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.ccf2a64c9719476c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(4) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(4) > span:nth-of-type(1)::text[0]", + "text": "模板占用空间" + }, + "copy.cf03e0c315f6d137": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#refresh-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#refresh-status::text[0]", + "text": "刷新状态" + }, + "copy.cff794ebcf387556": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(2)::text[0]", + "text": "全红" + }, + "copy.d190f3800f935914": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#preview-refresh-warning", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#preview-refresh-warning::text[0]", + "text": "刷新间隔过短,会增加核桃派负载,可能影响系统运行。" + }, + "copy.d4d86b57334fb08a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#voltage-calibration-preview > div:nth-of-type(5) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:voltage-calibration-dialog", + "source": "index.html::#voltage-calibration-preview > div:nth-of-type(5) > dt:nth-of-type(1)::text[0]", + "text": "预计结果" + }, + "copy.d684bf59f24f377c": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#low-voltage-protection-enabled", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#low-voltage-protection-enabled::@data-unavailable-reason", + "text": "正在读取保护开关状态,请稍候。" + }, + "copy.d6a9b5f5fddf5b5a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-apply-next-boot", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-apply-next-boot::text[0]", + "text": "下次开机生效" + }, + "copy.d79d7f41986e76bd": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-name-dialog > form:nth-of-type(1) > div:nth-of-type(2) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-name-dialog", + "source": "index.html::#template-name-dialog > form:nth-of-type(1) > div:nth-of-type(2) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.d7c11148a905d110": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(4)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(4)::text[0]", + "text": "全蓝" + }, + "copy.d7d7606f29593681": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#background-color", + "type": "attribute" + }, + "scope": "canvas", + "source": "index.html::#background-color::@aria-label", + "text": "选择画布背景颜色" + }, + "copy.d9541ebf887002a5": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#workspace-animations > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#workspace-animations > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "内容" + }, + "copy.da499f45166370a8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(2) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(2) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。" + }, + "copy.da7b4f20963f65be": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#canvas-title", + "type": "attribute" + }, + "scope": "canvas", + "source": "index.html::#canvas-title::@aria-label", + "text": "像素画布" + }, + "copy.db367926c95a158d": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#animation-batch-toolbar", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-batch-toolbar::@aria-label", + "text": "动图帧批量操作" + }, + "copy.db3de16551b5348a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(8) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(8) > dl:nth-of-type(1) > div:nth-of-type(1) > dt:nth-of-type(1)::text[0]", + "text": "当前软件版本" + }, + "copy.db78742e4cb6ecee": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(3)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(3)::text[0]", + "text": "全绿" + }, + "copy.dc87264aec83aa19": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#network-diagnostics-close", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:network-diagnostics-dialog", + "source": "index.html::#network-diagnostics-close::text[0]", + "text": "关闭" + }, + "copy.de7d351565050068": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-copy-destination-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-copy-destination-dialog", + "source": "index.html::#animation-copy-destination-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)::text[0]", + "text": "下一步" + }, + "copy.dea402c74025a500": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#draft-conflict-notice > div:nth-of-type(1) > strong:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#draft-conflict-notice > div:nth-of-type(1) > strong:nth-of-type(1)::text[0]", + "text": "另一标签页修改了画板草稿" + }, + "copy.ded02f791805c30c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-details-dialog > form:nth-of-type(1) > section:nth-of-type(1) > dl:nth-of-type(1) > div:nth-of-type(2) > dt:nth-of-type(1)::text[0]", + "text": "折合单核占用" + }, + "copy.df7f55d47d7a4ee5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-apply-immediate", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-apply-immediate::text[0]", + "text": "立即生效(会导致网页断开)" + }, + "copy.df84ad38164170a2": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#current-display-speed-controls", + "type": "attribute" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-speed-controls::@aria-label", + "text": "动图播放倍速" + }, + "copy.dfda56f2b2367ca8": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(6) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(6) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip" + }, + "copy.dynamic.animation.page_status": { + "kind": "dynamic_template", + "placeholders": [ + "page", + "pages", + "total" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "animations", + "source": "views/animations.js::page_status", + "text": "第 {page} / {pages} 页 · 共 {total} 帧" + }, + "copy.dynamic.board.animation_edit_mode": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::animation_edit_mode", + "text": "动图帧编辑 · 不会直接更新实屏" + }, + "copy.dynamic.board.composition_eyebrow": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "default_hidden": true, + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::composition_eyebrow", + "text": "64 × 64 统一画板" + }, + "copy.dynamic.board.composition_mode": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "default_hidden": true, + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::composition_mode", + "text": "正向 0° · 统一画板" + }, + "copy.dynamic.board.composition_title": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "default_hidden": true, + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::composition_title", + "text": "合成预览" + }, + "copy.dynamic.board.monitor_eyebrow": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "default_hidden": true, + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::monitor_eyebrow", + "text": "64 × 64 屏幕监视器" + }, + "copy.dynamic.board.monitor_mode": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "default_hidden": true, + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::monitor_mode", + "text": "预览当前显示屏幕画面" + }, + "copy.dynamic.board.monitor_title": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "scene-board", + "source": "scene-board.js::monitor_title", + "text": "当前屏幕画面" + }, + "copy.dynamic.current_display.control_failed": { + "kind": "dynamic_template", + "placeholders": [ + "reason" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_control_failed", + "text": "动图播放控制失败:{reason}" + }, + "copy.dynamic.current_display.load_failed": { + "kind": "dynamic_template", + "placeholders": [ + "reason" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_load_failed", + "text": "当前画面读取失败:{reason}" + }, + "copy.dynamic.current_display.loading": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_loading", + "text": "正在读取当前画面…" + }, + "copy.dynamic.current_display.pause": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_pause", + "text": "暂停" + }, + "copy.dynamic.current_display.paused": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_paused", + "text": "动图已暂停" + }, + "copy.dynamic.current_display.pausing": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_pausing", + "text": "正在暂停…" + }, + "copy.dynamic.current_display.resume": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_resume", + "text": "继续播放" + }, + "copy.dynamic.current_display.resumed": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_resumed", + "text": "动图继续播放" + }, + "copy.dynamic.current_display.resuming": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_resuming", + "text": "正在继续播放…" + }, + "copy.dynamic.current_display.speed_changed": { + "kind": "dynamic_template", + "placeholders": [ + "speed" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_speed_changed", + "text": "已切换到 {speed}×" + }, + "copy.dynamic.current_display.speed_changing": { + "kind": "dynamic_template", + "placeholders": [ + "speed" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "dialog:current-display-dialog", + "source": "views/device.js::current_display_speed_changing", + "text": "正在切换到 {speed}×…" + }, + "copy.dynamic.device.current_content": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "topbar", + "source": "views/device.js::current_content", + "text": "当前画面:{name}" + }, + "copy.dynamic.device.voltage_disconnected": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "device", + "source": "views/device.js::voltage_disconnected", + "text": "电压传感器断开" + }, + "copy.dynamic.device.voltage_error": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "device", + "source": "views/device.js::voltage_error", + "text": "电压读取异常" + }, + "copy.dynamic.device.voltage_loading": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "device", + "source": "views/device.js::voltage_loading", + "text": "正在读取电压…" + }, + "copy.dynamic.device.voltage_ok": { + "kind": "dynamic_template", + "placeholders": [ + "volts" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "device", + "source": "views/device.js::voltage_ok", + "text": "屏幕输入电压 {volts} V" + }, + "copy.dynamic.media.cancel": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::cancel", + "text": "取消并删除" + }, + "copy.dynamic.media.cancel_source": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::cancel_source", + "text": "取消并删除源文件" + }, + "copy.dynamic.media.completed": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::completed", + "text": "“{name}”转换完成,已加入内容库。" + }, + "copy.dynamic.media.convert": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::convert", + "text": "保存设置并开始转换" + }, + "copy.dynamic.media.convert_failed": { + "kind": "dynamic_template", + "placeholders": [ + "error" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::convert_failed", + "text": "无法开始转换:{error}" + }, + "copy.dynamic.media.crop_zoom": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::crop_zoom", + "text": "取景缩放" + }, + "copy.dynamic.media.delete_failed": { + "kind": "dynamic_template", + "placeholders": [ + "error" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::delete_failed", + "text": "删除任务失败:{error}" + }, + "copy.dynamic.media.deleted": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::deleted", + "text": "任务和源文件已删除。" + }, + "copy.dynamic.media.empty": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::empty", + "text": "当前没有等待或转换中的任务。" + }, + "copy.dynamic.media.fit_mode": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::fit_mode", + "text": "1:1 适配" + }, + "copy.dynamic.media.list_failed": { + "kind": "dynamic_template", + "placeholders": [ + "error" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::list_failed", + "text": "任务列表读取失败:{error}" + }, + "copy.dynamic.media.mode_contain": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::mode_contain", + "text": "完整包含" + }, + "copy.dynamic.media.mode_crop": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::mode_crop", + "text": "自由取景" + }, + "copy.dynamic.media.mode_stretch": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::mode_stretch", + "text": "拉伸" + }, + "copy.dynamic.media.name_conflict": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::name_conflict", + "text": "保存名称已存在,请更换名称。" + }, + "copy.dynamic.media.output_name": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::output_name", + "text": "保存名称" + }, + "copy.dynamic.media.padding": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::padding", + "text": "留白颜色" + }, + "copy.dynamic.media.preview": { + "kind": "dynamic_template", + "placeholders": [ + "index" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::preview", + "text": "代表帧 {index}" + }, + "copy.dynamic.media.queue_position": { + "kind": "dynamic_template", + "placeholders": [ + "state", + "position" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::queue_position", + "text": "{state} · 队列第 {position} 位" + }, + "copy.dynamic.media.queued": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::queued", + "text": "任务已加入转换队列。" + }, + "copy.dynamic.media.retry": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::retry", + "text": "重试" + }, + "copy.dynamic.media.retry_failed": { + "kind": "dynamic_template", + "placeholders": [ + "error" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::retry_failed", + "text": "重试失败:{error}" + }, + "copy.dynamic.media.state_analyzing": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::state_analyzing", + "text": "分析中" + }, + "copy.dynamic.media.state_awaiting": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::state_awaiting", + "text": "等待设置" + }, + "copy.dynamic.media.state_converting": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::state_converting", + "text": "转换中" + }, + "copy.dynamic.media.state_failed": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::state_failed", + "text": "失败" + }, + "copy.dynamic.media.state_queued": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::state_queued", + "text": "排队中" + }, + "copy.dynamic.media.state_uploading": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::state_uploading", + "text": "上传中" + }, + "copy.dynamic.media.transparency": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::transparency", + "text": "透明像素填充色" + }, + "copy.dynamic.media.upload_failed": { + "kind": "dynamic_template", + "placeholders": [ + "error" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::upload_failed", + "text": "上传失败:{error}" + }, + "copy.dynamic.media.uploaded": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "media-import", + "source": "views/media-import.js::uploaded", + "text": "上传完成,设备正在分析媒体。" + }, + "copy.dynamic.template.save_bound": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "templates", + "source": "views/templates.js::save_bound", + "text": "保存模板" + }, + "copy.dynamic.template.save_choice": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "templates", + "source": "views/templates.js::save_choice", + "text": "请选择“{name}”的保存方式。" + }, + "copy.dynamic.template.save_new": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "templates", + "source": "views/templates.js::save_new", + "text": "保存为模板" + }, + "copy.dynamic.template.unsaved_replace": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "templates", + "source": "views/templates.js::unsaved_replace", + "text": "当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?" + }, + "copy.dynamic.text.font_catalog_failed": { + "kind": "dynamic_template", + "placeholders": [ + "error" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_catalog_failed", + "text": "字体目录读取失败,当前字体值已保留:{error}" + }, + "copy.dynamic.text.font_error_invalid": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_invalid", + "text": "字体导入失败:文件损坏或不包含可用字体。" + }, + "copy.dynamic.text.font_error_network": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_network", + "text": "字体导入失败:无法连接设备,请检查网络后重试。" + }, + "copy.dynamic.text.font_error_stale": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_stale", + "text": "页面版本已过期,请刷新后再导入字体。" + }, + "copy.dynamic.text.font_error_storage": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_storage", + "text": "字体导入失败:设备存储空间不足。" + }, + "copy.dynamic.text.font_error_timeout": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_timeout", + "text": "字体导入超时,请检查核桃派网络或服务状态后重试。" + }, + "copy.dynamic.text.font_error_too_large": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_too_large", + "text": "字体导入失败:文件超过 32 MiB。" + }, + "copy.dynamic.text.font_error_type": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_type", + "text": "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。" + }, + "copy.dynamic.text.font_error_unknown": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_error_unknown", + "text": "字体导入失败:服务器未能完成导入,请稍后重试。" + }, + "copy.dynamic.text.font_exists": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_exists", + "text": "字体“{name}”已经存在" + }, + "copy.dynamic.text.font_exists_applied": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_exists_applied", + "text": "字体“{name}”已经存在,已应用到当前文字" + }, + "copy.dynamic.text.font_imported": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_imported", + "text": "字体“{name}”已导入" + }, + "copy.dynamic.text.font_imported_applied": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_imported_applied", + "text": "字体“{name}”已导入并应用到当前文字" + }, + "copy.dynamic.text.font_importing": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_importing", + "text": "正在导入字体“{name}”…" + }, + "copy.dynamic.text.font_selected": { + "kind": "dynamic_template", + "placeholders": [ + "name" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_selected", + "text": "已选择字体“{name}”" + }, + "copy.dynamic.text.font_size_error": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_size_error", + "text": "字体文件不能超过 32 MiB。" + }, + "copy.dynamic.text.font_type_error": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "text", + "source": "views/text.js::font_type_error", + "text": "只支持 TTF、OTF、TTC、OTC 字体文件。" + }, + "copy.dynamic.workspace_order.edit": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_edit", + "text": "自定义项目位置" + }, + "copy.dynamic.workspace_order.first_item": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_first_item", + "text": "当前已经是第一项,不能继续上移。" + }, + "copy.dynamic.workspace_order.last_item": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_last_item", + "text": "当前已经是最后一项,不能继续下移。" + }, + "copy.dynamic.workspace_order.load_failed": { + "kind": "dynamic_template", + "placeholders": [ + "reason" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_load_failed", + "text": "项目位置读取失败:{reason}" + }, + "copy.dynamic.workspace_order.loading": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_loading", + "text": "正在读取…" + }, + "copy.dynamic.workspace_order.move_down": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_move_down", + "text": "下移" + }, + "copy.dynamic.workspace_order.move_up": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_move_up", + "text": "上移" + }, + "copy.dynamic.workspace_order.save": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_save", + "text": "保存" + }, + "copy.dynamic.workspace_order.save_failed": { + "kind": "dynamic_template", + "placeholders": [ + "reason" + ], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_save_failed", + "text": "项目位置保存失败:{reason}" + }, + "copy.dynamic.workspace_order.save_required": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_save_required", + "text": "请先保存项目位置" + }, + "copy.dynamic.workspace_order.saved": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_saved", + "text": "项目位置已保存" + }, + "copy.dynamic.workspace_order.saving": { + "kind": "dynamic_template", + "placeholders": [], + "render": { + "type": "dynamic_template" + }, + "scope": "drawer", + "source": "app.js::workspace_order_saving", + "text": "正在保存…" + }, + "copy.e16656afc22d307c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#template-name-dialog > form:nth-of-type(1) > div:nth-of-type(2) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:template-name-dialog", + "source": "index.html::#template-name-dialog > form:nth-of-type(1) > div:nth-of-type(2) > button:nth-of-type(2)::text[0]", + "text": "确认" + }, + "copy.e17304ffbf7fc7d2": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#performance-mode-switch-state", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#performance-mode-switch-state::text[0]", + "text": "关闭" + }, + "copy.e19cf48f2257aac1": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-dialog-close", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-dialog-close::text[0]", + "text": "关闭" + }, + "copy.e26968d8a3ff29f3": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "全黑(不亮)" + }, + "copy.e2c30a70b7881784": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#move-canvas-view", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#move-canvas-view::text[0]", + "text": "移动画布视线" + }, + "copy.e35be56896828c1c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#ota-failure-log-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:ota-failure-log-dialog", + "source": "index.html::#ota-failure-log-title::text[0]", + "text": "OTA 更新失败日志" + }, + "copy.e36cd3992e91a73e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(4)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(4)::text[0]", + "text": "配置原文" + }, + "copy.e37afe3d0e15b25f": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#resource-cpu-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:resource-details-dialog", + "source": "index.html::#resource-cpu-title::text[0]", + "text": "CPU" + }, + "copy.e3885d56293e0911": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-shell > article:nth-of-type(2) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-shell > article:nth-of-type(2) > p:nth-of-type(1)::text[0]", + "text": "新建帧来源" + }, + "copy.e398c789a543d507": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#refresh-animations", + "text_index": 0, + "type": "static_text" + }, + "scope": "animations", + "source": "index.html::#refresh-animations::text[0]", + "text": "刷新" + }, + "copy.e582af12d7a5c674": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(3) > section:nth-of-type(1) > div:nth-of-type(2) > span:nth-of-type(1)::text[0]", + "text": "设备可用空间" + }, + "copy.e5f725639ce57842": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(4) > dt:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > dl:nth-of-type(1) > div:nth-of-type(4) > dt:nth-of-type(1)::text[0]", + "text": "校准时间" + }, + "copy.e86c32227a574113": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-dirty-dialog > form:nth-of-type(1) > h2:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-dirty-dialog", + "source": "index.html::#animation-dirty-dialog > form:nth-of-type(1) > h2:nth-of-type(1)::text[0]", + "text": "当前动图帧尚未保存" + }, + "copy.e922cb840b008348": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(7) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(7) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "网络提示显示" + }, + "copy.ea98db7ddf094616": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-playback-controls > div:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-playback-controls > div:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "播放位置" + }, + "copy.eabc95867f54f8e5": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-preview-placeholder", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#animation-preview-placeholder::text[0]", + "text": "请先打开一个动图" + }, + "copy.eb806b9ae697f25e": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#last-message", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#last-message::text[0]", + "text": "控制台已就绪" + }, + "copy.ec993108f9022830": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#low-voltage-protection-description", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#low-voltage-protection-description::text[0]", + "text": "开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度" + }, + "copy.ed2a85ed66683baa": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#continue-canvas-edit", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#continue-canvas-edit::text[0]", + "text": "继续编辑" + }, + "copy.ed9fd6e48622162a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-dhcp-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-dhcp-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.ee2125922127e2f4": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-display-speed-controls > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:current-display-dialog", + "source": "index.html::#current-display-speed-controls > button:nth-of-type(1)::text[0]", + "text": "0.5×" + }, + "copy.ee99d61181e81b5b": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(3)", + "type": "attribute" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(4) > div:nth-of-type(3)::@aria-label", + "text": "笔尖粗细快捷选择" + }, + "copy.eedc1f0921b94904": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#animation-batch-delete", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-batch-delete::@data-unavailable-reason", + "text": "请先勾选至少一帧。" + }, + "copy.eee1b0d4004b2561": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#current-workspace-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#current-workspace-title::text[0]", + "text": "设备状态和测试" + }, + "copy.efdd13bfd5037544": { + "kind": "html_text", + "placeholders": [], + "render": { + "default_hidden": true, + "selector": "#text-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "text", + "source": "index.html::#text-title::text[0]", + "text": "文字显示" + }, + "copy.f029af7c569b7ab9": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#drawer-backdrop", + "type": "attribute" + }, + "scope": "global", + "source": "index.html::#drawer-backdrop::@aria-label", + "text": "关闭功能菜单" + }, + "copy.f065282a8b068bff": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(7) > div:nth-of-type(2) > label:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(7) > div:nth-of-type(2) > label:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "秒后显示网络提示" + }, + "copy.f11483759f71a661": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "data-unavailable-reason", + "selector": "#animation-batch-copy", + "type": "attribute" + }, + "scope": "animations", + "source": "index.html::#animation-batch-copy::@data-unavailable-reason", + "text": "请先勾选至少一帧。" + }, + "copy.f1b3bc8fb0e02592": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#workspace-device > article:nth-of-type(3) > section:nth-of-type(1)", + "type": "attribute" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(3) > section:nth-of-type(1)::@aria-label", + "text": "设备与软件空间" + }, + "copy.f342182ac004c358": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-ssid-modified", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-ssid-modified::text[0]", + "text": "对应内容已经修改" + }, + "copy.f4f56af1b3a18ecb": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#library-copy-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:library-copy-dialog", + "source": "index.html::#library-copy-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)::text[0]", + "text": "复制" + }, + "copy.f62ef0f4a72a4e4c": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(1) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]", + "text": "屏幕状态" + }, + "copy.f85e0c6e1282ece1": { + "kind": "html_attribute", + "placeholders": [], + "render": { + "attribute": "aria-label", + "selector": "#low-voltage-protection-enabled", + "type": "attribute" + }, + "scope": "settings", + "source": "index.html::#low-voltage-protection-enabled::@aria-label", + "text": "低电压亮度保护" + }, + "copy.f86b6371b88f559a": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(4) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(4) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。" + }, + "copy.f8c580915aefe8dd": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-manual-fields > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-manual-fields > label:nth-of-type(1)::text[0]", + "text": "静态 IPv4 地址" + }, + "copy.f8eeb9f2e97a1353": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-drawer > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(2)", + "text_index": 0, + "type": "static_text" + }, + "scope": "drawer", + "source": "index.html::#workspace-drawer > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(2)::text[0]", + "text": "奇妙小屏幕控制器" + }, + "copy.f93de8ef6f27ea57": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#library-copy-dialog > form:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:library-copy-dialog", + "source": "index.html::#library-copy-dialog > form:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "复制到" + }, + "copy.f9f5fb7163cfd7d6": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "canvas", + "source": "index.html::#workspace-canvas > article:nth-of-type(1) > div:nth-of-type(1) > div:nth-of-type(1) > span:nth-of-type(1)::text[0]", + "text": "画笔颜色" + }, + "copy.fa2b0ba044e93109": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#wifi-active-status", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#wifi-active-status::text[0]", + "text": "正在读取当前网络…" + }, + "copy.fa5c23cd421868aa": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-dialog-title", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-dialog-title::text[0]", + "text": "编辑 FRP 配置文件" + }, + "copy.fb99ecae4e2a3302": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#animation-position-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:animation-position-dialog", + "source": "index.html::#animation-position-dialog > form:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(1)::text[0]", + "text": "取消" + }, + "copy.fb9da579d1e4b4f7": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#topbar-current-content", + "text_index": 0, + "type": "static_text" + }, + "scope": "global", + "source": "index.html::#topbar-current-content::text[0]", + "text": "当前画面:正在读取…" + }, + "copy.fc57b377af2977cb": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-settings > article:nth-of-type(11) > div:nth-of-type(1) > p:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "settings", + "source": "index.html::#workspace-settings > article:nth-of-type(11) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", + "text": "此功能通常是远程排查使用,请保证你知道你在做什么。" + }, + "copy.fd2f81e365f893ba": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(1)", + "text_index": 0, + "type": "static_text" + }, + "scope": "dialog:frp-config-dialog", + "source": "index.html::#frp-config-dialog > div:nth-of-type(1) > label:nth-of-type(1)::text[0]", + "text": "已保存配置" + }, + "copy.ff765a69c8b49cee": { + "kind": "html_text", + "placeholders": [], + "render": { + "selector": "#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(5)", + "text_index": 0, + "type": "static_text" + }, + "scope": "device", + "source": "index.html::#workspace-device > article:nth-of-type(2) > div:nth-of-type(1) > button:nth-of-type(5)::text[0]", + "text": "全白(高亮度会导致严重耗电)" + } + }, + "schema_version": 2 +} diff --git a/核桃派软件源代码/app/static/views/animations.js b/核桃派软件源代码/app/static/views/animations.js new file mode 100644 index 0000000..f8b4960 --- /dev/null +++ b/核桃派软件源代码/app/static/views/animations.js @@ -0,0 +1,1047 @@ +import { announce, apiJson, registerWorkspace } from "../core.js"; +import { + animationEditState, + enterAnimationEdit, + getScene, + leaveAnimationEdit, + markAnimationFrameSaved, + registerSceneReplacementGuard, + syncAnimationEditMetadata, +} from "../scene-board.js"; +import { sceneToStorageValue } from "../scene-model.js"; +import { + animationEditContextName, + effectiveFrameName, + formatFrameDuration, + reorderSelectedFrameIds, + validateFrameDuration, +} from "../animation-ui-model.js"; +import { + createAnimationPreviewGroup, + createAnimationThumbnailBox, +} from "../animation-thumbnail-preview.js"; +import { protectCardDragStart } from "../sortable-card.js"; +import { renderUiCopy } from "../ui-copy-runtime.js"; +import { refreshStatus } from "./device.js"; +import { + setControlBusy, + setControlReady, + setControlUnavailable, +} from "../interaction-feedback.js"; + +const folders = document.getElementById("animation-folders"); +const frames = document.getElementById("animation-frames"); +const framePanel = document.getElementById("animation-frame-panel"); +const frameTitle = document.getElementById("animation-frame-title"); +const feedback = document.getElementById("animation-feedback"); +const createButton = document.getElementById("create-animation"); +const addFrameButton = document.getElementById("add-animation-frame"); +const refreshButton = document.getElementById("refresh-animations"); +const previewImage = document.getElementById("animation-preview-image"); +const previewPlaceholder = document.getElementById("animation-preview-placeholder"); +const previewStatus = document.getElementById("animation-preview-status"); +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 dirtyDialog = document.getElementById("animation-dirty-dialog"); +const dirtyMessage = document.getElementById("animation-dirty-message"); +const copyDialog = document.getElementById("library-copy-dialog"); +const copyTarget = document.getElementById("library-copy-target"); +const boardShell = document.getElementById("board-shell"); +const selectionStatus = document.getElementById("animation-selection-status"); +const batchCopyButton = document.getElementById("animation-batch-copy"); +const batchMoveButton = document.getElementById("animation-batch-move"); +const batchDeleteButton = document.getElementById("animation-batch-delete"); +const operationDialog = document.getElementById("animation-operation-dialog"); +const operationTitle = document.getElementById("animation-operation-title"); +const operationMessage = document.getElementById("animation-operation-message"); +const operationConfirm = document.getElementById("animation-operation-confirm"); +const copyDestinationDialog = document.getElementById("animation-copy-destination-dialog"); +const copyDestination = document.getElementById("animation-copy-destination"); +const positionDialog = document.getElementById("animation-position-dialog"); +const positionTitle = document.getElementById("animation-position-title"); +const positionHint = document.getElementById("animation-position-hint"); +const positionTarget = document.getElementById("animation-position-target"); +const framePagination = document.getElementById("animation-frame-pagination"); +const pagePrevious = document.getElementById("animation-page-prev"); +const pageNext = document.getElementById("animation-page-next"); +const pageStatus = document.getElementById("animation-page-status"); + +const SELECTION_LOCK_MESSAGE = "请先完成批量操作或取消全部勾选。"; +const NO_SELECTION_MESSAGE = "请先勾选至少一帧。"; +const FRAME_PAGE_SIZE = 100; + +let list = []; +let current = null; +let busy = false; +let workspaceActive = false; +let frameOffset = 0; +const selectedFrameIds = new Set(); +const durationUnits = new Map(); +const folderPreviewGroup = createAnimationPreviewGroup(); +const currentPreviewGroup = createAnimationPreviewGroup(); + +function askName(title, initial = "", { allowEmpty = false } = {}) { + nameDialogTitle.textContent = title; + nameDialogNote.textContent = ""; + nameDialogNote.hidden = true; + nameInput.value = initial; + nameInput.required = !allowEmpty; + nameDialog.returnValue = ""; + nameDialog.showModal(); + setTimeout(() => { nameInput.focus(); nameInput.select(); }, 0); + return new Promise((resolve) => nameDialog.addEventListener("close", () => { + resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null); + }, { once: true })); +} + +function dirtyChoice() { + const state = animationEditState(); + const displayName = state + ? animationEditContextName(state.animationName, { name: state.frameName }, state.position) + : "当前动图帧"; + dirtyMessage.textContent = `“${displayName}”有未保存修改。是否应用当前修改到动图?`; + dirtyDialog.returnValue = ""; + dirtyDialog.showModal(); + return new Promise((resolve) => dirtyDialog.addEventListener("close", () => { + resolve(dirtyDialog.returnValue || "cancel"); + }, { once: true })); +} + +function confirmAnimationOperation(title, message, confirmLabel = "确认") { + operationTitle.textContent = title; + operationMessage.textContent = message; + operationConfirm.textContent = confirmLabel; + operationDialog.returnValue = ""; + operationDialog.showModal(); + return new Promise((resolve) => operationDialog.addEventListener("close", () => { + resolve(operationDialog.returnValue === "confirm"); + }, { once: true })); +} + +async function chooseCopyDestination(animations) { + copyDestination.replaceChildren(...animations.map((animation) => ( + new Option(animation.name, animation.id) + ))); + if (!animations.length) return null; + copyDestinationDialog.returnValue = ""; + copyDestinationDialog.showModal(); + await new Promise((resolve) => copyDestinationDialog.addEventListener("close", resolve, { once: true })); + if (copyDestinationDialog.returnValue !== "next") return null; + return animations.find((animation) => animation.id === copyDestination.value) || null; +} + +async function chooseInsertionPosition( + animation, + { disabledIds = new Set(), title = "选择插入位置", copyMode = false } = {}, +) { + positionTitle.textContent = title; + positionHint.textContent = copyMode + ? "选择目标帧后,复制的帧将按当前顺序插入到该帧之后;选择“插入到最前”则置于第一帧前。" + : "选择目标帧后,所选帧将按当前顺序插入到该帧之后;选择“插入到最前”则置于第一帧前。"; + const options = [new Option("插入到最前", "")]; + animation.frames.forEach((frame, index) => { + const disabled = disabledIds.has(frame.id); + const position = frame.position ?? index + 1; + const label = `第 ${position} 帧 · ${effectiveFrameName(animation.name, frame, position)}`; + const option = new Option(disabled ? `${label}(已选择,不可作为移动目标)` : label, frame.id); + if (disabled) { + option.setAttribute("disabled", ""); + option.className = "animation-position-disabled"; + } + options.push(option); + }); + positionTarget.replaceChildren(...options); + positionDialog.returnValue = ""; + positionDialog.showModal(); + await new Promise((resolve) => positionDialog.addEventListener("close", resolve, { once: true })); + if (positionDialog.returnValue !== "confirm") return undefined; + return positionTarget.value || null; +} + +async function resolveDirty() { + const state = animationEditState(); + if (!state?.dirty) return true; + const choice = await dirtyChoice(); + if (choice === "cancel") return false; + if (choice === "save") return saveCurrentFrame(); + return true; +} + +export async function resolveAnimationEditBeforeNavigation() { + if (!animationEditState()) return true; + if (!await resolveDirty()) return false; + leaveAnimationEdit(); + return true; +} + +function selectionMode() { + return selectedFrameIds.size > 0; +} + +function frameTotal(animation = current) { + return animation?.frame_total ?? animation?.frame_count ?? 0; +} + +function usesPartialFramePage(animation = current) { + return Boolean(animation && frameTotal(animation) > animation.frames.length); +} + +function animationPageUrl(id, offset = frameOffset, limit = FRAME_PAGE_SIZE) { + const query = new URLSearchParams({ frame_offset: String(offset), frame_limit: String(limit) }); + return `/api/animations/${id}?${query}`; +} + +async function loadAnimationPage(id, requestedOffset = frameOffset) { + const offset = Math.max(0, requestedOffset); + let detail = await apiJson(animationPageUrl(id, offset)); + if (!detail.frames.length && detail.frame_total > 0 && offset >= detail.frame_total) { + const lastOffset = Math.floor((detail.frame_total - 1) / FRAME_PAGE_SIZE) * FRAME_PAGE_SIZE; + detail = await apiJson(animationPageUrl(id, lastOffset)); + } + frameOffset = detail.frame_offset ?? 0; + return detail; +} + +function renderFramePagination() { + if (!current) { + framePagination.hidden = true; + return; + } + const total = frameTotal(); + const pages = Math.max(1, Math.ceil(total / FRAME_PAGE_SIZE)); + const page = Math.min(pages, Math.floor(frameOffset / FRAME_PAGE_SIZE) + 1); + framePagination.hidden = total <= FRAME_PAGE_SIZE; + renderUiCopy( + "copy.dynamic.animation.page_status", + pageStatus, + "第 {page} / {pages} 页 · 共 {total} 帧", + { page, pages, total }, + ); + if (busy) { + setControlBusy(pagePrevious); + setControlBusy(pageNext); + } else { + if (frameOffset === 0) setControlUnavailable(pagePrevious, "当前已经是第一页。"); + else setControlReady(pagePrevious); + if (frameOffset + current.frames.length >= total) setControlUnavailable(pageNext, "当前已经是最后一页。"); + else setControlReady(pageNext); + } +} + +function selectedIdsInFrameOrder() { + return current ? current.frames.filter((frame) => selectedFrameIds.has(frame.id)).map((frame) => frame.id) : []; +} + +function syncToolbarState() { + const selecting = selectionMode(); + selectionStatus.textContent = selecting ? `已选择 ${selectedFrameIds.size} 帧` : "未选择帧"; + if (busy) return; + if (selecting) { + setControlUnavailable(createButton, SELECTION_LOCK_MESSAGE); + setControlUnavailable(refreshButton, SELECTION_LOCK_MESSAGE); + setControlUnavailable(addFrameButton, SELECTION_LOCK_MESSAGE); + setControlReady(batchCopyButton); + setControlReady(batchMoveButton); + setControlReady(batchDeleteButton); + return; + } + setControlReady(createButton); + setControlReady(refreshButton); + if (current) setControlReady(addFrameButton); + else setControlUnavailable(addFrameButton, "请先新建或打开一个动图,再把当前画板添加为帧。"); + setControlUnavailable(batchCopyButton, NO_SELECTION_MESSAGE); + setControlUnavailable(batchMoveButton, NO_SELECTION_MESSAGE); + setControlUnavailable(batchDeleteButton, NO_SELECTION_MESSAGE); +} + +function clearSelection({ render = true } = {}) { + selectedFrameIds.clear(); + if (render && current) renderFrames(); + if (render) renderFolders(); + syncToolbarState(); +} + +function retainExistingSelection() { + const existing = new Set(current?.frames.map((frame) => frame.id) || []); + for (const frameId of selectedFrameIds) { + if (!existing.has(frameId)) selectedFrameIds.delete(frameId); + } +} + +function stopAnimationPreview({ clear = false } = {}) { + currentPreviewGroup.clear(); + if (!clear) return; + previewImage.hidden = true; + previewImage.removeAttribute("src"); + previewImage.alt = ""; + previewPlaceholder.hidden = false; +} + +function showPreviewPlaceholder(message) { + stopAnimationPreview({ clear: true }); + previewPlaceholder.textContent = message; + previewStatus.textContent = message; +} + +function startAnimationPreview() { + stopAnimationPreview(); + if (!workspaceActive || document.visibilityState !== "visible") return; + if (!current) { + showPreviewPlaceholder("请先打开一个动图"); + return; + } + if (!current.frame_count) { + showPreviewPlaceholder("当前动图还没有帧"); + return; + } + previewImage.src = current.thumbnail_url; + previewImage.alt = `${current.name} 动图预览`; + currentPreviewGroup.register({ + animation: current, + image: previewImage, + target: previewImage, + onLoad: () => { + if (!workspaceActive) return; + previewImage.hidden = false; + previewPlaceholder.hidden = true; + }, + onInitialError: () => { + if (!workspaceActive) return; + previewImage.hidden = true; + previewPlaceholder.hidden = false; + previewPlaceholder.textContent = "帧预览加载失败"; + previewStatus.textContent = "帧预览加载失败,将继续尝试下一帧"; + }, + onFrame: ({ frame, index, total }) => { + const displayName = effectiveFrameName(current.name, frame, frame.position ?? index + 1); + previewImage.alt = `当前动图帧:${displayName}`; + previewStatus.textContent = `${current.name} · 第 ${index + 1}/${total} 帧`; + }, + }); + currentPreviewGroup.setActive(true); +} + +async function mutate(action, message, control = null, { clearSelectionOnSuccess = false } = {}) { + if (busy) return null; + busy = true; + folders.inert = true; + frames.inert = true; + folders.setAttribute("aria-busy", "true"); + frames.setAttribute("aria-busy", "true"); + const globalControls = [ + createButton, refreshButton, addFrameButton, + batchCopyButton, batchMoveButton, batchDeleteButton, + pagePrevious, pageNext, + ]; + globalControls.forEach((item) => setControlBusy(item)); + if (control && !globalControls.includes(control)) setControlBusy(control); + feedback.textContent = "正在处理动图操作…"; + feedback.dataset.state = "pending"; + try { + const result = await action(); + if (result?.frames) syncAnimationEditMetadata(result); + if (clearSelectionOnSuccess) selectedFrameIds.clear(); + if (message) announce(message); + await refreshAnimations(current?.id || null); + document.dispatchEvent(new CustomEvent("matrix:library-change", { detail: { source: "animations" } })); + return result; + } catch (error) { + feedback.textContent = error.status === 409 || error.status === 428 + ? "动图已被其他页面修改,请取消勾选并刷新后重试;本次操作没有部分生效。" + : error.message; + feedback.dataset.state = "error"; + announce(feedback.textContent, true); + return null; + } finally { + busy = false; + folders.inert = false; + frames.inert = false; + folders.removeAttribute("aria-busy"); + frames.removeAttribute("aria-busy"); + globalControls.forEach((item) => setControlReady(item)); + if (control) setControlReady(control); + syncToolbarState(); + renderFramePagination(); + } +} + +function button(label, action, className = "") { + const item = document.createElement("button"); + item.type = "button"; + item.textContent = label; + item.className = className; + item.addEventListener("click", () => action(item)); + return item; +} + +async function openAnimation(id, control = null) { + if (selectionMode()) { + announce(SELECTION_LOCK_MESSAGE, true); + return; + } + const state = animationEditState(); + if (state && state.animationId !== id && !await resolveAnimationEditBeforeNavigation()) return; + if (control) setControlBusy(control, { label: "正在打开…", message: "正在打开动图…" }); + try { + current = await loadAnimationPage(id, 0); + selectedFrameIds.clear(); + framePanel.hidden = false; + frameTitle.textContent = `${current.name} · ${current.frame_count} 帧`; + feedback.textContent = `已打开“${current.name}”`; + feedback.dataset.state = "ready"; + renderFrames(); + renderFolders(); + syncToolbarState(); + startAnimationPreview(); + } catch (error) { + feedback.textContent = `动图打开失败:${error.message}`; + feedback.dataset.state = "error"; + announce(feedback.textContent, true); + } finally { + if (control) setControlReady(control); + } +} + +function renderFolders() { + folderPreviewGroup.clear(); + const selecting = selectionMode(); + const cards = list.map((item) => { + const card = document.createElement("article"); + card.className = `animation-folder-card panel${current?.id === item.id ? " active" : ""}`; + const imageBox = createAnimationThumbnailBox(folderPreviewGroup, item); + const title = document.createElement("h3"); + title.className = "animation-folder-name"; + title.textContent = item.name; + title.dataset.hoverTooltip = item.name; + const meta = document.createElement("p"); + meta.textContent = `${item.frame_count} 帧 · ${(item.total_duration_ms / 1000).toFixed(2)} 秒`; + const actions = document.createElement("div"); + actions.className = "template-card-actions"; + const open = button("打开", (control) => openAnimation(item.id, control), "primary-button"); + const play = button("播放", (control) => mutate(async () => { + const result = await apiJson(`/api/animations/${item.id}/play`, { + method: "POST", + headers: { "If-Match": `"${item.revision}"` }, + body: "{}", + }); + await refreshStatus(); + return result; + }, `开始播放“${item.name}”`, control), item.frame_count ? "" : "disabled-button"); + const rename = button("重命名", async (control) => { + const name = await askName("重命名动图", item.name); + if (name) mutate(() => apiJson(`/api/animations/${item.id}`, { + method: "PATCH", + headers: { "If-Match": `"${item.revision}"` }, + body: JSON.stringify({ name }), + }), "动图已重命名", control); + }); + const remove = button("删除", async (control) => { + if (await confirmAnimationOperation("确认删除动图", `永久删除动图“${item.name}”及全部帧吗?`, "删除")) { + mutate(() => apiJson(`/api/animations/${item.id}`, { + method: "DELETE", + headers: { "If-Match": `"${item.revision}"` }, + }), "动图已删除", control); + } + }, "danger-button"); + actions.append(open, play, rename, remove); + if (selecting) { + [...actions.children].forEach((control) => setControlUnavailable(control, SELECTION_LOCK_MESSAGE)); + } else if (!item.frame_count) { + setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。"); + } + card.append(imageBox, title, meta, actions); + return card; + }); + if (!cards.length) { + const empty = document.createElement("p"); + empty.className = "template-empty panel"; + empty.textContent = "还没有动图,请先新建一个动图。"; + cards.push(empty); + } + folders.replaceChildren(...cards); +} + +async function copyFrame(frame, control) { + if (selectionMode()) return; + setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" }); + try { + const data = await apiJson("/api/animations"); + copyTarget.replaceChildren( + new Option("静态模板", "static"), + ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}`)), + ); + copyDialog.returnValue = ""; + copyDialog.showModal(); + await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true })); + if (copyDialog.returnValue !== "copy") return; + const [type, animationId] = copyTarget.value.split(":"); + if (type === "static") { + await mutate(() => apiJson("/api/library/copy", { + method: "POST", + body: JSON.stringify({ + source_type: "animation_frame", + source_id: frame.id, + source_animation_id: current.id, + source_revision: current.revision, + destination_type: "static", + destination_animation_id: null, + destination_revision: null, + }), + }), "帧已复制为静态模板", control); + return; + } + const destination = await apiJson(`/api/animations/${animationId}`); + const insertAfterFrameId = await chooseInsertionPosition(destination, { + title: "选择单帧复制位置", + copyMode: true, + }); + if (insertAfterFrameId === undefined) return; + await mutate(() => apiJson(`/api/animations/${current.id}/frames/copy`, { + method: "POST", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ + frame_ids: [frame.id], + destination_animation_id: destination.id, + destination_revision: destination.revision, + insert_after_frame_id: insertAfterFrameId, + }), + }), "帧已复制到指定位置", control); + } catch (error) { + feedback.textContent = `复制目标读取失败:${error.message}`; + feedback.dataset.state = "error"; + announce(feedback.textContent, true); + } finally { + setControlReady(control); + } +} + +async function changeDuration(frame, duration) { + return mutate(() => apiJson(`/api/animations/${current.id}/frames/${frame.id}`, { + method: "PATCH", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ duration_ms: duration }), + }), "帧时长已更新"); +} + +async function applyDurationToSelection(frame, duration, restoreInput) { + const selectedIds = selectedIdsInFrameOrder(); + const confirmed = await confirmAnimationOperation( + "确认批量修改时长", + `将 ${duration} 毫秒应用到已选择的 ${selectedIds.length} 帧吗?`, + "应用到已选帧", + ); + if (!confirmed) { + restoreInput(); + return false; + } + const result = await mutate(() => apiJson(`/api/animations/${current.id}/frame-durations`, { + method: "PUT", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ duration_ms: duration, frame_ids: selectedIds }), + }), `已更新 ${selectedIds.length} 帧的显示时间`); + return Boolean(result); +} + +async function moveFrame(index, delta) { + if (selectionMode()) return; + const frame = current.frames[index]; + const targetPosition = frame.position + delta; + if (targetPosition < 1 || targetPosition > frameTotal()) return; + if (usesPartialFramePage()) { + let beforeFrameId = null; + if (delta < 0) { + const previous = index > 0 + ? current.frames[index - 1] + : (await apiJson(animationPageUrl(current.id, frame.position - 2, 1))).frames[0]; + beforeFrameId = previous?.id ?? null; + } else if (frame.position + 1 < frameTotal()) { + const afterNext = index + 2 < current.frames.length + ? current.frames[index + 2] + : (await apiJson(animationPageUrl(current.id, frame.position + 1, 1))).frames[0]; + beforeFrameId = afterNext?.id ?? null; + } + await mutate(() => apiJson(`/api/animations/${current.id}/frame-move`, { + method: "POST", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ frame_ids: [frame.id], before_frame_id: beforeFrameId }), + }), "帧顺序已更新"); + return; + } + const order = current.frames.map((item) => item.id); + const target = index + delta; + if (target < 0 || target >= order.length) return; + [order[index], order[target]] = [order[target], order[index]]; + await mutate(() => apiJson(`/api/animations/${current.id}/frame-order`, { + method: "PUT", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ frame_ids: order }), + }), "帧顺序已更新"); +} + +async function editFrame(frame, control) { + if (selectionMode() || !await resolveDirty()) return; + setControlBusy(control, { label: "正在打开…", message: "正在读取动图帧…" }); + try { + const full = await apiJson(`/api/animations/${current.id}/frames/${frame.id}`); + enterAnimationEdit(full); + const displayName = effectiveFrameName(current.name, frame, frame.position); + feedback.textContent = `正在编辑“${displayName}”`; + renderFrames(); + boardShell.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + block: "start", + }); + } catch (error) { + feedback.textContent = `动图帧读取失败:${error.message}`; + feedback.dataset.state = "error"; + announce(feedback.textContent, true); + } finally { + setControlReady(control); + } +} + +function configureDurationInput(input, unit, durationMs, displayName) { + input.value = formatFrameDuration(durationMs, unit); + if (unit === "s") { + input.min = "0.050"; + input.max = "604800"; + input.step = "0.001"; + input.inputMode = "decimal"; + input.setAttribute("aria-label", `${displayName}显示时间,单位秒`); + } else { + input.min = "50"; + input.max = "604800000"; + input.step = "1"; + input.inputMode = "numeric"; + input.setAttribute("aria-label", `${displayName}显示时间,单位毫秒`); + } +} + +function renderFrames() { + if (!current) return; + const selecting = selectionMode(); + frames.replaceChildren(...current.frames.map((frame, index) => { + const editing = animationEditState()?.frameId === frame.id; + const selected = selectedFrameIds.has(frame.id); + const displayName = effectiveFrameName(current.name, frame, frame.position); + const card = document.createElement("article"); + card.className = `template-card animation-frame-card sortable-card${editing ? " editing" : ""}${selected ? " selected" : ""}`; + card.dataset.frameId = frame.id; + if (!selecting && !usesPartialFramePage()) protectCardDragStart(card); + else card.draggable = false; + if (editing) card.setAttribute("aria-current", "true"); + + const positionRow = document.createElement("div"); + positionRow.className = "animation-frame-position-row"; + const positionText = document.createElement("span"); + positionText.append(document.createTextNode("第 ")); + const currentPosition = document.createElement("span"); + currentPosition.className = "animation-frame-position-current"; + currentPosition.textContent = String(frame.position); + const totalPosition = document.createElement("span"); + totalPosition.className = "animation-frame-position-total"; + totalPosition.textContent = String(frameTotal()); + positionText.append(currentPosition, document.createTextNode(" / "), totalPosition, document.createTextNode(" 帧")); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "animation-frame-select"; + checkbox.checked = selected; + checkbox.setAttribute("aria-label", `选择${displayName}`); + checkbox.addEventListener("change", async () => { + if (checkbox.checked && animationEditState() && !await resolveAnimationEditBeforeNavigation()) { + checkbox.checked = false; + return; + } + if (checkbox.checked) selectedFrameIds.add(frame.id); + else selectedFrameIds.delete(frame.id); + renderFrames(); + renderFolders(); + syncToolbarState(); + }); + positionRow.append(positionText, checkbox); + + const title = document.createElement("h3"); + title.className = "template-card-name animation-frame-name"; + title.textContent = displayName; + const editingBadge = document.createElement("span"); + editingBadge.className = "animation-frame-editing-badge"; + editingBadge.textContent = "当前正在编辑"; + editingBadge.hidden = !editing; + const rename = button("重命名", async (control) => { + const name = await askName("帧名称(留空恢复自动名称)", frame.name || "", { allowEmpty: true }); + if (name !== null) mutate(() => apiJson(`/api/animations/${current.id}/frames/${frame.id}`, { + method: "PATCH", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ name: name || null }), + }), name ? "帧名称已更新" : "帧已恢复自动名称", control); + }, "animation-frame-rename"); + + const durationField = document.createElement("div"); + durationField.className = "animation-duration-field"; + const durationHeading = document.createElement("div"); + durationHeading.className = "animation-duration-heading"; + const durationLabel = document.createElement("span"); + durationLabel.textContent = "显示时间"; + const copyDuration = button("复制到已选帧", () => commitDuration(true)); + durationHeading.append(durationLabel, copyDuration); + const durationControls = document.createElement("div"); + durationControls.className = "animation-duration-controls"; + const duration = document.createElement("input"); + duration.type = "number"; + const unit = durationUnits.get(frame.id) || "ms"; + configureDurationInput(duration, unit, frame.duration_ms, displayName); + const unitButton = button(unit === "ms" ? "毫秒" : "秒", () => { + const nextUnit = (durationUnits.get(frame.id) || "ms") === "ms" ? "s" : "ms"; + durationUnits.set(frame.id, nextUnit); + configureDurationInput(duration, nextUnit, frame.duration_ms, displayName); + unitButton.textContent = nextUnit === "ms" ? "毫秒" : "秒"; + duration.setAttribute("aria-invalid", "false"); + durationError.hidden = true; + }, "animation-duration-unit"); + unitButton.setAttribute("aria-label", `${displayName}显示时间单位,点击切换秒或毫秒`); + durationControls.append(duration, unitButton); + const durationError = document.createElement("span"); + durationError.className = "animation-duration-error"; + durationError.id = `duration-error-${frame.id}`; + durationError.hidden = true; + duration.setAttribute("aria-describedby", durationError.id); + durationField.append(durationHeading, durationControls, durationError); + let durationCommitPending = false; + async function commitDuration(requireSelection = false) { + if (durationCommitPending) return; + if (requireSelection && !selectionMode()) return; + durationCommitPending = true; + const activeUnit = durationUnits.get(frame.id) || "ms"; + const validation = validateFrameDuration(duration.value, activeUnit); + duration.setAttribute("aria-invalid", String(!validation.valid)); + durationError.textContent = validation.error; + durationError.hidden = validation.valid; + if (!validation.valid) { + announce(validation.error, true); + queueMicrotask(() => { durationCommitPending = false; }); + return; + } + const restoreInput = () => { + configureDurationInput(duration, activeUnit, frame.duration_ms, displayName); + duration.setAttribute("aria-invalid", "false"); + durationError.hidden = true; + }; + if (selectionMode()) { + await applyDurationToSelection(frame, validation.duration, restoreInput); + } else if (validation.duration !== frame.duration_ms) { + await changeDuration(frame, validation.duration); + } + durationCommitPending = false; + } + duration.addEventListener("change", () => commitDuration(false)); + duration.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitDuration(false); + duration.blur(); + } + }); + + const imageBox = document.createElement("div"); + imageBox.className = "template-image-box animation-frame-image-box"; + const image = document.createElement("img"); + image.className = "template-image"; + image.src = frame.thumbnail_url; + image.alt = displayName; + image.width = 64; + image.height = 64; + image.draggable = false; + image.addEventListener("error", () => imageBox.classList.add("template-image-error")); + imageBox.append(image); + const edit = button("编辑此帧", (control) => editFrame(frame, control), "primary-button animation-frame-edit"); + const actions = document.createElement("div"); + actions.className = "template-card-actions animation-frame-secondary-actions"; + const moveUp = button("上移", () => moveFrame(index, -1)); + const moveDown = button("下移", () => moveFrame(index, 1)); + const copy = button("复制", (control) => copyFrame(frame, control)); + const remove = button("删除", async (control) => { + if (await confirmAnimationOperation("确认删除帧", `删除“${displayName}”吗?`, "删除")) { + mutate(() => apiJson(`/api/animations/${current.id}/frames/${frame.id}`, { + method: "DELETE", + headers: { "If-Match": `"${current.revision}"` }, + }), "帧已删除", control); + } + }, "danger-button"); + actions.append(moveUp, moveDown, copy, remove); + if (selecting) { + [rename, edit, moveUp, moveDown, copy, remove].forEach((control) => { + setControlUnavailable(control, SELECTION_LOCK_MESSAGE); + }); + setControlReady(copyDuration); + } else { + setControlUnavailable(copyDuration, NO_SELECTION_MESSAGE); + if (frame.position === 1) setControlUnavailable(moveUp, "当前已经是第一帧,不能继续上移。"); + if (frame.position === frameTotal()) setControlUnavailable(moveDown, "当前已经是最后一帧,不能继续下移。"); + } + card.addEventListener("dragstart", (event) => { + if (selectionMode() || usesPartialFramePage()) { + event.preventDefault(); + return; + } + if (!event.defaultPrevented) event.dataTransfer.setData("text/plain", frame.id); + }); + card.addEventListener("dragover", (event) => { + if (!selectionMode() && !usesPartialFramePage()) event.preventDefault(); + }); + card.addEventListener("drop", (event) => { + if (selectionMode() || usesPartialFramePage()) return; + event.preventDefault(); + const source = event.dataTransfer.getData("text/plain"); + const order = current.frames.map((item) => item.id); + const from = order.indexOf(source); + if (from < 0 || from === index) return; + order.splice(index, 0, order.splice(from, 1)[0]); + mutate(() => apiJson(`/api/animations/${current.id}/frame-order`, { + method: "PUT", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ frame_ids: order }), + }), "帧顺序已更新"); + }); + card.append(positionRow, title, editingBadge, rename, durationField, imageBox, edit, actions); + return card; + })); + if (!current.frames.length) { + const empty = document.createElement("p"); + empty.textContent = "此动图还没有帧。"; + frames.append(empty); + } + renderFramePagination(); +} + +async function batchMove() { + const selectedIds = selectedIdsInFrameOrder(); + const insertAfterFrameId = await chooseInsertionPosition(current, { + disabledIds: new Set(selectedIds), + title: "批量移动帧", + }); + if (insertAfterFrameId === undefined) return; + if (usesPartialFramePage()) { + let beforeFrameId = null; + const selected = new Set(selectedIds); + if (insertAfterFrameId === null) { + const first = await apiJson(animationPageUrl(current.id, 0, selectedIds.length + 1)); + beforeFrameId = first.frames.find((frame) => !selected.has(frame.id))?.id ?? null; + } else { + const target = current.frames.find((frame) => frame.id === insertAfterFrameId); + const following = await apiJson(animationPageUrl( + current.id, + target?.position ?? 0, + selectedIds.length + 1, + )); + beforeFrameId = following.frames.find((frame) => !selected.has(frame.id))?.id ?? null; + } + await mutate(() => apiJson(`/api/animations/${current.id}/frame-move`, { + method: "POST", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ frame_ids: selectedIds, before_frame_id: beforeFrameId }), + }), `已移动 ${selectedIds.length} 帧`, batchMoveButton, { clearSelectionOnSuccess: true }); + return; + } + const existing = current.frames.map((frame) => frame.id); + const order = reorderSelectedFrameIds(existing, selectedIds, insertAfterFrameId); + if (!order) { + feedback.textContent = "无法生成批量移动顺序,请刷新后重试。"; + announce(feedback.textContent, true); + return; + } + if (order.every((frameId, index) => frameId === existing[index])) { + feedback.textContent = "所选帧已经位于该位置,顺序未变化。"; + clearSelection(); + announce(feedback.textContent); + return; + } + await mutate(() => apiJson(`/api/animations/${current.id}/frame-order`, { + method: "PUT", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ frame_ids: order }), + }), `已移动 ${selectedIds.length} 帧`, batchMoveButton, { clearSelectionOnSuccess: true }); +} + +async function batchCopy() { + setControlBusy(batchCopyButton, { label: "正在准备…", message: "正在读取复制目标…" }); + try { + const data = await apiJson("/api/animations"); + const destinationMetadata = await chooseCopyDestination(data.animations); + if (!destinationMetadata) return; + const destination = await apiJson(`/api/animations/${destinationMetadata.id}`); + const insertAfterFrameId = await chooseInsertionPosition(destination, { + title: "选择批量复制位置", + copyMode: true, + }); + if (insertAfterFrameId === undefined) return; + const selectedIds = selectedIdsInFrameOrder(); + await mutate(() => apiJson(`/api/animations/${current.id}/frames/copy`, { + method: "POST", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ + frame_ids: selectedIds, + destination_animation_id: destination.id, + destination_revision: destination.revision, + insert_after_frame_id: insertAfterFrameId, + }), + }), `已复制 ${selectedIds.length} 帧到“${destination.name}”`, batchCopyButton, { + clearSelectionOnSuccess: true, + }); + } catch (error) { + feedback.textContent = `批量复制准备失败:${error.message}`; + feedback.dataset.state = "error"; + announce(feedback.textContent, true); + } finally { + setControlReady(batchCopyButton); + syncToolbarState(); + } +} + +async function batchDelete() { + const selectedFrames = current.frames.filter((frame) => selectedFrameIds.has(frame.id)); + const names = selectedFrames.slice(0, 3).map((frame, index) => ( + effectiveFrameName(current.name, frame, current.frames.indexOf(frame) + 1) + )); + const suffix = selectedFrames.length > names.length ? `等 ${selectedFrames.length} 帧` : `${selectedFrames.length} 帧`; + const confirmed = await confirmAnimationOperation( + "确认批量删除", + `永久删除 ${names.join("、")} ${suffix}吗?删除后无法恢复。`, + "批量删除", + ); + if (!confirmed) return; + await mutate(() => apiJson(`/api/animations/${current.id}/frames/batch-delete`, { + method: "POST", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ frame_ids: selectedIdsInFrameOrder() }), + }), `已删除 ${selectedFrames.length} 帧`, batchDeleteButton, { clearSelectionOnSuccess: true }); +} + +export async function refreshAnimations(selectId = null) { + const ownsBusyState = !busy; + if (ownsBusyState) { + setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取动图…" }); + feedback.textContent = "正在读取动图…"; + feedback.dataset.state = "pending"; + } + try { + const data = await apiJson("/api/animations"); + list = data.animations; + const id = selectId || current?.id; + current = id && list.some((item) => item.id === id) ? await loadAnimationPage(id) : null; + if (current) retainExistingSelection(); + else selectedFrameIds.clear(); + framePanel.hidden = !current; + if (current) frameTitle.textContent = `${current.name} · ${current.frame_count} 帧`; + renderFolders(); + if (current) renderFrames(); + else renderFramePagination(); + feedback.textContent = current ? `已打开“${current.name}”` : `共 ${list.length} 个动图`; + feedback.dataset.state = "ready"; + syncToolbarState(); + startAnimationPreview(); + if (ownsBusyState) announce(feedback.textContent); + } catch (error) { + feedback.textContent = `动图读取失败:${error.message}`; + feedback.dataset.state = "error"; + announce(feedback.textContent, true); + } finally { + if (ownsBusyState) setControlReady(refreshButton); + syncToolbarState(); + } +} + +async function saveCurrentFrame() { + const state = animationEditState(); + if (!state) return true; + const result = await mutate(async () => { + const saved = await apiJson(`/api/animations/${state.animationId}/frames/${state.frameId}`, { + method: "PUT", + headers: { "If-Match": `"${state.revision}"` }, + body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }), + }); + document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "animation-frame-save" } })); + return saved; + }, "当前动图帧已保存"); + if (!result) return false; + const detail = result.frames.find((item) => item.id === state.frameId); + markAnimationFrameSaved({ revision: result.revision, ...detail }); + renderFrames(); + startAnimationPreview(); + return true; +} + +createButton.addEventListener("click", async () => { + const name = await askName("新建动图"); + if (!name) return; + const result = await mutate(() => apiJson("/api/animations", { + method: "POST", + body: JSON.stringify({ name }), + }), `动图“${name}”已创建`, createButton); + if (result) openAnimation(result.id); +}); +addFrameButton.addEventListener("click", () => current && mutate(async () => { + const result = await apiJson(`/api/animations/${current.id}/frames`, { + method: "POST", + headers: { "If-Match": `"${current.revision}"` }, + body: JSON.stringify({ scene: sceneToStorageValue(getScene()), duration_ms: 500 }), + }); + document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "animation-frame-create" } })); + return result; +}, "新帧已创建", addFrameButton)); +refreshButton.addEventListener("click", () => refreshAnimations()); +batchCopyButton.addEventListener("click", batchCopy); +batchMoveButton.addEventListener("click", batchMove); +batchDeleteButton.addEventListener("click", batchDelete); +pagePrevious.addEventListener("click", async () => { + if (!current || busy || selectionMode() || !await resolveAnimationEditBeforeNavigation()) return; + selectedFrameIds.clear(); + current = await loadAnimationPage(current.id, Math.max(0, frameOffset - FRAME_PAGE_SIZE)); + renderFrames(); + startAnimationPreview(); +}); +pageNext.addEventListener("click", async () => { + if (!current || busy || selectionMode() || !await resolveAnimationEditBeforeNavigation()) return; + selectedFrameIds.clear(); + current = await loadAnimationPage(current.id, frameOffset + FRAME_PAGE_SIZE); + renderFrames(); + startAnimationPreview(); +}); +document.addEventListener("matrix:save-animation-frame", () => saveCurrentFrame()); +document.addEventListener("matrix:request-exit-animation-edit", async () => { + if (await resolveDirty()) leaveAnimationEdit(); +}); +document.addEventListener("matrix:library-change", (event) => { + if (event.detail?.source !== "animations") refreshAnimations(current?.id); +}); +document.addEventListener("matrix:open-animation", (event) => openAnimation(event.detail.id)); +window.addEventListener("beforeunload", (event) => { + if (animationEditState()?.dirty) { + event.preventDefault(); + event.returnValue = ""; + } +}); +registerSceneReplacementGuard(resolveAnimationEditBeforeNavigation); + +registerWorkspace({ + id: "animations", + title: "动图管理", + elementId: "workspace-animations", + order: 50, + persistence: "server", + onEnter: async () => { + workspaceActive = true; + folderPreviewGroup.setActive(true); + currentPreviewGroup.setActive(true); + await refreshAnimations(); + }, + onLeave: () => { + workspaceActive = false; + selectedFrameIds.clear(); + folderPreviewGroup.setActive(false); + folderPreviewGroup.clear(); + currentPreviewGroup.setActive(false); + stopAnimationPreview(); + }, +}); diff --git a/核桃派软件源代码/app/static/views/canvas.js b/核桃派软件源代码/app/static/views/canvas.js new file mode 100644 index 0000000..c13e26c --- /dev/null +++ b/核桃派软件源代码/app/static/views/canvas.js @@ -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; + }, +}); diff --git a/核桃派软件源代码/app/static/views/device.js b/核桃派软件源代码/app/static/views/device.js new file mode 100644 index 0000000..71f5f59 --- /dev/null +++ b/核桃派软件源代码/app/static/views/device.js @@ -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(() => {})); diff --git a/核桃派软件源代码/app/static/views/frp-settings.js b/核桃派软件源代码/app/static/views/frp-settings.js new file mode 100644 index 0000000..bc76e0c --- /dev/null +++ b/核桃派软件源代码/app/static/views/frp-settings.js @@ -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); +}); diff --git a/核桃派软件源代码/app/static/views/media-import.js b/核桃派软件源代码/app/static/views/media-import.js new file mode 100644 index 0000000..b5b9617 --- /dev/null +++ b/核桃派软件源代码/app/static/views/media-import.js @@ -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, +}); diff --git a/核桃派软件源代码/app/static/views/settings.js b/核桃派软件源代码/app/static/views/settings.js new file mode 100644 index 0000000..bb8d9ce --- /dev/null +++ b/核桃派软件源代码/app/static/views/settings.js @@ -0,0 +1,1113 @@ +import { + announce, + apiJson, + registerWorkspace, + setActiveButton, +} from "../core.js"; +import { setPreviewRefreshInterval } from "../scene-board.js"; +import { + ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT, + ANIMATION_PREVIEW_MAX_MAX_CONCURRENT, + ANIMATION_PREVIEW_MIN_MAX_CONCURRENT, + normalizeAnimationPreviewMaxConcurrent, + setAnimationPreviewMaxConcurrent, +} from "../animation-thumbnail-preview.js"; +import { refreshStatus } from "./device.js"; +import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js"; +import { discardWifiSettingsDrafts, loadWifiSettings } from "./wifi-settings.js"; +import { discardFrpDrafts, loadFrpSettings } from "./frp-settings.js"; + +const orientationButtons = Array.from(document.querySelectorAll("[data-orientation]")); +const matrixRefreshRateButtons = Array.from( + document.querySelectorAll("[data-matrix-refresh-rate]"), +); +const brightness = document.getElementById("config-brightness"); +const brightnessValue = document.getElementById("brightness-value"); +const brightnessProtectionStatus = document.getElementById("brightness-protection-status"); +const feedback = document.getElementById("settings-feedback"); +const voltageCurrent = document.getElementById("voltage-current"); +const voltageSensorStatus = document.getElementById("voltage-sensor-status"); +const voltageCalibrationStatus = document.getElementById("voltage-calibration-status"); +const voltageCalibratedAt = document.getElementById("voltage-calibrated-at"); +const startCalibration = document.getElementById("start-voltage-calibration"); +const resetCalibration = document.getElementById("reset-voltage-calibration"); +const calibrationDialog = document.getElementById("voltage-calibration-dialog"); +const calibrationReference = document.getElementById("voltage-reference-input"); +const calibrationDialogFeedback = document.getElementById("voltage-calibration-dialog-feedback"); +const calibrationPreview = document.getElementById("voltage-calibration-preview"); +const previewCalibration = document.getElementById("preview-voltage-calibration"); +const confirmCalibration = document.getElementById("confirm-voltage-calibration"); +const previewRefreshInput = document.getElementById("preview-refresh-interval"); +const previewRefreshWarning = document.getElementById("preview-refresh-warning"); +const previewRefreshError = document.getElementById("preview-refresh-error"); +const lowVoltageProtectionToggle = document.getElementById("low-voltage-protection-enabled"); +const lowVoltageProtectionSwitchState = document.getElementById("low-voltage-protection-switch-state"); +const lowVoltageProtectionFeedback = document.getElementById("low-voltage-protection-feedback"); +const performanceModeToggle = document.getElementById("performance-mode-enabled"); +const performanceModeSwitchState = document.getElementById("performance-mode-switch-state"); +const performanceModeFeedback = document.getElementById("performance-mode-feedback"); +const animationPreviewMaxConcurrentInput = document.getElementById("animation-preview-max-concurrent"); +const animationPreviewConcurrencyFeedback = document.getElementById("animation-preview-concurrency-feedback"); +const otaCurrentVersion = document.getElementById("ota-current-version"); +const otaFeatureUpdatedAt = document.getElementById("ota-feature-updated-at"); +const otaFileInput = document.getElementById("ota-file-input"); +const otaSelectFile = document.getElementById("ota-select-file"); +const otaProgressGroup = document.getElementById("ota-progress-group"); +const otaProgressStage = document.getElementById("ota-progress-stage"); +const otaProgressValue = document.getElementById("ota-progress-value"); +const otaProgress = document.getElementById("ota-progress"); +const otaFeedback = document.getElementById("ota-feedback"); +const otaFailureLogActions = document.getElementById("ota-failure-log-actions"); +const otaViewFailureLog = document.getElementById("ota-view-failure-log"); +const otaCopyFailureLog = document.getElementById("ota-copy-failure-log"); +const otaFailureLogDialog = document.getElementById("ota-failure-log-dialog"); +const otaFailureLogContent = document.getElementById("ota-failure-log-content"); +const otaFailureLogFeedback = document.getElementById("ota-failure-log-feedback"); +const otaFailureLogClose = document.getElementById("ota-failure-log-close"); +const otaFailureLogCopy = document.getElementById("ota-failure-log-copy"); +let confirmedOrientation = 0; +let confirmedBrightness = 40; +let confirmedMatrixRefreshRate = 100; +let confirmedPreviewRefreshInterval = 1000; +let confirmedLowVoltageProtectionEnabled = false; +let confirmedPerformanceModeEnabled = false; +let confirmedAnimationPreviewMaxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT; +let voltageConfig = null; +let voltageStatus = null; +let lowVoltageProtectionStatus = null; +let latestEffectiveBrightness = null; +let displayTestActive = false; +let displayTestBrightness = null; +let calibrationProposal = null; +let desiredBrightness = null; +let applyingBrightness = false; +let orientationPending = false; +let matrixRefreshRatePending = false; +let previewRefreshPending = false; +let desiredPreviewRefreshInterval = null; +let savingPreviewRefreshInterval = null; +let lowVoltageProtectionPending = false; +let performanceModePending = false; +let animationPreviewConcurrencyPending = false; +let otaCurrentSoftwareVersion = null; +let otaPolling = false; +let otaFailureLogText = ""; +let otaFailureDialogShownFor = null; + +const LOW_VOLTAGE_PROTECTION_MODES = new Set([ + "disabled", + "unavailable", + "inactive", + "limiting", + "critical", +]); + +function setFeedback(text, state = "idle") { + feedback.textContent = text; + feedback.dataset.state = state; + feedback.hidden = !text; +} + +function setOtaFeedback(text, state = "idle") { + otaFeedback.textContent = text; + otaFeedback.dataset.state = state; + otaFeedback.hidden = !text; +} + +function showOtaProgress(percent, stage) { + const value = Math.max(0, Math.min(100, Number(percent) || 0)); + otaProgressGroup.hidden = false; + otaProgress.value = value; + otaProgress.textContent = `${value}%`; + otaProgressValue.textContent = `${value}%`; + otaProgressStage.textContent = stage || "正在更新"; +} + +function xhrUpload(file) { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("POST", `/api/ota/update?filename=${encodeURIComponent(file.name)}`); + request.setRequestHeader("Content-Type", "application/octet-stream"); + request.upload.addEventListener("progress", (event) => { + if (!event.lengthComputable) return; + showOtaProgress(Math.floor(event.loaded * 100 / event.total), "正在上传更新包"); + }); + request.addEventListener("load", () => { + let body = {}; + try { body = request.responseText ? JSON.parse(request.responseText) : {}; } catch {} + if (request.status >= 200 && request.status < 300) resolve(body); + else reject(new Error(body.detail || `上传失败(HTTP ${request.status})`)); + }); + request.addEventListener("error", () => reject(new Error("上传连接中断,设备尚未接受更新包"))); + request.addEventListener("abort", () => reject(new Error("上传已中止"))); + request.send(file); + }); +} + +async function fetchOtaStatus() { + const response = await fetch("/api/ota/status", { cache: "no-store" }); + if (!response.ok) throw new Error(`读取 OTA 状态失败(HTTP ${response.status})`); + return response.json(); +} + +async function fetchOtaFailureLog() { + const response = await fetch("/api/ota/failure-log", { cache: "no-store" }); + if (!response.ok) throw new Error(`读取 OTA 失败日志失败(HTTP ${response.status})`); + return response.text(); +} + +function setOtaFailureLogFeedback(text, state = "idle") { + otaFailureLogFeedback.textContent = text; + otaFailureLogFeedback.dataset.state = state; + otaFailureLogFeedback.hidden = !text; +} + +async function ensureOtaFailureLog() { + if (!otaFailureLogText) otaFailureLogText = await fetchOtaFailureLog(); + return otaFailureLogText; +} + +async function showOtaFailureLog() { + try { + const text = await ensureOtaFailureLog(); + otaFailureLogContent.textContent = text; + setOtaFailureLogFeedback(""); + if (!otaFailureLogDialog.open) otaFailureLogDialog.showModal(); + otaFailureLogContent.focus(); + } catch (error) { + announce(error.message, "error"); + } +} + +function fallbackCopyText(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + textarea.remove(); + if (!copied) throw new Error("浏览器拒绝复制操作"); +} + +async function copyOtaFailureLog() { + try { + const text = await ensureOtaFailureLog(); + let copied = false; + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + copied = true; + } catch {} + } + if (!copied) fallbackCopyText(text); + setOtaFailureLogFeedback("日志已复制到剪贴板。", "success"); + announce("OTA 失败日志已复制", "success"); + } catch (error) { + setOtaFailureLogFeedback(`复制失败:${error.message}`, "error"); + announce(`复制 OTA 失败日志失败:${error.message}`, "error"); + } +} + +function formatBeijingFeatureUpdatedAt(value) { + const date = new Date(value); + if (!value || Number.isNaN(date.getTime())) return "读取失败"; + const parts = Object.fromEntries( + new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date).map(({ type, value: part }) => [type, part]), + ); + return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}(北京时间)`; +} + +function applyOtaStatus(status) { + const current = status?.current_version; + if (current) { + otaCurrentVersion.textContent = current; + if (otaCurrentSoftwareVersion && current !== otaCurrentSoftwareVersion) { + window.location.reload(); + return; + } + otaCurrentSoftwareVersion = current; + } + otaFeatureUpdatedAt.textContent = formatBeijingFeatureUpdatedAt(status?.feature_updated_at); + const job = status?.job; + if (status?.active && job) { + otaFailureLogActions.hidden = true; + otaFailureLogText = ""; + showOtaProgress(job.percent, job.message || job.phase); + setControlBusy(otaSelectFile, { label: "正在更新…" }); + setOtaFeedback("更新已交给设备后台处理;网页短暂断开属于正常现象。", "pending"); + } else { + setControlReady(otaSelectFile); + if (job?.phase === "failed") { + showOtaProgress(0, "更新失败"); + setOtaFeedback(job.error || "更新失败,设备已恢复原版本。", "error"); + otaFailureLogActions.hidden = !status.failure_log_available; + const failureKey = `${job.id || "unknown"}:${job.finished_at || ""}`; + if (status.failure_log_available && otaFailureDialogShownFor !== failureKey) { + otaFailureDialogShownFor = failureKey; + void showOtaFailureLog(); + } + } else if (job?.phase === "complete") { + otaFailureLogActions.hidden = true; + otaFailureLogText = ""; + otaProgressGroup.hidden = true; + setOtaFeedback(""); + } + } +} + +otaViewFailureLog.addEventListener("click", () => void showOtaFailureLog()); +otaCopyFailureLog.addEventListener("click", () => void copyOtaFailureLog()); +otaFailureLogCopy.addEventListener("click", () => void copyOtaFailureLog()); +otaFailureLogClose.addEventListener("click", () => otaFailureLogDialog.close()); + +async function pollOtaUntilFinished() { + if (otaPolling) return; + otaPolling = true; + let disconnected = false; + try { + while (true) { + try { + const status = await fetchOtaStatus(); + if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending"); + applyOtaStatus(status); + if (!status.active) return; + } catch { + disconnected = true; + setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending"); + } + await new Promise((resolve) => window.setTimeout(resolve, 1000)); + } + } finally { + otaPolling = false; + } +} + +async function loadOtaStatus() { + try { + const status = await fetchOtaStatus(); + applyOtaStatus(status); + if (status.active) pollOtaUntilFinished(); + } catch (error) { + otaCurrentVersion.textContent = "读取失败"; + otaFeatureUpdatedAt.textContent = "读取失败"; + setOtaFeedback(error.message, "error"); + } +} + +otaSelectFile.addEventListener("click", () => otaFileInput.click()); +otaFileInput.addEventListener("change", async () => { + const file = otaFileInput.files?.[0]; + if (!file) return; + setControlBusy(otaSelectFile, { label: "正在上传…", message: "开始上传完整 OTA 更新包" }); + setOtaFeedback("正在上传;上传完成并通过校验后将自动开始安装。", "pending"); + showOtaProgress(0, "正在上传更新包"); + try { + await xhrUpload(file); + setOtaFeedback("更新包已接受,安装将在设备后台继续。", "success"); + await pollOtaUntilFinished(); + } catch (error) { + setControlReady(otaSelectFile); + setOtaFeedback(error.message, "error"); + } finally { + otaFileInput.value = ""; + } +}); + +function showOrientation(value) { + setActiveButton( + orientationButtons, + orientationButtons.find((button) => Number(button.dataset.orientation) === Number(value)), + ); +} + +function showBrightness(value) { + brightness.value = String(value); + brightnessValue.textContent = `${value}%`; +} + +function showMatrixRefreshRate(value) { + setActiveButton( + matrixRefreshRateButtons, + matrixRefreshRateButtons.find( + (button) => Number(button.dataset.matrixRefreshRate) === Number(value), + ), + ); +} + +function finitePercent(value) { + if (value === null || value === undefined || value === "") return null; + const number = Number(value); + if (!Number.isFinite(number)) return null; + return Math.max(1, Math.min(100, Math.floor(number))); +} + +function lowVoltageProtectionMode() { + const mode = lowVoltageProtectionStatus?.mode; + return LOW_VOLTAGE_PROTECTION_MODES.has(mode) ? mode : "unavailable"; +} + +function estimatedEffectiveBrightness(userBrightness) { + const user = finitePercent(userBrightness); + if (user === null) return null; + const mode = lowVoltageProtectionMode(); + if (mode === "critical") return 35; + if (mode === "limiting") { + const limit = finitePercent(lowVoltageProtectionStatus?.brightness_limit_percent); + return limit === null ? user : Math.min(user, limit); + } + return user; +} + +function showBrightnessProtectionStatus(userBrightness, effectiveBrightness = undefined) { + const mode = lowVoltageProtectionMode(); + brightnessProtectionStatus.dataset.mode = mode; + if (mode !== "limiting" && mode !== "critical") { + brightnessProtectionStatus.hidden = true; + if (brightnessProtectionStatus.textContent) brightnessProtectionStatus.textContent = ""; + return; + } + + const user = finitePercent(userBrightness) ?? confirmedBrightness; + const reportedEffective = finitePercent(effectiveBrightness); + const effective = reportedEffective ?? estimatedEffectiveBrightness(user); + const staleSuffix = lowVoltageProtectionStatus?.reading_stale === true + ? " 电压读数异常,正在保持上次保护。" + : ""; + let text; + if (mode === "critical") { + text = `用户画面已禁用,低电图标固定以 35% 亮度显示。用户设置 ${user}% 会保留并在电压恢复后生效。${staleSuffix}`; + } else { + const limit = finitePercent(lowVoltageProtectionStatus?.brightness_limit_percent); + const limitText = limit === null ? "-" : String(limit); + const effectiveText = effective === null ? "-" : String(effective); + text = `当前允许的最高亮度:${limitText}%。用户设置:${user}%;当前实际亮度:${effectiveText}%。${staleSuffix}`; + } + if (brightnessProtectionStatus.textContent !== text) { + brightnessProtectionStatus.textContent = text; + } + brightnessProtectionStatus.hidden = false; +} + +function showLowVoltageProtectionSwitch(enabled) { + const checked = enabled === true; + lowVoltageProtectionToggle.checked = checked; + lowVoltageProtectionSwitchState.textContent = checked ? "开启" : "关闭"; +} + +function setLowVoltageProtectionFeedback(text, state = "idle") { + lowVoltageProtectionFeedback.textContent = text; + lowVoltageProtectionFeedback.dataset.state = state; + lowVoltageProtectionFeedback.hidden = !text; +} + +function syncLowVoltageProtectionEnabled(value) { + if (typeof value !== "boolean") return false; + confirmedLowVoltageProtectionEnabled = value; + showLowVoltageProtectionSwitch(value); + if (!lowVoltageProtectionPending) setControlReady(lowVoltageProtectionToggle); + return true; +} + +function showPerformanceModeSwitch(enabled) { + const checked = enabled === true; + performanceModeToggle.checked = checked; + performanceModeSwitchState.textContent = checked ? "开启" : "关闭"; +} + +function setPerformanceModeFeedback(text, state = "idle") { + performanceModeFeedback.textContent = text; + performanceModeFeedback.dataset.state = state; + performanceModeFeedback.hidden = !text; +} + +function syncPerformanceModeStatus(status) { + if (!status || typeof status !== "object") { + setControlUnavailable(performanceModeToggle, "CPU 调频状态不可用,请稍后重试。"); + setPerformanceModeFeedback("CPU 调频状态不可用", "error"); + return; + } + if (!performanceModePending && typeof status.requested === "boolean") { + confirmedPerformanceModeEnabled = status.requested === true && status.effective === true; + showPerformanceModeSwitch(confirmedPerformanceModeEnabled); + } + if (performanceModePending) return; + if (status.available === true) { + setControlReady(performanceModeToggle); + if (status.requested === true && status.effective !== true) { + setPerformanceModeFeedback( + `性能模式未实际生效${status.last_error ? `:${status.last_error}` : ""}`, + "error", + ); + } else { + setPerformanceModeFeedback( + status.effective === true ? "性能模式已开启并实际生效" : "性能模式已关闭", + "ready", + ); + } + return; + } + const reason = status.last_error + ? `CPU 调频策略不可用:${status.last_error}` + : "CPU 调频策略不可用;系统修复完成前不能开启性能模式。"; + setControlUnavailable(performanceModeToggle, reason); + setPerformanceModeFeedback(reason, "error"); +} + +function brightnessFeedbackText(stage, userBrightness) { + const user = finitePercent(userBrightness) ?? confirmedBrightness; + const mode = lowVoltageProtectionMode(); + if (displayTestActive && mode !== "critical") { + const test = finitePercent(displayTestBrightness); + const suffix = test === null ? "测试亮度" : `测试亮度 ${test}%`; + return stage === "pending" + ? `正在保存用户亮度 ${user}%;当前仍由${suffix}覆盖…` + : `用户亮度 ${user}% 已保存,但当前由${suffix}覆盖`; + } + if (mode !== "limiting" && mode !== "critical") { + return stage === "pending" + ? `正在应用并保存用户亮度 ${user}%…` + : `用户亮度 ${user}% 已生效并保存`; + } + const effective = estimatedEffectiveBrightness(user); + return stage === "pending" + ? `正在应用并保存用户亮度 ${user}%;当前实际亮度 ${effective}%…` + : `用户亮度 ${user}% 已保存;当前实际亮度 ${effective}%`; +} + +function showPreviewRefreshWarning(value) { + previewRefreshWarning.hidden = !(Number.isInteger(value) && value < 100); +} + +function showPreviewRefreshError(message = "") { + previewRefreshError.textContent = message; + previewRefreshError.hidden = !message; +} + +function showPreviewRefreshInterval(value) { + previewRefreshInput.value = String(value); + showPreviewRefreshWarning(Number(value)); + showPreviewRefreshError(); +} + +function showAnimationPreviewMaxConcurrent(value) { + animationPreviewMaxConcurrentInput.value = String(value); +} + +function setAnimationPreviewConcurrencyFeedback(message = "", state = "idle") { + animationPreviewConcurrencyFeedback.textContent = message; + animationPreviewConcurrencyFeedback.dataset.state = state; + animationPreviewConcurrencyFeedback.hidden = !message; +} + +function voltageStatusText(status) { + if (status === "ok") return "正常"; + if (status === "disconnected") return "传感器断开"; + if (status === "error") return "读取异常"; + return "正在读取"; +} + +function showVoltageDetails() { + const status = voltageStatus?.status || "starting"; + voltageCurrent.textContent = status === "ok" && Number.isFinite(Number(voltageStatus.volts)) + ? `${Number(voltageStatus.volts).toFixed(2)} V` + : "-"; + voltageSensorStatus.textContent = voltageStatusText(status); + const calibratedAt = voltageConfig?.voltage_calibrated_at; + voltageCalibrationStatus.textContent = calibratedAt ? "已校准" : "未校准(标称值)"; + voltageCalibratedAt.textContent = calibratedAt + ? new Date(calibratedAt).toLocaleString("zh-CN") + : "-"; + if (status === "ok") setControlReady(startCalibration); + else if (status === "disconnected") setControlUnavailable(startCalibration, "电压传感器已断开,请连接传感器并等待状态恢复后再校准。"); + else if (status === "error") setControlUnavailable(startCalibration, "电压读取异常,请检查传感器和接线后再校准。"); + else setControlUnavailable(startCalibration, "正在读取电压传感器状态,请稍候。"); + if (resetCalibration.dataset.controlState !== "busy") { + if (calibratedAt) setControlReady(resetCalibration); + else setControlUnavailable(resetCalibration, "当前没有已保存的校准,无需恢复标称值。"); + } +} + +function resetCalibrationDialog() { + calibrationProposal = null; + calibrationPreview.hidden = true; + setControlUnavailable(confirmCalibration, "请先完成“采样并预览”,再保存校准。"); + setControlReady(previewCalibration); + calibrationReference.disabled = false; + calibrationDialogFeedback.textContent = "点击“采样并预览”,约需 1 秒。"; +} + +function showCalibrationProposal(proposal) { + document.getElementById("calibration-reference-result").textContent = `${Number(proposal.reference_volts).toFixed(3)} V`; + document.getElementById("calibration-raw-result").textContent = `${Number(proposal.uncalibrated_volts).toFixed(3)} V`; + document.getElementById("calibration-current-factor").textContent = Number(proposal.current_factor).toFixed(6); + document.getElementById("calibration-proposed-factor").textContent = Number(proposal.proposed_factor).toFixed(6); + document.getElementById("calibration-projected-result").textContent = `${Number(proposal.projected_volts).toFixed(3)} V`; + calibrationPreview.hidden = false; +} + +async function loadSettings() { + loadWifiSettings(); + loadFrpSettings(); + try { + const config = await apiJson("/api/config"); + confirmedOrientation = Number(config.orientation); + confirmedBrightness = Number(config.brightness); + confirmedMatrixRefreshRate = Number(config.matrix_refresh_rate_limit_hz); + confirmedPreviewRefreshInterval = Number(config.preview_refresh_interval_ms); + confirmedPerformanceModeEnabled = config.performance_mode_enabled === true; + confirmedAnimationPreviewMaxConcurrent = normalizeAnimationPreviewMaxConcurrent( + config.animation_preview_max_concurrent, + ); + setAnimationPreviewMaxConcurrent(confirmedAnimationPreviewMaxConcurrent); + voltageConfig = config; + syncLowVoltageProtectionEnabled(config.low_voltage_protection_enabled); + showPerformanceModeSwitch(confirmedPerformanceModeEnabled); + showAnimationPreviewMaxConcurrent(confirmedAnimationPreviewMaxConcurrent); + setAnimationPreviewConcurrencyFeedback(); + showOrientation(confirmedOrientation); + showBrightness(confirmedBrightness); + showMatrixRefreshRate(confirmedMatrixRefreshRate); + showBrightnessProtectionStatus(confirmedBrightness, latestEffectiveBrightness); + showPreviewRefreshInterval(confirmedPreviewRefreshInterval); + setPreviewRefreshInterval(confirmedPreviewRefreshInterval); + showVoltageDetails(); + setFeedback(""); + } catch (error) { + setFeedback(`设置读取失败:${error.message}`, "error"); + announce(`设置读取失败:${error.message}`, true); + } +} + +loadOtaStatus(); + +startCalibration.addEventListener("click", () => { + resetCalibrationDialog(); + calibrationDialog.showModal(); + calibrationReference.focus(); + calibrationReference.select(); +}); + +previewCalibration.addEventListener("click", async () => { + const referenceVolts = Number(calibrationReference.value); + if (!Number.isFinite(referenceVolts) || referenceVolts < 4.5 || referenceVolts > 5.5) { + calibrationDialogFeedback.textContent = "请输入 4.500V 到 5.500V 之间的万用表读数。"; + return; + } + calibrationProposal = null; + calibrationPreview.hidden = true; + setControlUnavailable(confirmCalibration, "正在生成新的校准预览,请稍候。"); + setControlBusy(previewCalibration, { label: "正在采样…", message: "正在采集 15 个新样本…" }); + calibrationReference.disabled = true; + calibrationDialogFeedback.textContent = "正在采集 15 个新样本,请保持供电和表笔稳定…"; + try { + const proposal = await apiJson("/api/power/voltage/calibration/preview", { + method: "POST", + body: JSON.stringify({ reference_volts: referenceVolts }), + }); + calibrationProposal = proposal; + showCalibrationProposal(proposal); + setControlReady(confirmCalibration); + calibrationDialogFeedback.textContent = "请核对预览结果;确认后才会写入本机校准。"; + } catch (error) { + calibrationDialogFeedback.textContent = `校准预览失败:${error.message}`; + setControlUnavailable(confirmCalibration, "请先完成“采样并预览”,再保存校准。"); + } finally { + setControlReady(previewCalibration); + calibrationReference.disabled = false; + } +}); + +confirmCalibration.addEventListener("click", async () => { + if (!calibrationProposal) return; + setControlBusy(confirmCalibration, { label: "正在保存…", message: "正在保存校准并重新采样…" }); + setControlBusy(previewCalibration); + calibrationDialogFeedback.textContent = "正在保存并重新采样…"; + try { + const result = await apiJson("/api/power/voltage/calibration/confirm", { + method: "POST", + body: JSON.stringify({ proposal_id: calibrationProposal.proposal_id }), + }); + voltageConfig = result.config; + voltageStatus = result.screen_input_voltage; + syncLowVoltageProtectionEnabled(result.config?.low_voltage_protection_enabled); + showVoltageDetails(); + calibrationDialog.close(); + setFeedback("电压传感器已完成本机校准", "success"); + announce("电压传感器校准已保存"); + await refreshStatus(); + } catch (error) { + calibrationDialogFeedback.textContent = `保存校准失败:${error.message}`; + } finally { + setControlReady(confirmCalibration); + setControlReady(previewCalibration); + } +}); + +resetCalibration.addEventListener("click", async () => { + const confirmed = window.confirm("确认恢复标称校准系数 1.0?更换 ADC 或测量接线后应执行此操作并重新校准。"); + if (!confirmed) return; + setControlBusy(resetCalibration, { label: "正在恢复…", message: "正在恢复标称校准…" }); + setFeedback("正在恢复标称校准…", "pending"); + try { + const result = await apiJson("/api/power/voltage/calibration/reset", { + method: "POST", + body: "{}", + }); + voltageConfig = result.config; + voltageStatus = result.screen_input_voltage; + syncLowVoltageProtectionEnabled(result.config?.low_voltage_protection_enabled); + showVoltageDetails(); + setFeedback("已恢复标称校准系数 1.0", "success"); + announce("电压传感器已恢复标称值"); + await refreshStatus(); + } catch (error) { + setFeedback(`恢复标称值失败:${error.message}`, "error"); + announce(`恢复标称值失败:${error.message}`, true); + } finally { + setControlReady(resetCalibration); + showVoltageDetails(); + } +}); + +lowVoltageProtectionToggle.addEventListener("change", async () => { + if (lowVoltageProtectionPending) { + showLowVoltageProtectionSwitch(confirmedLowVoltageProtectionEnabled); + return; + } + + const requested = lowVoltageProtectionToggle.checked === true; + const previous = confirmedLowVoltageProtectionEnabled; + lowVoltageProtectionPending = true; + setControlBusy(lowVoltageProtectionToggle); + lowVoltageProtectionSwitchState.textContent = requested ? "正在开启" : "正在关闭"; + setLowVoltageProtectionFeedback( + requested ? "正在开启低电压亮度保护…" : "正在关闭低电压亮度保护…", + "pending", + ); + + let saved = false; + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ low_voltage_protection_enabled: requested }), + }); + if (typeof config.low_voltage_protection_enabled !== "boolean") { + throw new Error("服务器返回了无效的保护开关状态"); + } + voltageConfig = config; + confirmedLowVoltageProtectionEnabled = config.low_voltage_protection_enabled; + showLowVoltageProtectionSwitch(confirmedLowVoltageProtectionEnabled); + setLowVoltageProtectionFeedback( + confirmedLowVoltageProtectionEnabled + ? "低电压亮度保护已开启" + : "低电压亮度保护已关闭", + "success", + ); + announce(confirmedLowVoltageProtectionEnabled + ? "低电压亮度保护已开启" + : "低电压亮度保护已关闭"); + saved = true; + } catch (error) { + confirmedLowVoltageProtectionEnabled = previous; + showLowVoltageProtectionSwitch(previous); + setLowVoltageProtectionFeedback(`低电压亮度保护保存失败:${error.message}`, "error"); + announce(`低电压亮度保护保存失败:${error.message}`, true); + } finally { + lowVoltageProtectionPending = false; + setControlReady(lowVoltageProtectionToggle); + } + + if (saved) { + try { + await refreshStatus(); + } catch (error) { + setLowVoltageProtectionFeedback( + `保护开关已保存,但状态刷新失败:${error.message}`, + "error", + ); + } + } +}); + +performanceModeToggle.addEventListener("change", async () => { + if (performanceModePending) { + showPerformanceModeSwitch(confirmedPerformanceModeEnabled); + return; + } + const requested = performanceModeToggle.checked === true; + const previous = confirmedPerformanceModeEnabled; + performanceModePending = true; + setControlBusy(performanceModeToggle); + performanceModeSwitchState.textContent = requested ? "正在开启" : "正在关闭"; + setPerformanceModeFeedback( + requested ? "正在应用 performance governor…" : "正在恢复原 CPU governor…", + "pending", + ); + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ performance_mode_enabled: requested }), + }); + if (typeof config.performance_mode_enabled !== "boolean") { + throw new Error("服务器返回了无效的性能模式状态"); + } + confirmedPerformanceModeEnabled = config.performance_mode_enabled; + showPerformanceModeSwitch(confirmedPerformanceModeEnabled); + setPerformanceModeFeedback( + confirmedPerformanceModeEnabled ? "性能模式已开启" : "性能模式已关闭并恢复原 governor", + "success", + ); + announce(confirmedPerformanceModeEnabled ? "性能模式已开启" : "性能模式已关闭"); + } catch (error) { + confirmedPerformanceModeEnabled = previous; + showPerformanceModeSwitch(previous); + setPerformanceModeFeedback(`性能模式切换失败:${error.message}`, "error"); + announce(`性能模式切换失败:${error.message}`, true); + } finally { + performanceModePending = false; + setControlReady(performanceModeToggle); + } + try { + await refreshStatus(); + } catch (error) { + setPerformanceModeFeedback(`性能模式状态刷新失败:${error.message}`, "error"); + } +}); + +async function saveAnimationPreviewMaxConcurrent(requested) { + if (animationPreviewConcurrencyPending) return; + const previous = confirmedAnimationPreviewMaxConcurrent; + animationPreviewConcurrencyPending = true; + setControlBusy(animationPreviewMaxConcurrentInput); + setAnimationPreviewConcurrencyFeedback(`正在保存同时播放数量 ${requested}…`, "pending"); + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ animation_preview_max_concurrent: requested }), + }); + const saved = normalizeAnimationPreviewMaxConcurrent( + config.animation_preview_max_concurrent, + ); + confirmedAnimationPreviewMaxConcurrent = saved; + setAnimationPreviewMaxConcurrent(saved); + showAnimationPreviewMaxConcurrent(saved); + setAnimationPreviewConcurrencyFeedback(`同时播放数量 ${saved} 已保存`, "success"); + announce(`模板界面同时播放的动图数量已设置为 ${saved}`); + } catch (error) { + confirmedAnimationPreviewMaxConcurrent = previous; + setAnimationPreviewMaxConcurrent(previous); + showAnimationPreviewMaxConcurrent(previous); + setAnimationPreviewConcurrencyFeedback(`同时播放数量保存失败:${error.message}`, "error"); + announce(`同时播放数量保存失败:${error.message}`, true); + } finally { + animationPreviewConcurrencyPending = false; + setControlReady(animationPreviewMaxConcurrentInput); + } +} + +function commitAnimationPreviewMaxConcurrent() { + if (animationPreviewConcurrencyPending) return; + const raw = animationPreviewMaxConcurrentInput.value.trim(); + const requested = Number(raw); + try { + if (raw === "" || !/^\d+$/.test(raw)) throw new Error(); + normalizeAnimationPreviewMaxConcurrent(requested); + } catch { + showAnimationPreviewMaxConcurrent(confirmedAnimationPreviewMaxConcurrent); + setAnimationPreviewConcurrencyFeedback( + `请输入 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数。`, + "error", + ); + return; + } + if (requested === confirmedAnimationPreviewMaxConcurrent) { + setAnimationPreviewConcurrencyFeedback(); + return; + } + void saveAnimationPreviewMaxConcurrent(requested); +} + +animationPreviewMaxConcurrentInput.addEventListener("input", () => { + setAnimationPreviewConcurrencyFeedback(); +}); +animationPreviewMaxConcurrentInput.addEventListener("change", commitAnimationPreviewMaxConcurrent); +animationPreviewMaxConcurrentInput.addEventListener("blur", commitAnimationPreviewMaxConcurrent); +animationPreviewMaxConcurrentInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + animationPreviewMaxConcurrentInput.blur(); +}); + +orientationButtons.forEach((button) => { + button.addEventListener("click", async () => { + if (orientationPending) return; + const requested = Number(button.dataset.orientation); + if (requested === confirmedOrientation) return; + orientationPending = true; + orientationButtons.forEach((item) => setControlBusy(item)); + showOrientation(requested); + setFeedback("正在应用屏幕方向…", "pending"); + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ orientation: requested }), + }); + confirmedOrientation = Number(config.orientation); + showOrientation(confirmedOrientation); + setFeedback(`方向 ${confirmedOrientation}° 已生效并保存`, "success"); + announce(`屏幕方向已切换为 ${confirmedOrientation}°`); + await refreshStatus(); + } catch (error) { + showOrientation(confirmedOrientation); + setFeedback(`方向应用失败:${error.message}`, "error"); + announce(`方向应用失败:${error.message}`, true); + } finally { + orientationPending = false; + orientationButtons.forEach((item) => setControlReady(item)); + } + }); +}); + +matrixRefreshRateButtons.forEach((button) => { + button.addEventListener("click", async () => { + if (matrixRefreshRatePending) return; + const requested = Number(button.dataset.matrixRefreshRate); + if (requested === confirmedMatrixRefreshRate) return; + + const previous = confirmedMatrixRefreshRate; + matrixRefreshRatePending = true; + matrixRefreshRateButtons.forEach((item) => setControlBusy(item)); + showMatrixRefreshRate(requested); + setFeedback(`正在应用 ${requested}Hz 扫描上限…`, "pending"); + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ matrix_refresh_rate_limit_hz: requested }), + }); + const applied = Number(config.matrix_refresh_rate_limit_hz); + if (!matrixRefreshRateButtons.some( + (item) => Number(item.dataset.matrixRefreshRate) === applied + )) { + throw new Error("服务器返回了无效的扫描刷新率挡位"); + } + confirmedMatrixRefreshRate = applied; + showMatrixRefreshRate(confirmedMatrixRefreshRate); + setFeedback(`${confirmedMatrixRefreshRate}Hz 上限已生效并保存`, "success"); + announce(`屏幕扫描刷新率上限已切换为 ${confirmedMatrixRefreshRate}Hz`); + await refreshStatus(); + } catch (error) { + confirmedMatrixRefreshRate = previous; + showMatrixRefreshRate(previous); + setFeedback(`扫描刷新率应用失败:${error.message}`, "error"); + announce(`扫描刷新率应用失败:${error.message}`, true); + } finally { + matrixRefreshRatePending = false; + matrixRefreshRateButtons.forEach((item) => setControlReady(item)); + } + }); +}); + +async function processBrightnessQueue() { + if (applyingBrightness) return; + applyingBrightness = true; + while (desiredBrightness !== null) { + const requested = desiredBrightness; + desiredBrightness = null; + setFeedback(brightnessFeedbackText("pending", requested), "pending"); + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ brightness: requested }), + }); + confirmedBrightness = Number(config.brightness); + latestEffectiveBrightness = null; + showBrightnessProtectionStatus(confirmedBrightness); + setFeedback(brightnessFeedbackText("success", confirmedBrightness), "success"); + } catch (error) { + if (desiredBrightness === null) { + showBrightness(confirmedBrightness); + showBrightnessProtectionStatus(confirmedBrightness, latestEffectiveBrightness); + } + setFeedback(`亮度应用失败:${error.message}`, "error"); + announce(`亮度应用失败:${error.message}`, true); + } + } + applyingBrightness = false; + await refreshStatus().catch(() => {}); +} + +function queueBrightness(value) { + desiredBrightness = Math.max(1, Math.min(100, Number(value))); + processBrightnessQueue(); +} + +brightness.addEventListener("input", () => { + brightnessValue.textContent = `${brightness.value}%`; + showBrightnessProtectionStatus(brightness.value); + setFeedback(brightnessFeedbackText("pending", brightness.value), "pending"); + queueBrightness(brightness.value); +}); +brightness.addEventListener("change", () => { + queueBrightness(brightness.value); +}); + +async function processPreviewRefreshQueue() { + if (previewRefreshPending) return; + previewRefreshPending = true; + while (desiredPreviewRefreshInterval !== null) { + const requested = desiredPreviewRefreshInterval; + desiredPreviewRefreshInterval = null; + savingPreviewRefreshInterval = requested; + setFeedback(`正在保存预览刷新间隔 ${requested}ms…`, "pending"); + try { + const config = await apiJson("/api/config", { + method: "PUT", + body: JSON.stringify({ preview_refresh_interval_ms: requested }), + }); + confirmedPreviewRefreshInterval = Number(config.preview_refresh_interval_ms); + showPreviewRefreshInterval(confirmedPreviewRefreshInterval); + setPreviewRefreshInterval(confirmedPreviewRefreshInterval); + setFeedback(`预览刷新间隔 ${confirmedPreviewRefreshInterval}ms 已保存`, "success"); + } catch (error) { + if (desiredPreviewRefreshInterval === null) { + showPreviewRefreshInterval(confirmedPreviewRefreshInterval); + } + setFeedback(`预览刷新间隔保存失败:${error.message}`, "error"); + announce(`预览刷新间隔保存失败:${error.message}`, true); + } finally { + savingPreviewRefreshInterval = null; + } + } + previewRefreshPending = false; + await refreshStatus().catch(() => {}); +} + +function commitPreviewRefreshInterval() { + const value = previewRefreshInput.value.trim(); + const requested = Number(value); + if ( + value === "" + || !/^\d+$/.test(value) + || !Number.isInteger(requested) + || requested < 1 + || requested > 60000 + ) { + previewRefreshInput.value = String(confirmedPreviewRefreshInterval); + showPreviewRefreshWarning(confirmedPreviewRefreshInterval); + showPreviewRefreshError("请输入 1 到 60000 之间的整数。"); + return; + } + showPreviewRefreshError(); + showPreviewRefreshWarning(requested); + if ( + requested === savingPreviewRefreshInterval + || requested === desiredPreviewRefreshInterval + ) return; + if (requested === confirmedPreviewRefreshInterval && !previewRefreshPending) return; + desiredPreviewRefreshInterval = requested; + void processPreviewRefreshQueue(); +} + +previewRefreshInput.addEventListener("input", () => { + showPreviewRefreshWarning(null); + showPreviewRefreshError(); +}); +previewRefreshInput.addEventListener("change", commitPreviewRefreshInterval); +previewRefreshInput.addEventListener("blur", commitPreviewRefreshInterval); +previewRefreshInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + previewRefreshInput.blur(); +}); + +document.addEventListener("matrix:status", (event) => { + const detail = event.detail || {}; + syncPerformanceModeStatus(detail.system?.performance_mode); + const statusMatrixRefreshRate = Number( + detail.screen?.driver_options?.limit_refresh_rate_hz, + ); + if ( + Number.isInteger(statusMatrixRefreshRate) + && !matrixRefreshRatePending + && matrixRefreshRateButtons.some( + (button) => Number(button.dataset.matrixRefreshRate) === statusMatrixRefreshRate, + ) + ) { + confirmedMatrixRefreshRate = statusMatrixRefreshRate; + showMatrixRefreshRate(confirmedMatrixRefreshRate); + } + const statusPreviewInterval = Number(detail.ui?.preview_refresh_interval_ms); + if ( + Number.isInteger(statusPreviewInterval) + && !previewRefreshPending + && desiredPreviewRefreshInterval === null + && document.activeElement !== previewRefreshInput + ) { + confirmedPreviewRefreshInterval = statusPreviewInterval; + showPreviewRefreshInterval(confirmedPreviewRefreshInterval); + setPreviewRefreshInterval(confirmedPreviewRefreshInterval); + } + + const state = detail.state; + displayTestActive = state?.display_test?.active === true; + displayTestBrightness = displayTestActive + ? finitePercent(state?.display_test?.brightness) + : null; + voltageStatus = detail.power?.screen_input_voltage || null; + lowVoltageProtectionStatus = detail.power?.low_voltage_protection || null; + latestEffectiveBrightness = finitePercent(state?.effective_brightness); + showVoltageDetails(); + + if (!lowVoltageProtectionPending && typeof lowVoltageProtectionStatus?.enabled === "boolean") { + const protectionChanged = lowVoltageProtectionStatus.enabled + !== confirmedLowVoltageProtectionEnabled; + syncLowVoltageProtectionEnabled(lowVoltageProtectionStatus.enabled); + if (protectionChanged && voltageConfig !== null) { + setLowVoltageProtectionFeedback( + confirmedLowVoltageProtectionEnabled + ? "保护开关已从其他页面同步为开启" + : "保护开关已从其他页面同步为关闭", + "ready", + ); + } + } + showBrightnessProtectionStatus(state?.brightness ?? confirmedBrightness, latestEffectiveBrightness); + + if (orientationPending || applyingBrightness || desiredBrightness !== null) return; + if (!state) return; + const nextOrientation = Number(state.orientation); + const nextBrightness = Number(state.brightness); + const changed = nextOrientation !== confirmedOrientation || nextBrightness !== confirmedBrightness; + confirmedOrientation = nextOrientation; + confirmedBrightness = nextBrightness; + showOrientation(confirmedOrientation); + showBrightness(confirmedBrightness); + showBrightnessProtectionStatus(confirmedBrightness, latestEffectiveBrightness); + if (changed && detail.externalChange) { + setFeedback("方向或亮度已由其他控制页面更新", "ready"); + } +}); + +registerWorkspace({ + id: "settings", + title: "系统设置", + elementId: "workspace-settings", + order: 20, + persistence: "server", + onEnter: loadSettings, + onLeave: () => { + discardWifiSettingsDrafts(); + discardFrpDrafts(); + }, +}); diff --git a/核桃派软件源代码/app/static/views/templates.js b/核桃派软件源代码/app/static/views/templates.js new file mode 100644 index 0000000..ae63d3c --- /dev/null +++ b/核桃派软件源代码/app/static/views/templates.js @@ -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(); diff --git a/核桃派软件源代码/app/static/views/text.js b/核桃派软件源代码/app/static/views/text.js new file mode 100644 index 0000000..4f472f1 --- /dev/null +++ b/核桃派软件源代码/app/static/views/text.js @@ -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, +}); diff --git a/核桃派软件源代码/app/static/views/wifi-settings.js b/核桃派软件源代码/app/static/views/wifi-settings.js new file mode 100644 index 0000000..5b387bc --- /dev/null +++ b/核桃派软件源代码/app/static/views/wifi-settings.js @@ -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(); diff --git a/核桃派软件源代码/app/static/wifi-draft-state.js b/核桃派软件源代码/app/static/wifi-draft-state.js new file mode 100644 index 0000000..2eec98c --- /dev/null +++ b/核桃派软件源代码/app/static/wifi-draft-state.js @@ -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]; + }, + }); +} diff --git a/核桃派软件源代码/app/static/workspace-order.js b/核桃派软件源代码/app/static/workspace-order.js new file mode 100644 index 0000000..87e3bb4 --- /dev/null +++ b/核桃派软件源代码/app/static/workspace-order.js @@ -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; +} diff --git a/核桃派软件源代码/app/system/__init__.py b/核桃派软件源代码/app/system/__init__.py new file mode 100644 index 0000000..ee95073 --- /dev/null +++ b/核桃派软件源代码/app/system/__init__.py @@ -0,0 +1 @@ +"""Host-system controls used by the matrix service.""" diff --git a/核桃派软件源代码/app/system/performance.py b/核桃派软件源代码/app/system/performance.py new file mode 100644 index 0000000..bbe4d99 --- /dev/null +++ b/核桃派软件源代码/app/system/performance.py @@ -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()) diff --git a/核桃派软件源代码/app/templates/__init__.py b/核桃派软件源代码/app/templates/__init__.py new file mode 100644 index 0000000..e20b7d9 --- /dev/null +++ b/核桃派软件源代码/app/templates/__init__.py @@ -0,0 +1,2 @@ +"""Editable scene template persistence and thumbnail rendering.""" + diff --git a/核桃派软件源代码/app/templates/store.py b/核桃派软件源代码/app/templates/store.py new file mode 100644 index 0000000..284d83e --- /dev/null +++ b/核桃派软件源代码/app/templates/store.py @@ -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[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P[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 diff --git a/核桃派软件源代码/app/ui_copy_editor/__init__.py b/核桃派软件源代码/app/ui_copy_editor/__init__.py new file mode 100644 index 0000000..251ba81 --- /dev/null +++ b/核桃派软件源代码/app/ui_copy_editor/__init__.py @@ -0,0 +1,2 @@ +"""Build-gated UI copy editor support.""" + diff --git a/核桃派软件源代码/app/ui_copy_editor/settings.py b/核桃派软件源代码/app/ui_copy_editor/settings.py new file mode 100644 index 0000000..2f3c8fa --- /dev/null +++ b/核桃派软件源代码/app/ui_copy_editor/settings.py @@ -0,0 +1,3 @@ +"""This flag is changed only when producing a dedicated editing build.""" + +UI_COPY_EDITOR_BUILD_ENABLED = False diff --git a/核桃派软件源代码/app/ui_copy_editor/static/editor.css b/核桃派软件源代码/app/ui_copy_editor/static/editor.css new file mode 100644 index 0000000..ba26edc --- /dev/null +++ b/核桃派软件源代码/app/ui_copy_editor/static/editor.css @@ -0,0 +1,213 @@ +#ui-copy-editor-toggle { + position: fixed; + z-index: 1400; + right: max(16px, env(safe-area-inset-right)); + bottom: max(16px, env(safe-area-inset-bottom)); + min-width: 104px; + min-height: 48px; + border: 1px solid #61d6b4; + border-radius: 999px; + background: #0f5f50; + color: #fff; + box-shadow: 0 12px 32px rgb(0 0 0 / 45%); + font-weight: 800; +} + +#ui-copy-editor-panel { + position: fixed; + z-index: 1390; + inset: 0 0 0 auto; + width: min(420px, calc(100vw - 24px)); + overflow: auto; + padding: 18px 18px calc(84px + env(safe-area-inset-bottom)); + border-left: 1px solid #374151; + background: #0d1118; + color: #f5f7fb; + box-shadow: -16px 0 48px rgb(0 0 0 / 55%); + font: 14px/1.45 system-ui, sans-serif; +} + +#ui-copy-editor-panel[hidden] { display: none; } + +.ui-copy-editor-header, +.ui-copy-editor-modes, +.ui-copy-editor-tools, +.ui-copy-list-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.ui-copy-editor-header { margin-bottom: 12px; } +.ui-copy-editor-header strong { display: block; font-size: 18px; } +.ui-copy-editor-header span { color: #9ca3af; font-size: 12px; } +.ui-copy-editor-header button { min-width: 44px; min-height: 44px; font-size: 24px; } + +.ui-copy-editor-modes { margin-bottom: 10px; } +.ui-copy-editor-modes button { flex: 1; min-height: 42px; } +.ui-copy-editor-modes button.active { border-color: #61d6b4; background: #173b35; color: #8ff2d6; } + +.ui-copy-save-status { + min-height: 38px; + margin: 8px 0; + padding: 9px 10px; + border-radius: 8px; + background: #171d27; + color: #b9c2d0; +} +.ui-copy-save-status[data-state="saved"] { color: #72e4c4; } +.ui-copy-save-status[data-state="pending"] { color: #ffd27a; } +.ui-copy-save-status[data-state="error"], +.ui-copy-save-status[data-state="conflict"] { color: #ff9b9b; } + +.ui-copy-editor-tools { margin-bottom: 12px; } +.ui-copy-editor-tools button, +.ui-copy-editor-tools a { + min-height: 40px; + padding: 9px 12px; + border: 1px solid #394252; + border-radius: 8px; + background: #171d27; + color: #f5f7fb; + text-decoration: none; +} + +.ui-copy-search, +.ui-copy-selected-form label { display: grid; gap: 6px; margin: 10px 0; } +.ui-copy-search input, +.ui-copy-selected-form input, +.ui-copy-selected-form textarea, +.ui-copy-selected-form select { + width: 100%; + min-height: 42px; + box-sizing: border-box; + border: 1px solid #3b4658; + border-radius: 8px; + background: #111722; + color: #fff; + padding: 9px 10px; + font: inherit; +} +.ui-copy-selected-form textarea { resize: vertical; } + +.ui-copy-list-heading { margin: 14px 0 8px; color: #cdd5e1; } +.ui-copy-list { display: grid; gap: 6px; max-height: 40vh; overflow: auto; padding-right: 4px; } +.ui-copy-list-item { + display: grid; + gap: 3px; + width: 100%; + min-height: 48px; + padding: 9px 10px; + border: 1px solid #293242; + border-radius: 8px; + background: #141a24; + color: #f5f7fb; + text-align: left; +} +.ui-copy-list-item.active { border-color: #61d6b4; background: #17332f; } +.ui-copy-list-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ui-copy-list-meta { color: #8e9aab; font-size: 11px; } + +.ui-copy-selected-form { + margin-top: 14px; + padding: 14px; + border: 1px solid #344054; + border-radius: 10px; + background: #141a24; +} +.ui-copy-selected-form h3 { margin: 0 0 4px; font-size: 16px; overflow-wrap: anywhere; } +.ui-copy-selected-form > p { margin: 4px 0 8px; color: #9ca8b8; overflow-wrap: anywhere; } +.ui-copy-form-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.ui-copy-form-actions > button { flex: 1; min-height: 44px; } +.ui-copy-confirm-button { + min-height: 46px; + border-color: #61d6b4 !important; + background: #0f5f50 !important; + color: #fff !important; + font-weight: 800; +} +.ui-copy-danger-button { + width: 100%; + min-height: 44px; + margin-top: 10px; + border: 1px solid #ff7777 !important; + background: #552126 !important; + color: #ffd6d6 !important; + font-weight: 800; +} +.ui-copy-danger-button:disabled { opacity: .6; } +.ui-copy-delete-confirmation { + margin-top: 10px; + padding: 12px; + border: 1px solid #ff7777; + border-radius: 8px; + background: #30171b; +} +.ui-copy-delete-confirmation[hidden] { display: none; } +.ui-copy-delete-confirmation p { margin: 6px 0; color: #ffd6d6; } +.ui-copy-form-error { + margin: 8px 0 !important; + padding: 9px 10px; + border-left: 3px solid #ff7777; + border-radius: 5px; + background: #30171b; + color: #ffb2b2 !important; +} +.ui-copy-form-error[hidden] { display: none; } +.ui-copy-checkbox { grid-template-columns: auto 1fr !important; align-items: center; } +.ui-copy-checkbox input { width: 22px; min-height: 22px; } +.ui-copy-placeholder-note { color: #ffd27a !important; } + +[data-ui-copy-editor-mode="select"] [data-ui-copy-id] { cursor: crosshair !important; } +[data-ui-copy-editor-mode="select"] [data-ui-copy-id]:hover, +.ui-copy-selected { + outline: 3px solid #61d6b4 !important; + outline-offset: 3px; + border-radius: 3px; +} + +.ui-copy-anchor-candidate { outline: 2px dashed #d8a8ff !important; outline-offset: 3px; cursor: crosshair !important; } +.ui-copy-anchor-candidate:hover { outline-style: solid !important; background-image: linear-gradient(rgb(126 71 177 / 10%), rgb(126 71 177 / 10%)); } + +.ui-copy-custom-insertion { + box-sizing: border-box; + max-width: min(100%, 58rem); + margin: 10px auto; + padding: 10px 12px; + border-left: 3px solid #61d6b4; + border-radius: 6px; + background: #151c26; + color: #e7edf6; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +@media (max-width: 40rem) { + #ui-copy-editor-panel { + inset: auto 0 0 0; + width: auto; + max-height: min(78vh, 720px); + padding: 14px 14px calc(78px + env(safe-area-inset-bottom)); + border-top: 1px solid #374151; + border-left: 0; + border-radius: 18px 18px 0 0; + box-shadow: 0 -16px 48px rgb(0 0 0 / 55%); + } + .ui-copy-list { max-height: 24vh; } + html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="select"] #ui-copy-editor-panel, + html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="insert"] #ui-copy-editor-panel { + max-height: 172px; + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + overflow: hidden; + } + html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="select"] #ui-copy-editor-panel :is(.ui-copy-editor-tools, .ui-copy-search, .ui-copy-list-heading, .ui-copy-list, .ui-copy-selected-form), + html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="insert"] #ui-copy-editor-panel :is(.ui-copy-editor-tools, .ui-copy-search, .ui-copy-list-heading, .ui-copy-list, .ui-copy-selected-form) { + display: none !important; + } +} + +@media (prefers-reduced-motion: no-preference) { + #ui-copy-editor-toggle { transition: transform 120ms ease; } + #ui-copy-editor-toggle:active { transform: scale(.97); } +} diff --git a/核桃派软件源代码/app/ui_copy_editor/static/editor.js b/核桃派软件源代码/app/ui_copy_editor/static/editor.js new file mode 100644 index 0000000..0cf8f0a --- /dev/null +++ b/核桃派软件源代码/app/ui_copy_editor/static/editor.js @@ -0,0 +1,605 @@ +const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__; + +if (!bootstrap) { + throw new Error("UI copy editor bootstrap is missing"); +} + +const runtime = await import(bootstrap.runtime_url); +const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g; +const functionalValuePattern = /^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$/; + +const [catalogResponse, draftResponse] = await Promise.all([ + fetch(bootstrap.catalog_url, { cache: "no-store" }), + fetch(bootstrap.api_url, { cache: "no-store" }), +]); +if (!catalogResponse.ok || !draftResponse.ok) { + throw new Error("无法载入文案目录或草稿"); +} + +const catalog = await catalogResponse.json(); +let draft = await draftResponse.json(); +let etag = draftResponse.headers.get("ETag") || `"${draft.revision}"`; +let mode = "browse"; +let selectedCopyId = null; +let selectedAnchorId = null; +let selectedInsertionId = null; +let saveBusy = false; +const history = []; +const staticTextNodes = new Map(); + +const toggle = document.createElement("button"); +toggle.id = "ui-copy-editor-toggle"; +toggle.type = "button"; +toggle.textContent = "文案编辑"; +toggle.setAttribute("aria-controls", "ui-copy-editor-panel"); +toggle.setAttribute("aria-expanded", "false"); + +const panel = document.createElement("aside"); +panel.id = "ui-copy-editor-panel"; +panel.hidden = true; +panel.setAttribute("aria-label", "文案编辑器"); +panel.innerHTML = ` +
    +
    文案编辑器输入不会立即改变页面
    + +
    +
    + + + +
    +
    草稿已载入,等待操作
    +
    + + 导出草稿 +
    + +
    已登记文案与新增提示
    +
    + + +`; +document.body.append(toggle, panel); + +const elements = { + status: panel.querySelector("#ui-copy-save-status"), + list: panel.querySelector("#ui-copy-list"), + count: panel.querySelector("#ui-copy-list-count"), + search: panel.querySelector("#ui-copy-search-input"), + undo: panel.querySelector("#ui-copy-undo"), + copyForm: panel.querySelector("#ui-copy-selected-form"), + copyTitle: panel.querySelector("#ui-copy-selected-title"), + copySource: panel.querySelector("#ui-copy-selected-source"), + text: panel.querySelector("#ui-copy-text"), + font: panel.querySelector("#ui-copy-font-size"), + fontLabel: panel.querySelector("#ui-copy-font-label"), + placeholderNote: panel.querySelector("#ui-copy-placeholder-note"), + formError: panel.querySelector("#ui-copy-form-error"), + restore: panel.querySelector("#ui-copy-restore"), + delete: panel.querySelector("#ui-copy-delete"), + deleteConfirmation: panel.querySelector("#ui-copy-delete-confirmation"), + insertionForm: panel.querySelector("#ui-copy-insertion-form"), + insertionTitle: panel.querySelector("#ui-copy-insertion-title"), + insertionSource: panel.querySelector("#ui-copy-insertion-source"), + insertionText: panel.querySelector("#ui-copy-insertion-text"), + insertionSize: panel.querySelector("#ui-copy-insertion-size"), + insertionPlacement: panel.querySelector("#ui-copy-insertion-placement"), + insertionError: panel.querySelector("#ui-copy-insertion-error"), + insertionDelete: panel.querySelector("#ui-copy-insertion-delete"), + insertionDeleteConfirmation: panel.querySelector("#ui-copy-insertion-delete-confirmation"), +}; + +function normalizedText(value) { + return String(value).replace(/\s+/g, " ").trim(); +} + +function editableTextNodes(element) { + return Array.from(element.childNodes).filter((node) => { + if (node.nodeType !== Node.TEXT_NODE) return false; + const text = normalizedText(node.nodeValue || ""); + return text && text !== "-" && !functionalValuePattern.test(text); + }); +} + +function wrapTextNode(node, text) { + const span = document.createElement("span"); + span.className = "ui-copy-editable-text"; + span.textContent = text; + const raw = node.nodeValue || ""; + const leading = raw.match(/^\s*/)?.[0] || ""; + const trailing = raw.match(/\s*$/)?.[0] || ""; + node.replaceWith(document.createTextNode(leading), span, document.createTextNode(trailing)); + return span; +} + +function bindStaticCatalog() { + for (const [copyId, item] of Object.entries(catalog.items)) { + const binding = item.render; + if (!binding || binding.type === "dynamic_template") continue; + let host; + try { + host = document.querySelector(binding.selector); + } catch (_error) { + continue; + } + if (!host || panel.contains(host)) continue; + if (binding.type === "attribute") { + runtime.registerStaticUiCopy(copyId, host, host, item.text, item.kind, binding.attribute); + continue; + } + if (!staticTextNodes.has(binding.selector)) { + staticTextNodes.set(binding.selector, editableTextNodes(host)); + } + const node = staticTextNodes.get(binding.selector)[binding.text_index]; + let target; + if (node?.isConnected) target = host.tagName === "OPTION" ? host : wrapTextNode(node, item.text); + else if (binding.default_hidden) { + target = document.createElement("span"); + target.className = "ui-copy-editable-text"; + host.append(target); + } else continue; + runtime.registerStaticUiCopy(copyId, target, host, item.text, item.kind, null, binding.default_hidden); + } +} + +function bindAnchors() { + for (const [anchorId, anchor] of Object.entries(catalog.anchors)) { + let element; + try { + element = document.querySelector(anchor.selector); + } catch (_error) { + continue; + } + if (element && !panel.contains(element)) element.dataset.uiCopyAnchorId = anchorId; + } +} + +function setStatus(text, state = "idle") { + elements.status.textContent = text; + elements.status.dataset.state = state; +} + +function showError(element, text = "") { + element.textContent = text; + element.hidden = !text; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function writableDocument(value) { + return { + schema_version: value.schema_version, + base_catalog_digest: value.base_catalog_digest, + revision: value.revision, + updated_at: value.updated_at, + overrides: clone(value.overrides), + insertions: clone(value.insertions), + }; +} + +function placeholders(value) { + return Array.from(String(value).matchAll(placeholderPattern), (match) => match[1]).sort(); +} + +function samePlaceholders(left, right) { + return JSON.stringify(placeholders(left)) === JSON.stringify(placeholders(right)); +} + +function effectiveText(copyId) { + return draft.overrides[copyId]?.text ?? catalog.items[copyId].text; +} + +function insertionElementId(id) { + return `ui-copy-insertion-${id}`; +} + +function applyInsertions() { + document.querySelectorAll(".ui-copy-custom-insertion").forEach((element) => element.remove()); + for (const insertion of draft.insertions) { + const anchor = document.querySelector(`[data-ui-copy-anchor-id="${CSS.escape(insertion.anchor_id)}"]`); + if (!anchor) continue; + const element = document.createElement("p"); + element.id = insertionElementId(insertion.id); + element.className = "ui-copy-custom-insertion"; + element.dataset.uiCopyInsertionId = insertion.id; + element.textContent = insertion.text; + element.style.fontSize = `${insertion.font_size_px}px`; + anchor.insertAdjacentElement(insertion.placement === "before" ? "beforebegin" : "afterend", element); + } +} + +function updateSelectionOutline() { + document.querySelectorAll(".ui-copy-selected").forEach((element) => element.classList.remove("ui-copy-selected")); + if (selectedCopyId) { + runtime.uiCopyRecords(selectedCopyId).forEach((record) => record.target.classList.add("ui-copy-selected")); + } else if (selectedInsertionId) { + document.getElementById(insertionElementId(selectedInsertionId))?.classList.add("ui-copy-selected"); + } +} + +function renderList() { + const query = normalizedText(elements.search.value).toLocaleLowerCase(); + const entries = Object.entries(catalog.items) + .filter(([copyId, item]) => `${copyId} ${item.text} ${effectiveText(copyId)} ${item.scope}`.toLocaleLowerCase().includes(query)) + .map(([copyId, item]) => ({ type: "copy", id: copyId, text: effectiveText(copyId), meta: `${item.scope} · ${item.render.type}` })); + for (const insertion of draft.insertions) { + if (`${insertion.id} ${insertion.text}`.toLocaleLowerCase().includes(query)) { + entries.push({ type: "insertion", id: insertion.id, text: insertion.text, meta: "新增提示" }); + } + } + elements.list.replaceChildren(); + for (const entry of entries) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-copy-list-item"; + button.classList.toggle("active", entry.id === selectedCopyId || entry.id === selectedInsertionId); + const text = document.createElement("span"); + text.className = "ui-copy-list-text"; + text.textContent = entry.text || "(已隐藏)"; + const meta = document.createElement("span"); + meta.className = "ui-copy-list-meta"; + meta.textContent = entry.type === "copy" && !runtime.uiCopyRecords(entry.id).length + ? `${entry.meta} · 当前未显示` + : entry.meta; + button.append(text, meta); + button.addEventListener("click", () => entry.type === "copy" ? selectCopy(entry.id) : selectInsertion(entry.id)); + elements.list.append(button); + } + elements.count.textContent = String(entries.length); +} + +function resetCopyFields(copyId) { + const item = catalog.items[copyId]; + const override = draft.overrides[copyId] || {}; + elements.text.value = override.text ?? item.text; + elements.font.value = override.font_size_px ?? ""; + const effectiveHidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(item.render.default_hidden); + elements.delete.textContent = effectiveHidden ? "恢复显示这行文字" : "删除这行文字"; + elements.delete.disabled = item.kind === "html_attribute"; + elements.restore.disabled = !draft.overrides[copyId]; + elements.fontLabel.hidden = item.kind === "html_attribute"; + elements.delete.hidden = item.kind === "html_attribute"; + const tokens = placeholders(item.text); + elements.placeholderNote.hidden = tokens.length === 0; + elements.placeholderNote.textContent = tokens.length ? `必须原样保留变量:${tokens.map((token) => `{${token}}`).join("、")}` : ""; + elements.deleteConfirmation.hidden = true; + showError(elements.formError); +} + +function selectCopy(copyId) { + if (!catalog.items[copyId]) return; + selectedCopyId = copyId; + selectedInsertionId = null; + selectedAnchorId = null; + const item = catalog.items[copyId]; + elements.copyTitle.textContent = copyId; + elements.copySource.textContent = `${item.scope} · ${item.source}`; + resetCopyFields(copyId); + elements.copyForm.hidden = false; + elements.insertionForm.hidden = true; + document.documentElement.dataset.uiCopyEditorSelection = "true"; + updateSelectionOutline(); + renderList(); + elements.text.focus(); +} + +function selectInsertion(insertionId) { + const insertion = draft.insertions.find((item) => item.id === insertionId); + if (!insertion) return; + selectedCopyId = null; + selectedAnchorId = insertion.anchor_id; + selectedInsertionId = insertionId; + elements.insertionTitle.textContent = "修改新增提示"; + elements.insertionSource.textContent = `${insertion.workspace_id} · ${insertion.anchor_id}`; + elements.insertionText.value = insertion.text; + elements.insertionSize.value = insertion.font_size_px; + elements.insertionPlacement.value = insertion.placement; + elements.insertionDelete.hidden = false; + elements.insertionDeleteConfirmation.hidden = true; + showError(elements.insertionError); + elements.copyForm.hidden = true; + elements.insertionForm.hidden = false; + document.documentElement.dataset.uiCopyEditorSelection = "true"; + updateSelectionOutline(); + renderList(); +} + +function selectAnchor(anchorId) { + const anchor = catalog.anchors[anchorId]; + if (!anchor) return; + selectedCopyId = null; + selectedInsertionId = null; + selectedAnchorId = anchorId; + elements.insertionTitle.textContent = "新增提示文字"; + elements.insertionSource.textContent = `${anchor.scope} · ${anchor.selector}`; + elements.insertionText.value = ""; + elements.insertionSize.value = "16"; + elements.insertionPlacement.value = "after"; + elements.insertionDelete.hidden = true; + elements.insertionDeleteConfirmation.hidden = true; + showError(elements.insertionError); + elements.copyForm.hidden = true; + elements.insertionForm.hidden = false; + document.documentElement.dataset.uiCopyEditorSelection = "true"; + updateSelectionOutline(); + renderList(); + elements.insertionText.focus(); +} + +function setMode(nextMode) { + mode = nextMode; + document.documentElement.dataset.uiCopyEditorMode = mode; + document.documentElement.dataset.uiCopyEditorSelection = String(Boolean(selectedCopyId || selectedInsertionId || selectedAnchorId)); + panel.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode)); + document.querySelectorAll("[data-ui-copy-anchor-id]").forEach((element) => { + element.classList.toggle("ui-copy-anchor-candidate", mode === "insert"); + }); + if (mode === "select") setStatus("点击页面中带轮廓的程序文案;原控件不会被触发"); + else if (mode === "insert") setStatus("点击页面中的虚线区域,选择新增提示位置"); + else setStatus("浏览模式:页面功能保持可用"); +} + +async function commitCandidate(candidate, { recordHistory = true, afterSave = null } = {}) { + if (saveBusy) return false; + saveBusy = true; + setStatus("正在保存,页面尚未改变…", "saving"); + const previous = writableDocument(draft); + try { + const response = await fetch(bootstrap.api_url, { + method: "PUT", + headers: { "Content-Type": "application/json", "If-Match": etag }, + body: JSON.stringify(writableDocument(candidate)), + }); + if (!response.ok) { + let message = `保存失败(${response.status})`; + try { + const detail = await response.json(); + if (detail?.detail) message = detail.detail; + } catch (_error) {} + if (response.status === 409) setStatus(`草稿冲突:${message}。页面和输入内容均未改变,请刷新后重试。`, "conflict"); + else setStatus(`${message}。页面和输入内容均未改变,可直接重试。`, "error"); + return false; + } + const saved = await response.json(); + etag = response.headers.get("ETag") || `"${saved.revision}"`; + if (recordHistory) history.push(previous); + draft = saved; + runtime.setUiCopyDraft(draft); + applyInsertions(); + afterSave?.(); + elements.undo.disabled = history.length === 0; + updateSelectionOutline(); + renderList(); + setStatus(`已保存修订 ${draft.revision},页面已更新`, "saved"); + return true; + } catch (error) { + setStatus(`保存失败:${error.message}。页面和输入内容均未改变,可直接重试。`, "error"); + return false; + } finally { + saveBusy = false; + } +} + +function copyCandidate(copyId, override) { + const candidate = writableDocument(draft); + if (Object.keys(override).length) candidate.overrides[copyId] = override; + else delete candidate.overrides[copyId]; + return candidate; +} + +elements.copyForm.addEventListener("input", () => { + showError(elements.formError); + setStatus("修改暂存在编辑框中;点击“确定修改”前页面不会改变", "pending"); +}); + +elements.copyForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const copyId = selectedCopyId; + if (!copyId) return; + const item = catalog.items[copyId]; + const prior = draft.overrides[copyId] || {}; + const text = elements.text.value; + if (!text.trim()) { + resetCopyFields(copyId); + showError(elements.formError, "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。"); + setStatus("未保存:空文字已恢复,页面没有改变", "error"); + return; + } + if (!samePlaceholders(text, item.text)) { + showError(elements.formError, "变量占位符不能删除、增加或改名。"); + return; + } + const override = {}; + if (text !== item.text) override.text = text; + if (item.kind !== "html_attribute" && elements.font.value !== "") { + const size = Number(elements.font.value); + if (!Number.isInteger(size) || size < 8 || size > 64) { + showError(elements.formError, "字号必须是 8 到 64 之间的整数。"); + return; + } + override.font_size_px = size; + } + if (Object.hasOwn(prior, "hidden")) override.hidden = prior.hidden; + await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) }); +}); + +elements.restore.addEventListener("click", async () => { + if (!selectedCopyId) return; + const copyId = selectedCopyId; + await commitCandidate(copyCandidate(copyId, {}), { afterSave: () => resetCopyFields(copyId) }); +}); + +elements.delete.addEventListener("click", () => { + if (!selectedCopyId) return; + const item = catalog.items[selectedCopyId]; + const current = draft.overrides[selectedCopyId] || {}; + const effectiveHidden = Object.hasOwn(current, "hidden") ? current.hidden : Boolean(item.render.default_hidden); + if (effectiveHidden) { + const override = { ...current, hidden: false }; + void commitCandidate(copyCandidate(selectedCopyId, override), { afterSave: () => resetCopyFields(selectedCopyId) }); + return; + } + elements.deleteConfirmation.hidden = false; + elements.deleteConfirmation.querySelector("#ui-copy-delete-confirm").focus(); +}); +panel.querySelector("#ui-copy-delete-cancel").addEventListener("click", () => { elements.deleteConfirmation.hidden = true; }); +panel.querySelector("#ui-copy-delete-confirm").addEventListener("click", async () => { + if (!selectedCopyId) return; + const copyId = selectedCopyId; + const override = { ...(draft.overrides[copyId] || {}), hidden: true }; + await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) }); +}); + +elements.insertionForm.addEventListener("input", () => { + showError(elements.insertionError); + setStatus("新增提示的修改尚未确认;页面不会改变", "pending"); +}); + +elements.insertionForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!selectedAnchorId) return; + const text = elements.insertionText.value; + if (!text.trim()) { + const existing = draft.insertions.find((item) => item.id === selectedInsertionId); + if (existing) elements.insertionText.value = existing.text; + showError(elements.insertionError, existing + ? "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。" + : "文字内容不能留空,没有发送保存请求。"); + return; + } + const size = Number(elements.insertionSize.value); + if (!Number.isInteger(size) || size < 8 || size > 64) { + showError(elements.insertionError, "字号必须是 8 到 64 之间的整数。"); + return; + } + const anchor = catalog.anchors[selectedAnchorId]; + const insertion = { + id: selectedInsertionId || crypto.randomUUID(), + workspace_id: anchor.scope, + anchor_id: selectedAnchorId, + placement: elements.insertionPlacement.value, + text, + font_size_px: size, + }; + const candidate = writableDocument(draft); + const index = candidate.insertions.findIndex((item) => item.id === insertion.id); + if (index >= 0) candidate.insertions[index] = insertion; + else candidate.insertions.push(insertion); + await commitCandidate(candidate, { afterSave: () => selectInsertion(insertion.id) }); +}); + +elements.insertionDelete.addEventListener("click", () => { + elements.insertionDeleteConfirmation.hidden = false; + panel.querySelector("#ui-copy-insertion-delete-confirm").focus(); +}); +panel.querySelector("#ui-copy-insertion-delete-cancel").addEventListener("click", () => { elements.insertionDeleteConfirmation.hidden = true; }); +panel.querySelector("#ui-copy-insertion-delete-confirm").addEventListener("click", async () => { + if (!selectedInsertionId) return; + const candidate = writableDocument(draft); + candidate.insertions = candidate.insertions.filter((item) => item.id !== selectedInsertionId); + await commitCandidate(candidate, { + afterSave: () => { + selectedInsertionId = null; + selectedAnchorId = null; + elements.insertionForm.hidden = true; + document.documentElement.dataset.uiCopyEditorSelection = "false"; + }, + }); +}); + +elements.undo.addEventListener("click", async () => { + if (!history.length) return; + const target = clone(history[history.length - 1]); + target.revision = draft.revision; + target.updated_at = draft.updated_at; + target.base_catalog_digest = draft.base_catalog_digest; + const saved = await commitCandidate(target, { + recordHistory: false, + afterSave: () => { + history.pop(); + if (selectedCopyId) resetCopyFields(selectedCopyId); + if (selectedInsertionId && !draft.insertions.some((item) => item.id === selectedInsertionId)) { + selectedInsertionId = null; + elements.insertionForm.hidden = true; + } + }, + }); + if (saved) elements.undo.disabled = history.length === 0; +}); + +panel.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode))); +elements.search.addEventListener("input", renderList); + +document.addEventListener("click", (event) => { + if (panel.contains(event.target) || toggle.contains(event.target)) return; + if (mode === "select") { + const target = event.target.closest?.("[data-ui-copy-id]"); + if (!target) return; + event.preventDefault(); + event.stopImmediatePropagation(); + selectCopy(target.dataset.uiCopyId); + } else if (mode === "insert") { + const anchor = event.target.closest?.("[data-ui-copy-anchor-id]"); + if (!anchor) return; + event.preventDefault(); + event.stopImmediatePropagation(); + selectAnchor(anchor.dataset.uiCopyAnchorId); + } +}, true); + +function setPanelOpen(open) { + panel.hidden = !open; + toggle.setAttribute("aria-expanded", String(open)); + if (open) panel.querySelector("[data-mode].active")?.focus(); +} +toggle.addEventListener("click", () => setPanelOpen(panel.hidden)); +panel.querySelector("#ui-copy-editor-close").addEventListener("click", () => setPanelOpen(false)); + +bindStaticCatalog(); +bindAnchors(); +runtime.setUiCopyDraft(draft); +applyInsertions(); +renderList(); +setMode("browse"); diff --git a/核桃派软件源代码/app/ui_copy_editor/store.py b/核桃派软件源代码/app/ui_copy_editor/store.py new file mode 100644 index 0000000..00ea188 --- /dev/null +++ b/核桃派软件源代码/app/ui_copy_editor/store.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +from typing import Any +from uuid import UUID + +from app.persistence import atomic_write_bytes + + +SCHEMA_VERSION = 1 +FONT_SIZE_MIN = 8 +FONT_SIZE_MAX = 64 +TEXT_LENGTH_MAX = 2000 +INSERTION_LIMIT = 500 +DRAFT_FIELDS = frozenset({ + "schema_version", + "base_catalog_digest", + "revision", + "updated_at", + "overrides", + "insertions", +}) +OVERRIDE_FIELDS = frozenset({"text", "font_size_px", "hidden"}) +INSERTION_FIELDS = frozenset({ + "id", + "workspace_id", + "anchor_id", + "placement", + "text", + "font_size_px", +}) +PLACEHOLDER_PATTERN = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_]*)\}") + + +class UiCopyDraftError(RuntimeError): + pass + + +class UiCopyDraftConflictError(UiCopyDraftError): + pass + + +class UiCopyDraftValidationError(UiCopyDraftError): + pass + + +def _canonical_bytes(document: dict[str, Any]) -> bytes: + return (json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +class UiCopyDraftStore: + def __init__(self, data_dir: Path, catalog_path: Path) -> None: + self.directory = Path(data_dir) / "development" / "ui-copy-editor" + self.path = self.directory / "draft.json" + self.catalog_path = Path(catalog_path) + self.catalog_bytes = self.catalog_path.read_bytes() + self.catalog_digest = hashlib.sha256(self.catalog_bytes).hexdigest() + try: + self.catalog = json.loads(self.catalog_bytes) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UiCopyDraftValidationError("UI copy catalog is not valid UTF-8 JSON") from exc + self._validate_catalog() + self.document = self._load() + + @property + def items(self) -> dict[str, dict[str, Any]]: + return self.catalog["items"] + + @property + def anchors(self) -> dict[str, dict[str, Any]]: + return self.catalog["anchors"] + + def _validate_catalog(self) -> None: + if not isinstance(self.catalog, dict) or self.catalog.get("schema_version") != 2: + raise UiCopyDraftValidationError("UI copy catalog schema is unsupported") + if set(self.catalog) != {"schema_version", "items", "anchors", "insertions"}: + raise UiCopyDraftValidationError("UI copy catalog contains unknown fields") + if not isinstance(self.catalog["items"], dict) or not isinstance(self.catalog["anchors"], dict): + raise UiCopyDraftValidationError("UI copy catalog collections are invalid") + for copy_id, item in self.catalog["items"].items(): + if not isinstance(copy_id, str) or not isinstance(item, dict): + raise UiCopyDraftValidationError("UI copy catalog item is invalid") + render = item.get("render") + if not isinstance(render, dict) or render.get("type") not in { + "static_text", + "attribute", + "dynamic_template", + }: + raise UiCopyDraftValidationError(f"UI copy binding is invalid: {copy_id}") + if type(render.get("default_hidden", False)) is not bool: + raise UiCopyDraftValidationError(f"UI copy default hidden state is invalid: {copy_id}") + + def empty_document(self) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "base_catalog_digest": self.catalog_digest, + "revision": 0, + "updated_at": None, + "overrides": {}, + "insertions": [], + } + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return self.empty_document() + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UiCopyDraftValidationError("UI copy draft is not valid UTF-8 JSON") from exc + return self.validate_document(raw, allow_catalog_mismatch=True) + + def validate_document( + self, + raw: Any, + *, + allow_catalog_mismatch: bool = False, + ) -> dict[str, Any]: + if not isinstance(raw, dict): + raise UiCopyDraftValidationError("UI copy draft must be a JSON object") + unknown = set(raw) - DRAFT_FIELDS + missing = DRAFT_FIELDS - set(raw) + if unknown or missing: + raise UiCopyDraftValidationError("UI copy draft fields are invalid") + version = raw.get("schema_version") + if type(version) is not int or version != SCHEMA_VERSION: + raise UiCopyDraftValidationError("UI copy draft schema_version is unsupported") + digest = raw.get("base_catalog_digest") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise UiCopyDraftValidationError("UI copy draft catalog digest is invalid") + if not allow_catalog_mismatch and digest != self.catalog_digest: + raise UiCopyDraftConflictError("UI copy catalog changed; export the existing draft before editing") + revision = raw.get("revision") + if type(revision) is not int or revision < 0: + raise UiCopyDraftValidationError("UI copy draft revision is invalid") + updated_at = raw.get("updated_at") + if updated_at is not None and not isinstance(updated_at, str): + raise UiCopyDraftValidationError("UI copy draft updated_at is invalid") + overrides = self._validate_overrides(raw.get("overrides")) + insertions = self._validate_insertions(raw.get("insertions")) + return { + "schema_version": SCHEMA_VERSION, + "base_catalog_digest": digest, + "revision": revision, + "updated_at": updated_at, + "overrides": overrides, + "insertions": insertions, + } + + def _validate_overrides(self, raw: Any) -> dict[str, dict[str, Any]]: + if not isinstance(raw, dict): + raise UiCopyDraftValidationError("UI copy overrides must be an object") + checked: dict[str, dict[str, Any]] = {} + for copy_id, value in raw.items(): + if copy_id not in self.items: + raise UiCopyDraftValidationError(f"unknown UI copy id: {copy_id}") + if not isinstance(value, dict) or not value or set(value) - OVERRIDE_FIELDS: + raise UiCopyDraftValidationError(f"invalid UI copy override: {copy_id}") + item = self.items[copy_id] + normalized: dict[str, Any] = {} + if "text" in value: + text = value["text"] + if not isinstance(text, str) or not text or len(text) > TEXT_LENGTH_MAX: + raise UiCopyDraftValidationError(f"invalid UI copy text: {copy_id}") + expected = Counter(item.get("placeholders", [])) + if Counter(PLACEHOLDER_PATTERN.findall(text)) != expected: + raise UiCopyDraftValidationError(f"UI copy placeholders changed: {copy_id}") + normalized["text"] = text + if "font_size_px" in value: + size = value["font_size_px"] + if item.get("kind") == "html_attribute": + raise UiCopyDraftValidationError(f"attribute copy cannot have a font size: {copy_id}") + if type(size) is not int or not FONT_SIZE_MIN <= size <= FONT_SIZE_MAX: + raise UiCopyDraftValidationError(f"invalid UI copy font size: {copy_id}") + normalized["font_size_px"] = size + if "hidden" in value: + hidden = value["hidden"] + if item.get("kind") == "html_attribute": + raise UiCopyDraftValidationError(f"attribute copy cannot be hidden: {copy_id}") + if type(hidden) is not bool: + raise UiCopyDraftValidationError(f"invalid UI copy hidden state: {copy_id}") + normalized["hidden"] = hidden + checked[copy_id] = normalized + return checked + + def _validate_insertions(self, raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list) or len(raw) > INSERTION_LIMIT: + raise UiCopyDraftValidationError("UI copy insertions are invalid") + checked: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in raw: + if not isinstance(item, dict) or set(item) != INSERTION_FIELDS: + raise UiCopyDraftValidationError("UI copy insertion fields are invalid") + identifier = item["id"] + try: + UUID(str(identifier)) + except (ValueError, TypeError, AttributeError) as exc: + raise UiCopyDraftValidationError("UI copy insertion id is invalid") from exc + if identifier in seen: + raise UiCopyDraftValidationError("UI copy insertion id is duplicated") + seen.add(identifier) + anchor_id = item["anchor_id"] + anchor = self.anchors.get(anchor_id) + if anchor is None: + raise UiCopyDraftValidationError("UI copy insertion anchor is unknown") + workspace_id = item["workspace_id"] + if workspace_id != anchor.get("scope"): + raise UiCopyDraftValidationError("UI copy insertion workspace does not match its anchor") + placement = item["placement"] + if placement not in {"before", "after"}: + raise UiCopyDraftValidationError("UI copy insertion placement is invalid") + text = item["text"] + if not isinstance(text, str) or not text or len(text) > TEXT_LENGTH_MAX: + raise UiCopyDraftValidationError("UI copy insertion text is invalid") + size = item["font_size_px"] + if type(size) is not int or not FONT_SIZE_MIN <= size <= FONT_SIZE_MAX: + raise UiCopyDraftValidationError("UI copy insertion font size is invalid") + checked.append({ + "id": identifier, + "workspace_id": workspace_id, + "anchor_id": anchor_id, + "placement": placement, + "text": text, + "font_size_px": size, + }) + return checked + + def status_document(self) -> dict[str, Any]: + return { + **self.document, + "catalog_mismatch": self.document["base_catalog_digest"] != self.catalog_digest, + "catalog_digest": self.catalog_digest, + } + + def replace(self, raw: dict[str, Any], *, expected_revision: int) -> dict[str, Any]: + if self.document["base_catalog_digest"] != self.catalog_digest: + raise UiCopyDraftConflictError("UI copy catalog changed; export the existing draft before editing") + if expected_revision != self.document["revision"]: + raise UiCopyDraftConflictError("UI copy draft was modified by another page") + candidate = {**raw, "revision": expected_revision, "updated_at": self.document["updated_at"]} + checked = self.validate_document(candidate) + checked["revision"] = expected_revision + 1 + checked["updated_at"] = _now() + self.directory.mkdir(parents=True, exist_ok=True) + atomic_write_bytes(self.path, _canonical_bytes(checked)) + self.document = checked + return self.status_document() diff --git a/核桃派软件源代码/kernel/README.md b/核桃派软件源代码/kernel/README.md new file mode 100644 index 0000000..bf717cc --- /dev/null +++ b/核桃派软件源代码/kernel/README.md @@ -0,0 +1,55 @@ +# AXP313A / cpufreq candidate kernel + +The production application version is intentionally unchanged. This directory describes the +separately named `6.1.31-matrix-axp313a1` kernel used to repair the WalnutPi ZeroW PMIC binding. + +Source inputs are fixed to: + +- WalnutPi Linux branch `6.1.31`, commit `30ff3fd5cf45417622b447a12e7a947402ffe34d`. +- Upstream MFD commit `75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15`. +- Upstream regulator commit `60fd7eb89670d2636ac3156881acbd103c6eba6a`. +- Upstream identifier fix `4628b804555773daa982d9578d89fe04fcdde374`. + +The build used the official GitHub archive named +`walnutpi-linux-6.1.31-30ff3fd5.tar.gz`; its measured SHA-256 is +`8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f`. +The fixed archive is registered at +`发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5/` so the kernel can be +rebuilt without network access. It is a build input and is never copied into an IMG or OTA. + +The exact production files accepted by the browser and real HUB75 GPIO tests are registered at +`发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1/`. Future IMG exports install this +precompiled payload and do not rebuild the kernel unless patches, DTS, or kernel configuration +change. + +`scripts/build_axp313a_kernel.sh` accepts an already extracted source tree and a copy of the +running board's `/boot/config-6.1.31`. It never downloads dependencies. The three verbatim +upstream patches, the WalnutPi 6.1 reconciliation patch, and the board DTS patches are verified +against `patches/SHA256SUMS` before use. WalnutPi's branch already contains a partial AXP313A +backport, so the reconciliation patch asserts that baseline and completes the three pinned +upstream changes using the vendor kernel's older power-off API. The follow-up DTS patch keeps +the bootloader's DLDO1 VCC-IO rail explicitly always-on at 3.3V; DCDC3 remains at the measured +bootloader value of 1.10V so the candidate does not raise a non-CPU rail. The final local +build-only patch replaces a vendor UWE5622 Makefile's output-tree-relative include lookup with +Kbuild's `$(srctree)` path. It is required only so the unchanged board configuration can be +built in the isolated `O=` directory; it does not change driver logic. A second mechanical +build-only patch applies the same `$(srctree)` correction to the five Realtek module directories +identified in the first config audit. A third patch covers the separately enabled RTL8192EU +directory. A fourth covers the enabled `CONFIG_88XXAU=m`/RTL8812AU directory found by auditing +every vendor entry in the wireless parent Makefile, rather than only symbols prefixed `RTL`. +Its PHYDM include root is fixed in a separate final patch so each discovered build assumption +remains independently auditable. These patches do not change C source, feature selection, or +runtime paths. The RTL88x2CS `CONFIG_RTL8822C` branch has one additional `TopDIR` include fixed +to `$(srctree)` after the full module build reached that otherwise output-tree-relative file. + +The install path is deliberately separate from the vendor kernel: + +- `/boot/Image-matrix-axp313a1` +- `/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb` +- `/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb` +- `/lib/modules/6.1.31-matrix-axp313a1` + +The managed boot script consumes `/boot/matrix-kernel-good` once before each candidate boot. +Only `matrix-axp313a-health.service` recreates it after the kernel, AXP313A, cpufreq, application, +and HUB75 checks pass. If the candidate fails and reboots, the absent marker selects the original +`Image` and original DTB. Original boot files are retained as `.matrix-original` copies. diff --git a/核桃派软件源代码/kernel/patches/0003-walnutpi-6.1-axp313a-strict-backport.patch b/核桃派软件源代码/kernel/patches/0003-walnutpi-6.1-axp313a-strict-backport.patch new file mode 100644 index 0000000..964dcdd --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0003-walnutpi-6.1-axp313a-strict-backport.patch @@ -0,0 +1,140 @@ +From: Matrix Screen Controller maintainers +Subject: [PATCH] mfd: regulator: complete the AXP313A backport for WalnutPi 6.1 + +WalnutPi's 6.1.31 tree already carries an early, partial copy of the +AXP313A MFD and regulator changes. Complete that backport with the +upstream shutdown register and power-key resources, use the upstream +regulator identifiers and names, and include the later CONTROL spelling +fix from upstream commit 4628b804555773daa982d9578d89fe04fcdde374. + +The originating upstream commits are stored verbatim beside this patch: +75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15 and +60fd7eb89670d2636ac3156881acbd103c6eba6a. + +--- + drivers/mfd/axp20x.c | 24 ++++++++++++++++++------ + drivers/regulator/axp20x-regulator.c | 14 +++++++------- + include/linux/mfd/axp20x.h | 17 +++++++++-------- + 3 files changed, 34 insertions(+), 21 deletions(-) + +diff --git a/drivers/mfd/axp20x.c b/drivers/mfd/axp20x.c +--- a/drivers/mfd/axp20x.c ++++ b/drivers/mfd/axp20x.c +@@ -160,7 +160,8 @@ static const struct regmap_range axp313a_writeable_ranges[] = { + }; + + static const struct regmap_range axp313a_volatile_ranges[] = { +- regmap_reg_range(AXP313A_ON_INDICATE, AXP313A_IRQ_STATE), ++ regmap_reg_range(AXP313A_SHUTDOWN_CTRL, AXP313A_SHUTDOWN_CTRL), ++ regmap_reg_range(AXP313A_IRQ_STATE, AXP313A_IRQ_STATE), + }; + + static const struct regmap_access_table axp313a_writeable_table = { +@@ -255,6 +256,11 @@ static const struct resource axp809_pek_resources[] = { + DEFINE_RES_IRQ_NAMED(AXP809_IRQ_PEK_FAL_EDGE, "PEK_DBF"), + }; + ++static const struct resource axp313a_pek_resources[] = { ++ DEFINE_RES_IRQ_NAMED(AXP313A_IRQ_PEK_RIS_EDGE, "PEK_DBR"), ++ DEFINE_RES_IRQ_NAMED(AXP313A_IRQ_PEK_FAL_EDGE, "PEK_DBF"), ++}; ++ + static const struct regmap_config axp152_regmap_config = { + .reg_bits = 8, + .val_bits = 8, +@@ -735,8 +741,12 @@ static const struct mfd_cell axp152_cells[] = { + }; + + static struct mfd_cell axp313a_cells[] = { +- { +- .name = "axp20x-regulator", ++ { ++ .name = "axp20x-regulator", ++ }, { ++ .name = "axp313a-pek", ++ .num_resources = ARRAY_SIZE(axp313a_pek_resources), ++ .resources = axp313a_pek_resources, + }, + }; + +@@ -898,9 +909,14 @@ static struct axp20x_dev *axp20x_pm_power_off; + static void axp20x_power_off(void) + { ++ unsigned int shutdown_reg = AXP20X_OFF_CTRL; ++ + if (axp20x_pm_power_off->variant == AXP288_ID) + return; + +- regmap_write(axp20x_pm_power_off->regmap, AXP20X_OFF_CTRL, ++ if (axp20x_pm_power_off->variant == AXP313A_ID) ++ shutdown_reg = AXP313A_SHUTDOWN_CTRL; ++ ++ regmap_write(axp20x_pm_power_off->regmap, shutdown_reg, + AXP20X_OFF); + + /* Give capacitors etc. time to drain to avoid kernel panic msg. */ +diff --git a/drivers/regulator/axp20x-regulator.c b/drivers/regulator/axp20x-regulator.c +--- a/drivers/regulator/axp20x-regulator.c ++++ b/drivers/regulator/axp20x-regulator.c +@@ -666,20 +666,20 @@ static const struct regulator_desc axp313a_regulators[] = { + AXP_DESC_RANGES(AXP313A, DCDC1, "dcdc1", "vin1", + axp313a_dcdc1_ranges, AXP313A_DCDC1_NUM_VOLTAGES, +- AXP313A_DCDC1_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_DCDC1_CONTROL, AXP313A_DCDC_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(0)), + AXP_DESC_RANGES(AXP313A, DCDC2, "dcdc2", "vin2", + axp313a_dcdc2_ranges, AXP313A_DCDC23_NUM_VOLTAGES, +- AXP313A_DCDC2_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_DCDC2_CONTROL, AXP313A_DCDC_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(1)), + AXP_DESC_RANGES(AXP313A, DCDC3, "dcdc3", "vin3", + axp313a_dcdc3_ranges, AXP313A_DCDC23_NUM_VOLTAGES, +- AXP313A_DCDC3_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_DCDC3_CONTROL, AXP313A_DCDC_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(2)), +- AXP_DESC(AXP313A, LDO1, "ldo1", "vin1", 500, 3500, 100, +- AXP313A_ALDO1_CONRTOL, AXP313A_LDO_V_OUT_MASK, ++ AXP_DESC(AXP313A, ALDO1, "aldo1", "vin1", 500, 3500, 100, ++ AXP313A_ALDO1_CONTROL, AXP313A_LDO_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(3)), +- AXP_DESC(AXP313A, LDO2, "ldo2", "vin1", 500, 3500, 100, +- AXP313A_DLDO1_CONRTOL, AXP313A_LDO_V_OUT_MASK, ++ AXP_DESC(AXP313A, DLDO1, "dldo1", "vin1", 500, 3500, 100, ++ AXP313A_DLDO1_CONTROL, AXP313A_LDO_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(4)), + AXP_DESC_FIXED(AXP313A, RTC_LDO, "rtc-ldo", "vin1", 1800), + }; +diff --git a/include/linux/mfd/axp20x.h b/include/linux/mfd/axp20x.h +--- a/include/linux/mfd/axp20x.h ++++ b/include/linux/mfd/axp20x.h +@@ -94,11 +94,12 @@ enum axp20x_variants { + + #define AXP313A_ON_INDICATE 0x00 + #define AXP313A_OUTPUT_CONTROL 0x10 +-#define AXP313A_DCDC1_CONRTOL 0x13 +-#define AXP313A_DCDC2_CONRTOL 0x14 +-#define AXP313A_DCDC3_CONRTOL 0x15 +-#define AXP313A_ALDO1_CONRTOL 0x16 +-#define AXP313A_DLDO1_CONRTOL 0x17 ++#define AXP313A_DCDC1_CONTROL 0x13 ++#define AXP313A_DCDC2_CONTROL 0x14 ++#define AXP313A_DCDC3_CONTROL 0x15 ++#define AXP313A_ALDO1_CONTROL 0x16 ++#define AXP313A_DLDO1_CONTROL 0x17 ++#define AXP313A_SHUTDOWN_CTRL 0x1a + #define AXP313A_OUTPUT_MONITOR 0x1d + #define AXP313A_IRQ_EN 0x20 + #define AXP313A_IRQ_STATE 0x21 +@@ -338,8 +339,8 @@ enum { + AXP313A_DCDC1 = 0, + AXP313A_DCDC2, + AXP313A_DCDC3, +- AXP313A_LDO1, +- AXP313A_LDO2, ++ AXP313A_ALDO1, ++ AXP313A_DLDO1, + AXP313A_RTC_LDO, + AXP313A_REG_ID_MAX, + }; +-- +2.39.2 diff --git a/核桃派软件源代码/kernel/patches/0004-walnutpi-axp313a-dts.patch b/核桃派软件源代码/kernel/patches/0004-walnutpi-axp313a-dts.patch new file mode 100644 index 0000000..bbb1dfb --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0004-walnutpi-axp313a-dts.patch @@ -0,0 +1,54 @@ +diff --git a/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts b/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts +--- a/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts ++++ b/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts +@@ -306,26 +306,19 @@ + &r_i2c { + status = "okay"; + +- axp305: pmic@36 { +- compatible = "x-powers,axp305", "x-powers,axp805", +- "x-powers,axp806"; ++ axp313a: pmic@36 { ++ compatible = "x-powers,axp313a"; + interrupt-controller; + #interrupt-cells = <1>; + reg = <0x36>; + +- x-powers,self-working-mode; +- vina-supply = <®_vcc5v>; +- vinb-supply = <®_vcc5v>; +- vinc-supply = <®_vcc5v>; +- vind-supply = <®_vcc5v>; +- vine-supply = <®_vcc5v>; +- aldoin-supply = <®_vcc5v>; +- bldoin-supply = <®_vcc5v>; +- cldoin-supply = <®_vcc5v>; ++ vin1-supply = <®_vcc5v>; ++ vin2-supply = <®_vcc5v>; ++ vin3-supply = <®_vcc5v>; + + regulators { + +- reg_bldo1: bldo1 { ++ reg_bldo1: aldo1 { + regulator-always-on; + regulator-min-microvolt = <1800000>; + regulator-max-microvolt = <1800000>; +@@ -348,15 +341,11 @@ + + reg_dcdc3: dcdc3 { + regulator-always-on; +- regulator-min-microvolt = <1300000>; +- regulator-max-microvolt = <1300000>; ++ regulator-min-microvolt = <1100000>; ++ regulator-max-microvolt = <1100000>; + regulator-name = "vdd-dram"; + }; + +- +- sw { +- /* unused */ +- }; + }; + }; + }; diff --git a/核桃派软件源代码/kernel/patches/0005-walnutpi-axp313a-preserve-dldo1.patch b/核桃派软件源代码/kernel/patches/0005-walnutpi-axp313a-preserve-dldo1.patch new file mode 100644 index 0000000..a851b1a --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0005-walnutpi-axp313a-preserve-dldo1.patch @@ -0,0 +1,26 @@ +From: Matrix Screen Controller maintainers +Subject: [PATCH] arm64: dts: walnutpi: keep AXP313A DLDO1 at boot voltage + +The WalnutPi U-Boot board description keeps DLDO1 enabled at 3.3V for +VCC-IO. Describe that rail as always-on after switching the Linux PMIC +binding to AXP313A so regulator cleanup cannot disable it as unused. + +--- + arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts | 7 +++++++ + 1 file changed, 7 insertions(+) + +diff --git a/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts b/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts +--- a/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts ++++ b/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dts +@@ -328 +328,8 @@ ++ reg_dldo1: dldo1 { ++ regulator-always-on; ++ regulator-min-microvolt = <3300000>; ++ regulator-max-microvolt = <3300000>; ++ regulator-name = "vcc3v3"; ++ }; ++ +- reg_dcdc2: dcdc2 { ++ reg_dcdc2: dcdc2 { +-- +2.39.2 diff --git a/核桃派软件源代码/kernel/patches/0006-walnutpi-uwe5622-out-of-tree-build.patch b/核桃派软件源代码/kernel/patches/0006-walnutpi-uwe5622-out-of-tree-build.patch new file mode 100644 index 0000000..9ec8159 --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0006-walnutpi-uwe5622-out-of-tree-build.patch @@ -0,0 +1,54 @@ +From: Matrix Screen Controller maintainers +Subject: [PATCH] wifi: uwe5622: make the vendor include path O= safe + +The vendor Makefile resolves its source directory by changing into a path +relative to the output tree. That fails for the isolated O= build used by +the candidate kernel and drops the directory containing wcn_bus.h. Use +Kbuild's absolute srctree instead. This does not change driver behaviour. + +--- + drivers/net/wireless/rtl8189es/Makefile | 6 +++--- + drivers/net/wireless/rtl8189es/hal/phydm/phydm.mk | 2 +- + drivers/net/wireless/uwe5622/Makefile | 2 +- + drivers/net/wireless/uwe5622/unisocwcn/Makefile | 6 +++--- + 4 files changed, 8 insertions(+), 8 deletions(-) + +diff --git a/drivers/net/wireless/uwe5622/Makefile b/drivers/net/wireless/uwe5622/Makefile +--- a/drivers/net/wireless/uwe5622/Makefile ++++ b/drivers/net/wireless/uwe5622/Makefile +@@ -5 +5 @@ +-UNISOCWCN_DIR := $(shell cd $(src)/unisocwcn/ && /bin/pwd) ++UNISOCWCN_DIR := $(srctree)/drivers/net/wireless/uwe5622/unisocwcn + +diff --git a/drivers/net/wireless/uwe5622/unisocwcn/Makefile b/drivers/net/wireless/uwe5622/unisocwcn/Makefile +--- a/drivers/net/wireless/uwe5622/unisocwcn/Makefile ++++ b/drivers/net/wireless/uwe5622/unisocwcn/Makefile +@@ -132,3 +132,3 @@ +-ccflags-y += -I$(src)/include/ +-ccflags-y += -I$(src)/platform/ +-ccflags-y += -I$(src)/platform/rf/ ++ccflags-y += -I$(srctree)/drivers/net/wireless/uwe5622/unisocwcn/include/ ++ccflags-y += -I$(srctree)/drivers/net/wireless/uwe5622/unisocwcn/platform/ ++ccflags-y += -I$(srctree)/drivers/net/wireless/uwe5622/unisocwcn/platform/rf/ + +diff --git a/drivers/net/wireless/rtl8189es/Makefile b/drivers/net/wireless/rtl8189es/Makefile +--- a/drivers/net/wireless/rtl8189es/Makefile ++++ b/drivers/net/wireless/rtl8189es/Makefile +@@ -22 +22 @@ +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189es/include +@@ -252 +252 @@ +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189es/platform +@@ -255 +255 @@ +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189es/hal/btc + +diff --git a/drivers/net/wireless/rtl8189es/hal/phydm/phydm.mk b/drivers/net/wireless/rtl8189es/hal/phydm/phydm.mk +--- a/drivers/net/wireless/rtl8189es/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl8189es/hal/phydm/phydm.mk +@@ -1 +1 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189es/hal/phydm +-- +2.39.2 diff --git a/核桃派软件源代码/kernel/patches/0007-walnutpi-enabled-realtek-out-of-tree-build.patch b/核桃派软件源代码/kernel/patches/0007-walnutpi-enabled-realtek-out-of-tree-build.patch new file mode 100644 index 0000000..c02e4af --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0007-walnutpi-enabled-realtek-out-of-tree-build.patch @@ -0,0 +1,197 @@ +--- a/drivers/net/wireless/rtl8189fs/Makefile ++++ b/drivers/net/wireless/rtl8189fs/Makefile +@@ -20,7 +20,7 @@ + EXTRA_CFLAGS += -Wno-date-time # Fix compile error && warning on gcc 4.9 and later + endif + +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189fs/include + + EXTRA_CFLAGS += -DCONFIG_LITTLE_ENDIAN + EXTRA_CFLAGS += -DCONFIG_IOCTL_CFG80211 -DRTW_USE_CFG80211_STA_EVENT +@@ -250,10 +250,10 @@ + hal/led/hal_$(HCI_NAME)_led.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189fs/platform + _PLATFORM_FILES := platform/platform_ops.o + +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189fs/hal/btc + + ########### HAL_RTL8188E ################################# + ifeq ($(CONFIG_RTL8188E), y) +--- a/drivers/net/wireless/rtl8189fs/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl8189fs/hal/phydm/phydm.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189fs/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_antdiv.o\ +--- a/drivers/net/wireless/rtl8189fs/hal/phydm/sd4_phydm_2_kernel.mk ++++ b/drivers/net/wireless/rtl8189fs/hal/phydm/sd4_phydm_2_kernel.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8189fs/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_interface.o\ +--- a/drivers/net/wireless/rtl8723ds/Makefile ++++ b/drivers/net/wireless/rtl8723ds/Makefile +@@ -19,8 +19,8 @@ + EXTRA_CFLAGS += -Wno-date-time # Fix compile error && warning on gcc 4.9 and later + endif + +-EXTRA_CFLAGS += -I$(src)/include +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723ds/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723ds/hal/phydm + + EXTRA_LDFLAGS += --strip-debug + +@@ -232,11 +232,11 @@ + hal/phydm/phydm_ccx.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723ds/platform + _PLATFORM_FILES := platform/platform_ops.o + + ifeq ($(CONFIG_BT_COEXIST), y) +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723ds/hal/btc + _OUTSRC_FILES += hal/btc/HalBtc8192e1Ant.o \ + hal/btc/HalBtc8192e2Ant.o \ + hal/btc/HalBtc8723b1Ant.o \ +--- a/drivers/net/wireless/rtl8723du/Makefile ++++ b/drivers/net/wireless/rtl8723du/Makefile +@@ -6,7 +6,7 @@ + EXTRA_CFLAGS += -Wno-date-time # Fix compile error && warning on gcc 4.9 and later + endif + +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723du/include + + #EXTRA_LDFLAGS += --strip-debug + +@@ -59,10 +59,10 @@ + hal/hal_usb_led.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723du/platform + _PLATFORM_FILES := platform/platform_ops.o + +-EXTRA_CFLAGS += -I$(src)/hal ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723du/hal + + include $(TopDIR)/hal/phydm/phydm.mk + ########### HAL_RTL8723D ################################# +--- a/drivers/net/wireless/rtl8723du/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl8723du/hal/phydm/phydm.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8723du/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_antdiv.o\ +--- a/drivers/net/wireless/rtl8811cu/Makefile ++++ b/drivers/net/wireless/rtl8811cu/Makefile +@@ -28,7 +28,7 @@ + EXTRA_CFLAGS += -Wno-date-time # Fix compile error && warning on gcc 4.9 and later + endif + +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8811cu/include + + EXTRA_LDFLAGS += --strip-debug + +@@ -282,10 +282,10 @@ + hal/led/hal_$(HCI_NAME)_led.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8811cu/platform + _PLATFORM_FILES := platform/platform_ops.o + +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8811cu/hal/btc + + ########### HAL_RTL8188E ################################# + ifeq ($(CONFIG_RTL8188E), y) +@@ -2459,7 +2459,7 @@ + rtk_core += core/rtw_sdio.o + endif + +-EXTRA_CFLAGS += -I$(src)/core/crypto ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8811cu/core/crypto + rtk_core += \ + core/crypto/aes-internal.o \ + core/crypto/aes-internal-enc.o \ +--- a/drivers/net/wireless/rtl8811cu/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl8811cu/hal/phydm/phydm.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8811cu/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_antdiv.o\ +--- a/drivers/net/wireless/rtl8811cu/hal/phydm/sd4_phydm_2_kernel.mk ++++ b/drivers/net/wireless/rtl8811cu/hal/phydm/sd4_phydm_2_kernel.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8811cu/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_interface.o\ +--- a/drivers/net/wireless/rtl88x2cs/Makefile ++++ b/drivers/net/wireless/rtl88x2cs/Makefile +@@ -20,7 +20,7 @@ + EXTRA_CFLAGS += -Wno-date-time # Fix compile error && warning on gcc 4.9 and later + endif + +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl88x2cs/include + + EXTRA_LDFLAGS += --strip-debug + +@@ -268,10 +268,10 @@ + hal/led/hal_$(HCI_NAME)_led.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl88x2cs/platform + _PLATFORM_FILES := platform/platform_ops.o + +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl88x2cs/hal/btc + + ########### HAL_RTL8188E ################################# + ifeq ($(CONFIG_RTL8188E), y) +@@ -2370,7 +2370,7 @@ + rtk_core += core/rtw_sdio.o + endif + +-EXTRA_CFLAGS += -I$(src)/core/crypto ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl88x2cs/core/crypto + rtk_core += \ + core/crypto/aes-internal.o \ + core/crypto/aes-internal-enc.o \ +--- a/drivers/net/wireless/rtl88x2cs/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl88x2cs/hal/phydm/phydm.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl88x2cs/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_antdiv.o\ +--- a/drivers/net/wireless/rtl88x2cs/hal/phydm/sd4_phydm_2_kernel.mk ++++ b/drivers/net/wireless/rtl88x2cs/hal/phydm/sd4_phydm_2_kernel.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl88x2cs/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_interface.o\ diff --git a/核桃派软件源代码/kernel/patches/0008-walnutpi-rtl8192eu-out-of-tree-build.patch b/核桃派软件源代码/kernel/patches/0008-walnutpi-rtl8192eu-out-of-tree-build.patch new file mode 100644 index 0000000..1c39b4c --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0008-walnutpi-rtl8192eu-out-of-tree-build.patch @@ -0,0 +1,40 @@ +--- a/drivers/net/wireless/rtl8192eu/Makefile ++++ b/drivers/net/wireless/rtl8192eu/Makefile +@@ -11,7 +11,7 @@ + ifeq ($(GCC_VER_49),1) + endif + +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8192eu/include + + EXTRA_LDFLAGS += --strip-debug + +@@ -251,10 +251,10 @@ + hal/led/hal_$(HCI_NAME)_led.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8192eu/platform + _PLATFORM_FILES := platform/platform_ops.o + +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8192eu/hal/btc + + ########### HAL_RTL8188E ################################# + ifeq ($(CONFIG_RTL8188E), y) +--- a/drivers/net/wireless/rtl8192eu/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl8192eu/hal/phydm/phydm.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8192eu/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_antdiv.o\ +--- a/drivers/net/wireless/rtl8192eu/hal/phydm/sd4_phydm_2_kernel.mk ++++ b/drivers/net/wireless/rtl8192eu/hal/phydm/sd4_phydm_2_kernel.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8192eu/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_interface.o\ diff --git a/核桃派软件源代码/kernel/patches/0009-walnutpi-rtl8812au-out-of-tree-build.patch b/核桃派软件源代码/kernel/patches/0009-walnutpi-rtl8812au-out-of-tree-build.patch new file mode 100644 index 0000000..976e828 --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0009-walnutpi-rtl8812au-out-of-tree-build.patch @@ -0,0 +1,24 @@ +--- a/drivers/net/wireless/rtl8812au/Makefile ++++ b/drivers/net/wireless/rtl8812au/Makefile +@@ -20,7 +20,7 @@ + #EXTRA_CFLAGS += -Wno-date-time # Fix compile error && warning on gcc 4.9 and later + #endif + +-EXTRA_CFLAGS += -I$(src)/include ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8812au/include + EXTRA_LDFLAGS += --strip-all -O3 + + ########################## WIFI IC ############################ +@@ -230,10 +230,10 @@ + hal/led/hal_$(HCI_NAME)_led.o + + +-EXTRA_CFLAGS += -I$(src)/platform ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8812au/platform + _PLATFORM_FILES := platform/platform_ops.o + +-EXTRA_CFLAGS += -I$(src)/hal/btc ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8812au/hal/btc + + ########### HAL_RTL8188E ################################# + ifeq ($(CONFIG_RTL8188E), y) diff --git a/核桃派软件源代码/kernel/patches/0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch b/核桃派软件源代码/kernel/patches/0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch new file mode 100644 index 0000000..44394c1 --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch @@ -0,0 +1,16 @@ +--- a/drivers/net/wireless/rtl8812au/hal/phydm/phydm.mk ++++ b/drivers/net/wireless/rtl8812au/hal/phydm/phydm.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8812au/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_antdiv.o\ +--- a/drivers/net/wireless/rtl8812au/hal/phydm/sd4_phydm_2_kernel.mk ++++ b/drivers/net/wireless/rtl8812au/hal/phydm/sd4_phydm_2_kernel.mk +@@ -1,4 +1,4 @@ +-EXTRA_CFLAGS += -I$(src)/hal/phydm ++EXTRA_CFLAGS += -I$(srctree)/drivers/net/wireless/rtl8812au/hal/phydm + + _PHYDM_FILES := hal/phydm/phydm_debug.o \ + hal/phydm/phydm_interface.o\ diff --git a/核桃派软件源代码/kernel/patches/0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch b/核桃派软件源代码/kernel/patches/0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch new file mode 100644 index 0000000..a8b8648 --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch @@ -0,0 +1,11 @@ +--- a/drivers/net/wireless/rtl88x2cs/Makefile ++++ b/drivers/net/wireless/rtl88x2cs/Makefile +@@ -2316,7 +2316,7 @@ + + ########### HAL_RTL8822C ################################# + ifeq ($(CONFIG_RTL8822C), y) +-include $(TopDIR)/drivers/net/wireless/rtl88x2cs/rtl8822c.mk ++include $(srctree)/drivers/net/wireless/rtl88x2cs/rtl8822c.mk + EXTRA_CFLAGS += -DCONFIG_RTW_IOCTL_SET_COUNTRY + endif + diff --git a/核桃派软件源代码/kernel/patches/0012-walnutpi-emmc-axp313a-dts.patch b/核桃派软件源代码/kernel/patches/0012-walnutpi-emmc-axp313a-dts.patch new file mode 100644 index 0000000..474c74a --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0012-walnutpi-emmc-axp313a-dts.patch @@ -0,0 +1,81 @@ +From: Matrix Screen Controller maintainers +Subject: [PATCH] arm64: dts: walnutpi: describe AXP313A on eMMC variant + +The vendor eMMC board DTS duplicates the PMIC description instead of +including the non-eMMC board DTS. Apply the same AXP313A regulator mapping +to both variants so either boot-device layout gets the cpufreq and rail +safety fix. + +--- + .../sun50i-h616-walnutpi-1b-emmc.dts | 34 ++++++++----------- + 1 file changed, 15 insertions(+), 19 deletions(-) + +diff --git a/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dts b/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dts +--- a/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dts ++++ b/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dts +@@ -306,32 +306,32 @@ + &r_i2c { + status = "okay"; + +- axp305: pmic@36 { +- compatible = "x-powers,axp305", "x-powers,axp805", +- "x-powers,axp806"; ++ axp313a: pmic@36 { ++ compatible = "x-powers,axp313a"; + interrupt-controller; + #interrupt-cells = <1>; + reg = <0x36>; + +- x-powers,self-working-mode; +- vina-supply = <®_vcc5v>; +- vinb-supply = <®_vcc5v>; +- vinc-supply = <®_vcc5v>; +- vind-supply = <®_vcc5v>; +- vine-supply = <®_vcc5v>; +- aldoin-supply = <®_vcc5v>; +- bldoin-supply = <®_vcc5v>; +- cldoin-supply = <®_vcc5v>; ++ vin1-supply = <®_vcc5v>; ++ vin2-supply = <®_vcc5v>; ++ vin3-supply = <®_vcc5v>; + + regulators { + +- reg_bldo1: bldo1 { ++ reg_bldo1: aldo1 { + regulator-always-on; + regulator-min-microvolt = <1800000>; + regulator-max-microvolt = <1800000>; + regulator-name = "vcc1v8"; + }; + ++ reg_dldo1: dldo1 { ++ regulator-always-on; ++ regulator-min-microvolt = <3300000>; ++ regulator-max-microvolt = <3300000>; ++ regulator-name = "vcc3v3"; ++ }; ++ + reg_dcdc2: dcdc2 { + regulator-always-on; + regulator-min-microvolt = <810000>; +@@ -348,15 +348,11 @@ + + reg_dcdc3: dcdc3 { + regulator-always-on; +- regulator-min-microvolt = <1300000>; +- regulator-max-microvolt = <1300000>; ++ regulator-min-microvolt = <1100000>; ++ regulator-max-microvolt = <1100000>; + regulator-name = "vdd-dram"; + }; + +- +- sw { +- /* unused */ +- }; + }; + }; + }; +-- +2.39.2 diff --git a/核桃派软件源代码/kernel/patches/0013-walnutpi-axp313a-fixed-dcdc-frequency.patch b/核桃派软件源代码/kernel/patches/0013-walnutpi-axp313a-fixed-dcdc-frequency.patch new file mode 100644 index 0000000..775cba5 --- /dev/null +++ b/核桃派软件源代码/kernel/patches/0013-walnutpi-axp313a-fixed-dcdc-frequency.patch @@ -0,0 +1,30 @@ +From: Matrix Screen Controller maintainers +Subject: [PATCH] regulator: axp20x: accept absent DCDC frequency on AXP313A + +The vendor 6.1.31 tree contains an early AXP313A implementation which +requires dcdcfreq to equal 3000000. The parser passes zero when the +x-powers,dcdc-freq property is absent, so that implementation rejects the +valid binding and logs -EINVAL during every boot. + +AXP313A has a fixed 3 MHz switching frequency and the upstream binding +disallows the property. Match the final upstream behaviour: accept the +zero value representing an absent property and reject any configured value. + +--- + drivers/regulator/axp20x-regulator.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/drivers/regulator/axp20x-regulator.c b/drivers/regulator/axp20x-regulator.c +--- a/drivers/regulator/axp20x-regulator.c ++++ b/drivers/regulator/axp20x-regulator.c +@@ -1090,7 +1090,7 @@ static int axp20x_set_dcdc_freq(struct platform_device *pdev, u32 dcdcfreq) + case AXP313A_ID: + /* The DCDC PWM frequency seems to be fixed to 3 MHz. */ +- if (dcdcfreq != 3000000) { ++ if (dcdcfreq != 0) { + dev_err(&pdev->dev, + "DCDC frequency on AXP313a is fixed to 3 MHz.\n"); + return -EINVAL; + } +-- +2.39.2 diff --git a/核桃派软件源代码/kernel/patches/4628b804555773daa982d9578d89fe04fcdde374.patch b/核桃派软件源代码/kernel/patches/4628b804555773daa982d9578d89fe04fcdde374.patch new file mode 100644 index 0000000..7192dc5 --- /dev/null +++ b/核桃派软件源代码/kernel/patches/4628b804555773daa982d9578d89fe04fcdde374.patch @@ -0,0 +1,73 @@ +From 4628b804555773daa982d9578d89fe04fcdde374 Mon Sep 17 00:00:00 2001 +From: Andre Przywara +Date: Sun, 10 Mar 2024 01:02:08 +0000 +Subject: [PATCH] regulator: axp20x: fix typo-ed identifier + +The registers to set the X-Powers AXP313 regulators are of course +"CONTROL" registers, not "CONRTOL" ones. + +Fix the typo in the header file and in its users. No functional change. + +Signed-off-by: Andre Przywara +Acked-by: Jernej Skrabec +Tested-by: Ryan Walklin +Link: https://lore.kernel.org/r/20240310010211.28653-2-andre.przywara@arm.com +Signed-off-by: Lee Jones +--- + drivers/regulator/axp20x-regulator.c | 10 +++++----- + include/linux/mfd/axp20x.h | 10 +++++----- + 2 files changed, 10 insertions(+), 10 deletions(-) + +diff --git a/drivers/regulator/axp20x-regulator.c b/drivers/regulator/axp20x-regulator.c +index c657820b0bbb56..7bcc2d508b65a3 100644 +--- a/drivers/regulator/axp20x-regulator.c ++++ b/drivers/regulator/axp20x-regulator.c +@@ -733,21 +733,21 @@ static const struct linear_range axp313a_dcdc3_ranges[] = { + static const struct regulator_desc axp313a_regulators[] = { + AXP_DESC_RANGES(AXP313A, DCDC1, "dcdc1", "vin1", + axp313a_dcdc1_ranges, AXP313A_DCDC1_NUM_VOLTAGES, +- AXP313A_DCDC1_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_DCDC1_CONTROL, AXP313A_DCDC_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(0)), + AXP_DESC_RANGES(AXP313A, DCDC2, "dcdc2", "vin2", + axp313a_dcdc2_ranges, AXP313A_DCDC23_NUM_VOLTAGES, +- AXP313A_DCDC2_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_DCDC2_CONTROL, AXP313A_DCDC_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(1)), + AXP_DESC_RANGES(AXP313A, DCDC3, "dcdc3", "vin3", + axp313a_dcdc3_ranges, AXP313A_DCDC23_NUM_VOLTAGES, +- AXP313A_DCDC3_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_DCDC3_CONTROL, AXP313A_DCDC_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(2)), + AXP_DESC(AXP313A, ALDO1, "aldo1", "vin1", 500, 3500, 100, +- AXP313A_ALDO1_CONRTOL, AXP313A_LDO_V_OUT_MASK, ++ AXP313A_ALDO1_CONTROL, AXP313A_LDO_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(3)), + AXP_DESC(AXP313A, DLDO1, "dldo1", "vin1", 500, 3500, 100, +- AXP313A_DLDO1_CONRTOL, AXP313A_LDO_V_OUT_MASK, ++ AXP313A_DLDO1_CONTROL, AXP313A_LDO_V_OUT_MASK, + AXP313A_OUTPUT_CONTROL, BIT(4)), + AXP_DESC_FIXED(AXP313A, RTC_LDO, "rtc-ldo", "vin1", 1800), + }; +diff --git a/include/linux/mfd/axp20x.h b/include/linux/mfd/axp20x.h +index f1755163dd9f14..9b2b7ac2107f44 100644 +--- a/include/linux/mfd/axp20x.h ++++ b/include/linux/mfd/axp20x.h +@@ -104,11 +104,11 @@ enum axp20x_variants { + + #define AXP313A_ON_INDICATE 0x00 + #define AXP313A_OUTPUT_CONTROL 0x10 +-#define AXP313A_DCDC1_CONRTOL 0x13 +-#define AXP313A_DCDC2_CONRTOL 0x14 +-#define AXP313A_DCDC3_CONRTOL 0x15 +-#define AXP313A_ALDO1_CONRTOL 0x16 +-#define AXP313A_DLDO1_CONRTOL 0x17 ++#define AXP313A_DCDC1_CONTROL 0x13 ++#define AXP313A_DCDC2_CONTROL 0x14 ++#define AXP313A_DCDC3_CONTROL 0x15 ++#define AXP313A_ALDO1_CONTROL 0x16 ++#define AXP313A_DLDO1_CONTROL 0x17 + #define AXP313A_SHUTDOWN_CTRL 0x1a + #define AXP313A_IRQ_EN 0x20 + #define AXP313A_IRQ_STATE 0x21 + diff --git a/核桃派软件源代码/kernel/patches/60fd7eb89670d2636ac3156881acbd103c6eba6a.patch b/核桃派软件源代码/kernel/patches/60fd7eb89670d2636ac3156881acbd103c6eba6a.patch new file mode 100644 index 0000000..464873a --- /dev/null +++ b/核桃派软件源代码/kernel/patches/60fd7eb89670d2636ac3156881acbd103c6eba6a.patch @@ -0,0 +1,132 @@ +From 60fd7eb89670d2636ac3156881acbd103c6eba6a Mon Sep 17 00:00:00 2001 +From: Martin Botka +Date: Wed, 24 May 2023 01:00:11 +0100 +Subject: [PATCH] regulator: axp20x: Add support for AXP313a variant + +The AXP313a is your typical I2C controlled PMIC, although in a lighter +fashion compared to the other X-Powers PMICs: it has only three DCDC +rails, three LDOs, and no battery charging support. + +The AXP313a datasheet does not describe a register to change the DCDC +switching frequency, and talks of it being fixed at 3 MHz. Check that +the property allowing to change that frequency is absent from the DT, +and bail out otherwise. + +The third LDO, RTCLDO, is fixed, and cannot even be turned on or off, +programmatically. On top of that, its voltage is customisable (either +1.8V or 3.3V), which we cannot describe easily using the existing +regulator wrapper functions. This should be fixed properly, using +regulator-{min,max}-microvolt in the DT, but this requires more changes +to the code. As some other PMICs (AXP2xx, AXP803) seem to paper over the +same problem as well, we follow suit here and pretend it's a fixed 1.8V +regulator. A proper fix can follow later. The BSP code seems to ignore +this regulator altogether. + +Describe the AXP313A's voltage settings and switch registers, how the +voltages are encoded, and connect this to the MFD device via its +regulator ID. + +Signed-off-by: Martin Botka +Signed-off-by: Andre Przywara +Reviewed-by: Chen-Yu Tsai +Reviewed-by: Mark Brown +Tested-by: Shengyu Qu +Link: https://lore.kernel.org/r/20230524000012.15028-3-andre.przywara@arm.com +Signed-off-by: Mark Brown +--- + drivers/regulator/axp20x-regulator.c | 60 ++++++++++++++++++++++++++++ + 1 file changed, 60 insertions(+) + +diff --git a/drivers/regulator/axp20x-regulator.c b/drivers/regulator/axp20x-regulator.c +index 943172b1972262..db77c72d39a0fb 100644 +--- a/drivers/regulator/axp20x-regulator.c ++++ b/drivers/regulator/axp20x-regulator.c +@@ -134,6 +134,11 @@ + #define AXP22X_PWR_OUT_DLDO4_MASK BIT_MASK(6) + #define AXP22X_PWR_OUT_ALDO3_MASK BIT_MASK(7) + ++#define AXP313A_DCDC1_NUM_VOLTAGES 107 ++#define AXP313A_DCDC23_NUM_VOLTAGES 88 ++#define AXP313A_DCDC_V_OUT_MASK GENMASK(6, 0) ++#define AXP313A_LDO_V_OUT_MASK GENMASK(4, 0) ++ + #define AXP803_PWR_OUT_DCDC1_MASK BIT_MASK(0) + #define AXP803_PWR_OUT_DCDC2_MASK BIT_MASK(1) + #define AXP803_PWR_OUT_DCDC3_MASK BIT_MASK(2) +@@ -638,6 +643,48 @@ static const struct regulator_desc axp22x_drivevbus_regulator = { + .ops = &axp20x_ops_sw, + }; + ++static const struct linear_range axp313a_dcdc1_ranges[] = { ++ REGULATOR_LINEAR_RANGE(500000, 0, 70, 10000), ++ REGULATOR_LINEAR_RANGE(1220000, 71, 87, 20000), ++ REGULATOR_LINEAR_RANGE(1600000, 88, 106, 100000), ++}; ++ ++static const struct linear_range axp313a_dcdc2_ranges[] = { ++ REGULATOR_LINEAR_RANGE(500000, 0, 70, 10000), ++ REGULATOR_LINEAR_RANGE(1220000, 71, 87, 20000), ++}; ++ ++/* ++ * This is deviating from the datasheet. The values here are taken from the ++ * BSP driver and have been confirmed by measurements. ++ */ ++static const struct linear_range axp313a_dcdc3_ranges[] = { ++ REGULATOR_LINEAR_RANGE(500000, 0, 70, 10000), ++ REGULATOR_LINEAR_RANGE(1220000, 71, 102, 20000), ++}; ++ ++static const struct regulator_desc axp313a_regulators[] = { ++ AXP_DESC_RANGES(AXP313A, DCDC1, "dcdc1", "vin1", ++ axp313a_dcdc1_ranges, AXP313A_DCDC1_NUM_VOLTAGES, ++ AXP313A_DCDC1_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_OUTPUT_CONTROL, BIT(0)), ++ AXP_DESC_RANGES(AXP313A, DCDC2, "dcdc2", "vin2", ++ axp313a_dcdc2_ranges, AXP313A_DCDC23_NUM_VOLTAGES, ++ AXP313A_DCDC2_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_OUTPUT_CONTROL, BIT(1)), ++ AXP_DESC_RANGES(AXP313A, DCDC3, "dcdc3", "vin3", ++ axp313a_dcdc3_ranges, AXP313A_DCDC23_NUM_VOLTAGES, ++ AXP313A_DCDC3_CONRTOL, AXP313A_DCDC_V_OUT_MASK, ++ AXP313A_OUTPUT_CONTROL, BIT(2)), ++ AXP_DESC(AXP313A, ALDO1, "aldo1", "vin1", 500, 3500, 100, ++ AXP313A_ALDO1_CONRTOL, AXP313A_LDO_V_OUT_MASK, ++ AXP313A_OUTPUT_CONTROL, BIT(3)), ++ AXP_DESC(AXP313A, DLDO1, "dldo1", "vin1", 500, 3500, 100, ++ AXP313A_DLDO1_CONRTOL, AXP313A_LDO_V_OUT_MASK, ++ AXP313A_OUTPUT_CONTROL, BIT(4)), ++ AXP_DESC_FIXED(AXP313A, RTC_LDO, "rtc-ldo", "vin1", 1800), ++}; ++ + /* DCDC ranges shared with AXP813 */ + static const struct linear_range axp803_dcdc234_ranges[] = { + REGULATOR_LINEAR_RANGE(500000, +@@ -1040,6 +1087,15 @@ static int axp20x_set_dcdc_freq(struct platform_device *pdev, u32 dcdcfreq) + def = 3000; + step = 150; + break; ++ case AXP313A_ID: ++ /* The DCDC PWM frequency seems to be fixed to 3 MHz. */ ++ if (dcdcfreq != 0) { ++ dev_err(&pdev->dev, ++ "DCDC frequency on AXP313a is fixed to 3 MHz.\n"); ++ return -EINVAL; ++ } ++ ++ return 0; + default: + dev_err(&pdev->dev, + "Setting DCDC frequency for unsupported AXP variant\n"); +@@ -1232,6 +1288,10 @@ static int axp20x_regulator_probe(struct platform_device *pdev) + drivevbus = of_property_read_bool(pdev->dev.parent->of_node, + "x-powers,drive-vbus-en"); + break; ++ case AXP313A_ID: ++ regulators = axp313a_regulators; ++ nregulators = AXP313A_REG_ID_MAX; ++ break; + case AXP803_ID: + regulators = axp803_regulators; + nregulators = AXP803_REG_ID_MAX; + diff --git a/核桃派软件源代码/kernel/patches/75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch b/核桃派软件源代码/kernel/patches/75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch new file mode 100644 index 0000000..5fee70a --- /dev/null +++ b/核桃派软件源代码/kernel/patches/75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch @@ -0,0 +1,263 @@ +From 75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15 Mon Sep 17 00:00:00 2001 +From: Martin Botka +Date: Wed, 24 May 2023 01:00:10 +0100 +Subject: [PATCH] mfd: axp20x: Add support for AXP313a PMIC + +The AXP313a is a PMIC chip produced by X-Powers, it can be connected via +an I2C bus. +The name AXP1530 seems to appear as well, and this is what is used in +the BSP driver. From all we know it's the same chip, just a different +name. However we have only seen AXP313a chips in the wild, so go with +this name. + +Compared to the other AXP PMICs it's a rather simple affair: just three +DCDC converters, three LDOs, and no battery charging support. + +Describe the regmap and the MFD bits, along with the registers exposed +via I2C. Aside from the various regulators, also describe the power key +interrupts, and adjust the shutdown handler routine to use a different +register than the other PMICs. +Eventually advertise the device using the new compatible string. + +Signed-off-by: Martin Botka +Signed-off-by: Andre Przywara +Reviewed-by: Chen-Yu Tsai +Link: https://lore.kernel.org/r/20230524000012.15028-2-andre.przywara@arm.com +Signed-off-by: Lee Jones +--- + drivers/mfd/axp20x-i2c.c | 2 + + drivers/mfd/axp20x.c | 78 +++++++++++++++++++++++++++++++++++++- + include/linux/mfd/axp20x.h | 32 ++++++++++++++++ + 3 files changed, 111 insertions(+), 1 deletion(-) + +diff --git a/drivers/mfd/axp20x-i2c.c b/drivers/mfd/axp20x-i2c.c +index b4f5cb457117ee..a49e5e217554ae 100644 +--- a/drivers/mfd/axp20x-i2c.c ++++ b/drivers/mfd/axp20x-i2c.c +@@ -63,6 +63,7 @@ static const struct of_device_id axp20x_i2c_of_match[] = { + { .compatible = "x-powers,axp209", .data = (void *)AXP209_ID }, + { .compatible = "x-powers,axp221", .data = (void *)AXP221_ID }, + { .compatible = "x-powers,axp223", .data = (void *)AXP223_ID }, ++ { .compatible = "x-powers,axp313a", .data = (void *)AXP313A_ID }, + { .compatible = "x-powers,axp803", .data = (void *)AXP803_ID }, + { .compatible = "x-powers,axp806", .data = (void *)AXP806_ID }, + { .compatible = "x-powers,axp15060", .data = (void *)AXP15060_ID }, +@@ -77,6 +78,7 @@ static const struct i2c_device_id axp20x_i2c_id[] = { + { "axp209", 0 }, + { "axp221", 0 }, + { "axp223", 0 }, ++ { "axp313a", 0 }, + { "axp803", 0 }, + { "axp806", 0 }, + { "axp15060", 0 }, +diff --git a/drivers/mfd/axp20x.c b/drivers/mfd/axp20x.c +index 72b87aae60cc02..07a846ecbf18eb 100644 +--- a/drivers/mfd/axp20x.c ++++ b/drivers/mfd/axp20x.c +@@ -39,6 +39,7 @@ static const char * const axp20x_model_names[] = { + "AXP221", + "AXP223", + "AXP288", ++ "AXP313a", + "AXP803", + "AXP806", + "AXP809", +@@ -156,6 +157,25 @@ static const struct regmap_range axp806_writeable_ranges[] = { + regmap_reg_range(AXP806_REG_ADDR_EXT, AXP806_REG_ADDR_EXT), + }; + ++static const struct regmap_range axp313a_writeable_ranges[] = { ++ regmap_reg_range(AXP313A_ON_INDICATE, AXP313A_IRQ_STATE), ++}; ++ ++static const struct regmap_range axp313a_volatile_ranges[] = { ++ regmap_reg_range(AXP313A_SHUTDOWN_CTRL, AXP313A_SHUTDOWN_CTRL), ++ regmap_reg_range(AXP313A_IRQ_STATE, AXP313A_IRQ_STATE), ++}; ++ ++static const struct regmap_access_table axp313a_writeable_table = { ++ .yes_ranges = axp313a_writeable_ranges, ++ .n_yes_ranges = ARRAY_SIZE(axp313a_writeable_ranges), ++}; ++ ++static const struct regmap_access_table axp313a_volatile_table = { ++ .yes_ranges = axp313a_volatile_ranges, ++ .n_yes_ranges = ARRAY_SIZE(axp313a_volatile_ranges), ++}; ++ + static const struct regmap_range axp806_volatile_ranges[] = { + regmap_reg_range(AXP20X_IRQ1_STATE, AXP20X_IRQ2_STATE), + }; +@@ -248,6 +268,11 @@ static const struct resource axp288_fuel_gauge_resources[] = { + DEFINE_RES_IRQ(AXP288_IRQ_WL1), + }; + ++static const struct resource axp313a_pek_resources[] = { ++ DEFINE_RES_IRQ_NAMED(AXP313A_IRQ_PEK_RIS_EDGE, "PEK_DBR"), ++ DEFINE_RES_IRQ_NAMED(AXP313A_IRQ_PEK_FAL_EDGE, "PEK_DBF"), ++}; ++ + static const struct resource axp803_pek_resources[] = { + DEFINE_RES_IRQ_NAMED(AXP803_IRQ_PEK_RIS_EDGE, "PEK_DBR"), + DEFINE_RES_IRQ_NAMED(AXP803_IRQ_PEK_FAL_EDGE, "PEK_DBF"), +@@ -304,6 +329,15 @@ static const struct regmap_config axp288_regmap_config = { + .cache_type = REGCACHE_RBTREE, + }; + ++static const struct regmap_config axp313a_regmap_config = { ++ .reg_bits = 8, ++ .val_bits = 8, ++ .wr_table = &axp313a_writeable_table, ++ .volatile_table = &axp313a_volatile_table, ++ .max_register = AXP313A_IRQ_STATE, ++ .cache_type = REGCACHE_RBTREE, ++}; ++ + static const struct regmap_config axp806_regmap_config = { + .reg_bits = 8, + .val_bits = 8, +@@ -456,6 +490,16 @@ static const struct regmap_irq axp288_regmap_irqs[] = { + INIT_REGMAP_IRQ(AXP288, BC_USB_CHNG, 5, 1), + }; + ++static const struct regmap_irq axp313a_regmap_irqs[] = { ++ INIT_REGMAP_IRQ(AXP313A, PEK_RIS_EDGE, 0, 7), ++ INIT_REGMAP_IRQ(AXP313A, PEK_FAL_EDGE, 0, 6), ++ INIT_REGMAP_IRQ(AXP313A, PEK_SHORT, 0, 5), ++ INIT_REGMAP_IRQ(AXP313A, PEK_LONG, 0, 4), ++ INIT_REGMAP_IRQ(AXP313A, DCDC3_V_LOW, 0, 3), ++ INIT_REGMAP_IRQ(AXP313A, DCDC2_V_LOW, 0, 2), ++ INIT_REGMAP_IRQ(AXP313A, DIE_TEMP_HIGH, 0, 0), ++}; ++ + static const struct regmap_irq axp803_regmap_irqs[] = { + INIT_REGMAP_IRQ(AXP803, ACIN_OVER_V, 0, 7), + INIT_REGMAP_IRQ(AXP803, ACIN_PLUGIN, 0, 6), +@@ -606,6 +650,17 @@ static const struct regmap_irq_chip axp288_regmap_irq_chip = { + + }; + ++static const struct regmap_irq_chip axp313a_regmap_irq_chip = { ++ .name = "axp313a_irq_chip", ++ .status_base = AXP313A_IRQ_STATE, ++ .ack_base = AXP313A_IRQ_STATE, ++ .unmask_base = AXP313A_IRQ_EN, ++ .init_ack_masked = true, ++ .irqs = axp313a_regmap_irqs, ++ .num_irqs = ARRAY_SIZE(axp313a_regmap_irqs), ++ .num_regs = 1, ++}; ++ + static const struct regmap_irq_chip axp803_regmap_irq_chip = { + .name = "axp803", + .status_base = AXP20X_IRQ1_STATE, +@@ -745,6 +800,11 @@ static const struct mfd_cell axp152_cells[] = { + }, + }; + ++static struct mfd_cell axp313a_cells[] = { ++ MFD_CELL_NAME("axp20x-regulator"), ++ MFD_CELL_RES("axp313a-pek", axp313a_pek_resources), ++}; ++ + static const struct resource axp288_adc_resources[] = { + DEFINE_RES_IRQ_NAMED(AXP288_IRQ_GPADC, "GPADC"), + }; +@@ -914,8 +974,18 @@ static const struct mfd_cell axp_regulator_only_cells[] = { + static int axp20x_power_off(struct sys_off_data *data) + { + struct axp20x_dev *axp20x = data->cb_data; ++ unsigned int shutdown_reg; + +- regmap_write(axp20x->regmap, AXP20X_OFF_CTRL, AXP20X_OFF); ++ switch (axp20x->variant) { ++ case AXP313A_ID: ++ shutdown_reg = AXP313A_SHUTDOWN_CTRL; ++ break; ++ default: ++ shutdown_reg = AXP20X_OFF_CTRL; ++ break; ++ } ++ ++ regmap_write(axp20x->regmap, shutdown_reg, AXP20X_OFF); + + /* Give capacitors etc. time to drain to avoid kernel panic msg. */ + mdelay(500); +@@ -978,6 +1048,12 @@ int axp20x_match_device(struct axp20x_dev *axp20x) + axp20x->regmap_irq_chip = &axp288_regmap_irq_chip; + axp20x->irq_flags = IRQF_TRIGGER_LOW; + break; ++ case AXP313A_ID: ++ axp20x->nr_cells = ARRAY_SIZE(axp313a_cells); ++ axp20x->cells = axp313a_cells; ++ axp20x->regmap_cfg = &axp313a_regmap_config; ++ axp20x->regmap_irq_chip = &axp313a_regmap_irq_chip; ++ break; + case AXP803_ID: + axp20x->nr_cells = ARRAY_SIZE(axp803_cells); + axp20x->cells = axp803_cells; +diff --git a/include/linux/mfd/axp20x.h b/include/linux/mfd/axp20x.h +index beb3f44f85c50c..fff7fa6b7c5dc6 100644 +--- a/include/linux/mfd/axp20x.h ++++ b/include/linux/mfd/axp20x.h +@@ -17,6 +17,7 @@ enum axp20x_variants { + AXP221_ID, + AXP223_ID, + AXP288_ID, ++ AXP313A_ID, + AXP803_ID, + AXP806_ID, + AXP809_ID, +@@ -92,6 +93,17 @@ enum axp20x_variants { + #define AXP22X_ALDO3_V_OUT 0x2a + #define AXP22X_CHRG_CTRL3 0x35 + ++#define AXP313A_ON_INDICATE 0x00 ++#define AXP313A_OUTPUT_CONTROL 0x10 ++#define AXP313A_DCDC1_CONRTOL 0x13 ++#define AXP313A_DCDC2_CONRTOL 0x14 ++#define AXP313A_DCDC3_CONRTOL 0x15 ++#define AXP313A_ALDO1_CONRTOL 0x16 ++#define AXP313A_DLDO1_CONRTOL 0x17 ++#define AXP313A_SHUTDOWN_CTRL 0x1a ++#define AXP313A_IRQ_EN 0x20 ++#define AXP313A_IRQ_STATE 0x21 ++ + #define AXP806_STARTUP_SRC 0x00 + #define AXP806_CHIP_ID 0x03 + #define AXP806_PWR_OUT_CTRL1 0x10 +@@ -363,6 +375,16 @@ enum { + AXP22X_REG_ID_MAX, + }; + ++enum { ++ AXP313A_DCDC1 = 0, ++ AXP313A_DCDC2, ++ AXP313A_DCDC3, ++ AXP313A_ALDO1, ++ AXP313A_DLDO1, ++ AXP313A_RTC_LDO, ++ AXP313A_REG_ID_MAX, ++}; ++ + enum { + AXP806_DCDCA = 0, + AXP806_DCDCB, +@@ -616,6 +638,16 @@ enum axp288_irqs { + AXP288_IRQ_BC_USB_CHNG, + }; + ++enum axp313a_irqs { ++ AXP313A_IRQ_DIE_TEMP_HIGH, ++ AXP313A_IRQ_DCDC2_V_LOW = 2, ++ AXP313A_IRQ_DCDC3_V_LOW, ++ AXP313A_IRQ_PEK_LONG, ++ AXP313A_IRQ_PEK_SHORT, ++ AXP313A_IRQ_PEK_FAL_EDGE, ++ AXP313A_IRQ_PEK_RIS_EDGE, ++}; ++ + enum axp803_irqs { + AXP803_IRQ_ACIN_OVER_V = 1, + AXP803_IRQ_ACIN_PLUGIN, + diff --git a/核桃派软件源代码/kernel/patches/SHA256SUMS b/核桃派软件源代码/kernel/patches/SHA256SUMS new file mode 100644 index 0000000..90f3e8d --- /dev/null +++ b/核桃派软件源代码/kernel/patches/SHA256SUMS @@ -0,0 +1,14 @@ +4732299a8d031b7c47467be396cd2fc39fd550aaf375f5c7c682e48d1f6ee374 0003-walnutpi-6.1-axp313a-strict-backport.patch +25d38b4a5bd4c2b4fa756f0e99b16634dffac78422461377ef09fdc53a44425e 0004-walnutpi-axp313a-dts.patch +b8d1626136366cecfc8f0c8c0670e2a56ec054f1f60c6a747f2ca166c07a76a1 0005-walnutpi-axp313a-preserve-dldo1.patch +8c90cf4c86e6d842d7d6920129cff222b0dcd119a76666b8a43ed8674d74bd95 0006-walnutpi-uwe5622-out-of-tree-build.patch +9eafe8cc28d58f43c452e8b09abc47fb2fbc7df44fe946528a7bb991b057d60f 0007-walnutpi-enabled-realtek-out-of-tree-build.patch +ba883758b870f56103e0edfdd1e14cf7bfda294ecb6bf7f9e35f0f0540764e49 0008-walnutpi-rtl8192eu-out-of-tree-build.patch +c32ec66a62bee42785120f767f24aec91f45bd62d8dabceae3b4b662cc9ddb68 0009-walnutpi-rtl8812au-out-of-tree-build.patch +4f31e4665a01cb12a750485e6650105e4afe0bf89dcf7a1035f655ff48339bff 0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch +fd0f25bc723a3e597b74aaad5c34326b7cf963fb413f0e1861bf592e810e9f6f 0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch +bf4fb64669ebcb2403ce89faf8ef27849d1373b35d92b1b39114e2993ca2aee6 0012-walnutpi-emmc-axp313a-dts.patch +2b6b6dd7ca744f282ab66ebee9d054152dc763dbd5a0eb8c87bbd7404898c535 0013-walnutpi-axp313a-fixed-dcdc-frequency.patch +f7ed2aa06de80e5a2c64ba3f2e45ab09f951611d96a8e24188df57eee95a096c 4628b804555773daa982d9578d89fe04fcdde374.patch +28bb52ee914724cf8d72e01b758f021d41344c34dc76cefe697189c7a4a0ebf8 60fd7eb89670d2636ac3156881acbd103c6eba6a.patch +7121582279b9344a9b653d97905c87955fb4716001b411b89bddb0ca8b3b4e2f 75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch diff --git a/核桃派软件源代码/output/playwright/frp-desktop.png b/核桃派软件源代码/output/playwright/frp-desktop.png new file mode 100644 index 0000000..a8dadc1 Binary files /dev/null and b/核桃派软件源代码/output/playwright/frp-desktop.png differ diff --git a/核桃派软件源代码/output/playwright/frp-editor-narrow.png b/核桃派软件源代码/output/playwright/frp-editor-narrow.png new file mode 100644 index 0000000..8b53c78 Binary files /dev/null and b/核桃派软件源代码/output/playwright/frp-editor-narrow.png differ diff --git a/核桃派软件源代码/output/playwright/frp-narrow.png b/核桃派软件源代码/output/playwright/frp-narrow.png new file mode 100644 index 0000000..18e1192 Binary files /dev/null and b/核桃派软件源代码/output/playwright/frp-narrow.png differ diff --git a/核桃派软件源代码/pytest.ini b/核桃派软件源代码/pytest.ini new file mode 100644 index 0000000..435c40e --- /dev/null +++ b/核桃派软件源代码/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +testpaths = tests + diff --git a/核桃派软件源代码/requirements-dev.txt b/核桃派软件源代码/requirements-dev.txt new file mode 100644 index 0000000..2b7625a --- /dev/null +++ b/核桃派软件源代码/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.4.1 +httpx==0.28.1 + diff --git a/核桃派软件源代码/requirements.txt b/核桃派软件源代码/requirements.txt new file mode 100644 index 0000000..00581e8 --- /dev/null +++ b/核桃派软件源代码/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pillow>=10.0 +fonttools>=4.0,<5 diff --git a/核桃派软件源代码/scripts/__init__.py b/核桃派软件源代码/scripts/__init__.py new file mode 100644 index 0000000..98b3fab --- /dev/null +++ b/核桃派软件源代码/scripts/__init__.py @@ -0,0 +1 @@ +"""Deployment and maintenance helpers for Matrix Screen Controller.""" diff --git a/核桃派软件源代码/scripts/axp313a_kernel_health.py b/核桃派软件源代码/scripts/axp313a_kernel_health.py new file mode 100644 index 0000000..ff77692 --- /dev/null +++ b/核桃派软件源代码/scripts/axp313a_kernel_health.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import argparse +import json +import os +import platform +import subprocess +import time +import urllib.request +from pathlib import Path +from typing import Any + + +CANDIDATE_RELEASE = "6.1.31-matrix-axp313a1" +MARKER_PATH = Path("/boot/matrix-kernel-good") +CPUFREQ_ROOT = Path("/sys/devices/system/cpu/cpufreq") + + +class HealthError(RuntimeError): + pass + + +def _read(path: Path) -> str: + return path.read_text(encoding="ascii", errors="strict").strip() + + +def check_axp313a_binding(sys_root: Path = Path("/sys")) -> None: + matches = list((sys_root / "bus/i2c/devices").glob("*-0036/of_node/compatible")) + if not matches: + raise HealthError("PMIC device-tree node at I2C address 0x36 is missing") + compatible = matches[0].read_bytes().replace(b"\0", b"\n").decode("ascii", errors="replace") + if "x-powers,axp313a" not in compatible.splitlines(): + raise HealthError(f"PMIC compatible is not x-powers,axp313a: {compatible!r}") + driver_link = matches[0].parents[1] / "driver" + if not driver_link.exists() or Path(os.path.realpath(driver_link)).name != "axp20x-i2c": + raise HealthError("AXP313A is not bound to axp20x-i2c") + + +def check_cpufreq( + cpufreq_root: Path = CPUFREQ_ROOT, + debugfs_deferred: Path = Path("/sys/kernel/debug/devices_deferred"), +) -> None: + policy = cpufreq_root / "policy0" + if not policy.is_dir(): + raise HealthError("cpufreq policy0 is missing") + affected = set(_read(policy / "affected_cpus").split()) + if affected != {"0", "1", "2", "3"}: + raise HealthError(f"policy0 does not cover CPU0-3: {sorted(affected)}") + available = set(_read(policy / "scaling_available_governors").split()) + if "performance" not in available: + raise HealthError("performance governor is unavailable") + if debugfs_deferred.is_file() and "cpufreq-dt" in debugfs_deferred.read_text( + encoding="utf-8", errors="replace" + ): + raise HealthError("cpufreq-dt is still deferred") + + +def check_cpu_regulator(sys_root: Path = Path("/sys")) -> None: + regulator_root = sys_root / "class/regulator" + for directory in regulator_root.glob("regulator.*"): + name_path = directory / "name" + if not name_path.is_file() or _read(name_path) != "vdd-cpu": + continue + voltage = int(_read(directory / "microvolts")) + if not 810_000 <= voltage <= 1_080_000: + raise HealthError(f"vdd-cpu is outside the approved range: {voltage}uV") + return + raise HealthError("vdd-cpu regulator is missing") + + +def check_kernel_messages(messages: str) -> None: + forbidden = ( + "DCDC frequency on AXP313a is fixed to 3 MHz", + "Error setting dcdc frequency", + ) + for message in forbidden: + if message in messages: + raise HealthError(f"AXP313A regulator logged a boot error: {message}") + + +def check_kernel_log() -> None: + result = subprocess.run( + ["/bin/dmesg"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + check_kernel_messages(result.stdout) + + +def _fetch_status(url: str) -> dict[str, Any]: + # The candidate kernel is first validated against the already-installed + # production application. That older build can take several seconds to + # answer while its display compositor is busy, which must not masquerade + # as a kernel/cpufreq failure. The stricter 500 ms HTTP performance gate + # is measured separately after the candidate application is staged. + with urllib.request.urlopen(url, timeout=10.0) as response: + return json.loads(response.read().decode("utf-8")) + + +def check_driver_pair(first: dict[str, Any], second: dict[str, Any]) -> None: + screen = second.get("screen") or {} + if screen.get("driver") != "walnutpi-h618-hub75": + raise HealthError("production H618 driver is not active") + driver = screen.get("driver_status") or {} + previous = (first.get("screen") or {}).get("driver_status") or {} + for field in ("actual_refresh_rate_hz", "panel_scan_rate_hz"): + if float(driver.get(field) or 0.0) < 95.0: + raise HealthError(f"{field} is below 95Hz") + if driver.get("scans_per_frame") != 1: + raise HealthError("scans_per_frame is not 1") + frame_delta = int(driver.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0) + scan_delta = int(driver.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0) + miss_delta = int(driver.get("deadline_misses") or 0) - int(previous.get("deadline_misses") or 0) + if frame_delta <= 0 or scan_delta != frame_delta: + raise HealthError("HUB75 completed frame/scan counters are stalled or unequal") + if miss_delta < 0 or miss_delta / frame_delta > 0.001: + raise HealthError("HUB75 deadline miss rate exceeds 0.1%") + for field in ("oe_pulse_faults", "oe_forced_blanks"): + if int(driver.get(field) or 0) != int(previous.get(field) or 0): + raise HealthError(f"{field} increased during health check") + for field in ("driver_error", "oe_timing_error", "safeoff_error"): + if driver.get(field): + raise HealthError(f"{field} is not empty: {driver[field]}") + if driver.get("cpu_affinity") != 3 or driver.get("cpu_affinity_active") is not True: + raise HealthError("CPU3 affinity is not active") + if driver.get("realtime_priority") != 50 or driver.get("realtime_priority_active") is not True: + raise HealthError("SCHED_FIFO 50 is not active") + if driver.get("memory_locked") is not True: + raise HealthError("refresh memory is not locked") + + +def wait_for_driver(url: str, timeout_seconds: int = 60) -> None: + deadline = time.monotonic() + timeout_seconds + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + first = _fetch_status(url) + time.sleep(2) + second = _fetch_status(url) + check_driver_pair(first, second) + return + except Exception as exc: + last_error = exc + time.sleep(1) + raise HealthError(f"application/GPIO health timed out: {last_error}") + + +def write_marker(marker: Path = MARKER_PATH) -> None: + temporary = marker.with_name(f".{marker.name}.tmp") + with temporary.open("w", encoding="ascii", newline="\n") as handle: + handle.write(f"{CANDIDATE_RELEASE}\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, marker) + os.sync() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Confirm the one-shot WalnutPi AXP313A kernel") + parser.add_argument("--status-url", default="http://127.0.0.1:8080/api/status") + parser.add_argument("--marker", type=Path, default=MARKER_PATH) + args = parser.parse_args() + if platform.release() != CANDIDATE_RELEASE: + raise HealthError(f"unexpected candidate release: {platform.release()}") + check_axp313a_binding() + check_cpufreq() + check_cpu_regulator() + check_kernel_log() + wait_for_driver(args.status_url) + write_marker(args.marker) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except HealthError as exc: + print(f"AXP313A candidate health failed: {exc}", flush=True) + raise SystemExit(1) diff --git a/核桃派软件源代码/scripts/boot_space.py b/核桃派软件源代码/scripts/boot_space.py new file mode 100644 index 0000000..a85fe86 --- /dev/null +++ b/核桃派软件源代码/scripts/boot_space.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import tarfile + +from scripts.kernel_artifact import ARTIFACT_NAME, REQUIRED_BOOT_FILES, verify_kernel_dependency +from scripts.render_dual_kernel_boot import BootScriptError, render + + +DEFAULT_SAFETY_BYTES = 32 * 1024 * 1024 +CONTROL_FILE_COUNT = 2 # matrix-original.SHA256SUMS and matrix-kernel-good + + +class BootSpaceError(ValueError): + pass + + +@dataclass(frozen=True) +class BootSpaceBudget: + required_bytes: int + safety_bytes: int + total_bytes: int + available_bytes: int + cluster_bytes: int + + +def _allocated(size: int, cluster_bytes: int) -> int: + if type(size) is not int or size < 0: + raise BootSpaceError("boot file size must be a non-negative integer") + if type(cluster_bytes) is not int or cluster_bytes <= 0: + raise BootSpaceError("boot cluster size must be a positive integer") + return max(1, (size + cluster_bytes - 1) // cluster_bytes) * cluster_bytes + + +def calculate_budget( + *, + candidate_sizes: dict[str, int], + boot_cmd: bytes, + boot_scr: bytes, + cluster_bytes: int, + available_bytes: int, + safety_bytes: int = DEFAULT_SAFETY_BYTES, +) -> BootSpaceBudget: + if set(candidate_sizes) != REQUIRED_BOOT_FILES: + raise BootSpaceError("candidate boot file set is not exact") + if type(available_bytes) is not int or available_bytes < 0: + raise BootSpaceError("available boot bytes must be a non-negative integer") + if type(safety_bytes) is not int or safety_bytes < 0: + raise BootSpaceError("boot safety bytes must be a non-negative integer") + try: + managed_cmd = render(boot_cmd.decode("utf-8")).encode("utf-8") + except (UnicodeError, BootScriptError) as exc: + raise BootSpaceError("original boot.cmd cannot be rendered safely") from exc + # mkimage adds a 64-byte legacy image header. Count both temporary and final + # managed scripts so the estimate remains safe even when FAT cannot reuse the + # original chains during replacement. + managed_scr_size = len(managed_cmd) + 64 + allocations = list(candidate_sizes.values()) + allocations.extend((len(boot_cmd), len(boot_scr))) # rollback copies + allocations.extend((len(managed_cmd), managed_scr_size)) # mktemp files + allocations.extend((len(managed_cmd), managed_scr_size)) # final replacements + allocations.extend(1 for _ in range(CONTROL_FILE_COUNT)) + required = sum(_allocated(size, cluster_bytes) for size in allocations) + total = required + safety_bytes + return BootSpaceBudget(required, safety_bytes, total, available_bytes, cluster_bytes) + + +def candidate_sizes_from_dependency(dependency: Path) -> dict[str, int]: + dependency = Path(dependency).resolve() + verify_kernel_dependency(dependency) + with tarfile.open(dependency / ARTIFACT_NAME, mode="r:gz") as archive: + normalized = { + member.name.removeprefix("./"): member.size + for member in archive.getmembers() + if member.isfile() + } + return {name: normalized[name] for name in REQUIRED_BOOT_FILES} + + +def candidate_sizes_from_artifact_root(root: Path) -> dict[str, int]: + root = Path(root).resolve() + result: dict[str, int] = {} + for name in REQUIRED_BOOT_FILES: + path = root / name + if not path.is_file(): + raise BootSpaceError(f"candidate boot file is missing: {name}") + result[name] = path.stat().st_size + return result + + +def check_mounted_boot( + boot_root: Path, + artifact_root: Path, + *, + safety_bytes: int = DEFAULT_SAFETY_BYTES, +) -> BootSpaceBudget: + boot_root = Path(boot_root).resolve() + if not boot_root.is_dir() or boot_root == Path(boot_root.anchor): + raise BootSpaceError("boot root must be an explicit mounted directory") + boot_cmd = (boot_root / "boot.cmd").read_bytes() + boot_scr = (boot_root / "boot.scr").read_bytes() + stats = os.statvfs(boot_root) + cluster_bytes = stats.f_frsize or stats.f_bsize + available_bytes = stats.f_bavail * cluster_bytes + budget = calculate_budget( + candidate_sizes=candidate_sizes_from_artifact_root(artifact_root), + boot_cmd=boot_cmd, + boot_scr=boot_scr, + cluster_bytes=cluster_bytes, + available_bytes=available_bytes, + safety_bytes=safety_bytes, + ) + if budget.available_bytes < budget.total_bytes: + raise BootSpaceError( + "boot filesystem lacks room for the candidate kernel and rollback files: " + f"available={budget.available_bytes}, required={budget.required_bytes}, " + f" safety={budget.safety_bytes}, total={budget.total_bytes}" + ) + return budget + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate safe /boot space before kernel installation") + parser.add_argument("--boot-root", type=Path, required=True) + parser.add_argument("--artifact-root", type=Path, required=True) + parser.add_argument("--safety-bytes", type=int, default=DEFAULT_SAFETY_BYTES) + args = parser.parse_args() + result = check_mounted_boot( + args.boot_root, + args.artifact_root, + safety_bytes=args.safety_bytes, + ) + print(json.dumps(asdict(result), ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (BootSpaceError, OSError, tarfile.TarError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + raise SystemExit(1) diff --git a/核桃派软件源代码/scripts/build_axp313a_kernel.sh b/核桃派软件源代码/scripts/build_axp313a_kernel.sh new file mode 100644 index 0000000..86721cd --- /dev/null +++ b/核桃派软件源代码/scripts/build_axp313a_kernel.sh @@ -0,0 +1,105 @@ +#!/bin/sh +set -eu + +SOURCE= +CONFIG= +OUTPUT= +JOBS=${JOBS:-2} +RELEASE=6.1.31-matrix-axp313a1 +LOCALVERSION_SUFFIX=-matrix-axp313a1 +VENDOR_COMMIT=30ff3fd5cf45417622b447a12e7a947402ffe34d + +while [ "$#" -gt 0 ]; do + case "$1" in + --source) SOURCE=$2; shift 2 ;; + --config) CONFIG=$2; shift 2 ;; + --output) OUTPUT=$2; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$SOURCE" ] || [ -z "$CONFIG" ] || [ -z "$OUTPUT" ]; then + echo "usage: build_axp313a_kernel.sh --source DIR --config FILE --output EMPTY_DIR" >&2 + exit 2 +fi +SOURCE=$(CDPATH= cd -- "$SOURCE" && pwd) +CONFIG=$(readlink -f -- "$CONFIG") +OUTPUT=$(readlink -f -- "$OUTPUT") +SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PATCH_ROOT=$SCRIPT_ROOT/kernel/patches +BUILD=$OUTPUT/build +STAGE=$OUTPUT/stage + +case "$SOURCE" in /|/boot|/opt|/usr|/var|/home) echo "source path is too broad" >&2; exit 1 ;; esac +case "$OUTPUT" in /|/boot|/opt|/usr|/var|/home) echo "output path is too broad" >&2; exit 1 ;; esac +if [ ! -f "$SOURCE/Makefile" ] || [ ! -f "$CONFIG" ]; then + echo "kernel source or config is missing" >&2 + exit 1 +fi +if [ -n "$(find "$OUTPUT" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + echo "output directory must be empty" >&2 + exit 1 +fi +case "$JOBS" in *[!0-9]*|'') echo "JOBS must be a positive integer" >&2; exit 1 ;; esac +if [ "$JOBS" -lt 1 ] || [ "$JOBS" -gt 4 ]; then + echo "JOBS must be within 1..4" >&2 + exit 1 +fi + +(cd "$PATCH_ROOT" && sha256sum -c SHA256SUMS) +for upstream_patch in \ + 75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch \ + 60fd7eb89670d2636ac3156881acbd103c6eba6a.patch \ + 4628b804555773daa982d9578d89fe04fcdde374.patch +do + test -f "$PATCH_ROOT/$upstream_patch" +done +grep -q 'AXP313A_ID' "$SOURCE/include/linux/mfd/axp20x.h" +grep -q 'axp313a_regulators' "$SOURCE/drivers/regulator/axp20x-regulator.c" +for patch_file in \ + 0003-walnutpi-6.1-axp313a-strict-backport.patch \ + 0004-walnutpi-axp313a-dts.patch \ + 0005-walnutpi-axp313a-preserve-dldo1.patch \ + 0006-walnutpi-uwe5622-out-of-tree-build.patch \ + 0007-walnutpi-enabled-realtek-out-of-tree-build.patch \ + 0008-walnutpi-rtl8192eu-out-of-tree-build.patch \ + 0009-walnutpi-rtl8812au-out-of-tree-build.patch \ + 0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch \ + 0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch \ + 0012-walnutpi-emmc-axp313a-dts.patch \ + 0013-walnutpi-axp313a-fixed-dcdc-frequency.patch +do + patch -d "$SOURCE" -p1 --forward --batch --fuzz=0 < "$PATCH_ROOT/$patch_file" +done + +mkdir -p -- "$BUILD" "$STAGE/boot" "$STAGE/lib" +cp -- "$CONFIG" "$BUILD/.config" +make -C "$SOURCE" O="$BUILD" olddefconfig +"$SOURCE/scripts/config" --file "$BUILD/.config" \ + --set-str LOCALVERSION '' \ + --disable LOCALVERSION_AUTO \ + --set-str SYSTEM_TRUSTED_KEYS '' \ + --set-str SYSTEM_REVOCATION_KEYS '' +make -C "$SOURCE" O="$BUILD" olddefconfig +actual_release=$(make -s -C "$SOURCE" O="$BUILD" \ + LOCALVERSION="$LOCALVERSION_SUFFIX" kernelrelease) +if [ "$actual_release" != "$RELEASE" ]; then + echo "unexpected kernel release: $actual_release" >&2 + exit 1 +fi +make -C "$SOURCE" O="$BUILD" LOCALVERSION="$LOCALVERSION_SUFFIX" \ + -j"$JOBS" Image dtbs modules +make -C "$SOURCE" O="$BUILD" LOCALVERSION="$LOCALVERSION_SUFFIX" \ + modules_install INSTALL_MOD_PATH="$STAGE" + +install -m 0644 "$BUILD/arch/arm64/boot/Image" "$STAGE/boot/Image-matrix-axp313a1" +install -m 0644 "$BUILD/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b.dtb" \ + "$STAGE/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" +install -m 0644 "$BUILD/arch/arm64/boot/dts/allwinner/sun50i-h616-walnutpi-1b-emmc.dtb" \ + "$STAGE/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" +install -m 0644 "$BUILD/System.map" "$STAGE/boot/System.map-$RELEASE" +install -m 0644 "$BUILD/.config" "$STAGE/boot/config-$RELEASE" +printf '%s\n' "$VENDOR_COMMIT" > "$STAGE/SOURCE_COMMIT" +printf '%s\n' "$RELEASE" > "$STAGE/KERNEL_RELEASE" +(cd "$STAGE" && find boot lib SOURCE_COMMIT KERNEL_RELEASE -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS) +echo "candidate kernel built at $STAGE" diff --git a/核桃派软件源代码/scripts/build_ota_package.py b/核桃派软件源代码/scripts/build_ota_package.py new file mode 100644 index 0000000..468e1b3 --- /dev/null +++ b/核桃派软件源代码/scripts/build_ota_package.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Create one self-contained browser OTA package on the development computer.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +from pathlib import Path +import sys + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +if str(SOURCE_ROOT) not in sys.path: + sys.path.insert(0, str(SOURCE_ROOT)) + +from app.ota.package import build_package +from app.ota.versioning import read_software_version +from app.ota.policy import read_policy + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--notes", required=True, help="short release description") + parser.add_argument("--output-root", type=Path) + args = parser.parse_args() + source_root = Path(__file__).resolve().parents[1] + project_root = source_root.parent + wheelhouse = project_root / "发布更新相关" / "其他依赖" / "aarch64-py311" + frpc_bundle = project_root / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64" + version = read_software_version(source_root) + policy = read_policy(source_root) + if policy["checkpoints"] and version < type(version).parse(policy["checkpoints"][-1]["version"]): + parser.error("源码包含未发布的软件依赖;请使用 export_release.py ota --version 导出安装节点") + if version.patch != 0: + frpc_bundle = None + output_root = (args.output_root or project_root / "发布更新相关" / "OTA数据包").resolve() + version_dir = output_root / str(version) + if version_dir.exists(): + parser.error(f"version output already exists and will not be overwritten: {version_dir}") + version_dir.mkdir(parents=True) + package_path = version_dir / f"matrix-screen-controller-{version}.ota" + created = datetime.now(timezone.utc) + try: + info = build_package( + source_root, + wheelhouse, + package_path, + version=version, + release_notes=args.notes, + created_at=created, + system_dependencies=frpc_bundle, + ) + package_sha = hashlib.sha256(package_path.read_bytes()).hexdigest() + readme = ( + f"# 奇妙小屏幕控制器 OTA {version}\n\n" + f"- 软件版本:`{version}`\n" + f"- 打包时间:`{info.created_at}`\n" + f"- 更新说明:{args.notes.strip()}\n" + f"- 更新包:`{package_path.name}`\n" + f"- 文件大小:`{package_path.stat().st_size}` bytes\n" + f"- 文件 SHA-256:`{package_sha}`\n" + f"- 载荷 SHA-256:`{info.payload_sha256}`\n\n" + "通过设备网页的“系统设置 → 软件更新”选择 `.ota` 文件。" + "本包是离线全量包,不使用服务器下载、增量、加密或签名。\n" + ) + (version_dir / "README.md").write_text(readme, encoding="utf-8", newline="\n") + except BaseException: + package_path.unlink(missing_ok=True) + try: + version_dir.rmdir() + except OSError: + pass + raise + print(version_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/build_sd_image.py b/核桃派软件源代码/scripts/build_sd_image.py new file mode 100644 index 0000000..d7e4566 --- /dev/null +++ b/核桃派软件源代码/scripts/build_sd_image.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import gzip +import hashlib +import json +from pathlib import Path +import shutil +import sys +import tarfile + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +if str(SOURCE_ROOT) not in sys.path: + sys.path.insert(0, str(SOURCE_ROOT)) + +from scripts.fat16_image import Fat16Image +from scripts.image_config import PRODUCT_ID, create_config_file, read_config_file, validate_config +from scripts.debian_closure import IMAGE_DEBIAN_ROOTS, verify_local_closure +from scripts.kernel_artifact import KernelArtifactInfo, verify_kernel_dependency, verify_source_dependency +from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget, candidate_sizes_from_dependency + + +EXCLUDED_PARTS = {".venv", "data", "__pycache__", ".pytest_cache", "node_modules"} +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", +} +IMAGE_FORMAT_VERSION = 3 +BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ" + + +def _allowed_source(path: Path, root: Path) -> bool: + relative = path.relative_to(root) + if any(part in EXCLUDED_PARTS for part in relative.parts): + return False + if relative.as_posix() in NATIVE_BUILD_OUTPUTS: + return False + return path.suffix not in {".pyc", ".pyo"} + + +def _add_file(tar: tarfile.TarFile, path: Path, name: str) -> None: + info = tar.gettarinfo(str(path), arcname=name) + info.uid = info.gid = 0 + info.uname = info.gname = "root" + info.mtime = 0 + info.mode = 0o755 if path.suffix == ".sh" else 0o644 + with path.open("rb") as handle: + tar.addfile(info, handle) + + +def build_bundle( + source: Path, + wheelhouse: Path, + debian: Path, + output: Path, + system_dependencies: Path | None = None, +) -> None: + with output.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar: + for path in sorted(source.rglob("*"), key=lambda item: item.relative_to(source).as_posix()): + if path.is_file() and _allowed_source(path, source): + _add_file(tar, path, f"project/核桃派软件源代码/{path.relative_to(source).as_posix()}") + for root, archive_root in ( + (wheelhouse, "project/离线依赖/其他依赖/aarch64-py311"), + (debian, "project/离线依赖/其他依赖/debian12-aarch64"), + ): + for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()): + if path.is_file(): + _add_file(tar, path, f"{archive_root}/{path.relative_to(root).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(): + _add_file( + tar, + path, + f"project/离线依赖/其他依赖/frp/0.71.0/linux-arm64/{path.relative_to(system_dependencies).as_posix()}", + ) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def image_metadata( + version: str, + bundle_bytes: int, + bundle_sha256: str, + kernel: KernelArtifactInfo, + boot_required_bytes: int, + boot_safety_bytes: int = DEFAULT_SAFETY_BYTES, +) -> dict: + return { + "format_version": IMAGE_FORMAT_VERSION, + "product": PRODUCT_ID, + "software_version": version, + "bundle_path": BUNDLE_IMAGE_PATH, + "bundle_bytes": bundle_bytes, + "bundle_sha256": bundle_sha256, + "kernel_release": kernel.kernel_release, + "kernel_artifact": kernel.artifact, + "kernel_artifact_bytes": kernel.artifact_bytes, + "kernel_artifact_sha256": kernel.artifact_sha256, + "kernel_unpacked_file_bytes": kernel.unpacked_file_bytes, + "boot_required_bytes": boot_required_bytes, + "boot_safety_bytes": boot_safety_bytes, + } + + +def build_image( + base_image: Path, + source: Path, + wheelhouse: Path, + debian: Path, + kernel: Path, + kernel_source: Path, + config: dict | bytes, + output: Path, + bootstrap_bundle: Path, + system_dependencies: Path | None = None, +) -> dict: + base_image = Path(base_image).resolve() + source = Path(source).resolve() + wheelhouse = Path(wheelhouse).resolve() + debian = Path(debian).resolve() + kernel = Path(kernel).resolve() + kernel_source = Path(kernel_source).resolve() + output = Path(output).resolve() + bootstrap_bundle = Path(bootstrap_bundle).resolve() + system_dependencies = Path(system_dependencies).resolve() if system_dependencies is not None else None + if system_dependencies is None: + raise ValueError("the verified frpc system dependency bundle is required") + if isinstance(config, bytes): + config_bytes = config + config, _, _ = read_config_file(config_bytes) + config = validate_config(config) + else: + config = validate_config(config) + config_bytes = create_config_file(config) + version = config["software_version"] + if (source / "VERSION").read_text(encoding="utf-8").strip() != version: + raise ValueError("image configuration version does not match source VERSION") + for manifest in ( + wheelhouse / "SHA256SUMS", + debian / "SHA256SUMS", + debian / "BASE_IMAGE_PACKAGES.tsv", + system_dependencies / "SHA256SUMS", + ): + if not manifest.is_file(): + raise FileNotFoundError(manifest) + verify_local_closure(debian, debian / "BASE_IMAGE_PACKAGES.tsv", list(IMAGE_DEBIAN_ROOTS)) + kernel_info = verify_kernel_dependency(kernel) + verify_source_dependency(kernel_source) + if output.exists() or bootstrap_bundle.exists(): + raise FileExistsError(output if output.exists() else bootstrap_bundle) + output.parent.mkdir(parents=True, exist_ok=True) + bootstrap_bundle.parent.mkdir(parents=True, exist_ok=True) + + try: + build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies) + bundle_digest = sha256_file(bootstrap_bundle) + shutil.copyfile(base_image, output) + except BaseException: + output.unlink(missing_ok=True) + bootstrap_bundle.unlink(missing_ok=True) + raise + try: + firstboot = (source / "scripts" / "image_firstboot.sh").read_bytes() + with Fat16Image(output, writable=True) as image: + image.write_file("MSCCFG.BIN", config_bytes, contiguous=True) + image.write_file("MSCINIT", firstboot) + with Fat16Image(output) as image: + boot_budget = calculate_budget( + candidate_sizes=candidate_sizes_from_dependency(kernel), + boot_cmd=image.read_file("BOOT.CMD"), + boot_scr=image.read_file("BOOT.SCR"), + cluster_bytes=image.cluster_bytes, + available_bytes=image.free_bytes(), + ) + if boot_budget.available_bytes < boot_budget.total_bytes: + raise RuntimeError( + "FAT boot partition lacks safe candidate-kernel space: " + f"available={boot_budget.available_bytes}, required={boot_budget.required_bytes}, " + f"safety={boot_budget.safety_bytes}, total={boot_budget.total_bytes}" + ) + meta = image_metadata( + version, + bootstrap_bundle.stat().st_size, + bundle_digest, + kernel_info, + boot_budget.required_bytes, + boot_budget.safety_bytes, + ) + rendered_meta = ( + json.dumps(meta, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + ).encode("utf-8") + with Fat16Image(output, writable=True) as image: + image.write_file("MSCMETA.JSN", rendered_meta) + with Fat16Image(output) as image: + if image.read_file("MSCCFG.BIN") != config_bytes: + raise RuntimeError("image configuration read-back failed") + if image.find("MSCBOOT.TGZ") is not None: + raise RuntimeError("FAT boot partition unexpectedly contains the application bundle") + if image.read_file("MSCMETA.JSN") != rendered_meta: + raise RuntimeError("image metadata read-back failed") + if image.read_file("MSCINIT") != firstboot: + raise RuntimeError("image first-boot program read-back failed") + final_budget = calculate_budget( + candidate_sizes=candidate_sizes_from_dependency(kernel), + boot_cmd=image.read_file("BOOT.CMD"), + boot_scr=image.read_file("BOOT.SCR"), + cluster_bytes=image.cluster_bytes, + available_bytes=image.free_bytes(), + ) + if final_budget.available_bytes < final_budget.total_bytes: + raise RuntimeError("final FAT boot partition no longer satisfies the recorded space budget") + except BaseException: + output.unlink(missing_ok=True) + bootstrap_bundle.unlink(missing_ok=True) + raise + return { + **meta, + "created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "image_bytes": output.stat().st_size, + "image_sha256": sha256_file(output), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build a Rufus-writable WalnutPi controller image") + parser.add_argument("--base-image", type=Path, required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--wheelhouse", type=Path, required=True) + parser.add_argument("--debian", type=Path, required=True) + parser.add_argument("--kernel", type=Path, required=True) + parser.add_argument("--kernel-source", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--bootstrap-bundle-output", type=Path, required=True) + parser.add_argument("--frpc-bundle", type=Path, required=True) + args = parser.parse_args() + config = json.loads(args.config.read_text(encoding="utf-8")) + info = build_image( + args.base_image, + args.source, + args.wheelhouse, + args.debian, + args.kernel, + args.kernel_source, + config, + args.output, + args.bootstrap_bundle_output, + args.frpc_bundle, + ) + print(json.dumps(info, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/build_ui_copy_catalog.py b/核桃派软件源代码/scripts/build_ui_copy_catalog.py new file mode 100644 index 0000000..2e3ecc7 --- /dev/null +++ b/核桃派软件源代码/scripts/build_ui_copy_catalog.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from dataclasses import dataclass, field +from html.parser import HTMLParser +from pathlib import Path +from typing import Any, Iterable + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +STATIC_ROOT = SOURCE_ROOT / "app" / "static" +OUTPUT_PATH = STATIC_ROOT / "ui-copy.json" +DYNAMIC_PATH = STATIC_ROOT / "ui-copy-dynamic.json" +TEXT_ATTRIBUTES = ("aria-label", "placeholder", "data-unavailable-reason", "title") +ANCHOR_TAGS = {"article", "section", "header", "main", "form", "fieldset", "div"} +IGNORED_TAGS = {"script", "style", "svg", "path", "noscript"} +PLACEHOLDER_PATTERN = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_]*)\}") +FUNCTIONAL_VALUE_PATTERN = re.compile(r"^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$") + + +@dataclass +class HtmlNode: + tag: str + attrs: dict[str, str] + parent: "HtmlNode | None" = None + children: list["HtmlNode | str"] = field(default_factory=list) + + +class TreeParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.root = HtmlNode("document", {}) + self.stack = [self.root] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + node = HtmlNode(tag, {key: value or "" for key, value in attrs}, self.stack[-1]) + self.stack[-1].children.append(node) + if tag not in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}: + self.stack.append(node) + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + node = HtmlNode(tag, {key: value or "" for key, value in attrs}, self.stack[-1]) + self.stack[-1].children.append(node) + + def handle_endtag(self, tag: str) -> None: + for index in range(len(self.stack) - 1, 0, -1): + if self.stack[index].tag == tag: + del self.stack[index:] + return + + def handle_data(self, data: str) -> None: + self.stack[-1].children.append(data) + + +def normalized_text(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def stable_id(prefix: str, *parts: object) -> str: + raw = "\0".join(str(part) for part in parts) + return f"{prefix}.{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}" + + +def iter_nodes(node: HtmlNode) -> Iterable[HtmlNode]: + for child in node.children: + if isinstance(child, HtmlNode): + yield child + yield from iter_nodes(child) + + +def nearest_scope(node: HtmlNode) -> str: + current: HtmlNode | None = node + while current: + identifier = current.attrs.get("id", "") + if identifier.startswith("workspace-"): + return identifier.removeprefix("workspace-") + if current.tag == "dialog" and identifier: + return f"dialog:{identifier}" + current = current.parent + return "global" + + +def copy_ignored(node: HtmlNode) -> bool: + current: HtmlNode | None = node + while current: + if "data-ui-copy-ignore" in current.attrs or "data-ui-copy-value" in current.attrs: + return True + current = current.parent + return False + + +def selector_for(node: HtmlNode) -> str: + if identifier := node.attrs.get("id"): + return f"#{identifier}" + parts: list[str] = [] + current: HtmlNode | None = node + while current and current.tag != "document": + if identifier := current.attrs.get("id"): + parts.append(f"#{identifier}") + break + parent = current.parent + if parent is None: + parts.append(current.tag) + break + siblings = [child for child in parent.children if isinstance(child, HtmlNode) and child.tag == current.tag] + position = next(index for index, sibling in enumerate(siblings, 1) if sibling is current) + parts.append(f"{current.tag}:nth-of-type({position})") + current = parent + return " > ".join(reversed(parts)) + + +def placeholders(text: str) -> list[str]: + return PLACEHOLDER_PATTERN.findall(text) + + +def add_item(items: dict[str, dict[str, Any]], item_id: str, *, text: str, scope: str, + source: str, kind: str, render: dict[str, Any] | None = None) -> None: + if not text or item_id in items: + return + entry: dict[str, Any] = { + "text": text, + "scope": scope, + "source": source, + "kind": kind, + "placeholders": placeholders(text), + } + if render: + entry["render"] = render + items[item_id] = entry + + +def collect_html(html_path: Path, items: dict[str, dict[str, Any]], anchors: dict[str, dict[str, str]]) -> None: + parser = TreeParser() + parser.feed(html_path.read_text(encoding="utf-8")) + for node in iter_nodes(parser.root): + if node.tag in IGNORED_TAGS or copy_ignored(node): + continue + selector = selector_for(node) + scope = nearest_scope(node) + if node.tag in ANCHOR_TAGS: + anchor_id = stable_id("anchor", selector) + anchors[anchor_id] = {"selector": selector, "scope": scope, "tag": node.tag} + + text_position = 0 + default_hidden_text = normalized_text(node.attrs.get("data-ui-copy-default-text", "")) + if default_hidden_text: + item_id = stable_id("copy", "index.html", selector, "text", text_position) + add_item( + items, + item_id, + text=default_hidden_text, + scope=scope, + source=f"index.html::{selector}::text[{text_position}]", + kind="html_text", + render={ + "type": "static_text", + "selector": selector, + "text_index": text_position, + "default_hidden": True, + }, + ) + text_position += 1 + for child in node.children: + if not isinstance(child, str): + continue + text = normalized_text(child) + if not text or text == "-" or FUNCTIONAL_VALUE_PATTERN.fullmatch(text): + continue + item_id = stable_id("copy", "index.html", selector, "text", text_position) + add_item( + items, + item_id, + text=text, + scope=scope, + source=f"index.html::{selector}::text[{text_position}]", + kind="html_text", + render={"type": "static_text", "selector": selector, "text_index": text_position}, + ) + text_position += 1 + + for attribute in TEXT_ATTRIBUTES: + text = normalized_text(node.attrs.get(attribute, "")) + if not text: + continue + item_id = stable_id("copy", "index.html", selector, "attribute", attribute) + add_item( + items, + item_id, + text=text, + scope=scope, + source=f"index.html::{selector}::@{attribute}", + kind="html_attribute", + render={"type": "attribute", "selector": selector, "attribute": attribute}, + ) + + +def scan_javascript_literals(source: str) -> Iterable[tuple[int, str, str]]: + index = 0 + while index < len(source): + quote = source[index] + if quote not in {'"', "'", "`"}: + index += 1 + continue + start = index + index += 1 + value: list[str] = [] + expression_depth = 0 + placeholder_number = 0 + while index < len(source): + char = source[index] + if char == "\\": + if index + 1 < len(source): + value.extend((char, source[index + 1])) + index += 2 + continue + if quote == "`" and char == "$" and index + 1 < len(source) and source[index + 1] == "{": + placeholder_number += 1 + value.append(f"{{value{placeholder_number}}}") + index += 2 + expression_depth = 1 + inner_quote: str | None = None + while index < len(source) and expression_depth: + current = source[index] + if inner_quote: + if current == "\\": + index += 2 + continue + if current == inner_quote: + inner_quote = None + elif current in {'"', "'", "`"}: + inner_quote = current + elif current == "{": + expression_depth += 1 + elif current == "}": + expression_depth -= 1 + index += 1 + continue + if char == quote: + index += 1 + raw = "".join(value) + raw = raw.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t") + raw = raw.replace(f"\\{quote}", quote).replace("\\\\", "\\") + yield start, quote, raw + break + value.append(char) + index += 1 + + +def collect_dynamic(items: dict[str, dict[str, Any]]) -> None: + raw = json.loads(DYNAMIC_PATH.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("ui-copy-dynamic.json must be an object") + for item_id, value in raw.items(): + if not isinstance(value, dict) or set(value) - {"text", "scope", "source", "default_hidden"}: + raise ValueError(f"invalid dynamic UI copy item: {item_id}") + if not {"text", "scope", "source"} <= set(value) or type(value.get("default_hidden", False)) is not bool: + raise ValueError(f"invalid dynamic UI copy item: {item_id}") + add_item( + items, + item_id, + text=normalized_text(value["text"]), + scope=value["scope"], + source=value["source"], + kind="dynamic_template", + render={"type": "dynamic_template", **({"default_hidden": True} if value.get("default_hidden") else {})}, + ) + + +def build_catalog() -> dict[str, Any]: + items: dict[str, dict[str, Any]] = {} + anchors: dict[str, dict[str, str]] = {} + collect_html(STATIC_ROOT / "index.html", items, anchors) + collect_dynamic(items) + return { + "schema_version": 2, + "items": dict(sorted(items.items())), + "anchors": dict(sorted(anchors.items())), + "insertions": [], + } + + +def canonical_bytes(catalog: dict[str, Any]) -> bytes: + return (json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build or verify the UI copy catalog") + parser.add_argument("--check", action="store_true", help="fail when ui-copy.json is not current") + args = parser.parse_args() + content = canonical_bytes(build_catalog()) + if args.check: + if not OUTPUT_PATH.is_file() or OUTPUT_PATH.read_bytes() != content: + raise SystemExit("ui-copy.json is out of date; run scripts/build_ui_copy_catalog.py") + print("ui-copy.json is current") + return 0 + OUTPUT_PATH.write_bytes(content) + print(f"wrote {OUTPUT_PATH} ({len(json.loads(content)['items'])} items)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/capture_axp313a_state.sh b/核桃派软件源代码/scripts/capture_axp313a_state.sh new file mode 100644 index 0000000..f63f658 --- /dev/null +++ b/核桃派软件源代码/scripts/capture_axp313a_state.sh @@ -0,0 +1,104 @@ +#!/bin/sh +set -eu + +OUTPUT= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) OUTPUT=$2; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$(id -u)" -ne 0 ]; then + echo "state capture must run as root" >&2 + exit 1 +fi +if [ -z "$OUTPUT" ]; then + echo "usage: capture_axp313a_state.sh --output NEW_DIR" >&2 + exit 2 +fi +parent=$(dirname -- "$OUTPUT") +mkdir -p -- "$parent" +parent=$(CDPATH= cd -- "$parent" && pwd) +OUTPUT=$parent/$(basename -- "$OUTPUT") +case "$OUTPUT" in + /|/boot|/opt|/var|/var/lib|/var/lib/matrix-screen-controller|/run|/etc) + echo "unsafe state output directory: $OUTPUT" >&2 + exit 1 + ;; +esac +if [ -e "$OUTPUT" ]; then + echo "state output directory must not exist: $OUTPUT" >&2 + exit 1 +fi +install -d -m 0700 -- "$OUTPUT" + +uname -a > "$OUTPUT/uname.txt" +cat /proc/cmdline > "$OUTPUT/cmdline.txt" +(cd /boot && find . -maxdepth 1 -type f -print0 | sort -z | xargs -0 sha256sum) \ + > "$OUTPUT/boot.sha256" +(cd /var/lib && find matrix-screen-controller -type f -print0 | sort -z | xargs -0 sha256sum) \ + > "$OUTPUT/persistent.sha256" + +if [ -d /sys/kernel/debug/regmap ]; then + for registers in /sys/kernel/debug/regmap/*-0036/registers; do + [ -f "$registers" ] || continue + regmap_name=$(basename -- "$(dirname -- "$registers")") + cp -- "$registers" "$OUTPUT/pmic-$regmap_name-registers.txt" + done +fi + +: > "$OUTPUT/regulators.txt" +for regulator in /sys/class/regulator/regulator.*; do + [ -d "$regulator" ] || continue + printf '[%s]\n' "$(basename -- "$regulator")" >> "$OUTPUT/regulators.txt" + for field in name microvolts state status; do + if [ -r "$regulator/$field" ]; then + if value=$(cat "$regulator/$field" 2>/dev/null); then + printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/regulators.txt" + else + printf '%s=\n' "$field" >> "$OUTPUT/regulators.txt" + fi + fi + done +done + +: > "$OUTPUT/cpufreq.txt" +for policy in /sys/devices/system/cpu/cpufreq/policy*; do + [ -d "$policy" ] || continue + printf '[%s]\n' "$(basename -- "$policy")" >> "$OUTPUT/cpufreq.txt" + for field in affected_cpus related_cpus scaling_available_frequencies \ + scaling_available_governors scaling_cur_freq scaling_governor; do + if [ -r "$policy/$field" ]; then + if value=$(cat "$policy/$field" 2>/dev/null); then + printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/cpufreq.txt" + else + printf '%s=\n' "$field" >> "$OUTPUT/cpufreq.txt" + fi + fi + done +done +if [ -r /sys/kernel/debug/devices_deferred ]; then + cp -- /sys/kernel/debug/devices_deferred "$OUTPUT/devices_deferred.txt" +fi + +: > "$OUTPUT/gpu.txt" +for devfreq in /sys/class/devfreq/*; do + [ -d "$devfreq" ] || continue + case "$(basename -- "$devfreq")" in + *gpu*|*mali*) + printf '[%s]\n' "$(basename -- "$devfreq")" >> "$OUTPUT/gpu.txt" + for field in available_frequencies cur_freq governor min_freq max_freq; do + if [ -r "$devfreq/$field" ]; then + if value=$(cat "$devfreq/$field" 2>/dev/null); then + printf '%s=%s\n' "$field" "$value" >> "$OUTPUT/gpu.txt" + else + printf '%s=\n' "$field" >> "$OUTPUT/gpu.txt" + fi + fi + done + ;; + esac +done + +sync +echo "state captured at $OUTPUT" diff --git a/核桃派软件源代码/scripts/check_repository_hygiene.py b/核桃派软件源代码/scripts/check_repository_hygiene.py new file mode 100644 index 0000000..061c036 --- /dev/null +++ b/核桃派软件源代码/scripts/check_repository_hygiene.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import argparse +import ipaddress +import os +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + + +SCRIPT_PATH = Path(__file__).resolve() +WORKSPACE_ROOT = SCRIPT_PATH.parents[2] +PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt") +EXAMPLE_CREDENTIAL = PurePosixPath( + "测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt" +) +BINARY_EXTENSIONS = { + ".deb", + ".dll", + ".docx", + ".exe", + ".gif", + ".gz", + ".img", + ".jpeg", + ".jpg", + ".otf", + ".ota", + ".pdf", + ".png", + ".rar", + ".so", + ".ttf", + ".whl", + ".woff", + ".woff2", + ".zip", +} +PRIVATE_IP_ALLOWED_PREFIXES = ( + "整体开发需求/", + "测试相关资料/如何测试/", + "核桃派软件源代码/", + "发布更新相关/其他依赖/", +) +SAFE_SECRET_VALUES = { + "changeme", + "example", + "example123", + "fake", + "fake-secret", + "password", + "secret123", + "test", + "test-password", +} +# The password is a high-confidence private marker. Addresses, usernames and +# hostnames are checked structurally because common fixture values also occur in +# provisioning source and tests. +CURRENT_DEVICE_KEYS = ("密码",) +PUBLIC_IMAGE_KEYS = ( + "镜像默认用户名", + "镜像默认账户密码", + "镜像默认WiFi SSID", + "镜像默认WiFi密码", +) + + +class HygieneError(RuntimeError): + """Repository state cannot be audited safely.""" + + +def _git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + ("git", *arguments), + cwd=WORKSPACE_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=check, + ) + + +def _decode_paths(payload: bytes) -> list[PurePosixPath]: + return [ + PurePosixPath(item.decode("utf-8", errors="surrogateescape")) + for item in payload.split(b"\0") + if item + ] + + +def candidate_paths(staged: bool) -> list[PurePosixPath]: + if staged: + result = _git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z") + return _decode_paths(result.stdout) + tracked = _decode_paths(_git("ls-files", "-z").stdout) + untracked = _decode_paths(_git("ls-files", "--others", "--exclude-standard", "-z").stdout) + return sorted(set((*tracked, *untracked)), key=str) + + +def _is_binary(path: Path) -> bool: + if path.suffix.casefold() in BINARY_EXTENSIONS: + return True + try: + return b"\0" in path.read_bytes()[:8192] + except OSError as error: + raise HygieneError(f"无法读取候选文件:{path.relative_to(WORKSPACE_ROOT)}") from error + + +def _host_text_patterns() -> list[re.Pattern[str]]: + windows_prefix = r"[A-Za-z]:[\\/]" + r"(?:Users|Documents and Settings)[\\/]" + mac_prefix = r"/" + r"Users/[A-Za-z0-9._-]+/" + linux_home = r"/" + r"home/[A-Za-z0-9._-]+/" + patterns = [re.compile(windows_prefix, re.IGNORECASE), re.compile(mac_prefix), re.compile(linux_home)] + for value in (Path.home().name, os.environ.get("COMPUTERNAME", "")): + if value and len(value) >= 4: + patterns.append(re.compile(re.escape(value), re.IGNORECASE)) + return patterns + + +def _private_value_markers() -> tuple[str, ...]: + path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts) + if not path.is_file(): + return () + fields: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + separator = ":" if ":" in line else ":" if ":" in line else None + if separator is None: + continue + key, value = (part.strip() for part in line.split(separator, 1)) + fields[key] = value + public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS} + return tuple( + value + for key in CURRENT_DEVICE_KEYS + if len(value := fields.get(key, "")) >= 4 and value not in public_values + ) + + +def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]: + markers: list[bytes] = [] + host_values = { + str(Path.home()), + str(WORKSPACE_ROOT), + Path.home().name, + os.environ.get("COMPUTERNAME", ""), + } + for value in (*host_values, *private_values): + if value and len(value) >= 4: + variants = {value, value.replace("\\", "/")} + for variant in variants: + markers.extend((variant.encode("utf-8"), variant.encode("utf-16le"))) + return markers + + +def _binary_contains_forbidden_marker(path: Path, private_values: tuple[str, ...]) -> bool: + markers = _binary_markers(private_values) + overlap = max(map(len, markers)) - 1 + tail = b"" + with path.open("rb") as stream: + while chunk := stream.read(8 * 1024 * 1024): + sample = tail + chunk + lowered_sample = sample.lower() + if any(marker.lower() in lowered_sample for marker in markers): + return True + tail = sample[-overlap:] if overlap else b"" + return False + + +def _text_issues( + relative: PurePosixPath, + text: str, + private_values: tuple[str, ...] = (), +) -> list[str]: + issues: list[str] = [] + if any(pattern.search(text) for pattern in _host_text_patterns()): + issues.append("包含开发电脑专属路径、用户名或主机名") + if any(value in text for value in private_values): + issues.append("包含当前设备私有凭据值") + + key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----") + if key_header.search(text): + issues.append("包含私钥正文") + + relative_string = relative.as_posix() + if not ( + relative_string.startswith("核桃派软件源代码/tests/") + or PurePosixPath(relative_string).name.startswith("test_") + or relative == EXAMPLE_CREDENTIAL + ): + assignment = re.compile( + r"(?i)(?:password|passwd|token|secret|api[_-]?key|密码)\s*[:=]\s*[\"']([^\"']{4,})[\"']" + ) + for match in assignment.finditer(text): + if match.group(1).casefold() not in SAFE_SECRET_VALUES: + issues.append("包含疑似硬编码秘密") + break + + if not ( + relative_string.startswith(PRIVATE_IP_ALLOWED_PREFIXES) + or "/tests/" in f"/{relative_string}" + ): + for token in re.findall(r"(? list[tuple[str, str]]: + findings: list[tuple[str, str]] = [] + private_values = _private_value_markers() + for relative in paths: + if relative == PRIVATE_CREDENTIAL: + findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合")) + continue + path = WORKSPACE_ROOT.joinpath(*relative.parts) + if not path.is_file(): + continue + if _is_binary(path): + if scan_binary and _binary_contains_forbidden_marker(path, private_values): + findings.append((relative.as_posix(), "二进制包含开发电脑标识、主目录路径或当前设备秘密")) + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + findings.append((relative.as_posix(), "文本候选不是有效 UTF-8")) + continue + findings.extend( + (relative.as_posix(), issue) for issue in _text_issues(relative, text, private_values) + ) + return findings + + +def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list[tuple[str, str]]: + findings: list[tuple[str, str]] = [] + ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False) + if ignored.returncode != 0: + findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除")) + + if staged: + example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False) + if example_in_index.returncode != 0: + findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据没有进入本次提交")) + elif EXAMPLE_CREDENTIAL not in paths: + findings.append((EXAMPLE_CREDENTIAL.as_posix(), "示例凭据不在 Git 候选集合")) + + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description="检查 Git 候选文件中的凭据、主机路径和秘密") + parser.add_argument("--staged", action="store_true", help="只检查已暂存的新建或修改文件") + parser.add_argument( + "--skip-binary-scan", + action="store_true", + help="跳过大体积二进制字节扫描,仅供快速诊断", + ) + args = parser.parse_args() + try: + paths = candidate_paths(args.staged) + findings = _check_repository_contract(paths, args.staged) + findings.extend(audit_paths(paths, scan_binary=not args.skip_binary_scan)) + except (HygieneError, OSError, subprocess.CalledProcessError) as error: + print(f"仓库卫生检查无法完成:{error}", file=sys.stderr) + return 2 + + if findings: + print("仓库卫生检查失败;以下输出只包含文件路径和问题类型:", file=sys.stderr) + for path, issue in findings: + print(f"- {path}: {issue}", file=sys.stderr) + return 1 + print(f"仓库卫生检查通过:已检查 {len(paths)} 个 Git 候选文件,未输出任何秘密值。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/debian_closure.py b/核桃派软件源代码/scripts/debian_closure.py new file mode 100644 index 0000000..6ed2425 --- /dev/null +++ b/核桃派软件源代码/scripts/debian_closure.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from collections import deque +from dataclasses import dataclass +import hashlib +from io import BytesIO +import json +import lzma +from pathlib import Path +import re +import tarfile +from urllib.parse import urljoin +from urllib.request import urlopen + + +PACKAGE_NAME = re.compile(r"^([a-z0-9][a-z0-9+.-]*(?::(?:any|native|arm64))?)") +ARCH_FILTER = re.compile(r"\[([^]]+)]") +VERSION_FILTER = re.compile(r"\((=|>=|<=|>>|<<)\s*([^)\s]+)\)") +IMAGE_DEBIAN_ROOTS = ("ffmpeg", "libheif-examples", "python3.11-venv") + + +@dataclass(frozen=True) +class Dependency: + name: str + operator: str = "" + version: str = "" + + +@dataclass(frozen=True) +class PackageRecord: + name: str + version: str + architecture: str + dependencies: tuple[tuple[Dependency, ...], ...] + provides: tuple[str, ...] + filename: str = "" + sha256: str = "" + size: int = 0 + repository_base: str = "" + local_path: Path | None = None + + +def normalize_package_name(value: str) -> str: + value = value.strip() + if ":" in value: + name, qualifier = value.rsplit(":", 1) + if qualifier in {"any", "native", "arm64"}: + return name + return value + + +def _arch_applies(value: str, architecture: str = "arm64") -> bool: + match = ARCH_FILTER.search(value) + if not match: + return True + filters = match.group(1).split() + positives = {item for item in filters if not item.startswith("!")} + negatives = {item[1:] for item in filters if item.startswith("!")} + return architecture not in negatives and (not positives or architecture in positives) + + +def parse_dependency_field(value: str) -> tuple[tuple[Dependency, ...], ...]: + groups: list[tuple[Dependency, ...]] = [] + for raw_group in value.split(","): + alternatives: list[Dependency] = [] + for raw_alternative in raw_group.split("|"): + if not _arch_applies(raw_alternative): + continue + match = PACKAGE_NAME.match(raw_alternative.strip()) + if not match: + continue + version_match = VERSION_FILTER.search(raw_alternative) + alternatives.append( + Dependency( + normalize_package_name(match.group(1)), + version_match.group(1) if version_match else "", + version_match.group(2) if version_match else "", + ) + ) + if alternatives: + groups.append(tuple(alternatives)) + return tuple(groups) + + +def parse_control_stanzas(text: str) -> list[dict[str, str]]: + stanzas: list[dict[str, str]] = [] + current: dict[str, str] = {} + current_key = "" + for line in text.splitlines() + [""]: + if not line: + if current: + stanzas.append(current) + current = {} + current_key = "" + continue + if line[0].isspace() and current_key: + current[current_key] += " " + line.strip() + continue + key, separator, value = line.partition(":") + if separator: + current_key = key + current[key] = value.strip() + return stanzas + + +def record_from_fields( + fields: dict[str, str], *, repository_base: str = "", local_path: Path | None = None +) -> PackageRecord: + dependencies = ", ".join( + value for key in ("Pre-Depends", "Depends") if (value := fields.get(key, "")) + ) + provides = tuple( + normalize_package_name(item.strip().split()[0]) + for item in fields.get("Provides", "").split(",") + if item.strip() + ) + return PackageRecord( + name=normalize_package_name(fields["Package"]), + version=fields["Version"], + architecture=fields.get("Architecture", "arm64"), + dependencies=parse_dependency_field(dependencies), + provides=provides, + filename=fields.get("Filename", ""), + sha256=fields.get("SHA256", "").lower(), + size=int(fields.get("Size", "0")), + repository_base=repository_base, + local_path=local_path, + ) + + +def load_package_index(path: Path, repository_base: str) -> dict[str, PackageRecord]: + path = Path(path) + if path.suffix == ".xz": + with lzma.open(path, "rt", encoding="utf-8") as handle: + text = handle.read() + else: + text = path.read_text(encoding="utf-8") + records: dict[str, PackageRecord] = {} + for fields in parse_control_stanzas(text): + if fields.get("Architecture") not in {"arm64", "all"}: + continue + record = record_from_fields(fields, repository_base=repository_base) + records.setdefault(record.name, record) + return records + + +def _read_ar_member(path: Path, wanted_prefix: str) -> bytes: + data = Path(path).read_bytes() + if not data.startswith(b"!\n"): + raise ValueError(f"not a Debian ar archive: {path}") + offset = 8 + while offset + 60 <= len(data): + header = data[offset : offset + 60] + name = header[:16].decode("ascii").strip().rstrip("/") + size = int(header[48:58].decode("ascii").strip()) + start = offset + 60 + end = start + size + if name.startswith(wanted_prefix): + return data[start:end] + offset = end + (size % 2) + raise ValueError(f"{wanted_prefix} is missing from {path}") + + +def read_deb_control(path: Path) -> dict[str, str]: + control_archive = _read_ar_member(path, "control.tar") + with tarfile.open(fileobj=BytesIO(control_archive), mode="r:*") as archive: + member = next( + (item for item in archive.getmembers() if item.name.lstrip("./") == "control"), + None, + ) + if member is None: + raise ValueError(f"control file is missing from {path}") + handle = archive.extractfile(member) + if handle is None: + raise ValueError(f"control file cannot be read from {path}") + stanzas = parse_control_stanzas(handle.read().decode("utf-8")) + if len(stanzas) != 1: + raise ValueError(f"unexpected control data in {path}") + return stanzas[0] + + +def load_local_packages(directory: Path) -> dict[str, PackageRecord]: + records: dict[str, PackageRecord] = {} + for path in sorted(Path(directory).glob("*.deb")): + record = record_from_fields(read_deb_control(path), local_path=path.resolve()) + if record.name in records: + raise ValueError(f"duplicate package {record.name} in {directory}") + records[record.name] = record + return records + + +def load_installed_inventory(path: Path) -> dict[str, str]: + installed: dict[str, str] = {} + for number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1): + if not line: + continue + parts = line.split("\t") + if len(parts) != 3: + raise ValueError(f"invalid inventory line {number}") + status, name, version = parts + if status == "ii ": + installed[normalize_package_name(name)] = version + return installed + + +def _exact_version_matches(dependency: Dependency, version: str) -> bool: + return dependency.operator != "=" or dependency.version == version + + +def resolve_closure( + roots: list[str], + installed: dict[str, str], + local: dict[str, PackageRecord], + available: dict[str, PackageRecord], +) -> tuple[dict[str, PackageRecord], list[str]]: + installed = dict(installed) + for name in local: + installed.pop(name, None) + providers: dict[str, list[PackageRecord]] = {} + for record in [*local.values(), *available.values()]: + for provided in record.provides: + providers.setdefault(provided, []).append(record) + selected: dict[str, PackageRecord] = {} + queue: deque[str] = deque(normalize_package_name(item) for item in roots) + unresolved: list[str] = [] + + def candidate_for(dependency: Dependency) -> PackageRecord | None: + direct = local.get(dependency.name) or available.get(dependency.name) + if direct and _exact_version_matches(dependency, direct.version): + return direct + for provider in providers.get(dependency.name, []): + if _exact_version_matches(dependency, provider.version): + return provider + return None + + while queue: + name = queue.popleft() + if name in selected: + continue + record = local.get(name) or available.get(name) + if record is None: + unresolved.append(f"required package is unavailable: {name}") + continue + selected[name] = record + for group in record.dependencies: + if any( + dependency.name in installed + and _exact_version_matches(dependency, installed[dependency.name]) + for dependency in group + ): + continue + if any( + dependency.name in selected + and _exact_version_matches(dependency, selected[dependency.name].version) + for dependency in group + ): + continue + choice = next((candidate_for(dependency) for dependency in group if candidate_for(dependency)), None) + if choice is None: + unresolved.append( + f"{record.name} cannot satisfy: " + + " | ".join( + f"{item.name} {item.operator} {item.version}".strip() for item in group + ) + ) + continue + queue.append(choice.name) + return selected, sorted(set(unresolved)) + + +def verify_local_closure(directory: Path, inventory: Path, roots: list[str]) -> dict[str, PackageRecord]: + local = load_local_packages(directory) + selected, unresolved = resolve_closure(roots, load_installed_inventory(inventory), local, {}) + missing = sorted(set(roots) - set(selected)) + if missing or unresolved: + detail = "; ".join([*(f"missing root: {item}" for item in missing), *unresolved]) + raise ValueError(f"offline Debian dependency closure is incomplete: {detail}") + return selected + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as handle: + for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def download_records(records: dict[str, PackageRecord], output: Path) -> list[Path]: + output = Path(output) + output.mkdir(parents=True, exist_ok=True) + downloaded: list[Path] = [] + for record in sorted(records.values(), key=lambda item: item.name): + if record.local_path is not None: + continue + if not record.filename or not record.sha256 or not record.size: + raise ValueError(f"repository metadata is incomplete for {record.name}") + target = output / Path(record.filename).name + if target.exists(): + if target.stat().st_size != record.size or _sha256_file(target) != record.sha256: + raise ValueError(f"existing download does not match repository metadata: {target}") + downloaded.append(target) + continue + url = urljoin(record.repository_base.rstrip("/") + "/", record.filename) + partial = target.with_suffix(target.suffix + ".partial") + with urlopen(url, timeout=60) as response, partial.open("wb") as handle: + while chunk := response.read(1024 * 1024): + handle.write(chunk) + if partial.stat().st_size != record.size or _sha256_file(partial) != record.sha256: + partial.unlink(missing_ok=True) + raise ValueError(f"download verification failed: {record.name}") + partial.replace(target) + downloaded.append(target) + return downloaded + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve a minimal Debian 12/AArch64 offline closure") + parser.add_argument("--inventory", type=Path, required=True) + parser.add_argument("--local", type=Path, required=True) + parser.add_argument("--index", action="append", default=[], metavar="PATH=REPOSITORY_BASE") + parser.add_argument("--root", action="append", required=True) + parser.add_argument("--download-to", type=Path) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + + available: dict[str, PackageRecord] = {} + index_info: list[dict[str, str]] = [] + for value in args.index: + path_text, separator, repository_base = value.partition("=") + if not separator: + parser.error("--index must be PATH=REPOSITORY_BASE") + path = Path(path_text).resolve() + for name, record in load_package_index(path, repository_base).items(): + available.setdefault(name, record) + index_info.append({"file": path.name, "sha256": _sha256_file(path), "base": repository_base}) + + local = load_local_packages(args.local) + selected, unresolved = resolve_closure( + args.root, load_installed_inventory(args.inventory), local, available + ) + if unresolved: + raise SystemExit("\n".join(unresolved)) + downloaded = download_records(selected, args.download_to) if args.download_to else [] + report = { + "schema_version": 1, + "architecture": "arm64", + "roots": args.root, + "inventory_sha256": _sha256_file(args.inventory), + "indices": index_info, + "selected": [ + { + "package": item.name, + "version": item.version, + "source": "local" if item.local_path else item.filename, + "sha256": _sha256_file(item.local_path) if item.local_path else item.sha256, + "size": item.local_path.stat().st_size if item.local_path else item.size, + } + for item in sorted(selected.values(), key=lambda record: record.name) + ], + "downloaded_files": [item.name for item in downloaded], + } + rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n" + if args.report: + args.report.write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/dedicated_host.py b/核桃派软件源代码/scripts/dedicated_host.py new file mode 100644 index 0000000..7cd5bc6 --- /dev/null +++ b/核桃派软件源代码/scripts/dedicated_host.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable, Sequence + + +EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl" +DISABLED_SERVICES = ( + "lightdm.service", + "bluetooth.service", + "aw859-bluetooth.service", + "gpioc-server.service", + "map_device.service", +) + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: str = "" + stderr: str = "" + + +@dataclass(frozen=True) +class HostPaths: + pinctrl_compatible: Path + dev_mem: Path + i2c_device: Path + cpu3: Path + native_library: Path + + @classmethod + def detect( + cls, + root: Path = Path("/"), + source_root: Path | None = None, + ) -> "HostPaths": + source_root = source_root or Path(__file__).resolve().parents[1] + compatible = ( + root + / "proc" + / "device-tree" + / "soc" + / "pinctrl@300b000" + / "compatible" + ) + return cls( + pinctrl_compatible=compatible, + dev_mem=root / "dev" / "mem", + i2c_device=root / "dev" / "i2c-1", + cpu3=root / "sys" / "devices" / "system" / "cpu" / "cpu3", + native_library=( + source_root / "app" / "display" / "native" / "libh618_hub75.so" + ), + ) + + +@dataclass(frozen=True) +class HostCheck: + name: str + ok: bool + detail: str + + +Runner = Callable[[Sequence[str]], CommandResult] + + +def run_command(args: Sequence[str]) -> CommandResult: + completed = subprocess.run( + list(args), + check=False, + capture_output=True, + text=True, + ) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + +def _compatible_values(path: Path) -> tuple[str, ...]: + if not path.is_file(): + return () + return tuple( + value.decode("ascii", errors="replace") + for value in path.read_bytes().split(b"\0") + if value + ) + + +def check_profile( + paths: HostPaths, + *, + service_only: bool = False, + runner: Runner = run_command, +) -> list[HostCheck]: + compatible = _compatible_values(paths.pinctrl_compatible) + architecture = runner(("uname", "-m")) + network_manager = runner(("systemctl", "is-active", "NetworkManager.service")) + + checks = [ + HostCheck( + "H618 PI pinctrl", + EXPECTED_PINCTRL in compatible, + ", ".join(compatible) if compatible else "compatible node missing", + ), + HostCheck( + "/dev/mem accessible", + paths.dev_mem.exists() + and os.access(paths.dev_mem, os.R_OK | os.W_OK), + str(paths.dev_mem), + ), + HostCheck( + "/dev/i2c-1 present", + paths.i2c_device.exists(), + str(paths.i2c_device), + ), + HostCheck("CPU3 present", paths.cpu3.is_dir(), str(paths.cpu3)), + HostCheck( + "native HUB75 library", + paths.native_library.is_file(), + str(paths.native_library), + ), + HostCheck( + "AArch64 userspace", + architecture.returncode == 0 and architecture.stdout.strip() == "aarch64", + architecture.stdout.strip() or architecture.stderr.strip(), + ), + HostCheck( + "NetworkManager active", + network_manager.returncode == 0 + and network_manager.stdout.strip() == "active", + network_manager.stdout.strip() or network_manager.stderr.strip(), + ), + ] + if not service_only: + route = runner(("ip", "route", "show", "default")) + checks.insert( + -1, + HostCheck( + "default network route", + route.returncode == 0 and bool(route.stdout.strip()), + route.stdout.strip() or route.stderr.strip(), + ), + ) + for unit in DISABLED_SERVICES: + state = runner(("systemctl", "is-active", unit)) + checks.append( + HostCheck( + f"{unit} inactive", + state.stdout.strip() != "active", + state.stdout.strip() or state.stderr.strip() or "inactive", + ) + ) + return checks + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Verify the dedicated WalnutPi ZeroW/H618 host profile." + ) + parser.add_argument("command", choices=("check",)) + parser.add_argument("--service", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + checks = check_profile(HostPaths.detect(), service_only=args.service) + if args.json: + print(json.dumps([asdict(check) for check in checks], ensure_ascii=False)) + else: + for check in checks: + print(f"{'PASS' if check.ok else 'FAIL'}: {check.name}: {check.detail}") + return 0 if all(check.ok for check in checks) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/核桃派软件源代码/scripts/deploy_walnutpi.sh b/核桃派软件源代码/scripts/deploy_walnutpi.sh new file mode 100644 index 0000000..1ac7623 --- /dev/null +++ b/核桃派软件源代码/scripts/deploy_walnutpi.sh @@ -0,0 +1,136 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "deploy_walnutpi.sh must run as root" >&2 + exit 1 +fi + +SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd) +WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311} +FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64} +DEFER_SERVICE_START=${DEFER_SERVICE_START:-0} +MANIFEST=$WHEELHOUSE/SHA256SUMS +TARGET=/opt/matrix-screen-controller +UNIT=/etc/systemd/system/matrix-screen-controller.service +OTA_UNIT=/etc/systemd/system/matrix-screen-controller-ota.service +CONVERTER_UNIT=/etc/systemd/system/matrix-screen-converter@.service + +if [ "$(uname -m)" != "aarch64" ]; then + echo "this payload is only for the WalnutPi AArch64 host" >&2 + exit 1 +fi +for command in ffmpeg ffprobe heif-convert flock; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required media decoder command is missing: $command" >&2 + echo "install Debian packages ffmpeg and libheif-examples before deployment" >&2 + exit 1 + fi +done +FILTERS=$(ffmpeg -hide_banner -filters 2>/dev/null) +for filter in zscale tonemap; do + if ! printf '%s\n' "$FILTERS" | grep -Eq "[[:space:]]$filter[[:space:]]"; then + echo "required FFmpeg filter is missing: $filter" >&2 + exit 1 + fi +done +if [ ! -f "$MANIFEST" ]; then + echo "offline wheel manifest missing: $MANIFEST" >&2 + exit 1 +fi +if [ -e "$TARGET" ]; then + if [ "$DEFER_SERVICE_START" != "1" ]; then + echo "$TARGET already exists; this fresh-install script will not overwrite it" >&2 + exit 1 + fi + if [ ! -f "$TARGET/VERSION" ] || [ "$(cat "$TARGET/VERSION")" != "$(cat "$SOURCE_ROOT/VERSION")" ]; then + echo "$TARGET exists but is not the same software version; refusing first-boot recovery" >&2 + exit 1 + fi + for path in \ + "$TARGET/.venv/bin/python" \ + "$TARGET/scripts/dedicated_host.py" \ + "$TARGET/app/display/native/libh618_hub75.so" \ + "$TARGET/app/display/native/hub75_safeoff" \ + "$TARGET/systemd/matrix-screen-controller.service" \ + "$TARGET/systemd/matrix-screen-controller-ota.service" \ + "$TARGET/systemd/matrix-screen-converter@.service"; do + if [ ! -e "$path" ]; then + echo "incomplete first-boot deployment cannot be recovered: $path" >&2 + exit 1 + fi + done + FRPC_BUNDLE="$FRPC_BUNDLE" DEFER_SERVICE_START=1 /bin/sh "$TARGET/scripts/install_frpc_system.sh" + "$TARGET/.venv/bin/python" "$TARGET/scripts/dedicated_host.py" check --service + install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT" + install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT" + install -m 0644 "$TARGET/systemd/matrix-screen-converter@.service" "$CONVERTER_UNIT" + systemctl daemon-reload + systemctl enable matrix-screen-controller.service + systemctl is-enabled --quiet matrix-screen-controller.service + echo "Recovered the verified same-version deployment; service start is deferred until reboot" + exit 0 +fi + +(cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS) +STAGING=$(mktemp -d /opt/matrix-screen-controller.stage.XXXXXX) +cleanup() { + case ${STAGING:-} in + /opt/matrix-screen-controller.stage.*) + if [ -d "$STAGING" ]; then + rm -rf -- "$STAGING" + fi + ;; + esac +} +trap cleanup EXIT HUP INT TERM + +cp -a "$SOURCE_ROOT/." "$STAGING/" +python3 -m venv "$STAGING/.venv" +"$STAGING/.venv/bin/pip" install \ + --no-index \ + --find-links "$WHEELHOUSE" \ + -r "$STAGING/requirements.txt" +make -C "$STAGING/app/display/native" clean all test +systemctl set-default multi-user.target +systemctl disable --now \ + lightdm.service \ + bluetooth.service \ + aw859-bluetooth.service \ + gpioc-server.service \ + map_device.service +if [ "$DEFER_SERVICE_START" = "1" ]; then + "$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service +else + "$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check +fi + +install -d -m 0711 /var/lib/matrix-screen-controller +install -d -m 0750 /run/matrix-screen-controller +mv -- "$STAGING" "$TARGET" +STAGING= +FRPC_BUNDLE="$FRPC_BUNDLE" DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$TARGET/scripts/install_frpc_system.sh" +install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT" +install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT" +install -m 0644 "$TARGET/systemd/matrix-screen-converter@.service" "$CONVERTER_UNIT" +systemctl daemon-reload +if [ "$DEFER_SERVICE_START" = "1" ]; then + systemctl enable matrix-screen-controller.service + systemctl is-enabled --quiet matrix-screen-controller.service + echo "WalnutPi Matrix Screen Controller installed; service start is deferred until reboot" + exit 0 +fi +systemctl enable --now matrix-screen-controller.service + +attempt=0 +while [ "$attempt" -lt 30 ]; do + if curl --fail --silent http://127.0.0.1:8080/api/status >/dev/null; then + break + fi + attempt=$((attempt + 1)) + sleep 1 +done +systemctl --no-pager --full status matrix-screen-controller.service +curl --fail --silent --show-error http://127.0.0.1:8080/api/status >/dev/null +echo "WalnutPi Matrix Screen Controller deployed successfully" diff --git a/核桃派软件源代码/scripts/export_release.py b/核桃派软件源代码/scripts/export_release.py new file mode 100644 index 0000000..824b0f2 --- /dev/null +++ b/核桃派软件源代码/scripts/export_release.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import uuid + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +if str(SOURCE_ROOT) not in sys.path: + sys.path.insert(0, str(SOURCE_ROOT)) + +from app.ota.package import build_package, extract_payload +from app.ota.versioning import SoftwareVersion, read_software_version +from app.ota.policy import export_bundle, package_format, release_metadata, read_policy +from scripts.build_sd_image import build_image, sha256_file +from scripts.fat16_image import Fat16Image +from scripts.image_config import create_config_file, read_config_file + + +def next_patch(version: SoftwareVersion) -> SoftwareVersion: + return SoftwareVersion(version.major, version.minor, version.patch + 1) + + +def _image_readme(version: SoftwareVersion, manifest: dict, notes: str, config: dict) -> str: + return ( + f"# 奇妙小屏幕控制器 IMAGE {version}\n\n" + f"- 软件版本:`{version}`\n" + f"- 导出时间:`{manifest['created_at']}`\n" + f"- 说明:{notes.strip()}\n" + f"- 产物:`{manifest['artifact']}`\n" + f"- SHA-256:`{manifest['image_sha256']}`\n\n" + "## 未修改镜像的默认设置\n\n" + f"- Linux 用户名:`{config['account']['username']}`\n" + f"- Linux 密码:`{config['account']['password']}`\n" + f"- Wi-Fi SSID:`{config['wifi']['ssid']}`\n" + f"- Wi-Fi 密码:`{config['wifi']['password']}`\n" + "- IPv4:`DHCP`\n\n" + "> 这些是公开的默认凭据。建议刷写前使用镜像编辑器修改;直接使用未修改镜像时," + "请按上述 SSID 和密码开启热点,并在首次登录后立即更换账户及 Wi-Fi 设置。\n\n" + "首次启动会离线安装全部软件并自动重启一次。请等待设备重新连接热点后再通过 SSH 或网页访问。\n\n" + "本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n" + ) + + +def _ota_readme(version: SoftwareVersion, manifest: dict, notes: str) -> str: + metadata = release_metadata(version) + return ( + f"# 奇妙小屏幕控制器 OTA {version}\n\n" + f"- 软件版本:`{version}`\n" + f"- 导出时间:`{manifest['created_at']}`\n" + f"- 说明:{notes.strip()}\n" + f"- 产物:`{manifest['artifact']}`\n\n" + f"- 包类型:{'软件安装包(必经升级版本)' if metadata['is_checkpoint'] else '应用更新包'}\n" + f"- 前置系列基线:`{metadata['required_checkpoint']}`\n\n" + "在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。" + "1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。" + "初次安装默认关闭。禁止跳过失败测试;失败自动恢复。\n\n" + "本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。\n" + ) + + +def _copy_source(source: Path, destination: Path, version: SoftwareVersion) -> None: + ignored = shutil.ignore_patterns(".venv", "__pycache__", ".pytest_cache", "node_modules", "data", "output", ".playwright-cli", "system-dependencies") + shutil.copytree(source, destination, ignore=ignored) + (destination / "VERSION").write_text(f"{version}\n", encoding="utf-8", newline="\n") + + +def _history(path: Path) -> dict: + if path.is_file(): + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or document.get("schema_version") != 1 or not isinstance(document.get("releases"), list): + raise ValueError("发布记录.json is invalid") + return document + return { + "schema_version": 1, + "releases": [ + { + "version": "1.0.1", + "artifact_type": "ota", + "created_at": "2026-08-13T15:31:23+08:00", + "notes": "新增浏览器全量 OTA。", + "artifact_path": "发布更新相关/OTA数据包/1.0.1/matrix-screen-controller-1.0.1.ota", + "artifact_sha256": sha256_file(path.parent / "发布更新相关" / "OTA数据包" / "1.0.1" / "matrix-screen-controller-1.0.1.ota"), + } + ], + } + + +def _atomic_json(path: Path, document: dict) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") + os.replace(temporary, path) + + +def _atomic_bytes(path: Path, body: bytes) -> None: + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + temporary.write_bytes(body) + os.replace(temporary, path) + + +def _require_image_host() -> None: + if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0: + raise ValueError("image export must run as root on Linux") + missing = [name for name in ("bash", "losetup", "mount", "umount", "systemctl", "sha256sum") if shutil.which(name) is None] + if missing: + raise ValueError(f"image export host is missing required commands: {', '.join(missing)}") + + +def _validated_repair_input( + final_directory: Path, + history: dict, + version: SoftwareVersion, +) -> tuple[bytes, int]: + manifest_path = final_directory / "manifest.json" + if not manifest_path.is_file(): + raise ValueError("current image release manifest is missing") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + artifact_name = f"matrix-screen-controller-{version}.img" + artifact = final_directory / artifact_name + sidecar = final_directory / f"{artifact_name}.sha256" + if ( + manifest.get("artifact_type") != "image" + or manifest.get("software_version") != str(version) + or manifest.get("artifact") != artifact_name + or not artifact.is_file() + or not sidecar.is_file() + ): + raise ValueError("current image release files do not match the repair target") + digest = sha256_file(artifact) + expected_sidecar = f"{digest} {artifact_name}\n".encode("ascii") + if sidecar.read_bytes() != expected_sidecar or manifest.get("image_sha256") != digest: + raise ValueError("current image release digest is inconsistent") + matches = [ + (index, item) + for index, item in enumerate(history["releases"]) + if item.get("version") == str(version) + ] + if len(matches) != 1: + raise ValueError("current version must have exactly one release record before repair") + index, record = matches[0] + expected_path = f"发布更新相关/导出包/{version}/{artifact_name}" + if ( + record.get("artifact_type") != "image" + or record.get("artifact_path") != expected_path + or record.get("artifact_sha256") != digest + ): + raise ValueError("current image release record is inconsistent") + with Fat16Image(artifact) as image: + config_bytes = image.read_file("MSCCFG.BIN") + config, _, _ = read_config_file(config_bytes) + if config["software_version"] != str(version): + raise ValueError("current image configuration version does not match VERSION") + return config_bytes, index + + +def _commit_repair( + final_directory: Path, + temporary_directory: Path, + history_path: Path, + history: dict, +) -> Path | None: + backup = final_directory.parent / f".{final_directory.name}.{uuid.uuid4().hex}.invalid-backup" + os.replace(final_directory, backup) + try: + os.replace(temporary_directory, final_directory) + _atomic_json(history_path, history) + except BaseException: + if final_directory.exists(): + shutil.rmtree(final_directory, ignore_errors=True) + os.replace(backup, final_directory) + raise + shutil.rmtree(backup, ignore_errors=True) + return backup if backup.exists() else None + + +def main() -> int: + parser = argparse.ArgumentParser(description="Export one versioned OTA or SD image release") + parser.add_argument("kind", choices=("ota", "image")) + parser.add_argument("--notes", required=True) + parser.add_argument("--version", type=SoftwareVersion.parse, help="explicit release version, e.g. 1.1.0") + parser.add_argument("--config", type=Path, help="required JSON provisioning config for image export") + parser.add_argument("--candidate-output", type=Path, help="build a verified image candidate directory without publishing") + parser.add_argument("--validated-candidate", type=Path, help="exact real-device validated OTA file or image candidate directory") + parser.add_argument("--validation-report", type=Path, help="matching real-device validation JSON") + parser.add_argument( + "--repair-current", + action="store_true", + help="rebuild and atomically replace the registered current-version image", + ) + args = parser.parse_args() + if args.repair_current and args.version is not None: + parser.error("--repair-current cannot be combined with --version") + if args.kind == "ota" and args.repair_current: + if args.validated_candidate is None or args.validation_report is None: + parser.error("OTA repair requires --validated-candidate and --validation-report") + from scripts.repair_ota_release import promote + print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes)) + return 0 + image_promotion = args.kind == "image" and args.validated_candidate is not None + if image_promotion: + if args.validation_report is None: + parser.error("image candidate promotion requires --validation-report") + if args.config is not None or args.version is not None or args.candidate_output is not None or args.repair_current: + parser.error("image candidate promotion cannot use --config, --version, --candidate-output, or --repair-current") + try: + _require_image_host() + except ValueError as exc: + parser.error(str(exc)) + from scripts.promote_image_release import promote + print(promote(SOURCE_ROOT, args.validated_candidate, args.validation_report, args.notes)) + return 0 + if args.validated_candidate is not None or args.validation_report is not None: + parser.error("candidate validation options require OTA --repair-current or image promotion") + if args.kind == "image" and args.config is None and not args.repair_current: + parser.error("--config is required for image candidate export") + if args.kind == "image" and not args.repair_current and args.candidate_output is None: + parser.error("new image releases must use --candidate-output and pass real-card validation before publishing") + if args.kind == "ota" and args.candidate_output is not None: + parser.error("--candidate-output is only supported for image export") + if args.repair_current and args.config is not None: + parser.error("--repair-current reuses the registered image configuration; do not pass --config") + if args.kind == "image": + try: + _require_image_host() + except ValueError as exc: + parser.error(str(exc)) + + source = SOURCE_ROOT + project = source.parent + lock = project / ".release-export.lock" + try: + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + parser.error("another release export is already active") + os.close(descriptor) + try: + current = read_software_version(source) + target = current if args.repair_current else (args.version or next_patch(current)) + dependency_bundle = None + if not args.repair_current and args.kind == "ota": + dependency_bundle = export_bundle(source, project / "发布更新相关" / "其他依赖", current, target) + if args.kind == "image" and not args.repair_current and target <= current: + parser.error("image candidate version must be newer than the current version") + output_root = project / "发布更新相关" / "OTA数据包" if args.kind == "ota" else project / "发布更新相关" / "导出包" + official_directory = output_root / str(target) + final_directory = args.candidate_output.resolve() if args.candidate_output is not None else official_directory + if args.candidate_output is not None: + try: + final_directory.relative_to(output_root.resolve()) + except ValueError: + pass + else: + parser.error("candidate output must be outside the formal image release directory") + if final_directory.exists() and not args.repair_current: + parser.error(f"release directory already exists: {final_directory}") + if official_directory.exists() and args.candidate_output is not None: + parser.error(f"formal image release directory already exists: {official_directory}") + if args.repair_current and not final_directory.is_dir(): + parser.error(f"current image release directory is missing: {final_directory}") + output_root.mkdir(parents=True, exist_ok=True) + history_path = project / "发布记录.json" + history = _history(history_path) + policy = read_policy(source) + for previous in history["releases"]: + recorded = previous.get("component_checkpoints", []) + if any(entry not in policy["checkpoints"] for entry in recorded): + parser.error("已发布的依赖节点不可改写;请追加新的 minor .0 节点") + history_original = history_path.read_bytes() if history_path.is_file() else None + version_original = (source / "VERSION").read_bytes() + repair_record_index: int | None = None + repair_config: bytes | None = None + if args.repair_current: + try: + repair_config, repair_record_index = _validated_repair_input( + final_directory, history, target + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + parser.error(str(exc)) + build_parent = final_directory.parent if args.candidate_output is not None else output_root + build_parent.mkdir(parents=True, exist_ok=True) + temporary_directory = build_parent / f".{final_directory.name}.{uuid.uuid4().hex}.building" + temporary_directory.mkdir() + published = False + replaced_directory: Path | None = None + try: + with tempfile.TemporaryDirectory(prefix="matrix-release-source-") as staging_text: + staged_source = Path(staging_text) / "核桃派软件源代码" + _copy_source(source, staged_source, target) + created = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + if args.kind == "ota": + artifact = temporary_directory / f"matrix-screen-controller-{target}.ota" + info = build_package( + staged_source, + project / "发布更新相关" / "其他依赖" / "aarch64-py311", + artifact, + version=target, + release_notes=args.notes, + system_dependencies=dependency_bundle, + ) + verified = Path(staging_text) / "verified-ota" + extract_payload(info, verified) + if (verified / "software/VERSION").read_text(encoding="utf-8").strip() != str(target): + raise ValueError("exported payload version mismatch") + if (verified / "software/FEATURE_UPDATED_AT").read_bytes() != (source / "FEATURE_UPDATED_AT").read_bytes(): + raise ValueError("exported feature timestamp mismatch") + if read_policy(verified / "software") != policy: + raise ValueError("exported dependency policy mismatch") + manifest = { + "format_version": package_format(target), + "artifact_type": "ota", + "software_version": str(target), + "created_at": info.created_at, + "notes": args.notes.strip(), + "artifact": artifact.name, + "artifact_bytes": artifact.stat().st_size, + "artifact_sha256": sha256_file(artifact), + **release_metadata(target), + } + else: + if repair_config is not None: + config: dict | bytes = repair_config + else: + config = json.loads(args.config.read_text(encoding="utf-8")) + config["software_version"] = str(target) + artifact = temporary_directory / f"matrix-screen-controller-{target}.img" + bootstrap_bundle = Path(staging_text) / "MSCBOOT.TGZ" + kernel_dependency = ( + project + / "发布更新相关" + / "其他依赖" + / "aarch64-kernel" + / "6.1.31-matrix-axp313a1" + ) + kernel_source = ( + project + / "发布更新相关" + / "其他依赖" + / "kernel-source" + / "walnutpi-linux-6.1.31-30ff3fd5" + ) + manifest = build_image( + project / "发布更新相关" / "核桃派镜像" / "2025-3-6_V2.5.1_WalnutPi-1B_6.1.31_debian12_server.img", + staged_source, + project / "发布更新相关" / "其他依赖" / "aarch64-py311", + project / "发布更新相关" / "其他依赖" / "debian12-aarch64", + kernel_dependency, + kernel_source, + config, + artifact, + bootstrap_bundle, + project / "发布更新相关" / "其他依赖" / "frp" / "0.71.0" / "linux-arm64", + ) + subprocess.run( + [ + "bash", + str(staged_source / "scripts" / "install_image_bootstrap.sh"), + str(artifact), + str(staged_source / "systemd" / "matrix-image-firstboot.service"), + str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"), + str(kernel_dependency), + str(bootstrap_bundle), + ], + check=True, + ) + subprocess.run( + [ + "bash", + str(staged_source / "scripts" / "verify_image_bootstrap.sh"), + str(artifact), + str(staged_source / "systemd" / "matrix-image-firstboot.service"), + str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"), + str(kernel_dependency), + str(bootstrap_bundle), + ], + check=True, + ) + manifest["created_at"] = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + manifest["image_bytes"] = artifact.stat().st_size + manifest["image_sha256"] = sha256_file(artifact) + manifest.update({"artifact_type": "image", "notes": args.notes.strip(), "artifact": artifact.name}) + config_document, _generation, _slot = read_config_file( + repair_config if repair_config is not None else create_config_file(config) + ) + manifest["config_sha256"] = hashlib.sha256( + repair_config if repair_config is not None else create_config_file(config_document) + ).hexdigest() + (temporary_directory / f"{artifact.name}.sha256").write_text( + f"{manifest['image_sha256'] if args.kind == 'image' else manifest['artifact_sha256']} {artifact.name}\n", + encoding="ascii", + newline="\n", + ) + (temporary_directory / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + (temporary_directory / "README.md").write_text( + _image_readme(target, manifest, args.notes, config_document) + if args.kind == "image" + else _ota_readme(target, manifest, args.notes), + encoding="utf-8", + newline="\n", + ) + if args.candidate_output is not None: + os.replace(temporary_directory, final_directory) + published = True + print(final_directory) + return 0 + release_record = { + "version": str(target), + "artifact_type": args.kind, + "created_at": manifest["created_at"], + "notes": args.notes.strip(), + "artifact_path": f"{'发布更新相关/OTA数据包' if args.kind == 'ota' else '发布更新相关/导出包'}/{target}/{artifact.name}", + "artifact_sha256": manifest["image_sha256"] if args.kind == "image" else manifest["artifact_sha256"], + **(release_metadata(target) if args.kind == "ota" else {}), + **({"component_checkpoints": policy["checkpoints"]} if args.kind == "ota" else {}), + } + if args.repair_current: + assert repair_record_index is not None + history["releases"][repair_record_index] = release_record + replaced_directory = _commit_repair( + final_directory, + temporary_directory, + history_path, + history, + ) + published = True + if replaced_directory is not None and replaced_directory.exists(): + print(f"warning: obsolete image backup still requires cleanup: {replaced_directory}", file=sys.stderr) + replaced_directory = None + else: + os.replace(temporary_directory, final_directory) + published = True + history["releases"].append(release_record) + _atomic_json(history_path, history) + version_temp = source / ".VERSION.release.tmp" + version_temp.write_text(f"{target}\n", encoding="utf-8", newline="\n") + os.replace(version_temp, source / "VERSION") + print(final_directory) + except BaseException: + shutil.rmtree(temporary_directory, ignore_errors=True) + if published and not args.repair_current and args.candidate_output is None: + shutil.rmtree(final_directory, ignore_errors=True) + if history_original is None: + history_path.unlink(missing_ok=True) + else: + _atomic_bytes(history_path, history_original) + _atomic_bytes(source / "VERSION", version_original) + (source / ".VERSION.release.tmp").unlink(missing_ok=True) + if replaced_directory is not None and replaced_directory.exists() and not final_directory.exists(): + os.replace(replaced_directory, final_directory) + raise + finally: + lock.unlink(missing_ok=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/fat16_image.py b/核桃派软件源代码/scripts/fat16_image.py new file mode 100644 index 0000000..e9fb7ae --- /dev/null +++ b/核桃派软件源代码/scripts/fat16_image.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import struct +from typing import BinaryIO + + +class Fat16Error(ValueError): + pass + + +@dataclass(frozen=True) +class FatEntry: + directory_offset: int + first_cluster: int + size: int + + +class Fat16Image: + def __init__(self, path: Path, *, writable: bool = False): + self.path = Path(path) + self.handle: BinaryIO = self.path.open("r+b" if writable else "rb") + self.writable = writable + self._load_geometry() + + def __enter__(self) -> "Fat16Image": + return self + + def __exit__(self, *_args: object) -> None: + self.handle.close() + + def _load_geometry(self) -> None: + self.handle.seek(0) + mbr = self.handle.read(512) + if len(mbr) != 512 or mbr[510:512] != b"\x55\xaa": + raise Fat16Error("image does not contain a valid MBR") + entry = mbr[446:462] + self.partition_lba = struct.unpack_from(" bytes: + value = name.upper() + if value.count(".") > 1: + raise Fat16Error("FAT filename must use 8.3 form") + base, dot, extension = value.partition(".") + allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$~!#%&-{}()@'`" + if not 1 <= len(base) <= 8 or len(extension) > 3 or any(c not in allowed for c in base + extension): + raise Fat16Error(f"unsupported FAT 8.3 filename: {name}") + return base.ljust(8).encode("ascii") + extension.ljust(3).encode("ascii") + + def _read_fat(self) -> bytearray: + self.handle.seek(self.fat_offset) + return bytearray(self.handle.read(self.fat_sectors * self.bytes_per_sector)) + + def _fat_value(self, fat: bytes, cluster: int) -> int: + return struct.unpack_from(" None: + struct.pack_into(" None: + if not self.writable: + raise Fat16Error("image was opened read-only") + for index in range(self.fat_count): + self.handle.seek(self.fat_offset + index * self.fat_sectors * self.bytes_per_sector) + self.handle.write(fat) + + def find(self, name: str) -> FatEntry | None: + expected = self._name83(name) + self.handle.seek(self.root_offset) + root = self.handle.read(self.root_bytes) + for index in range(self.root_entries): + offset = index * 32 + raw = root[offset : offset + 32] + if raw[0] == 0x00: + break + if raw[0] == 0xE5 or raw[11] == 0x0F: + continue + if raw[:11] == expected: + return FatEntry( + self.root_offset + offset, + struct.unpack_from(" list[int]: + if first_cluster < 2: + return [] + result: list[int] = [] + seen: set[int] = set() + cluster = first_cluster + while 2 <= cluster < 0xFFF8: + if cluster in seen or cluster > self.cluster_count + 1: + raise Fat16Error("FAT cluster chain is invalid") + seen.add(cluster) + result.append(cluster) + cluster = self._fat_value(fat, cluster) + return result + + def _cluster_offset(self, cluster: int) -> int: + return self.data_offset + (cluster - 2) * self.cluster_bytes + + def read_file(self, name: str) -> bytes: + entry = self.find(name) + if entry is None: + raise FileNotFoundError(name) + fat = self._read_fat() + remaining = entry.size + result = bytearray() + for cluster in self._chain(entry.first_cluster, fat): + self.handle.seek(self._cluster_offset(cluster)) + chunk = self.handle.read(min(self.cluster_bytes, remaining)) + result.extend(chunk) + remaining -= len(chunk) + if remaining == 0: + break + if remaining: + raise Fat16Error(f"FAT file is truncated: {name}") + return bytes(result) + + def free_bytes(self) -> int: + fat = self._read_fat() + free_clusters = sum( + 1 + for cluster in range(2, self.cluster_count + 2) + if self._fat_value(fat, cluster) == 0 + ) + return free_clusters * self.cluster_bytes + + def _free_chain(self, first_cluster: int, fat: bytearray) -> None: + for cluster in self._chain(first_cluster, fat): + self._set_fat_value(fat, cluster, 0) + + def _directory_slot(self, name: str) -> int: + existing = self.find(name) + if existing is not None: + return existing.directory_offset + self.handle.seek(self.root_offset) + root = self.handle.read(self.root_bytes) + for index in range(self.root_entries): + if root[index * 32] in {0x00, 0xE5}: + return self.root_offset + index * 32 + raise Fat16Error("FAT16 root directory is full") + + def write_file(self, name: str, data: bytes, *, contiguous: bool = False) -> None: + if not self.writable: + raise Fat16Error("image was opened read-only") + content = bytes(data) + fat = self._read_fat() + existing = self.find(name) + if existing is not None: + self._free_chain(existing.first_cluster, fat) + required = max(1, (len(content) + self.cluster_bytes - 1) // self.cluster_bytes) + free = [cluster for cluster in range(2, self.cluster_count + 2) if self._fat_value(fat, cluster) == 0] + if contiguous: + clusters: list[int] = [] + run: list[int] = [] + previous = -2 + for cluster in free: + run = run + [cluster] if cluster == previous + 1 else [cluster] + previous = cluster + if len(run) == required: + clusters = run + break + else: + clusters = free[:required] + if len(clusters) != required: + raise Fat16Error("FAT16 partition does not have enough free clusters") + for index, cluster in enumerate(clusters): + next_cluster = clusters[index + 1] if index + 1 < len(clusters) else 0xFFFF + self._set_fat_value(fat, cluster, next_cluster) + chunk = content[index * self.cluster_bytes : (index + 1) * self.cluster_bytes] + self.handle.seek(self._cluster_offset(cluster)) + self.handle.write(chunk) + if len(chunk) < self.cluster_bytes: + self.handle.write(bytes(self.cluster_bytes - len(chunk))) + self._write_fats(fat) + directory_offset = self._directory_slot(name) + record = bytearray(32) + record[:11] = self._name83(name) + record[11] = 0x20 + struct.pack_into(" Path: + path = Path(value).expanduser().resolve(strict=True) + if not path.is_dir(): + raise FixtureError("pressure-test data root must be an existing directory") + if path == Path(path.anchor) or path == Path.home().resolve(): + raise FixtureError("pressure-test data root is too broad") + if path == PRODUCTION_DATA_ROOT or PRODUCTION_DATA_ROOT in path.parents: + raise FixtureError("refusing to use the production data root") + if not path.name.startswith(REQUIRED_BASENAME_PREFIX): + raise FixtureError( + f"pressure-test directory name must start with {REQUIRED_BASENAME_PREFIX!r}" + ) + if any(path.iterdir()): + raise FixtureError("pressure-test data root must be empty") + return path + + +def solid_scene(sequence: int) -> dict[str, object]: + if not 0 <= sequence <= 0xFFFFFF: + raise FixtureError("fixture sequence exceeds unique RGB color space") + color = bytes(((sequence >> 16) & 0xFF, (sequence >> 8) & 0xFF, sequence & 0xFF)) + return { + "version": 1, + "width": 64, + "height": 64, + "pixelRgb": base64.b64encode(color * (64 * 64)).decode("ascii"), + "elements": [], + } + + +def fixture_name(index: int) -> str: + if index % 3 == 0: + return f"压力动图_{index:03d}_" + "很长的中文名称" * 7 + if index % 3 == 1: + return f"stress_{index:03d}_" + "abcdefghijklmnopqrstuvwxyz" * 2 + return f"混合Stress_{index:03d}_" + "无空格LongName" * 5 + + +def generate_fixture( + data_root: Path, + *, + animation_count: int = 100, + frames_per_animation: int = 100, + duration_ms: int = 50, +) -> dict[str, int | str]: + if not 1 <= animation_count <= 1000: + raise FixtureError("animation count must be within 1..1000") + if not 1 <= frames_per_animation <= 30_000: + raise FixtureError("frames per animation must be within 1..30000") + if not 50 <= duration_ms <= 604_800_000: + raise FixtureError("frame duration must be within 50..604800000ms") + if animation_count * frames_per_animation > 0x1000000: + raise FixtureError("fixture needs more than 24-bit unique solid colors") + + store = AnimationStore(data_root) + sequence = 0 + for animation_index in range(animation_count): + def frames(): + nonlocal sequence + for _frame_index in range(frames_per_animation): + scene = solid_scene(sequence) + sequence += 1 + yield {"scene": scene, "name": None, "duration_ms": duration_ms} + + store.create_from_frames(fixture_name(animation_index), frames()) + + return { + "data_root": str(data_root), + "animations": animation_count, + "frames_per_animation": frames_per_animation, + "total_frames": animation_count * frames_per_animation, + "duration_ms": duration_ms, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Generate isolated 64x64 animations for thumbnail performance tests." + ) + parser.add_argument("--data-root", required=True) + parser.add_argument("--animations", type=int, default=100) + parser.add_argument("--frames", type=int, default=100) + parser.add_argument("--duration-ms", type=int, default=50) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + root = validate_empty_test_root(args.data_root) + result = generate_fixture( + root, + animation_count=args.animations, + frames_per_animation=args.frames, + duration_ms=args.duration_ms, + ) + except (FixtureError, OSError, ValueError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + return 1 + print(json.dumps({"ok": True, **result}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/hub75_visual_hold.py b/核桃派软件源代码/scripts/hub75_visual_hold.py new file mode 100644 index 0000000..08ffa5e --- /dev/null +++ b/核桃派软件源代码/scripts/hub75_visual_hold.py @@ -0,0 +1,132 @@ +"""Hold a real-driver visual pattern until systemd or the operator stops it.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import signal +import time + +from PIL import Image + +from app.display.matrix_driver import MatrixDisplayDriver +from app.display.ota_indicator import render_ota_indicator +from app.display.transforms import apply_orientation, apply_row_bit_order, get_row_bit_order +from app.display.wifi_indicator import ( + WIFI_SCROLL_FPS, + WIFI_SCROLL_GAP_PIXELS, + WIFI_SCROLL_PIXELS_PER_SECOND, + render_wifi_indicator, + text_width, +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--brightness", type=int, default=50) + parser.add_argument("--mode", choices=("wifi", "complex", "ota"), default="wifi") + parser.add_argument( + "--message", + default="SSID: DRIVER-TEST IP: http://192.168.1.2:8080/", + ) + parser.add_argument("--progress-file") + parser.add_argument("--orientation", type=int, choices=(0, 90, 180, 270), default=0) + args = parser.parse_args() + if not 1 <= args.brightness <= 100: + parser.error("brightness must be in 1..100") + + stopping = False + + def request_stop(_signum: int, _frame: object) -> None: + nonlocal stopping + stopping = True + + signal.signal(signal.SIGINT, request_stop) + signal.signal(signal.SIGTERM, request_stop) + + driver = MatrixDisplayDriver(args.brightness, limit_refresh_rate_hz=100) + cycle_pixels = ( + 64 + text_width(args.message) + WIFI_SCROLL_GAP_PIXELS + if args.mode == "wifi" + else 192 + ) + started = time.monotonic() + next_frame = started + try: + while not stopping: + now = time.monotonic() + offset = ((now - started) * WIFI_SCROLL_PIXELS_PER_SECOND) % cycle_pixels + if args.mode == "wifi": + frame = render_wifi_indicator( + args.message, + offset=offset, + connected=True, + ) + elif args.mode == "complex": + frame = render_complex_pattern(offset) + else: + percent, stage = read_ota_progress(args.progress_file) + frame = render_ota_indicator(percent, f"{stage}:{int(offset) % 4}") + transformed = apply_row_bit_order( + apply_orientation(frame, args.orientation), + get_row_bit_order(), + ) + driver.set_image(transformed) + next_frame += 1.0 / WIFI_SCROLL_FPS + time.sleep(max(0.0, next_frame - time.monotonic())) + finally: + driver.clear() + driver.close() + return 0 + + +def read_ota_progress(filename: str | None) -> tuple[int, str]: + if not filename: + return 0, "准备更新" + try: + document = json.loads(Path(filename).read_text(encoding="utf-8")) + job = document.get("job") if isinstance(document, dict) else None + if not isinstance(job, dict): + return 0, "准备更新" + return max(0, min(100, int(job.get("percent", 0)))), str(job.get("message") or "更新中")[:80] + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + return 0, "准备更新" + + +def render_complex_pattern(offset: float) -> Image.Image: + """Render a dense, high-contrast 192-pixel panorama through a 64px window.""" + phase = int(offset) % 192 + pixels: list[tuple[int, int, int]] = [] + palette = ( + (255, 32, 32), + (255, 176, 24), + (48, 255, 64), + (32, 224, 255), + (48, 64, 255), + (224, 48, 255), + ) + for y in range(64): + for x in range(64): + source_x = (x + phase) % 192 + base = palette[(source_x // 16) % len(palette)] + checker = ((source_x // 4) ^ (y // 4)) & 1 + diagonal = ((source_x + y * 2) // 8) & 1 + gain = 1.0 if checker else 0.48 + if diagonal: + gain *= 0.78 + radial = ((source_x % 32) - 16) ** 2 + (y - 32) ** 2 + if 70 <= radial <= 150: + color = (255, 255, 255) + elif radial < 36: + color = (8, 8, 8) + else: + color = tuple(max(8, min(255, int(channel * gain))) for channel in base) + pixels.append(color) + image = Image.new("RGB", (64, 64)) + image.putdata(pixels) + return image + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/image_bootstrap_payload.py b/核桃派软件源代码/scripts/image_bootstrap_payload.py new file mode 100644 index 0000000..d7595ad --- /dev/null +++ b/核桃派软件源代码/scripts/image_bootstrap_payload.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import asdict +import hashlib +import json +from pathlib import Path +import shutil + +from scripts.kernel_artifact import ARTIFACT_NAME, KernelArtifactError, verify_kernel_dependency + + +TARGET_RELATIVE = Path("opt/matrix-image-bootstrap") +APP_RELATIVE = Path("app/MSCBOOT.TGZ") +KERNEL_RELATIVE = Path("axp313a") +DEFAULT_RESERVE_BYTES = 256 * 1024 * 1024 +KERNEL_FILES = {ARTIFACT_NAME, "SHA256SUMS", "METADATA.json"} + + +class ImageBootstrapPayloadError(ValueError): + pass + + +def _root(path: Path) -> Path: + resolved = Path(path).resolve() + if not resolved.is_dir() or resolved == Path(resolved.anchor): + raise ImageBootstrapPayloadError("image root must be an explicit mounted or isolated directory") + return resolved + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_staged_payload(root: Path, bundle: Path, dependency: Path) -> dict[str, object]: + root = _root(root) + bundle = Path(bundle).resolve() + dependency = Path(dependency).resolve() + expected_kernel = verify_kernel_dependency(dependency) + target = root / TARGET_RELATIVE + app_target = target / APP_RELATIVE + kernel_target = target / KERNEL_RELATIVE + if not target.is_dir() or {path.name for path in target.iterdir()} != {"app", "axp313a"}: + raise ImageBootstrapPayloadError("staged image bootstrap payload directories are not exact") + if not app_target.is_file() or {path.name for path in app_target.parent.iterdir()} != {"MSCBOOT.TGZ"}: + raise ImageBootstrapPayloadError("staged image application payload files are not exact") + if app_target.stat().st_size != bundle.stat().st_size or _sha256(app_target) != _sha256(bundle): + raise ImageBootstrapPayloadError("staged image application payload differs from the build bundle") + if not kernel_target.is_dir() or {path.name for path in kernel_target.iterdir()} != KERNEL_FILES: + raise ImageBootstrapPayloadError("staged image kernel payload files are not exact") + for name in ("SHA256SUMS", "METADATA.json"): + if (kernel_target / name).read_bytes() != (dependency / name).read_bytes(): + raise ImageBootstrapPayloadError(f"staged image kernel {name} differs from the dependency") + actual_kernel = verify_kernel_dependency(kernel_target) + if actual_kernel != expected_kernel: + raise ImageBootstrapPayloadError("staged image kernel payload identity changed") + return { + "target": TARGET_RELATIVE.as_posix(), + "bundle_path": (TARGET_RELATIVE / APP_RELATIVE).as_posix(), + "bundle_bytes": app_target.stat().st_size, + "bundle_sha256": _sha256(app_target), + "kernel": asdict(actual_kernel), + } + + +def stage_payload( + root: Path, + bundle: Path, + dependency: Path, + *, + reserve_bytes: int = DEFAULT_RESERVE_BYTES, +) -> dict[str, object]: + root = _root(root) + bundle = Path(bundle).resolve() + dependency = Path(dependency).resolve() + if not bundle.is_file(): + raise ImageBootstrapPayloadError("image application bundle is missing") + if type(reserve_bytes) is not int or reserve_bytes < 0: + raise ImageBootstrapPayloadError("reserve_bytes must be a non-negative integer") + verify_kernel_dependency(dependency) + payload_bytes = bundle.stat().st_size + sum((dependency / name).stat().st_size for name in KERNEL_FILES) + if shutil.disk_usage(root).free < payload_bytes + reserve_bytes: + raise ImageBootstrapPayloadError("copied image root filesystem lacks room for both offline payloads") + target = root / TARGET_RELATIVE + if target.exists(): + raise ImageBootstrapPayloadError("image bootstrap payload target already exists") + created = False + try: + (target / APP_RELATIVE.parent).mkdir(parents=True, mode=0o755) + (target / KERNEL_RELATIVE).mkdir(mode=0o755) + created = True + shutil.copy2(bundle, target / APP_RELATIVE) + for name in KERNEL_FILES: + shutil.copy2(dependency / name, target / KERNEL_RELATIVE / name) + return verify_staged_payload(root, bundle, dependency) + except BaseException: + if created: + shutil.rmtree(target, ignore_errors=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description="Stage or verify rootfs image bootstrap payloads") + parser.add_argument("action", choices=("stage", "verify")) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--bundle", type=Path, required=True) + parser.add_argument("--dependency", type=Path, required=True) + parser.add_argument("--reserve-bytes", type=int, default=DEFAULT_RESERVE_BYTES) + args = parser.parse_args() + if args.action == "stage": + result = stage_payload(args.root, args.bundle, args.dependency, reserve_bytes=args.reserve_bytes) + else: + result = verify_staged_payload(args.root, args.bundle, args.dependency) + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ImageBootstrapPayloadError, KernelArtifactError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + raise SystemExit(1) diff --git a/核桃派软件源代码/scripts/image_config.py b/核桃派软件源代码/scripts/image_config.py new file mode 100644 index 0000000..457a4dd --- /dev/null +++ b/核桃派软件源代码/scripts/image_config.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import hashlib +import ipaddress +import json +from pathlib import Path +import re +import struct +from typing import Any + + +PRODUCT_ID = "matrix-screen-controller-walnutpi" +CONFIG_FILE_BYTES = 64 * 1024 +HEADER_BYTES = 4096 +SLOT_BYTES = (CONFIG_FILE_BYTES - HEADER_BYTES) // 2 +FILE_MAGIC = b"MSCCFG2\0" +SLOT_MAGIC = b"MSCSLOT\0" +FORMAT_VERSION = 1 +_HEADER = struct.Struct("<8sII") +_SLOT_HEADER = struct.Struct("<8sQI32s") +_USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") + + +class ImageConfigError(ValueError): + pass + + +def validate_config(value: Any) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != { + "schema_version", + "product", + "software_version", + "account", + "wifi", + "ipv4", + }: + raise ImageConfigError("image configuration fields are invalid") + if value["schema_version"] != 1 or value["product"] != PRODUCT_ID: + raise ImageConfigError("image configuration product or schema is unsupported") + if not isinstance(value["software_version"], str) or not re.fullmatch( + r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", + value["software_version"], + ): + raise ImageConfigError("software_version must use MAJOR.MINOR.PATCH") + + account = value["account"] + if not isinstance(account, dict) or set(account) != {"username", "password"}: + raise ImageConfigError("account configuration is invalid") + if not isinstance(account["username"], str) or not _USERNAME.fullmatch(account["username"]): + raise ImageConfigError("account username is not a supported Linux username") + _validate_secret(account["password"], "account password", minimum=8, maximum=128) + + wifi = value["wifi"] + if not isinstance(wifi, dict) or set(wifi) != {"ssid", "password"}: + raise ImageConfigError("Wi-Fi configuration is invalid") + _validate_text(wifi["ssid"], "Wi-Fi SSID", minimum=1, maximum_bytes=32) + _validate_secret(wifi["password"], "Wi-Fi password", minimum=8, maximum=63) + + ipv4 = value["ipv4"] + if not isinstance(ipv4, dict) or set(ipv4) != { + "mode", + "address", + "prefix", + "gateway", + "dns", + }: + raise ImageConfigError("IPv4 configuration is invalid") + if ipv4["mode"] not in {"dhcp", "static"}: + raise ImageConfigError("IPv4 mode must be dhcp or static") + if ipv4["mode"] == "dhcp": + if ipv4 != {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}: + raise ImageConfigError("DHCP configuration must not include static fields") + else: + try: + address = ipaddress.IPv4Address(ipv4["address"]) + gateway = ipaddress.IPv4Address(ipv4["gateway"]) + except (ipaddress.AddressValueError, TypeError) as exc: + raise ImageConfigError("static IPv4 address or gateway is invalid") from exc + prefix = ipv4["prefix"] + if type(prefix) is not int or not 1 <= prefix <= 32: + raise ImageConfigError("static IPv4 prefix must be between 1 and 32") + network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False) + if gateway not in network: + raise ImageConfigError("static IPv4 gateway must be in the same subnet") + if not isinstance(ipv4["dns"], list) or not 1 <= len(ipv4["dns"]) <= 4: + raise ImageConfigError("static IPv4 DNS must contain one to four addresses") + try: + ipv4["dns"] = [str(ipaddress.IPv4Address(item)) for item in ipv4["dns"]] + except (ipaddress.AddressValueError, TypeError) as exc: + raise ImageConfigError("static IPv4 DNS contains an invalid address") from exc + return value + + +def _validate_text(value: Any, label: str, *, minimum: int, maximum_bytes: int) -> None: + if not isinstance(value, str) or len(value) < minimum or len(value.encode("utf-8")) > maximum_bytes: + raise ImageConfigError(f"{label} length is invalid") + if any(character in value for character in ("\0", "\r", "\n")): + raise ImageConfigError(f"{label} contains a forbidden character") + + +def _validate_secret(value: Any, label: str, *, minimum: int, maximum: int) -> None: + if not isinstance(value, str) or not minimum <= len(value) <= maximum: + raise ImageConfigError(f"{label} length is invalid") + if any(character in value for character in ("\0", "\r", "\n")): + raise ImageConfigError(f"{label} contains a forbidden character") + + +def _slot_offset(index: int) -> int: + return HEADER_BYTES + index * SLOT_BYTES + + +def _encode_slot(config: dict[str, Any], generation: int) -> bytes: + payload = (json.dumps(validate_config(config), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + maximum = SLOT_BYTES - _SLOT_HEADER.size + if len(payload) > maximum: + raise ImageConfigError("image configuration payload is too large") + header = _SLOT_HEADER.pack(SLOT_MAGIC, generation, len(payload), hashlib.sha256(payload).digest()) + return header + payload + bytes(SLOT_BYTES - len(header) - len(payload)) + + +def create_config_file(config: dict[str, Any]) -> bytes: + result = bytearray(CONFIG_FILE_BYTES) + _HEADER.pack_into(result, 0, FILE_MAGIC, FORMAT_VERSION, CONFIG_FILE_BYTES) + result[_slot_offset(0) : _slot_offset(0) + SLOT_BYTES] = _encode_slot(config, 1) + return bytes(result) + + +def read_config_file(data: bytes) -> tuple[dict[str, Any], int, int]: + if len(data) != CONFIG_FILE_BYTES: + raise ImageConfigError("image configuration file size is invalid") + magic, version, declared_size = _HEADER.unpack_from(data, 0) + if magic != FILE_MAGIC or version != FORMAT_VERSION or declared_size != CONFIG_FILE_BYTES: + raise ImageConfigError("image configuration header is invalid") + valid: list[tuple[int, int, dict[str, Any]]] = [] + for index in range(2): + offset = _slot_offset(index) + slot_magic, generation, payload_bytes, digest = _SLOT_HEADER.unpack_from(data, offset) + if slot_magic != SLOT_MAGIC or payload_bytes <= 0 or payload_bytes > SLOT_BYTES - _SLOT_HEADER.size: + continue + payload = data[offset + _SLOT_HEADER.size : offset + _SLOT_HEADER.size + payload_bytes] + if hashlib.sha256(payload).digest() != digest: + continue + try: + document = validate_config(json.loads(payload.decode("utf-8"))) + except (UnicodeError, json.JSONDecodeError, ImageConfigError): + continue + valid.append((generation, index, document)) + if not valid: + raise ImageConfigError("image configuration has no valid slot") + generation, index, document = max(valid, key=lambda item: (item[0], item[1])) + return document, generation, index + + +def update_config_file(data: bytes, config: dict[str, Any]) -> bytes: + _current, generation, active = read_config_file(data) + target = 1 - active + result = bytearray(data) + offset = _slot_offset(target) + result[offset : offset + SLOT_BYTES] = _encode_slot(config, generation + 1) + return bytes(result) + + +def read_config_path(path: Path) -> dict[str, Any]: + document, _generation, _slot = read_config_file(Path(path).read_bytes()) + return document + diff --git a/核桃派软件源代码/scripts/image_firstboot.sh b/核桃派软件源代码/scripts/image_firstboot.sh new file mode 100644 index 0000000..d672173 --- /dev/null +++ b/核桃派软件源代码/scripts/image_firstboot.sh @@ -0,0 +1,170 @@ +#!/bin/bash +set -Eeuo pipefail + +STATUS=/boot/MSCSTAT.TXT +PACKAGE_LOG=/boot/MSCPKG.TXT +PACKAGE_INVENTORY=/boot/MSCPKGS.TSV +DEPLOY_LOG=/boot/MSCDEPLOY.TXT +CONFIG=/boot/MSCCFG.BIN +BUNDLE=/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ +META=/boot/MSCMETA.JSN +WORK=/run/matrix-image-provision +KERNEL_ROOT=/opt/matrix-image-bootstrap/axp313a +KERNEL_ARCHIVE=$KERNEL_ROOT/axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz +KERNEL_METADATA=$KERNEL_ROOT/METADATA.json +KERNEL_WORK=/var/tmp/matrix-axp313a-image-install + +status() { + printf '%s\n' "$1" > "$STATUS" + sync "$STATUS" || true +} + +failed() { + code=$? + trap - ERR + rm -rf -- /var/tmp/matrix-axp313a-image-install + status "FAILED: stage=${STAGE:-starting}; code=$code; line=${BASH_LINENO[0]:-unknown}. Power off, remove the TF card, and inspect this file on Windows." + exit "$code" +} +trap failed ERR + +if [ "$(id -u)" -ne 0 ]; then + status "FAILED: the one-time provisioner did not run as root." + exit 1 +fi + +STAGE=validate +status "RUNNING: validating the offline image payload." +rm -rf -- "$WORK" +install -d -m 0700 "$WORK" +python3 - "$META" "$BUNDLE" "$KERNEL_ARCHIVE" "$KERNEL_METADATA" <<'PY' +import hashlib +import json +from pathlib import Path +import sys + +meta = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +bundle = Path(sys.argv[2]) +kernel_artifact = Path(sys.argv[3]) +kernel = json.loads(Path(sys.argv[4]).read_text(encoding="utf-8")) +assert meta == { + "format_version": 3, + "product": "matrix-screen-controller-walnutpi", + "software_version": meta["software_version"], + "bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ", + "bundle_bytes": meta["bundle_bytes"], + "bundle_sha256": meta["bundle_sha256"], + "kernel_release": "6.1.31-matrix-axp313a1", + "kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", + "kernel_artifact_bytes": meta["kernel_artifact_bytes"], + "kernel_artifact_sha256": meta["kernel_artifact_sha256"], + "kernel_unpacked_file_bytes": meta["kernel_unpacked_file_bytes"], + "boot_required_bytes": meta["boot_required_bytes"], + "boot_safety_bytes": 32 * 1024 * 1024, +} +assert isinstance(meta["boot_required_bytes"], int) and meta["boot_required_bytes"] > 0 +assert bundle.stat().st_size == meta["bundle_bytes"] +digest = hashlib.sha256() +with bundle.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) +assert digest.hexdigest() == meta["bundle_sha256"] +assert kernel["schema_version"] == 1 +assert kernel["architecture"] == "aarch64" +assert kernel["kernel_release"] == meta["kernel_release"] +assert kernel["artifact"] == meta["kernel_artifact"] +assert kernel["artifact_bytes"] == meta["kernel_artifact_bytes"] == kernel_artifact.stat().st_size +assert kernel["artifact_sha256"] == meta["kernel_artifact_sha256"] +assert kernel["unpacked_file_bytes"] == meta["kernel_unpacked_file_bytes"] +digest = hashlib.sha256() +with kernel_artifact.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) +assert digest.hexdigest() == meta["kernel_artifact_sha256"] +PY +tar -xzf "$BUNDLE" -C "$WORK" +cd "$WORK/project/核桃派软件源代码" +PYTHONPATH=. python3 scripts/kernel_artifact.py --kernel "$KERNEL_ROOT" >/dev/null +cd "$WORK/project/离线依赖/其他依赖/aarch64-py311" +sha256sum -c SHA256SUMS +cd "$WORK/project/离线依赖/其他依赖/debian12-aarch64" +sha256sum -c SHA256SUMS + +STAGE=packages +status "RUNNING: installing offline Debian packages." +export DEBIAN_FRONTEND=noninteractive +dpkg-query -W -f='${db:Status-Abbrev}\t${binary:Package}\t${Version}\n' >"$PACKAGE_INVENTORY" +if ! dpkg -i "$WORK"/project/离线依赖/其他依赖/debian12-aarch64/*.deb >"$PACKAGE_LOG" 2>&1; then + status "FAILED: stage=packages; offline Debian package installation failed. Inspect MSCPKG.TXT on Windows; it contains package diagnostics but no configured credentials." + exit 1 +fi +rm -f -- "$PACKAGE_LOG" "$PACKAGE_INVENTORY" + +STAGE=network +status "RUNNING: applying the requested network configuration." +cd "$WORK/project/核桃派软件源代码" +PYTHONPATH=. python3 -m scripts.provision_device network --config "$CONFIG" +systemctl restart NetworkManager.service + +STAGE=deploy +status "RUNNING: installing and testing matrix-screen-controller." +{ + if [ -e /opt/matrix-screen-controller ]; then + printf '%s\n' 'A previous deployment attempt left /opt/matrix-screen-controller in place.' + systemctl --no-pager --full status matrix-screen-controller.service || true + journalctl --no-pager -u matrix-screen-controller.service -n 120 || true + fi +} >"$DEPLOY_LOG" 2>&1 +FRPC_RUN_USER=$(PYTHONPATH=. python3 -c 'from pathlib import Path; from scripts.image_config import read_config_path; import sys; print(read_config_path(Path(sys.argv[1]))["account"]["username"])' "$CONFIG") +if ! FRPC_RUN_USER="$FRPC_RUN_USER" DEFER_SERVICE_START=1 WHEELHOUSE="$WORK/project/离线依赖/其他依赖/aarch64-py311" FRPC_BUNDLE="$WORK/project/离线依赖/其他依赖/frp/0.71.0/linux-arm64" ./scripts/deploy_walnutpi.sh >>"$DEPLOY_LOG" 2>&1; then + status "FAILED: stage=deploy; controller deployment failed. Inspect MSCDEPLOY.TXT on Windows; it contains deployment diagnostics but no configured credentials." + exit 1 +fi +rm -f -- "$DEPLOY_LOG" + +STAGE=account +status "RUNNING: creating the configured account and unique device identity." +PYTHONPATH=. python3 -m scripts.provision_device account --config "$CONFIG" +FRPC_RUN_USER="$FRPC_RUN_USER" DEFER_SERVICE_START=1 FRPC_BUNDLE="$WORK/project/离线依赖/其他依赖/frp/0.71.0/linux-arm64" /bin/sh ./scripts/install_frpc_system.sh +systemctl disable boot_script.service >/dev/null 2>&1 || true +PYTHONPATH=. python3 -m scripts.provision_device identity --config "$CONFIG" + +STAGE=ssh +status "RUNNING: enabling password-authenticated SSH for the configured administrator." +PYTHONPATH=. python3 -m scripts.provision_device ssh --config "$CONFIG" + +STAGE=kernel +status "RUNNING: installing the verified offline AXP313A kernel." +kernel_unpacked_bytes=$(python3 -c 'import json; print(json.load(open("/opt/matrix-image-bootstrap/axp313a/METADATA.json", encoding="utf-8"))["unpacked_file_bytes"])') +available_bytes=$(df -Pk /var/tmp | awk 'NR == 2 { printf "%.0f\n", $4 * 1024 }') +required_bytes=$((kernel_unpacked_bytes * 2 + 268435456)) +if [ "$available_bytes" -lt "$required_bytes" ]; then + printf '%s\n' 'insufficient root filesystem space to install the offline kernel safely' >&2 + exit 1 +fi +rm -rf -- "$KERNEL_WORK" +install -d -m 0700 "$KERNEL_WORK" +tar -xzf "$KERNEL_ARCHIVE" -C "$KERNEL_WORK" +cd "$WORK/project/核桃派软件源代码" +./scripts/install_axp313a_kernel.sh --artifact "$KERNEL_WORK" +rm -rf -- "$KERNEL_WORK" +rm -rf -- /opt/matrix-image-bootstrap + +STAGE=finish +install -d -m 0711 /var/lib/matrix-screen-controller +printf '%s\n' "$(cat VERSION)" > /var/lib/matrix-screen-controller/.factory-image-version +if command -v shred >/dev/null 2>&1; then + shred -n 1 -z "$CONFIG" || true +fi +rm -f -- "$CONFIG" "$META" /boot/MSCINIT +systemctl disable matrix-image-firstboot.service >/dev/null 2>&1 || true +rm -f -- /etc/systemd/system/matrix-image-firstboot.service +rm -rf -- "$WORK" +sync +if nmcli -t -f NAME connection show --active 2>/dev/null | grep -Fxq 'matrix-screen' \ + && ip route show default | grep -q .; then + status "SUCCESS: provisioning completed; the device is rebooting into the installed controller." +else + status "SUCCESS: provisioning completed while Wi-Fi is not connected; the installed controller will keep trying after reboot." +fi +systemctl reboot diff --git a/核桃派软件源代码/scripts/image_kernel_payload.py b/核桃派软件源代码/scripts/image_kernel_payload.py new file mode 100644 index 0000000..bb09f5b --- /dev/null +++ b/核桃派软件源代码/scripts/image_kernel_payload.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import asdict +import json +from pathlib import Path +import shutil + +from scripts.kernel_artifact import ARTIFACT_NAME, KernelArtifactError, verify_kernel_dependency + + +TARGET_RELATIVE = Path("opt/matrix-image-bootstrap/axp313a") +DEFAULT_RESERVE_BYTES = 256 * 1024 * 1024 +EXPECTED_FILES = {ARTIFACT_NAME, "SHA256SUMS", "METADATA.json"} + + +class ImageKernelPayloadError(ValueError): + pass + + +def _root(path: Path) -> Path: + resolved = Path(path).resolve() + if not resolved.is_dir() or resolved == Path(resolved.anchor): + raise ImageKernelPayloadError("image root must be an explicit mounted or isolated directory") + return resolved + + +def verify_staged_payload(root: Path, dependency: Path) -> dict[str, object]: + root = _root(root) + dependency = Path(dependency).resolve() + expected = verify_kernel_dependency(dependency) + target = root / TARGET_RELATIVE + if not target.is_dir() or {path.name for path in target.iterdir()} != EXPECTED_FILES: + raise ImageKernelPayloadError("staged image kernel payload files are not exact") + for name in ("SHA256SUMS", "METADATA.json"): + if (target / name).read_bytes() != (dependency / name).read_bytes(): + raise ImageKernelPayloadError(f"staged image kernel {name} differs from the dependency") + actual = verify_kernel_dependency(target) + if actual != expected: + raise ImageKernelPayloadError("staged image kernel payload identity changed") + return {"target": TARGET_RELATIVE.as_posix(), **asdict(actual)} + + +def stage_payload( + root: Path, + dependency: Path, + *, + reserve_bytes: int = DEFAULT_RESERVE_BYTES, +) -> dict[str, object]: + root = _root(root) + dependency = Path(dependency).resolve() + if type(reserve_bytes) is not int or reserve_bytes < 0: + raise ImageKernelPayloadError("reserve_bytes must be a non-negative integer") + info = verify_kernel_dependency(dependency) + required = info.artifact_bytes + reserve_bytes + if shutil.disk_usage(root).free < required: + raise ImageKernelPayloadError("copied image root filesystem lacks room for the offline kernel payload") + target = root / TARGET_RELATIVE + if target.exists(): + raise ImageKernelPayloadError("image kernel payload target already exists") + created = False + try: + target.mkdir(parents=True, mode=0o755) + created = True + for name in EXPECTED_FILES: + shutil.copy2(dependency / name, target / name) + return verify_staged_payload(root, dependency) + except BaseException: + if created: + shutil.rmtree(target, ignore_errors=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser(description="Stage or verify the offline kernel in a copied IMG root") + parser.add_argument("action", choices=("stage", "verify")) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--dependency", type=Path, required=True) + parser.add_argument("--reserve-bytes", type=int, default=DEFAULT_RESERVE_BYTES) + args = parser.parse_args() + if args.action == "stage": + result = stage_payload(args.root, args.dependency, reserve_bytes=args.reserve_bytes) + else: + result = verify_staged_payload(args.root, args.dependency) + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ImageKernelPayloadError, KernelArtifactError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + raise SystemExit(1) diff --git a/核桃派软件源代码/scripts/install_axp313a_kernel.sh b/核桃派软件源代码/scripts/install_axp313a_kernel.sh new file mode 100644 index 0000000..e65ac37 --- /dev/null +++ b/核桃派软件源代码/scripts/install_axp313a_kernel.sh @@ -0,0 +1,170 @@ +#!/bin/sh +set -eu + +ARTIFACT= +while [ "$#" -gt 0 ]; do + case "$1" in + --artifact) ARTIFACT=$2; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$(id -u)" -ne 0 ] || [ "$(uname -m)" != "aarch64" ]; then + echo "installer must run as root on the WalnutPi AArch64 host" >&2 + exit 1 +fi +if [ -z "$ARTIFACT" ]; then + echo "usage: install_axp313a_kernel.sh --artifact DIR" >&2 + exit 2 +fi +ARTIFACT=$(CDPATH= cd -- "$ARTIFACT" && pwd) +SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +RELEASE=6.1.31-matrix-axp313a1 +MODULE_TARGET=/lib/modules/$RELEASE +IMAGE_TARGET=/boot/Image-matrix-axp313a1 +DTB_TARGET=/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb +EMMC_DTB_TARGET=/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb +BOOT_CMD=/boot/boot.cmd +BOOT_SCR=/boot/boot.scr +ORIGINAL_CMD=/boot/boot.cmd.matrix-original +ORIGINAL_SCR=/boot/boot.scr.matrix-original +COMMITTED=0 +BOOT_REPLACED=0 +SYSTEM_FILES_INSTALLED=0 + +cleanup_failed_install() { + code=$? + trap - EXIT HUP INT TERM + if [ "$code" -ne 0 ] && [ "$COMMITTED" -eq 0 ]; then + if [ "$BOOT_REPLACED" -eq 1 ] && [ -f "$ORIGINAL_CMD" ] && [ -f "$ORIGINAL_SCR" ]; then + install -m 0644 "$ORIGINAL_CMD" "$BOOT_CMD" + install -m 0644 "$ORIGINAL_SCR" "$BOOT_SCR" + fi + rm -f -- "$IMAGE_TARGET" "$DTB_TARGET" "$EMMC_DTB_TARGET" \ + "/boot/System.map-$RELEASE" "/boot/config-$RELEASE" /boot/matrix-kernel-good + case "$MODULE_TARGET" in /lib/modules/6.1.31-matrix-axp313a1) rm -rf -- "$MODULE_TARGET" ;; esac + if [ "$SYSTEM_FILES_INSTALLED" -eq 1 ]; then + rm -f -- /etc/systemd/system/matrix-axp313a-health.service \ + /etc/systemd/system/matrix-axp313a-rollback.service \ + /opt/matrix-screen-controller-system/axp313a_kernel_health.py + fi + systemctl daemon-reload >/dev/null 2>&1 || true + sync + fi + exit "$code" +} +trap cleanup_failed_install EXIT HUP INT TERM + +if [ ! -f "$ARTIFACT/SHA256SUMS" ]; then + echo "candidate artifact manifest is missing" >&2 + exit 1 +fi +(cd "$ARTIFACT" && sha256sum -c SHA256SUMS) +for path in \ + "$ARTIFACT/boot/Image-matrix-axp313a1" \ + "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" \ + "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" \ + "$ARTIFACT/lib/modules/$RELEASE" +do + if [ ! -e "$path" ]; then echo "candidate artifact is incomplete: $path" >&2; exit 1; fi +done +if [ -e "$MODULE_TARGET" ] || [ -e "$IMAGE_TARGET" ] || [ -e "$DTB_TARGET" ] || [ -e "$EMMC_DTB_TARGET" ]; then + echo "candidate kernel paths already exist; refusing to overwrite" >&2 + exit 1 +fi +if [ ! -f "$BOOT_CMD" ] || [ ! -f "$BOOT_SCR" ]; then + echo "original WalnutPi boot files are missing" >&2 + exit 1 +fi +command -v mkimage >/dev/null +command -v fdtget >/dev/null +command -v strings >/dev/null + +# Refuse before the first candidate boot write unless the complete candidate, +# rollback set, temporary scripts and fixed safety reserve all fit. +PYTHONPATH="$SOURCE_ROOT" python3 "$SOURCE_ROOT/scripts/boot_space.py" \ + --boot-root /boot --artifact-root "$ARTIFACT" + +if [ "$(cat "$ARTIFACT/KERNEL_RELEASE" 2>/dev/null || true)" != "$RELEASE" ]; then + echo "candidate kernel release marker is wrong" >&2 + exit 1 +fi +if [ "$(cat "$ARTIFACT/SOURCE_COMMIT" 2>/dev/null || true)" != \ + "30ff3fd5cf45417622b447a12e7a947402ffe34d" ]; then + echo "candidate vendor source commit marker is wrong" >&2 + exit 1 +fi +if ! strings "$ARTIFACT/boot/Image-matrix-axp313a1" | \ + grep -Fq "Linux version $RELEASE"; then + echo "candidate Image does not contain the expected release" >&2 + exit 1 +fi + +verify_dtb() { + dtb=$1 + pmic=/soc/i2c@7081400/pmic@36 + regulators=$pmic/regulators + if [ "$(fdtget -t s "$dtb" "$pmic" compatible)" != "x-powers,axp313a" ]; then + echo "candidate DTB does not use the exact AXP313A compatible: $dtb" >&2 + return 1 + fi + for specification in \ + "aldo1 1800000 1800000 vcc1v8" \ + "dldo1 3300000 3300000 vcc3v3" \ + "dcdc1 810000 1160000 vdd-gpu-sys" \ + "dcdc2 810000 1080000 vdd-cpu" \ + "dcdc3 1100000 1100000 vdd-dram" + do + set -- $specification + node=$regulators/$1 + [ "$(fdtget -t i "$dtb" "$node" regulator-min-microvolt)" = "$2" ] + [ "$(fdtget -t i "$dtb" "$node" regulator-max-microvolt)" = "$3" ] + [ "$(fdtget -t s "$dtb" "$node" regulator-name)" = "$4" ] + fdtget "$dtb" "$node" regulator-always-on >/dev/null + done + cpu_supply=$(fdtget -t x "$dtb" /cpus/cpu@0 cpu-supply) + dcdc2_phandle=$(fdtget -t x "$dtb" "$regulators/dcdc2" phandle) + if [ "$cpu_supply" != "$dcdc2_phandle" ]; then + echo "candidate DTB CPU0 supply is not DCDC2: $dtb" >&2 + return 1 + fi +} + +verify_dtb "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" +verify_dtb "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" + +install -m 0644 "$ARTIFACT/boot/Image-matrix-axp313a1" "$IMAGE_TARGET" +install -m 0644 "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb" "$DTB_TARGET" +install -m 0644 "$ARTIFACT/boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb" "$EMMC_DTB_TARGET" +install -m 0644 "$ARTIFACT/boot/System.map-$RELEASE" "/boot/System.map-$RELEASE" +install -m 0644 "$ARTIFACT/boot/config-$RELEASE" "/boot/config-$RELEASE" +cp -a -- "$ARTIFACT/lib/modules/$RELEASE" "$MODULE_TARGET" +depmod "$RELEASE" + +if [ ! -f "$ORIGINAL_CMD" ]; then install -m 0644 "$BOOT_CMD" "$ORIGINAL_CMD"; fi +if [ ! -f "$ORIGINAL_SCR" ]; then install -m 0644 "$BOOT_SCR" "$ORIGINAL_SCR"; fi +sha256sum "$ORIGINAL_CMD" "$ORIGINAL_SCR" > /boot/matrix-original.SHA256SUMS + +install -d -m 0755 /opt/matrix-screen-controller-system +install -m 0755 "$SOURCE_ROOT/scripts/axp313a_kernel_health.py" \ + /opt/matrix-screen-controller-system/axp313a_kernel_health.py +install -m 0644 "$SOURCE_ROOT/systemd/matrix-axp313a-health.service" \ + /etc/systemd/system/matrix-axp313a-health.service +install -m 0644 "$SOURCE_ROOT/systemd/matrix-axp313a-rollback.service" \ + /etc/systemd/system/matrix-axp313a-rollback.service +SYSTEM_FILES_INSTALLED=1 + +cmd_tmp=$(mktemp /boot/.boot.cmd.matrix.XXXXXX) +scr_tmp=$(mktemp /boot/.boot.scr.matrix.XXXXXX) +python3 "$SOURCE_ROOT/scripts/render_dual_kernel_boot.py" "$ORIGINAL_CMD" "$cmd_tmp" +mkimage -C none -A arm -T script -d "$cmd_tmp" "$scr_tmp" >/dev/null +install -m 0644 "$cmd_tmp" "$BOOT_CMD" +install -m 0644 "$scr_tmp" "$BOOT_SCR" +BOOT_REPLACED=1 +rm -f -- "$cmd_tmp" "$scr_tmp" +printf '%s\n' "$RELEASE" > /boot/matrix-kernel-good +sync +systemctl daemon-reload +systemctl enable matrix-axp313a-health.service >/dev/null +COMMITTED=1 +trap - EXIT HUP INT TERM +echo "AXP313A candidate installed; reboot is required" diff --git a/核桃派软件源代码/scripts/install_frpc_system.sh b/核桃派软件源代码/scripts/install_frpc_system.sh new file mode 100644 index 0000000..e0d450e --- /dev/null +++ b/核桃派软件源代码/scripts/install_frpc_system.sh @@ -0,0 +1,105 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "install_frpc_system.sh must run as root" >&2 + exit 1 +fi +if [ "$(uname -m)" != "aarch64" ]; then + echo "frpc system payload is only for AArch64" >&2 + exit 1 +fi + +SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd) +BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64} +UNIT_SOURCE=$SOURCE_ROOT/systemd/matrix-screen-frpc.service +UNIT=/etc/systemd/system/matrix-screen-frpc.service +DROPIN_DIR=/etc/systemd/system/matrix-screen-frpc.service.d +DROPIN=$DROPIN_DIR/user.conf +BINARY=/usr/local/bin/frpc +SERVICE=matrix-screen-frpc.service +STATE_ROOT=/var/lib/matrix-screen-controller/frp + +if [ ! -f "$BUNDLE/SHA256SUMS" ] || [ ! -f "$BUNDLE/frpc" ] || [ ! -f "$BUNDLE/LICENSE" ]; then + echo "verified frpc offline bundle is incomplete: $BUNDLE" >&2 + exit 1 +fi +(cd "$BUNDLE" && sha256sum -c SHA256SUMS) + +RUN_USER=${FRPC_RUN_USER:-${SUDO_USER:-}} +if [ -z "$RUN_USER" ] && [ -f "$DROPIN" ]; then + RUN_USER=$(sed -n 's/^User=//p' "$DROPIN" | head -n 1) +fi +if [ -z "$RUN_USER" ]; then + RUN_USER=$(python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); from scripts.ota_components import account; print(account())' "$SOURCE_ROOT") +fi +case "$RUN_USER" in + ''|root|*[!a-zA-Z0-9_-]*) + echo "FRPC_RUN_USER must identify the non-root maintenance account" >&2 + exit 1 + ;; +esac +if ! getent passwd "$RUN_USER" >/dev/null 2>&1 && [ "${DEFER_SERVICE_START:-0}" != "1" ]; then + echo "FRP maintenance account does not exist" >&2 + exit 1 +fi +RUN_GROUP=$RUN_USER +if getent passwd "$RUN_USER" >/dev/null 2>&1; then RUN_GROUP=$(id -gn "$RUN_USER"); fi +binary_matches=0 +if [ -f "$BINARY" ] && [ ! -L "$BINARY" ] && cmp -s "$BUNDLE/frpc" "$BINARY" && [ "$(stat -c %a:%u:%g "$BINARY")" = 755:0:0 ]; then binary_matches=1; fi +unit_matches=0 +if [ -f "$DROPIN" ] && cmp -s "$UNIT_SOURCE" "$UNIT" && [ "$(stat -c %a:%u:%g "$UNIT")" = 644:0:0 ] && [ "$(stat -c %a:%u:%g "$DROPIN")" = 644:0:0 ] && [ "$(cat "$DROPIN")" = "$(printf '[Service]\nUser=%s\nGroup=%s\n' "$RUN_USER" "$RUN_GROUP")" ]; then unit_matches=1; fi + +was_active=0 +was_enabled=0 +systemctl is-active --quiet "$SERVICE" 2>/dev/null && was_active=1 || true +systemctl is-enabled --quiet "$SERVICE" 2>/dev/null && was_enabled=1 || true +if [ "$was_active" -eq 1 ] && { [ "$binary_matches" -eq 0 ] || [ "$unit_matches" -eq 0 ]; }; then systemctl stop "$SERVICE"; fi + +install -d -m 0755 /usr/local/bin +temporary=/usr/local/bin/.frpc.install.$$ +cleanup() { + case "$temporary" in + /usr/local/bin/.frpc.install.*) rm -f -- "$temporary" ;; + esac +} +trap cleanup EXIT HUP INT TERM +if [ "$binary_matches" -eq 0 ]; then + install -m 0755 "$BUNDLE/frpc" "$temporary" + "$temporary" --version | grep -Fx '0.71.0' >/dev/null + mv -f -- "$temporary" "$BINARY" +else + "$BINARY" --version | grep -Fx '0.71.0' >/dev/null + echo "frpc binary already verified; skipped" +fi +trap - EXIT HUP INT TERM + +if [ "$unit_matches" -eq 0 ]; then + install -m 0644 "$UNIT_SOURCE" "$UNIT" + install -d -m 0755 "$DROPIN_DIR" + printf '[Service]\nUser=%s\nGroup=%s\n' "$RUN_USER" "$RUN_GROUP" > "$DROPIN" + chmod 0644 "$DROPIN" +fi + +if getent passwd "$RUN_USER" >/dev/null 2>&1; then + RUN_GROUP=$(id -gn "$RUN_USER") + # Local accounts may only traverse (not list) the persistent root; the + # FRP subtree itself remains restricted to root and the maintenance group. + chmod 0711 "$(dirname -- "$STATE_ROOT")" + install -d -o root -g "$RUN_GROUP" -m 2750 "$STATE_ROOT" + install -d -o root -g "$RUN_GROUP" -m 2750 "$STATE_ROOT/profiles" + find "$STATE_ROOT" -type d -exec chown root:"$RUN_GROUP" {} \; -exec chmod 2750 {} \; + find "$STATE_ROOT" -type f -exec chown root:"$RUN_GROUP" {} \; -exec chmod 0640 {} \; + if [ -f "$STATE_ROOT/active.env" ]; then chmod 0600 "$STATE_ROOT/active.env"; fi +elif [ "${DEFER_SERVICE_START:-0}" != "1" ]; then + echo "FRP maintenance account does not exist: $RUN_USER" >&2 + exit 1 +fi + +if [ "$unit_matches" -eq 0 ]; then + systemctl daemon-reload + if [ "$was_enabled" -eq 1 ]; then systemctl enable "$SERVICE" >/dev/null; else systemctl disable "$SERVICE" >/dev/null 2>&1 || true; fi +fi +if [ "$was_active" -eq 1 ] && { [ "$binary_matches" -eq 0 ] || [ "$unit_matches" -eq 0 ]; }; then systemctl start "$SERVICE"; fi +echo "frpc 0.71.0 installed as a system component" diff --git a/核桃派软件源代码/scripts/install_image_bootstrap.sh b/核桃派软件源代码/scripts/install_image_bootstrap.sh new file mode 100644 index 0000000..bb4d58a --- /dev/null +++ b/核桃派软件源代码/scripts/install_image_bootstrap.sh @@ -0,0 +1,82 @@ +#!/bin/bash +set -Eeuo pipefail + +if [ "$(id -u)" -ne 0 ]; then + printf '%s\n' 'install_image_bootstrap.sh must run as root.' >&2 + exit 1 +fi +if [ "$#" -ne 5 ]; then + printf '%s\n' 'usage: install_image_bootstrap.sh IMAGE SERVICE_UNIT SSH_CONFIG KERNEL_DEPENDENCY APP_BUNDLE' >&2 + exit 2 +fi + +IMAGE=$(readlink -f -- "$1") +UNIT=$(readlink -f -- "$2") +SSH_CONFIG=$(readlink -f -- "$3") +KERNEL_DEPENDENCY=$(readlink -f -- "$4") +APP_BUNDLE=$(readlink -f -- "$5") +SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +[ -f "$IMAGE" ] +[ -f "$UNIT" ] +[ -f "$SSH_CONFIG" ] +[ -d "$KERNEL_DEPENDENCY" ] +[ -f "$APP_BUNDLE" ] +KERNEL_ARCHIVE=axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz +[ -f "$KERNEL_DEPENDENCY/$KERNEL_ARCHIVE" ] +[ -f "$KERNEL_DEPENDENCY/SHA256SUMS" ] +[ -f "$KERNEL_DEPENDENCY/METADATA.json" ] +PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/kernel_artifact.py" \ + --kernel "$KERNEL_DEPENDENCY" >/dev/null + +LOOP= +MOUNT_DIR=$(mktemp -d /var/tmp/matrix-image-root.XXXXXX) +cleanup() { + if mountpoint -q "$MOUNT_DIR"; then + umount "$MOUNT_DIR" + fi + if [ -n "$LOOP" ]; then + losetup -d "$LOOP" + fi + rmdir "$MOUNT_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +LOOP=$(losetup --find --show --partscan "$IMAGE") +ROOT_PARTITION="${LOOP}p2" +for _attempt in $(seq 1 20); do + [ -b "$ROOT_PARTITION" ] && break + sleep 0.25 +done +[ -b "$ROOT_PARTITION" ] +mount -o rw "$ROOT_PARTITION" "$MOUNT_DIR" + +[ -x "$MOUNT_DIR/usr/sbin/sshd" ] +[ -x "$MOUNT_DIR/usr/bin/sudo" ] +[ -x "$MOUNT_DIR/usr/sbin/visudo" ] +[ -f "$MOUNT_DIR/lib/systemd/system/ssh.service" ] + +install -D -m 0644 "$UNIT" "$MOUNT_DIR/etc/systemd/system/matrix-image-firstboot.service" +install -D -m 0644 "$SSH_CONFIG" "$MOUNT_DIR/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf" +install -d -m 0755 "$MOUNT_DIR/etc/systemd/system/multi-user.target.wants" +ln -sfn ../matrix-image-firstboot.service \ + "$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/matrix-image-firstboot.service" +systemctl --root="$MOUNT_DIR" unmask ssh.service +systemctl --root="$MOUNT_DIR" enable ssh.service + +# The exported image must not clone identity or network state from the baseline. +find "$MOUNT_DIR/etc/NetworkManager/system-connections" -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true +find "$MOUNT_DIR/var/lib/NetworkManager" -mindepth 1 -maxdepth 1 -type f -delete 2>/dev/null || true +rm -f -- "$MOUNT_DIR"/etc/ssh/ssh_host_* "$MOUNT_DIR/etc/machine-id" +: > "$MOUNT_DIR/etc/machine-id" +rm -f -- "$MOUNT_DIR/var/lib/dbus/machine-id" +ln -sfn /etc/machine-id "$MOUNT_DIR/var/lib/dbus/machine-id" + +PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/image_bootstrap_payload.py" stage \ + --root "$MOUNT_DIR" --bundle "$APP_BUNDLE" --dependency "$KERNEL_DEPENDENCY" >/dev/null + +sync +umount "$MOUNT_DIR" +losetup -d "$LOOP" +LOOP= + +printf '%s\n' 'rootfs bootstrap installed' diff --git a/核桃派软件源代码/scripts/kernel_artifact.py b/核桃派软件源代码/scripts/kernel_artifact.py new file mode 100644 index 0000000..9992a78 --- /dev/null +++ b/核桃派软件源代码/scripts/kernel_artifact.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +import gzip +import hashlib +import json +from pathlib import Path, PurePosixPath +import tarfile +from typing import BinaryIO + + +KERNEL_RELEASE = "6.1.31-matrix-axp313a1" +SOURCE_COMMIT = "30ff3fd5cf45417622b447a12e7a947402ffe34d" +SOURCE_ARCHIVE_NAME = "walnutpi-linux-6.1.31-30ff3fd5.tar.gz" +SOURCE_ARCHIVE_SHA256 = "8659bb3d64313c4693c3605374167a8145186e5c9d43eb771a52a7359699302f" +ARTIFACT_NAME = "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz" +MAX_REGULAR_FILES = 4_096 +MAX_UNPACKED_BYTES = 512 * 1024 * 1024 +REQUIRED_BOOT_FILES = { + "boot/Image-matrix-axp313a1", + "boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb", + "boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb", + f"boot/System.map-{KERNEL_RELEASE}", + f"boot/config-{KERNEL_RELEASE}", +} + + +class KernelArtifactError(ValueError): + pass + + +@dataclass(frozen=True) +class KernelArtifactInfo: + artifact: str + artifact_bytes: int + artifact_sha256: str + kernel_release: str + source_commit: str + unpacked_file_bytes: int + regular_file_count: int + module_file_count: int + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _load_json(path: Path) -> dict: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise KernelArtifactError(f"invalid JSON metadata: {path}") from exc + if not isinstance(document, dict): + raise KernelArtifactError(f"metadata must be an object: {path}") + return document + + +def _verify_outer_sum(directory: Path, expected_name: str) -> tuple[Path, str]: + manifest = directory / "SHA256SUMS" + if not manifest.is_file(): + raise KernelArtifactError(f"SHA256SUMS is missing: {directory}") + try: + lines = [line for line in manifest.read_text(encoding="ascii").splitlines() if line] + except (OSError, UnicodeError) as exc: + raise KernelArtifactError(f"SHA256SUMS is unreadable: {directory}") from exc + if len(lines) != 1 or " " not in lines[0]: + raise KernelArtifactError(f"SHA256SUMS must contain exactly one binary: {directory}") + expected_digest, name = lines[0].split(" ", 1) + if name != expected_name or len(expected_digest) != 64: + raise KernelArtifactError(f"SHA256SUMS entry is wrong: {directory}") + path = directory / name + if not path.is_file(): + raise KernelArtifactError(f"dependency binary is missing: {path}") + actual_digest = sha256_file(path) + if actual_digest != expected_digest: + raise KernelArtifactError(f"dependency SHA-256 mismatch: {path}") + return path, actual_digest + + +def _normalized_member_name(raw: str) -> str: + while raw.startswith("./"): + raw = raw[2:] + pure = PurePosixPath(raw) + if not raw or raw == "." or pure.is_absolute() or ".." in pure.parts or "\\" in raw: + if raw == ".": + return "" + raise KernelArtifactError(f"unsafe archive path: {raw!r}") + return pure.as_posix() + + +def _member_bytes(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes: + handle: BinaryIO | None = archive.extractfile(member) + if handle is None: + raise KernelArtifactError(f"archive member cannot be read: {member.name}") + return handle.read() + + +def _parse_internal_sums(body: bytes) -> dict[str, str]: + try: + lines = body.decode("ascii").splitlines() + except UnicodeError as exc: + raise KernelArtifactError("internal SHA256SUMS is not ASCII") from exc + result: dict[str, str] = {} + for line in lines: + if not line or " " not in line: + raise KernelArtifactError("internal SHA256SUMS contains an invalid line") + digest, name = line.split(" ", 1) + normalized = _normalized_member_name(name) + if len(digest) != 64 or normalized in result: + raise KernelArtifactError("internal SHA256SUMS contains an invalid entry") + result[normalized] = digest + return result + + +def verify_kernel_dependency(directory: Path) -> KernelArtifactInfo: + directory = Path(directory).resolve() + artifact, artifact_digest = _verify_outer_sum(directory, ARTIFACT_NAME) + metadata = _load_json(directory / "METADATA.json") + expected_keys = { + "schema_version", + "architecture", + "kernel_release", + "source_commit", + "source_archive_sha256", + "artifact", + "artifact_bytes", + "artifact_sha256", + "unpacked_file_bytes", + "regular_file_count", + "module_file_count", + "provenance", + } + if set(metadata) != expected_keys: + raise KernelArtifactError("kernel metadata fields are not exact") + fixed = { + "schema_version": 1, + "architecture": "aarch64", + "kernel_release": KERNEL_RELEASE, + "source_commit": SOURCE_COMMIT, + "source_archive_sha256": SOURCE_ARCHIVE_SHA256, + "artifact": ARTIFACT_NAME, + } + if any(metadata.get(key) != value for key, value in fixed.items()): + raise KernelArtifactError("kernel metadata identity is wrong") + if metadata["artifact_bytes"] != artifact.stat().st_size: + raise KernelArtifactError("kernel artifact size metadata is wrong") + if metadata["artifact_sha256"] != artifact_digest: + raise KernelArtifactError("kernel artifact digest metadata is wrong") + for key in ("unpacked_file_bytes", "regular_file_count", "module_file_count"): + if type(metadata[key]) is not int or metadata[key] <= 0: + raise KernelArtifactError(f"kernel metadata {key} must be a positive integer") + if not isinstance(metadata["provenance"], str) or not metadata["provenance"].strip(): + raise KernelArtifactError("kernel metadata provenance is missing") + + with artifact.open("rb") as raw: + header = raw.read(10) + if len(header) != 10 or header[:2] != b"\x1f\x8b" or header[4:8] != b"\0\0\0\0": + raise KernelArtifactError("kernel artifact gzip header is not deterministic") + + try: + archive = tarfile.open(artifact, mode="r:gz") + except (tarfile.TarError, OSError, gzip.BadGzipFile) as exc: + raise KernelArtifactError("kernel artifact is not a readable tar.gz") from exc + with archive: + regular: dict[str, tarfile.TarInfo] = {} + seen: set[str] = set() + unpacked_bytes = 0 + for member in archive.getmembers(): + name = _normalized_member_name(member.name) + if not name: + if not member.isdir(): + raise KernelArtifactError("archive root must be a directory") + continue + if name in seen: + raise KernelArtifactError(f"duplicate archive path: {name}") + seen.add(name) + if not (member.isfile() or member.isdir()): + raise KernelArtifactError(f"archive contains a link or special file: {name}") + if member.uid != 0 or member.gid != 0 or int(member.mtime) != 0: + raise KernelArtifactError(f"archive ownership or timestamp is not normalized: {name}") + if member.isfile(): + regular[name] = member + unpacked_bytes += member.size + if len(regular) > MAX_REGULAR_FILES or unpacked_bytes > MAX_UNPACKED_BYTES: + raise KernelArtifactError("kernel artifact exceeds safety limits") + + required = REQUIRED_BOOT_FILES | {"KERNEL_RELEASE", "SOURCE_COMMIT", "SHA256SUMS"} + missing = required - regular.keys() + if missing: + raise KernelArtifactError(f"kernel artifact is incomplete: {sorted(missing)}") + module_prefix = f"lib/modules/{KERNEL_RELEASE}/" + unexpected = { + name for name in regular if name not in required and not name.startswith(module_prefix) + } + if unexpected: + raise KernelArtifactError(f"kernel artifact contains an unowned file: {sorted(unexpected)}") + forbidden = { + f"lib/modules/{KERNEL_RELEASE}/build", + f"lib/modules/{KERNEL_RELEASE}/source", + } + if forbidden & seen: + raise KernelArtifactError("kernel artifact contains build/source module links") + if _member_bytes(archive, regular["KERNEL_RELEASE"]) != f"{KERNEL_RELEASE}\n".encode(): + raise KernelArtifactError("kernel release marker is wrong") + if _member_bytes(archive, regular["SOURCE_COMMIT"]) != f"{SOURCE_COMMIT}\n".encode(): + raise KernelArtifactError("kernel source commit marker is wrong") + image = _member_bytes(archive, regular["boot/Image-matrix-axp313a1"]) + if f"Linux version {KERNEL_RELEASE}".encode() not in image: + raise KernelArtifactError("kernel Image does not contain the expected release") + + internal = _parse_internal_sums(_member_bytes(archive, regular["SHA256SUMS"])) + verified_files = set(regular) - {"SHA256SUMS"} + if set(internal) != verified_files: + raise KernelArtifactError("internal SHA256SUMS does not cover exactly every payload file") + for name, digest in internal.items(): + actual = hashlib.sha256(_member_bytes(archive, regular[name])).hexdigest() + if actual != digest: + raise KernelArtifactError(f"internal SHA-256 mismatch: {name}") + + module_count = sum(1 for name in regular if name.startswith(module_prefix)) + measurements = { + "unpacked_file_bytes": unpacked_bytes, + "regular_file_count": len(regular), + "module_file_count": module_count, + } + if any(metadata[key] != value for key, value in measurements.items()): + raise KernelArtifactError("kernel artifact measurements do not match metadata") + return KernelArtifactInfo( + artifact=ARTIFACT_NAME, + artifact_bytes=artifact.stat().st_size, + artifact_sha256=artifact_digest, + kernel_release=KERNEL_RELEASE, + source_commit=SOURCE_COMMIT, + **measurements, + ) + + +def verify_source_dependency(directory: Path) -> dict[str, object]: + directory = Path(directory).resolve() + archive, digest = _verify_outer_sum(directory, SOURCE_ARCHIVE_NAME) + metadata = _load_json(directory / "METADATA.json") + expected = { + "schema_version": 1, + "source": "WalnutPi Linux 6.1.31", + "source_commit": SOURCE_COMMIT, + "archive": SOURCE_ARCHIVE_NAME, + "archive_bytes": archive.stat().st_size, + "archive_sha256": SOURCE_ARCHIVE_SHA256, + "purpose": "Offline reproducible build input for 6.1.31-matrix-axp313a1", + } + if metadata != expected or digest != SOURCE_ARCHIVE_SHA256: + raise KernelArtifactError("kernel source dependency metadata is wrong") + return expected + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify offline AXP313A kernel dependencies") + parser.add_argument("--kernel", type=Path, required=True) + parser.add_argument("--source", type=Path) + args = parser.parse_args() + result: dict[str, object] = {"kernel": asdict(verify_kernel_dependency(args.kernel))} + if args.source: + result["source"] = verify_source_dependency(args.source) + print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KernelArtifactError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + raise SystemExit(1) diff --git a/核桃派软件源代码/scripts/measure_gpio_status.py b/核桃派软件源代码/scripts/measure_gpio_status.py new file mode 100644 index 0000000..d017e4c --- /dev/null +++ b/核桃派软件源代码/scripts/measure_gpio_status.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import statistics +import time +import urllib.request +from io import BytesIO +from pathlib import Path +from typing import Any + +from PIL import Image + + +class MeasurementError(RuntimeError): + pass + + +def fetch(url: str, *, timeout: float = 2.0) -> tuple[bytes, float]: + started = time.monotonic() + with urllib.request.urlopen(url, timeout=timeout) as response: + body = response.read() + return body, (time.monotonic() - started) * 1000.0 + + +def rgb_digest(body: bytes) -> str: + with Image.open(BytesIO(body)) as image: + rgb = image.convert("RGB") + return hashlib.sha256(rgb.tobytes()).hexdigest() + + +def percentile_95(values: list[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + return ordered[max(0, min(len(ordered) - 1, int(len(ordered) * 0.95 + 0.999999) - 1))] + + +def driver_status(sample: dict[str, Any]) -> dict[str, Any]: + driver = (sample.get("screen") or {}).get("driver_status") + if not isinstance(driver, dict): + raise MeasurementError("status does not contain screen.driver_status") + return driver + + +def validate_samples( + samples: list[dict[str, Any]], + *, + performance: str, + frame_before: str, + frame_after: str, +) -> dict[str, Any]: + if len(samples) < 2: + raise MeasurementError("at least two status samples are required") + drivers = [driver_status(sample) for sample in samples] + minimum_actual = min(float(item.get("actual_refresh_rate_hz") or 0) for item in drivers) + minimum_scan = min(float(item.get("panel_scan_rate_hz") or 0) for item in drivers) + if minimum_actual < 95.0 or minimum_scan < 95.0: + raise MeasurementError("actual or panel scan refresh rate fell below 95Hz") + if any(item.get("scans_per_frame") != 1 for item in drivers): + raise MeasurementError("scans_per_frame was not 1 throughout the sample") + + stalled_intervals = 0 + maximum_consecutive_stalls = 0 + for previous, current in zip(drivers, drivers[1:]): + frame_delta = int(current.get("completed_frames") or 0) - int(previous.get("completed_frames") or 0) + scan_delta = int(current.get("completed_scans") or 0) - int(previous.get("completed_scans") or 0) + if frame_delta < 0 or scan_delta != frame_delta: + raise MeasurementError("completed frame and scan deltas were negative or unequal") + stalled_intervals = stalled_intervals + 1 if frame_delta == 0 else 0 + maximum_consecutive_stalls = max(maximum_consecutive_stalls, stalled_intervals) + if maximum_consecutive_stalls >= 2: + raise MeasurementError("completed frames stalled for two consecutive samples") + + frame_delta = int(drivers[-1].get("completed_frames") or 0) - int(drivers[0].get("completed_frames") or 0) + scan_delta = int(drivers[-1].get("completed_scans") or 0) - int(drivers[0].get("completed_scans") or 0) + miss_delta = int(drivers[-1].get("deadline_misses") or 0) - int(drivers[0].get("deadline_misses") or 0) + if frame_delta <= 0 or scan_delta != frame_delta: + raise MeasurementError("completed frame/scan totals did not advance equally") + if miss_delta < 0 or miss_delta / frame_delta > 0.001: + raise MeasurementError("deadline miss rate exceeded 0.1%") + + fault_fields = sorted( + key for key, value in drivers[0].items() + if "fault" in key and isinstance(value, (int, float)) and not isinstance(value, bool) + ) + for field in fault_fields: + if any(float(current.get(field) or 0) != float(drivers[0].get(field) or 0) for current in drivers[1:]): + raise MeasurementError(f"{field} changed during the measurement") + for field in ("driver_error", "oe_timing_error", "safeoff_error"): + if any(item.get(field) for item in drivers): + raise MeasurementError(f"{field} was not empty") + for item in drivers: + if item.get("cpu_affinity") != 3 or item.get("cpu_affinity_active") is not True: + raise MeasurementError("CPU3 affinity was not continuously active") + if item.get("realtime_priority") != 50 or item.get("realtime_priority_active") is not True: + raise MeasurementError("SCHED_FIFO 50 was not continuously active") + if item.get("memory_locked") is not True: + raise MeasurementError("refresh memory was not continuously locked") + + for sample in samples: + status = ((sample.get("system") or {}).get("performance_mode") or {}) + if performance == "on": + if status.get("requested") is not True or status.get("effective") is not True: + raise MeasurementError("performance mode was not requested and effective") + if not status.get("current_governors") or any( + governor != "performance" for governor in status["current_governors"].values() + ): + raise MeasurementError("not every cpufreq policy used the performance governor") + elif performance == "off": + if status.get("effective") is not False: + raise MeasurementError("performance mode remained effective while required off") + current = status.get("current_governors") or {} + restore = status.get("restore_governors") or {} + if current and current != restore: + raise MeasurementError("governors were not restored while performance mode was off") + + if frame_before != frame_after: + raise MeasurementError("current RGB frame changed during thumbnail browsing") + return { + "ok": True, + "samples": len(samples), + "minimum_actual_refresh_rate_hz": minimum_actual, + "minimum_panel_scan_rate_hz": minimum_scan, + "completed_frames_delta": frame_delta, + "completed_scans_delta": scan_delta, + "deadline_misses_delta": miss_delta, + "deadline_miss_rate": miss_delta / frame_delta, + "maximum_consecutive_stalled_intervals": maximum_consecutive_stalls, + "fault_fields_checked": fault_fields, + "current_frame_rgb_sha256": frame_before, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Measure real HUB75 GPIO refresh while browsing") + parser.add_argument("--base-url", default="http://127.0.0.1:8080") + parser.add_argument("--seconds", type=int, default=60) + parser.add_argument("--performance", choices=("on", "off", "ignore"), default="ignore") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if not 2 <= args.seconds <= 600: + parser.error("--seconds must be within 2..600") + + frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame") + frame_before = rgb_digest(frame_body) + samples: list[dict[str, Any]] = [] + latencies: list[float] = [] + started = time.monotonic() + for second in range(args.seconds + 1): + target = started + second + if target > time.monotonic(): + time.sleep(target - time.monotonic()) + body, latency = fetch(f"{args.base_url}/api/status") + samples.append(json.loads(body.decode("utf-8"))) + latencies.append(latency) + frame_body, _ = fetch(f"{args.base_url}/api/display/current-frame") + result = validate_samples( + samples, + performance=args.performance, + frame_before=frame_before, + frame_after=rgb_digest(frame_body), + ) + result["status_latency_p95_ms"] = percentile_95(latencies) + if result["status_latency_p95_ms"] > 500.0: + raise MeasurementError("/api/status p95 latency exceeded 500ms") + encoded = json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + if args.output: + args.output.write_text(encoded, encoding="utf-8", newline="\n") + print(encoded, end="") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except MeasurementError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + raise SystemExit(1) diff --git a/核桃派软件源代码/scripts/media_worker.py b/核桃派软件源代码/scripts/media_worker.py new file mode 100644 index 0000000..4d5a310 --- /dev/null +++ b/核桃派软件源代码/scripts/media_worker.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from app.media.worker import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/migrate_state_root.py b/核桃派软件源代码/scripts/migrate_state_root.py new file mode 100644 index 0000000..e5c5627 --- /dev/null +++ b/核桃派软件源代码/scripts/migrate_state_root.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import stat +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +DEFAULT_SOURCE = Path("/opt/matrix-screen-controller/data") +DEFAULT_TARGET = Path("/var/lib/matrix-screen-controller") +STAGING_NAME = ".matrix-screen-controller.migrate" + +ROOT_RUNTIME_FILES = { + "last_frame.png", + "startup_indicator.json", + "startup_indicator.json.tmp", + "config.json.tmp", +} +ROOT_RUNTIME_DIRECTORIES = {"runtime"} +ATOMIC_TEMP_RE = re.compile(r"^\..+\.[0-9a-f]{32}\.tmp$") + + +class StateRootMigrationError(RuntimeError): + """Raised when the state root cannot be migrated without data loss.""" + + +@dataclass(frozen=True) +class MigrationResult: + status: str + source: Path + target: Path + file_count: int + total_bytes: int + + def to_dict(self) -> dict[str, Any]: + return { + "status": self.status, + "source": str(self.source), + "target": str(self.target), + "file_count": self.file_count, + "total_bytes": self.total_bytes, + } + + +def _is_transient(relative: Path) -> bool: + if relative.parts and relative.parts[0] in ROOT_RUNTIME_DIRECTORIES: + return True + if len(relative.parts) == 1 and relative.name in ROOT_RUNTIME_FILES: + return True + return bool(ATOMIC_TEMP_RE.fullmatch(relative.name)) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_manifest(root: Path) -> dict[str, dict[str, Any]]: + root = Path(root) + if not root.exists(): + return {} + if not root.is_dir() or root.is_symlink(): + raise StateRootMigrationError(f"state root must be a real directory: {root}") + + manifest: dict[str, dict[str, Any]] = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + if _is_transient(relative): + continue + if path.is_symlink(): + raise StateRootMigrationError(f"state root contains an unsupported symlink: {relative}") + if path.is_dir(): + manifest[relative.as_posix()] = {"type": "directory"} + continue + if not path.is_file(): + raise StateRootMigrationError(f"state root contains an unsupported entry: {relative}") + size = path.stat().st_size + manifest[relative.as_posix()] = { + "type": "file", + "size": size, + "sha256": _sha256(path), + } + return manifest + + +def _summary(manifest: dict[str, dict[str, Any]]) -> tuple[int, int]: + files = [entry for entry in manifest.values() if entry["type"] == "file"] + return len(files), sum(int(entry["size"]) for entry in files) + + +def _copy_ignore(source: Path): + def ignore(directory: str, names: list[str]) -> set[str]: + base = Path(directory) + ignored: set[str] = set() + for name in names: + relative = (base / name).relative_to(source) + if _is_transient(relative): + ignored.add(name) + return ignored + + return ignore + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _fsync_tree(root: Path) -> None: + if os.name == "nt": + return + directories = [root] + for path in root.rglob("*"): + if path.is_file(): + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + elif path.is_dir(): + directories.append(path) + for directory in reversed(directories): + _fsync_directory(directory) + + +def _assert_staging_path(staging: Path, target: Path) -> None: + if staging.parent != target.parent or staging.name != STAGING_NAME: + raise StateRootMigrationError(f"refusing to clean unexpected staging path: {staging}") + + +def _remove_staging(staging: Path, target: Path) -> None: + if not staging.exists(): + return + _assert_staging_path(staging, target) + if staging.is_symlink() or not staging.is_dir(): + raise StateRootMigrationError(f"refusing to replace unexpected staging entry: {staging}") + shutil.rmtree(staging) + + +def migrate_state_root(source: Path, target: Path) -> MigrationResult: + source_input = Path(source) + target_input = Path(target) + if source_input.is_symlink() or target_input.is_symlink(): + raise StateRootMigrationError("source and target state roots must not be symlinks") + source = source_input.resolve(strict=False) + target = target_input.resolve(strict=False) + if source == target: + raise StateRootMigrationError("source and target state roots must be different") + if source in target.parents or target in source.parents: + raise StateRootMigrationError("source and target state roots must not contain each other") + + source_manifest = build_manifest(source) + if not source.exists(): + target_manifest = build_manifest(target) + file_count, total_bytes = _summary(target_manifest) + if file_count == 0: + raise StateRootMigrationError( + "legacy source is missing and target contains no persistent files" + ) + return MigrationResult("target-only", source, target, file_count, total_bytes) + source_file_count, _source_total_bytes = _summary(source_manifest) + if source_file_count == 0: + raise StateRootMigrationError("legacy source contains no persistent files") + + target_manifest = build_manifest(target) + if target_manifest: + if target_manifest != source_manifest: + raise StateRootMigrationError( + "source and target both contain different state; refusing to merge or overwrite" + ) + file_count, total_bytes = _summary(target_manifest) + return MigrationResult("already-prepared", source, target, file_count, total_bytes) + + target_parent = target.parent + target_parent.mkdir(parents=True, exist_ok=True) + staging = target_parent / STAGING_NAME + _remove_staging(staging, target) + + if target.exists(): + if target.is_symlink() or not target.is_dir(): + raise StateRootMigrationError(f"target state root is not a real directory: {target}") + if any(target.iterdir()): + raise StateRootMigrationError(f"target state root is not empty: {target}") + + try: + shutil.copytree( + source, + staging, + copy_function=shutil.copy2, + ignore=_copy_ignore(source), + ) + copied_manifest = build_manifest(staging) + if copied_manifest != source_manifest: + raise StateRootMigrationError("copied state failed SHA-256 inventory verification") + _fsync_tree(staging) + if target.exists(): + target.rmdir() + os.replace(staging, target) + if os.name != "nt": + os.chmod(target, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP) + _fsync_directory(target_parent) + except Exception: + _remove_staging(staging, target) + raise + + file_count, total_bytes = _summary(source_manifest) + return MigrationResult("migrated", source, target, file_count, total_bytes) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Copy the legacy Matrix Screen Controller data directory to its persistent " + "Linux state root without deleting the source." + ) + ) + parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) + parser.add_argument("--target", type=Path, default=DEFAULT_TARGET) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + result = migrate_state_root(args.source, args.target) + except (OSError, StateRootMigrationError) as exc: + print(f"state migration failed: {exc}", file=sys.stderr) + return 1 + print(json.dumps(result.to_dict(), ensure_ascii=True, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/migrate_ui_copy_draft.py b/核桃派软件源代码/scripts/migrate_ui_copy_draft.py new file mode 100644 index 0000000..46dff29 --- /dev/null +++ b/核桃派软件源代码/scripts/migrate_ui_copy_draft.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import sys +from typing import Any + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +if str(SOURCE_ROOT) not in sys.path: + sys.path.insert(0, str(SOURCE_ROOT)) + +from app.persistence import atomic_write_many_bytes + + +def _read_json(path: Path) -> tuple[dict[str, Any], bytes]: + content = path.read_bytes() + value = json.loads(content.decode("utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value, content + + +def _json_bytes(value: dict[str, Any]) -> bytes: + return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def build_migration( + draft: dict[str, Any], + old_catalog: dict[str, Any], + old_catalog_bytes: bytes, + new_catalog: dict[str, Any], + new_catalog_bytes: bytes, + *, + excluded_copy_ids: set[str], +) -> tuple[dict[str, Any], dict[str, Any]]: + if old_catalog.get("schema_version") != 1: + raise ValueError("old catalog must use schema v1") + if new_catalog.get("schema_version") != 2: + raise ValueError("new catalog must use schema v2") + if draft.get("schema_version") != 1: + raise ValueError("draft must use schema v1") + old_digest = hashlib.sha256(old_catalog_bytes).hexdigest() + if draft.get("base_catalog_digest") != old_digest: + raise ValueError("draft does not match the supplied old catalog") + overrides = draft.get("overrides") + insertions = draft.get("insertions") + if not isinstance(overrides, dict) or not isinstance(insertions, list): + raise ValueError("draft collections are invalid") + + new_items = new_catalog.get("items") + new_anchors = new_catalog.get("anchors") + if not isinstance(new_items, dict) or not isinstance(new_anchors, dict): + raise ValueError("new catalog collections are invalid") + + retained_overrides: dict[str, Any] = {} + removed_overrides: list[dict[str, Any]] = [] + for copy_id, override in overrides.items(): + if copy_id in excluded_copy_ids: + removed_overrides.append({"copy_id": copy_id, "reason": "explicitly_excluded", "override": override}) + elif copy_id not in new_items: + removed_overrides.append({"copy_id": copy_id, "reason": "not_registered_in_v2", "override": override}) + else: + retained_overrides[copy_id] = override + + retained_insertions: list[dict[str, Any]] = [] + removed_insertions: list[dict[str, Any]] = [] + for insertion in insertions: + anchor = new_anchors.get(insertion.get("anchor_id")) if isinstance(insertion, dict) else None + if not isinstance(anchor, dict) or anchor.get("scope") != insertion.get("workspace_id"): + removed_insertions.append({"reason": "anchor_not_registered_in_v2", "insertion": insertion}) + else: + retained_insertions.append(insertion) + + migrated_at = _now() + new_digest = hashlib.sha256(new_catalog_bytes).hexdigest() + migrated = { + "schema_version": 1, + "base_catalog_digest": new_digest, + "revision": int(draft.get("revision", -1)) + 1, + "updated_at": migrated_at, + "overrides": retained_overrides, + "insertions": retained_insertions, + } + report = { + "schema_version": 1, + "migrated_at": migrated_at, + "from_catalog_digest": old_digest, + "to_catalog_digest": new_digest, + "from_revision": draft.get("revision"), + "to_revision": migrated["revision"], + "retained_override_ids": sorted(retained_overrides), + "removed_overrides": removed_overrides, + "retained_insertion_ids": [item.get("id") for item in retained_insertions], + "removed_insertions": removed_insertions, + } + return migrated, report + + +def migrate_files( + draft_path: Path, + old_catalog_path: Path, + new_catalog_path: Path, + report_path: Path, + *, + excluded_copy_ids: set[str], +) -> tuple[dict[str, Any], dict[str, Any]]: + draft, _draft_bytes = _read_json(draft_path) + old_catalog, old_bytes = _read_json(old_catalog_path) + new_catalog, new_bytes = _read_json(new_catalog_path) + migrated, report = build_migration( + draft, + old_catalog, + old_bytes, + new_catalog, + new_bytes, + excluded_copy_ids=excluded_copy_ids, + ) + atomic_write_many_bytes( + ( + (draft_path, _json_bytes(migrated)), + (report_path, _json_bytes(report)), + ) + ) + return migrated, report + + +def main() -> None: + parser = argparse.ArgumentParser(description="Migrate a UI copy draft from catalog v1 to catalog v2") + parser.add_argument("--draft", type=Path, required=True) + parser.add_argument("--old-catalog", type=Path, required=True) + parser.add_argument("--new-catalog", type=Path, required=True) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--exclude-copy-id", action="append", default=[]) + args = parser.parse_args() + migrated, report = migrate_files( + args.draft, + args.old_catalog, + args.new_catalog, + args.report, + excluded_copy_ids=set(args.exclude_copy_id), + ) + print(json.dumps({ + "revision": migrated["revision"], + "retained_overrides": len(report["retained_override_ids"]), + "removed_overrides": len(report["removed_overrides"]), + }, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/核桃派软件源代码/scripts/ota_components.py b/核桃派软件源代码/scripts/ota_components.py new file mode 100644 index 0000000..d18cdcb --- /dev/null +++ b/核桃派软件源代码/scripts/ota_components.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""Durable component transaction; also runnable by old workers and at boot. + +The journal is mirrored into the original and candidate data roots before any +system mutation. The worker may rename either root; one journal always survives. +The recovery executable is independent of releases that the old worker deletes. +Only stdlib imports: recovery must not depend on a candidate venv. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import time + +DATA = Path('/var/lib/matrix-screen-controller') +TARGET = Path('/opt/matrix-screen-controller') +RELEASES = Path('/opt/matrix-screen-controller.releases') +RUNTIME = Path('/run/matrix-screen-controller') +HELPER = Path('/opt/matrix-screen-controller-component-recovery.py') +RECOVERY_UNIT = Path('/etc/systemd/system/matrix-screen-component-recovery.service') +ACCOUNT_POLICY = Path('/etc/sudoers.d/90-matrix-screen-controller-account') +WORK_ROOT = Path('/opt/matrix-screen-controller-ota') +RUNTIME_GUARD = Path('/run/systemd/system/matrix-screen-controller.service.d/90-matrix-ota-runtime.conf') +RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRuntimeDirectoryPreserve=yes\n' +SERVICE = 'matrix-screen-controller.service' +WORKER = 'matrix-screen-controller-ota.service' +FRP = 'matrix-screen-frpc.service' +JOURNAL = Path('ota/component-transaction') +FILES = { + 'frpc': Path('/usr/local/bin/frpc'), + 'frpc-unit': Path('/etc/systemd/system/matrix-screen-frpc.service'), + 'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'), + 'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'), + 'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'), +} + + +def run(args: list[str], *, check=True, **kwargs): + return subprocess.run(args, check=check, capture_output=True, timeout=60, **kwargs) + + +def sync_directory(path: Path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def atomic(path: Path, body: bytes, mode=0o600): + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + '.tmp') + with temp.open('wb') as handle: + os.chmod(temp, mode) + handle.write(body) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, path) + sync_directory(path.parent) + + +def read(path: Path): + return json.loads(path.read_text(encoding='utf-8')) + + +def protect_runtime(): + if RUNTIME_GUARD.exists(): + raise RuntimeError('OTA runtime protection path already exists; refusing to overwrite') + atomic(RUNTIME_GUARD, RUNTIME_GUARD_BODY, 0o644) + run(['systemctl', 'daemon-reload']) + value = run(['systemctl', 'show', SERVICE, '--property=RuntimeDirectoryPreserve', '--value']).stdout.strip() + if value != b'yes': + raise RuntimeError('OTA runtime directory protection did not take effect; refusing to stop service') + + +def release_runtime_guard(): + if RUNTIME_GUARD.exists(): + if RUNTIME_GUARD.read_bytes() != RUNTIME_GUARD_BODY: + raise RuntimeError('OTA runtime protection was modified; preserving it for inspection') + RUNTIME_GUARD.unlink() + try: + RUNTIME_GUARD.parent.rmdir() + except OSError: + pass + run(['systemctl', 'daemon-reload']) + + +def metadata(path: Path): + info = path.stat(follow_symlinks=False) + if stat.S_ISLNK(info.st_mode): + raise RuntimeError('managed component paths must not be symbolic links') + return {'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid} + + +def apply_metadata(path: Path, item: dict): + os.chown(path, item['uid'], item['gid'], follow_symlinks=False) + path.chmod(item['mode']) + + +def permission_snapshot(root: Path): + paths = [root] + if (root / 'frp').exists(): + paths += [root / 'frp', *(root / 'frp').rglob('*')] + return {str(p.relative_to(root)): metadata(p) for p in paths} + + +def restore_permissions(root: Path, saved: dict): + for name, item in saved.items(): + path = root / name + if path.exists(): + apply_metadata(path, item) + # Remove only empty directories created by the installer, never user files. + for name in ('frp/profiles', 'frp'): + if name not in saved: + try: + (root / name).rmdir() + except OSError: + pass + + +def mirror_permissions(candidate: Path): + for name, item in permission_snapshot(DATA).items(): + path = candidate / name + if not path.exists() and (DATA / name).is_dir(): + path.mkdir(parents=True) + if path.exists(): + apply_metadata(path, item) + + +def account() -> str: + import grp + import pwd + dropin = FILES['frpc-dropin'] + if dropin.is_file(): + users = re.findall(r'^User=([a-zA-Z0-9_-]+)$', dropin.read_text(encoding='utf-8'), re.M) + if len(users) == 1: + try: + if pwd.getpwnam(users[0]).pw_uid != 0: + return users[0] + except KeyError: + pass + if ACCOUNT_POLICY.is_file(): + match = re.fullmatch(r'([a-zA-Z0-9_-]+)\s+ALL=\(ALL:ALL\)\s+ALL\s*', + ACCOUNT_POLICY.read_text(encoding='utf-8').strip()) + if match: + try: + if pwd.getpwnam(match[1]).pw_uid != 0: + return match[1] + except KeyError: + pass + raise RuntimeError('项目维护账户配置无效;尚未修改系统组件') + group = grp.getgrnam('sudo') + users = [p.pw_name for p in pwd.getpwall() if 1000 <= p.pw_uid < 65534 + and (p.pw_name in group.gr_mem or p.pw_gid == group.gr_gid) + and p.pw_shell not in ('/usr/sbin/nologin', '/bin/false')] + if len(users) != 1: + raise RuntimeError('无法唯一确定非 root 维护账户,尚未修改系统组件') + return users[0] + + +def check_installed(source: Path, version: str): + import pwd + import grp + entries = read(source / 'UPGRADE_POLICY.json')['checkpoints'] + required = {} + version_tuple = tuple(map(int, version.split('.'))) + for entry in entries: + if tuple(map(int, entry['version'].split('.'))) <= version_tuple: + required.update(entry['components']) + if not required: + return + expected = required['frpc'] + binary = FILES['frpc'] + error = 'frp 系统组件缺失或不完整;请先修复软件安装包组件后重试' + if not binary.is_file() or hashlib.sha256(binary.read_bytes()).hexdigest() != expected['sha256']: + raise RuntimeError(error) + if metadata(binary)['mode'] != 0o755 or run([str(binary), '--version']).stdout.decode().strip() != expected['version']: + raise RuntimeError(error) + if not FILES['frpc-unit'].is_file() or FILES['frpc-unit'].read_bytes() != (source / 'systemd/matrix-screen-frpc.service').read_bytes(): + raise RuntimeError(error) + user = account() + group = grp.getgrgid(pwd.getpwnam(user).pw_gid).gr_name + wanted = f'[Service]\nUser={user}\nGroup={group}\n'.encode() + if not FILES['frpc-dropin'].is_file() or FILES['frpc-dropin'].read_bytes() != wanted: + raise RuntimeError(error) + for name in ('frpc-unit', 'frpc-dropin'): + if metadata(FILES[name]) != {'mode': 0o644, 'uid': 0, 'gid': 0}: + raise RuntimeError(error) + if binary.stat().st_uid != 0 or binary.stat().st_gid != 0: + raise RuntimeError(error) + gid = pwd.getpwnam(user).pw_gid + if stat.S_IMODE(DATA.stat().st_mode) != 0o711: + raise RuntimeError(error) + for name in ('frp', 'frp/profiles'): + if not (DATA / name).is_dir(): + raise RuntimeError(error) + for path in [DATA / 'frp', *(DATA / 'frp').rglob('*')]: + mode = 0o2750 if path.is_dir() else (0o600 if path == DATA / 'frp/active.env' else 0o640) + if metadata(path) != {'mode': mode, 'uid': 0, 'gid': gid}: + raise RuntimeError(error) + + +def begin(source: Path, candidate: Path): + request_path = RUNTIME / 'ota-request.json' + # A regular migration / local test is not authorization to touch the host. + if not request_path.is_file(): + return + request = read(request_path) + job = request.get('job_id', '') + if not re.fullmatch(r'[a-zA-Z0-9_-]+', job): + raise RuntimeError('invalid component transaction id') + version = (source / 'VERSION').read_text(encoding='utf-8').strip() + expected_release = RELEASES / f'{version}-{job}' + expected_candidate = DATA.with_name(f'matrix-screen-controller.ota.{job}') + if source != expected_release or candidate != expected_candidate or version != request['target_version']: + return + if os.geteuid() != 0 or os.uname().machine != 'aarch64': + raise RuntimeError('component transaction requires AArch64 root') + from app.ota.policy import check_upgrade + from app.ota.versioning import SoftwareVersion + check_upgrade(SoftwareVersion.parse(request['current_version']), SoftwareVersion.parse(version)) + if RUNTIME_GUARD.exists(): + raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复') + bundle = source / 'system-dependencies/frpc' + if not bundle.is_dir(): + check_installed(source, version) + return + # Verify the pinned binary, not just a self-reported checksum file. + from app.ota.policy import required_components + expected = required_components(source, SoftwareVersion.parse(version))['frpc'] + if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']: + raise RuntimeError('frpc payload differs from the registered dependency') + user = account() + for root in (DATA, candidate): + if (root / JOURNAL).exists(): + raise RuntimeError('存在未完成组件事务,请先恢复') + journal = DATA / JOURNAL + journal.mkdir(parents=True, mode=0o700) + record = {'job_id': job, 'version': version, 'old_version': request['current_version'], + 'accepted_at': request.get('accepted_at', ''), + 'previous_target': os.readlink(TARGET) if TARGET.is_symlink() else None, + 'permissions': permission_snapshot(DATA), 'files': {}, + 'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0, + 'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0} + try: + for name, path in FILES.items(): + record['files'][name] = metadata(path) if path.exists() else None + if path.exists(): + atomic(journal / name, path.read_bytes()) + atomic(journal / 'state.json', json.dumps(record).encode()) + shutil.copytree(journal, candidate / JOURNAL) + # copytree itself is not durable across power loss. + for path in (candidate / JOURNAL).iterdir(): + with path.open('rb') as handle: + os.fsync(handle.fileno()) + sync_directory(candidate / JOURNAL) + atomic(HELPER, Path(__file__).read_bytes(), 0o700) + unit = ('[Unit]\nDescription=Recover interrupted OTA component transaction\n' + 'After=local-fs.target\nBefore=matrix-screen-controller.service\n' + '[Service]\nType=oneshot\nExecStart=/usr/bin/python3 ' + str(HELPER) + ' recover\n' + '[Install]\nWantedBy=multi-user.target\n') + atomic(RECOVERY_UNIT, unit.encode(), 0o644) + run(['systemctl', 'daemon-reload']) + run(['systemctl', 'enable', RECOVERY_UNIT.name]) + run(['systemd-run', '--quiet', '--collect', '--unit=matrix-screen-component-watch', + '/usr/bin/python3', str(HELPER), 'watch']) + protect_runtime() + env = os.environ.copy() + env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user) + run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env) + mirror_permissions(candidate) + check_installed(source, version) + print('FRP component transaction prepared; waiting for OTA health result') + except BaseException: + # The watcher handles subsequent worker failure. Restore immediately too, + # so a failed migration never leaves new system files while rolling back. + if (journal / 'state.json').is_file(): + restore_components(journal, record) + else: + shutil.rmtree(journal) + raise + + +def restore_components(journal: Path, record: dict): + run(['systemctl', 'stop', FRP], check=False) + for name in ('frpc', 'frpc-unit', 'frpc-dropin'): + path = FILES[name] + item = record['files'][name] + if item is None: + path.unlink(missing_ok=True) + else: + atomic(path, (journal / name).read_bytes(), item['mode']) + apply_metadata(path, item) + restore_permissions(DATA, record['permissions']) + run(['systemctl', 'daemon-reload']) + # A previously absent unit cannot be disabled; avoid treating absence as error. + if record['files']['frpc-unit'] is not None: + run(['systemctl', 'enable' if record['enabled'] else 'disable', FRP]) + else: + run(['systemctl', 'disable', FRP], check=False) + link = Path('/etc/systemd/system/multi-user.target.wants') / FRP + link.unlink(missing_ok=True) + if record['active']: + run(['systemctl', 'start', FRP]) + + +def locate_journal(): + if (DATA / JOURNAL / 'state.json').is_file(): + return DATA / JOURNAL + candidates = list(DATA.parent.glob('matrix-screen-controller.rollback.*/ota/component-transaction/state.json')) + if len(candidates) == 1: + return candidates[0].parent + if candidates: + raise RuntimeError('multiple recovery journals; refusing ambiguous recovery') + return None + + +def committed(record: dict) -> bool: + try: + result = read(DATA / 'ota/state.json')['last_result'] + return (result['status'] == 'success' and result['target_version'] == record['version'] + and (TARGET / 'VERSION').read_text(encoding='utf-8').strip() == record['version'] + and result['installed_at'] >= record['accepted_at']) + except (OSError, ValueError, KeyError, TypeError): + return False + + +def recover_application(journal: Path, record: dict): + job = record['job_id'] + release = RELEASES / f"{record['version']}-{job}" + backup = DATA.with_name(f'matrix-screen-controller.rollback.{job}') + displaced = RELEASES / f"{record['old_version']}-pre-ota-{job}" + switched = TARGET.is_symlink() and TARGET.resolve() == release.resolve() + moved = not TARGET.exists() and (displaced.exists() or record['previous_target']) + if switched or moved or backup.exists(): + run(['systemctl', 'stop', SERVICE], check=False) + if backup.exists(): + if DATA.exists(): + failed = DATA.with_name(f'matrix-screen-controller.failed.{job}') + if failed.exists(): + raise RuntimeError('failed data recovery path already exists') + DATA.rename(failed) + backup.rename(DATA) + shutil.rmtree(failed) + else: + backup.rename(DATA) + journal = DATA / JOURNAL + if switched: + TARGET.unlink() + if switched or moved: + if record['previous_target']: + os.symlink(record['previous_target'], TARGET, target_is_directory=True) + elif displaced.exists(): + displaced.rename(TARGET) + else: + raise RuntimeError('old application is missing; retaining recovery journal') + # Legacy rollback restores only its main unit, not its OTA unit. + for name in ('app-unit', 'ota-unit'): + if record['files'][name]: + atomic(FILES[name], (journal / name).read_bytes(), record['files'][name]['mode']) + apply_metadata(FILES[name], record['files'][name]) + return journal + + +def finish(*, boot=False): + journal = locate_journal() + if journal is None: + return + record = read(journal / 'state.json') + success = committed(record) + if not success: + journal = recover_application(journal, record) + restore_components(journal, record) + runtime_log = RUNTIME / 'ota-worker.log' + try: + if runtime_log.is_file(): + body = runtime_log.read_bytes()[-1024 * 1024:] + body = body.decode('utf-8', errors='replace').encode('utf-8')[-1024 * 1024:] + if body: + atomic(DATA / 'ota/last-failure.log', body) + except OSError: + # Diagnostic storage must never prevent recovery or its cleanup. + pass + result = {'schema_version': 1, 'last_result': { + 'status': 'failed', 'target_version': record['version'], + 'installed_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + 'packaged_at': '', 'release_notes': '', 'error': 'OTA 未完成,组件与应用已恢复'}} + # Preserve a more detailed failure result produced by the worker. + try: + existing = read(DATA / 'ota/state.json')['last_result'] + except (OSError, ValueError, KeyError): + existing = {} + if existing.get('status') != 'failed' or existing.get('target_version') != record['version']: + atomic(DATA / 'ota/state.json', json.dumps(result).encode()) + status_path = RUNTIME / 'ota-status.json' + try: + status = read(status_path) + except (OSError, ValueError): + status = {} + if status.get('job', {}).get('id') == record['job_id'] and status.get('job', {}).get('phase') != 'failed': + status['active'] = False + status['job'].update(phase='failed', percent=0, message='更新中断,已恢复原版本', + error='组件事务已恢复', finished_at=result['last_result']['installed_at']) + atomic(status_path, json.dumps(status).encode()) + run(['systemctl', 'daemon-reload']) + if not boot: + run(['systemctl', 'start', '--no-block', SERVICE]) + job = record['job_id'] + # Finish cleanup even if the old worker died after persisting its success. + release = RELEASES / f"{record['version']}-{job}" + candidate = DATA.with_name(f'matrix-screen-controller.ota.{job}') + backup = DATA.with_name(f'matrix-screen-controller.rollback.{job}') + if success: + old_release = RELEASES / f"{record['old_version']}-pre-ota-{job}" + if record['previous_target']: + previous = Path(record['previous_target']) + previous = previous if previous.is_absolute() else TARGET.parent / previous + if previous.parent == RELEASES and previous != release: + old_release = previous + if old_release.exists() and old_release != TARGET.resolve(): + shutil.rmtree(old_release) + if backup.exists(): + shutil.rmtree(backup) + elif release.exists() and release != TARGET.resolve(): + shutil.rmtree(release) + if candidate.exists(): + shutil.rmtree(candidate) + work = WORK_ROOT / f'work.{job}' + if work.exists(): + shutil.rmtree(work) + request_path = RUNTIME / 'ota-request.json' + try: + request = read(request_path) + except (OSError, ValueError): + request = {} + if request.get('job_id') == job: + package = Path(request.get('package_path', '')) + if package.parent == WORK_ROOT / 'uploads' and package.name == job + '.ota': + package.unlink(missing_ok=True) + request_path.unlink(missing_ok=True) + release_runtime_guard() + for root in (DATA, DATA.with_name(f'matrix-screen-controller.ota.{job}'), + DATA.with_name(f'matrix-screen-controller.rollback.{job}')): + if (root / JOURNAL).exists(): + shutil.rmtree(root / JOURNAL) + # Installer payloads are transaction inputs, not persistent application data. + if success and (release / 'system-dependencies').is_dir(): + shutil.rmtree(release / 'system-dependencies') + run(['systemctl', 'disable', RECOVERY_UNIT.name]) + RECOVERY_UNIT.unlink(missing_ok=True) + run(['systemctl', 'daemon-reload']) + HELPER.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('mode', choices=['watch', 'recover']) + args = parser.parse_args() + if os.geteuid() != 0: + raise RuntimeError('root required') + if args.mode == 'watch': + while True: + state = run(['systemctl', 'show', WORKER, '--property=ActiveState', '--value']).stdout.strip() + if state not in (b'active', b'activating', b'deactivating'): + break + time.sleep(0.5) + finish(boot=args.mode == 'recover') + + +if __name__ == '__main__': + main() diff --git a/核桃派软件源代码/scripts/ota_worker.py b/核桃派软件源代码/scripts/ota_worker.py new file mode 100644 index 0000000..477b53d --- /dev/null +++ b/核桃派软件源代码/scripts/ota_worker.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Privileged one-shot worker for a previously validated browser OTA package.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time +import traceback +from typing import Any + +from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file +from app.ota.package import extract_payload, inspect_package +from app.ota.state import read_json, utc_now, write_json, write_last_result +from app.ota.versioning import SoftwareVersion + +TARGET = Path("/opt/matrix-screen-controller") +RELEASES = Path("/opt/matrix-screen-controller.releases") +DATA_ROOT = Path("/var/lib/matrix-screen-controller") +RUNTIME_ROOT = Path("/run/matrix-screen-controller") +OTA_WORK_ROOT = Path("/opt/matrix-screen-controller-ota") +UNIT_PATH = Path("/etc/systemd/system/matrix-screen-controller.service") +SERVICE = "matrix-screen-controller.service" +REQUEST_PATH = RUNTIME_ROOT / "ota-request.json" + + +class UpdateFailed(RuntimeError): + pass + + +def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]: + records: dict[str, tuple[int, str]] = {} + ignored = ignored or set() + if not root.exists(): + return records + for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.as_posix()): + relative = path.relative_to(root).as_posix() + if relative in ignored: + continue + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + records[relative] = (path.stat().st_size, digest.hexdigest()) + return records + + +class Transaction: + def __init__(self, request: dict[str, Any]) -> None: + self.request = request + self.job_id = checked_token(request.get("job_id"), "job_id") + self.target_version = SoftwareVersion.parse(str(request.get("target_version"))) + self.current_version = SoftwareVersion.parse(str(request.get("current_version"))) + if self.target_version <= self.current_version: + raise UpdateFailed("target version is not newer than the installed version") + self.package_path = Path(str(request.get("package_path"))) + self.status_path = Path(str(request.get("status_path"))) + if self.status_path != RUNTIME_ROOT / "ota-status.json": + raise UpdateFailed("unexpected OTA status path") + self.work_root = OTA_WORK_ROOT / f"work.{self.job_id}" + self.extract_root = self.work_root / "extracted" + self.release = RELEASES / f"{self.target_version}-{self.job_id}" + self.data_candidate = DATA_ROOT.with_name(f"matrix-screen-controller.ota.{self.job_id}") + self.data_backup = DATA_ROOT.with_name(f"matrix-screen-controller.rollback.{self.job_id}") + self.unit_backup = self.work_root / "matrix-screen-controller.service.old" + self.previous_target: Path | None = None + self.previous_directory: Path | None = None + self.program_swapped = False + self.data_swapped = False + self.visual: subprocess.Popen[bytes] | None = None + self.diagnostics = DiagnosticLog(RUNTIME_ROOT / "ota-worker.log") + self.diagnostics.reset() + self.diagnostics.section("更新任务") + self.diagnostics.write( + f"job_id={self.job_id} current_version={self.current_version} " + f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}" + ) + self.job = { + "id": self.job_id, + "target_version": str(self.target_version), + "packaged_at": str(request.get("packaged_at") or ""), + "phase": "preparing", + "percent": 8, + "message": "正在准备更新", + "started_at": str(request.get("accepted_at") or utc_now()), + "finished_at": None, + "error": None, + } + + def update(self, phase: str, percent: int, message: str) -> None: + self.diagnostics.write(f"phase={phase} percent={percent} message={message}") + self.job.update(phase=phase, percent=max(0, min(100, percent)), message=message) + write_json(self.status_path, {"schema_version": 1, "active": True, "job": self.job}) + + 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]: + return self.diagnostics.run( + command, + cwd=cwd, + env=env, + check=check, + failure_label=failure_label, + log_output=log_output, + ) + + def prepare(self) -> None: + if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)): + raise UpdateFailed("OTA transaction paths already exist") + self.work_root.mkdir(parents=True, mode=0o700) + test_root = self.work_root / "test-root" + test_tmp = self.work_root / "test-tmp" + test_root.mkdir(parents=True, mode=0o700) + test_tmp.mkdir(parents=True, mode=0o700) + self.diagnostics.host_summary(test_root=test_root) + self.diagnostics.section("更新包校验") + self.diagnostics.write(f"package={self.package_path.name} sha256={sha256_file(self.package_path)}") + package = inspect_package(self.package_path, current_version=self.current_version) + self.diagnostics.write( + f"package_valid=true target_version={package.target_version} expanded_bytes={package.expanded_bytes}" + ) + if package.target_version != self.target_version: + raise UpdateFailed("request and package versions do not match") + stat = shutil.disk_usage("/opt") + required = max(package.expanded_bytes * 4, 512 * 1024 * 1024) + if stat.free < required: + raise UpdateFailed("not enough free space to stage and validate the OTA release") + + self.update("extracting", 12, "正在解压全量更新包") + extract_payload(package, self.extract_root) + software = self.extract_root / "software" + wheelhouse = self.extract_root / "wheelhouse" + frpc_bundle = software / "system-dependencies/frpc" + if SoftwareVersion.parse((software / "VERSION").read_text(encoding="utf-8")) != self.target_version: + raise UpdateFailed("extracted software VERSION does not match the package") + + self.update("dependencies", 22, "正在校验离线依赖并创建运行环境") + self.run(["sha256sum", "-c", "SHA256SUMS"], cwd=wheelhouse, failure_label="离线 wheel 摘要校验") + if frpc_bundle.is_dir(): + self.run(["sha256sum", "-c", "SHA256SUMS"], cwd=frpc_bundle, failure_label="frpc 系统依赖摘要校验") + shutil.move(str(software), self.release) + self.run(["python3", "--version"], failure_label="系统 Python 版本检查") + self.run(["python3", "-m", "venv", str(self.release / ".venv")], failure_label="创建虚拟环境") + self.run([ + str(self.release / ".venv/bin/pip"), + "install", + "--no-index", + "--disable-pip-version-check", + "--find-links", + str(wheelhouse), + "-r", + str(self.release / "requirements-dev.txt"), + ], failure_label="安装离线依赖") + self.run([str(self.release / ".venv/bin/python"), "-m", "pip", "--version"], failure_label="pip 版本检查") + self.run([str(self.release / ".venv/bin/python"), "-m", "pytest", "--version"], failure_label="pytest 版本检查") + + self.update("building", 42, "正在编译并测试屏幕驱动") + self.run( + ["make", "-C", str(self.release / "app/display/native"), "clean", "all", "test"], + failure_label="原生驱动编译与测试", + ) + self.run( + [str(self.release / ".venv/bin/python"), "-m", "compileall", "-q", str(self.release / "app")], + failure_label="Python 静态编译", + ) + + self.update("testing", 55, "正在运行新版本自动化测试") + test_env = os.environ.copy() + test_env.update( + MATRIX_DRIVER="mock", + MATRIX_TEST_ROOT=str(test_root), + MATRIX_DATA_DIR=str(test_root / "data"), + MATRIX_RUNTIME_DIR=str(test_root / "runtime"), + MATRIX_SOURCE_ONLY_UPDATE_TESTS="1", + TMPDIR=str(test_tmp), + TEMP=str(test_tmp), + TMP=str(test_tmp), + ) + self.run([ + str(self.release / ".venv/bin/python"), "-m", "pytest", + "-p", "no:cacheprovider", "-q", f"--basetemp={self.work_root / 'pytest'}", + "--tb=short", "--disable-warnings", + ], cwd=self.release, env=test_env, failure_label="新版本 pytest") + self.run( + [str(self.release / ".venv/bin/python"), str(self.release / "scripts/dedicated_host.py"), "check"], + failure_label="专用主机检查", + ) + + self.update("migrating", 68, "正在迁移用户数据副本") + shutil.copytree(DATA_ROOT, self.data_candidate, symlinks=True) + runtime_candidate = self.work_root / "migration-runtime" + migration_env = os.environ.copy() + migration_env["PYTHONPATH"] = str(self.release) + self.run([ + str(self.release / ".venv/bin/python"), + "-m", + "scripts.prepare_data_root", + "--data-root", + str(self.data_candidate), + "--runtime-root", + str(runtime_candidate), + ], cwd=self.release, env=migration_env, failure_label="用户数据副本迁移") + + def switch(self) -> None: + self.update("switching", 78, "正在切换到新版本") + shutil.copy2(UNIT_PATH, self.unit_backup) + self.run(["systemctl", "stop", SERVICE], failure_label="停止旧服务") + self.start_visual() + + RELEASES.mkdir(parents=True, exist_ok=True) + if TARGET.is_symlink(): + self.previous_target = Path(os.readlink(TARGET)) + TARGET.unlink() + elif TARGET.is_dir(): + self.previous_directory = RELEASES / f"{self.current_version}-pre-ota-{self.job_id}" + TARGET.rename(self.previous_directory) + else: + raise UpdateFailed("production target is neither a release symlink nor a directory") + os.symlink(self.release, TARGET, target_is_directory=True) + self.program_swapped = True + + DATA_ROOT.rename(self.data_backup) + self.data_candidate.rename(DATA_ROOT) + self.data_swapped = True + shutil.copy2(self.release / "systemd/matrix-screen-controller.service", UNIT_PATH) + shutil.copy2(self.release / "systemd/matrix-screen-controller-ota.service", Path("/etc/systemd/system/matrix-screen-controller-ota.service")) + # prepare_data_root owns the component transaction, including for legacy + # workers. Its independent guardian commits after our final health result. + self.run(["systemctl", "daemon-reload"], failure_label="重载 systemd unit") + self.stop_visual() + self.run(["systemctl", "start", SERVICE], failure_label="启动新服务") + + def verify(self) -> None: + self.update("verifying", 90, "正在验证新版本和真实屏幕驱动") + deadline = time.monotonic() + 45 + last_error = "service did not respond" + attempt = 0 + while time.monotonic() < deadline: + attempt += 1 + try: + response = self.run( + [ + "curl", "--fail", "--silent", "--max-time", "5", + "http://127.0.0.1:8080/api/status", + ], + check=False, + failure_label=f"健康检查第 {attempt} 次", + log_output=False, + ) + if response.returncode != 0: + raise UpdateFailed(f"health endpoint returned exit code {response.returncode}") + status = json.loads(response.stdout.decode("utf-8", errors="strict")) + screen = status.get("screen", {}) + driver = screen.get("driver_status") or {} + if status.get("service", {}).get("software_version") != str(self.target_version): + raise UpdateFailed("new service reports the wrong software version") + if screen.get("driver") != "walnutpi-h618-hub75": + raise UpdateFailed("new service did not start the production HUB75 driver") + if screen.get("hardware_mapping") != "walnutpi-pi-bank-pwm-oe-v2": + raise UpdateFailed("new service reports the wrong hardware mapping") + if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0: + raise UpdateFailed("new service did not pass the PWM4 OE health check") + self.diagnostics.write( + "health_summary=" + f"version:{status.get('service', {}).get('software_version')} " + f"driver:{screen.get('driver')} mapping:{screen.get('hardware_mapping')} " + f"oe_backend:{driver.get('oe_timing_backend')} oe_pulse_faults:{driver.get('oe_pulse_faults')}" + ) + self.diagnostics.write(f"health_check_passed=true attempts={attempt}") + return + except (UpdateFailed, OSError, UnicodeError, json.JSONDecodeError) as exc: + last_error = str(exc) + self.diagnostics.write(f"health_check_attempt={attempt} failed={exc}") + time.sleep(1) + raise UpdateFailed(f"new release health check timed out: {last_error}") + + def succeed(self) -> None: + self.diagnostics.section("更新成功") + self.diagnostics.write("health_check=passed rollback_required=false") + result = { + "status": "success", + "target_version": str(self.target_version), + "packaged_at": str(self.request.get("packaged_at") or ""), + "installed_at": utc_now(), + "release_notes": str(self.request.get("release_notes") or ""), + "error": None, + } + clear_failure_log(DATA_ROOT) + write_last_result(DATA_ROOT, result) + self.job.update( + phase="complete", + percent=100, + message="更新完成", + finished_at=result["installed_at"], + error=None, + ) + write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job}) + self.package_path.unlink(missing_ok=True) + self.request_path().unlink(missing_ok=True) + shutil.rmtree(self.data_backup, ignore_errors=True) + if self.previous_directory is not None: + shutil.rmtree(self.previous_directory, ignore_errors=True) + if self.previous_target is not None: + previous_release = self.previous_target if self.previous_target.is_absolute() else TARGET.parent / self.previous_target + if previous_release.parent == RELEASES: + shutil.rmtree(previous_release, ignore_errors=True) + shutil.rmtree(self.work_root, ignore_errors=True) + prune_empty(self.package_path.parent) + self.diagnostics.path.unlink(missing_ok=True) + + def rollback(self, error: BaseException) -> None: + self.diagnostics.section("失败与回滚") + self.diagnostics.write(f"failure={error.__class__.__name__}: {error}") + self.diagnostics.write( + "traceback_short:\n" + "".join(traceback.format_exception(error, limit=8)).rstrip() + ) + rollback_errors: list[str] = [] + + def attempt(label: str, action) -> None: + try: + action() + self.diagnostics.write(f"rollback_step={label} result=ok") + except BaseException as rollback_error: + detail = f"{label}: {rollback_error.__class__.__name__}: {rollback_error}" + rollback_errors.append(detail) + self.diagnostics.write(f"rollback_step={label} result=failed detail={detail}") + + attempt("停止独立显示进程", self.stop_visual) + if self.program_swapped or self.data_swapped: + attempt( + "停止待回滚服务", + lambda: self.run(["systemctl", "stop", SERVICE], check=False, failure_label="回滚停止服务"), + ) + if self.data_swapped: + failed_data = DATA_ROOT.with_name(f"matrix-screen-controller.failed.{self.job_id}") + if DATA_ROOT.exists(): + attempt("移开失败数据", lambda: DATA_ROOT.rename(failed_data)) + if self.data_backup.exists(): + attempt("恢复原数据", lambda: self.data_backup.rename(DATA_ROOT)) + attempt("清理失败数据", lambda: shutil.rmtree(failed_data, ignore_errors=True)) + if self.program_swapped: + if TARGET.is_symlink(): + attempt("移除失败版本链接", TARGET.unlink) + if self.previous_directory is not None and self.previous_directory.exists(): + attempt("恢复原程序目录", lambda: self.previous_directory.rename(TARGET)) + elif self.previous_target is not None: + attempt( + "恢复原程序链接", + lambda: os.symlink(self.previous_target, TARGET, target_is_directory=True), + ) + if self.unit_backup.exists(): + attempt("恢复原 systemd unit", lambda: shutil.copy2(self.unit_backup, UNIT_PATH)) + attempt( + "回滚后重载 systemd", + lambda: self.run(["systemctl", "daemon-reload"], check=False, failure_label="回滚重载 systemd"), + ) + if self.program_swapped or self.data_swapped: + attempt( + "启动原服务", + lambda: self.run(["systemctl", "start", SERVICE], check=False, failure_label="回滚启动原服务"), + ) + attempt("清理失败程序候选", lambda: shutil.rmtree(self.release, ignore_errors=True)) + attempt("清理数据候选", lambda: shutil.rmtree(self.data_candidate, ignore_errors=True)) + recovery = "回滚完成" if not rollback_errors else "回滚存在错误:" + ";".join(rollback_errors) + self.diagnostics.write(f"final_recovery_state={recovery}") + brief_error = str(error).splitlines()[0][:1000] or error.__class__.__name__ + result = { + "status": "failed", + "target_version": str(self.target_version), + "packaged_at": str(self.request.get("packaged_at") or ""), + "installed_at": utc_now(), + "release_notes": str(self.request.get("release_notes") or ""), + "error": brief_error, + } + try: + self.diagnostics.persist(DATA_ROOT) + self.diagnostics.write("persistent_failure_log=written") + except OSError as persist_error: + self.diagnostics.write( + f"persistent_failure_log=failed detail={persist_error.__class__.__name__}: {persist_error}" + ) + try: + write_last_result(DATA_ROOT, result) + except OSError: + pass + self.job.update( + phase="failed", + percent=0, + message="更新失败,已恢复原版本", + finished_at=result["installed_at"], + error=brief_error, + ) + write_json(self.status_path, {"schema_version": 1, "active": False, "job": self.job}) + self.package_path.unlink(missing_ok=True) + self.request_path().unlink(missing_ok=True) + shutil.rmtree(self.work_root, ignore_errors=True) + self.diagnostics.path.unlink(missing_ok=True) + + def start_visual(self) -> None: + self.diagnostics.section("启动独立更新显示") + python = TARGET / ".venv/bin/python" + self.visual = subprocess.Popen([ + str(python), "-m", "scripts.hub75_visual_hold", + "--mode", "ota", + "--brightness", "40", + "--progress-file", str(self.status_path), + "--orientation", str(int(self.request.get("orientation", 0))), + ]) + time.sleep(1) + if self.visual.poll() is not None: + raise UpdateFailed("independent OTA display process failed to start") + self.diagnostics.write("ota_visual_process=running") + + def stop_visual(self) -> None: + if self.visual is None: + return + if self.visual.poll() is None: + self.visual.send_signal(signal.SIGTERM) + try: + self.visual.wait(timeout=5) + except subprocess.TimeoutExpired: + self.visual.kill() + self.visual.wait(timeout=2) + self.visual = None + self.diagnostics.write("ota_visual_process=stopped") + + def request_path(self) -> Path: + return Path(str(self.request.get("request_path") or REQUEST_PATH)) + + +def checked_token(value: Any, name: str) -> str: + candidate = str(value or "") + if not candidate or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for character in candidate): + raise UpdateFailed(f"invalid {name}") + return candidate + + +def prune_empty(path: Path) -> None: + try: + path.rmdir() + except OSError: + pass + + +def main() -> int: + if os.geteuid() != 0 or os.uname().machine != "aarch64": + print("OTA worker must run as root on the WalnutPi AArch64 host", file=sys.stderr) + return 2 + request = read_json(REQUEST_PATH) + if request is None or request.get("schema_version") != 1: + print("OTA request is missing or invalid", file=sys.stderr) + return 2 + transaction: Transaction | None = None + try: + transaction = Transaction(request) + transaction.prepare() + transaction.switch() + transaction.verify() + transaction.succeed() + return 0 + except BaseException as exc: + if transaction is not None: + transaction.rollback(exc) + print(f"OTA update failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/prepare_data_root.py b/核桃派软件源代码/scripts/prepare_data_root.py new file mode 100644 index 0000000..663124f --- /dev/null +++ b/核桃派软件源代码/scripts/prepare_data_root.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Run every registered persistent-data migration against an isolated data root.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from app.animations.store import AnimationStore +from app.config.store import ConfigStore +from app.fonts.store import FontCatalog +from app.library_order import LibraryOrderStore +from app.network.store import WifiConfigStore +from app.templates.store import TemplateStore + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--data-root", required=True) + parser.add_argument("--runtime-root", required=True) + args = parser.parse_args() + data_root = Path(args.data_root).resolve() + runtime_root = Path(args.runtime_root).resolve() + os.environ["MATRIX_DATA_DIR"] = str(data_root) + os.environ["MATRIX_RUNTIME_DIR"] = str(runtime_root) + + config = ConfigStore() + fonts = FontCatalog(config.data_dir) + TemplateStore(config.data_dir, font_resolver=fonts.resolve) + AnimationStore(config.data_dir, font_resolver=fonts.resolve) + LibraryOrderStore(config.data_dir) + WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="ota-preflight") + from scripts.ota_components import begin + begin(Path(__file__).resolve().parents[1], data_root) + print(f"Persistent data prepared successfully: schema={config.path.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/promote_image_release.py b/核桃派软件源代码/scripts/promote_image_release.py new file mode 100644 index 0000000..c998301 --- /dev/null +++ b/核桃派软件源代码/scripts/promote_image_release.py @@ -0,0 +1,174 @@ +"""Promote an exact, real-card-validated image candidate without rebuilding it.""" +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import uuid + +from app.ota.versioning import SoftwareVersion, read_software_version +from scripts.build_sd_image import build_bundle, sha256_file +from scripts.fat16_image import Fat16Image +from scripts.image_config import read_config_file + + +REPORT_FIELDS = { + "schema_version", "artifact_type", "image_sha256", "software_version", "status", + "firstboot_completed", "automatic_reboot_passed", "default_wifi_connected", + "ssh_password_login_passed", "sudo_password_passed", "root_login_rejected", + "networkmanager_passed", "controller_service_passed", "kernel_health_passed", + "h618_mapping_passed", "web_ui_passed", "plaintext_config_removed", + "machine_identity_regenerated", "second_reboot_passed", "validated_at", +} +PASS_FIELDS = REPORT_FIELDS - { + "schema_version", "artifact_type", "image_sha256", "software_version", "status", "validated_at" +} + + +def _validate_report(path: Path, digest: str, version: SoftwareVersion) -> dict: + report = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(report, dict) or set(report) != REPORT_FIELDS: + raise ValueError("image validation report fields are invalid") + try: + validated_at = datetime.fromisoformat(report.get("validated_at", "")) + except (TypeError, ValueError) as exc: + raise ValueError("image validation timestamp is invalid") from exc + if ( + report["schema_version"] != 1 + or report["artifact_type"] != "image" + or report["image_sha256"] != digest + or report["software_version"] != str(version) + or report["status"] != "success" + or validated_at.tzinfo is None + or any(report[name] is not True for name in PASS_FIELDS) + ): + raise ValueError("candidate lacks matching successful real-card validation") + return report + + +def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path: + from scripts.export_release import ( + _atomic_bytes, _atomic_json, _copy_source, _history, _image_readme, next_patch, + ) + + source = Path(source).resolve() + project = source.parent + candidate = Path(candidate).resolve() + validation = Path(validation).resolve() + lock = project / ".release-export.lock" + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(descriptor) + archive: Path | None = None + staged: Path | None = None + final: Path | None = None + published = False + history_path = project / "发布记录.json" + history_original = history_path.read_bytes() + version_original = (source / "VERSION").read_bytes() + try: + if not candidate.is_dir(): + raise ValueError("validated image candidate directory is missing") + manifest = json.loads((candidate / "manifest.json").read_text(encoding="utf-8")) + version = SoftwareVersion.parse(manifest.get("software_version", "")) + current = read_software_version(source) + if version != next_patch(current): + raise ValueError("validated image candidate must be the next patch version") + artifact_name = f"matrix-screen-controller-{version}.img" + if {path.name for path in candidate.iterdir()} != { + "README.md", "manifest.json", artifact_name, f"{artifact_name}.sha256", + }: + raise ValueError("image candidate directory contents are not exact") + artifact = candidate / artifact_name + digest = sha256_file(artifact) + if ( + manifest.get("artifact_type") != "image" + or manifest.get("artifact") != artifact_name + or manifest.get("image_sha256") != digest + or manifest.get("image_bytes") != artifact.stat().st_size + or manifest.get("notes") != notes.strip() + or (candidate / f"{artifact_name}.sha256").read_bytes() + != f"{digest} {artifact_name}\n".encode("ascii") + ): + raise ValueError("image candidate metadata does not match its artifact") + with Fat16Image(artifact) as image: + config_bytes = image.read_file("MSCCFG.BIN") + config, _generation, _slot = read_config_file(config_bytes) + if config["software_version"] != str(version): + raise ValueError("image candidate configuration version is invalid") + if manifest.get("config_sha256") != hashlib.sha256(config_bytes).hexdigest(): + raise ValueError("image candidate configuration digest is invalid") + if (candidate / "README.md").read_text(encoding="utf-8") != _image_readme(version, manifest, notes, config): + raise ValueError("image candidate README does not match its embedded defaults") + _validate_report(validation, digest, version) + + dependency_root = project / "发布更新相关" / "其他依赖" + kernel = dependency_root / "aarch64-kernel" / "6.1.31-matrix-axp313a1" + with tempfile.TemporaryDirectory(prefix="matrix-image-promotion-") as temporary_text: + temporary = Path(temporary_text) + staged_source = temporary / "核桃派软件源代码" + _copy_source(source, staged_source, version) + expected_bundle = temporary / "MSCBOOT.TGZ" + build_bundle( + staged_source, + dependency_root / "aarch64-py311", + dependency_root / "debian12-aarch64", + expected_bundle, + dependency_root / "frp" / "0.71.0" / "linux-arm64", + ) + subprocess.run( + [ + "bash", str(staged_source / "scripts" / "verify_image_bootstrap.sh"), + str(artifact), str(staged_source / "systemd" / "matrix-image-firstboot.service"), + str(staged_source / "systemd" / "10-walnutpi-screen-hardening.conf"), + str(kernel), str(expected_bundle), + ], + check=True, + ) + + history = _history(history_path) + if any(record.get("version") == str(version) for record in history["releases"]): + raise ValueError("validated image version is already registered") + final = project / "发布更新相关" / "导出包" / str(version) + if final.exists(): + raise ValueError("formal image release directory already exists") + stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + archive = project / "各种归档" / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_IMAGE{version}实卡验收_{uuid.uuid4().hex[:6]}" + archive.mkdir(parents=True) + shutil.copy2(validation, archive / "实机验收.json") + staged = final.parent / f".{version}.{uuid.uuid4().hex}.publishing" + shutil.copytree(candidate, staged) + if sha256_file(staged / artifact_name) != digest: + raise ValueError("candidate copy checksum mismatch") + history["releases"].append({ + "version": str(version), + "artifact_type": "image", + "created_at": manifest["created_at"], + "notes": notes.strip(), + "artifact_path": f"发布更新相关/导出包/{version}/{artifact_name}", + "artifact_sha256": digest, + "validation_path": f"{archive.relative_to(project).as_posix()}/实机验收.json", + "validated_at": stamp, + }) + os.replace(staged, final) + staged = None + published = True + _atomic_json(history_path, history) + _atomic_bytes(source / "VERSION", f"{version}\n".encode("utf-8")) + return final + except BaseException: + if staged is not None: + shutil.rmtree(staged, ignore_errors=True) + if published and final is not None: + shutil.rmtree(final, ignore_errors=True) + _atomic_bytes(history_path, history_original) + _atomic_bytes(source / "VERSION", version_original) + if archive is not None: + shutil.rmtree(archive, ignore_errors=True) + raise + finally: + lock.unlink(missing_ok=True) diff --git a/核桃派软件源代码/scripts/provision_device.py b/核桃派软件源代码/scripts/provision_device.py new file mode 100644 index 0000000..74f9b31 --- /dev/null +++ b/核桃派软件源代码/scripts/provision_device.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import uuid + +try: + import pwd +except ImportError: # pragma: no cover - allows Windows unit tests to import this module + pwd = None # type: ignore[assignment] + +from scripts.image_config import read_config_path + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +SSH_POLICY_SOURCE = SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf" +SSH_POLICY_TARGET = Path("/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf") +SSH_RUNTIME_DIRECTORY = Path("/run/sshd") +SUDOERS_TARGET = Path("/etc/sudoers.d/90-matrix-screen-controller-account") +SUDOERS_MAIN = Path("/etc/sudoers") +HOSTS_PATH = Path("/etc/hosts") +SSH_EXPECTED = { + "permitrootlogin": "no", + "passwordauthentication": "yes", + "kbdinteractiveauthentication": "no", + "permitemptypasswords": "no", +} + + +def run(args: list[str], *, input_text: str | None = None) -> None: + subprocess.run(args, input=input_text, text=True, check=True) + + +def _nm_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace(";", "\\;") + + +def _rooted(root: Path, path: Path) -> Path: + return root / path.relative_to("/") + + +def _remove_temporary(path: Path) -> None: + if os.name == "nt" and path.exists(): + path.chmod(0o600) + path.unlink(missing_ok=True) + + +def _atomic_write(path: Path, data: str, mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_text(data, encoding="utf-8", newline="\n") + temporary.chmod(mode) + os.replace(temporary, path) + finally: + _remove_temporary(temporary) + + +def _sanitize_vendor_sudoers(data: str) -> str: + forbidden = ( + re.compile(r"^\s*Defaults\s+rootpw\s*(?:#.*)?$"), + re.compile(r"^\s*pi\s+ALL\s*=\s*\(ALL\)\s+NOPASSWD\s*:\s*ALL\s*(?:#.*)?$"), + ) + lines = [line for line in data.splitlines() if not any(pattern.fullmatch(line) for pattern in forbidden)] + return "\n".join(lines) + "\n" + + +def _install_sudoers(path: Path, main_path: Path, username: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + main_temporary = main_path.with_name(f".{main_path.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_text(f"{username} ALL=(ALL:ALL) ALL\n", encoding="utf-8", newline="\n") + temporary.chmod(0o440) + main_temporary.write_text( + _sanitize_vendor_sudoers(main_path.read_text(encoding="utf-8")), + encoding="utf-8", + newline="\n", + ) + main_temporary.chmod(0o440) + run(["visudo", "-cf", str(temporary)]) + run(["visudo", "-cf", str(main_temporary)]) + os.replace(main_temporary, main_path) + os.replace(temporary, path) + run(["visudo", "-c"]) + finally: + _remove_temporary(temporary) + _remove_temporary(main_temporary) + + +def _configure_hostname_mapping(path: Path, hostname: str) -> None: + lines = path.read_text(encoding="utf-8").splitlines() + replacement = f"127.0.1.1\t{hostname}" + replaced = False + for index, line in enumerate(lines): + fields = line.split() + if fields and fields[0] == "127.0.1.1": + lines[index] = replacement + replaced = True + break + if not replaced: + lines.append(replacement) + _atomic_write(path, "\n".join(lines) + "\n", 0o644) + + +def configure_network(config: dict) -> None: + wifi = config["wifi"] + ipv4 = config["ipv4"] + connection_uuid = str(uuid.uuid4()) + lines = [ + "[connection]", + "id=matrix-screen", + f"uuid={connection_uuid}", + "type=wifi", + "interface-name=wlan0", + "autoconnect=true", + "", + "[wifi]", + "mode=infrastructure", + f"ssid={_nm_escape(wifi['ssid'])}", + "", + "[wifi-security]", + "key-mgmt=wpa-psk", + f"psk={_nm_escape(wifi['password'])}", + "", + "[ipv4]", + ] + if ipv4["mode"] == "dhcp": + lines.extend(("method=auto", "")) + else: + dns = ";".join(ipv4["dns"]) + ";" + lines.extend( + ( + f"address1={ipv4['address']}/{ipv4['prefix']},{ipv4['gateway']}", + f"dns={dns}", + "method=manual", + "", + ) + ) + lines.extend(("[ipv6]", "method=disabled", "")) + root = Path("/etc/NetworkManager/system-connections") + root.mkdir(parents=True, exist_ok=True) + for candidate in root.glob("*.nmconnection"): + candidate.unlink() + target = root / "matrix-screen.nmconnection" + target.write_text("\n".join(lines), encoding="utf-8", newline="\n") + target.chmod(0o600) + + +def configure_account(config: dict, *, root: Path = Path("/")) -> None: + if pwd is None: + raise RuntimeError("account provisioning requires the POSIX pwd module") + account = config["account"] + username = account["username"] + try: + pwd.getpwnam(username) + except KeyError: + pi = pwd.getpwnam("pi") + groups = subprocess.check_output(["id", "-nG", "pi"], text=True).split() + supplementary = ",".join(group for group in groups if group not in {"pi", username}) + args = ["useradd", "--create-home", "--shell", "/bin/bash"] + if supplementary: + args.extend(("--groups", supplementary)) + args.append(username) + run(args) + home = _rooted(root, Path(f"/home/{username}")) + if Path(pi.pw_dir).is_dir() and not any(home.iterdir()): + for source in Path(pi.pw_dir).iterdir(): + if source.name not in {".ssh", ".bash_history"}: + destination = home / source.name + if source.is_dir(): + shutil.copytree(source, destination, symlinks=True) + elif source.is_file(): + shutil.copy2(source, destination) + run(["chown", "-R", f"{username}:{username}", str(home)]) + run(["chpasswd"], input_text=f"{username}:{account['password']}\n") + run(["usermod", "--append", "--groups", "sudo", username]) + sudoers = _rooted(root, SUDOERS_TARGET) + _install_sudoers(sudoers, _rooted(root, SUDOERS_MAIN), username) + if username != "pi": + run(["passwd", "--lock", "pi"]) + + +def reset_identity(*, root: Path = Path("/")) -> None: + _configure_hostname_mapping(_rooted(root, HOSTS_PATH), "matrix-screen") + run(["hostnamectl", "set-hostname", "matrix-screen"]) + run(["systemctl", "stop", "ssh.service"]) + for key in _rooted(root, Path("/etc/ssh")).glob("ssh_host_*"): + if key.is_file(): + key.unlink() + _rooted(root, Path("/etc/machine-id")).write_text("", encoding="ascii") + dbus = _rooted(root, Path("/var/lib/dbus/machine-id")) + if dbus.exists() or dbus.is_symlink(): + dbus.unlink() + run(["systemd-machine-id-setup"]) + run(["ssh-keygen", "-A"]) + + +def configure_ssh(*, root: Path = Path("/")) -> None: + policy = SSH_POLICY_SOURCE.read_text(encoding="utf-8") + target = _rooted(root, SSH_POLICY_TARGET) + _atomic_write(target, policy, 0o644) + runtime = _rooted(root, SSH_RUNTIME_DIRECTORY) + runtime.mkdir(parents=True, exist_ok=True) + runtime.chmod(0o755) + run(["/usr/sbin/sshd", "-t"]) + effective = subprocess.check_output(["/usr/sbin/sshd", "-T"], text=True) + observed = {} + for line in effective.splitlines(): + key, separator, value = line.partition(" ") + if separator and key in SSH_EXPECTED: + observed[key] = value.strip() + if observed != SSH_EXPECTED: + raise RuntimeError(f"effective SSH policy is invalid: {observed!r}") + run(["systemctl", "unmask", "ssh.service"]) + run(["systemctl", "enable", "ssh.service"]) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("network", "account", "identity", "ssh", "print-public")) + parser.add_argument("--config", type=Path, required=True) + args = parser.parse_args() + config = read_config_path(args.config) + if args.command == "network": + configure_network(config) + elif args.command == "account": + configure_account(config) + elif args.command == "identity": + reset_identity() + elif args.command == "ssh": + configure_ssh() + else: + public = { + "software_version": config["software_version"], + "username": config["account"]["username"], + "ssid": config["wifi"]["ssid"], + "ipv4": config["ipv4"], + } + print(json.dumps(public, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/refresh_sd_image_payload.py b/核桃派软件源代码/scripts/refresh_sd_image_payload.py new file mode 100644 index 0000000..f1881fa --- /dev/null +++ b/核桃派软件源代码/scripts/refresh_sd_image_payload.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +if str(SOURCE_ROOT) not in sys.path: + sys.path.insert(0, str(SOURCE_ROOT)) + +from scripts.fat16_image import Fat16Image +from scripts.image_config import PRODUCT_ID, read_config_file + + +def reject_in_place_refresh(image_path: Path) -> None: + image_path = Path(image_path).resolve() + with Fat16Image(image_path) as image: + config, _, _ = read_config_file(image.read_file("MSCCFG.BIN")) + metadata = json.loads(image.read_file("MSCMETA.JSN").decode("utf-8")) + if config.get("product") != PRODUCT_ID or metadata.get("product") != PRODUCT_ID: + raise ValueError("image product does not match this project") + raise ValueError( + "in-place image payload refresh is retired; rebuild from the official baseline with " + "export_release.py image --repair-current" + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Reject unsafe in-place image refreshes and direct callers to the transactional exporter" + ) + parser.add_argument("--image", type=Path, required=True) + args = parser.parse_args() + reject_in_place_refresh(args.image) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/render_dual_kernel_boot.py b/核桃派软件源代码/scripts/render_dual_kernel_boot.py new file mode 100644 index 0000000..f982d38 --- /dev/null +++ b/核桃派软件源代码/scripts/render_dual_kernel_boot.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + + +RELEASE_TAG = "matrix-axp313a1" +MARKER = "matrix-kernel-good" + + +class BootScriptError(RuntimeError): + pass + + +def render(source: str) -> str: + if "matrix_kernel_candidate=1" in source: + raise BootScriptError("boot script is already candidate-managed") + anchor = ( + 'if test "${docker_optimizations}" = "on"; then setenv bootargs ' + '"${bootargs} cgroup_enable=memory swapaccount=1"; fi\n' + ) + if source.count(anchor) != 1: + raise BootScriptError("bootargs anchor is missing or ambiguous") + candidate = f''' + +# Matrix AXP313A candidate: consume the marker before boot so a failed reboot falls back. +setenv matrix_kernel_file "Image" +setenv matrix_dtb_suffix "" +if test -e ${{devtype}} ${{devnum}} ${{prefix}}{MARKER}; then + if test -e ${{devtype}} ${{devnum}} ${{prefix}}Image-{RELEASE_TAG}; then + fatrm ${{devtype}} ${{devnum}} ${{prefix}}{MARKER} + setenv matrix_kernel_file "Image-{RELEASE_TAG}" + setenv matrix_dtb_suffix "-{RELEASE_TAG}" + setenv bootargs "${{bootargs}} matrix_kernel_candidate=1 panic=10" + echo "Booting one-shot Matrix AXP313A candidate" + fi +fi +''' + replacements = { + "${prefix}${fdtfile_emmc}.dtb": "${prefix}${fdtfile_emmc}${matrix_dtb_suffix}.dtb", + "${prefix}${fdtfile}.dtb": "${prefix}${fdtfile}${matrix_dtb_suffix}.dtb", + "${prefix}Image": "${prefix}${matrix_kernel_file}", + } + expected = { + "${prefix}${fdtfile_emmc}.dtb": 1, + "${prefix}${fdtfile}.dtb": 2, + "${prefix}Image": 1, + } + for old, new in replacements.items(): + if source.count(old) != expected[old]: + raise BootScriptError(f"boot path {old!r} is missing or ambiguous") + source = source.replace(old, new) + rendered = source.replace(anchor, anchor + candidate, 1) + return rendered + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render a one-shot dual-kernel WalnutPi boot.cmd") + parser.add_argument("source", type=Path) + parser.add_argument("destination", type=Path) + args = parser.parse_args() + source = args.source.read_text(encoding="utf-8") + rendered = render(source) + args.destination.write_text(rendered, encoding="utf-8", newline="\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/核桃派软件源代码/scripts/repair_ota_release.py b/核桃派软件源代码/scripts/repair_ota_release.py new file mode 100644 index 0000000..a64402c --- /dev/null +++ b/核桃派软件源代码/scripts/repair_ota_release.py @@ -0,0 +1,102 @@ +"""Promote an explicitly authorized, device-validated OTA without rebuilding it.""" +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import shutil +import tempfile +import uuid + +from app.ota.package import inspect_package, extract_payload, _source_file_allowed +from app.ota.policy import read_policy, required_components, release_metadata, package_format +from app.ota.versioning import read_software_version +from app.ota.diagnostics import sha256_file + + +def promote(source: Path, candidate: Path, validation: Path, notes: str) -> Path: + from scripts.export_release import _history, _commit_repair + project = source.parent + lock = project / '.release-export.lock' + fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(fd) + try: + version = read_software_version(source) + current = project / '发布更新相关' / 'OTA数据包' / str(version) + name = f'matrix-screen-controller-{version}.ota' + history_path = project / '发布记录.json' + history = _history(history_path) + matches = [(i, r) for i, r in enumerate(history['releases']) if r['version'] == str(version)] + if len(matches) != 1 or matches[0][1]['artifact_type'] != 'ota': + raise ValueError('current version must have exactly one OTA release record') + index, previous = matches[0] + original_sha = sha256_file(current / name) + manifest = json.loads((current / 'manifest.json').read_text(encoding='utf-8')) + if (previous['artifact_sha256'] != original_sha or manifest['artifact_sha256'] != original_sha + or (current / (name + '.sha256')).read_text(encoding='ascii') != f'{original_sha} {name}\n'): + raise ValueError('original OTA release metadata does not match its artifact') + info = inspect_package(candidate) + digest = sha256_file(candidate) + report = json.loads(validation.read_text(encoding='utf-8')) + if (info.target_version != version or report.get('package_sha256') != digest + or report.get('installed_version') != str(version) or report.get('status') != 'success' + or report.get('runtime_lifecycle_passed') is not True + or report.get('user_data_preserved') is not True + or report.get('frp_state_preserved') is not True + or report.get('transaction_cleanup_passed') is not True): + raise ValueError('candidate lacks matching successful real-device validation') + if digest == original_sha: + raise ValueError('repair candidate is identical to the current artifact') + with tempfile.TemporaryDirectory(prefix='matrix-ota-repair-check-') as text: + unpacked = Path(text) / 'unpacked' + extract_payload(info, unpacked) + software = unpacked / 'software' + expected = {p.relative_to(source).as_posix() for p in source.rglob('*') + if p.is_file() and _source_file_allowed(p, source)} + actual = {p.relative_to(software).as_posix() for p in software.rglob('*') + if p.is_file() and _source_file_allowed(p, software)} + if actual != expected or any((software / n).read_bytes() != (source / n).read_bytes() for n in expected): + raise ValueError('validated candidate differs from current source; validate a new candidate') + for component, requirement in required_components(source, version).items(): + if version.patch == 0 and sha256_file(software / 'system-dependencies' / component / component) != requirement['sha256']: + raise ValueError('candidate system dependency differs from policy') + stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds') + archive = project / '各种归档' / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_OTA{version}修复前_{uuid.uuid4().hex[:6]}" + archive.mkdir(parents=True) + shutil.copytree(current, archive / '原安装包') + shutil.copy2(history_path, archive / '发布记录_修复前.json') + shutil.copy2(validation, archive / '实机验收.json') + staged = current.parent / f'.{version}.{uuid.uuid4().hex}.repairing' + staged.mkdir() + try: + shutil.copy2(candidate, staged / name) + if sha256_file(staged / name) != digest: + raise ValueError('candidate copy checksum mismatch') + metadata = {**manifest, **release_metadata(version), 'format_version': package_format(version), + 'created_at': info.created_at, 'notes': notes, 'artifact_bytes': candidate.stat().st_size, + 'artifact_sha256': digest, 'repaired_at': stamp, 'replaces_sha256': original_sha} + (staged / 'manifest.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') + (staged / (name+'.sha256')).write_text(f'{digest} {name}\n', encoding='ascii', newline='\n') + (staged / 'README.md').write_text( + f'# OTA {version} 软件安装包(修复版)\n\n{notes}\n\n' + f'- SHA-256:`{digest}`\n- 修复时间:`{stamp}`\n' + '- 通过系统设置上传 `.ota`,无需解压。1.0.x 必须先安装本节点,再安装后续补丁。\n' + '- 已通过真实 systemd 停启测试及测试设备 OTA;正式包与实机验收包字节一致。\n' + '- 已安装 frp 时保留配置和启停状态,完整组件跳过,缺失或损坏时离线修复。\n' + '- 本包修复停止旧服务时运行目录被删除的问题;仍为 1.1.0,不是 1.1.1。\n' + f'- 原失败包与旧记录:`{archive.relative_to(project).as_posix()}`。\n', encoding='utf-8') + repair = {'at': stamp, 'reason': notes, 'previous_sha256': original_sha, + 'archive_path': archive.relative_to(project).as_posix()} + history['releases'][index] = {**previous, 'created_at': info.created_at, 'notes': notes, + 'artifact_sha256': digest, 'component_checkpoints': read_policy(source)['checkpoints'], + 'repairs': [*previous.get('repairs', []), repair]} + leftover = _commit_repair(current, staged, history_path, history) + if leftover: + print(f'obsolete temporary backup requires cleanup: {leftover}') + except BaseException: + shutil.rmtree(staged, ignore_errors=True) + raise + return current + finally: + lock.unlink(missing_ok=True) diff --git a/核桃派软件源代码/scripts/test_runtime_directory_systemd.py b/核桃派软件源代码/scripts/test_runtime_directory_systemd.py new file mode 100644 index 0000000..82a6ca5 --- /dev/null +++ b/核桃派软件源代码/scripts/test_runtime_directory_systemd.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Explicit opt-in real-systemd regression; no production service is stopped.""" +from pathlib import Path +import importlib.util +import json +import os +import shutil +import subprocess +import uuid + + +def main(): + if os.geteuid() != 0 or not Path('/run/systemd/system').is_dir(): + raise RuntimeError('this explicit integration check requires root and real systemd') + spec = importlib.util.spec_from_file_location('runtime_guard', Path(__file__).with_name('ota_components.py')) + component = importlib.util.module_from_spec(spec) + spec.loader.exec_module(component) + token = 'matrix-runtime-test-' + uuid.uuid4().hex + service = token + '.service' + unit = Path('/run/systemd/system') / service + directory = Path('/run') / token + dropin = unit.with_name(service + '.d') / '90-matrix-ota-runtime.conf' + component.SERVICE = service + component.RUNTIME_GUARD = dropin + def run(*args, check=True): + return subprocess.run(['systemctl', *args], check=check, capture_output=True) + def write_unit(command='/bin/true'): + unit.write_text('[Unit]\nDescription=Isolated OTA RuntimeDirectory regression\n' + '[Service]\nType=oneshot\nRemainAfterExit=yes\n' + f'ExecStart={command}\nRuntimeDirectory={token}\nRuntimeDirectoryMode=0750\n' + 'RuntimeDirectoryPreserve=restart\n', encoding='utf-8') + def markers(): + for name in ('ota-request.json', 'ota-status.json', 'ota-worker.log'): + (directory / name).write_bytes(b'unchanged') + def assert_markers(): + assert all((directory / n).read_bytes() == b'unchanged' + for n in ('ota-request.json', 'ota-status.json', 'ota-worker.log')) + try: + write_unit() + run('daemon-reload');run('start', service);markers() + run('stop', service) + assert not directory.exists(), 'negative control did not reproduce stop cleanup' + print('PASS: real systemd Preserve=restart deletes runtime directory on explicit stop', flush=True) + run('start', service);markers() + component.protect_runtime() + run('restart', service);assert_markers() + run('stop', service);assert_markers() + write_unit('/bin/false');run('daemon-reload') + assert run('start', service, check=False).returncode != 0 + assert_markers() + component.release_runtime_guard() + assert not dropin.exists() + assert run('show', service, '--property=RuntimeDirectoryPreserve', '--value').stdout.strip() == b'restart' + print('PASS: real stop/restart/start-failure preserve OTA request/status/log; temporary protection cleaned', flush=True) + print(json.dumps({'runtime_lifecycle_passed': True})) + finally: + run('stop', service, check=False) + unit.unlink(missing_ok=True) + if dropin.exists(): + dropin.unlink() + try: + dropin.parent.rmdir() + except OSError: + pass + run('daemon-reload');run('reset-failed', service, check=False) + assert directory.parent == Path('/run') and directory.name.startswith('matrix-runtime-test-') + if directory.exists(): + shutil.rmtree(directory) + + +if __name__ == '__main__': + main() diff --git a/核桃派软件源代码/scripts/update_walnutpi.sh b/核桃派软件源代码/scripts/update_walnutpi.sh new file mode 100644 index 0000000..1b52fe0 --- /dev/null +++ b/核桃派软件源代码/scripts/update_walnutpi.sh @@ -0,0 +1,262 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "update_walnutpi.sh must run as root" >&2 + exit 1 +fi +if [ "$(uname -m)" != "aarch64" ]; then + echo "this update is only for the WalnutPi AArch64 host" >&2 + exit 1 +fi + +SOURCE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SOURCE_ROOT/.." && pwd) +WHEELHOUSE=${WHEELHOUSE:-$PROJECT_ROOT/发布更新相关/其他依赖/aarch64-py311} +FRPC_BUNDLE=${FRPC_BUNDLE:-$PROJECT_ROOT/发布更新相关/其他依赖/frp/0.71.0/linux-arm64} +MANIFEST=$WHEELHOUSE/SHA256SUMS +TARGET=/opt/matrix-screen-controller +DATA_ROOT=/var/lib/matrix-screen-controller +UNIT=/etc/systemd/system/matrix-screen-controller.service +OTA_UNIT=/etc/systemd/system/matrix-screen-controller-ota.service +SERVICE=matrix-screen-controller.service +SOFTWARE_VERSION=$(tr -d '\r\n' < "$SOURCE_ROOT/VERSION") +RELEASE_ID=${RELEASE_ID:-$SOFTWARE_VERSION-$(date -u +%Y%m%dT%H%M%SZ)} +RELEASES=/opt/matrix-screen-controller.releases + +case "$RELEASE_ID" in + *[!A-Za-z0-9._-]*|'') + echo "RELEASE_ID may contain only letters, digits, dot, underscore, and dash" >&2 + exit 1 + ;; +esac + +STAGING=/opt/matrix-screen-controller.stage.$RELEASE_ID +BACKUP=/opt/matrix-screen-controller.rollback.$RELEASE_ID +NEW_RELEASE=$RELEASES/$RELEASE_ID +DATA_CANDIDATE=/var/lib/matrix-screen-controller.update.$RELEASE_ID +DATA_BACKUP=/var/lib/matrix-screen-controller.rollback.$RELEASE_ID +MIGRATION_RUNTIME=/run/matrix-screen-controller-update.$RELEASE_ID.migration +STATUS_FILE=/run/matrix-screen-controller-update.$RELEASE_ID.status +TEST_DATA=/run/matrix-screen-controller-update.$RELEASE_ID.test-data +TEST_RUNTIME=/run/matrix-screen-controller-update.$RELEASE_ID.test-runtime +FRPC_BACKUP=/run/matrix-screen-controller-update.$RELEASE_ID.frpc-backup +SWAPPED=0 +DATA_BACKED_UP=0 +SUCCESS=0 +SERVICE_STOPPED=0 +FRPC_CHANGED=0 +FRPC_WAS_ACTIVE=0 +FRPC_WAS_ENABLED=0 +PREVIOUS_LINK= + +cleanup_paths() { + for path in "$STAGING" "$DATA_CANDIDATE" "$MIGRATION_RUNTIME" "$STATUS_FILE" "$TEST_DATA" "$TEST_RUNTIME" "$FRPC_BACKUP"; do + case "$path" in + /opt/matrix-screen-controller.stage.$RELEASE_ID|/var/lib/matrix-screen-controller.update.$RELEASE_ID|/run/matrix-screen-controller-update.$RELEASE_ID.migration|/run/matrix-screen-controller-update.$RELEASE_ID.status|/run/matrix-screen-controller-update.$RELEASE_ID.test-data|/run/matrix-screen-controller-update.$RELEASE_ID.test-runtime|/run/matrix-screen-controller-update.$RELEASE_ID.frpc-backup) + if [ -d "$path" ]; then rm -rf -- "$path"; else rm -f -- "$path"; fi + ;; + esac + done +} + +rollback() { + if [ "$SUCCESS" -eq 1 ]; then + return + fi + if [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then + echo "new release failed; restoring previous program and persistent data" >&2 + fi + if [ "$SERVICE_STOPPED" -eq 1 ] || [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then + systemctl stop "$SERVICE" || true + fi + if [ "$DATA_BACKED_UP" -eq 1 ]; then + FAILED_DATA=/var/lib/matrix-screen-controller.failed.$RELEASE_ID + if [ -e "$DATA_ROOT" ] && [ ! -e "$FAILED_DATA" ]; then mv -- "$DATA_ROOT" "$FAILED_DATA"; fi + if [ -d "$DATA_BACKUP" ]; then mv -- "$DATA_BACKUP" "$DATA_ROOT"; fi + case "$FAILED_DATA" in + /var/lib/matrix-screen-controller.failed.$RELEASE_ID) + if [ -d "$FAILED_DATA" ]; then rm -rf -- "$FAILED_DATA"; fi + ;; + esac + fi + if [ "$FRPC_CHANGED" -eq 1 ]; then + systemctl stop matrix-screen-frpc.service 2>/dev/null || true + if [ -f "$FRPC_BACKUP/frpc" ]; then install -m 0755 "$FRPC_BACKUP/frpc" /usr/local/bin/frpc; else rm -f -- /usr/local/bin/frpc; fi + if [ -f "$FRPC_BACKUP/unit" ]; then install -m 0644 "$FRPC_BACKUP/unit" /etc/systemd/system/matrix-screen-frpc.service; else rm -f -- /etc/systemd/system/matrix-screen-frpc.service; fi + if [ -f "$FRPC_BACKUP/dropin" ]; then + install -d -m 0755 /etc/systemd/system/matrix-screen-frpc.service.d + install -m 0644 "$FRPC_BACKUP/dropin" /etc/systemd/system/matrix-screen-frpc.service.d/user.conf + else + rm -f -- /etc/systemd/system/matrix-screen-frpc.service.d/user.conf + fi + systemctl daemon-reload + if [ "$FRPC_WAS_ENABLED" -eq 1 ]; then systemctl enable matrix-screen-frpc.service >/dev/null; else systemctl disable matrix-screen-frpc.service >/dev/null 2>&1 || true; fi + if [ "$FRPC_WAS_ACTIVE" -eq 1 ]; then systemctl start matrix-screen-frpc.service || true; fi + fi + if [ "$SWAPPED" -eq 1 ]; then + if [ -L "$TARGET" ]; then + rm -f -- "$TARGET" + elif [ -d "$TARGET" ]; then + FAILED=/opt/matrix-screen-controller.failed.$RELEASE_ID + if [ ! -e "$FAILED" ]; then mv -- "$TARGET" "$FAILED"; fi + fi + if [ -d "$BACKUP" ]; then + mv -- "$BACKUP" "$TARGET" + elif [ -n "$PREVIOUS_LINK" ]; then + ln -s -- "$PREVIOUS_LINK" "$TARGET" + fi + rm -rf -- "$NEW_RELEASE" + install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT" + install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT" + systemctl daemon-reload + fi + if [ "$SERVICE_STOPPED" -eq 1 ] || [ "$SWAPPED" -eq 1 ] || [ "$DATA_BACKED_UP" -eq 1 ]; then + systemctl start "$SERVICE" || true + fi +} + +finish() { + code=$? + trap - EXIT HUP INT TERM + rollback + cleanup_paths + exit "$code" +} +trap finish EXIT HUP INT TERM + +if [ ! -f "$MANIFEST" ]; then + echo "offline wheel manifest missing: $MANIFEST" >&2 + exit 1 +fi +if [ ! -d "$TARGET" ]; then + echo "existing production target missing: $TARGET" >&2 + exit 1 +fi +if [ ! -d "$DATA_ROOT" ]; then + echo "existing persistent data root missing: $DATA_ROOT" >&2 + exit 1 +fi +if [ -e "$STAGING" ] || [ -e "$BACKUP" ] || [ -e "$NEW_RELEASE" ] || [ -e "$DATA_CANDIDATE" ] || [ -e "$DATA_BACKUP" ]; then + echo "release staging or rollback path already exists for $RELEASE_ID" >&2 + exit 1 +fi + +(cd "$WHEELHOUSE" && sha256sum -c SHA256SUMS) +mkdir -m 0750 -- "$STAGING" +cp -a "$SOURCE_ROOT/." "$STAGING/" +python3 -m venv "$STAGING/.venv" +"$STAGING/.venv/bin/pip" install \ + --no-index \ + --find-links "$WHEELHOUSE" \ + -r "$STAGING/requirements-dev.txt" +make -C "$STAGING/app/display/native" clean all test +"$STAGING/.venv/bin/python" -m compileall -q "$STAGING/app" +mkdir -p -- "$TEST_DATA" "$TEST_RUNTIME/tmp" +(cd "$STAGING" && \ + MATRIX_DRIVER=mock \ + MATRIX_TEST_ROOT="$TEST_DATA" \ + MATRIX_DATA_DIR="$TEST_DATA" \ + MATRIX_RUNTIME_DIR="$TEST_RUNTIME" \ + MATRIX_SOURCE_ONLY_UPDATE_TESTS=1 \ + TMPDIR="$TEST_RUNTIME/tmp" \ + TEMP="$TEST_RUNTIME/tmp" \ + TMP="$TEST_RUNTIME/tmp" \ + "$STAGING/.venv/bin/python" -m pytest -p no:cacheprovider -q \ + --basetemp="$TEST_RUNTIME/pytest" --tb=short --disable-warnings) +"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check + +systemctl stop "$SERVICE" +SERVICE_STOPPED=1 +cp -a -- "$DATA_ROOT" "$DATA_CANDIDATE" +PYTHONPATH="$STAGING" "$STAGING/.venv/bin/python" -m scripts.prepare_data_root \ + --data-root "$DATA_CANDIDATE" \ + --runtime-root "$MIGRATION_RUNTIME" +mkdir -p -- "$RELEASES" +if [ -L "$TARGET" ]; then + PREVIOUS_LINK=$(readlink "$TARGET") + rm -f -- "$TARGET" + SWAPPED=1 +else + mv -- "$TARGET" "$BACKUP" + SWAPPED=1 +fi +mv -- "$STAGING" "$NEW_RELEASE" +ln -s -- "$NEW_RELEASE" "$TARGET" +mv -- "$DATA_ROOT" "$DATA_BACKUP" +DATA_BACKED_UP=1 +mv -- "$DATA_CANDIDATE" "$DATA_ROOT" +install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" "$UNIT" +install -m 0644 "$TARGET/systemd/matrix-screen-controller-ota.service" "$OTA_UNIT" +install -d -m 0700 "$FRPC_BACKUP" +systemctl is-active --quiet matrix-screen-frpc.service 2>/dev/null && FRPC_WAS_ACTIVE=1 || true +systemctl is-enabled --quiet matrix-screen-frpc.service 2>/dev/null && FRPC_WAS_ENABLED=1 || true +if [ -f /usr/local/bin/frpc ]; then cp -p -- /usr/local/bin/frpc "$FRPC_BACKUP/frpc"; fi +if [ -f /etc/systemd/system/matrix-screen-frpc.service ]; then cp -p -- /etc/systemd/system/matrix-screen-frpc.service "$FRPC_BACKUP/unit"; fi +if [ -f /etc/systemd/system/matrix-screen-frpc.service.d/user.conf ]; then cp -p -- /etc/systemd/system/matrix-screen-frpc.service.d/user.conf "$FRPC_BACKUP/dropin"; fi +FRPC_CHANGED=1 +FRPC_BUNDLE="$FRPC_BUNDLE" /bin/sh "$TARGET/scripts/install_frpc_system.sh" +systemctl daemon-reload +systemctl start "$SERVICE" + +check_health() { + attempt=0 + while [ "$attempt" -lt 30 ]; do + if curl --fail --silent http://127.0.0.1:8080/api/status >"$STATUS_FILE"; then + if "$TARGET/.venv/bin/python" - "$STATUS_FILE" <<'PY' +import json +from pathlib import Path +import sys + +status = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +screen = status.get("screen", {}) +driver = screen.get("driver_status") or {} +expected_version = Path("/opt/matrix-screen-controller/VERSION").read_text(encoding="utf-8").strip() +assert status.get("service", {}).get("software_version") == expected_version +assert screen.get("driver") == "walnutpi-h618-hub75" +assert screen.get("hardware_mapping") == "walnutpi-pi-bank-pwm-oe-v2" +assert driver.get("oe_timing_backend") == "h618-pwm4" +assert driver.get("oe_pulse_faults") == 0 +PY + then + return 0 + fi + fi + attempt=$((attempt + 1)) + sleep 1 + done + return 1 +} + +if ! check_health; then + echo "new release did not pass the production health check" >&2 + exit 1 +fi +systemctl restart "$SERVICE" +if ! check_health; then + echo "new release did not pass the restart health check" >&2 + exit 1 +fi + +SUCCESS=1 +SWAPPED=0 +DATA_BACKED_UP=0 +SERVICE_STOPPED=0 +FRPC_CHANGED=0 +case "$BACKUP" in + /opt/matrix-screen-controller.rollback.$RELEASE_ID) + rm -rf -- "$BACKUP" + ;; +esac +case "$DATA_BACKUP" in + /var/lib/matrix-screen-controller.rollback.$RELEASE_ID) + rm -rf -- "$DATA_BACKUP" + ;; +esac +case "$PREVIOUS_LINK" in + /opt/matrix-screen-controller.releases/*) + if [ "$PREVIOUS_LINK" != "$NEW_RELEASE" ]; then rm -rf -- "$PREVIOUS_LINK"; fi + ;; +esac +systemctl --no-pager --full status "$SERVICE" +echo "WalnutPi Matrix Screen Controller updated successfully: $RELEASE_ID" diff --git a/核桃派软件源代码/scripts/verify_image_bootstrap.sh b/核桃派软件源代码/scripts/verify_image_bootstrap.sh new file mode 100644 index 0000000..3d7ca06 --- /dev/null +++ b/核桃派软件源代码/scripts/verify_image_bootstrap.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -Eeuo pipefail + +if [ "$(id -u)" -ne 0 ]; then + printf '%s\n' 'verify_image_bootstrap.sh must run as root.' >&2 + exit 1 +fi +if [ "$#" -ne 5 ]; then + printf '%s\n' 'usage: verify_image_bootstrap.sh IMAGE SERVICE_UNIT SSH_CONFIG KERNEL_DEPENDENCY APP_BUNDLE' >&2 + exit 2 +fi + +IMAGE=$(readlink -f -- "$1") +UNIT=$(readlink -f -- "$2") +SSH_CONFIG=$(readlink -f -- "$3") +KERNEL_DEPENDENCY=$(readlink -f -- "$4") +APP_BUNDLE=$(readlink -f -- "$5") +SCRIPT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +[ -f "$IMAGE" ] +[ -f "$UNIT" ] +[ -f "$SSH_CONFIG" ] +[ -d "$KERNEL_DEPENDENCY" ] +[ -f "$APP_BUNDLE" ] +[ -f "$KERNEL_DEPENDENCY/SHA256SUMS" ] +[ -f "$KERNEL_DEPENDENCY/METADATA.json" ] + +LOOP= +MOUNT_DIR=$(mktemp -d /var/tmp/matrix-image-verify.XXXXXX) +BOOT_MOUNT=$(mktemp -d /var/tmp/matrix-image-boot-verify.XXXXXX) +cleanup() { + if mountpoint -q "$BOOT_MOUNT"; then + umount "$BOOT_MOUNT" + fi + if mountpoint -q "$MOUNT_DIR"; then + umount "$MOUNT_DIR" + fi + if [ -n "$LOOP" ]; then + losetup -d "$LOOP" + fi + rmdir "$MOUNT_DIR" 2>/dev/null || true + rmdir "$BOOT_MOUNT" 2>/dev/null || true +} +trap cleanup EXIT + +LOOP=$(losetup --find --show --partscan --read-only "$IMAGE") +ROOT_PARTITION="${LOOP}p2" +BOOT_PARTITION="${LOOP}p1" +for _attempt in $(seq 1 20); do + [ -b "$ROOT_PARTITION" ] && break + sleep 0.25 +done +[ -b "$ROOT_PARTITION" ] +[ -b "$BOOT_PARTITION" ] +mount -o ro,noload "$ROOT_PARTITION" "$MOUNT_DIR" +mount -o ro "$BOOT_PARTITION" "$BOOT_MOUNT" + +INSTALLED="$MOUNT_DIR/etc/systemd/system/matrix-image-firstboot.service" +ENABLED="$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/matrix-image-firstboot.service" +SSH_ENABLED="$MOUNT_DIR/etc/systemd/system/multi-user.target.wants/ssh.service" +SSH_INSTALLED="$MOUNT_DIR/etc/ssh/sshd_config.d/10-walnutpi-screen-hardening.conf" +cmp -s "$UNIT" "$INSTALLED" +cmp -s "$SSH_CONFIG" "$SSH_INSTALLED" +[ -L "$ENABLED" ] +[ "$(readlink "$ENABLED")" = '../matrix-image-firstboot.service' ] +grep -Fxq 'ExecStart=/boot/MSCINIT' "$INSTALLED" +grep -Eq '^Before=.*ssh\.service.*boot_script\.service' "$INSTALLED" +[ -x "$MOUNT_DIR/usr/sbin/sshd" ] +[ -x "$MOUNT_DIR/usr/bin/sudo" ] +[ -x "$MOUNT_DIR/usr/sbin/visudo" ] +[ -f "$MOUNT_DIR/lib/systemd/system/ssh.service" ] +[ -L "$SSH_ENABLED" ] +[ "$(basename "$(readlink "$SSH_ENABLED")")" = 'ssh.service' ] +[ ! -L "$MOUNT_DIR/etc/systemd/system/ssh.service" ] || [ "$(readlink "$MOUNT_DIR/etc/systemd/system/ssh.service")" != '/dev/null' ] +[ ! -s "$MOUNT_DIR/etc/machine-id" ] +[ "$(readlink "$MOUNT_DIR/var/lib/dbus/machine-id")" = '/etc/machine-id' ] +if find "$MOUNT_DIR/etc/ssh" -maxdepth 1 -name 'ssh_host_*' -print -quit 2>/dev/null | grep -q .; then + printf '%s\n' 'export still contains an SSH host key' >&2 + exit 1 +fi +if find "$MOUNT_DIR/etc/NetworkManager/system-connections" -mindepth 1 -maxdepth 1 -type f -print -quit 2>/dev/null | grep -q .; then + printf '%s\n' 'export still contains a NetworkManager connection' >&2 + exit 1 +fi + +KERNEL_ARCHIVE=axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz +KERNEL_TARGET=$MOUNT_DIR/opt/matrix-image-bootstrap/axp313a +APP_TARGET=$MOUNT_DIR/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ +PYTHONPATH="$SCRIPT_ROOT" python3 "$SCRIPT_ROOT/scripts/image_bootstrap_payload.py" verify \ + --root "$MOUNT_DIR" --bundle "$APP_BUNDLE" --dependency "$KERNEL_DEPENDENCY" >/dev/null +[ ! -e "$BOOT_MOUNT/MSCBOOT.TGZ" ] +python3 - "$BOOT_MOUNT/MSCMETA.JSN" "$KERNEL_DEPENDENCY/METADATA.json" \ + "$KERNEL_TARGET/$KERNEL_ARCHIVE" "$APP_TARGET" <<'PY' +import hashlib +import json +from pathlib import Path +import sys + +image = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +kernel = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +artifact = Path(sys.argv[3]) +bundle = Path(sys.argv[4]) +assert image["format_version"] == 3 +assert image["bundle_path"] == "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ" +assert image["bundle_bytes"] == bundle.stat().st_size +assert image["kernel_release"] == kernel["kernel_release"] +assert image["kernel_artifact"] == kernel["artifact"] +assert image["kernel_artifact_bytes"] == kernel["artifact_bytes"] == artifact.stat().st_size +assert image["kernel_artifact_sha256"] == kernel["artifact_sha256"] +assert image["kernel_unpacked_file_bytes"] == kernel["unpacked_file_bytes"] +digest = hashlib.sha256() +with artifact.open("rb") as handle: + for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): + digest.update(chunk) +assert digest.hexdigest() == kernel["artifact_sha256"] +digest = hashlib.sha256() +with bundle.open("rb") as handle: + for chunk in iter(lambda: handle.read(4 * 1024 * 1024), b""): + digest.update(chunk) +assert digest.hexdigest() == image["bundle_sha256"] +assert image["boot_safety_bytes"] == 32 * 1024 * 1024 +assert image["boot_required_bytes"] > 0 +PY + +printf '%s\n' 'rootfs bootstrap verified' diff --git a/核桃派软件源代码/systemd/10-walnutpi-screen-hardening.conf b/核桃派软件源代码/systemd/10-walnutpi-screen-hardening.conf new file mode 100644 index 0000000..05f009f --- /dev/null +++ b/核桃派软件源代码/systemd/10-walnutpi-screen-hardening.conf @@ -0,0 +1,4 @@ +PermitRootLogin no +PasswordAuthentication yes +KbdInteractiveAuthentication no +PermitEmptyPasswords no diff --git a/核桃派软件源代码/systemd/matrix-axp313a-health.service b/核桃派软件源代码/systemd/matrix-axp313a-health.service new file mode 100644 index 0000000..5bcea9a --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-axp313a-health.service @@ -0,0 +1,14 @@ +[Unit] +Description=Confirm one-shot Matrix AXP313A kernel health +After=matrix-screen-controller.service +Wants=matrix-screen-controller.service +OnFailure=matrix-axp313a-rollback.service +ConditionKernelCommandLine=matrix_kernel_candidate=1 + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /opt/matrix-screen-controller-system/axp313a_kernel_health.py +TimeoutStartSec=90 + +[Install] +WantedBy=multi-user.target diff --git a/核桃派软件源代码/systemd/matrix-axp313a-rollback.service b/核桃派软件源代码/systemd/matrix-axp313a-rollback.service new file mode 100644 index 0000000..af85aff --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-axp313a-rollback.service @@ -0,0 +1,9 @@ +[Unit] +Description=Return to original WalnutPi kernel after candidate failure +DefaultDependencies=no +After=matrix-axp313a-health.service + +[Service] +Type=oneshot +ExecStart=-/opt/matrix-screen-controller/app/display/native/hub75_safeoff +ExecStart=/bin/systemctl --no-block reboot diff --git a/核桃派软件源代码/systemd/matrix-image-firstboot.service b/核桃派软件源代码/systemd/matrix-image-firstboot.service new file mode 100644 index 0000000..b3e6fb0 --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-image-firstboot.service @@ -0,0 +1,15 @@ +[Unit] +Description=Matrix Screen Controller one-time offline image provisioning +RequiresMountsFor=/boot +Wants=NetworkManager.service +After=local-fs.target NetworkManager.service resize_part.service +Before=ssh.service boot_script.service matrix-screen-controller.service +ConditionPathExists=/boot/MSCINIT + +[Service] +Type=oneshot +ExecStart=/boot/MSCINIT +TimeoutStartSec=infinity + +[Install] +WantedBy=multi-user.target diff --git a/核桃派软件源代码/systemd/matrix-screen-controller-ota.service b/核桃派软件源代码/systemd/matrix-screen-controller-ota.service new file mode 100644 index 0000000..f53e69d --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-screen-controller-ota.service @@ -0,0 +1,15 @@ +[Unit] +Description=WalnutPi Matrix Screen Controller OTA Worker +After=matrix-screen-controller.service +ConditionPathExists=/run/matrix-screen-controller/ota-request.json + +[Service] +Type=oneshot +WorkingDirectory=/opt/matrix-screen-controller +Environment=PYTHONDONTWRITEBYTECODE=1 +ExecStart=/opt/matrix-screen-controller/.venv/bin/python -m scripts.ota_worker +TimeoutStartSec=infinity +Nice=5 + +[Install] +WantedBy=multi-user.target diff --git a/核桃派软件源代码/systemd/matrix-screen-controller-preview-perf.service b/核桃派软件源代码/systemd/matrix-screen-controller-preview-perf.service new file mode 100644 index 0000000..883574f --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-screen-controller-preview-perf.service @@ -0,0 +1,31 @@ +[Unit] +Description=Temporary isolated animation thumbnail GPIO performance test +After=NetworkManager.service +Wants=NetworkManager.service +Conflicts=matrix-screen-controller.service + +[Service] +Type=simple +WorkingDirectory=/var/tmp/matrix-screen-controller-preview-candidate-20260824/source +Environment=MATRIX_DRIVER=auto +Environment=MATRIX_DATA_DIR=/var/tmp/matrix-screen-controller-preview-perf-20260824 +Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller-preview-perf-20260824 +Environment=PYTHONDONTWRITEBYTECODE=1 +Environment=MATRIX_MEDIA_WORKER_MODE=inline +RuntimeDirectory=matrix-screen-controller-preview-perf-20260824 +RuntimeDirectoryMode=0750 +RuntimeDirectoryPreserve=restart +CPUAffinity=0 1 2 +LimitRTPRIO=50 +LimitMEMLOCK=infinity +LimitRTTIME=infinity +ExecStartPre=/bin/sh -c 'cat /proc/sys/kernel/sched_rt_runtime_us > /run/matrix-screen-controller-preview-perf-20260824/sched_rt_runtime_us.original && printf "%%s\n" -1 > /proc/sys/kernel/sched_rt_runtime_us' +ExecStartPre=/var/tmp/matrix-screen-controller-preview-candidate-20260824/source/.venv/bin/python /var/tmp/matrix-screen-controller-preview-candidate-20260824/source/scripts/dedicated_host.py check --service +ExecStartPre=/var/tmp/matrix-screen-controller-preview-candidate-20260824/source/app/display/native/hub75_safeoff +ExecStart=/var/tmp/matrix-screen-controller-preview-candidate-20260824/source/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 18081 +ExecStopPost=/var/tmp/matrix-screen-controller-preview-candidate-20260824/source/app/display/native/hub75_safeoff +ExecStopPost=/var/tmp/matrix-screen-controller-preview-candidate-20260824/source/.venv/bin/python -m app.system.performance restore --runtime-root /run/matrix-screen-controller-preview-perf-20260824 +ExecStopPost=/bin/sh -c 'if test -f /run/matrix-screen-controller-preview-perf-20260824/sched_rt_runtime_us.original; then cat /run/matrix-screen-controller-preview-perf-20260824/sched_rt_runtime_us.original > /proc/sys/kernel/sched_rt_runtime_us; rm -f /run/matrix-screen-controller-preview-perf-20260824/sched_rt_runtime_us.original; fi' +Restart=no +TimeoutStopSec=30 + diff --git a/核桃派软件源代码/systemd/matrix-screen-controller.service b/核桃派软件源代码/systemd/matrix-screen-controller.service new file mode 100644 index 0000000..702e365 --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-screen-controller.service @@ -0,0 +1,34 @@ +[Unit] +Description=WalnutPi H618 Matrix Screen Controller +After=NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=simple +WorkingDirectory=/opt/matrix-screen-controller +Environment=MATRIX_DRIVER=auto +Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller +Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller +Environment=PYTHONDONTWRITEBYTECODE=1 +Environment=MATRIX_MEDIA_WORKER_MODE=systemd +StateDirectory=matrix-screen-controller +StateDirectoryMode=0711 +RuntimeDirectory=matrix-screen-controller +RuntimeDirectoryMode=0750 +RuntimeDirectoryPreserve=yes +CPUAffinity=0 1 2 +LimitRTPRIO=50 +LimitMEMLOCK=infinity +LimitRTTIME=infinity +ExecStartPre=/bin/sh -c 'cat /proc/sys/kernel/sched_rt_runtime_us > /run/matrix-screen-controller/sched_rt_runtime_us.original && printf "%%s\n" -1 > /proc/sys/kernel/sched_rt_runtime_us' +ExecStartPre=/opt/matrix-screen-controller/.venv/bin/python /opt/matrix-screen-controller/scripts/dedicated_host.py check --service +ExecStartPre=/opt/matrix-screen-controller/app/display/native/hub75_safeoff +ExecStart=/opt/matrix-screen-controller/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8080 +ExecStopPost=/opt/matrix-screen-controller/app/display/native/hub75_safeoff +ExecStopPost=/opt/matrix-screen-controller/.venv/bin/python -m app.system.performance restore --runtime-root /run/matrix-screen-controller +ExecStopPost=/bin/sh -c 'if test -f /run/matrix-screen-controller/sched_rt_runtime_us.original; then cat /run/matrix-screen-controller/sched_rt_runtime_us.original > /proc/sys/kernel/sched_rt_runtime_us; rm -f /run/matrix-screen-controller/sched_rt_runtime_us.original; fi' +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/核桃派软件源代码/systemd/matrix-screen-converter@.service b/核桃派软件源代码/systemd/matrix-screen-converter@.service new file mode 100644 index 0000000..a55cde3 --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-screen-converter@.service @@ -0,0 +1,24 @@ +[Unit] +Description=Matrix Screen isolated media converter %i +After=matrix-screen-controller.service + +[Service] +Type=oneshot +WorkingDirectory=/opt/matrix-screen-controller +Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller +Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller +Environment=PYTHONDONTWRITEBYTECODE=1 +ExecStart=/usr/bin/flock --exclusive /run/matrix-screen-controller/converter.lock /opt/matrix-screen-controller/.venv/bin/python -m app.media.worker %i +AllowedCPUs=0 1 +CPUAffinity=0 1 +CPUQuota=150% +MemoryMax=384M +MemorySwapMax=0 +IOWeight=10 +Nice=10 +PrivateNetwork=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/matrix-screen-controller /run/matrix-screen-controller +TimeoutStartSec=4h diff --git a/核桃派软件源代码/systemd/matrix-screen-frpc.service b/核桃派软件源代码/systemd/matrix-screen-frpc.service new file mode 100644 index 0000000..ff01b23 --- /dev/null +++ b/核桃派软件源代码/systemd/matrix-screen-frpc.service @@ -0,0 +1,19 @@ +[Unit] +Description=Matrix Screen Controller FRP Client +After=network-online.target +Wants=network-online.target +ConditionPathExists=/var/lib/matrix-screen-controller/frp/active.env + +[Service] +Type=simple +EnvironmentFile=/var/lib/matrix-screen-controller/frp/active.env +WorkingDirectory=/var/lib/matrix-screen-controller/frp +UMask=0027 +ExecStart=/usr/local/bin/frpc -c ${FRPC_CONFIG} --allow-unsafe=TokenSourceExec +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillSignal=SIGTERM + +[Install] +WantedBy=multi-user.target diff --git a/核桃派软件源代码/tests/conftest.py b/核桃派软件源代码/tests/conftest.py new file mode 100644 index 0000000..1dd51f1 --- /dev/null +++ b/核桃派软件源代码/tests/conftest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +_temporary_root: tempfile.TemporaryDirectory | None = None +configured_root = os.environ.get("MATRIX_TEST_ROOT") +legacy_worker_environment = ( + not configured_root + and os.environ.get("MATRIX_SOURCE_ONLY_UPDATE_TESTS") == "1" + and bool(os.environ.get("MATRIX_DATA_DIR")) + and bool(os.environ.get("MATRIX_RUNTIME_DIR")) +) +if legacy_worker_environment: + configured_root = str(Path(os.environ["MATRIX_DATA_DIR"]).parent / "test-root") +if configured_root: + test_root = Path(configured_root) +else: + _temporary_root = tempfile.TemporaryDirectory( + prefix="matrix-screen-controller-pytest-", + ) + test_root = Path(_temporary_root.name) + +test_root.mkdir(parents=True, exist_ok=True) +if legacy_worker_environment: + temporary_path = test_root / "tmp" + temporary_path.mkdir(parents=True, exist_ok=True) + os.environ["MATRIX_TEST_ROOT"] = str(test_root) + os.environ["TMPDIR"] = str(temporary_path) + os.environ["TEMP"] = str(temporary_path) + os.environ["TMP"] = str(temporary_path) + tempfile.tempdir = str(temporary_path) + +os.environ["MATRIX_DRIVER"] = "mock" +os.environ["MATRIX_DATA_DIR"] = str(test_root / "data") +os.environ["MATRIX_RUNTIME_DIR"] = str(test_root / "runtime") +os.environ.pop("STATE_DIRECTORY", None) +os.environ.pop("RUNTIME_DIRECTORY", None) + + +def pytest_configure(config) -> None: + if legacy_worker_environment and config.option.basetemp is None: + config.option.basetemp = str(test_root / "pytest") + + +def pytest_sessionfinish(session, exitstatus) -> None: + del session, exitstatus + if _temporary_root is not None: + _temporary_root.cleanup() diff --git a/核桃派软件源代码/tests/test_animation_preview_fixture.py b/核桃派软件源代码/tests/test_animation_preview_fixture.py new file mode 100644 index 0000000..ebaeb50 --- /dev/null +++ b/核桃派软件源代码/tests/test_animation_preview_fixture.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json + +import pytest + +from app.animations.store import AnimationStore +from scripts.generate_animation_preview_fixture import ( + FixtureError, + generate_fixture, + validate_empty_test_root, +) + + +def test_fixture_requires_empty_explicit_test_directory(tmp_path): + wrong_name = tmp_path / "ordinary" + wrong_name.mkdir() + with pytest.raises(FixtureError, match="name must start"): + validate_empty_test_root(wrong_name) + + root = tmp_path / "matrix-screen-controller-preview-perf-unit" + root.mkdir() + (root / "existing.txt").write_text("keep", encoding="utf-8") + with pytest.raises(FixtureError, match="must be empty"): + validate_empty_test_root(root) + assert (root / "existing.txt").read_text(encoding="utf-8") == "keep" + + +def test_fixture_rejects_production_root(monkeypatch, tmp_path): + root = tmp_path / "matrix-screen-controller-preview-perf-production" + root.mkdir() + monkeypatch.setattr( + "scripts.generate_animation_preview_fixture.PRODUCTION_DATA_ROOT", + root, + ) + with pytest.raises(FixtureError, match="production data root"): + validate_empty_test_root(root) + + +def test_fixture_generates_distinct_complete_animations(tmp_path): + root = tmp_path / "matrix-screen-controller-preview-perf-small" + root.mkdir() + checked = validate_empty_test_root(root) + result = generate_fixture( + checked, + animation_count=2, + frames_per_animation=3, + duration_ms=50, + ) + assert result["total_frames"] == 6 + + store = AnimationStore(root, cleanup_playback=False) + listing = store.list()["animations"] + assert len(listing) == 2 + assert all(item["frame_count"] == 3 for item in listing) + detail = store.get(listing[0]["id"]) + assert [frame["duration_ms"] for frame in detail["frames"]] == [50, 50, 50] + assert len({frame["digest"] for item in listing for frame in store.get(item["id"])["frames"]}) == 6 + for item in listing: + document = json.loads( + (root / "animations" / item["id"] / "animation.json").read_text(encoding="utf-8") + ) + assert document["schema_version"] == 2 diff --git a/核桃派软件源代码/tests/test_animation_ui.mjs b/核桃派软件源代码/tests/test_animation_ui.mjs new file mode 100644 index 0000000..0ab265f --- /dev/null +++ b/核桃派软件源代码/tests/test_animation_ui.mjs @@ -0,0 +1,281 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + animationEditContextName, + animationPreviewFrameAtElapsed, + effectiveFrameName, + formatFrameDuration, + reorderSelectedFrameIds, + validateFrameDuration, +} from "../app/static/animation-ui-model.js"; +import { + DRAG_START_GUARD_PX, + pointInsideExpandedRect, + protectCardDragStart, + reorderByKey, +} from "../app/static/sortable-card.js"; +import { + ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT, + ANIMATION_PREVIEW_MAX_MAX_CONCURRENT, + ANIMATION_PREVIEW_MIN_MAX_CONCURRENT, + ANIMATION_PREVIEW_PAGE_SIZE, + ANIMATION_PREVIEW_PRELOAD_COUNT, + loadCompleteAnimationPreview, + normalizeAnimationPreviewMaxConcurrent, +} from "../app/static/animation-thumbnail-preview.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function staticSource(relativePath) { + return readFile(path.join(root, "app", "static", relativePath), "utf8"); +} + +test("derives automatic frame names from current animation name and position", () => { + const frame = { name: null, position: 2 }; + assert.equal(effectiveFrameName("眨眼", frame), "眨眼-第2帧"); + assert.equal(effectiveFrameName("新名字", frame, 1), "新名字-第1帧"); +}); + +test("keeps custom frame names and restores automatic names when cleared", () => { + assert.equal(effectiveFrameName("眨眼", { name: "闭眼", position: 2 }), "闭眼"); + assert.equal(effectiveFrameName("眨眼", { name: " ", position: 2 }), "眨眼-第2帧"); +}); + +test("builds non-repeating animation edit context names", () => { + assert.equal(animationEditContextName("眨眼", { name: null }, 2), "眨眼-第2帧"); + assert.equal(animationEditContextName("眨眼", { name: "闭眼" }, 2), "眨眼-闭眼"); + assert.equal(animationEditContextName("眨眼", { name: " " }, 1), "眨眼-第1帧"); +}); + +test("selects saved animation preview frames by ordered durations", () => { + const frames = [ + { id: "a", duration_ms: 100 }, + { id: "b", duration_ms: 200 }, + { id: "c", duration_ms: 50 }, + ]; + assert.deepEqual(animationPreviewFrameAtElapsed(frames, 0), { + frame: frames[0], index: 0, remainingMs: 100, totalDurationMs: 350, + }); + assert.equal(animationPreviewFrameAtElapsed(frames, 99).index, 0); + assert.equal(animationPreviewFrameAtElapsed(frames, 100).index, 1); + assert.equal(animationPreviewFrameAtElapsed(frames, 299).remainingMs, 1); + assert.equal(animationPreviewFrameAtElapsed(frames, 300).index, 2); + assert.equal(animationPreviewFrameAtElapsed(frames, 350).index, 0); + assert.equal(animationPreviewFrameAtElapsed(frames, -1).index, 2); + assert.equal(animationPreviewFrameAtElapsed([], 0), null); + assert.equal(animationPreviewFrameAtElapsed([{ duration_ms: 0 }], 0), null); +}); + +test("loads every preview frame through revision-checked pagination", async () => { + const animation = { id: "animation-a", revision: "revision-a", frame_count: 5 }; + const calls = []; + const frames = await loadCompleteAnimationPreview(animation, { + pageSize: 2, + fetchJson: async (url, options) => { + calls.push({ url, signal: options.signal }); + const parsed = new URL(url, "http://localhost"); + const offset = Number(parsed.searchParams.get("frame_offset")); + const pageFrames = Array.from({ length: Math.min(2, 5 - offset) }, (_, index) => ({ + id: `frame-${offset + index}`, + duration_ms: 50 + offset + index, + thumbnail_url: `/thumbnail-${offset + index}.png`, + })); + return { + revision: "revision-a", + frame_count: 5, + frame_total: 5, + frames: pageFrames, + }; + }, + }); + assert.deepEqual(frames.map((frame) => frame.id), [ + "frame-0", "frame-1", "frame-2", "frame-3", "frame-4", + ]); + assert.deepEqual(calls.map(({ url }) => new URL(url, "http://localhost").searchParams.get("frame_offset")), ["0", "2", "4"]); + assert.equal(ANIMATION_PREVIEW_PAGE_SIZE, 500); + assert.equal(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT, 2); + assert.equal(ANIMATION_PREVIEW_MIN_MAX_CONCURRENT, 1); + assert.equal(ANIMATION_PREVIEW_MAX_MAX_CONCURRENT, 50); + assert.equal(ANIMATION_PREVIEW_PRELOAD_COUNT, 3); +}); + +test("validates configurable animation preview concurrency", () => { + for (const value of [1, 2, 50]) { + assert.equal(normalizeAnimationPreviewMaxConcurrent(value), value); + } + for (const value of [0, 51, true, 2.5, "2", null, undefined]) { + assert.throws(() => normalizeAnimationPreviewMaxConcurrent(value)); + } +}); + +test("rejects mixed revisions instead of showing a partial animation", async () => { + const animation = { id: "animation-a", revision: "revision-a", frame_count: 3 }; + let call = 0; + await assert.rejects( + loadCompleteAnimationPreview(animation, { + pageSize: 2, + fetchJson: async () => { + call += 1; + return { + revision: call === 1 ? "revision-a" : "revision-b", + frame_count: 3, + frame_total: 3, + frames: call === 1 + ? [{ id: "a", duration_ms: 50 }, { id: "b", duration_ms: 50 }] + : [{ id: "c", duration_ms: 50 }], + }; + }, + }), + (error) => error.code === "ANIMATION_PREVIEW_STALE", + ); +}); + +test("validates frame duration only when explicitly called", () => { + assert.deepEqual(validateFrameDuration("500"), { valid: true, duration: 500, error: "" }); + assert.deepEqual(validateFrameDuration("0.050", "s"), { valid: true, duration: 50, error: "" }); + assert.deepEqual(validateFrameDuration("604800", "s"), { valid: true, duration: 604800000, error: "" }); + assert.equal(formatFrameDuration(50, "s"), "0.05"); + assert.equal(formatFrameDuration(1230, "s"), "1.23"); + assert.equal(formatFrameDuration(604800000, "ms"), "604800000"); + for (const value of ["", "49", "604800001", "50.5", "abc"]) { + const result = validateFrameDuration(value); + assert.equal(result.valid, false); + assert.equal(result.duration, null); + } + for (const value of ["0.049", "0.1234", "604800.001", "1e2", ".5"]) { + assert.equal(validateFrameDuration(value, "s").valid, false); + } +}); + +test("moves selected frames as one ordered group", () => { + const ids = ["a", "b", "c", "d", "e"]; + assert.deepEqual(reorderSelectedFrameIds(ids, ["b", "d"], null), ["b", "d", "a", "c", "e"]); + assert.deepEqual(reorderSelectedFrameIds(ids, ["d", "b"], "c"), ["a", "c", "b", "d", "e"]); + assert.equal(reorderSelectedFrameIds(ids, ["b", "b"], null), null); + assert.equal(reorderSelectedFrameIds(ids, ["b"], "b"), null); + assert.equal(reorderSelectedFrameIds(ids, ["missing"], null), null); +}); + +test("protects an eight pixel ring around interactive drag controls", () => { + const rect = { left: 20, top: 30, right: 80, bottom: 60 }; + assert.equal(DRAG_START_GUARD_PX, 8); + assert.equal(pointInsideExpandedRect(12, 22, rect), true); + assert.equal(pointInsideExpandedRect(88, 68, rect), true); + assert.equal(pointInsideExpandedRect(11.99, 40, rect), false); + assert.equal(pointInsideExpandedRect(40, 68.01, rect), false); +}); + +test("reorders mixed library keys without mutating the source list", () => { + const keys = ["template:a", "animation:b", "template:c"]; + assert.deepEqual(reorderByKey(keys, "template:c", "template:a"), [ + "template:c", "template:a", "animation:b", + ]); + assert.deepEqual(keys, ["template:a", "animation:b", "template:c"]); + assert.equal(reorderByKey(keys, "missing", "template:a"), null); + assert.equal(reorderByKey(keys, "template:a", "template:a"), null); +}); + +test("restores drag eligibility after protected pointer cancellation and drag end", async () => { + const originalWindow = globalThis.window; + const windowListeners = new Map(); + globalThis.window = { + addEventListener(type, listener) { windowListeners.set(type, listener); }, + removeEventListener(type, listener) { + if (windowListeners.get(type) === listener) windowListeners.delete(type); + }, + }; + const listeners = new Map(); + const classes = new Set(); + const control = { getBoundingClientRect: () => ({ left: 20, top: 20, right: 60, bottom: 50 }) }; + const card = { + draggable: false, + querySelectorAll: () => [control], + addEventListener(type, listener) { listeners.set(type, listener); }, + classList: { + add(value) { classes.add(value); }, + remove(value) { classes.delete(value); }, + }, + }; + try { + protectCardDragStart(card); + let prevented = false; + listeners.get("pointerdown")({ pointerId: 7, clientX: 64, clientY: 40, target: null, preventDefault() { prevented = true; } }); + assert.equal(card.draggable, false); + assert.equal(prevented, false); + windowListeners.get("pointercancel")({ pointerId: 7 }); + await Promise.resolve(); + assert.equal(card.draggable, true); + + listeners.get("pointerdown")({ pointerId: 8, clientX: 100, clientY: 100, target: null }); + listeners.get("dragstart")({ clientX: 100, clientY: 100, target: null, preventDefault() { prevented = true; } }); + assert.equal(classes.has("is-dragging"), true); + listeners.get("dragend")({}); + assert.equal(classes.has("is-dragging"), false); + assert.equal(card.draggable, true); + } finally { + globalThis.window = originalWindow; + } +}); + +test("animation workspace exposes two previews and the simplified control order", async () => { + const [index, animations, templates, preview, feedback, style] = await Promise.all([ + staticSource("index.html"), + staticSource("views/animations.js"), + staticSource("views/templates.js"), + staticSource("animation-thumbnail-preview.js"), + staticSource("interaction-feedback.js"), + staticSource("style.css"), + ]); + assert.match(index, /id="animation-preview-image"/); + assert.match(index, /id="animation-board-preview"/); + assert.match(index, /id="template-dialog-note"/); + assert.match(index, /id="animation-batch-copy"/); + assert.match(index, /id="animation-position-dialog"/); + assert.match(index, /id="animation-frame-pagination"/); + assert.match(index, /id="animation-page-prev"/); + assert.match(index, /id="animation-page-next"/); + assert.match(index, />新建动图<\/button>/); + assert.doesNotMatch(index, /id="animation-apply-all"/); + assert.ok(index.indexOf('class="animation-toolbar panel"') < index.indexOf('id="animation-frame-panel"')); + assert.ok(index.indexOf('id="animation-frame-panel"') < index.indexOf('class="animation-existing"')); + assert.match(animations, /\/frame-durations/); + assert.match(animations, /frame_limit:\s*String\(limit\)/); + assert.match(animations, /\/frame-move/); + assert.match(animations, /frame\.position/); + assert.match(animations, /frames\/copy/); + assert.match(animations, /frames\/batch-delete/); + assert.match(animations, /复制到已选帧/); + assert.match(animations, /reorderSelectedFrameIds/); + assert.match(animations, /protectCardDragStart\(card\)/); + assert.match(animations, /createAnimationThumbnailBox/); + assert.match(animations, /animation-folder-name/); + assert.match(animations, /dataset\.hoverTooltip = item\.name/); + assert.match(templates, /createAnimationThumbnailBox/); + assert.match(preview, /IntersectionObserver/); + assert.match(preview, /rootMargin:\s*"0px"/); + assert.match(preview, /visibilitychange/); + assert.match(preview, /document\.visibilityState === "visible"/); + assert.match(preview, /prefers-reduced-motion: reduce/); + assert.match(preview, /shouldRun\(record\)/); + assert.match(preview, /selected < this\.maxConcurrent/); + assert.match(preview, /activeInitialLoads < this\.maxConcurrent/); + assert.match(preview, /activeLoads < this\.maxConcurrent/); + assert.match(preview, /setAnimationPreviewMaxConcurrent/); + assert.match(preview, /record\.preloads\.clear\(\)[\s\S]*record\.schedule = null/); + assert.match(preview, /initialUrl: animation\.thumbnail_url/); + assert.doesNotMatch(preview, /image\.src = animation\.thumbnail_url/); + assert.match(preview, /isRecordVisible\(record\)/); + assert.match(preview, /getBoundingClientRect\(\)/); + assert.match(feedback, /event\.pointerType === "mouse"/); + assert.match(feedback, /\(hover: hover\) and \(pointer: fine\)/); + assert.match(style, /\.animation-folder-name\s*\{[\s\S]*text-overflow: ellipsis;[\s\S]*white-space: nowrap;/); + assert.match(templates, /当前帧将被保存为一个静态图模板/); + assert.match(templates, /animationEditState\(\)/); + assert.match(templates, /api\/library\/order/); + assert.match(templates, /prepareDemoCard/); + assert.match(templates, /prepareSortableLibraryCard/); + assert.match(templates, /两个演示案例固定在最前/); +}); diff --git a/核桃派软件源代码/tests/test_animations.py b/核桃派软件源代码/tests/test_animations.py new file mode 100644 index 0000000..2408de4 --- /dev/null +++ b/核桃派软件源代码/tests/test_animations.py @@ -0,0 +1,507 @@ +import base64 +import json +import time +from io import BytesIO + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from app.animations.constants import MAX_DURATION_MS +from app.animations.store import AnimationStore +from app.demo_library import DEMO_ANIMATION_ID, DEMO_RESTRICTED_MESSAGE +from app.display.startup_indicator import render_startup_smile +from app.main import create_app +from app.templates.store import TemplateValidationError + + +def scene(color): + return { + "version": 1, + "width": 64, + "height": 64, + "pixelRgb": base64.b64encode(bytes(color) * (64 * 64)).decode("ascii"), + "elements": [], + } + + +def test_animation_metadata_size_cache_tracks_revision(tmp_path): + store = AnimationStore(tmp_path) + created = store.create_from_frames("容量缓存", [{ + "scene": scene((1, 2, 3)), "duration_ms": 50, + }]) + + cached_revision, cached_size = store._metadata_size_cache[created["id"]] + assert cached_revision == created["revision"] + assert cached_size == created["size_bytes"] + + renamed = store.rename(created["id"], "容量缓存新版", created["revision"]) + next_revision, next_size = store._metadata_size_cache[created["id"]] + assert next_revision == renamed["revision"] + assert next_revision != cached_revision + assert next_size == renamed["size_bytes"] + + listed = store.list() + listed["animations"].clear() + assert store.list()["animations"][0]["name"] == "容量缓存新版" + + renamed_again = store.rename(created["id"], "容量缓存最终版", renamed["revision"]) + assert store.list()["animations"][0]["revision"] == renamed_again["revision"] + + +def test_animation_store_primes_first_listing_during_startup(tmp_path, monkeypatch): + store = AnimationStore(tmp_path) + created = store.create_from_frames("启动缓存", [{ + "scene": scene((4, 5, 6)), "duration_ms": 50, + }]) + + restarted = AnimationStore(tmp_path) + monkeypatch.setattr( + restarted, + "_all", + lambda: (_ for _ in ()).throw(AssertionError("listing re-read persisted frames")), + ) + + listing = restarted.list() + assert listing["animations"][0]["id"] == created["id"] + + +def test_animation_list_cache_tracks_commits_from_another_store(tmp_path): + reader = AnimationStore(tmp_path) + writer = AnimationStore(tmp_path, cleanup_playback=False) + assert reader.list()["animations"] == [] + + created = writer.create_from_frames("外部转换", [{ + "scene": scene((7, 8, 9)), "duration_ms": 50, + }]) + assert [item["id"] for item in reader.list()["animations"]] == [created["id"]] + + renamed = writer.rename(created["id"], "外部转换新版", created["revision"]) + assert reader.list()["animations"][0]["name"] == "外部转换新版" + + writer.delete(created["id"], renamed["revision"]) + assert reader.list()["animations"] == [] + + +def test_animation_api_sees_an_external_store_commit_without_restart(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(app) as client: + assert client.get("/api/animations").json()["animations"] == [] + external = AnimationStore(app.state.config_store.data_dir, cleanup_playback=False) + created = external.create_from_frames("独立转换进程", [{ + "scene": scene((10, 11, 12)), "duration_ms": 50, + }]) + + listed = client.get("/api/animations").json()["animations"] + assert [item["id"] for item in listed] == [created["id"]] + + +def test_default_animation_delete_and_last_frame_delete_fall_back(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(app) as client: + created = client.post("/api/animations", json={"name": "默认用户动图"}).json() + with_frame = client.post( + f"/api/animations/{created['id']}/frames", + headers={"If-Match": f'"{created["revision"]}"'}, + json={"scene": scene((9, 8, 7)), "duration_ms": 200}, + ).json() + assert client.put( + "/api/display/default-content", + json={"type": "animation", "id": created["id"]}, + ).status_code == 200 + deleted = client.delete( + f"/api/animations/{created['id']}", + headers={"If-Match": f'"{with_frame["revision"]}"'}, + ) + assert deleted.status_code == 200 + assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID + + second = client.post("/api/animations", json={"name": "删空默认动图"}).json() + second = client.post( + f"/api/animations/{second['id']}/frames", + headers={"If-Match": f'"{second["revision"]}"'}, + json={"scene": scene((4, 5, 6)), "duration_ms": 300}, + ).json() + assert client.put( + "/api/display/default-content", + json={"type": "animation", "id": second["id"]}, + ).status_code == 200 + only_frame = second["frames"][0] + emptied = client.delete( + f"/api/animations/{second['id']}/frames/{only_frame['id']}", + headers={"If-Match": f'"{second["revision"]}"'}, + ) + assert emptied.status_code == 200 + assert emptied.json()["frame_count"] == 0 + assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID + + +def test_animation_crud_reorder_duration_thumbnail_and_persistence(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + created = client.post("/api/animations", json={"name": "眨眼"}).json() + assert created["frame_count"] == 0 + + first = client.post( + f"/api/animations/{created['id']}/frames", + headers={"If-Match": f'"{created["revision"]}"'}, + json={"scene": scene((255, 0, 0)), "duration_ms": 1000, "name": "睁眼"}, + ).json() + second = client.post( + f"/api/animations/{created['id']}/frames", + headers={"If-Match": f'"{first["revision"]}"'}, + json={"scene": scene((0, 255, 0)), "duration_ms": 200}, + ).json() + assert [item["position"] for item in second["frames"]] == [1, 2] + thumb = client.get(second["frames"][0]["thumbnail_url"]) + assert thumb.status_code == 200 + assert Image.open(BytesIO(thumb.content)).getpixel((0, 0)) == (255, 0, 0) + thumbnail_path = second["frames"][0]["thumbnail_url"].split("?", 1)[0] + assert client.get(f"{thumbnail_path}?v=0000000000000000").status_code == 404 + + reordered = client.put( + f"/api/animations/{created['id']}/frame-order", + headers={"If-Match": f'"{second["revision"]}"'}, + json={"frame_ids": [second["frames"][1]["id"], second["frames"][0]["id"]]}, + ).json() + assert [item["id"] for item in reordered["frames"]] == [second["frames"][1]["id"], second["frames"][0]["id"]] + durations = client.put( + f"/api/animations/{created['id']}/frame-durations", + headers={"If-Match": f'"{reordered["revision"]}"'}, + json={"duration_ms": 500}, + ).json() + assert [item["duration_ms"] for item in durations["frames"]] == [500, 500] + renamed = client.patch( + f"/api/animations/{created['id']}", + headers={"If-Match": f'"{durations["revision"]}"'}, + json={"name": "眨眼新版"}, + ).json() + assert renamed["name"] == "眨眼新版" + assert [item["name"] for item in renamed["frames"]] == [None, "睁眼"] + assert client.put( + f"/api/animations/{created['id']}/frame-order", + headers={"If-Match": f'"{renamed["revision"]}"'}, + json={"frame_ids": [renamed["frames"][0]["id"]]}, + ).status_code == 422 + + restarted = create_app(project_root=tmp_path, driver_kind="mock") + persisted = TestClient(restarted).get(f"/api/animations/{created['id']}").json() + assert persisted["frame_count"] == 2 + document = json.loads((tmp_path / "data" / "animations" / created["id"] / "animation.json").read_text(encoding="utf-8")) + assert document["schema_version"] == 2 + assert all("scene" not in frame and "scene_file" in frame for frame in document["frames"]) + assert len(list((tmp_path / "data" / "animations" / created["id"] / "scenes").glob("*.json"))) == 2 + + +def test_animation_copy_play_snapshot_status_and_replace(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + template = client.post("/api/templates", json={"name": "红色", "scene": scene((255, 0, 0))}).json() + animation = client.post("/api/animations", json={"name": "循环"}).json() + copied = client.post("/api/library/copy", json={ + "source_type": "template", "source_id": template["id"], "source_revision": template["revision"], + "destination_type": "animation", "destination_animation_id": animation["id"], "destination_revision": animation["revision"], + }) + assert copied.status_code == 201 + detail = copied.json()["item"] + assert detail["frames"][0]["duration_ms"] == 500 + play = client.post(f"/api/animations/{animation['id']}/play", headers={"If-Match": f'"{detail["revision"]}"'}, json={}) + assert play.status_code == 200 + state = client.get("/api/status").json()["state"] + assert state["animation_active"] is True + assert state["animation_id"] == animation["id"] + assert state["mode"] == "animation" + assert client.delete(f"/api/animations/{animation['id']}", headers={"If-Match": f'"{detail["revision"]}"'}).status_code == 409 + assert client.post("/api/display/fill", json={"color": "#0000FF"}).status_code == 200 + assert client.get("/api/status").json()["state"]["animation_active"] is False + assert client.delete(f"/api/animations/{animation['id']}", headers={"If-Match": f'"{detail["revision"]}"'}).status_code == 200 + + +def test_demo_animation_is_second_read_only_and_deep_copies_to_normal_animation(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + + assert client.get("/api/animations").json()["animations"] == [] + listing = client.get("/api/animations?include_demo=true").json() + demo = listing["animations"][0] + assert demo["id"] == DEMO_ANIMATION_ID + assert demo["name"] == "演示动图" + assert demo["demo_order"] == 2 + assert demo["read_only"] is True + assert demo["frame_count"] == 2 + assert demo["total_duration_ms"] == 1500 + assert listing["storage"]["animations_bytes"] == 0 + + detail = client.get(f"/api/animations/{DEMO_ANIMATION_ID}").json() + assert [frame["duration_ms"] for frame in detail["frames"]] == [1300, 200] + frames = [client.get(f"/api/animations/{DEMO_ANIMATION_ID}/frames/{frame['id']}").json() for frame in detail["frames"]] + assert base64.b64decode(frames[0]["scene"]["pixelRgb"]) == render_startup_smile().tobytes() + assert base64.b64decode(frames[1]["scene"]["pixelRgb"]) == render_startup_smile(eyes_closed=True).tobytes() + + rename = client.patch( + f"/api/animations/{DEMO_ANIMATION_ID}", + headers={"If-Match": f'"{demo["revision"]}"'}, + json={"name": "不能改"}, + ) + assert rename.status_code == 409 + assert rename.json()["detail"] == DEMO_RESTRICTED_MESSAGE + frame_update = client.patch( + f"/api/animations/{DEMO_ANIMATION_ID}/frames/{detail['frames'][0]['id']}", + headers={"If-Match": f'"{demo["revision"]}"'}, + json={"duration_ms": 800}, + ) + assert frame_update.status_code == 409 + removed = client.delete( + f"/api/animations/{DEMO_ANIMATION_ID}", + headers={"If-Match": f'"{demo["revision"]}"'}, + ) + assert removed.status_code == 409 + + copied = client.post(f"/api/animations/{DEMO_ANIMATION_ID}/copy", json={}) + assert copied.status_code == 201 + copy_item = copied.json() + assert copy_item["id"] != DEMO_ANIMATION_ID + assert copy_item["name"] == "演示动图 - 副本" + assert [frame["duration_ms"] for frame in copy_item["frames"]] == [1300, 200] + assert all(frame["id"] not in {item["id"] for item in detail["frames"]} for frame in copy_item["frames"]) + renamed = client.patch( + f"/api/animations/{copy_item['id']}", + headers={"If-Match": f'"{copy_item["revision"]}"'}, + json={"name": "我的笑脸动图"}, + ) + assert renamed.status_code == 200 + assert len(list((tmp_path / "data" / "animations").glob("*/animation.json"))) == 1 + + +def test_animation_play_rejects_empty_and_corrupt_record_is_preserved(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + empty = client.post("/api/animations", json={"name": "空"}).json() + assert client.post(f"/api/animations/{empty['id']}/play", headers={"If-Match": f'"{empty["revision"]}"'}, json={}).status_code == 409 + path = tmp_path / "data" / "animations" / empty["id"] / "animation.json" + original = b'{"schema_version":99}\n' + path.write_bytes(original) + with pytest.raises(TemplateValidationError): + AnimationStore(tmp_path / "data") + assert path.read_bytes() == original + + +def test_display_animation_uses_synchronized_frames_and_does_not_tick_revision(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + service = app.state.display_service + calls = [] + original = service.driver.set_image + + def recording(image, *, synchronized=False): + calls.append(synchronized) + return original(image, synchronized=synchronized) + + service.driver.set_image = recording + before = service.get_status()["state"]["revision"] + service.start_animation("animation-a", "revision-a", [ + {"id": "a", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": 50}, + {"id": "b", "image": Image.new("RGB", (64, 64), "green"), "duration_ms": 50}, + ]) + try: + time.sleep(0.13) + status = service.get_status()["state"] + assert status["revision"] == before + 1 + assert status["animation_active"] is True + assert calls and all(calls) + service.set_brightness(23) + assert service.get_status()["state"]["animation_active"] is True + finally: + service.close() + + +def test_display_animation_accepts_seven_days_and_close_interrupts_wait(tmp_path): + service = create_app(project_root=tmp_path, driver_kind="mock").state.display_service + service.start_animation("animation-long", "revision-long", [ + {"id": "long", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": MAX_DURATION_MS}, + ]) + started = time.monotonic() + service.close() + assert time.monotonic() - started < 1 + + +def test_animation_duration_limits_and_selected_batch_update(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + animation = client.post("/api/animations", json={"name": "长时长"}).json() + first = client.post( + f"/api/animations/{animation['id']}/frames", + headers={"If-Match": f'"{animation["revision"]}"'}, + json={"scene": scene((1, 2, 3)), "duration_ms": 50, "name": "一"}, + ).json() + second = client.post( + f"/api/animations/{animation['id']}/frames", + headers={"If-Match": f'"{first["revision"]}"'}, + json={"scene": scene((4, 5, 6)), "duration_ms": MAX_DURATION_MS, "name": "二"}, + ).json() + third = client.post( + f"/api/animations/{animation['id']}/frames", + headers={"If-Match": f'"{second["revision"]}"'}, + json={"scene": scene((7, 8, 9)), "duration_ms": 500, "name": "三"}, + ).json() + assert client.post( + f"/api/animations/{animation['id']}/frames", + headers={"If-Match": f'"{third["revision"]}"'}, + json={"scene": scene((0, 0, 0)), "duration_ms": MAX_DURATION_MS + 1}, + ).status_code == 422 + + selected = client.put( + f"/api/animations/{animation['id']}/frame-durations", + headers={"If-Match": f'"{third["revision"]}"'}, + json={"duration_ms": 1234, "frame_ids": [third["frames"][0]["id"], third["frames"][2]["id"]]}, + ) + assert selected.status_code == 200 + selected_detail = selected.json() + assert [frame["duration_ms"] for frame in selected_detail["frames"]] == [1234, MAX_DURATION_MS, 1234] + assert client.put( + f"/api/animations/{animation['id']}/frame-durations", + headers={"If-Match": f'"{selected_detail["revision"]}"'}, + json={"duration_ms": 700, "frame_ids": []}, + ).status_code == 422 + assert client.put( + f"/api/animations/{animation['id']}/frame-durations", + headers={"If-Match": f'"{selected_detail["revision"]}"'}, + json={"duration_ms": 700, "frame_ids": [selected_detail["frames"][0]["id"]] * 2}, + ).status_code == 422 + + all_updated = client.put( + f"/api/animations/{animation['id']}/frame-durations", + headers={"If-Match": f'"{selected_detail["revision"]}"'}, + json={"duration_ms": 900}, + ).json() + assert [frame["duration_ms"] for frame in all_updated["frames"]] == [900, 900, 900] + + +def test_animation_batch_copy_positions_and_batch_delete(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + source = client.post("/api/animations", json={"name": "来源"}).json() + for index, (color, duration) in enumerate([((255, 0, 0), 100), ((0, 255, 0), 200), ((0, 0, 255), 300)], 1): + source = client.post( + f"/api/animations/{source['id']}/frames", + headers={"If-Match": f'"{source["revision"]}"'}, + json={"scene": scene(color), "duration_ms": duration, "name": f"来源{index}"}, + ).json() + destination = client.post("/api/animations", json={"name": "目标"}).json() + for index, color in enumerate([((10, 10, 10)), ((20, 20, 20))], 1): + destination = client.post( + f"/api/animations/{destination['id']}/frames", + headers={"If-Match": f'"{destination["revision"]}"'}, + json={"scene": scene(color), "duration_ms": 500, "name": f"目标{index}"}, + ).json() + + copied_response = client.post( + f"/api/animations/{source['id']}/frames/copy", + headers={"If-Match": f'"{source["revision"]}"'}, + json={ + "frame_ids": [source["frames"][2]["id"], source["frames"][0]["id"]], + "destination_animation_id": destination["id"], + "destination_revision": destination["revision"], + "insert_after_frame_id": destination["frames"][0]["id"], + }, + ) + assert copied_response.status_code == 201 + copied = copied_response.json() + detail = copied["animation"] + assert [frame["name"] for frame in detail["frames"]] == ["目标1", "来源1", "来源3", "目标2"] + assert [frame["duration_ms"] for frame in detail["frames"]] == [500, 100, 300, 500] + assert copied["copied_frame_ids"] == [detail["frames"][1]["id"], detail["frames"][2]["id"]] + assert not set(copied["copied_frame_ids"]) & {frame["id"] for frame in source["frames"]} + assert all(client.get(frame["thumbnail_url"]).status_code == 200 for frame in detail["frames"][1:3]) + + same_copy = client.post( + f"/api/animations/{source['id']}/frames/copy", + headers={"If-Match": f'"{source["revision"]}"'}, + json={ + "frame_ids": [source["frames"][1]["id"]], + "destination_animation_id": source["id"], + "destination_revision": source["revision"], + "insert_after_frame_id": None, + }, + ) + assert same_copy.status_code == 201 + same_detail = same_copy.json()["animation"] + assert [frame["name"] for frame in same_detail["frames"]][:2] == ["来源2", "来源1"] + + duplicate = client.post( + f"/api/animations/{source['id']}/frames/copy", + headers={"If-Match": f'"{same_detail["revision"]}"'}, + json={ + "frame_ids": [same_detail["frames"][0]["id"]] * 2, + "destination_animation_id": destination["id"], + "destination_revision": detail["revision"], + "insert_after_frame_id": None, + }, + ) + assert duplicate.status_code == 422 + assert client.post( + f"/api/animations/{source['id']}/frames/copy", + headers={"If-Match": f'"{same_detail["revision"]}"'}, + json={ + "frame_ids": [same_detail["frames"][0]["id"]], + "destination_animation_id": DEMO_ANIMATION_ID, + "destination_revision": "demo", + "insert_after_frame_id": None, + }, + ).status_code == 409 + + partial_delete = client.post( + f"/api/animations/{destination['id']}/frames/batch-delete", + headers={"If-Match": f'"{detail["revision"]}"'}, + json={"frame_ids": copied["copied_frame_ids"]}, + ) + assert partial_delete.status_code == 200 + remaining = partial_delete.json() + assert [frame["name"] for frame in remaining["frames"]] == ["目标1", "目标2"] + delete_all = client.post( + f"/api/animations/{destination['id']}/frames/batch-delete", + headers={"If-Match": f'"{remaining["revision"]}"'}, + json={"frame_ids": [frame["id"] for frame in remaining["frames"]]}, + ) + assert delete_all.status_code == 200 + assert delete_all.json()["frames"] == [] + + +def test_animation_copy_failure_cleans_new_thumbnails(tmp_path, monkeypatch): + store = AnimationStore(tmp_path / "data") + source = store.create("来源") + source = store.add_frame(source["id"], scene((1, 2, 3)), source["revision"], name="一", duration_ms=100) + destination = store.create("目标") + thumbnails = store._thumb_dir(destination["id"]) + before = list(thumbnails.glob("*.png")) if thumbnails.exists() else [] + + def fail_write(_record): + raise TemplateValidationError("forced write failure") + + monkeypatch.setattr(store, "_write", fail_write) + with pytest.raises(TemplateValidationError, match="forced write failure"): + store.copy_frames( + source["id"], + [source["frames"][0]["id"]], + source["revision"], + destination["id"], + destination["revision"], + None, + ) + after = list(thumbnails.glob("*.png")) if thumbnails.exists() else [] + assert after == before + + +def test_media_batch_animation_is_hidden_until_atomic_directory_commit(tmp_path, monkeypatch): + store = AnimationStore(tmp_path / "data") + original = store._write_at + + def fail_metadata(record, directory_id): + if directory_id.startswith(".staging-"): + raise OSError("simulated interruption") + return original(record, directory_id) + + monkeypatch.setattr(store, "_write_at", fail_metadata) + with pytest.raises(OSError, match="simulated interruption"): + store.create_from_frames("批量导入", [{ + "scene": scene((1, 2, 3)), "duration_ms": 100, "name": None, + }]) + + assert store.list()["animations"] == [] + assert not list(store.root.glob(".staging-*")) diff --git a/核桃派软件源代码/tests/test_api.py b/核桃派软件源代码/tests/test_api.py new file mode 100644 index 0000000..76e625d --- /dev/null +++ b/核桃派软件源代码/tests/test_api.py @@ -0,0 +1,1306 @@ +import base64 +from io import BytesIO +from pathlib import Path + +from fastapi.testclient import TestClient +from PIL import Image + +from app.config.store import ConfigStore +from app.demo_library import DEMO_ANIMATION_ID, DEMO_ANIMATION_NAME +from app.main import create_app, static_content_version +from app.network.manager import MockNetworkManager +from app.power.voltage import VoltageMonitor + + +def solid_scene(color=(12, 34, 56)): + return { + "version": 1, + "width": 64, + "height": 64, + "pixelRgb": base64.b64encode(bytes(color) * (64 * 64)).decode("ascii"), + "elements": [], + } + + +def test_status_config_and_display_api(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + + status = client.get("/api/status") + assert status.status_code == 200 + assert status.json()["screen"]["driver"] == "mock" + assert status.json()["screen"]["driver_options"]["rows"] == 64 + assert status.json()["screen"]["driver_options"]["scan_rows"] == 32 + assert status.json()["screen"]["driver_options"]["row_address_bits"] == 5 + assert status.json()["screen"]["driver_options"]["pwm_bits"] == 7 + assert status.json()["screen"]["hardware_mapping"] == "walnutpi-pi-bank-pwm-oe-v2" + assert status.json()["screen"]["driver_options"]["limit_refresh_rate_hz"] == 100 + assert status.json()["screen"]["driver_status"]["actual_refresh_rate_hz"] is None + assert status.json()["screen"]["driver_status"]["panel_scan_rate_hz"] is None + assert status.json()["screen"]["driver_status"]["completed_scans"] is None + assert status.json()["screen"]["driver_status"]["scans_per_frame"] is None + assert status.json()["power"]["screen_input_voltage"]["status"] == "starting" + assert status.json()["state"]["animation_playback"] == { + "active": False, + "session_id": None, + "animation_id": None, + "animation_revision": None, + "frame_id": None, + "frame_index": None, + "frame_count": None, + "position_ms": None, + "total_duration_ms": None, + "paused": None, + "speed": None, + "supported_speeds": [0.5, 1.0, 1.5, 2.0], + } + assert status.json()["power"]["low_voltage_protection"] == { + "enabled": False, + "mode": "disabled", + "brightness_limit_percent": None, + "reading_stale": False, + "revision": 0, + "sampling_interval_seconds": 5.0, + "enforcement_error_code": None, + } + assert status.json()["resources"] == { + "status": "starting", + "sampled_at": None, + "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": None, + } + assert status.json()["ui"]["preview_refresh_interval_ms"] == 1000 + assert status.json()["system"]["performance_mode"] == { + "requested": False, + "available": False, + "effective": False, + "current_governors": {}, + "restore_governors": {}, + "last_error": None, + } + assert status.json()["state"]["display_test"] == { + "active": False, + "color": None, + "brightness": None, + } + + config = client.put( + "/api/config", + json={ + "orientation": 180, + "brightness": 30, + "preview_refresh_interval_ms": 99, + "matrix_refresh_rate_limit_hz": 80, + "custom_test_color": "#a1b2c3", + "workspace_order": ["templates", "device", "settings", "canvas", "text", "animations"], + "animation_preview_max_concurrent": 50, + }, + ) + assert config.status_code == 200 + assert config.json()["orientation"] == 180 + assert config.json()["preview_refresh_interval_ms"] == 99 + assert config.json()["matrix_refresh_rate_limit_hz"] == 80 + assert config.json()["custom_test_color"] == "#A1B2C3" + assert config.json()["workspace_order"] == [ + "templates", "device", "settings", "canvas", "text", "animations", + ] + assert client.get("/api/config").json()["preview_refresh_interval_ms"] == 99 + assert client.get("/api/config").json()["low_voltage_protection_enabled"] is False + assert client.get("/api/status").json()["ui"]["preview_refresh_interval_ms"] == 99 + assert client.get("/api/status").json()["screen"]["driver_options"][ + "limit_refresh_rate_hz" + ] == 80 + assert "schema_version" not in config.json() + assert config.json()["performance_mode_enabled"] is False + assert config.json()["animation_preview_max_concurrent"] == 50 + + homepage = client.get("/") + assert 'id="topbar-cpu"' in homepage.text + assert 'id="topbar-memory"' in homepage.text + assert 'id="topbar-low-voltage-warning"' in homepage.text + assert 'id="low-voltage-protection-enabled"' in homepage.text + assert 'id="brightness-protection-status"' in homepage.text + assert 'id="resource-details-dialog"' in homepage.text + assert 'id="matrix-refresh-rate-grid"' in homepage.text + assert homepage.text.count("data-matrix-refresh-rate=") == 7 + assert 'aria-controls="resource-details-dialog"' in homepage.text + assert "资源占用详情" in homepage.text + assert ( + homepage.text.index('id="topbar-cpu"') + < homepage.text.index('id="topbar-memory"') + < homepage.text.index('id="topbar-low-voltage-warning"') + < homepage.text.index('id="topbar-voltage"') + ) + + for value in (0, 60001): + assert client.put( + "/api/config", + json={"preview_refresh_interval_ms": value}, + ).status_code == 400 + for value in (True, 1.5, "100"): + assert client.put( + "/api/config", + json={"preview_refresh_interval_ms": value}, + ).status_code == 422 + + for value in (1, 2, 50): + response = client.put( + "/api/config", + json={"animation_preview_max_concurrent": value}, + ) + assert response.status_code == 200 + assert response.json()["animation_preview_max_concurrent"] == value + for value in (0, 51): + assert client.put( + "/api/config", + json={"animation_preview_max_concurrent": value}, + ).status_code == 400 + for value in (None, True, 2.0, "2", [], {}): + assert client.put( + "/api/config", + json={"animation_preview_max_concurrent": value}, + ).status_code == 422 + + for value in (None, 0, 1, "true", [], {}): + assert client.put( + "/api/config", + json={"low_voltage_protection_enabled": value}, + ).status_code == 422 + assert client.get("/api/config").json()["low_voltage_protection_enabled"] is False + + for value in (15, 20, 30, 45, 60, 80, 100): + response = client.put( + "/api/config", + json={"matrix_refresh_rate_limit_hz": value}, + ) + assert response.status_code == 200 + assert response.json()["matrix_refresh_rate_limit_hz"] == value + assert client.get("/api/status").json()["screen"]["driver_options"][ + "limit_refresh_rate_hz" + ] == value + for value in (0, 14, 16, 101): + assert client.put( + "/api/config", + json={"matrix_refresh_rate_limit_hz": value}, + ).status_code == 400 + for value in (True, 60.0, "60", [], {}): + assert client.put( + "/api/config", + json={"matrix_refresh_rate_limit_hz": value}, + ).status_code == 422 + + for value in (None, True, 123, "red", "#12345"): + response = client.put("/api/config", json={"custom_test_color": value}) + assert response.status_code in {400, 422} + + saved_workspace_order = client.get("/api/config").json()["workspace_order"] + for value in (None, [], ["device", "device"], ["bad id"], [1, "device"], "device"): + response = client.put("/api/config", json={"workspace_order": value}) + assert response.status_code in {400, 422} + assert client.get("/api/config").json()["workspace_order"] == saved_workspace_order + + legacy_config = client.put("/api/config", json={"default_font": "default", "default_text_size": 14}) + assert legacy_config.status_code == 200 + assert legacy_config.json()["default_text_size"] == 14 + + fill = client.post("/api/display/fill", json={"color": "#00FF00"}) + assert fill.status_code == 200 + assert fill.json() == {"ok": True, "mode": "fill"} + + text = client.post("/api/display/text", json={"text": "OK", "size": 12, "x": 4, "y": 24}) + assert text.status_code == 200 + assert text.json()["mode"] == "text" + + diagnostic = client.post("/api/display/diagnostic", json={"mode": "corners_lines"}) + assert diagnostic.status_code == 200 + assert diagnostic.json() == {"ok": True, "mode": "diagnostic:corners_lines"} + + +def test_display_test_api_is_temporary_persistent_across_reads_and_restorable(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + assert client.put("/api/config", json={"brightness": 31}).status_code == 200 + assert client.post("/api/display/fill", json={"color": "#010203"}).status_code == 200 + + started = client.post("/api/display/test/fill", json={"color": "#FF0000"}) + assert started.status_code == 200 + assert started.json()["display_test"] == { + "active": True, + "color": "#FF0000", + "brightness": 50, + } + assert started.json()["effective_brightness"] == 50 + assert client.get("/api/config").json()["brightness"] == 31 + assert client.get("/api/status").json()["state"]["display_test"]["active"] is True + + changed = client.put("/api/display/test/brightness", json={"brightness": 72}) + assert changed.status_code == 200 + assert changed.json()["display_test"]["brightness"] == 72 + switched = client.post("/api/display/test/fill", json={"color": "#0000FF"}) + assert switched.json()["display_test"]["brightness"] == 72 + + exited = client.delete("/api/display/test") + assert exited.status_code == 200 + assert exited.json()["display_test"]["active"] is False + assert client.delete("/api/display/test").status_code == 200 + final = client.get("/api/status").json()["state"] + assert final["mode"] == "fill" + assert final["brightness"] == 31 + assert final["effective_brightness"] == 31 + restored = Image.open(BytesIO(client.get("/api/display/current-frame").content)) + assert restored.getpixel((0, 0)) == (1, 2, 3) + + assert client.put("/api/display/test/brightness", json={"brightness": 50}).status_code == 409 + assert client.put("/api/display/test/brightness", json={"brightness": True}).status_code == 422 + assert client.post("/api/display/test/fill", json={"color": "bad"}).status_code == 400 + + +def test_device_storage_api_and_successful_persistent_write_invalidate_cache(tmp_path): + class FakeStorageMonitor: + def __init__(self): + self.invalidations = 0 + + def get_status(self): + return { + "sampled_at": "2026-07-29T00:00:00Z", + "device_total_bytes": 1000, + "device_free_bytes": 700, + "software_bytes": 200, + "templates_bytes": 50, + } + + def invalidate(self): + self.invalidations += 1 + + fake = FakeStorageMonitor() + app = create_app( + project_root=tmp_path, + driver_kind="mock", + storage_monitor_factory=lambda _program, _data: fake, + ) + client = TestClient(app) + + response = client.get("/api/device-storage") + assert response.status_code == 200 + assert response.json()["software_bytes"] == 200 + assert response.headers["cache-control"] == "no-store" + assert fake.invalidations == 0 + + assert client.put("/api/config", json={"custom_test_color": "#112233"}).status_code == 200 + assert fake.invalidations == 1 + assert client.post("/api/display/test/fill", json={"color": "#112233"}).status_code == 200 + assert client.put("/api/display/test/brightness", json={"brightness": 45}).status_code == 200 + assert fake.invalidations == 1 + + +def test_current_display_frame_api_is_logical_and_side_effect_free(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + frame = bytearray([0, 0, 0] * (64 * 64)) + frame[(7 * 64 + 2) * 3:(7 * 64 + 2) * 3 + 3] = bytes((12, 34, 56)) + app.state.display_service.show_rgb_bytes(bytes(frame), mode="composition") + app.state.display_service.set_orientation(270) + before = client.get("/api/status").json()["state"] + + response = client.get("/api/display/current-frame") + image = Image.open(BytesIO(response.content)) + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["cache-control"] == "no-store" + assert image.mode == "RGB" + assert image.size == (64, 64) + assert image.getpixel((2, 7)) == (12, 34, 56) + assert client.get("/api/status").json()["state"] == before + + +def test_animation_playback_api_controls_frame_and_rejects_invalid_or_stale_sessions(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(app) as client: + animation = client.post("/api/animations", json={"name": "播放控制"}).json() + first = client.post( + f"/api/animations/{animation['id']}/frames", + headers={"If-Match": f'"{animation["revision"]}"'}, + json={"scene": solid_scene((255, 0, 0)), "duration_ms": 200}, + ).json() + second = client.post( + f"/api/animations/{animation['id']}/frames", + headers={"If-Match": f'"{first["revision"]}"'}, + json={"scene": solid_scene((0, 255, 0)), "duration_ms": 300}, + ).json() + played = client.post( + f"/api/animations/{animation['id']}/play", + headers={"If-Match": f'"{second["revision"]}"'}, + ) + assert played.status_code == 200 + playback = client.get("/api/status").json()["state"]["animation_playback"] + session_id = playback["session_id"] + assert playback["frame_count"] == 2 + assert playback["total_duration_ms"] == 500 + + controlled = client.patch( + "/api/display/animation-playback", + json={ + "session_id": session_id, + "position_ms": 250, + "paused": True, + "speed": 1.5, + }, + ) + assert controlled.status_code == 200 + assert controlled.json()["state_revision"] >= 1 + result = controlled.json()["animation_playback"] + assert result["position_ms"] == 250 + assert result["frame_index"] == 2 + assert result["paused"] is True + assert result["speed"] == 1.5 + frame = Image.open(BytesIO(client.get("/api/display/current-frame").content)) + assert frame.getpixel((0, 0)) == (0, 255, 0) + + invalid_payloads = [ + {"session_id": session_id}, + {"session_id": session_id, "position_ms": 500}, + {"session_id": session_id, "position_ms": True}, + {"session_id": session_id, "paused": 1}, + {"session_id": session_id, "speed": 1.25}, + {"session_id": session_id, "speed": "2"}, + {"session_id": session_id, "paused": None}, + {"session_id": session_id, "paused": True, "unknown": 1}, + ] + for payload in invalid_payloads: + assert client.patch("/api/display/animation-playback", json=payload).status_code == 422 + + replayed = client.post( + f"/api/animations/{animation['id']}/play", + headers={"If-Match": f'"{second["revision"]}"'}, + ) + assert replayed.status_code == 200 + reset = client.get("/api/status").json()["state"]["animation_playback"] + assert reset["session_id"] != session_id + assert reset["position_ms"] < 100 + assert reset["paused"] is False + assert reset["speed"] == 1.0 + stale = client.patch( + "/api/display/animation-playback", + json={"session_id": session_id, "paused": True}, + ) + assert stale.status_code == 409 + + +def test_create_app_explicit_project_root_ignores_storage_environment( + tmp_path, + monkeypatch, +): + environment_data = tmp_path / "environment-data" + environment_runtime = tmp_path / "environment-runtime" + monkeypatch.setenv("MATRIX_DATA_DIR", str(environment_data)) + monkeypatch.setenv("STATE_DIRECTORY", str(tmp_path / "state-data")) + monkeypatch.setenv("MATRIX_RUNTIME_DIR", str(environment_runtime)) + monkeypatch.setenv("RUNTIME_DIRECTORY", str(tmp_path / "systemd-runtime")) + explicit = tmp_path / "explicit" + + app = create_app(project_root=explicit, driver_kind="mock") + + assert app.state.config_store.data_dir == explicit / "data" + assert app.state.config_store.runtime_dir == explicit / "data" / "runtime" + assert not environment_data.exists() + assert not environment_runtime.exists() + + +def test_consecutive_brightness_updates_keep_config_status_and_driver_in_sync(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + + for value in (20, 55, 37): + response = client.put("/api/config", json={"brightness": value}) + assert response.status_code == 200 + assert response.json()["brightness"] == value + + status = client.get("/api/status") + assert status.status_code == 200 + assert status.json()["state"]["brightness"] == 37 + assert status.json()["screen"]["driver_options"]["brightness"] == 37 + assert app.state.config_store.config["brightness"] == 37 + + +def test_voltage_status_calibration_and_reset_do_not_change_display_state(tmp_path): + class Device: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return None + + def configure(self): + return None + + def read_fresh(self): + return 13115, 0x0C + + app = create_app( + project_root=tmp_path, + driver_kind="mock", + voltage_monitor_factory=lambda store: VoltageMonitor(store, device_factory=Device), + ) + client = TestClient(app) + app.state.voltage_monitor.sample_now() + state_before = client.get("/api/status").json()["state"] + + invalid = client.post( + "/api/power/voltage/calibration/preview", + json={"reference_volts": 4.0}, + ) + assert invalid.status_code == 400 + + preview = client.post( + "/api/power/voltage/calibration/preview", + json={"reference_volts": 5.0}, + ) + assert preview.status_code == 200 + proposal = preview.json() + assert proposal["uncalibrated_volts"] > 4.9 + assert client.get("/api/config").json()["voltage_calibrated_at"] is None + + confirmed = client.post( + "/api/power/voltage/calibration/confirm", + json={"proposal_id": proposal["proposal_id"]}, + ) + assert confirmed.status_code == 200 + assert confirmed.json()["screen_input_voltage"]["calibrated"] is True + assert confirmed.json()["config"]["voltage_calibrated_at"].endswith("Z") + assert client.post( + "/api/power/voltage/calibration/confirm", + json={"proposal_id": proposal["proposal_id"]}, + ).status_code == 409 + + reset = client.post("/api/power/voltage/calibration/reset", json={}) + assert reset.status_code == 200 + assert reset.json()["config"]["voltage_calibration_factor"] == 1.0 + assert reset.json()["config"]["voltage_calibrated_at"] is None + assert client.get("/api/status").json()["state"] == state_before + + +def test_low_voltage_protection_api_overlays_without_changing_user_state(tmp_path): + class MutableDevice: + raw = 11777 # Approximately 4.49V with the Unit ADC divider. + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return None + + def configure(self): + return None + + def read_fresh(self): + return self.raw, 0x0C + + device = MutableDevice() + app = create_app( + project_root=tmp_path, + driver_kind="mock", + voltage_monitor_factory=lambda store: VoltageMonitor( + store, + device_factory=lambda: device, + ), + ) + client = TestClient(app) + + enabled = client.put( + "/api/config", + json={"low_voltage_protection_enabled": True}, + ) + assert enabled.status_code == 200 + assert enabled.json()["low_voltage_protection_enabled"] is True + + critical = client.get("/api/status").json() + assert critical["power"]["low_voltage_protection"]["mode"] == "critical" + assert critical["power"]["low_voltage_protection"]["brightness_limit_percent"] == 35 + assert critical["power"]["low_voltage_protection"]["sampling_interval_seconds"] == 0.5 + assert critical["state"]["mode"] == "low_voltage_indicator" + assert critical["state"]["brightness"] == 40 + assert critical["state"]["effective_brightness"] == 35 + assert critical["state"]["revision"] == 0 + + indicator = Image.open(BytesIO(client.get("/api/display/current-frame").content)) + assert {color for _count, color in indicator.getcolors(maxcolors=4096)} == { + (0, 0, 0), + (255, 0, 0), + } + + assert client.post("/api/display/fill", json={"color": "#00FF00"}).status_code == 200 + assert client.put( + "/api/config", + json={"orientation": 90, "brightness": 77}, + ).status_code == 200 + while_critical = client.get("/api/status").json() + assert while_critical["state"]["mode"] == "low_voltage_indicator" + assert while_critical["state"]["brightness"] == 77 + assert while_critical["state"]["effective_brightness"] == 35 + assert app.state.config_store.config["last_mode"] == "fill" + user_revision = while_critical["state"]["revision"] + protection_revision = while_critical["power"]["low_voltage_protection"]["revision"] + + device.raw = 12853 # Approximately 4.90V. + app.state.voltage_monitor.sample_now() + first_recovery = client.get("/api/status").json() + assert first_recovery["power"]["low_voltage_protection"]["mode"] == "critical" + app.state.voltage_monitor.sample_now() + + recovered = client.get("/api/status").json() + assert recovered["power"]["low_voltage_protection"]["mode"] == "inactive" + assert recovered["power"]["low_voltage_protection"]["revision"] > protection_revision + assert recovered["state"]["revision"] == user_revision + assert recovered["state"]["mode"] == "fill" + assert recovered["state"]["brightness"] == 77 + assert recovered["state"]["effective_brightness"] == 77 + restored = Image.open(BytesIO(client.get("/api/display/current-frame").content)) + assert {color for _count, color in restored.getcolors(maxcolors=4096)} == { + (0, 255, 0), + } + + +def test_startup_sample_applies_critical_overlay_before_smile(tmp_path): + class CriticalDevice: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return None + + def configure(self): + return None + + def read_fresh(self): + return 11777, 0x0C + + ConfigStore(project_root=tmp_path).update({ + "low_voltage_protection_enabled": True, + "last_mode": "fill", + }) + app = create_app( + project_root=tmp_path, + driver_kind="mock", + startup_boot_id="low-voltage-startup", + voltage_monitor_factory=lambda store: VoltageMonitor( + store, + device_factory=CriticalDevice, + ), + ) + + with TestClient(app) as client: + status = client.get("/api/status").json() + assert status["power"]["low_voltage_protection"]["mode"] == "critical" + assert status["state"]["mode"] == "low_voltage_indicator" + assert status["state"]["effective_brightness"] == 35 + assert status["state"]["startup_indicator_active"] is False + assert status["state"]["animation_active"] is True + assert status["state"]["current_content"] == { + "category": "system", + "id": None, + "name": "低电压提示", + } + assert app.state.config_store.config["last_mode"] == "fill" + + +def test_bad_color_returns_400(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + + response = client.post("/api/display/fill", json={"color": "green"}) + + assert response.status_code == 400 + + +def test_shared_color_palette_api(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + app.state.config_store.update({"color_palette": []}) + client = TestClient(app) + + added = client.post("/api/colors/palette", json={"color": "#aabbcc"}) + assert added.status_code == 200 + assert added.json() == {"colors": ["#AABBCC"], "limit": 24} + + duplicate = client.post("/api/colors/palette", json={"color": "#AABBCC"}) + assert duplicate.status_code == 200 + assert duplicate.json()["colors"] == ["#AABBCC"] + + invalid = client.post("/api/colors/palette", json={"color": "blue"}) + assert invalid.status_code == 400 + + deleted = client.delete("/api/colors/palette/AABBCC") + assert deleted.status_code == 200 + assert deleted.json()["colors"] == [] + assert client.delete("/api/colors/palette/AABBCC").status_code == 200 + + +def test_shared_color_palette_limit(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + app.state.config_store.update({"color_palette": []}) + client = TestClient(app) + + for index in range(24): + response = client.post("/api/colors/palette", json={"color": f"#{index:06X}"}) + assert response.status_code == 200 + + overflow = client.post("/api/colors/palette", json={"color": "#FFFFFF"}) + assert overflow.status_code == 409 + + +def test_canvas_websocket(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + legacy_frame = bytes([0, 0, 0]) * (64 * 64) + composition_frame = bytes([18, 52, 86]) * (64 * 64) + + with client.websocket_connect("/ws/canvas") as websocket: + websocket.send_json({ + "type": "frame_rgb", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": base64.b64encode(legacy_frame).decode("ascii"), + }) + assert websocket.receive_json() == {"type": "ack", "mode": "canvas", "applied": True} + assert app.state.config_store.config["last_mode"] == "canvas" + + websocket.send_json({ + "type": "frame_rgb", + "source": "composition", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": base64.b64encode(composition_frame).decode("ascii"), + }) + assert websocket.receive_json() == {"type": "ack", "mode": "composition", "applied": True} + assert app.state.config_store.config["last_mode"] == "composition" + frame_path = tmp_path / "data" / "runtime" / "last_frame.png" + frame_before_error = frame_path.read_bytes() + + websocket.send_json({ + "type": "frame_rgb", + "source": "canvas", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": base64.b64encode(legacy_frame).decode("ascii"), + }) + error = websocket.receive_json() + assert error["type"] == "error" + assert "source" in error["message"] + assert app.state.config_store.config["last_mode"] == "composition" + assert frame_path.read_bytes() == frame_before_error + + websocket.send_json({"type": "frame_rgb", "width": 1, "height": 1, "encoding": "base64_rgb888", "data": ""}) + assert websocket.receive_json()["type"] == "error" + + +def test_preview_endpoints_return_png_without_display_side_effects(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + fill = client.post("/api/display/fill", json={"color": "#123456"}) + assert fill.status_code == 200 + frame_path = tmp_path / "data" / "runtime" / "last_frame.png" + frame_before = frame_path.read_bytes() + status_before = client.get("/api/status").json()["state"] + + text_preview = client.post("/api/preview/text", json={ + "text": "UP", + "size": 12, + "x": 4, + "y": 24, + "color": "#FFFFFF", + "background": "#000000", + }) + assert text_preview.status_code == 200 + assert text_preview.headers["content-type"] == "image/png" + text_image = Image.open(BytesIO(text_preview.content)) + assert text_image.size == (64, 64) + assert text_image.mode == "RGB" + + text_layer_preview = client.post("/api/preview/text-layer", json={ + "text": "TOP", + "size": 12, + "x": 4, + "y": 24, + "color": "#FF8000", + }) + assert text_layer_preview.status_code == 200 + assert text_layer_preview.headers["content-type"] == "image/png" + text_layer_image = Image.open(BytesIO(text_layer_preview.content)) + assert text_layer_image.size == (64, 64) + assert text_layer_image.mode == "RGBA" + assert text_layer_image.getpixel((63, 63)) == (0, 0, 0, 0) + assert text_layer_image.getchannel("A").getbbox() is not None + + diagnostic_preview = client.post("/api/preview/diagnostic", json={"mode": "corners_lines"}) + assert diagnostic_preview.status_code == 200 + diagnostic_image = Image.open(BytesIO(diagnostic_preview.content)) + assert diagnostic_image.size == (64, 64) + assert diagnostic_image.getpixel((0, 0)) == (255, 0, 0) + + assert client.get("/api/status").json()["state"] == status_before + assert frame_path.read_bytes() == frame_before + + +def test_multilingual_text_preview_uses_covered_fonts_and_rejects_emoji(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + samples = ("你好", "日本", "한글", "Привет", "Ελλάδα", "مرحبا", "שלום", "สวัสดี") + + for sample in samples: + response = client.post("/api/preview/text-layer", json={ + "text": sample, + "font": "default", + "size": 18, + "x": 0, + "y": 16, + "color": "#FFFFFF", + }) + assert response.status_code == 200, (sample, response.text) + image = Image.open(BytesIO(response.content)) + assert image.mode == "RGBA" + assert image.getchannel("A").getbbox() is not None + + unsupported = client.post("/api/preview/text-layer", json={ + "text": "😀", + "font": "default", + "size": 18, + "x": 0, + "y": 16, + "color": "#FFFFFF", + }) + assert unsupported.status_code == 400 + assert "U+1F600" in unsupported.json()["detail"] + + +def test_composition_can_be_reapplied_after_diagnostic_override(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + composition_frame = bytearray([0, 0, 0] * (64 * 64)) + composition_frame[0:3] = bytes((12, 34, 56)) + composition_frame[-3:] = bytes((78, 90, 123)) + encoded_frame = base64.b64encode(composition_frame).decode("ascii") + + with client.websocket_connect("/ws/canvas") as websocket: + websocket.send_json({ + "type": "frame_rgb", + "source": "composition", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": encoded_frame, + }) + assert websocket.receive_json()["mode"] == "composition" + + diagnostic = client.post("/api/display/diagnostic", json={"mode": "corners_lines"}) + assert diagnostic.status_code == 200 + assert app.state.config_store.config["last_mode"] == "diagnostic:corners_lines" + diagnostic_image = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert diagnostic_image.getpixel((0, 0)) == (255, 0, 0) + + with client.websocket_connect("/ws/canvas") as websocket: + websocket.send_json({ + "type": "frame_rgb", + "source": "composition", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": encoded_frame, + }) + assert websocket.receive_json() == { + "type": "ack", + "mode": "composition", + "applied": True, + } + + restored = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert restored.tobytes() == bytes(composition_frame) + assert app.state.config_store.config["last_mode"] == "composition" + + +def test_mobile_workspace_assets_are_served(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + + index = client.get("/") + assert index.status_code == 200 + assert index.headers["cache-control"] == "no-store" + assert f'' in index.text + assert f'/static/{app.state.app_version}/style.css' in index.text + assert f'/static/{app.state.app_version}/app.js' in index.text + assert 'id="workspace-drawer"' in index.text + assert 'id="workspace-order-toggle"' in index.text + assert "自定义项目位置" in index.text + assert 'id="workspace-settings"' in index.text + assert 'id="topbar-voltage"' in index.text + assert 'id="start-voltage-calibration"' in index.text + assert 'id="voltage-calibration-dialog"' in index.text + assert 'id="board-stage"' in index.text + assert 'id="scene-canvas"' in index.text + assert 'id="pixel-grid"' in index.text + assert 'id="pixel-edit-toggle"' in index.text + assert 'id="pixel-edit-status"' in index.text + assert 'id="text-element-list"' in index.text + assert 'id="create-text"' in index.text + assert 'id="workspace-templates"' in index.text + assert 'id="workspace-animations"' in index.text + assert 'id="animation-frames"' in index.text + assert 'id="animation-edit-notice"' in index.text + assert 'id="animation-preview-image"' in index.text + assert 'id="animation-board-preview"' in index.text + assert 'id="animation-dirty-message"' in index.text + assert 'id="library-copy-dialog"' in index.text + assert 'id="template-grid"' in index.text + assert 'id="save-template"' in index.text + assert 'id="template-dialog-note"' in index.text + assert 'id="animation-apply-all"' not in index.text + assert 'id="template-confirm-dialog"' in index.text + assert 'id="config-font"' not in index.text + assert 'id="text-font"' in index.text + assert 'role="combobox"' in index.text + assert 'id="import-font"' in index.text + assert 'accept=".ttf,.otf,.ttc,.otc"' in index.text + assert "选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件" in index.text + assert 'id="text-background"' not in index.text + assert 'type="color"' not in index.text + assert index.text.count("data-color-picker") == 4 + settings = index.text[index.text.index('id="workspace-settings"'):] + assert settings.index("

    屏幕亮度

    ") < settings.index("

    屏幕方向

    ") < settings.index("

    供电电压

    ") < settings.index("

    预览刷新间隔

    ") + assert 'id="preview-refresh-interval"' in settings + assert "刷新间隔过短,会增加核桃派负载,可能影响系统运行。" in settings + assert "角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。" in settings + assert "设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。" in settings + assert '

    性能模式

    ' in settings + assert "如果使用出现卡顿,可以打开此选项,但会降低续航。" in settings + assert settings.index('

    性能模式

    ') < settings.index("

    模板界面同时播放的动图数量

    ") + assert 'id="animation-preview-max-concurrent"' in settings + assert 'min="1"' in settings[settings.index('id="animation-preview-max-concurrent"'):] + assert 'max="50"' in settings[settings.index('id="animation-preview-max-concurrent"'):] + assert "不建议开启太多,否则会出现严重的性能问题。" in settings + assert '

    frp配置

    ' in settings + assert "此功能通常是远程排查使用,请保证你知道你在做什么。" in settings + assert settings.index('

    frp配置

    ') > settings.index('id="animation-preview-max-concurrent"') + assert "方向和亮度立即应用并保存;电压传感器按设备独立校准。" not in settings + assert "更换 ADC 模块、测量线或测量点后,请恢复标称值并重新校准。" not in settings + assert "方向和亮度会自动保存" not in settings + legacy = client.get("/static/core.js") + assert legacy.status_code == 200 + assert legacy.headers["cache-control"] == "no-store" + assert client.get("/static/scene-model.js").status_code == 200 + scene_board = client.get("/static/scene-board.js") + assert scene_board.status_code == 200 + assert 'new Set(["device", "settings", "templates"])' in scene_board.text + assert client.get("/static/template-edit-model.js").status_code == 200 + assert ( + 'renderUiCopy("copy.dynamic.board.monitor_mode", boardMode, ' + '"预览当前显示屏幕画面", {}, { defaultHidden: true })' + in scene_board.text + ) + assert 'renderUiValue(boardMode, "")' in scene_board.text + assert "当前设备逻辑画面 · 只读监视" not in scene_board.text + assert client.get("/static/color-math.js").status_code == 200 + canvas_tools = client.get("/static/canvas-tools-model.js") + assert canvas_tools.status_code == 200 + assert 'presets: Object.freeze([1, 2, 3, 4])' in canvas_tools.text + assert client.get("/static/color-picker.js").status_code == 200 + assert client.get("/static/views/templates.js").status_code == 200 + assert client.get("/static/views/animations.js").status_code == 200 + assert client.get("/static/animation-ui-model.js").status_code == 200 + assert client.get("/static/sortable-card.js").status_code == 200 + interaction_feedback = client.get("/static/interaction-feedback.js") + assert interaction_feedback.status_code == 200 + assert 'setAttribute("aria-disabled", "true")' in interaction_feedback.text + assert 'setAttribute("aria-busy", "true")' in interaction_feedback.text + device_view = client.get("/static/views/device.js") + assert device_view.status_code == 200 + assert "/api/preview/diagnostic" not in device_view.text + assert "showTestFill" not in device_view.text + assert 'limiting: "电压过低,屏幕亮度限制"' in device_view.text + assert 'critical: "电压过低,屏幕已经禁用,请充电"' in device_view.text + settings_view = client.get("/static/views/settings.js") + assert settings_view.status_code == 200 + assert "JSON.stringify({ low_voltage_protection_enabled: requested })" in settings_view.text + assert "showLowVoltageProtectionSwitch(previous)" in settings_view.text + assert "低电压亮度保护保存失败" in settings_view.text + assert "JSON.stringify({ performance_mode_enabled: requested })" in settings_view.text + assert "性能模式切换失败" in settings_view.text + assert "JSON.stringify({ animation_preview_max_concurrent: requested })" in settings_view.text + assert "同时播放数量保存失败" in settings_view.text + protection_sync = settings_view.text.index( + "lowVoltageProtectionStatus = detail.power?.low_voltage_protection || null;" + ) + pending_early_return = settings_view.text.index( + "if (orientationPending || applyingBrightness || desiredBrightness !== null) return;" + ) + assert protection_sync < pending_early_return + assert "保护开关已从其他页面同步为开启" in settings_view.text + assert "当前允许的最高亮度:${limitText}%" in settings_view.text + versioned_app = client.get(f"/static/{app.state.app_version}/app.js") + assert versioned_app.status_code == 200 + assert versioned_app.headers["cache-control"] == "public, max-age=31536000, immutable" + assert 'from "./core.js"' in versioned_app.text + assert client.get(f"/static/{app.state.app_version}/views/device.js").status_code == 200 + assert client.get(f"/static/{app.state.app_version}/canvas-tools-model.js").status_code == 200 + assert client.get(f"/static/{app.state.app_version}/interaction-feedback.js").status_code == 200 + + status = client.get("/api/status") + assert status.headers["cache-control"] == "no-store" + assert status.json()["service"]["app_version"] == app.state.app_version + assert status.json()["service"]["instance_id"] == app.state.instance_id + assert status.json()["state"]["revision"] == 0 + + +def test_app_lifecycle_starts_default_animation_and_screen_write_replaces_it(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="boot-api") + + with TestClient(app) as client: + status = client.get("/api/status").json() + config = client.get("/api/config").json() + assert status["state"]["mode"] == "animation" + assert status["state"]["startup_indicator_active"] is False + assert status["state"]["animation_active"] is True + assert status["state"]["current_content"] == { + "category": "animation", + "id": DEMO_ANIMATION_ID, + "name": DEMO_ANIMATION_NAME, + } + assert status["state"]["brightness"] == config["brightness"] + assert status["state"]["effective_brightness"] == config["brightness"] + + preview = client.post("/api/preview/diagnostic", json={"mode": "corners_lines"}) + assert preview.status_code == 200 + assert client.get("/api/status").json()["state"]["animation_active"] is True + + invalid = client.post("/api/display/fill", json={"color": "not-a-color"}) + assert invalid.status_code == 400 + assert client.get("/api/status").json()["state"]["animation_active"] is True + + applied = client.post("/api/display/clear") + assert applied.status_code == 200 + final_status = client.get("/api/status").json() + assert final_status["state"]["startup_indicator_active"] is False + assert final_status["state"]["animation_active"] is False + assert final_status["state"]["effective_brightness"] == config["brightness"] + assert final_status["state"]["mode"] == "clear" + assert final_status["state"]["current_content"]["name"] == "未保存内容" + + +def test_maintenance_black_startup_skips_default_content_and_boot_overlay(tmp_path): + app = create_app( + project_root=tmp_path, + driver_kind="mock", + startup_boot_id="maintenance-black", + maintenance_black=True, + ) + + with TestClient(app) as client: + status = client.get("/api/status").json() + frame = Image.open(BytesIO(client.get("/api/display/current-frame").content)) + + assert app.state.maintenance_black is True + assert status["state"]["mode"] == "clear" + assert status["state"]["animation_active"] is False + assert status["state"]["wifi_indicator_active"] is False + assert frame.convert("RGB").getbbox() is None + + +def test_default_content_api_applies_persists_and_validates_template_source(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="default-api") + with TestClient(app) as client: + initial = client.get("/api/display/default-content") + assert initial.status_code == 200 + assert initial.json()["id"] == DEMO_ANIMATION_ID + assert initial.json()["is_demo"] is True + + empty = client.post("/api/animations", json={"name": "空动图"}).json() + rejected = client.put( + "/api/display/default-content", + json={"type": "animation", "id": empty["id"]}, + ) + assert rejected.status_code == 409 + assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID + + created = client.post( + "/api/templates", + json={"name": "默认静态图", "scene": solid_scene((120, 30, 10))}, + ).json() + selected = client.put( + "/api/display/default-content", + json={"type": "template", "id": created["id"]}, + ) + assert selected.status_code == 200 + assert selected.json()["name"] == "默认静态图" + state = client.get("/api/status").json()["state"] + assert state["mode"] == "template" + assert state["current_content"] == { + "category": "template", + "id": created["id"], + "name": "默认静态图", + } + + frame = bytes((120, 30, 10)) * (64 * 64) + with client.websocket_connect("/ws/canvas") as websocket: + websocket.send_json({ + "type": "frame_rgb", + "source": "composition", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": base64.b64encode(frame).decode("ascii"), + "content_source": { + "type": "template", + "id": created["id"], + "revision": created["revision"], + }, + }) + assert websocket.receive_json()["applied"] is True + assert client.get("/api/status").json()["state"]["current_content"]["name"] == "默认静态图" + + updated = client.put( + f"/api/templates/{created['id']}", + headers={"If-Match": f'"{created["revision"]}"'}, + json={"scene": solid_scene((1, 2, 3))}, + ).json() + assert updated["revision"] != created["revision"] + with client.websocket_connect("/ws/canvas") as websocket: + websocket.send_json({ + "type": "frame_rgb", + "source": "composition", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": base64.b64encode(frame).decode("ascii"), + "content_source": { + "type": "template", + "id": created["id"], + "revision": created["revision"], + }, + }) + assert websocket.receive_json()["applied"] is True + assert client.get("/api/status").json()["state"]["current_content"] == { + "category": "unsaved", + "id": None, + "name": "未保存内容", + } + with client.websocket_connect("/ws/canvas") as websocket: + websocket.send_json({ + "type": "frame_rgb", + "source": "composition", + "width": 64, + "height": 64, + "encoding": "base64_rgb888", + "data": base64.b64encode(frame).decode("ascii"), + "content_source": { + "type": "template", + "id": "not-a-uuid", + "revision": created["revision"], + }, + }) + assert websocket.receive_json()["type"] == "error" + + restarted = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="default-api-restart") + with TestClient(restarted) as client: + assert client.get("/api/display/default-content").json()["id"] == created["id"] + assert client.get("/api/status").json()["state"]["current_content"]["name"] == "默认静态图" + + +def test_static_content_version_changes_with_paths_and_content(tmp_path): + static_dir = tmp_path / "static" + static_dir.mkdir() + (static_dir / "app.js").write_text("one", encoding="utf-8") + first = static_content_version(static_dir) + assert first == static_content_version(static_dir) + + (static_dir / "app.js").write_text("two", encoding="utf-8") + second = static_content_version(static_dir) + assert second != first + + (static_dir / "nested").mkdir() + (static_dir / "nested" / "app.js").write_text("two", encoding="utf-8") + assert static_content_version(static_dir) != second + + +def test_systemd_preserves_runtime_marker_across_service_stop_and_restart(): + unit = (Path(__file__).resolve().parents[1] / "systemd" / "matrix-screen-controller.service").read_text(encoding="utf-8") + assert "RuntimeDirectoryPreserve=yes" in unit + assert "scripts/dedicated_host.py check --service" in unit + assert "After=NetworkManager.service" in unit + assert "Wants=NetworkManager.service" in unit + assert "network-online.target" not in unit + + +def test_wifi_settings_api_reveals_password_only_on_dedicated_get(tmp_path): + connection_uuid = "12345678-1234-5678-1234-567812345678" + backend = MockNetworkManager( + saved={ + "connection_uuid": connection_uuid, + "ssid": "phone-hotspot", + "password": "secret123", + "password_configured": True, + "ipv4_mode": "dhcp", + "address": None, + "prefix": None, + "gateway": None, + "dns_servers": [], + }, + active={ + "connected": True, + "connection_uuid": connection_uuid, + "ssid": "phone-hotspot", + "ipv4_address": "192.168.9.93", + "control_url": "http://192.168.9.93:8080/", + }, + ) + app = create_app( + project_root=tmp_path, + driver_kind="mock", + startup_boot_id="wifi-api", + network_backend=backend, + ) + with TestClient(app) as client: + initial = client.get("/api/network/wifi") + assert initial.status_code == 200 + assert initial.json()["saved"]["password_configured"] is True + assert initial.json()["saved"]["password"] == "secret123" + assert initial.json()["active"]["control_url"] == "http://192.168.9.93:8080/" + status_network = client.get("/api/status").json()["network"] + assert "password" not in status_network["saved"] + startup_activation_count = len(backend.activations) + + payload = { + "ssid": "phone-hotspot", + "password": None, + "ipv4_mode": "manual", + "address": "192.168.9.94", + "gateway": "192.168.9.1", + "prefix": None, + "dns_servers": [], + "prompt_delay_seconds": 45, + "activation": "next_boot", + } + deferred = client.put("/api/network/wifi", json=payload) + assert deferred.status_code == 200 + assert deferred.json()["activation"] == "next_boot" + assert len(backend.activations) == startup_activation_count + + payload["activation"] = "immediate" + immediate = client.put("/api/network/wifi", json=payload) + assert immediate.status_code == 202 + assert immediate.json()["activation"] == "immediate" + assert len(backend.activations) == startup_activation_count + 1 + assert backend.activations[-1] == connection_uuid + assert "secret123" not in immediate.text + + +def test_wifi_prompt_delay_api_is_independent_from_network_manager(tmp_path): + connection_uuid = "12345678-1234-5678-1234-567812345678" + backend = MockNetworkManager( + saved={ + "connection_uuid": connection_uuid, + "ssid": "phone-hotspot", + "password": "secret123", + "password_configured": True, + "ipv4_mode": "dhcp", + "address": None, + "prefix": None, + "gateway": None, + "dns_servers": [], + }, + active={ + "connected": True, + "connection_uuid": connection_uuid, + "ssid": "phone-hotspot", + "ipv4_address": "192.168.9.93", + "control_url": "http://192.168.9.93:8080/", + }, + ) + app = create_app( + project_root=tmp_path, + driver_kind="mock", + startup_boot_id="wifi-delay-api", + network_backend=backend, + ) + with TestClient(app) as client: + before = client.get("/api/network/wifi").json() + saved_before = dict(backend.saved) + activations_before = list(backend.activations) + + response = client.put( + "/api/network/wifi/prompt-delay", + json={"prompt_delay_seconds": 45}, + ) + + assert response.status_code == 200 + assert response.json()["prompt_delay_seconds"] == 45 + after = client.get("/api/network/wifi").json() + assert after["prompt_delay_seconds"] == 45 + assert after["prompt"]["deadline_monotonic"] == before["prompt"]["deadline_monotonic"] + assert backend.saved == saved_before + assert backend.activations == activations_before + assert client.put( + "/api/network/wifi/prompt-delay", + json={"prompt_delay_seconds": 0}, + ).status_code == 400 + assert client.put( + "/api/network/wifi/prompt-delay", + json={"prompt_delay_seconds": True}, + ).status_code == 422 + + +def test_homepage_dismisses_wifi_prompt_for_current_boot(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="wifi-home") + with TestClient(app) as client: + before = client.get("/api/network/wifi").json() + assert before["prompt"]["dismissed_for_boot"] is False + assert client.get("/api/status").json()["state"]["current_content"]["id"] == DEMO_ANIMATION_ID + app.state.display_service.show_wifi_indicator( + connected=True, + message="SSID: test IP: http://192.168.1.20:8080/", + ) + assert client.get("/api/status").json()["state"]["current_content"] == { + "category": "system", + "id": None, + "name": "WiFi 提示", + } + assert client.get("/").status_code == 200 + after = client.get("/api/network/wifi").json() + assert after["prompt"]["dismissed_for_boot"] is True + restored = client.get("/api/status").json()["state"] + assert restored["current_content"]["id"] == DEMO_ANIMATION_ID + assert restored["animation_active"] is True + + +def test_device_view_hides_hardware_diagnostics_and_uses_temporary_test_api(): + source = ( + Path(__file__).resolve().parents[1] + / "app" + / "static" + / "views" + / "device.js" + ).read_text(encoding="utf-8") + + assert "disable_hardware_pulsing" not in source + assert "/api/display/diagnostic" not in source + assert "/api/display/test/fill" in source + assert "/api/display/test/brightness" in source + assert "/api/device-storage" in source + + +def test_settings_view_exposes_transactional_refresh_rate_buttons(): + static_root = Path(__file__).resolve().parents[1] / "app" / "static" + source = (static_root / "views" / "settings.js").read_text(encoding="utf-8") + html = (static_root / "index.html").read_text(encoding="utf-8") + + assert html.count("data-matrix-refresh-rate=") == 7 + assert "设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。" in html + assert "matrix_refresh_rate_limit_hz" in source + assert "扫描刷新率应用失败" in source diff --git a/核桃派软件源代码/tests/test_canvas_tools.mjs b/核桃派软件源代码/tests/test_canvas_tools.mjs new file mode 100644 index 0000000..bfdd920 --- /dev/null +++ b/核桃派软件源代码/tests/test_canvas_tools.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + BRUSH_MODE_NORMAL, + BRUSH_MODE_PIXEL, + PixelUndoHistory, + brushModeSpec, + brushStampPoints, + clampCanvasView, + clampFloatingButton, + linePoints, + normalizeBrushSize, + restoreCanvasToolState, + zoomCanvasView, +} from "../app/static/canvas-tools-model.js"; + +test("normal and pixel modes expose independent ranges and presets", () => { + assert.deepEqual(brushModeSpec(BRUSH_MODE_NORMAL).presets, [1, 2, 4, 8]); + assert.deepEqual(brushModeSpec(BRUSH_MODE_PIXEL).presets, [1, 2, 3, 4]); + assert.equal(normalizeBrushSize(99, BRUSH_MODE_NORMAL), 16); + assert.equal(normalizeBrushSize(99, BRUSH_MODE_PIXEL), 4); + assert.equal(normalizeBrushSize("bad", BRUSH_MODE_PIXEL), 1); +}); + +test("pixel stamps are exact squares from 1x1 through 4x4", () => { + for (let size = 1; size <= 4; size += 1) { + const points = brushStampPoints(32, 32, size, BRUSH_MODE_PIXEL); + assert.equal(points.length, size * size); + assert.equal(new Set(points.map(({ x }) => x)).size, size); + assert.equal(new Set(points.map(({ y }) => y)).size, size); + } +}); + +test("pixel stamps clip at every canvas edge", () => { + const points = brushStampPoints(0, 0, 4, BRUSH_MODE_PIXEL); + assert.equal(points.length, 9); + assert.ok(points.every(({ x, y }) => x >= 0 && y >= 0 && x < 64 && y < 64)); + const opposite = brushStampPoints(63, 63, 4, BRUSH_MODE_PIXEL); + assert.equal(opposite.length, 4); + assert.ok(opposite.every(({ x, y }) => x >= 0 && y >= 0 && x < 64 && y < 64)); + assert.equal(brushStampPoints(0, 32, 4, BRUSH_MODE_PIXEL).length, 12); + assert.equal(brushStampPoints(32, 0, 4, BRUSH_MODE_PIXEL).length, 12); +}); + +test("normal circle geometry remains unchanged", () => { + assert.equal(brushStampPoints(32, 32, 1, BRUSH_MODE_NORMAL).length, 1); + assert.equal(brushStampPoints(32, 32, 2, BRUSH_MODE_NORMAL).length, 4); + assert.equal(brushStampPoints(32, 32, 4, BRUSH_MODE_NORMAL).length, 12); +}); + +test("line interpolation includes endpoints without coordinate gaps", () => { + const points = linePoints({ x: 2, y: 3 }, { x: 19, y: 11 }); + assert.deepEqual(points[0], { x: 2, y: 3 }); + assert.deepEqual(points.at(-1), { x: 19, y: 11 }); + for (let index = 1; index < points.length; index += 1) { + assert.ok(Math.abs(points[index].x - points[index - 1].x) <= 1); + assert.ok(Math.abs(points[index].y - points[index - 1].y) <= 1); + } +}); + +test("v1 tools migrate into normal mode while v2 restores both sizes", () => { + assert.deepEqual(restoreCanvasToolState(null, { + paintColor: "#123456", + backgroundColor: "#654321", + tool: "eraser", + brushSize: 8, + }), { + paintColor: "#123456", + backgroundColor: "#654321", + tool: "eraser", + pixelEditEnabled: false, + normalBrushSize: 8, + pixelBrushSize: 1, + }); + assert.deepEqual(restoreCanvasToolState({ + tool: "brush", + pixelEditEnabled: true, + normalBrushSize: 12, + pixelBrushSize: 4, + }), { + paintColor: null, + backgroundColor: null, + tool: "brush", + pixelEditEnabled: true, + normalBrushSize: 12, + pixelBrushSize: 4, + }); + assert.equal(restoreCanvasToolState("damaged", { brushSize: "bad" }).normalBrushSize, 1); +}); + +test("pixel mode markup and grid stay accessible and non-interactive", async () => { + const [index, style, view] = await Promise.all([ + readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), + readFile(new URL("../app/static/style.css", import.meta.url), "utf8"), + readFile(new URL("../app/static/views/canvas.js", import.meta.url), "utf8"), + ]); + assert.match(index, /id="pixel-edit-toggle"[^>]+aria-pressed="false"/); + assert.match(index, /id="pixel-edit-status"[^>]+aria-live="polite"/); + assert.match(index, /id="pixel-grid"[^>]+aria-hidden="true"[^>]+hidden/); + assert.match(style, /\.pixel-grid\s*\{[^}]*pointer-events:\s*none/s); + assert.match(style, /background-size:\s*calc\(100% \/ 64\) calc\(100% \/ 64\)/); + assert.match(view, /pixelGrid\.hidden = !\(pixelEditEnabled && getInteractionMode\(\) === "pixels"\)/); +}); + +test("pixel undo history stores changed snapshots, caps entries, and clears", () => { + const history = new PixelUndoHistory(2); + const first = Uint8Array.from([0, 0, 0]); + assert.equal(history.record(first, first), false); + assert.equal(history.size, 0); + assert.equal(history.record(first, Uint8Array.from([1, 0, 0])), true); + first[0] = 9; + history.record(Uint8Array.from([1, 0, 0]), Uint8Array.from([2, 0, 0])); + history.record(Uint8Array.from([2, 0, 0]), Uint8Array.from([3, 0, 0])); + assert.equal(history.size, 2); + assert.deepEqual(history.undo(), Uint8Array.from([2, 0, 0])); + assert.deepEqual(history.undo(), Uint8Array.from([1, 0, 0])); + assert.equal(history.undo(), null); + history.record(Uint8Array.from([3]), Uint8Array.from([4])); + history.clear(); + assert.equal(history.size, 0); +}); + +test("canvas view clamps scale and keeps a visible section in the viewport", () => { + const bounds = { viewportWidth: 320, viewportHeight: 568, baseSize: 296, minVisible: 48 }; + assert.deepEqual(clampCanvasView({ scale: 99, x: 9999, y: -9999 }, bounds), { + scale: 8, + x: 1296, + y: -1420, + }); + assert.deepEqual(clampCanvasView({ scale: 0, x: 0, y: 0 }, bounds), { scale: 1, x: 0, y: 0 }); +}); + +test("zooming around an anchor preserves its logical position", () => { + const bounds = { viewportWidth: 400, viewportHeight: 600, baseSize: 360, minVisible: 48 }; + const view = { scale: 1, x: 0, y: 0 }; + const zoomed = zoomCanvasView(view, 2, { x: 300, y: 250 }, bounds); + assert.deepEqual(zoomed, { scale: 2, x: -100, y: 50 }); + const logicalBefore = { + x: (300 - 200 - view.x) / view.scale, + y: (250 - 300 - view.y) / view.scale, + }; + const logicalAfter = { + x: (300 - 200 - zoomed.x) / zoomed.scale, + y: (250 - 300 - zoomed.y) / zoomed.scale, + }; + assert.deepEqual(logicalAfter, logicalBefore); +}); + +test("floating editor button stays inside its safe viewport bounds", () => { + assert.deepEqual(clampFloatingButton({ x: -20, y: 999 }, { + viewportWidth: 320, + viewportHeight: 568, + size: 56, + inset: 8, + }), { x: 36, y: 532 }); +}); + +test("canvas editor markup, guarded input, and checkpoint events stay wired", async () => { + const [index, style, canvasView, sceneBoard, templates, animations] = await Promise.all([ + readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), + readFile(new URL("../app/static/style.css", import.meta.url), "utf8"), + readFile(new URL("../app/static/views/canvas.js", import.meta.url), "utf8"), + readFile(new URL("../app/static/scene-board.js", import.meta.url), "utf8"), + readFile(new URL("../app/static/views/templates.js", import.meta.url), "utf8"), + readFile(new URL("../app/static/views/animations.js", import.meta.url), "utf8"), + ]); + assert.match(index, /id="start-canvas-edit"[^>]*>开始编辑<\/button>/); + assert.match(index, /id="canvas-assistive-toggle"[^>]+aria-expanded="false"/); + assert.match(index, /id="undo-canvas-edit"[^>]+aria-disabled="true"/); + assert.match(index, /id="move-canvas-view"/); + assert.match(index, /id="continue-canvas-edit"/); + assert.match(style, /body\.canvas-edit-active #board-shell\s*\{[^}]*position:\s*fixed/s); + assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s); + assert.match(canvasView, /if \(!editorActive \|\| getInteractionMode\(\) !== "pixels"\) return/); + assert.match(canvasView, /new PixelUndoHistory\(\)/); + assert.match(sceneBoard, /matrix:scene-replaced/); + assert.match(templates, /matrix:canvas-history-checkpoint/); + assert.match(animations, /matrix:canvas-history-checkpoint/); +}); diff --git a/核桃派软件源代码/tests/test_color_math.mjs b/核桃派软件源代码/tests/test_color_math.mjs new file mode 100644 index 0000000..769b68c --- /dev/null +++ b/核桃派软件源代码/tests/test_color_math.mjs @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + hexToRgb, + hsvToRgb, + normalizeHex, + rgbToHex, + rgbToHsv, + updateRecentColors, +} from "../app/static/color-math.js"; + +test("normalizes and converts hexadecimal colors", () => { + assert.equal(normalizeHex("aabbcc"), "#AABBCC"); + assert.equal(normalizeHex("#12FG00"), null); + assert.deepEqual(hexToRgb("#40A0FF"), { r: 64, g: 160, b: 255 }); + assert.equal(rgbToHex({ r: 64, g: 160, b: 255 }), "#40A0FF"); +}); + +test("converts RGB and HSV boundaries", () => { + assert.deepEqual(rgbToHsv({ r: 255, g: 0, b: 0 }), { h: 0, s: 100, v: 100 }); + assert.deepEqual(hsvToRgb({ h: 120, s: 100, v: 100 }), { r: 0, g: 255, b: 0 }); + assert.deepEqual(hsvToRgb({ h: 360, s: 100, v: 100 }), { r: 255, g: 0, b: 0 }); + assert.deepEqual(rgbToHsv({ r: 0, g: 0, b: 0 }), { h: 0, s: 0, v: 0 }); +}); + +test("round trips representative colors within 8-bit rounding", () => { + for (const hex of ["#000000", "#FFFFFF", "#40A0FF", "#8B32C7", "#117744", "#F08020"]) { + const source = hexToRgb(hex); + const result = hsvToRgb(rgbToHsv(source)); + for (const channel of ["r", "g", "b"]) { + assert.ok(Math.abs(source[channel] - result[channel]) <= 2, `${hex} ${channel}`); + } + } +}); + +test("recent colors deduplicate, normalize, and evict the oldest", () => { + let colors = []; + for (let index = 0; index < 12; index += 1) colors = updateRecentColors(colors, `#${index.toString(16).padStart(6, "0")}`); + assert.equal(colors.length, 10); + assert.equal(colors[0], "#00000B"); + colors = updateRecentColors(colors, "#000005"); + assert.equal(colors[0], "#000005"); + assert.equal(colors.filter((color) => color === "#000005").length, 1); +}); diff --git a/核桃派软件源代码/tests/test_config_store.py b/核桃派软件源代码/tests/test_config_store.py new file mode 100644 index 0000000..b2ed049 --- /dev/null +++ b/核桃派软件源代码/tests/test_config_store.py @@ -0,0 +1,702 @@ +import json +from pathlib import Path + +import pytest + +import app.persistence as persistence +from app.config.store import ( + COLOR_PALETTE_LIMIT, + CONFIG_SCHEMA_VERSION, + CONFIG_V1_FIELDS, + CONFIG_V2_FIELDS, + CONFIG_V3_FIELDS, + CONFIG_V4_FIELDS, + CONFIG_V5_FIELDS, + CONFIG_V6_FIELDS, + CONFIG_V7_FIELDS, + CONFIG_V8_FIELDS, + CONFIG_V9_FIELDS, + CONFIG_V10_FIELDS, + DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT, + DEFAULT_COLOR_PALETTE, + DEFAULT_DISPLAY, + DEFAULT_WORKSPACE_ORDER, + DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ, + DEFAULT_PREVIEW_REFRESH_INTERVAL_MS, + MATRIX_REFRESH_RATE_LIMITS, + ConfigError, + ConfigStore, + resolve_data_dir, + resolve_runtime_dir, +) + + +def test_config_store_creates_and_updates(tmp_path): + store = ConfigStore(project_root=tmp_path) + + assert store.config["orientation"] == 0 + assert store.config["color_palette"] == DEFAULT_COLOR_PALETTE + assert store.config["voltage_calibration_factor"] == 1.0 + assert store.config["voltage_calibrated_at"] is None + assert store.config["preview_refresh_interval_ms"] == DEFAULT_PREVIEW_REFRESH_INTERVAL_MS + assert store.config["low_voltage_protection_enabled"] is False + assert store.config["matrix_refresh_rate_limit_hz"] == 100 + assert store.config["custom_test_color"] == "#40A0FF" + assert store.config["default_display"] == DEFAULT_DISPLAY + assert store.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER + assert store.config["performance_mode_enabled"] is False + assert store.config["animation_preview_max_concurrent"] == DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT + assert (tmp_path / "data" / "config.json").exists() + assert "schema_version" not in store.config + persisted = json.loads((tmp_path / "data" / "config.json").read_text(encoding="utf-8")) + assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION + + updated = store.update({"orientation": 180, "brightness": 35}) + assert updated["orientation"] == 180 + assert updated["brightness"] == 35 + + reloaded = ConfigStore(project_root=tmp_path) + assert reloaded.config["orientation"] == 180 + assert reloaded.config["brightness"] == 35 + + +def test_v1_config_migrates_preview_interval_without_losing_device_values(tmp_path): + original_store = ConfigStore(project_root=tmp_path) + original_store.update({ + "orientation": 270, + "brightness": 31, + "color_palette": ["#010203", "#AABBCC"], + "voltage_calibration_factor": 1.01, + "voltage_calibrated_at": "2026-07-20T12:34:56.000Z", + "voltage_calibration_reference": 5.0, + "voltage_calibration_uncalibrated": 4.950495, + }) + v1_document = json.loads(original_store.path.read_text(encoding="utf-8")) + v1_document["schema_version"] = 1 + v1_document.pop("preview_refresh_interval_ms") + v1_document.pop("low_voltage_protection_enabled") + v1_document.pop("matrix_refresh_rate_limit_hz") + v1_document.pop("custom_test_color") + v1_document.pop("default_display") + v1_document.pop("workspace_order") + v1_document.pop("performance_mode_enabled") + v1_document.pop("animation_preview_max_concurrent") + original_store.path.write_text( + json.dumps(v1_document, ensure_ascii=False), + encoding="utf-8", + ) + + migrated = ConfigStore(project_root=tmp_path) + persisted = json.loads(migrated.path.read_text(encoding="utf-8")) + + assert migrated.config["preview_refresh_interval_ms"] == DEFAULT_PREVIEW_REFRESH_INTERVAL_MS + assert migrated.config["orientation"] == 270 + assert migrated.config["brightness"] == 31 + assert migrated.config["color_palette"] == ["#010203", "#AABBCC"] + assert migrated.config["voltage_calibration_factor"] == 1.01 + assert migrated.config["voltage_calibrated_at"] == "2026-07-20T12:34:56.000Z" + assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION + assert migrated.config["low_voltage_protection_enabled"] is False + assert migrated.config["matrix_refresh_rate_limit_hz"] == 100 + + +def test_v2_config_migrates_protection_switch_without_losing_values(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({ + "orientation": 90, + "brightness": 27, + "preview_refresh_interval_ms": 321, + "color_palette": ["#123456"], + }) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 2 + document.pop("low_voltage_protection_enabled") + document.pop("matrix_refresh_rate_limit_hz") + document.pop("custom_test_color") + document.pop("default_display") + document.pop("workspace_order") + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["low_voltage_protection_enabled"] is False + assert migrated.config["orientation"] == 90 + assert migrated.config["brightness"] == 27 + assert migrated.config["preview_refresh_interval_ms"] == 321 + assert migrated.config["color_palette"] == ["#123456"] + assert migrated.config["matrix_refresh_rate_limit_hz"] == 100 + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v3_config_migrates_matrix_refresh_limit_without_losing_values(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({ + "orientation": 180, + "brightness": 29, + "preview_refresh_interval_ms": 444, + "low_voltage_protection_enabled": True, + }) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 3 + document.pop("matrix_refresh_rate_limit_hz") + document.pop("custom_test_color") + document.pop("default_display") + document.pop("workspace_order") + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["matrix_refresh_rate_limit_hz"] == DEFAULT_MATRIX_REFRESH_RATE_LIMIT_HZ + assert migrated.config["orientation"] == 180 + assert migrated.config["brightness"] == 29 + assert migrated.config["preview_refresh_interval_ms"] == 444 + assert migrated.config["low_voltage_protection_enabled"] is True + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v4_config_migrates_custom_test_color_without_losing_values(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": 270, "brightness": 37}) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 4 + document.pop("custom_test_color") + document.pop("default_display") + document.pop("workspace_order") + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["custom_test_color"] == "#40A0FF" + assert migrated.config["orientation"] == 270 + assert migrated.config["brightness"] == 37 + assert migrated.config["default_display"] == DEFAULT_DISPLAY + assert migrated.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v5_config_migrates_default_display_without_losing_values(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": 90, "brightness": 33, "custom_test_color": "#123456"}) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 5 + document.pop("default_display") + document.pop("workspace_order") + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["default_display"] == DEFAULT_DISPLAY + assert migrated.config["orientation"] == 90 + assert migrated.config["brightness"] == 33 + assert migrated.config["custom_test_color"] == "#123456" + assert migrated.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v6_config_migrates_workspace_order_without_losing_values(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": 180, "brightness": 26}) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 6 + document.pop("workspace_order") + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["workspace_order"] == DEFAULT_WORKSPACE_ORDER + assert migrated.config["orientation"] == 180 + assert migrated.config["brightness"] == 26 + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v7_config_inserts_media_workspace_without_reordering_existing_items(tmp_path): + store = ConfigStore(project_root=tmp_path) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 7 + document["workspace_order"] = ["settings", "text", "templates", "animations", "device", "canvas"] + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["workspace_order"] == [ + "settings", "text", "templates", "media-import", "animations", "device", "canvas", + ] + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v8_config_migrates_performance_mode_disabled(tmp_path): + store = ConfigStore(project_root=tmp_path) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 8 + document.pop("performance_mode_enabled") + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["performance_mode_enabled"] is False + assert migrated.config["animation_preview_max_concurrent"] == 2 + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_v9_config_migrates_animation_preview_concurrency_without_losing_values(tmp_path): + store = ConfigStore(project_root=tmp_path) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["schema_version"] = 9 + document["performance_mode_enabled"] = True + document.pop("animation_preview_max_concurrent") + store.path.write_text(json.dumps(document), encoding="utf-8") + + migrated = ConfigStore(project_root=tmp_path) + + assert migrated.config["performance_mode_enabled"] is True + assert migrated.config["animation_preview_max_concurrent"] == 2 + assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 10 + + +def test_config_schema_field_sets_are_explicit_and_incremental(): + assert "preview_refresh_interval_ms" not in CONFIG_V1_FIELDS + assert "low_voltage_protection_enabled" not in CONFIG_V1_FIELDS + assert CONFIG_V2_FIELDS == CONFIG_V1_FIELDS | {"preview_refresh_interval_ms"} + assert CONFIG_V3_FIELDS == CONFIG_V2_FIELDS | {"low_voltage_protection_enabled"} + assert CONFIG_V4_FIELDS == CONFIG_V3_FIELDS | {"matrix_refresh_rate_limit_hz"} + assert CONFIG_V5_FIELDS == CONFIG_V4_FIELDS | {"custom_test_color"} + assert CONFIG_V6_FIELDS == CONFIG_V5_FIELDS | {"default_display"} + assert CONFIG_V7_FIELDS == CONFIG_V6_FIELDS | {"workspace_order"} + assert CONFIG_V8_FIELDS == CONFIG_V7_FIELDS + assert CONFIG_V9_FIELDS == CONFIG_V8_FIELDS | {"performance_mode_enabled"} + assert CONFIG_V10_FIELDS == CONFIG_V9_FIELDS | {"animation_preview_max_concurrent"} + + +def test_animation_preview_concurrency_is_strict_and_persistent(tmp_path): + store = ConfigStore(project_root=tmp_path) + + for value in (1, 2, 50): + assert store.update({"animation_preview_max_concurrent": value})[ + "animation_preview_max_concurrent" + ] == value + assert ConfigStore(project_root=tmp_path).config[ + "animation_preview_max_concurrent" + ] == value + + for value in (0, 51, True, 2.0, "2", None): + with pytest.raises(ConfigError): + store.update({"animation_preview_max_concurrent": value}) + + +def test_workspace_order_is_strict_unique_and_persistent(tmp_path): + store = ConfigStore(project_root=tmp_path) + selected = ["templates", "device", "settings", "canvas", "text", "animations"] + + assert store.update({"workspace_order": selected})["workspace_order"] == selected + assert ConfigStore(project_root=tmp_path).config["workspace_order"] == selected + + for value in ( + None, + [], + ["device", "device"], + ["bad id"], + ["_bad"], + [1, "device"], + "device", + ): + with pytest.raises(ConfigError): + store.update({"workspace_order": value}) + assert store.config["workspace_order"] == selected + + +@pytest.mark.parametrize("value", [["device", "device"], ["bad id"], [], [1]]) +def test_invalid_persisted_workspace_order_is_preserved_and_blocks_startup(tmp_path, value): + store = ConfigStore(project_root=tmp_path) + document = json.loads(store.path.read_text(encoding="utf-8")) + document["workspace_order"] = value + original = json.dumps(document, ensure_ascii=False).encode("utf-8") + store.path.write_bytes(original) + + with pytest.raises(ConfigError): + ConfigStore(project_root=tmp_path) + + assert store.path.read_bytes() == original + + +def test_default_display_is_strict_normalized_and_persistent(tmp_path): + store = ConfigStore(project_root=tmp_path) + selected = { + "type": "template", + "id": "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + } + + assert store.update({"default_display": selected})["default_display"] == { + "type": "template", + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + } + assert ConfigStore(project_root=tmp_path).config["default_display"]["id"] == ( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + ) + + for value in ( + None, + {}, + {"type": "template"}, + {"type": "image", "id": selected["id"]}, + {"type": "template", "id": "not-a-uuid"}, + {"type": "template", "id": selected["id"], "name": "extra"}, + ): + with pytest.raises(ConfigError): + store.update({"default_display": value}) + + +def test_custom_test_color_is_normalized_strict_and_persistent(tmp_path): + store = ConfigStore(project_root=tmp_path) + + assert store.update({"custom_test_color": "#a1b2c3"})["custom_test_color"] == "#A1B2C3" + assert ConfigStore(project_root=tmp_path).config["custom_test_color"] == "#A1B2C3" + + for value in (None, True, 123, "red", "#12345", "#1234567"): + with pytest.raises(ConfigError): + store.update({"custom_test_color": value}) + assert store.config["custom_test_color"] == "#A1B2C3" + + +def test_matrix_refresh_rate_limit_requires_a_fixed_integer_preset(tmp_path): + store = ConfigStore(project_root=tmp_path) + + for value in sorted(MATRIX_REFRESH_RATE_LIMITS): + assert store.update({"matrix_refresh_rate_limit_hz": value})[ + "matrix_refresh_rate_limit_hz" + ] == value + assert ConfigStore(project_root=tmp_path).config[ + "matrix_refresh_rate_limit_hz" + ] == value + + for value in (0, 14, 16, 60.0, True, "60", None): + with pytest.raises(ConfigError): + store.update({"matrix_refresh_rate_limit_hz": value}) + + +@pytest.mark.parametrize("value", [0, 1, None, "true", 1.0]) +def test_low_voltage_protection_switch_requires_a_strict_boolean(tmp_path, value): + store = ConfigStore(project_root=tmp_path) + + with pytest.raises(ConfigError, match="must be a boolean"): + store.update({"low_voltage_protection_enabled": value}) + + assert store.config["low_voltage_protection_enabled"] is False + + +def test_low_voltage_protection_switch_persists_across_restart(tmp_path): + store = ConfigStore(project_root=tmp_path) + + store.update({"low_voltage_protection_enabled": True}) + + assert ConfigStore(project_root=tmp_path).config[ + "low_voltage_protection_enabled" + ] is True + + +def test_preview_refresh_interval_range_type_and_restart_persistence(tmp_path): + store = ConfigStore(project_root=tmp_path) + + for value in (1, 99, 100, 60000): + assert store.update({"preview_refresh_interval_ms": value})[ + "preview_refresh_interval_ms" + ] == value + assert ConfigStore(project_root=tmp_path).config[ + "preview_refresh_interval_ms" + ] == value + + for value in (0, 60001, True, 1.5, "100"): + with pytest.raises(ConfigError): + store.update({"preview_refresh_interval_ms": value}) + + +def test_palette_normalizes_persists_and_updates_atomically(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"color_palette": []}) + + store.add_palette_color("#aabbcc") + store.add_palette_color("#AABBCC") + store.add_palette_color("#010203") + store.remove_palette_color("#aabbcc") + store.remove_palette_color("#ffffff") + + assert store.config["color_palette"] == ["#010203"] + assert ConfigStore(project_root=tmp_path).config["color_palette"] == ["#010203"] + + +def test_legacy_palette_repairs_entries_without_losing_other_config(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + raw_palette = ["#aabbcc", "bad", "#AABBCC"] + [f"#{index:06X}" for index in range(30)] + (data_dir / "config.json").write_text(json.dumps({ + "orientation": 180, + "brightness": 35, + "color_palette": raw_palette, + }), encoding="utf-8") + + store = ConfigStore(project_root=tmp_path) + + assert store.config["orientation"] == 180 + assert store.config["brightness"] == 35 + assert store.config["color_palette"][0] == "#AABBCC" + assert len(store.config["color_palette"]) == COLOR_PALETTE_LIMIT + persisted = json.loads((data_dir / "config.json").read_text(encoding="utf-8")) + assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION + assert persisted["color_palette"] == store.config["color_palette"] + + +def test_config_without_palette_is_migrated_to_defaults(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + path = data_dir / "config.json" + path.write_text(json.dumps({"orientation": 90, "brightness": 50}), encoding="utf-8") + + store = ConfigStore(project_root=tmp_path) + + assert store.config["orientation"] == 90 + assert store.config["color_palette"] == DEFAULT_COLOR_PALETTE + persisted = json.loads(path.read_text(encoding="utf-8")) + assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION + assert persisted["color_palette"] == DEFAULT_COLOR_PALETTE + + +def test_config_store_rejects_corrupt_file_without_modifying_it(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + path = data_dir / "config.json" + original = b"{bad json" + path.write_bytes(original) + + with pytest.raises(ConfigError, match="unreadable"): + ConfigStore(project_root=tmp_path) + + assert path.read_bytes() == original + + +def test_voltage_calibration_migrates_and_persists_as_one_group(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({ + "voltage_calibration_factor": 1.01, + "voltage_calibrated_at": "2026-07-20T12:34:56.000Z", + "voltage_calibration_reference": 5.0, + "voltage_calibration_uncalibrated": 4.950495, + }) + + reloaded = ConfigStore(project_root=tmp_path) + + assert reloaded.config["voltage_calibration_factor"] == 1.01 + assert reloaded.config["voltage_calibrated_at"] == "2026-07-20T12:34:56.000Z" + + +def test_legacy_config_migration_preserves_valid_voltage_calibration(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + path = data_dir / "config.json" + calibration = { + "voltage_calibration_factor": 1.01, + "voltage_calibrated_at": "2026-07-20T12:34:56.000Z", + "voltage_calibration_reference": 5.0, + "voltage_calibration_uncalibrated": 4.950495, + } + path.write_text( + json.dumps({"orientation": 180, "brightness": 35, **calibration}), + encoding="utf-8", + ) + + store = ConfigStore(project_root=tmp_path) + + assert {key: store.config[key] for key in calibration} == calibration + persisted = json.loads(path.read_text(encoding="utf-8")) + assert {key: persisted[key] for key in calibration} == calibration + assert persisted["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_invalid_or_partial_voltage_calibration_is_preserved_and_blocks_startup(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + path = data_dir / "config.json" + path.write_text(json.dumps({ + "orientation": 180, + "brightness": 35, + "voltage_calibration_factor": 9, + "voltage_calibrated_at": "bad", + "voltage_calibration_reference": 5.0, + }), encoding="utf-8") + + original = path.read_bytes() + + with pytest.raises(ConfigError, match="calibration is incomplete"): + ConfigStore(project_root=tmp_path) + + assert path.read_bytes() == original + + +@pytest.mark.parametrize( + "document, message", + [ + ({"schema_version": CONFIG_SCHEMA_VERSION + 1}, "newer than supported"), + ({"orientation": 0, "unexpected": True}, "unknown persisted config fields"), + ], +) +def test_unmigratable_config_is_preserved_and_blocks_startup( + tmp_path, + document, + message, +): + data_dir = tmp_path / "data" + data_dir.mkdir() + path = data_dir / "config.json" + original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8") + path.write_bytes(original) + + with pytest.raises(ConfigError, match=message): + ConfigStore(project_root=tmp_path) + + assert path.read_bytes() == original + + +def test_config_migration_is_idempotent(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + path = data_dir / "config.json" + path.write_text(json.dumps({"orientation": 270, "brightness": 42}), encoding="utf-8") + + first = ConfigStore(project_root=tmp_path) + migrated = path.read_bytes() + second = ConfigStore(project_root=tmp_path) + + assert first.config == second.config + assert path.read_bytes() == migrated + assert json.loads(migrated)["schema_version"] == CONFIG_SCHEMA_VERSION + + +def test_atomic_write_failure_keeps_previous_config_and_cleans_temp_files( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": 90}) + original = store.path.read_bytes() + + def fail_replace(_source, _target): + raise OSError("replace failed") + + monkeypatch.setattr(persistence.os, "replace", fail_replace) + with pytest.raises(OSError, match="replace failed"): + store.update({"orientation": 180}) + + assert store.path.read_bytes() == original + assert store.config["orientation"] == 90 + assert list(store.data_dir.glob("*.tmp")) == [] + + +def test_directory_sync_failure_restores_previous_config_and_cleans_temp_files( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": 90}) + original = store.path.read_bytes() + sync_calls = 0 + + def fail_second_sync(_directory): + nonlocal sync_calls + sync_calls += 1 + if sync_calls == 2: + raise OSError("directory sync failed") + + monkeypatch.setattr(persistence, "_fsync_directory", fail_second_sync) + with pytest.raises(OSError, match="directory sync failed"): + store.update({"orientation": 180}) + + assert store.path.read_bytes() == original + assert store.config["orientation"] == 90 + assert list(store.data_dir.glob(".*.tmp")) == [] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("brightness", True), + ("orientation", 0.0), + ("default_text_size", "12"), + ("low_voltage_protection_enabled", 1), + ("performance_mode_enabled", 1), + ("animation_preview_max_concurrent", True), + ], +) +def test_current_config_schema_rejects_noncanonical_json_types( + tmp_path, + field, + value, +): + store = ConfigStore(project_root=tmp_path) + document = json.loads(store.path.read_text(encoding="utf-8")) + document[field] = value + original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8") + store.path.write_bytes(original) + + with pytest.raises(ConfigError, match="must be"): + ConfigStore(project_root=tmp_path) + + assert store.path.read_bytes() == original + + +def test_explicit_project_root_overrides_storage_environment(tmp_path, monkeypatch): + environment_data = tmp_path / "environment-data" + environment_runtime = tmp_path / "environment-runtime" + monkeypatch.setenv("MATRIX_DATA_DIR", str(environment_data)) + monkeypatch.setenv("STATE_DIRECTORY", str(tmp_path / "state")) + monkeypatch.setenv("MATRIX_RUNTIME_DIR", str(environment_runtime)) + monkeypatch.setenv("RUNTIME_DIRECTORY", str(tmp_path / "runtime")) + explicit = tmp_path / "explicit" + + store = ConfigStore(project_root=explicit) + + assert store.data_dir == explicit / "data" + assert store.runtime_dir == explicit / "data" / "runtime" + assert not environment_data.exists() + assert not environment_runtime.exists() + + +def test_environment_storage_path_precedence_and_runtime_fallback(tmp_path, monkeypatch): + matrix_data = tmp_path / "matrix-data" + state_data = tmp_path / "state-data" + matrix_runtime = tmp_path / "matrix-runtime" + systemd_runtime = tmp_path / "systemd-runtime" + monkeypatch.setenv("MATRIX_DATA_DIR", str(matrix_data)) + monkeypatch.setenv("STATE_DIRECTORY", str(state_data)) + monkeypatch.setenv("MATRIX_RUNTIME_DIR", str(matrix_runtime)) + monkeypatch.setenv("RUNTIME_DIRECTORY", str(systemd_runtime)) + + assert resolve_data_dir() == matrix_data + assert resolve_runtime_dir(data_dir=matrix_data) == matrix_runtime + + monkeypatch.delenv("MATRIX_DATA_DIR") + monkeypatch.delenv("MATRIX_RUNTIME_DIR") + assert resolve_data_dir() == state_data + assert resolve_runtime_dir(data_dir=state_data) == systemd_runtime + + monkeypatch.delenv("STATE_DIRECTORY") + monkeypatch.delenv("RUNTIME_DIRECTORY") + configured_data = tmp_path / "configured-only" + monkeypatch.setenv("MATRIX_DATA_DIR", str(configured_data)) + assert ( + resolve_runtime_dir(data_dir=resolve_data_dir()) + == Path(__file__).resolve().parents[1] / "data" / "runtime" + ) + + monkeypatch.delenv("MATRIX_DATA_DIR") + assert resolve_data_dir() == Path(__file__).resolve().parents[1] / "data" diff --git a/核桃派软件源代码/tests/test_current_display_playback_ui.mjs b/核桃派软件源代码/tests/test_current_display_playback_ui.mjs new file mode 100644 index 0000000..1261dac --- /dev/null +++ b/核桃派软件源代码/tests/test_current_display_playback_ui.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + CURRENT_DISPLAY_SPEEDS, + LatestSeekDispatcher, + formatPlaybackTime, + projectPlaybackPosition, +} from "../app/static/current-display-playback.js"; + +const staticRoot = new URL("../app/static/", import.meta.url); + +test("formats and projects looping playback without moving paused content", () => { + assert.deepEqual(CURRENT_DISPLAY_SPEEDS, [0.5, 1, 1.5, 2]); + assert.equal(formatPlaybackTime(65032), "1:05.03"); + assert.equal(projectPlaybackPosition({ + active: true, + position_ms: 900, + total_duration_ms: 1000, + paused: false, + speed: 2, + }, 100), 100); + assert.equal(projectPlaybackPosition({ + active: true, + position_ms: 450, + total_duration_ms: 1000, + paused: true, + speed: 2, + }, 100), 450); +}); + +test("seek dispatcher keeps one request in flight and flushes the newest position", async () => { + let now = 0; + let releaseFirst; + const sent = []; + const timers = new Map(); + let timerSequence = 0; + const dispatcher = new LatestSeekDispatcher((position) => { + sent.push(position); + if (sent.length === 1) return new Promise((resolve) => { releaseFirst = resolve; }); + return Promise.resolve(); + }, { + now: () => now, + setTimer: (callback) => { + const id = ++timerSequence; + timers.set(id, callback); + return id; + }, + clearTimer: (id) => timers.delete(id), + }); + + dispatcher.enqueue(10); + await Promise.resolve(); + await Promise.resolve(); + dispatcher.enqueue(20); + dispatcher.enqueue(30); + dispatcher.enqueue(40, { flush: true }); + assert.deepEqual(sent, [10]); + + releaseFirst(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(sent, [10, 40]); + assert.equal(timers.size, 0); +}); + +test("topbar current display dialog exposes static close and animation controls", async () => { + const [html, device, style] = await Promise.all([ + readFile(new URL("index.html", staticRoot), "utf8"), + readFile(new URL("views/device.js", staticRoot), "utf8"), + readFile(new URL("style.css", staticRoot), "utf8"), + ]); + assert.match(html, /id="topbar-current-content"[^>]*type="button"[^>]*aria-controls="current-display-dialog"/); + assert.match(html, /id="current-display-dialog"[^>]*aria-labelledby="current-display-dialog-title"/); + assert.match(html, /id="current-display-preview-image"/); + assert.match(html, /id="current-display-progress"[^>]*type="range"/); + assert.equal((html.match(/data-playback-speed=/g) || []).length, 4); + assert.match(device, /event\.target === currentDisplayDialog/); + assert.match(device, /currentDisplayPlaybackControls\.hidden/); + assert.match(device, /intervalMs: 100/); + assert.match(device, /currentDisplayFinalSeekPosition/); + assert.match(device, /document\.visibilityState === "visible"/); + assert.match(style, /\.current-display-preview-surface[\s\S]*aspect-ratio:\s*1/); + assert.match(style, /@media \(max-width: 30rem\)[\s\S]*\.current-display-playback-actions[\s\S]*grid-template-columns:\s*1fr/); +}); diff --git a/核桃派软件源代码/tests/test_debian_closure.py b/核桃派软件源代码/tests/test_debian_closure.py new file mode 100644 index 0000000..8037790 --- /dev/null +++ b/核桃派软件源代码/tests/test_debian_closure.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from scripts.debian_closure import ( + PackageRecord, + parse_control_stanzas, + parse_dependency_field, + resolve_closure, +) + + +def package(name: str, dependencies: str = "", *, version: str = "1") -> PackageRecord: + return PackageRecord( + name=name, + version=version, + architecture="arm64", + dependencies=parse_dependency_field(dependencies), + provides=(), + ) + + +def test_control_parser_joins_continuation_lines(): + stanzas = parse_control_stanzas( + "Package: demo\nVersion: 1\nDepends: first,\n second | third\n\n" + ) + assert stanzas == [ + {"Package": "demo", "Version": "1", "Depends": "first, second | third"} + ] + + +def test_dependency_parser_filters_other_architectures_and_normalizes_qualifiers(): + groups = parse_dependency_field( + "base:any (>= 1), wrong [amd64], choice [arm64] | fallback, native:native" + ) + assert [[item.name for item in group] for group in groups] == [ + ["base"], + ["choice", "fallback"], + ["native"], + ] + + +def test_resolver_uses_installed_packages_and_recurses_only_for_missing_groups(): + local = {"app": package("app", "base, codec (= 2), optional | fallback")} + available = { + "codec": package("codec", "runtime", version="2"), + "runtime": package("runtime"), + "fallback": package("fallback"), + } + selected, unresolved = resolve_closure( + ["app"], {"base": "1", "optional": "5"}, local, available + ) + assert unresolved == [] + assert set(selected) == {"app", "codec", "runtime"} + + +def test_resolver_reports_an_unavailable_dependency(): + selected, unresolved = resolve_closure( + ["app"], {}, {"app": package("app", "missing")}, {} + ) + assert set(selected) == {"app"} + assert unresolved == ["app cannot satisfy: missing"] diff --git a/核桃派软件源代码/tests/test_dedicated_host.py b/核桃派软件源代码/tests/test_dedicated_host.py new file mode 100644 index 0000000..c177938 --- /dev/null +++ b/核桃派软件源代码/tests/test_dedicated_host.py @@ -0,0 +1,121 @@ +from pathlib import Path + +from scripts.dedicated_host import CommandResult, HostPaths, check_profile + + +def make_paths(tmp_path: Path) -> HostPaths: + compatible = ( + tmp_path + / "proc" + / "device-tree" + / "soc" + / "pinctrl@300b000" + / "compatible" + ) + compatible.parent.mkdir(parents=True) + compatible.write_bytes(b"allwinner,sun50i-h616-pinctrl\0") + dev_mem = tmp_path / "dev" / "mem" + dev_mem.parent.mkdir(parents=True) + dev_mem.touch() + i2c = tmp_path / "dev" / "i2c-1" + i2c.touch() + cpu3 = tmp_path / "sys" / "devices" / "system" / "cpu" / "cpu3" + cpu3.mkdir(parents=True) + native = tmp_path / "source" / "app" / "display" / "native" / "libh618_hub75.so" + native.parent.mkdir(parents=True) + native.touch() + return HostPaths(compatible, dev_mem, i2c, cpu3, native) + + +def healthy_runner(args): + if tuple(args[:2]) == ("systemctl", "is-active") and args[2] != "NetworkManager.service": + return CommandResult(3, "inactive\n") + responses = { + ("uname", "-m"): CommandResult(0, "aarch64\n"), + ("ip", "route", "show", "default"): CommandResult( + 0, "default via 192.168.198.1 dev wlan0\n" + ), + ("systemctl", "is-active", "NetworkManager.service"): CommandResult( + 0, "active\n" + ), + } + return responses[tuple(args)] + + +def test_check_profile_accepts_walnutpi_contract(tmp_path): + checks = check_profile(make_paths(tmp_path), runner=healthy_runner) + assert all(check.ok for check in checks), [ + (check.name, check.detail) for check in checks if not check.ok + ] + + +def test_service_preflight_does_not_require_default_route(tmp_path): + def runner(args): + assert tuple(args) != ("ip", "route", "show", "default") + return healthy_runner(args) + + checks = check_profile(make_paths(tmp_path), service_only=True, runner=runner) + assert all(check.ok for check in checks) + assert all(check.name != "default network route" for check in checks) + + +def test_manual_profile_still_requires_default_route(tmp_path): + def runner(args): + if tuple(args) == ("ip", "route", "show", "default"): + return CommandResult(0, "") + return healthy_runner(args) + + checks = check_profile(make_paths(tmp_path), runner=runner) + assert any( + check.name == "default network route" and not check.ok for check in checks + ) + + +def test_check_profile_rejects_wrong_pinctrl(tmp_path): + paths = make_paths(tmp_path) + paths.pinctrl_compatible.write_bytes(b"vendor,other-pinctrl\0") + checks = check_profile(paths, runner=healthy_runner) + assert any(check.name == "H618 PI pinctrl" and not check.ok for check in checks) + + +def test_check_profile_reports_missing_reserved_i2c_bus(tmp_path): + paths = make_paths(tmp_path) + paths.i2c_device.unlink() + checks = check_profile(paths, service_only=True, runner=healthy_runner) + assert any(check.name == "/dev/i2c-1 present" and not check.ok for check in checks) + + +def test_check_profile_rejects_running_vendor_gpio_service(tmp_path): + def runner(args): + if tuple(args) == ("systemctl", "is-active", "gpioc-server.service"): + return CommandResult(0, "active\n") + return healthy_runner(args) + + checks = check_profile(make_paths(tmp_path), runner=runner) + assert any( + check.name == "gpioc-server.service inactive" and not check.ok + for check in checks + ) + + +def test_check_profile_requires_compiled_native_library(tmp_path): + paths = make_paths(tmp_path) + paths.native_library.unlink() + checks = check_profile(paths, runner=healthy_runner) + assert any( + check.name == "native HUB75 library" and not check.ok for check in checks + ) + + +def test_detect_uses_walnutpi_device_tree_and_source_root(tmp_path): + source = tmp_path / "payload" + paths = HostPaths.detect(tmp_path, source) + assert paths.pinctrl_compatible == ( + tmp_path + / "proc" + / "device-tree" + / "soc" + / "pinctrl@300b000" + / "compatible" + ) + assert paths.native_library == source / "app/display/native/libh618_hub75.so" diff --git a/核桃派软件源代码/tests/test_device_storage.py b/核桃派软件源代码/tests/test_device_storage.py new file mode 100644 index 0000000..6a9ced9 --- /dev/null +++ b/核桃派软件源代码/tests/test_device_storage.py @@ -0,0 +1,63 @@ +from types import SimpleNamespace + +from app.monitoring.storage import DeviceStorageMonitor, directory_bytes + + +def test_directory_bytes_deduplicates_nested_roots_and_counts_template_content(tmp_path): + program = tmp_path / "program" + data = program / "data" + templates = data / "templates" + animations = data / "animations" + templates.mkdir(parents=True) + animations.mkdir() + (program / "app.py").write_bytes(b"app") + (data / "config.json").write_bytes(b"config") + (templates / "one.json").write_bytes(b"template") + (animations / "frame.json").write_bytes(b"animation") + + assert directory_bytes([program, data]) == 3 + 6 + 8 + 9 + assert directory_bytes([templates, animations]) == 8 + 9 + + +def test_device_storage_monitor_caches_expires_and_invalidates(tmp_path): + program = tmp_path / "program" + data = tmp_path / "data" + program.mkdir() + data.mkdir() + now = [10.0] + calls = [] + + def directory_reader(paths): + resolved = tuple(str(path) for path in paths) + calls.append(resolved) + return 100 if len(calls) % 2 else 25 + + monitor = DeviceStorageMonitor( + program, + data, + cache_seconds=300, + clock=lambda: now[0], + usage_reader=lambda _path: SimpleNamespace(total=1000, used=400, free=600), + directory_reader=directory_reader, + ) + + first = monitor.get_status() + second = monitor.get_status() + assert first == second + assert first["device_total_bytes"] == 1000 + assert first["device_free_bytes"] == 600 + assert first["software_bytes"] == 100 + assert first["templates_bytes"] == 25 + assert len(calls) == 2 + + now[0] += 299 + assert monitor.get_status() == first + assert len(calls) == 2 + + now[0] += 1 + monitor.get_status() + assert len(calls) == 4 + + monitor.invalidate() + monitor.get_status() + assert len(calls) == 6 diff --git a/核桃派软件源代码/tests/test_device_ui.mjs b/核桃派软件源代码/tests/test_device_ui.mjs new file mode 100644 index 0000000..e7df7a0 --- /dev/null +++ b/核桃派软件源代码/tests/test_device_ui.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const root = new URL("../app/static/", import.meta.url); + +test("device workspace exposes the compact immediate pure-color test flow", async () => { + const [html, device, board, picker, templates, style] = await Promise.all([ + readFile(new URL("index.html", root), "utf8"), + readFile(new URL("views/device.js", root), "utf8"), + readFile(new URL("scene-board.js", root), "utf8"), + readFile(new URL("color-picker.js", root), "utf8"), + readFile(new URL("views/templates.js", root), "utf8"), + readFile(new URL("style.css", root), "utf8"), + ]); + const deviceMarkup = html.slice( + html.indexOf('id="workspace-device"'), + html.indexOf('id="workspace-canvas"'), + ); + const templateMarkup = html.slice( + html.indexOf('id="workspace-templates"'), + html.indexOf('id="workspace-settings"'), + ); + + assert.doesNotMatch(deviceMarkup, /

    设备<\/p>/); + assert.doesNotMatch(deviceMarkup, /id="device-title"/); + assert.doesNotMatch(deviceMarkup, /data-test-diagnostic|apply-test|clear-screen/); + assert.doesNotMatch(deviceMarkup, /全白只用于/); + assert.equal((deviceMarkup.match(/data-test-fill=/g) || []).length, 5); + assert.match(deviceMarkup, /id="custom-test-fill"[^>]*>自定义纯色/); + assert.match(deviceMarkup, /id="display-test-controls"[^>]*hidden/); + assert.match(deviceMarkup, /id="test-brightness"[^>]*value="50"/); + assert.match(deviceMarkup, /id="exit-display-test"/); + assert.match(deviceMarkup, /设备总空间[\s\S]*设备可用空间[\s\S]*当前软件总占用空间[\s\S]*模板占用空间/); + assert.doesNotMatch(templateMarkup, /template-storage|template-total-size|template-free-space/); + + assert.match(device, /\/api\/display\/test\/fill/); + assert.match(device, /\/api\/display\/test\/brightness/); + assert.match(device, /method: "DELETE"/); + assert.match(device, /STORAGE_REFRESH_INTERVAL_MS = 300000/); + assert.match(board, /activeWorkspace === "device"/); + assert.match(picker, /pickerReadonly/); + assert.match(picker, /color-picker-readonly/); + assert.doesNotMatch(templates, /template-total-size|template-free-space|template-total-space/); + + assert.ok(html.indexOf('id="topbar-current-content"') < html.indexOf('id="topbar-cpu"')); + assert.ok(templateMarkup.indexOf('id="select-default-content"') < templateMarkup.indexOf('id="refresh-templates"')); + assert.match(templateMarkup, /id="select-default-content"[^>]*aria-pressed="false"/); + assert.match(device, /copy\.dynamic\.device\.current_content/); + assert.match(device, /当前画面:\{name\}/); + assert.match(templates, /\/api\/display\/default-content/); + assert.match(templates, /selectingDefault/); + assert.match(templates, /空动图不能设为默认/); + assert.match(board, /content_source: sceneSource/); + assert.match(style, /\.template-card\.template-default-content/); + assert.match(style, /\.current-content-indicator/); +}); diff --git a/核桃派软件源代码/tests/test_display_service.py b/核桃派软件源代码/tests/test_display_service.py new file mode 100644 index 0000000..87566e4 --- /dev/null +++ b/核桃派软件源代码/tests/test_display_service.py @@ -0,0 +1,1302 @@ +import json +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from PIL import Image + +import app.display.text_renderer as text_renderer +import app.display.matrix_driver as matrix_driver_module +from app.config.store import ConfigStore +from app.display.diagnostics import render_diagnostic +from app.display.low_voltage_indicator import ( + BACKGROUND as LOW_VOLTAGE_BACKGROUND, + RED as LOW_VOLTAGE_RED, + render_low_voltage_indicator, +) +from app.display.matrix_driver import ( + MatrixDisplayDriver, + get_default_driver_options, + get_matrix_driver_options, +) +from app.display.service import AnimationPlaybackConflictError, DisplayService +from app.display.ota_indicator import ( + OTA_CANVAS_SIZE, + OTA_CONTENT_OFFSET, + OTA_CONTENT_SIZE, + render_ota_indicator, +) +from app.display.startup_indicator import BACKGROUND, PINK, render_startup_smile +from app.display.text_renderer import FontFace, TextOptions, render_text, render_text_layer +from app.display.transforms import apply_orientation, apply_row_bit_order, get_row_bit_order +from app.power.protection import LowVoltageProtectionDirective + + +def protection_directive(sequence, mode, brightness_limit=None, *, enabled=True): + return LowVoltageProtectionDirective( + sequence=sequence, + enabled=enabled, + mode=mode, + brightness_limit_percent=brightness_limit, + reading_stale=False, + ) + + +def test_ota_indicator_uses_40_percent_and_restores_user_output(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"brightness": 27}) + service = DisplayService(store, driver_kind="mock") + status_path = store.runtime_dir / "ota-status.json" + status_path.write_text( + json.dumps({"active": True, "job": {"percent": 42, "message": "正在测试"}}), + encoding="utf-8", + ) + try: + service.fill((1, 2, 3)) + user_frame = service.get_current_frame() + service.start_ota_indicator(status_path) + status = service.get_status() + assert status["state"]["mode"] == "ota_update_indicator" + assert status["state"]["effective_brightness"] == 40 + assert status["state"]["ota_update"]["active"] is True + assert status["state"]["ota_update"]["percent"] == 42 + service.stop_ota_indicator() + restored = service.get_status() + assert restored["state"]["mode"] == "fill" + assert restored["state"]["effective_brightness"] == 27 + assert service.get_current_frame().tobytes() == user_frame.tobytes() + finally: + service.close() + + +def test_ota_indicator_scales_entire_frame_to_centered_90_percent_area(): + frame = render_ota_indicator(42, "正在测试") + + assert frame.size == (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE) + assert OTA_CONTENT_SIZE == 58 + assert OTA_CONTENT_OFFSET == 3 + assert OTA_CONTENT_OFFSET * 2 + OTA_CONTENT_SIZE == OTA_CANVAS_SIZE + assert frame.crop((0, 0, OTA_CANVAS_SIZE, OTA_CONTENT_OFFSET)).getbbox() is None + assert frame.crop((0, OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET, OTA_CANVAS_SIZE, OTA_CANVAS_SIZE)).getbbox() is None + assert frame.crop((0, 0, OTA_CONTENT_OFFSET, OTA_CANVAS_SIZE)).getbbox() is None + assert frame.crop((OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET, 0, OTA_CANVAS_SIZE, OTA_CANVAS_SIZE)).getbbox() is None + + +def test_ota_indicator_keeps_labels_complete_and_inside_the_scaled_content(): + frame = render_ota_indicator(42, "正在测试") + + label = frame.crop((20, 34, 45, 45)) + assert label.getbbox() is not None + assert all(channel in {0, 245, 250} for channel in label.tobytes()) + # OTA uses three separated, four-way-connected glyphs after the shrink. + lit = { + (x, y) + for y in range(label.height) + for x in range(label.width) + if label.getpixel((x, y)) != (0, 0, 0) + } + components = 0 + while lit: + components += 1 + pending = [lit.pop()] + while pending: + x, y = pending.pop() + for neighbour in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if neighbour in lit: + lit.remove(neighbour) + pending.append(neighbour) + assert components == 3 + + percent = frame.crop((20, 53, 45, OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET)) + assert percent.getbbox() is not None + assert frame.crop((0, OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET, OTA_CANVAS_SIZE, OTA_CANVAS_SIZE)).getbbox() is None + + +def test_critical_low_voltage_remains_above_ota_indicator(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + status_path = store.runtime_dir / "ota-status.json" + status_path.write_text(json.dumps({"active": True, "job": {"percent": 10}}), encoding="utf-8") + try: + service.start_ota_indicator(status_path) + service.apply_power_protection(protection_directive(1, "critical", 35)) + status = service.get_status() + assert status["state"]["mode"] == "low_voltage_indicator" + assert status["state"]["effective_brightness"] == 35 + assert status["state"]["ota_update"]["active"] is True + finally: + service.close() + + +def test_display_service_writes_mock_frame(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + service.fill((255, 0, 0)) + + assert store.config["last_mode"] == "fill" + assert (tmp_path / "data" / "runtime" / "last_frame.png").exists() + + +def test_current_frame_snapshot_is_logical_and_side_effect_free(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + image = Image.new("RGB", (64, 64), (0, 0, 0)) + image.putpixel((2, 7), (12, 34, 56)) + service.show_image(image, mode="composition") + service.set_orientation(90) + before = service.get_status()["state"].copy() + + snapshot = service.get_current_frame() + snapshot.putpixel((2, 7), (255, 255, 255)) + + assert snapshot.mode == "RGB" + assert snapshot.size == (64, 64) + assert service.get_current_frame().getpixel((2, 7)) == (12, 34, 56) + assert service.get_status()["state"] == before + + +def test_current_frame_snapshot_uses_active_startup_frame(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock", startup_boot_id="snapshot-boot") + + try: + assert service.start_startup_indicator() is True + before = service.get_status()["state"].copy() + snapshot = service.get_current_frame() + + assert snapshot.tobytes() == render_startup_smile().tobytes() + assert service.get_status()["state"] == before + finally: + service.close() + + +def test_display_test_uses_temporary_brightness_and_restores_user_output(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"brightness": 37}) + service = DisplayService(store, driver_kind="mock") + user_frame = Image.new("RGB", (64, 64), (12, 34, 56)) + service.show_image(user_frame, mode="composition") + revision = service.get_status()["state"]["revision"] + + started = service.start_display_test_fill((255, 0, 0)) + state = service.get_status()["state"] + assert started == {"active": True, "color": "#FF0000", "brightness": 50} + assert state["mode"] == "display_test:fill" + assert state["effective_brightness"] == 50 + assert state["revision"] == revision + 1 + assert service.get_current_frame().getpixel((0, 0)) == (255, 0, 0) + assert store.config["brightness"] == 37 + assert store.config["last_mode"] == "composition" + + service.set_display_test_brightness(72) + switched = service.start_display_test_fill((0, 0, 255)) + assert switched["brightness"] == 72 + assert service.get_status()["state"]["effective_brightness"] == 72 + assert store.config["brightness"] == 37 + + service.set_brightness(29) + service.set_orientation(90) + assert service.get_status()["state"]["mode"] == "display_test:fill" + assert service.stop_display_test() == { + "active": False, + "color": None, + "brightness": None, + } + restored = service.get_status()["state"] + assert restored["mode"] == "composition" + assert restored["brightness"] == 29 + assert restored["effective_brightness"] == 29 + assert restored["orientation"] == 90 + assert service.get_current_frame().tobytes() == user_frame.tobytes() + + +def test_display_test_obeys_low_voltage_and_normal_content_cancels_it(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + service.start_display_test_fill((255, 0, 0)) + service.set_display_test_brightness(80) + + service.apply_power_protection(protection_directive(1, "limiting", 60)) + assert service.get_status()["state"]["effective_brightness"] == 60 + assert service.get_current_frame().getpixel((0, 0)) == (255, 0, 0) + + service.apply_power_protection(protection_directive(2, "critical", 35)) + state = service.get_status()["state"] + assert state["mode"] == "low_voltage_indicator" + assert state["effective_brightness"] == 35 + assert state["display_test"] == { + "active": True, + "color": "#FF0000", + "brightness": 80, + } + + service.apply_power_protection(protection_directive(3, "inactive")) + assert service.get_status()["state"]["mode"] == "display_test:fill" + service.fill((0, 255, 0)) + state = service.get_status()["state"] + assert state["display_test"]["active"] is False + assert state["mode"] == "fill" + assert service.get_current_frame().getpixel((0, 0)) == (0, 255, 0) + + +def test_display_test_brightness_requires_an_active_session(tmp_path): + service = DisplayService(ConfigStore(project_root=tmp_path), driver_kind="mock") + + with pytest.raises(RuntimeError, match="not active"): + service.set_display_test_brightness(50) + for value in (0, 101, True, 1.5): + with pytest.raises(ValueError, match="1..100"): + service.set_display_test_brightness(value) + + +def test_startup_smile_frames_have_fixed_pixel_art_geometry(): + open_frame = render_startup_smile() + closed_frame = render_startup_smile(eyes_closed=True) + + assert open_frame.mode == "RGB" + assert open_frame.size == (64, 64) + assert open_frame.getpixel((0, 0)) == BACKGROUND + assert open_frame.getpixel((16, 18)) == PINK + assert open_frame.getpixel((40, 18)) == PINK + assert open_frame.getpixel((30, 50)) == PINK + assert closed_frame.getpixel((14, 23)) == PINK + assert closed_frame.getpixel((38, 23)) == PINK + assert closed_frame.getpixel((16, 18)) == BACKGROUND + assert open_frame.crop((0, 32, 64, 64)).tobytes() == closed_frame.crop((0, 32, 64, 64)).tobytes() + + +def test_startup_indicator_uses_fixed_brightness_and_saved_orientation(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": 90, "brightness": 27, "last_mode": "composition"}) + service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") + + try: + assert service.start_startup_indicator() is True + status = service.get_status() + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + + assert status["state"] == { + "mode": "startup_indicator", + "orientation": 90, + "brightness": 27, + "effective_brightness": 50, + "startup_indicator_active": True, + "wifi_indicator_active": False, + "wifi_indicator_connected": None, + "animation_active": False, + "animation_id": None, + "animation_revision": None, + "animation_frame_id": None, + "animation_frame_index": None, + "animation_playback": { + "active": False, + "session_id": None, + "animation_id": None, + "animation_revision": None, + "frame_id": None, + "frame_index": None, + "frame_count": None, + "position_ms": None, + "total_duration_ms": None, + "paused": None, + "speed": None, + "supported_speeds": [0.5, 1.0, 1.5, 2.0], + }, + "current_content": {"category": "system", "id": None, "name": "开机提示"}, + "display_test": {"active": False, "color": None, "brightness": None}, + "ota_update": {"active": False, "percent": None, "stage": None}, + "revision": 0, + } + assert status["screen"]["driver_options"]["brightness"] == 50 + assert saved.getpixel((45, 16)) == PINK + assert store.config["last_mode"] == "composition" + assert store.config["brightness"] == 27 + finally: + service.close() + + +def test_startup_indicator_blinks_until_dismissed(tmp_path, monkeypatch): + import app.display.service as service_module + + monkeypatch.setattr(service_module, "OPEN_EYE_SECONDS", 0.01) + monkeypatch.setattr(service_module, "CLOSED_EYE_SECONDS", 0.01) + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") + submitted = [] + original_set_image = service.driver.set_image + + def record_image(image): + submitted.append(image.copy()) + original_set_image(image) + + monkeypatch.setattr(service.driver, "set_image", record_image) + try: + service.start_startup_indicator() + deadline = time.monotonic() + 0.5 + while len(submitted) < 3 and time.monotonic() < deadline: + time.sleep(0.005) + + assert len(submitted) >= 3 + assert submitted[0].getpixel((16, 18)) == PINK + # Relaxing from the saved user brightness submits the smile once at + # the old dim setting and once again after raising to 50%. + assert submitted[1].getpixel((16, 18)) == PINK + assert submitted[2].getpixel((16, 18)) == BACKGROUND + finally: + service.close() + + +def test_successful_display_command_dismisses_for_current_boot_only(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"brightness": 31}) + first = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") + same_boot = None + next_boot = None + + try: + first.start_startup_indicator() + first.fill((12, 34, 56)) + status = first.get_status() + marker = json.loads( + (tmp_path / "data" / "runtime" / "startup_indicator.json").read_text( + encoding="utf-8" + ) + ) + + assert status["state"]["startup_indicator_active"] is False + assert status["state"]["effective_brightness"] == 31 + assert first.driver.brightness == 31 + assert marker == {"dismissed_boot_id": "boot-a"} + + same_boot = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") + assert same_boot.start_startup_indicator() is False + + next_boot = DisplayService(store, driver_kind="mock", startup_boot_id="boot-b") + assert next_boot.start_startup_indicator() is True + finally: + first.close() + if same_boot is not None: + same_boot.close() + if next_boot is not None: + next_boot.close() + + +@pytest.mark.parametrize("mutation", ["orientation", "brightness"]) +def test_same_value_screen_setting_still_dismisses_startup_indicator(tmp_path, mutation): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock", startup_boot_id=f"boot-{mutation}") + + try: + service.start_startup_indicator() + if mutation == "orientation": + service.set_orientation(store.config["orientation"]) + else: + service.set_brightness(store.config["brightness"]) + + status = service.get_status() + assert status["state"]["startup_indicator_active"] is False + assert status["state"]["effective_brightness"] == store.config["brightness"] + assert status["state"]["revision"] == 1 + finally: + service.close() + + +def test_invalid_or_failed_display_command_keeps_startup_indicator(tmp_path, monkeypatch): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") + + try: + service.start_startup_indicator() + with pytest.raises(ValueError, match="frame size"): + service.show_rgb_bytes(b"bad") + assert service.get_status()["state"]["startup_indicator_active"] is True + + monkeypatch.setattr(store, "set_last_mode", lambda _mode: (_ for _ in ()).throw(OSError("save failed"))) + with pytest.raises(OSError, match="save failed"): + service.fill((12, 34, 56)) + + status = service.get_status() + assert status["state"]["startup_indicator_active"] is True + assert status["state"]["effective_brightness"] == 50 + assert service.driver.brightness == 50 + assert not (tmp_path / "data" / "runtime" / "startup_indicator.json").exists() + finally: + service.close() + + +def test_corrupt_startup_session_state_fails_open(tmp_path): + store = ConfigStore(project_root=tmp_path) + marker = tmp_path / "data" / "runtime" / "startup_indicator.json" + marker.write_text("not-json", encoding="utf-8") + service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") + + try: + assert service.start_startup_indicator() is True + finally: + service.close() + + +def test_runtime_brightness_updates_driver_options(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + service.set_brightness(35) + + status = service.get_status() + assert status["state"]["brightness"] == 35 + assert status["screen"]["driver_options"]["brightness"] == 35 + + +def test_refresh_rate_limit_updates_in_place_and_keeps_current_frame(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + image = Image.new("RGB", (64, 64), (12, 34, 56)) + service.show_image(image, mode="composition") + previous_driver = service.driver + + service.set_refresh_rate_limit(60) + + assert service.driver is previous_driver + assert store.config["matrix_refresh_rate_limit_hz"] == 60 + assert service.get_status()["screen"]["driver_options"][ + "limit_refresh_rate_hz" + ] == 60 + restored = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert restored.getpixel((10, 10)) == (12, 34, 56) + + +def test_refresh_rate_limit_keeps_animation_position_and_playback(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + service.start_animation("animation-a", "revision-a", [ + { + "id": "a", + "image": Image.new("RGB", (64, 64), "red"), + "duration_ms": 60000, + }, + { + "id": "b", + "image": Image.new("RGB", (64, 64), "green"), + "duration_ms": 60000, + }, + ]) + try: + before = service.get_status()["state"] + service.set_refresh_rate_limit(80) + after = service.get_status()["state"] + + assert after["animation_active"] is True + assert after["animation_id"] == "animation-a" + assert after["animation_frame_index"] == before["animation_frame_index"] + assert after["revision"] == before["revision"] + 1 + finally: + service.close() + + +def test_animation_playback_pause_seek_speed_loop_and_session_reset(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + frames = [ + {"id": "red", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": 500}, + {"id": "green", "image": Image.new("RGB", (64, 64), "green"), "duration_ms": 500}, + ] + try: + service.start_animation("animation-a", "revision-a", frames) + initial = service.get_status()["state"]["animation_playback"] + assert initial["active"] is True + assert initial["position_ms"] < 100 + assert initial["paused"] is False + assert initial["speed"] == 1.0 + assert initial["supported_speeds"] == [0.5, 1.0, 1.5, 2.0] + first_session = initial["session_id"] + + paused = service.control_animation_playback( + first_session, + position_ms=600, + paused=True, + ) + assert paused["position_ms"] == 600 + assert paused["frame_id"] == "green" + assert service.get_current_frame().getpixel((0, 0)) == (0, 128, 0) + time.sleep(0.08) + assert service.get_status()["state"]["animation_playback"]["position_ms"] == 600 + + for speed in (0.5, 1.0, 1.5, 2.0): + changed = service.control_animation_playback(first_session, speed=speed) + assert changed["speed"] == speed + assert changed["paused"] is True + + service.control_animation_playback(first_session, paused=False, speed=2.0) + time.sleep(0.12) + advanced = service.get_status()["state"]["animation_playback"]["position_ms"] + assert 780 <= advanced <= 980 + + service.control_animation_playback(first_session, position_ms=999, paused=False, speed=1.0) + time.sleep(0.03) + wrapped = service.get_status()["state"]["animation_playback"]["position_ms"] + assert wrapped < 150 + + service.start_animation("animation-a", "revision-a", frames) + restarted = service.get_status()["state"]["animation_playback"] + assert restarted["session_id"] != first_session + assert restarted["position_ms"] < 100 + assert restarted["paused"] is False + assert restarted["speed"] == 1.0 + with pytest.raises(AnimationPlaybackConflictError): + service.control_animation_playback(first_session, paused=True) + + service.show_image(Image.new("RGB", (64, 64), "blue"), mode="image") + inactive = service.get_status()["state"]["animation_playback"] + assert inactive["active"] is False + assert inactive["session_id"] is None + assert inactive["supported_speeds"] == [0.5, 1.0, 1.5, 2.0] + finally: + service.close() + + +def test_refresh_rate_config_write_failure_rolls_back_driver_and_frame( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + image = Image.new("RGB", (64, 64), (9, 8, 7)) + service.show_image(image, mode="composition") + original_update = store.update + + def fail_refresh(values): + if "matrix_refresh_rate_limit_hz" in values: + raise OSError("simulated config write failure") + return original_update(values) + + monkeypatch.setattr(store, "update", fail_refresh) + + with pytest.raises(RuntimeError, match="saved setting was not changed"): + service.set_refresh_rate_limit(60) + + assert store.config["matrix_refresh_rate_limit_hz"] == 100 + assert service.get_status()["screen"]["driver_options"][ + "limit_refresh_rate_hz" + ] == 100 + restored = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert restored.getpixel((10, 10)) == (9, 8, 7) + + +def test_refresh_rate_driver_update_failure_restores_original_limit( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + original_set = service.driver.set_refresh_rate_limit + + def fail_requested(value): + if value == 60: + raise RuntimeError("simulated driver update failure") + return original_set(value) + + monkeypatch.setattr(service.driver, "set_refresh_rate_limit", fail_requested) + + with pytest.raises(RuntimeError, match="saved setting was not changed"): + service.set_refresh_rate_limit(60) + + assert store.config["matrix_refresh_rate_limit_hz"] == 100 + status = service.get_status()["screen"] + assert status["driver_available"] is True + assert status["driver_options"]["limit_refresh_rate_hz"] == 100 + + +def test_refresh_rate_double_failure_reports_rollback_error_without_mock_fallback( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + original_driver = service.driver + + def fail_all(_value): + raise RuntimeError("simulated native update and rollback failure") + + monkeypatch.setattr(service.driver, "set_refresh_rate_limit", fail_all) + + with pytest.raises(RuntimeError, match="rollback also failed"): + service.set_refresh_rate_limit(60) + + assert store.config["matrix_refresh_rate_limit_hz"] == 100 + status = service.get_status()["screen"] + assert service.driver is original_driver + assert status["driver_available"] is True + assert status["driver_error"] is None + assert status["driver_options"]["limit_refresh_rate_hz"] == 100 + + +def test_state_revision_changes_only_after_successful_mutations(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + assert service.get_status()["state"]["revision"] == 0 + service.set_orientation(90) + assert service.get_status()["state"]["revision"] == 1 + service.set_orientation(90) + assert service.get_status()["state"]["revision"] == 1 + service.set_brightness(35) + assert service.get_status()["state"]["revision"] == 2 + service.fill((1, 2, 3)) + assert service.get_status()["state"]["revision"] == 3 + + +def test_runtime_brightness_preserves_current_frame_and_mode(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + image = Image.new("RGB", (64, 64), (0, 0, 0)) + image.putpixel((3, 5), (255, 0, 0)) + service.show_image(image, mode="canvas") + frame_path = tmp_path / "data" / "runtime" / "last_frame.png" + before = frame_path.read_bytes() + + service.set_brightness(35) + + assert frame_path.read_bytes() == before + assert store.config["last_mode"] == "canvas" + + +def test_runtime_brightness_resubmits_current_transformed_frame(tmp_path, monkeypatch): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + image = Image.new("RGB", (64, 64), (0, 0, 0)) + image.putpixel((0, 0), (255, 0, 0)) + service.show_image(image, mode="canvas") + service.set_orientation(90) + submitted = [] + original_set_image = service.driver.set_image + + def record_image(frame): + submitted.append(frame.copy()) + original_set_image(frame) + + monkeypatch.setattr(service.driver, "set_image", record_image) + + service.set_brightness(35) + + assert len(submitted) == 1 + assert submitted[0].getpixel((63, 0)) == (255, 0, 0) + assert store.config["last_mode"] == "canvas" + assert service.get_status()["state"]["revision"] == 3 + + +def test_runtime_brightness_failure_restores_previous_brightness_and_frame(tmp_path, monkeypatch): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + service.fill((12, 34, 56)) + brightness_updates = [] + frame_brightnesses = [] + original_set_brightness = service.driver.set_brightness + original_set_image = service.driver.set_image + + def record_brightness(value): + brightness_updates.append(value) + original_set_brightness(value) + + def record_image(frame): + frame_brightnesses.append(service.driver.brightness) + original_set_image(frame) + + def fail_update(_update): + raise OSError("config write failed") + + monkeypatch.setattr(service.driver, "set_brightness", record_brightness) + monkeypatch.setattr(service.driver, "set_image", record_image) + monkeypatch.setattr(store, "update", fail_update) + + with pytest.raises(OSError, match="config write failed"): + service.set_brightness(35) + + assert brightness_updates == [35, 40] + assert frame_brightnesses == [35, 35, 40] + assert service.driver.brightness == 40 + assert store.config["brightness"] == 40 + assert store.config["last_mode"] == "fill" + assert service.get_status()["state"]["revision"] == 1 + + +def test_low_voltage_indicator_is_fixed_red_pixel_art_on_black(): + frame = render_low_voltage_indicator() + + assert frame.mode == "RGB" + assert frame.size == (64, 64) + assert {color for _count, color in frame.getcolors(maxcolors=4096)} == { + LOW_VOLTAGE_BACKGROUND, + LOW_VOLTAGE_RED, + } + assert frame.getpixel((0, 0)) == LOW_VOLTAGE_BACKGROUND + assert frame.getpixel((9, 17)) == LOW_VOLTAGE_RED + assert frame.getpixel((53, 31)) == LOW_VOLTAGE_RED + assert frame.getpixel((30, 31)) == LOW_VOLTAGE_BACKGROUND + assert frame.getpixel((18, 36)) == LOW_VOLTAGE_RED + + +def test_power_limit_caps_effective_output_without_mutating_user_state(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"brightness": 90, "last_mode": "composition"}) + service = DisplayService(store, driver_kind="mock") + frame = Image.new("RGB", (64, 64), (12, 34, 56)) + service.show_image(frame, mode="canvas") + revision = service.get_status()["state"]["revision"] + + service.apply_power_protection(protection_directive(5, "limiting", 75)) + + state = service.get_status()["state"] + assert state["mode"] == "canvas" + assert state["brightness"] == 90 + assert state["effective_brightness"] == 75 + assert state["revision"] == revision + assert store.config["brightness"] == 90 + assert service.get_current_frame().tobytes() == frame.tobytes() + + # An older, stricter sample is ignored rather than arriving late and + # replacing the already published monitor decision. + service.apply_power_protection(protection_directive(4, "critical", 35)) + assert service.get_status()["state"]["effective_brightness"] == 75 + assert service.get_current_frame().tobytes() == frame.tobytes() + + service.set_brightness(40) + service.apply_power_protection(protection_directive(6, "limiting", 50)) + state = service.get_status()["state"] + assert state["brightness"] == 40 + assert state["effective_brightness"] == 40 + assert store.config["brightness"] == 40 + + service.apply_power_protection(protection_directive(7, "inactive")) + assert service.get_status()["state"]["effective_brightness"] == 40 + + +def test_critical_overlay_preserves_and_restores_latest_user_state(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"brightness": 70, "last_mode": "composition"}) + service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-critical") + user_frame = Image.new("RGB", (64, 64), (0, 0, 0)) + user_frame.putpixel((0, 0), (12, 34, 56)) + + try: + assert service.start_startup_indicator() is True + service.apply_power_protection(protection_directive(1, "critical", 35)) + + state = service.get_status()["state"] + assert state["mode"] == "low_voltage_indicator" + assert state["brightness"] == 70 + assert state["effective_brightness"] == 35 + assert state["startup_indicator_active"] is True + assert state["revision"] == 0 + assert store.config["last_mode"] == "composition" + assert service.get_current_frame().tobytes() == render_low_voltage_indicator().tobytes() + + # User mutations remain durable underneath the overlay and a + # successful display command still dismisses the boot smile. + service.show_image(user_frame, mode="canvas") + service.set_brightness(80) + service.set_orientation(90) + + state = service.get_status()["state"] + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png").convert("RGB") + expected_indicator = apply_orientation(render_low_voltage_indicator(), 90) + assert state["mode"] == "low_voltage_indicator" + assert state["brightness"] == 80 + assert state["effective_brightness"] == 35 + assert state["orientation"] == 90 + assert state["startup_indicator_active"] is False + assert state["revision"] == 3 + assert store.config["last_mode"] == "canvas" + assert saved.tobytes() == expected_indicator.tobytes() + + service.apply_power_protection(protection_directive(2, "inactive")) + + state = service.get_status()["state"] + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png").convert("RGB") + assert state["mode"] == "canvas" + assert state["brightness"] == 80 + assert state["effective_brightness"] == 80 + assert state["revision"] == 3 + assert service.get_current_frame().tobytes() == user_frame.tobytes() + assert saved.tobytes() == apply_orientation(user_frame, 90).tobytes() + finally: + service.close() + + +def test_startup_indicator_resumes_below_limit_after_critical_recovers(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-resume") + + try: + service.start_startup_indicator() + service.apply_power_protection(protection_directive(1, "critical", 35)) + service.apply_power_protection(protection_directive(2, "limiting", 30)) + + state = service.get_status()["state"] + assert state["mode"] == "startup_indicator" + assert state["effective_brightness"] == 30 + assert state["startup_indicator_active"] is True + assert service.get_current_frame().tobytes() == render_startup_smile().tobytes() + finally: + service.close() + + +@pytest.mark.parametrize("orientation", [0, 90, 180, 270]) +def test_critical_indicator_inherits_all_orientations(tmp_path, orientation): + store = ConfigStore(project_root=tmp_path) + store.update({"orientation": orientation}) + service = DisplayService(store, driver_kind="mock") + + service.apply_power_protection(protection_directive(1, "critical", 35)) + + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png").convert("RGB") + expected = apply_row_bit_order( + apply_orientation(render_low_voltage_indicator(), orientation), + get_row_bit_order(), + ) + assert saved.tobytes() == expected.tobytes() + + +def test_protection_transitions_use_safe_driver_call_order(tmp_path, monkeypatch): + store = ConfigStore(project_root=tmp_path) + store.update({"brightness": 80}) + service = DisplayService(store, driver_kind="mock") + events = [] + original_set_brightness = service.driver.set_brightness + original_set_image = service.driver.set_image + + def record_brightness(value): + events.append(("brightness", value)) + original_set_brightness(value) + + def record_image(frame): + events.append(("image", service.driver.brightness)) + original_set_image(frame) + + monkeypatch.setattr(service.driver, "set_brightness", record_brightness) + monkeypatch.setattr(service.driver, "set_image", record_image) + + service.apply_power_protection(protection_directive(1, "limiting", 60)) + assert events == [("brightness", 60), ("image", 60)] + + events.clear() + service.apply_power_protection(protection_directive(2, "inactive")) + assert events == [("image", 60), ("brightness", 80), ("image", 80)] + + +def test_failed_tightening_keeps_lower_brightness_and_equal_sequence_retries( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + service.fill((12, 34, 56)) + user_frame = service.get_current_frame() + original_set_image = service.driver.set_image + attempts = 0 + + def fail_first_image(frame): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise OSError("frame submit failed") + original_set_image(frame) + + monkeypatch.setattr(service.driver, "set_image", fail_first_image) + directive = protection_directive(9, "critical", 35) + + with pytest.raises(OSError, match="frame submit failed"): + service.apply_power_protection(directive) + + state = service.get_status()["state"] + assert state["mode"] == "low_voltage_indicator" + assert state["effective_brightness"] == 35 + assert state["revision"] == 1 + assert service.driver.brightness == 35 + assert service.get_current_frame().tobytes() == user_frame.tobytes() + + service.apply_power_protection(directive) + assert service.get_current_frame().tobytes() == render_low_voltage_indicator().tobytes() + assert attempts == 2 + + +def test_failed_relaxation_keeps_old_brightness_and_equal_sequence_retries( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + user_frame = Image.new("RGB", (64, 64), (12, 34, 56)) + service.show_image(user_frame, mode="canvas") + service.apply_power_protection(protection_directive(1, "critical", 35)) + original_set_brightness = service.driver.set_brightness + failed = False + + def fail_first_restore(value): + nonlocal failed + if value == 40 and not failed: + failed = True + raise RuntimeError("brightness restore failed") + original_set_brightness(value) + + monkeypatch.setattr(service.driver, "set_brightness", fail_first_restore) + directive = protection_directive(2, "inactive") + + with pytest.raises(RuntimeError, match="brightness restore failed"): + service.apply_power_protection(directive) + + assert service.get_status()["state"]["effective_brightness"] == 35 + assert service.driver.brightness == 35 + assert service.get_current_frame().tobytes() == user_frame.tobytes() + + service.apply_power_protection(directive) + assert service.get_status()["state"]["effective_brightness"] == 40 + assert service.driver.brightness == 40 + assert service.get_current_frame().tobytes() == user_frame.tobytes() + + +def test_matrix_driver_keeps_brightness_when_native_update_fails(): + driver = MatrixDisplayDriver.__new__(MatrixDisplayDriver) + driver._context = 1 + driver._library = SimpleNamespace(hub75_set_brightness=lambda _context, _value: -1) + driver._stats_error = lambda: "native brightness not supported" + driver.brightness = 40 + driver.driver_options = get_default_driver_options(40) + + with pytest.raises(RuntimeError, match="not supported"): + driver.set_brightness(25) + + assert driver.brightness == 40 + assert driver.driver_options.brightness == 40 + + +def test_display_service_rejects_wrong_frame_size(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + with pytest.raises(ValueError, match="frame size"): + service.show_rgb_bytes(b"bad") + + +def test_orientation_90_rotates_clockwise(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + service.set_orientation(90) + + image = Image.new("RGB", (64, 64), (0, 0, 0)) + image.putpixel((0, 0), (255, 0, 0)) + service.show_image(image) + + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert saved.getpixel((63, 0)) == (255, 0, 0) + + +def test_orientation_change_reapplies_current_frame_without_changing_mode(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + image = Image.new("RGB", (64, 64), (0, 0, 0)) + image.putpixel((0, 0), (255, 0, 0)) + image.putpixel((63, 0), (0, 255, 0)) + service.show_image(image, mode="text") + + service.set_orientation(90) + + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert saved.getpixel((63, 0)) == (255, 0, 0) + assert saved.getpixel((63, 63)) == (0, 255, 0) + assert store.config["last_mode"] == "text" + assert store.config["orientation"] == 90 + + +def test_row_bit_order_remaps_low_five_row_bits(monkeypatch): + monkeypatch.setenv("MATRIX_ROW_BIT_ORDER", "4,3,2,1,0") + image = Image.new("RGB", (64, 64), (0, 0, 0)) + image.putpixel((0, 16), (255, 0, 0)) + + remapped = apply_row_bit_order(image, get_row_bit_order()) + + assert remapped.getpixel((0, 1)) == (255, 0, 0) + assert remapped.getpixel((0, 16)) == (0, 0, 0) + + +def test_text_renderer_path(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + service.show_text(TextOptions(text="OK", size=12, x=4, y=24)) + + assert store.config["last_mode"] == "text" + + +def test_text_layer_is_transparent_and_legacy_text_keeps_background(): + options = TextOptions( + text="OK", + size=24, + x=4, + y=4, + color=(10, 20, 30), + background=(40, 50, 60), + ) + + layer = render_text_layer(options) + legacy = render_text(options) + + assert layer.mode == "RGBA" + assert layer.size == (64, 64) + assert layer.getpixel((63, 63)) == (0, 0, 0, 0) + assert layer.getchannel("A").getbbox() is not None + assert layer.getchannel("R").getextrema()[1] == 10 + assert layer.getchannel("G").getextrema()[1] == 20 + assert layer.getchannel("B").getextrema()[1] == 30 + assert layer.getchannel("A").getextrema()[1] == 255 + assert legacy.mode == "RGB" + assert legacy.getpixel((63, 63)) == (40, 50, 60) + expected = Image.alpha_composite( + Image.new("RGBA", (64, 64), (40, 50, 60, 255)), + layer, + ).convert("RGB") + assert legacy.tobytes() == expected.tobytes() + + +def test_multilingual_font_resolution_covers_representative_languages(monkeypatch): + samples = ( + "你好", "日本", "한글", "Привет", "Ελλάδα", "مرحبا", "שלום", + "नमस्ते", "สวัสดี", + ) + face = FontFace("multilingual-test.ttf") + coverage = frozenset(ord(character) for sample in samples for character in sample) + monkeypatch.setattr(text_renderer, "fallback_font_faces", lambda: (face,)) + monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: None) + monkeypatch.setattr(text_renderer, "_font_coverage", lambda _face: coverage) + + for sample in samples: + assert text_renderer.resolve_font_face("default", sample) == face + + +def test_custom_font_missing_glyphs_falls_back_for_the_complete_text(monkeypatch): + custom = FontFace("latin-only.ttf") + fallback = FontFace("cjk-with-latin.ttf") + coverage = { + custom: frozenset(map(ord, "temperature 25C")), + fallback: frozenset(map(ord, "温度 25°C")), + } + monkeypatch.setattr(text_renderer, "_custom_font_faces", lambda _font: (custom,)) + monkeypatch.setattr(text_renderer, "fallback_font_faces", lambda: (fallback,)) + monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: None) + monkeypatch.setattr(text_renderer, "_font_coverage", lambda face: coverage[face]) + + assert text_renderer.resolve_font_face("latin-only.ttf", "温度 25°C") == fallback + + +def test_unsupported_glyph_reports_unicode_codepoint(monkeypatch): + face = FontFace("language-font.ttf") + monkeypatch.setattr(text_renderer, "fallback_font_faces", lambda: (face,)) + monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: None) + monkeypatch.setattr(text_renderer, "_font_coverage", lambda _face: frozenset(map(ord, "ABC"))) + + assert text_renderer.resolve_font_face("default", "😀") is None + with pytest.raises(ValueError, match=r"U\+1F600"): + text_renderer.load_font("default", 18, "😀") + + +@pytest.mark.parametrize(("sample", "language"), [ + ("こんにちは", "ja"), + ("한글", "ko"), + ("你好", "zh-cn"), + ("مرحبا", "ar"), + ("שלום", "he"), + ("नमस्ते", "hi"), + ("สวัสดี", "th"), + ("Привет", "ru"), + ("Ελλάδα", "el"), +]) +def test_text_script_selects_the_matching_fontconfig_language(sample, language): + assert text_renderer._preferred_font_language(sample) == language + + +def test_preferred_font_short_circuits_fallback_discovery(monkeypatch): + preferred = FontFace("preferred.ttf") + monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: preferred) + monkeypatch.setattr(text_renderer, "_font_coverage", lambda _face: frozenset(map(ord, "你好"))) + + def unexpected_fallback(): + raise AssertionError("fallback discovery should not run") + + monkeypatch.setattr(text_renderer, "fallback_font_faces", unexpected_fallback) + assert text_renderer.resolve_font_face("default", "你好") == preferred + + +def test_noto_cjk_uses_region_specific_ttc_faces(monkeypatch, tmp_path): + cjk = tmp_path / "NotoSansCJK-Regular.ttc" + cjk.touch() + monkeypatch.setattr(text_renderer, "PREFERRED_FONT_CANDIDATES", {}) + monkeypatch.setattr(text_renderer, "_fontconfig_face", lambda _language: FontFace(str(cjk), 2)) + + assert text_renderer._preferred_font_face("ja").index == 0 + assert text_renderer._preferred_font_face("ko").index == 1 + assert text_renderer._preferred_font_face("zh-cn").index == 2 + + +def test_truetype_loader_prefers_raqm_when_available(monkeypatch): + captured = {} + text_renderer._load_truetype.cache_clear() + monkeypatch.setattr(text_renderer.features, "check_feature", lambda name: name == "raqm") + + def fake_truetype(path, **kwargs): + captured.update(path=path, **kwargs) + return object() + + monkeypatch.setattr(text_renderer.ImageFont, "truetype", fake_truetype) + text_renderer._load_truetype(FontFace("complex-script.ttf", 2), 14) + + assert captured["index"] == 2 + assert captured["layout_engine"] == text_renderer.ImageFont.Layout.RAQM + text_renderer._load_truetype.cache_clear() + + +def test_diagnostic_frame_has_corner_markers(): + image = render_diagnostic("corners_lines") + + assert image.getpixel((0, 0)) == (255, 0, 0) + assert image.getpixel((63, 0)) == (0, 255, 0) + assert image.getpixel((0, 63)) == (0, 0, 255) + assert image.getpixel((63, 63)) == (255, 255, 255) + + +def test_address_bit_diagnostic_marks_expected_rows(): + image = render_diagnostic("address_bit_3") + + assert image.getpixel((10, 7)) == (0, 0, 0) + assert image.getpixel((10, 8)) == (255, 255, 0) + assert image.getpixel((10, 15)) == (255, 255, 0) + assert image.getpixel((10, 16)) == (0, 0, 0) + + +def test_display_service_writes_diagnostic_mock_frame(tmp_path): + store = ConfigStore(project_root=tmp_path) + service = DisplayService(store, driver_kind="mock") + + service.show_diagnostic("row_bands") + + saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") + assert store.config["last_mode"] == "diagnostic:row_bands" + assert saved.getpixel((4, 4)) == (255, 0, 0) + assert saved.getpixel((4, 60)) == (255, 255, 255) + + +def test_driver_options_ignore_legacy_tuning_environment(monkeypatch): + monkeypatch.setenv("MATRIX_ROW_ADDRESS_TYPE", "1") + monkeypatch.setenv("MATRIX_MULTIPLEXING", "2") + monkeypatch.setenv("MATRIX_SCAN_MODE", "1") + monkeypatch.setenv("MATRIX_RGB_SEQUENCE", "RBG") + monkeypatch.setenv("MATRIX_PANEL_TYPE", "FM6126A") + monkeypatch.setenv("MATRIX_GPIO_SLOWDOWN", "5") + monkeypatch.setenv("MATRIX_PWM_BITS", "6") + + options = get_matrix_driver_options(35) + + assert options.brightness == 35 + assert options.scan_rows == 32 + assert options.row_address_bits == 5 + assert options.hardware_mapping == "walnutpi-pi-bank-pwm-oe-v2" + assert options.pwm_bits == 7 + + +def test_driver_defaults_fix_walnutpi_native_contract(): + options = get_default_driver_options(40) + + assert options.rows == 64 + assert options.cols == 64 + assert options.scan_rows == 32 + assert options.row_address_bits == 5 + assert options.pwm_bits == 7 + assert options.pio_base == "0x0300B000" + assert options.pi_bank_offset == "0x120" + assert options.data_offset == "0x10" + assert options.limit_refresh_rate_hz == 100 + + +@pytest.mark.parametrize("value", [15, 20, 30, 45, 60, 80, 100]) +def test_driver_options_accept_refresh_rate_presets(value): + assert get_matrix_driver_options(35, value).limit_refresh_rate_hz == value + + +@pytest.mark.parametrize("value", [0, 14, 16, 101]) +def test_driver_options_reject_unknown_refresh_rate_presets(value): + with pytest.raises(ValueError, match="limit_refresh_rate_hz"): + get_matrix_driver_options(35, value) + + +def test_real_driver_uses_native_library_and_reports_runtime_stats(monkeypatch): + class FakeNative: + def __init__(self): + self.created = None + self.destroyed = False + + def hub75_create(self, config, _error, _size): + self.created = config._obj + return 123 + + def hub75_start(self, _context): + return 0 + + def hub75_submit_rgb(self, _context, _buffer, length): + return 0 if length == 64 * 64 * 3 else -1 + + def hub75_set_brightness(self, _context, _value): + return 0 + + def hub75_set_refresh_rate(self, _context, _value): + return 0 + + def hub75_clear(self, _context): + return 0 + + def hub75_get_stats(self, _context, stats): + value = stats._obj + value.actual_refresh_rate_hz = 44.8 + value.panel_scan_rate_hz = 89.6 + value.completed_frames = 100 + value.completed_scans = 200 + value.scans_per_frame = 2 + value.buffer_swaps = 2 + value.cpu_affinity = 3 + value.cpu_affinity_active = 1 + value.realtime_priority = 50 + value.realtime_priority_active = 1 + value.memory_locked = 1 + value.running = 1 + value.brightness = 40 + value.refresh_rate_hz = 45 + value.governor = b"performance" + return 0 + + def hub75_destroy(self, _context): + self.destroyed = True + + native = FakeNative() + monkeypatch.setattr(matrix_driver_module, "_load_native_library", lambda: native) + driver = MatrixDisplayDriver(40, limit_refresh_rate_hz=45) + + assert native.created.refresh_rate_hz == 45 + status = driver.get_status() + assert status["driver"] == "walnutpi-h618-hub75" + assert status["driver_options"]["limit_refresh_rate_hz"] == 45 + assert status["driver_status"]["actual_refresh_rate_hz"] == 44.8 + assert status["driver_status"]["panel_scan_rate_hz"] == 89.6 + assert status["driver_status"]["completed_scans"] == 200 + assert status["driver_status"]["scans_per_frame"] == 2 + assert status["driver_status"]["cpu_affinity_active"] is True + assert status["driver_status"]["governor"] == "performance" + driver.close() + assert native.destroyed is True + assert driver._context is None + + +def test_real_driver_reports_missing_native_library(monkeypatch): + monkeypatch.setattr(matrix_driver_module, "NATIVE_LIBRARY", Path("missing-native-driver.so")) + + with pytest.raises(RuntimeError, match="native HUB75 library is missing"): + MatrixDisplayDriver(40) diff --git a/核桃派软件源代码/tests/test_font_picker.mjs b/核桃派软件源代码/tests/test_font_picker.mjs new file mode 100644 index 0000000..5a9af81 --- /dev/null +++ b/核桃派软件源代码/tests/test_font_picker.mjs @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { + filterFontItems, + groupFontItems, + normalizeFontItems, +} from "../app/static/font-picker.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("font catalog normalization, grouping, and search are stable", () => { + const items = normalizeFontItems([ + { id: "default", label: "自动多语言字体", family: "自动多语言字体", style: "自动回退", source: "automatic" }, + { id: "user:abc:0", label: "测试宋体", family: "测试宋体", style: "Regular", source: "imported" }, + { id: "system:def", label: "Noto Sans — Bold", family: "Noto Sans", style: "Bold", source: "system" }, + { id: "system:def", label: "重复", family: "重复", style: "Bold", source: "system" }, + { id: "bad", source: "unknown" }, + ]); + assert.deepEqual(items.map((item) => item.id), ["default", "user:abc:0", "system:def"]); + assert.deepEqual(groupFontItems(items).map((group) => group.label), ["自动字体", "已导入字体", "系统字体"]); + assert.deepEqual(filterFontItems(items, "宋体").map((item) => item.id), ["user:abc:0"]); + assert.deepEqual(filterFontItems(items, "bold").map((item) => item.id), ["system:def"]); + assert.equal(filterFontItems(items, "没有").length, 0); +}); + +test("font picker implements commit-only searchable combobox keyboard behavior", async () => { + const source = await readFile(path.join(root, "app", "static", "font-picker.js"), "utf8"); + assert.match(source, /setAttribute\(\"role\", \"option\"\)/); + assert.match(source, /ArrowDown/); + assert.match(source, /ArrowUp/); + assert.match(source, /Home/); + assert.match(source, /End/); + assert.match(source, /Enter/); + assert.match(source, /Escape/); + assert.match(source, /onCommit\?\.\(item\)/); + assert.doesNotMatch(source, /onCommit\?\.\([^)]*query/); +}); diff --git a/核桃派软件源代码/tests/test_fonts.py b/核桃派软件源代码/tests/test_fonts.py new file mode 100644 index 0000000..5bec3d2 --- /dev/null +++ b/核桃派软件源代码/tests/test_fonts.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +from fastapi.testclient import TestClient +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from PIL import Image + +from app.fonts.store import MAX_FONT_UPLOAD_BYTES, FontCatalog +from app.main import create_app + + +def build_test_font(family: str = "Codex Test Sans", style: str = "Regular") -> bytes: + builder = FontBuilder(1000, isTTF=True) + glyph_order = [".notdef", "space", "A", "B"] + builder.setupGlyphOrder(glyph_order) + + glyphs = {} + empty = TTGlyphPen(None) + glyphs[".notdef"] = empty.glyph() + glyphs["space"] = TTGlyphPen(None).glyph() + for name, inset in (("A", 100), ("B", 180)): + pen = TTGlyphPen(None) + pen.moveTo((inset, 0)) + pen.lineTo((800 - inset, 0)) + pen.lineTo((800 - inset, 800)) + pen.lineTo((inset, 800)) + pen.closePath() + glyphs[name] = pen.glyph() + + builder.setupGlyf(glyphs) + builder.setupHorizontalMetrics({name: (900, 0) for name in glyph_order}) + builder.setupHorizontalHeader(ascent=800, descent=-200) + builder.setupCharacterMap({32: "space", 65: "A", 66: "B"}) + builder.setupNameTable({ + "familyName": family, + "styleName": style, + "uniqueFontIdentifier": f"{family}-{style}", + "fullName": f"{family} {style}", + "psName": f"{family.replace(' ', '')}-{style.replace(' ', '')}", + }) + builder.setupOS2( + sTypoAscender=800, + sTypoDescender=-200, + usWinAscent=800, + usWinDescent=200, + ) + builder.setupPost() + builder.setupMaxp() + output = BytesIO() + builder.save(output) + return output.getvalue() + + +def test_font_catalog_import_duplicate_restart_and_render(tmp_path: Path) -> None: + font_bytes = build_test_font() + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + + initial = client.get("/api/fonts") + assert initial.status_code == 200 + document = initial.json() + assert document["items"][0] == { + "id": "default", + "label": "自动多语言字体", + "family": "自动多语言字体", + "style": "自动回退", + "source": "automatic", + } + assert document["accepted_extensions"] == [".ttf", ".otf", ".ttc", ".otc"] + assert document["max_upload_bytes"] == MAX_FONT_UPLOAD_BYTES + assert str(tmp_path) not in initial.text + + created = client.post( + "/api/fonts/import?filename=Codex%20Test.ttf", + content=font_bytes, + headers={"Content-Type": "application/octet-stream"}, + ) + assert created.status_code == 201 + payload = created.json() + assert payload["created"] is True + assert len(payload["fonts"]) == 1 + imported = payload["fonts"][0] + assert imported["id"].startswith("user:") + assert imported["family"] == "Codex Test Sans" + assert imported["source"] == "imported" + assert str(tmp_path) not in created.text + + stored = list((tmp_path / "data" / "fonts").glob("*.font")) + assert len(stored) == 1 + assert stored[0].stem in imported["id"] + + duplicate = client.post( + "/api/fonts/import?filename=renamed.otf", + content=font_bytes, + headers={"Content-Type": "application/octet-stream"}, + ) + assert duplicate.status_code == 200 + assert duplicate.json() == {"created": False, "fonts": [imported]} + assert len(list((tmp_path / "data" / "fonts").glob("*.font"))) == 1 + + preview = client.post("/api/preview/text-layer", json={ + "text": "AB", + "font": imported["id"], + "size": 18, + "x": 0, + "y": 0, + "align": "left", + "color": "#FFFFFF", + }) + assert preview.status_code == 200 + assert Image.open(BytesIO(preview.content)).getchannel("A").getbbox() is not None + + restarted = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + items = restarted.get("/api/fonts").json()["items"] + assert imported in items + restarted_preview = restarted.post("/api/preview/text-layer", json={ + "text": "A", + "font": imported["id"], + "size": 18, + "x": 0, + "y": 0, + "align": "left", + "color": "#FFFFFF", + }) + assert restarted_preview.status_code == 200 + + +def test_font_import_rejects_type_content_and_size_without_residue(tmp_path: Path) -> None: + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + root = tmp_path / "data" / "fonts" + + wrong_content_type = client.post( + "/api/fonts/import?filename=test.ttf", + content=b"not-a-font", + headers={"Content-Type": "text/plain"}, + ) + assert wrong_content_type.status_code == 415 + + wrong_extension = client.post( + "/api/fonts/import?filename=test.woff2", + content=b"not-a-font", + headers={"Content-Type": "application/octet-stream"}, + ) + assert wrong_extension.status_code == 415 + + damaged = client.post( + "/api/fonts/import?filename=test.ttf", + content=b"not-a-font", + headers={"Content-Type": "application/octet-stream"}, + ) + assert damaged.status_code == 422 + + oversized = client.post( + "/api/fonts/import?filename=test.ttf", + content=b"x", + headers={ + "Content-Type": "application/octet-stream", + "Content-Length": str(MAX_FONT_UPLOAD_BYTES + 1), + }, + ) + assert oversized.status_code == 413 + assert not list(root.glob(".upload-*.tmp")) + assert not list(root.glob("*.font")) + + +def test_invalid_persisted_font_is_preserved_and_reported(tmp_path: Path) -> None: + root = tmp_path / "data" / "fonts" + root.mkdir(parents=True) + damaged = root / f"{'a' * 64}.font" + damaged.write_bytes(b"damaged") + + catalog = FontCatalog(tmp_path / "data") + document = catalog.document() + assert damaged.read_bytes() == b"damaged" + assert document["warnings"] == [f"已保留但忽略损坏字体:{damaged.name}"] + assert all(item["source"] != "imported" for item in document["items"]) diff --git a/核桃派软件源代码/tests/test_frp.py b/核桃派软件源代码/tests/test_frp.py new file mode 100644 index 0000000..551294f --- /dev/null +++ b/核桃派软件源代码/tests/test_frp.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient +import pytest + +from app.frp import FrpServiceError, FrpValidationError, MockFrpBackend +from app.frp.manager import FrpController +from app.main import create_app +from app.network.diagnostics import MockNetworkDiagnostics + + +VALID_CONFIG = b'serverAddr = "example.invalid"\nserverPort = 7000\n' + + +def test_frp_catalog_defaults_empty_and_persists_multiple_profiles(tmp_path: Path) -> None: + backend = MockFrpBackend() + controller = FrpController(tmp_path, backend) + assert controller.status()["profiles"] == [] + assert controller.status()["service"]["enabled"] is False + + first = controller.catalog.create(filename="support.toml", name="support", content=VALID_CONFIG) + second = controller.catalog.create(filename="support.yaml", name="support", content=b"serverAddr: example.invalid\n") + assert first["name"] == "support" + assert second["name"] == "support (2)" + assert controller.status()["selected_id"] == first["id"] + + reloaded = FrpController(tmp_path, backend) + assert [item["name"] for item in reloaded.status()["profiles"]] == ["support", "support (2)"] + + +def test_frp_validation_failure_never_replaces_active_content(tmp_path: Path) -> None: + backend = MockFrpBackend() + controller = FrpController(tmp_path, backend) + profile = controller.catalog.create(filename="support.toml", name="support", content=VALID_CONFIG) + controller.start() + backend.fail_validation = True + with pytest.raises(FrpValidationError): + controller.catalog.update(profile["id"], name="changed", content=b"INVALID_FRP_CONFIG") + metadata, content = controller.content(profile["id"]) + assert metadata["name"] == "support" + assert content == VALID_CONFIG + assert controller.status()["service"]["running"] is True + + +def test_frp_active_restart_failure_rolls_back_file(tmp_path: Path) -> None: + backend = MockFrpBackend() + controller = FrpController(tmp_path, backend) + profile = controller.catalog.create(filename="support.toml", name="support", content=VALID_CONFIG) + controller.start() + backend.fail_restart = True + with pytest.raises(FrpServiceError): + controller.catalog.update(profile["id"], name="changed", content=b'serverAddr = "new.invalid"\n') + metadata, content = controller.content(profile["id"]) + assert metadata["name"] == "support" + assert content == VALID_CONFIG + + +def test_frp_active_profile_cannot_be_deleted(tmp_path: Path) -> None: + backend = MockFrpBackend() + controller = FrpController(tmp_path, backend) + profile = controller.catalog.create(filename="support.toml", name="support", content=VALID_CONFIG) + controller.start() + with pytest.raises(Exception, match="stop FRP"): + controller.catalog.delete(profile["id"]) + controller.stop() + controller.catalog.delete(profile["id"]) + assert controller.status()["profiles"] == [] + assert controller.status()["selected_id"] is None + + +def test_frp_damaged_catalog_disables_only_frp_subsystem(tmp_path: Path) -> None: + root = tmp_path / "frp" + root.mkdir() + (root / "catalog.json").write_text("{broken", encoding="utf-8") + controller = FrpController(tmp_path, MockFrpBackend()) + assert controller.status()["available"] is False + assert "preserved" in controller.status()["error"] + + +def test_frp_catalog_rejects_path_escape_and_future_schema_without_touching_files(tmp_path: Path) -> None: + root = tmp_path / "frp" + profile_id = "eae6f82d-75f8-4a27-8457-e4483f839295" + profile_root = root / "profiles" / profile_id + profile_root.mkdir(parents=True) + outside = root / "escape.toml" + outside.write_bytes(VALID_CONFIG) + catalog = { + "schema_version": 1, + "selected_id": profile_id, + "profiles": [{ + "id": profile_id, + "name": "support", + "filename": "../../escape.toml", + "created_at": "2026-09-07T10:00:00+00:00", + "updated_at": "2026-09-07T10:00:00+00:00", + }], + } + (root / "catalog.json").write_text(json.dumps(catalog), encoding="utf-8") + controller = FrpController(tmp_path, MockFrpBackend()) + assert controller.status()["available"] is False + assert outside.read_bytes() == VALID_CONFIG + + catalog["schema_version"] = 2 + (root / "catalog.json").write_text(json.dumps(catalog), encoding="utf-8") + future = FrpController(tmp_path, MockFrpBackend()) + assert future.status()["available"] is False + assert outside.read_bytes() == VALID_CONFIG + + +def test_frp_api_crud_start_stop_and_network_diagnostics(tmp_path: Path) -> None: + backend = MockFrpBackend() + diagnostic = MockNetworkDiagnostics() + app = create_app(project_root=tmp_path, driver_kind="mock", frp_backend=backend, network_diagnostics=diagnostic) + with TestClient(app) as client: + initial = client.get("/api/frp").json() + assert initial["profiles"] == [] + assert client.post("/api/frp/service/start").status_code == 409 + + created = client.post( + "/api/frp/configs?filename=support.toml&name=Support", + content=VALID_CONFIG, + headers={"Content-Type": "application/octet-stream"}, + ) + assert created.status_code == 201 + profile = created.json()["profile"] + assert client.get(f"/api/frp/configs/{profile['id']}").content == VALID_CONFIG + assert client.post("/api/frp/service/start").json()["frp"]["service"]["enabled"] is True + assert client.post("/api/network/diagnostics").json()["overall"] == "ok" + assert client.post("/api/frp/service/stop").json()["frp"]["service"]["enabled"] is False + assert client.delete(f"/api/frp/configs/{profile['id']}").status_code == 200 + + +@pytest.mark.parametrize("filename", ["x.exe", "../x.toml.exe", "x"]) +def test_frp_rejects_unsupported_config_names(tmp_path: Path, filename: str) -> None: + app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(app) as client: + response = client.post( + f"/api/frp/configs?filename={filename}", + content=VALID_CONFIG, + headers={"Content-Type": "application/octet-stream"}, + ) + assert response.status_code == 422 diff --git a/核桃派软件源代码/tests/test_frp_ui.mjs b/核桃派软件源代码/tests/test_frp_ui.mjs new file mode 100644 index 0000000..2d731dc --- /dev/null +++ b/核桃派软件源代码/tests/test_frp_ui.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const root = new URL("../app/static/", import.meta.url); + +test("settings places unrestricted FRP maintenance at the bottom", async () => { + const html = await readFile(new URL("index.html", root), "utf8"); + const settings = html.slice(html.indexOf('id="workspace-settings"')); + assert.match(settings, /

    frp配置<\/h3>/); + assert.match(settings, /此功能通常是远程排查使用,请保证你知道你在做什么。/); + assert.match(settings, />启动frp<\/button>/); + assert.match(settings, />编辑配置文件<\/button>/); + assert.match(settings, />网络检测<\/button>/); + assert.ok(settings.indexOf("frp-setting-card") > settings.indexOf('id="animation-preview-max-concurrent"')); +}); + +test("FRP editor uses raw server storage and official validation errors", async () => { + const source = await readFile(new URL("views/frp-settings.js", root), "utf8"); + assert.match(source, /\/api\/frp\/configs/); + assert.match(source, /Content-Type": "text\/plain; charset=utf-8"/); + assert.match(source, /正在使用 frpc 校验配置/); + assert.match(source, /discardFrpDrafts/); + assert.doesNotMatch(source, /localStorage|sessionStorage/); +}); + +test("network diagnostics renders layered evidence", async () => { + const source = await readFile(new URL("views/frp-settings.js", root), "utf8"); + assert.match(source, /\/api\/network\/diagnostics/); + assert.match(source, /网络检测失败/); + assert.match(source, /result\.checks\.forEach/); +}); diff --git a/核桃派软件源代码/tests/test_gpio_measurement.py b/核桃派软件源代码/tests/test_gpio_measurement.py new file mode 100644 index 0000000..9bec247 --- /dev/null +++ b/核桃派软件源代码/tests/test_gpio_measurement.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.measure_gpio_status import MeasurementError, percentile_95, validate_samples + + +def sample(frames: int, *, misses: int = 0, fault: int = 0, performance: bool = True): + return { + "screen": { + "driver_status": { + "actual_refresh_rate_hz": 97.4, + "panel_scan_rate_hz": 97.4, + "scans_per_frame": 1, + "completed_frames": frames, + "completed_scans": frames, + "deadline_misses": misses, + "oe_pulse_faults": fault, + "oe_forced_blanks": 0, + "driver_error": None, + "oe_timing_error": None, + "safeoff_error": None, + "cpu_affinity": 3, + "cpu_affinity_active": True, + "realtime_priority": 50, + "realtime_priority_active": True, + "memory_locked": True, + } + }, + "system": { + "performance_mode": { + "requested": performance, + "effective": performance, + "current_governors": {"policy0": "performance" if performance else "schedutil"}, + "restore_governors": {"policy0": "schedutil"}, + } + }, + } + + +def test_gpio_measurement_accepts_equal_progress_and_fixed_rgb_frame(): + result = validate_samples( + [sample(100), sample(198), sample(295)], + performance="on", + frame_before="same", + frame_after="same", + ) + assert result["completed_frames_delta"] == 195 + assert result["deadline_miss_rate"] == 0 + assert percentile_95([1, 2, 3, 100]) == 100 + + +def test_gpio_measurement_rejects_fault_growth_and_changed_frame(): + with pytest.raises(MeasurementError, match="oe_pulse_faults"): + validate_samples( + [sample(100), sample(198, fault=1)], + performance="on", + frame_before="same", + frame_after="same", + ) + with pytest.raises(MeasurementError, match="RGB frame changed"): + validate_samples( + [sample(100), sample(198)], + performance="on", + frame_before="before", + frame_after="after", + ) + + +def test_gpio_measurement_requires_governor_restore_when_off(): + off = [sample(100, performance=False), sample(198, performance=False)] + validate_samples(off, performance="off", frame_before="same", frame_after="same") + off[-1]["system"]["performance_mode"]["current_governors"] = {"policy0": "powersave"} + with pytest.raises(MeasurementError, match="not restored"): + validate_samples(off, performance="off", frame_before="same", frame_after="same") + + +def test_preview_performance_unit_is_isolated_and_preserves_realtime_contract(): + source_root = Path(__file__).resolve().parents[1] + unit = (source_root / "systemd/matrix-screen-controller-preview-perf.service").read_text( + encoding="utf-8" + ) + assert "Conflicts=matrix-screen-controller.service" in unit + assert "MATRIX_DATA_DIR=/var/tmp/matrix-screen-controller-preview-perf-20260824" in unit + assert "MATRIX_RUNTIME_DIR=/run/matrix-screen-controller-preview-perf-20260824" in unit + assert "/var/lib/matrix-screen-controller" not in unit + assert "CPUAffinity=0 1 2" in unit + assert "LimitRTPRIO=50" in unit + assert "LimitMEMLOCK=infinity" in unit + assert unit.count("hub75_safeoff") == 2 + assert "app.system.performance restore" in unit diff --git a/核桃派软件源代码/tests/test_interaction_feedback.mjs b/核桃派软件源代码/tests/test_interaction_feedback.mjs new file mode 100644 index 0000000..78225b2 --- /dev/null +++ b/核桃派软件源代码/tests/test_interaction_feedback.mjs @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const staticDir = path.join(root, "app", "static"); + +async function source(relativePath) { + return readFile(path.join(staticDir, relativePath), "utf8"); +} + +test("unavailable controls never masquerade as indefinite loading", async () => { + const [style, index, interaction, app] = await Promise.all([ + source("style.css"), + source("index.html"), + source("interaction-feedback.js"), + source("app.js"), + ]); + + assert.doesNotMatch(style, /cursor\s*:\s*wait/); + assert.doesNotMatch(index, /]*\sdisabled(?:\s|>|=)/i); + assert.doesNotMatch(index, /]*type="checkbox"[^>]*\sdisabled(?:\s|>|=)/i); + assert.match(index, /id="control-unavailable-tooltip"[^>]*role="tooltip"/); + assert.match(index, /id="control-feedback-toast"[^>]*role="status"[^>]*aria-live="polite"/); + assert.match(index, /请先新建或打开一个动图,再把当前画板添加为帧。/); + assert.match(interaction, /setAttribute\("aria-disabled", "true"\)/); + assert.match(interaction, /setAttribute\("aria-busy", "true"\)/); + assert.match(interaction, /data-unavailable-reason/); + assert.match(app, /import "\.\/interaction-feedback\.js"/); +}); + +test("action controls do not bypass the shared state module", async () => { + const files = [ + "app.js", + "color-picker.js", + "scene-board.js", + "views/animations.js", + "views/device.js", + "views/settings.js", + "views/templates.js", + "views/text.js", + ]; + const directAssignments = []; + for (const relativePath of files) { + const text = await source(relativePath); + text.split(/\r?\n/).forEach((line, index) => { + if (/\.disabled\s*=/.test(line) + && !/fields\.disabled\s*=/.test(line) + && !/calibrationReference\.disabled\s*=/.test(line)) { + directAssignments.push(`${relativePath}:${index + 1}:${line.trim()}`); + } + }); + } + assert.deepEqual(directAssignments, []); +}); + +test("HTTP requests time out with a stable user-facing error", async () => { + const originalWindow = globalThis.window; + const originalFetch = globalThis.fetch; + globalThis.window = { setTimeout, clearTimeout }; + globalThis.fetch = (_path, options) => new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + }); + try { + const { apiJson, DEFAULT_API_TIMEOUT_MS } = await import("../app/static/core.js"); + assert.equal(DEFAULT_API_TIMEOUT_MS, 15000); + await assert.rejects( + () => apiJson("/never-returns", { timeoutMs: 5 }), + (error) => error.code === "REQUEST_TIMEOUT" + && error.message === "请求超时,请检查核桃派网络或服务状态后重试", + ); + } finally { + globalThis.window = originalWindow; + globalThis.fetch = originalFetch; + } +}); + +test("demo library actions stay black, explain restrictions, and preserve copy", async () => { + const [templates, style] = await Promise.all([ + source("views/templates.js"), + source("style.css"), + ]); + + assert.match(templates, /演示案例不得编辑,请复制/); + assert.match(templates, /include_demo=true/); + assert.match(templates, /demo_order/); + assert.match(templates, /api\/animations\/\$\{animation\.id\}\/copy/); + assert.match(templates, /restrictDemoAction\(edit\)/); + assert.match(templates, /restrictDemoAction\(rename\)/); + assert.match(templates, /restrictDemoAction\(remove\)/); + assert.doesNotMatch(templates, /restrictDemoAction\(copy\)/); + assert.match(style, /\.demo-restricted-action\[aria-disabled="true"\][\s\S]*background:\s*#050505/); +}); + +test("sortable cards protect controls and keep demo cards fixed", async () => { + const [sortable, animations, templates, style] = await Promise.all([ + source("sortable-card.js"), + source("views/animations.js"), + source("views/templates.js"), + source("style.css"), + ]); + + assert.match(sortable, /DRAG_START_GUARD_PX = 8/); + assert.match(sortable, /button, input, select, textarea/); + assert.match(sortable, /pointerdown/); + assert.doesNotMatch(sortable, /pointerdown[\s\S]{0,500}preventDefault\(\)/); + assert.match(animations, /protectCardDragStart\(card\)/); + assert.match(templates, /protectCardDragStart\(card, \{ locked: true \}\)/); + assert.match(templates, /template-card-order-actions/); + assert.match(templates, /libraryDragType/); + assert.match(style, /\.library-demo-fixed/); + assert.match(style, /\.library-sortable-card\.library-drop-target/); +}); diff --git a/核桃派软件源代码/tests/test_kernel_artifact_dependency.py b/核桃派软件源代码/tests/test_kernel_artifact_dependency.py new file mode 100644 index 0000000..508f783 --- /dev/null +++ b/核桃派软件源代码/tests/test_kernel_artifact_dependency.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import gzip +import hashlib +import io +import json +import os +from pathlib import Path +import tarfile + +import pytest + +from scripts.image_kernel_payload import ( + ImageKernelPayloadError, + stage_payload, + verify_staged_payload, +) +from scripts.image_bootstrap_payload import ( + APP_RELATIVE, + TARGET_RELATIVE, + ImageBootstrapPayloadError, + stage_payload as stage_bootstrap_payload, + verify_staged_payload as verify_bootstrap_payload, +) +from scripts.kernel_artifact import ( + ARTIFACT_NAME, + KERNEL_RELEASE, + REQUIRED_BOOT_FILES, + SOURCE_ARCHIVE_SHA256, + SOURCE_COMMIT, + KernelArtifactError, + sha256_file, + verify_kernel_dependency, + verify_source_dependency, +) + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = SOURCE_ROOT.parent + + +def _dependency( + root: Path, + *, + release_marker: str = KERNEL_RELEASE, + source_marker: str = SOURCE_COMMIT, + extra_member: tarfile.TarInfo | None = None, +) -> Path: + root.mkdir() + files = { + "boot/Image-matrix-axp313a1": f"prefix Linux version {KERNEL_RELEASE} suffix".encode(), + "boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": b"dtb-sd", + "boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": b"dtb-emmc", + f"boot/System.map-{KERNEL_RELEASE}": b"system-map", + f"boot/config-{KERNEL_RELEASE}": b"config", + "KERNEL_RELEASE": f"{release_marker}\n".encode(), + "SOURCE_COMMIT": f"{source_marker}\n".encode(), + f"lib/modules/{KERNEL_RELEASE}/kernel/example.ko": b"module", + } + sums = "".join( + f"{hashlib.sha256(body).hexdigest()} {name}\n" for name, body in sorted(files.items()) + ).encode() + files["SHA256SUMS"] = sums + artifact = root / ARTIFACT_NAME + with artifact.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive: + for name, body in sorted(files.items()): + info = tarfile.TarInfo(name) + info.size = len(body) + info.uid = info.gid = 0 + info.mtime = 0 + archive.addfile(info, io.BytesIO(body)) + if extra_member is not None: + extra_member.uid = extra_member.gid = 0 + extra_member.mtime = 0 + archive.addfile(extra_member) + digest = sha256_file(artifact) + (root / "SHA256SUMS").write_text(f"{digest} {ARTIFACT_NAME}\n", encoding="ascii") + metadata = { + "schema_version": 1, + "architecture": "aarch64", + "kernel_release": KERNEL_RELEASE, + "source_commit": SOURCE_COMMIT, + "source_archive_sha256": SOURCE_ARCHIVE_SHA256, + "artifact": ARTIFACT_NAME, + "artifact_bytes": artifact.stat().st_size, + "artifact_sha256": digest, + "unpacked_file_bytes": sum(len(body) for body in files.values()), + "regular_file_count": len(files), + "module_file_count": 1, + "provenance": "synthetic test payload", + } + (root / "METADATA.json").write_text(json.dumps(metadata), encoding="utf-8") + return root + + +def test_registered_kernel_and_source_dependencies_are_complete(): + kernel = PROJECT_ROOT / "发布更新相关/其他依赖/aarch64-kernel/6.1.31-matrix-axp313a1" + source = PROJECT_ROOT / "发布更新相关/其他依赖/kernel-source/walnutpi-linux-6.1.31-30ff3fd5" + if os.environ.get("MATRIX_SOURCE_ONLY_UPDATE_TESTS") == "1": + if not kernel.exists() and not source.exists(): + pytest.skip("source-only update payload intentionally omits image build dependencies") + info = verify_kernel_dependency(kernel) + assert info.module_file_count == 3048 + assert info.regular_file_count == 3056 + assert info.unpacked_file_bytes == 160051604 + assert verify_source_dependency(source)["archive_bytes"] == 249095239 + registry = json.loads((PROJECT_ROOT / "发布更新相关/其他依赖/DEPENDENCIES.json").read_text(encoding="utf-8")) + active = {item["id"]: item for item in registry["active"]} + assert active["axp313a-kernel-runtime"]["path"] == "aarch64-kernel/6.1.31-matrix-axp313a1" + assert active["walnutpi-linux-kernel-source"]["path"] == ( + "kernel-source/walnutpi-linux-6.1.31-30ff3fd5" + ) + + +def test_synthetic_kernel_dependency_round_trip(tmp_path: Path): + info = verify_kernel_dependency(_dependency(tmp_path / "kernel")) + assert info.kernel_release == KERNEL_RELEASE + assert info.module_file_count == 1 + assert REQUIRED_BOOT_FILES + + +def test_kernel_dependency_rejects_outer_digest_damage(tmp_path: Path): + dependency = _dependency(tmp_path / "kernel") + artifact = dependency / ARTIFACT_NAME + body = bytearray(artifact.read_bytes()) + body[-1] ^= 0xFF + artifact.write_bytes(body) + with pytest.raises(KernelArtifactError, match="SHA-256 mismatch"): + verify_kernel_dependency(dependency) + + +def test_kernel_dependency_rejects_wrong_release_marker(tmp_path: Path): + dependency = _dependency(tmp_path / "kernel", release_marker="wrong-release") + with pytest.raises(KernelArtifactError, match="release marker"): + verify_kernel_dependency(dependency) + + +def test_kernel_dependency_rejects_wrong_source_marker_and_missing_binary(tmp_path: Path): + dependency = _dependency(tmp_path / "wrong", source_marker="0" * 40) + with pytest.raises(KernelArtifactError, match="source commit marker"): + verify_kernel_dependency(dependency) + missing = _dependency(tmp_path / "missing") + (missing / ARTIFACT_NAME).unlink() + with pytest.raises(KernelArtifactError, match="binary is missing"): + verify_kernel_dependency(missing) + + +@pytest.mark.parametrize( + ("name", "kind", "message"), + ( + ("unsafe-link", tarfile.SYMTYPE, "link or special file"), + ("../escape", tarfile.REGTYPE, "unsafe archive path"), + ("secret.txt", tarfile.REGTYPE, "unowned file"), + ), +) +def test_kernel_dependency_rejects_links_and_unsafe_paths( + tmp_path: Path, name: str, kind: bytes, message: str +): + extra = tarfile.TarInfo(name) + extra.type = kind + if kind == tarfile.SYMTYPE: + extra.linkname = "boot/Image-matrix-axp313a1" + dependency = _dependency(tmp_path / "kernel", extra_member=extra) + with pytest.raises(KernelArtifactError, match=message): + verify_kernel_dependency(dependency) + + +def test_image_root_payload_stage_verify_and_corruption(tmp_path: Path): + dependency = _dependency(tmp_path / "kernel") + root = tmp_path / "root" + root.mkdir() + result = stage_payload(root, dependency, reserve_bytes=0) + assert result["kernel_release"] == KERNEL_RELEASE + assert verify_staged_payload(root, dependency) == result + staged = root / "opt/matrix-image-bootstrap/axp313a" / ARTIFACT_NAME + staged.write_bytes(staged.read_bytes() + b"damage") + with pytest.raises(KernelArtifactError, match="SHA-256 mismatch"): + verify_staged_payload(root, dependency) + + +def test_image_root_payload_refuses_existing_target_and_insufficient_space(tmp_path: Path): + dependency = _dependency(tmp_path / "kernel") + existing_root = tmp_path / "existing" + existing_root.mkdir() + stage_payload(existing_root, dependency, reserve_bytes=0) + with pytest.raises(ImageKernelPayloadError, match="already exists"): + stage_payload(existing_root, dependency, reserve_bytes=0) + small_root = tmp_path / "small" + small_root.mkdir() + with pytest.raises(ImageKernelPayloadError, match="lacks room"): + stage_payload(small_root, dependency, reserve_bytes=10**30) + + +def test_image_bootstrap_stages_application_and_kernel_together(tmp_path: Path): + dependency = _dependency(tmp_path / "kernel") + bundle = tmp_path / "MSCBOOT.TGZ" + bundle.write_bytes(b"application-bundle") + root = tmp_path / "root" + root.mkdir() + result = stage_bootstrap_payload(root, bundle, dependency, reserve_bytes=0) + assert result["bundle_bytes"] == len(b"application-bundle") + assert result["bundle_path"] == "opt/matrix-image-bootstrap/app/MSCBOOT.TGZ" + assert verify_bootstrap_payload(root, bundle, dependency) == result + assert (root / TARGET_RELATIVE / APP_RELATIVE).read_bytes() == bundle.read_bytes() + + +def test_image_bootstrap_rejects_damage_existing_target_and_combined_space(tmp_path: Path): + dependency = _dependency(tmp_path / "kernel") + bundle = tmp_path / "MSCBOOT.TGZ" + bundle.write_bytes(b"application-bundle") + root = tmp_path / "root" + root.mkdir() + stage_bootstrap_payload(root, bundle, dependency, reserve_bytes=0) + with pytest.raises(ImageBootstrapPayloadError, match="already exists"): + stage_bootstrap_payload(root, bundle, dependency, reserve_bytes=0) + staged = root / TARGET_RELATIVE / APP_RELATIVE + staged.write_bytes(b"damaged") + with pytest.raises(ImageBootstrapPayloadError, match="differs"): + verify_bootstrap_payload(root, bundle, dependency) + small_root = tmp_path / "small" + small_root.mkdir() + with pytest.raises(ImageBootstrapPayloadError, match="both offline payloads"): + stage_bootstrap_payload(small_root, bundle, dependency, reserve_bytes=10**30) diff --git a/核桃派软件源代码/tests/test_kernel_recovery.py b/核桃派软件源代码/tests/test_kernel_recovery.py new file mode 100644 index 0000000..852df70 --- /dev/null +++ b/核桃派软件源代码/tests/test_kernel_recovery.py @@ -0,0 +1,180 @@ +from pathlib import Path + +import pytest + +from scripts.axp313a_kernel_health import ( + HealthError, + check_cpufreq, + check_driver_pair, + check_kernel_messages, +) +from scripts.render_dual_kernel_boot import BootScriptError, render + + +BOOT_SOURCE = '''# DO NOT EDIT THIS FILE +setenv docker_optimizations "on" +setenv bootargs "root=/dev/mmcblk1p2" +if test "${docker_optimizations}" = "on"; then setenv bootargs "${bootargs} cgroup_enable=memory swapaccount=1"; fi +if mmc dev 1; then + load ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile_emmc}.dtb +else + load ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb +fi +if test "${overlay_error}" = "true"; then + load ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb +fi +load ${devtype} ${devnum} ${kernel_addr_r} ${prefix}Image +''' + + +def test_dual_kernel_boot_consumes_health_marker_and_keeps_original_paths(): + managed = render(BOOT_SOURCE) + + assert "fatrm ${devtype} ${devnum} ${prefix}matrix-kernel-good" in managed + assert 'setenv matrix_kernel_file "Image-matrix-axp313a1"' in managed + assert 'setenv matrix_dtb_suffix "-matrix-axp313a1"' in managed + assert "matrix_kernel_candidate=1 panic=10" in managed + assert "${prefix}${fdtfile}${matrix_dtb_suffix}.dtb" in managed + assert "${prefix}${matrix_kernel_file}" in managed + assert 'setenv matrix_kernel_file "Image"' in managed + assert 'setenv matrix_dtb_suffix ""' in managed + + +def test_dual_kernel_boot_refuses_to_stack_management_blocks(): + with pytest.raises(BootScriptError, match="already candidate-managed"): + render(render(BOOT_SOURCE)) + + +def test_cpufreq_health_requires_policy0_all_cpus_and_performance(tmp_path): + policy = tmp_path / "cpufreq" / "policy0" + policy.mkdir(parents=True) + (policy / "affected_cpus").write_text("0 1 2 3\n", encoding="ascii") + (policy / "scaling_available_governors").write_text( + "powersave performance schedutil\n", encoding="ascii" + ) + deferred = tmp_path / "devices_deferred" + deferred.write_text("", encoding="utf-8") + + check_cpufreq(policy.parent, deferred) + + deferred.write_text("platform cpufreq-dt\n", encoding="utf-8") + with pytest.raises(HealthError, match="still deferred"): + check_cpufreq(policy.parent, deferred) + + +def driver_status(frames: int, misses: int = 0) -> dict: + return { + "screen": { + "driver": "walnutpi-h618-hub75", + "driver_status": { + "actual_refresh_rate_hz": 97.5, + "panel_scan_rate_hz": 97.5, + "completed_frames": frames, + "completed_scans": frames, + "scans_per_frame": 1, + "deadline_misses": misses, + "oe_pulse_faults": 0, + "oe_forced_blanks": 0, + "driver_error": None, + "oe_timing_error": None, + "safeoff_error": None, + "cpu_affinity": 3, + "cpu_affinity_active": True, + "realtime_priority": 50, + "realtime_priority_active": True, + "memory_locked": True, + }, + }, + } + + +def test_candidate_health_accepts_only_progressing_equal_gpio_counters(): + check_driver_pair(driver_status(1000), driver_status(1200)) + + stalled = driver_status(1000) + with pytest.raises(HealthError, match="stalled or unequal"): + check_driver_pair(stalled, stalled) + + +def test_candidate_health_rejects_axp313a_dcdc_boot_errors(): + check_kernel_messages("axp20x-regulator: regulator probe completed\n") + with pytest.raises(HealthError, match="regulator logged a boot error"): + check_kernel_messages( + "axp20x-regulator axp20x-regulator: Error setting dcdc frequency: -22\n" + ) + + +def test_kernel_scripts_pin_release_commits_and_preserve_original_boot_files(): + source_root = Path(__file__).resolve().parents[1] + build = (source_root / "scripts/build_axp313a_kernel.sh").read_text(encoding="utf-8") + install = (source_root / "scripts/install_axp313a_kernel.sh").read_text(encoding="utf-8") + capture = (source_root / "scripts/capture_axp313a_state.sh").read_text(encoding="utf-8") + dts_patch = (source_root / "kernel/patches/0004-walnutpi-axp313a-dts.patch").read_text( + encoding="utf-8" + ) + dldo_patch = ( + source_root / "kernel/patches/0005-walnutpi-axp313a-preserve-dldo1.patch" + ).read_text(encoding="utf-8") + uwe_patch = ( + source_root / "kernel/patches/0006-walnutpi-uwe5622-out-of-tree-build.patch" + ).read_text(encoding="utf-8") + realtek_patch = ( + source_root / "kernel/patches/0007-walnutpi-enabled-realtek-out-of-tree-build.patch" + ).read_text(encoding="utf-8") + rtl8192_patch = ( + source_root / "kernel/patches/0008-walnutpi-rtl8192eu-out-of-tree-build.patch" + ).read_text(encoding="utf-8") + rtl8812_patch = ( + source_root / "kernel/patches/0009-walnutpi-rtl8812au-out-of-tree-build.patch" + ).read_text(encoding="utf-8") + rtl8812_phydm_patch = ( + source_root / "kernel/patches/0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch" + ).read_text(encoding="utf-8") + rtl88x2cs_topdir_patch = ( + source_root / "kernel/patches/0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch" + ).read_text(encoding="utf-8") + emmc_dts_patch = ( + source_root / "kernel/patches/0012-walnutpi-emmc-axp313a-dts.patch" + ).read_text(encoding="utf-8") + dcdc_frequency_patch = ( + source_root / "kernel/patches/0013-walnutpi-axp313a-fixed-dcdc-frequency.patch" + ).read_text(encoding="utf-8") + unit = (source_root / "systemd/matrix-axp313a-health.service").read_text(encoding="utf-8") + + assert "30ff3fd5cf45417622b447a12e7a947402ffe34d" in build + assert "75c8cb2f4cb218aaf4ea68cab08d6dbc96eeae15.patch" in build + assert "60fd7eb89670d2636ac3156881acbd103c6eba6a.patch" in build + assert "4628b804555773daa982d9578d89fe04fcdde374.patch" in build + assert "/boot/boot.cmd.matrix-original" in install + assert "/boot/boot.scr.matrix-original" in install + assert "KERNEL_RELEASE" in build + assert "verify_dtb" in install + assert "dldo1 3300000 3300000 vcc3v3" in install + assert "cpu_supply" in install and "dcdc2_phandle" in install + assert "+\t\t\t\tregulator-min-microvolt = <1100000>;" in dts_patch + assert "reg_dldo1: dldo1" in dldo_patch + assert "regulator-min-microvolt = <3300000>" in dldo_patch + assert "0005-walnutpi-axp313a-preserve-dldo1.patch" in build + assert "0006-walnutpi-uwe5622-out-of-tree-build.patch" in build + assert "$(srctree)/drivers/net/wireless/uwe5622/unisocwcn" in uwe_patch + assert "0007-walnutpi-enabled-realtek-out-of-tree-build.patch" in build + for directory in ("rtl8189fs", "rtl8723ds", "rtl8723du", "rtl8811cu", "rtl88x2cs"): + assert f"$(srctree)/drivers/net/wireless/{directory}" in realtek_patch + assert "0008-walnutpi-rtl8192eu-out-of-tree-build.patch" in build + assert "$(srctree)/drivers/net/wireless/rtl8192eu" in rtl8192_patch + assert "0009-walnutpi-rtl8812au-out-of-tree-build.patch" in build + assert "$(srctree)/drivers/net/wireless/rtl8812au" in rtl8812_patch + assert "0010-walnutpi-rtl8812au-phydm-out-of-tree-build.patch" in build + assert "$(srctree)/drivers/net/wireless/rtl8812au/hal/phydm" in rtl8812_phydm_patch + assert "0011-walnutpi-rtl88x2cs-topdir-out-of-tree-build.patch" in build + assert "$(srctree)/drivers/net/wireless/rtl88x2cs/rtl8822c.mk" in rtl88x2cs_topdir_patch + assert "0012-walnutpi-emmc-axp313a-dts.patch" in build + assert "sun50i-h616-walnutpi-1b-emmc.dts" in emmc_dts_patch + assert "x-powers,axp313a" in emmc_dts_patch + assert "reg_dldo1: dldo1" in emmc_dts_patch + assert "0013-walnutpi-axp313a-fixed-dcdc-frequency.patch" in build + assert "+\t\tif (dcdcfreq != 0)" in dcdc_frequency_patch + assert "-\t\tif (dcdcfreq != 3000000)" in dcdc_frequency_patch + assert "/var/lib/matrix-screen-controller" in capture + assert "state output directory must not exist" in capture + assert "ConditionKernelCommandLine=matrix_kernel_candidate=1" in unit diff --git a/核桃派软件源代码/tests/test_library_order.py b/核桃派软件源代码/tests/test_library_order.py new file mode 100644 index 0000000..0238992 --- /dev/null +++ b/核桃派软件源代码/tests/test_library_order.py @@ -0,0 +1,172 @@ +import base64 +import json + +import pytest +from fastapi.testclient import TestClient + +from app.demo_library import DEMO_ANIMATION_ID, DEMO_STATIC_ID +from app.library_order import LibraryOrderValidationError +from app.main import create_app + + +def scene(color=(0, 0, 0)) -> dict: + return { + "version": 1, + "width": 64, + "height": 64, + "pixelRgb": base64.b64encode(bytes(color) * (64 * 64)).decode("ascii"), + "elements": [], + } + + +def create_template(client: TestClient, name: str) -> dict: + response = client.post("/api/templates", json={"name": name, "scene": scene()}) + assert response.status_code == 201 + return response.json() + + +def create_animation(client: TestClient, name: str) -> dict: + response = client.post("/api/animations", json={"name": name}) + assert response.status_code == 201 + return response.json() + + +def put_order(client: TestClient, items: list[dict], revision: str): + return client.put( + "/api/library/order", + headers={"If-Match": f'"{revision}"'}, + json={"items": items}, + ) + + +def test_library_order_defaults_mixes_types_and_persists_only_after_update(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + first_template = create_template(client, "静态一") + second_template = create_template(client, "静态二") + first_animation = create_animation(client, "动图一") + second_animation = create_animation(client, "动图二") + + order_path = tmp_path / "data" / "library" / "order.json" + default = client.get("/api/library/order") + assert default.status_code == 200 + assert default.json()["items"] == [ + {"type": "template", "id": second_template["id"]}, + {"type": "template", "id": first_template["id"]}, + {"type": "animation", "id": second_animation["id"]}, + {"type": "animation", "id": first_animation["id"]}, + ] + assert not order_path.exists() + + mixed = [ + {"type": "animation", "id": first_animation["id"]}, + {"type": "template", "id": first_template["id"]}, + {"type": "animation", "id": second_animation["id"]}, + {"type": "template", "id": second_template["id"]}, + ] + assert client.put("/api/library/order", json={"items": mixed}).status_code == 428 + updated = put_order(client, mixed, default.json()["revision"]) + assert updated.status_code == 200 + assert updated.json()["items"] == mixed + persisted = json.loads(order_path.read_text(encoding="utf-8")) + assert persisted == {"schema_version": 1, "items": mixed} + + restarted = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + assert restarted.get("/api/library/order").json() == updated.json() + stale = put_order(restarted, list(reversed(mixed)), default.json()["revision"]) + assert stale.status_code == 409 + + +def test_library_order_rejects_invalid_demo_duplicate_and_changed_sets(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + template = create_template(client, "静态") + animation = create_animation(client, "动图") + current = client.get("/api/library/order").json() + + demo = put_order( + client, + [{"type": "template", "id": DEMO_STATIC_ID}, {"type": "animation", "id": DEMO_ANIMATION_ID}], + current["revision"], + ) + assert demo.status_code == 422 + duplicate = put_order( + client, + [ + {"type": "template", "id": template["id"]}, + {"type": "template", "id": template["id"]}, + {"type": "animation", "id": animation["id"]}, + ], + current["revision"], + ) + assert duplicate.status_code == 422 + missing = put_order( + client, + [{"type": "template", "id": template["id"]}], + current["revision"], + ) + assert missing.status_code == 409 + extra_field = client.put( + "/api/library/order", + headers={"If-Match": f'"{current["revision"]}"'}, + json={"items": [{"type": "template", "id": template["id"], "name": "bad"}]}, + ) + assert extra_field.status_code == 422 + + +def test_library_order_appends_new_items_and_filters_deleted_references(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + first = create_template(client, "静态一") + animation = create_animation(client, "动图") + initial = client.get("/api/library/order").json() + ordered = [ + {"type": "animation", "id": animation["id"]}, + {"type": "template", "id": first["id"]}, + ] + assert put_order(client, ordered, initial["revision"]).status_code == 200 + + appended = create_template(client, "静态二") + after_create = client.get("/api/library/order").json() + assert after_create["items"] == [ + *ordered, + {"type": "template", "id": appended["id"]}, + ] + + removed = client.delete( + f"/api/templates/{first['id']}", + headers={"If-Match": f'"{first["revision"]}"'}, + ) + assert removed.status_code == 200 + after_delete = client.get("/api/library/order").json() + assert after_delete["items"] == [ + {"type": "animation", "id": animation["id"]}, + {"type": "template", "id": appended["id"]}, + ] + + +@pytest.mark.parametrize( + "content", + [ + b"not json", + json.dumps({"schema_version": 2, "items": []}).encode(), + json.dumps({"schema_version": 1, "items": [], "extra": True}).encode(), + json.dumps({ + "schema_version": 1, + "items": [ + {"type": "template", "id": "11111111-1111-4111-8111-111111111111"}, + {"type": "template", "id": "11111111-1111-4111-8111-111111111111"}, + ], + }).encode(), + json.dumps({ + "schema_version": 1, + "items": [{"type": "template", "id": DEMO_STATIC_ID}], + }).encode(), + ], +) +def test_invalid_library_order_blocks_startup_and_preserves_original(tmp_path, content): + path = tmp_path / "data" / "library" / "order.json" + path.parent.mkdir(parents=True) + path.write_bytes(content) + + with pytest.raises(LibraryOrderValidationError): + create_app(project_root=tmp_path, driver_kind="mock") + + assert path.read_bytes() == content diff --git a/核桃派软件源代码/tests/test_media_conversion.py b/核桃派软件源代码/tests/test_media_conversion.py new file mode 100644 index 0000000..ad747ef --- /dev/null +++ b/核桃派软件源代码/tests/test_media_conversion.py @@ -0,0 +1,357 @@ +import time +from io import BytesIO +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from app.main import create_app +from app.media.converter import MediaConversionError, converted_frames, transform_frame + + +@pytest.fixture(autouse=True) +def deterministic_media_disk_space(monkeypatch): + monkeypatch.setattr( + "app.media.manager.shutil.disk_usage", + lambda _path: SimpleNamespace( + total=8 * 1024**3, + used=2 * 1024**3, + free=6 * 1024**3, + ), + ) + + +def png_bytes(image: Image.Image) -> bytes: + output = BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +def settings(mode="crop", transparency="#000000", padding="#000000"): + return { + "fit_mode": mode, "center_x": 0.5, "center_y": 0.5, "zoom": 1.0, + "transparency_color": transparency, "padding_color": padding, + } + + +def wait_for(client, job_id, state, timeout=5): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = client.get("/api/media-imports/" + job_id) + if response.status_code == 404: + if state == "gone": + return None + raise AssertionError("job disappeared") + job = response.json() + if job["state"] == "failed": + raise AssertionError(job["error"]) + if job["state"] == state: + return job + time.sleep(0.02) + raise AssertionError("media job did not reach " + state) + + +def upload_media(client, payload, filename="a.png", name=None): + query = "/api/media-imports?filename=" + filename + if name is not None: + query += "&name=" + name + return client.post( + query, content=payload, + headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, + ) + + +def test_upload_rejects_when_device_cannot_retain_two_gibibytes(tmp_path, monkeypatch): + monkeypatch.setattr( + "app.media.manager.shutil.disk_usage", + lambda _path: SimpleNamespace( + total=4 * 1024**3, + used=2 * 1024**3, + free=2 * 1024**3, + ), + ) + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (1, 1), "white")) + with TestClient(app) as client: + response = upload_media(client, payload) + assert response.status_code == 507 + assert response.json()["detail"] == "device must retain at least 2 GiB free space" + + +def test_contain_keeps_transparency_and_padding_colors_independent(): + image = Image.new("RGBA", (2, 1), (255, 0, 0, 0)) + rgb = transform_frame(image, settings("contain", "#00FF00", "#0000FF")) + output = Image.frombytes("RGB", (64, 64), rgb) + assert output.getpixel((0, 0)) == (0, 0, 255) + assert output.getpixel((32, 32)) == (0, 255, 0) + + +def test_crop_and_stretch_generate_exact_rgb888(): + image = Image.new("RGBA", (160, 90), (12, 34, 56, 255)) + for mode in ("crop", "stretch"): + rgb = transform_frame(image, settings(mode)) + assert len(rgb) == 64 * 64 * 3 + output = Image.frombytes("RGB", (64, 64), rgb) + assert output.getpixel((32, 32)) == (12, 34, 56) + assert Image.frombytes("RGB", (64, 64), transform_frame(image, settings("stretch"))).getpixel((0, 0)) == (12, 34, 56) + + +def test_free_crop_starts_with_the_complete_non_square_source(): + image = Image.new("RGB", (8, 4), "red") + for x in range(4, 8): + for y in range(4): + image.putpixel((x, y), (0, 255, 0)) + rgb = transform_frame(image, settings("crop", padding="#0000FF")) + output = Image.frombytes("RGB", (64, 64), rgb) + assert output.getpixel((32, 0)) == (0, 0, 255) + assert output.getpixel((8, 32))[0] > 240 + assert output.getpixel((56, 32))[1] > 240 + assert output.getpixel((32, 63)) == (0, 0, 255) + + +def test_free_crop_uses_separate_transparency_and_void_colors(): + image = Image.new("RGBA", (2, 1), (255, 0, 0, 0)) + rgb = transform_frame(image, settings("crop", "#00FF00", "#0000FF")) + output = Image.frombytes("RGB", (64, 64), rgb) + assert output.getpixel((32, 0)) == (0, 0, 255) + assert output.getpixel((32, 32)) == (0, 255, 0) + + +def test_free_crop_can_leave_only_one_source_pixel_visible_at_each_corner(): + image = Image.new("RGBA", (2, 1), (255, 0, 0, 255)) + cases = [ + (-31 / 64, -31 / 32, (63, 63)), + (95 / 64, -31 / 32, (0, 63)), + (-31 / 64, 63 / 32, (63, 0)), + (95 / 64, 63 / 32, (0, 0)), + ] + for center_x, center_y, visible_at in cases: + value = settings("crop", padding="#0000FF") + value.update({"center_x": center_x, "center_y": center_y}) + output = Image.frombytes("RGB", (64, 64), transform_frame(image, value)) + assert output.getpixel(visible_at)[0] > 240 + assert sum(pixel[0] > 240 for pixel in output.getdata()) == 1 + + +def test_free_crop_zoom_selects_a_non_center_region_and_rejects_an_empty_view(): + image = Image.new("RGB", (8, 4), "red") + for x in range(4, 8): + for y in range(4): + image.putpixel((x, y), (0, 255, 0)) + value = settings("crop", padding="#0000FF") + value.update({"zoom": 2, "center_x": 0.25}) + output = Image.frombytes("RGB", (64, 64), transform_frame(image, value)) + assert output.getpixel((16, 32))[0] > 240 + assert output.getpixel((48, 32))[0] > 240 + value["center_x"] = -2 + try: + transform_frame(image, value) + except MediaConversionError as exc: + assert "at least one output pixel" in str(exc) + else: + raise AssertionError("fully empty free framing must be rejected") + + +def test_static_upload_analysis_conversion_and_source_cleanup(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGBA", (80, 40), (220, 10, 30, 128))) + with TestClient(app) as client: + uploaded = client.post( + "/api/media-imports?filename=wrong-extension.bin&name=导入静态图", + content=payload, + headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, + ) + assert uploaded.status_code == 202 + job_id = uploaded.json()["id"] + job = wait_for(client, job_id, "awaiting_settings") + assert job["metadata"]["decoder"] == "pillow" + assert job["metadata"]["dynamic"] is False + assert len(job["previews"]) == 1 + updated = client.put( + "/api/media-imports/" + job_id + "/settings", + json={ + "fit_mode": "contain", "center_x": 0.5, "center_y": 0.5, "zoom": 1, + "transparency_color": "#00FF00", "padding_color": "#0000FF", + }, + ) + assert updated.status_code == 200 + assert client.post("/api/media-imports/" + job_id + "/convert").status_code == 202 + wait_for(client, job_id, "gone") + templates = client.get("/api/templates").json()["templates"] + assert any(item["name"] == "导入静态图" for item in templates) + assert not (tmp_path / "data" / "media-import" / "jobs" / job_id).exists() + + +def test_identical_uploads_use_independent_jobs_and_default_names(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (8, 4), "white")) + with TestClient(app) as client: + first = upload_media(client, payload, "同一素材.test.png") + second = upload_media(client, payload, "同一素材.test.png") + assert first.status_code == second.status_code == 202 + first_id, second_id = first.json()["id"], second.json()["id"] + assert first_id != second_id + first_job = wait_for(client, first_id, "awaiting_settings") + second_job = wait_for(client, second_id, "awaiting_settings") + assert first_job["name"] == second_job["name"] == "同一素材.test" + first_source = tmp_path / "data" / "media-import" / "jobs" / first_id / "source.bin" + second_source = tmp_path / "data" / "media-import" / "jobs" / second_id / "source.bin" + assert first_source.read_bytes() == second_source.read_bytes() == payload + + assert client.put( + "/api/media-imports/" + first_id + "/settings", + json={"name": "素材左侧", "center_x": 0.25}, + ).status_code == 200 + assert client.put( + "/api/media-imports/" + second_id + "/settings", + json={"name": "素材右侧", "center_x": 0.75}, + ).status_code == 200 + assert client.post("/api/media-imports/" + first_id + "/convert").status_code == 202 + assert client.post("/api/media-imports/" + second_id + "/convert").status_code == 202 + wait_for(client, first_id, "gone") + wait_for(client, second_id, "gone") + names = {item["name"] for item in client.get("/api/templates").json()["templates"]} + assert {"素材左侧", "素材右侧"} <= names + + +def test_default_upload_name_falls_back_and_legacy_name_is_kept(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (1, 1), "white")) + with TestClient(app) as client: + fallback = upload_media(client, payload, ".png") + legacy = upload_media(client, payload, "ignored.png", "旧客户端名称") + assert wait_for(client, fallback.json()["id"], "awaiting_settings")["name"] == "未命名媒体" + assert wait_for(client, legacy.json()["id"], "awaiting_settings")["name"] == "旧客户端名称" + + +def test_active_jobs_reserve_names_and_rejected_job_stays_editable(tmp_path, monkeypatch): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (1, 1), "white")) + with TestClient(app) as client: + first = upload_media(client, payload) + second = upload_media(client, payload) + first_id, second_id = first.json()["id"], second.json()["id"] + wait_for(client, first_id, "awaiting_settings") + wait_for(client, second_id, "awaiting_settings") + monkeypatch.setattr(app.state.media_import_manager, "_schedule", lambda *_args: None) + + assert client.put( + "/api/media-imports/" + first_id + "/settings", json={"name": "Unique Name"}, + ).status_code == 200 + assert client.post("/api/media-imports/" + first_id + "/convert").status_code == 202 + + conflict = client.put( + "/api/media-imports/" + second_id + "/settings", json={"name": " unique name "}, + ) + assert conflict.status_code == 409 + assert conflict.json()["detail"] == "output name already exists" + editable = client.get("/api/media-imports/" + second_id).json() + assert editable["state"] == "awaiting_settings" + assert editable["name"] == "a" + + assert client.delete("/api/media-imports/" + first_id).status_code == 200 + renamed = client.put( + "/api/media-imports/" + second_id + "/settings", json={"name": "Unique Name"}, + ) + assert renamed.status_code == 200 + + +def test_restarted_manager_keeps_queued_name_reserved(tmp_path, monkeypatch): + payload = png_bytes(Image.new("RGB", (1, 1), "white")) + first_app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(first_app) as first_client: + first = upload_media(first_client, payload) + first_id = first.json()["id"] + wait_for(first_client, first_id, "awaiting_settings") + monkeypatch.setattr(first_app.state.media_import_manager, "_schedule", lambda *_args: None) + assert first_client.put( + "/api/media-imports/" + first_id + "/settings", json={"name": "重启占用"}, + ).status_code == 200 + assert first_client.post("/api/media-imports/" + first_id + "/convert").status_code == 202 + + restarted_app = create_app(project_root=tmp_path, driver_kind="mock") + manager = restarted_app.state.media_import_manager + while not manager._queue.empty(): + manager._queue.get_nowait() + with TestClient(restarted_app) as restarted_client: + second = upload_media(restarted_client, payload) + second_id = second.json()["id"] + wait_for(restarted_client, second_id, "awaiting_settings") + conflict = restarted_client.put( + "/api/media-imports/" + second_id + "/settings", json={"name": "重启占用"}, + ) + assert conflict.status_code == 409 + assert restarted_client.get("/api/media-imports/" + first_id).json()["state"] == "queued" + + +def test_media_name_conflicts_with_templates_and_animations_across_types(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (1, 1), "white")) + app.state.animation_store.create("已有动图") + with TestClient(app) as client: + client.post("/api/templates", json={ + "name": "已有模板", + "scene": { + "version": 1, "width": 64, "height": 64, + "pixelRgb": "AAAA" * 4096, "elements": [], + }, + }) + for existing in ("已有动图", "已有模板"): + uploaded = upload_media(client, payload) + job_id = uploaded.json()["id"] + wait_for(client, job_id, "awaiting_settings") + conflict = client.put( + "/api/media-imports/" + job_id + "/settings", + json={"name": " " + existing.swapcase() + " "}, + ) + assert conflict.status_code == 409 + assert client.get("/api/media-imports/" + job_id).json()["state"] == "awaiting_settings" + + +def test_upload_contract_and_name_conflict_are_rejected_before_conversion(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (1, 1), "white")) + with TestClient(app) as client: + assert client.post( + "/api/media-imports?filename=a.png&name=A", content=payload, + headers={"Content-Type": "image/png"}, + ).status_code == 415 + client.post("/api/templates", json={ + "name": "冲突", + "scene": { + "version": 1, "width": 64, "height": 64, + "pixelRgb": "AAAA" * 4096, "elements": [], + }, + }) + uploaded = client.post( + "/api/media-imports?filename=a.png&name=冲突", content=payload, + headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, + ) + job_id = uploaded.json()["id"] + wait_for(client, job_id, "awaiting_settings") + response = client.post("/api/media-imports/" + job_id + "/convert") + assert response.status_code == 409 + + +def test_media_settings_api_accepts_outside_centers_but_rejects_an_empty_view(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + payload = png_bytes(Image.new("RGB", (2, 1), "white")) + with TestClient(app) as client: + uploaded = client.post( + "/api/media-imports?filename=a.png&name=自由取景", content=payload, + headers={"Content-Type": "application/octet-stream", "Content-Length": str(len(payload))}, + ) + job_id = uploaded.json()["id"] + wait_for(client, job_id, "awaiting_settings") + accepted = client.put( + "/api/media-imports/" + job_id + "/settings", + json={"center_x": -31 / 64, "center_y": -31 / 32}, + ) + assert accepted.status_code == 200 + rejected = client.put( + "/api/media-imports/" + job_id + "/settings", + json={"center_x": -2}, + ) + assert rejected.status_code == 400 + assert "at least one output pixel" in rejected.json()["detail"] diff --git a/核桃派软件源代码/tests/test_media_formats.py b/核桃派软件源代码/tests/test_media_formats.py new file mode 100644 index 0000000..539d977 --- /dev/null +++ b/核桃派软件源代码/tests/test_media_formats.py @@ -0,0 +1,82 @@ +import shutil +import subprocess + +import pytest +from PIL import Image + +from app.media.converter import analyze, converted_frames + + +VIDEO_CASES = [ + ("mp4", "mpeg4"), + ("mov", "mpeg4"), + ("mkv", "ffv1"), + ("avi", "mpeg4"), + ("webm", "libvpx-vp9"), +] + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="FFmpeg is not installed") +@pytest.mark.parametrize(("extension", "codec"), VIDEO_CASES) +def test_common_video_containers_probe_sample_and_merge_identical_frames(tmp_path, extension, codec): + source = tmp_path / ("constant." + extension) + result = subprocess.run([ + "ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", + "-i", "color=c=red:s=96x54:r=30:d=0.3", + "-an", "-c:v", codec, "-y", str(source), + ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False, timeout=30) + if result.returncode: + pytest.skip(codec + " encoder unavailable") + metadata = analyze(source, tmp_path / ("previews-" + extension)) + frames = list(converted_frames(source, metadata, { + "fit_mode": "contain", "center_x": 0.5, "center_y": 0.5, "zoom": 1, + "transparency_color": "#000000", "padding_color": "#010203", + })) + assert metadata["decoder"] == "ffmpeg" + assert metadata["dynamic"] is True + assert 250 <= metadata["duration_ms"] <= 350 + assert len(frames) == 1 + assert frames[0][1] >= 250 + assert len(frames[0][0]) == 64 * 64 * 3 + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="FFmpeg is not installed") +def test_ffmpeg_free_crop_keeps_the_complete_wide_frame_and_void_padding(tmp_path): + source = tmp_path / "wide.mkv" + result = subprocess.run([ + "ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", + "-i", "color=c=red:s=96x48:r=20:d=0.1", + "-an", "-c:v", "ffv1", "-y", str(source), + ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False, timeout=30) + if result.returncode: + pytest.skip("ffv1 encoder unavailable") + metadata = analyze(source, tmp_path / "wide-previews") + frames = list(converted_frames(source, metadata, { + "fit_mode": "crop", "center_x": 0.5, "center_y": 0.5, "zoom": 1, + "transparency_color": "#00FF00", "padding_color": "#0000FF", + })) + output = Image.frombytes("RGB", (64, 64), frames[0][0]) + assert output.getpixel((32, 0)) == (0, 0, 255) + assert output.getpixel((32, 32))[0] > 240 + assert output.getpixel((32, 63)) == (0, 0, 255) + + +@pytest.mark.skipif(not shutil.which("heif-enc"), reason="libheif examples are not installed") +def test_heic_uses_local_libheif_decoder(tmp_path): + png = tmp_path / "source.png" + source = tmp_path / "source.heic" + Image.new("RGB", (32, 20), (4, 5, 6)).save(png) + result = subprocess.run( + ["heif-enc", "-q", "100", str(png), "-o", str(source)], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False, timeout=30, + ) + if result.returncode: + pytest.skip("HEIC encoder unavailable") + metadata = analyze(source, tmp_path / "heic-previews") + frames = list(converted_frames(source, metadata, { + "fit_mode": "stretch", "center_x": 0.5, "center_y": 0.5, "zoom": 1, + "transparency_color": "#000000", "padding_color": "#000000", + })) + assert metadata["decoder"] in {"pillow", "heif"} + assert metadata["dynamic"] is False + assert len(frames) == 1 diff --git a/核桃派软件源代码/tests/test_media_import_ui.mjs b/核桃派软件源代码/tests/test_media_import_ui.mjs new file mode 100644 index 0000000..7776009 --- /dev/null +++ b/核桃派软件源代码/tests/test_media_import_ui.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import { clampCropCenter, cropCenterBounds, cropGeometry } from "../app/static/media-crop-geometry.js"; +import { shouldReuseMediaJobCard } from "../app/static/media-import-ui-model.js"; + +const html = fs.readFileSync(new URL("../app/static/index.html", import.meta.url), "utf8"); +const app = fs.readFileSync(new URL("../app/static/app.js", import.meta.url), "utf8"); +const view = fs.readFileSync(new URL("../app/static/views/media-import.js", import.meta.url), "utf8"); +const copyCatalog = JSON.parse(fs.readFileSync(new URL("../app/static/ui-copy.json", import.meta.url), "utf8")); + +test("media import workspace exposes streaming upload, crop controls, and queue polling", () => { + assert.match(app, /views\/media-import\.js/); + assert.match(html, /id="workspace-media-import"/); + assert.match(html, /id="media-source-file"/); + assert.doesNotMatch(html, /id="media-output-name"/); + assert.match(html, /class="media-source-field"/); + assert.match(html, /id="media-upload-progress"/); + assert.match(view, /XMLHttpRequest/); + assert.match(view, /pointermove/); + assert.match(view, /transparency_color/); + assert.match(view, /padding_color/); + assert.match(view, /document\.hidden/); + assert.match(view, /setInterval\(refreshJobs, 2000\)/); + assert.match(view, /id: "media-import"/); + assert.doesNotMatch(view, /nameInput/); + assert.doesNotMatch(view, /&name=/); + assert.doesNotMatch(view, /裁切填满/); + assert.doesNotMatch(fs.readFileSync(new URL("../app/static/style.css", import.meta.url), "utf8"), /object-fit:\s*cover/); +}); + +test("free framing starts from the complete source and keeps one pixel visible", () => { + const centered = cropGeometry({ + viewportWidth: 400, viewportHeight: 400, + sourceWidth: 800, sourceHeight: 400, + zoom: 1, centerX: 0.5, centerY: 0.5, + }); + assert.deepEqual(centered, { width: 400, height: 200, left: 0, top: 100 }); + + const bounds = cropCenterBounds({ + viewportWidth: 400, viewportHeight: 400, + sourceWidth: 800, sourceHeight: 400, zoom: 1, + }); + const corner = cropGeometry({ + viewportWidth: 400, viewportHeight: 400, + sourceWidth: 800, sourceHeight: 400, zoom: 1, + centerX: bounds.minX, centerY: bounds.minY, + }); + assert.equal(corner.left, 393.75); + assert.equal(corner.top, 393.75); + assert.deepEqual( + clampCropCenter({ centerX: -100, centerY: 100 }, bounds), + { centerX: bounds.minX, centerY: bounds.maxY }, + ); +}); + +test("free framing copy is stable and catalogued", () => { + assert.match(view, /copy\.dynamic\.media\.mode_crop", "自由取景"/); + assert.equal(copyCatalog.items["copy.dynamic.media.mode_crop"]?.text, "自由取景"); + assert.equal(copyCatalog.items["copy.dynamic.media.crop_zoom"]?.text, "取景缩放"); +}); + +test("upload action has visible ordinary-build copy", () => { + assert.match(html, /id="media-upload-button"[^>]*>上传文件<\/button>/); + const item = Object.values(copyCatalog.items).find( + (candidate) => candidate.render?.selector === "#media-upload-button", + ); + assert.equal(item?.text, "上传文件"); + assert.equal(Boolean(item?.render?.default_hidden), false); +}); + +test("media naming happens after analysis and conflicts keep the draft", () => { + assert.match(view, /copy\.dynamic\.media\.name_conflict", "保存名称已存在,请更换名称。"/); + assert.match(view, /error\.message === "output name already exists"/); + assert.match(view, /const file = fileInput\.files\[0\]/); + assert.doesNotMatch(html, />保存名称<\/span>[\s\S]*media-upload-button/); + assert.equal(copyCatalog.items["copy.dynamic.media.name_conflict"]?.text, "保存名称已存在,请更换名称。"); +}); + +test("awaiting-settings cards survive polling without hiding server transitions", () => { + const rendered = { + id: "job-1", state: "awaiting_settings", updatedAt: "2026-08-15T10:00:00Z", dirty: false, + }; + const unchanged = { + id: "job-1", state: "awaiting_settings", updated_at: "2026-08-15T10:00:00Z", + }; + assert.equal(shouldReuseMediaJobCard(rendered, unchanged), true); + assert.equal(shouldReuseMediaJobCard( + { ...rendered, dirty: true }, + { ...unchanged, updated_at: "2026-08-15T10:01:00Z" }, + ), true); + assert.equal(shouldReuseMediaJobCard( + rendered, + { ...unchanged, updated_at: "2026-08-15T10:01:00Z" }, + ), false); + assert.equal(shouldReuseMediaJobCard( + rendered, + { ...unchanged, state: "queued" }, + ), false); +}); diff --git a/核桃派软件源代码/tests/test_migrate_state_root.py b/核桃派软件源代码/tests/test_migrate_state_root.py new file mode 100644 index 0000000..414a2aa --- /dev/null +++ b/核桃派软件源代码/tests/test_migrate_state_root.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json + +import pytest + +from scripts.migrate_state_root import ( + STAGING_NAME, + StateRootMigrationError, + build_manifest, + migrate_state_root, +) + + +def seed_legacy_state(root): + (root / "templates" / "records").mkdir(parents=True) + (root / "templates" / "thumbnails").mkdir(parents=True) + (root / "future-user-data").mkdir() + (root / "runtime").mkdir() + (root / ".portrait.tmp").mkdir() + (root / "config.json").write_text( + json.dumps({"brightness": 37, "voltage_calibration_factor": 1.01}), + encoding="utf-8", + ) + (root / "templates" / "records" / "template.json").write_text("{}", encoding="utf-8") + (root / "templates" / "thumbnails" / "template.png").write_bytes(b"png") + (root / "future-user-data" / "keep.bin").write_bytes(b"keep") + (root / "last_frame.png").write_bytes(b"runtime") + (root / "startup_indicator.json").write_text("{}", encoding="utf-8") + (root / "runtime" / "last_frame.png").write_bytes(b"new-runtime") + (root / ".portrait.tmp" / "keep.bin").write_bytes(b"keep") + (root / "config.json.tmp").write_text("partial", encoding="utf-8") + (root / "templates" / "records" / ".record.json.0123456789abcdef0123456789abcdef.tmp").write_text( + "partial", + encoding="utf-8", + ) + + +def test_migration_preserves_persistent_unknown_files_and_keeps_source(tmp_path): + source = tmp_path / "opt" / "data" + target = tmp_path / "var" / "lib" / "matrix-screen-controller" + seed_legacy_state(source) + + result = migrate_state_root(source, target) + + assert result.status == "migrated" + assert source.is_dir() + assert (source / "last_frame.png").exists() + assert (target / "config.json").exists() + assert (target / "templates" / "records" / "template.json").exists() + assert (target / "future-user-data" / "keep.bin").read_bytes() == b"keep" + assert not (target / "last_frame.png").exists() + assert not (target / "startup_indicator.json").exists() + assert not (target / "runtime").exists() + assert (target / ".portrait.tmp" / "keep.bin").read_bytes() == b"keep" + assert not (target / "config.json.tmp").exists() + assert not ( + target + / "templates" + / "records" + / ".record.json.0123456789abcdef0123456789abcdef.tmp" + ).exists() + assert build_manifest(source) == build_manifest(target) + + +def test_migration_is_idempotent_when_target_matches(tmp_path): + source = tmp_path / "legacy" + target = tmp_path / "state" + seed_legacy_state(source) + migrate_state_root(source, target) + + second = migrate_state_root(source, target) + + assert second.status == "already-prepared" + assert not (target.parent / STAGING_NAME).exists() + + +def test_migration_refuses_different_nonempty_target_without_modifying_it(tmp_path): + source = tmp_path / "legacy" + target = tmp_path / "state" + seed_legacy_state(source) + target.mkdir() + existing = target / "config.json" + existing.write_text('{"brightness": 99}', encoding="utf-8") + before = existing.read_bytes() + + with pytest.raises(StateRootMigrationError, match="refusing to merge"): + migrate_state_root(source, target) + + assert existing.read_bytes() == before + assert not (target.parent / STAGING_NAME).exists() + + +def test_missing_source_keeps_current_target_untouched(tmp_path): + source = tmp_path / "missing" + target = tmp_path / "state" + target.mkdir() + config = target / "config.json" + config.write_text('{"schema_version": 1}', encoding="utf-8") + + result = migrate_state_root(source, target) + + assert result.status == "target-only" + assert config.read_text(encoding="utf-8") == '{"schema_version": 1}' + + +def test_migration_refuses_overlapping_state_roots(tmp_path): + source = tmp_path / "legacy" + seed_legacy_state(source) + + with pytest.raises(StateRootMigrationError, match="must not contain"): + migrate_state_root(source, source / "new-state") + + +def test_missing_source_and_empty_target_fail_closed(tmp_path): + source = tmp_path / "missing" + target = tmp_path / "empty" + target.mkdir() + + with pytest.raises(StateRootMigrationError, match="no persistent files"): + migrate_state_root(source, target) + + +def test_empty_legacy_source_fails_closed(tmp_path): + source = tmp_path / "empty-source" + source.mkdir() + + with pytest.raises(StateRootMigrationError, match="source contains no persistent files"): + migrate_state_root(source, tmp_path / "target") + + +def test_state_root_symlink_is_rejected(tmp_path): + real_source = tmp_path / "real-source" + seed_legacy_state(real_source) + source_link = tmp_path / "source-link" + try: + source_link.symlink_to(real_source, target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable on this host") + + with pytest.raises(StateRootMigrationError, match="must not be symlinks"): + migrate_state_root(source_link, tmp_path / "target") diff --git a/核桃派软件源代码/tests/test_ota.py b/核桃派软件源代码/tests/test_ota.py new file mode 100644 index 0000000..705b97e --- /dev/null +++ b/核桃派软件源代码/tests/test_ota.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import hashlib +import io +import json +from pathlib import Path +import shutil +import tarfile +import zipfile + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app +from app.ota.diagnostics import clear_failure_log, persist_failure_log +from app.ota.package import OtaPackageError, build_package, extract_payload, inspect_package +from app.ota.state import write_last_result +from app.ota.versioning import read_feature_updated_at, SoftwareVersion, read_software_version + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] +CURRENT_VERSION = read_software_version(SOURCE_ROOT) +FEATURE_UPDATED_AT = read_feature_updated_at(SOURCE_ROOT) + + +def following_version(version: SoftwareVersion) -> str: + return str(SoftwareVersion(version.major, version.minor, version.patch + 1)) + + +def make_source(tmp_path: Path, version: str = "1.0.2") -> tuple[Path, Path]: + source = tmp_path / "source" + source.mkdir(parents=True) + (source / "VERSION").write_text(version + "\n", encoding="utf-8") + (source / "FEATURE_UPDATED_AT").write_text("2026-09-04T12:40+08:00\n", encoding="utf-8") + (source / "requirements.txt").write_text("fastapi==0.116.1\n", encoding="utf-8") + (source / "module.py").write_text("VALUE = 1\n", encoding="utf-8") + (source / "data").mkdir() + (source / "data" / "must-not-ship.json").write_text("{}", encoding="utf-8") + (source / "__pycache__").mkdir() + (source / "__pycache__" / "bad.pyc").write_bytes(b"bad") + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() + wheel = wheelhouse / "example.whl" + wheel.write_bytes(b"wheel") + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + (wheelhouse / "SHA256SUMS").write_text(f"{digest} {wheel.name}\n", encoding="utf-8") + return source, wheelhouse + + +def make_package(tmp_path: Path, version: str = "1.0.2") -> Path: + source, wheelhouse = make_source(tmp_path, version) + package = tmp_path / f"matrix-screen-controller-{version}.ota" + build_package( + source, + wheelhouse, + package, + version=SoftwareVersion.parse(version), + release_notes="test release", + ) + return package + + +def rewrite_zip(package: Path, replacements: dict[str, bytes]) -> None: + with zipfile.ZipFile(package, "r") as archive: + values = {name: archive.read(name) for name in archive.namelist()} + values.update(replacements) + package.unlink() + with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_STORED) as archive: + for name, value in values.items(): + archive.writestr(name, value) + + +def test_strict_software_version_ordering(): + assert SoftwareVersion.parse("1.0.1") > SoftwareVersion.parse("1.0.0") + assert SoftwareVersion.parse("1.10.0") > SoftwareVersion.parse("1.2.99") + for invalid in ("v1.0.0", "1.0", "01.0.0", "1.0.0-beta"): + with pytest.raises(ValueError): + SoftwareVersion.parse(invalid) + + +def test_feature_updated_at_requires_valid_beijing_minute(tmp_path): + (tmp_path / "FEATURE_UPDATED_AT").write_text("2026-09-04T12:40+08:00\n", encoding="utf-8") + assert read_feature_updated_at(tmp_path) == "2026-09-04T12:40+08:00" + + for invalid in ( + "2026-09-04T12:40:00+08:00", + "2026-09-04T12:40Z", + "2026-09-04T04:40+00:00", + "2026-02-30T12:40+08:00", + ): + (tmp_path / "FEATURE_UPDATED_AT").write_text(invalid + "\n", encoding="utf-8") + with pytest.raises(ValueError): + read_feature_updated_at(tmp_path) + + (tmp_path / "FEATURE_UPDATED_AT").unlink() + with pytest.raises(RuntimeError, match="timestamp file is unreadable"): + read_feature_updated_at(tmp_path) + + +def test_full_package_build_inspect_extract_and_exclusions(tmp_path): + package = make_package(tmp_path) + info = inspect_package(package, current_version=SoftwareVersion.parse("1.0.0")) + assert str(info.target_version) == "1.0.2" + destination = tmp_path / "unpacked" + extract_payload(info, destination) + assert (destination / "software" / "module.py").is_file() + assert (destination / "software" / "FEATURE_UPDATED_AT").read_text(encoding="utf-8").strip() == "2026-09-04T12:40+08:00" + assert (destination / "wheelhouse" / "SHA256SUMS").is_file() + assert not (destination / "software" / "data").exists() + assert not (destination / "software" / "__pycache__").exists() + + +def test_package_carries_verified_frpc_system_dependency(tmp_path): + source, wheelhouse = make_source(tmp_path) + system_dependencies = tmp_path / "frpc" + system_dependencies.mkdir() + binary = system_dependencies / "frpc" + binary.write_bytes(b"official-aarch64-frpc") + digest = hashlib.sha256(binary.read_bytes()).hexdigest() + (system_dependencies / "SHA256SUMS").write_text(f"{digest} frpc\n", encoding="utf-8") + package = tmp_path / "with-frpc.ota" + build_package( + source, + wheelhouse, + package, + version=SoftwareVersion.parse("1.0.2"), + release_notes="FRP payload", + system_dependencies=system_dependencies, + ) + + destination = tmp_path / "with-frpc-unpacked" + extract_payload(inspect_package(package), destination) + assert (destination / "software" / "system-dependencies" / "frpc" / "frpc").read_bytes() == binary.read_bytes() + assert (destination / "software" / "system-dependencies" / "frpc" / "SHA256SUMS").is_file() + + +def test_same_or_older_version_is_rejected(tmp_path): + package = make_package(tmp_path) + for current in ("1.0.2", "1.0.3", "2.0.0"): + with pytest.raises(OtaPackageError, match="must be newer"): + inspect_package(package, current_version=SoftwareVersion.parse(current)) + + +def test_payload_checksum_tampering_is_rejected(tmp_path): + package = make_package(tmp_path) + with zipfile.ZipFile(package, "r") as archive: + payload = archive.read("payload.tar.gz") + b"tampered" + rewrite_zip(package, {"payload.tar.gz": payload}) + with pytest.raises(OtaPackageError, match="checksum or size"): + inspect_package(package) + + +def test_unsafe_tar_path_is_rejected_during_extract(tmp_path): + package = make_package(tmp_path) + with zipfile.ZipFile(package, "r") as archive: + manifest = json.loads(archive.read("manifest.json")) + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + content = b"escape" + member = tarfile.TarInfo("software/../../escape") + member.size = len(content) + tar.addfile(member, io.BytesIO(content)) + payload = buffer.getvalue() + manifest["payload_sha256"] = hashlib.sha256(payload).hexdigest() + manifest["payload_bytes"] = len(payload) + manifest["expanded_bytes"] = len(b"escape") + rewrite_zip(package, { + "manifest.json": (json.dumps(manifest) + "\n").encode(), + "payload.tar.gz": payload, + }) + info = inspect_package(package) + with pytest.raises(OtaPackageError, match="unsafe OTA payload path"): + extract_payload(info, tmp_path / "unsafe") + assert not (tmp_path / "escape").exists() + + +def test_ota_api_exposes_software_version_and_accepts_one_new_package(tmp_path): + project = tmp_path / "project" + project.mkdir() + target = following_version(CURRENT_VERSION) + package = make_package(tmp_path / "package", target) + app = create_app( + project_root=project, + driver_kind="mock", + ota_worker_starter=lambda: None, + ota_staging_root=tmp_path / "ota-staging", + ) + with TestClient(app) as client: + status = client.get("/api/status").json() + assert status["service"]["software_version"] == str(CURRENT_VERSION) + assert status["service"]["feature_updated_at"] == FEATURE_UPDATED_AT + initial = client.get("/api/ota/status").json() + assert initial["current_version"] == str(CURRENT_VERSION) + assert initial["feature_updated_at"] == FEATURE_UPDATED_AT + assert initial["failure_log_available"] is False + assert initial["failure_log_bytes"] == 0 + assert client.get("/api/ota/failure-log").status_code == 404 + response = client.post( + f"/api/ota/update?filename=matrix-screen-controller-{target}.ota", + content=package.read_bytes(), + headers={"Content-Type": "application/octet-stream"}, + ) + assert response.status_code == 202 + assert response.json()["job"]["target_version"] == target + assert client.get("/api/ota/status").json()["active"] is True + locked = client.put("/api/config", json={"brightness": 30}) + assert locked.status_code == 423 + concurrent = client.post( + f"/api/ota/update?filename=matrix-screen-controller-{target}.ota", + content=package.read_bytes(), + headers={"Content-Type": "application/octet-stream"}, + ) + assert concurrent.status_code == 409 + + +def test_ota_api_rejects_same_version_before_starting_worker(tmp_path): + project = tmp_path / "project" + project.mkdir() + package = make_package(tmp_path / "package", str(CURRENT_VERSION)) + starts = [] + app = create_app( + project_root=project, + driver_kind="mock", + ota_worker_starter=lambda: starts.append(True), + ota_staging_root=tmp_path / "ota-staging", + ) + with TestClient(app) as client: + response = client.post( + f"/api/ota/update?filename=matrix-screen-controller-{CURRENT_VERSION}.ota", + content=package.read_bytes(), + headers={"Content-Type": "application/octet-stream"}, + ) + assert response.status_code == 409 + assert starts == [] + + +def test_ota_failure_log_api_requires_failed_result_and_disables_caching(tmp_path): + project = tmp_path / "project" + project.mkdir() + app = create_app(project_root=project, driver_kind="mock") + data_root = app.state.ota_manager.data_root + runtime_log = tmp_path / "ota-worker.log" + runtime_log.write_text("\npytest failed\n", encoding="utf-8") + persist_failure_log(data_root, runtime_log) + + with TestClient(app) as client: + assert client.get("/api/ota/failure-log").status_code == 404 + write_last_result(data_root, {"status": "failed", "target_version": "9.9.9"}) + status = client.get("/api/ota/status").json() + assert status["failure_log_available"] is True + assert status["failure_log_bytes"] == len(runtime_log.read_bytes()) + response = client.get("/api/ota/failure-log") + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.headers["content-type"].startswith("text/plain") + assert response.content == runtime_log.read_bytes() + + write_last_result(data_root, {"status": "success", "target_version": "9.9.9"}) + assert client.get("/api/ota/status").json()["failure_log_available"] is False + assert client.get("/api/ota/failure-log").status_code == 404 + clear_failure_log(data_root) diff --git a/核桃派软件源代码/tests/test_ota_checkpoints.py b/核桃派软件源代码/tests/test_ota_checkpoints.py new file mode 100644 index 0000000..aba5bd0 --- /dev/null +++ b/核桃派软件源代码/tests/test_ota_checkpoints.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import zipfile + +import pytest + +from app.ota.policy import check_upgrade, export_bundle, package_format, read_policy +from app.ota.versioning import SoftwareVersion as V +from app.ota.package import build_package, inspect_package, OtaPackageError +from scripts import ota_components as c + + +@pytest.mark.parametrize('current,target', [ + ('1.0.0', '1.0.3'), ('1.0.3', '1.1.0'), ('1.0.6', '1.1.0'), + ('1.1.0', '1.1.3'), ('1.1.0', '1.2.0'), ('1.1.8', '1.2.0'), ('1.2.0', '1.2.9'), +]) +def test_allowed_upgrade_paths(current, target): + check_upgrade(V.parse(current), V.parse(target)) + + +@pytest.mark.parametrize('current,target,required', [ + ('1.0.6', '1.1.1', '1.1.0'), ('1.0.3', '1.2.0', '1.1.0'), + ('1.0.0', '1.2.5', '1.1.0'), ('1.1.3', '1.2.1', '1.2.0'), +]) +def test_cannot_skip_software_install(current, target, required): + with pytest.raises(ValueError, match=required): + check_upgrade(V.parse(current), V.parse(target)) + + +def source_and_wheels(root, version): + source = root / 'source' + source.mkdir() + (source / 'VERSION').write_text(version, encoding='utf-8') + wheels = root / 'wheels' + wheels.mkdir() + (wheels / 'SHA256SUMS').write_text('test', encoding='utf-8') + return source, wheels + + +def test_bridge_v1_and_patch_v2_with_identical_manifest_shape(tmp_path): + for version in ('1.1.0', '1.1.3'): + root = tmp_path / version + root.mkdir() + source, wheels = source_and_wheels(root, version) + package = root / 'release.ota' + build_package(source, wheels, package, version=V.parse(version), release_notes='test') + with zipfile.ZipFile(package) as z: + manifest = json.loads(z.read('manifest.json')) + assert manifest['format_version'] == package_format(V.parse(version)) + if version == '1.1.3': + with pytest.raises(OtaPackageError, match='1.1.0'): + inspect_package(package, current_version=V.parse('1.0.6')) + inspect_package(package, current_version=V.parse('1.1.0')) + + +def test_patch_rejects_system_binary_payload(tmp_path): + source, wheels = source_and_wheels(tmp_path, '1.1.1') + with pytest.raises(OtaPackageError, match='普通补丁'): + build_package(source, wheels, tmp_path/'bad.ota', version=V.parse('1.1.1'), + release_notes='test', system_dependencies=wheels) + + +def test_dependency_change_requires_checkpoint(tmp_path): + source = tmp_path / 'source' + source.mkdir() + binary = tmp_path / 'deps/frp/v/frpc' + binary.parent.mkdir(parents=True) + binary.write_bytes(b'original') + policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': { + 'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': hashlib.sha256(b'original').hexdigest()}}}]} + (source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8') + assert export_bundle(source, tmp_path/'deps', V.parse('1.0.6'), V.parse('1.1.0')) == binary.parent + assert export_bundle(source, tmp_path/'deps', V.parse('1.1.0'), V.parse('1.1.3')) is None + binary.write_bytes(b'replaced') + with pytest.raises(ValueError, match='依赖发生变化'): + export_bundle(source, tmp_path/'deps', V.parse('1.1.0'), V.parse('1.1.3')) + policy['checkpoints'][0]['version'] = '1.2.0' + (source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8') + with pytest.raises(ValueError, match='consecutive'): + read_policy(source) + + +@pytest.fixture +def host(tmp_path, monkeypatch): + for name, relative in [('DATA', 'var/data'), ('TARGET', 'opt/current'), ('RELEASES', 'opt/releases'), + ('RUNTIME', 'run'), ('HELPER', 'opt/recover.py'), ('RECOVERY_UNIT', 'etc/recovery.service'), + ('WORK_ROOT', 'opt/work'), ('ACCOUNT_POLICY', 'etc/account'), + ('RUNTIME_GUARD', 'run/systemd/service.d/guard.conf')]: + monkeypatch.setattr(c, name, tmp_path / relative) + files = {name: tmp_path / 'etc' / name for name in c.FILES} + monkeypatch.setattr(c, 'FILES', files) + c.DATA.mkdir(parents=True) + c.RELEASES.mkdir(parents=True) + for name, path in files.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(('old-'+name).encode()) + commands = [] + def run(args, **kwargs): + commands.append(args) + return subprocess.CompletedProcess(args, 0, b'', b'') + monkeypatch.setattr(c, 'run', run) + monkeypatch.setattr(c, 'sync_directory', lambda path: None) + monkeypatch.setattr(c, 'apply_metadata', lambda *args: None) + return commands + + +def make_journal(): + journal = c.DATA / c.JOURNAL + journal.mkdir(parents=True) + record = {'job_id': 'job123', 'version': '1.1.0', 'old_version': '1.0.6', + 'accepted_at': '2026-09-07T00:00:00Z', 'previous_target': None, + 'permissions': {}, 'active': True, 'enabled': True, + 'files': {name: {'mode': 0o644, 'uid': 0, 'gid': 0} for name in c.FILES}} + for name, path in c.FILES.items(): + (journal/name).write_bytes(path.read_bytes()) + (journal/'state.json').write_text(json.dumps(record), encoding='utf-8') + return journal, record + + +def test_failed_install_restores_system_bytes_and_running_state(host): + journal, record = make_journal() + for name in ('frpc', 'frpc-unit', 'frpc-dropin'): + c.FILES[name].write_bytes(b'new') + c.restore_components(journal, record) + for name in ('frpc', 'frpc-unit', 'frpc-dropin'): + assert c.FILES[name].read_bytes() == ('old-'+name).encode() + assert ['systemctl', 'enable', c.FRP] in host + assert ['systemctl', 'start', c.FRP] in host + + +def test_power_loss_between_data_renames_recovers_original(host): + journal, record = make_journal() + (c.DATA/'user-content').write_bytes(b'unchanged') + backup = c.DATA.with_name('matrix-screen-controller.rollback.job123') + c.DATA.rename(backup) + assert c.locate_journal() == backup / c.JOURNAL + recovered = c.recover_application(backup/c.JOURNAL, record) + assert recovered == c.DATA/c.JOURNAL + assert (c.DATA/'user-content').read_bytes() == b'unchanged' + assert not backup.exists() + + +def test_legacy_failure_restores_ota_unit_too(host): + journal, record = make_journal() + c.FILES['ota-unit'].write_bytes(b'new-worker-unit') + c.recover_application(journal, record) + assert c.FILES['ota-unit'].read_bytes() == b'old-ota-unit' + + +def test_success_cleanup_keeps_installed_component_and_user_data(host): + journal, record = make_journal() + c.TARGET.mkdir(parents=True) + (c.TARGET/'VERSION').write_text('1.1.0', encoding='utf-8') + result = {'last_result': {'status': 'success', 'target_version': '1.1.0', 'installed_at': '2026-09-07T01:00:00Z'}} + (c.DATA/'ota/state.json').write_text(json.dumps(result), encoding='utf-8') + (c.DATA/'user-content').write_bytes(b'keep') + c.FILES['frpc'].write_bytes(b'new-verified') + c.finish() + assert c.FILES['frpc'].read_bytes() == b'new-verified' + assert (c.DATA/'user-content').read_bytes() == b'keep' + assert not journal.exists() + assert ['systemctl', 'stop', c.FRP] not in host + + +def test_old_success_result_cannot_commit_new_transaction(host): + journal, record = make_journal() + c.TARGET.mkdir(parents=True) + (c.TARGET/'VERSION').write_text('1.1.0', encoding='utf-8') + (c.DATA/'ota/state.json').write_text(json.dumps({'last_result': { + 'status': 'success', 'target_version': '1.1.0', 'installed_at': '2026-09-01T00:00:00Z'}}), encoding='utf-8') + assert not c.committed(record) + + +def test_normal_migration_does_not_touch_components(host): + c.begin(c.TARGET, c.DATA) + assert host == [] + + +def release_project(tmp_path, monkeypatch): + from scripts import export_release as exporter + source = tmp_path / 'software' + source.mkdir() + (source / 'VERSION').write_text('1.0.6\n', encoding='utf-8') + (source / 'FEATURE_UPDATED_AT').write_text('2026-09-07T20:00+08:00\n', encoding='utf-8') + deps = tmp_path / '发布更新相关/其他依赖' + binary = deps / 'frp/v/frpc' + binary.parent.mkdir(parents=True) + binary.write_bytes(b'frpc') + digest = hashlib.sha256(b'frpc').hexdigest() + (binary.parent/'SHA256SUMS').write_text(f'{digest} frpc\n', encoding='utf-8') + wheels = deps / 'aarch64-py311' + wheels.mkdir() + (wheels/'SHA256SUMS').write_text('fixture', encoding='utf-8') + policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': { + 'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]} + (source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8') + (tmp_path/'发布记录.json').write_text('{"schema_version":1,"releases":[]}', encoding='utf-8') + monkeypatch.setattr(exporter, 'SOURCE_ROOT', source) + return exporter, source + + +def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch): + import sys + import tarfile + exporter, source = release_project(tmp_path, monkeypatch) + monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test']) + assert exporter.main() == 0 + assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0' + monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch']) + assert exporter.main() == 0 + artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota')) + with zipfile.ZipFile(artifact) as z, z.open('payload.tar.gz') as f, tarfile.open(fileobj=f, mode='r|gz') as t: + assert not any('system-dependencies' in item.name for item in t) + records = json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8'))['releases'] + assert [r['package_kind'] for r in records] == ['software-install', 'application'] + assert all(r['artifact_path'].startswith('发布更新相关/OTA数据包/') for r in records) + assert all((tmp_path / r['artifact_path']).is_file() for r in records) + assert (source/'FEATURE_UPDATED_AT').read_text(encoding='utf-8') == '2026-09-07T20:00+08:00\n' + + +def test_failed_export_does_not_consume_version(tmp_path, monkeypatch): + import sys + exporter, source = release_project(tmp_path, monkeypatch) + original = (tmp_path/'发布记录.json').read_bytes() + original_version = (source/'VERSION').read_bytes() + monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test']) + def fail(*args): + raise OSError('injected history write failure') + monkeypatch.setattr(exporter, '_atomic_json', fail) + with pytest.raises(OSError, match='injected'): + exporter.main() + assert (source/'VERSION').read_bytes() == original_version + assert (tmp_path/'发布记录.json').read_bytes() == original + assert not list((tmp_path/'发布更新相关/OTA数据包').iterdir()) + assert not (tmp_path/'.release-export.lock').exists() + + +def test_recovery_error_keeps_durable_journal(host, monkeypatch): + journal, record = make_journal() + def fail(*args): + raise OSError('injected restore failure') + monkeypatch.setattr(c, 'restore_components', fail) + with pytest.raises(OSError, match='injected'): + c.finish() + assert (journal/'state.json').is_file() + + +def test_provisioned_account_does_not_need_sudo_group(host, monkeypatch): + import sys + from types import SimpleNamespace + c.FILES['frpc-dropin'].unlink() + c.ACCOUNT_POLICY.write_text('maintainer ALL=(ALL:ALL) ALL\n', encoding='utf-8') + monkeypatch.setitem(sys.modules, 'pwd', SimpleNamespace(getpwnam=lambda name: SimpleNamespace(pw_uid=1001))) + monkeypatch.setitem(sys.modules, 'grp', SimpleNamespace()) + assert c.account() == 'maintainer' + c.ACCOUNT_POLICY.write_text('invalid', encoding='utf-8') + with pytest.raises(RuntimeError, match='账户配置无效'): + c.account() + + +@pytest.mark.parametrize('fail_commit', [False, True]) +def test_same_version_repair_promotes_exact_candidate_and_preserves_old_artifact(tmp_path, monkeypatch, fail_commit): + import sys + from scripts.repair_ota_release import promote + exporter, source = release_project(tmp_path, monkeypatch) + monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original']) + exporter.main() + artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota' + old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes() + (source/'fix.py').write_text('FIXED = True\n',encoding='utf-8') + candidate=tmp_path/'candidate.ota' + build_package(source,tmp_path/'发布更新相关/其他依赖/aarch64-py311',candidate, + version=V.parse('1.1.0'),release_notes='repair', + system_dependencies=tmp_path/'发布更新相关/其他依赖/frp/v') + report=tmp_path/'validation.json' + report.write_text(json.dumps({'package_sha256':hashlib.sha256(candidate.read_bytes()).hexdigest(), + 'installed_version':'1.1.0','status':'success','runtime_lifecycle_passed':True, + 'user_data_preserved':True,'frp_state_preserved':True,'transaction_cleanup_passed':True}),encoding='utf-8') + if fail_commit: + def fail(*args): raise OSError('injected publication failure') + monkeypatch.setattr(exporter,'_atomic_json',fail) + with pytest.raises(OSError,match='publication'): + promote(source,candidate,report,'repair') + assert artifact.read_bytes()==old + assert (tmp_path/'发布记录.json').read_bytes()==old_history + else: + promote(source,candidate,report,'repair') + assert artifact.read_bytes()==candidate.read_bytes() + history=json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8')) + assert len(history['releases'])==1 + assert len(history['releases'][0]['repairs'])==1 + assert (source/'VERSION').read_text(encoding='utf-8').strip()=='1.1.0' + archived=list((tmp_path/'各种归档').glob('*/原安装包/*.ota')) + assert len(archived)==1 and archived[0].read_bytes()==old diff --git a/核桃派软件源代码/tests/test_ota_diagnostics.py b/核桃派软件源代码/tests/test_ota_diagnostics.py new file mode 100644 index 0000000..1e6e5cc --- /dev/null +++ b/核桃派软件源代码/tests/test_ota_diagnostics.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from app.ota.diagnostics import ( + MAX_FAILURE_LOG_BYTES, + DiagnosticLog, + bounded_failure_log, + clear_failure_log, + failure_log_metadata, + failure_log_path, + persist_failure_log, + read_failure_log, +) +from app.ota.state import read_last_result +from scripts import ota_worker + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] + + +def test_failure_log_normalizes_invalid_utf8_and_truncates_head_and_tail(): + body = b"HEADER\xff\n" + (b"middle\n" * 200_000) + b"LATEST FAILURE\n" + rendered = bounded_failure_log(body) + assert len(rendered) <= MAX_FAILURE_LOG_BYTES + text = rendered.decode("utf-8") + assert "HEADER�" in text + assert "日志已截断" in text + assert text.endswith("LATEST FAILURE\n") + + +def test_failure_log_is_atomically_replaced_gated_by_result_and_cleared(tmp_path): + data_root = tmp_path / "data" + runtime_log = tmp_path / "ota-worker.log" + runtime_log.write_bytes(b"first") + target = persist_failure_log(data_root, runtime_log) + failed = {"status": "failed"} + assert target == failure_log_path(data_root) + assert failure_log_metadata(data_root, failed) == (True, 5) + assert read_failure_log(data_root, failed) == b"first" + assert read_failure_log(data_root, {"status": "success"}) is None + + runtime_log.write_bytes(b"second failure") + persist_failure_log(data_root, runtime_log) + assert read_failure_log(data_root, failed) == b"second failure" + assert list(target.parent.glob("*")) == [target] + + clear_failure_log(data_root) + assert failure_log_metadata(data_root, failed) == (False, 0) + + +def test_diagnostic_command_captures_success_failure_non_utf8_and_start_error(tmp_path): + log = DiagnosticLog(tmp_path / "worker.log") + log.reset() + result = log.run([sys.executable, "-c", "print('pytest output')"], failure_label="success") + assert result.returncode == 0 + + with pytest.raises(RuntimeError, match="7"): + log.run( + [sys.executable, "-c", "import os,sys; os.write(1, b'bad-\\xff'); sys.exit(7)"], + failure_label="single test", + ) + with pytest.raises(RuntimeError, match="无法启动"): + log.run([str(tmp_path / "missing-command")], failure_label="startup") + + text = log.path.read_text(encoding="utf-8") + assert "pytest output" in text + assert "bad-�" in text + assert "exit_code=7" in text + assert "command_start_failed=" in text + + secret_log = DiagnosticLog(tmp_path / "redacted.log") + secret_log.reset() + secret_log.run( + [sys.executable, "-c", "print('ssid=private-network ip=192.0.2.10')"], + log_output=False, + ) + redacted = secret_log.path.read_text(encoding="utf-8") + assert "private-network" not in redacted + assert "192.0.2.10" not in redacted + assert "已按脱敏规则隐藏" in redacted + + +def test_legacy_103_worker_environment_keeps_all_test_temporary_paths_in_transaction(tmp_path): + transaction = tmp_path / "work.legacy103" + environment = os.environ.copy() + for name in ("MATRIX_TEST_ROOT", "TMPDIR", "TEMP", "TMP", "PYTEST_ADDOPTS"): + environment.pop(name, None) + environment.update( + MATRIX_DATA_DIR=str(transaction / "test-data"), + MATRIX_RUNTIME_DIR=str(transaction / "test-runtime"), + MATRIX_SOURCE_ONLY_UPDATE_TESTS="1", + MATRIX_LEGACY_ISOLATION_PROBE="1", + PYTHONPATH=str(SOURCE_ROOT), + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-p", + "no:cacheprovider", + "-q", + "tests/test_ota_isolation_probe.py", + "--tb=short", + "--disable-warnings", + ], + cwd=SOURCE_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout + assert not (transaction.parent / ".pytest_cache").exists() + + strict_environment = dict(environment, MATRIX_STRICT_PYTEST_FAILURE_PROBE="1") + strict = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-p", + "no:cacheprovider", + "-q", + "tests/test_ota_isolation_probe.py", + "--tb=short", + "--disable-warnings", + ], + cwd=SOURCE_ROOT, + env=strict_environment, + capture_output=True, + text=True, + timeout=60, + ) + assert strict.returncode != 0 + assert "strict legacy-worker pytest probe executed" in strict.stdout + + +def test_development_source_contains_no_diagnostic_pytest_bypass(): + conftest = (SOURCE_ROOT / "tests" / "conftest.py").read_text(encoding="utf-8") + retired_marker = "OTA_" + "DIAGNOSTIC_" + "BOOTSTRAP" + retired_symbol = "diagnostic_" + "bootstrap" + pytest_early_exit = "pytest" + ".exit" + retired_override = "MATRIX_FORCE_" + "LEGACY_ISOLATION_PROBE" + assert not (SOURCE_ROOT / retired_marker).exists() + assert retired_symbol not in conftest + assert pytest_early_exit not in conftest + assert retired_override not in conftest + + +def test_worker_and_manual_entry_keep_strict_pytest_and_transaction_paths(): + worker = (SOURCE_ROOT / "scripts" / "ota_worker.py").read_text(encoding="utf-8") + manual = (SOURCE_ROOT / "scripts" / "update_walnutpi.sh").read_text(encoding="utf-8") + for option in ("no:cacheprovider", "--basetemp", "--tb=short", "--disable-warnings"): + assert option in worker + assert option in manual + for variable in ("MATRIX_TEST_ROOT", "TMPDIR", "MATRIX_SOURCE_ONLY_UPDATE_TESTS"): + assert variable in worker + assert variable in manual + assert "check=False" not in worker.split('failure_label="新版本 pytest"')[0].rsplit("self.run", 1)[1] + + +def test_pre_switch_failure_preserves_installed_state_and_records_rollback_problem(tmp_path, monkeypatch): + target = tmp_path / "opt" / "matrix-screen-controller" + releases = tmp_path / "opt" / "releases" + data_root = tmp_path / "var" / "data" + runtime_root = tmp_path / "run" + work_root = tmp_path / "opt" / "ota" + unit = tmp_path / "etc" / "matrix-screen-controller.service" + package = tmp_path / "uploaded.ota" + for directory in (target, releases, data_root, runtime_root, work_root, unit.parent): + directory.mkdir(parents=True, exist_ok=True) + (target / "old-program").write_text("unchanged", encoding="utf-8") + (data_root / "user-data").write_text("unchanged", encoding="utf-8") + unit.write_text("old unit", encoding="utf-8") + package.write_bytes(b"candidate") + + monkeypatch.setattr(ota_worker, "TARGET", target) + monkeypatch.setattr(ota_worker, "RELEASES", releases) + monkeypatch.setattr(ota_worker, "DATA_ROOT", data_root) + monkeypatch.setattr(ota_worker, "RUNTIME_ROOT", runtime_root) + monkeypatch.setattr(ota_worker, "OTA_WORK_ROOT", work_root) + monkeypatch.setattr(ota_worker, "UNIT_PATH", unit) + monkeypatch.setattr(ota_worker, "REQUEST_PATH", runtime_root / "ota-request.json") + request = { + "job_id": "rollback-test", + "current_version": "1.0.4", + "target_version": "1.0.5", + "package_path": str(package), + "status_path": str(runtime_root / "ota-status.json"), + "request_path": str(runtime_root / "ota-request.json"), + } + transaction = ota_worker.Transaction(request) + transaction.work_root.mkdir(parents=True) + transaction.release.mkdir(parents=True) + transaction.data_candidate.mkdir(parents=True) + monkeypatch.setattr( + transaction, + "stop_visual", + lambda: (_ for _ in ()).throw(RuntimeError("simulated rollback fault")), + ) + + transaction.rollback(RuntimeError("pytest failed")) + + assert (target / "old-program").read_text(encoding="utf-8") == "unchanged" + assert (data_root / "user-data").read_text(encoding="utf-8") == "unchanged" + assert unit.read_text(encoding="utf-8") == "old unit" + assert not transaction.release.exists() + assert not transaction.data_candidate.exists() + assert not transaction.work_root.exists() + result = read_last_result(data_root) + assert result is not None and result["status"] == "failed" + saved = failure_log_path(data_root).read_text(encoding="utf-8") + assert "pytest failed" in saved + assert "simulated rollback fault" in saved + assert "final_recovery_state=" in saved diff --git a/核桃派软件源代码/tests/test_ota_isolation_probe.py b/核桃派软件源代码/tests/test_ota_isolation_probe.py new file mode 100644 index 0000000..bf3f2eb --- /dev/null +++ b/核桃派软件源代码/tests/test_ota_isolation_probe.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import os +from pathlib import Path +import tempfile + +import pytest + + +@pytest.mark.skipif( + os.environ.get("MATRIX_LEGACY_ISOLATION_PROBE") != "1", + reason="only executed by the isolated legacy-worker compatibility test", +) +def test_legacy_worker_paths_are_transaction_scoped(tmp_path): + root = Path(os.environ["MATRIX_TEST_ROOT"]).resolve() + assert Path(os.environ["MATRIX_DATA_DIR"]).resolve().is_relative_to(root) + assert Path(os.environ["MATRIX_RUNTIME_DIR"]).resolve().is_relative_to(root) + assert Path(tempfile.gettempdir()).resolve().is_relative_to(root) + assert tmp_path.resolve().is_relative_to(root) + with tempfile.NamedTemporaryFile() as handle: + assert Path(handle.name).resolve().is_relative_to(root) + if os.environ.get("MATRIX_STRICT_PYTEST_FAILURE_PROBE") == "1": + pytest.fail("strict legacy-worker pytest probe executed") diff --git a/核桃派软件源代码/tests/test_ota_runtime_lifecycle.py b/核桃派软件源代码/tests/test_ota_runtime_lifecycle.py new file mode 100644 index 0000000..3fd227a --- /dev/null +++ b/核桃派软件源代码/tests/test_ota_runtime_lifecycle.py @@ -0,0 +1,112 @@ +from pathlib import Path +import shutil +import subprocess + +import pytest + +from app.ota.diagnostics import DiagnosticLog, MAX_FAILURE_LOG_BYTES +from scripts import ota_components as component + + +def test_directory_loss_restores_recent_diagnostics(tmp_path): + path = tmp_path / 'run/log' + log = DiagnosticLog(path) + log.reset();log.write('before stop') + shutil.rmtree(path.parent) + log.write('after stop') + assert 'before stop' in path.read_text(encoding='utf-8') + assert 'after stop' in path.read_text(encoding='utf-8') + + +def test_unwritable_log_does_not_mask_command_failure_or_prevent_persistence(tmp_path, monkeypatch, capsys): + path = tmp_path / 'run/log' + log = DiagnosticLog(path) + log.reset() + original = Path.open + def broken(self, *args, **kwargs): + if self == path: + raise PermissionError('do not print confidential OS details') + return original(self, *args, **kwargs) + monkeypatch.setattr(Path, 'open', broken) + monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 9, b'original failure')) + with pytest.raises(RuntimeError, match='original failure'): + log.run(['example']) + log.write('rollback executed') + target = log.persist(tmp_path/'data') + assert 'rollback executed' in target.read_text(encoding='utf-8') + assert 'confidential' not in capsys.readouterr().err + + +def test_fallback_buffer_is_bounded(tmp_path): + log = DiagnosticLog(tmp_path/'log') + log.write('x' * (MAX_FAILURE_LOG_BYTES * 2)) + assert len(log._recent) <= MAX_FAILURE_LOG_BYTES + + +def test_runtime_guard_verified_and_does_not_overwrite_user_configuration(tmp_path, monkeypatch): + path = tmp_path/'dropin/guard.conf' + monkeypatch.setattr(component, 'RUNTIME_GUARD', path) + monkeypatch.setattr(component, 'sync_directory', lambda p: None) + commands=[] + def run(args, **kwargs): + commands.append(args) + return subprocess.CompletedProcess(args, 0, b'yes\n') + monkeypatch.setattr(component, 'run', run) + component.protect_runtime() + assert path.read_bytes() == component.RUNTIME_GUARD_BODY + assert commands[-1][1] == 'show' + component.release_runtime_guard() + assert not path.exists() + path.parent.mkdir();path.write_bytes(b'user setting') + with pytest.raises(RuntimeError, match='overwrite'): + component.protect_runtime() + with pytest.raises(RuntimeError, match='modified'): + component.release_runtime_guard() + assert path.read_bytes() == b'user setting' + + +def test_ineffective_guard_refuses_to_proceed(tmp_path, monkeypatch): + monkeypatch.setattr(component, 'RUNTIME_GUARD', tmp_path/'guard') + monkeypatch.setattr(component, 'sync_directory', lambda p: None) + monkeypatch.setattr(component, 'run', lambda a, **k: subprocess.CompletedProcess(a,0,b'restart\n')) + with pytest.raises(RuntimeError, match='refusing to stop'): + component.protect_runtime() + + +def test_component_validation_accepts_current_transaction_runtime_guard(tmp_path, monkeypatch): + import hashlib + import json + import os + import sys + from types import SimpleNamespace + source = tmp_path/'source' + (source/'systemd').mkdir(parents=True) + (source/'systemd/matrix-screen-frpc.service').write_bytes(b'unit') + (source/'UPGRADE_POLICY.json').write_text(json.dumps({'checkpoints':[ + {'version':'1.1.0','components':{'frpc':{'version':'0.71.0', + 'sha256':hashlib.sha256(b'binary').hexdigest()}}}]}),encoding='utf-8') + files={name:tmp_path/name for name in ('frpc','frpc-unit','frpc-dropin')} + for name,body in [('frpc',b'binary'),('frpc-unit',b'unit'), + ('frpc-dropin',b'[Service]\nUser=maintainer\nGroup=maintainer\n')]: + files[name].write_bytes(body) + data=tmp_path/'data';(data/'frp/profiles').mkdir(parents=True) + guard=tmp_path/'guard';guard.write_bytes(component.RUNTIME_GUARD_BODY) + monkeypatch.setattr(component,'FILES',files) + monkeypatch.setattr(component,'DATA',data) + monkeypatch.setattr(component,'RUNTIME_GUARD',guard) + monkeypatch.setattr(component,'account',lambda:'maintainer') + monkeypatch.setitem(sys.modules,'pwd',SimpleNamespace(getpwnam=lambda u:SimpleNamespace(pw_gid=1000))) + monkeypatch.setitem(sys.modules,'grp',SimpleNamespace(getgrgid=lambda g:SimpleNamespace(gr_name='maintainer'))) + monkeypatch.setattr(component,'run',lambda a,**k:subprocess.CompletedProcess(a,0,b'0.71.0\n')) + monkeypatch.setattr(component,'metadata',lambda p:{'mode':0o755 if p==files['frpc'] else 0o2750 if p.is_relative_to(data) else 0o644, + 'uid':0,'gid':1000 if p.is_relative_to(data) else 0}) + original=Path.stat + def normalized_stat(self,*a,**k): + value=original(self,*a,**k) + fields=list(value) + if self==data:fields[0]=(fields[0]&~0o7777)|0o711 + if self==files['frpc']:fields[4]=fields[5]=0 + return os.stat_result(fields) + monkeypatch.setattr(Path,'stat',normalized_stat) + component.check_installed(source,'1.1.0') + assert guard.read_bytes()==component.RUNTIME_GUARD_BODY diff --git a/核桃派软件源代码/tests/test_performance_mode.py b/核桃派软件源代码/tests/test_performance_mode.py new file mode 100644 index 0000000..17ff283 --- /dev/null +++ b/核桃派软件源代码/tests/test_performance_mode.py @@ -0,0 +1,153 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app +from app.system.performance import PerformanceModeError, PerformanceModeManager + + +def make_policy(root: Path, name: str, governor: str = "schedutil") -> Path: + policy = root / name + policy.mkdir(parents=True) + (policy / "scaling_governor").write_text(f"{governor}\n", encoding="ascii") + (policy / "scaling_available_governors").write_text( + "conservative ondemand userspace powersave performance schedutil\n", + encoding="ascii", + ) + return policy + + +def read_governor(policy: Path) -> str: + return (policy / "scaling_governor").read_text(encoding="ascii").strip() + + +def test_performance_mode_applies_all_policies_and_restores_baseline(tmp_path): + sysfs = tmp_path / "sysfs" + policy0 = make_policy(sysfs, "policy0", "schedutil") + policy4 = make_policy(sysfs, "policy4", "powersave") + manager = PerformanceModeManager(tmp_path / "run", sysfs_root=sysfs) + saved: list[bool] = [] + + manager.transact(True, saved.append) + + assert saved == [True] + assert read_governor(policy0) == "performance" + assert read_governor(policy4) == "performance" + assert manager.status(True) == { + "requested": True, + "available": True, + "effective": True, + "current_governors": {"policy0": "performance", "policy4": "performance"}, + "restore_governors": {"policy0": "schedutil", "policy4": "powersave"}, + "last_error": None, + } + + manager.transact(False, saved.append) + + assert saved == [True, False] + assert read_governor(policy0) == "schedutil" + assert read_governor(policy4) == "powersave" + + +def test_performance_mode_rolls_back_all_policies_when_write_fails(tmp_path, monkeypatch): + sysfs = tmp_path / "sysfs" + policy0 = make_policy(sysfs, "policy0", "schedutil") + policy4 = make_policy(sysfs, "policy4", "powersave") + manager = PerformanceModeManager(tmp_path / "run", sysfs_root=sysfs) + original_write = manager._write_text + failed = False + + def fail_policy4(path: Path, value: str) -> None: + nonlocal failed + if path.parent.name == "policy4" and value == "performance" and not failed: + failed = True + raise OSError("injected write failure") + original_write(path, value) + + monkeypatch.setattr(manager, "_write_text", fail_policy4) + + with pytest.raises(PerformanceModeError, match="injected write failure"): + manager.transact(True, lambda _enabled: None) + + assert read_governor(policy0) == "schedutil" + assert read_governor(policy4) == "powersave" + assert manager.status(False)["effective"] is False + + +def test_performance_mode_rolls_back_governors_when_persistence_fails(tmp_path): + sysfs = tmp_path / "sysfs" + policy0 = make_policy(sysfs, "policy0", "schedutil") + manager = PerformanceModeManager(tmp_path / "run", sysfs_root=sysfs) + + def fail_persist(_enabled: bool) -> None: + raise OSError("injected persistence failure") + + with pytest.raises(PerformanceModeError, match="transaction failed"): + manager.transact(True, fail_persist) + + assert read_governor(policy0) == "schedutil" + assert manager.status(False)["last_error"] == "injected persistence failure" + + +def test_preserved_record_prevents_performance_from_becoming_restart_baseline(tmp_path): + sysfs = tmp_path / "sysfs" + policy0 = make_policy(sysfs, "policy0", "schedutil") + runtime = tmp_path / "run" + first = PerformanceModeManager(runtime, sysfs_root=sysfs) + first.transact(True, lambda _enabled: None) + + restarted = PerformanceModeManager(runtime, sysfs_root=sysfs) + + assert restarted.status(True)["restore_governors"] == {"policy0": "schedutil"} + restarted.restore(remove_record=True) + assert read_governor(policy0) == "schedutil" + assert not restarted.record_path.exists() + + +def test_unavailable_cpufreq_is_reported_without_fake_success(tmp_path): + manager = PerformanceModeManager(tmp_path / "run", sysfs_root=tmp_path / "missing") + + assert manager.status(False) == { + "requested": False, + "available": False, + "effective": False, + "current_governors": {}, + "restore_governors": {}, + "last_error": None, + } + with pytest.raises(PerformanceModeError, match="unavailable"): + manager.transact(True, lambda _enabled: None) + + +def test_config_api_performance_mode_is_transactional_with_fake_sysfs(tmp_path): + sysfs = tmp_path / "sysfs" + policy0 = make_policy(sysfs, "policy0", "schedutil") + + def factory(runtime_root: Path) -> PerformanceModeManager: + return PerformanceModeManager(runtime_root, sysfs_root=sysfs) + + with TestClient( + create_app( + project_root=tmp_path / "project", + driver_kind="mock", + performance_manager_factory=factory, + ) + ) as client: + initial = client.get("/api/status").json()["system"]["performance_mode"] + assert initial["requested"] is False + assert initial["available"] is True + assert read_governor(policy0) == "schedutil" + + enabled = client.put("/api/config", json={"performance_mode_enabled": True}) + assert enabled.status_code == 200 + assert enabled.json()["performance_mode_enabled"] is True + assert read_governor(policy0) == "performance" + assert client.get("/api/status").json()["system"]["performance_mode"]["effective"] is True + + disabled = client.put("/api/config", json={"performance_mode_enabled": False}) + assert disabled.status_code == 200 + assert disabled.json()["performance_mode_enabled"] is False + assert read_governor(policy0) == "schedutil" + + assert read_governor(policy0) == "schedutil" diff --git a/核桃派软件源代码/tests/test_provision_device.py b/核桃派软件源代码/tests/test_provision_device.py new file mode 100644 index 0000000..0b67551 --- /dev/null +++ b/核桃派软件源代码/tests/test_provision_device.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import os +from pathlib import Path +import stat +import subprocess +from types import SimpleNamespace + +import pytest + +from scripts import provision_device + + +@pytest.fixture(autouse=True) +def restore_windows_test_file_permissions(tmp_path): + yield + if os.name == "nt": + for path in tmp_path.rglob("*"): + if path.is_file(): + path.chmod(0o600) + + +def account_config(username: str = "example") -> dict: + return {"account": {"username": username, "password": "example-password"}} + + +class FakePwd: + def __init__(self, values: dict[str, SimpleNamespace]) -> None: + self.values = values + + def getpwnam(self, username: str) -> SimpleNamespace: + try: + return self.values[username] + except KeyError as exc: + raise KeyError(username) from exc + + +def write_vendor_sudoers(root: Path) -> Path: + path = root / "etc" / "sudoers" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "Defaults env_reset\n" + "@includedir /etc/sudoers.d\n" + "Defaults rootpw\n" + "pi ALL=(ALL) NOPASSWD:ALL\n" + "Defaults !env_reset\n", + encoding="utf-8", + ) + return path + + +def test_new_account_gets_password_protected_full_sudo_and_locks_pi(tmp_path, monkeypatch): + main_sudoers = write_vendor_sudoers(tmp_path) + pi_home = tmp_path / "home" / "pi" + pi_home.mkdir(parents=True) + (pi_home / ".profile").write_text("profile\n", encoding="utf-8") + (pi_home / ".ssh").mkdir() + monkeypatch.setattr(provision_device, "pwd", FakePwd({"pi": SimpleNamespace(pw_dir=str(pi_home))})) + monkeypatch.setattr( + provision_device.subprocess, + "check_output", + lambda args, text: "pi sudo adm\n" if args == ["id", "-nG", "pi"] else "", + ) + calls: list[tuple[list[str], str | None]] = [] + + def fake_run(args: list[str], *, input_text: str | None = None) -> None: + calls.append((args, input_text)) + if args[0] == "useradd": + (tmp_path / "home" / "example").mkdir(parents=True) + + monkeypatch.setattr(provision_device, "run", fake_run) + provision_device.configure_account(account_config(), root=tmp_path) + + assert (["useradd", "--create-home", "--shell", "/bin/bash", "--groups", "sudo,adm", "example"], None) in calls + assert (["chpasswd"], "example:example-password\n") in calls + assert (["usermod", "--append", "--groups", "sudo", "example"], None) in calls + assert (["passwd", "--lock", "pi"], None) in calls + assert any(args[:2] == ["visudo", "-cf"] for args, _ in calls) + sudoers = tmp_path / "etc" / "sudoers.d" / "90-matrix-screen-controller-account" + assert sudoers.read_text(encoding="utf-8") == "example ALL=(ALL:ALL) ALL\n" + assert main_sudoers.read_text(encoding="utf-8") == ( + "Defaults env_reset\n@includedir /etc/sudoers.d\nDefaults !env_reset\n" + ) + if os.name != "nt": + assert stat.S_IMODE(sudoers.stat().st_mode) == 0o440 + assert (tmp_path / "home" / "example" / ".profile").is_file() + assert not (tmp_path / "home" / "example" / ".ssh").exists() + + +def test_existing_pi_account_is_updated_without_locking_it(tmp_path, monkeypatch): + main_sudoers = write_vendor_sudoers(tmp_path) + monkeypatch.setattr( + provision_device, + "pwd", + FakePwd({"pi": SimpleNamespace(pw_dir=str(tmp_path / "home" / "pi"))}), + ) + calls: list[tuple[list[str], str | None]] = [] + monkeypatch.setattr( + provision_device, + "run", + lambda args, input_text=None: calls.append((args, input_text)), + ) + provision_device.configure_account(account_config("pi"), root=tmp_path) + + assert not any(args[0] == "useradd" for args, _ in calls) + assert (["usermod", "--append", "--groups", "sudo", "pi"], None) in calls + assert not any(args == ["passwd", "--lock", "pi"] for args, _ in calls) + assert (tmp_path / "etc" / "sudoers.d" / "90-matrix-screen-controller-account").read_text( + encoding="utf-8" + ) == "pi ALL=(ALL:ALL) ALL\n" + assert "rootpw" not in main_sudoers.read_text(encoding="utf-8") + assert "NOPASSWD" not in main_sudoers.read_text(encoding="utf-8") + + +def test_invalid_sudoers_is_not_installed(tmp_path, monkeypatch): + main_sudoers = write_vendor_sudoers(tmp_path) + original = main_sudoers.read_text(encoding="utf-8") + monkeypatch.setattr( + provision_device, + "pwd", + FakePwd({"example": SimpleNamespace(pw_dir=str(tmp_path / "home" / "example"))}), + ) + + def fake_run(args: list[str], *, input_text: str | None = None) -> None: + if args[0] == "visudo": + raise subprocess.CalledProcessError(1, args) + + monkeypatch.setattr(provision_device, "run", fake_run) + with pytest.raises(subprocess.CalledProcessError): + provision_device.configure_account(account_config(), root=tmp_path) + assert not (tmp_path / "etc" / "sudoers.d" / "90-matrix-screen-controller-account").exists() + assert main_sudoers.read_text(encoding="utf-8") == original + + +def test_identity_reset_updates_hostname_mapping_and_unique_identity_files(tmp_path, monkeypatch): + hosts = tmp_path / "etc" / "hosts" + hosts.parent.mkdir(parents=True) + hosts.write_text("127.0.0.1\tlocalhost\n127.0.1.1\twalnutpi\n", encoding="utf-8") + machine_id = tmp_path / "etc" / "machine-id" + machine_id.write_text("old-machine-id\n", encoding="ascii") + ssh = tmp_path / "etc" / "ssh" + ssh.mkdir() + (ssh / "ssh_host_ed25519_key").write_text("old-key", encoding="ascii") + dbus = tmp_path / "var" / "lib" / "dbus" + dbus.mkdir(parents=True) + (dbus / "machine-id").write_text("old-machine-id\n", encoding="ascii") + calls: list[list[str]] = [] + monkeypatch.setattr(provision_device, "run", lambda args, input_text=None: calls.append(args)) + + provision_device.reset_identity(root=tmp_path) + + assert hosts.read_text(encoding="utf-8") == "127.0.0.1\tlocalhost\n127.0.1.1\tmatrix-screen\n" + assert machine_id.read_text(encoding="ascii") == "" + assert not (ssh / "ssh_host_ed25519_key").exists() + assert not (dbus / "machine-id").exists() + assert ["hostnamectl", "set-hostname", "matrix-screen"] in calls + + +def _effective_ssh(**overrides: str) -> str: + values = {**provision_device.SSH_EXPECTED, **overrides} + return "\n".join(f"{key} {value}" for key, value in values.items()) + "\n" + + +def test_ssh_policy_is_validated_then_enabled_without_starting(tmp_path, monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr(provision_device, "run", lambda args, input_text=None: calls.append(args)) + monkeypatch.setattr( + provision_device.subprocess, + "check_output", + lambda args, text: _effective_ssh(), + ) + provision_device.configure_ssh(root=tmp_path) + + installed = tmp_path / "etc" / "ssh" / "sshd_config.d" / "10-walnutpi-screen-hardening.conf" + assert installed.read_text(encoding="utf-8") == provision_device.SSH_POLICY_SOURCE.read_text(encoding="utf-8") + runtime = tmp_path / "run" / "sshd" + assert runtime.is_dir() + if os.name != "nt": + assert stat.S_IMODE(installed.stat().st_mode) == 0o644 + assert stat.S_IMODE(runtime.stat().st_mode) == 0o755 + assert calls == [ + ["/usr/sbin/sshd", "-t"], + ["systemctl", "unmask", "ssh.service"], + ["systemctl", "enable", "ssh.service"], + ] + assert all("--now" not in call and "start" not in call for call in calls) + + +def test_ssh_effective_policy_mismatch_fails_before_service_enable(tmp_path, monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr(provision_device, "run", lambda args, input_text=None: calls.append(args)) + monkeypatch.setattr( + provision_device.subprocess, + "check_output", + lambda args, text: _effective_ssh(passwordauthentication="no"), + ) + with pytest.raises(RuntimeError, match="effective SSH policy is invalid"): + provision_device.configure_ssh(root=tmp_path) + assert calls == [["/usr/sbin/sshd", "-t"]] diff --git a/核桃派软件源代码/tests/test_release_image.py b/核桃派软件源代码/tests/test_release_image.py new file mode 100644 index 0000000..14eab7c --- /dev/null +++ b/核桃派软件源代码/tests/test_release_image.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import json +import hashlib +from pathlib import Path +import subprocess +import sys +import tarfile + +import pytest + +from scripts.image_config import ( + CONFIG_FILE_BYTES, + ImageConfigError, + create_config_file, + read_config_file, + update_config_file, +) +from scripts.export_release import next_patch +from scripts import export_release +from scripts.promote_image_release import PASS_FIELDS, _validate_report +from scripts.build_sd_image import build_bundle, image_metadata +from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget +from scripts.kernel_artifact import KernelArtifactInfo +from app.ota.versioning import SoftwareVersion + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] + + +def config() -> dict: + return { + "schema_version": 1, + "product": "matrix-screen-controller-walnutpi", + "software_version": "1.0.2", + "account": {"username": "example", "password": "example-password"}, + "wifi": {"ssid": "example-network", "password": "example-wifi-password"}, + "ipv4": {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []}, + } + + +def test_release_staging_changes_version_without_changing_feature_timestamp(tmp_path): + source = tmp_path / "source" + source.mkdir() + (source / "VERSION").write_text("1.0.3\n", encoding="utf-8") + feature_timestamp = b"2026-09-04T12:40+08:00\n" + (source / "FEATURE_UPDATED_AT").write_bytes(feature_timestamp) + destination = tmp_path / "staged" + + export_release._copy_source(source, destination, SoftwareVersion.parse("1.0.4")) + + assert (destination / "VERSION").read_text(encoding="utf-8") == "1.0.4\n" + assert (destination / "FEATURE_UPDATED_AT").read_bytes() == feature_timestamp + assert (source / "VERSION").read_text(encoding="utf-8") == "1.0.3\n" + assert (source / "FEATURE_UPDATED_AT").read_bytes() == feature_timestamp + + +def test_image_readme_exposes_only_the_intended_defaults(): + value = config() + manifest = { + "created_at": "2026-09-08T12:00:00+08:00", + "artifact": "matrix-screen-controller-1.1.1.img", + "image_sha256": "a" * 64, + } + body = export_release._image_readme(SoftwareVersion.parse("1.1.1"), manifest, "image", value) + assert value["account"]["username"] in body + assert value["account"]["password"] in body + assert value["wifi"]["ssid"] in body + assert value["wifi"]["password"] in body + assert "公开的默认凭据" in body and "DHCP" in body + assert "前置系列基线" not in body and "系统设置上传 OTA" not in body + + +def test_source_readme_does_not_embed_a_development_device_address(): + body = (SOURCE_ROOT / "README.md").read_text(encoding="utf-8") + assert "192.168.198.94" not in body + assert "<设备通过 DHCP 获得的 IPv4>" in body + + +def test_image_validation_report_is_strict_and_digest_bound(tmp_path): + report = { + "schema_version": 1, + "artifact_type": "image", + "image_sha256": "b" * 64, + "software_version": "1.1.1", + "status": "success", + "validated_at": "2026-09-08T12:00:00+08:00", + **{name: True for name in PASS_FIELDS}, + } + path = tmp_path / "report.json" + path.write_text(json.dumps(report), encoding="utf-8") + assert _validate_report(path, "b" * 64, SoftwareVersion.parse("1.1.1")) == report + report["unexpected_secret"] = "must be rejected" + path.write_text(json.dumps(report), encoding="utf-8") + with pytest.raises(ValueError, match="fields"): + _validate_report(path, "b" * 64, SoftwareVersion.parse("1.1.1")) + + +def test_image_candidate_does_not_publish_or_consume_version(tmp_path, monkeypatch): + source = tmp_path / "核桃派软件源代码" + source.mkdir() + (source / "VERSION").write_text("1.1.0\n", encoding="utf-8") + (source / "FEATURE_UPDATED_AT").write_text("2026-09-08T10:19+08:00\n", encoding="utf-8") + (source / "UPGRADE_POLICY.json").write_text('{"schema_version":1,"checkpoints":[]}', encoding="utf-8") + history = tmp_path / "发布记录.json" + history.write_text('{"schema_version":1,"releases":[]}', encoding="utf-8") + config_path = tmp_path / "image-config.json" + config_path.write_text(json.dumps(config()), encoding="utf-8") + candidate = tmp_path / "candidate" + + def fake_build(*args): + output, bundle = args[-3], args[-2] + output.write_bytes(b"candidate-image") + bundle.write_bytes(b"bundle") + return { + "format_version": 3, + "software_version": "1.1.1", + "created_at": "2026-09-08T12:00:00+08:00", + "image_bytes": output.stat().st_size, + "image_sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + + monkeypatch.setattr(export_release, "SOURCE_ROOT", source) + monkeypatch.setattr(export_release, "build_image", fake_build) + monkeypatch.setattr(export_release, "_require_image_host", lambda: None) + monkeypatch.setattr(export_release.subprocess, "run", lambda *args, **kwargs: None) + monkeypatch.setattr( + sys, + "argv", + ["export", "image", "--version", "1.1.1", "--config", str(config_path), + "--candidate-output", str(candidate), "--notes", "candidate"], + ) + assert export_release.main() == 0 + assert (source / "VERSION").read_text(encoding="utf-8") == "1.1.0\n" + assert history.read_text(encoding="utf-8") == '{"schema_version":1,"releases":[]}' + assert not (tmp_path / "发布更新相关" / "导出包" / "1.1.1").exists() + assert {path.name for path in candidate.iterdir()} == { + "README.md", "manifest.json", "matrix-screen-controller-1.1.1.img", + "matrix-screen-controller-1.1.1.img.sha256", + } + + +def test_dual_slot_config_round_trip_and_fallback(): + initial = create_config_file(config()) + assert len(initial) == CONFIG_FILE_BYTES + document, generation, slot = read_config_file(initial) + assert document["account"]["username"] == "example" + assert (generation, slot) == (1, 0) + changed = config() + changed["wifi"]["ssid"] = "second-network" + updated = update_config_file(initial, changed) + document, generation, slot = read_config_file(updated) + assert document["wifi"]["ssid"] == "second-network" + assert (generation, slot) == (2, 1) + damaged = bytearray(updated) + damaged[34_900] ^= 0xFF + document, generation, slot = read_config_file(bytes(damaged)) + assert document["wifi"]["ssid"] == "example-network" + assert (generation, slot) == (1, 0) + + +def test_config_validation_rejects_bad_network_and_secrets(): + value = config() + value["wifi"]["password"] = "short" + with pytest.raises(ImageConfigError): + create_config_file(value) + value = config() + value["ipv4"] = { + "mode": "static", + "address": "192.168.1.20", + "prefix": 24, + "gateway": "192.168.2.1", + "dns": ["192.168.1.1"], + } + with pytest.raises(ImageConfigError): + create_config_file(value) + + +def test_release_sequence_is_shared(): + assert str(next_patch(SoftwareVersion.parse("1.0.1"))) == "1.0.2" + assert str(next_patch(SoftwareVersion.parse("1.0.2"))) == "1.0.3" + + +def test_image_metadata_v3_has_exact_payload_and_boot_identity(): + kernel = KernelArtifactInfo( + artifact="axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", + artifact_bytes=10, + artifact_sha256="a" * 64, + kernel_release="6.1.31-matrix-axp313a1", + source_commit="30ff3fd5cf45417622b447a12e7a947402ffe34d", + unpacked_file_bytes=20, + regular_file_count=9, + module_file_count=1, + ) + assert image_metadata("1.0.3", 30, "b" * 64, kernel, 40) == { + "format_version": 3, + "product": "matrix-screen-controller-walnutpi", + "software_version": "1.0.3", + "bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ", + "bundle_bytes": 30, + "bundle_sha256": "b" * 64, + "kernel_release": "6.1.31-matrix-axp313a1", + "kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz", + "kernel_artifact_bytes": 10, + "kernel_artifact_sha256": "a" * 64, + "kernel_unpacked_file_bytes": 20, + "boot_required_bytes": 40, + "boot_safety_bytes": DEFAULT_SAFETY_BYTES, + } + + +def test_fat_bundle_excludes_kernel_binary_and_source_dependencies(tmp_path: Path): + source = tmp_path / "source" + wheelhouse = tmp_path / "wheelhouse" + debian = tmp_path / "debian" + kernel = tmp_path / "offline/aarch64-kernel" + kernel_source = tmp_path / "offline/kernel-source" + for directory in (source, wheelhouse, debian, kernel, kernel_source): + directory.mkdir(parents=True) + (source / "app.py").write_text("app\n", encoding="utf-8") + (source / "FEATURE_UPDATED_AT").write_text("2026-09-04T12:40+08:00\n", encoding="utf-8") + (wheelhouse / "wheel.whl").write_bytes(b"wheel") + (debian / "package.deb").write_bytes(b"deb") + (kernel / "kernel.tar.gz").write_bytes(b"kernel") + (kernel_source / "source.tar.gz").write_bytes(b"source") + output = tmp_path / "bundle.tgz" + build_bundle(source, wheelhouse, debian, output) + with tarfile.open(output, "r:gz") as archive: + names = {member.name for member in archive.getmembers()} + assert "project/核桃派软件源代码/app.py" in names + assert "project/核桃派软件源代码/FEATURE_UPDATED_AT" in names + assert "project/离线依赖/其他依赖/aarch64-py311/wheel.whl" in names + assert "project/离线依赖/其他依赖/debian12-aarch64/package.deb" in names + assert not any("aarch64-kernel" in name or "kernel-source" in name for name in names) + + +def test_firstboot_defers_controller_start_to_avoid_systemd_ordering_deadlock(): + firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") + deploy = (SOURCE_ROOT / "scripts" / "deploy_walnutpi.sh").read_text(encoding="utf-8") + assert "DEFER_SERVICE_START=1" in firstboot + assert 'if [ "$DEFER_SERVICE_START" = "1" ]; then' in deploy + assert deploy.count('dedicated_host.py" check --service') == 2 + assert "systemctl enable matrix-screen-controller.service" in deploy + assert "service start is deferred until reboot" in deploy + + +def test_firstboot_does_not_block_offline_installation_on_wifi_connectivity(): + firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") + + network_config = "scripts.provision_device network --config" + deferred_deploy = "DEFER_SERVICE_START=1" + offline_success = ( + "SUCCESS: provisioning completed while Wi-Fi is not connected; " + "the installed controller will keep trying after reboot." + ) + + assert firstboot.index(network_config) < firstboot.index(deferred_deploy) + assert "systemctl restart NetworkManager.service" in firstboot + assert "nmcli --wait" not in firstboot + assert "connection up matrix-screen" not in firstboot + assert "for _attempt in" not in firstboot + assert "ip route show default | grep -q ." in firstboot + assert offline_success in firstboot + assert firstboot.index("rm -f -- \"$CONFIG\"") < firstboot.index(offline_success) + + +def test_image_payload_v3_stages_both_payloads_outside_fat(): + builder = (SOURCE_ROOT / "scripts/build_sd_image.py").read_text(encoding="utf-8") + exporter = (SOURCE_ROOT / "scripts/export_release.py").read_text(encoding="utf-8") + installer = (SOURCE_ROOT / "scripts/install_image_bootstrap.sh").read_text(encoding="utf-8") + verifier = (SOURCE_ROOT / "scripts/verify_image_bootstrap.sh").read_text(encoding="utf-8") + firstboot = (SOURCE_ROOT / "scripts/image_firstboot.sh").read_text(encoding="utf-8") + + assert 'IMAGE_FORMAT_VERSION = 3' in builder + assert 'BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"' in builder + assert '"kernel_artifact_sha256"' in builder + assert '"kernel_unpacked_file_bytes"' in builder + assert "build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies)" in builder + assert "aarch64-kernel" in exporter and "kernel-source" in exporter + assert "image_bootstrap_payload.py\" stage" in installer + assert "image_bootstrap_payload.py\" verify" in verifier + assert '"format_version": 3' in firstboot + assert "[ ! -e \"$BOOT_MOUNT/MSCBOOT.TGZ\" ]" in verifier + assert "scripts/kernel_artifact.py --kernel" in firstboot + assert "install_axp313a_kernel.sh --artifact" in firstboot + assert "kernel_unpacked_bytes * 2 + 268435456" in firstboot + assert "rm -rf -- /opt/matrix-image-bootstrap" in firstboot + + +def test_in_place_image_payload_refresh_is_retired(): + refresher = (SOURCE_ROOT / "scripts/refresh_sd_image_payload.py").read_text(encoding="utf-8") + assert "in-place image payload refresh is retired" in refresher + assert "export_release.py image --repair-current" in refresher + assert 'write_file("MSCBOOT.TGZ"' not in refresher + + +def test_export_help_works_without_site_packages(): + result = subprocess.run( + [sys.executable, "-S", str(SOURCE_ROOT / "scripts/export_release.py"), "--help"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "--repair-current" in result.stdout + assert "PIL" not in result.stderr and "fontTools" not in result.stderr + + +def test_boot_budget_counts_candidate_rollback_temporary_and_safety_files(): + candidate = { + "boot/Image-matrix-axp313a1": 9_000, + "boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": 2_000, + "boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": 2_000, + "boot/System.map-6.1.31-matrix-axp313a1": 3_000, + "boot/config-6.1.31-matrix-axp313a1": 1_000, + } + boot_cmd = b'''# DO NOT EDIT THIS FILE\nsetenv docker_optimizations "on"\nsetenv bootargs "root=/dev/mmcblk1p2"\nif test "${docker_optimizations}" = "on"; then setenv bootargs "${bootargs} cgroup_enable=memory swapaccount=1"; fi\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile_emmc}.dtb\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb\nload ${devtype} ${devnum} ${kernel_addr_r} ${prefix}Image\n''' + budget = calculate_budget( + candidate_sizes=candidate, + boot_cmd=boot_cmd, + boot_scr=b"scr", + cluster_bytes=4096, + available_bytes=10**9, + ) + assert budget.required_bytes > sum(candidate.values()) + assert budget.safety_bytes == 32 * 1024 * 1024 + assert budget.total_bytes == budget.required_bytes + budget.safety_bytes + + +def test_repair_commit_replaces_directory_and_record_without_duplicate(tmp_path: Path): + final = tmp_path / "1.0.3" + staged = tmp_path / ".1.0.3.building" + final.mkdir() + staged.mkdir() + (final / "old").write_text("old", encoding="utf-8") + (staged / "new").write_text("new", encoding="utf-8") + history_path = tmp_path / "发布记录.json" + history = {"schema_version": 1, "releases": [{"version": "1.0.3", "artifact_sha256": "new"}]} + history_path.write_text('{"schema_version":1,"releases":[]}', encoding="utf-8") + leftover = export_release._commit_repair(final, staged, history_path, history) + assert leftover is None + assert (final / "new").read_text(encoding="utf-8") == "new" + assert not (final / "old").exists() + assert json.loads(history_path.read_text(encoding="utf-8")) == history + assert not list(tmp_path.glob("*.invalid-backup")) + + +def test_repair_commit_restores_old_directory_when_record_write_fails(tmp_path: Path, monkeypatch): + final = tmp_path / "1.0.3" + staged = tmp_path / ".1.0.3.building" + final.mkdir() + staged.mkdir() + (final / "old").write_text("old", encoding="utf-8") + (staged / "new").write_text("new", encoding="utf-8") + history_path = tmp_path / "发布记录.json" + original_history = b'{"schema_version":1,"releases":[]}\n' + history_path.write_bytes(original_history) + + def fail_record(_path, _document): + raise OSError("simulated record failure") + + monkeypatch.setattr(export_release, "_atomic_json", fail_record) + with pytest.raises(OSError, match="simulated record failure"): + export_release._commit_repair( + final, + staged, + history_path, + {"schema_version": 1, "releases": [{"version": "1.0.3"}]}, + ) + assert (final / "old").read_text(encoding="utf-8") == "old" + assert not (final / "new").exists() + assert history_path.read_bytes() == original_history + + +def test_firstboot_requires_managed_password_ssh_after_identity_reset(): + firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") + policy = (SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf").read_text(encoding="utf-8") + assert policy.splitlines() == [ + "PermitRootLogin no", + "PasswordAuthentication yes", + "KbdInteractiveAuthentication no", + "PermitEmptyPasswords no", + ] + identity = "python3 -m scripts.provision_device identity --config" + ssh = "python3 -m scripts.provision_device ssh --config" + assert firstboot.index(identity) < firstboot.index(ssh) < firstboot.index("systemctl reboot") + assert "systemctl enable --now ssh.service" not in firstboot + + +def test_firstboot_cannot_time_out_or_reload_away_its_running_unit(): + firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8") + unit = (SOURCE_ROOT / "systemd" / "matrix-image-firstboot.service").read_text(encoding="utf-8") + success = 'status "SUCCESS: provisioning completed; the device is rebooting into the installed controller."' + + assert "TimeoutStartSec=infinity" in unit + assert "systemctl daemon-reload" not in firstboot + assert firstboot.index('rm -f -- /etc/systemd/system/matrix-image-firstboot.service') < firstboot.index(success) + assert firstboot.index('rm -rf -- "$WORK"') < firstboot.index(success) + assert firstboot.index("sync\n") < firstboot.index(success) < firstboot.index("systemctl reboot") + + +def test_image_export_installs_and_verifies_managed_ssh_policy(): + exporter = (SOURCE_ROOT / "scripts" / "export_release.py").read_text(encoding="utf-8") + installer = (SOURCE_ROOT / "scripts" / "install_image_bootstrap.sh").read_text(encoding="utf-8") + verifier = (SOURCE_ROOT / "scripts" / "verify_image_bootstrap.sh").read_text(encoding="utf-8") + name = "10-walnutpi-screen-hardening.conf" + assert exporter.count(name) == 2 + assert name in installer and 'enable ssh.service' in installer + assert name in verifier and 'multi-user.target.wants/ssh.service' in verifier diff --git a/核桃派软件源代码/tests/test_repository_hygiene.py b/核桃派软件源代码/tests/test_repository_hygiene.py new file mode 100644 index 0000000..e2fe9b8 --- /dev/null +++ b/核桃派软件源代码/tests/test_repository_hygiene.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path, PurePosixPath + +import pytest + + +WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +CREDENTIAL_SCRIPT = ( + WORKSPACE_ROOT / "测试相关资料" / "核桃派的用户名和密码和ip" / "prepare_credentials.py" +) +HYGIENE_SCRIPT = WORKSPACE_ROOT / "核桃派软件源代码" / "scripts" / "check_repository_hygiene.py" + +if not CREDENTIAL_SCRIPT.is_file() or not HYGIENE_SCRIPT.is_file() or not (WORKSPACE_ROOT / ".git").exists(): + pytest.skip("repository hygiene tests require a complete Git workspace", allow_module_level=True) + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +credentials = _load_module( + "prepare_credentials", + CREDENTIAL_SCRIPT, +) +hygiene = _load_module( + "check_repository_hygiene", + HYGIENE_SCRIPT, +) + + +def _complete_credentials() -> str: + values = { + "设备": "board-a", + "主机名": "matrix-a", + "用户名": "operator", + "密码": "local-value-one", + "IP": "192.0.2.10", + "SSH": "ssh operator at device", + "镜像默认用户名": "image-user", + "镜像默认账户密码": "local-value-two", + "镜像默认WiFi SSID": "lab-network", + "镜像默认WiFi密码": "local-value-three", + } + return "\n".join(f"{key}:{values[key]}" for key in credentials.REQUIRED_KEYS) + "\n" + + +def test_missing_credentials_are_copied_then_blocked_without_values(tmp_path, capsys): + isolated = tmp_path / "含 中文 空格" + isolated.mkdir() + example = isolated / credentials.EXAMPLE_NAME + example.write_text("# instruction\n设备:<填写>\n", encoding="utf-8") + + assert credentials.ensure_credentials(isolated) is False + private = isolated / credentials.PRIVATE_NAME + assert private.read_bytes() == example.read_bytes() + output = capsys.readouterr().out + assert "<填写>" not in output + assert "不要继续" in output + + +def test_placeholder_or_missing_fields_block_and_existing_file_is_untouched(tmp_path): + (tmp_path / credentials.EXAMPLE_NAME).write_text("# instruction\n", encoding="utf-8") + private = tmp_path / credentials.PRIVATE_NAME + private.write_text("设备:<填写设备>\n", encoding="utf-8") + before = private.read_bytes() + + try: + credentials.ensure_credentials(tmp_path) + except credentials.CredentialPreparationError: + pass + else: + raise AssertionError("unfinished credentials must be rejected") + assert private.read_bytes() == before + + +def test_complete_credentials_pass_without_echoing_values(tmp_path, capsys): + (tmp_path / credentials.EXAMPLE_NAME).write_text("# instruction\n", encoding="utf-8") + private = tmp_path / credentials.PRIVATE_NAME + private.write_text(_complete_credentials(), encoding="utf-8") + + assert credentials.ensure_credentials(tmp_path) is True + output = capsys.readouterr().out + assert "local-value" not in output + assert "未输出任何凭据值" in output + + +def test_hygiene_text_rules_detect_host_paths_keys_and_archive_private_ip(): + windows_path = "C:" + "\\Users\\someone\\repo" + key = "-----BEGIN " + "OPENSSH PRIVATE KEY-----" + issues = hygiene._text_issues(PurePosixPath("docs/note.md"), windows_path + "\n" + key) + assert any("开发电脑" in issue for issue in issues) + assert any("私钥" in issue for issue in issues) + + ip_issues = hygiene._text_issues(PurePosixPath("各种归档/note.md"), "address=192.168.1.2") + assert any("IPv4" in issue for issue in ip_issues) + allowed = hygiene._text_issues( + PurePosixPath("核桃派软件源代码/tests/example.py"), "address=192.168.1.2" + ) + assert not any("IPv4" in issue for issue in allowed) + + +def test_repository_contract_has_example_and_ignored_private_file(): + paths = hygiene.candidate_paths(staged=False) + assert hygiene.EXAMPLE_CREDENTIAL in paths + assert hygiene.PRIVATE_CREDENTIAL not in paths + assert hygiene._check_repository_contract(paths, staged=False) == [] diff --git a/核桃派软件源代码/tests/test_resource_monitor.py b/核桃派软件源代码/tests/test_resource_monitor.py new file mode 100644 index 0000000..97f3a49 --- /dev/null +++ b/核桃派软件源代码/tests/test_resource_monitor.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from app.monitoring.resources import ResourceMonitor + + +def write_proc_snapshot( + proc_root, + *, + cpu_line: str, + core_lines: tuple[str, ...], + process_ticks: tuple[int, int], + rss_pages: int = 100, + mem_total_kib: int = 100_000, + mem_available_kib: int = 40_000, +) -> None: + self_dir = proc_root / "self" + self_dir.mkdir(parents=True, exist_ok=True) + (proc_root / "stat").write_text( + "\n".join((cpu_line, *core_lines, "")), + encoding="ascii", + ) + utime, stime = process_ticks + process_stat = f"1 (matrix app) S 1 2 3 4 5 6 7 8 9 10 {utime} {stime} 0 0\n" + (self_dir / "stat").write_text(process_stat, encoding="ascii") + (self_dir / "statm").write_text(f"1000 {rss_pages} 0 0 0 0 0\n", encoding="ascii") + (proc_root / "meminfo").write_text( + f"MemTotal: {mem_total_kib} kB\n" + f"MemAvailable: {mem_available_kib} kB\n", + encoding="ascii", + ) + + +def test_resource_monitor_uses_delta_cpu_and_current_memory(tmp_path): + proc_root = tmp_path / "proc" + sampled_at = datetime(2026, 7, 21, 8, 0, tzinfo=timezone.utc) + write_proc_snapshot( + proc_root, + cpu_line="cpu 100 0 100 800 0 0 0 0 0 0", + core_lines=( + "cpu0 25 0 25 200 0 0 0 0 0 0", + "cpu1 25 0 25 200 0 0 0 0 0 0", + "cpu2 25 0 25 200 0 0 0 0 0 0", + "cpu3 25 0 25 200 0 0 0 0 0 0", + ), + process_ticks=(20, 5), + ) + monitor = ResourceMonitor(proc_root=proc_root, page_size=4096, now=lambda: sampled_at) + + first = monitor.sample_now() + + assert first["status"] == "ok" + assert first["sampled_at"] == "2026-07-21T08:00:00.000Z" + assert first["cpu"] == { + "application_percent": None, + "total_percent": None, + "logical_cpu_count": 4, + "application_core_equivalent": None, + "cores_percent": [None, None, None, None], + } + assert first["memory"]["application_bytes"] == 409_600 + assert first["memory"]["application_percent"] == pytest.approx(0.4) + assert first["memory"]["total_percent"] == pytest.approx(60.0) + + write_proc_snapshot( + proc_root, + cpu_line="cpu 130 0 120 850 0 0 0 0 0 0", + core_lines=( + "cpu0 35 0 30 210 0 0 0 0 0 0", + "cpu1 35 0 30 210 0 0 0 0 0 0", + "cpu2 30 0 30 215 0 0 0 0 0 0", + "cpu3 30 0 30 215 0 0 0 0 0 0", + ), + process_ticks=(24, 6), + ) + second = monitor.sample_now() + + assert second["cpu"]["application_percent"] == pytest.approx(5.0) + assert second["cpu"]["application_core_equivalent"] == pytest.approx(0.2) + assert second["cpu"]["total_percent"] == pytest.approx(50.0) + assert second["cpu"]["cores_percent"] == pytest.approx([60.0, 60.0, 40.0, 40.0]) + assert second["error_code"] is None + + +def test_resource_monitor_reports_application_using_more_than_one_core(tmp_path): + proc_root = tmp_path / "proc" + first_cores = tuple(f"cpu{index} 100 0 0 0" for index in range(4)) + second_cores = tuple(f"cpu{index} 200 0 0 0" for index in range(4)) + write_proc_snapshot( + proc_root, + cpu_line="cpu 400 0 0 0", + core_lines=first_cores, + process_ticks=(20, 0), + ) + monitor = ResourceMonitor(proc_root=proc_root, page_size=4096) + monitor.sample_now() + write_proc_snapshot( + proc_root, + cpu_line="cpu 800 0 0 0", + core_lines=second_cores, + process_ticks=(170, 0), + ) + + status = monitor.sample_now() + + assert status["cpu"]["application_percent"] == pytest.approx(37.5) + assert status["cpu"]["application_core_equivalent"] == pytest.approx(1.5) + assert status["cpu"]["cores_percent"] == pytest.approx([100.0] * 4) + + +def test_resource_monitor_discards_cpu_delta_when_core_set_changes(tmp_path): + proc_root = tmp_path / "proc" + write_proc_snapshot( + proc_root, + cpu_line="cpu 200 0 0 0", + core_lines=("cpu0 100 0 0 0", "cpu1 100 0 0 0"), + process_ticks=(10, 0), + ) + monitor = ResourceMonitor(proc_root=proc_root, page_size=4096) + monitor.sample_now() + write_proc_snapshot( + proc_root, + cpu_line="cpu 600 0 0 0", + core_lines=tuple(f"cpu{index} 150 0 0 0" for index in range(4)), + process_ticks=(50, 0), + ) + + status = monitor.sample_now() + + assert status["cpu"]["logical_cpu_count"] == 4 + assert status["cpu"]["application_percent"] is None + assert status["cpu"]["application_core_equivalent"] is None + assert status["cpu"]["total_percent"] is None + assert status["cpu"]["cores_percent"] == [None, None, None, None] + + +def test_resource_monitor_clamps_percentages_and_returns_defensive_copies(tmp_path): + proc_root = tmp_path / "proc" + write_proc_snapshot( + proc_root, + cpu_line="cpu 100 0 0 0 0 0 0 0 0 0", + core_lines=tuple(f"cpu{index} 25 0 0 0" for index in range(4)), + process_ticks=(0, 0), + rss_pages=50_000, + mem_total_kib=100_000, + mem_available_kib=0, + ) + monitor = ResourceMonitor(proc_root=proc_root, page_size=4096) + monitor.sample_now() + write_proc_snapshot( + proc_root, + cpu_line="cpu 200 0 0 0 0 0 0 0 0 0", + core_lines=tuple(f"cpu{index} 50 0 0 0" for index in range(4)), + process_ticks=(300, 0), + rss_pages=50_000, + mem_total_kib=100_000, + mem_available_kib=0, + ) + + status = monitor.sample_now() + status["cpu"]["total_percent"] = -1 + status["cpu"]["cores_percent"][0] = -1 + status["memory"]["application_percent"] = -1 + cached = monitor.get_status() + + assert cached["cpu"]["application_percent"] == 100.0 + assert cached["cpu"]["application_core_equivalent"] == 4.0 + assert cached["cpu"]["total_percent"] == 100.0 + assert cached["cpu"]["cores_percent"] == [100.0] * 4 + assert cached["memory"]["application_percent"] == 100.0 + assert cached["memory"]["total_percent"] == 100.0 + + +def test_resource_monitor_failure_is_isolated_and_does_not_create_files(tmp_path): + proc_root = tmp_path / "missing-proc" + monitor = ResourceMonitor(proc_root=proc_root, poll_interval=0.01, page_size=4096) + + status = monitor.sample_now() + monitor.start() + monitor.close() + + assert status["status"] == "error" + assert status["error_code"] == "proc_read_error" + assert status["cpu"] == { + "application_percent": None, + "total_percent": None, + "logical_cpu_count": None, + "application_core_equivalent": None, + "cores_percent": [], + } + assert status["memory"] == { + "application_bytes": None, + "application_percent": None, + "total_percent": None, + } + assert not proc_root.exists() diff --git a/核桃派软件源代码/tests/test_scene_model.mjs b/核桃派软件源代码/tests/test_scene_model.mjs new file mode 100644 index 0000000..9f662ad --- /dev/null +++ b/核桃派软件源代码/tests/test_scene_model.mjs @@ -0,0 +1,333 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_TEXT_ELEMENT, + LEGACY_STORAGE_KEYS, + RGB_BYTE_LENGTH, + RGBA_BYTE_LENGTH, + SCENE_HEIGHT, + SCENE_STORAGE_KEY, + SCENE_VERSION, + SCENE_WIDTH, + addTextElement, + compositeRgbaLayer, + compositeRgbaLayers, + createRgbPixels, + createScene, + createTextElement, + deleteTextElement, + deserializeScene, + duplicateTextElement, + inspectSceneStorageValue, + migrateLegacyScene, + normalizeTextElement, + restoreSceneDraft, + sceneToStorageValue, + serializeScene, + updateTextElement, +} from "../app/static/scene-model.js"; + +function patternedPixels(seed = 0) { + return Uint8ClampedArray.from({ length: RGB_BYTE_LENGTH }, (_, index) => (index + seed) % 256); +} + +function rgbaPixel(red, green, blue, alpha) { + const layer = new Uint8ClampedArray(RGBA_BYTE_LENGTH); + layer.set([red, green, blue, alpha], 0); + return layer; +} + +function memoryStorage(initial = {}, { failSet = false } = {}) { + const values = new Map(Object.entries(initial)); + const operations = []; + return { + values, + operations, + getItem(key) { + operations.push(["get", key]); + return values.has(key) ? values.get(key) : null; + }, + setItem(key, value) { + operations.push(["set", key]); + if (failSet) throw new Error("quota exceeded"); + values.set(key, String(value)); + }, + removeItem(key) { + operations.push(["remove", key]); + values.delete(key); + }, + }; +} + +test("creates a fixed 64x64 RGB scene", () => { + const pixels = createRgbPixels([12, 34, 56]); + const scene = createScene({ pixels }); + assert.equal(SCENE_STORAGE_KEY, "matrixController:scene"); + assert.equal(scene.version, SCENE_VERSION); + assert.equal(scene.width, SCENE_WIDTH); + assert.equal(scene.height, SCENE_HEIGHT); + assert.equal(scene.pixels.length, RGB_BYTE_LENGTH); + assert.deepEqual(Array.from(scene.pixels.slice(0, 6)), [12, 34, 56, 12, 34, 56]); + pixels[0] = 255; + assert.equal(scene.pixels[0], 12, "scene owns a copy of its pixel layer"); +}); + +test("serializes pixelRgb as base64 and round trips an independent scene", () => { + const original = addTextElement(createScene({ pixels: patternedPixels(7) }), { + text: "你好 · مرحبا · नमस्ते · ไทย", + color: "#12abef", + }); + const stored = sceneToStorageValue(original); + assert.equal(typeof stored.pixelRgb, "string"); + assert.equal("pixels" in stored, false); + assert.equal("pixelsRgb" in stored, false); + assert.equal("revision" in stored.elements[0], false); + const restored = deserializeScene(serializeScene(original)); + assert.ok(restored); + assert.deepEqual(Array.from(restored.pixels), Array.from(original.pixels)); + assert.deepEqual(restored.elements, original.elements); + assert.notStrictEqual(restored.pixels, original.pixels); + assert.notStrictEqual(restored.elements[0], original.elements[0]); + assert.equal(restored.elements[0].text, "你好 · مرحبا · नमस्ते · ไทย"); +}); + +test("moves a valid v1 draft to the stable key before removing exact legacy keys", () => { + const original = addTextElement(createScene({ pixels: patternedPixels(23) }), { text: "keep me" }); + const storage = memoryStorage({ + [LEGACY_STORAGE_KEYS.sceneV1]: serializeScene(original), + [LEGACY_STORAGE_KEYS.canvasV3]: JSON.stringify({ pixels: Array.from(patternedPixels(2)) }), + [LEGACY_STORAGE_KEYS.textV1]: JSON.stringify({ "text-value": "older text" }), + "unrelated:key": "keep", + }); + + const loaded = restoreSceneDraft(storage); + + assert.equal(loaded.status, "migrated"); + assert.equal(loaded.canPersist, true); + assert.deepEqual(Array.from(loaded.scene.pixels), Array.from(original.pixels)); + assert.equal(loaded.scene.elements[0].text, "keep me"); + assert.ok(deserializeScene(storage.values.get(SCENE_STORAGE_KEY))); + for (const key of Object.values(LEGACY_STORAGE_KEYS)) { + assert.equal(storage.values.has(key), false); + } + assert.equal(storage.values.get("unrelated:key"), "keep"); + const setIndex = storage.operations.findIndex(([operation]) => operation === "set"); + const firstRemoveIndex = storage.operations.findIndex(([operation]) => operation === "remove"); + assert.ok(setIndex >= 0 && firstRemoveIndex > setIndex, "the new draft is written before legacy cleanup"); +}); + +test("keeps the v1 draft and all legacy keys when stable-key migration cannot write", () => { + const originalRaw = serializeScene(addTextElement(createScene(), { text: "do not delete" })); + const storage = memoryStorage({ + [LEGACY_STORAGE_KEYS.sceneV1]: originalRaw, + [LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(patternedPixels(4)) }), + }, { failSet: true }); + + const loaded = restoreSceneDraft(storage); + + assert.equal(loaded.status, "write-failed"); + assert.equal(loaded.canPersist, false); + assert.equal(storage.values.has(SCENE_STORAGE_KEY), false); + assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.sceneV1), originalRaw); + assert.equal(storage.values.has(LEGACY_STORAGE_KEYS.canvasV2), true); + assert.equal(storage.operations.some(([operation]) => operation === "remove"), false); +}); + +test("preserves and blocks a future stable draft instead of falling back or writing blank", () => { + const futureRaw = JSON.stringify({ + ...sceneToStorageValue(createScene({ pixels: patternedPixels(31) })), + version: SCENE_VERSION + 1, + }); + const legacyRaw = serializeScene(addTextElement(createScene(), { text: "older fallback" })); + const storage = memoryStorage({ + [SCENE_STORAGE_KEY]: futureRaw, + [LEGACY_STORAGE_KEYS.sceneV1]: legacyRaw, + }); + + assert.equal(inspectSceneStorageValue(futureRaw).status, "future"); + const loaded = restoreSceneDraft(storage); + + assert.equal(loaded.status, "future-version"); + assert.equal(loaded.canPersist, false); + assert.equal(storage.values.get(SCENE_STORAGE_KEY), futureRaw); + assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.sceneV1), legacyRaw); + assert.equal(storage.operations.some(([operation]) => operation === "set"), false); + assert.equal(storage.operations.some(([operation]) => operation === "remove"), false); +}); + +test("preserves and blocks a damaged stable draft", () => { + const storage = memoryStorage({ [SCENE_STORAGE_KEY]: "{damaged" }); + const loaded = restoreSceneDraft(storage); + + assert.equal(loaded.status, "damaged-current"); + assert.equal(loaded.canPersist, false); + assert.equal(storage.values.get(SCENE_STORAGE_KEY), "{damaged"); + assert.equal(storage.operations.some(([operation]) => operation === "set"), false); +}); + +test("does not discard a damaged higher-priority legacy key during fallback migration", () => { + const validFallback = JSON.stringify({ pixels: Array.from(patternedPixels(6)) }); + const storage = memoryStorage({ + [LEGACY_STORAGE_KEYS.canvasV3]: "{damaged", + [LEGACY_STORAGE_KEYS.canvasV2]: validFallback, + }); + + const loaded = restoreSceneDraft(storage); + + assert.equal(loaded.status, "legacy-migration-failed"); + assert.equal(loaded.canPersist, false); + assert.equal(storage.values.has(SCENE_STORAGE_KEY), false); + assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.canvasV3), "{damaged"); + assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.canvasV2), validFallback); + assert.equal(storage.operations.some(([operation]) => operation === "remove"), false); +}); + +test("rejects damaged scene roots while skipping only damaged elements", () => { + const valid = sceneToStorageValue(addTextElement(createScene(), { text: "keep" })); + assert.equal(deserializeScene({ ...valid, version: 2 }), null); + assert.equal(deserializeScene({ ...valid, width: 32 }), null); + assert.equal(deserializeScene({ ...valid, pixelRgb: "not base64" }), null); + assert.equal(deserializeScene("{broken"), null); + + const recovered = deserializeScene({ + ...valid, + elements: [ + valid.elements[0], + { id: "bad-type", type: "image", text: "skip" }, + { id: "bad-text", type: "text", text: 123 }, + { ...valid.elements[0], text: "duplicate id" }, + ], + }); + assert.ok(recovered); + assert.equal(recovered.elements.length, 1); + assert.equal(recovered.elements[0].text, "keep"); +}); + +test("normalizes text fields and creates a centered lowercase ok element", () => { + const defaults = createTextElement([]); + assert.equal(defaults.text, "ok"); + assert.equal(defaults.color, "#FFFFFF"); + assert.equal(defaults.align, "center"); + assert.equal(defaults.x, 32); + assert.equal(defaults.y, 26); + + const normalized = normalizeTextElement({ + id: " custom ", + type: "text", + text: "hello", + font: " custom.ttf ", + size: 100, + x: -500, + y: 11.7, + align: "invalid", + color: "#12abef", + revision: -3, + background: "#FF0000", + }); + assert.deepEqual(normalized, { + id: "custom", + type: "text", + text: "hello", + font: "custom.ttf", + size: 64, + x: -128, + y: 12, + align: DEFAULT_TEXT_ELEMENT.align, + color: "#12ABEF", + revision: 0, + }); + assert.equal("background" in normalized, false); +}); + +test("migrates the first valid canvas by v3, v2, legacy priority and merges text v1", () => { + const v3 = patternedPixels(3); + const v2 = patternedPixels(9); + const legacy = patternedPixels(17); + const textV1 = { + "text-value": "legacy text", + "text-font": "legacy.ttf", + "text-size": "18", + "text-x": "7", + "text-y": "8", + "text-align": "right", + "text-color": "#abcdef", + "text-background": "#123456", + }; + const migrated = migrateLegacyScene({ + canvasV3: JSON.stringify({ pixels: Array.from(v3) }), + canvasV2: { pixels: Array.from(v2) }, + matrixCanvasRgb: Array.from(legacy).join(","), + textV1: JSON.stringify(textV1), + }); + assert.ok(migrated); + assert.deepEqual(Array.from(migrated.pixels), Array.from(v3)); + assert.equal(migrated.elements[0].text, "legacy text"); + assert.equal(migrated.elements[0].font, "legacy.ttf"); + assert.equal(migrated.elements[0].size, 18); + assert.equal(migrated.elements[0].color, "#ABCDEF"); + assert.equal("background" in migrated.elements[0], false); + + const fallback = migrateLegacyScene({ + [LEGACY_STORAGE_KEYS.canvasV3]: "{damaged", + [LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(v2) }), + [LEGACY_STORAGE_KEYS.canvasRgb]: Array.from(legacy).join(","), + }); + assert.deepEqual(Array.from(fallback.pixels), Array.from(v2)); + + const oldest = migrateLegacyScene({ + canvasV3: { pixels: [1, 2, 3] }, + canvasV2: null, + matrixCanvasRgb: Array.from(legacy).join(","), + }); + assert.deepEqual(Array.from(oldest.pixels), Array.from(legacy)); + assert.equal(migrateLegacyScene({ canvasV3: "bad", textV1: "bad" }), null); +}); + +test("supports independent add, update, and delete operations for multiple text elements", () => { + let scene = addTextElement(createScene(), { text: "one", x: 1, color: "#FF0000" }); + scene = addTextElement(scene, { text: "two", x: 20, color: "#00FF00" }); + const [first, second] = scene.elements; + assert.notEqual(first.id, second.id); + + const updated = updateTextElement(scene, first.id, { text: "ONE", x: 6, color: "#0000ff" }); + assert.equal(updated.elements[0].text, "ONE"); + assert.equal(updated.elements[0].x, 6); + assert.equal(updated.elements[0].color, "#0000FF"); + assert.equal(updated.elements[0].revision, first.revision + 1); + assert.deepEqual(updated.elements[1], second, "editing one element leaves the other unchanged"); + assert.equal(scene.elements[0].text, "one", "CRUD helpers do not mutate the input scene"); + + const deleted = deleteTextElement(updated, first.id); + assert.deepEqual(deleted.elements.map((element) => element.id), [second.id]); +}); + +test("duplicates with a deep copy, new id, 2px offset, and topmost order", () => { + let scene = addTextElement(createScene(), { text: "copy me", x: 126, y: 10 }); + const original = scene.elements[0]; + scene = duplicateTextElement(scene, original.id); + const duplicate = scene.elements[1]; + assert.equal(scene.elements.length, 2); + assert.notStrictEqual(duplicate, original); + assert.notEqual(duplicate.id, original.id); + assert.equal(duplicate.text, original.text); + assert.equal(duplicate.x, 128); + assert.equal(duplicate.y, 12); + assert.equal(duplicate.revision, 0); + assert.equal(scene.elements.at(-1).id, duplicate.id, "the duplicate is appended as the top layer"); + + const changed = updateTextElement(scene, duplicate.id, { text: "changed" }); + assert.equal(changed.elements[0].text, "copy me"); + assert.equal(changed.elements[1].text, "changed"); +}); + +test("alpha-composites full RGBA layers in array order without mutating inputs", () => { + const base = createRgbPixels([0, 0, 0]); + const red = rgbaPixel(255, 0, 0, 128); + const blue = rgbaPixel(0, 0, 255, 128); + const result = compositeRgbaLayers(base, [red, blue]); + assert.deepEqual(Array.from(result.slice(0, 3)), [64, 0, 128]); + assert.deepEqual(Array.from(base.slice(0, 3)), [0, 0, 0]); + assert.deepEqual(Array.from(compositeRgbaLayers(base, [blue, red]).slice(0, 3)), [128, 0, 64]); + assert.deepEqual(Array.from(compositeRgbaLayer(base, rgbaPixel(10, 20, 30, 255)).slice(0, 3)), [10, 20, 30]); + assert.throws(() => compositeRgbaLayers(base, [new Uint8Array(4)]), /64x64 RGBA8888/); +}); diff --git a/核桃派软件源代码/tests/test_settings_ui.mjs b/核桃派软件源代码/tests/test_settings_ui.mjs new file mode 100644 index 0000000..b3b5202 --- /dev/null +++ b/核桃派软件源代码/tests/test_settings_ui.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const settingsUrl = new URL("../app/static/views/settings.js", import.meta.url); + +test("settings brightness explains an active temporary display-test override", async () => { + const source = await readFile(settingsUrl, "utf8"); + assert.match(source, /displayTestActive = state\?\.display_test\?\.active === true/); + assert.match(source, /用户亮度 \$\{user\}% 已保存,但当前由\$\{suffix\}覆盖/); + assert.match(source, /state\?\.display_test\?\.brightness/); +}); + +test("settings exposes full OTA upload progress and service recovery polling", async () => { + const [source, html, css] = await Promise.all([ + readFile(settingsUrl, "utf8"), + readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), + readFile(new URL("../app/static/style.css", import.meta.url), "utf8"), + ]); + assert.match(html, /

    软件版本<\/h3>/); + assert.match(html, /最新功能更新时间/); + assert.match(html, /id="ota-feature-updated-at"/); + assert.match(html, />导入 OTA 更新<\/button>/); + assert.match(html, /class="ota-power-warning">不要在更新期间断电。<\/span>/); + assert.doesNotMatch(html, /软件更新|从本机选择完整 OTA 更新包|最近一次 OTA 更新/); + assert.doesNotMatch(html, /ota-last-update|ota-selected-file/); + assert.match(css, /\.ota-power-warning\s*{[^}]*color:\s*var\(--text\)/s); + assert.match(source, /new XMLHttpRequest\(\)/); + assert.match(source, /\/api\/ota\/update\?filename=/); + assert.match(source, /request\.upload\.addEventListener\("progress"/); + assert.match(source, /fetch\("\/api\/ota\/status"/); + assert.match(source, /status\?\.feature_updated_at/); + assert.match(source, /timeZone: "Asia\/Shanghai"/); + assert.match(source, /(北京时间)/); + assert.match(source, /设备正在切换软件版本,等待服务恢复/); + assert.doesNotMatch(source, /otaLastUpdate|otaLastVersion|otaLastPackagedAt|otaLastInstalledAt|otaLastResult/); + assert.match(source, /job\?\.phase === "complete"/); + assert.match(source, /otaProgressGroup\.hidden = true/); + assert.doesNotMatch(source, /showOtaProgress\(100, "更新完成"\)/); + assert.doesNotMatch(source, /更新完成,正在载入新版本页面/); +}); + +test("settings automatically opens and safely copies a detailed OTA failure log", async () => { + const [source, html, css] = await Promise.all([ + readFile(settingsUrl, "utf8"), + readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), + readFile(new URL("../app/static/style.css", import.meta.url), "utf8"), + ]); + assert.match(html, /id="ota-failure-log-dialog"[\s\S]*OTA 更新失败日志/); + assert.match(html, /id="ota-failure-log-content"[^>]*tabindex="0"/); + assert.match(html, /复制全部日志/); + assert.match(html, /查看失败日志/); + assert.match(source, /fetch\("\/api\/ota\/failure-log", \{ cache: "no-store" \}\)/); + assert.match(source, /otaFailureLogContent\.textContent = text/); + assert.match(source, /otaFailureLogDialog\.showModal\(\)/); + assert.match(source, /navigator\.clipboard\?\.writeText/); + assert.match(source, /document\.createElement\("textarea"\)/); + assert.match(source, /document\.execCommand\("copy"\)/); + assert.match(source, /status\.failure_log_available/); + assert.match(css, /\.ota-failure-log-dialog pre\s*{[^}]*overflow:\s*auto/s); + assert.doesNotMatch(source, /otaFailureLogContent\.innerHTML/); +}); + +test("settings exposes a transactional performance-mode switch with the fixed warning", async () => { + const [source, index] = await Promise.all([ + readFile(settingsUrl, "utf8"), + readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), + ]); + assert.match(index, /id="performance-mode-enabled"/); + assert.match(index, /如果使用出现卡顿,可以打开此选项,但会降低续航。/); + assert.match(source, /JSON\.stringify\(\{ performance_mode_enabled: requested \}\)/); + assert.match(source, /syncPerformanceModeStatus\(detail\.system\?\.performance_mode\)/); + assert.match(source, /status\.requested === true && status\.effective === true/); + assert.match(source, /CPU 调频策略不可用/); +}); + +test("settings exposes persisted animation preview concurrency below performance mode", async () => { + const [source, app, index] = await Promise.all([ + readFile(settingsUrl, "utf8"), + readFile(new URL("../app/static/app.js", import.meta.url), "utf8"), + readFile(new URL("../app/static/index.html", import.meta.url), "utf8"), + ]); + const performanceAt = index.indexOf('

    性能模式

    '); + const concurrencyAt = index.indexOf('

    模板界面同时播放的动图数量

    '); + assert.ok(performanceAt >= 0 && concurrencyAt > performanceAt); + assert.match(index, /id="animation-preview-max-concurrent"[\s\S]*min="1"[\s\S]*max="50"[\s\S]*step="1"/); + assert.match(index, /不建议开启太多,否则会出现严重的性能问题。/); + assert.match(source, /JSON\.stringify\(\{ animation_preview_max_concurrent: requested \}\)/); + assert.match(source, /setAnimationPreviewMaxConcurrent\(saved\)/); + assert.match(source, /showAnimationPreviewMaxConcurrent\(previous\)/); + assert.match(app, /config\.animation_preview_max_concurrent/); + assert.match(app, /ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT/); +}); diff --git a/核桃派软件源代码/tests/test_template_management_ui.mjs b/核桃派软件源代码/tests/test_template_management_ui.mjs new file mode 100644 index 0000000..80ae4b5 --- /dev/null +++ b/核桃派软件源代码/tests/test_template_management_ui.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + createTemplateEditContext, + sceneNeedsTemplateSave, + templateEditSnapshot, +} from "../app/static/template-edit-model.js"; + +const staticRoot = new URL("../app/static/", import.meta.url); + +test("template edit baseline distinguishes clean, dirty, and unbound scenes", () => { + const context = createTemplateEditContext({ + id: "template-1", + name: "测试模板", + revision: "revision-1", + read_only: true, + }, "scene-a"); + + assert.equal(sceneNeedsTemplateSave(null, "scene-a"), true); + assert.equal(sceneNeedsTemplateSave(context, "scene-a"), false); + assert.equal(sceneNeedsTemplateSave(context, "scene-b"), true); + assert.deepEqual(templateEditSnapshot(context, "scene-a"), { + id: "template-1", + name: "测试模板", + revision: "revision-1", + readOnly: true, + dirty: false, + }); + assert.equal(templateEditSnapshot(context, "scene-b").dirty, true); +}); + +test("template manager uses a monitor and unified four-row card actions", async () => { + const [html, board, templates, style] = await Promise.all([ + readFile(new URL("index.html", staticRoot), "utf8"), + readFile(new URL("scene-board.js", staticRoot), "utf8"), + readFile(new URL("views/templates.js", staticRoot), "utf8"), + readFile(new URL("style.css", staticRoot), "utf8"), + ]); + + assert.match(board, /MONITOR_WORKSPACES = new Set\(\["device", "settings", "templates"\]\)/); + assert.match(templates, /actions\.append\(play, edit, copy, rename, remove\)/); + assert.match(templates, /template-card-delete-action/); + assert.match(templates, /api\/templates\/\$\{template\.id\}\/play/); + assert.match(templates, /enterTemplateEdit\(full\)/); + assert.match(templates, /当前内容将会丢失/); + assert.match(templates, /actionButton\("上移"/); + assert.match(templates, /actionButton\("下移"/); + assert.match(templates, /name\.dataset\.hoverTooltip = animation\.name/); + assert.match(templates, /template-card-name animation-folder-name/); + assert.match(style, /\.template-card-content-actions,[\s\S]*grid-template-columns: repeat\(2/); + assert.match(style, /\.template-card-delete-action\s*\{\s*grid-column: 1 \/ -1/); + assert.match(html, /id="template-save-choice-dialog"/); + assert.match(html, /value="current">保存到当前模板/); + assert.match(html, /value="new">另存为新模板/); +}); + +test("animation editing backs up and restores template edit ownership", async () => { + const board = await readFile(new URL("scene-board.js", staticRoot), "utf8"); + assert.match(board, /templateEdit: templateEdit \? \{ \.\.\.templateEdit \} : null/); + assert.match(board, /templateEditContext: backup\.templateEdit/); + assert.match(board, /markTemplateSaved/); +}); diff --git a/核桃派软件源代码/tests/test_templates.py b/核桃派软件源代码/tests/test_templates.py new file mode 100644 index 0000000..def1f96 --- /dev/null +++ b/核桃派软件源代码/tests/test_templates.py @@ -0,0 +1,498 @@ +import base64 +import json +from io import BytesIO + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +import app.persistence as persistence +from app.main import create_app +from app.demo_library import DEMO_ANIMATION_ID, DEMO_STATIC_ID, DEMO_RESTRICTED_MESSAGE +from app.display.startup_indicator import render_startup_smile +from app.templates.store import ( + TEMPLATE_SCHEMA_VERSION, + TemplateError, + TemplateStorageFullError, + TemplateStore, + TemplateValidationError, +) + + +def scene(background=(1, 2, 3), text="TOP"): + pixels = bytes(background) * (64 * 64) + return { + "version": 1, + "width": 64, + "height": 64, + "pixelRgb": base64.b64encode(pixels).decode("ascii"), + "elements": [{ + "id": "text-1", + "type": "text", + "text": text, + "font": "default", + "size": 12, + "x": 4, + "y": 24, + "align": "left", + "color": "#FFFFFF", + }], + } + + +def test_deleting_default_template_immediately_falls_back_to_demo_animation(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(app) as client: + created = client.post( + "/api/templates", + json={"name": "即将删除的默认图", "scene": scene(text="DEFAULT")}, + ).json() + selected = client.put( + "/api/display/default-content", + json={"type": "template", "id": created["id"]}, + ) + assert selected.status_code == 200 + + deleted = client.delete( + f"/api/templates/{created['id']}", + headers={"If-Match": f'"{created["revision"]}"'}, + ) + assert deleted.status_code == 200 + assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID + state = client.get("/api/status").json()["state"] + assert state["animation_active"] is True + assert state["current_content"]["id"] == DEMO_ANIMATION_ID + + +def test_template_play_is_temporary_revision_checked_and_supports_demo(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + with TestClient(app) as client: + created = client.post( + "/api/templates", + json={"name": "临时播放", "scene": scene(background=(12, 34, 56), text="PLAY")}, + ).json() + default_before = client.get("/api/display/default-content").json() + + played = client.post( + f"/api/templates/{created['id']}/play", + headers={"If-Match": f'"{created["revision"]}"'}, + json={}, + ) + assert played.status_code == 200 + assert played.json() == { + "ok": True, + "template_id": created["id"], + "revision": created["revision"], + } + state = client.get("/api/status").json()["state"] + assert state["mode"] == "template" + assert state["animation_active"] is False + assert state["current_content"] == { + "category": "template", + "id": created["id"], + "name": "临时播放", + } + assert client.get("/api/display/default-content").json() == default_before + current_frame = Image.open(BytesIO(client.get("/api/display/current-frame").content)).convert("RGB") + thumbnail = Image.open(BytesIO(client.get(created["thumbnail_url"]).content)).convert("RGB") + assert current_frame.tobytes() == thumbnail.tobytes() + + before_rejected = current_frame.tobytes() + assert client.post(f"/api/templates/{created['id']}/play", json={}).status_code == 428 + assert client.post( + f"/api/templates/{created['id']}/play", + headers={"If-Match": '"stale"'}, + json={}, + ).status_code == 409 + assert Image.open(BytesIO(client.get("/api/display/current-frame").content)).convert("RGB").tobytes() == before_rejected + assert client.post( + "/api/templates/00000000-0000-4000-8000-000000000099/play", + headers={"If-Match": '"missing"'}, + json={}, + ).status_code == 404 + assert client.post( + "/api/templates/not-a-uuid/play", + headers={"If-Match": '"missing"'}, + json={}, + ).status_code == 404 + + demo = client.get("/api/templates?include_demo=true").json()["templates"][0] + demo_play = client.post( + f"/api/templates/{DEMO_STATIC_ID}/play", + headers={"If-Match": f'"{demo["revision"]}"'}, + json={}, + ) + assert demo_play.status_code == 200 + demo_state = client.get("/api/status").json()["state"] + assert demo_state["current_content"]["id"] == DEMO_STATIC_ID + assert client.get("/api/display/default-content").json() == default_before + + +def test_template_crud_copy_persistence_and_thumbnail_cleanup(tmp_path): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + + created_response = client.post("/api/templates", json={"name": "中文模板", "scene": scene()}) + assert created_response.status_code == 201 + created = created_response.json() + assert created["name"] == "中文模板" + assert created["size_bytes"] > 0 + assert "schema_version" not in created + old_url = created["thumbnail_url"] + old_thumbnail_name = f"{created['id']}-{created['digest']}.png" + persisted_record = json.loads( + ( + tmp_path + / "data" + / "templates" + / "records" + / f"{created['id']}.json" + ).read_text(encoding="utf-8") + ) + assert persisted_record["schema_version"] == TEMPLATE_SCHEMA_VERSION + + duplicate_name = client.post("/api/templates", json={"name": "中文模板", "scene": scene()}) + assert duplicate_name.status_code == 409 + duplicate_case = client.post("/api/templates", json={"name": "中文模板 ", "scene": scene()}) + assert duplicate_case.status_code == 409 + + thumbnail_response = client.get(old_url) + assert thumbnail_response.status_code == 200 + image = Image.open(BytesIO(thumbnail_response.content)) + assert image.size == (64, 64) + assert image.mode == "RGB" + assert image.getpixel((0, 0)) == (1, 2, 3) + assert len(set(image.getdata())) > 1 + + updated_response = client.put( + f"/api/templates/{created['id']}", + headers={"If-Match": f'"{created["revision"]}"'}, + json={"scene": scene(background=(9, 8, 7), text="NEW")}, + ) + assert updated_response.status_code == 200 + updated = updated_response.json() + assert updated["digest"] != created["digest"] + assert updated["thumbnail_url"] != old_url + assert client.get(old_url).status_code == 404 + assert not (tmp_path / "data" / "templates" / "thumbnails" / old_thumbnail_name).exists() + updated_image = Image.open(BytesIO(client.get(updated["thumbnail_url"]).content)) + assert updated_image.getpixel((0, 0)) == (9, 8, 7) + + renamed = client.patch( + f"/api/templates/{created['id']}", + headers={"If-Match": f'"{updated["revision"]}"'}, + json={"name": "新名字"}, + ) + assert renamed.status_code == 200 + renamed_template = renamed.json() + assert renamed_template["digest"] == updated["digest"] + copied = client.post(f"/api/templates/{created['id']}/copy", json={}) + assert copied.status_code == 201 + assert copied.json()["name"] == "新名字 - 副本" + copied_again = client.post(f"/api/templates/{created['id']}/copy", json={}) + assert copied_again.json()["name"] == "新名字 - 副本 2" + + restarted = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + listing = restarted.get("/api/templates").json() + assert [item["name"] for item in listing["templates"]] == [ + "新名字 - 副本 2", "新名字 - 副本", "新名字", + ] + assert listing["storage"]["templates_bytes"] == sum(item["size_bytes"] for item in listing["templates"]) + assert listing["storage"]["free_bytes"] > 0 + + delete_headers = {"If-Match": f'"{renamed_template["revision"]}"'} + assert client.delete(f"/api/templates/{created['id']}", headers=delete_headers).json() == {"ok": True} + assert client.get(f"/api/templates/{created['id']}").status_code == 404 + assert client.delete(f"/api/templates/{created['id']}", headers=delete_headers).status_code == 404 + + +def test_demo_static_template_is_first_read_only_and_copies_to_normal_content(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + + assert client.get("/api/templates").json()["templates"] == [] + listing = client.get("/api/templates?include_demo=true").json() + demo = listing["templates"][0] + assert demo["id"] == DEMO_STATIC_ID + assert demo["name"] == "演示静态图" + assert demo["demo_order"] == 1 + assert demo["read_only"] is True + assert demo["size_bytes"] == 0 + assert listing["storage"]["templates_bytes"] == 0 + + detail = client.get(f"/api/templates/{DEMO_STATIC_ID}").json() + assert base64.b64decode(detail["scene"]["pixelRgb"]) == render_startup_smile().tobytes() + thumbnail = client.get(demo["thumbnail_url"]) + assert thumbnail.status_code == 200 + assert Image.open(BytesIO(thumbnail.content)).tobytes() == render_startup_smile().tobytes() + + for method, payload in (("put", {"scene": scene()}), ("patch", {"name": "不能改"})): + response = getattr(client, method)( + f"/api/templates/{DEMO_STATIC_ID}", + headers={"If-Match": f'"{demo["revision"]}"'}, + json=payload, + ) + assert response.status_code == 409 + assert response.json()["detail"] == DEMO_RESTRICTED_MESSAGE + removed = client.delete( + f"/api/templates/{DEMO_STATIC_ID}", + headers={"If-Match": f'"{demo["revision"]}"'}, + ) + assert removed.status_code == 409 + + copied = client.post("/api/library/copy", json={ + "source_type": "template", + "source_id": DEMO_STATIC_ID, + "source_revision": demo["revision"], + "destination_type": "static", + }) + assert copied.status_code == 201 + copy_item = copied.json()["item"] + assert copy_item["id"] != DEMO_STATIC_ID + assert copy_item["name"] == "演示静态图 - 副本" + assert "read_only" not in copy_item + renamed = client.patch( + f"/api/templates/{copy_item['id']}", + headers={"If-Match": f'"{copy_item["revision"]}"'}, + json={"name": "我的笑脸"}, + ) + assert renamed.status_code == 200 + assert len(list((tmp_path / "data" / "templates" / "records").glob("*.json"))) == 1 + + +def test_template_preserves_multilingual_text(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + multilingual = "你好 25°C" + created = client.post( + "/api/templates", + json={"name": "多语言", "scene": scene(text=multilingual)}, + ) + + assert created.status_code == 201 + stored = client.get(f"/api/templates/{created.json()['id']}") + assert stored.status_code == 200 + assert stored.json()["scene"]["elements"][0]["text"] == multilingual + + +def test_template_mutations_require_current_revision(tmp_path): + client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) + created = client.post("/api/templates", json={"name": "shared", "scene": scene()}).json() + path = f"/api/templates/{created['id']}" + original_revision = created["revision"] + + assert client.put(path, json={"scene": scene(text="missing")}).status_code == 428 + first = client.put( + path, + headers={"If-Match": f'"{original_revision}"'}, + json={"scene": scene(text="first")}, + ) + assert first.status_code == 200 + current = first.json() + + stale_headers = {"If-Match": f'"{original_revision}"'} + assert client.put(path, headers=stale_headers, json={"scene": scene(text="stale")}).status_code == 409 + assert client.patch(path, headers=stale_headers, json={"name": "stale name"}).status_code == 409 + assert client.delete(path, headers=stale_headers).status_code == 409 + preserved = client.get(path).json() + assert preserved["name"] == "shared" + assert preserved["scene"]["elements"][0]["text"] == "first" + assert preserved["revision"] == current["revision"] + + +def test_template_validation_and_status_codes(tmp_path, monkeypatch): + app = create_app(project_root=tmp_path, driver_kind="mock") + client = TestClient(app) + broken = scene() + broken["pixelRgb"] = "bad" + assert client.post("/api/templates", json={"name": "bad", "scene": broken}).status_code == 422 + + duplicate_ids = scene() + duplicate_ids["elements"].append(dict(duplicate_ids["elements"][0])) + assert client.post("/api/templates", json={"name": "bad ids", "scene": duplicate_ids}).status_code == 422 + assert client.get("/api/templates/not-a-uuid").status_code == 404 + + def full(*_args, **_kwargs): + raise TemplateStorageFullError("not enough disk space to save template") + + monkeypatch.setattr(app.state.template_store, "create", full) + assert client.post("/api/templates", json={"name": "full", "scene": scene()}).status_code == 507 + + +def test_reconcile_removes_only_strict_thumbnail_artifacts(tmp_path): + store = TemplateStore(tmp_path / "data") + created = store.create("keep", scene()) + thumbnail_dir = tmp_path / "data" / "templates" / "thumbnails" + orphan = thumbnail_dir / "11111111-1111-1111-1111-111111111111-0123456789abcdef.png" + unrelated = thumbnail_dir / "user-picture.png" + temporary = thumbnail_dir / ".partial.png.abc.tmp" + orphan.write_bytes(b"orphan") + unrelated.write_bytes(b"unrelated") + temporary.write_bytes(b"temporary") + + TemplateStore(tmp_path / "data") + + assert not orphan.exists() + assert not temporary.exists() + assert unrelated.read_bytes() == b"unrelated" + assert store.thumbnail_path(created["id"], created["digest"]).exists() + + +def test_legacy_template_migrates_once_without_changing_revision_or_digest(tmp_path): + data_dir = tmp_path / "data" + original_store = TemplateStore(data_dir) + created = original_store.create("legacy", scene()) + record_path = ( + data_dir + / "templates" + / "records" + / f"{created['id']}.json" + ) + legacy = json.loads(record_path.read_text(encoding="utf-8")) + legacy.pop("schema_version") + record_path.write_text( + json.dumps(legacy, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + migrated_store = TemplateStore(data_dir) + migrated = migrated_store.get(created["id"]) + migrated_bytes = record_path.read_bytes() + + assert migrated["revision"] == created["revision"] + assert migrated["digest"] == created["digest"] + assert "schema_version" not in migrated + assert json.loads(migrated_bytes)["schema_version"] == TEMPLATE_SCHEMA_VERSION + + TemplateStore(data_dir) + assert record_path.read_bytes() == migrated_bytes + + +@pytest.mark.parametrize("failure_kind", ["future", "corrupt", "unknown"]) +def test_invalid_template_record_blocks_startup_without_deleting_any_thumbnail( + tmp_path, + failure_kind, +): + data_dir = tmp_path / "data" + initial = TemplateStore(data_dir) + created = initial.create("keep", scene()) + record_path = ( + data_dir + / "templates" + / "records" + / f"{created['id']}.json" + ) + thumbnail_path = initial.thumbnail_path(created["id"], created["digest"]) + thumbnail_bytes = thumbnail_path.read_bytes() + orphan = ( + data_dir + / "templates" + / "thumbnails" + / "11111111-1111-1111-1111-111111111111-0123456789abcdef.png" + ) + orphan.write_bytes(b"orphan") + + if failure_kind == "corrupt": + original = b"{bad json" + else: + document = json.loads(record_path.read_text(encoding="utf-8")) + if failure_kind == "future": + document["schema_version"] = TEMPLATE_SCHEMA_VERSION + 1 + else: + document["unexpected"] = True + original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8") + record_path.write_bytes(original) + + with pytest.raises(TemplateValidationError, match="invalid template record"): + TemplateStore(data_dir) + + assert record_path.read_bytes() == original + assert thumbnail_path.read_bytes() == thumbnail_bytes + assert orphan.read_bytes() == b"orphan" + + +def test_template_preflight_validates_every_record_before_migrating_any(tmp_path): + data_dir = tmp_path / "data" + initial = TemplateStore(data_dir) + initial.create("one", scene(text="one")) + initial.create("two", scene(text="two")) + record_paths = sorted((data_dir / "templates" / "records").glob("*.json")) + legacy_path, invalid_path = record_paths + legacy_document = json.loads(legacy_path.read_text(encoding="utf-8")) + legacy_document.pop("schema_version") + legacy_bytes = ( + json.dumps(legacy_document, ensure_ascii=False, indent=2) + "\n" + ).encode("utf-8") + legacy_path.write_bytes(legacy_bytes) + invalid_path.write_bytes(b"{bad json") + + with pytest.raises(TemplateValidationError): + TemplateStore(data_dir) + + assert legacy_path.read_bytes() == legacy_bytes + + +def test_template_migration_write_failure_rolls_back_every_record(tmp_path, monkeypatch): + data_dir = tmp_path / "data" + initial = TemplateStore(data_dir) + initial.create("one", scene(text="one")) + initial.create("two", scene(text="two")) + record_paths = sorted((data_dir / "templates" / "records").glob("*.json")) + legacy_bytes = {} + for path in record_paths: + document = json.loads(path.read_text(encoding="utf-8")) + document.pop("schema_version") + content = (json.dumps(document, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + path.write_bytes(content) + legacy_bytes[path] = content + + real_replace = persistence.os.replace + replace_calls = 0 + + def fail_second_replace(source, target): + nonlocal replace_calls + replace_calls += 1 + if replace_calls == 2: + raise OSError("second migration replace failed") + return real_replace(source, target) + + monkeypatch.setattr(persistence.os, "replace", fail_second_replace) + with pytest.raises(TemplateError, match="second migration replace failed"): + TemplateStore(data_dir) + + assert {path: path.read_bytes() for path in record_paths} == legacy_bytes + assert list((data_dir / "templates" / "records").glob(".*.tmp")) == [] + + +@pytest.mark.parametrize( + ("path_parts", "value"), + [ + (("scene", "version"), True), + (("scene", "width"), 64.0), + (("scene", "elements", 0, "size"), True), + ], +) +def test_current_template_schema_rejects_noncanonical_json_types( + tmp_path, + path_parts, + value, +): + data_dir = tmp_path / "data" + initial = TemplateStore(data_dir) + created = initial.create("strict", scene()) + record_path = ( + data_dir + / "templates" + / "records" + / f"{created['id']}.json" + ) + document = json.loads(record_path.read_text(encoding="utf-8")) + target = document + for part in path_parts[:-1]: + target = target[part] + target[path_parts[-1]] = value + original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8") + record_path.write_bytes(original) + + with pytest.raises(TemplateValidationError, match="must be an integer"): + TemplateStore(data_dir) + + assert record_path.read_bytes() == original diff --git a/核桃派软件源代码/tests/test_ui_copy_editor.mjs b/核桃派软件源代码/tests/test_ui_copy_editor.mjs new file mode 100644 index 0000000..d39bb22 --- /dev/null +++ b/核桃派软件源代码/tests/test_ui_copy_editor.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const editorRoot = new URL("../app/ui_copy_editor/static/", import.meta.url); + +test("special editor exposes the agreed modes and guarded editing flow", async () => { + const [source, style, runtime, board, device, html] = await Promise.all([ + readFile(new URL("editor.js", editorRoot), "utf8"), + readFile(new URL("editor.css", editorRoot), "utf8"), + readFile(new URL("../../static/ui-copy-runtime.js", editorRoot), "utf8"), + readFile(new URL("../../static/scene-board.js", editorRoot), "utf8"), + readFile(new URL("../../static/views/device.js", editorRoot), "utf8"), + readFile(new URL("../../static/index.html", editorRoot), "utf8"), + ]); + + assert.match(source, /data-mode="browse"/); + assert.match(source, /data-mode="select"/); + assert.match(source, /data-mode="insert"/); + assert.match(source, /确定修改/); + assert.match(source, /删除这行文字/); + assert.match(source, /确认删除文字/); + assert.match(source, /"If-Match": etag/); + assert.match(source, /event\.stopImmediatePropagation\(\)/); + assert.match(source, /crypto\.randomUUID\(\)/); + assert.match(source, /history\.push/); + assert.match(source, /页面和输入内容均未改变/); + assert.doesNotMatch(source, /MutationObserver/); + assert.doesNotMatch(source, /setTimeout/); + assert.doesNotMatch(source, /applyAllBindings/); + assert.doesNotMatch(source, /innerHTML\s*=\s*copyTextInput/); + + const inputHandler = source.match(/elements\.copyForm\.addEventListener\("input", \(\) => \{([\s\S]*?)\n\}\);/)?.[1] || ""; + assert.doesNotMatch(inputHandler, /fetch|setUiCopyDraft|applyUiCopyId|textContent\s*=/); + assert.match(runtime, /preserveAccessibleName/); + assert.match(runtime, /record\.defaultHidden/); + assert.match(source, /恢复显示这行文字/); + assert.doesNotMatch(runtime, /\.hidden\s*=/); + assert.doesNotMatch(runtime, /MutationObserver/); + assert.match(board, /renderUiCopy\("copy\.dynamic\.board\.monitor_eyebrow"/); + assert.match(device, /copy\.dynamic\.device\.voltage_ok/); + assert.match(html, /id="topbar-cpu"[^>]*data-ui-copy-value/); + assert.match(html, /id="brightness-value"[^>]*data-ui-copy-value/); + + assert.match(style, /#ui-copy-editor-toggle[\s\S]*position:\s*fixed/); + assert.match(style, /right:\s*max\(16px, env\(safe-area-inset-right\)\)/); + assert.match(style, /@media \(max-width: 40rem\)/); + assert.doesNotMatch(style, /\.ui-copy-custom-insertion[\s\S]{0,250}position:\s*absolute/); +}); diff --git a/核桃派软件源代码/tests/test_ui_copy_editor.py b/核桃派软件源代码/tests/test_ui_copy_editor.py new file mode 100644 index 0000000..04be5ba --- /dev/null +++ b/核桃派软件源代码/tests/test_ui_copy_editor.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import json +from pathlib import Path +import re +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app +from app.ui_copy_editor.store import UiCopyDraftStore, UiCopyDraftValidationError +from app.ui_copy_editor.settings import UI_COPY_EDITOR_BUILD_ENABLED +from scripts.build_ui_copy_catalog import build_catalog, canonical_bytes +from scripts.migrate_ui_copy_draft import build_migration + + +def _put_body(document: dict) -> dict: + return { + "schema_version": document["schema_version"], + "base_catalog_digest": document["base_catalog_digest"], + "revision": document["revision"], + "updated_at": document["updated_at"], + "overrides": document["overrides"], + "insertions": document["insertions"], + } + + +def _special_client(tmp_path: Path) -> tuple[TestClient, object]: + app = create_app(tmp_path, driver_kind="mock", ui_copy_editor_build_enabled=True) + return TestClient(app), app + + +def test_generated_ui_copy_catalog_is_current_and_covers_static_and_dynamic_copy(): + static_root = Path(__file__).resolve().parents[1] / "app" / "static" + catalog_path = static_root / "ui-copy.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + + assert catalog_path.read_bytes() == canonical_bytes(build_catalog()) + assert catalog["schema_version"] == 2 + assert len(catalog["items"]) >= 200 + assert len(catalog["anchors"]) >= 100 + assert any(item["kind"] == "html_text" for item in catalog["items"].values()) + assert any(item["kind"] == "dynamic_template" and item["placeholders"] for item in catalog["items"].values()) + assert {item["render"]["type"] for item in catalog["items"].values()} == { + "static_text", "attribute", "dynamic_template" + } + assert catalog["items"]["copy.dynamic.device.voltage_ok"]["placeholders"] == ["volts"] + assert "copy.700c9d89e165f685" not in catalog["items"] + sources = {item["source"] for item in catalog["items"].values()} + assert not any("#brightness-value::text" in source for source in sources) + assert not any("#topbar-cpu::text" in source for source in sources) + + finalized_text = { + "copy.267697d7d5b31a20": "WiFi设置", + "copy.2c783f3068fc5ac0": "测试亮度(此亮度独立于屏幕亮度)", + "copy.5378fd8ef58a2368": "拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)", + "copy.623419ad30e1d25e": "亮度(实际亮度会受到电压保护功能限制)", + "copy.6da7a87e7c173f8d": "设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。", + "copy.71922de473a7c23f": "保存的动图", + "copy.8556f6cbbb523cd9": "屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)", + "copy.b8aeabe524848619": "屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)", + "copy.bc1387b9107d6c7d": "仅为程序设定值,屏幕实际情况软件无法得知", + "copy.da499f45166370a8": "角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。", + "copy.df7f55d47d7a4ee5": "立即生效(会导致网页断开)", + "copy.dfda56f2b2367ca8": "设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip", + "copy.e26968d8a3ff29f3": "全黑(不亮)", + "copy.ec993108f9022830": "开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度", + "copy.f86b6371b88f559a": "设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。", + "copy.ff765a69c8b49cee": "全白(高亮度会导致严重耗电)", + } + finalized_hidden = { + "copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.209c8a51007fadfb", + "copy.4a2ff24bc62e26ef", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d", + "copy.5e26a038197f59b8", "copy.62029c969ffb9c9d", "copy.670c95eddb9bac43", + "copy.99050fd7437476f6", "copy.b0e7bd7e96cbe4d3", "copy.d9541ebf887002a5", + "copy.dynamic.board.composition_eyebrow", "copy.dynamic.board.composition_mode", + "copy.dynamic.board.composition_title", "copy.dynamic.board.monitor_eyebrow", + "copy.dynamic.board.monitor_mode", "copy.efdd13bfd5037544", + } + assert {copy_id: catalog["items"][copy_id]["text"] for copy_id in finalized_text} == finalized_text + assert all(catalog["items"][copy_id]["render"].get("default_hidden") is True for copy_id in finalized_hidden) + assert len(finalized_text) + len(finalized_hidden) == 34 + + +def test_normal_build_has_no_editor_entrypoints(tmp_path): + assert UI_COPY_EDITOR_BUILD_ENABLED is False + app = create_app(tmp_path, driver_kind="mock") + client = TestClient(app) + + home = client.get("/") + assert home.status_code == 200 + assert "__UI_COPY_EDITOR_BUILD__" not in home.text + assert "ui-copy-editor" not in home.text + assert client.get("/api/dev/ui-copy").status_code == 404 + assert client.put("/api/dev/ui-copy", json={}).status_code == 404 + assert client.get("/api/dev/ui-copy/export").status_code == 404 + assert client.get("/__ui-copy-editor/not-mounted/editor.js").status_code == 404 + + +def test_special_build_serves_versioned_editor_and_persists_revisioned_draft(tmp_path): + client, app = _special_client(tmp_path) + home = client.get("/") + assert home.status_code == 200 + assert "__UI_COPY_EDITOR_BUILD__" in home.text + assert home.text.index("__UI_COPY_EDITOR_BUILD__") < home.text.index(f'/static/{app.state.app_version}/app.js') + assert "ui-copy-runtime.js" in home.text + script_match = re.search(r'src="(?P/__ui-copy-editor/[^"]+/editor\.js)"', home.text) + assert script_match + asset = client.get(script_match.group("url")) + assert asset.status_code == 200 + assert asset.headers["cache-control"] == "public, max-age=31536000, immutable" + + initial_response = client.get("/api/dev/ui-copy") + initial = initial_response.json() + assert initial_response.headers["etag"] == '"0"' + assert initial["revision"] == 0 + assert initial["overrides"] == {} + assert initial["insertions"] == [] + assert initial["catalog_mismatch"] is False + assert client.put("/api/dev/ui-copy", json=_put_body(initial)).status_code == 428 + + copy_id, item = next( + (key, value) + for key, value in app.state.ui_copy_store.items.items() + if value["kind"] == "html_text" and not value["placeholders"] + ) + anchor_id, anchor = next(iter(app.state.ui_copy_store.anchors.items())) + body = _put_body(initial) + body["overrides"] = { + copy_id: {"text": item["text"] + "(测试)", "font_size_px": 18, "hidden": False} + } + body["insertions"] = [{ + "id": str(uuid4()), + "workspace_id": anchor["scope"], + "anchor_id": anchor_id, + "placement": "after", + "text": "临时新增提示", + "font_size_px": 16, + }] + saved_response = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body) + assert saved_response.status_code == 200 + saved = saved_response.json() + assert saved_response.headers["etag"] == '"1"' + assert saved["revision"] == 1 + assert saved["overrides"] == body["overrides"] + assert saved["insertions"] == body["insertions"] + + stale = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body) + assert stale.status_code == 409 + exported = client.get("/api/dev/ui-copy/export") + assert exported.status_code == 200 + assert "attachment" in exported.headers["content-disposition"] + assert exported.json()["revision"] == 1 + + recreated = create_app(tmp_path, driver_kind="mock", ui_copy_editor_build_enabled=True) + reread = TestClient(recreated).get("/api/dev/ui-copy").json() + assert reread["revision"] == 1 + assert reread["overrides"] == body["overrides"] + assert reread["insertions"] == body["insertions"] + + +@pytest.mark.parametrize( + "mutation, expected", + [ + (lambda body, _store: body["overrides"].update({"copy.unknown": {"text": "x"}}), "unknown UI copy id"), + (lambda body, store: body["overrides"].update({next(iter(store.items)): {"font_size_px": 7}}), "font size"), + (lambda body, _store: body.update({"unknown": True}), "fields are invalid"), + ], +) +def test_special_build_rejects_invalid_draft_without_writing(tmp_path, mutation, expected): + client, app = _special_client(tmp_path) + initial = client.get("/api/dev/ui-copy").json() + body = _put_body(initial) + mutation(body, app.state.ui_copy_store) + response = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body) + assert response.status_code == 422 + assert expected in response.json()["detail"] + assert not app.state.ui_copy_store.path.exists() + + +def test_dynamic_placeholders_must_keep_the_same_names_and_multiplicity(tmp_path): + client, app = _special_client(tmp_path) + initial = client.get("/api/dev/ui-copy").json() + copy_id, item = next( + (key, value) + for key, value in app.state.ui_copy_store.items.items() + if value["placeholders"] + ) + body = _put_body(initial) + body["overrides"] = {copy_id: {"text": item["text"].replace("{" + item["placeholders"][0] + "}", "")}} + response = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body) + assert response.status_code == 422 + assert "placeholders changed" in response.json()["detail"] + + +def test_corrupt_or_future_ui_copy_draft_is_preserved_and_rejected(tmp_path): + source_catalog = Path(__file__).resolve().parents[1] / "app" / "static" / "ui-copy.json" + draft_path = tmp_path / "development" / "ui-copy-editor" / "draft.json" + draft_path.parent.mkdir(parents=True) + corrupt = b'{"schema_version":' + draft_path.write_bytes(corrupt) + with pytest.raises(UiCopyDraftValidationError, match="valid UTF-8 JSON"): + UiCopyDraftStore(tmp_path, source_catalog) + assert draft_path.read_bytes() == corrupt + + future = { + "schema_version": 2, + "base_catalog_digest": "0" * 64, + "revision": 0, + "updated_at": None, + "overrides": {}, + "insertions": [], + } + draft_path.write_text(json.dumps(future), encoding="utf-8") + before = draft_path.read_bytes() + with pytest.raises(UiCopyDraftValidationError, match="schema_version"): + UiCopyDraftStore(tmp_path, source_catalog) + assert draft_path.read_bytes() == before + + +def test_catalog_mismatch_allows_export_but_blocks_replacement(tmp_path): + source_catalog = Path(__file__).resolve().parents[1] / "app" / "static" / "ui-copy.json" + store = UiCopyDraftStore(tmp_path, source_catalog) + document = store.empty_document() + store.replace(document, expected_revision=0) + raw = json.loads(store.path.read_text(encoding="utf-8")) + raw["base_catalog_digest"] = "0" * 64 + store.path.write_text(json.dumps(raw), encoding="utf-8") + + mismatched = UiCopyDraftStore(tmp_path, source_catalog) + assert mismatched.status_document()["catalog_mismatch"] is True + with pytest.raises(Exception, match="catalog changed"): + mismatched.replace(raw, expected_revision=1) + + +def test_catalog_v1_to_v2_migration_retains_registered_copy_and_reports_removed_copy(): + old_catalog = { + "schema_version": 1, + "items": { + "copy.keep": {"text": "方向"}, + "copy.remove": {"text": "1"}, + }, + "anchors": {}, + "insertions": [], + } + new_catalog = { + "schema_version": 2, + "items": { + "copy.keep": { + "text": "方向", "scope": "device", "source": "test", + "kind": "html_text", "placeholders": [], + "render": {"type": "static_text", "selector": "#direction", "text_index": 0}, + }, + }, + "anchors": {}, + "insertions": [], + } + old_bytes = canonical_bytes(old_catalog) + new_bytes = canonical_bytes(new_catalog) + import hashlib + draft = { + "schema_version": 1, + "base_catalog_digest": hashlib.sha256(old_bytes).hexdigest(), + "revision": 44, + "updated_at": "2026-08-05T04:38:54Z", + "overrides": { + "copy.keep": {"text": "屏幕方向(仅为软件设定)"}, + "copy.remove": {"text": "误改按钮"}, + }, + "insertions": [], + } + + migrated, report = build_migration( + draft, old_catalog, old_bytes, new_catalog, new_bytes, + excluded_copy_ids={"copy.remove"}, + ) + + assert migrated["revision"] == 45 + assert migrated["overrides"] == {"copy.keep": {"text": "屏幕方向(仅为软件设定)"}} + assert migrated["base_catalog_digest"] == hashlib.sha256(new_bytes).hexdigest() + assert report["removed_overrides"] == [{ + "copy_id": "copy.remove", + "reason": "explicitly_excluded", + "override": {"text": "误改按钮"}, + }] diff --git a/核桃派软件源代码/tests/test_voltage_monitor.py b/核桃派软件源代码/tests/test_voltage_monitor.py new file mode 100644 index 0000000..0e9e300 --- /dev/null +++ b/核桃派软件源代码/tests/test_voltage_monitor.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest + +from app.config.store import ConfigStore +from app.power.voltage import ( + AdcDisconnectedError, + CALIBRATION_SAMPLE_COUNT, + CalibrationConflictError, + PROTECTION_ENFORCEMENT_ERROR_CODE, + SampleGroup, + VoltageMonitor, + voltage_from_raw, +) +from app.power.protection import ( + LowVoltageProtectionDirective, + brightness_limit_for_voltage, + error_retry_interval, + protection_directive_for_voltage, + successful_sampling_interval, +) + + +class FakeDevice: + def __init__(self, factory: "FakeDeviceFactory") -> None: + self.factory = factory + + def __enter__(self) -> "FakeDevice": + with self.factory.guard: + self.factory.active += 1 + self.factory.max_active = max(self.factory.max_active, self.factory.active) + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + with self.factory.guard: + self.factory.active -= 1 + + def configure(self) -> None: + self.factory.configure_count += 1 + + def read_fresh(self) -> tuple[int, int]: + if self.factory.read_delay: + time.sleep(self.factory.read_delay) + with self.factory.guard: + value = self.factory.raw_values[self.factory.read_count % len(self.factory.raw_values)] + self.factory.read_count += 1 + return value, 0x0C + + +class FakeDeviceFactory: + def __init__(self, raw_values: list[int] | None = None, read_delay: float = 0.0) -> None: + self.raw_values = raw_values or [13115] + self.read_delay = read_delay + self.read_count = 0 + self.configure_count = 0 + self.active = 0 + self.max_active = 0 + self.guard = threading.Lock() + + def __call__(self) -> FakeDevice: + return FakeDevice(self) + + +class DisconnectedFactory: + def __call__(self): + raise AdcDisconnectedError("bus_missing", "没有找到 /dev/i2c-1") + + +def voltage_group(volts: float) -> SampleGroup: + return SampleGroup( + raw_median=0.0, + uncalibrated_volts=volts, + config=0x0C, + spread_volts=0.0, + ) + + +def install_voltage_sequence(monkeypatch, monitor: VoltageMonitor, volts: list[float]) -> None: + values = iter(volts) + monkeypatch.setattr(monitor, "_read_group", lambda _count: voltage_group(next(values))) + + +def test_voltage_conversion_and_normal_sample(tmp_path): + store = ConfigStore(project_root=tmp_path) + factory = FakeDeviceFactory([13113, 13114, 13115, 13116, 13117]) + monitor = VoltageMonitor(store, device_factory=factory) + + status = monitor.sample_now() + + assert voltage_from_raw(13115) == pytest.approx(5.000094, abs=0.000001) + assert status["status"] == "ok" + assert status["volts"] == pytest.approx(voltage_from_raw(13115)) + assert status["sampled_at"].endswith("Z") + assert status["calibrated"] is False + assert factory.read_count == 5 + + +def test_calibration_uses_fresh_samples_and_does_not_chain_old_factor(tmp_path): + store = ConfigStore(project_root=tmp_path) + factory = FakeDeviceFactory([13000]) + monitor = VoltageMonitor(store, device_factory=factory) + + first = monitor.preview_calibration(5.000) + first_factor = first["proposed_factor"] + confirmed = monitor.confirm_calibration(first["proposal_id"]) + + assert first["current_factor"] == 1.0 + assert first["projected_volts"] == pytest.approx(5.0) + assert confirmed["calibrated"] is True + assert store.config["voltage_calibration_factor"] == pytest.approx(first_factor) + + second = monitor.preview_calibration(5.000) + assert second["current_factor"] == pytest.approx(first_factor) + assert second["proposed_factor"] == pytest.approx(first_factor) + assert second["proposed_factor"] != pytest.approx(first_factor * first_factor) + assert factory.read_count == CALIBRATION_SAMPLE_COUNT * 2 + 5 + + +def test_calibration_rejects_bad_reference_unstable_input_and_expired_proposal(tmp_path): + store = ConfigStore(project_root=tmp_path) + monitor = VoltageMonitor(store, device_factory=FakeDeviceFactory([13115])) + + with pytest.raises(Exception, match="4.500V"): + monitor.preview_calibration(4.0) + + unstable = VoltageMonitor( + store, + device_factory=FakeDeviceFactory([12900, 13200]), + ) + with pytest.raises(Exception, match="采样波动"): + unstable.preview_calibration(5.0) + + proposal = monitor.preview_calibration(5.0) + assert monitor._proposal is not None + monitor._proposal = replace( + monitor._proposal, + expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + with pytest.raises(CalibrationConflictError, match="超过 120 秒"): + monitor.confirm_calibration(proposal["proposal_id"]) + + +def test_disconnection_is_reported_without_zero_voltage(tmp_path): + store = ConfigStore(project_root=tmp_path) + monitor = VoltageMonitor(store, device_factory=DisconnectedFactory()) + + status = monitor.sample_now() + + assert status == { + "status": "disconnected", + "volts": None, + "sampled_at": None, + "calibrated": False, + "error_code": "bus_missing", + } + + +def test_normal_and_calibration_sampling_never_open_i2c_concurrently(tmp_path): + store = ConfigStore(project_root=tmp_path) + factory = FakeDeviceFactory([13115], read_delay=0.002) + monitor = VoltageMonitor(store, device_factory=factory) + errors: list[Exception] = [] + + def normal_sample() -> None: + try: + monitor.sample_now() + except Exception as exc: # pragma: no cover - diagnostic guard + errors.append(exc) + + thread = threading.Thread(target=normal_sample) + thread.start() + monitor.preview_calibration(5.0) + thread.join() + + assert errors == [] + assert factory.max_active == 1 + + +def test_reset_clears_calibration_metadata(tmp_path): + store = ConfigStore(project_root=tmp_path) + monitor = VoltageMonitor(store, device_factory=FakeDeviceFactory([13115])) + proposal = monitor.preview_calibration(5.0) + monitor.confirm_calibration(proposal["proposal_id"]) + + status = monitor.reset_calibration() + + assert status["calibrated"] is False + assert store.config["voltage_calibration_factor"] == 1.0 + assert store.config["voltage_calibrated_at"] is None + + +def test_background_monitor_starts_polls_and_stops_cleanly(tmp_path): + store = ConfigStore(project_root=tmp_path) + factory = FakeDeviceFactory([13115]) + monitor = VoltageMonitor(store, device_factory=factory, poll_interval=0.02) + + monitor.start() + deadline = time.monotonic() + 1.0 + while factory.read_count < 5 and time.monotonic() < deadline: + time.sleep(0.005) + monitor.close() + + assert factory.read_count >= 5 + assert monitor._thread is not None + assert not monitor._thread.is_alive() + + +@pytest.mark.parametrize( + ("volts", "mode", "limit"), + [ + (4.800001, "inactive", None), + (4.8, "limiting", 100), + (4.65, "limiting", 75), + (4.56, "limiting", 60), + (4.5, "limiting", 50), + (4.499999, "critical", 35), + ], +) +def test_protection_policy_uses_exact_thresholds_and_floor_curve(volts, mode, limit): + directive = protection_directive_for_voltage(7, True, volts) + + assert directive == LowVoltageProtectionDirective(7, True, mode, limit, False) + assert brightness_limit_for_voltage(volts) == limit + + +def test_disabled_and_unavailable_policy_do_not_invent_a_brightness_cap(): + assert protection_directive_for_voltage(1, False, 4.0) == ( + LowVoltageProtectionDirective(1, False, "disabled", None, False) + ) + assert protection_directive_for_voltage( + 2, + True, + None, + reading_stale=True, + ) == LowVoltageProtectionDirective(2, True, "unavailable", None, True) + + +def test_monitor_applies_stricter_state_immediately_and_relaxes_after_two_groups( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"low_voltage_protection_enabled": True}) + monitor = VoltageMonitor(store) + install_voltage_sequence(monkeypatch, monitor, [4.7, 4.6, 4.4, 4.6, 4.7]) + + monitor.sample_now() + assert monitor.get_protection_status()["brightness_limit_percent"] == 83 + monitor.sample_now() + assert monitor.get_protection_status()["brightness_limit_percent"] == 66 + monitor.sample_now() + assert monitor.get_protection_status()["mode"] == "critical" + + monitor.sample_now() + assert monitor.get_protection_status()["mode"] == "critical" + monitor.sample_now() + status = monitor.get_protection_status() + assert status["mode"] == "limiting" + assert status["brightness_limit_percent"] == 66 + + +def test_failed_sample_holds_last_protection_and_cancels_pending_relaxation( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"low_voltage_protection_enabled": True}) + monitor = VoltageMonitor(store) + outcomes = iter([voltage_group(4.4), voltage_group(4.6), "failure", voltage_group(4.6), voltage_group(4.6)]) + + def read(_count): + outcome = next(outcomes) + if outcome == "failure": + raise AdcDisconnectedError("bus_missing", "missing") + return outcome + + monkeypatch.setattr(monitor, "_read_group", read) + monitor.sample_now() + monitor.sample_now() + monitor.sample_now() + failed = monitor.get_protection_status() + assert failed["mode"] == "critical" + assert failed["reading_stale"] is True + + monitor.sample_now() + fresh_once = monitor.get_protection_status() + assert fresh_once["mode"] == "critical" + assert fresh_once["reading_stale"] is False + monitor.sample_now() + assert monitor.get_protection_status()["mode"] == "limiting" + + +def test_failure_without_history_is_unavailable_and_never_locks_screen(tmp_path): + store = ConfigStore(project_root=tmp_path) + store.update({"low_voltage_protection_enabled": True}) + monitor = VoltageMonitor(store, device_factory=DisconnectedFactory()) + + monitor.sample_now() + first = monitor.get_protection_status() + monitor.sample_now() + second = monitor.get_protection_status() + monitor.sample_now() + third = monitor.get_protection_status() + + assert first == { + "enabled": True, + "mode": "unavailable", + "brightness_limit_percent": None, + "reading_stale": False, + "revision": 0, + "sampling_interval_seconds": 1.0, + "enforcement_error_code": None, + } + assert second["sampling_interval_seconds"] == 2.0 + assert third["sampling_interval_seconds"] == 5.0 + assert second["revision"] == third["revision"] == 0 + + +def test_listener_runs_outside_monitor_locks_and_failed_enforcement_retries( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"low_voltage_protection_enabled": True}) + monitor = VoltageMonitor(store) + install_voltage_sequence(monkeypatch, monitor, [4.6, 4.6]) + calls: list[LowVoltageProtectionDirective] = [] + + def listener(directive): + assert not monitor._state_lock._is_owned() + assert monitor._io_lock.acquire(blocking=False) + monitor._io_lock.release() + calls.append(directive) + if len(calls) == 1: + raise RuntimeError("display failed") + + monitor.set_protection_listener(listener) + assert monitor.get_protection_status()["enforcement_error_code"] == ( + PROTECTION_ENFORCEMENT_ERROR_CODE + ) + + monitor.sample_now() + assert monitor.get_protection_status()["enforcement_error_code"] is None + first_successful_sequence = calls[-1].sequence + monitor.sample_now() + assert calls[-1].sequence == first_successful_sequence + + +def test_late_sample_cannot_overwrite_newer_voltage_or_protection( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"low_voltage_protection_enabled": True}) + monitor = VoltageMonitor(store) + first_started = threading.Event() + release_first = threading.Event() + call_count = 0 + guard = threading.Lock() + + def read(_count): + nonlocal call_count + with guard: + call_count += 1 + call = call_count + if call == 1: + first_started.set() + assert release_first.wait(1.0) + return voltage_group(4.4) + return voltage_group(4.9) + + monkeypatch.setattr(monitor, "_read_group", read) + first = threading.Thread(target=monitor.sample_now) + first.start() + assert first_started.wait(1.0) + monitor.sample_now() + release_first.set() + first.join(timeout=1.0) + + assert monitor.get_status()["volts"] == pytest.approx(4.9) + assert monitor.get_protection_status()["mode"] == "inactive" + + +def test_protection_status_and_sensor_status_are_defensive_deep_copies(tmp_path): + monitor = VoltageMonitor(ConfigStore(project_root=tmp_path)) + with monitor._state_lock: + monitor._snapshot["diagnostic"] = {"values": [1]} + + status = monitor.get_status() + status["diagnostic"]["values"].append(2) + protection = monitor.get_protection_status() + protection["brightness_limit_percent"] = 99 + + assert monitor.get_status()["diagnostic"]["values"] == [1] + assert monitor.get_protection_status()["brightness_limit_percent"] is None + + +def test_dynamic_sampling_and_error_backoff_follow_policy(): + assert error_retry_interval(1) == 1.0 + assert error_retry_interval(2) == 2.0 + assert error_retry_interval(3) == 5.0 + assert error_retry_interval(20) == 5.0 + assert successful_sampling_interval( + enabled=True, + mode="inactive", + volts=4.9, + previous_volts=5.0, + elapsed_seconds=1.0, + previous_interval_seconds=5.0, + first_critical_sample=False, + ) == pytest.approx(1.0) + assert successful_sampling_interval( + enabled=True, + mode="critical", + volts=4.4, + previous_volts=4.4, + elapsed_seconds=1.0, + previous_interval_seconds=0.5, + first_critical_sample=False, + ) == 1.0 + + +def test_explicit_poll_interval_overrides_dynamic_and_error_intervals( + tmp_path, + monkeypatch, +): + store = ConfigStore(project_root=tmp_path) + store.update({"low_voltage_protection_enabled": True}) + monitor = VoltageMonitor(store, poll_interval=0.02) + install_voltage_sequence(monkeypatch, monitor, [4.4]) + + monitor.sample_now() + assert monitor.get_protection_status()["sampling_interval_seconds"] == 0.02 + monkeypatch.setattr( + monitor, + "_read_group", + lambda _count: (_ for _ in ()).throw(AdcDisconnectedError("missing", "missing")), + ) + monitor.sample_now() + assert monitor.get_protection_status()["sampling_interval_seconds"] == 0.02 + + +def test_background_monitor_waits_one_interval_before_first_sample(tmp_path): + factory = FakeDeviceFactory([13115]) + monitor = VoltageMonitor( + ConfigStore(project_root=tmp_path), + device_factory=factory, + poll_interval=0.08, + ) + + monitor.start() + time.sleep(0.025) + assert factory.read_count == 0 + deadline = time.monotonic() + 0.5 + while factory.read_count < 5 and time.monotonic() < deadline: + time.sleep(0.005) + monitor.close() + + assert factory.read_count >= 5 diff --git a/核桃派软件源代码/tests/test_walnutpi_native_driver.py b/核桃派软件源代码/tests/test_walnutpi_native_driver.py new file mode 100644 index 0000000..85df52d --- /dev/null +++ b/核桃派软件源代码/tests/test_walnutpi_native_driver.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import pytest + +from app.display.matrix_driver import MatrixDriverOptions, validate_driver_options +from app.display.native_model import ( + HEIGHT, + HARDWARE_MAPPING, + HUB75_PINS, + PI_BANK_OFFSET, + PI_DATA_OFFSET, + PIO_BASE, + PWM_BASE, + PWM_BITS, + REFRESH_RATE_LIMITS, + RESERVED_PI_BITS, + SCAN_ROWS, + WIDTH, + address_mask, + bitplane_index, + build_bitplanes, + map_channel, + oe_ticks_for_plane, +) + + +def test_walnutpi_pin_mapping_matches_fixed_hub75_contract(): + assert [pin.signal for pin in HUB75_PINS] == [ + "R1", "G1", "B1", "R2", "G2", "B2", "A", "B", "C", "D", "E", "CLK", "OE", "LAT", + ] + assert [pin.physical for pin in HUB75_PINS] == [29, 31, 33, 35, 37, 8, 10, 28, 27, 15, 16, 38, 40, 36] + assert [pin.bit for pin in HUB75_PINS] == [0, 1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15] + assert not ({pin.bit for pin in HUB75_PINS} & RESERVED_PI_BITS) + assert RESERVED_PI_BITS == {7, 8, 16} + assert HUB75_PINS[-2].gpio == "PI14/PWM4" + assert HUB75_PINS[-1].gpio == "PI15" + assert HARDWARE_MAPPING == "walnutpi-pi-bank-pwm-oe-v2" + + +def test_h618_pi_register_addresses_are_fixed(): + assert PIO_BASE == 0x0300B000 + assert PWM_BASE == 0x0300A000 + assert PI_BANK_OFFSET == 0x120 + assert PI_DATA_OFFSET == 0x10 + assert PIO_BASE + PI_BANK_OFFSET + PI_DATA_OFFSET == 0x0300B130 + + +def test_row_address_uses_abcde_bits_in_pi_bank(): + assert address_mask(0) == 0 + assert address_mask(1) == 1 << 6 + assert address_mask(2) == 1 << 9 + assert address_mask(16) == 1 << 12 + assert address_mask(31) == sum(1 << bit for bit in (6, 9, 10, 11, 12)) + with pytest.raises(ValueError): + address_mask(32) + + +def test_bitplanes_pair_top_and_bottom_rows_without_address_or_control_bits(): + frame = bytearray(WIDTH * HEIGHT * 3) + frame[0:3] = bytes((0xFF, 0x80, 0x40)) + bottom = SCAN_ROWS * WIDTH * 3 + frame[bottom:bottom + 3] = bytes((0x20, 0x10, 0x08)) + + planes = build_bitplanes(bytes(frame)) + + expected = [0] * PWM_BITS + values = (0xFF, 0x80, 0x40, 0x20, 0x10, 0x08) + for component, value in enumerate(values): + mapped = map_channel(value, 100) >> 4 + for plane in range(PWM_BITS): + if mapped & (1 << plane): + expected[plane] |= 1 << component + assert [planes[bitplane_index(0, plane, 0)] for plane in range(PWM_BITS)] == expected + assert all(word & ~0x3F == 0 for word in planes) + + +def test_white_frame_sets_all_six_color_bits_in_all_seven_planes(): + planes = build_bitplanes(bytes([0xFF]) * (WIDTH * HEIGHT * 3)) + assert len(planes) == SCAN_ROWS * PWM_BITS * WIDTH + assert set(planes) == {0x3F} + + +def test_bitplane_model_rejects_wrong_frame_size_and_coordinates(): + with pytest.raises(ValueError, match="64x64"): + build_bitplanes(b"bad") + with pytest.raises(ValueError, match="out of range"): + bitplane_index(0, PWM_BITS, 0) + + +@pytest.mark.parametrize("refresh_rate", sorted(REFRESH_RATE_LIMITS)) +def test_native_driver_accepts_only_documented_refresh_rates(refresh_rate): + validate_driver_options(MatrixDriverOptions(limit_refresh_rate_hz=refresh_rate)) + + +def test_native_driver_rejects_geometry_pwm_mapping_and_unknown_rate_changes(): + for options in ( + MatrixDriverOptions(rows=32), + MatrixDriverOptions(cols=128), + MatrixDriverOptions(scan_rows=16), + MatrixDriverOptions(row_address_bits=4), + MatrixDriverOptions(pwm_bits=6), + MatrixDriverOptions(hardware_mapping="other"), + MatrixDriverOptions(limit_refresh_rate_hz=70), + ): + with pytest.raises(ValueError): + validate_driver_options(options) + + +def test_cie_brightness_is_monotonic_and_pwm4_weights_are_fixed(): + mapped = [map_channel(255, brightness) for brightness in range(1, 101)] + assert mapped == sorted(mapped) + assert mapped[-1] == 2047 + for brightness in range(1, 101): + for plane in range(PWM_BITS): + ticks, remainder = oe_ticks_for_plane(10_250_000, brightness, plane, 999) + assert ticks == 1 << (plane + 4) + assert remainder == 0 + + +def test_brightness_changes_bitplanes_instead_of_temporally_dithering_oe(): + white = bytes([0xFF]) * (WIDTH * HEIGHT * 3) + low = build_bitplanes(white, brightness=20) + high = build_bitplanes(white, brightness=100) + assert low != high + assert set(high) == {0x3F} + + +@pytest.mark.parametrize("arguments", [ + (0, 1, 0, 0), + (5_000_000, 0, 0, 0), + (5_000_000, 1, PWM_BITS, 0), + (5_000_000, 1, 0, -1), +]) +def test_pwm4_fixed_point_rejects_invalid_inputs(arguments): + with pytest.raises(ValueError): + oe_ticks_for_plane(*arguments) diff --git a/核桃派软件源代码/tests/test_wifi.py b/核桃派软件源代码/tests/test_wifi.py new file mode 100644 index 0000000..c19d811 --- /dev/null +++ b/核桃派软件源代码/tests/test_wifi.py @@ -0,0 +1,281 @@ +import json +import time +from pathlib import Path + +import pytest + +from app.config.store import ConfigStore +from app.display.service import DisplayService +from app.display.wifi_indicator import render_wifi_indicator +from app.network.manager import MockNetworkManager +from app.network.service import WifiNetworkService, validate_wifi_settings +from app.network.store import WifiConfigError, WifiConfigStore + + +UUID = "12345678-1234-5678-1234-567812345678" + + +def saved_profile(**updates): + value = { + "connection_uuid": UUID, + "ssid": "test-hotspot", + "password": "secret123", + "password_configured": True, + "ipv4_mode": "dhcp", + "address": None, + "prefix": None, + "gateway": None, + "dns_servers": [], + } + value.update(updates) + return value + + +def active_profile(**updates): + value = { + "connected": True, + "connection_uuid": UUID, + "ssid": "test-hotspot", + "ipv4_address": "192.168.1.93", + "control_url": "http://192.168.1.93:8080/", + } + value.update(updates) + return value + + +def test_wifi_store_creates_strict_v1_and_preserves_boot_session(tmp_path): + data = tmp_path / "data" + runtime = tmp_path / "run" + first = WifiConfigStore(data, runtime, boot_id="boot-a", monotonic=lambda: 100.0) + + assert first.config == { + "managed_connection_uuid": None, + "prompt_delay_seconds": 30, + } + assert first.session["deadline_monotonic"] == 130.0 + first.update({"managed_connection_uuid": UUID, "prompt_delay_seconds": 45}) + first.dismiss_for_boot() + + same = WifiConfigStore(data, runtime, boot_id="boot-a", monotonic=lambda: 110.0) + assert same.is_new_boot_session is False + assert same.session["deadline_monotonic"] == 130.0 + assert same.session["dismissed"] is True + assert same.config["prompt_delay_seconds"] == 45 + + next_boot = WifiConfigStore(data, runtime, boot_id="boot-b", monotonic=lambda: 200.0) + assert next_boot.is_new_boot_session is True + assert next_boot.session["deadline_monotonic"] == 245.0 + assert next_boot.session["dismissed"] is False + persisted = json.loads((data / "wifi_config.json").read_text(encoding="utf-8")) + assert persisted["schema_version"] == 1 + + +@pytest.mark.parametrize( + "document", + [ + {"schema_version": 2, "managed_connection_uuid": None, "prompt_delay_seconds": 30}, + {"schema_version": 1, "managed_connection_uuid": None, "prompt_delay_seconds": True}, + {"schema_version": 1, "managed_connection_uuid": None, "prompt_delay_seconds": 30, "extra": 1}, + ], +) +def test_invalid_wifi_store_is_rejected_without_rewrite(tmp_path, document): + data = tmp_path / "data" + data.mkdir() + path = data / "wifi_config.json" + original = (json.dumps(document, ensure_ascii=False) + "\n").encode("utf-8") + path.write_bytes(original) + + with pytest.raises(WifiConfigError): + WifiConfigStore(data, tmp_path / "run", boot_id="boot") + assert path.read_bytes() == original + + +def test_wifi_validation_defaults_optional_manual_fields(): + checked = validate_wifi_settings( + { + "ssid": "phone", + "password": "12345678", + "ipv4_mode": "manual", + "address": "192.168.7.50", + "gateway": "192.168.7.1", + "prefix": None, + "dns_servers": [], + }, + previous=None, + ) + assert checked["prefix"] == 24 + assert checked["dns_servers"] == ["192.168.7.1"] + + with pytest.raises(WifiConfigError, match="gateway"): + validate_wifi_settings( + { + "ssid": "phone", + "password": "12345678", + "ipv4_mode": "manual", + "address": "192.168.7.50", + "gateway": "10.0.0.1", + }, + previous=None, + ) + + +def test_network_service_binds_active_profile_and_separates_activation_modes(tmp_path): + config = ConfigStore(project_root=tmp_path) + display = DisplayService(config, driver_kind="mock", startup_boot_id="boot") + backend = MockNetworkManager(saved_profile(), active_profile()) + wifi_store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="boot") + service = WifiNetworkService(wifi_store, backend, display) + request = { + "ssid": "test-hotspot", + "password": None, + "ipv4_mode": "manual", + "address": "192.168.1.94", + "gateway": "192.168.1.1", + "prefix": None, + "dns_servers": [], + "prompt_delay_seconds": 30, + "activation": "next_boot", + } + try: + assert wifi_store.config["managed_connection_uuid"] == UUID + assert "password" not in service.get_status()["saved"] + assert service.get_status(include_secret=True)["saved"]["password"] == "secret123" + saved = service.save_settings(request) + assert saved["activation"] == "next_boot" + assert backend.activations == [] + assert backend.saved["address"] == "192.168.1.94" + assert backend.saved["password"] == "secret123" + + deadline = wifi_store.session["deadline_monotonic"] + network_snapshot = dict(backend.saved) + delay_result = service.save_prompt_delay(45) + assert delay_result["prompt_delay_seconds"] == 45 + assert wifi_store.config["prompt_delay_seconds"] == 45 + assert wifi_store.session["deadline_monotonic"] == deadline + assert backend.saved == network_snapshot + assert backend.activations == [] + + request["activation"] = "immediate" + scheduled = service.save_settings(request) + service.activate_saved(scheduled["operation_id"]) + assert backend.activations == [UUID] + assert wifi_store.session["operation"]["state"] == "succeeded" + finally: + service.close() + display.close() + + +def test_wifi_indicator_frames_and_display_overlay_restore_user_state(tmp_path): + connected = render_wifi_indicator( + "SSID: demo IP: http://192.168.1.2:8080/", + offset=0, + connected=True, + ) + disconnected = render_wifi_indicator( + "SSID: demo 密码: 12345678", + offset=0, + connected=False, + ) + assert connected.mode == disconnected.mode == "RGB" + assert connected.size == disconnected.size == (64, 64) + assert disconnected.getbbox() is not None + + connected_text = render_wifi_indicator("IP: 192.168", offset=64, connected=True) + text_pixels = [ + pixel + for y in range(26, 42) + for x in range(64) + if (pixel := connected_text.getpixel((x, y))) != (0, 0, 0) + ] + assert text_pixels + assert set(text_pixels) == {(255, 255, 255)} + + disconnected_text = render_wifi_indicator("SSID: demo", offset=64, connected=False) + lower_pixels = [ + pixel + for y in range(42, 64) + for x in range(64) + if (pixel := disconnected_text.getpixel((x, y))) != (0, 0, 0) + ] + assert lower_pixels + assert set(lower_pixels) == {(255, 255, 255)} + + config = ConfigStore(project_root=tmp_path) + config.update({"brightness": 27, "last_mode": "composition"}) + display = DisplayService(config, driver_kind="mock", startup_boot_id="boot-wifi") + try: + display.fill((12, 34, 56)) + before_revision = display.get_status()["state"]["revision"] + display.show_wifi_indicator(connected=True, message="SSID: demo IP: http://192.168.1.2:8080/") + status = display.get_status()["state"] + assert status["mode"] == "wifi_connected_indicator" + assert status["effective_brightness"] == 50 + assert status["wifi_indicator_active"] is True + assert status["revision"] == before_revision + assert config.config["last_mode"] == "fill" + + display.dismiss_wifi_indicator() + final = display.get_status()["state"] + assert final["mode"] == "fill" + assert final["effective_brightness"] == 27 + assert display.get_current_frame().getpixel((0, 0)) == (12, 34, 56) + finally: + display.close() + + +def test_network_cached_status_does_not_repeat_backend_queries(tmp_path): + class CountingNetworkManager(MockNetworkManager): + def __init__(self): + super().__init__(saved_profile(), active_profile()) + self.saved_reads = 0 + self.active_reads = 0 + + def read_saved(self, connection_uuid, *, include_secret=False): + self.saved_reads += 1 + return super().read_saved(connection_uuid, include_secret=include_secret) + + def read_active(self): + self.active_reads += 1 + return super().read_active() + + config = ConfigStore(project_root=tmp_path) + display = DisplayService(config, driver_kind="mock", startup_boot_id="boot") + backend = CountingNetworkManager() + wifi_store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="boot") + service = WifiNetworkService(wifi_store, backend, display) + try: + live = service.get_status() + reads = (backend.saved_reads, backend.active_reads) + backend.active = active_profile(connected=False) + + cached = service.get_cached_status() + + assert cached["active"] == live["active"] + assert (backend.saved_reads, backend.active_reads) == reads + finally: + service.close() + display.close() + + +def test_dismissed_network_prompt_does_not_reappear_after_disconnect(tmp_path): + config = ConfigStore(project_root=tmp_path) + display = DisplayService(config, driver_kind="mock", startup_boot_id="boot") + backend = MockNetworkManager(saved_profile(), active_profile()) + wifi_store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id="boot") + service = WifiNetworkService(wifi_store, backend, display, poll_seconds=0.01) + try: + service._refresh_indicator() + assert display.get_status()["state"]["wifi_indicator_active"] is True + service.dismiss_for_boot() + backend.active = active_profile( + connected=False, + ssid=None, + ipv4_address=None, + control_url=None, + ) + time.sleep(0.02) + assert wifi_store.session["dismissed"] is True + assert display.get_status()["state"]["wifi_indicator_active"] is False + finally: + service.close() + display.close() diff --git a/核桃派软件源代码/tests/test_wifi_ui.mjs b/核桃派软件源代码/tests/test_wifi_ui.mjs new file mode 100644 index 0000000..c0af763 --- /dev/null +++ b/核桃派软件源代码/tests/test_wifi_ui.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { createLatchedDraftState } from "../app/static/wifi-draft-state.js"; + +const root = path.resolve(import.meta.dirname, ".."); + +test("latched WiFi draft state remains dirty until explicitly cleared", () => { + const state = createLatchedDraftState(["ssid", "password"]); + + assert.equal(state.dirty, false); + assert.equal(state.mark("ssid"), true); + assert.equal(state.dirty, true); + assert.equal(state.mark("ssid"), false); + assert.deepEqual(state.keys(), ["ssid"]); + state.mark("password"); + assert.deepEqual(state.keys(), ["ssid", "password"]); + state.clear(); + assert.equal(state.dirty, false); + assert.throws(() => state.mark("unknown"), /Unknown draft field/); +}); + +test("WiFi settings expose independent network and prompt-delay controls", () => { + const html = fs.readFileSync(path.join(root, "app/static/index.html"), "utf8"); + const source = fs.readFileSync(path.join(root, "app/static/views/wifi-settings.js"), "utf8"); + const settingsSource = fs.readFileSync(path.join(root, "app/static/views/settings.js"), "utf8"); + const css = fs.readFileSync(path.join(root, "app/static/style.css"), "utf8"); + + for (const id of [ + "wifi-ssid", + "wifi-password", + "wifi-dhcp-enabled", + "wifi-address", + "wifi-gateway", + "wifi-prefix", + "wifi-dns", + "wifi-apply-immediate", + "wifi-apply-next-boot", + "wifi-prompt-delay", + "wifi-prompt-delay-save", + "wifi-prompt-feedback", + ]) assert.match(html, new RegExp(`id="${id}"`)); + + for (const id of ["ssid", "password", "dhcp", "address", "gateway", "prefix", "dns"]) { + assert.match(html, new RegExp(`id="wifi-${id}-modified"`)); + } + assert.equal((html.match(/对应内容已经修改/g) || []).length, 7); + assert.match(html, /id="wifi-password"[^>]*type="text"/); + assert.ok(html.indexOf("WiFi设置") < html.indexOf("网络提示显示")); + + assert.match(source, /applyWifi\("immediate"\)/); + assert.match(source, /applyWifi\("next_boot"\)/); + assert.match(source, /manualFields\.hidden = enabled/); + assert.match(source, /passwordInput\.value = state\.password/); + assert.match(source, /field\.control\.addEventListener\("input"/); + assert.match(source, /dhcpToggle\.addEventListener\("change"/); + assert.match(source, /apiJson\("\/api\/network\/wifi\/prompt-delay"/); + assert.match(source, /setControlUnavailable\(immediateButton/); + assert.match(source, /clearNetworkDraft\(\)/); + assert.match(source, /clearDelayDraft\(\)/); + assert.match(settingsSource, /onLeave:\s*\(\) => \{[\s\S]*discardWifiSettingsDrafts\(\)[\s\S]*discardFrpDrafts\(\)[\s\S]*\}/); + + const buildRequest = source.slice(source.indexOf("function buildRequest"), source.indexOf("function captureNetworkBaseline")); + assert.doesNotMatch(buildRequest, /prompt_delay_seconds/); + assert.match(css, /\.wifi-field-modified/); + assert.match(css, /\.wifi-action-ready/); + assert.match(css, /\.wifi-prompt-delay-row/); + assert.match(css, /grid-template-columns: 1fr/); +}); diff --git a/核桃派软件源代码/tests/test_workspace_layout.py b/核桃派软件源代码/tests/test_workspace_layout.py new file mode 100644 index 0000000..d2265dd --- /dev/null +++ b/核桃派软件源代码/tests/test_workspace_layout.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +HARNESS_RELATIVE = Path("测试相关资料/如何测试/本机测试环境/local_test.py") + + +@pytest.fixture +def relocated_harness(tmp_path): + original = PROJECT_ROOT / HARNESS_RELATIVE + if not original.is_file() and os.environ.get("MATRIX_SOURCE_ONLY_UPDATE_TESTS") == "1": + pytest.skip("source-only update payload intentionally omits the workstation harness") + root = tmp_path / "独立 工作区" + script = root / HARNESS_RELATIVE + script.parent.mkdir(parents=True) + shutil.copyfile(original, script) + source = root / "核桃派软件源代码" + (source / "app").mkdir(parents=True) + for name in ("requirements.txt", "requirements-dev.txt"): + (source / name).write_text("", encoding="utf-8") + spec = importlib.util.spec_from_file_location("relocated_local_test", script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return root, module + + +def test_harness_locates_relocated_source_from_unrelated_cwd(relocated_harness, tmp_path): + root, harness = relocated_harness + probe = ( + "import runpy, sys; from pathlib import Path; " + "h = runpy.run_path(sys.argv[1]); h['_validate_layout'](); " + "assert h['WORKSPACE_ROOT'] == Path(sys.argv[2]); " + "assert h['SOURCE_DIR'] == Path(sys.argv[2]) / '核桃派软件源代码'" + ) + subprocess.run( + [sys.executable, "-c", probe, str(root / HARNESS_RELATIVE), str(root)], + cwd=tmp_path, + check=True, + ) + + +def test_clean_keeps_environment_and_manual_data_in_relocated_workspace(relocated_harness): + root, harness = relocated_harness + keep = { + harness.VENV_PYTHON: b"interpreter sentinel", + harness.REQUIREMENTS_STAMP: b"requirements sentinel", + harness.DATA_DIR / "config.json": b"manual config sentinel", + harness.DATA_DIR / "templates" / "example.json": b"manual template sentinel", + } + for path, content in keep.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + for directory in (harness.WORK_DIR, harness.DATA_DIR / "runtime", harness.SOURCE_DIR / "app/__pycache__"): + directory.mkdir(parents=True) + (directory / "temporary").write_bytes(b"temporary") + harness.clean() + assert all(path.read_bytes() == content for path, content in keep.items()) + assert not harness.WORK_DIR.exists() + assert not (harness.DATA_DIR / "runtime").exists() + assert not (harness.SOURCE_DIR / "app/__pycache__").exists() + with pytest.raises(harness.HarnessError): + harness._assert_within(root.parent / "outside", root) + + +def test_clean_rejects_outside_target_before_deleting(relocated_harness, tmp_path, monkeypatch): + _, harness = relocated_harness + outside = tmp_path / "outside" + outside.mkdir() + marker = outside / "preserve" + marker.write_bytes(b"unchanged") + monkeypatch.setattr(harness, "WORK_DIR", outside) + with pytest.raises(harness.HarnessError): + harness.clean() + assert marker.read_bytes() == b"unchanged" diff --git a/核桃派软件源代码/tests/test_workspace_order_ui.mjs b/核桃派软件源代码/tests/test_workspace_order_ui.mjs new file mode 100644 index 0000000..3101712 --- /dev/null +++ b/核桃派软件源代码/tests/test_workspace_order_ui.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { moveWorkspaceOrder, resolveWorkspaceOrder } from "../app/static/workspace-order.js"; + +const staticRoot = new URL("../app/static/", import.meta.url); +const definitions = [ + { id: "device" }, + { id: "settings" }, + { id: "canvas" }, + { id: "text" }, + { id: "animations" }, + { id: "templates" }, +]; + +test("workspace order resolves saved ids and appends new registrations", () => { + const resolved = resolveWorkspaceOrder( + definitions, + ["removed", "templates", "device", "templates", "settings"], + ); + assert.deepEqual( + resolved.map((definition) => definition.id), + ["templates", "device", "settings", "canvas", "text", "animations"], + ); + assert.deepEqual(definitions.map((definition) => definition.id), [ + "device", "settings", "canvas", "text", "animations", "templates", + ]); +}); + +test("workspace order movement is immutable and respects both boundaries", () => { + const original = definitions.map((definition) => definition.id); + assert.deepEqual(moveWorkspaceOrder(original, 1, -1), [ + "settings", "device", "canvas", "text", "animations", "templates", + ]); + assert.equal(moveWorkspaceOrder(original, 0, -1), null); + assert.equal(moveWorkspaceOrder(original, original.length - 1, 1), null); + assert.deepEqual(original, ["device", "settings", "canvas", "text", "animations", "templates"]); +}); + +test("drawer exposes one editable list and commits only from the save action", async () => { + const [html, app, core, style] = await Promise.all([ + readFile(new URL("index.html", staticRoot), "utf8"), + readFile(new URL("app.js", staticRoot), "utf8"), + readFile(new URL("core.js", staticRoot), "utf8"), + readFile(new URL("style.css", staticRoot), "utf8"), + ]); + + assert.ok(html.indexOf('id="workspace-order-toggle"') < html.indexOf('id="workspace-nav"')); + assert.match(html, /id="workspace-order-toggle"[^>]*>自定义项目位置<\/button>/); + assert.doesNotMatch(app, /nav-group|groupOrder/); + assert.doesNotMatch(core, /"group"|"groupOrder"/); + assert.match(app, /copy\.dynamic\.workspace_order\.move_up/); + assert.match(app, /copy\.dynamic\.workspace_order\.move_down/); + assert.match(app, /method:\s*"PUT"[\s\S]*workspace_order:\s*draftWorkspaceOrder/); + assert.match(app, /workspaceOrderEditing \|\| workspaceOrderBusy/); + assert.match(app, /requestCloseDrawer\(true\)/); + assert.match(app, /event\.key === "Escape"[\s\S]*requestCloseDrawer\(true\)/); + assert.doesNotMatch(app, /beforeunload|localStorage/); + assert.match(style, /\.workspace-nav\.is-order-editing \.nav-item-row/); +}); diff --git a/测试相关资料/如何测试/本机测试环境/.gitignore b/测试相关资料/如何测试/本机测试环境/.gitignore new file mode 100644 index 0000000..b951a0d --- /dev/null +++ b/测试相关资料/如何测试/本机测试环境/.gitignore @@ -0,0 +1,5 @@ +.venv/ +data/ +work/ +__pycache__/ +*.pyc diff --git a/测试相关资料/如何测试/本机测试环境/README.md b/测试相关资料/如何测试/本机测试环境/README.md new file mode 100644 index 0000000..c00f14a --- /dev/null +++ b/测试相关资料/如何测试/本机测试环境/README.md @@ -0,0 +1,45 @@ +# 本机持久测试环境 + +本目录保存奇妙小屏幕控制器的本机测试工具、可复用虚拟环境和人工保留的 mock 数据。命令从工作区根目录执行,脚本会自行定位中文路径下的源码目录。 + +## 常用命令 + +```powershell +python ".\测试相关资料\如何测试\本机测试环境\local_test.py" setup +python ".\测试相关资料\如何测试\本机测试环境\local_test.py" test --suite all +python ".\测试相关资料\如何测试\本机测试环境\local_test.py" serve --port 8765 +python ".\测试相关资料\如何测试\本机测试环境\local_test.py" clean +``` + +测试套件: + +- `config`:配置/模板 schema、耐久写入、路径解析和旧数据根迁移 pytest。 +- `display`:显示服务、渲染和帧变换 pytest。 +- `api`:REST、WebSocket 和静态资源 pytest。 +- `python`:全部 pytest。 +- `frontend`:颜色/场景 Node 测试及全部静态 JavaScript 语法检查。 +- `all`:`python` 与 `frontend`。 + +`test` 和 `serve` 会自动调用 `setup`。首次运行或两个 requirements 文件变化时才从官方 PyPI 安装/更新依赖;未变化时只运行快速的 `pip check`。 + +## 保留与清理边界 + +`serve` 显式使用: + +- 持久数据根:`data/` +- 运行期数据根:`data/runtime/` + +这两个目录用于本机隔离,不替代生产环境的 `/var/lib/matrix-screen-controller` 和 `/run/matrix-screen-controller`。自动化用例必须使用 `work/` 下或 pytest 提供的隔离临时目录,不得修改人工保留的数据。 + +长期保留: + +- `.venv/` 及其中的 `.requirements.sha256` +- `data/config.json` +- `data/templates/` 中人工保存的模板及有效缩略图 +- `data/animations/` 中人工保存的动图、帧及有效缩略图 +- `data/` 中以后按 `CONFIG-DATA-LIFECYCLE` 登记的其他持久测试数据 +- 本目录的脚本、README 和 `.gitignore` + +`clean` 只删除可再生产物:`work/`、整个 `data/runtime/`、源码缓存、覆盖率文件和已知浏览器测试产物。它不会清空 `data/`、扫描 `%TEMP%` 或整盘,也不会自动删除损坏的 `.venv`。测试产生的配置/模板反例必须留在隔离临时目录并由测试框架回收,不能把损坏样例或多代备份积累在本目录。 + +完整的分层测试选择、远端冒烟和实屏档位见上一级的《核桃派点阵屏控制服务测试流程.md》。 diff --git a/测试相关资料/如何测试/本机测试环境/local_test.py b/测试相关资料/如何测试/本机测试环境/local_test.py new file mode 100644 index 0000000..f2c9fbc --- /dev/null +++ b/测试相关资料/如何测试/本机测试环境/local_test.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Sequence + + +HARNESS_DIR = Path(__file__).resolve().parent +WORKSPACE_ROOT = HARNESS_DIR.parents[2] +SOURCE_DIR = WORKSPACE_ROOT / "核桃派软件源代码" +VENV_DIR = HARNESS_DIR / ".venv" +WORK_DIR = HARNESS_DIR / "work" +DATA_DIR = HARNESS_DIR / "data" +VENV_PYTHON = VENV_DIR / ("Scripts/python.exe" if os.name == "nt" else "bin/python") +REQUIREMENTS_STAMP = VENV_DIR / ".requirements.sha256" +REQUIREMENTS_FILES = ( + SOURCE_DIR / "requirements.txt", + SOURCE_DIR / "requirements-dev.txt", +) +PYTEST_TARGETS = { + "config": ( + "tests/test_config_store.py", + "tests/test_wifi.py", + "tests/test_templates.py", + "tests/test_animations.py", + "tests/test_animation_preview_fixture.py", + "tests/test_migrate_state_root.py", + ), + "display": ("tests/test_display_service.py",), + "api": ("tests/test_api.py", "tests/test_fonts.py", "tests/test_ui_copy_editor.py"), + "python": (), +} +SUITES = (*PYTEST_TARGETS, "frontend", "all") + + +class HarnessError(RuntimeError): + """A user-actionable local test environment error.""" + + +def _display_command(command: Sequence[str]) -> str: + return subprocess.list2cmdline([str(part) for part in command]) + + +def _run( + command: Sequence[str | Path], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, +) -> None: + normalized = [str(part) for part in command] + print(f"> {_display_command(normalized)}", flush=True) + subprocess.run(normalized, cwd=cwd, env=env, check=True) + + +def _validate_layout() -> None: + missing = [path for path in (*REQUIREMENTS_FILES, SOURCE_DIR / "app") if not path.exists()] + if missing: + joined = "、".join(str(path) for path in missing) + raise HarnessError(f"无法定位项目文件:{joined}") + + +def _requirements_fingerprint() -> str: + digest = hashlib.sha256() + for path in REQUIREMENTS_FILES: + digest.update(path.name.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def setup_environment() -> None: + _validate_layout() + HARNESS_DIR.mkdir(parents=True, exist_ok=True) + + if VENV_DIR.exists() and not VENV_PYTHON.is_file(): + raise HarnessError( + f"持久环境存在但 Python 不可用:{VENV_PYTHON}。" + "请先把损坏的 .venv 改名留作检查,再手动删除或移走它,然后重新运行 setup;" + "工具不会自动删除现有环境。" + ) + + if not VENV_PYTHON.is_file(): + print(f"创建持久测试环境:{VENV_DIR}", flush=True) + _run((sys.executable, "-m", "venv", VENV_DIR)) + + fingerprint = _requirements_fingerprint() + recorded = ( + REQUIREMENTS_STAMP.read_text(encoding="ascii").strip() + if REQUIREMENTS_STAMP.is_file() + else "" + ) + + if recorded != fingerprint: + print("依赖首次安装或 requirements 已变化,正在从官方 PyPI 同步。", flush=True) + _run( + ( + VENV_PYTHON, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--upgrade", + "--index-url", + "https://pypi.org/simple", + "-r", + REQUIREMENTS_FILES[-1], + ) + ) + _run((VENV_PYTHON, "-m", "pip", "check")) + REQUIREMENTS_STAMP.write_text(f"{fingerprint}\n", encoding="ascii") + print("依赖同步完成,已记录 requirements 哈希。", flush=True) + else: + print("requirements 未变化,跳过依赖安装。", flush=True) + _run((VENV_PYTHON, "-m", "pip", "check")) + + +def _base_environment() -> dict[str, str]: + WORK_DIR.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env["MATRIX_DRIVER"] = "mock" + env["PYTHONPYCACHEPREFIX"] = str(WORK_DIR / "pycache") + return env + + +def _test_environment() -> dict[str, str]: + env = _base_environment() + # Tests that construct an explicit project root remain isolated per case. + # The process-level paths below protect module-level application imports + # from ever reading or migrating either source-tree or manual mock data. + test_data_dir = WORK_DIR / "test-app-data" + env["MATRIX_TEST_ROOT"] = str(test_data_dir) + env["MATRIX_DATA_DIR"] = str(test_data_dir) + env["MATRIX_RUNTIME_DIR"] = str(test_data_dir / "runtime") + for name in ("STATE_DIRECTORY", "RUNTIME_DIRECTORY"): + env.pop(name, None) + return env + + +def _serve_environment() -> dict[str, str]: + DATA_DIR.mkdir(parents=True, exist_ok=True) + env = _base_environment() + env["MATRIX_DATA_DIR"] = str(DATA_DIR) + env["MATRIX_RUNTIME_DIR"] = str(DATA_DIR / "runtime") + return env + + +def _remove_path(path: Path) -> bool: + if not path.exists() and not path.is_symlink(): + return False + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + print(f"已清理:{path}", flush=True) + return True + + +def _assert_within(path: Path, root: Path) -> None: + resolved_path = path.resolve(strict=False) + resolved_root = root.resolve(strict=False) + try: + resolved_path.relative_to(resolved_root) + except ValueError as exc: + raise HarnessError(f"拒绝处理工作区外路径:{resolved_path}") from exc + + +def _prepare_pytest_temp() -> Path: + pytest_temp = WORK_DIR / "pytest" + _assert_within(pytest_temp, HARNESS_DIR) + _remove_path(pytest_temp) + pytest_temp.mkdir(parents=True, exist_ok=True) + return pytest_temp + + +def _run_pytest(suite: str, env: dict[str, str]) -> None: + pytest_temp = _prepare_pytest_temp() + command: list[str | Path] = [ + VENV_PYTHON, + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + f"--basetemp={pytest_temp}", + ] + command.extend(PYTEST_TARGETS[suite]) + _run(command, cwd=SOURCE_DIR, env=env) + + +def _run_frontend_tests(env: dict[str, str]) -> None: + node = shutil.which("node") + if not node: + raise HarnessError("未找到 Node.js;frontend/all 套件需要可执行的 node。") + + _run( + ( + node, + "--test", + "tests/test_animation_ui.mjs", + "tests/test_canvas_tools.mjs", + "tests/test_color_math.mjs", + "tests/test_current_display_playback_ui.mjs", + "tests/test_device_ui.mjs", + "tests/test_font_picker.mjs", + "tests/test_frp_ui.mjs", + "tests/test_interaction_feedback.mjs", + "tests/test_scene_model.mjs", + "tests/test_settings_ui.mjs", + "tests/test_template_management_ui.mjs", + "tests/test_ui_copy_editor.mjs", + "tests/test_wifi_ui.mjs", + "tests/test_workspace_order_ui.mjs", + ), + cwd=SOURCE_DIR, + env=env, + ) + javascript_files = sorted((SOURCE_DIR / "app" / "static").rglob("*.js")) + if not javascript_files: + raise HarnessError("未找到 app/static 下的 JavaScript 文件。") + for path in javascript_files: + _run((node, "--check", path), cwd=SOURCE_DIR, env=env) + _run((node, "--check", "app/ui_copy_editor/static/editor.js"), cwd=SOURCE_DIR, env=env) + _run((VENV_PYTHON, "scripts/build_ui_copy_catalog.py", "--check"), cwd=SOURCE_DIR, env=env) + + +def run_tests(suite: str) -> None: + setup_environment() + env = _test_environment() + + if suite == "all": + _run_pytest("python", env) + _run_frontend_tests(env) + elif suite == "frontend": + _run_frontend_tests(env) + else: + _run_pytest(suite, env) + print(f"本机测试套件通过:{suite}", flush=True) + + +def serve(port: int) -> None: + setup_environment() + env = _serve_environment() + print(f"本机 mock 页面:http://127.0.0.1:{port}", flush=True) + try: + _run( + ( + VENV_PYTHON, + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + str(port), + ), + cwd=SOURCE_DIR, + env=env, + ) + except KeyboardInterrupt: + print("本机 mock 服务已停止。", flush=True) + + +def clean() -> None: + _validate_layout() + fixed_targets = [ + WORK_DIR, + DATA_DIR / "runtime", + SOURCE_DIR / ".pytest_cache", + SOURCE_DIR / ".coverage", + SOURCE_DIR / "coverage.xml", + WORKSPACE_ROOT / ".playwright-cli", + WORKSPACE_ROOT / "output" / "playwright", + HARNESS_DIR / "__pycache__", + ] + for target in fixed_targets: + _assert_within(target, WORKSPACE_ROOT) + + cache_dirs: list[Path] = [] + for root in (SOURCE_DIR / "app", SOURCE_DIR / "scripts", SOURCE_DIR / "tests"): + if root.is_dir(): + cache_dirs.extend(root.rglob("__pycache__")) + for target in cache_dirs: + _assert_within(target, SOURCE_DIR) + + removed = 0 + for target in sorted(cache_dirs, key=lambda item: len(item.parts), reverse=True): + removed += int(_remove_path(target)) + for target in fixed_targets: + removed += int(_remove_path(target)) + + if not VENV_PYTHON.is_file(): + raise HarnessError("清理后未找到持久 .venv Python;清理验收失败。") + if not REQUIREMENTS_STAMP.is_file(): + raise HarnessError("清理后依赖哈希不存在;清理验收失败。") + if (DATA_DIR / "config.json").exists(): + print("已保留:data/config.json", flush=True) + print(f"清理完成,共移除 {removed} 个可再生目标;.venv 已保留。", flush=True) + + +def _port(value: str) -> int: + port = int(value) + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("端口必须在 1..65535。") + return port + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="奇妙小屏幕控制器本机持久测试工具") + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("setup", help="创建或更新持久 .venv") + + test_parser = subparsers.add_parser("test", help="运行所需的本机测试套件") + test_parser.add_argument("--suite", choices=SUITES, default="all") + + serve_parser = subparsers.add_parser("serve", help="以前台方式启动 mock 网页") + serve_parser.add_argument("--port", type=_port, default=8765) + + subparsers.add_parser("clean", help="清理可再生产物,保留 .venv 和本机配置") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + if args.command == "setup": + setup_environment() + elif args.command == "test": + run_tests(args.suite) + elif args.command == "serve": + serve(args.port) + elif args.command == "clean": + clean() + else: + raise HarnessError(f"未知命令:{args.command}") + except (HarnessError, OSError, subprocess.CalledProcessError) as exc: + print(f"本机测试工具失败:{exc}", file=sys.stderr, flush=True) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md new file mode 100644 index 0000000..94948f2 --- /dev/null +++ b/测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md @@ -0,0 +1,260 @@ +# 核桃派点阵屏控制服务测试流程 + +本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。 + +## 0. 测试映射 + +| 测试编号 | 主要覆盖 | 执行位置 | 人工停顿 | +|---|---|---|---| +| `TEST-WORKSPACE-LAYOUT` | `DEPLOY-WORKSPACE-LAYOUT`、测试与发布路径、隔离和清理 | 本机中文及空格路径 | 否 | +| `TEST-REPOSITORY-HYGIENE` | `DEPLOY-REPOSITORY-HYGIENE`、凭据门禁、Git ignore、秘密与主机路径扫描 | 本机 Git 工作区 | 仅等待用户填写私有凭据时 | +| `TEST-LOCAL-MOCK` | `WEB-*`、`DISPLAY-MOCK`、`CONFIG-*` | Windows 或隔离 Linux | 否 | +| `TEST-NATIVE-UNIT` | `DISPLAY-DRIVER`、bitplane、映射、亮度、双缓冲 | 本机,不访问 `/dev/mem` | 否 | +| `TEST-REMOTE-HEALTH` | `DEPLOY-*`、系统、网络、磁盘、工具 | 核桃派,无屏 | 否 | +| `TEST-MANUAL-UPDATE` | `DEPLOY-UPDATE`、数据副本迁移、程序/数据原子切换、失败回滚和清理 | 核桃派,无屏 | 否 | +| `TEST-NATIVE-NOLOAD` | H618 寄存器映射、线程、刷新率、统计、安全关闭 | 核桃派,HUB75 未连接 | 否 | +| `TEST-API-CONFIG` | REST、schema v10、工作区顺序、性能模式、动图并发、持久化、字体、模板、动图 | mock / 核桃派 | 否 | +| `TEST-FRP-MAINTENANCE` | `WEB-FRP-MAINTENANCE`、`CONFIG-FRP`、`DEPLOY-FRP`、多配置、systemd 持久与真实 SSH 穿透 | Windows mock + 核桃派 | 否 | +| `TEST-NETWORK-DIAGNOSTICS` | `WEB-NETWORK-DIAGNOSTICS`、接口、路由、DNS、TCP/TLS 和 ICMP 分类 | Windows mock + 核桃派 | 否 | +| `TEST-WS-CANVAS` | RGB888、最新帧覆盖、正常断开 | mock / 核桃派 | 否 | +| `TEST-CANVAS-EDIT` | `WEB-CANVAS-EDIT` 默认只读、全屏编辑、悬浮菜单、撤销和平移缩放 | Windows / 本机浏览器 | 否 | +| `TEST-TEMPLATE-MANAGEMENT` | `WEB-PREVIEW`、`WEB-TEMPLATES`、静态/动图播放编辑解耦、保存选择与未保存守卫 | Windows / mock / 核桃派 | 否 | +| `TEST-CURRENT-PLAYBACK` | `WEB-CURRENT-DISPLAY`、当前帧弹层、动图跳转、暂停/继续、倍速和会话失效 | Windows / mock + 核桃派软件接口 | 用户最终交互确认 | +| `TEST-ANIMATION-THUMBNAIL-PERF` | 动图长名称、完整动态缩略图、浏览器/HTTP 资源与 H618 GPIO 刷新隔离 | Windows / mock + 核桃派/HUB75 | 仅摄像头歧义时确认 | +| `TEST-CPUFREQ-RECOVERY` | `HW-CPUFREQ-AXP313A`、v9 性能模式事务、原 governor 恢复 | Windows fake sysfs + 核桃派 | 否 | +| `TEST-KERNEL-ROLLBACK` | `DEPLOY-KERNEL-ROLLBACK`、离线内核载荷、候选健康标记、失败回原内核 | 隔离脚本 + 核桃派 | 仅早期硬锁时断电一次 | +| `TEST-WORKSPACE-ORDER` | 单层侧栏、内存排序、关闭拦截与设备共享持久化 | Windows / 本机浏览器 + mock | 否 | +| `TEST-MEDIA-CONVERSION` | `WEB-MEDIA-IMPORT`、格式探测、裁切、透明合成、队列、清理和内容库提交 | Windows / mock / 核桃派 | 否 | +| `TEST-MEDIA-PERF` | 转换 unit 资源隔离、显示刷新统计、API 响应和当前逻辑帧不变 | 核桃派 + 运行中 HUB75 | 否 | +| `TEST-RESTART-PERSISTENCE` | systemd 重启、默认内容、用户资源 | 核桃派 | 服务重启否;整机断电是 | +| `TEST-OTA-LOCAL` | 包协议、正式版本与功能时间、版本拒绝、迁移、回滚、进度和前端交互 | Windows / mock | 否 | +| `TEST-SYSTEM-INDICATOR-PIXELS` | `DISPLAY-OTA-INDICATOR`、`DISPLAY-WIFI-INDICATOR` 的 OTA、联网 IP 与断网提示像素完整性 | Windows / mock + 核桃派/HUB75 | 用户逐画面确认 | +| `TEST-OTA-REAL` | 浏览器上传、40% 更新覆盖、90% 居中画面、真实切换、数据保全和清理 | 核桃派 + HUB75 | 只在画面无法由摄像头判断时确认 | +| `TEST-RELEASE-VERSION` | OTA/镜像共用版本、IMG 候选/实卡晋升、失败回滚、并发和发布记录 | Windows / mock + Linux | 否 | +| `TEST-IMAGE-LOCAL` | FAT16、离线载荷、双槽配置、编辑器和秘密排除 | Windows | 否 | +| `TEST-IMAGE-FIRSTBOOT` | 写卡、无外设自动安装、重启、联网、SSH 和网页 | 核桃派,无屏 | 写卡与插卡通电 | +| `TEST-REAL-SCREEN` | `HW-MATRIX`、颜色、行址、方向、内容与稳定性 | 核桃派 + HUB75 | 每个失败项停顿 | +| `TEST-ADC-HARDWARE` | `HW-SCREEN-VOLTAGE`、校准、故障恢复 | 核桃派 + ADC | 接线/万用表停顿 | +| `TEST-LOW-VOLTAGE` | 限亮、图标、恢复 | 实屏 + ADC + 可调电源 | 每一档停顿 | +| `TEST-CLEANUP` | 临时项、持久数据、GPIO 安全状态 | 本机和核桃派 | 否 | +| `TEST-INDEPENDENCE` | 单目录可拿走、无外部依赖 | 隔离临时副本 | 否 | + +## 1. 本机 mock 与自动化 + +从独立项目根执行: + +```powershell +python ".\测试相关资料\如何测试\本机测试环境\local_test.py" setup +python ".\测试相关资料\如何测试\本机测试环境\local_test.py" test +``` + +前端测试: + +```powershell +$tests = Get-ChildItem -LiteralPath ".\核桃派软件源代码\tests" -Filter "*.mjs" -File +node --test @($tests.FullName) +``` + +合格标准:Python、Node、静态资源和原生 fake-register 全部通过;条件跳过项必须与当前主机缺少真实硬件相符;没有读取真实凭据、生产数据或 `/dev/mem`。 + +`TEST-SYSTEM-INDICATOR-PIXELS` 在 mock 中逐像素验证 OTA 的 `OTA`、百分比和进度条缩小后无断笔、百分比未被底边裁切、四边仍各有 3 像素黑边;联网 IP 与断网提示的字体覆盖像素只能是全白或全灭,禁止抗锯齿灰阶造成低亮度缺块。部署后依次只显示 OTA、联网 IP、断网提示三个画面;每次只显示一个并停止,由用户回复内容无误后再进入下一个,不使用摄像头代替本轮人工确认,三个画面均确认前不得写成通过。 + +`TEST-CANVAS-EDIT` 使用本机 mock 服务,在约 `320px`、`390px`、平板和桌面视口逐项检查:像素工作区默认滑动画板不落笔且页面可滚动;点击“开始编辑”后才进入全屏绘画;悬浮按钮点击与拖动不产生像素;移动模式支持单指平移、双指缩放和桌面滚轮缩放,切回绘画后落点准确;画笔、橡皮、填充和清空可连续撤销。退出编辑回到颜色工具区,离开工作区强制退出;文字工作区保持原有选择、拖动和缩放。成功应用画板、保存或更新模板、保存或新建动图帧后撤销历史清空,失败请求保留历史。该测试不连接 HUB75、ADC,也不进行实屏目测。 + +`TEST-TEMPLATE-MANAGEMENT` 使用 mock 和前端自动化验证:模板管理顶部为与系统设置相同的 `160 CSS px` 当前帧监视器且没有应用、导出或画板交互;静态模板和动图卡片均按“播放/编辑、复制/重命名、删除、上移/下移”两列四行排列,演示案例显示相同布局并对受限项提供原因。静态与动图播放只改变设备输出,不替换普通画布或动图编辑上下文,也不修改默认内容;静态编辑进入像素画布,未绑定草稿或保存后又修改的模板在被替换前提示“当前内容将会丢失”,干净模板直接切换,取消及读取失败保持原状态。普通模板保存覆盖当前或另存后都更新绑定基线,冲突保留未保存内容;演示静态图只能另存。板端只用故意过期的静态播放修订验证 `409` 且当前帧字节不变,不要求摄像头、实屏目测或物理操作。 + +`TEST-CURRENT-PLAYBACK` 使用 mock 服务和前端自动化验证:非动图顶栏按钮打开当前逻辑帧,点击任意预览区域、遮罩和 `Escape` 均关闭;动图对话框展示完整时长、当前位置、播放/暂停和 `0.5x/1x/1.5x/2x`,控件点击不关闭。服务测试覆盖暂停位置冻结、继续、跳转目标帧、四档倍速、循环边界、关闭线程,以及新静态内容、新动图和同一动图重新播放均重置到 `0ms/播放/1x` 并拒绝旧会话。拖动最多每 `100ms` 一个请求、在途请求不并发、松手提交最终位置,状态轮询不得抢动滑块。部署后只检查服务、接口和新静态资源,由用户实际拉动进度、暂停后关闭重开继续、切换新内容及四档倍速;该软件逻辑验收不要求 GPIO、摄像头、ADC 或其他实屏测试,用户确认前不得写成完成。 + +`TEST-ANIMATION-THUMBNAIL-PERF` 先在隔离 mock 数据根生成 100 个动图、每个 100 张不同的 `64x64` 帧、每帧 `50ms`。自动化验证已有动图名称单行尾部省略,细指针鼠标只在截断时显示完整名称,触摸/点击/长按不展开,重命名仍取得完整值;动图管理和模板管理都按完整帧顺序及原时长循环,页面隐藏、离开视口、切换工作区、减少动态效果、缩略图失败和分页间修订变化均停止或安全回退。配置 v9→v10 必须默认加入 `animation_preview_max_concurrent=2`,接口只接受 `1..50` 的严格整数;设置页在性能模式正下方显示“模板界面同时播放的动图数量”和固定风险提示,启动加载及保存成功后立即让两个管理界面共享新上限,保存失败恢复最近有效值。自动化分别以 `1`、`2`、`50` 验证首帧加载、完整时间表读取和实际播放并发,降低上限时必须释放超额预载并取消可取消请求。服务重启后的首次动图列表必须直接使用启动期严格校验形成的缓存,测试用替身禁止再次读取全量持久帧;100×100 板端首次列表不得触发前端超时。四类视口不得横向溢出;两轮完整滚动和反复切换期间操作响应不超过 `200ms`、单个主线程长任务不超过 `200ms`、`/api/status` p95 不超过 `500ms`,预热后服务 CPU 相对静态首帧基线平均增幅不超过 10 个百分点、RSS 增幅不超过 `64MiB`,浏览器 JS 堆增幅不超过 `100MiB` 且第二轮不再单调增长。设备默认值 `2` 仍执行全部网页、HTTP 与 GPIO 门槛;隔离数据下把 50 张卡片同时置于视口并持续 60 秒时,网页变慢只记录为已提示风险,但 GPIO 仍必须达到刷新率、miss、连续完成帧、fault 和当前 RGB 帧一致性门槛。该小逻辑部署验收免除摄像头和肉眼实屏判断。 + +本机门槛通过后,先独立完成 `TEST-CPUFREQ-RECOVERY` 和 `TEST-KERNEL-ROLLBACK`,再在核桃派上用候选程序、独立临时数据根和临时 systemd unit 运行真实驱动,生产持久数据不得参与压力数据生成。100Hz 档依次采集:性能模式关闭的 60 秒空闲基线;性能模式开启后以 5% 亮度浏览动图管理 60 秒;保持开启并以 40% 亮度浏览模板管理 60 秒;最后关闭性能模式并用生产数据冒烟 60 秒。每阶段用 `scripts/measure_gpio_status.py` 每秒读取 `/api/status` 并保存 JSON 结果:逻辑实际刷新和面板扫描均不低于 95Hz;完成帧与完成扫描持续增长且区间增量相等,`scans_per_frame=1`;截止丢失增量不高于完成帧增量的 0.1%,不得连续两秒无完成帧;OE pulse fault、forced blank 和其他 fault 不增长,驱动/PWM/安全关闭错误为空,CPU3 绑定、实时优先级和内存锁定保持生效,性能阶段全部 policy 必须实际为 `performance`,关闭后必须恢复启动前 governor;`/api/display/current-frame` RGB 字节不变。实屏不得出现同步黑场、局部高亮块、坏行、错色或可见卡顿;只剩拍频/曝光歧义时等待用户确认。 + +如果最多 2 张视口内动态缩略图未通过网页、服务或 GPIO 任一门槛,候选应用先改为只播放距视口中心最近的 1 张并完整重测;最多 1 张仍失败则撤销动态缩略图、保留静态首帧。两种应用失败都不得撤销已经独立通过的 AXP313A/cpufreq、性能模式、CPU3 绑定、实时优先级和内存锁定成果。候选应用失败时恢复生产应用并在聊天中报告瓶颈和互斥方案;全部通过后才使用原子更新脚本部署并在重启后复测 60 秒,最终 `performance_mode_enabled` 必须为 `false`。 + +`TEST-CPUFREQ-RECOVERY` 在 fake sysfs 注入多 policy、缺失 policy、单 policy 写失败和配置保存失败,要求已写 governor 全部回滚;所有 mock/fake-register 应用默认使用运行根下不可用的隔离 cpufreq 路径,只有该专项测试可通过工厂注入 fake sysfs,任何 mock 测试不得读取或改写宿主机真实 governor。保留运行根记录后模拟异常重启,不得把遗留 `performance` 当作原 governor。板端候选内核必须识别 `x-powers,axp313a`、注册 `vdd-cpu` DCDC2、消除 `cpufreq-dt` deferred,`policy0` 覆盖 CPU0-3且包含 `performance`;DCDC2 始终处于 `810000..1080000µV`,ALDO1 保持 1.8V、DLDO1 保持常开 3.3V、DCDC3 保持引导器实际设置的 1.10V,修复前后的其他 PMIC 寄存器、GPU 状态和持久数据清单不得有非预期变化。 + +`TEST-KERNEL-ROLLBACK` 先在隔离目录验证 boot 脚本只出现一个候选管理块、原文件路径保持可达、健康标记在候选启动前删除。板端保留原 `Image`、DTB、模块和 `boot.scr`,候选使用独立名称;健康服务只有在内核版本、AXP313A、cpufreq、服务和 GPIO 全部通过后才重建标记。注入健康失败后必须自动重启到原内核;若候选在健康服务前早期硬锁且硬件看门狗没有重启,停止自动操作并请用户只断电重启一次,随后确认无标记路径进入原内核。 + +`TEST-WORKSPACE-ORDER` 在相同四类视口检查:侧栏六个项目无“设备/内容”分类标题;叉号下方提供“自定义项目位置”;进入编辑后按钮变为“保存”且项目只显示上下移动操作。首末边界不可用,项目点击不导航,叉号、遮罩和 `Escape` 只提示保存并保持侧栏打开。保存成功后侧栏保持打开,刷新页面、另一浏览器和服务重启均恢复新顺序;请求失败保留内存草稿,未保存时刷新、关闭页面或模拟崩溃后恢复上一次已保存顺序。该测试不连接 HUB75 或 ADC。 + +`TEST-MEDIA-CONVERSION` 使用确定性合成素材覆盖静态 PNG/JPEG/WebP/AVIF、透明 PNG/GIF/APNG/WebP、HEIC 和 MP4/MOV/WebM/MKV/AVI。逐项验证三种适配;“自由取景”在 `zoom=1` 时必须先完整显示方向及 SAR 归一后的横图或竖图,不得预先居中裁成正方形,缩放后可选择非中心区域并由整段媒体共用同一组焦点/缩放。素材可从四边拖出固定 1:1 视窗直至只剩一个输出像素,再拖到全空必须拒绝;素材自身透明像素使用透明填充色,素材外虚空使用留白色。继续验证 20fps 上限、相邻最终 RGB 帧合并和静态/动图分类。流式上传必须覆盖 4 GiB 上限、长度不符、中断 `.part` 清理、伪扩展名、音频/播放列表/网络协议/异常尺寸拒绝、2 GiB 空间保留和 30,000 帧门槛;自动化必须注入确定性的充足/不足空间结果,不能因本机磁盘很大而漏测不足,也不能因板端 `/run` 容量小于生产持久盘而误报正常上传失败。同一文件名和相同字节连续上传必须产生不同 UUID 与独立源文件,分别改名和取景后均可转换。上传区域不得要求保存名称,分析完成后按文件名预填;已有静态模板、已有动图及其他排队/转换任务的同名、大小写变体或首尾空白变体必须在保存设置或排队时返回 `409`,被拒任务保持可编辑,取消、失败、完成和重启恢复后按规则释放或保留名称占用。任务成功或取消立即清理,失败与待设置保留 24 小时;重启恢复队列,任何失败不留下部分模板或动图。浏览器在约 `320px`、`390px`、平板和桌面验证上传区单列文件选择、上传进度、最多五张代表帧、自由取景拖动缩放、双颜色、名称冲突提示、排队/重试/取消、隐藏页暂停轮询、成功卡片消失和对应内容库刷新。动图列表必须先在主服务中预热缓存,再由独立转换进程提交 GIF,并确认不重启主服务即可从 `/api/animations` 和模板管理看到新动图。等待设置卡片还必须在自动轮询至少两个周期及手动刷新后保持同一 DOM 节点、输入焦点、未提交名称、适配模式、取景焦点、缩放、颜色和代表帧选择;服务端状态转换后则立即退出本地编辑并显示新状态。 + +`TEST-MEDIA-PERF` 在当前用户画面保持不变时分别转换静态图、透明动图、1080p 和 4K 视频。转换 unit 必须只运行在 CPU0-1、`CPUQuota=150%`、`MemoryMax=384M`、无 swap 和网络;CPU3 绑定、实时优先级、内存锁定、OE/PWM 状态保持真实正常。100Hz 档实际刷新和物理扫描满足既有门槛,转换窗口截止丢失率不高于 0.1%,无 OOM、fault 或 `last_error`,普通状态和模板 API 持续可响应。转换前后 `/api/display/current-frame` RGB 字节完全相同;主动播放结果后与软件期望帧逐像素比较,动态时间误差不超过 50ms。重启项还要确认 `/proc/sys/kernel/sched_rt_runtime_us` 在显示服务运行时为 `-1`、服务停止时恢复启动前数值,并在整机重启后再次确认 `realtime_priority_active=true`。本测试只读逻辑帧和统计,不要求用户或摄像头判断。 + +`TEST-MANUAL-UPDATE` 使用 `scripts/update_walnutpi.sh`:更新前记录服务状态、当前 release 和持久根清单;脚本必须在停服后克隆持久根,在副本上运行 `scripts.prepare_data_root`,确认配置 v8→v9 默认加入并关闭性能模式后再原子切换程序和数据。源码轻量更新包不携带预编译内核、固定内核源码或镜像;仅当更新器明确设置源码轻量测试标记且两类大型材料均不存在时,登记材料完整性用例可跳过,其他设备端 Python 测试必须全部运行,任一材料只缺一类仍失败,本机完整项目仍强制校验两类材料。板端测试的应用数据、运行数据、pytest basetemp 和通用临时目录必须全部位于本次 `/run/matrix-screen-controller-update.` 命名空间,禁用 pytest cache,结束后精确清理且不得使用默认 `/tmp`。首次健康检查成功后必须再次 `systemctl restart` 并重新轮询真实 `/api/status`。成功时原用户文件完整保留,只允许登记的 schema 迁移改变内容,并删除旧 release、本次 staging、迁移副本和回滚副本;注入迁移或健康检查失败时必须恢复旧程序、旧 unit 和未修改的旧持久根。本测试不连接或操作 HUB75、ADC。 + +`TEST-OTA-LOCAL` 还必须覆盖:严格版本比较、严格 `+08:00` 分钟级功能时间、两个状态 API 返回同一固定值、完整包携带该文件、载荷排除、摘要损坏、路径穿越、压缩膨胀、超限和中断上传清理;并发任务拒绝、写冻结、进度恢复、配置新增预设、退休字段/文件定向删除、未知用户文件保留、切换失败精确回滚、事务恢复和旧 release 清理。模拟 1.0.3 旧 worker 只传三个旧环境变量时,新包 conftest 必须仍把应用/运行数据、`tmp_path` 和 Python 临时文件放入事务目录,并实际执行测试;故意失败用例必须返回非零。当前源码及临时候选载荷不得包含 1.0.6 诊断标记、`pytest.exit` 成功早退或其他测试绕过入口。日志测试覆盖各阶段、命令退出码、pytest 短 traceback、非 UTF-8、安全主机摘要、`1 MiB` 头尾截断、单份原子覆盖、成功删除和日志写入失败降级;不得出现凭据、网络配置、用户内容或完整环境。状态 API 与纯文本日志 API 覆盖可用/缺失、字节数、`404` 和 `no-store`。前端必须覆盖软件版本与功能时间、上传进度、处理进度、预期断线、恢复刷新、失败自动弹窗、纯文本安全、长日志滚动、重新查看以及 Clipboard/textarea 两条复制路径;常态页面不得显示最近 OTA 结果,完成任务后不得继续显示 100% 进度条或完成提示。普通开发部署必须保持 `VERSION` 不变并按功能变更更新 `FEATURE_UPDATED_AT`,只有明确的 OTA 或镜像导出成功才推进共用版本,单纯导出不得改写功能时间。 + +`TEST-RELEASE-VERSION` 交替模拟 OTA、镜像、同一代码连续导出两种产物、构建失败、已有目录、并发锁和历史导出 IMG 被删除。OTA 按已登记节点推进;IMG 不使用 OTA 跳转门禁,但新候选必须严格高于当前版本。`image --candidate-output` 只能写正式目录外且不得改变 `VERSION`、发布记录或正式目录;`image --validated-candidate --validation-report` 只接受下一补丁、摘要一致、字段严格且全部门槛为真的无秘密实卡报告,重建期望 bundle 并复核真实 IMG 后才原字节晋升。候选后源码变化、报告多余字段、摘要错误、已有版本、并发、复制或记录写入失败均须保持版本、记录和正式目录字节不变。另行覆盖用户明确授权的 `image --repair-current` 原有同版本修复语义。 + +`TEST-IMAGE-LOCAL` 校验官方 IMG 摘要在导出前后不变;独立读取 MBR、FAT16、rootfs 离线 bundle、软件版本和全部 SHA-256。镜像元数据必须严格为 v3,记录应用载荷 rootfs 路径、压缩字节数和摘要,以及已登记内核 release、压缩/展开字节数、摘要、FAT 安装预算和 `32 MiB` 安全余量;FAT 必须不存在 `MSCBOOT.TGZ`。预编译载荷逐项覆盖外层/内部摘要、3,048 个模块普通文件、五个 boot 文件、固定 release/commit、确定性 gzip、路径穿越、符号链接、特殊文件和损坏元数据。隔离假根验证应用与内核载荷共同写入、读回、既有目标拒绝及“两类压缩载荷 + 256 MiB”空间门槛;合成 FAT 分别覆盖空间通过、边界和不足,预算按簇计入五个 boot 文件、受管/原始/临时启动脚本与状态文件,并确认不足发生在任何候选 boot 文件写入前。Linux 上再只读挂载真实复制件根分区,逐字节比对两类载荷和 FAT 元数据,并确认 `matrix-image-firstboot.service` 与 `ssh.service` 均已启用、受管 sshd 配置逐字匹配、一次性程序指向 FAT 分区且排序早于 SSH 和普通启动脚本。根分区还必须包含 `sshd`、`sudo` 和 `visudo`,SSH 不得被 mask。以 `BASE_IMAGE_PACKAGES.tsv` 为基准解析 FFmpeg、libheif 和 Python venv 的全部直接/传递 Debian 依赖;删除任意仅由闭包提供的包、根包、基准清单、内核依赖或摘要条目时,构建必须在复制正式镜像前失败。导出入口必须在 `python3 -S` 下完成参数解析,不得因 Pillow 或 FontTools 未安装而在发布锁前退出。静态回归必须确认首启写入网络配置并重启 NetworkManager 后直接进入部署,不得循环调用 `nmcli connection up`、等待 SSID/DHCP 或以默认路由缺失退出;`DEFER_SERVICE_START=1` 的全新安装和同版本恢复均使用不要求默认路由的 `dedicated_host.py check --service`,普通部署的严格检查仍要求默认路由。离线完成状态必须固定、无凭据并明确安装成功和 WiFi 未连接。首启只启用控制服务,SSH 也只能 `enable`、不能在具有 `Before=` 排序的 oneshot 内 `start` 或 `enable --now`;账户必须显式加入 `sudo` 组,sudoers 临时文件先经 `visudo` 校验再原子安装,实际 sshd 策略必须经 `sshd -t/-T` 校验。同版本中断恢复必须拒绝版本或安装文件不完整的 `/opt`。破坏活动配置槽必须回退旧槽,两个槽都损坏、产品错误、未知 schema、非法账户/WiFi/IPv4 均拒绝。编辑器覆盖打开、密码遮挡、修改原包确认、另存为不改原包和另存后自动重开;中文、空格及长路径必须生成无 BOM UTF-8 规范摘要并由独立 `Get-FileHash` 复核,摘要长度、十六进制、分隔符、多行、文件名或内容错误以及旧版中文名变成 `?` 的摘要均拒绝。保留名、错误扩展名、同路径、既有 IMG、孤立同名摘要和不可写目录必须在修改前拒绝且不留半成品。主窗口、密码输入框和全部应用内提示在 100%、150%、200% 缩放及约 1280×720、1920×1080、4K 工作区完整可达,跨不同 DPI 显示器或改变分辨率后不得裁切;低分辨率允许滚动。镜像扫描不得包含项目凭据、当前设备数据、SSH 主机密钥、NetworkManager 连接、缓存或编译输出。 + +`TEST-IMAGE-LOCAL` 的编辑器回归必须直接运行 Python/PySide6 源码单元测试,并将最终 Windows 10/11 x64 单 EXE 单独复制到隔离临时目录,在不依赖源码目录、Python、.NET、Qt 或外部 DLL 的环境中重复 `--validate` 与 `--apply`。同一合成 FAT16 镜像还必须由主工程 `image_config.py` 交叉读取,并覆盖非活动槽更新、活动槽损坏回退和失败不留新 IMG/摘要/临时文件。GUI 人工验收继续覆盖单 IMG 拖放、密码遮挡、确认流程、另存后自动重开、进度响应以及 100%/150%/200% DPI、1280×720、1080p、4K 和跨显示器变化。macOS 只验证源码和构建说明完整,未在 macOS 实际构建时不得写成 macOS 产物通过。 + +`TEST-IMAGE-LOCAL` 还必须确认 IMG README 使用镜像专用模板,逐字匹配 FAT 配置中的默认账户、密码、Wi-Fi SSID、Wi-Fi 密码和 DHCP,并包含公开默认值修改警告;不得出现 OTA 包类型、前置节点或上传说明。除该 README 与 IMG 配置外,manifest、发布记录、日志和验收报告均不得包含这些默认值,当前开发机凭据任何位置都不得出现。 + +`TEST-IMAGE-LOCAL` 的 SSH 静态回归还必须确认:直接运行 `sshd -t/-T` 前创建模式 `0755` 的 `/run/sshd`,不得依赖尚未启动的 `ssh.service` 创建其易失运行目录。 + +`TEST-IMAGE-FIRSTBOOT` 写卡前重新按可移动总线、容量及非系统/非启动属性锁定目标并确认会清空。核桃派与外设全部断开时插卡通电;先让预设 SSID 完全不存在,设备仍应自行扩容、从 rootfs 离线载荷安装、配置网络、生成唯一身份和 SSH 主机密钥、校验并安装预编译内核后重启一次。FAT 必须显示无凭据的“安装成功但 WiFi 未连接”状态,不得出现 `FAILED: stage=network`;断电取卡只读确认控制程序、服务、账户、主机密钥和候选内核均已安装且明文配置已清理。认证失败和认证成功但 DHCP 失败分别执行同一门槛,均不得阻断安装;NetworkManager、载荷、空间、硬件、权限、SSH 或内核故障仍必须失败。内核阶段要求根分区剩余空间不低于“两倍展开大小 + 256 MiB”,并在写入 `/boot` 前重新满足 v3 元数据登记的实际 boot 预算和 `32 MiB` 余量;成功必须删除 rootfs 两类压缩载荷和 `/var/tmp` 展开目录,失败必须保留压缩载荷供完整重试。候选启动后确认 `6.1.31-matrix-axp313a1`、AXP313A、cpufreq 和应用健康标记;注入安装失败必须保持原 boot 文件且不重启,注入候选健康失败必须自动回原内核。首启 unit 必须使用无限启动时限;FAT 成功状态只能在秘密、rootfs 载荷和临时文件清理、首启 unit 禁用及全盘同步之后出现。随后恢复原热点且不重刷、不重新部署,最多等待 20 分钟,确认设备由正常运行服务自动联网、屏幕断网提示按既有逻辑恢复,并用镜像编辑器写入的账户和密码完成 SSH 登录;`systemctl is-enabled/is-active ssh.service` 必须均成功,root 密码登录必须拒绝。先清除 sudo 时间戳并确认 `sudo -n` 被拒绝,再输入同一账户密码执行任意 root 命令;检查 `/etc/sudoers` 不含 `Defaults rootpw` 或旧 `pi` 的 `NOPASSWD`,且当前主机名可由 `/etc/hosts` 立即解析。随后确认 `/api/status` 版本正确、真实 H618 映射就绪、浏览器控制界面可加载;再次重启后 SSH、sudo 和网页仍通过。SSH 未监听、未实际登录、内核健康或权限策略不符都不得作为正式镜像通过。失败且网络不可达时断电取卡读取 FAT 状态文件;Debian 安装失败还必须读取不含配置凭据的 `MSCPKG.TXT` 和 `MSCPKGS.TSV`,不得连接屏幕绕过或把结果写成通过。成功时两个诊断文件必须删除。 + +候选晋升的最低真实硬件门槛是同一摘要 IMG 在默认热点从上电起可用时完成一次完整正向首启、自动重启、SSH/sudo/root 拒绝、服务、内核、H618、网页和再次重启复验。SSID 不存在、认证失败和 DHCP 失败分支仍须通过自动化;没有另行执行真实故障网络矩阵时,在验收记录中明确写为“本次未重复实卡验证”,不得写成实卡通过。 + +工作区 OTA 今后长期保留。1.0.1 缺失已由用户接受,原 `1.0.0 → 1.0.1` 组合仅保留为历史用例,不再要求补齐或执行,不提示验收材料缺失,不影响开发、常规测试和使用。新的真实 OTA 验收使用本次任务明确的源/目标版本及实际包,并遵守安装节点门禁;不得为重跑历史用例自动导出或替换版本。 + +`TEST-OTA-REAL` 按本次任务明确的源/目标版本执行:先部署源版本,再从系统设置选择 `发布更新相关/OTA数据包/<目标版本>/` 中的真实包。更新前后对持久根做文件清单,除 schema 迁移和单条 OTA 状态外保持一致;确认版本、实例 ID、真实驱动、硬件映射、PWM4 OE 后端和 fault 计数。再次上传同一包必须在停服前拒绝。成功后不得残留旧 release、上传包、staging、数据备份或活动 OTA unit。另用受控失败包确认 1.0.5 及后续旧版本服务恢复后自动弹出最近失败日志,日志包含失败用例与回滚结论且可在局域网 HTTP 页面完整复制;成功更新清除旧日志。1.0.6 是发布记录中唯一经授权跳过旧 worker pytest 的历史诊断引导包,只用于先安装日志能力,其成品不得覆盖、实现不得复制到当前源码或任何后续包;1.0.7 及以后全部恢复严格 pytest 门槛。实屏应在更新期间以 40% 亮度显示整体缩放为 58×58、位于 `(3,3)` 的居中 OTA 进度,四边均有黑色空白,并在成功后恢复默认内容;摄像头画面不清晰时必须等待用户肉眼确认。 + +## 2. 原生驱动单元测试 + +`TEST-FRP-MAINTENANCE` 在 Windows mock 覆盖默认无配置且关闭、多格式创建/上传/读取/编辑/选择/删除、重名、UTF-8 与 `1 MiB` 上限、UUID 路径隔离、官方校验失败不落盘、活动编辑重启失败回滚、运行配置删除冲突、目录损坏保全、状态脱敏和设置页操作。板端校验官方 `frpc` 摘要/版本、普通维护账户、单实例、关闭时 disabled、开启后整机重启自动恢复。最后导入一次性 22 端口样例,从外部端点 SSH 登录并核对同一主机密钥;通过后关闭 FRP 并删除样例,不在源码、日志或归档保留其密密值。 + +`TEST-NETWORK-DIAGNOSTICS` 用可注入结果分别验证网卡/WiFi 未连接、默认网关缺失、DNS 失败、TCP 不可达、TLS/时间异常、单站点失败与 ICMP 被屏蔽但 TLS 成功。板端使用当前 NetworkManager 连接运行一次,返回中国大陆可访问多站点证据,单个目标临时异常不写成本机 DNS 或网关已确认故障。 + +`TEST-NATIVE-UNIT` 必须使用内存中的 fake register block,不打开真实设备: + +- 物理脚到 `PI0..PI16` 和 HUB75 信号映射精确,`OE=PI14/PWM4`、`LAT=PI15/GPIO`,PI7/PI8/PI16 永不出现在显示输出 mask;映射标识固定为 `walnutpi-pi-bank-pwm-oe-v2`。 +- PIO 基址 `0x0300B000`、PWM 基址 `0x0300A000`、PI bank offset `0x120`、data offset `0x10`、mux 寄存器分组与 bit 位置正确。 +- 64×64 RGB888 与 `1..100` 亮度经 CIE1931 映射到 11-bit 通道值,再转换为内部位 `4..10` 的七个输出 bitplane;覆盖上下半屏六个颜色位、ABCDE 行地址、亮度非递减和白色 100% 映射到 `2047`。 +- 双缓冲仅在完整帧边界交换;覆盖未展示 back buffer 不产生队列和撕裂。 +- 亮度 `1..100` 和刷新挡位 `15/20/30/45/60/80/100` 校验、CIE 映射和状态更新正确;所有亮度每逻辑帧只做一次完整扫描,累计帧数与累计扫描数保持 `1:1`,亮度变化只改变 bitplane 数据。 +- fake PWM4 寄存器测试覆盖:低有效极性、通道 4 启停、`PWM_MODE=1` 单脉冲、`PWM_PUL_START` 触发/自动清零模拟、PPR 可写忙状态、总周期等于有效周期、`125ns` tick 和固定 `2..128us` 位权、活动 PWM5 的兼容共享时钟被接受且 enable/bypass/PCR5/PPR5 不被覆盖、不兼容共享时钟被拒绝、LAT/OE 新 mux、帧边界切换和寄存器回读失败时强制黑屏。 +- 初始化失败、PWM4/PWM5 占用冲突、线程失败、提交长度错误、关闭、析构和 `hub75_safeoff` 都先禁用 PWM4、强制 OE 高并清零占用位。 + +## 3. 核桃派远端健康 + +SSH 后先执行远端工具检查: + +```sh +command -v git python3 curl wget gcc g++ make +python3 --version +gcc --version | head -n 1 +uname -a +systemctl is-system-running +df -h / +free -h +nmcli -t -f GENERAL.STATE,IP4.ADDRESS,IP4.GATEWAY device show wlan0 +``` + +要求系统为 Debian 12 / AArch64,地址、网关、时间、磁盘和内存正常。核桃派无 VPN;依赖安装只能使用已验证国内源或电脑准备的带 SHA-256 清单离线包。 + +冷启动时 `ExecStartPre` 使用 `dedicated_host.py check --service`,该模式不得因 NetworkManager 尚未生成默认路由而失败;服务进入就绪后,本节健康检查仍必须确认默认路由。检查当次 boot 的 journal,不得依靠一次 `default network route` 失败和 `Restart=on-failure` 重试才启动成功。 + +## 4. 无负载 H618 基准 + +开始前确认:HUB75、屏幕 5V 和 ADC 全部未连接。该阶段允许软件切换空载 GPIO,不要求用户接线。 + +1. 编译项目自带原生共享库和 benchmark。 +2. 用 fake-register 模式先跑全部功能测试。 +3. 读取板端 device tree,必须得到 `allwinner,sun50i-h616-pinctrl`、PIO `0x0300B000` 和 PWM `0x0300A000`;确认 `PI14` mux `0x5` 为 PWM4。若 PWM5 已启用,必须确认共享字段为兼容的 `24MHz/div1`,并记录 PWM5 enable、bypass、PCR5、PPR5 前值供停止后逐项比对;不兼容则停止。 +4. 真实 `/dev/mem` 基准启动时先把 `PI14` 作为 GPIO 拉高 OE、数据/地址/CLK/LAT 低,再配置 PWM4 单脉冲模式并切换复用;确认 `PCR4.PWM_MODE=1`、`PWM_PUL_START` 每次脉冲后由硬件清零,禁止用 `PER` 反复启停连续 PWM;PI7、PI8、PI16 及 PWM5 前后值保持不变。 +5. 以 64×64、7 个输出 bitplane、逻辑上限 100 Hz 分别在亮度 5 和 40 连续运行至少 60 秒;100 Hz 档包含固定 `0.25ms` 调度保护间隔,预期逻辑和物理扫描均约 97.56 Hz、`scans_per_frame=1`;并逐档检查 15/20/30/45/60/80/100 Hz,确认亮度不再改变 OE 时序或扫描次数。 +6. 连续提交不同帧验证 buffer swap 只发生在帧边界;同时读取状态。 +7. 正常停止和强制异常路径都检查 PWM4 禁用、OE 强制高、占用 data bit 清零、mux 恢复安全输入、PWM5 不变、映射与线程释放;再分别直接执行 `hub75_safeoff` 和经 systemd `ExecStopPost` 执行,结果相同。 + +接屏门槛:100 Hz 逻辑档实际值和面板物理扫描率均至少 95 Hz,`completed_scans = completed_frames`、`scans_per_frame=1`、`max_programmed_oe_ns=128000`。亮度 5 和 40 的 60 秒截止丢失率均不高于 0.1%,`oe_timing_backend=h618-pwm4`、`oe_pulse_faults=0`、PWM 错误为空,统计单调、RSS 不持续增长;CPU3 绑定、实时优先级、内存锁定和 governor 分项报告真实结果。任何安全关闭失败、PWM5 改动或非保留 GPIO 被改动都直接失败。 + +## 5. 实时服务与重启 + +先用 `MATRIX_DRIVER=mock` 验证:首页、`/api/status`、配置更新、字体导入与文字 PNG、模板和缩略图、两帧动图、默认内容、WebSocket RGB888。正常关闭 WebSocket 后 journal 不得出现 close 后再次发送。 + +再用真实驱动但仍不接屏启动服务,`/api/status` 必须返回: + +- `driver=walnutpi-h618-hub75`; +- 逻辑实际刷新率、面板物理扫描率、完成帧、完成扫描、每帧扫描数、deadline miss、buffer swap; +- CPU 绑定、实时优先级、内存锁定、governor 和最后错误; +- `oe_timing_backend=h618-pwm4`、OE pulse fault/forced blank、最大编程脉宽和 PWM 错误; +- 不得把 mock 回退报告为真实成功。 + +执行 `systemctl restart matrix-screen-controller`,等待 `/api/status` 真正就绪后复核配置 schema v7、工作区顺序、字体、模板 schema v1、动图 schema v1、默认内容和新进程 instance ID。不得用固定 2 秒代替就绪轮询。 + +冷启动与服务重启都应在第一次 `ExecStartPre` 通过;若默认路由尚未出现,`--service` 预检仍允许应用启动并由后台网络流程接管。最终必须同时确认服务启动后默认路由、固定地址和网关正常。 + +## 6. HUB75 接线与实屏顺序 + +只有 `TEST-NATIVE-NOLOAD` 达标后才生成并使用接线资料。开始前明确要求核桃派和屏幕全部断电;旧接线只互换两根线为 `OE→物理脚40/PI14/PWM4`、`LAT→物理脚36/PI15/GPIO`,其余信号、共地和屏幕独立 5V 不变。用户完成并回复确认前不得启动新驱动,本阶段不连接 ADC。 + +上电后严格逐项: + +1. 低亮度全黑:确认无随机亮点和异常发热。 +2. 全红、全绿、全蓝:确认 R/G/B 线组和上下半屏。 +3. 同帧混合色带与 `#FF4FA3` 粉色:确认红/橙/黄/绿/青/蓝/紫/白顺序正确,粉色不偏黄,排除 G/B 互换和 8-bit→7-bit 回绕。 +4. 全白:确认组合颜色与稳定供电,不长时间高亮。 +5. 四角与 `y=0/31/32/63`:确认上下半屏、CLK/LAT/OE。 +6. 行带与 address check:确认 A/B/C/D/E,无半屏、重复行或乱序。 +7. 0/90/180/270 方向。 +8. `OK123`、中文和多语言文字。 +9. 模板、图片、两帧动图和持续刷新。 + +上一项失败就停止。全黑有随机点只检查 CLK/LAT/OE/共地;单色错只检查六根 RGB;半屏或 32 行重复只检查 E;行序错误只检查 ABCDE。用户修正后只复测失败项,不跨项宣布通过。 + +亮度稳定性必须额外用 DroidCam 或等价摄像头验证。在 `1/5/20/50/60/100%` 各连续录制至少 30 秒,固定只含屏幕中央的裁剪区并舍弃前 5 秒自动曝光收敛段;比较稳定段逐帧整屏均值、每秒峰峰差和极值帧,检查局部高亮块、同步黑场、固定坏行和错色。随后锁定曝光扫过 `1..100`,要求测得亮度单调。只有复现异常或房间人员进入裁剪区时才增加独立录像,不得把重连后的曝光重置样本混合统计。 + +以下任一项都判为刷新失败:稳定段出现可重复的局部高亮块、整屏同步黑场或大幅周期性亮度跳变;出现固定坏行、固定错色;亮度扫描不单调;100 Hz 档 `panel_scan_rate_hz < 95` 或 `scans_per_frame != 1`;截止丢失或 OE/PWM fault 持续增长。滚动快门把面板扫描拍成随帧移动的窄亮带或暗带时,若条带数量随物理扫描率变化、整屏均值稳定且驱动统计正常,只记录为摄像头拍频,不能单独判失败;房间人员、整体曝光缓慢变化和 DroidCam 水印也不得误判为屏幕故障。若只剩拍频歧义,暂停请用户直接观察一次是否仍有肉眼可见闪烁。最终必须请用户对 5% 与 20% 各做一次肉眼确认。 + +若用户在已经取得驱动统计、连续录像和肉眼结论后明确决定把单一稳定性问题延期,可以继续验证与该问题相互独立的颜色、行地址和内容功能,但必须同时满足:在可单独拿走的项目 README 中登记当前症状、已做缓解、未通过项和后续调查方向;一次性证据写入移植记录;最终验收把该项列为未通过而不是跳过或通过。延期不豁免新增黑场、固定坏行、固定错色、截止丢失或异常发热的立即停止规则。 + +## 7. ADC 和校准 + +实屏通过后再次整机断电,用户只连接物理脚 3/5 的 SDA/SCL、5V、GND 和屏幕输入测量点。上电后顺序固定: + +1. `/dev/i2c-1` 存在; +2. 总线发现地址 `0x48`; +3. 原始值稳定且不是伪造 `0V`; +4. 断开/权限/短读状态明确,网页与显示继续; +5. 暂停请用户把万用表接到同一屏幕输入点,只提供当前稳定读数; +6. 预览校准、确认提案、再读软件值与万用表复核; +7. 重启服务后校准保留;断开再接后自动恢复。 + +一次只处理一个万用表读数,不能提前假设校准通过。 + +## 8. 低电压保护 + +仅在 ADC 校准可靠后执行。每档开始前暂停,让用户调到一个指定安全电压并确认稳定;Codex 读取软件状态和实屏结果后才给下一档。 + +- 正常档:高于 4.8V,用户亮度与画面不变。 +- 单个限亮档:4.5..4.8V,检查线性亮度上限,不改用户保存亮度。 +- 临界档:严格低于 4.5V,检查 35% 黑底红图标和方向。 +- 恢复档:严格高于 4.8V,满足两组确认后恢复最新用户输出。 + +故障、过期读数和恢复中的任何歧义都暂停,不一次要求整条电压阶梯。 + +## 9. 清理与最终独立性 + +- 停止所有临时 unit,按已验证的明确绝对路径删除临时源码、venv、wheelhouse、构建和测试数据;保留 `/var/lib/matrix-screen-controller`。 +- 复核 GPIO 安全状态、系统 `running`、生产服务健康、无临时端口和无残留诊断环境变量。 +- 独立目录不得含 `.venv`、缓存、运行数据、旧设备配置、旧凭据、主机密钥或外层绝对路径。 +- 把整个 `奇妙小屏幕控制器-核桃派` 复制到一个隔离临时目录,只按本目录 README/测试文档重新编译和测试;任何目录外依赖都失败。 + + +### TEST-OTA-CHECKPOINT:软件安装节点与组件事务 + +映射 `DEPLOY-OTA-CHECKPOINT`、`DEPLOY-OTA-COMPONENT-TRANSACTION`、`DEPLOY-FRP`、`WEB-OTA`。运行完整本机套件,并执行 `tests/test_ota_checkpoints.py`。 + +- 允许 `1.0.3/1.0.6 → 1.1.0`、`1.1.0 → 1.1.3`、`1.1.x → 1.2.0`;拒绝 `1.0.x → 1.1.1/1.2.0`、`1.1.x → 1.2.1`,提示首个缺失节点;同版和降级仍拒绝。 +- 用真实旧更新器检验 1.1.0 v1 包解包、测试、迁移桥接,确认旧解析器拒绝后续 v2 包。旧材料只作一次性验收输入,不能成为日后构建和常规测试的依赖。 +- 软件安装节点携带登记的系统组件;普通补丁不含这些二进制,篡改依赖摘要或已发布节点必须阻止导出。导出失败不推进版本,不覆盖历史产物;删除旧导出 IMG 后仍能构建;不得为测试删除长期保留的正式 OTA。 +- 在隔离 AArch64 文件系统检查无 frp、完整已有 frp、二进制损坏/不同版本、缺 unit/drop-in;匹配二进制不重写,不必要时不重启,缺失部分修复。正常配置和启停状态保持,首次关闭。检查维护账户不依赖 SUDO_USER。 +- 注入安装、pytest、迁移、服务启动、健康检查故障以及程序/数据切换中断;必须恢复原文件、权限、启停状态、用户内容和旧版本。模拟组件恢复失败时保留 journal,不能报告恢复成功。 +- 本机或隔离环境模拟的 systemd/HTTP/显示结果不能写为真实设备 OTA、重启或实屏通过。真实调试设备只修正必要的旧 worker 兼容性时,版本保持 1.0.6,不提前安装 1.1.0。 + + +### TEST-OTA-RUNTIME-LIFECYCLE:真实 systemd 停启与日志降级 + +映射 `DEPLOY-OTA-RUNTIME-LIFECYCLE`、`TEST-OTA-LOG-RESILIENCE`。本机运行 `tests/test_ota_runtime_lifecycle.py` 及 OTA/发布回归,覆盖日志降级、保护核验失败时拒绝停服、不覆盖用户 drop-in,以及组件校验允许本次事务持有的保护文件。板端运行 `sudo python3 scripts/test_runtime_directory_systemd.py`:脚本创建随机命名专用 unit,先证明 restart 策略在独立 stop 时删除目录,再实际调用生产保护函数,验证 stop、restart、启动失败期间请求/进度/日志字节不变,并清理测试 unit 和目录。两类证据分别记录。 + +完整包仍执行全部 pytest。涉及主服务停启的 OTA 修复必须走真实设备 OTA,核对磁盘与 API 版本、成功状态、持久数据、frp 启停和事务清理。systemctl 替身仅能验证业务分支,不能作为真实目录生命周期或 OTA 通过证据。显示效果与软件验收分开记录,缺少人工/摄像头证据时不能记为实屏通过。 + +同版本修复覆盖验证报告不匹配、候选源码不匹配、历史摘要损坏、提交失败回退及原始包完整归档;正式产物必须与实机验收候选包摘要一致,不得在实机成功后重新构建替换。 + +## 工作区目录适配验收(`TEST-WORKSPACE-LAYOUT`) + +从工作区根目录及其他调用目录分别通过测试入口绝对路径执行测试;复制源码、测试入口、当前需求和必要离线材料到含中文及空格的隔离目录,按本文件命令运行 Python、前端与 mock 健康检查。复制件不得依赖原工作区或历史导出产物。发布定位用临时夹具测试,确认依赖来自 `发布更新相关/其他依赖/`,OTA 与 IMG 输出分别位于 `发布更新相关/` 下的 `OTA数据包/`、`导出包/`;不为路径验证推进正式版本。 + +在隔离目录执行 `clean`,确认 `.venv`、依赖指纹和人工持久数据保持不变,仅删除登记的临时目标;越界路径必须拒绝。核对现存文档链接和发布记录中的路径格式;历史导出 IMG 不做齐全性门槛,缺失默认视为用户正常清理,不列故障、不追补、不阻塞开发。OTA 今后长期保留,1.0.1 缺失为已接受例外,不再提示或要求重跑该历史组合;保留原发布记录,不重新生成。镜像内部 `project/离线依赖/` 属于载荷协议,构建器与首启脚本应继续保持一致。 + +## Git 仓库安全验收(`TEST-REPOSITORY-HYGIENE`) + +先运行 `python "核桃派软件源代码/scripts/check_repository_hygiene.py"`。确认 Git 候选集合不包含 `测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt`,但包含同目录 `.example.txt`、正式 IMG、OTA、离线依赖、编辑器程序和历史归档;`.gitattributes` 可以为大体积二进制配置 LFS filter,卫生检查不得因此失败。检查输出只能包含文件路径和问题类型,不得包含命中的秘密或凭据值。 + +在含中文和空格的隔离目录测试 `prepare_credentials.py`:私有文件缺失时逐字复制示例、返回非零并要求用户填写;占位符、空值和缺字段继续失败;填写完整后成功;已有文件在所有路径均保持字节不变。随后检查文本中的 Windows/macOS/Linux 开发者主目录、当前用户名和主机名、私钥头、高可信秘密及非测试归档中的私有 IPv4;对大文件流式检查 ASCII、UTF-8 和 UTF-16LE 主机标识。测试假值、回环地址、硬件地址、设备固定部署路径和正式 IMG README 的公开默认值不得误报。 + +执行 `git diff --check`、`python -m pytest -q tests/test_repository_hygiene.py tests/test_workspace_layout.py` 和本机 `all` 套件。最终用 `git check-ignore -v` 复核私有文件由根规则命中,并确认 `git status --short --ignored` 只把秘密、本机状态、运行数据和可再生缓存列为忽略项;使用 `git lfs track` 与 `git lfs ls-files` 核对已配置和已暂存的 LFS 对象。本测试不执行 commit 或 push。 diff --git a/测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py b/测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py new file mode 100644 index 0000000..4ea7e24 --- /dev/null +++ b/测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +DIRECTORY = Path(__file__).resolve().parent +EXAMPLE_NAME = "用户名密码ip.example.txt" +PRIVATE_NAME = "用户名密码ip.txt" +REQUIRED_KEYS = ( + "设备", + "主机名", + "用户名", + "密码", + "IP", + "SSH", + "镜像默认用户名", + "镜像默认账户密码", + "镜像默认WiFi SSID", + "镜像默认WiFi密码", +) +PLACEHOLDER_MARKERS = ("<", ">", "示例", "example", "请填写", "待填写") + + +class CredentialPreparationError(RuntimeError): + """A safe, user-actionable credential preparation failure.""" + + +def _parse_fields(path: Path) -> dict[str, str]: + fields: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + separator = ":" if ":" in line else ":" if ":" in line else None + if separator is None: + raise CredentialPreparationError("凭据文件含有无法识别的非注释行。") + key, value = (part.strip() for part in line.split(separator, 1)) + if not key or key in fields: + raise CredentialPreparationError("凭据文件含有空字段名或重复字段。") + fields[key] = value + return fields + + +def ensure_credentials(directory: Path = DIRECTORY) -> bool: + example_path = directory / EXAMPLE_NAME + private_path = directory / PRIVATE_NAME + if not example_path.is_file(): + raise CredentialPreparationError(f"缺少示例文件:{EXAMPLE_NAME}") + + if not private_path.exists(): + payload = example_path.read_bytes() + try: + with private_path.open("xb") as stream: + stream.write(payload) + except FileExistsError: + pass + print(f"已创建私有凭据副本:测试相关资料/核桃派的用户名和密码和ip/{PRIVATE_NAME}") + print("请填写全部字段后重新运行本检查;在此之前不要继续 SSH、部署或镜像定制。") + return False + if not private_path.is_file(): + raise CredentialPreparationError(f"私有凭据路径不是普通文件:{PRIVATE_NAME}") + + fields = _parse_fields(private_path) + missing = [key for key in REQUIRED_KEYS if key not in fields] + if missing: + raise CredentialPreparationError("凭据文件缺少字段:" + "、".join(missing)) + unfinished = [ + key + for key in REQUIRED_KEYS + if not fields[key] + or any(marker.casefold() in fields[key].casefold() for marker in PLACEHOLDER_MARKERS) + ] + if unfinished: + raise CredentialPreparationError("凭据文件仍有未填写字段:" + "、".join(unfinished)) + + print("私有凭据文件可用;未输出任何凭据值。") + return True + + +def main() -> int: + try: + return 0 if ensure_credentials() else 2 + except (CredentialPreparationError, OSError, UnicodeError) as error: + print(f"凭据检查未通过:{error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt b/测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt new file mode 100644 index 0000000..9e9ac88 --- /dev/null +++ b/测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt @@ -0,0 +1,11 @@ +# 请复制本文件为“用户名密码ip.txt”,删除文件名中的“.example”案例标记,填写你自己的全部信息后,agent 工具才可以开始使用这些记录。 +设备:<填写设备说明> +主机名:<填写主机名> +用户名:<填写SSH用户名> +密码:<填写SSH密码> +IP:<填写设备IPv4地址> +SSH:<填写SSH连接说明> +镜像默认用户名:<填写镜像默认用户名> +镜像默认账户密码:<填写镜像默认账户密码> +镜像默认WiFi SSID:<填写镜像默认WiFi名称> +镜像默认WiFi密码:<填写镜像默认WiFi密码> diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md new file mode 100644 index 0000000..8972d7d --- /dev/null +++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/02_ADS1110使用与开发约定_给Codex.md @@ -0,0 +1,176 @@ +# M5Stack Unit ADC v1.1 / ADS1110 开发约定 + +本文供后续 Codex 或维护者读取。动手写驱动前,先确认实物是 `M5Stack Unit ADC v1.1`、SKU `U013-V11`、ADC 芯片 `ADS1110`。不要把旧版 Unit ADC、ADS1100 库或其他 ADS111x 芯片的寄存器接口混进来。 + +## 1. 当前项目边界 + +- 主控:`WalnutPi ZeroW`(H618) +- 目的:测量点阵屏输入端 5V 电压,作为供电压降的数字观测值 +- I²C 总线:`/dev/i2c-1` +- 7 位地址:`0x48` +- GPIO:物理脚 3 / `PI8/SDA1`,物理脚 5 / `PI7/SCL1` +- 供电假设:系统已经提供可靠、共地的 `5V/GND` +- 当前交付:本地资料、独立脚本,以及主服务中的电压状态、校准和可选低电压亮度保护 +- 明确不做:电流、功率、实际发光亮度识别、BMS、物理断电、充电管理或核桃派关机 + +测量端是并联采样。屏幕负载电流不得经过模块。v1.1 原理图中 P1 测量负端经 `0Ω` 电阻接模块 GND,因此不能把它当成与核桃派隔离的差分表使用。 + +## 2. 资料优先级和已知冲突 + +遇到资料冲突时采用以下优先级: + +1. 实物型号、v1.1 原理图和 TI ADS1110 数据手册 +2. M5Stack Unit ADC v1.1 产品描述及管脚表 +3. M5Stack 产品页中的规格汇总表 +4. M5Stack 旧 Arduino 示例和 `M5-ADS1100` 仓库 + +当前 M5Stack 产品页存在混杂信息:正文把 v1.1 描述为 ADS1110,并给出 `15/30/60/240 SPS`;但部分规格/比较表仍显示 ADS1100 或 `8/16/32/128 SPS`,链接的旧 Arduino 例程也使用 `M5_ADS1100.h`。对 `U013-V11` 必须使用 ADS1110 的 `15/30/60/240 SPS` 位定义,不得照抄旧库常量。 + +本地官方文件、原始 URL、下载日期和 SHA-256 见 `官方资料/来源索引.md`。 + +## 3. 板级模拟换算 + +v1.1 原理图在测量输入端使用 `510kΩ` 与 `100kΩ` 分压,并由 LM358 缓冲后送入 ADS1110。标称分压倍率为: + +```text +(510k + 100k) / 100k = 6.1 +``` + +ADS1110 在 PGA=1 时的差分满量程为 `±2.048V`。选择 15 SPS 时输出为 16 位二进制补码,因此: + +```text +ADC差分电压 = signed_raw × 2.048 / 32768 +屏幕输入电压 = ADC差分电压 × 6.1 × calibration_factor +``` + +默认 `calibration_factor = 1.0`。屏幕端每 LSB 的标称分辨率为: + +```text +2.048 / 32768 × 6.1 = 0.00038125V +``` + +快速合理性检查: + +| 屏幕输入端标称电压 | 预期 raw(约) | +|---:|---:| +| `0V` | `0` | +| `4.5V` | `11803` | +| `5.0V` | `13115` | +| `5.3V` | `13902` | +| `12.0V` | `31475` | + +`raw=32767` 对应标称约 `12.4924V`,但这只是数学满量程;模块官方输入范围仍按 `0V ~ 12V`,不能把多出的计算余量当成允许输入范围。 + +## 4. ADS1110 配置字节 + +ADS1110 配置寄存器只有 1 字节: + +| 位 | 名称 | 本项目值 | 含义 | +|---:|---|---:|---| +| 7 | `ST/DRDY` | 写入时 `0` | 连续模式下写入值被忽略;读取时 `0` 表示本次结果是新数据 | +| 6:5 | 保留 | `00` | 必须为 0 | +| 4 | `SC` | `0` | 连续转换模式 | +| 3:2 | `DR1:DR0` | `11` | 15 SPS、16 位 | +| 1:0 | `PGA1:PGA0` | `00` | PGA=1 | + +因此本项目写入: + +```text +0b00001100 = 0x0C +``` + +ADS1110 上电默认配置读值通常为 `0x8C`;位 7 是读取状态,校验配置时只比较低 7 位是否等于 `0x0C`。不要把 ADS1100 的采样率名称套在相同位值上。 + +## 5. I²C 读写顺序 + +ADS1110 没有常见的“寄存器地址指针”步骤: + +1. 以 7 位地址 `0x48` 打开 I²C 从设备。 +2. 写入单个配置字节 `0x0C`。 +3. 至少等待一个 15 SPS 转换周期,约 `66.7ms`;实现可留少量余量。 +4. 一次读取 3 字节: + - 字节 0:输出寄存器高字节 + - 字节 1:输出寄存器低字节 + - 字节 2:配置/状态字节 +5. 将前两个字节按大端、有符号 16 位二进制补码解析。 +6. 检查配置低 7 位仍为 `0x0C`;位 7 为 `0` 时,本次结果为新数据。 + +不要使用会自动在事务前发送“寄存器编号”的 SMBus 便捷函数,除非确认它能发出 ADS1110 所需的原始事务。当前独立脚本直接操作 `/dev/i2c-1`,避免引入依赖或错误的寄存器前缀。 + +## 6. 独立脚本接口 + +文件:`示例程序/read_unit_adc_v1_1.py` + +默认行为: + +- 总线 `/dev/i2c-1` +- 地址 `0x48` +- 配置 `0x0C` +- 每次取 5 个新样本的中位数 +- 输出屏幕输入端电压、raw 中位数和配置字节 + +常用命令: + +```bash +# 本机纯计算/协议自测,不访问 I²C,Windows 也可以运行 +python "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --self-test + +# 核桃派单次读数 +python3 "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" + +# 连续读数 +python3 "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --watch + +# 使用一次比例校正 +python3 "硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py" --calibration-factor 1.00604 --watch +``` + +额外参数通过 `--help` 查看。独立工具必须保持 Python 标准库实现并与主服务监测器分开;不能把独立无限循环直接放进 FastAPI 进程,也不能与主服务同时长期读取同一地址。 + +## 7. 错误与排障语义 + +后续修改脚本时必须保留可区分的错误: + +- `/dev/i2c-1` 不存在:I²C 未启用或设备节点不同。 +- 权限不足:用户没有访问 I²C 设备的权限。 +- 地址无响应:`0x48` 没有 ACK,优先查接线、供电和型号。 +- 地址被占用:内核驱动已经 claim 该地址。 +- 短读取:没有取得完整 3 字节,结果不可用。 +- 配置低 7 位不是 `0x0C`:设备/协议不符或配置被改变,不能继续按 15 SPS/PGA=1 换算。 +- 负 raw:按二进制补码保留并报告,不要在协议层静默截成 0;对本项目的正电压输入,持续负值应视为接线或偏置异常。 +- 读数超出 `0V ~ 12V`:报告异常并检查硬件,不能用软件裁剪后假装正常。 + +`i2cdetect` 只用于首次诊断,不要在长期读取过程中反复扫描总线。 + +## 8. 主服务集成约束 + +主服务已经按 `WEB-SCREEN-VOLTAGE` / `CONFIG-SCREEN-VOLTAGE` 接入网页电压显示和每台设备独立的软件校准,并按 `WEB-LOW-VOLTAGE-PROTECTION`、`CONFIG-LOW-VOLTAGE-PROTECTION`、`DISPLAY-LOW-VOLTAGE-PROTECTION` 接入可选保护。实现必须继续满足: + +- ADC 缺失或读取失败不能拖垮显示服务。 +- 只有一个组件负责持续读取设备,其他组件读取缓存值,避免并发争用 I²C。 +- 原始电压、校正系数、采样时间和错误状态要可区分,不能把读取失败伪装成 `0V`。 +- 低电压保护阈值来自本项目真实屏幕和供电试验:校准后、未舍入的组中位数严格大于 `4.8V` 不限制,`4.5..4.8V` 使用 `floor(50 + 50 × (V-4.5)/0.3)` 限制实际亮度,严格低于 `4.5V` 进入 35% 低电图标;不能从 ADC 量程另行推导或改写边界。35% 来自覆盖保护膜后的红色可视性复测。 +- 电压正常不等于屏幕状态正常;界面文案必须称为“屏幕输入电压”,不能称为“屏幕亮度传感器”。 +- 正常轮询与校准专用采样都必须经过同一个监测组件,不能让 API 请求和后台线程并发打开设备。 +- 校准使用同一测量点的可靠万用表参考值,只做单点比例修正;更换 ADC、接线或测量点后恢复 `1.0` 并重新校准。 +- 每组继续取 `5` 个 15 SPS 新样本中位数。保护关闭或稳定高于 `4.8V` 时组起始间隔为 `5s`;高于阈值但持续下降时按预计到达阈值缩短为 `1..5s`;限亮为 `1s`;首次临界为 `0.5s`,之后按 `clamp(0.5, 2.0, 0.02/max(|dV/dt|, 0.01))` 自适应,放慢每轮最多增加 `0.5s`、加速立即生效;错误按 `1/2/5s` 退避。 +- 每次 I²C 尝试使用单调序号并拒绝晚到旧结果。更严格保护一组立即发布,任何放宽需连续两组成功样本;失败取消待放宽,首次尚无样本不锁屏,已有成功样本后保持最后保护并标记 stale。 +- 保护回调必须在监测锁和 I²C 锁外执行;回调错误与 ADC 状态分开,不能污染样本或解除保护,并要在后续周期重试。完整网页、配置和显示契约以 `WEB-LOW-VOLTAGE-PROTECTION`、`CONFIG-LOW-VOLTAGE-PROTECTION`、`DISPLAY-LOW-VOLTAGE-PROTECTION` 为准。 + +## 9. 浏览器救援条件 + +以下情况才进入浏览器: + +- 实物不是 `U013-V11` 或 PCB 修订发生变化。 +- 地址不是 `0x48`。 +- 本地 PDF 损坏或与实物丝印冲突。 +- 需要确认 ADS1110 电气极限、时序或未在本文覆盖的模式。 + +优先访问官方来源: + +- M5Stack: +- v1.1 原理图: +- TI ADS1110: +- 核桃派项目约定:使用系统已有的 `/dev/i2c-1`,不运行其他开发板专用配置工具 + +浏览时使用 `U013-V11 ADS1110` 作为组合关键词,并记录所用来源和日期。论坛、商家详情和旧 `M5-ADS1100` 代码只能作为线索,不能覆盖原理图与 TI 数据手册。 diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/README.md b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/README.md new file mode 100644 index 0000000..2548d4a --- /dev/null +++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/README.md @@ -0,0 +1,11 @@ +# M5Stack Unit ADC v1.1 资料入口 + +本目录只适用于 `M5Stack Unit ADC v1.1`、SKU `U013-V11`、ADC 芯片 `ADS1110`。不要使用旧版 Unit ADC、ADS1100 示例或其他 ADS111x 寄存器协议替代。 + +- 人工接线与首次上电:[核桃派ZeroW_Unit_ADC接线与首次上电检查.md](核桃派ZeroW_Unit_ADC接线与首次上电检查.md) +- 协议、换算、错误与主服务边界:[02_ADS1110使用与开发约定_给Codex.md](02_ADS1110使用与开发约定_给Codex.md) +- 标准库独立脚本:[示例程序/read_unit_adc_v1_1.py](示例程序/read_unit_adc_v1_1.py) +- 官方资料与 SHA-256:[官方资料/来源索引.md](官方资料/来源索引.md) + +固定事实:总线 `/dev/i2c-1`,7 位地址 `0x48`,配置低 7 位 `0x0C`,15 SPS、PGA=1。模块并联测量屏幕输入端电压,不承载屏幕电流。 + diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/M5Stack_Unit_ADC_v1.1_产品资料.pdf b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/M5Stack_Unit_ADC_v1.1_产品资料.pdf new file mode 100644 index 0000000..2046f3e Binary files /dev/null and b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/M5Stack_Unit_ADC_v1.1_产品资料.pdf differ diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/M5Stack_Unit_ADC_v1.1_原理图.pdf b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/M5Stack_Unit_ADC_v1.1_原理图.pdf new file mode 100644 index 0000000..b5204c8 Binary files /dev/null and b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/M5Stack_Unit_ADC_v1.1_原理图.pdf differ diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/TI_ADS1110_数据手册.pdf b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/TI_ADS1110_数据手册.pdf new file mode 100644 index 0000000..97f02f0 Binary files /dev/null and b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/TI_ADS1110_数据手册.pdf differ diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/来源索引.md b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/来源索引.md new file mode 100644 index 0000000..f9ca2f2 --- /dev/null +++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/官方资料/来源索引.md @@ -0,0 +1,59 @@ +# M5Stack Unit ADC v1.1 官方资料索引 + +下载日期:`2026-07-20`(Asia/Shanghai) + +本目录用于离线接线和开发。PDF 均直接从 M5Stack 或 Texas Instruments 官方地址取得,未修改内容。SHA-256 用于以后判断文件是否被误改或重新下载后是否发生变化。 + +## 本地文件 + +| 本地文件 | 官方来源 | 页数 | 字节数 | SHA-256 | +|---|---|---:|---:|---| +| [`M5Stack_Unit_ADC_v1.1_产品资料.pdf`](M5Stack_Unit_ADC_v1.1_产品资料.pdf) | [M5Stack 产品 PDF](https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/docs/static/pdf/static/zh_CN/unit/Unit-ADC_V1.1.pdf) | 5 | 2,244,061 | `46B43649CB12D297E0F4D35A7575B76E6D83643838876892B84A8D14CF34E49A` | +| [`M5Stack_Unit_ADC_v1.1_原理图.pdf`](M5Stack_Unit_ADC_v1.1_原理图.pdf) | [M5Stack v1.1 原理图](https://m5stack-doc.oss-cn-shenzhen.aliyuncs.com/576/Sch_UNIT_ADC_v1.1.pdf) | 1 | 95,462 | `01BA1309BB599D97125128CD398F2A364CFEBCEFA9BE1B0258BAD2FB0CB89F79` | +| [`TI_ADS1110_数据手册.pdf`](TI_ADS1110_数据手册.pdf) | [TI ADS1110 数据手册](https://www.ti.com/lit/ds/symlink/ads1110.pdf) | 26 | 858,090 | `1E15543D347DBF9E119A8DEA59D5154ED0A76DCFEFDAC9032693808C05BCE09D` | + +重新核验哈希时,在 PowerShell 中运行: + +```powershell +Get-ChildItem -LiteralPath ".\硬件相关资料和硬件的连接\M5Stack Unit ADC v1.1相关内容\官方资料" -Filter *.pdf | + Get-FileHash -Algorithm SHA256 +``` + +## 资料职责和优先级 + +1. **v1.1 原理图**:确认板级接线、`510kΩ/100kΩ` 分压、LM358 缓冲、I²C 上拉及测量负端与 GND 的关系。 +2. **TI ADS1110 数据手册**:确认寄存器位、15/30/60/240 SPS、PGA、二进制补码、三字节读取和电气极限。 +3. **M5Stack 产品资料**:确认产品 SKU、包装、线色、输入范围、地址和机械尺寸。 +4. **网页或示例代码**:只作补充,不能覆盖原理图和 TI 数据手册。 + +## M5Stack 页面中的已知冲突 + +截至下载日期,M5Stack Unit ADC v1.1 页面正文写明 `ADS1110` 和 `15/30/60/240 SPS`,但同一页面的部分规格/比较表仍出现 `ADS1100` 或 `8/16/32/128 SPS`,其 Arduino 链接也指向旧 `M5-ADS1100` 示例。 + +本项目固定使用: + +- 芯片:`ADS1110` +- 配置:连续转换、15 SPS、PGA=1 +- 配置字节:`0x0C` +- 读取:输出高字节、输出低字节、配置字节 + +不要把旧示例中的 `RATE_8`、`RATE_16`、`RATE_32`、`RATE_128` 名称或 `M5_ADS1100.h` 当成 v1.1 的权威定义。 + +## 浏览器后路 + +平时先使用本地 PDF。仅在下列情况进入浏览器: + +- 实物不是 `U013-V11`,或 PCB 丝印/端子与本地原理图不同。 +- `i2cdetect -y 1` 发现的地址不是 `0x48`。 +- 本地 PDF 无法打开或哈希不符。 +- 官方修订了硬件、原理图或 ADS1110 数据手册。 +- 需要查询本文档未覆盖的核桃派 I²C 系统配置。 + +优先网址: + +- M5Stack 在线产品页: +- M5Stack 官方硬件资料入口: +- TI ADS1110 产品资料: +- 核桃派 I²C 以本项目已经核验的 `/dev/i2c-1`、物理脚 3/5 和设备树为准;不得套用其他开发板的配置命令。 + +浏览器检索时组合使用 `M5Stack Unit ADC v1.1 U013-V11 ADS1110`。只搜索 `M5Stack ADC` 很容易进入旧 ADS1100 页面。若官方资料发生变化,记录访问日期、URL 和实物修订后再更新本地文件及哈希,不静默覆盖现有资料。 diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/核桃派ZeroW_Unit_ADC接线与首次上电检查.md b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/核桃派ZeroW_Unit_ADC接线与首次上电检查.md new file mode 100644 index 0000000..23d0777 --- /dev/null +++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/核桃派ZeroW_Unit_ADC接线与首次上电检查.md @@ -0,0 +1,66 @@ +# 核桃派 ZeroW + Unit ADC v1.1 接线与首次上电检查 + +适用实物:核桃派 ZeroW、已经接好并通过基础画面测试的 HUB75 屏、`M5Stack Unit ADC v1.1 / U013-V11 / ADS1110`。本模块只并联测量屏幕输入端 5V,不测电流。 + +## 接线前必须断电 + +> 拔掉核桃派 USB-C,并关闭或拔掉屏幕独立 5V 电源。屏幕、核桃派和 Unit ADC 全部无电后才能插拔或移动任何线。 + +- 不拆改已经验证通过的 HUB75 信号线。 +- 不把屏幕 5V 接到核桃派物理脚 2 或 4,不通过核桃派排针给屏幕供电。 +- 不让屏幕的大电流经过 Unit ADC、Grove 线或 VIN 测量线。 +- 不使用物理脚 32;它仍然保留。 +- ADC 输入不得超过模块官方 `0..12V` 范围,当前只测约 5V。 + +## 六个连接点 + +Unit ADC 有两组不同职责的连接:Grove 四线负责模块供电与 I²C;VIN 两线只负责并联测量。两组线都接到同一套屏幕输入 5V/GND,但不能省略或混淆。 + +| 模块端 | 常见线色 | 连接位置 | 用途 | +|---|---|---|---| +| Grove `SDA` | 黄 | 核桃派物理脚 `3` / `PI8` / `SDA1` | I²C 数据 | +| Grove `SCL` | 白 | 核桃派物理脚 `5` / `PI7` / `SCL1` | I²C 时钟 | +| Grove `5V` | 红 | 屏幕输入端所用的同一稳定系统 `5V` | 只给 Unit ADC 供电 | +| Grove `GND` | 黑 | 屏幕输入端所用的同一系统 `GND` | 模块供电地与 I²C 共地 | +| 测量 `VIN+` | 按端子丝印 | 并联到屏幕电源输入端的 `5V` 测试点 | 被测正端 | +| 测量 `VIN-/GND` | 按端子丝印 | 并联到同一屏幕电源输入端的 `GND` | 被测负端 | + +不要只根据跳线颜色猜测 VIN 正负;VIN 端必须以模块外壳、PCB 或端子丝印为准。Grove 线色若与表格不同,也必须以模块和线束标注的 `SDA/SCL/5V/GND` 为准。 + +## 并联关系 + +```text +屏幕电源 5V ────────┬────────> 屏幕 5V 输入(原大电流路径不变) + ├────────> Unit ADC Grove 红线 5V + └────────> Unit ADC VIN+ + +屏幕电源 GND ───────┬────────> 屏幕 GND 输入(原大电流路径不变) + ├────────> Unit ADC Grove 黑线 GND + └────────> Unit ADC VIN-/GND + +核桃派物理脚 3 ──────────────> Unit ADC 黄线 SDA +核桃派物理脚 5 ──────────────> Unit ADC 白线 SCL +``` + +## 断电状态下逐项检查 + +- [ ] 模块丝印确认为 `U013-V11`,芯片方案为 `ADS1110`。 +- [ ] 核桃派 USB-C 已拔,屏幕独立 5V 已关闭或拔掉。 +- [ ] 黄线 `SDA → 物理脚 3`,白线 `SCL → 物理脚 5`,没有对调。 +- [ ] 红线只接系统 5V,黑线只接系统 GND,没有反接。 +- [ ] `VIN+` 接屏幕输入端 5V,`VIN-/GND` 接同一点的 GND。 +- [ ] 屏幕原有粗电源线保持直接供电,任何负载电流都不经过 ADC。 +- [ ] HUB75 信号接线保持原样,物理脚 32 仍空置。 +- [ ] 所有裸露导体固定,无短路、松脱或碰到金属背板的风险。 + +完成以上检查后仍然保持断电,并回复:`ADC 接线完成,可上电检查`。得到下一步指令前不要自行上电。 + +## 上电后由 Codex 逐项检查 + +1. 先确认核桃派和服务恢复在线,不立即校准。 +2. 确认 `/dev/i2c-1` 仍存在,地址 `0x48` 有响应。 +3. 检查配置低 7 位为 `0x0C`,取得 5 个新样本中位数。 +4. 确认未校准电压合理且稳定,网页故障状态自动恢复。 +5. 再单独暂停,请用户把万用表并联到同一个屏幕输入 5V/GND 点并只提供一个稳定读数。 + +任一步出现无响应、持续负 raw、短读、配置不符、异常发热或显示故障,都先断电并只检查对应线组,不跨过失败项。 diff --git a/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py new file mode 100644 index 0000000..a5a4441 --- /dev/null +++ b/硬件相关资料和硬件的连接/M5Stack Unit ADC v1.1相关内容/示例程序/read_unit_adc_v1_1.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""读取 M5Stack Unit ADC v1.1 (U013-V11 / ADS1110) 的屏幕输入电压。 + +真实 I2C 读取仅支持 Linux;--self-test 不访问硬件,可在 Windows 上运行。 +本脚本只使用 Python 标准库,不接入项目主服务。 +""" + +from __future__ import annotations + +import argparse +import errno +import math +import os +import statistics +import sys +import time +from dataclasses import dataclass +from typing import Sequence + + +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 + + +class AdcError(RuntimeError): + """用户可处理的 ADC 访问错误。""" + + +class AdcProtocolError(AdcError): + """ADS1110 返回的数据不符合本项目固定协议。""" + + +@dataclass(frozen=True) +class Reading: + raw_median: float + volts: float + config: int + + +def parse_address(value: str) -> int: + try: + address = int(value, 0) + except ValueError as exc: + raise argparse.ArgumentTypeError("I2C 地址应为 0x48 这类整数") from exc + if not 0x08 <= address <= 0x77: + raise argparse.ArgumentTypeError("7 位 I2C 地址必须在 0x08 到 0x77 之间") + return address + + +def positive_int(value: str) -> int: + try: + result = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("应输入正整数") from exc + if result <= 0: + raise argparse.ArgumentTypeError("应输入大于 0 的整数") + return result + + +def positive_float(value: str) -> float: + try: + result = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("应输入正数") from exc + if not math.isfinite(result) or result <= 0: + raise argparse.ArgumentTypeError("应输入大于 0 的有限数值") + return result + + +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("calibration_factor 必须是大于 0 的有限数值") + adc_volts = float(raw) * REFERENCE_VOLTS / ADC_CODE_SCALE + return adc_volts * BOARD_DIVIDER_RATIO * calibration_factor + + +def validate_config(config: int) -> None: + if not 0 <= config <= 0xFF: + raise AdcProtocolError(f"配置字节超出范围: {config!r}") + actual = config & CONFIG_VALUE_MASK + if actual != CONFIG_CONTINUOUS_15SPS_PGA1: + raise AdcProtocolError( + "ADS1110 配置不符合连续转换/15SPS/PGA=1: " + f"收到 0x{config:02X},低 7 位应为 0x{CONFIG_CONTINUOUS_15SPS_PGA1:02X}" + ) + + +def decode_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] + validate_config(config) + return raw, config + + +def describe_os_error(action: str, exc: OSError, address: int) -> AdcError: + remote_io = getattr(errno, "EREMOTEIO", 121) + if exc.errno in (remote_io, errno.ENXIO): + return AdcError( + f"{action}失败:I2C 地址 0x{address:02X} 没有响应;" + "请检查 v1.1 型号、5V/GND、SDA/SCL,并先运行 i2cdetect -y 1" + ) + if exc.errno == errno.EBUSY: + return AdcError( + f"{action}失败:I2C 地址 0x{address:02X} 已被内核驱动占用;" + "如果 i2cdetect 显示 UU,请先查明占用原因" + ) + if exc.errno in (errno.EACCES, errno.EPERM): + return AdcError( + f"{action}失败:没有访问 I2C 设备的权限;请检查 /dev/i2c-1 权限和当前用户的 i2c 组" + ) + return AdcError(f"{action}失败:{exc.strerror or exc} (errno={exc.errno})") + + +class Ads1110Device: + def __init__(self, bus_path: str, address: int) -> None: + self.bus_path = bus_path + self.address = address + self._fd: int | None = None + + def __enter__(self) -> "Ads1110Device": + if os.name != "posix": + raise AdcError("真实 I2C 读取只支持 Linux;在 Windows 上请使用 --self-test") + + try: + import fcntl + except ImportError as exc: + raise AdcError("当前 Python 缺少 Linux fcntl,无法访问 /dev/i2c-* 设备") from exc + + try: + self._fd = os.open(self.bus_path, os.O_RDWR) + except FileNotFoundError as exc: + raise AdcError( + f"没有找到 {self.bus_path};请确认核桃派 I2C1 设备节点和总线编号" + ) from exc + except PermissionError as exc: + raise AdcError( + f"没有权限打开 {self.bus_path};请检查设备权限和当前用户的 i2c 组" + ) from exc + except OSError as exc: + raise describe_os_error(f"打开 {self.bus_path}", exc, self.address) from exc + + try: + fcntl.ioctl(self._fd, I2C_SLAVE, self.address) + except OSError as exc: + self.close() + raise describe_os_error("选择 I2C 从设备", exc, self.address) 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 AdcError("I2C 设备尚未打开") + return self._fd + + def configure(self) -> None: + fd = self._require_fd() + try: + written = os.write(fd, bytes([CONFIG_CONTINUOUS_15SPS_PGA1])) + except OSError as exc: + raise describe_os_error("写入 ADS1110 配置", exc, self.address) from exc + if written != 1: + raise AdcProtocolError(f"ADS1110 配置应写入 1 字节,实际写入 {written} 字节") + + def read_fresh(self) -> tuple[int, int]: + fd = self._require_fd() + deadline = time.monotonic() + FRESH_DATA_TIMEOUT_SECONDS + + while True: + time.sleep(CONVERSION_WAIT_SECONDS) + try: + data = os.read(fd, 3) + except OSError as exc: + raise describe_os_error("读取 ADS1110", exc, self.address) from exc + + raw, config = decode_conversion(data) + if (config & DATA_READY_MASK) == 0: + return raw, config + if time.monotonic() >= deadline: + raise AdcProtocolError("等待 ADS1110 新转换数据超时,ST/DRDY 始终为 1") + + +def take_reading( + device: Ads1110Device, + sample_count: int, + calibration_factor: float, +) -> Reading: + raw_values: list[int] = [] + last_config = CONFIG_CONTINUOUS_15SPS_PGA1 + for _ in range(sample_count): + raw, last_config = device.read_fresh() + raw_values.append(raw) + + raw_median = float(statistics.median(raw_values)) + return Reading( + raw_median=raw_median, + volts=voltage_from_raw(raw_median, calibration_factor), + config=last_config, + ) + + +def format_raw(raw: float) -> str: + if raw.is_integer(): + return str(int(raw)) + return f"{raw:.1f}" + + +def print_reading(reading: Reading) -> None: + print( + f"屏幕输入端电压: {reading.volts:.3f} V " + f"raw中位数: {format_raw(reading.raw_median)} " + f"配置: 0x{reading.config:02X}", + flush=True, + ) + if reading.volts < 0 or reading.volts > 12: + print( + "警告:读数超出模块规定的 0V~12V 输入范围,请停止并检查型号、接线和测量点。", + file=sys.stderr, + flush=True, + ) + + +def require_self_test(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def run_self_test() -> None: + require_self_test(CONFIG_CONTINUOUS_15SPS_PGA1 == 0x0C, "固定配置字节不是 0x0C") + require_self_test(voltage_from_raw(0) == 0.0, "raw=0 的换算结果不是 0V") + + raw_5v, config = decode_conversion(bytes([0x33, 0x3B, 0x0C])) + require_self_test(raw_5v == 13115, "5V 样例 raw 解析错误") + require_self_test(config == 0x0C, "5V 样例配置解析错误") + require_self_test( + abs(voltage_from_raw(raw_5v) - 5.0) < 0.001, + "raw=13115 没有换算为约 5.000V", + ) + + negative_raw, config_with_old_data = decode_conversion(bytes([0xFF, 0xFF, 0x8C])) + require_self_test(negative_raw == -1, "16 位负数符号解析错误") + require_self_test(config_with_old_data == 0x8C, "ST/DRDY 状态位解析错误") + + require_self_test( + abs(voltage_from_raw(32767) - 12.49241875) < 1e-9, + "正满量程换算错误", + ) + + try: + decode_conversion(bytes([0x00, 0x00])) + except AdcProtocolError: + pass + else: + raise AssertionError("短读取必须触发 AdcProtocolError") + + try: + validate_config(0x08) + except AdcProtocolError: + pass + else: + raise AssertionError("错误采样配置必须触发 AdcProtocolError") + + print("自测通过:配置 0x0C = 连续转换 / 15SPS / PGA=1") + print(f"自测通过:raw=13115 -> {voltage_from_raw(13115):.6f}V(约 5.000V)") + print("自测通过:16 位负数符号、满量程、短读取和错误配置检查正常") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="读取 M5Stack Unit ADC v1.1 (ADS1110) 的屏幕输入端电压" + ) + parser.add_argument("--bus", default=DEFAULT_BUS, help=f"I2C 设备路径,默认 {DEFAULT_BUS}") + parser.add_argument( + "--address", + type=parse_address, + default=DEFAULT_ADDRESS, + help="7 位 I2C 地址,默认 0x48", + ) + parser.add_argument( + "--samples", + type=positive_int, + default=5, + help="每次输出使用的新样本数量,取中位数,默认 5", + ) + parser.add_argument( + "--calibration-factor", + type=positive_float, + default=1.0, + help="可选比例校正系数,默认 1.0", + ) + parser.add_argument("--watch", action="store_true", help="连续读取,按 Ctrl+C 停止") + parser.add_argument( + "--interval", + type=positive_float, + default=1.0, + help="--watch 两次输出之间的最短秒数,默认 1.0", + ) + parser.add_argument( + "--self-test", + action="store_true", + help="只测试协议常量和换算,不访问 I2C,可在 Windows 运行", + ) + return parser + + +def run_hardware(args: argparse.Namespace) -> None: + with Ads1110Device(args.bus, args.address) as device: + device.configure() + while True: + started = time.monotonic() + print_reading(take_reading(device, args.samples, args.calibration_factor)) + if not args.watch: + return + elapsed = time.monotonic() - started + time.sleep(max(0.0, args.interval - elapsed)) + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.self_test: + run_self_test() + return 0 + + try: + run_hardware(args) + except KeyboardInterrupt: + print("\n已停止。") + return 0 + except (AdcError, ValueError) as exc: + print(f"错误:{exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/硬件相关资料和硬件的连接/点阵屏幕相关资料/屏幕型号和参考网站.txt b/硬件相关资料和硬件的连接/点阵屏幕相关资料/屏幕型号和参考网站.txt new file mode 100644 index 0000000..a683749 --- /dev/null +++ b/硬件相关资料和硬件的连接/点阵屏幕相关资料/屏幕型号和参考网站.txt @@ -0,0 +1,2 @@ +屏幕型号:RGB-Matrix-P3-64x64-F +参考网站:https://docs.waveshare.net/RGB-Matrix-Px-64x64?variant=P3-64x64-F&spec=P3-64x64-F \ No newline at end of file diff --git a/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.docx b/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.docx new file mode 100644 index 0000000..683e1f2 Binary files /dev/null and b/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.docx differ diff --git a/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md b/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md new file mode 100644 index 0000000..917e270 --- /dev/null +++ b/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线与首次上电检查.md @@ -0,0 +1,88 @@ +# 核桃派 ZeroW + 64×64 HUB75 屏接线与首次上电检查 + +适用范围:一块核桃派 ZeroW、一块 `RGB-Matrix-P3-64x64-F`(64×64、1/32 扫描、ABCDE)、一套独立 5V 屏幕电源。本阶段**不连接 Unit ADC**。 + +## 先看安全边界 + +> **接线前必须让核桃派和屏幕全部断电。** 拔掉核桃派 USB-C 供电,关闭或拔掉屏幕 5V 电源。不得带电插拔 HUB75、不得把屏幕 5V 接到核桃派 40-pin 的 5V 脚、不得把 5V 接到任何 PI 信号脚。 + +- 核桃派继续用自己的 USB-C 供电。 +- 屏幕用独立、极性正确的 5V 电源直接接屏幕电源端;参考额定能力 5V/4A。 +- 两者只通过 HUB75 GND 和电源负极形成共地;屏幕的大电流不得流经核桃派排针。 +- 只接屏幕标有 `INPUT` / `IN` 的 HUB75 口;`OUTPUT` / `OUT` 本项目不用。 +- 物理脚 3、5 留给后续 ADC,物理脚 32(PI16)保留,本阶段都不接。 + +## HUB75 INPUT 的编号 + +以下编号采用本项目已经核验的 Waveshare HUB75 INPUT 定义,不根据线材颜色猜测: + +```text +HUB75 INPUT(按信号定义核对) + +pin 16 R1 pin 15 G1 pin 14 B1 pin 13 GND +pin 12 R2 pin 11 G2 pin 10 B2 pin 9 E +pin 8 A pin 7 B pin 6 C pin 5 D +pin 4 CLK pin 3 LAT pin 2 OE pin 1 GND +``` + +若插座缺口、丝印或观看方向与上面排布不一致,以屏幕 PCB 的 `R1/G1/.../GND` 信号丝印和 `INPUT` 标记为准,不要强插排线。 + +## 逐根信号接线表 + +| HUB75 pin | 信号 | 核桃派物理脚 | H618 管脚 | 检查用途 | +|---:|---|---:|---|---| +| 16 | `R1` | 29 | `PI0` | 上半屏红 | +| 15 | `G1` | 31 | `PI1` | 上半屏绿 | +| 14 | `B1` | 33 | `PI2` | 上半屏蓝 | +| 13 | `GND` | 34 | `GND` | 第二根信号地 | +| 12 | `R2` | 35 | `PI3` | 下半屏红 | +| 11 | `G2` | 37 | `PI4` | 下半屏绿 | +| 10 | `B2` | 8 | `PI5` | 下半屏蓝 | +| 9 | `E` | 16 | `PI12` | 行地址 bit 4,不能省 | +| 8 | `A` | 10 | `PI6` | 行地址 bit 0 | +| 7 | `B` | 28 | `PI9` | 行地址 bit 1 | +| 6 | `C` | 27 | `PI10` | 行地址 bit 2 | +| 5 | `D` | 15 | `PI11` | 行地址 bit 3 | +| 4 | `CLK` | 38 | `PI13` | 移位时钟 | +| 3 | `LAT` | 36 | `PI15/GPIO` | GPIO 锁存 | +| 2 | `OE` | 40 | `PI14/PWM4` | 硬件定时输出使能,低有效 | +| 1 | `GND` | 30 | `GND` | 第一根信号地 | + +## 屏幕独立供电 + +1. 屏幕电源正极只接屏幕电源端 `5V` / `VCC`。 +2. 屏幕电源负极只接屏幕电源端 `GND`。 +3. 核桃派物理脚 30、34 分别接 HUB75 INPUT pin 1、13 的 GND,形成信号共地。 +4. 不把屏幕 5V 接到核桃派物理脚 2 或 4;不从核桃派给屏幕供电。 +5. 电源关闭时用目视或万用表通断档确认没有把屏幕 5V 和 GND 反接。 + +## 断电完成后的逐项检查 + +- [ ] 核桃派 USB-C 已拔,屏幕 5V 电源已关闭或拔掉。 +- [ ] 使用的是屏幕 `INPUT`,不是 `OUTPUT`。 +- [ ] 16 根 HUB75 连接逐行与表格一致,没有按线色猜信号。 +- [ ] `A/B/C/D/E` 五根都已接,尤其 `E → 物理脚 16`。 +- [ ] `CLK/LAT/OE → 物理脚 38/36/40`;特别确认 LAT/OE 不是旧版的相反接法。 +- [ ] 两组 RGB 没有上下半屏或红绿蓝互换。 +- [ ] HUB75 pin 1、13 分别到核桃派 GND 物理脚 30、34。 +- [ ] 屏幕独立 5V 极性正确,且没有接到核桃派 5V 排针。 +- [ ] 物理脚 3、5、32 都保持空置;Unit ADC 完全未接。 +- [ ] 裸露导体没有互碰,线材不会被风扇、屏幕背板或金属外壳夹住。 + +## 首次上电顺序(必须等 Codex 指令) + +本表只配套硬件映射 `walnutpi-pi-bank-pwm-oe-v2`。接线完成后先不要自行长时间点亮。回复“屏幕接线完成,LAT 已接物理脚 36,OE 已接物理脚 40,已按要求检查,可上电测试”。随后按指令:先启动低亮度全黑安全画面,再依次测试全红、全绿、全蓝、全白、四角、关键行、方向、文字、图片和动画。任何一步异常都停在该步,只检查对应线组。 + +## 快速故障对应关系 + +| 现象 | 只优先检查 | +|---|---| +| 上半屏缺某颜色 | `R1/G1/B1` 对应单线 | +| 下半屏缺某颜色 | `R2/G2/B2` 对应单线 | +| 红绿蓝互换 | 六根 RGB 的信号名,不查行地址 | +| 半屏、重复行、行位置错误 | `A/B/C/D/E`,特别是 `E` | +| 画面横向错位或随机数据 | `CLK` 与六根 RGB | +| 整屏不亮或闪一下 | `OE`、`LAT`、共地和屏幕 5V 极性 | +| 随机亮点或抖动 | 共地、线长、接触和 5V 稳定性 | + +失败时整机断电后再动线;不得带电逐根试插。 diff --git a/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线标注图.png b/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线标注图.png new file mode 100644 index 0000000..381b47d Binary files /dev/null and b/硬件相关资料和硬件的连接/点阵屏幕相关资料/手动接线说明/核桃派ZeroW_HUB75_接线标注图.png differ