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=
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 正在读取电压…
+
+
+
+
+
+
+
控制程序已更新
+
当前页面版本已过期。画板仍可编辑和导出,但刷新前不会再向设备或模板提交操作。
+
+
+
+
+
+
+
另一标签页修改了画板草稿
+
请选择载入另一标签页的最新草稿,或明确保留当前标签页的草稿。
+
+
+
+
+
+
+
+
+
+
+
+
正在恢复画板草稿…
+
+
+
+
+
+
+
+
+
+
+
+
+ 动图与当前画板预览
+
+ 已保存内容
+ 当前动图
+
+
![]()
+
请先打开一个动图
+
+ 请先打开一个动图
+
+
+ 新建帧来源
+ 当前画板
+
+
+
+ “从当前画板新建帧”将复制此画面
+
+
+
+
+
+
+
+
+
+
+
+
+
屏幕状态
+ 仅为程序设定值,屏幕实际情况软件无法得知
+
+
+ - 屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)
- -
+ - 亮度(实际亮度会受到电压保护功能限制)
- -
+
+
+
+
+ 纯色测试
+
+
+
+
+
+
+
+
+ 颜色 RGB / HEX
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设备容量
+
+ 设备总空间-
+ 设备可用空间-
+ 当前软件总占用空间-
+ 模板占用空间-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。
+
+
+
+
+
+
+
+
+
+
文字元素
+
新建文字或从列表选择一个元素
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请选择或新建动图。
+
+
+
+
+
+
+
+
+
+
+
+
+
屏幕亮度
+
拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)
+
+
+
+
+
+
+
+
+
+
+
屏幕方向
+
角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。
+
+
+
+
+
+
+
+
+
+
+
+
屏幕扫描刷新率
+
设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
供电电压
+
设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。
+
+
+ - 当前电压
- -
+ - 传感器状态
- 正在读取
+ - 校准状态
- 未校准
+ - 校准时间
- -
+
+
+
+
+
+
+
+
低电压亮度保护
+
开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度
+
+
+
+
+
+
+
+
+
预览刷新间隔
+
设置设备状态和系统设置中当前屏幕画面的刷新速度。
+
+
+
+ 刷新间隔过短,会增加核桃派负载,可能影响系统运行。
+
+
+
+
+
+
+
WiFi设置
+
设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip
+
+
+
+
+
+
+
+
+
+
+
+
网络提示显示
+
设置设备开机后等待多少秒再显示网络连接提示。
+
+
+
+
+
+
+
+
+
+
+
软件版本
+
+
+ - 当前软件版本
- 读取中…
+ - 最新功能更新时间
- 读取中…
+
+
+
+
+ 不要在更新期间断电。
+
+
+
+
+
+
+
+
+
+
+
+
+
性能模式
+
如果使用出现卡顿,可以打开此选项,但会降低续航。
+
+
+
+ 正在读取 CPU 调频状态…
+
+
+
+
+
模板界面同时播放的动图数量
+
不建议开启太多,否则会出现严重的性能问题。
+
+
+
+
+
+
+
+
frp配置
+
此功能通常是远程排查使用,请保证你知道你在做什么。
+
+
+ - 当前配置
- 无
+ - 运行状态
- 关闭
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ",
+ f' \n'
+ " ",
+ 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 = `
+ `;
+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 @@
+
+
+