同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
+9
View File
@@ -1,5 +1,14 @@
# Private device and workstation credentials. Keep the example next to it tracked. # Private device and workstation credentials. Keep the example next to it tracked.
/测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt /测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.txt
/移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json
# Android local toolchain, generated builds and signing material.
/移动端相关内容/安卓app/安卓程序源代码/**/.gradle/
/移动端相关内容/安卓app/安卓程序源代码/**/.kotlin/
/移动端相关内容/安卓app/安卓程序源代码/**/build/
/移动端相关内容/安卓app/安卓程序源代码/**/local.properties
*.jks
*.keystore
# Local secrets and private keys. # Local secrets and private keys.
.env .env
+11 -1
View File
@@ -21,6 +21,10 @@
## 2. 需求与测试编号 ## 2. 需求与测试编号
移动端是独立维护的程序,入口为 `移动端相关内容/README.md`,协作规则见 `移动端相关内容/AGENTS.md`。本轮已授权 Android 首版;未来设备端更新不得自动迭代或发布 App。设备公共接口、协议、驱动边界及部署变化必须在 `移动端相关内容/开发要求/跨端兼容与变更记录.md` 登记影响;对外契约不变的驱动内部修改不要求手机更新。App、设备及协议分别管理版本。正式 APK 签名只在用户明确命令后执行,当前只允许 debug 签名。
移动测试设备真实登记唯一位置为 `移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json`,精确忽略;提交同目录空白 `测试设备.example.json` 和门禁脚本。此登记只存手机测试信息,不复制核桃派或开发机凭据;自动化前检查登记及 ADB 授权,不回显真实设备标识,不覆盖已有登记。
- `WEB-*`:网页、REST、WebSocket、交互和安全入口。 - `WEB-*`:网页、REST、WebSocket、交互和安全入口。
- `DISPLAY-*`:显示服务、帧、方向、字体、动画、mock 与 H618 真实驱动。 - `DISPLAY-*`:显示服务、帧、方向、字体、动画、mock 与 H618 真实驱动。
- `CONFIG-*`:schema、默认值、迁移、持久数据和损坏保护。 - `CONFIG-*`:schema、默认值、迁移、持久数据和损坏保护。
@@ -36,7 +40,9 @@
- 核桃派无负载验证编译、寄存器映射、线程调度、双缓冲、截止统计和异常清屏;在该门槛通过前禁止接屏。 - 核桃派无负载验证编译、寄存器映射、线程调度、双缓冲、截止统计和异常清屏;在该门槛通过前禁止接屏。
- HUB75 实屏测试按全黑、红、绿、蓝、白、四角、关键行、方向、文字、图片、动画逐项执行;上一项失败就停止,只排查对应线组。 - HUB75 实屏测试按全黑、红、绿、蓝、白、四角、关键行、方向、文字、图片、动画逐项执行;上一项失败就停止,只排查对应线组。
- ADC 测试按总线、地址、原始读取、同点万用表校准、正常档、单个低压档、恢复逐项执行,不合并人工动作。 - ADC 测试按总线、地址、原始读取、同点万用表校准、正常档、单个低压档、恢复逐项执行,不合并人工动作。
- 有清晰摄像头时由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时必须等待用户确认。 - 普通网页前端、手机界面、BLE 控制入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收;优先协议、状态、输出帧与驱动统计,不适用时记录“不适用”。
- 仅在驱动、扫描时序、底层输出变化或具体显示异常确需视觉证据时提出视觉检查。必须先停止相关步骤、说明原因、询问用户下一步如何启动,并等待本次明确指示;不得自动启动 DroidCam、摄像头、采集或视觉测试程序,也不得主动切换实屏测试图案。专用测试手机的一般自动化授权不能替代此门禁。
- 用户明确启动后,有清晰摄像头时可由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时仍等待用户确认。
## 4. 人工停顿与电气安全 ## 4. 人工停顿与电气安全
@@ -92,3 +98,7 @@
- 普通开发不检查历史 IMG 是否齐全;只有用户明确要求处理某个镜像时,才核对该任务实际需要的输入文件。官方基线和其他离线依赖按各自规则管理。 - 普通开发不检查历史 IMG 是否齐全;只有用户明确要求处理某个镜像时,才核对该任务实际需要的输入文件。官方基线和其他离线依赖按各自规则管理。
- 仓库内描述工作区文件时使用相对路径或 `<仓库根目录>`、`<受保护临时目录>` 等文字占位符,不固化盘符、Windows 用户目录、macOS 用户目录或开发者 home。核桃派上的 `/opt`、`/var/lib`、`/run`、`/boot`、`/usr/local` 等固定部署路径不属于此限制。 - 仓库内描述工作区文件时使用相对路径或 `<仓库根目录>`、`<受保护临时目录>` 等文字占位符,不固化盘符、Windows 用户目录、macOS 用户目录或开发者 home。核桃派上的 `/opt`、`/var/lib`、`/run`、`/boot`、`/usr/local` 等固定部署路径不属于此限制。
- 正式 IMG 通过仓库外的发行渠道保存,根 `.gitignore` 必须忽略所有 `*.img`;Git 只保留对应 README、manifest、SHA-256、发布记录等元数据,不得让源码、文档命令或后续构建依赖仓库中存在 IMG。OTA、离线依赖、编辑器程序和历史归档仍由私有 Git 仓库保留,大体积二进制可使用 Git LFS;秘密、本机状态、运行数据和可再生缓存继续按各自规则排除。 - 正式 IMG 通过仓库外的发行渠道保存,根 `.gitignore` 必须忽略所有 `*.img`;Git 只保留对应 README、manifest、SHA-256、发布记录等元数据,不得让源码、文档命令或后续构建依赖仓库中存在 IMG。OTA、离线依赖、编辑器程序和历史归档仍由私有 Git 仓库保留,大体积二进制可使用 Git LFS;秘密、本机状态、运行数据和可再生缓存继续按各自规则排除。
## 9. 测试经验与交付反馈
每轮测试完成后,将测试中遇到的问题及已验证解法补到对应可重复测试流程、排障指南或构建文档,防止后续重复试错;单次失败证据、恢复及重测结果留在测试归档。已知问题先查已有经验,不把未证实推测固化成根因。最终反馈说明经验更新位置和仍未解决的边界;没有新增经验时沿用已有记录,不重复复制。文档不得包含真实凭据、测试设备标识或开发机绝对路径。
+1
View File
@@ -6,6 +6,7 @@
- `核桃派软件源代码/`:FastAPI 服务、网页、H618 HUB75 原生驱动、部署与发布脚本。 - `核桃派软件源代码/`:FastAPI 服务、网页、H618 HUB75 原生驱动、部署与发布脚本。
- `整体开发需求/`:带稳定编号的功能和部署需求。 - `整体开发需求/`:带稳定编号的功能和部署需求。
- `移动端相关内容/`:独立 KMP 手机控制器需求、BLE 协议、Android 源码/测试/交付;入口见该目录 README,当前进度见 Android 测试结果归档。
- `测试相关资料/如何测试/`:本机、板端、实屏与 ADC 的分层测试流程。 - `测试相关资料/如何测试/`:本机、板端、实屏与 ADC 的分层测试流程。
- `硬件相关资料和硬件的连接/`:核桃派、HUB75 和 ADS1110 接线资料。 - `硬件相关资料和硬件的连接/`:核桃派、HUB75 和 ADS1110 接线资料。
- `发布更新相关/`:官方基线 IMG、正式导出包、OTA、离线依赖和镜像编辑器。 - `发布更新相关/`:官方基线 IMG、正式导出包、OTA、离线依赖和镜像编辑器。
@@ -0,0 +1,13 @@
# 奇妙小屏幕控制器 OTA 1.1.2
- 软件版本:`1.1.2`
- 导出时间:`2026-09-26T16:59:03+08:00`
- 说明:发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。
- 产物:`matrix-screen-controller-1.1.2.ota`
- 包类型:应用更新包
- 前置系列基线:`1.1.0`
在系统设置上传 OTA;每个 minor 的 .0 必须按顺序安装,同系列补丁允许跳过。1.1.0 含 frpc 0.71.0 离线组件;已有完整组件会跳过,否则修复并保留配置和启停状态。初次安装默认关闭。禁止跳过失败测试;失败自动恢复。
本目录是可删除的发布产物,不得被源码、脚本或后续版本引用。
@@ -0,0 +1,13 @@
{
"format_version": 2,
"artifact_type": "ota",
"software_version": "1.1.2",
"created_at": "2026-09-26T16:59:03+08:00",
"notes": "发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。",
"artifact": "matrix-screen-controller-1.1.2.ota",
"artifact_bytes": 27005144,
"artifact_sha256": "8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f",
"package_kind": "application",
"required_checkpoint": "1.1.0",
"is_checkpoint": false
}
@@ -0,0 +1 @@
8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f matrix-screen-controller-1.1.2.ota
@@ -13,7 +13,11 @@
"path": "debian12-aarch64", "path": "debian12-aarch64",
"reason": "媒体导入需要 ffmpeg、ffprobe、zscale、tonemap 和 heif-convert", "reason": "媒体导入需要 ffmpeg、ffprobe、zscale、tonemap 和 heif-convert",
"source": "清华 TUNA 的 Debian bookworm、bookworm-updates 与 bookworm-security AArch64 镜像;仓库索引和包均校验 SHA-256", "source": "清华 TUNA 的 Debian bookworm、bookworm-updates 与 bookworm-security AArch64 镜像;仓库索引和包均校验 SHA-256",
"roots": ["ffmpeg", "libheif-examples", "python3.11-venv"], "roots": [
"ffmpeg",
"libheif-examples",
"python3.11-venv"
],
"package_count": 84, "package_count": 84,
"baseline": "debian12-aarch64/BASE_IMAGE_PACKAGES.tsv", "baseline": "debian12-aarch64/BASE_IMAGE_PACKAGES.tsv",
"resolution": "debian12-aarch64/RESOLUTION.json", "resolution": "debian12-aarch64/RESOLUTION.json",
@@ -45,6 +49,17 @@
"introduced_in": "1.1.0", "introduced_in": "1.1.0",
"development_baseline": "1.0.6 调试设备已预装;正式旧版本不包含此组件", "development_baseline": "1.0.6 调试设备已预装;正式旧版本不包含此组件",
"ota_lifecycle": "仅软件安装节点携带二进制,普通补丁校验已安装组件;事务失败恢复原文件与状态" "ota_lifecycle": "仅软件安装节点携带二进制,普通补丁校验已安装组件;事务失败恢复原文件与状态"
},
{
"id": "mobile-ble-python",
"path": "aarch64-py311",
"reason": "BLE D-Bus GATT 与免配对应用层加密",
"packages": [
"dbus-next==0.2.3",
"cryptography==46.0.5"
],
"source": "PyPI official wheels, SHA256SUMS verified",
"introduced_in": "Android 首版开发,未推进设备发布版本"
} }
], ],
"retired": [] "retired": []
@@ -12,4 +12,6 @@
依赖生命周期以 `DEPENDENCIES.json` 为准。新增运行依赖必须同时加入文件、摘要和用途;删除依赖时删除二进制,并在 `retired` 中记录停用版本和原因。 依赖生命周期以 `DEPENDENCIES.json` 为准。新增运行依赖必须同时加入文件、摘要和用途;删除依赖时删除二进制,并在 `retired` 中记录停用版本和原因。
移动控制器首版新增 `cryptography==46.0.5`、`cffi==2.1.1`、`pycparser==3.0` 和 `dbus-next==0.2.3`,已保存到 `aarch64-py311/` 并登记 SHA-256 与生命周期。前者实现 BLE 临时 ECDH/HKDF/AES-GCM,后者接入系统 BlueZ D-Bus;cffi/pycparser 为依赖闭包。普通开发部署在电脑校验后上传,使用设备 venv 的 pip `--no-index --find-links` 安装,不在核桃派访问 PyPI。蓝牙固件与 hciattach 复用官方板端已有组件,不以安装 Python 包替代适配器实测。
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`,不增加板端下载。 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`,不增加板端下载。
@@ -1,8 +1,11 @@
f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 annotated_types-0.8.0-py3-none-any.whl f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 annotated_types-0.8.0-py3-none-any.whl
9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 anyio-4.14.2-py3-none-any.whl 9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 anyio-4.14.2-py3-none-any.whl
62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 certifi-2026.7.22-py3-none-any.whl 62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 certifi-2026.7.22-py3-none-any.whl
3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 click-8.4.2-py3-none-any.whl e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 click-8.4.2-py3-none-any.whl
4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 colorama-0.4.6-py2.py3-none-any.whl 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 colorama-0.4.6-py2.py3-none-any.whl
c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b dbus_next-0.2.3-py3-none-any.whl
c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565 fastapi-0.116.1-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 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 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 h11-0.16.0-py3-none-any.whl
@@ -14,8 +17,9 @@ f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 iniconfig-2.3.
d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c packaging-26.3-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 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 e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 pluggy-1.6.0-py3-none-any.whl
7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 pycparser-3.0-py3-none-any.whl
45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba pydantic-2.13.4-py3-none-any.whl 45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba pydantic-2.13.4-py3-none-any.whl
7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 pygments-2.20.0-py3-none-any.whl 81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 pygments-2.20.0-py3-none-any.whl
539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 pytest-8.4.1-py3-none-any.whl 539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 pytest-8.4.1-py3-none-any.whl
1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a python_dotenv-1.2.2-py3-none-any.whl 1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a python_dotenv-1.2.2-py3-none-any.whl
@@ -0,0 +1 @@
66a82db730b00f2e3554a1503c946330d14ae103823b8d3242ec5c922049d2b2 11223344.img
+23
View File
@@ -89,6 +89,29 @@
"artifact_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d", "artifact_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d",
"validation_path": "各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json", "validation_path": "各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json",
"validated_at": "2026-09-08T09:09:53+00:00" "validated_at": "2026-09-08T09:09:53+00:00"
},
{
"version": "1.1.2",
"artifact_type": "ota",
"created_at": "2026-09-26T16:59:03+08:00",
"notes": "发布当前工作区设备端更新:BLE 控制与网络配置改进、OTA 内核恢复和运行期保护;包含新增离线 Python 依赖。",
"artifact_path": "发布更新相关/OTA数据包/1.1.2/matrix-screen-controller-1.1.2.ota",
"artifact_sha256": "8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f",
"package_kind": "application",
"required_checkpoint": "1.1.0",
"is_checkpoint": false,
"component_checkpoints": [
{
"version": "1.1.0",
"components": {
"frpc": {
"version": "0.71.0",
"sha256": "6e8e45fd0c7514b636fd8d049212f8a5715e8b33c412cf968988252e7a8a00f2",
"bundle": "frp/0.71.0/linux-arm64"
}
}
}
]
} }
] ]
} }
@@ -2,6 +2,8 @@
本文描述核桃派 ZeroW 在局域网内启动的网页配置端。这个网页是用户后续操作点阵屏幕的主要入口,当前阶段提供可叠加的像素与多文字内容编辑,以及纯色、诊断、清屏等独占设备测试能力。 本文描述核桃派 ZeroW 在局域网内启动的网页配置端。这个网页是用户后续操作点阵屏幕的主要入口,当前阶段提供可叠加的像素与多文字内容编辑,以及纯色、诊断、清屏等独占设备测试能力。
移动端是独立控制入口,设备接入要求见 `03_移动端接入需求.md`,手机需求见 `../移动端相关内容/开发要求/移动端需求.md`。网页布局调整不应影响 BLE 协议;设备功能改动须登记移动端兼容影响,不自动更新 App。
项目默认一台核桃派只做这一件事:开机后自动启动网页服务和屏幕控制服务,同一局域网内的手机、电脑或平板通过 `http://核桃派IP:8080` 访问。 项目默认一台核桃派只做这一件事:开机后自动启动网页服务和屏幕控制服务,同一局域网内的手机、电脑或平板通过 `http://核桃派IP:8080` 访问。
## 0. 需求编号索引 ## 0. 需求编号索引
@@ -81,6 +83,7 @@
| `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 | | `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 |
| `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v10 严格保存整数 `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 | | `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v10 严格保存整数 `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 |
| `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 | | `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 |
| `DEPLOY-OTA-KERNEL-RECOVERY` | OTA 内核能力守护与轻量恢复 | OTA 前后比较内核、cpufreq、性能模式和候选启动标记;正常能力退化时回滚应用。已回退设备可更新应用,只有本机候选内核、DTB、模块及回滚设施通过固定摘要校验时,更新完成后才独立重启尝试恢复一次;失败不得自动重试,缺失或损坏材料只报告需要专用修复包,普通 OTA 不携带内核归档。 |
| `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 | | `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 |
| `WEB-SECURITY` | 局域网安全边界 | 第一阶段默认可信局域网,保留访问密码和上传限制扩展入口。 | | `WEB-SECURITY` | 局域网安全边界 | 第一阶段默认可信局域网,保留访问密码和上传限制扩展入口。 |
| `WEB-ERRORS` | 日志和错误展示 | 后端记录关键事件,前端显示屏幕可用性和最近操作结果。 | | `WEB-ERRORS` | 日志和错误展示 | 后端记录关键事件,前端显示屏幕可用性和最近操作结果。 |
@@ -248,12 +251,12 @@ WantedBy=multi-user.target
生产核桃派只运行本控制器及其必需的网络、监测和系统服务,必须使用可重复、可检查的专用主机配置: 生产核桃派只运行本控制器及其必需的网络、监测和系统服务,必须使用可重复、可检查的专用主机配置:
- 默认启动目标为 `multi-user.target`;LightDM、`display-manager`、Xorg、桌面会话和蓝牙服务必须停用并屏蔽,不得在控制器运行时自动恢复。 - 默认启动目标为 `multi-user.target`;LightDM、`display-manager`、Xorg 和桌面会话必须停用并屏蔽。移动控制器接入后允许必要的 BlueZ 与板载蓝牙初始化服务,按 `DEPLOY-MOBILE-BLE` 同步修改专用检查与部署;未完成该实施步骤前不得只手动解除屏蔽交付。
- 板载音频必须通过 boot 配置关闭,`snd_bcm2835` 必须加入模块黑名单且在控制器启动前确认未加载;WirePlumber、PipeWire 及其用户级 socket 必须全局屏蔽,SSH 登录不得重新拉起音频会话。不得通过关闭矩阵硬件脉冲来兼容板载音频。 - 板载音频必须通过 boot 配置关闭,`snd_bcm2835` 必须加入模块黑名单且在控制器启动前确认未加载;WirePlumber、PipeWire 及其用户级 socket 必须全局屏蔽,SSH 登录不得重新拉起音频会话。不得通过关闭矩阵硬件脉冲来兼容板载音频。
- Wi-Fi、SSH、Avahi、`/dev/i2c-1`、Unit ADC、电压保护和 swap 必须保留;专用化不得修改无线网络、SSH 凭据、持久数据根或用户配置。 - Wi-Fi、SSH、Avahi、`/dev/i2c-1`、Unit ADC、电压保护和 swap 必须保留;专用化不得修改无线网络、SSH 凭据、持久数据根或用户配置。
- `scripts/dedicated_host.py check` 只读报告全部专用条件;`apply` 以幂等方式更新 boot 配置、模块黑名单和 systemd 启用状态,但不得自动重启。boot 配置更新必须原子替换、保留无关内容,不创建多代备份。 - `scripts/dedicated_host.py check` 只读报告全部专用条件;`apply` 以幂等方式更新 boot 配置、模块黑名单和 systemd 启用状态,但不得自动重启。boot 配置更新必须原子替换、保留无关内容,不创建多代备份。
- systemd 在启动应用前执行只读专用主机检查。任一硬件脉冲前置条件不满足时服务必须明确失败并记录原因,不能静默进入画面不稳定的软件脉冲模式。 - systemd 在启动应用前执行只读专用主机检查。任一硬件脉冲前置条件不满足时服务必须明确失败并记录原因,不能静默进入画面不稳定的软件脉冲模式。
- 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;未来确需桌面或蓝牙时,必须先修改本需求和专用主机脚本,再重新执行刷新率、资源负载和完整实屏验收。不得临时取消屏蔽后直接交付。 - 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;恢复桌面或增加蓝牙必须同步需求、专用脚本,并检查刷新率、资源负载和输出帧。普通前端/BLE 入口改动不强制实屏视觉验收;仅驱动、扫描时序、底层输出改变或具体异常确需视觉证据时,先暂停询问用户如何启动,获明确指示后检查。不得自动启动摄像头、DroidCam、采集或视觉程序,也不得主动切换实屏测试图案。
多语言文字部署还必须满足 `DEPLOY-FONTS`: 多语言文字部署还必须满足 `DEPLOY-FONTS`:
@@ -46,6 +46,8 @@
## 1. 设计原则 ## 1. 设计原则
移动端接入契约见 `03_移动端接入需求.md` 和 `../移动端相关内容/开发要求/设备通信协议.md`。手机通过公共控制服务使用本显示层,不能操作网页 DOM 或绕过保护直接访问驱动。驱动或输出契约变化必须登记移动端兼容影响;内部实现变化不等于需要 App 升级。普通前端/BLE 入口改动不强制实屏视觉验收,确需视觉检查须先停下询问用户如何启动并等待指示。
### 1.1 固定帧接口(`DISPLAY-FRAME`) ### 1.1 固定帧接口(`DISPLAY-FRAME`)
项目内部提交到 `DisplayService` 和真实/模拟驱动时,统一使用 `64x64 RGB` 帧作为显示边界。网页场景中的透明文字等 `RGBA` 图层属于上层合成中间产物,必须先按顺序扁平化到 RGB,不能直接交给驱动。 项目内部提交到 `DisplayService` 和真实/模拟驱动时,统一使用 `64x64 RGB` 帧作为显示边界。网页场景中的透明文字等 `RGBA` 图层属于上层合成中间产物,必须先按顺序扁平化到 RGB,不能直接交给驱动。
@@ -0,0 +1,28 @@
# 03 移动端接入需求
本文件定义核桃派侧行为,手机产品需求与协议分别见 `../移动端相关内容/开发要求/移动端需求.md`、`../移动端相关内容/开发要求/设备通信协议.md`。当前是已确认的实施契约,不代表已部署。
| 编号 | 要求 |
|---|---|
| DEPLOY-MOBILE-BLE | 启用必要 BlueZ 和板载蓝牙初始化服务,修改 dedicated_host 的 apply/check 及部署生命周期;不启用桌面或音频。依赖同步离线材料与摘要。 |
| DEVICE-MOBILE-CONTROL | 网页与 BLE 共享公共业务服务;保留显示仲裁、低压保护、持久化和修订冲突;不经网页 DOM/HTTP 回环调用实现手机控制。 |
| DEVICE-MOBILE-SESSION | 协议 1.0、自动加密、单连接/单控制会话、握手和活跃超时、连接暂停及断开恢复广播;无效请求不得执行。 |
| DEVICE-MOBILE-IDENTITY | 持久随机 UUID 和可改昵称;短编号仅用于展示;旧设备升级保留身份,新镜像不得携带构建主机或测试设备身份。 |
| DEVICE-MOBILE-COMPAT | 主版本与能力声明,兼容新增不破坏旧客户端;破坏变更登记移动端待办,不自动更新手机程序。 |
| WEB-MOBILE-PRESENCE | 手机握手成功时网页短提示,连接期间顶栏常驻手机名,断开更新;初次加载只显示当前状态;昵称安全文本渲染。 |
| DEVICE-WIFI-SCAN | NetworkManager 扫描并报告 SSID/安全类型/信号/当前连接,支持手动隐藏 SSID;参数化调用、有界等待、结果脱敏。 |
| DEVICE-MOBILE-SETTINGS | 字段级变更与共享修订,网页也更新修订;旧草稿冲突不静默覆盖。WiFi 保存和连接完成分别报告,不返回已存密码。 |
| DEVICE-MOBILE-LIFECYCLE | 蓝牙异常不拖垮显示服务;服务停启使会话与密钥失效,恢复后重新握手;真实 systemd 验证持久/运行目录边界。 |
| TEST-MOBILE-INTEGRATION | 本机契约、网页回归、真机 BLE、WiFi、双手机、性能和独立重建分别记录;模拟结果不替代物理 BLE。 |
DEVICE-MOBILE-SETTINGS 的网络往返约束:NetworkManager 读回多个 DNS 时须兼容逗号及换行分隔,接口统一返回独立地址数组,读回合法配置可保持密码重新提交。网页与 BLE 复用同一解析规则,不要求 App 为某一 nmcli 输出格式做适配。
DEPLOY-MOBILE-BLE/DEVICE-MOBILE-LIFECYCLE 的升级约束:包含移动端安装脚本的后续 OTA,即使不携带新的 FRP 二进制,也必须进入系统组件事务。修改蓝牙前持久备份 main.conf、首次原配置副本、两个项目 drop-in、两个蓝牙服务的启用/屏蔽及运行状态;失败恢复原内容,删除本次新增文件,并恢复原服务状态。旧事务日志没有移动端字段时保持旧恢复行为。本轮只实现并验证该逻辑,不导出 OTA 或推进发布版本。
设备当前屏幕和 ADC 已接好,不能冒充无负载测试。蓝牙启用前后检查刷新率、截止丢失、资源与输出帧;普通前端/BLE 接入不强制实屏视觉检查。驱动或底层输出改变、出现具体显示异常才提出视觉检查,先暂停询问用户下一步如何启动并等待明确指示;不自动开摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。
测试步骤与映射见 `../测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md` 和移动端的 `安卓app/如何安卓测试/README.md`。本任务只手工部署,不自动导出 IMG/OTA;只有可部署功能修改才更新 FEATURE_UPDATED_AT,文档/登记脚本不改该时间。
## 2026-09-26 接口修订
DEVICE-MOBILE-CONTROL:BLE library.list 与网页共用 LibraryOrderStore 的用户混合顺序,演示条目保持固定位置;顺序参与分页修订。
DEVICE-MOBILE-SETTINGS:WiFi 激活任务可选 error_code 为 AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN;原因来自 NetworkManager 的结构化状态,仅可靠认证证据可报告 AUTH_FAILED,不输出原始命令错误或密码。
+1 -1
View File
@@ -1 +1 @@
2026-09-10T11:10+08:00 2026-09-26T16:47+08:00
+6
View File
@@ -4,6 +4,10 @@
## 路径与运行方式 ## 路径与运行方式
移动端控制器独立维护,入口见 `../移动端相关内容/README.md`;设备侧新增契约见 `../整体开发需求/03_移动端接入需求.md`。设备更新只评估并登记手机兼容影响,不自动迭代 App。BLE 与网页应复用公共业务层;不得让 App 依赖网页布局。当前蓝牙接入尚待实现,既有脚本仍执行原禁用策略,不能将需求文档当成设备已启用蓝牙。
普通前端/BLE 入口改动不新增实屏视觉验收。确需视觉检查时先停止、解释原因、询问用户如何启动并等待指示,不自动开摄像头、DroidCam 或视觉程序,不主动切换实屏测试图案。软件统计和逻辑帧检查按正常自动测试执行。
下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。 下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。
- 程序:`/opt/matrix-screen-controller` - 程序:`/opt/matrix-screen-controller`
@@ -78,6 +82,8 @@ sudo sh scripts/update_walnutpi.sh
## 浏览器全量 OTA ## 浏览器全量 OTA
OTA 会记录更新前的候选内核、cpufreq 和性能模式状态。原本正常的设备若在更新中失去这些能力,应用更新按事务回滚;原本已回退到原内核的设备允许先更新应用。更新完成后,若已安装的候选 Image、两份 DTB、模块树、受管启动脚本和健康服务均与登记材料一致,独立任务仅安排一次候选重启;结果保存在持久根的 `kernel-recovery.json`,通过 `/api/ota/status.kernel_recovery` 查询。失败后不会在后续 OTA 自动重试。缺失或损坏候选材料时只提示需要专用内核修复包,普通 OTA 不携带大型内核归档。
系统设置显示 `VERSION` 中的正式软件版本和 `FEATURE_UPDATED_AT` 中固定的北京时间,并允许上传完整 `.ota` 文件。页面不展示最近 OTA 结果,但上传、安装、断线恢复和失败反馈保持可见;失败后会自动打开可滚动、可复制的完整诊断日志,关闭后仍可重新查看或复制。设备只在 `/var/lib/matrix-screen-controller/ota/last-failure.log` 原子保留最近一份不超过 1 MiB 的脱敏失败日志,下一次成功更新会将其删除;`GET /api/ota/failure-log` 仅在最近结果失败且日志存在时返回 `text/plain`,并禁止缓存。设备不连接更新服务器,不使用增量、加密或签名;包内 SHA-256 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。 系统设置显示 `VERSION` 中的正式软件版本和 `FEATURE_UPDATED_AT` 中固定的北京时间,并允许上传完整 `.ota` 文件。页面不展示最近 OTA 结果,但上传、安装、断线恢复和失败反馈保持可见;失败后会自动打开可滚动、可复制的完整诊断日志,关闭后仍可重新查看或复制。设备只在 `/var/lib/matrix-screen-controller/ota/last-failure.log` 原子保留最近一份不超过 1 MiB 的脱敏失败日志,下一次成功更新会将其删除;`GET /api/ota/failure-log` 仅在最近结果失败且日志存在时返回 `text/plain`,并禁止缓存。设备不连接更新服务器,不使用增量、加密或签名;包内 SHA-256 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。
从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。 从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。
+1 -1
View File
@@ -1 +1 @@
1.1.1 1.1.2
+47
View File
@@ -0,0 +1,47 @@
"""Shared device operations used by REST and mobile adapters."""
from __future__ import annotations
import hashlib
import json
import threading
from app.config.store import validate_config
class DeviceControl:
def __init__(self, store, display, power_monitor, performance_mode):
self.store = store
self.display = display
self.power_monitor = power_monitor
self.performance_mode = performance_mode
self.lock = threading.RLock()
@staticmethod
def revision(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest()
def update_config(self, values):
with self.lock:
config = validate_config({**self.store.config, **values})
if "low_voltage_protection_enabled" in values:
enabled = config["low_voltage_protection_enabled"]
self.store.update({"low_voltage_protection_enabled": enabled})
self.power_monitor.set_protection_enabled(enabled)
if enabled:
self.power_monitor.sample_now()
if "orientation" in values:
self.display.set_orientation(config["orientation"])
if "brightness" in values:
self.display.set_brightness(config["brightness"])
if "matrix_refresh_rate_limit_hz" in values:
self.display.set_refresh_rate_limit(config["matrix_refresh_rate_limit_hz"])
if "performance_mode_enabled" in values:
self.performance_mode.transact(config["performance_mode_enabled"],
lambda enabled: self.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:
self.store.update(remaining)
return self.store.config
+36 -37
View File
@@ -15,6 +15,10 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator
from app.control import DeviceControl
from app.mobile.control import MobileControl
from app.mobile.session import SessionManager
from app.mobile.bluez import BluezRuntime
from app.config.store import ( from app.config.store import (
COLOR_PALETTE_LIMIT, COLOR_PALETTE_LIMIT,
DEFAULT_DISPLAY, DEFAULT_DISPLAY,
@@ -561,6 +565,8 @@ def create_app(
staging_root=ota_staging_root, staging_root=ota_staging_root,
) )
control = DeviceControl(store, display, power_monitor, performance_mode)
def resolve_content(reference: dict[str, str]) -> dict[str, Any]: def resolve_content(reference: dict[str, str]) -> dict[str, Any]:
checked = normalize_default_display(reference) checked = normalize_default_display(reference)
content_type = checked["type"] content_type = checked["type"]
@@ -831,8 +837,9 @@ def create_app(
}, },
) )
@app.get("/api/status") mobile_runtime = None
def get_status() -> dict:
def read_device_status() -> dict:
status = display.get_status() status = display.get_status()
status["service"] = { status["service"] = {
"app_version": app.state.app_version, "app_version": app.state.app_version,
@@ -855,8 +862,25 @@ def create_app(
store.config["performance_mode_enabled"] store.config["performance_mode_enabled"]
), ),
} }
status["mobile"] = mobile_runtime.status() if mobile_runtime else {"available": False, "connected": False, "reason": "initialization_failed"}
return status return status
try:
mobile = MobileControl(control, network, templates, animations, resolve_content,
apply_resolved_content, effective_default_content, set_default_content,
read_device_status, storage_monitor.get_status, library_order=library_order)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity)
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
app.state.mobile_control = mobile
app.state.mobile_sessions = mobile_sessions
except Exception:
logger.exception("Mobile control initialization failed; display remains available")
app.state.mobile_runtime = mobile_runtime
@app.get("/api/status")
def get_status() -> dict:
return read_device_status()
@app.get("/api/ota/status") @app.get("/api/ota/status")
def get_ota_status() -> dict: def get_ota_status() -> dict:
return { return {
@@ -1123,41 +1147,7 @@ def create_app(
def put_config(update: ConfigUpdate) -> dict: def put_config(update: ConfigUpdate) -> dict:
values = update.model_dump(exclude_none=True) values = update.model_dump(exclude_none=True)
try: try:
config = validate_config({**store.config, **values}) return control.update_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: except (ConfigError, PerformanceModeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@@ -2023,6 +2013,8 @@ def create_app(
@app.on_event("startup") @app.on_event("startup")
def startup() -> None: def startup() -> None:
if mobile_runtime:
mobile_runtime.start()
performance_mode.start(store.config["performance_mode_enabled"]) performance_mode.start(store.config["performance_mode_enabled"])
media_imports.start() media_imports.start()
power_monitor.sample_now() power_monitor.sample_now()
@@ -2034,8 +2026,13 @@ def create_app(
network.start() network.start()
except Exception: except Exception:
logger.exception("Failed to restore normal content after OTA completion") logger.exception("Failed to restore normal content after OTA completion")
try:
ota.schedule_kernel_recovery_after_ota()
except Exception:
logger.exception("Failed to evaluate kernel recovery after OTA completion")
ota.set_completion_callback(restore_after_ota) ota.set_completion_callback(restore_after_ota)
ota.finalize_kernel_recovery_after_boot()
if ota.resume_or_start_monitor(restore_after_ota): if ota.resume_or_start_monitor(restore_after_ota):
logger.warning("OTA transaction is active; keeping the update indicator visible") logger.warning("OTA transaction is active; keeping the update indicator visible")
elif maintenance_black: elif maintenance_black:
@@ -2046,6 +2043,8 @@ def create_app(
@app.on_event("shutdown") @app.on_event("shutdown")
def shutdown() -> None: def shutdown() -> None:
if mobile_runtime:
mobile_runtime.close()
logger.info("Shutting down; clearing display") logger.info("Shutting down; clearing display")
media_imports.stop() media_imports.stop()
power_monitor.close() power_monitor.close()
@@ -0,0 +1 @@
"""Versioned mobile control transport, independent of web presentation."""
@@ -0,0 +1,252 @@
"""BlueZ GATT adapter. Failure is isolated from the display service."""
import asyncio
import logging
import threading
from dbus_next import Variant, DBusError, BusType, PropertyAccess
from dbus_next.aio import MessageBus
from dbus_next.service import ServiceInterface, method, dbus_property
from .protocol import fragments
log = logging.getLogger(__name__)
ROOT = '/org/qimiaoscreen/mobile'
SERVICE = ROOT + '/service0'
AD = ROOT + '/advertisement0'
SERVICE_UUID = '9f57a001-6c31-4c58-bc22-1f728b641001'
RX_UUID = '9f57a002-6c31-4c58-bc22-1f728b641001'
TX_UUID = '9f57a003-6c31-4c58-bc22-1f728b641001'
class ObjectManager(ServiceInterface):
def __init__(self, objects):
super().__init__('org.freedesktop.DBus.ObjectManager')
self.objects = objects
@method()
def GetManagedObjects(self) -> 'a{oa{sa{sv}}}':
return {path: {obj.name: obj.properties()} for path, obj in self.objects.items()}
class GattService(ServiceInterface):
def __init__(self):
super().__init__('org.bluez.GattService1')
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> 's':
return SERVICE_UUID
@dbus_property(access=PropertyAccess.READ)
def Primary(self) -> 'b':
return True
def properties(self):
return dict(UUID=Variant('s', self.UUID), Primary=Variant('b', True))
class Characteristic(ServiceInterface):
def __init__(self, uuid, runtime):
super().__init__('org.bluez.GattCharacteristic1')
self.uuid, self.runtime = uuid, runtime
self.notifying = False
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> 's':
return self.uuid
@dbus_property(access=PropertyAccess.READ)
def Service(self) -> 'o':
return SERVICE
@dbus_property(access=PropertyAccess.READ)
def Flags(self) -> 'as':
return ['write'] if self.uuid == RX_UUID else ['notify']
@dbus_property(access=PropertyAccess.READ)
def Value(self) -> 'ay':
return b''
@dbus_property(access=PropertyAccess.READ)
def Notifying(self) -> 'b':
return self.notifying
@method()
async def WriteValue(self, value: 'ay', options: 'a{sv}'):
if self.uuid != RX_UUID:
raise DBusError('org.bluez.Error.NotPermitted', 'Not permitted')
await self.runtime.write(value, options)
@method()
def StartNotify(self):
if self.uuid != TX_UUID:
raise DBusError('org.bluez.Error.NotSupported', 'Not supported')
self.notifying = True
@method()
def StopNotify(self):
self.notifying = False
def properties(self):
return dict(UUID=Variant('s', self.UUID), Service=Variant('o', SERVICE), Flags=Variant('as', self.Flags))
class Advertisement(ServiceInterface):
def __init__(self, short_id):
super().__init__('org.bluez.LEAdvertisement1')
self.local_name = 'QMS-' + short_id
@dbus_property(access=PropertyAccess.READ)
def Type(self) -> 's':
return 'peripheral'
@dbus_property(access=PropertyAccess.READ)
def ServiceUUIDs(self) -> 'as':
return [SERVICE_UUID]
@dbus_property(access=PropertyAccess.READ)
def LocalName(self) -> 's':
return self.local_name
@method()
def Release(self):
pass
class BluezRuntime:
def __init__(self, sessions, enabled=False):
self.sessions = sessions
self.enabled = enabled
self.stop_event = threading.Event()
self.thread = None
self.state = dict(available=False, reason='disabled')
self.bus = self.manager = self.ad_manager = None
self.advertising = False
self.tx = None
self.message_id = 0
self.write_lock = None
self.counters = dict(rx_fragments=0, tx_fragments=0, rx_bytes=0, tx_bytes=0, last_response_kind=0, mtu=23)
def status(self):
return {**self.state, **self.sessions.status(), 'transport': dict(self.counters)}
def start(self):
if not self.enabled or self.thread:
return
self.thread = threading.Thread(target=lambda: asyncio.run(self._run()), name='mobile-ble', daemon=True)
self.thread.start()
def close(self):
self.stop_event.set()
if self.thread:
self.thread.join(timeout=8)
async def _proxy(self, path):
introspection = await asyncio.wait_for(self.bus.introspect('org.bluez', path), 5)
return self.bus.get_proxy_object('org.bluez', path, introspection)
async def _disconnect(self, owner):
was_owner = self.sessions.owner == owner
try:
proxy = await self._proxy(owner)
await asyncio.wait_for(proxy.get_interface('org.bluez.Device1').call_disconnect(), 3)
except Exception:
pass
self.sessions.disconnect(owner)
if was_owner:
self.message_id = 0
async def write(self, value, options):
self.counters['rx_fragments'] += 1
self.counters['rx_bytes'] += len(value)
owner = options.get('device')
if not owner or options.get('offset', Variant('q', 0)).value != 0:
raise DBusError('org.bluez.Error.NotAuthorized', 'Invalid request')
owner = owner.value
if not self.sessions.connect(owner):
await self._disconnect(owner)
raise DBusError('org.bluez.Error.NotAuthorized', 'Busy')
async with self.write_lock:
try:
if not self.tx.notifying:
raise ValueError()
response = await asyncio.to_thread(self.sessions.accept, owner, bytes(value))
if response is None:
return
mtu = min(self.sessions.peer_mtu, max(23, options.get('mtu', Variant('q', 23)).value))
self.counters['mtu'] = mtu
self.counters['last_response_kind'] = response[0]
for fragment in fragments(response[0], self.message_id, response[1], mtu):
self.tx.emit_properties_changed({'Value': fragment})
self.counters['tx_fragments'] += 1
self.counters['tx_bytes'] += len(fragment)
await asyncio.sleep(0.002)
self.message_id += 1
except Exception:
await self._disconnect(owner)
raise DBusError('org.bluez.Error.NotAuthorized', 'Session rejected') from None
async def _run(self):
while not self.stop_event.is_set():
try:
await self._serve()
except Exception:
self.state = dict(available=False, reason='bluetooth_unavailable')
log.warning('Mobile BLE unavailable; display service remains active')
finally:
if self.sessions.owner is not None:
self.sessions.disconnect(self.sessions.owner)
self.message_id = 0
self.advertising = False
if self.bus:
self.bus.disconnect()
self.bus = None
for _ in range(5):
if self.stop_event.is_set():
return
await asyncio.sleep(1)
async def _serve(self):
self.bus = await asyncio.wait_for(MessageBus(bus_type=BusType.SYSTEM).connect(), 5)
root_proxy = await self._proxy('/')
self.manager = root_proxy.get_interface('org.freedesktop.DBus.ObjectManager')
objects = await asyncio.wait_for(self.manager.call_get_managed_objects(), 5)
adapters = [path for path, interfaces in objects.items() if 'org.bluez.GattManager1' in interfaces and 'org.bluez.LEAdvertisingManager1' in interfaces]
if not adapters:
raise RuntimeError('No GATT peripheral adapter')
adapter = await self._proxy(sorted(adapters)[0])
properties = adapter.get_interface('org.freedesktop.DBus.Properties')
await properties.call_set('org.bluez.Adapter1', 'Powered', Variant('b', True))
await properties.call_set('org.bluez.Adapter1', 'Pairable', Variant('b', False))
self.ad_manager = adapter.get_interface('org.bluez.LEAdvertisingManager1')
self.tx = Characteristic(TX_UUID, self)
exports = {SERVICE: GattService(), SERVICE + '/rx': Characteristic(RX_UUID, self), SERVICE + '/tx': self.tx}
self.bus.export(ROOT, ObjectManager(exports))
for path, interface in exports.items():
self.bus.export(path, interface)
self.bus.export(AD, Advertisement(self.sessions.identity()['short_id']))
await asyncio.wait_for(adapter.get_interface('org.bluez.GattManager1').call_register_application(ROOT, {}), 5)
self.write_lock = asyncio.Lock()
self.state = dict(available=True, reason=None)
while not self.stop_event.is_set():
objects = await asyncio.wait_for(self.manager.call_get_managed_objects(), 5)
connected = [path for path, interfaces in objects.items()
if interfaces.get('org.bluez.Device1', {}).get('Connected', Variant('b', False)).value]
owner = self.sessions.owner
if owner and owner not in connected:
self.sessions.disconnect(owner)
self.message_id = 0
for path in connected:
if not self.sessions.connect(path):
await self._disconnect(path)
if self.sessions.expired():
await self._disconnect(self.sessions.owner)
should_advertise = self.sessions.owner is None
if should_advertise != self.advertising:
if should_advertise:
await asyncio.wait_for(self.ad_manager.call_register_advertisement(AD, {}), 5)
else:
await asyncio.wait_for(self.ad_manager.call_unregister_advertisement(AD), 5)
self.advertising = should_advertise
await asyncio.sleep(0.5)
if self.sessions.owner:
await self._disconnect(self.sessions.owner)
@@ -0,0 +1,222 @@
"""Explicit mobile capability surface, sharing existing device business operations."""
from __future__ import annotations
import base64
import json
import time
import threading
from io import BytesIO
from pathlib import Path
from uuid import UUID, uuid4
from contextlib import nullcontext
from app.persistence import atomic_write_bytes
from app.demo_library import demo_template, demo_animation
from app.display.service import ANIMATION_PLAYBACK_SPEEDS, AnimationPlaybackConflictError
from app.templates.store import TemplateConflictError, TemplateNotFoundError
from .session import RpcError, checked_name
SETTINGS = ("brightness", "orientation", "matrix_refresh_rate_limit_hz", "performance_mode_enabled")
class MobileControl:
def __init__(self, control, network, templates, animations, resolve, apply, get_default, set_default,
status, storage, library_order=None):
self.control, self.network = control, network
self.templates, self.animations = templates, animations
self.resolve, self.apply = resolve, apply
self.get_default, self.set_default = get_default, set_default
self.status, self.storage = status, storage
self.tasks = {}
self.library_order = library_order
self.path = Path(control.store.data_dir) / "mobile/identity.json"
if self.path.exists():
identity = json.loads(self.path.read_text(encoding="utf-8"))
if set(identity) != {"schema", "device_id", "name"} or identity["schema"] != 1:
raise ValueError("Unsupported mobile identity")
UUID(identity["device_id"])
checked_name(identity["name"])
self.identity_record = identity
else:
self.identity_record = dict(schema=1, device_id=str(uuid4()), name="奇妙小屏幕")
self._persist_identity(self.identity_record)
def _persist_identity(self, identity):
atomic_write_bytes(self.path, (json.dumps(identity, ensure_ascii=False) + "\n").encode("utf-8"))
def identity(self):
record = self.identity_record
return dict(device_id=record["device_id"], device_name=record["name"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=0,
capabilities=["status", "settings", "library", "playback", "frame", "device_name", "wifi", "wifi_scan"],
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
def settings(self):
config = self.control.store.config
values = {key: config[key] for key in SETTINGS}
values["prompt_delay_seconds"] = self.network.get_cached_status()["prompt_delay_seconds"]
return dict(revision=self.control.revision(values), values=values, writable_fields=list(values),
allowed=dict(orientation=[0, 90, 180, 270], matrix_refresh_rate_limit_hz=[15, 20, 30, 45, 60, 80, 100],
playback_speeds=list(ANIMATION_PLAYBACK_SPEEDS)))
def _content(self, params):
reference = {key: params.get(key) for key in ("type", "id")}
resolved = self.resolve(reference)
if params.get("revision") != resolved["revision"]:
raise RpcError("CONFLICT", "内容已改变,请刷新")
return resolved
@staticmethod
def _png(image):
output = BytesIO()
image.save(output, format="PNG")
return dict(mime="image/png", data_base64=base64.b64encode(output.getvalue()).decode("ascii"))
def dispatch(self, method, params):
try:
with self.control.lock, (self.network.configuration_lock if method in ('wifi.get', 'wifi.set') else nullcontext()):
return self._dispatch(method, params)
except (TemplateConflictError, AnimationPlaybackConflictError):
raise RpcError("CONFLICT", "内容或播放会话已改变,请刷新") from None
except TemplateNotFoundError:
raise RpcError("NOT_FOUND", "内容不存在") from None
except (ValueError, TypeError, KeyError):
raise RpcError("BAD_REQUEST", "参数无效,请检查后重试") from None
def _dispatch(self, method, params):
if method == 'wifi.scan':
return dict(networks=self.network.backend.scan(), scanned_at_ms=int(time.time() * 1000))
if method == 'wifi.get':
return self.wifi()
if method == 'wifi.set':
before = self.wifi()
if params.get('expected_revision') != before['revision']:
raise RpcError('CONFLICT', '网络设置已改变,请刷新')
action = params.get('password_action')
security = params.get('security')
if security not in ('open', 'wpa-psk') or action not in ('keep', 'replace', 'none'):
raise ValueError()
saved = before.get('saved') or {}
if security == 'open':
if action != 'none' or params.get('password'):
raise ValueError()
elif action == 'keep':
if params.get('ssid') != saved.get('ssid') or not saved.get('password_configured') or params.get('password'):
raise ValueError()
elif action != 'replace' or not params.get('password'):
raise ValueError()
values = {key: params[key] for key in ('ssid', 'security', 'ipv4_mode', 'address', 'prefix', 'gateway', 'dns_servers', 'activation') if key in params}
values['password'] = params.get('password') if action == 'replace' else None
result = self.network.save_settings(values)
task_id = result['operation_id']
if len(self.tasks) >= 64:
del self.tasks[next(iter(self.tasks))]
self.tasks[task_id] = dict(state='pending' if result['activation'] == 'immediate' else 'succeeded', stage='saved')
if result['activation'] == 'immediate':
threading.Thread(target=self._activate, args=(task_id,), name='mobile-wifi', daemon=True).start()
return dict(revision=self.wifi()['revision'], task_id=task_id)
if method == 'task.get':
task_id = params.get('task_id')
if not isinstance(task_id, str) or task_id not in self.tasks:
raise RpcError('NOT_FOUND', '任务不存在或已过期')
return dict(self.tasks[task_id])
if method == "device.rename":
identity = {**self.identity_record, "name": checked_name(params.get("name"))}
self._persist_identity(identity)
self.identity_record = identity
return self.identity()
if method == "status.get":
return self.status()
if method == "storage.get":
return self.storage()
if method == "settings.get":
return self.settings()
if method == "settings.patch":
if params.get("expected_revision") != self.settings()["revision"]:
raise RpcError("CONFLICT", "设置已改变,请刷新")
changes = params.get("changes")
if not isinstance(changes, dict) or not changes or set(changes) - {*SETTINGS, "prompt_delay_seconds"}:
raise RpcError("BAD_REQUEST", "不支持的设置字段")
# Separate NetworkManager persistence from display configuration.
if "prompt_delay_seconds" in changes and len(changes) != 1:
raise RpcError("BAD_REQUEST", "网络提示延迟须单独提交")
if "prompt_delay_seconds" in changes:
self.network.save_prompt_delay(changes["prompt_delay_seconds"])
else:
self.control.update_config(changes)
return self.settings()
if method == "library.list":
items = []
for kind, records in (("template", [demo_template()] + self.templates.list()["templates"]),
("animation", [demo_animation()] + self.animations.list()["animations"])):
for item in records:
items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"],
is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0,
thumbnail_revision=item["revision"]))
if self.library_order is not None:
user_items = [item for item in items if not item['is_demo']]
order = self.library_order.get([{'type': item['type'], 'id': item['id']} for item in user_items])['items']
by_key = {(item['type'], item['id']): item for item in user_items}
items = [item for item in items if item['is_demo']] + [by_key[(ref['type'], ref['id'])] for ref in order]
revision = self.control.revision(items)
limit = params.get("limit", 20)
if type(limit) is not int or not 1 <= limit <= 50:
raise ValueError()
offset = 0
if params.get("cursor"):
cursor = params["cursor"].split(":")
if len(cursor) != 2 or cursor[0] != revision:
raise RpcError("CONFLICT", "内容列表已改变,请重新加载")
offset = int(cursor[1])
if not 0 <= offset <= len(items):
raise ValueError()
next_offset = offset + limit
return dict(library_revision=revision, items=items[offset:next_offset],
next_cursor=f"{revision}:{next_offset}" if next_offset < len(items) else None)
if method == "library.thumbnail":
content = self._content(params)
return self._png(content["image"] if content["type"] == "template" else content["frames"][0]["image"])
if method in ("content.play", "content.default.set"):
content = self._content(params)
if method == "content.default.set":
return self.set_default({"type": content["type"], "id": content["id"]})
self.apply(content, notify_activity=True)
return {key: content[key] for key in ("type", "id", "name", "revision", "is_demo")}
if method == "content.default.get":
content = self.get_default()
return {key: content[key] for key in ("type", "id", "name", "revision", "is_demo")}
if method == "playback.patch":
if set(params) - {"session_id", "paused", "position_ms", "speed"}:
raise ValueError()
playback = self.control.display.control_animation_playback(params.get("session_id"),
paused=params.get("paused"), position_ms=params.get("position_ms"), speed=params.get("speed"))
return dict(animation_playback=playback, state_revision=self.control.display.get_state_revision())
if method == "frame.get":
image = self.control.display.get_current_frame()
revision = self.control.revision(base64.b64encode(image.tobytes()).decode("ascii"))
if params.get("known_revision") == revision:
return dict(unchanged=True, frame_revision=revision)
return dict(unchanged=False, frame_revision=revision, sampled_at_ms=int(time.time() * 1000), **self._png(image))
raise RpcError("UNSUPPORTED_CAPABILITY", "设备不支持此操作")
def wifi(self):
status = self.network.get_status(include_secret=False)
saved = status.get('saved')
if saved:
saved = {key: value for key, value in saved.items() if key != 'password'}
saved.setdefault('security', 'wpa-psk' if saved.get('password_configured') else 'open')
revision = self.control.revision(dict(saved=saved, generation=self.network.settings_revision))
return {**status, 'saved': saved, 'revision': revision}
def _activate(self, task_id):
self.tasks[task_id] = dict(state='running', stage='connecting')
try:
self.network.activate_saved(task_id)
operation = self.network.get_cached_status().get('operation') or {}
state = operation.get('state') if operation.get('id') == task_id else 'failed'
self.tasks[task_id] = dict(state='succeeded' if state == 'succeeded' else 'failed', stage='finished')
if state != 'succeeded':
code = operation.get('error_code', 'UNKNOWN')
self.tasks[task_id]['error_code'] = code if code in {'AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN'} else 'UNKNOWN'
except Exception:
self.tasks[task_id] = dict(state='failed', stage='finished', error_code='UNKNOWN')
@@ -0,0 +1,165 @@
"""QMS BLE v1 framing and ephemeral authenticated encryption.
This encrypts traffic, but does not authenticate the identity of an App.
Each instance belongs to exactly one connection; discard it after any error.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import struct
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
MAX_MESSAGE = 65536
HEADER = struct.Struct("!HBBIHHI")
LABEL = b"QMS-BLE-1"
class ProtocolError(ValueError):
"""Fixed public error: never include untrusted payload or crypto details."""
def fragments(kind: int, message_id: int, payload: bytes, mtu: int):
if kind not in (1, 2, 3, 4) or not 0 <= message_id <= 0xFFFFFFFF:
raise ProtocolError("BAD_REQUEST")
if not 1 <= len(payload) <= MAX_MESSAGE or not 23 <= mtu <= 517:
raise ProtocolError("BAD_REQUEST")
capacity = mtu - 19
count = (len(payload) + capacity - 1) // capacity
for index in range(count):
yield HEADER.pack(0x514D, 1, kind, message_id, index, count, len(payload)) + payload[index * capacity:(index + 1) * capacity]
class Reassembler:
def __init__(self, clock=time.monotonic):
self.clock = clock
self.next_message = 0
self.pending = None
self.buffer = bytearray()
self.index = 0
self.started = 0.0
def accept(self, part: bytes):
if not 16 < len(part) <= 514:
raise ProtocolError("BAD_REQUEST")
magic, wire, kind, mid, index, count, total = HEADER.unpack(part[:16])
if magic != 0x514D or wire != 1 or kind not in (1, 2, 3, 4) or not 1 <= count <= total <= MAX_MESSAGE:
raise ProtocolError("BAD_REQUEST")
signature = (kind, mid, count, total)
if self.pending is None:
if index != 0 or mid != self.next_message:
raise ProtocolError("BAD_REQUEST")
self.pending = signature
self.started = self.clock()
if self.clock() - self.started >= 15:
raise ProtocolError("TIMEOUT")
if self.pending != signature or index != self.index or index >= count:
raise ProtocolError("BAD_REQUEST")
self.buffer.extend(part[16:])
self.index += 1
if len(self.buffer) > total or (self.index < count and len(self.buffer) >= total):
raise ProtocolError("BAD_REQUEST")
if self.index != count:
return None
if len(self.buffer) != total:
raise ProtocolError("BAD_REQUEST")
result = kind, bytes(self.buffer)
self.pending = None
self.buffer.clear()
self.index = 0
self.next_message += 1
return result
def json_object(raw: bytes) -> dict:
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate")
result[key] = value
return result
try:
value = json.loads(raw.decode("utf-8"), object_pairs_hook=unique,
parse_constant=lambda _: (_ for _ in ()).throw(ValueError()))
if not isinstance(value, dict):
raise ValueError()
return value
except (ValueError, UnicodeError, RecursionError):
raise ProtocolError("BAD_REQUEST") from None
class Handshake:
def __init__(self, *, private_key=None, random_bytes=None):
# Explicit values are exclusively for public interoperability fixtures.
self.key = private_key or ec.generate_private_key(ec.SECP256R1())
self.random = random_bytes if random_bytes is not None else os.urandom(32)
if len(self.random) != 32:
raise ValueError("random length")
public = self.key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
self.hello = json.dumps(dict(protocol_major=1, protocol_minor=0,
public_key=base64.b64encode(public).decode("ascii"),
random=base64.b64encode(self.random).decode("ascii")), separators=(",", ":")).encode("utf-8")
def finish(self, peer_hello: bytes, *, server: bool):
if len(peer_hello) > 1024:
raise ProtocolError("BAD_REQUEST")
peer = json_object(peer_hello)
if type(peer.get("protocol_major")) is not int or peer["protocol_major"] != 1:
raise ProtocolError("UNSUPPORTED_VERSION")
if type(peer.get("protocol_minor")) is not int or peer["protocol_minor"] < 0:
raise ProtocolError("BAD_REQUEST")
try:
public = base64.b64decode(peer["public_key"], validate=True)
random = base64.b64decode(peer["random"], validate=True)
if len(public) != 65 or public[0] != 4 or len(random) != 32:
raise ValueError()
peer_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), public)
secret = self.key.exchange(ec.ECDH(), peer_key)
except (KeyError, TypeError, ValueError):
raise ProtocolError("BAD_REQUEST") from None
c, s = (peer_hello, self.hello) if server else (self.hello, peer_hello)
cr, sr = (random, self.random) if server else (self.random, random)
transcript = hashlib.sha256(struct.pack("!I", len(c)) + c + struct.pack("!I", len(s)) + s).digest()
material = HKDF(algorithm=hashes.SHA256(), length=72,
salt=hashlib.sha256(cr + sr).digest(), info=LABEL + transcript).derive(secret)
return Cipher(material, transcript, server=server)
class Cipher:
def __init__(self, material: bytes, transcript: bytes, *, server: bool):
self.tx_direction = 1 if server else 0
self.rx_direction = 1 - self.tx_direction
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
self.prefixes = (material[64:68], material[68:72])
self.transcript = transcript
self.tx_sequence = self.rx_sequence = 0
self.failed = False
def encrypt(self, payload: bytes) -> bytes:
if self.failed or not 1 <= len(payload) <= MAX_MESSAGE - 24 or self.tx_sequence >= 2**64:
raise ProtocolError("BAD_REQUEST")
seq = struct.pack("!Q", self.tx_sequence)
d = self.tx_direction
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, LABEL + self.transcript + bytes([d]) + seq)
self.tx_sequence += 1
return result
def decrypt(self, record: bytes) -> bytes:
try:
if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence:
raise ValueError()
seq, d = record[:8], self.rx_direction
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], LABEL + self.transcript + bytes([d]) + seq)
self.rx_sequence += 1
return result
except Exception:
self.failed = True
raise ProtocolError("BAD_REQUEST") from None
@@ -0,0 +1,133 @@
"""Single-owner encrypted RPC session; transport cannot bypass this gate."""
from __future__ import annotations
import json
import threading
import time
from .protocol import Handshake, ProtocolError, Reassembler
class RpcError(Exception):
def __init__(self, code, message):
self.code, self.message = code, message
super().__init__(code)
def checked_name(value):
if not isinstance(value, str) or not value.strip() or len(value) > 40:
raise RpcError("BAD_REQUEST", "名称须为 1 至 40 个字符")
if any(ord(char) < 32 or ord(char) == 127 for char in value):
raise RpcError("BAD_REQUEST", "名称包含不支持的字符")
return value.strip()
class SessionManager:
def __init__(self, dispatch, identity, *, clock=time.monotonic):
self.dispatch = dispatch
self.identity = identity
self.clock = clock
self.lock = threading.RLock()
self.owner = None
self.receiver = None
self.cipher = None
self.opened = False
self.client_name = None
self.last_request = -1
self.connected_at = self.last_activity = 0
self.generation = 0
self.peer_mtu = 23
def connect(self, owner):
with self.lock:
if self.owner is not None:
return self.owner == owner
self.owner = owner
self.receiver = Reassembler(self.clock)
self.connected_at = self.last_activity = self.clock()
return True
def disconnect(self, owner):
with self.lock:
if self.owner != owner:
return
self.owner = self.receiver = self.cipher = self.client_name = None
self.opened = False
self.last_request = -1
self.peer_mtu = 23
self.generation += 1
def expired(self):
with self.lock:
if self.owner is None:
return False
deadline = self.last_activity + 20 if self.opened else self.connected_at + 10
return self.clock() >= deadline or (
self.receiver.pending is not None and self.clock() - self.receiver.started >= 15)
def status(self):
with self.lock:
return dict(connected=self.opened, client_name=self.client_name if self.opened else None,
generation=self.generation)
def accept(self, owner, fragment):
with self.lock:
if owner != self.owner or self.expired():
raise ProtocolError("NOT_READY")
message = self.receiver.accept(fragment)
if message is None:
return None
kind, payload = message
if self.cipher is None:
if kind != 1:
raise ProtocolError("NOT_READY")
handshake = Handshake()
self.cipher = handshake.finish(payload, server=True)
return 2, handshake.hello
if kind != 3:
raise ProtocolError("BAD_REQUEST")
from .protocol import json_object
request = json_object(self.cipher.decrypt(payload))
request_id = request.get("id")
if not isinstance(request_id, str) or not 1 <= len(request_id) <= 64 or not request_id.isascii() or not request_id.isdecimal():
raise ProtocolError("BAD_REQUEST")
number = int(request_id)
response = dict(id=request_id)
try:
if number <= self.last_request:
raise RpcError("CONFLICT", "请求编号已使用")
self.last_request = number
method, params = request.get("method"), request.get("params")
if not isinstance(method, str) or not isinstance(params, dict):
raise RpcError("BAD_REQUEST", "请求格式无效")
if not self.opened:
if method != "session.open":
raise ProtocolError("NOT_READY")
name = checked_name(params.get("client_name"))
peer_mtu = params.get('receive_mtu', 23)
if type(peer_mtu) is not int or not 23 <= peer_mtu <= 517:
raise RpcError('BAD_REQUEST', '接收分片上限无效')
result = self.identity()
self.peer_mtu = peer_mtu
self.client_name = name
self.opened = True
self.generation += 1
elif method == "session.ping":
result = dict(alive=True)
elif method == "session.rename":
self.client_name = checked_name(params.get("client_name"))
result = dict(client_name=self.client_name)
elif method == "session.open":
raise RpcError("CONFLICT", "会话已经建立")
else:
result = self.dispatch(method, params)
response.update(ok=True, result=result)
self.last_activity = self.clock()
except RpcError as error:
response.update(ok=False, error=dict(code=error.code, message=error.message, retryable=False))
except ProtocolError:
raise
except Exception:
response.update(ok=False, error=dict(code="DEVICE_ERROR", message="设备操作失败,请刷新状态", retryable=False))
raw = json.dumps(response, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
return 3, self.cipher.encrypt(raw)
@@ -2,6 +2,8 @@ from __future__ import annotations
import subprocess import subprocess
import threading import threading
import time
import re
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Protocol, Sequence from typing import Any, Protocol, Sequence
@@ -10,9 +12,13 @@ from uuid import uuid4
class NetworkManagerError(RuntimeError): class NetworkManagerError(RuntimeError):
"""A sanitized NetworkManager operation failure.""" """A sanitized NetworkManager operation failure."""
def __init__(self, message: str, *, error_code: str = "UNKNOWN"):
super().__init__(message)
self.error_code = error_code
class NetworkBackend(Protocol): class NetworkBackend(Protocol):
def scan(self) -> list[dict[str, Any]]: ...
def discover_connection_uuid(self) -> str | None: ... def discover_connection_uuid(self) -> str | None: ...
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ... def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ...
def read_active(self) -> dict[str, Any]: ... def read_active(self) -> dict[str, Any]: ...
@@ -42,6 +48,40 @@ class NmcliNetworkManager:
executable: str = "/usr/bin/nmcli" executable: str = "/usr/bin/nmcli"
interface: str = "wlan0" interface: str = "wlan0"
def scan(self) -> list[dict[str, Any]]:
rows = self._run(['--terse', '--escape', 'yes', '--fields', 'IN-USE,SSID,SECURITY,SIGNAL',
'device', 'wifi', 'list', 'ifname', self.interface, '--rescan', 'yes'], timeout=15)
strongest = {}
for row in rows.splitlines():
fields, current, escaped = [], [], False
for character in row:
if escaped:
current.append(character)
escaped = False
elif character == '\\':
escaped = True
elif character == ':':
fields.append(''.join(current))
current = []
else:
current.append(character)
fields.append(''.join(current))
if len(fields) != 4 or not fields[1]:
continue
active, ssid, security, signal = fields
if not signal.isdecimal():
continue
kind = 'open' if security in ('', '--') else (
'wpa-psk' if ('WPA1' in security or 'WPA2' in security) and '802.1X' not in security else 'unsupported')
item = dict(ssid=ssid, security=kind, signal_percent=min(100, max(0, int(signal))), connected=active == '*')
key = (ssid, kind)
if key not in strongest or item['signal_percent'] > strongest[key]['signal_percent']:
item['connected'] = item['connected'] or strongest.get(key, {}).get('connected', False)
strongest[key] = item
elif item['connected']:
strongest[key]['connected'] = True
return sorted(strongest.values(), key=lambda item: -item['signal_percent'])[:100]
def _run( def _run(
self, self,
arguments: Sequence[str], arguments: Sequence[str],
@@ -105,6 +145,7 @@ class NmcliNetworkManager:
raw_dns = self._connection_value(connection_uuid, "ipv4.dns") raw_dns = self._connection_value(connection_uuid, "ipv4.dns")
password = None password = None
password_configured = False password_configured = False
key_mgmt = self._connection_value(connection_uuid, "802-11-wireless-security.key-mgmt")
if include_secret: if include_secret:
password = self._connection_value( password = self._connection_value(
connection_uuid, connection_uuid,
@@ -113,10 +154,6 @@ class NmcliNetworkManager:
) )
password_configured = bool(password and password != "--") password_configured = bool(password and password != "--")
else: else:
key_mgmt = self._connection_value(
connection_uuid,
"802-11-wireless-security.key-mgmt",
)
password_configured = bool(key_mgmt and key_mgmt != "--") password_configured = bool(key_mgmt and key_mgmt != "--")
prefix = None prefix = None
plain_address = address plain_address = address
@@ -129,13 +166,17 @@ class NmcliNetworkManager:
return { return {
"connection_uuid": connection_uuid, "connection_uuid": connection_uuid,
"ssid": ssid, "ssid": ssid,
"security": "open" if key_mgmt in ("", "--") else "wpa-psk" if key_mgmt == "wpa-psk" else "unsupported",
"password": password if password_configured else None, "password": password if password_configured else None,
"password_configured": password_configured, "password_configured": password_configured,
"ipv4_mode": "dhcp" if method == "auto" else "manual", "ipv4_mode": "dhcp" if method == "auto" else "manual",
"address": plain_address or None, "address": plain_address or None,
"prefix": prefix, "prefix": prefix,
"gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None, "gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None,
"dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()], "dns_servers": list(dict.fromkeys(
item.strip() for line in raw_dns.splitlines() for item in line.split(",")
if item.strip() and item.strip() != "--"
)),
} }
def read_active(self) -> dict[str, Any]: def read_active(self) -> dict[str, Any]:
@@ -174,9 +215,10 @@ class NmcliNetworkManager:
def _modify_arguments(settings: dict[str, Any]) -> list[str]: def _modify_arguments(settings: dict[str, Any]) -> list[str]:
arguments = [ arguments = [
"802-11-wireless.ssid", settings["ssid"], "802-11-wireless.ssid", settings["ssid"],
"802-11-wireless-security.key-mgmt", "wpa-psk",
] ]
if settings.get("password") is not None: if settings.get('security', 'wpa-psk') != 'open':
arguments.extend(['802-11-wireless-security.key-mgmt', 'wpa-psk'])
if settings.get("password") is not None and settings.get('security') != 'open':
arguments.extend(["802-11-wireless-security.psk", settings["password"]]) arguments.extend(["802-11-wireless-security.psk", settings["password"]])
if settings["ipv4_mode"] == "dhcp": if settings["ipv4_mode"] == "dhcp":
arguments.extend([ arguments.extend([
@@ -206,6 +248,8 @@ class NmcliNetworkManager:
return connection_uuid return connection_uuid
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
if settings.get('security') == 'open':
self._run(['connection', 'modify', 'uuid', connection_uuid, 'remove', '802-11-wireless-security'])
self._run([ self._run([
"connection", "modify", "uuid", connection_uuid, "connection", "modify", "uuid", connection_uuid,
*self._modify_arguments(settings), *self._modify_arguments(settings),
@@ -213,12 +257,36 @@ class NmcliNetworkManager:
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
bounded = max(1, min(int(timeout), 30)) bounded = max(1, min(int(timeout), 30))
started = int(time.time())
try:
self._run([ self._run([
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid, "--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
], timeout=bounded + 2) ], timeout=bounded + 2)
except NetworkManagerError:
code = "UNKNOWN"
try:
reason = self._run(["--get-values", "GENERAL.REASON", "device", "show", self.interface])
number = int(reason.split(" ", 1)[0])
if number == 53:
code = "NETWORK_UNAVAILABLE"
elif number in (5, 6, 15, 16, 17):
code = "IP_CONFIG_FAILED"
# A timeout or missing-secret request alone is not proof of a wrong password.
# Only the supplicant's explicit WRONG_KEY event from this attempt qualifies.
if code == "UNKNOWN":
result = subprocess.run(["journalctl", "-u", "wpa_supplicant.service", "--since", f"@{started}",
"--output=cat", "--no-pager"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3)
pattern = re.compile(r"^" + re.escape(self.interface) + r": CTRL-EVENT-SSID-TEMP-DISABLED .*\breason=WRONG_KEY\b", re.MULTILINE)
if result.returncode == 0 and pattern.search(result.stdout):
code = "AUTH_FAILED"
except (NetworkManagerError, ValueError, OSError, subprocess.SubprocessError):
pass
raise NetworkManagerError("WiFi activation failed", error_code=code) from None
class MockNetworkManager: class MockNetworkManager:
def scan(self):
return []
def __init__( def __init__(
self, self,
saved: dict[str, Any] | None = None, saved: dict[str, Any] | None = None,
@@ -269,7 +337,7 @@ class MockNetworkManager:
with self._lock: with self._lock:
previous_password = self.saved.get("password") if self.saved else None previous_password = self.saved.get("password") if self.saved else None
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)} self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
if settings.get("password") is None: if settings.get("password") is None and settings.get('security') != 'open':
self.saved["password"] = previous_password self.saved["password"] = previous_password
self.saved["password_configured"] = bool(self.saved.get("password")) self.saved["password_configured"] = bool(self.saved.get("password"))
@@ -30,10 +30,16 @@ def validate_wifi_settings(
raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes") raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes")
password_value = raw.get("password") password_value = raw.get("password")
security = raw.get('security', 'wpa-psk')
if security not in ('open', 'wpa-psk'):
raise WifiConfigError('unsupported WiFi security')
password = None if password_value in (None, "") else str(password_value) password = None if password_value in (None, "") else str(password_value)
if password is not None and not 8 <= _utf8_length(password) <= 63: if password is not None and not 8 <= _utf8_length(password) <= 63:
raise WifiConfigError("password must contain 8..63 UTF-8 bytes") raise WifiConfigError("password must contain 8..63 UTF-8 bytes")
if previous is None or previous.get("ssid") != ssid: if security == 'open':
if password is not None:
raise WifiConfigError('open networks cannot have a password')
elif previous is None or previous.get("ssid") != ssid:
if password is None: if password is None:
raise WifiConfigError("a new password is required when the SSID changes") raise WifiConfigError("a new password is required when the SSID changes")
elif password is None and not previous.get("password_configured"): elif password is None and not previous.get("password_configured"):
@@ -79,8 +85,9 @@ def validate_wifi_settings(
return { return {
"ssid": ssid, "ssid": ssid,
"security": security,
"password": password, "password": password,
"password_configured": bool(password or (previous or {}).get("password_configured")), "password_configured": security != 'open' and bool(password or (previous or {}).get("password_configured")),
"ipv4_mode": mode, "ipv4_mode": mode,
"address": address, "address": address,
"prefix": prefix if mode == "manual" else None, "prefix": prefix if mode == "manual" else None,
@@ -105,6 +112,8 @@ class WifiNetworkService:
self.monotonic = monotonic self.monotonic = monotonic
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
self._lock = threading.RLock() self._lock = threading.RLock()
self.configuration_lock = self._lock
self.settings_revision = str(uuid4())
self._stop = threading.Event() self._stop = threading.Event()
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._activation_active = False self._activation_active = False
@@ -256,6 +265,7 @@ class WifiNetworkService:
else "WiFi 配置已保存,将在下次断电开机后生效" else "WiFi 配置已保存,将在下次断电开机后生效"
) )
self.store.set_operation({"id": operation_id, "state": state, "message": message}) self.store.set_operation({"id": operation_id, "state": state, "message": message})
self.settings_revision = str(uuid4())
return { return {
"operation_id": operation_id, "operation_id": operation_id,
"activation": activation, "activation": activation,
@@ -297,7 +307,8 @@ class WifiNetworkService:
self.store.set_operation({ self.store.set_operation({
"id": operation_id, "id": operation_id,
"state": "failed", "state": "failed",
"message": str(exc), "message": "WiFi activation failed",
"error_code": getattr(exc, "error_code", "UNKNOWN"),
}) })
finally: finally:
with self._lock: with self._lock:
@@ -115,6 +115,9 @@ class WifiConfigStore:
"state": str(operation.get("state") or "idle"), "state": str(operation.get("state") or "idle"),
"message": str(operation.get("message") or ""), "message": str(operation.get("message") or ""),
} }
if operation.get("error_code") is not None:
code = operation["error_code"]
safe["error_code"] = code if code in {"AUTH_FAILED", "NETWORK_UNAVAILABLE", "IP_CONFIG_FAILED", "UNKNOWN"} else "UNKNOWN"
if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}: if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}:
raise WifiConfigError("invalid wifi operation state") raise WifiConfigError("invalid wifi operation state")
with self._lock: with self._lock:
@@ -11,6 +11,7 @@ from typing import Any, AsyncIterable, Callable
from uuid import uuid4 from uuid import uuid4
from app.display.service import DisplayService from app.display.service import DisplayService
from app.system.kernel_recovery import KernelRecovery
from .diagnostics import failure_log_metadata, read_failure_log from .diagnostics import failure_log_metadata, read_failure_log
from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package
@@ -84,8 +85,45 @@ class OtaManager:
"last_result": last_result, "last_result": last_result,
"failure_log_available": failure_log_available, "failure_log_available": failure_log_available,
"failure_log_bytes": failure_log_bytes, "failure_log_bytes": failure_log_bytes,
"kernel_recovery": self.kernel_recovery_status(),
} }
def _production_recovery(self) -> KernelRecovery | None:
if os.name == "nt" or self.data_root != Path("/var/lib/matrix-screen-controller"):
return None
return KernelRecovery(self.data_root)
def kernel_recovery_status(self) -> dict[str, str]:
recovery = self._production_recovery()
return recovery.status() if recovery else {"state": "not_needed", "reason": ""}
def schedule_kernel_recovery_after_ota(self) -> None:
recovery = self._production_recovery()
if recovery is None:
return
result = read_last_result(self.data_root)
if not result or result.get("status") != "success" or result.get("target_version") != str(self.software_version):
return
result_id = f"{result.get('target_version')}:{result.get('installed_at')}"
if not recovery.prepare_attempt(result_id):
return
command = [
"systemd-run", "--quiet", "--collect", "--unit=matrix-kernel-recovery",
"--on-active=5s", "/usr/bin/python3",
"/opt/matrix-screen-controller/app/system/kernel_recovery.py", "arm-and-reboot",
]
try:
subprocess.run(command, check=True, capture_output=True, timeout=10)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
recovery.write_record("failed", f"could not schedule kernel recovery: {exc}", attempts=0)
def finalize_kernel_recovery_after_boot(self) -> None:
recovery = self._production_recovery()
if recovery is None:
return
thread = threading.Thread(target=recovery.finalize_after_boot, name="kernel-recovery-finalize", daemon=True)
thread.start()
def failure_log(self) -> bytes | None: def failure_log(self) -> bytes | None:
last_result = read_last_result(self.data_root) last_result = read_last_result(self.data_root)
return read_failure_log(self.data_root, last_result) return read_failure_log(self.data_root, last_result)
@@ -41,6 +41,7 @@
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p> <p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
</div> </div>
<div class="topbar-statuses" aria-label="设备实时状态"> <div class="topbar-statuses" aria-label="设备实时状态">
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">手机未连接</span>
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true"> <button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
当前画面:正在读取… 当前画面:正在读取…
</button> </button>
@@ -3618,6 +3618,18 @@
"source": "index.html::#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]", "source": "index.html::#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]",
"text": "内容" "text": "内容"
}, },
"copy.9977eafad4a6d6e9": {
"kind": "html_text",
"placeholders": [],
"render": {
"selector": "#topbar-mobile",
"text_index": 0,
"type": "static_text"
},
"scope": "global",
"source": "index.html::#topbar-mobile::text[0]",
"text": "手机未连接"
},
"copy.9a395662799ecd7a": { "copy.9a395662799ecd7a": {
"kind": "html_text", "kind": "html_text",
"placeholders": [], "placeholders": [],
@@ -16,6 +16,7 @@ import {
} from "../current-display-playback.js"; } from "../current-display-playback.js";
const statusEls = { const statusEls = {
mobile: document.getElementById("topbar-mobile"),
orientation: document.getElementById("status-orientation"), orientation: document.getElementById("status-orientation"),
brightness: document.getElementById("status-brightness"), brightness: document.getElementById("status-brightness"),
currentContent: document.getElementById("topbar-current-content"), currentContent: document.getElementById("topbar-current-content"),
@@ -760,9 +761,22 @@ export async function refreshDeviceStorage() {
} }
} }
let lastMobileSession = null;
export async function refreshStatus({ source = "manual" } = {}) { export async function refreshStatus({ source = "manual" } = {}) {
try { try {
const data = await apiJson("/api/status"); const data = await apiJson("/api/status");
const mobile = data.mobile;
if (statusEls.mobile) {
statusEls.mobile.textContent = mobile?.connected
? `手机:${mobile.client_name || "已连接"}`
: (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接");
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
announce(`${mobile.client_name || "手机"}已连接`);
}
lastMobileSession = session;
}
syncCurrentDisplayDialogStatus(data); syncCurrentDisplayDialogStatus(data);
statusEls.orientation.textContent = `${data.state.orientation}°`; statusEls.orientation.textContent = `${data.state.orientation}°`;
showDeviceBrightness(data.state); showDeviceBrightness(data.state);
@@ -263,6 +263,20 @@ function applyOtaStatus(status) {
otaProgressGroup.hidden = true; otaProgressGroup.hidden = true;
setOtaFeedback(""); setOtaFeedback("");
} }
if (job?.phase !== "failed") {
const recovery = status?.kernel_recovery;
if (recovery?.state === "pending") {
setOtaFeedback("应用更新完成;正在进行一次候选内核恢复重启…", "pending");
} else if (recovery?.state === "succeeded") {
setOtaFeedback("候选内核与 CPU 调频策略已恢复。", "ready");
} else if (recovery?.state === "failed") {
setOtaFeedback(`内核恢复失败:${recovery.reason || "请查看设备诊断"}`, "error");
} else if (recovery?.state === "requires_package") {
setOtaFeedback("应用已更新;内核材料缺失或损坏,需要专用修复包。", "error");
} else if (recovery?.state === "ready") {
setOtaFeedback("当前设备运行原内核,性能模式不可用;下次 OTA 将尝试轻量恢复。", "error");
}
}
} }
} }
@@ -281,7 +295,7 @@ async function pollOtaUntilFinished() {
const status = await fetchOtaStatus(); const status = await fetchOtaStatus();
if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending"); if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending");
applyOtaStatus(status); applyOtaStatus(status);
if (!status.active) return; if (!status.active && status.kernel_recovery?.state !== "pending") return;
} catch { } catch {
disconnected = true; disconnected = true;
setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending"); setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending");
@@ -297,7 +311,7 @@ async function loadOtaStatus() {
try { try {
const status = await fetchOtaStatus(); const status = await fetchOtaStatus();
applyOtaStatus(status); applyOtaStatus(status);
if (status.active) pollOtaUntilFinished(); if (status.active || status.kernel_recovery?.state === "pending") pollOtaUntilFinished();
} catch (error) { } catch (error) {
otaCurrentVersion.textContent = "读取失败"; otaCurrentVersion.textContent = "读取失败";
otaFeatureUpdatedAt.textContent = "读取失败"; otaFeatureUpdatedAt.textContent = "读取失败";
@@ -0,0 +1,289 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import time
from typing import Any
from uuid import uuid4
CANDIDATE_RELEASE = "6.1.31-matrix-axp313a1"
RECORD_NAME = "kernel-recovery.json"
BOOT_HASHES = {
"Image-matrix-axp313a1": "f8b6801cb9a300ee126635fd8834c5cdc6bd55857bb4438982db7f6fd78a8541",
"sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": "10ef0eca3b9ec2063a8e4f47d0afc5e30b6e1a2ed8c4fcf9b0450630e2c11f66",
"sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": "d0203cd03eb2c316cb36ed94016cbeb469d499ce06d866aaeef0494c45037c12",
}
MODULE_COUNT = 3035
MODULE_DIGEST = "8326558d841bc137c87d153d68903da574ebccaaee1bc6d6c129a8526af29346"
class KernelRecovery:
def __init__(self, data_root: Path, *, host_root: Path = Path("/")) -> None:
self.data_root = Path(data_root)
self.host_root = Path(host_root)
self.record_path = self.data_root / RECORD_NAME
def host(self, absolute: str) -> Path:
return self.host_root / absolute.lstrip("/")
@staticmethod
def _digest(path: Path) -> bytes:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.digest()
def boot_id(self) -> str:
return self.host("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
def release(self) -> str:
return self.host("/proc/sys/kernel/osrelease").read_text(encoding="ascii").strip()
def capability(self) -> dict[str, Any]:
policies = {}
for policy in sorted(self.host("/sys/devices/system/cpu/cpufreq").glob("policy*")):
if not policy.is_dir():
continue
try:
current = (policy / "scaling_governor").read_text(encoding="ascii").strip()
available = (policy / "scaling_available_governors").read_text(encoding="ascii").split()
except OSError:
current, available = "", []
policies[policy.name] = {"current": current, "performance": "performance" in available}
try:
release = self.release()
except OSError:
release = "unavailable"
return {
"release": release,
"policies": policies,
"available": bool(policies) and all(value["current"] and value["performance"] for value in policies.values()),
"marker": self.host("/boot/matrix-kernel-good").is_file(),
}
def verify_candidate(self) -> str | None:
boot = self.host("/boot")
for name, expected in BOOT_HASHES.items():
path = boot / name
try:
if self._digest(path).hex() != expected:
return f"candidate file differs from registered artifact: {name}"
except OSError:
return f"candidate file is missing or unreadable: {name}"
original = (
"Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb",
"boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS",
)
if any(not (boot / name).is_file() for name in original):
return "original kernel rollback files are incomplete"
try:
command = (boot / "boot.cmd").read_text(encoding="utf-8")
script = (boot / "boot.scr").read_bytes()
except (OSError, UnicodeError):
return "managed boot script is unreadable"
if command.count("matrix_kernel_candidate=1") != 1 or command.count("matrix-kernel-good") != 2:
return "managed boot script is missing or ambiguous"
if b"matrix_kernel_candidate=1" not in script or b"Image-matrix-axp313a1" not in script:
return "compiled boot script does not select the candidate"
health_unit = self.host("/etc/systemd/system/matrix-axp313a-health.service")
health_script = self.host("/opt/matrix-screen-controller-system/axp313a_kernel_health.py")
try:
unit = health_unit.read_text(encoding="utf-8")
except OSError:
return "candidate health service is missing"
if "ConditionKernelCommandLine=matrix_kernel_candidate=1" not in unit or not health_script.is_file():
return "candidate health service is incomplete"
enabled = self.host("/etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service")
if not enabled.exists():
return "candidate health service is not enabled"
module_root = self.host(f"/lib/modules/{CANDIDATE_RELEASE}")
if not (module_root / "modules.dep").is_file():
return "candidate module dependency index is missing"
modules = sorted(module_root.rglob("*.ko"), key=lambda item: item.relative_to(module_root).as_posix())
if len(modules) != MODULE_COUNT:
return "candidate module count differs from registered artifact"
aggregate = hashlib.sha256()
try:
for module in modules:
aggregate.update(module.relative_to(module_root).as_posix().encode("ascii"))
aggregate.update(b"\0")
aggregate.update(self._digest(module))
except (OSError, UnicodeError):
return "candidate modules are unreadable"
if aggregate.hexdigest() != MODULE_DIGEST:
return "candidate modules differ from registered artifact"
return None
def read_record(self) -> dict[str, Any] | None:
if not self.record_path.exists():
return None
try:
value = json.loads(self.record_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return {"state": "failed", "reason": "kernel recovery record is invalid; manual inspection required"}
if not isinstance(value, dict) or value.get("schema_version") != 1:
return {"state": "failed", "reason": "kernel recovery record has an unsupported schema"}
return value
def write_record(self, state: str, reason: str, **fields: Any) -> None:
self.data_root.mkdir(parents=True, exist_ok=True)
document = {"schema_version": 1, "state": state, "reason": reason,
"recorded_at": time.time(), **fields}
temporary = self.record_path.with_name(f".{RECORD_NAME}.{uuid4().hex}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(document, handle, ensure_ascii=False, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, self.record_path)
finally:
temporary.unlink(missing_ok=True)
def status(self) -> dict[str, str]:
record = self.read_record()
if record:
if record.get("state") == "pending" and record.get("attempts") == 0:
recorded_at = record.get("recorded_at")
if isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 180:
self.write_record("failed", "kernel recovery task did not start", attempts=0)
return {"state": "failed", "reason": "kernel recovery task did not start"}
if record.get("state") == "pending" and record.get("attempts") == 1:
recorded_at = record.get("recorded_at")
if (isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 300
and self.boot_id() == record.get("source_boot_id")):
self.write_record("failed", "candidate reboot did not occur", attempts=1)
return {"state": "failed", "reason": "candidate reboot did not occur"}
if record.get("state") == "succeeded":
capability = self.capability()
if capability["release"] != CANDIDATE_RELEASE:
return {"state": "failed", "reason": "candidate kernel was lost after a successful recovery"}
if not capability["available"] or not capability["marker"]:
return {"state": "pending", "reason": "waiting for candidate boot health"}
return {"state": str(record.get("state", "failed")), "reason": str(record.get("reason", ""))}
capability = self.capability()
if capability["release"] == CANDIDATE_RELEASE and capability["available"] and capability["marker"]:
return {"state": "not_needed", "reason": ""}
return {"state": "ready", "reason": "candidate kernel is not active"}
def prepare_attempt(self, ota_result_id: str) -> bool:
capability = self.capability()
if capability["release"] == CANDIDATE_RELEASE and capability["available"] and capability["marker"]:
return False
if self.read_record() is not None:
return False
if capability["release"] != "6.1.31":
self.write_record("failed", "automatic recovery only supports the original 6.1.31 kernel",
ota_result_id=ota_result_id)
return False
reason = self.verify_candidate()
if reason:
self.write_record("requires_package", reason, ota_result_id=ota_result_id)
return False
self.write_record("pending", "waiting for one candidate boot", ota_result_id=ota_result_id,
source_boot_id=self.boot_id(), attempts=0)
return True
def arm_and_reboot(self, *, reboot: bool = True) -> None:
record = self.read_record()
if not record or record.get("state") != "pending" or record.get("attempts") != 0:
raise RuntimeError("no unused kernel recovery attempt is pending")
recorded_at = record.get("recorded_at")
if isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 180:
self.write_record("failed", "kernel recovery task started too late", attempts=0)
return
if self.boot_id() != record.get("source_boot_id"):
self.write_record("failed", "boot changed before kernel recovery was armed", attempts=0)
return
if self.release() != "6.1.31":
self.write_record("failed", "automatic recovery requires the original 6.1.31 kernel", attempts=0)
return
for _ in range(60):
worker = subprocess.run(["systemctl", "is-active", "--quiet", "matrix-screen-controller-ota.service"],
check=False, timeout=5)
if worker.returncode != 0 and not (self.data_root / "ota/component-transaction").exists():
break
time.sleep(1)
else:
self.write_record("failed", "OTA component transaction did not finish", attempts=1)
return
reason = self.verify_candidate()
if reason:
self.write_record("requires_package", reason, attempts=1)
return
marker = self.host("/boot/matrix-kernel-good")
temporary = marker.with_name(".matrix-kernel-good.recovery")
if marker.exists() or temporary.exists():
self.write_record("failed", "candidate boot marker already exists; manual inspection required", attempts=1)
return
try:
with temporary.open("x", encoding="ascii", newline="\n") as handle:
handle.write(f"{CANDIDATE_RELEASE}\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, marker)
if hasattr(os, "sync"):
os.sync()
self.write_record("pending", "candidate reboot requested", attempts=1,
source_boot_id=record["source_boot_id"], ota_result_id=record.get("ota_result_id", ""))
if reboot:
subprocess.run(["systemctl", "reboot"], check=True, timeout=15)
except Exception as exc:
temporary.unlink(missing_ok=True)
marker.unlink(missing_ok=True)
self.write_record("failed", f"could not request candidate reboot: {exc}", attempts=1)
raise
def finalize_after_boot(self, *, wait_seconds: int = 100) -> None:
record = self.read_record()
if record and record.get("state") == "succeeded" and self.boot_id() != record.get("successful_boot_id"):
for _ in range(wait_seconds):
capability = self.capability()
if capability["release"] != CANDIDATE_RELEASE:
self.write_record("failed", "candidate kernel was lost after a successful recovery", attempts=1)
return
if capability["available"] and capability["marker"]:
self.write_record("succeeded", "candidate kernel and cpufreq passed boot health",
attempts=1, successful_boot_id=self.boot_id())
return
time.sleep(1)
self.write_record("failed", "candidate boot health did not complete", attempts=1)
return
if not record or record.get("state") != "pending" or record.get("attempts") != 1:
return
if self.boot_id() == record.get("source_boot_id"):
return
for _ in range(wait_seconds):
capability = self.capability()
if capability["release"] != CANDIDATE_RELEASE:
self.write_record("failed", "candidate boot fell back to the original kernel", attempts=1)
return
if capability["available"] and capability["marker"]:
self.write_record("succeeded", "candidate kernel and cpufreq passed boot health",
attempts=1, successful_boot_id=self.boot_id())
return
time.sleep(1)
self.write_record("failed", "candidate boot health did not complete", attempts=1)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=("arm-and-reboot", "status"))
parser.add_argument("--data-root", type=Path, default=Path("/var/lib/matrix-screen-controller"))
args = parser.parse_args()
recovery = KernelRecovery(args.data_root)
if args.command == "status":
print(json.dumps(recovery.status(), ensure_ascii=False))
else:
recovery.arm_and_reboot()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -224,6 +224,11 @@ class PerformanceModeManager:
except OSError as exc: except OSError as exc:
self._last_error = f"could not read current governors: {exc}" self._last_error = f"could not read current governors: {exc}"
effective_requested = self._requested if requested is None else requested effective_requested = self._requested if requested is None else requested
error = self._last_error
if not self._policy_paths() and error is None:
error = "当前内核未提供 CPU 调频策略;请检查候选内核是否回退"
elif self._policy_paths() and not self._restore_governors and error is None:
error = "无法读取 CPU 调频策略的原始值"
effective = bool( effective = bool(
effective_requested effective_requested
and current and current
@@ -235,7 +240,7 @@ class PerformanceModeManager:
"effective": effective, "effective": effective,
"current_governors": current, "current_governors": current,
"restore_governors": dict(self._restore_governors), "restore_governors": dict(self._restore_governors),
"last_error": self._last_error, "last_error": error,
} }
@@ -1,4 +1,4 @@
-r requirements.txt -r requirements.txt
pytest==8.4.1 pytest==8.4.1
httpx==0.28.1 httpx==0.28.1
paramiko==4.0.0
@@ -2,3 +2,5 @@ fastapi==0.116.1
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
pillow>=10.0 pillow>=10.0
fonttools>=4.0,<5 fonttools>=4.0,<5
cryptography==46.0.5
dbus-next==0.2.3
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import ipaddress import ipaddress
import json
import os import os
import re import re
import subprocess import subprocess
@@ -15,7 +16,14 @@ PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和
EXAMPLE_CREDENTIAL = PurePosixPath( EXAMPLE_CREDENTIAL = PurePosixPath(
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt" "测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
) )
PRIVATE_MOBILE_REGISTRATION = PurePosixPath(
"移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json"
)
EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json")
BINARY_EXTENSIONS = { BINARY_EXTENSIONS = {
".apk",
".aab",
".jar",
".deb", ".deb",
".dll", ".dll",
".docx", ".docx",
@@ -120,7 +128,7 @@ def _host_text_patterns() -> list[re.Pattern[str]]:
def _private_value_markers() -> tuple[str, ...]: def _private_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts) path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
if not path.is_file(): if not path.is_file():
return () return _mobile_value_markers()
fields: dict[str, str] = {} fields: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines(): for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip() line = raw_line.strip()
@@ -132,13 +140,36 @@ def _private_value_markers() -> tuple[str, ...]:
key, value = (part.strip() for part in line.split(separator, 1)) key, value = (part.strip() for part in line.split(separator, 1))
fields[key] = value fields[key] = value
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS} public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
return tuple( return _mobile_value_markers() + tuple(
value value
for key in CURRENT_DEVICE_KEYS for key in CURRENT_DEVICE_KEYS
if len(value := fields.get(key, "")) >= 4 and value not in public_values if len(value := fields.get(key, "")) >= 4 and value not in public_values
) )
def _mobile_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_MOBILE_REGISTRATION.parts)
if not path.is_file():
return ()
try:
document = json.loads(path.read_text(encoding="utf-8"))
devices = document["devices"]
if not isinstance(devices, list):
raise ValueError
values = []
for device in devices:
if not isinstance(device, dict):
raise ValueError
serial = device.get("serial", "")
if not isinstance(serial, str):
raise ValueError
if serial and "<" not in serial and len(serial) >= 4:
values.append(serial)
return tuple(values)
except (OSError, UnicodeError, ValueError, KeyError, TypeError):
raise HygieneError("移动端真实登记损坏,无法安全提取私有标识;未输出内容。") from None
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]: def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
markers: list[bytes] = [] markers: list[bytes] = []
host_values = { host_values = {
@@ -178,7 +209,7 @@ def _text_issues(
if any(pattern.search(text) for pattern in _host_text_patterns()): if any(pattern.search(text) for pattern in _host_text_patterns()):
issues.append("包含开发电脑专属路径、用户名或主机名") issues.append("包含开发电脑专属路径、用户名或主机名")
if any(value in text for value in private_values): if any(value in text for value in private_values):
issues.append("包含当前设备私有凭据值") issues.append("包含当前设备私有凭据或测试手机标识")
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----") key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
if key_header.search(text): if key_header.search(text):
@@ -217,9 +248,12 @@ def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[
findings: list[tuple[str, str]] = [] findings: list[tuple[str, str]] = []
private_values = _private_value_markers() private_values = _private_value_markers()
for relative in paths: for relative in paths:
if relative == PRIVATE_CREDENTIAL: if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION):
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合")) findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
continue continue
if relative.suffix.lower() in {".jks", ".keystore"}:
findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合"))
continue
path = WORKSPACE_ROOT.joinpath(*relative.parts) path = WORKSPACE_ROOT.joinpath(*relative.parts)
if not path.is_file(): if not path.is_file():
continue continue
@@ -243,6 +277,15 @@ def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False) ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
if ignored.returncode != 0: if ignored.returncode != 0:
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除")) findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
mobile_ignored = _git("check-ignore", "--quiet", "--", PRIVATE_MOBILE_REGISTRATION.as_posix(), check=False)
if mobile_ignored.returncode != 0:
findings.append((PRIVATE_MOBILE_REGISTRATION.as_posix(), "真实手机登记未被 .gitignore 排除"))
if staged:
mobile_example = _git("cat-file", "-e", f":{EXAMPLE_MOBILE_REGISTRATION.as_posix()}", check=False)
if mobile_example.returncode != 0:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 索引"))
elif EXAMPLE_MOBILE_REGISTRATION not in paths:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 候选集合"))
if staged: if staged:
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False) example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
@@ -13,8 +13,6 @@ from typing import Callable, Sequence
EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl" EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl"
DISABLED_SERVICES = ( DISABLED_SERVICES = (
"lightdm.service", "lightdm.service",
"bluetooth.service",
"aw859-bluetooth.service",
"gpioc-server.service", "gpioc-server.service",
"map_device.service", "map_device.service",
) )
@@ -96,10 +96,9 @@ make -C "$STAGING/app/display/native" clean all test
systemctl set-default multi-user.target systemctl set-default multi-user.target
systemctl disable --now \ systemctl disable --now \
lightdm.service \ lightdm.service \
bluetooth.service \
aw859-bluetooth.service \
gpioc-server.service \ gpioc-server.service \
map_device.service map_device.service
DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$STAGING/scripts/install_mobile_bluetooth.sh"
if [ "$DEFER_SERVICE_START" = "1" ]; then if [ "$DEFER_SERVICE_START" = "1" ]; then
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service "$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
else else
@@ -0,0 +1,48 @@
#!/bin/sh
# Enable the existing WalnutPi vendor controller, without changing WiFi rfkill.
set -eu
test "$(id -u)" = 0 || { echo 'root is required' >&2; exit 1; }
test -x /usr/bin/hciattach
test -x /usr/sbin/rfkill
test -f /lib/systemd/system/aw859-bluetooth.service
install -d -m 0755 /etc/systemd/system/aw859-bluetooth.service.d
cat > /etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
[Service]
ExecStartPre=
ExecStartPre=/usr/sbin/modprobe hci_uart
ExecStartPre=/usr/sbin/rfkill unblock bluetooth
ExecStart=
ExecStart=/usr/bin/hciattach -n -s 1500000 /dev/ttyBT0 sprd
Restart=on-failure
RestartSec=5
EOF
install -d -m 0755 /etc/systemd/system/bluetooth.service.d
cat > /etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
[Unit]
After=aw859-bluetooth.service
Wants=aw859-bluetooth.service
EOF
python3 - <<'PY'
from pathlib import Path
import re
p = Path('/etc/bluetooth/main.conf')
text = p.read_text(encoding='utf-8')
backup = p.with_name('main.conf.matrix-mobile-original')
if not backup.exists():
with backup.open('x', encoding='utf-8') as stream:
stream.write(text)
for key, value in [('ControllerMode', 'le'), ('AlwaysPairable', 'false')]:
pattern = r'(?m)^\s*' + key + r'\s*=.*$'
if re.search(pattern, text):
text = re.sub(pattern, key + '=' + value, text)
else:
text = text.replace('[General]', '[General]\n' + key + '=' + value, 1)
p.write_text(text, encoding='utf-8')
PY
systemctl unmask bluetooth.service aw859-bluetooth.service
systemctl daemon-reload
systemctl enable bluetooth.service aw859-bluetooth.service
if [ "${DEFER_SERVICE_START:-0}" != 1 ]; then
# Bluetooth failure must not prevent deploying or running the display service.
systemctl start aw859-bluetooth.service bluetooth.service || echo 'Bluetooth unavailable; inspect mobile status' >&2
fi
@@ -32,6 +32,8 @@ RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRu
SERVICE = 'matrix-screen-controller.service' SERVICE = 'matrix-screen-controller.service'
WORKER = 'matrix-screen-controller-ota.service' WORKER = 'matrix-screen-controller-ota.service'
FRP = 'matrix-screen-frpc.service' FRP = 'matrix-screen-frpc.service'
MOBILE_SERVICES = ('aw859-bluetooth.service', 'bluetooth.service')
MOBILE_FILES = ('bluetooth-conf', 'bluetooth-original', 'bluetooth-dropin', 'aw859-dropin')
JOURNAL = Path('ota/component-transaction') JOURNAL = Path('ota/component-transaction')
FILES = { FILES = {
'frpc': Path('/usr/local/bin/frpc'), 'frpc': Path('/usr/local/bin/frpc'),
@@ -39,6 +41,10 @@ FILES = {
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'), 'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'), 'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'), 'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
'bluetooth-conf': Path('/etc/bluetooth/main.conf'),
'bluetooth-original': Path('/etc/bluetooth/main.conf.matrix-mobile-original'),
'bluetooth-dropin': Path('/etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf'),
'aw859-dropin': Path('/etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf'),
} }
@@ -229,15 +235,18 @@ def begin(source: Path, candidate: Path):
if RUNTIME_GUARD.exists(): if RUNTIME_GUARD.exists():
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复') raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
bundle = source / 'system-dependencies/frpc' bundle = source / 'system-dependencies/frpc'
mobile_installer = source / 'scripts/install_mobile_bluetooth.sh'
if not bundle.is_dir(): if not bundle.is_dir():
check_installed(source, version) check_installed(source, version)
if not mobile_installer.is_file():
return return
# Verify the pinned binary, not just a self-reported checksum file. # Verify the pinned binary, not just a self-reported checksum file.
from app.ota.policy import required_components from app.ota.policy import required_components
if bundle.is_dir():
expected = required_components(source, SoftwareVersion.parse(version))['frpc'] expected = required_components(source, SoftwareVersion.parse(version))['frpc']
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']: if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError('frpc payload differs from the registered dependency') raise RuntimeError('frpc payload differs from the registered dependency')
user = account() user = account() if bundle.is_dir() else None
for root in (DATA, candidate): for root in (DATA, candidate):
if (root / JOURNAL).exists(): if (root / JOURNAL).exists():
raise RuntimeError('存在未完成组件事务,请先恢复') raise RuntimeError('存在未完成组件事务,请先恢复')
@@ -249,6 +258,13 @@ def begin(source: Path, candidate: Path):
'permissions': permission_snapshot(DATA), 'files': {}, 'permissions': permission_snapshot(DATA), 'files': {},
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0, 'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0} 'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
if mobile_installer.is_file():
record['mobile_services'] = {
unit: {
'enabled_state': run(['systemctl', 'is-enabled', unit], check=False).stdout.decode().strip(),
'active': run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0,
} for unit in MOBILE_SERVICES
}
try: try:
for name, path in FILES.items(): for name, path in FILES.items():
record['files'][name] = metadata(path) if path.exists() else None record['files'][name] = metadata(path) if path.exists() else None
@@ -273,11 +289,14 @@ def begin(source: Path, candidate: Path):
'/usr/bin/python3', str(HELPER), 'watch']) '/usr/bin/python3', str(HELPER), 'watch'])
protect_runtime() protect_runtime()
env = os.environ.copy() env = os.environ.copy()
if bundle.is_dir():
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user) env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env) run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
if mobile_installer.is_file():
run(['/bin/sh', str(mobile_installer)], env=env)
mirror_permissions(candidate) mirror_permissions(candidate)
check_installed(source, version) check_installed(source, version)
print('FRP component transaction prepared; waiting for OTA health result') print('System component transaction prepared; waiting for OTA health result')
except BaseException: except BaseException:
# The watcher handles subsequent worker failure. Restore immediately too, # The watcher handles subsequent worker failure. Restore immediately too,
# so a failed migration never leaves new system files while rolling back. # so a failed migration never leaves new system files while rolling back.
@@ -289,6 +308,10 @@ def begin(source: Path, candidate: Path):
def restore_components(journal: Path, record: dict): def restore_components(journal: Path, record: dict):
mobile_services = record.get('mobile_services', {})
for unit in reversed(MOBILE_SERVICES):
if unit in mobile_services:
run(['systemctl', 'stop', unit], check=False)
run(['systemctl', 'stop', FRP], check=False) run(['systemctl', 'stop', FRP], check=False)
for name in ('frpc', 'frpc-unit', 'frpc-dropin'): for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
path = FILES[name] path = FILES[name]
@@ -299,6 +322,15 @@ def restore_components(journal: Path, record: dict):
atomic(path, (journal / name).read_bytes(), item['mode']) atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item) apply_metadata(path, item)
restore_permissions(DATA, record['permissions']) restore_permissions(DATA, record['permissions'])
if mobile_services:
for name in MOBILE_FILES:
path = FILES[name]
item = record['files'][name]
if item is None:
path.unlink(missing_ok=True)
else:
atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item)
run(['systemctl', 'daemon-reload']) run(['systemctl', 'daemon-reload'])
# A previously absent unit cannot be disabled; avoid treating absence as error. # A previously absent unit cannot be disabled; avoid treating absence as error.
if record['files']['frpc-unit'] is not None: if record['files']['frpc-unit'] is not None:
@@ -309,6 +341,24 @@ def restore_components(journal: Path, record: dict):
link.unlink(missing_ok=True) link.unlink(missing_ok=True)
if record['active']: if record['active']:
run(['systemctl', 'start', FRP]) run(['systemctl', 'start', FRP])
for unit in MOBILE_SERVICES:
previous = mobile_services.get(unit)
if previous is None:
continue
run(['systemctl', 'unmask', unit])
state = previous['enabled_state']
run(['systemctl', 'disable', unit], check=False)
if state == 'enabled':
run(['systemctl', 'enable', unit])
elif state == 'enabled-runtime':
run(['systemctl', 'enable', '--runtime', unit])
elif state in ('masked', 'masked-runtime'):
args = ['systemctl', 'mask']
if state == 'masked-runtime':
args.append('--runtime')
run(args + [unit])
if previous['active']:
run(['systemctl', 'start', unit])
def locate_journal(): def locate_journal():
@@ -14,11 +14,13 @@ import sys
import time import time
import traceback import traceback
from typing import Any from typing import Any
import urllib.request
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
from app.ota.package import extract_payload, inspect_package from app.ota.package import extract_payload, inspect_package
from app.ota.state import read_json, utc_now, write_json, write_last_result from app.ota.state import read_json, utc_now, write_json, write_last_result
from app.ota.versioning import SoftwareVersion from app.ota.versioning import SoftwareVersion
from app.system.kernel_recovery import CANDIDATE_RELEASE, KernelRecovery
TARGET = Path("/opt/matrix-screen-controller") TARGET = Path("/opt/matrix-screen-controller")
RELEASES = Path("/opt/matrix-screen-controller.releases") RELEASES = Path("/opt/matrix-screen-controller.releases")
@@ -34,6 +36,32 @@ class UpdateFailed(RuntimeError):
pass pass
def verify_kernel_capability_retained(
before: dict[str, Any],
after: dict[str, Any],
performance_before: dict[str, Any],
performance_after: dict[str, Any],
) -> None:
previously_healthy = (
before.get("release") == CANDIDATE_RELEASE
and before.get("available") is True
and before.get("marker") is True
)
if not previously_healthy:
return
if (after["release"] != CANDIDATE_RELEASE or not after["available"] or not after["marker"]
or set(after["policies"]) != set(before["policies"])):
raise UpdateFailed("OTA lost the validated candidate kernel or cpufreq policy")
requested = performance_before.get("requested")
if (performance_after.get("available") is not True or performance_after.get("requested") is not requested
or (requested is True and performance_after.get("effective") is not True)):
raise UpdateFailed("OTA changed performance mode availability or effective state")
if requested is False and performance_after.get("current_governors") != {
name: value["current"] for name, value in before["policies"].items()
}:
raise UpdateFailed("OTA did not restore the original CPU governors")
def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]: def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]:
records: dict[str, tuple[int, str]] = {} records: dict[str, tuple[int, str]] = {}
ignored = ignored or set() ignored = ignored or set()
@@ -81,6 +109,9 @@ class Transaction:
f"job_id={self.job_id} current_version={self.current_version} " f"job_id={self.job_id} current_version={self.current_version} "
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}" f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
) )
self.kernel_recovery = KernelRecovery(DATA_ROOT)
self.kernel_before: dict[str, Any] | None = None
self.performance_before: dict[str, Any] | None = None
self.job = { self.job = {
"id": self.job_id, "id": self.job_id,
"target_version": str(self.target_version), "target_version": str(self.target_version),
@@ -118,6 +149,13 @@ class Transaction:
) )
def prepare(self) -> None: def prepare(self) -> None:
self.kernel_before = self.kernel_recovery.capability()
with urllib.request.urlopen("http://127.0.0.1:8080/api/status", timeout=8) as response:
self.performance_before = json.loads(response.read().decode("utf-8"))["system"]["performance_mode"]
self.diagnostics.write(
f"kernel_before={self.kernel_before['release']} "
f"cpufreq_available={self.kernel_before['available']} marker={self.kernel_before['marker']}"
)
if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)): 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") raise UpdateFailed("OTA transaction paths already exist")
self.work_root.mkdir(parents=True, mode=0o700) self.work_root.mkdir(parents=True, mode=0o700)
@@ -273,6 +311,15 @@ class Transaction:
raise UpdateFailed("new service reports the wrong hardware mapping") raise UpdateFailed("new service reports the wrong hardware mapping")
if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0: 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") raise UpdateFailed("new service did not pass the PWM4 OE health check")
before = self.kernel_before or {}
if (before.get("available") and before.get("marker")
and before.get("release") == CANDIDATE_RELEASE):
verify_kernel_capability_retained(
before, self.kernel_recovery.capability(), self.performance_before or {},
(status.get("system") or {}).get("performance_mode") or {},
)
else:
self.diagnostics.write("kernel_preexisting_degraded=true; application update may proceed")
self.diagnostics.write( self.diagnostics.write(
"health_summary=" "health_summary="
f"version:{status.get('service', {}).get('software_version')} " f"version:{status.get('service', {}).get('software_version')} "
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Exercise the independent recovery task after a real systemd service stop."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import time
from uuid import uuid4
def run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30)
def main() -> int:
if os.geteuid() != 0 or not Path("/run/systemd/system").is_dir():
raise RuntimeError("real systemd lifecycle test requires root on a systemd host")
token = uuid4().hex[:12]
directory_name = f"matrix-kernel-recovery-test-{token}"
main_unit = f"{directory_name}-main"
task_unit = f"{directory_name}-task"
runtime = Path("/run") / directory_name
if runtime.exists():
raise RuntimeError("isolated runtime directory already exists")
try:
run("systemd-run", "--quiet", f"--unit={main_unit}",
f"--property=RuntimeDirectory={directory_name}",
"--property=RuntimeDirectoryPreserve=yes", "/bin/sleep", "120")
for _ in range(30):
if runtime.is_dir():
break
time.sleep(0.1)
else:
raise RuntimeError("temporary service did not create its runtime directory")
if run("systemctl", "show", main_unit, "--property=RuntimeDirectoryPreserve", "--value").stdout.strip() != "yes":
raise RuntimeError("temporary service did not enable runtime preservation")
run("systemctl", "stop", main_unit)
if not runtime.is_dir():
raise RuntimeError("systemd removed the runtime directory after service stop")
proof = runtime / "independent-task.txt"
run("systemd-run", "--quiet", "--wait", "--collect", f"--unit={task_unit}",
"/usr/bin/python3", "-c",
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text('ok', encoding='ascii')",
str(proof))
if proof.read_text(encoding="ascii") != "ok":
raise RuntimeError("independent task did not survive the main service stop")
print("real systemd recovery lifecycle passed")
return 0
finally:
subprocess.run(["systemctl", "stop", main_unit], check=False, capture_output=True, timeout=15)
if runtime.is_dir() and runtime.parent == Path("/run") and runtime.name == directory_name:
shutil.rmtree(runtime)
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=WalnutPi H618 Matrix Screen Controller Description=WalnutPi H618 Matrix Screen Controller
After=NetworkManager.service After=NetworkManager.service
Wants=NetworkManager.service Wants=NetworkManager.service bluetooth.service aw859-bluetooth.service
[Service] [Service]
Type=simple Type=simple
@@ -11,6 +11,7 @@ Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller
Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller
Environment=PYTHONDONTWRITEBYTECODE=1 Environment=PYTHONDONTWRITEBYTECODE=1
Environment=MATRIX_MEDIA_WORKER_MODE=systemd Environment=MATRIX_MEDIA_WORKER_MODE=systemd
Environment=MATRIX_BLE_ENABLED=1
StateDirectory=matrix-screen-controller StateDirectory=matrix-screen-controller
StateDirectoryMode=0711 StateDirectoryMode=0711
RuntimeDirectory=matrix-screen-controller RuntimeDirectory=matrix-screen-controller
+1 -1
View File
@@ -86,7 +86,7 @@ def test_status_config_and_display_api(tmp_path):
"effective": False, "effective": False,
"current_governors": {}, "current_governors": {},
"restore_governors": {}, "restore_governors": {},
"last_error": None, "last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
} }
assert status.json()["state"]["display_test"] == { assert status.json()["state"]["display_test"] == {
"active": False, "active": False,
@@ -49,6 +49,13 @@ def test_check_profile_accepts_walnutpi_contract(tmp_path):
] ]
def test_bluetooth_is_not_a_display_startup_blocker(tmp_path):
def runner(args):
assert not any('bluetooth' in part for part in args)
return healthy_runner(args)
assert all(check.ok for check in check_profile(make_paths(tmp_path), runner=runner))
def test_service_preflight_does_not_require_default_route(tmp_path): def test_service_preflight_does_not_require_default_route(tmp_path):
def runner(args): def runner(args):
assert tuple(args) != ("ip", "route", "show", "default") assert tuple(args) != ("ip", "route", "show", "default")
@@ -1,7 +1,14 @@
import hashlib
from pathlib import Path from pathlib import Path
import subprocess
import pytest import pytest
from app.system import kernel_recovery as kr
from app.ota.manager import OtaManager
from app.ota.state import write_json, write_last_result
from app.ota.versioning import SoftwareVersion
from scripts.ota_worker import UpdateFailed, verify_kernel_capability_retained
from scripts.axp313a_kernel_health import ( from scripts.axp313a_kernel_health import (
HealthError, HealthError,
check_cpufreq, check_cpufreq,
@@ -11,6 +18,159 @@ from scripts.axp313a_kernel_health import (
from scripts.render_dual_kernel_boot import BootScriptError, render from scripts.render_dual_kernel_boot import BootScriptError, render
def make_recovery(tmp_path: Path, monkeypatch) -> kr.KernelRecovery:
host = tmp_path / "host"
data = tmp_path / "data"
boot = host / "boot"
boot.mkdir(parents=True)
files = {
"Image-matrix-axp313a1": b"candidate-image",
"sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": b"candidate-sd-dtb",
"sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": b"candidate-emmc-dtb",
}
monkeypatch.setattr(kr, "BOOT_HASHES", {name: hashlib.sha256(value).hexdigest() for name, value in files.items()})
for name, value in files.items():
(boot / name).write_bytes(value)
for name in ("Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb",
"boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS"):
(boot / name).write_bytes(b"original")
(boot / "boot.cmd").write_text(
"matrix-kernel-good matrix-kernel-good matrix_kernel_candidate=1",
encoding="utf-8",
)
(boot / "boot.scr").write_bytes(b"matrix_kernel_candidate=1 Image-matrix-axp313a1")
health = host / "etc/systemd/system/matrix-axp313a-health.service"
health.parent.mkdir(parents=True)
health.write_text("ConditionKernelCommandLine=matrix_kernel_candidate=1", encoding="utf-8")
script = host / "opt/matrix-screen-controller-system/axp313a_kernel_health.py"
script.parent.mkdir(parents=True)
script.write_text("health", encoding="utf-8")
enabled = host / "etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service"
enabled.parent.mkdir(parents=True)
enabled.write_text("enabled", encoding="utf-8")
modules = host / "lib/modules" / kr.CANDIDATE_RELEASE
(modules / "kernel").mkdir(parents=True)
(modules / "modules.dep").write_text("index", encoding="ascii")
module = modules / "kernel/example.ko"
module.write_bytes(b"module")
digest = hashlib.sha256(b"kernel/example.ko\0" + hashlib.sha256(b"module").digest()).hexdigest()
monkeypatch.setattr(kr, "MODULE_COUNT", 1)
monkeypatch.setattr(kr, "MODULE_DIGEST", digest)
release = host / "proc/sys/kernel/osrelease"
release.parent.mkdir(parents=True)
release.write_text("6.1.31", encoding="ascii")
boot_id = host / "proc/sys/kernel/random/boot_id"
boot_id.parent.mkdir(parents=True)
boot_id.write_text("old-boot", encoding="ascii")
return kr.KernelRecovery(data, host_root=host)
def test_candidate_validation_and_single_recovery_attempt(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
assert recovery.verify_candidate() is None
assert recovery.prepare_attempt("1.2.0:time") is True
assert recovery.prepare_attempt("1.2.0:time") is False
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1))
recovery.arm_and_reboot(reboot=False)
assert recovery.status()["state"] == "pending"
with pytest.raises(RuntimeError, match="no unused"):
recovery.arm_and_reboot(reboot=False)
recovery.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii")
recovery.host("/proc/sys/kernel/osrelease").write_text(kr.CANDIDATE_RELEASE, encoding="ascii")
policy = recovery.host("/sys/devices/system/cpu/cpufreq/policy0")
policy.mkdir(parents=True)
(policy / "scaling_governor").write_text("schedutil", encoding="ascii")
(policy / "scaling_available_governors").write_text("schedutil performance", encoding="ascii")
recovery.finalize_after_boot(wait_seconds=1)
assert recovery.status()["state"] == "succeeded"
def test_recovery_failure_and_damaged_candidate_do_not_retry(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
recovery.host("/boot/Image-matrix-axp313a1").write_bytes(b"damaged")
assert recovery.prepare_attempt("1.2.0:time") is False
assert recovery.status()["state"] == "requires_package"
assert recovery.prepare_attempt("1.2.1:later") is False
other = make_recovery(tmp_path / "other", monkeypatch)
assert other.prepare_attempt("1.2.0:time") is True
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1))
other.arm_and_reboot(reboot=False)
other.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii")
other.finalize_after_boot(wait_seconds=1)
assert other.status()["state"] == "failed"
assert other.prepare_attempt("1.2.1:later") is False
def test_scheduled_recovery_that_never_runs_becomes_persistent_failure(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
assert recovery.prepare_attempt("1.2.0:time") is True
monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181)
assert recovery.status()["state"] == "failed"
assert recovery.read_record()["state"] == "failed"
assert recovery.prepare_attempt("1.2.1:later") is False
def test_delayed_recovery_task_does_not_reboot(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
assert recovery.prepare_attempt("1.2.0:time") is True
monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181)
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: pytest.fail("late task ran a command"))
recovery.arm_and_reboot()
assert recovery.read_record()["state"] == "failed"
def test_ota_rejects_new_cpufreq_loss_and_keeps_preexisting_degradation():
before = {"release": kr.CANDIDATE_RELEASE, "available": True, "marker": True,
"policies": {"policy0": {"current": "schedutil", "performance": True}}}
after = {**before, "policies": {}, "available": False}
off = {"requested": False, "available": True, "effective": False,
"current_governors": {"policy0": "schedutil"}}
with pytest.raises(UpdateFailed, match="lost the validated"):
verify_kernel_capability_retained(before, after, off, off)
verify_kernel_capability_retained({**before, "available": False}, after, off, off)
with pytest.raises(UpdateFailed, match="governors"):
verify_kernel_capability_retained(before, before, off, {**off, "current_governors": {"policy0": "performance"}})
def test_legacy_worker_completion_schedules_lightweight_recovery(tmp_path, monkeypatch):
class Display:
def start_ota_indicator(self, _path):
pass
def stop_ota_indicator(self):
pass
class Recovery:
def __init__(self):
self.attempts = []
def status(self):
return {"state": "ready", "reason": ""}
def prepare_attempt(self, result_id):
self.attempts.append(result_id)
return len(self.attempts) == 1
recovery = Recovery()
manager = OtaManager(
code_root=tmp_path / "code", data_root=tmp_path / "data", runtime_root=tmp_path / "run",
software_version=SoftwareVersion.parse("1.2.0"), display=Display(),
)
monkeypatch.setattr(manager, "_production_recovery", lambda: recovery)
calls = []
monkeypatch.setattr("app.ota.manager.subprocess.run", lambda command, **kwargs: calls.append(command))
write_json(manager.status_path, {"schema_version": 1, "active": True, "job": {"id": "old-worker"}})
assert manager.resume_or_start_monitor(manager.schedule_kernel_recovery_after_ota)
write_last_result(manager.data_root, {"status": "success", "target_version": "1.2.0",
"installed_at": "2026-09-26T08:00:00Z"})
write_json(manager.status_path, {"schema_version": 1, "active": False, "job": {"id": "old-worker"}})
manager._monitor_thread.join(timeout=3)
assert recovery.attempts == ["1.2.0:2026-09-26T08:00:00Z"]
assert len(calls) == 1
assert "systemd-run" in calls[0]
BOOT_SOURCE = '''# DO NOT EDIT THIS FILE BOOT_SOURCE = '''# DO NOT EDIT THIS FILE
setenv docker_optimizations "on" setenv docker_optimizations "on"
setenv bootargs "root=/dev/mmcblk1p2" setenv bootargs "root=/dev/mmcblk1p2"
@@ -0,0 +1,167 @@
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.mobile.session import RpcError
def test_identity_persistence_and_rename(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
before = mobile.identity()
renamed = mobile.dispatch('device.rename', {'name': '客厅小屏幕'})
assert renamed['device_id'] == before['device_id']
assert renamed['device_name'] == '客厅小屏幕'
other = create_app(project_root=tmp_path, driver_kind='mock')
assert other.state.mobile_control.identity() == renamed
def test_web_change_invalidates_mobile_revision(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
settings = mobile.dispatch('settings.get', {})
client = TestClient(app)
assert client.put('/api/config', json={'brightness': 37}).status_code == 200
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('settings.patch', {'expected_revision': settings['revision'], 'changes': {'brightness': 20}})
revision = mobile.settings()['revision']
mobile.dispatch('settings.patch', {'expected_revision': revision, 'changes': {'brightness': 25}})
assert client.get('/api/config').json()['brightness'] == 25
def test_library_and_frame_readback(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
first = mobile.dispatch('library.list', {'limit': 1})
assert len(first['items']) == 1 and first['next_cursor']
second = mobile.dispatch('library.list', {'cursor': first['next_cursor'], 'limit': 1})
assert second['items'][0]['id'] != first['items'][0]['id']
item = first['items'][0]
assert mobile.dispatch('library.thumbnail', item)['mime'] == 'image/png'
mobile.dispatch('content.play', item)
frame = mobile.dispatch('frame.get', {})
assert mobile.dispatch('frame.get', {'known_revision': frame['frame_revision']})['unchanged']
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('content.play', {**item, 'revision': 'old'})
def test_forbidden_operation_and_settings_fields(tmp_path):
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
with pytest.raises(RpcError, match='UNSUPPORTED_CAPABILITY'):
mobile.dispatch('shell.run', {'command': 'anything'})
with pytest.raises(RpcError, match='BAD_REQUEST'):
mobile.dispatch('settings.patch', {'expected_revision': mobile.settings()['revision'], 'changes': {'low_voltage_protection_enabled': False}})
def test_wifi_open_network_and_password_not_returned(tmp_path):
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
revision = mobile.dispatch('wifi.get', {})['revision']
result = mobile.dispatch('wifi.set', dict(expected_revision=revision, ssid='Test Open', security='open',
password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot'))
assert mobile.dispatch('task.get', {'task_id': result['task_id']})['state'] == 'succeeded'
wifi = mobile.dispatch('wifi.get', {})
assert 'password' not in wifi['saved']
assert not wifi['saved']['password_configured']
assert wifi['saved']['security'] == 'open'
assert wifi['revision'] != revision
mobile.dispatch('wifi.set', dict(expected_revision=wifi['revision'], ssid='Test Private', security='wpa-psk',
password_action='replace', password='public-test-fixture', ipv4_mode='dhcp', activation='next_boot'))
wifi = mobile.dispatch('wifi.get', {})
assert 'password' not in wifi['saved'] and wifi['saved']['password_configured']
def test_scan_escaped_ssid_deduplicates_and_marks_unsupported():
from app.network.manager import NmcliNetworkManager
class Stub(NmcliNetworkManager):
def _run(self, args, *, timeout=10):
assert timeout == 15 and '--rescan' in args
return '*:Test\\:WiFi:WPA2:42\n:Test\\:WiFi:WPA2:81\n:Open:--:37\n:EAP:WPA2 802.1X:80'
rows = Stub().scan()
assert rows[0] == dict(ssid='Test:WiFi', security='wpa-psk', signal_percent=81, connected=True)
assert rows[1]['security'] == 'unsupported'
assert rows[2]['security'] == 'open'
def test_mobile_default_and_animation_controls_share_web_state(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
with TestClient(app) as client:
mobile = app.state.mobile_control
items = mobile.dispatch('library.list', {})['items']
animation = next(item for item in items if item['type'] == 'animation' and item['playable'])
mobile.dispatch('content.default.set', animation)
assert mobile.dispatch('content.default.get', {})['id'] == animation['id']
assert client.get('/api/display/default-content').json()['id'] == animation['id']
playback = client.get('/api/status').json()['state']['animation_playback']
controlled = mobile.dispatch('playback.patch', dict(
session_id=playback['session_id'], paused=True, position_ms=0, speed=1.5))
assert controlled['animation_playback']['paused']
current = client.get('/api/status').json()['state']['animation_playback']
assert current['position_ms'] == 0 and current['speed'] == 1.5
mobile.dispatch('content.play', animation)
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('playback.patch', dict(session_id=playback['session_id'], paused=False))
def test_mobile_library_uses_persistent_mixed_order_and_invalidates_cursor(tmp_path):
from app.demo_library import demo_template
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
templates, animations = mobile.templates, mobile.animations
import base64
t = templates.create('user-template', {'version': 1, 'width': 64, 'height': 64, 'pixelRgb': base64.b64encode(bytes(64*64*3)).decode('ascii'), 'elements': []})
# Empty animations are valid library items, even though not playable.
a = animations.create('user-animation')
defaults = [{'type': 'template', 'id': t['id']}, {'type': 'animation', 'id': a['id']}]
store = app.state.library_order_store
current = store.get(defaults)
first = mobile.dispatch('library.list', {'limit': 1})
order = list(reversed(defaults))
store.update(order, default_items=defaults, expected_revision=current['revision'])
items = mobile.dispatch('library.list', {})['items']
assert [{'type': i['type'], 'id': i['id']} for i in items if not i['is_demo']] == order
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.list', {'cursor': first['next_cursor']})
@pytest.mark.parametrize('reason,expected', [(53, 'NETWORK_UNAVAILABLE'), (5, 'IP_CONFIG_FAILED'), (11, 'UNKNOWN'), (7, 'UNKNOWN')])
def test_wifi_failure_classification_does_not_guess_authentication(monkeypatch, reason, expected):
import subprocess
from app.network.manager import NmcliNetworkManager, NetworkManagerError
class Backend(NmcliNetworkManager):
def _run(self, args, *, timeout=10):
if 'up' in args:
raise NetworkManagerError('sanitized')
return str(reason) + ' (reason)'
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, '', ''))
with pytest.raises(NetworkManagerError) as error:
Backend().activate('public-test-id')
assert error.value.error_code == expected
def test_wifi_auth_requires_explicit_wrong_key_event(monkeypatch):
import subprocess
from app.network.manager import NmcliNetworkManager, NetworkManagerError
class Backend(NmcliNetworkManager):
def _run(self, args, *, timeout=10):
if 'up' in args: raise NetworkManagerError('sanitized')
return '7 (no-secrets)'
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0,
'wlan0: CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="fixture" auth_failures=1 duration=10 reason=WRONG_KEY', ''))
with pytest.raises(NetworkManagerError) as error:
Backend().activate('public-test-id')
assert error.value.error_code == 'AUTH_FAILED'
assert str(error.value) == 'WiFi activation failed'
@pytest.mark.parametrize('failure', ['AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN'])
def test_task_returns_only_sanitized_failure_code(tmp_path, failure):
from app.network.manager import NetworkManagerError
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
def fail(*args, **kwargs):
raise NetworkManagerError('private backend detail must not escape', error_code=failure)
mobile.dispatch('wifi.set', dict(expected_revision=mobile.wifi()['revision'], ssid='Test Open', security='open', password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot'))
mobile.network.backend.activate = fail
task = 'public-test-task'
mobile._activate(task)
result = mobile.dispatch('task.get', {'task_id': task})
assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure}
assert 'private' not in str(result)
@@ -0,0 +1,88 @@
import json
from pathlib import Path
import pytest
from app.mobile.protocol import Handshake, ProtocolError, Reassembler, fragments, json_object
from cryptography.hazmat.primitives.asymmetric import ec
@pytest.mark.parametrize("mtu", [23, 64, 247, 517])
def test_fragments_roundtrip_and_replay(mtu):
data = ("奇妙小屏幕" * 200).encode()
parts = list(fragments(3, 0, data, mtu))
receiver = Reassembler()
for part in parts[:-1]:
assert receiver.accept(part) is None
assert receiver.accept(parts[-1]) == (3, data)
with pytest.raises(ProtocolError):
receiver.accept(parts[0])
def test_fragment_reordering_and_deadline():
parts = list(fragments(1, 0, b"hello world", 23))
with pytest.raises(ProtocolError):
Reassembler().accept(parts[1])
now = [0.0]
receiver = Reassembler(lambda: now[0])
receiver.accept(parts[0])
now[0] = 15
with pytest.raises(ProtocolError, match="TIMEOUT"):
receiver.accept(parts[1])
def pair():
client, server = Handshake(), Handshake()
return client.finish(server.hello, server=False), server.finish(client.hello, server=True)
def test_bidirectional_encryption_and_replay():
client, server = pair()
wire = client.encrypt('你好'.encode())
assert server.decrypt(wire) == '你好'.encode()
assert client.decrypt(server.encrypt(b'reply')) == b'reply'
with pytest.raises(ProtocolError):
server.decrypt(wire)
with pytest.raises(ProtocolError):
server.decrypt(client.encrypt(b'next'))
def test_tamper_direction_and_connection_isolation():
client, server = pair()
wire = client.encrypt(b'command')
with pytest.raises(ProtocolError):
client.decrypt(wire)
with pytest.raises(ProtocolError):
server.decrypt(wire[:-1] + bytes([wire[-1] ^ 1]))
_, other = pair()
with pytest.raises(ProtocolError):
other.decrypt(wire)
def test_invalid_hello_and_json():
handshake = Handshake()
hello = json.loads(handshake.hello)
hello['protocol_major'] = 2
with pytest.raises(ProtocolError, match='UNSUPPORTED_VERSION'):
handshake.finish(json.dumps(hello).encode(), server=True)
for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'[]', b'\xff'):
with pytest.raises(ProtocolError):
json_object(raw)
def test_shared_golden_vector():
path = Path(__file__).resolve().parents[2] / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json'
vector = json.loads(path.read_text(encoding='utf-8'))
def raw(key):
return bytes.fromhex(vector[key])
client = Handshake(private_key=ec.derive_private_key(int(vector['client_scalar']), ec.SECP256R1()), random_bytes=raw('client_random'))
server = Handshake(private_key=ec.derive_private_key(int(vector['server_scalar']), ec.SECP256R1()), random_bytes=raw('server_random'))
assert client.hello == raw('client_hello')
assert server.hello == raw('server_hello')
cc, sc = client.finish(server.hello, server=False), server.finish(client.hello, server=True)
assert cc.transcript == raw('transcript')
assert cc.encrypt(raw('request')) == raw('client_record')
assert sc.encrypt(raw('response')) == raw('server_record')
assert sc.decrypt(raw('client_record')) == raw('request')
assert cc.decrypt(raw('server_record')) == raw('response')
assert [p.hex() for p in fragments(3, 0, raw('client_record'), 23)] == vector['fragments_mtu23']
@@ -0,0 +1,74 @@
import json
import pytest
from app.mobile.protocol import Handshake, ProtocolError, fragments
from app.mobile.session import SessionManager
def connection(clock=lambda: 0):
calls = []
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"}, clock=clock)
assert manager.connect('a')
assert not manager.connect('b')
client = Handshake()
result = None
for part in fragments(1, 0, client.hello, 247):
result = manager.accept('a', part)
cipher = client.finish(result[1], server=False)
return manager, cipher, calls
def request(manager, cipher, mid, rid, method, params=None):
raw = json.dumps(dict(id=rid, method=method, params=params or {})).encode()
for part in fragments(3, mid, cipher.encrypt(raw), 247):
result = manager.accept('a', part)
return json.loads(cipher.decrypt(result[1]))
def test_no_control_before_open():
manager, cipher, calls = connection()
with pytest.raises(ProtocolError):
request(manager, cipher, 1, '1', 'settings.patch')
assert calls == []
def test_single_owner_duplicate_and_disconnect():
manager, cipher, calls = connection()
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
assert manager.status()['client_name'] == '手机'
assert request(manager, cipher, 2, '2', 'content.play')['ok']
assert not request(manager, cipher, 3, '2', 'content.play')['ok']
assert calls == ['content.play']
manager.disconnect('b')
assert manager.status()['connected']
manager.disconnect('a')
assert not manager.status()['connected']
assert manager.connect('b')
def test_handshake_and_idle_deadlines():
now = [0]
manager, cipher, _ = connection(lambda: now[0])
now[0] = 10
assert manager.expired()
manager, cipher, _ = connection(lambda: now[0])
request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})
now[0] = 29
assert not manager.expired()
now[0] = 30
assert manager.expired()
def test_negotiated_receive_mtu_resets_on_disconnect():
manager, cipher, _ = connection()
assert manager.peer_mtu == 23
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': 247})['ok']
assert manager.peer_mtu == 247
manager.disconnect('a')
assert manager.peer_mtu == 23
@pytest.mark.parametrize('mtu', [0, 22, 518, True, '247'])
def test_invalid_receive_mtu_never_opens_session(mtu):
manager, cipher, _ = connection()
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok']
assert not manager.opened
@@ -133,6 +133,31 @@ def test_failed_install_restores_system_bytes_and_running_state(host):
assert ['systemctl', 'start', c.FRP] in host assert ['systemctl', 'start', c.FRP] in host
def test_mobile_rollback_restores_files_masks_and_running_state(host):
journal, record = make_journal()
record['mobile_services'] = {
'aw859-bluetooth.service': {'enabled_state': 'masked', 'active': False},
'bluetooth.service': {'enabled_state': 'enabled', 'active': True},
}
record['files']['bluetooth-original'] = None
for name in c.MOBILE_FILES:
c.FILES[name].write_bytes(b'changed-by-mobile-install')
c.restore_components(journal, record)
assert not c.FILES['bluetooth-original'].exists()
for name in set(c.MOBILE_FILES) - {'bluetooth-original'}:
assert c.FILES[name].read_bytes() == ('old-' + name).encode()
assert ['systemctl', 'mask', 'aw859-bluetooth.service'] in host
assert ['systemctl', 'enable', 'bluetooth.service'] in host
assert ['systemctl', 'start', 'bluetooth.service'] in host
assert ['systemctl', 'start', 'aw859-bluetooth.service'] not in host
def test_legacy_component_journal_does_not_touch_bluetooth(host):
journal, record = make_journal()
c.restore_components(journal, record)
assert not any(unit in args for args in host for unit in c.MOBILE_SERVICES)
def test_power_loss_between_data_renames_recovers_original(host): def test_power_loss_between_data_renames_recovers_original(host):
journal, record = make_journal() journal, record = make_journal()
(c.DATA/'user-content').write_bytes(b'unchanged') (c.DATA/'user-content').write_bytes(b'unchanged')
@@ -181,6 +206,35 @@ def test_normal_migration_does_not_touch_components(host):
assert host == [] assert host == []
def test_mobile_only_patch_enters_durable_component_transaction(host, monkeypatch):
from types import SimpleNamespace
source = c.RELEASES / '1.1.2-mobilepatch'
source.mkdir()
(source / 'VERSION').write_text('1.1.2', encoding='utf-8')
(source / 'scripts').mkdir()
(source / 'scripts/install_mobile_bluetooth.sh').write_text('# test fixture', encoding='utf-8')
candidate = c.DATA.with_name('matrix-screen-controller.ota.mobilepatch')
candidate.mkdir()
c.RUNTIME.mkdir(parents=True, exist_ok=True)
(c.RUNTIME / 'ota-request.json').write_text(json.dumps({
'job_id': 'mobilepatch', 'current_version': '1.1.1', 'target_version': '1.1.2'
}), encoding='utf-8')
monkeypatch.setattr(c.os, 'geteuid', lambda: 0, raising=False)
monkeypatch.setattr(c.os, 'uname', lambda: SimpleNamespace(machine='aarch64'), raising=False)
monkeypatch.setattr(c, 'check_installed', lambda *args: None)
monkeypatch.setattr(c, 'protect_runtime', lambda: None)
monkeypatch.setattr(c, 'mirror_permissions', lambda *args: None)
# Host test verifies transaction ordering; POSIX durability is covered by
# real Linux lifecycle checks, not Windows read-only descriptor fsync.
monkeypatch.setattr(c.os, 'fsync', lambda *args: None)
c.begin(source, candidate)
record = json.loads((c.DATA / c.JOURNAL / 'state.json').read_text(encoding='utf-8'))
assert set(record['mobile_services']) == set(c.MOBILE_SERVICES)
assert (candidate / c.JOURNAL / 'state.json').is_file()
assert ['/bin/sh', str(source / 'scripts/install_mobile_bluetooth.sh')] in host
assert not any('install_frpc_system.sh' in str(arg) for args in host for arg in args)
def release_project(tmp_path, monkeypatch): def release_project(tmp_path, monkeypatch):
from scripts import export_release as exporter from scripts import export_release as exporter
source = tmp_path / 'software' source = tmp_path / 'software'
@@ -114,7 +114,7 @@ def test_unavailable_cpufreq_is_reported_without_fake_success(tmp_path):
"effective": False, "effective": False,
"current_governors": {}, "current_governors": {},
"restore_governors": {}, "restore_governors": {},
"last_error": None, "last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
} }
with pytest.raises(PerformanceModeError, match="unavailable"): with pytest.raises(PerformanceModeError, match="unavailable"):
manager.transact(True, lambda _enabled: None) manager.transact(True, lambda _enabled: None)
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import json
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import pytest import pytest
@@ -110,3 +111,30 @@ def test_repository_contract_has_example_and_ignored_private_file():
assert hygiene.EXAMPLE_CREDENTIAL in paths assert hygiene.EXAMPLE_CREDENTIAL in paths
assert hygiene.PRIVATE_CREDENTIAL not in paths assert hygiene.PRIVATE_CREDENTIAL not in paths
assert hygiene._check_repository_contract(paths, staged=False) == [] assert hygiene._check_repository_contract(paths, staged=False) == []
def test_mobile_markers_are_checked_even_without_board_credentials(tmp_path, monkeypatch):
monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path)
private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts)
private.parent.mkdir(parents=True)
private.write_text(json.dumps({"devices": [{"serial": "FAKE-SERIAL-PRIVATE-123"}]}), encoding="utf-8")
markers = hygiene._private_value_markers()
assert "FAKE-SERIAL-PRIVATE-123" in markers
assert hygiene._text_issues(PurePosixPath("docs/report.md"), "FAKE-SERIAL-PRIVATE-123", markers)
def test_corrupt_mobile_registration_fails_closed(tmp_path, monkeypatch):
monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path)
private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts)
private.parent.mkdir(parents=True)
private.write_text("broken", encoding="utf-8")
with pytest.raises(hygiene.HygieneError, match="移动端真实登记损坏"):
hygiene._private_value_markers()
def test_mobile_private_and_signing_files_are_rejected():
findings = hygiene.audit_paths(
[hygiene.PRIVATE_MOBILE_REGISTRATION, PurePosixPath("keys/debug.keystore")],
scan_binary=False,
)
assert len(findings) == 2
+15 -1
View File
@@ -7,11 +7,25 @@ import pytest
from app.config.store import ConfigStore from app.config.store import ConfigStore
from app.display.service import DisplayService from app.display.service import DisplayService
from app.display.wifi_indicator import render_wifi_indicator from app.display.wifi_indicator import render_wifi_indicator
from app.network.manager import MockNetworkManager from app.network.manager import MockNetworkManager, NmcliNetworkManager
from app.network.service import WifiNetworkService, validate_wifi_settings from app.network.service import WifiNetworkService, validate_wifi_settings
from app.network.store import WifiConfigError, WifiConfigStore from app.network.store import WifiConfigError, WifiConfigStore
@pytest.mark.parametrize('raw_dns', ['192.0.2.1,192.0.2.2', '192.0.2.1\n192.0.2.2', '192.0.2.1, 192.0.2.2\n192.0.2.1'])
def test_nmcli_multiple_dns_can_roundtrip_without_password(monkeypatch, raw_dns):
backend = NmcliNetworkManager()
values = {'802-11-wireless.ssid': 'test-network', 'ipv4.method': 'manual',
'ipv4.addresses': '192.0.2.20/24', 'ipv4.gateway': '192.0.2.1',
'ipv4.dns': raw_dns, '802-11-wireless-security.key-mgmt': 'wpa-psk'}
monkeypatch.setattr(backend, '_connection_value', lambda uuid, field, **kwargs: values.get(field, ''))
saved = backend.read_saved('test-profile')
assert saved['dns_servers'] == ['192.0.2.1', '192.0.2.2']
checked = validate_wifi_settings(saved, previous=saved)
assert checked['dns_servers'] == saved['dns_servers']
assert checked['password'] is None
UUID = "12345678-1234-5678-1234-567812345678" UUID = "12345678-1234-5678-1234-567812345678"
@@ -2,6 +2,18 @@
本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。 本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。
## 视觉检查的适用范围与启动门禁
普通网页前端、手机界面、BLE 控制入口开发未改变驱动或显示输出实现时,不新增实屏视觉验收,优先协议、状态、逻辑帧与驱动统计。不需要视觉检查记“不适用”,不阻塞交付。只有驱动、扫描时序、底层输出改变或具体异常确需视觉证据时才提出。
**任何视觉检查开始前必须停止相关步骤、说明原因、询问用户下一步如何启动,并等待本次明确指示。** 不自动启动 DroidCam、摄像头、采集或视觉测试程序,也不主动切换实屏测试图案。专用测试手机的一般授权不替代此门禁。下文已有实屏/摄像头步骤仅在适用且取得本次启动指示后执行;“画面清晰由 Codex 判断”不是提前启动授权。接线、断电和调压仍另行逐阶段等待。
## 移动端接入测试
NetworkManager DNS 往返:`tests/test_wifi.py::test_nmcli_multiple_dns_can_roundtrip_without_password` 覆盖逗号、换行和混合重复 DNS 返回,要求读回独立地址数组、保持密码即可通过配置校验。实机按移动端运行器的 `--reapply-current-wifi` 显式步骤验证原网络立即应用;不得将该项写成不同网络、密码替换或 DHCP/静态切换全部通过。
`TEST-MOBILE-INTEGRATION` 覆盖 `整体开发需求/03_移动端接入需求.md`,详细矩阵见 `移动端相关内容/安卓app/如何安卓测试/README.md`。分别验证公共业务回归、加密与分片、手机单会话、网页顶栏连接提示、WiFi 扫描/切换、资源与刷新统计、真实 systemd 恢复及私有登记门禁。BLE 软件接入本身不默认触发实屏视觉检查。双手机首次由用户安装 APK 辅助,之后相关连接逻辑变动才重测;未实测不得写成通过。
## 0. 测试映射 ## 0. 测试映射
| 测试编号 | 主要覆盖 | 执行位置 | 人工停顿 | | 测试编号 | 主要覆盖 | 执行位置 | 人工停顿 |
@@ -30,6 +42,7 @@
| `TEST-OTA-LOCAL` | 包协议、正式版本与功能时间、版本拒绝、迁移、回滚、进度和前端交互 | Windows / mock | 否 | | `TEST-OTA-LOCAL` | 包协议、正式版本与功能时间、版本拒绝、迁移、回滚、进度和前端交互 | Windows / mock | 否 |
| `TEST-SYSTEM-INDICATOR-PIXELS` | `DISPLAY-OTA-INDICATOR`、`DISPLAY-WIFI-INDICATOR` 的 OTA、联网 IP 与断网提示像素完整性 | Windows / mock + 核桃派/HUB75 | 用户逐画面确认 | | `TEST-SYSTEM-INDICATOR-PIXELS` | `DISPLAY-OTA-INDICATOR`、`DISPLAY-WIFI-INDICATOR` 的 OTA、联网 IP 与断网提示像素完整性 | Windows / mock + 核桃派/HUB75 | 用户逐画面确认 |
| `TEST-OTA-REAL` | 浏览器上传、40% 更新覆盖、90% 居中画面、真实切换、数据保全和清理 | 核桃派 + HUB75 | 只在画面无法由摄像头判断时确认 | | `TEST-OTA-REAL` | 浏览器上传、40% 更新覆盖、90% 居中画面、真实切换、数据保全和清理 | 核桃派 + HUB75 | 只在画面无法由摄像头判断时确认 |
| `TEST-OTA-KERNEL-RECOVERY` | `DEPLOY-OTA-KERNEL-RECOVERY`、能力退化回滚、候选材料校验、一次重启和结果持久化 | 本机 fake sysfs + 核桃派真实 systemd | 仅候选早期硬锁时断电一次 |
| `TEST-RELEASE-VERSION` | OTA/镜像共用版本、IMG 候选/实卡晋升、失败回滚、并发和发布记录 | Windows / mock + Linux | 否 | | `TEST-RELEASE-VERSION` | OTA/镜像共用版本、IMG 候选/实卡晋升、失败回滚、并发和发布记录 | Windows / mock + Linux | 否 |
| `TEST-IMAGE-LOCAL` | FAT16、离线载荷、双槽配置、编辑器和秘密排除 | Windows | 否 | | `TEST-IMAGE-LOCAL` | FAT16、离线载荷、双槽配置、编辑器和秘密排除 | Windows | 否 |
| `TEST-IMAGE-FIRSTBOOT` | 写卡、无外设自动安装、重启、联网、SSH 和网页 | 核桃派,无屏 | 写卡与插卡通电 | | `TEST-IMAGE-FIRSTBOOT` | 写卡、无外设自动安装、重启、联网、SSH 和网页 | 核桃派,无屏 | 写卡与插卡通电 |
@@ -73,6 +86,8 @@ node --test @($tests.FullName)
`TEST-CPUFREQ-RECOVERY` 在 fake sysfs 注入多 policy、缺失 policy、单 policy 写失败和配置保存失败,要求已写 governor 全部回滚;所有 mock/fake-register 应用默认使用运行根下不可用的隔离 cpufreq 路径,只有该专项测试可通过工厂注入 fake sysfs,任何 mock 测试不得读取或改写宿主机真实 governor。保留运行根记录后模拟异常重启,不得把遗留 `performance` 当作原 governor。板端候选内核必须识别 `x-powers,axp313a`、注册 `vdd-cpu` DCDC2、消除 `cpufreq-dt` deferred,`policy0` 覆盖 CPU0-3且包含 `performance`;DCDC2 始终处于 `810000..1080000µV`,ALDO1 保持 1.8V、DLDO1 保持常开 3.3V、DCDC3 保持引导器实际设置的 1.10V,修复前后的其他 PMIC 寄存器、GPU 状态和持久数据清单不得有非预期变化。 `TEST-CPUFREQ-RECOVERY` 在 fake sysfs 注入多 policy、缺失 policy、单 policy 写失败和配置保存失败,要求已写 governor 全部回滚;所有 mock/fake-register 应用默认使用运行根下不可用的隔离 cpufreq 路径,只有该专项测试可通过工厂注入 fake sysfs,任何 mock 测试不得读取或改写宿主机真实 governor。保留运行根记录后模拟异常重启,不得把遗留 `performance` 当作原 governor。板端候选内核必须识别 `x-powers,axp313a`、注册 `vdd-cpu` DCDC2、消除 `cpufreq-dt` deferred,`policy0` 覆盖 CPU0-3且包含 `performance`;DCDC2 始终处于 `810000..1080000µV`,ALDO1 保持 1.8V、DLDO1 保持常开 3.3V、DCDC3 保持引导器实际设置的 1.10V,修复前后的其他 PMIC 寄存器、GPU 状态和持久数据清单不得有非预期变化。
`TEST-OTA-KERNEL-RECOVERY` 先在本机 fake sysfs 和隔离 boot/module 树覆盖:健康候选更新前后 policy、标记或 governor 丢失必须令 OTA 回滚;既有原内核回退可完成应用更新,但核对候选 Image、两份 DTB、全部 `.ko` 摘要、原内核备份、受管启动脚本及已启用健康服务后才可安排一次独立重启。缺件、摘要不符或已失败记录不得自动重试;旧 worker 首次升级也须在新服务观察到成功结果后进入同一恢复判断。板端运行 `scripts/test_kernel_recovery_systemd.py`,验证临时主服务停止后独立任务仍可使用受保护运行目录;只清理本次随机命名的临时服务和目录。真实恢复后分别检查 `uname -r`、AXP313A、`vdd-cpu`、`policy0`、健康标记和 `/api/status`,短暂开启性能模式确认 `performance` 后关闭并恢复原 governor;候选失败则检查回退记录并停止再次重启。该逻辑不改变显示输出,视觉检查不适用。
`TEST-KERNEL-ROLLBACK` 先在隔离目录验证 boot 脚本只出现一个候选管理块、原文件路径保持可达、健康标记在候选启动前删除。板端保留原 `Image`、DTB、模块和 `boot.scr`,候选使用独立名称;健康服务只有在内核版本、AXP313A、cpufreq、服务和 GPIO 全部通过后才重建标记。注入健康失败后必须自动重启到原内核;若候选在健康服务前早期硬锁且硬件看门狗没有重启,停止自动操作并请用户只断电重启一次,随后确认无标记路径进入原内核。 `TEST-KERNEL-ROLLBACK` 先在隔离目录验证 boot 脚本只出现一个候选管理块、原文件路径保持可达、健康标记在候选启动前删除。板端保留原 `Image`、DTB、模块和 `boot.scr`,候选使用独立名称;健康服务只有在内核版本、AXP313A、cpufreq、服务和 GPIO 全部通过后才重建标记。注入健康失败后必须自动重启到原内核;若候选在健康服务前早期硬锁且硬件看门狗没有重启,停止自动操作并请用户只断电重启一次,随后确认无标记路径进入原内核。
`TEST-WORKSPACE-ORDER` 在相同四类视口检查:侧栏六个项目无“设备/内容”分类标题;叉号下方提供“自定义项目位置”;进入编辑后按钮变为“保存”且项目只显示上下移动操作。首末边界不可用,项目点击不导航,叉号、遮罩和 `Escape` 只提示保存并保持侧栏打开。保存成功后侧栏保持打开,刷新页面、另一浏览器和服务重启均恢复新顺序;请求失败保留内存草稿,未保存时刷新、关闭页面或模拟崩溃后恢复上一次已保存顺序。该测试不连接 HUB75 或 ADC。 `TEST-WORKSPACE-ORDER` 在相同四类视口检查:侧栏六个项目无“设备/内容”分类标题;叉号下方提供“自定义项目位置”;进入编辑后按钮变为“保存”且项目只显示上下移动操作。首末边界不可用,项目点击不导航,叉号、遮罩和 `Escape` 只提示保存并保持侧栏打开。保存成功后侧栏保持打开,刷新页面、另一浏览器和服务重启均恢复新顺序;请求失败保留内存草稿,未保存时刷新、关闭页面或模拟崩溃后恢复上一次已保存顺序。该测试不连接 HUB75 或 ADC。
+52
View File
@@ -0,0 +1,52 @@
# 移动端协作指南
先读根 `AGENTS.md`,再读本目录 README、开发要求和 Android 详细设计。当前为用户明确授权的首版开发;未来仅在用户明确要求移动端迭代时更新,不因设备端更新而自动修改或发布 App。
## 变更顺序
1. 修改稳定需求编号及兼容记录。
2. 协议变化先修改通信契约与跨语言测试向量,再分别修改必要端。
3. 修改源码、自动测试、构建说明;记录真实验证结果,未运行不得写通过。
4. 设备侧依赖增删同步离线依赖和 SHA-256;部署沿用根目录门禁与生命周期规则。
## 工程边界
- Kotlin Multiplatform + Compose Multiplatform,Android 入口、共享业务、共享界面分模块;Android API 不得泄露到 commonMain。
- 首版 Android minSdk 26;iOS 暂不实现,不声称 Windows 上完成 iOS 编译或蓝牙验收。
- 设备持久 ID、协议能力和业务命令是连接契约,不依赖蓝牙 MAC、网页布局或私有驱动调用。
- 只产出 debug APK,正式签名必须有用户明确指令;调试密钥也不进入 Git。
- 源码、测试、发布说明都在安卓app下;构建缓存、SDK、密钥、真实设备登记不进 Git。
- Android Studio 属于正常安装软件:下载官方安装包、校验、交给用户安装后停下等待,不安装进 Codex 工具目录。
- 不擅自关闭 VPN、不自动修改系统设置以修复下载。版本、依赖和 Wrapper 必须锁定并可复建。
## 测试手机与秘密
真实登记唯一位置为 `安卓app/如何安卓测试/测试设备登记/测试设备.local.json`,提交空白 `测试设备.example.json` 和检查脚本。现有登记不覆盖、不重建。自动选择手机必须使用有效登记,多个设备时不能默认取第一台。设备标识不写日志、报告、示例或提交,报告使用 `android-test-a` 等代号。
用户已授权主测试机为专用测试设备,可安装覆盖本 App、清理其测试数据、操作相关权限与蓝牙。授权记录保存在真实登记;不需要常规 root 操作。首次第二台手机互斥验收由用户安装交付 APK 协助,以后相关连接机制改动才要求重测。
用户补充要求:手机调试确需 root 时,先停止对话并说明准备请求的权限和用途,等待用户回复开始授权,再发起请求,由用户点击手机授权弹窗。本轮已明确授权用 root 为本 App 授予蓝牙扫描和连接权限;同一授权范围内不重复询问。新用途重新按此步骤处理,不将 root 授权等同于视觉检查授权。
## 视觉和物理操作门禁
2026-09-25 用户另已明确授权:使用 root 关闭并恢复专用测试手机蓝牙,以验证异常连接恢复。本用途与此前本 App 蓝牙权限授予均可继续,不扩展到其他系统修改;机器重启后须重新检查 ADB、登记身份与设备服务,不沿用旧进程状态。
2026-09-25 用户随后明确回复“开始授权,自动点击 App”:允许 root 根据本 App 当前语义控件位置执行点击,以及使用 Home 键测试前后台;不扩展到其他 App、系统安全开关或视觉检查。该用途不再重复询问,真实执行结果分别归档。
2026-09-26 用户为 `ANDROID-TEST-WIFI` 另行明确授权 root 完成所需测试:在本 App 设置页按语义识别的可见列表区域滚动,以操作 WiFi 表单。此授权限本次 WiFi 专项,不扩展到其他 App、系统页面或视觉检查。
普通前端、手机界面、BLE 控制入口开发未改驱动或显示输出实现时,不新增实屏视觉验收。优先协议、状态、输出帧和驱动统计。只有驱动、扫描时序、底层输出改变或具体显示异常确需视觉证据时才提出。
**每次需要视觉检查,必须先停止相关步骤,说明原因,询问用户下一步如何启动,等待明确指示。** 不自动启动 DroidCam、摄像头、采集程序或视觉测试程序,也不主动切换实屏测试图案。专用手机的一般自动化授权不替代本次视觉授权。不需要视觉检查时记录“不适用”,不阻塞交付。
断电、接线、拔插、调压仍逐阶段等待用户确认。没有物理结果不得记为实屏通过。
## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则)
本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。
ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi折叠/DHCP/确认;ANDROID-TEST-STATE 增加记忆迁移、忘记、连接中扫描、切换;ANDROID-TEST-WIFI 增加结构化失败文案;ANDROID-TEST-CONTROL 增加设备混合内容顺序。
## 测试经验沉淀与完成反馈
- 每轮测试前读 `安卓app/如何安卓测试/常见问题与排障.md`;遇到已有症状先沿用已验证方法,不从头试错。
- 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。
- 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。
+25
View File
@@ -0,0 +1,25 @@
# 奇妙小屏幕移动控制器
本目录维护独立的 Kotlin Multiplatform(KMP)移动程序。首版是 Android 8.0+ 的 BLE 控制器,未来 iOS 复用业务和 Compose Multiplatform 界面,平台蓝牙与权限分别实现。
## 阅读顺序
1. [协作约定](AGENTS.md)
2. [移动端需求](开发要求/移动端需求.md):产品行为、范围和平台差异。
3. [设备通信协议](开发要求/设备通信协议.md):手机与设备的稳定契约。
4. [安卓详细设计](安卓app/安卓开发详细设计.md):模块、状态、异常与实现边界。
5. [测试入口](安卓app/如何安卓测试/README.md)、[构建与发布](安卓app/构建与发布/README.md)。
设备侧入口为 `../整体开发需求/`、`../核桃派软件源代码/README.md` 和 `../测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md`。
## 维护边界
- 设备版本、App 版本、协议版本分别管理。用户明确要求移动端功能迭代时才改动或发布 App;更新设备不得顺便升级手机程序。
- 设备公共接口、驱动或部署调整必须评估移动端影响,记录在[跨端兼容记录](开发要求/跨端兼容与变更记录.md)。不影响契约的驱动内部调整不要求手机更新。
- 首版不依赖网页 DOM、按钮名称或布局,也不直接操作 GPIO。设备公共业务层统一处理显示保护和状态。
- 当前只允许 debug 签名。正式签名需要用户单独明确指令。
- 本文及需求是实施契约,不是验收证据。已完成工作和未完成门槛以[实施状态](安卓app/测试结果归档/实施状态.md)为准。
## 视觉检查
普通前端、App 界面、BLE 入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收。协议、输出帧和驱动统计优先。确需视觉检查时先停止相关步骤,说明原因,询问用户如何启动并等待明确指示;不得自动开启摄像头、DroidCam、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。
+10
View File
@@ -0,0 +1,10 @@
# 奇妙小屏幕 Android
先读 `../开发要求/移动端需求.md` 和 [详细设计](安卓开发详细设计.md)。工程放 `安卓程序源代码/`,共享逻辑和共享界面也在该工程内,为未来 iOS 保留接口。
- [测试流程](如何安卓测试/README.md)
- [测试设备登记](如何安卓测试/测试设备登记/README.md)
- [构建与发布](构建与发布/README.md)
- [实际实施状态](测试结果归档/实施状态.md)
Android Studio 正常安装完成前不声称已构建 APK。本轮只用 debug 签名。视觉检查须按 `../AGENTS.md` 先询问用户启动方式,专用测试机授权不能替代这一步。
@@ -0,0 +1,113 @@
# Android 可重复测试流程
本文件维护步骤与合格标准,不写一次性通过结果。每轮测试前阅读 [常见问题与排障](常见问题与排障.md),优先沿用已验证方法。结果记录到 `../测试结果归档/`。真机先检查 `测试设备登记/prepare_test_device.py`,多机精确选择,日志仅使用代号。源码与 UI 尚未实现的测试不得写成通过。
## 1. 门禁
- 用户授权主测试机可安装覆盖本 App、清理 App 测试数据、操作权限及蓝牙;自动测试点击使用 root,也不操作其他应用数据。
- 本软件调试所需测试权限可直接通过登记手机 ADB root 获取。root 或权限不可用时停止并等待用户;不操作其他 App 数据,不扩展视觉检查授权。
- 真实登记精确忽略,示例为空白。未授权、离线、型号/API 与登记不符时停止相关步骤,不挑另一个设备顶替。
- 普通前端/BLE 入口改动不要求实屏视觉验收,优先协议、状态、输出帧和驱动统计。确需视觉检查先停止,说明原因,询问用户下一步如何启动,等待本次明确指示;不自动启动 DroidCam、摄像头、采集或视觉测试程序,不主动切换实屏测试图案。专用机授权不能替代这一门禁。不适用记录“不适用”。
- 断电、接线、调压与第二台手机安装需要用户配合时逐阶段暂停。屏幕和 ADC 已连接不能记作无负载环境。
## 2. 测试矩阵
| 编号 | 覆盖 | 执行与合格标准 |
|---|---|---|
| ANDROID-TEST-REGISTER | MOBILE-TEST-PRIVACY | 标准库 unittest:缺失/占位/损坏拒绝、已有文件不覆盖、多设备不猜测、未授权拒绝、输出不带标识。 |
| ANDROID-TEST-CONTRACT | MOBILE-SECURITY、COMPAT | Python/Kotlin 黄金向量双向互验;MTU 23、分片、超长、篡改、重放、未知主版本、能力缺失均正确处理;变更不重放。 |
| ANDROID-TEST-STATE | CONNECTION、RECONNECT、BACKGROUND | fake clock/transport 验证串行操作、迟到回调、后台 30 秒、主动断开不重连、死进程超时释放。 |
| ANDROID-TEST-UI | STATUS、LIBRARY、PLAYBACK、SETTINGS、ERRORS | Compose instrumentation:四栏、空数据、错误恢复、字体放大、旋转、冲突保留草稿;稳定 testTag,不靠坐标盲点。 |
| ANDROID-TEST-PERMISSION | PLATFORM、DISCOVERY | UI Automator 验证允许、拒绝、永久拒绝、蓝牙关闭及恢复。API 26/30/31+ 模拟验证分别标注,不能当成真 BLE。 |
| ANDROID-TEST-BLE | DISCOVERY、CONNECTION、SECURITY | 主测试机真实扫描、握手、断开、后台释放、返回重连、设备服务重启、异常断线,全部无需系统配对。 |
| ANDROID-TEST-WIFI | WIFI、WIFI-SECRET | 核桃派扫描、隐藏 SSID 输入、字段校验、立即生效与确认取消、错误密码、切网后 BLE 状态仍可查;无密码回读/日志泄露。实际网络变更保留恢复配置。 |
| ANDROID-TEST-CONTROL | LIBRARY、PLAYBACK、DEFAULT、PREVIEW | 播放/默认/动画控制的返回值、状态与逻辑帧一致;预览单在途;用专用内容验证后恢复此前内容,不启动视觉程序。 |
| ANDROID-TEST-WEB | WEB-COEXIST | 网页修改手机可见,手机修改网页可见;连接短提示与常驻名称正确;初次加载不伪造连接事件;昵称 HTML 不执行。 |
| ANDROID-TEST-TWO-PHONES | CONNECTION | 提供 APK 给用户装第二台;A 连接 B 不能抢占,A 断开 B 可连接,A 异常退出可释放;真实双方结果分别记录。 |
| ANDROID-TEST-PERF | PREVIEW、STATUS | 广播、预览、WiFi 扫描与动画并发,记录实际刷新/截止丢失/CPU/内存,与启用前基线及既有标准比较,不要求新增目测。 |
| ANDROID-TEST-LIFECYCLE | 设备部署 | 真实 systemd 停启、适配器不可用/恢复、持久身份保留、运行目录与密钥失效;蓝牙异常不拖垮显示服务。 |
| ANDROID-TEST-BUILD | RELEASE | 按文档从中文/空格及隔离目录构建,debug 签名、版本、覆盖安装、SHA-256 和 Git 卫生检查通过;不读取正式签名。 |
## 3. 执行顺序
1. 测试登记脚本与仓库卫生检查。
2. 共享逻辑、设备业务、协议、现有本机回归;失败先修复。
3. 模拟设备的 Android UI 与权限测试。
4. 主测试机 BLE/控制/网络/网页联调,再做性能和生命周期。
5. 首次双手机验收由用户安装交付 APK 协助;日常只用主机,连接仲裁/广播/协议变化才重测。
6. 隔离重建、最终 debug APK 和脱敏证据归档。
7. 复盘本轮失败与调整:将已验证解法补到排障指南及对应流程,单次证据保留归档;最终反馈说明更新了哪些经验文档、哪些问题仍未解决。
当前可以执行的登记单元测试(从仓库根目录):
```powershell
python -X utf8 -m unittest discover -s "移动端相关内容/安卓app/如何安卓测试/测试设备登记" -p "test_*.py"
```
工程完成后,本文件必须补上经过验证的 Gradle task 与端到端脚本入口;不能先编造不存在的可执行命令。测试脚本应读取私有登记并在进程内传递目标,避免原始 ADB 输出进入报告。
### 已提供的真机测试入口
用构建脚本生成 `:androidApp:assembleDebug` 和 `:androidApp:assembleDebugAndroidTest` 后:
```text
python "移动端相关内容/安卓app/如何安卓测试/run_ble_test.py" --adb "<已有ADB路径>" --build "<本轮隔离工程目录>"
```
脚本先验证 android-test-a 的 USB 登记,从凭据门禁后的目标核桃派读取广播短名,仅扫描该板,安装覆盖两个 debug APK,再执行 `BleIntegrationTest`。不把临时广播身份写入公开报告。此测试只读状态、设置、内容、帧与 WiFi,不播放内容,不切网;断开后再连接核对持久 ID。运行器先验证 root 并为本 App 授予必要权限;所有点击经 root 按实时语义位置执行。权限未稳定生效必须停止等待用户,不使用普通注入或无障碍备用路径。
脚本只有明确 `OK (1 test)` 才返回成功;测试入口存在不代表真实验收通过。结果单独归档。
## 4. 证据与恢复
开发部署的真实失败恢复测试使用 `deploy_mobile_dev.py --deploy --test-rollback`,必须先结束手机控制会话。它和普通部署一样先保存可用程序副本,然后在安装文件及 systemd 配置完成后故意返回 97,触发同一 EXIT 回滚路径。脚本比较测试前后被部署程序文件、config.json、wifi_config.json 和持久身份的摘要,并确认真实主服务 active;随后仍需执行 BLE/生命周期恢复检查。此选项会短暂停止服务,但不主动切换测试图案,也不等同正式 OTA 或断电回滚测试。恢复副本保留供排查,不能把故意失败当作普通部署完成。
每轮记录 App versionCode/源码摘要、设备软件版本/功能时间、协议版本、测试代号、覆盖项、结果、失败原因、恢复结果。输出包含密码或设备真实标识时先脱敏再归档。屏幕视觉不适用与未验证应区分;没有第二台手机时互斥测试标为未验证,不能用两个同机 App 冒充两个 BLE 物理客户端。
故障后恢复测试前网络、相关手机设置和设备显示内容;不恢复过期配置覆盖用户在测试期间的新改动。只清理明确属于本次测试的数据,不泛化删除持久资源。
# 连接稳定性复测
`ControllerRecoveryTest` 的测试专用 SimulatedPlatform/SimulatedLink 使用同一分片与 RPC 路径,替换加密为显式测试直通实现,覆盖断线清缓存并重连、未完成请求取消、新旧扫描回调隔离。只在 commonTest 编译,生产扫描与 APK 不包含模拟入口;加密正确性由独立跨语言黄金向量测试覆盖。
默认 BLE 回归额外执行真实 `wifi.scan`,断言返回有界且不含密码,不输出网络名称;提交故意过期的设置修订,要求返回 CONFLICT 并继续 ping 成功。不会实际改配置。
真实网络激活验证可显式加 `--reapply-current-wifi`:先确认保存配置与当前已连网络相同,保留密码并将该原配置立即应用;轮询任务到成功,核对恢复连接及保存字段不变。可能短暂影响板端 WiFi,执行前说明;不替代不同 SSID、替换密码、DHCP/静态地址互换的单独验收。默认不启用,不能拿重新应用原网络当作所有网络场景都通过。
`ANDROID-TEST-WIFI` 的双网络专项入口是 `run_wifi_switch_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>`,需要先构建 `:androidApp:assembleDebugAndroidTest`。脚本通过 Windows 当前移动热点接口在进程内读取第二网络参数;App 仪器测试经短时私有文件读取密码,不把密码放在 ADB 参数或报告中。错误密码会令唯一受管网络暂时失联,必须单独加 `--wrong-only` 运行;该分支经 App 提交、BLE 任务失败状态与无密码日志核验,并用短时恢复计时器回到原网络。确认此分支后再加 `--skip-wrong`,依次验证热点 DHCP、静态 IPv4、回到 DHCP、立即恢复原网络和原网络复核。两种模式都先执行字段校验与 App 扫描,并在板端保存受保护的原 NetworkManager 配置;结束时检查恢复副本和定时器的清理结果。失败时若 SSH 因切网中断,等待原网络恢复并核验后再清理,不能把临时断链当成恢复失败或直接删除备份。
列表滚动统一使用 root;`--root-swipe` 保留兼容,运行器默认启用;辅助脚本先核对本 App 为前台,并将滑动坐标限制在语义识别的设置列表可见区域。该参数仅用于本 App 列表滚动,不授权其他应用或系统页面操作。一次中断后若板端仍在热点、且上次受保护副本与恢复定时器均存在,可用同一命令加 `--resume` 从静态 IPv4 阶段继续;不得在没有完整备份的情况下使用。
`--validate-only` 只执行表单与无效 IPv4 校验,不切网;`--final-check` 只复核原网络的 App、BLE、板端状态及恢复材料清理。所有模式均遵守相同的凭据门禁和手机登记检查。
`--verify-current-controls` 用当前修订重新提交现有亮度,要求回读不变;若设备已有活动动画,暂停、提交当前暂停位置和原倍速,finally 恢复原暂停状态。最后核对开机默认未改变。它不选择新内容或测试图案,也不代替改变亮度值、选择其他内容/默认内容的完整验收。无活动动画时明确记录该子项不适用。
部分厂商在覆盖安装返回成功后异步重置权限;已授权 `--grant-root` 时脚本最多三次授予本 App 蓝牙权限,每次等待 3 秒再复核,未稳定授予就停止。不得以此授权修改其他系统安全开关。
性能观察使用 `sample_mobile_performance.py --seconds 150 --output <新的报告JSON路径>`:仅保存刷新率、帧计数、截止丢失、OE 故障、CPU、内存、连接/动画布尔状态;不保存 SSID、标识、密码或完整状态。可与 BLE 回归同时运行。该采样属于运行观察,蓝牙始终开启时不能宣称已完成启用前后的因果对照实验。
真实服务生命周期检查:主测试手机先主动断开,运行 `python "移动端相关内容/安卓app/如何安卓测试/check_mobile_lifecycle.py" --restart-service`。脚本经过板端凭据门禁,实际 systemctl stop/start 主服务,以临时 sentinel 验证 RuntimeDirectory 在显式停服及启动后均保留;核对 config.json、wifi_config.json、mobile/identity.json 内容摘要不变,只输出布尔结果;最后删除自身 sentinel。停止后无论中间断言如何都尝试启动服务;不能用这项烟雾测试代替完整部署故障回滚、OTA 或物理显示验收。
可使用同一运行器 `--test activity` 执行 `AppLifecycleTest`:通过 UI Automator 语义节点操作实际 Activity,核验四栏入口、后台超过 30 秒再返回自动重连及主动断开。此测试不截图、不启动摄像头、不改变设备显示内容;与 Controller 直测分开记录。
若 MIUI 拒绝后台启动,测试通过 shell 显式启动本 App;若再拒绝输入注入,不直接修改系统权限。本软件测试默认启用 `--root-input`:根据语义查找到的本 App 控件当前位置发送 root input tap;后台测试仅发送 Home 键。运行器默认启用 root 输入,不提供普通输入备用分支。
同时已获得 root 切换手机蓝牙授权时,可再传 `--toggle-bluetooth`:在已连接页面关闭蓝牙,等待连接状态清除、扫描入口恢复,再打开蓝牙并通过 App 扫描和连接原设备;之后继续四栏及后台测试。此参数必须与 `--test activity --root-input` 同用。测试与主机运行器都在 finally 尝试恢复蓝牙,错误不能把手机留在测试关闭状态。
`run_ble_test.py` 使用登记手机和凭据门禁指定的开发板;覆盖安装 debug App 与测试 APK。运行器默认启用 `--grant-root`,用于覆盖安装后重新授予此 App 的蓝牙扫描/连接权限。脚本不输出真实手机编号和开发板广播编号。
`BleIntegrationTest` 先完成真实加密只读会话,读取状态、容量、设置、内容目录、当前帧和不含密码的网络信息;连续 60 轮间隔 2 秒读取状态与帧,主动断开后再次握手并核对持久设备 ID。随后使用生产 Controller 与真实 BLE 验证四栏数据读取、缩略图队列、短暂后台后返回取消释放、后台 30 秒释放、返回前台仅重连上次设备。测试不修改网络或显示内容。会话故障须保留具体阶段与错误,不把模拟测试结果标为实机通过。
`RpcTimeoutTest` 验证内部请求超时关闭传输、返回普通通信异常并拒绝继续使用旧 RPC;这与用户主动取消协程不同,后者不自动重试。
## 2026-09-26 用户最新 root 测试授权(替代前述按用途重复询问规则)
本软件调试所需各种测试权限可直接通过登记测试手机的 ADB root 获取,无需询问。所有自动测试点击一律使用 root,根据本 App 实时语义控件位置定位;滚动同样使用 root。不得通过无障碍或普通注入绕过 MIUI。测试前验证登记、ADB 和 root;权限不足或特殊情况必须停止聊天等待用户,不继续不完整测试。授权仅限调试本软件所需操作,不扩大到其他应用数据,视觉与物理操作门禁继续有效。
ANDROID-TEST-UI 增加四栏、三点菜单、昵称、长按保存设备、WiFi折叠/DHCP/确认;ANDROID-TEST-STATE 增加记忆迁移、忘记、连接中扫描、切换;ANDROID-TEST-WIFI 增加结构化失败文案;ANDROID-TEST-CONTROL 增加设备混合内容顺序。
### 四栏版专项入口
- `run_ble_test.py` 默认验证 root 并授予本 App 蓝牙权限;Activity 所有输入均为 root。`--test activity` 覆盖四栏、菜单、真实重命名和重连核验、恢复原昵称、忘记和再次手动连接。BLE 测试将设备网页顺序摘要与完整 BLE 分页列表摘要比较,不输出条目 ID。
- `run_wifi_switch_test.py --validate-only` 不需要电脑热点,验证空 SSID、确认弹窗、静态 IP 校验;`--wrong-only` 使用当前受管网络及故意错误测试密码,先备份并建立定时恢复,最后核验原网络和清理。只有跨 SSID 的 DHCP/静态往返才需要电脑热点,按前述 `--skip-wrong` 流程运行。
- `run_ui_layout_test.py --adb <已有ADB路径> --build <本轮隔离工程目录>` 在登记手机上临时测试约 340dp 宽度、1.3 倍字体和横屏。输入通过 root,语义窗口只用于定位和检查,不执行无障碍动作;电脑端 finally 恢复密度、字体和方向,字体/方向回读核验;若额外改变 USB 保持唤醒等设置,也必须保存、恢复并核验。权限不足停止;安全锁屏不能正常解锁时等待用户,不绕过密码。
- root 辅助脚本保持 LF 换行;文本通过 App 内临时剪贴板和 root 按键输入,结束清理,不把密码放在命令参数中。
排障指南按症状记录 root 命令解析、LF 换行、布局进程重建、语义定位、表单输入、安装权限、BLE 阶段释放及 WiFi 恢复。遇到这些情况先查 [对应解法](常见问题与排障.md),不得重复试用已知不可靠的输入或转储方法。
@@ -0,0 +1,37 @@
#!/system/bin/sh
# Installed only for an explicitly authorized root input test.
set -eu
dumpsys activity activities | grep 'topResumedActivity=.*org.qimiaoscreen.controller/' >/dev/null || exit 12
case "${1:-}" in
tap)
case "${2:-}:${3:-}" in *[!0-9:]*|:|:*|*:) exit 13 ;; esac
su -c "input tap $2 $3"
;;
swipe)
for value in "${2:-}" "${3:-}" "${4:-}" "${5:-}" "${6:-}"; do
case "$value" in ''|*[!0-9]*) exit 13 ;; esac
done
[ "$6" -ge 100 ] && [ "$6" -le 800 ] || exit 13
su -c "input swipe $2 $3 $4 $5 $6"
;;
select-all) su -c 'input keycombination 113 29' ;;
clear-field)
case "${2:-}" in ''|*[!0-9]*) exit 13 ;; esac
[ "$2" -ge 1 ] && [ "$2" -le 512 ] || exit 13
events=''
index=0
while [ "$index" -lt "$2" ]; do events="$events 67"; index=$((index + 1)); done
su -c "input keyevent 123; input keyevent$events"
;;
delete) su -c 'input keyevent KEYCODE_DEL' ;;
hide-keyboard)
if su -c 'dumpsys input_method' | grep -q 'mInputShown=true'; then su -c 'input keyevent KEYCODE_BACK'; fi
;;
paste) su -c 'input keyevent 279' ;;
back) su -c 'input keyevent KEYCODE_BACK' ;;
home) su -c 'input keyevent KEYCODE_HOME' ;;
bluetooth-disable) su -c 'cmd bluetooth_manager disable' ;;
bluetooth-enable) su -c 'cmd bluetooth_manager enable' ;;
*) exit 14 ;;
esac
echo QMS_INPUT_OK
@@ -0,0 +1,2 @@
#!/system/bin/sh
exec su -c "$*"
@@ -0,0 +1,83 @@
"""Authorized real systemd stop/start smoke test; no credentials in output."""
import argparse
import json
import shlex
from remote_support import connect, execute
REMOTE = r'''
import hashlib, json, subprocess, time, urllib.request
from pathlib import Path
from uuid import uuid4
def service(action):
subprocess.run(['systemctl', action, 'matrix-screen-controller.service'], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=45)
def status():
with urllib.request.urlopen('http://127.0.0.1:8080/api/status', timeout=3) as response:
return json.load(response)
data = Path('/var/lib/matrix-screen-controller')
paths = [data/'config.json', data/'wifi_config.json', data/'mobile/identity.json']
def fingerprints():
return {str(p.relative_to(data)): hashlib.sha256(p.read_bytes()).hexdigest() if p.exists() else None for p in paths}
before = fingerprints()
assert before['config.json'] and before['mobile/identity.json']
initial = status()
assert not initial['mobile']['connected'], 'Phone must be disconnected before lifecycle test'
sentinel = Path('/run/matrix-screen-controller') / ('mobile-lifecycle-' + uuid4().hex)
sentinel.write_text('preservation-check', encoding='utf-8')
stopped_preserved = False
try:
service('stop')
stopped_preserved = sentinel.exists()
finally:
service('start')
try:
current = None
for _ in range(30):
try:
current = status()
if current.get('mobile', {}).get('available'):
break
except Exception:
pass
time.sleep(1)
result = dict(runtime_preserved_on_stop=stopped_preserved,
runtime_preserved_after_start=sentinel.exists(),
persistent_configuration_unchanged=before == fingerprints(),
mobile_available=bool(current and current.get('mobile', {}).get('available')),
no_phone_session=bool(current and not current.get('mobile', {}).get('connected')))
print(json.dumps(result))
assert all(result.values()), 'Lifecycle checks failed'
finally:
sentinel.unlink(missing_ok=True)
'''
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--restart-service', action='store_true', required=True)
parser.parse_args()
client, fields = connect()
try:
code, output, _ = execute(client, 'python3 -c ' + shlex.quote(REMOTE),
password=fields['密码'], timeout=150)
# Only a small explicit boolean schema is allowed out of this test.
lines = [line for line in output.splitlines() if line.startswith('{')]
if lines:
result = json.loads(lines[-1])
if any(type(value) is not bool for value in result.values()):
raise RuntimeError('Unexpected lifecycle output')
print(json.dumps(result, ensure_ascii=False))
if code or not lines:
raise RuntimeError('Real lifecycle verification failed; raw output withheld')
finally:
client.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,158 @@
"""Explicit development deployment; preserves board data and keeps a rollback copy."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import tarfile
import tempfile
from uuid import uuid4
from remote_support import ROOT, connect, execute, redact
FILES = [
'app/main.py', 'app/control.py', 'app/network/manager.py', 'app/network/service.py', 'app/network/store.py',
'app/static/index.html', 'app/static/views/device.js', 'app/static/ui-copy.json',
'scripts/dedicated_host.py', 'scripts/deploy_walnutpi.sh', 'scripts/install_mobile_bluetooth.sh',
'scripts/ota_components.py',
'systemd/matrix-screen-controller.service', 'requirements.txt', 'FEATURE_UPDATED_AT',
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--deploy', action='store_true', required=True)
parser.add_argument('--test-rollback', action='store_true', help='Intentionally fail after installation and verify restoration on the real system')
args = parser.parse_args()
client, fields = connect()
token = uuid4().hex[:12]
stage = '/tmp/qms-mobile-stage-' + token
backup = '/opt/matrix-screen-controller.mobile-backup-' + token
source = ROOT / '核桃派软件源代码'
files = FILES + [p.relative_to(source).as_posix() for p in (source / 'app/mobile').glob('*.py')]
def snapshot():
paths = ['/opt/matrix-screen-controller/' + name for name in files]
paths += ['/var/lib/matrix-screen-controller/' + name for name in ('config.json', 'wifi_config.json', 'mobile/identity.json')]
import shlex
program = ('import hashlib,json;from pathlib import Path;'
'print(json.dumps({p:hashlib.sha256(Path(p).read_bytes()).hexdigest() if Path(p).is_file() else None for p in '
+ repr(paths) + '}))')
code, output, _ = execute(client, 'python3 -c ' + shlex.quote(program), password=fields['密码'])
if code:
raise RuntimeError('Cannot capture rollback verification snapshot')
return json.loads(output)
before = snapshot() if args.test_rollback else None
dependencies = ROOT / '发布更新相关/其他依赖/aarch64-py311'
try:
with tempfile.TemporaryDirectory(prefix='qms-deploy-') as local:
bundle = Path(local) / 'payload.tar.gz'
with tarfile.open(bundle, 'w:gz') as archive:
for name in files:
archive.add(source / name, arcname='source/' + name)
for pattern in ('cryptography-46.0.5-*.whl', 'dbus_next-0.2.3-*.whl', 'cffi-2.1.1-*.whl', 'pycparser-3.0-*.whl'):
matches = list(dependencies.glob(pattern))
if len(matches) != 1:
raise RuntimeError('Missing or ambiguous offline wheel')
archive.add(matches[0], arcname='wheels/' + matches[0].name)
digest = hashlib.sha256(bundle.read_bytes()).hexdigest()
sftp = client.open_sftp()
sftp.mkdir(stage)
sftp.put(str(bundle), stage + '/payload.tar.gz')
sftp.close()
script = r'''
set -eu
STAGE=__STAGE__
BACKUP=__BACKUP__
TARGET=/opt/matrix-screen-controller
test -d "$TARGET/.venv"
test ! -e "$BACKUP"
printf '%s %s\n' '__DIGEST__' "$STAGE/payload.tar.gz" | sha256sum -c -
tar -xzf "$STAGE/payload.tar.gz" -C "$STAGE"
mkdir "$BACKUP"
cp -a "$TARGET/." "$BACKUP/"
test -x "$BACKUP/.venv/bin/python"
cp -a /etc/systemd/system/matrix-screen-controller.service "$BACKUP/original-systemd.service"
cp -a /etc/bluetooth/main.conf "$BACKUP/original-bluetooth.conf"
systemctl is-enabled bluetooth.service > "$BACKUP/bluetooth.enabled" || true
systemctl is-enabled aw859-bluetooth.service > "$BACKUP/aw859.enabled" || true
for unit in bluetooth aw859-bluetooth; do
config=/etc/systemd/system/$unit.service.d/50-matrix-mobile.conf
if test -f "$config"; then cp -a "$config" "$BACKUP/$unit.dropin"; fi
done
rollback() {
set +e
systemctl stop matrix-screen-controller.service || true
if test -d "$TARGET/.venv" && test ! -e "$STAGE/failed-venv"; then
mv "$TARGET/.venv" "$STAGE/failed-venv"
cp -a "$BACKUP/.venv" "$TARGET/.venv"
fi
cp -a "$BACKUP/app/." "$TARGET/app/"
cp -a "$BACKUP/scripts/." "$TARGET/scripts/"
cp -a "$BACKUP/systemd/." "$TARGET/systemd/"
cp -a "$BACKUP/requirements.txt" "$TARGET/requirements.txt"
cp -a "$BACKUP/FEATURE_UPDATED_AT" "$TARGET/FEATURE_UPDATED_AT"
cp -a "$BACKUP/original-systemd.service" /etc/systemd/system/matrix-screen-controller.service
cp -a "$BACKUP/original-bluetooth.conf" /etc/bluetooth/main.conf
for unit in bluetooth aw859-bluetooth; do
config=/etc/systemd/system/$unit.service.d/50-matrix-mobile.conf
if test -f "$BACKUP/$unit.dropin"; then cp -a "$BACKUP/$unit.dropin" "$config"; else rm -f -- "$config"; fi
done
systemctl daemon-reload
if ! grep -qx enabled "$BACKUP/bluetooth.enabled"; then systemctl disable --now bluetooth.service || true; fi
if ! grep -qx enabled "$BACKUP/aw859.enabled"; then systemctl disable --now aw859-bluetooth.service || true; fi
systemctl start matrix-screen-controller.service || true
}
COMMITTED=0
trap 'if test "$COMMITTED" = 0; then rollback; fi' EXIT
trap 'exit 1' HUP INT TERM
"$TARGET/.venv/bin/python" -m pip install --no-index --find-links "$STAGE/wheels" cryptography==46.0.5 dbus-next==0.2.3
systemctl stop matrix-screen-controller.service
cp -a "$STAGE/source/." "$TARGET/"
/bin/sh "$TARGET/scripts/install_mobile_bluetooth.sh"
install -m 0644 "$TARGET/systemd/matrix-screen-controller.service" /etc/systemd/system/matrix-screen-controller.service
systemctl daemon-reload
__FAULT_INJECTION__
systemctl start matrix-screen-controller.service
attempt=0
while test "$attempt" -lt 25; do
if curl --fail --silent http://127.0.0.1:8080/api/status > "$STAGE/status.json"; then
python3 - "$STAGE/status.json" <<'PY'
import json, sys
status = json.load(open(sys.argv[1], encoding='utf-8'))
print(json.dumps({'service_active': True, 'mobile_available': status.get('mobile', {}).get('available'), 'mobile_reason': status.get('mobile', {}).get('reason')}, ensure_ascii=False))
PY
echo "Rollback copy retained: $BACKUP"
COMMITTED=1
trap - EXIT HUP INT TERM
exit 0
fi
attempt=$((attempt + 1))
sleep 1
done
exit 1
'''.replace('__STAGE__', stage).replace('__BACKUP__', backup).replace('__DIGEST__', digest).replace('__FAULT_INJECTION__', 'echo "Intentional rollback test failure"; exit 97' if args.test_rollback else ':')
sftp = client.open_sftp()
with sftp.open(stage + '/deploy.sh', 'w') as stream:
stream.write(script)
sftp.close()
code, output, error = execute(client, '/bin/sh ' + stage + '/deploy.sh', password=fields['密码'], timeout=240)
print(redact(output, fields))
# Raw package errors do not contain credentials; still redact known private fields.
print(redact(error, fields))
if args.test_rollback:
if code != 97 or snapshot() != before:
raise RuntimeError('Rollback verification failed; retained backup requires inspection')
code, output, _ = execute(client, 'systemctl is-active matrix-screen-controller.service', timeout=15)
if code or output.strip() != 'active':
raise RuntimeError('Rollback restored files but service is not active')
print('Intentional failure rolled back: program and persistent file digests unchanged; real service active.')
return
if code:
raise RuntimeError('Deployment failed; inspect retained rollback copy')
print('Development deployment finished; BLE validation is still required.')
finally:
client.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,64 @@
"""Private, pinned SSH access for this workspace; never expose credential values."""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import re
import paramiko
ROOT = Path(__file__).resolve().parents[3]
def connect():
gate_path = ROOT / "测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py"
spec = importlib.util.spec_from_file_location("qms_credentials", gate_path)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
if not gate.ensure_credentials(gate_path.parent):
raise RuntimeError("设备凭据门禁未通过")
fields = gate._parse_fields(gate_path.with_name(gate.PRIVATE_NAME))
host = fields["IP"].strip()
port_match = re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"])
port = int(port_match.group(1)) if port_match else 22
trust = Path.home() / ".codex-tools/tmp/qms-mobile-known_hosts"
trust.parent.mkdir(parents=True, exist_ok=True)
client = paramiko.SSHClient()
if trust.exists():
client.load_host_keys(str(trust))
# First-use trust is pinned in a private local store. Changed keys are
# rejected by Paramiko before this policy can be invoked.
class PinFirstUse(paramiko.MissingHostKeyPolicy):
def missing_host_key(self, ssh, hostname, key):
ssh.get_host_keys().add(hostname, key.get_name(), key)
ssh.save_host_keys(str(trust))
client.set_missing_host_key_policy(PinFirstUse())
try:
client.connect(host, port=port, username=fields["用户名"], password=fields["密码"],
look_for_keys=False, allow_agent=False, timeout=10,
banner_timeout=10, auth_timeout=10)
except Exception:
client.close()
raise RuntimeError("SSH 连接或主机密钥检查失败;未输出凭据") from None
return client, fields
def redact(text, fields):
values = sorted((v for v in fields.values() if len(v) >= 4), key=len, reverse=True)
for value in values:
text = text.replace(value, "[private]")
return text
def execute(client, command, *, password=None, timeout=30):
if password is not None:
import shlex
command = "sudo -S -p '' -- sh -c " + shlex.quote(command)
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
if password is not None:
stdin.write(password + "\n")
stdin.flush()
out, err = stdout.read().decode("utf-8", "replace"), stderr.read().decode("utf-8", "replace")
return stdout.channel.recv_exit_status(), out, err
@@ -0,0 +1,150 @@
"""Run registered BLE tests; WiFi reapplication requires its explicit option."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
import time
sys.path.insert(0, str(Path(__file__).resolve().parent / '测试设备登记'))
from prepare_test_device import load_registration, check_connected
from remote_support import connect, execute
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--adb', required=True)
parser.add_argument('--build', type=Path, required=True)
parser.add_argument('--poll-cycles', type=int, default=60)
parser.add_argument('--test', choices=('ble', 'activity', 'layout'), default='ble')
parser.add_argument('--root-input', action='store_true', help='Compatibility flag; activity tests always use authorized root input')
parser.add_argument('--reapply-current-wifi', action='store_true', help='Explicitly reapply the already active saved WiFi profile over BLE; may briefly reconnect WiFi')
parser.add_argument('--verify-current-controls', action='store_true', help='Reapply current brightness; briefly pause active animation and restore without selecting new content')
parser.add_argument('--toggle-bluetooth', action='store_true', help='Requires explicit root Bluetooth-switch authorization plus activity root input')
parser.add_argument('--grant-root', action='store_true', help='Compatibility flag; runner always verifies and grants authorized App permissions')
args = parser.parse_args()
args.grant_root = True
if args.test in ("activity", "layout"): args.root_input = True
if not 0 <= args.poll_cycles <= 60:
parser.error('poll-cycles must be between 0 and 60')
if args.root_input and args.test == 'ble':
parser.error('root-input only applies to activity tests')
if args.reapply_current_wifi and args.test != 'ble':
parser.error('reapply-current-wifi only applies to BLE tests')
if args.verify_current_controls and args.test != 'ble':
parser.error('verify-current-controls only applies to BLE tests')
if args.toggle_bluetooth and not (args.test == 'activity' and args.root_input):
parser.error('toggle-bluetooth requires activity and root-input')
device = next(d for d in load_registration() if d['alias'] == 'android-test-a')
check_connected(args.adb, device)
root_check = subprocess.run([args.adb, '-s', device['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
if root_check.returncode or root_check.stdout.strip() != '0':
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理,不使用备用注入方式')
client, fields = connect()
try:
code, output, error = execute(client, "python3 -c \"import json; p=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json')); print(json.dumps({'device_id':p['device_id']}))\"", password=fields['密码'])
if code:
raise RuntimeError('Cannot read authorized board identity')
board_id = json.loads(output)['device_id']
from uuid import UUID
UUID(board_id)
board_name = 'QMS-' + board_id.replace('-', '')[:8]
code, output, _ = execute(client, "python3 -c \"import urllib.request,json,hashlib; d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/api/library/order',timeout=5)); print(hashlib.sha256('\\n'.join(i['type']+'|'+i['id'] for i in d['items']).encode()).hexdigest())\"", password=fields['密码'])
library_hash = output.strip()
if code or len(library_hash) != 64 or any(c not in '0123456789abcdef' for c in library_hash):
raise RuntimeError('Cannot read device library order digest')
if not board_name.startswith('QMS-') or len(board_name) != 12:
raise RuntimeError('Invalid board identity')
finally:
client.close()
def adb(*command, timeout=120, quiet=False):
try:
result = subprocess.run([args.adb, '-s', device['serial'], *command], capture_output=True,
text=True, encoding='utf-8', errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
# ADB's host timeout does not stop Android instrumentation itself.
if command[:3] == ('shell', 'am', 'instrument'):
subprocess.run([args.adb, '-s', device['serial'], 'shell', 'am', 'force-stop',
'org.qimiaoscreen.controller'], capture_output=True, timeout=15)
raise RuntimeError('ADB operation timed out; device identifiers omitted') from None
output = (result.stdout + result.stderr).replace(device['serial'], '[test-phone]').replace(board_name, '[test-board]').replace(board_id, '[test-board-id]')
if not quiet:
print(output, flush=True)
if result.returncode:
raise RuntimeError('ADB command failed')
return output
for relative in ('androidApp/build/outputs/apk/debug/androidApp-debug.apk',
'androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk'):
apk = args.build / relative
if not apk.is_file():
raise RuntimeError('APK missing')
adb('install', '-r', str(apk))
adb('shell', 'am', 'force-stop', 'org.qimiaoscreen.controller')
if args.grant_root:
# Some vendor package managers asynchronously reset permissions after
# install succeeds. Verify after a settling window, not just at grant.
for attempt in range(3):
for permission in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT'):
adb('shell', 'su', '-c', 'pm grant org.qimiaoscreen.controller android.permission.' + permission)
time.sleep(3)
verified = adb('shell', 'dumpsys', 'package', 'org.qimiaoscreen.controller', quiet=True)
if all('android.permission.' + p + ': granted=true' in verified for p in ('BLUETOOTH_SCAN', 'BLUETOOTH_CONNECT')):
break
else:
raise RuntimeError('Bluetooth permission did not remain granted after installation')
if args.root_input:
adb('push', str(Path(__file__).with_name('authorized_app_input.sh')), '/data/local/tmp/qms-authorized-input.sh', quiet=True)
layout_original = None
if args.test == "layout":
adb("push", str(Path(__file__).with_name("authorized_root_command.sh")), "/data/local/tmp/qms-authorized-root.sh", quiet=True)
def root(command):
return adb("shell", "su", "-c", command, quiet=True).strip()
import re
layout_original = {key: root("settings get system " + key) for key in ("font_scale", "accelerometer_rotation", "user_rotation")}
layout_density = re.search(r"Override density: (\d+)", root("wm density"))
layout_density = layout_density[1] if layout_density else "reset"
dimensions = re.findall(r"(\d+)x(\d+)", root("wm size"))[-1]
narrow = min(map(int, dimensions)) * 160 // 340
try:
for font, rotation in (("1.0", "0"), ("1.3", "0"), ("1.3", "1")) if args.test == "layout" else ((None, None),):
if layout_original:
root("am force-stop org.qimiaoscreen.controller")
root("wm density " + str(narrow))
root("settings put system font_scale " + font)
root("settings put system accelerometer_rotation 0")
root("settings put system user_rotation " + rotation)
root("input keyevent KEYCODE_WAKEUP")
root("wm dismiss-keyguard")
root("am start -W -n org.qimiaoscreen.controller/.MainActivity")
output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'device_name', board_name,
'-e', 'poll_cycles', str(args.poll_cycles),
'-e', 'library_order_hash', library_hash,
'-e', 'root_input', str(args.root_input).lower(),
'-e', 'reapply_current_wifi', str(args.reapply_current_wifi).lower(),
'-e', 'verify_current_controls', str(args.verify_current_controls).lower(),
'-e', 'toggle_bluetooth', str(args.toggle_bluetooth).lower(),
'-e', 'class', 'org.qimiaoscreen.controller.' + {'ble': 'BleIntegrationTest', 'activity': 'AppLifecycleTest', 'layout': 'LayoutTest'}[args.test],
'org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner', timeout=360)
if "OK (1 test)" not in output:
raise RuntimeError("Instrumentation did not pass")
if layout_original:
print("Layout passed: font=" + font + ", rotation=" + rotation)
finally:
if layout_original:
root("am force-stop org.qimiaoscreen.controller")
root("wm density " + layout_density)
for key, value in layout_original.items():
root("settings delete system " + key if value == "null" else "settings put system " + key + " " + value)
if root("settings get system " + key) != value: raise RuntimeError("Layout settings restore failed")
if args.test == "layout":
adb("shell", "rm", "-f", "/data/local/tmp/qms-authorized-root.sh", quiet=True)
if args.toggle_bluetooth:
adb('shell', 'su', '-c', 'cmd bluetooth_manager enable', quiet=True)
if args.root_input:
adb('shell', 'rm', '-f', '/data/local/tmp/qms-authorized-input.sh', quiet=True)
if 'OK (1 test)' not in output:
raise RuntimeError('BLE instrumentation did not pass')
if __name__ == '__main__':
main()
@@ -0,0 +1,18 @@
"""Use the persistent Android semantic test with root input and restored settings."""
from pathlib import Path
import argparse
import subprocess
import sys
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--adb', required=True)
parser.add_argument('--build', type=Path, required=True)
args = parser.parse_args()
return subprocess.run([sys.executable, str(Path(__file__).with_name('run_ble_test.py')),
'--adb', args.adb, '--build', str(args.build), '--test', 'layout']).returncode
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,517 @@
"""Run the single real Android WiFi switch matrix with a timed board recovery path.
The hotspot and saved WiFi secrets stay in this process and in short-lived private
test files. Never print raw ADB, NetworkManager, SSH, or instrumentation output.
"""
from __future__ import annotations
import argparse
import base64
import importlib.util
import ipaddress
import json
import logging
from pathlib import Path
import re
import secrets
import subprocess
import sys
import time
from remote_support import connect, execute
sys.path.insert(0, str(Path(__file__).resolve().parent / "测试设备登记"))
from prepare_test_device import check_connected, load_registration
logging.getLogger("paramiko").setLevel(logging.CRITICAL)
def hotspot_configuration() -> dict:
script = (
'$ErrorActionPreference="Stop"; '
'$n=[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime]; '
'$t=[Windows.Networking.NetworkOperators.NetworkOperatorTetheringManager,Windows.Networking.NetworkOperators,ContentType=WindowsRuntime]; '
'$m=$t::CreateFromConnectionProfile($n::GetInternetConnectionProfile()); '
'if($m.TetheringOperationalState.ToString() -ne "On"){throw "hotspot off"}; '
'$c=$m.GetCurrentAccessPointConfiguration(); '
'$a=@(Get-NetAdapter -IncludeHidden|Where-Object {$_.InterfaceDescription -like "*Wi-Fi Direct Virtual Adapter*"}); '
'$ips=@($a|ForEach-Object {Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue}|Where-Object {$_.PrefixLength -eq 24}); '
'if($ips.Count -ne 1){throw "hotspot address ambiguous"}; '
'$j=@{ssid=$c.Ssid;password=$c.Passphrase;gateway=$ips[0].IPAddress;prefix=$ips[0].PrefixLength}|ConvertTo-Json -Compress; '
'[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))'
)
result = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script],
capture_output=True, timeout=20)
if result.returncode:
raise RuntimeError("电脑热点配置无法安全读取")
values = json.loads(base64.b64decode(result.stdout.strip()))
if not values["ssid"] or not 8 <= len(values["password"].encode("utf-8")) <= 63:
raise RuntimeError("电脑热点配置不满足测试条件")
gateway = ipaddress.ip_address(values["gateway"])
if not isinstance(gateway, ipaddress.IPv4Address) or values["prefix"] != 24:
raise RuntimeError("电脑热点 IPv4 参数不满足测试条件")
return values
def hotspot_client_ips(network: ipaddress.IPv4Network, gateway: str) -> list[str]:
if network is None:
return []
script = (
'$n=[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime];'
'$t=[Windows.Networking.NetworkOperators.NetworkOperatorTetheringManager,Windows.Networking.NetworkOperators,ContentType=WindowsRuntime];'
'$m=$t::CreateFromConnectionProfile($n::GetInternetConnectionProfile());'
'$x=@();foreach($c in $m.GetTetheringClients()){foreach($h in $c.HostNames){$x+=$h.CanonicalName}};'
'$j=$x|ConvertTo-Json -Compress;'
'[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))'
)
result = subprocess.run(["powershell.exe", "-NoProfile", "-Command", script],
capture_output=True, timeout=20)
if result.returncode or not result.stdout.strip():
return []
names = json.loads(base64.b64decode(result.stdout.strip()))
if isinstance(names, str):
names = [names]
addresses = []
for name in names or []:
try:
address = ipaddress.ip_address(name)
except ValueError:
continue
if address in network and str(address) != gateway:
addresses.append(str(address))
return addresses
def original_configuration(client, fields: dict) -> dict:
remote = r'''python3 - <<'PY'
import base64,configparser,json,pathlib,subprocess
u=json.load(open('/var/lib/matrix-screen-controller/wifi_config.json'))['managed_connection_uuid']
assert u
names=['802-11-wireless.ssid','802-11-wireless-security.psk','ipv4.method','ipv4.addresses','ipv4.gateway','ipv4.dns']
d={name:subprocess.check_output(['nmcli','-s','-g',name,'connection','show',u],text=True).strip() for name in names}
d['uuid']=u
for directory in ('/etc/NetworkManager/system-connections','/run/NetworkManager/system-connections'):
for path in pathlib.Path(directory).glob('*'):
if not path.is_file(): continue
cfg=configparser.ConfigParser(interpolation=None,strict=False)
try: cfg.read(path,encoding='utf-8')
except Exception: continue
if cfg.get('connection','uuid',fallback='')==u: d['path']=str(path)
if cfg.get('connection','uuid',fallback='')==u and not d['802-11-wireless-security.psk']:
d['802-11-wireless-security.psk']=cfg.get('wifi-security','psk',fallback='')
identity=json.load(open('/var/lib/matrix-screen-controller/mobile/identity.json'))
d['board_name']='QMS-'+identity['device_id'].replace('-','')[:8]
assert d.get('path')
print(base64.b64encode(json.dumps(d,ensure_ascii=False).encode()).decode())
PY'''
code, out, _ = execute(client, remote, password=fields["密码"], timeout=50)
if code:
raise RuntimeError("不能安全读取受管网络恢复配置")
values = json.loads(base64.b64decode(out.strip()))
if not values["802-11-wireless-security.psk"]:
raise RuntimeError("原网络缺少可恢复的密钥")
if values["ipv4.method"] not in ("manual", "auto"):
raise RuntimeError("原网络 IPv4 模式不受测试恢复器支持")
return values
def resume_configuration(client, fields: dict) -> tuple[dict, str, str]:
current = original_configuration(client, fields)
remote = r'''python3 - <<'PY'
import base64,configparser,json,pathlib
roots=list(pathlib.Path('/var/lib/matrix-screen-controller').glob('.wifi-test-restore-*'))
assert len(roots)==1
root=roots[0]
cfg=configparser.ConfigParser(interpolation=None)
assert cfg.read(root/'original.nmconnection',encoding='utf-8')
address=cfg.get('ipv4','address1',fallback='')
parts=address.split(',',1)
d={
'root':str(root),'unit':'qms-wifi-test-restore-'+root.name.rsplit('-',1)[-1],
'ssid':cfg['wifi']['ssid'],'psk':cfg['wifi-security']['psk'],
'mode':cfg['ipv4']['method'],'address':parts[0],
'gateway':parts[1] if len(parts)>1 else '',
'dns':cfg.get('ipv4','dns',fallback='').replace(';',',').strip(','),
'uuid':cfg['connection']['uuid'],
}
print(base64.b64encode(json.dumps(d,ensure_ascii=False).encode()).decode())
PY'''
code, out, _ = execute(client, remote, password=fields["密码"])
if code:
raise RuntimeError("无法读取上次测试的受保护恢复副本")
backup = json.loads(base64.b64decode(out.strip()))
if backup["uuid"] != current["uuid"] or not backup["psk"]:
raise RuntimeError("恢复副本与受管网络不匹配")
current.update({
"802-11-wireless.ssid": backup["ssid"],
"802-11-wireless-security.psk": backup["psk"],
"ipv4.method": backup["mode"],
"ipv4.addresses": backup["address"],
"ipv4.gateway": backup["gateway"],
"ipv4.dns": backup["dns"],
})
return current, backup["root"], backup["unit"]
def checked_remote(client, command: str, fields: dict, *, timeout=35) -> str:
code, out, _ = execute(client, command, password=fields["密码"], timeout=timeout)
if code:
raise RuntimeError("板端测试保护操作失败")
return out.strip()
def adb_call(adb: Path, serial: str, *arguments: str, timeout=40) -> str:
result = subprocess.run([str(adb), "-s", serial, *arguments],
capture_output=True, timeout=timeout)
if result.returncode:
raise RuntimeError("登记手机的 ADB 操作失败")
return result.stdout.decode("utf-8", "replace")
def connect_at(ip: str, expected_key, fields: dict):
import paramiko
class ExactKey(paramiko.MissingHostKeyPolicy):
def missing_host_key(self, ssh, hostname, key):
if key.get_name() != expected_key.get_name() or key.asbytes() != expected_key.asbytes():
raise RuntimeError("切网后 SSH 主机密钥不匹配")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(ExactKey())
try:
port = int(re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]).group(1)) if re.search(r"(?:^|\s)-p\s+(\d+)", fields["SSH"]) else 22
client.connect(ip, port=port, username=fields["用户名"], password=fields["密码"],
look_for_keys=False, allow_agent=False, timeout=8,
banner_timeout=8, auth_timeout=8)
return client
except Exception:
client.close()
raise RuntimeError("切网后无法通过已核验的 SSH 主机密钥连接") from None
def upload_text(client, text: str, destination: str) -> None:
import io
with client.open_sftp() as sftp:
sftp.putfo(io.BytesIO(text.encode("utf-8")), destination)
def prepare_recovery(client, fields: dict, original: dict, *, minutes: int = 15) -> tuple[str, str]:
suffix = secrets.token_hex(5)
root = f"/var/lib/matrix-screen-controller/.wifi-test-restore-{suffix}"
unit = f"qms-wifi-test-restore-{suffix}"
source = original["path"]
if not source.startswith(("/etc/NetworkManager/system-connections/", "/run/NetworkManager/system-connections/")):
raise RuntimeError("受管网络文件不在预期目录")
checked_remote(client, f"install -d -m 700 '{root}' && install -m 600 '{source}' '{root}/original.nmconnection'", fields)
script = ("#!/bin/sh\nset -eu\n"
f"install -m 600 '{root}/original.nmconnection' '{source}'\n"
"nmcli connection reload\n"
f"nmcli --wait 30 connection up uuid '{original['uuid']}' >/dev/null 2>&1 || true\n")
upload_text(client, script, f"/tmp/{unit}.sh")
checked_remote(client, f"install -m 700 '/tmp/{unit}.sh' '{root}/restore.sh' && rm -f '/tmp/{unit}.sh' && sh -n '{root}/restore.sh'", fields)
checked_remote(client, f"systemd-run --unit='{unit}' --on-active={minutes}m /bin/sh '{root}/restore.sh' >/dev/null && systemctl is-active '{unit}.timer'", fields)
return root, unit
def prepare_boot_recovery(client, fields: dict, root: str, unit: str) -> None:
unit += "-boot"
service = f"[Unit]\nDescription=Temporary Android WiFi test recovery\n[Service]\nType=oneshot\nExecStart=/bin/sh {root}/restore.sh\n"
timer = f"[Unit]\nDescription=Temporary Android WiFi test recovery after reboot\n[Timer]\nOnBootSec=4min\nUnit={unit}.service\n[Install]\nWantedBy=timers.target\n"
upload_text(client, service, f"/tmp/{unit}.service")
upload_text(client, timer, f"/tmp/{unit}.timer")
checked_remote(client, f"install -m 644 '/tmp/{unit}.service' '/etc/systemd/system/{unit}.service' && install -m 644 '/tmp/{unit}.timer' '/etc/systemd/system/{unit}.timer' && rm -f '/tmp/{unit}.service' '/tmp/{unit}.timer' && systemctl daemon-reload && systemctl enable '{unit}.timer' >/dev/null", fields)
def clean_recovery(client, fields: dict, root: str, unit: str) -> None:
# These exact paths are created by this runner. Leave them if cleanup fails.
command = (f"systemctl disable --now '{unit}-boot.timer' >/dev/null 2>&1 || true; "
f"systemctl stop '{unit}.timer' '{unit}-continued.timer' >/dev/null 2>&1 || true; "
f"systemctl stop '{unit}.service' >/dev/null 2>&1 || true; "
f"rm -f '/etc/systemd/system/{unit}-boot.service' '/etc/systemd/system/{unit}-boot.timer'; "
"systemctl daemon-reload; "
f"rm -f '{root}/restore.sh' '{root}/original.nmconnection'; rmdir '{root}'")
checked_remote(client, command, fields)
def verify_restored(client, fields: dict, root: str) -> bool:
remote = f'''python3 - <<'PY'
import configparser,hashlib,pathlib,subprocess
backup=pathlib.Path('{root}/original.nmconnection')
cfg=configparser.ConfigParser(interpolation=None);cfg.read(backup,encoding='utf-8')
u=cfg['connection']['uuid']
matches=[]
for directory in ('/etc/NetworkManager/system-connections','/run/NetworkManager/system-connections'):
for path in pathlib.Path(directory).glob('*'):
if not path.is_file(): continue
current=configparser.ConfigParser(interpolation=None)
try: current.read(path,encoding='utf-8')
except Exception: continue
if current.get('connection','uuid',fallback='')==u: matches.append(path)
active=subprocess.check_output(['nmcli','-g','GENERAL.CONNECTION','device','show','wlan0'],text=True).strip()
saved=subprocess.check_output(['nmcli','-g','802-11-wireless.ssid','connection','show',u],text=True).strip()
print(int(len(matches)==1 and hashlib.sha256(matches[0].read_bytes()).digest()==hashlib.sha256(backup.read_bytes()).digest() and saved==cfg['wifi']['ssid'] and active==cfg['connection']['id']))
PY'''
return checked_remote(client, remote, fields) == "1"
def wifi_snapshot(client, fields: dict) -> dict:
remote = "cd /opt/matrix-screen-controller && .venv/bin/python -c 'import json; from app.network.manager import NmcliNetworkManager; u=json.load(open(\"/var/lib/matrix-screen-controller/wifi_config.json\"))[\"managed_connection_uuid\"]; n=NmcliNetworkManager(); print(json.dumps({\"saved\":n.read_saved(u),\"active\":n.read_active()},ensure_ascii=False))'"
return json.loads(checked_remote(client, remote, fields, timeout=30))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--adb", type=Path, required=True)
parser.add_argument("--build", type=Path, required=True)
parser.add_argument("--resume", action="store_true", help="Continue after the saved recovery profile is active on the computer hotspot")
parser.add_argument("--root-swipe", action="store_true", help="Requires explicit root permission for scrolling only the foreground App settings list")
parser.add_argument("--skip-wrong", action="store_true", help="Use only when the wrong-password branch has been separately observed and archived")
parser.add_argument("--wrong-only", action="store_true", help="Verify the failure branch and restore the original profile")
parser.add_argument("--validate-only", action="store_true", help="Verify form and invalid IPv4 without switching networks")
parser.add_argument("--final-check", action="store_true", help="Verify App, BLE, board, and recovery cleanup on the original network")
args = parser.parse_args()
args.root_swipe = True
if not (args.wrong_only or args.skip_wrong or args.resume or args.validate_only or args.final_check):
raise RuntimeError("请选择 --validate-only、--wrong-only、--final-check 或已完成错误密码单测后的 --skip-wrong")
phone = next(d for d in load_registration() if d["alias"] == "android-test-a")
check_connected(str(args.adb), phone)
root_check = subprocess.run([str(args.adb), '-s', phone['serial'], 'shell', 'su', '-c', 'id -u'], capture_output=True, text=True, timeout=15)
if root_check.returncode or root_check.stdout.strip() != '0':
raise RuntimeError('测试手机 root 权限不可用;停止测试,等待用户处理')
local_only = args.validate_only or args.final_check or args.wrong_only
hotspot = dict(ssid="", password="", gateway="", prefix=24) if local_only else hotspot_configuration()
network = None
static_address = ""
if not local_only:
network = ipaddress.ip_network(f"{hotspot['gateway']}/{hotspot['prefix']}", strict=False)
static_address = str(network.network_address + 250)
if ipaddress.ip_address(static_address) not in network or static_address == hotspot["gateway"]:
raise RuntimeError("电脑热点静态地址候选不满足安全条件")
network_check = subprocess.run([
"powershell.exe", "-NoProfile", "-Command",
f"$g=Get-NetIPAddress -IPAddress '{hotspot['gateway']}' -AddressFamily IPv4 -ErrorAction SilentlyContinue; "
f"$n=Get-NetNeighbor -IPAddress '{static_address}' -ErrorAction SilentlyContinue; "
"if($g.PrefixLength -eq 24 -and ($null -eq $n -or $n.State -eq 'Unreachable')){exit 0}else{exit 1}",
], capture_output=True, timeout=15)
if network_check.returncode:
raise RuntimeError("热点网段或静态地址候选不满足安全条件")
if args.resume:
gate_path = Path(__file__).resolve().parents[3] / "测试相关资料/核桃派的用户名和密码和ip/prepare_credentials.py"
spec = importlib.util.spec_from_file_location("qms_credentials", gate_path)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
if not gate.ensure_credentials(gate_path.parent):
raise RuntimeError("设备凭据门禁未通过")
fields = gate._parse_fields(gate_path.with_name(gate.PRIVATE_NAME))
current_ip = adb_call(args.adb, phone["serial"], "shell", "run-as", "org.qimiaoscreen.controller", "cat", "files/qms-wifi-test-ip.txt").strip()
if ipaddress.ip_address(current_ip) not in network:
raise RuntimeError("当前设备地址不是电脑热点网段")
import paramiko
keys = paramiko.HostKeys()
keys.load(str(Path.home() / ".codex-tools/tmp/qms-mobile-known_hosts"))
expected_key = next(iter(keys.lookup(fields["IP"].strip()).values()))
board = connect_at(current_ip, expected_key, fields)
original, backup_root, backup_unit = resume_configuration(board, fields)
else:
board, fields = connect()
expected_key = board.get_transport().get_remote_server_key()
original = original_configuration(board, fields)
current_ip = fields["IP"].strip()
backup_root = backup_unit = None
if not local_only and hotspot["ssid"] == original["802-11-wireless.ssid"]:
raise RuntimeError("电脑热点与原网络相同,不能进行切换测试")
host_secret = None
completed: list[str] = []
try:
addresses = original["ipv4.addresses"].splitlines()
if original["ipv4.method"] == "manual" and not addresses:
raise RuntimeError("原静态地址无法恢复")
original_address, _, original_prefix = (addresses[0] if addresses else "").partition("/")
if args.wrong_only:
hotspot = dict(ssid=original['802-11-wireless.ssid'], password=original['802-11-wireless-security.psk'],
gateway=original['ipv4.gateway'], prefix=int(original_prefix or '24'))
values = {
"board_name": original["board_name"],
"hotspot_ssid": hotspot["ssid"], "hotspot_password": hotspot["password"],
"original_ssid": original["802-11-wireless.ssid"],
"original_password": original["802-11-wireless-security.psk"],
"original_mode": original["ipv4.method"], "original_address": original_address,
"original_prefix": original_prefix or "24",
"original_gateway": original["ipv4.gateway"],
"original_dns": ",".join(re.split(r"[,\n]+", original["ipv4.dns"])),
"static_address": static_address, "hotspot_gateway": hotspot["gateway"],
"wrong_password": "qms-invalid-password-2026",
}
private_tmp = Path.home() / ".codex-tools/tmp"
private_tmp.mkdir(parents=True, exist_ok=True)
path = private_tmp / ("qms-wifi-test-" + secrets.token_hex(8) + ".json")
path.write_text(json.dumps(values, ensure_ascii=False), encoding="utf-8")
host_secret = path
test_apk = args.build / "androidApp/build/outputs/apk/androidTest/debug/androidApp-debug-androidTest.apk"
if not test_apk.is_file():
raise RuntimeError("专项 Android 测试 APK 不存在")
adb_call(args.adb, phone["serial"], "install", "-r", str(test_apk), timeout=120)
adb_call(args.adb, phone["serial"], "push", str(path), "/data/local/tmp/qms-wifi-test-private.json")
adb_call(args.adb, phone["serial"], "shell", "chmod", "600", "/data/local/tmp/qms-wifi-test-private.json")
helper = Path(__file__).with_name("authorized_app_input.sh")
adb_call(args.adb, phone["serial"], "push", str(helper), "/data/local/tmp/qms-authorized-input.sh")
def run_stage(stage: str) -> None:
adb_call(args.adb, phone["serial"], "shell", "am", "force-stop", "org.qimiaoscreen.controller")
output = adb_call(args.adb, phone["serial"], "shell", "am", "instrument", "-w", "-r",
"-e", "wifi_stage", stage, "-e", "root_swipe", str(args.root_swipe).lower(), "-e", "class",
"org.qimiaoscreen.controller.WifiSwitchTest",
"org.qimiaoscreen.controller.test/androidx.test.runner.AndroidJUnitRunner",
timeout=180)
if "OK (1 test)" not in output:
safe = output
for private in sorted({*map(str, values.values()), *map(str, fields.values()), phone["serial"]}, key=len, reverse=True):
if len(private) >= 4:
safe = safe.replace(private, "[private]")
lines = [line for line in safe.splitlines() if any(marker in line for marker in
("UI tap", "Exception", "Error", "Assertion", "WifiSwitchTest.kt", "INSTRUMENTATION_CODE", "Failure", "java.lang", "androidx.test"))]
print("脱敏诊断:" + " | ".join((lines[:5] + lines[-8:]))[:1800], flush=True)
raise RuntimeError(f"App WiFi 专项阶段失败:{stage};原始日志已抑制")
if stage == 'wrong':
print('认证错误明确分类:' + str('AUTH_FAILED_CONFIRMED' in output), flush=True)
completed.append(stage)
print(f"阶段通过:{stage}", flush=True)
def reconnect_hotspot(preferred: str | None = None):
for _ in range(12):
candidates = ([preferred] if preferred else []) + hotspot_client_ips(network, hotspot["gateway"])
for address in dict.fromkeys(x for x in candidates if x):
try:
return address, connect_at(address, expected_key, fields)
except Exception:
pass
time.sleep(5)
raise RuntimeError("热点切换后无法找到已核验主机密钥的核桃派")
if args.final_check:
run_stage("verify")
snapshot = wifi_snapshot(board, fields)
if (snapshot["saved"]["ssid"] != original["802-11-wireless.ssid"]
or snapshot["active"]["ssid"] != original["802-11-wireless.ssid"]
or not snapshot["active"]["connected"]):
raise RuntimeError("最终板端原网络状态不一致")
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
residue = checked_remote(board, "find /var/lib/matrix-screen-controller -maxdepth 1 -name '.wifi-test-restore-*' -print | wc -l", fields)
if residue != "0":
raise RuntimeError("本轮 WiFi 恢复材料尚未清理")
print("App、BLE、板端原网络与恢复材料最终核验通过", flush=True)
return 0
if not args.resume:
run_stage("validate")
run_stage("validate_invalid")
if args.validate_only:
print("App 字段校验通过,原网络未切换", flush=True)
return 0
run_stage("scan")
backup_root, backup_unit = prepare_recovery(board, fields, original, minutes=3 if args.wrong_only else 15)
print("板端受保护备份与定时恢复已就绪", flush=True)
else:
checked_remote(board, f"systemd-run --unit='{backup_unit}-continued' --on-active=15m /bin/sh '{backup_root}/restore.sh' >/dev/null && systemctl is-active '{backup_unit}-continued.timer' && systemctl stop '{backup_unit}.timer'", fields)
print("已核验原备份并续设恢复定时器", flush=True)
if args.wrong_only:
run_stage("wrong")
run_stage("inspect_failure")
print("错误密码任务失败已由 App 中文状态确认;正在恢复原网络", flush=True)
return 0
stages = ("manual", "dhcp_return") if args.resume else ("dhcp", "manual", "dhcp_return")
for stage in stages:
run_stage(stage)
if stage != "wrong":
current_ip, new_board = reconnect_hotspot(values["static_address"] if stage == "manual" else None)
board.close()
board = new_board
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
snapshot = wifi_snapshot(board, fields)
if snapshot["saved"]["ssid"] != hotspot["ssid"] or snapshot["active"]["ssid"] != hotspot["ssid"] or not snapshot["active"]["connected"]:
raise RuntimeError(f"板端网络状态不符合阶段 {stage}")
expected_mode = "manual" if stage == "manual" else "dhcp"
if snapshot["saved"]["ipv4_mode"] != expected_mode:
raise RuntimeError(f"板端 IPv4 模式不符合阶段 {stage}")
if stage == "manual" and snapshot["active"]["ipv4_address"] != values["static_address"]:
raise RuntimeError("静态 IPv4 地址未实际生效")
run_stage("restore")
board.close()
board = None
for _ in range(24):
time.sleep(3)
try:
board = connect_at(fields["IP"].strip(), expected_key, fields)
break
except RuntimeError:
pass
if board is None:
raise RuntimeError("立即恢复原网络后 SSH 未恢复;板端定时恢复仍启用")
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
run_stage("verify")
snapshot = wifi_snapshot(board, fields)
if (snapshot["saved"]["ssid"] != original["802-11-wireless.ssid"]
or snapshot["active"]["ssid"] != original["802-11-wireless.ssid"]
or not snapshot["active"]["connected"]):
raise RuntimeError("立即生效后未恢复原网络")
code, journal, _ = execute(board, "journalctl -u matrix-screen-controller.service --since '-30 min' --no-pager -o cat", password=fields["密码"], timeout=30)
if code or any(secret in journal for secret in (hotspot["password"], original["802-11-wireless-security.psk"], values["wrong_password"])):
raise RuntimeError("板端日志检查失败或含有测试密码")
checked_remote(board, f"/bin/sh '{backup_root}/restore.sh'", fields, timeout=50)
if not verify_restored(board, fields, backup_root):
raise RuntimeError("原网络未完全恢复")
clean_recovery(board, fields, backup_root, backup_unit)
backup_root = backup_unit = None
print("原网络恢复、密码日志检查和恢复材料清理通过", flush=True)
return 0
finally:
if host_secret is not None:
host_secret.unlink(missing_ok=True)
for target in ("/data/local/tmp/qms-wifi-test-private.json", "/data/local/tmp/qms-authorized-input.sh"):
try: adb_call(args.adb, phone["serial"], "shell", "rm", "-f", target)
except Exception: pass
try: adb_call(args.adb, phone["serial"], "shell", "run-as", "org.qimiaoscreen.controller", "rm", "-f", "files/qms-wifi-test-ip.txt")
except Exception: pass
if backup_root and backup_unit:
for ip in dict.fromkeys([current_ip, *hotspot_client_ips(network, hotspot["gateway"]), fields["IP"].strip()]):
try:
if board is None: board = connect_at(ip, expected_key, fields)
try:
checked_remote(board, f"/bin/sh '{backup_root}/restore.sh'", fields, timeout=50)
except Exception:
# The connection can close while the restore switches WiFi.
pass
break
except Exception:
if board is not None: board.close()
board = None
if board is not None:
board.close()
board = None
for _ in range(40 if args.wrong_only else 12):
try:
board = connect_at(fields["IP"].strip(), expected_key, fields)
checked_remote(board, "systemctl is-active matrix-screen-controller.service", fields)
if not verify_restored(board, fields, backup_root):
raise RuntimeError("原网络恢复尚未核验")
if args.wrong_only:
code, journal, _ = execute(board, "journalctl -u matrix-screen-controller.service --since '-10 min' --no-pager -o cat", password=fields["密码"], timeout=30)
if code or any(secret in journal for secret in (hotspot["password"], original["802-11-wireless-security.psk"], values["wrong_password"])):
raise RuntimeError("错误密码阶段日志检查失败或含有测试密码")
clean_recovery(board, fields, backup_root, backup_unit)
print("失败后已核对原网络并清理保护材料", flush=True)
break
except Exception:
if board is not None: board.close()
board = None
time.sleep(5)
if board is None:
print("即时恢复无法连接;板端定时恢复仍已启用", flush=True)
if args.wrong_only:
raise RuntimeError("错误密码单测的原网络恢复尚未核验")
if board is not None: board.close()
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error:
print(f"WiFi 专项测试未通过:{error}", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,56 @@
"""Read-only numeric performance samples; no device identity or WiFi payloads."""
import argparse
import json
from pathlib import Path
import shlex
from remote_support import connect, execute
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--seconds', type=int, default=120)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
if not 5 <= args.seconds <= 600:
parser.error('seconds must be 5..600')
if args.output.exists():
parser.error('output already exists; do not overwrite evidence')
script = r'''
import json,time,urllib.request
rows=[]
start=time.monotonic()
while time.monotonic()-start < DURATION:
with urllib.request.urlopen('http://127.0.0.1:8080/api/status',timeout=5) as response:
s=json.load(response)
d=s['screen']['driver_status'];r=s['resources']
rows.append(dict(elapsed_seconds=round(time.monotonic()-start,3),
connected=s['mobile']['connected'],animation_active=s['state']['animation_active'],
refresh_hz=d['actual_refresh_rate_hz'],frames=d['completed_frames'],
deadline_misses=d['deadline_misses'],oe_faults=d['oe_pulse_faults'],
driver_error_present=bool(d['last_error'] or d['oe_timing_error']),
cpu_application_percent=r['cpu']['application_percent'],
memory_application_bytes=r['memory']['application_bytes']))
time.sleep(1)
print(json.dumps(rows))
'''.replace('DURATION', str(args.seconds))
client, fields = connect()
try:
code, output, _ = execute(client, 'python3 -c ' + shlex.quote(script), timeout=args.seconds + 20)
if code:
raise RuntimeError('Performance collection failed; raw output withheld')
rows = json.loads(output)
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open('x', encoding='utf-8') as stream:
json.dump(rows, stream, ensure_ascii=False, indent=2)
stream.write('\n')
print(json.dumps(dict(samples=len(rows),connected_samples=sum(r['connected'] for r in rows),
deadline_misses_delta=rows[-1]['deadline_misses']-rows[0]['deadline_misses'],
oe_faults_delta=rows[-1]['oe_faults']-rows[0]['oe_faults'],
min_refresh_hz=min(r['refresh_hz'] for r in rows),
driver_error_seen=any(r['driver_error_present'] for r in rows))))
finally:
client.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,63 @@
# Android 测试常见问题与排障
测试前阅读本文件及 [可重复测试流程](README.md)。这里维护可复用的方法;单次失败、重测次数、耗时与验收结果写入 `../测试结果归档/`。本页依据 [四栏版真机记录](../测试结果归档/2026-09-26_四栏界面优化.md) 整理,方法有效不等于所有同类错误均有相同根因。
## 1. root 命令在 instrumentation 中失败
主机 ADB 的 `su -c` 检查通过,但 `UiDevice.executeShellCommand("su -c '...'")` 失败时,先检查命令解析。该入口不能假设经过交互 shell;字符串中的引号不保证按 shell 语法处理。已验证做法是调用短时上传的 LF shell 辅助文件,由文件内部执行 `su -c "$*"`。布局检查使用 `authorized_root_command.sh`;点击、滑动、编辑使用有前台限制的 `authorized_app_input.sh`。运行器结束清理手机临时文件。
区分命令构造错误与真实权限不足。真实 root/ADB/授权不可用时停止等待用户,不继续测试、不使用普通注入或无障碍动作替代。辅助文件只用于已授权的本软件调试,不扩大用途。
## 2. Windows 写出的 shell 文件无法执行
CRLF 会破坏 Android/Linux shell 的解释器或参数。生成 `.sh` 使用显式 UTF-8/LF(例如 `Path.write_bytes`,或文本写入指定 `newline="\n"`),不要依赖 Windows 文本默认换行。上传前检查不含 CRLF;Git 的 `*.sh eol=lf` 不能代替对本次生成文件的检查。不要因脚本解析失败反复请求 root。
## 3. 布局变更终止测试进程
在 instrumentation 运行中改变密度、字体或方向,可能使应用/测试进程重建或终止。不要把此类 `Process crashed` 直接当成产品崩溃,也不要在同一进程里继续依赖原窗口句柄。
使用 `run_ui_layout_test.py`:电脑端先保存原密度覆盖、字体和旋转设置,在每组测试启动前设置目标布局;测试启动后显式启动 Activity,再读取语义节点并执行 root 输入。电脑端 `finally` 在正常结束或失败时恢复原值,原设置不存在时删除对应项,原密度未覆盖时使用 reset。字体/方向回读核验;若新增 USB 保持唤醒等设置改动,同样必须先保存、恢复并核验,不以默认值猜测。安全锁屏不能正常解锁时等待用户,不绕过密码。
## 4. 窗口转储看不到控件、文字重复或节点不可点击
独立 `uiautomator dump` 在本机 MIUI 上曾无法稳定读取 App 控件。使用已验证的持久 instrumentation 入口读取语义节点;读取不是无障碍动作注入。不要盲点坐标,也不要因此改用无障碍点击。
Compose 的可见文字节点未必有 clickable 标志,导航“连接”也可能与扫描结果按钮同名。先限制 App 包、当前页面/条目容器,再筛选可见且边界非空的节点;底栏可按同名可见节点位置选择。检查边界在当前屏幕内,重新读取中心点后执行 root tap。等待页面加载完成再取位置,布局变化后不可复用旧坐标。列表滚动只在语义识别出的本 App 可见滚动区域内执行 root swipe。
## 5. 找不到 DHCP 开关或密码可见复选框
Compose 语义树不保证使用 `android.widget.Switch`/`CheckBox` 类名。定位标签附近的 checkable 节点:按标签实时纵向范围筛选,并结合行内左右位置区分 DHCP 开关与密码可见复选框。点击后检查 checked 状态或静态地址字段是否出现;不要只认按钮类名,也不能用一个任意 checkable 节点代替。
## 6. 文本未清空、中文/密码输入失败、返回键退出 App
Ctrl+A 在该测试环境下不可靠。现有做法:App 前台进程设置临时剪贴板,root 点击实时字段位置,按 MOVE_END,再按当前文本长度加余量执行有界 DEL,最后 root paste。清空操作计数有上限,输入后回读字段核验。剪贴板在 `finally` 清理,密码不放 ADB 参数、日志或公开转储中。
仅在输入法实际显示时发送 BACK 收起键盘;否则 BACK 可能退出页面。使用 helper 的 `hide-keyboard` 分支检查 `mInputShown`。等“屏幕方向”等设置数据就绪后才展开 WiFi 表单,避免数据加载改变控件位置。
## 7. 保存按钮的禁用断言失败
Compose 的禁用语义可能位于父节点,文字节点自身仍 enabled。检查文字所在控件及祖先链的 enabled 状态,并结合实际行为(空名称不能打开确认/提交)判断;不要只断言文字节点的 enabled。
## 8. 安装成功后权限又被 MIUI 重置
`adb install` 成功不证明蓝牙权限稳定。现有运行器在覆盖安装后通过 root 授予本 App 必需权限,等待 3 秒并回读;最多三轮,仍未稳定则停止等待用户。不能无限重试,不能修改其他系统安全开关。测试前仍检查登记、ADB、root、前台和必要权限。
## 9. 连续测试的 BLE 扫描或握手偶发失败
每个正常测试阶段结束时通过连接页显式断开,等待释放后再结束进程;不要只依赖紧接着的 force-stop。异常时按失败阶段记录扫描、GATT、握手/任务是否已开始,清理旧测试会话后重新扫描,不跨会话复用临时句柄。主机超时不会自动终止 instrumentation,运行器还需结束本 App 的旧测试进程。
确需核验板端服务恢复时,先确认手机已断开,再执行 `check_mobile_lifecycle.py --restart-service`;它会真实停启服务并核验运行目录、持久摘要和接口恢复。保留首次失败,恢复后通过不证明偶发扫描失败的根因。若重现持续存在,停止反复重启,针对具体阶段排查,不降低超时/断言来制造通过。
## 10. WiFi 专项不具备第二网络、错误密码时 SSH 断开
`--validate-only`、`--wrong-only`、`--final-check` 不要求电脑热点;错误密码单测使用当前受管网络。跨 SSID 的 DHCP/静态往返才需要第二个可用网络;缺少时标未验证,不拿重应用原网络或表单测试替代。
任何真实切网先保存受保护原 NetworkManager 配置,并核验定时恢复已建立。错误密码可能使 SSH 断开,继续通过 BLE 查看任务,等待定时恢复;连通后比较原配置摘要、活动网络与服务状态,再清理备份和定时器。网络恢复没有核验就不能写通过,也不能提前删除唯一恢复材料。
失败任务必须区分提交前握手失败与已经提交后的网络失败。“密码错误”只在可靠认证证据/结构化 AUTH_FAILED 下通过验收;超时、缺少 secret 或未知错误不能强行归类。确认密码未进入日志,清理手机私有文件和临时剪贴板,最后执行 `--final-check`。
## 11. 测试完成后的固定复盘
每轮检查:本轮有哪些失败/调整、是否已有可重复防范步骤、恢复是否核验、哪些原因仍未知。已验证解法更新本页和对应流程/构建说明;一次性证据留归档,不能将未证实推测写成固定根因。
最终反馈写明经验已更新的文档,以及尚未解决的问题或未验证边界。若没有新增经验,说明沿用现有方法即可,不复制重复条目。遵守根协作规则:不写真实凭据、手机标识、开发机绝对路径,文档更新后执行仓库卫生检查。
@@ -0,0 +1,19 @@
# 测试设备登记
`测试设备.example.json` 是可提交的空白模板;`测试设备.local.json` 是被根 .gitignore 精确排除的真实登记,不提交、不回显、不自动覆盖。手机的真实序列号仅保存在后者,报告使用 `android-test-a`。
主测试手机已获用户专用测试授权:可自动安装覆盖本 App、清理本 App 测试数据、操作权限与蓝牙;常规测试不需要 root。不包括自动启动视觉检查。摄像头、DroidCam、采集和视觉测试必须另行询问如何启动并等待指示。
从仓库根运行,ADB 路径使用已安装工具的实际位置,不写入仓库配置:
```powershell
python -X utf8 "移动端相关内容/安卓app/如何安卓测试/测试设备登记/prepare_test_device.py" --adb "<ADB可执行文件>" check
```
首次在用户明确授权且仅连接一台已授权 USB 手机时,使用 `register-connected` 代替 `check` 自动读取型号、系统和设备标识并独占创建真实登记。脚本不执行 root、不安装程序、不启用摄像头、不修改手机设置。已有文件一律不覆盖;需要修正时由维护者只编辑真实登记。
无参数的 `check` 仅验证文件;带 `--adb` 同时核验在线授权、型号和 API。无登记时复制空白示例并停止,填写完成后重新检查;不能将空白模板当成真实设备使用。`--alias` 默认 android-test-a,后续多个登记必须明确代号,不自动取首台。
JSON 格式:schema_version 固定 1,devices 为列表。每项包含 alias、serial、model、android_release、api_level、connection(当前 usb)、dedicated_test_device、automation_allowed 和 visual_check_requires_user_instruction。最后两个授权布尔必须与用户实际授权一致,视觉门禁必须为 true;本脚本仅接受已授权的专用测试机。
不得将 `adb devices` 原始输出或异常完整命令放入提交报告。脚本失败只报告原因或字段名。登记不包含 WiFi 密码、root 密码、账户或其他应用数据。第二台手机可以由用户手动安装并辅助验收,无需自动登记其身份。
@@ -0,0 +1,174 @@
from __future__ import annotations
import argparse
import json
import re
import subprocess
from pathlib import Path
DIRECTORY = Path(__file__).resolve().parent
PRIVATE_NAME = "测试设备.local.json"
EXAMPLE_NAME = "测试设备.example.json"
class RegistrationError(RuntimeError):
"""A sanitized registration error; never include device values."""
def validate_document(document: object) -> list[dict]:
if not isinstance(document, dict) or document.get("schema_version") != 1:
raise RegistrationError("schema_version 必须为 1。")
devices = document.get("devices")
if not isinstance(devices, list) or not devices:
raise RegistrationError("devices 必须为非空列表。")
aliases: set[str] = set()
serials: set[str] = set()
for device in devices:
if not isinstance(device, dict):
raise RegistrationError("devices 条目格式错误。")
for field in ("alias", "serial", "model", "android_release"):
value = device.get(field)
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value):
raise RegistrationError(f"字段缺失或格式错误:{field}")
if "<" in value or ">" in value or "待填写" in value:
raise RegistrationError(f"字段仍为占位内容:{field}")
if not re.fullmatch(r"android-test-[a-z0-9-]+", device["alias"]):
raise RegistrationError("alias 必须使用 android-test- 开头的 ASCII 测试代号。")
if device["serial"] != device["serial"].strip() or any(c.isspace() for c in device["serial"]):
raise RegistrationError("serial 格式错误。")
api = device.get("api_level")
if type(api) is not int or api < 26:
raise RegistrationError("api_level 必须为不小于 26 的整数。")
if device.get("connection") != "usb":
raise RegistrationError("当前自动登记仅支持 usb。")
for field in ("dedicated_test_device", "automation_allowed", "visual_check_requires_user_instruction"):
if device.get(field) is not True:
raise RegistrationError(f"授权或门禁字段未明确启用:{field}")
if device["alias"] in aliases or device["serial"] in serials:
raise RegistrationError("登记存在重复 alias 或 serial。")
aliases.add(device["alias"])
serials.add(device["serial"])
return devices
def load_registration(directory: Path = DIRECTORY) -> list[dict]:
path = directory / PRIVATE_NAME
if not path.exists():
try:
with path.open("xb") as stream:
stream.write((directory / EXAMPLE_NAME).read_bytes())
except FileExistsError:
pass
raise RegistrationError("已准备空白私有登记;请填写真实登记后重新检查,当前停止真机步骤。")
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
raise RegistrationError("真实登记无法读取或不是有效 UTF-8 JSON;原文件未改动。") from None
return validate_document(document)
def run_adb(adb: str, arguments: list[str]) -> str:
try:
result = subprocess.run(
[adb, *arguments], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
raise RegistrationError("ADB 不可执行或操作超时;未输出命令及设备标识。") from None
if result.returncode != 0:
raise RegistrationError("ADB 操作失败;未输出原始设备日志。")
return result.stdout.strip()
def list_devices(adb: str) -> list[tuple[str, str, bool]]:
rows = []
# Windows ADB often omits usb: from devices -l and returns unknown for
# get-devpath. -d selects a USB transport only; ambiguity must fail closed.
try:
single_usb_serial = run_adb(adb, ["-d", "get-serialno"])
except RegistrationError:
single_usb_serial = ""
for line in run_adb(adb, ["devices", "-l"]).splitlines():
fields = line.split()
if len(fields) >= 2 and fields[1] in {"device", "offline", "unauthorized", "no"}:
usb = any(v.startswith("usb:") for v in fields[2:]) or fields[0] == single_usb_serial
rows.append((fields[0], fields[1], usb))
return rows
def read_identity(adb: str, serial: str) -> dict:
def prop(name: str) -> str:
return run_adb(adb, ["-s", serial, "shell", "getprop", name])
try:
api = int(prop("ro.build.version.sdk"))
except ValueError:
raise RegistrationError("无法读取有效的 Android API 版本。") from None
return {"serial": serial, "model": prop("ro.product.model"),
"android_release": prop("ro.build.version.release"), "api_level": api}
def register_connected(adb: str, alias: str, directory: Path = DIRECTORY) -> None:
path = directory / PRIVATE_NAME
if path.exists():
raise RegistrationError("真实登记已存在,不覆盖、不重建;请使用 check。")
rows = list_devices(adb)
if len(rows) != 1:
raise RegistrationError("首次自动登记要求仅接入一台手机;当前数量不符合。")
serial, status, usb = rows[0]
if status != "device":
raise RegistrationError("手机未授权或离线;请完成 USB 调试授权。")
if not usb:
raise RegistrationError("未确认 USB 传输,拒绝将无线设备自动登记为 USB。")
device = {"alias": alias, **read_identity(adb, serial), "connection": "usb",
"dedicated_test_device": True, "automation_allowed": True,
"visual_check_requires_user_instruction": True}
document = {"schema_version": 1, "devices": [device]}
validate_document(document)
try:
with path.open("x", encoding="utf-8", newline="\n") as stream:
json.dump(document, stream, ensure_ascii=False, indent=2)
stream.write("\n")
except FileExistsError:
raise RegistrationError("真实登记已被创建;拒绝覆盖。") from None
def check_connected(adb: str, device: dict) -> None:
row = next((r for r in list_devices(adb) if r[0] == device["serial"]), None)
if row is None or row[1] != "device" or not row[2]:
raise RegistrationError("登记手机未通过已授权 USB 连接检查;不改用其他手机。")
current = read_identity(adb, device["serial"])
if any(current[key] != device[key] for key in ("model", "android_release", "api_level")):
raise RegistrationError("手机系统或型号与登记不一致;请核实登记,不自动更新。")
def main() -> int:
parser = argparse.ArgumentParser(description="测试手机私有登记门禁;不输出真实标识")
parser.add_argument("--adb", help="已有 adb 可执行文件")
parser.add_argument("--alias", default="android-test-a")
parser.add_argument("action", choices=("check", "register-connected"), default="check", nargs="?")
args = parser.parse_args()
try:
if args.action == "register-connected":
if not args.adb:
raise RegistrationError("自动登记需要 --adb,且必须已有用户专用测试授权。")
register_connected(args.adb, args.alias)
devices = load_registration()
device = next((item for item in devices if item["alias"] == args.alias), None)
if device is None:
raise RegistrationError("未找到指定测试代号;不自动选择其他设备。")
if args.adb:
check_connected(args.adb, device)
print(f"测试登记通过:{device['alias']};未输出设备标识,视觉检查仍须单独询问。")
return 0
except RegistrationError as error:
print(f"测试登记未通过:{error}")
return 2
except OSError:
print("测试登记读写失败;未输出路径、设备标识或原始异常。")
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,101 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("registration", Path(__file__).with_name("prepare_test_device.py"))
registration = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(registration)
def valid_device():
return {"alias": "android-test-a", "serial": "FAKE-UNIT-SERIAL", "model": "Fixture",
"android_release": "13", "api_level": 33, "connection": "usb",
"dedicated_test_device": True, "automation_allowed": True,
"visual_check_requires_user_instruction": True}
class RegistrationTests(unittest.TestCase):
def test_windows_usb_without_listing_marker(self):
with patch.object(registration, "run_adb", side_effect=["FAKE-USB", "List of devices attached\nFAKE-USB device product:test transport_id:1\n"]):
self.assertEqual(registration.list_devices("adb"), [("FAKE-USB", "device", True)])
def test_ambiguous_usb_does_not_guess_from_serial_shape(self):
with patch.object(registration, "run_adb", side_effect=[registration.RegistrationError("ambiguous"), "List of devices attached\nFAKE-USB device transport_id:1\n"]):
self.assertEqual(registration.list_devices("adb"), [("FAKE-USB", "device", False)])
def test_blank_copy_blocks_and_preserves_existing(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary) / "中文 空格"
directory.mkdir()
payload = Path(__file__).with_name(registration.EXAMPLE_NAME).read_bytes()
(directory / registration.EXAMPLE_NAME).write_bytes(payload)
with self.assertRaises(registration.RegistrationError):
registration.load_registration(directory)
private = directory / registration.PRIVATE_NAME
self.assertEqual(private.read_bytes(), payload)
with self.assertRaises(registration.RegistrationError):
registration.load_registration(directory)
self.assertEqual(private.read_bytes(), payload)
def test_existing_file_never_overwritten_even_if_corrupt(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
private = directory / registration.PRIVATE_NAME
private.write_bytes(b"not-json")
with patch.object(registration, "list_devices") as listing:
with self.assertRaises(registration.RegistrationError):
registration.register_connected("adb", "android-test-a", directory)
listing.assert_not_called()
self.assertEqual(private.read_bytes(), b"not-json")
def test_ambiguous_unauthorized_and_wireless_registration_refused(self):
for rows in ([], [("A", "device", True), ("B", "device", True)],
[("A", "unauthorized", True)], [("A", "device", False)]):
with self.subTest(rows=rows), tempfile.TemporaryDirectory() as temporary:
with patch.object(registration, "list_devices", return_value=rows):
with self.assertRaises(registration.RegistrationError):
registration.register_connected("adb", "android-test-a", Path(temporary))
self.assertFalse((Path(temporary) / registration.PRIVATE_NAME).exists())
def test_private_creation_and_no_device_values_in_errors(self):
device = valid_device()
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
with patch.object(registration, "list_devices", return_value=[(device["serial"], "device", True)]), \
patch.object(registration, "read_identity", return_value={k: device[k] for k in ("serial", "model", "android_release", "api_level")}):
registration.register_connected("adb", device["alias"], directory)
self.assertEqual(registration.load_registration(directory), [device])
with patch.object(registration, "list_devices", return_value=[("DIFFERENT-PRIVATE-SERIAL", "device", True)]):
with self.assertRaises(registration.RegistrationError) as caught:
registration.check_connected("adb", device)
self.assertNotIn(device["serial"], str(caught.exception))
self.assertNotIn("DIFFERENT-PRIVATE-SERIAL", str(caught.exception))
def test_authorization_visual_gate_duplicates_and_placeholders(self):
for key, value in (("automation_allowed", False), ("visual_check_requires_user_instruction", False),
("serial", "<fill>"), ("api_level", True)):
device = valid_device()
device[key] = value
with self.subTest(key=key), self.assertRaises(registration.RegistrationError):
registration.validate_document({"schema_version": 1, "devices": [device]})
with self.assertRaises(registration.RegistrationError):
registration.validate_document({"schema_version": 1, "devices": [valid_device(), valid_device()]})
def test_system_change_blocks_without_modifying_document(self):
device = valid_device()
before = json.dumps(device)
with patch.object(registration, "list_devices", return_value=[(device["serial"], "device", True)]), \
patch.object(registration, "read_identity", return_value={**device, "api_level": 34}):
with self.assertRaises(registration.RegistrationError):
registration.check_connected("adb", device)
self.assertEqual(json.dumps(device), before)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,16 @@
{
"schema_version": 1,
"devices": [
{
"alias": "android-test-a",
"serial": "",
"model": "",
"android_release": "",
"api_level": null,
"connection": "usb",
"dedicated_test_device": false,
"automation_allowed": false,
"visual_check_requires_user_instruction": true
}
]
}
@@ -0,0 +1,70 @@
# Android 首版详细设计
本设计对应 MOBILE-* 和协议 1.0。已实现项以本文件说明及源码为准,真实通过范围见测试结果归档;测试目标不自动等于已完成验收。
## 1. 模块与依赖方向
工程使用 Kotlin DSL、Gradle Wrapper、版本目录和依赖校验;版本必须精确固定,不使用动态 latest。模块为 `androidApp`(Android 入口与平台组装)、`sharedCore`(KMP 协议/业务/状态机)、`sharedUi`(KMP Compose 界面);依赖 androidApp → sharedUi → sharedCore,androidApp 也实现 sharedCore 的平台接口。
应用 ID 固定 `org.qimiaoscreen.controller`,显示名称“奇妙小屏幕”,minSdk 26。首版 App 版本 `0.1.0`、versionCode 从 1 开始;交付构建增加 versionCode,不绑定设备 VERSION。debug 为当前唯一交付类型,无正式签名配置。平台工具链安装后将实际验证的 Kotlin、Compose、AGP、Gradle、JDK、SDK 版本登记在构建文档并提交锁定文件。
sharedCore 通过 MobilePlatform 聚合扫描、连接、手机型号、本地键值读写和 SecureChannel 工厂;DeviceLink 封装分片收发与关闭,SecureChannel 封装握手与加解密;Android BluetoothGatt、Activity、Context、Keystore 等类型不能出现在 commonMain。共享状态机使用协程与 StateFlow,取消结构化管理。JSON 使用 kotlinx.serialization,保留未知可选字段兼容策略。依赖注入显式通过构造函数完成,首版不引入额外 DI 框架。
Android 平台以 Java 安全 API 实现 P-256 ECDH/HKDF-SHA256/AES-GCM,公共模块定义字节契约;设备端使用成熟 Python 密码库。iOS 未来接入平台加密与 CoreBluetooth,不以 Android 类型模拟 iOS。
## 2. 状态机与异步行为
连接状态:PermissionRequired → BluetoothOff/Idle → Scanning → Connecting → Discovering → Subscribing → Handshaking → Ready;失败进入 Error(分类原因)并释放 GATT。主动断开清理订阅、密钥、分片、轮询和队列,但保留上次设备 ID。主动断开后当前前台会话不自动重连;用户再点连接或下一次进入前台才尝试。
扫描单轮 12 秒;点击重试开始新轮,不无限循环。自动重连扫描上次持久 ID 对应短编号,握手校验完整 ID,不因名称相同认错设备;候选完整 ID 不符则释放并报错,用户可重新扫描。App 最多一个 BluetoothGatt。每项 GATT 操作等待回调或超时后再执行下一项;迟到回调按连接 generation 丢弃。
用户控制队列优先,状态次之,缩略图与当前帧最低优先。单个预览在途,数据无变化不反复下载。背景立即停止轮询和缩略图,30 秒延迟任务释放连接;回前台取消延迟,Ready 则刷新,非 Ready 则重连上次设备。进程死亡不假设回调可执行,由设备心跳门限回收会话。
普通操作请求超时 10 秒,扫描 WiFi 20 秒,切网任务最多轮询 60 秒后转“结果尚未确认,可重新读取”;超时不是事务回滚证据,不自动重发变更。
## 3. 页面行为
设备页:未连接时扫描列表与权限/蓝牙恢复入口;连接时身份、断开、昵称修改、版本及分组状态卡。所有未知状态用“暂不可用”及原因,不置零;容量读取有缓存。手机昵称只存本地,连接时加密发送。
内容页:顶部当前帧及更新时间,下方当前内容/动图控制,再下方设备顺序的库列表。演示、静态、动图可辨认,缩略图懒加载;条目按钮为“播放”和“设为开机默认并播放”。当前实际 Activity 测试使用 UI Automator 的控件语义文本定位,root 点击仅采用该节点实时位置;尚未完成独立 Compose testTag 测试矩阵。动画会话变化时取消旧滑动提交,不对新动画误发旧位置。
设置页:先设备设置,再 WiFi,再 App 本地设置。亮度滑动结束才提交;方向、刷新率和性能模式明确操作反馈。多个未保存字段组成 changes,带修订,冲突保留草稿并要求刷新后再提交。不要完整提交缓存 settings。
WiFi:扫描来自设备;允许手工 SSID;密码动作“保留/替换”,开放网络不要求密码。不提供已存密码读取。只在本次编辑内暂存密码,提交或离开时清除,不写 SavedStateHandle、日志和磁盘。静态 IPv4 必填地址、前缀、网关、DNS,DHCP 模式不提交残留静态字段。保存与连接结果分别显示。未支持的 EAP 类型明确禁用,不猜测配置。
所有页面适应小屏、字体放大和横竖屏;按钮提供无障碍语义。UI 采用共享 Material 3,跟随系统深浅色,无 WebView。图标采用代码/向量资源,首版无需图像生成依赖。
## 4. 平台权限与本地数据
连接异常清理:GATT 建立、加密握手和 RPC 的内部期限到期属于通信失败,必须转换为普通连接错误;不能把 TimeoutCancellationException 当成页面退出引发的协程取消而吞掉。期限到期关闭 GATT,清除会话并恢复扫描入口;变更命令不重放。仅在业务握手开始前,对同一设备的 GATT 建立最多尝试两次,间隔 1 秒;用户主动取消不重试。MTU 请求值为 247,应用报告的接收上限不超过此值;厂商重复 MTU 回调只能更新保守上限,服务发现与 CCCD 订阅只启动一次。
Android 8–11 按平台要求申请前台定位扫描权限并解释用途,系统位置开关影响扫描时提供设置入口;12+ 申请 BLUETOOTH_SCAN/CONNECT,并声明扫描不用于定位。不申请后台定位、联系人、文件全盘访问或摄像头权限。权限拒绝后允许重试,永久拒绝显示系统设置入口;不在启动时重复轰炸权限弹窗。
本地 SharedPreferences 仅保存 last_device_id 与 client_name。缩略图仅驻留内存,每次断线/新连接清空;键为内容类型、条目 ID、修订,以连接隔离设备,最多 128 项或合计 8 MiB Base64 字符,按插入顺序淘汰。不是磁盘缓存,也不是 LRU。设备业务配置以核桃派为准,不自动恢复手机历史副本。禁用敏感测试数据备份;密钥和 WiFi 密码不持久化。
## 5. 设备侧集成
Android GATT 时序补充:连接后请求 MTU 247;若厂商先报告缓存值 517,不立即开始服务发现。收到不大于请求值的实际 MTU 回调后,启动一次服务发现;缺少此回调时使用 2 秒后备任务启动一次发现。连接关闭取消后备任务。这样避免尚未完成 MTU 交换时提前发现服务导致停滞;整体仍受 15 秒 GATT 建立期限约束。此处理不更改协议线格式。
公共控制服务提供与协议对应的业务方法,网页 REST 与 BLE 适配器复用,禁止 BLE HTTP 回环调用网页接口。现有模板解析、默认事务、动画控制和设置应用由同一服务处理。配置修订比较与写入串行,网页操作也更新修订。
BlueZ D-Bus 接入作为可恢复模块,单独管理连接/广播,不在刷新线程执行 IO。蓝牙不可用时设备状态公开故障,显示与网页继续工作。持久身份不进入镜像载荷。网络扫描调用参数化 nmcli,解析转义 SSID,不拼接 shell;超时与错误脱敏。
网页顶栏从共享状态取得 mobile.connected/client_name;已连接状态首次加载只显示常驻标记,观察到会话变更时才显示短提示,断开更新状态。昵称通过 textContent 渲染,不插入 HTML。
## 6. 测试、部署与恢复
模拟 transport 使用同一协议/业务模型,不把 fake 直接混入生产扫描列表;仅调试测试入口选择模拟。测试包括 commonTest、Android instrumentation 与 Python 设备契约测试,共享黄金向量确保加密/分片互操作。真实 BLE 不由模拟测试替代。
部署沿用离线依赖、持久数据保护、真实 systemd 生命周期和失败恢复,启用蓝牙同步修改 dedicated_host 检查。本轮不改驱动扫描算法,不默认视觉验收;确需视觉检查先停下询问用户启动方式。手机端安装与设备端部署分别记录版本/结果,不把任一方成功等同整体成功。
## 7. 当前交互补充与验收边界
当前帧成功读取或 unchanged 回复均刷新本机确认时间;界面显示距最近成功确认的秒数,超过 6 秒提示可能过期,不把静态内容未变化当作断线。内容页读取 content.default.get 显示开机默认名称;设置默认后重新回读。
临时 WiFi 密码仅存在 Compose remember 中,可切换本次可见状态,点击提交后立即清空并隐藏;离开页面不保留。DHCP 提交空 DNS 列表,不带静态地址/前缀/网关。权限拒绝时显示原因和重试/应用设置入口,该分支仍需各 Android 版本实测。
状态详情当前采用可展开 JSON,包含服务、显示、网络、电源、性能和容量;尚未完成所有字段的中文分组卡片。不能用 Android 13 的已授权主机测试替代 Android 8–11 的定位开关/权限验证;模拟测试只在 commonTest 内,不随生产 APK 发布。
## 2026-09-26 四栏实现修订
采用稳定页面编号 0连接、1设备状态、2显示内容、3设备设置;保存设备完整 ID、昵称、短编号、最近连接时间,不保存 MAC。扫描状态与连接独立,旧回调按代数隔离;RSSI 由平台 DeviceLink 可选方法读取。顶部共用 WiFi 任务状态。菜单、折叠表单、确认弹窗使用共享 Compose;版本元数据从 Android BuildConfig 注入。非敏感 WiFi 草稿跨页保留,密码仅当前编辑驻留,离页或提交清除。原三页/JSON详情/刷新率与性能入口描述由此节替代。
@@ -0,0 +1,9 @@
# KMP 工程位置
这里是唯一 Android/共享 KMP 工程根目录。模块为 androidApp、sharedCore、sharedUi,详细职责见相邻 `../安卓开发详细设计.md`。
当前工程骨架已构建 debug APK,界面仍为占位,不能作为控制器交付。Gradle Wrapper 9.5.0 固定发行摘要,版本目录锁定 Kotlin 2.4.20、Compose 1.12.1、AGP 9.3.1。实际构建使用 Android Studio 自带 JBR 25.0.3、Android SDK 37。Android 最低 API 26。
Windows 中文源码路径下编译可继续,但 JVM 测试出现 ClassNotFoundException;同一源码在 ASCII 隔离目录通过。使用 `../构建与发布/build_android.py` 构建,参数与说明见该目录 README。构建产物与密钥不提交。
`sharedCore/src/jvmTest/resources/protocol-v1.json` 是 Python/JVM 共用公开测试向量;固定标量 1、2 仅用于测试,生产会话必须每次生成新的临时密钥。
@@ -0,0 +1,37 @@
import java.time.ZonedDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "org.qimiaoscreen.controller"
compileSdk = 37
defaultConfig {
applicationId = "org.qimiaoscreen.controller"
minSdk = 26
targetSdk = 37
versionCode = 3
versionName = "0.2.0"
buildConfigField("String", "BUILD_DATE", "\"" + ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm XXX")) + "\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures { compose = true; buildConfig = true }
buildTypes { getByName("debug") { isDebuggable = true } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":sharedCore"))
implementation(project(":sharedUi"))
implementation(libs.activity.compose)
implementation(libs.coroutines.android)
implementation(libs.serialization.json)
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
}
@@ -0,0 +1,122 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Semantic UI checks only: no screenshot, camera or display-content changes. */
@RunWith(AndroidJUnit4::class)
class AppLifecycleTest {
@Test fun actualActivityReturnsFromBackground() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
val rootInput = InstrumentationRegistry.getArguments().getString("root_input") == "true"
fun rootCommand(args: String) {
val result = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $args")
check(result.contains("QMS_INPUT_OK")) { "Authorized root input did not complete" }
}
fun click(node: UiObject2) {
check(rootInput) { "All test clicks require authorized root input" }
check(node.applicationPackage == "org.qimiaoscreen.controller") { "Input target outside App" }
val point = node.visibleCenter
rootCommand("tap ${point.x} ${point.y}")
}
ui.wakeUp()
fun launch() {
// MIUI rejects background Activity starts made by the test's app UID.
// Start the explicit authorized app through the normal shell test API.
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000)) {
"App did not become foreground"
}
}
launch()
fun scanAndConnect() {
click(checkNotNull(ui.wait(Until.findObject(By.text("扫描设备")), 10_000)))
val name = InstrumentationRegistry.getArguments().getString("device_name")!!
val device = checkNotNull(ui.wait(Until.findObject(By.text(name)), 20_000))
click(checkNotNull(device.parent.parent.findObject(By.text("连接"))))
assertTrue(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
if (!ui.wait(Until.hasObject(By.text("断开连接")), 30_000)) {
scanAndConnect()
}
if (InstrumentationRegistry.getArguments().getString("toggle_bluetooth") == "true") {
check(rootInput)
try {
rootCommand("bluetooth-disable")
assertTrue("Connection state must clear after Bluetooth is disabled",
ui.wait(Until.hasObject(By.text("扫描设备")), 15_000))
} finally { rootCommand("bluetooth-enable") }
Thread.sleep(3_000)
scanAndConnect()
}
click(ui.findObject(By.text("设备状态")))
assertTrue(ui.wait(Until.hasObject(By.text("设备画面")), 15_000))
check(!ui.hasObject(By.text("运行状态")))
click(ui.findObject(By.text("显示内容")))
assertTrue(ui.wait(Until.hasObject(By.text("可播放内容")), 15_000))
click(ui.findObject(By.text("设备设置")))
assertTrue(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
check(!ui.hasObject(By.text("性能模式")))
click(ui.findObject(By.text("连接")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("关于")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textContains("版本 0.2.0")), 5_000))
click(ui.findObject(By.text("关闭")))
click(ui.findObject(By.text("⋮")))
click(checkNotNull(ui.wait(Until.findObject(By.text("软件设置")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("当前手机昵称")), 5_000))
click(ui.findObject(By.text("取消")))
check(rootInput); rootCommand("home")
Thread.sleep(33_000)
launch()
assertTrue("Activity should reconnect after background release",
ui.wait(Until.hasObject(By.text("断开连接")), 35_000))
fun openRename() {
val marker = checkNotNull(ui.wait(Until.findObject(By.text("已连接")), 5_000))
val point = marker.visibleCenter
rootCommand("swipe ${point.x} ${point.y} ${point.x} ${point.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("重命名设备")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("设备昵称")), 5_000))
}
fun rename(value: String) {
val field = checkNotNull(ui.wait(Until.findObject(By.clazz("android.widget.EditText")), 5_000))
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
click(checkNotNull(ui.wait(Until.findObject(By.text("保存")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:$value")), 15_000))
}
openRename()
val originalName = ui.findObject(By.clazz("android.widget.EditText")).text
var renamed = false
try {
rename("界面验证屏")
renamed = true
click(ui.findObject(By.text("断开连接")))
scanAndConnect()
assertTrue(ui.wait(Until.hasObject(By.textStartsWith("当前连接设备:界面验证屏")), 5_000))
} finally {
if (renamed) { openRename(); rename(originalName) }
}
val again = ui.findObject(By.text("已连接")).visibleCenter
rootCommand("swipe ${again.x} ${again.y} ${again.x} ${again.y} 700")
click(checkNotNull(ui.wait(Until.findObject(By.text("取消记忆")), 5_000)))
assertTrue(ui.wait(Until.hasObject(By.text("暂无已保存设备,扫描后选择即可连接")), 5_000))
scanAndConnect()
click(ui.findObject(By.text("断开连接")))
assertTrue(ui.wait(Until.hasObject(By.text("扫描设备")), 5_000))
}
}
@@ -0,0 +1,240 @@
package org.qimiaoscreen.controller
import android.Manifest
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.content.pm.PackageManager
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.*
import org.junit.Test
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.qimiaoscreen.core.*
/** Read-only by default; an explicit flag can reapply the already active WiFi profile. */
@RunWith(AndroidJUnit4::class)
class BleIntegrationTest {
@Test fun encryptedReadOnlySessionAndReconnect() = runBlocking {
val instrumentation = InstrumentationRegistry.getInstrumentation()
fun stage(name: String) { instrumentation.sendStatus(2, Bundle().apply { putString("stream", "BLE stage: $name\n") }) }
val context = instrumentation.targetContext
val permissions = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
check(permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
"Test permissions missing; stop and grant using authorized root runner"
}
val ble = AndroidBle(context)
val expectedName = checkNotNull(InstrumentationRegistry.getArguments().getString("device_name")) { "Explicit test board required" }
suspend fun discover(): DiscoveredDevice {
val found = CompletableDeferred<DiscoveredDevice>()
try {
ble.scan({ if (it.name == expectedName) found.complete(it) }, { found.completeExceptionally(IllegalStateException(it)) })
return withTimeout(20_000) { found.await() }
} finally { ble.stopScan() }
}
try {
val device = discover()
stage("scan complete")
ble.stopScan()
var identity: String? = null
repeat(2) { iteration ->
// Reconnection uses a fresh scan, as production does. A scan
// handle is transient and must not be retained across sessions.
val target = if (iteration == 0) device else discover()
val link = ble.connect(target.handle)
val traced = object : DeviceLink {
override val mtu get() = link.mtu
override suspend fun send(fragment: ByteArray) {
stage("send kind=${fragment[3]} index=${fragment.readInt(8, 2)} count=${fragment.readInt(10, 2)} bytes=${fragment.size} mtu=$mtu")
link.send(fragment)
}
override suspend fun receive(): ByteArray = link.receive().also {
if (it.readInt(8, 2) == 0L) stage("receive kind=${it[3]} count=${it.readInt(10, 2)} bytes=${it.size}")
}
override fun close() = link.close()
}
val rpc = DeviceRpc(traced, JavaSecureChannel())
stage("GATT ready $iteration")
try {
val hello = rpc.open("android-test-a")
stage("encrypted session open")
val id = hello.getValue("device_id").jsonPrimitive.content
if (identity == null) identity = id else assertEquals(identity, id)
assertEquals(1, hello.getValue("protocol_major").jsonPrimitive.int)
val status = rpc.call("status.get")
stage("status received")
assertTrue(status.containsKey("state"))
val settings = rpc.call("settings.get")
stage("settings received")
assertTrue(settings.getValue("values").jsonObject.containsKey("brightness"))
rpc.call("storage.get")
stage("storage received")
val library = rpc.call("library.list")
stage("library received")
assertTrue(library.getValue("items").jsonArray.isNotEmpty())
val expectedOrder = InstrumentationRegistry.getArguments().getString("library_order_hash")
if (expectedOrder != null) {
val all = library.getValue("items").jsonArray.map { it.jsonObject }.toMutableList()
var cursor = library["next_cursor"]?.jsonPrimitive?.contentOrNull
while (cursor != null) {
val page = rpc.call("library.list", buildJsonObject { put("cursor", cursor); put("limit", 50) })
all += page.getValue("items").jsonArray.map { it.jsonObject }
cursor = page["next_cursor"]?.jsonPrimitive?.contentOrNull
}
val canonical = all.filter { it["is_demo"]?.jsonPrimitive?.booleanOrNull != true }
.joinToString("\n") { it.getValue("type").jsonPrimitive.content + "|" + it.getValue("id").jsonPrimitive.content }
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 255) }
assertEquals("BLE must preserve device library order", expectedOrder, digest)
stage("library persistent order verified")
}
val frame = rpc.call("frame.get")
stage("frame received")
assertEquals("image/png", frame.getValue("mime").jsonPrimitive.content)
val wifi = rpc.call("wifi.get")
val saved = wifi["saved"]
if (saved != null && saved != JsonNull) assertFalse(saved.jsonObject.containsKey("password"))
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
if (iteration == 0) {
val networks = rpc.call("wifi.scan").getValue("networks").jsonArray
assertTrue(networks.size <= 100)
assertTrue(networks.all { !it.jsonObject.containsKey("password") })
stage("WiFi scan completed without logging network names")
try {
rpc.call("settings.patch", buildJsonObject {
put("expected_revision", "deliberately-stale-test-revision")
put("changes", buildJsonObject { put("brightness", settings.getValue("values").jsonObject.getValue("brightness")) })
})
fail("Stale revision must be rejected")
} catch (e: DeviceFailure) { assertEquals("CONFLICT", e.code) }
assertTrue(rpc.call("session.ping").getValue("alive").jsonPrimitive.boolean)
stage("configuration conflict preserves session")
if (InstrumentationRegistry.getArguments().getString("verify_current_controls") == "true") {
// Exercise accepted writes without changing the selected
// content, defaults, brightness, direction or network.
val currentSettings = rpc.call("settings.get")
val sameBrightness = currentSettings.getValue("values").jsonObject.getValue("brightness")
val patched = rpc.call("settings.patch", buildJsonObject {
put("expected_revision", currentSettings.getValue("revision"))
put("changes", buildJsonObject { put("brightness", sameBrightness) })
})
assertEquals(sameBrightness, patched.getValue("values").jsonObject.getValue("brightness"))
val originalDefault = rpc.call("content.default.get")
val currentState = rpc.call("status.get").getValue("state").jsonObject
val originalPlayback = currentState.getValue("animation_playback").jsonObject
if (originalPlayback["active"]?.jsonPrimitive?.booleanOrNull == true) {
val session = originalPlayback.getValue("session_id")
val originalPaused = originalPlayback.getValue("paused")
try {
val paused = rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", true) })
assertTrue(paused.getValue("animation_playback").jsonObject.getValue("paused").jsonPrimitive.boolean)
val held = paused.getValue("animation_playback").jsonObject
rpc.call("playback.patch", buildJsonObject {
put("session_id", session); put("position_ms", held.getValue("position_ms")); put("speed", originalPlayback.getValue("speed"))
})
} finally {
rpc.call("playback.patch", buildJsonObject { put("session_id", session); put("paused", originalPaused) })
}
stage("animation pause, same-position seek and same-speed write; original pause restored")
} else stage("animation write not applicable: no active animation")
assertEquals(originalDefault, rpc.call("content.default.get"))
stage("accepted settings write and default readback; selected content unchanged")
}
if (InstrumentationRegistry.getArguments().getString("reapply_current_wifi") == "true") {
val before = rpc.call("wifi.get")
val savedNetwork = before.getValue("saved").jsonObject
val activeNetwork = before.getValue("active").jsonObject
check(activeNetwork["connected"]?.jsonPrimitive?.boolean == true &&
activeNetwork["ssid"] == savedNetwork["ssid"]) { "Saved network must already be active" }
val security = savedNetwork.getValue("security").jsonPrimitive.content
check(security == "open" || savedNetwork["password_configured"]?.jsonPrimitive?.boolean == true)
val request = buildJsonObject {
for (key in listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers"))
savedNetwork[key]?.let { put(key, it) }
put("expected_revision", before.getValue("revision"))
put("password_action", if (security == "open") "none" else "keep")
put("activation", "immediate")
}
val task = rpc.call("wifi.set", request).getValue("task_id")
withTimeout(45_000) {
while (true) {
val result = rpc.call("task.get", buildJsonObject { put("task_id", task) })
val taskState = result.getValue("state").jsonPrimitive.content
check(taskState != "failed") { "Current-network activation failed" }
if (taskState == "succeeded") break
delay(1_000)
}
}
val after = rpc.call("wifi.get")
assertTrue("Current network restored", after.getValue("active").jsonObject["ssid"] == activeNetwork["ssid"])
assertTrue("Network is connected", after.getValue("active").jsonObject.getValue("connected").jsonPrimitive.boolean)
assertTrue("Saved fields retained", listOf("ssid", "security", "ipv4_mode", "address", "prefix", "gateway", "dns_servers").all {
after.getValue("saved").jsonObject[it] == savedNetwork[it]
})
stage("current WiFi reapplied with password retained; BLE remained usable")
}
repeat(InstrumentationRegistry.getArguments().getString("poll_cycles")?.toInt() ?: 60) { cycle ->
delay(2_000)
rpc.call("status.get")
rpc.call("frame.get")
if (cycle % 10 == 0) stage("continuous polling cycle $cycle")
}
}
} finally { stage("intentional session close $iteration"); rpc.close() }
if (iteration == 0) delay(3_000)
}
delay(3_000)
val saved = mutableMapOf<String, String>()
val platform = object : MobilePlatform {
override val model = "android-test-a"
override fun load(key: String) = saved[key]
override fun save(key: String, value: String) { saved[key] = value }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
override fun stopScan() = ble.stopScan()
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val controller = Controller(platform, controllerScope)
try {
withContext(Dispatchers.Main) { controller.connect(device) }
try {
withTimeout(30_000) { controller.state.first { it.status != null } }
} catch (e: TimeoutCancellationException) {
error("Controller failed: phase=${controller.state.value.phase}, error=${controller.state.value.error}")
}
stage("controller device page ready")
withContext(Dispatchers.Main) { controller.page(2) }
withTimeout(20_000) { controller.state.first { it.library.isNotEmpty() && !it.busy } }
withContext(Dispatchers.Main) { controller.state.value.library.take(3).forEach(controller::thumbnail) }
delay(10_000)
assertNotNull(controller.state.value.identity)
withContext(Dispatchers.Main) { controller.page(3) }
withTimeout(20_000) { controller.state.first { it.wifi != null && !it.busy } }
stage("controller content and settings ready")
withContext(Dispatchers.Main) { controller.foreground(false) }
delay(5_000)
withContext(Dispatchers.Main) { controller.foreground(true) }
delay(27_000)
assertNotNull("Returning before 30 seconds must cancel release", controller.state.value.identity)
withContext(Dispatchers.Main) { controller.foreground(false) }
withTimeout(35_000) { controller.state.first { it.identity == null } }
stage("controller background released")
delay(3_000)
withContext(Dispatchers.Main) { controller.foreground(true) }
withTimeout(35_000) { controller.state.first { it.identity != null && it.wifi != null } }
stage("controller foreground reconnected")
} finally {
withContext(Dispatchers.Main) { controller.disconnect() }
controllerScope.cancel()
}
} finally { ble.stopScan() }
}
}
@@ -0,0 +1,41 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Read semantic bounds; inject all input through the authorized root helper. */
@RunWith(AndroidJUnit4::class)
class LayoutTest {
@Test fun narrowLargeFontAndLandscape() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
fun root(command: String) = ui.executeShellCommand("sh /data/local/tmp/qms-authorized-root.sh $command").trim()
check(root("id -u") == "0") { "Root unavailable; stop testing" }
fun input(command: String) {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
Thread.sleep(250)
}
fun click(label: String) {
check(ui.wait(Until.hasObject(By.text(label)), 15_000)) { "Layout control unavailable: $label" }
val node = checkNotNull(ui.findObjects(By.text(label)).filter { !it.visibleBounds.isEmpty }
.maxByOrNull { it.visibleCenter.y }) { "Visible layout target unavailable: $label" }
check(node.applicationPackage == "org.qimiaoscreen.controller")
val bounds = node.visibleBounds
assertTrue(bounds.left >= 0 && bounds.top >= 0 && bounds.right <= ui.displayWidth && bounds.bottom <= ui.displayHeight)
val point = node.visibleCenter
input("tap ${point.x} ${point.y}")
}
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
assertTrue(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
for (label in listOf("连接", "设备状态", "显示内容", "设备设置", "连接")) click(label)
click("⋮"); click("关于"); click("关闭")
}
}
@@ -0,0 +1,252 @@
package org.qimiaoscreen.controller
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/** Real App WiFi form. Secrets arrive through a short-lived shell-only file, never test arguments or logs. */
@RunWith(AndroidJUnit4::class)
class WifiSwitchTest {
@Test fun wifiFormStage() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val ui = UiDevice.getInstance(instrumentation)
val args = InstrumentationRegistry.getArguments()
val stage = checkNotNull(args.getString("wifi_stage"))
val rootSwipe = true
val config = Json.parseToJsonElement(
ui.executeShellCommand("cat /data/local/tmp/qms-wifi-test-private.json")
).jsonObject
fun value(key: String) = config.getValue(key).jsonPrimitive.content
fun rootCommand(command: String) {
check(ui.executeShellCommand("sh /data/local/tmp/qms-authorized-input.sh $command").contains("QMS_INPUT_OK"))
}
fun click(node: UiObject2) {
var target = node
while ((!target.isClickable || target.visibleBounds.isEmpty) && target.parent != null) target = target.parent
check(target.applicationPackage == "org.qimiaoscreen.controller")
check(!target.visibleBounds.isEmpty)
val center = target.visibleCenter
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap class=${target.className} bounds=${target.visibleBounds}\n") })
rootCommand("tap ${center.x} ${center.y}")
ui.waitForIdle(1_000)
Thread.sleep(250)
}
fun scroll(down: Boolean): Boolean {
val root = checkNotNull(instrumentation.uiAutomation.rootInActiveWindow)
val pending = ArrayDeque<AccessibilityNodeInfo>()
pending.add(root)
var scroller: AccessibilityNodeInfo? = null
var height = 0
while (pending.isNotEmpty()) {
val node = pending.removeFirst()
val bounds = Rect()
node.getBoundsInScreen(bounds)
if (node.isScrollable && bounds.height() > height && bounds.width() > ui.displayWidth / 2) {
scroller = node
height = bounds.height()
}
for (index in 0 until node.childCount) node.getChild(index)?.let(pending::add)
}
checkNotNull(scroller) { "App settings list has no accessibility scroll action" }
if (rootSwipe) {
val bounds = Rect()
scroller.getBoundsInScreen(bounds)
val x = bounds.centerX()
val upper = bounds.top + bounds.height() / 5
val lower = bounds.bottom - bounds.height() / 5
rootCommand("swipe $x ${if (down) lower else upper} $x ${if (down) upper else lower} 350")
Thread.sleep(300)
return true
}
error("Root scrolling is required")
}
fun find(label: String): UiObject2 {
repeat(14) {
ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it }
scroll(true)
}
repeat(14) {
ui.wait(Until.findObject(By.text(label)), 1_000)?.let { return it }
scroll(false)
}
error("App control unavailable: $label")
}
fun edit(label: String, text: String) {
repeat(5) {
try {
val labelNode = find(label)
val field = generateSequence(labelNode) { it.parent }.first { it.className == "android.widget.EditText" }
repeat(40) { if (!field.isEnabled) Thread.sleep(100) }
check(field.isEnabled) { "App edit field remained disabled" }
val clipboard = instrumentation.targetContext.getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", text))
try {
click(field)
rootCommand("clear-field ${(field.text.length + 2).coerceIn(2, 512)}")
if (text.isNotEmpty()) rootCommand("paste")
rootCommand("hide-keyboard")
} finally { clipboard.clearPrimaryClip() }
return
} catch (_: androidx.test.uiautomator.StaleObjectException) {
Thread.sleep(300)
}
}
error("App edit field kept changing: $label")
}
fun dhcp(enabled: Boolean) {
val label = find("自动获取IP(DHCP)")
val bounds = label.visibleBounds
val toggle = if (label.isCheckable) label else checkNotNull(ui.findObjects(By.checkable(true))
.filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) }
.maxByOrNull { it.visibleCenter.x }) { "DHCP switch unavailable beside its label" }
if (toggle.isChecked != enabled) click(toggle)
}
fun choose(label: String) {
repeat(6) {
try {
val text = find(label)
val chip = generateSequence(text) { it.parent }.first { it.isClickable }
if (chip.isChecked) return
if (chip.isEnabled) click(chip)
Thread.sleep(500)
val updated = generateSequence(find(label)) { it.parent }.first { it.isClickable }
if (updated.isChecked) return
} catch (_: androidx.test.uiautomator.StaleObjectException) {
Thread.sleep(300)
}
}
error("App choice did not become selected: $label")
}
fun findStatus(): String {
repeat(14) {
ui.findObject(By.textStartsWith("当前WiFi:"))?.let { return it.text }
scroll(false)
}
error("Current WiFi status unavailable")
}
fun assertWifi(saved: String, active: String) {
val label = find("WiFi名称")
val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" }
assertEquals(saved, field.text)
val current = findStatus()
assertTrue(current.contains(active))
val ip = Regex("\\b(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\\b").find(current)?.value
checkNotNull(ip) { "Current WiFi IP unavailable" }
File(instrumentation.targetContext.filesDir, "qms-wifi-test-ip.txt").writeText(ip, Charsets.UTF_8)
}
ui.wakeUp()
ui.executeShellCommand("am start -W -n org.qimiaoscreen.controller/.MainActivity")
check(ui.wait(Until.hasObject(By.pkg("org.qimiaoscreen.controller")), 10_000))
if (!ui.wait(Until.hasObject(By.text("断开连接")), 25_000)) {
click(find("扫描设备"))
val board = checkNotNull(ui.wait(Until.findObject(By.text(value("board_name"))), 20_000))
click(checkNotNull(board.parent.parent.findObject(By.text("连接"))))
check(ui.wait(Until.hasObject(By.text("断开连接")), 30_000))
}
click(find("设备设置"))
check(ui.wait(Until.hasObject(By.text("屏幕方向")), 15_000))
ui.waitForIdle(1_000)
click(find("WiFi设置 展开"))
check(ui.wait(Until.hasObject(By.text("WiFi设置 收起")), 5_000))
when (stage) {
"validate" -> {
edit("WiFi名称", "")
val field = generateSequence(find("WiFi名称")) { it.parent }.first { it.className == "android.widget.EditText" }
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream", "UI tap diagnostic: SSID empty=${field.text.isEmpty()}\n") })
val save = find("保存WiFi设置")
assertTrue("Empty SSID must disable save", generateSequence(save) { it.parent }.any { !it.isEnabled })
edit("WiFi名称", value("original_ssid"))
assertWifi(value("original_ssid"), value("original_ssid"))
}
"validate_invalid" -> {
edit("WiFi名称", value("original_ssid"))
dhcp(false)
edit("静态IP地址", "999.999.999.999")
edit("前缀长度(例如24)", "24")
edit("网关", value("original_gateway"))
click(find("保存WiFi设置"))
check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000))
click(find("确定"))
check(ui.wait(Until.hasObject(By.textContains("参数无效")), 12_000)) { "Invalid IPv4 was not rejected" }
click(find("知道了"))
}
"scan" -> {
click(find("扫描WiFi"))
Thread.sleep(12_000)
check(!ui.hasObject(By.text("知道了"))) { "App WiFi scan reported an error" }
}
"wrong", "dhcp", "manual", "dhcp_return", "restore" -> {
val target = if (stage == "restore") value("original_ssid") else value("hotspot_ssid")
edit("WiFi名称", target)
if (stage == "wrong" || stage == "dhcp" || stage == "restore") {
val password = if (stage == "wrong") value("wrong_password") else if (stage == "restore") value("original_password") else value("hotspot_password")
edit("WiFi密码", password)
val show = find("显示输入的密码")
val bounds = show.visibleBounds
val checkbox = if (show.isCheckable) show else checkNotNull(ui.findObjects(By.checkable(true))
.filter { kotlin.math.abs(it.visibleCenter.y - bounds.centerY()) < bounds.height().coerceAtLeast(48) }
.minByOrNull { it.visibleCenter.x })
click(checkbox)
val label = find("WiFi密码")
val field = generateSequence(label) { it.parent }.first { it.className == "android.widget.EditText" }
assertEquals(password, field.text)
}
dhcp(!(stage == "manual" || stage == "restore" && value("original_mode") == "manual"))
if (stage == "manual" || stage == "restore" && value("original_mode") == "manual") {
val prefix = if (stage == "manual") "24" else value("original_prefix")
val address = if (stage == "manual") value("static_address") else value("original_address")
val gateway = if (stage == "manual") value("hotspot_gateway") else value("original_gateway")
val dns = if (stage == "manual") value("hotspot_gateway") else value("original_dns")
edit("静态IP地址", address)
edit("前缀长度(例如24)", prefix)
edit("网关", gateway)
edit("DNS(多个用逗号分隔)", dns)
}
click(find("保存WiFi设置"))
check(ui.wait(Until.hasObject(By.text("立即生效WiFi设置")), 5_000))
click(find("确定"))
if (stage == "wrong") {
Thread.sleep(45_000)
check(listOf("密码错误", "网络连接失败", "WiFi不存在", "无法获取IP").any { ui.hasObject(By.textContains(it)) }) {
"Wrong-password attempt must show a WiFi failure"
}
instrumentation.sendStatus(2, android.os.Bundle().apply { putString("stream",
if (ui.hasObject(By.textContains("密码错误"))) "AUTH_FAILED_CONFIRMED\n" else "WIFI_FAILURE_UNCLASSIFIED\n") })
} else {
Thread.sleep(5_000)
}
assertFalse(ui.hasObject(By.text(value("hotspot_password"))))
assertFalse(ui.hasObject(By.text(value("original_password"))))
assertFalse(ui.hasObject(By.text(value("wrong_password"))))
}
"verify" -> assertWifi(value("original_ssid"), value("original_ssid"))
"inspect_failure" -> {
val current = findStatus()
check(current.contains("失败") || current.contains("密码错误") || current.contains("不存在") || current.contains("无法获取IP")) { "Expected human-readable WiFi failure" }
}
else -> error("Unknown WiFi test stage")
}
// Release the authenticated owner before the next instrumentation process.
ui.findObject(By.text("知道了"))?.let(::click)
click(find("连接"))
val disconnect = ui.wait(Until.findObject(By.text("断开连接")), 3_000)
if (disconnect != null) click(disconnect)
Thread.sleep(2_000)
}
}
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application android:label="奇妙小屏幕" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:allowBackup="false" android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,179 @@
package org.qimiaoscreen.controller
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.os.ParcelUuid
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import org.qimiaoscreen.core.*
import java.util.UUID
/** Permissions are requested by the Activity before calling this adapter. */
@SuppressLint("MissingPermission")
class AndroidBle(private val context: Context) {
private val adapter get() = context.getSystemService(BluetoothManager::class.java).adapter
private var scanning: ScanCallback? = null
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) {
stopScan()
val scanner = adapter?.bluetoothLeScanner ?: run { onError("请打开手机蓝牙"); return }
val callback = object : ScanCallback() {
override fun onScanResult(type: Int, result: ScanResult) {
onDevice(DiscoveredDevice(result.device.address, result.scanRecord?.deviceName ?: "奇妙小屏幕", result.rssi))
}
override fun onScanFailed(code: Int) { onError("蓝牙扫描失败($code)") }
}
scanning = callback
scanner.startScan(listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(Protocol.SERVICE))).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), callback)
}
fun stopScan() { scanning?.let { adapter?.bluetoothLeScanner?.stopScan(it) }; scanning = null }
suspend fun connect(handle: String): DeviceLink {
stopScan()
// Retry only GATT establishment, before any application request exists.
// Never switch device or replay an ambiguous business mutation.
repeat(2) { attempt ->
val link = GattLink()
try {
val gatt = adapter.getRemoteDevice(handle).connectGatt(context, false, link.callback, BluetoothDevice.TRANSPORT_LE)
link.attach(gatt)
withTimeout(15_000) { link.ready.await() }
return link
} catch (e: Exception) {
link.close()
if (e is CancellationException && e !is TimeoutCancellationException) throw e
if (attempt == 1) {
if (e is TimeoutCancellationException) throw DeviceTransportFailure("蓝牙连接建立超时,请重新扫描")
throw e
}
delay(1_000)
}
}
error("蓝牙连接失败")
}
}
@SuppressLint("MissingPermission")
private class GattLink : DeviceLink {
@Volatile override var mtu = 23
private set
val ready = CompletableDeferred<Unit>()
private val messages = Channel<ByteArray>(256)
private val writes = Mutex()
@Volatile private var pending: CompletableDeferred<Unit>? = null
@Volatile private var gatt: BluetoothGatt? = null
@Volatile private var rx: BluetoothGattCharacteristic? = null
@Volatile private var closed = false
private var signalPending: CompletableDeferred<Int?>? = null
private var discoveryStarted = false
private val handler = Handler(Looper.getMainLooper())
private val discoveryFallback = Runnable { gatt?.let(::discoverOnce) }
@Synchronized private fun discoverOnce(g: BluetoothGatt) {
if (closed || discoveryStarted) return
discoveryStarted = true
handler.removeCallbacks(discoveryFallback)
if (!g.discoverServices()) fail()
}
fun attach(value: BluetoothGatt) { if (closed) value.close() else gatt = value }
private fun fail() {
val error = IllegalStateException("蓝牙连接已中断")
ready.completeExceptionally(error)
pending?.completeExceptionally(error)
messages.close(error)
close()
}
val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, state: Int) {
if (status != BluetoothGatt.GATT_SUCCESS || state == BluetoothProfile.STATE_DISCONNECTED) { fail(); return }
if (state == BluetoothProfile.STATE_CONNECTED) {
attach(g)
if (!g.requestMtu(247)) discoverOnce(g)
else handler.postDelayed(discoveryFallback, 2_000)
}
}
override fun onReadRemoteRssi(g: BluetoothGatt, rssi: Int, status: Int) {
signalPending?.complete(if (status == BluetoothGatt.GATT_SUCCESS) rssi else null)
}
override fun onMtuChanged(g: BluetoothGatt, value: Int, status: Int) {
// Some vendor stacks report 517 and then 247 for one request.
// Keep the advertised receive limit conservative and do not queue
// duplicate service discovery / CCCD writes on those callbacks.
if (status == BluetoothGatt.GATT_SUCCESS) mtu = value.coerceIn(23, 247)
// A cached 517 callback can precede completion of our 247 request.
// Starting discovery on it stalls this vendor stack. Wait for the
// requested size, or the bounded fallback if the stack chooses more.
if (status != BluetoothGatt.GATT_SUCCESS || value <= 247) discoverOnce(g)
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) { fail(); return }
val service = g.getService(UUID.fromString(Protocol.SERVICE))
rx = service?.getCharacteristic(UUID.fromString(Protocol.RX))
val tx = service?.getCharacteristic(UUID.fromString(Protocol.TX))
val descriptor = tx?.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
if (rx == null || tx == null || descriptor == null || !g.setCharacteristicNotification(tx, true)) { fail(); return }
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
if (!g.writeDescriptor(descriptor)) fail()
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) ready.complete(Unit) else fail()
}
@Deprecated("Legacy Android callback")
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
enqueue(characteristic, characteristic.value)
}
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
enqueue(characteristic, value)
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) pending?.complete(Unit) else fail()
}
}
private fun enqueue(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid.toString() != Protocol.TX || !messages.trySend(value.copyOf()).isSuccess) fail()
}
override suspend fun send(fragment: ByteArray) = writes.withLock {
check(!closed)
val completion = CompletableDeferred<Unit>()
pending = completion
try {
val characteristic = checkNotNull(rx)
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = fragment
@Suppress("DEPRECATION")
check(checkNotNull(gatt).writeCharacteristic(characteristic))
withTimeout(5_000) { completion.await() }
} finally { pending = null }
}
override suspend fun receive(): ByteArray = messages.receive()
override suspend fun readSignal(): Int? {
if (closed) return null
val completion = CompletableDeferred<Int?>()
signalPending = completion
return try {
if (gatt?.readRemoteRssi() != true) null else withTimeout(1_000) { completion.await() }
} catch (_: TimeoutCancellationException) { null } finally { signalPending = null }
}
override fun close() {
if (closed) return
closed = true
handler.removeCallbacks(discoveryFallback)
val error = IllegalStateException("蓝牙连接已关闭")
ready.completeExceptionally(error)
pending?.completeExceptionally(error)
messages.close()
gatt?.disconnect(); gatt?.close(); gatt = null
}
}
@@ -0,0 +1,65 @@
package org.qimiaoscreen.controller
import android.os.Bundle
import android.os.Build
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.util.Base64
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.*
import org.qimiaoscreen.core.*
import org.qimiaoscreen.ui.QimiaoApp
import org.qimiaoscreen.ui.AppInfo
class MainActivity : ComponentActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var controller: Controller
private val permissions get() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
private fun permitted() = permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private val requestPermission: ActivityResultLauncher<Array<String>> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
if (permitted()) controller.scan()
else {
controller.reportError("蓝牙权限未授予,无法扫描和连接设备")
val permanent = permissions.any { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED && !shouldShowRequestPermissionRationale(it) }
AlertDialog.Builder(this).setTitle("需要蓝牙扫描权限")
.setMessage(if (permanent) "请在应用设置中允许附近设备权限;Android 8–11 需要定位权限用于蓝牙扫描。" else "权限只用于发现和连接小屏幕,不读取联系人或手机文件。")
.setNegativeButton("暂不", null)
.setPositiveButton(if (permanent) "打开应用设置" else "重试") { _, _ ->
if (permanent) startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
else requestPermission.launch(permissions)
}.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val ble = AndroidBle(applicationContext)
val preferences = getSharedPreferences("controller", MODE_PRIVATE)
val platform = object : MobilePlatform {
override val model = Build.MODEL
override fun load(key: String) = preferences.getString(key, null)
override fun save(key: String, value: String) { preferences.edit().putString(key, value).apply() }
override fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit) = ble.scan(onDevice, onError)
override fun stopScan() { if (permitted()) ble.stopScan() }
override suspend fun connect(handle: String) = ble.connect(handle)
override fun crypto(): SecureChannel = JavaSecureChannel()
}
controller = Controller(platform, scope)
setContent { QimiaoApp(controller, onScan = {
if (permitted()) controller.scan() else requestPermission.launch(permissions)
}, appInfo = AppInfo(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toString(), BuildConfig.BUILD_DATE), decode = { encoded ->
runCatching { val bytes = Base64.decode(encoded, Base64.DEFAULT); BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() }.getOrNull()
}) }
}
override fun onStart() { super.onStart(); if (::controller.isInitialized && permitted()) controller.foreground(true) }
override fun onStop() { if (::controller.isInitialized) controller.foreground(false); super.onStop() }
override fun onDestroy() { if (::controller.isInitialized) controller.disconnect(); scope.cancel(); super.onDestroy() }
}
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.kmp) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.compose.compiler) apply false
}
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.workers.max=3
android.useAndroidX=true
kotlin.code.style=official
android.overridePathCheck=true
@@ -0,0 +1,20 @@
[versions]
kotlin = "2.4.20"
agp = "9.3.1"
compose = "1.12.1"
coroutines = "1.10.2"
serialization = "1.9.0"
[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
activity-compose = { module = "androidx.activity:activity-compose", version = "1.11.0" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-kmp = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
@@ -0,0 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
@@ -0,0 +1,7 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() }
}
rootProject.name = "QimiaoScreen"
include(":androidApp", ":sharedCore", ":sharedUi")
@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
android {
namespace = "org.qimiaoscreen.core"
compileSdk = 37
minSdk = 26
withHostTestBuilder {}
}
jvm()
sourceSets {
getByName("androidMain").kotlin.srcDir("src/javaCryptoMain/kotlin")
getByName("jvmMain").kotlin.srcDir("src/javaCryptoMain/kotlin")
commonMain.dependencies {
implementation(libs.coroutines.core)
implementation(libs.serialization.json)
}
commonTest.dependencies { implementation(kotlin("test")) }
}
}
@@ -0,0 +1,339 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.*
import kotlin.time.Clock
interface MobilePlatform {
val model: String
fun load(key: String): String?
fun save(key: String, value: String)
fun scan(onDevice: (DiscoveredDevice) -> Unit, onError: (String) -> Unit)
fun stopScan()
suspend fun connect(handle: String): DeviceLink
fun crypto(): SecureChannel
}
data class RememberedDevice(val id: String, val name: String, val shortId: String, val lastConnected: Long)
data class WifiDraft(val ssid: String = "", val security: String = "wpa-psk", val manual: Boolean = false,
val address: String = "", val prefix: String = "24", val gateway: String = "", val dns: String = "")
data class ControllerState(
val remembered: List<RememberedDevice> = emptyList(), val signal: Int? = null,
val wifiConnecting: Boolean = false, val wifiError: String? = null, val wifiTarget: String? = null,
val wifiDraft: WifiDraft? = null,
val page: Int = 0, val phase: String = "未连接", val scanning: Boolean = false,
val busy: Boolean = false, val error: String? = null, val clientName: String = "",
val devices: List<DiscoveredDevice> = emptyList(), val identity: JsonObject? = null,
val status: JsonObject? = null, val storage: JsonObject? = null,
val settings: JsonObject? = null, val wifi: JsonObject? = null,
val library: List<JsonObject> = emptyList(), val networks: List<JsonObject> = emptyList(),
val thumbnails: Map<String, String> = emptyMap(), val frame: String? = null,
val frameCheckedAtMs: Long? = null, val defaultContent: JsonObject? = null,
)
class Controller(private val platform: MobilePlatform, private val scope: CoroutineScope) {
private val mutable = MutableStateFlow(ControllerState(clientName = platform.load("client_name") ?: platform.model, remembered = loadRemembered()))
val state: StateFlow<ControllerState> = mutable.asStateFlow()
private fun loadRemembered(): List<RememberedDevice> = runCatching {
platform.load("remembered_devices")?.let { Protocol.json.parseToJsonElement(it).jsonArray.map { item ->
val o = item.jsonObject
RememberedDevice(o.getValue("id").jsonPrimitive.content, o.getValue("name").jsonPrimitive.content,
o.getValue("short_id").jsonPrimitive.content, o.getValue("last_connected").jsonPrimitive.long)
} } ?: emptyList()
}.getOrDefault(emptyList())
private fun persistRemembered(items: List<RememberedDevice>) {
platform.save("remembered_devices", JsonArray(items.map { buildJsonObject {
put("id", it.id); put("name", it.name); put("short_id", it.shortId); put("last_connected", it.lastConnected)
} }).toString())
change { it.copy(remembered = items) }
}
fun rememberedFor(device: DiscoveredDevice) = state.value.remembered.firstOrNull { device.name == "QMS-${it.shortId}" }
fun forget(id: String) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == id) disconnect()
persistRemembered(state.value.remembered.filterNot { it.id == id })
if (platform.load("last_device_id") == id) platform.save("last_device_id", "")
platform.save("forgotten_devices", JsonArray((forgottenIds() + id).map { JsonPrimitive(it) }).toString())
}
private fun forgottenIds(): Set<String> = runCatching { platform.load("forgotten_devices")?.let {
Protocol.json.parseToJsonElement(it).jsonArray.map { v -> v.jsonPrimitive.content }.toSet()
} ?: emptySet() }.getOrDefault(emptySet())
fun connectRemembered(device: RememberedDevice) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == device.id) return
val found = state.value.devices.firstOrNull { it.name == "QMS-${device.shortId}" }
if (found != null) connect(found, device.id) else { requestedDeviceId = device.id; scan() }
}
private var requestedDeviceId: String? = null
private var activeLink: DeviceLink? = null
fun wifiDraft(value: WifiDraft) = change { it.copy(wifiDraft = value) }
private var rpc: DeviceRpc? = null
private val commands = Mutex()
private var scanJob: Job? = null
private var pollJob: Job? = null
private var releaseJob: Job? = null
private var foreground = true
private var frameRevision: String? = null
private var connectionJob: Job? = null
private var commandJob: Job? = null
private var connectionGeneration = 0L
private var scanGeneration = 0L
private val pendingThumbnails = linkedMapOf<String, JsonObject>()
private fun change(update: (ControllerState) -> ControllerState) { mutable.update(update) }
fun dismissError() = change { it.copy(error = null) }
fun reportError(message: String) = change { it.copy(error = message) }
fun scan(auto: Boolean = false) {
if (connectionJob?.isActive == true) return
scanJob?.cancel()
platform.stopScan()
val generation = ++scanGeneration
change { it.copy(scanning = true, devices = emptyList(), error = null, phase = if (it.identity != null) it.phase else if (auto) "正在重连上次设备" else "正在扫描") }
val lastId = platform.load("last_device_id")?.takeIf { it.isNotBlank() && it !in forgottenIds() }
val short = lastId?.replace("-", "")?.take(8)
platform.scan({ device -> scope.launch {
if (!state.value.scanning || generation != scanGeneration) return@launch
change { it.copy(devices = (it.devices.filterNot { old -> old.handle == device.handle } + device).sortedByDescending { d -> d.signal }) }
val requested = requestedDeviceId
if (requested != null && device.name == "QMS-${requested.replace("-", "").take(8)}") {
requestedDeviceId = null; connect(device, requested)
} else if (auto && short != null && device.name == "QMS-$short") connect(device, lastId)
} }, { error -> scope.launch {
if (generation == scanGeneration) change { it.copy(error = error, scanning = false, phase = if (it.identity != null) it.phase else "未连接") }
} })
scanJob = scope.launch {
delay(12_000); if (generation != scanGeneration) return@launch; platform.stopScan(); requestedDeviceId = null
change { it.copy(scanning = false, phase = if (it.identity == null) "未连接" else it.phase,
error = if (auto && it.identity == null && connectionJob?.isActive != true) "未找到上次设备,可稍后重试" else it.error) }
}
}
fun connect(device: DiscoveredDevice, expectedId: String? = null) {
if (state.value.identity?.get("device_id")?.jsonPrimitive?.content == expectedId && expectedId != null) return
if (connectionJob?.isActive == true || rpc != null) disconnect()
scanGeneration++; scanJob?.cancel(); platform.stopScan()
val generation = ++connectionGeneration
frameRevision = null; pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, devices = it.devices, phase = "正在连接") }
connectionJob = scope.launch {
var pending: DeviceRpc? = null
try {
activeLink = platform.connect(device.handle)
pending = DeviceRpc(checkNotNull(activeLink), platform.crypto())
val identity = pending.open(state.value.clientName)
val id = identity.getValue("device_id").jsonPrimitive.content
require(expectedId == null || expectedId == id) { "设备身份与上次连接不一致" }
require(identity.getValue("protocol_major").jsonPrimitive.int == 1) { "通信协议不兼容" }
rpc = pending
platform.save("last_device_id", id)
platform.save("forgotten_devices", JsonArray((forgottenIds() - id).map { JsonPrimitive(it) }).toString())
persistRemembered((state.value.remembered.filterNot { it.id == id } + RememberedDevice(id,
identity.getValue("device_name").jsonPrimitive.content, identity.getValue("short_id").jsonPrimitive.content,
Clock.System.now().toEpochMilliseconds())).sortedByDescending { it.lastConnected })
change { it.copy(identity = identity, phase = "已连接", signal = device.signal) }
refreshAll()
startPolling()
} catch (e: Exception) {
pending?.close()
if (generation == connectionGeneration) {
rpc = null; activeLink = null
if (e !is CancellationException) change { it.copy(identity = null, phase = "未连接", error = e.message ?: "连接失败") }
}
}
}
}
fun disconnect() {
connectionGeneration++; scanGeneration++
connectionJob?.cancel(); commandJob?.cancel(); scanJob?.cancel(); pollJob?.cancel(); releaseJob?.cancel()
platform.stopScan(); rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; requestedDeviceId = null
pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered) }
}
private fun connectionLost(message: String) {
rpc?.close(); activeLink?.close(); activeLink = null; rpc = null; frameRevision = null; pendingThumbnails.clear()
change { ControllerState(page = it.page, clientName = it.clientName, remembered = it.remembered, phase = "连接中断", error = message) }
}
fun foreground(value: Boolean) {
foreground = value
releaseJob?.cancel()
if (value) {
if (rpc == null && !platform.load("last_device_id").isNullOrBlank()) scan(auto = true)
} else {
scanJob?.cancel(); platform.stopScan()
change { it.copy(scanning = false) }
releaseJob = scope.launch { delay(30_000); disconnect() }
}
}
fun page(value: Int) { change { it.copy(page = value) }; if (rpc != null) action { refreshPage() } }
private fun capabilities() = state.value.identity?.get("capabilities")?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
fun supports(capability: String) = capability in capabilities()
private suspend fun call(method: String, params: JsonObject = buildJsonObject {}) = checkNotNull(rpc).call(method, params)
private fun action(block: suspend () -> Unit) {
if (state.value.busy || rpc == null) return
change { it.copy(busy = true, error = null) }
val generation = connectionGeneration
commandJob = scope.launch {
try { commands.withLock { block() } }
catch (e: DeviceFailure) { change { it.copy(error = e.message) } }
catch (e: Exception) {
if (e !is CancellationException && generation == connectionGeneration) {
connectionLost(e.message ?: "操作结果未确认,请重连后检查设备状态"); pollJob?.cancel()
}
} finally { if (generation == connectionGeneration) change { it.copy(busy = false) } }
}
}
private suspend fun refreshAll() { commands.withLock { refreshStatus(); refreshPage() } }
private suspend fun refreshStatus() {
val result = call("status.get")
val wifi = if (supports("wifi")) call("wifi.get") else state.value.wifi
val signal = try { activeLink?.readSignal() } catch (e: CancellationException) { throw e } catch (_: Exception) { null }
currentCoroutineContext().ensureActive()
change { it.copy(status = result, wifi = wifi, signal = signal) }
}
private suspend fun refreshPage() {
when (state.value.page) {
0 -> Unit
1 -> if (supports("frame")) refreshFrame()
2 -> if (supports("library")) {
var items: List<JsonObject> = emptyList()
for (attempt in 0..1) {
try {
val loaded = mutableListOf<JsonObject>()
var cursor: String? = null
do {
val result = call("library.list", buildJsonObject { put("limit", 50); cursor?.let { put("cursor", it) } })
loaded += result.getValue("items").jsonArray.map { it.jsonObject }
cursor = result["next_cursor"]?.jsonPrimitive?.contentOrNull
} while (cursor != null)
items = loaded
break
} catch (e: DeviceFailure) { if (e.code != "CONFLICT" || attempt == 1) throw e }
}
val defaultContent = call("content.default.get")
change { it.copy(library = items, defaultContent = defaultContent) }
}
3 -> {
if (supports("settings")) { val value = call("settings.get"); change { it.copy(settings = value) } }
if (supports("wifi")) { val value = call("wifi.get"); change { it.copy(wifi = value) } }
}
}
}
private fun startPolling() {
pollJob?.cancel()
pollJob = scope.launch {
while (isActive && rpc != null) {
delay(2_000)
if (state.value.busy || !commands.tryLock()) continue
try {
if (!foreground) { call("session.ping"); continue }
refreshStatus()
if (state.value.page in 1..2 && supports("frame")) refreshFrame()
if (state.value.page == 2 && !state.value.busy && pendingThumbnails.isNotEmpty()) {
val (key, item) = pendingThumbnails.entries.first()
pendingThumbnails.remove(key)
val image = call("library.thumbnail", item).getValue("data_base64").jsonPrimitive.content
change { old ->
val cache = (old.thumbnails + (key to image)).toMutableMap()
while (cache.size > 128 || cache.values.sumOf { it.length } > 8 * 1024 * 1024) cache.remove(cache.keys.first())
old.copy(thumbnails = cache)
}
}
} catch (e: DeviceFailure) {
// A stale thumbnail or other business rejection does not
// invalidate the authenticated transport.
change { it.copy(error = e.message) }
} catch (e: Exception) {
if (e !is CancellationException) {
connectionLost(e.message ?: "连接已断开,请重试")
}
} finally { commands.unlock() }
}
}
}
private suspend fun refreshFrame() {
val result = call("frame.get", buildJsonObject { frameRevision?.let { put("known_revision", it) } })
frameRevision = result["frame_revision"]?.jsonPrimitive?.content
result["data_base64"]?.jsonPrimitive?.content?.let { image -> change { it.copy(frame = image) } }
change { it.copy(frameCheckedAtMs = Clock.System.now().toEpochMilliseconds()) }
}
fun refresh() = action { refreshStatus(); refreshPage(); if (state.value.page == 2 && supports("frame")) refreshFrame() }
fun thumbnail(item: JsonObject) {
val key = thumbnailKey(item)
if (key !in state.value.thumbnails) {
if (pendingThumbnails.size < 128) pendingThumbnails[key] = item
}
}
fun play(item: JsonObject, default: Boolean = false) = action {
call(if (default) "content.default.set" else "content.play", item); refreshStatus()
if (default) { val value = call("content.default.get"); change { it.copy(defaultContent = value) } }
}
fun playback(changes: JsonObject) = action {
val playback = state.value.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
val session = playback?.get("session_id") ?: throw DeviceFailure("NOT_READY", "当前没有可控制的动图")
call("playback.patch", JsonObject(changes + ("session_id" to session))); refreshStatus()
}
fun setting(key: String, value: JsonElement) = action {
val revision = state.value.settings?.get("revision") ?: return@action
val result = call("settings.patch", buildJsonObject { put("expected_revision", revision); put("changes", JsonObject(mapOf(key to value))) })
change { it.copy(settings = result) }; refreshStatus()
}
fun renameDevice(name: String, id: String? = state.value.identity?.get("device_id")?.jsonPrimitive?.content) {
if (id == null || state.value.identity?.get("device_id")?.jsonPrimitive?.content != id) { reportError("请连接设备"); return }
action {
val value = call("device.rename", buildJsonObject { put("name", name.trim()) })
change { it.copy(identity = value) }
persistRemembered(state.value.remembered.map { if (it.id == id) it.copy(name = value.getValue("device_name").jsonPrimitive.content) else it })
}
}
fun renamePhone(name: String) {
val normalized = name.trim()
if (normalized.isBlank() || normalized.length > 40 || normalized.any { it.code < 32 || it.code == 127 }) {
reportError("名称须为 1 至 40 个字符,不能包含控制字符"); return
}
if (state.value.busy) return
platform.save("client_name", normalized); change { it.copy(clientName = normalized) }
if (rpc != null) action { call("session.rename", buildJsonObject { put("client_name", normalized) }) }
}
fun scanWifi() = action { val result = call("wifi.scan"); change { it.copy(networks = result.getValue("networks").jsonArray.map { n -> n.jsonObject }) } }
fun saveWifi(values: JsonObject) = action {
val revision = state.value.wifi?.get("revision") ?: return@action
val target = values.getValue("ssid").jsonPrimitive.content
val result = call("wifi.set", JsonObject(values + ("expected_revision" to revision)))
change { it.copy(wifiConnecting = true, wifiError = null, wifiTarget = target) }
try {
val task = result["task_id"]
var finished = task == null
for (attempt in 0 until 60) {
if (finished) break
delay(1_000)
val value = call("task.get", buildJsonObject { put("task_id", checkNotNull(task)) })
when (value["state"]?.jsonPrimitive?.content) {
"succeeded" -> finished = true
"failed" -> {
val message = when (value["error_code"]?.jsonPrimitive?.content) {
"AUTH_FAILED" -> "${target}密码错误"
"NETWORK_UNAVAILABLE" -> "WiFi不存在或暂不可达"
"IP_CONFIG_FAILED" -> "无法获取IP,请检查地址设置"
else -> "网络连接失败,请检查设置"
}
change { it.copy(wifiError = message) }
throw DeviceFailure("DEVICE_ERROR", message)
}
}
}
if (!finished) { change { it.copy(wifiError = "连接结果尚未确认,请刷新") }; throw DeviceFailure("TIMEOUT", "网络任务仍在进行,请刷新状态") }
} finally { change { it.copy(wifiConnecting = false) }; refreshStatus() }
refreshPage()
}
}
fun wifiSummary(state: ControllerState): String {
if (state.identity == null) return "未连接"
if (state.wifiConnecting) return "连接中"
state.wifiError?.let { return it }
val active = state.wifi?.get("active")?.jsonObject ?: return "WiFi状态读取中"
if (active["connected"]?.jsonPrimitive?.booleanOrNull != true) return "未连接,WiFi不存在"
return listOfNotNull(active["ssid"]?.jsonPrimitive?.contentOrNull, active["ipv4_address"]?.jsonPrimitive?.contentOrNull).joinToString(" · ")
}
fun thumbnailKey(item: JsonObject): String = listOf("type", "id", "revision")
.joinToString("|") { item.getValue(it).jsonPrimitive.content }
@@ -0,0 +1,74 @@
package org.qimiaoscreen.core
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.*
data class DiscoveredDevice(val handle: String, val name: String, val signal: Int)
/** Platform owns the GATT connection. Handles are ephemeral, never device identity. */
interface DeviceLink {
val mtu: Int
suspend fun send(fragment: ByteArray)
suspend fun receive(): ByteArray
fun close()
suspend fun readSignal(): Int? = null
}
class DeviceFailure(val code: String, message: String) : Exception(message)
class DeviceTransportFailure(message: String) : Exception(message)
class DeviceRpc(private val link: DeviceLink, private val crypto: SecureChannel, private val requestTimeoutMs: Long = 10_000) {
private val mutex = Mutex()
private var tx = 0L
private var request = 0L
private val receiver = Reassembler()
private var usable = true
private suspend fun send(kind: Int, payload: ByteArray) {
Protocol.encode(kind, tx++, payload, link.mtu).forEach { link.send(it) }
}
private suspend fun receive(): Pair<Int, ByteArray> = withTimeout(15_000) {
var result: Pair<Int, ByteArray>? = null
while (result == null) result = receiver.accept(link.receive())
result
}
suspend fun open(name: String): JsonObject = mutex.withLock {
try {
withTimeout(10_000) {
send(1, crypto.hello)
val (kind, payload) = receive()
require(kind == 2) { "设备未接受握手" }
crypto.finish(payload)
exchange("session.open", buildJsonObject { put("client_name", name); put("receive_mtu", link.mtu) })
}
} catch (e: TimeoutCancellationException) {
close(); throw DeviceTransportFailure("连接握手超时,请重新连接")
} catch (e: Exception) { close(); throw e }
}
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject = mutex.withLock {
check(usable) { "设备连接已关闭" }
try {
withTimeout(if (method == "wifi.scan") 20_000 else requestTimeoutMs) { exchange(method, params) }
} catch (e: TimeoutCancellationException) {
close(); throw DeviceTransportFailure("设备回复超时($method),请重新连接")
} catch (e: DeviceFailure) { throw e }
catch (e: Exception) { close(); throw e }
}
private suspend fun exchange(method: String, params: JsonObject): JsonObject {
val id = (++request).toString()
val raw = buildJsonObject { put("id", id); put("method", method); put("params", params) }
send(3, crypto.encrypt(raw.toString().encodeToByteArray()))
val (kind, record) = receive()
require(kind == 3)
val response = Protocol.json.parseToJsonElement(crypto.decrypt(record).decodeToString(throwOnInvalidSequence = true)).jsonObject
require(response["id"]?.jsonPrimitive?.content == id)
if (!response.getValue("ok").jsonPrimitive.boolean) {
val error = response.getValue("error").jsonObject
throw DeviceFailure(error.getValue("code").jsonPrimitive.content, error.getValue("message").jsonPrimitive.content)
}
return response.getValue("result").jsonObject
}
fun close() { usable = false; link.close() }
}

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