同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Private device and workstation credentials. Keep the example next to it tracked.
|
||||
/测试相关资料/核桃派的用户名和密码和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.
|
||||
.env
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
|
||||
## 2. 需求与测试编号
|
||||
|
||||
移动端是独立维护的程序,入口为 `移动端相关内容/README.md`,协作规则见 `移动端相关内容/AGENTS.md`。本轮已授权 Android 首版;未来设备端更新不得自动迭代或发布 App。设备公共接口、协议、驱动边界及部署变化必须在 `移动端相关内容/开发要求/跨端兼容与变更记录.md` 登记影响;对外契约不变的驱动内部修改不要求手机更新。App、设备及协议分别管理版本。正式 APK 签名只在用户明确命令后执行,当前只允许 debug 签名。
|
||||
|
||||
移动测试设备真实登记唯一位置为 `移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json`,精确忽略;提交同目录空白 `测试设备.example.json` 和门禁脚本。此登记只存手机测试信息,不复制核桃派或开发机凭据;自动化前检查登记及 ADB 授权,不回显真实设备标识,不覆盖已有登记。
|
||||
|
||||
- `WEB-*`:网页、REST、WebSocket、交互和安全入口。
|
||||
- `DISPLAY-*`:显示服务、帧、方向、字体、动画、mock 与 H618 真实驱动。
|
||||
- `CONFIG-*`:schema、默认值、迁移、持久数据和损坏保护。
|
||||
@@ -36,7 +40,9 @@
|
||||
- 核桃派无负载验证编译、寄存器映射、线程调度、双缓冲、截止统计和异常清屏;在该门槛通过前禁止接屏。
|
||||
- HUB75 实屏测试按全黑、红、绿、蓝、白、四角、关键行、方向、文字、图片、动画逐项执行;上一项失败就停止,只排查对应线组。
|
||||
- ADC 测试按总线、地址、原始读取、同点万用表校准、正常档、单个低压档、恢复逐项执行,不合并人工动作。
|
||||
- 有清晰摄像头时由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时必须等待用户确认。
|
||||
- 普通网页前端、手机界面、BLE 控制入口开发没有改变驱动或显示输出实现时,不新增实屏视觉验收;优先协议、状态、输出帧与驱动统计,不适用时记录“不适用”。
|
||||
- 仅在驱动、扫描时序、底层输出变化或具体显示异常确需视觉证据时提出视觉检查。必须先停止相关步骤、说明原因、询问用户下一步如何启动,并等待本次明确指示;不得自动启动 DroidCam、摄像头、采集或视觉测试程序,也不得主动切换实屏测试图案。专用测试手机的一般自动化授权不能替代此门禁。
|
||||
- 用户明确启动后,有清晰摄像头时可由 Codex 判断;画面缺失或有拍频、曝光、视角歧义时仍等待用户确认。
|
||||
|
||||
## 4. 人工停顿与电气安全
|
||||
|
||||
@@ -92,3 +98,7 @@
|
||||
- 普通开发不检查历史 IMG 是否齐全;只有用户明确要求处理某个镜像时,才核对该任务实际需要的输入文件。官方基线和其他离线依赖按各自规则管理。
|
||||
- 仓库内描述工作区文件时使用相对路径或 `<仓库根目录>`、`<受保护临时目录>` 等文字占位符,不固化盘符、Windows 用户目录、macOS 用户目录或开发者 home。核桃派上的 `/opt`、`/var/lib`、`/run`、`/boot`、`/usr/local` 等固定部署路径不属于此限制。
|
||||
- 正式 IMG 通过仓库外的发行渠道保存,根 `.gitignore` 必须忽略所有 `*.img`;Git 只保留对应 README、manifest、SHA-256、发布记录等元数据,不得让源码、文档命令或后续构建依赖仓库中存在 IMG。OTA、离线依赖、编辑器程序和历史归档仍由私有 Git 仓库保留,大体积二进制可使用 Git LFS;秘密、本机状态、运行数据和可再生缓存继续按各自规则排除。
|
||||
|
||||
## 9. 测试经验与交付反馈
|
||||
|
||||
每轮测试完成后,将测试中遇到的问题及已验证解法补到对应可重复测试流程、排障指南或构建文档,防止后续重复试错;单次失败证据、恢复及重测结果留在测试归档。已知问题先查已有经验,不把未证实推测固化成根因。最终反馈说明经验更新位置和仍未解决的边界;没有新增经验时沿用已有记录,不重复复制。文档不得包含真实凭据、测试设备标识或开发机绝对路径。
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- `核桃派软件源代码/`:FastAPI 服务、网页、H618 HUB75 原生驱动、部署与发布脚本。
|
||||
- `整体开发需求/`:带稳定编号的功能和部署需求。
|
||||
- `移动端相关内容/`:独立 KMP 手机控制器需求、BLE 协议、Android 源码/测试/交付;入口见该目录 README,当前进度见 Android 测试结果归档。
|
||||
- `测试相关资料/如何测试/`:本机、板端、实屏与 ADC 的分层测试流程。
|
||||
- `硬件相关资料和硬件的连接/`:核桃派、HUB75 和 ADS1110 接线资料。
|
||||
- `发布更新相关/`:官方基线 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
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
8ee91e5103a9901f76245c642fe1c3efd43da75c611667788be49f8bcedf9a4f matrix-screen-controller-1.1.2.ota
|
||||
@@ -13,7 +13,11 @@
|
||||
"path": "debian12-aarch64",
|
||||
"reason": "媒体导入需要 ffmpeg、ffprobe、zscale、tonemap 和 heif-convert",
|
||||
"source": "清华 TUNA 的 Debian bookworm、bookworm-updates 与 bookworm-security AArch64 镜像;仓库索引和包均校验 SHA-256",
|
||||
"roots": ["ffmpeg", "libheif-examples", "python3.11-venv"],
|
||||
"roots": [
|
||||
"ffmpeg",
|
||||
"libheif-examples",
|
||||
"python3.11-venv"
|
||||
],
|
||||
"package_count": 84,
|
||||
"baseline": "debian12-aarch64/BASE_IMAGE_PACKAGES.tsv",
|
||||
"resolution": "debian12-aarch64/RESOLUTION.json",
|
||||
@@ -45,6 +49,17 @@
|
||||
"introduced_in": "1.1.0",
|
||||
"development_baseline": "1.0.6 调试设备已预装;正式旧版本不包含此组件",
|
||||
"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": []
|
||||
|
||||
@@ -12,4 +12,6 @@
|
||||
|
||||
依赖生命周期以 `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`,不增加板端下载。
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 annotated_types-0.8.0-py3-none-any.whl
|
||||
9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 anyio-4.14.2-py3-none-any.whl
|
||||
62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 certifi-2026.7.22-py3-none-any.whl
|
||||
3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
|
||||
e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 click-8.4.2-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
|
||||
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
|
||||
@@ -14,8 +17,9 @@ f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 iniconfig-2.3.
|
||||
d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c packaging-26.3-py3-none-any.whl
|
||||
bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
|
||||
e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 pluggy-1.6.0-py3-none-any.whl
|
||||
7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
|
||||
b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 pycparser-3.0-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
|
||||
539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7 pytest-8.4.1-py3-none-any.whl
|
||||
1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a python_dotenv-1.2.2-py3-none-any.whl
|
||||
|
||||
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
66a82db730b00f2e3554a1503c946330d14ae103823b8d3242ec5c922049d2b2 11223344.img
|
||||
Binary file not shown.
@@ -89,6 +89,29 @@
|
||||
"artifact_sha256": "8356b75ef970891db951a08429f8e2d21a6c0c4933065001778e10d74022c19d",
|
||||
"validation_path": "各种归档/20260908_090953_IMAGE1.1.1实卡验收_bb9884/实机验收.json",
|
||||
"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 在局域网内启动的网页配置端。这个网页是用户后续操作点阵屏幕的主要入口,当前阶段提供可叠加的像素与多文字内容编辑,以及纯色、诊断、清屏等独占设备测试能力。
|
||||
|
||||
移动端是独立控制入口,设备接入要求见 `03_移动端接入需求.md`,手机需求见 `../移动端相关内容/开发要求/移动端需求.md`。网页布局调整不应影响 BLE 协议;设备功能改动须登记移动端兼容影响,不自动更新 App。
|
||||
|
||||
项目默认一台核桃派只做这一件事:开机后自动启动网页服务和屏幕控制服务,同一局域网内的手机、电脑或平板通过 `http://核桃派IP:8080` 访问。
|
||||
|
||||
## 0. 需求编号索引
|
||||
@@ -81,6 +83,7 @@
|
||||
| `CONFIG-PERFORMANCE-MODE` | CPU 性能模式请求 | 配置 v9 严格保存布尔字段 `performance_mode_enabled`,v8 升级与新设备均默认关闭;请求状态与实际 governor 分开报告。 |
|
||||
| `CONFIG-ANIMATION-PREVIEW-CONCURRENCY` | 动态缩略图并发上限 | 配置 v10 严格保存整数 `animation_preview_max_concurrent`,v9 升级与新设备均默认 `2`,只允许 `1..50`;系统设置在性能模式下方保存该值,并明确提示高并发会造成严重性能问题。 |
|
||||
| `DEPLOY-KERNEL-ROLLBACK` | AXP313A 候选内核与自动回滚 | 已验证候选以带摘要的预编译离线载荷交付;原内核、DTB、模块和 boot 脚本保持可启动,未通过内核、cpufreq、服务和 GPIO 健康检查时下一次自动回到原内核。 |
|
||||
| `DEPLOY-OTA-KERNEL-RECOVERY` | OTA 内核能力守护与轻量恢复 | OTA 前后比较内核、cpufreq、性能模式和候选启动标记;正常能力退化时回滚应用。已回退设备可更新应用,只有本机候选内核、DTB、模块及回滚设施通过固定摘要校验时,更新完成后才独立重启尝试恢复一次;失败不得自动重试,缺失或损坏材料只报告需要专用修复包,普通 OTA 不携带内核归档。 |
|
||||
| `DEPLOY-MEDIA-DECODERS` | 媒体解码依赖与隔离 | Debian 提供 FFmpeg/ffprobe/libheif;独立转换 unit 限制 CPU、内存、IO、设备和网络访问。 |
|
||||
| `WEB-SECURITY` | 局域网安全边界 | 第一阶段默认可信局域网,保留访问密码和上传限制扩展入口。 |
|
||||
| `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 登录不得重新拉起音频会话。不得通过关闭矩阵硬件脉冲来兼容板载音频。
|
||||
- Wi-Fi、SSH、Avahi、`/dev/i2c-1`、Unit ADC、电压保护和 swap 必须保留;专用化不得修改无线网络、SSH 凭据、持久数据根或用户配置。
|
||||
- `scripts/dedicated_host.py check` 只读报告全部专用条件;`apply` 以幂等方式更新 boot 配置、模块黑名单和 systemd 启用状态,但不得自动重启。boot 配置更新必须原子替换、保留无关内容,不创建多代备份。
|
||||
- systemd 在启动应用前执行只读专用主机检查。任一硬件脉冲前置条件不满足时服务必须明确失败并记录原因,不能静默进入画面不稳定的软件脉冲模式。
|
||||
- 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;未来确需桌面或蓝牙时,必须先修改本需求和专用主机脚本,再重新执行刷新率、资源负载和完整实屏验收。不得临时取消屏蔽后直接交付。
|
||||
- 未来需要声音时优先使用不加载 `snd_bcm2835` 的 USB 音频;恢复桌面或增加蓝牙必须同步需求、专用脚本,并检查刷新率、资源负载和输出帧。普通前端/BLE 入口改动不强制实屏视觉验收;仅驱动、扫描时序、底层输出改变或具体异常确需视觉证据时,先暂停询问用户如何启动,获明确指示后检查。不得自动启动摄像头、DroidCam、采集或视觉程序,也不得主动切换实屏测试图案。
|
||||
|
||||
多语言文字部署还必须满足 `DEPLOY-FONTS`:
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
移动端接入契约见 `03_移动端接入需求.md` 和 `../移动端相关内容/开发要求/设备通信协议.md`。手机通过公共控制服务使用本显示层,不能操作网页 DOM 或绕过保护直接访问驱动。驱动或输出契约变化必须登记移动端兼容影响;内部实现变化不等于需要 App 升级。普通前端/BLE 入口改动不强制实屏视觉验收,确需视觉检查须先停下询问用户如何启动并等待指示。
|
||||
|
||||
### 1.1 固定帧接口(`DISPLAY-FRAME`)
|
||||
|
||||
项目内部提交到 `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 @@
|
||||
2026-09-10T11:10+08:00
|
||||
2026-09-26T16:47+08:00
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## 路径与运行方式
|
||||
|
||||
移动端控制器独立维护,入口见 `../移动端相关内容/README.md`;设备侧新增契约见 `../整体开发需求/03_移动端接入需求.md`。设备更新只评估并登记手机兼容影响,不自动迭代 App。BLE 与网页应复用公共业务层;不得让 App 依赖网页布局。当前蓝牙接入尚待实现,既有脚本仍执行原禁用策略,不能将需求文档当成设备已启用蓝牙。
|
||||
|
||||
普通前端/BLE 入口改动不新增实屏视觉验收。确需视觉检查时先停止、解释原因、询问用户如何启动并等待指示,不自动开摄像头、DroidCam 或视觉程序,不主动切换实屏测试图案。软件统计和逻辑帧检查按正常自动测试执行。
|
||||
|
||||
下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。
|
||||
|
||||
- 程序:`/opt/matrix-screen-controller`
|
||||
@@ -78,6 +82,8 @@ sudo sh scripts/update_walnutpi.sh
|
||||
|
||||
## 浏览器全量 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 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。
|
||||
|
||||
从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.1
|
||||
1.1.2
|
||||
|
||||
@@ -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
@@ -15,6 +15,10 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator
|
||||
|
||||
from app.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 (
|
||||
COLOR_PALETTE_LIMIT,
|
||||
DEFAULT_DISPLAY,
|
||||
@@ -561,6 +565,8 @@ def create_app(
|
||||
staging_root=ota_staging_root,
|
||||
)
|
||||
|
||||
control = DeviceControl(store, display, power_monitor, performance_mode)
|
||||
|
||||
def resolve_content(reference: dict[str, str]) -> dict[str, Any]:
|
||||
checked = normalize_default_display(reference)
|
||||
content_type = checked["type"]
|
||||
@@ -831,8 +837,9 @@ def create_app(
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/api/status")
|
||||
def get_status() -> dict:
|
||||
mobile_runtime = None
|
||||
|
||||
def read_device_status() -> dict:
|
||||
status = display.get_status()
|
||||
status["service"] = {
|
||||
"app_version": app.state.app_version,
|
||||
@@ -855,8 +862,25 @@ def create_app(
|
||||
store.config["performance_mode_enabled"]
|
||||
),
|
||||
}
|
||||
status["mobile"] = mobile_runtime.status() if mobile_runtime else {"available": False, "connected": False, "reason": "initialization_failed"}
|
||||
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")
|
||||
def get_ota_status() -> dict:
|
||||
return {
|
||||
@@ -1123,41 +1147,7 @@ def create_app(
|
||||
def put_config(update: ConfigUpdate) -> dict:
|
||||
values = update.model_dump(exclude_none=True)
|
||||
try:
|
||||
config = validate_config({**store.config, **values})
|
||||
if "low_voltage_protection_enabled" in values:
|
||||
enabled = config["low_voltage_protection_enabled"]
|
||||
store.update({"low_voltage_protection_enabled": enabled})
|
||||
power_monitor.set_protection_enabled(enabled)
|
||||
if enabled:
|
||||
power_monitor.sample_now()
|
||||
if "orientation" in values:
|
||||
display.set_orientation(config["orientation"])
|
||||
if "brightness" in values:
|
||||
display.set_brightness(config["brightness"])
|
||||
if "matrix_refresh_rate_limit_hz" in values:
|
||||
display.set_refresh_rate_limit(
|
||||
config["matrix_refresh_rate_limit_hz"]
|
||||
)
|
||||
if "performance_mode_enabled" in values:
|
||||
performance_mode.transact(
|
||||
config["performance_mode_enabled"],
|
||||
lambda enabled: store.update({"performance_mode_enabled": enabled}),
|
||||
)
|
||||
remaining = {
|
||||
key: config[key]
|
||||
for key in (
|
||||
"default_font",
|
||||
"default_text_size",
|
||||
"preview_refresh_interval_ms",
|
||||
"custom_test_color",
|
||||
"workspace_order",
|
||||
"animation_preview_max_concurrent",
|
||||
)
|
||||
if key in values
|
||||
}
|
||||
if remaining:
|
||||
store.update(remaining)
|
||||
return store.config
|
||||
return control.update_config(values)
|
||||
except (ConfigError, PerformanceModeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
@@ -2023,6 +2013,8 @@ def create_app(
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
if mobile_runtime:
|
||||
mobile_runtime.start()
|
||||
performance_mode.start(store.config["performance_mode_enabled"])
|
||||
media_imports.start()
|
||||
power_monitor.sample_now()
|
||||
@@ -2034,8 +2026,13 @@ def create_app(
|
||||
network.start()
|
||||
except Exception:
|
||||
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.finalize_kernel_recovery_after_boot()
|
||||
if ota.resume_or_start_monitor(restore_after_ota):
|
||||
logger.warning("OTA transaction is active; keeping the update indicator visible")
|
||||
elif maintenance_black:
|
||||
@@ -2046,6 +2043,8 @@ def create_app(
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown() -> None:
|
||||
if mobile_runtime:
|
||||
mobile_runtime.close()
|
||||
logger.info("Shutting down; clearing display")
|
||||
media_imports.stop()
|
||||
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 threading
|
||||
import time
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, Sequence
|
||||
@@ -10,9 +12,13 @@ from uuid import uuid4
|
||||
|
||||
class NetworkManagerError(RuntimeError):
|
||||
"""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):
|
||||
def scan(self) -> list[dict[str, Any]]: ...
|
||||
def discover_connection_uuid(self) -> str | None: ...
|
||||
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ...
|
||||
def read_active(self) -> dict[str, Any]: ...
|
||||
@@ -42,6 +48,40 @@ class NmcliNetworkManager:
|
||||
executable: str = "/usr/bin/nmcli"
|
||||
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(
|
||||
self,
|
||||
arguments: Sequence[str],
|
||||
@@ -105,6 +145,7 @@ class NmcliNetworkManager:
|
||||
raw_dns = self._connection_value(connection_uuid, "ipv4.dns")
|
||||
password = None
|
||||
password_configured = False
|
||||
key_mgmt = self._connection_value(connection_uuid, "802-11-wireless-security.key-mgmt")
|
||||
if include_secret:
|
||||
password = self._connection_value(
|
||||
connection_uuid,
|
||||
@@ -113,10 +154,6 @@ class NmcliNetworkManager:
|
||||
)
|
||||
password_configured = bool(password and password != "--")
|
||||
else:
|
||||
key_mgmt = self._connection_value(
|
||||
connection_uuid,
|
||||
"802-11-wireless-security.key-mgmt",
|
||||
)
|
||||
password_configured = bool(key_mgmt and key_mgmt != "--")
|
||||
prefix = None
|
||||
plain_address = address
|
||||
@@ -129,13 +166,17 @@ class NmcliNetworkManager:
|
||||
return {
|
||||
"connection_uuid": connection_uuid,
|
||||
"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_configured": password_configured,
|
||||
"ipv4_mode": "dhcp" if method == "auto" else "manual",
|
||||
"address": plain_address or None,
|
||||
"prefix": prefix,
|
||||
"gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None,
|
||||
"dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()],
|
||||
"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]:
|
||||
@@ -174,9 +215,10 @@ class NmcliNetworkManager:
|
||||
def _modify_arguments(settings: dict[str, Any]) -> list[str]:
|
||||
arguments = [
|
||||
"802-11-wireless.ssid", settings["ssid"],
|
||||
"802-11-wireless-security.key-mgmt", "wpa-psk",
|
||||
]
|
||||
if settings.get("password") is not None:
|
||||
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"]])
|
||||
if settings["ipv4_mode"] == "dhcp":
|
||||
arguments.extend([
|
||||
@@ -206,6 +248,8 @@ class NmcliNetworkManager:
|
||||
return connection_uuid
|
||||
|
||||
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([
|
||||
"connection", "modify", "uuid", connection_uuid,
|
||||
*self._modify_arguments(settings),
|
||||
@@ -213,12 +257,36 @@ class NmcliNetworkManager:
|
||||
|
||||
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
|
||||
bounded = max(1, min(int(timeout), 30))
|
||||
started = int(time.time())
|
||||
try:
|
||||
self._run([
|
||||
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
|
||||
], 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:
|
||||
def scan(self):
|
||||
return []
|
||||
def __init__(
|
||||
self,
|
||||
saved: dict[str, Any] | None = None,
|
||||
@@ -269,7 +337,7 @@ class MockNetworkManager:
|
||||
with self._lock:
|
||||
previous_password = self.saved.get("password") if self.saved else None
|
||||
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
|
||||
if settings.get("password") is None:
|
||||
if settings.get("password") is None and settings.get('security') != 'open':
|
||||
self.saved["password"] = previous_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")
|
||||
|
||||
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)
|
||||
if password is not None and not 8 <= _utf8_length(password) <= 63:
|
||||
raise WifiConfigError("password must contain 8..63 UTF-8 bytes")
|
||||
if previous is None or previous.get("ssid") != ssid:
|
||||
if 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:
|
||||
raise WifiConfigError("a new password is required when the SSID changes")
|
||||
elif password is None and not previous.get("password_configured"):
|
||||
@@ -79,8 +85,9 @@ def validate_wifi_settings(
|
||||
|
||||
return {
|
||||
"ssid": ssid,
|
||||
"security": security,
|
||||
"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,
|
||||
"address": address,
|
||||
"prefix": prefix if mode == "manual" else None,
|
||||
@@ -105,6 +112,8 @@ class WifiNetworkService:
|
||||
self.monotonic = monotonic
|
||||
self.poll_seconds = poll_seconds
|
||||
self._lock = threading.RLock()
|
||||
self.configuration_lock = self._lock
|
||||
self.settings_revision = str(uuid4())
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._activation_active = False
|
||||
@@ -256,6 +265,7 @@ class WifiNetworkService:
|
||||
else "WiFi 配置已保存,将在下次断电开机后生效"
|
||||
)
|
||||
self.store.set_operation({"id": operation_id, "state": state, "message": message})
|
||||
self.settings_revision = str(uuid4())
|
||||
return {
|
||||
"operation_id": operation_id,
|
||||
"activation": activation,
|
||||
@@ -297,7 +307,8 @@ class WifiNetworkService:
|
||||
self.store.set_operation({
|
||||
"id": operation_id,
|
||||
"state": "failed",
|
||||
"message": str(exc),
|
||||
"message": "WiFi activation failed",
|
||||
"error_code": getattr(exc, "error_code", "UNKNOWN"),
|
||||
})
|
||||
finally:
|
||||
with self._lock:
|
||||
|
||||
@@ -115,6 +115,9 @@ class WifiConfigStore:
|
||||
"state": str(operation.get("state") or "idle"),
|
||||
"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"}:
|
||||
raise WifiConfigError("invalid wifi operation state")
|
||||
with self._lock:
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, AsyncIterable, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from app.display.service import DisplayService
|
||||
from app.system.kernel_recovery import KernelRecovery
|
||||
|
||||
from .diagnostics import failure_log_metadata, read_failure_log
|
||||
from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package
|
||||
@@ -84,8 +85,45 @@ class OtaManager:
|
||||
"last_result": last_result,
|
||||
"failure_log_available": failure_log_available,
|
||||
"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:
|
||||
last_result = read_last_result(self.data_root)
|
||||
return read_failure_log(self.data_root, last_result)
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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]",
|
||||
"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": {
|
||||
"kind": "html_text",
|
||||
"placeholders": [],
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../current-display-playback.js";
|
||||
|
||||
const statusEls = {
|
||||
mobile: document.getElementById("topbar-mobile"),
|
||||
orientation: document.getElementById("status-orientation"),
|
||||
brightness: document.getElementById("status-brightness"),
|
||||
currentContent: document.getElementById("topbar-current-content"),
|
||||
@@ -760,9 +761,22 @@ export async function refreshDeviceStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
let lastMobileSession = null;
|
||||
|
||||
export async function refreshStatus({ source = "manual" } = {}) {
|
||||
try {
|
||||
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);
|
||||
statusEls.orientation.textContent = `${data.state.orientation}°`;
|
||||
showDeviceBrightness(data.state);
|
||||
|
||||
@@ -263,6 +263,20 @@ function applyOtaStatus(status) {
|
||||
otaProgressGroup.hidden = true;
|
||||
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();
|
||||
if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending");
|
||||
applyOtaStatus(status);
|
||||
if (!status.active) return;
|
||||
if (!status.active && status.kernel_recovery?.state !== "pending") return;
|
||||
} catch {
|
||||
disconnected = true;
|
||||
setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending");
|
||||
@@ -297,7 +311,7 @@ async function loadOtaStatus() {
|
||||
try {
|
||||
const status = await fetchOtaStatus();
|
||||
applyOtaStatus(status);
|
||||
if (status.active) pollOtaUntilFinished();
|
||||
if (status.active || status.kernel_recovery?.state === "pending") pollOtaUntilFinished();
|
||||
} catch (error) {
|
||||
otaCurrentVersion.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:
|
||||
self._last_error = f"could not read current governors: {exc}"
|
||||
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_requested
|
||||
and current
|
||||
@@ -235,7 +240,7 @@ class PerformanceModeManager:
|
||||
"effective": effective,
|
||||
"current_governors": current,
|
||||
"restore_governors": dict(self._restore_governors),
|
||||
"last_error": self._last_error,
|
||||
"last_error": error,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.1
|
||||
httpx==0.28.1
|
||||
|
||||
paramiko==4.0.0
|
||||
|
||||
@@ -2,3 +2,5 @@ fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pillow>=10.0
|
||||
fonttools>=4.0,<5
|
||||
cryptography==46.0.5
|
||||
dbus-next==0.2.3
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
@@ -15,7 +16,14 @@ PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和
|
||||
EXAMPLE_CREDENTIAL = PurePosixPath(
|
||||
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
|
||||
)
|
||||
PRIVATE_MOBILE_REGISTRATION = PurePosixPath(
|
||||
"移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json"
|
||||
)
|
||||
EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json")
|
||||
BINARY_EXTENSIONS = {
|
||||
".apk",
|
||||
".aab",
|
||||
".jar",
|
||||
".deb",
|
||||
".dll",
|
||||
".docx",
|
||||
@@ -120,7 +128,7 @@ def _host_text_patterns() -> list[re.Pattern[str]]:
|
||||
def _private_value_markers() -> tuple[str, ...]:
|
||||
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
|
||||
if not path.is_file():
|
||||
return ()
|
||||
return _mobile_value_markers()
|
||||
fields: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
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))
|
||||
fields[key] = value
|
||||
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
|
||||
return tuple(
|
||||
return _mobile_value_markers() + tuple(
|
||||
value
|
||||
for key in CURRENT_DEVICE_KEYS
|
||||
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]:
|
||||
markers: list[bytes] = []
|
||||
host_values = {
|
||||
@@ -178,7 +209,7 @@ def _text_issues(
|
||||
if any(pattern.search(text) for pattern in _host_text_patterns()):
|
||||
issues.append("包含开发电脑专属路径、用户名或主机名")
|
||||
if any(value in text for value in private_values):
|
||||
issues.append("包含当前设备私有凭据值")
|
||||
issues.append("包含当前设备私有凭据或测试手机标识")
|
||||
|
||||
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
|
||||
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]] = []
|
||||
private_values = _private_value_markers()
|
||||
for relative in paths:
|
||||
if relative == PRIVATE_CREDENTIAL:
|
||||
if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION):
|
||||
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
|
||||
continue
|
||||
if relative.suffix.lower() in {".jks", ".keystore"}:
|
||||
findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合"))
|
||||
continue
|
||||
path = WORKSPACE_ROOT.joinpath(*relative.parts)
|
||||
if not path.is_file():
|
||||
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)
|
||||
if ignored.returncode != 0:
|
||||
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:
|
||||
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"
|
||||
DISABLED_SERVICES = (
|
||||
"lightdm.service",
|
||||
"bluetooth.service",
|
||||
"aw859-bluetooth.service",
|
||||
"gpioc-server.service",
|
||||
"map_device.service",
|
||||
)
|
||||
|
||||
@@ -96,10 +96,9 @@ make -C "$STAGING/app/display/native" clean all test
|
||||
systemctl set-default multi-user.target
|
||||
systemctl disable --now \
|
||||
lightdm.service \
|
||||
bluetooth.service \
|
||||
aw859-bluetooth.service \
|
||||
gpioc-server.service \
|
||||
map_device.service
|
||||
DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$STAGING/scripts/install_mobile_bluetooth.sh"
|
||||
if [ "$DEFER_SERVICE_START" = "1" ]; then
|
||||
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
|
||||
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'
|
||||
WORKER = 'matrix-screen-controller-ota.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')
|
||||
FILES = {
|
||||
'frpc': Path('/usr/local/bin/frpc'),
|
||||
@@ -39,6 +41,10 @@ FILES = {
|
||||
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
|
||||
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
|
||||
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
|
||||
'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():
|
||||
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
|
||||
bundle = source / 'system-dependencies/frpc'
|
||||
mobile_installer = source / 'scripts/install_mobile_bluetooth.sh'
|
||||
if not bundle.is_dir():
|
||||
check_installed(source, version)
|
||||
if not mobile_installer.is_file():
|
||||
return
|
||||
# Verify the pinned binary, not just a self-reported checksum file.
|
||||
from app.ota.policy import required_components
|
||||
if bundle.is_dir():
|
||||
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
|
||||
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
|
||||
raise RuntimeError('frpc payload differs from the registered dependency')
|
||||
user = account()
|
||||
user = account() if bundle.is_dir() else None
|
||||
for root in (DATA, candidate):
|
||||
if (root / JOURNAL).exists():
|
||||
raise RuntimeError('存在未完成组件事务,请先恢复')
|
||||
@@ -249,6 +258,13 @@ def begin(source: Path, candidate: Path):
|
||||
'permissions': permission_snapshot(DATA), 'files': {},
|
||||
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
|
||||
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
|
||||
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:
|
||||
for name, path in FILES.items():
|
||||
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'])
|
||||
protect_runtime()
|
||||
env = os.environ.copy()
|
||||
if bundle.is_dir():
|
||||
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
|
||||
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)
|
||||
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:
|
||||
# The watcher handles subsequent worker failure. Restore immediately too,
|
||||
# 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):
|
||||
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)
|
||||
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
|
||||
path = FILES[name]
|
||||
@@ -299,6 +322,15 @@ def restore_components(journal: Path, record: dict):
|
||||
atomic(path, (journal / name).read_bytes(), item['mode'])
|
||||
apply_metadata(path, item)
|
||||
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'])
|
||||
# A previously absent unit cannot be disabled; avoid treating absence as error.
|
||||
if record['files']['frpc-unit'] is not None:
|
||||
@@ -309,6 +341,24 @@ def restore_components(journal: Path, record: dict):
|
||||
link.unlink(missing_ok=True)
|
||||
if record['active']:
|
||||
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():
|
||||
|
||||
@@ -14,11 +14,13 @@ import sys
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any
|
||||
import urllib.request
|
||||
|
||||
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
|
||||
from app.ota.package import extract_payload, inspect_package
|
||||
from app.ota.state import read_json, utc_now, write_json, write_last_result
|
||||
from app.ota.versioning import SoftwareVersion
|
||||
from app.system.kernel_recovery import CANDIDATE_RELEASE, KernelRecovery
|
||||
|
||||
TARGET = Path("/opt/matrix-screen-controller")
|
||||
RELEASES = Path("/opt/matrix-screen-controller.releases")
|
||||
@@ -34,6 +36,32 @@ class UpdateFailed(RuntimeError):
|
||||
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]]:
|
||||
records: dict[str, tuple[int, str]] = {}
|
||||
ignored = ignored or set()
|
||||
@@ -81,6 +109,9 @@ class Transaction:
|
||||
f"job_id={self.job_id} current_version={self.current_version} "
|
||||
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
|
||||
)
|
||||
self.kernel_recovery = KernelRecovery(DATA_ROOT)
|
||||
self.kernel_before: dict[str, Any] | None = None
|
||||
self.performance_before: dict[str, Any] | None = None
|
||||
self.job = {
|
||||
"id": self.job_id,
|
||||
"target_version": str(self.target_version),
|
||||
@@ -118,6 +149,13 @@ class Transaction:
|
||||
)
|
||||
|
||||
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)):
|
||||
raise UpdateFailed("OTA transaction paths already exist")
|
||||
self.work_root.mkdir(parents=True, mode=0o700)
|
||||
@@ -273,6 +311,15 @@ class Transaction:
|
||||
raise UpdateFailed("new service reports the wrong hardware mapping")
|
||||
if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0:
|
||||
raise UpdateFailed("new service did not pass the PWM4 OE health check")
|
||||
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(
|
||||
"health_summary="
|
||||
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]
|
||||
Description=WalnutPi H618 Matrix Screen Controller
|
||||
After=NetworkManager.service
|
||||
Wants=NetworkManager.service
|
||||
Wants=NetworkManager.service bluetooth.service aw859-bluetooth.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -11,6 +11,7 @@ Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller
|
||||
Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=MATRIX_MEDIA_WORKER_MODE=systemd
|
||||
Environment=MATRIX_BLE_ENABLED=1
|
||||
StateDirectory=matrix-screen-controller
|
||||
StateDirectoryMode=0711
|
||||
RuntimeDirectory=matrix-screen-controller
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_status_config_and_display_api(tmp_path):
|
||||
"effective": False,
|
||||
"current_governors": {},
|
||||
"restore_governors": {},
|
||||
"last_error": None,
|
||||
"last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
|
||||
}
|
||||
assert status.json()["state"]["display_test"] == {
|
||||
"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 runner(args):
|
||||
assert tuple(args) != ("ip", "route", "show", "default")
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
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 (
|
||||
HealthError,
|
||||
check_cpufreq,
|
||||
@@ -11,6 +18,159 @@ from scripts.axp313a_kernel_health import (
|
||||
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
|
||||
setenv docker_optimizations "on"
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
journal, record = make_journal()
|
||||
(c.DATA/'user-content').write_bytes(b'unchanged')
|
||||
@@ -181,6 +206,35 @@ def test_normal_migration_does_not_touch_components(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):
|
||||
from scripts import export_release as exporter
|
||||
source = tmp_path / 'software'
|
||||
|
||||
@@ -114,7 +114,7 @@ def test_unavailable_cpufreq_is_reported_without_fake_success(tmp_path):
|
||||
"effective": False,
|
||||
"current_governors": {},
|
||||
"restore_governors": {},
|
||||
"last_error": None,
|
||||
"last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
|
||||
}
|
||||
with pytest.raises(PerformanceModeError, match="unavailable"):
|
||||
manager.transact(True, lambda _enabled: None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
@@ -110,3 +111,30 @@ def test_repository_contract_has_example_and_ignored_private_file():
|
||||
assert hygiene.EXAMPLE_CREDENTIAL in paths
|
||||
assert hygiene.PRIVATE_CREDENTIAL not in paths
|
||||
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
|
||||
|
||||
@@ -7,11 +7,25 @@ import pytest
|
||||
from app.config.store import ConfigStore
|
||||
from app.display.service import DisplayService
|
||||
from app.display.wifi_indicator import render_wifi_indicator
|
||||
from app.network.manager import MockNetworkManager
|
||||
from app.network.manager import MockNetworkManager, NmcliNetworkManager
|
||||
from app.network.service import WifiNetworkService, validate_wifi_settings
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `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. 测试映射
|
||||
|
||||
| 测试编号 | 主要覆盖 | 执行位置 | 人工停顿 |
|
||||
@@ -30,6 +42,7 @@
|
||||
| `TEST-OTA-LOCAL` | 包协议、正式版本与功能时间、版本拒绝、迁移、回滚、进度和前端交互 | Windows / mock | 否 |
|
||||
| `TEST-SYSTEM-INDICATOR-PIXELS` | `DISPLAY-OTA-INDICATOR`、`DISPLAY-WIFI-INDICATOR` 的 OTA、联网 IP 与断网提示像素完整性 | Windows / mock + 核桃派/HUB75 | 用户逐画面确认 |
|
||||
| `TEST-OTA-REAL` | 浏览器上传、40% 更新覆盖、90% 居中画面、真实切换、数据保全和清理 | 核桃派 + HUB75 | 只在画面无法由摄像头判断时确认 |
|
||||
| `TEST-OTA-KERNEL-RECOVERY` | `DEPLOY-OTA-KERNEL-RECOVERY`、能力退化回滚、候选材料校验、一次重启和结果持久化 | 本机 fake sysfs + 核桃派真实 systemd | 仅候选早期硬锁时断电一次 |
|
||||
| `TEST-RELEASE-VERSION` | OTA/镜像共用版本、IMG 候选/实卡晋升、失败回滚、并发和发布记录 | Windows / mock + Linux | 否 |
|
||||
| `TEST-IMAGE-LOCAL` | FAT16、离线载荷、双槽配置、编辑器和秘密排除 | Windows | 否 |
|
||||
| `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-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-WORKSPACE-ORDER` 在相同四类视口检查:侧栏六个项目无“设备/内容”分类标题;叉号下方提供“自定义项目位置”;进入编辑后按钮变为“保存”且项目只显示上下移动操作。首末边界不可用,项目点击不导航,叉号、遮罩和 `Escape` 只提示保存并保持侧栏打开。保存成功后侧栏保持打开,刷新页面、另一浏览器和服务重启均恢复新顺序;请求失败保留内存草稿,未保存时刷新、关闭页面或模拟崩溃后恢复上一次已保存顺序。该测试不连接 HUB75 或 ADC。
|
||||
|
||||
@@ -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`;遇到已有症状先沿用已验证方法,不从头试错。
|
||||
- 测试中出现失败、临时修正或恢复操作,完成后必须复盘。可复用的已验证解法更新对应测试流程、排障或构建文档;单次日志、耗时和重测结果写测试归档,未知根因明确保留,不把重测通过当作根因证明。
|
||||
- 最终反馈须说明经验已写入哪些文档及未解决项;无新增经验时不重复造条目。不仅在聊天里解释,也不能只留一次性报告。遵守根目录秘密与文档路径规则。
|
||||
@@ -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、采集或视觉测试程序,不主动切换实屏测试图案。专用测试手机授权不等于视觉检查授权。不适用时记录“不适用”。
|
||||
@@ -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")
|
||||
}
|
||||
+122
@@ -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))
|
||||
}
|
||||
}
|
||||
+240
@@ -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() }
|
||||
}
|
||||
}
|
||||
+41
@@ -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("关闭")
|
||||
}
|
||||
}
|
||||
+252
@@ -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>
|
||||
+179
@@ -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
|
||||
}
|
||||
}
|
||||
+65
@@ -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" }
|
||||
Binary file not shown.
@@ -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
|
||||
Vendored
+248
@@ -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" "$@"
|
||||
Vendored
+82
@@ -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")) }
|
||||
}
|
||||
}
|
||||
+339
@@ -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 }
|
||||
+74
@@ -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
Reference in New Issue
Block a user