Dismiss WiFi boot prompt when mobile session opens

This commit is contained in:
2026-10-09 12:26:55 +08:00
parent a277da0f96
commit c0e26b97fe
16 changed files with 250 additions and 18 deletions
@@ -0,0 +1,47 @@
# 手机会话取消本次开机 WiFi 提示:实现与验收
日期:2026-10-09(北京时间)。设备正式版本保持 **1.1.3**,功能更新时间为 **2026-10-09T10:26+08:00**。未导出 OTA 或 IMG;App 源码、协议、版本和依赖未变,仅构建同源 debug 测试 APK 用于登记手机的定向验收。
## 行为与实现
手机完成加密握手、有效 `session.open` 成功后,通过可选会话回调接入现有 `dismiss_for_boot()`,取消本 boot 的断网及联网 IP 提示。无需发送显示内容,失败握手、无效参数和仅蓝牙链路连接不触发。取消标记复用运行目录及 boot ID;断开、网络变化和服务重启不恢复,整机重启重新计时。
提示取消与最终提交使用独立锁。NetworkManager 查询在锁外,提交时复查取消标记,避免慢查询阻塞连接或重新显示提示。原生驱动、显示仲裁、用户配置 schema 和公共协议保持现有契约。
## 实际测试结果
| 验证 | 结果 |
| --- | --- |
| 针对性手机会话与 WiFi 测试 | 29 项通过;覆盖延迟前连接、两种提示、失败握手/参数、断开及网络变化、迟到查询、boot 标记和用户帧/状态 |
| 本机完整 Python 回归 | 501 项通过,9 项按环境跳过 |
| 本机前端 | 72 项通过,JavaScript 语法与生成文案目录校验通过 |
| 隔离目录按文档复建 | 新建 venv;493 项通过,10 项按环境跳过;72 项前端测试及语法/目录校验通过 |
| 板端离线 staging | 原生编译、fake-register 与 safeoff 通过;497 项 Python 测试通过,6 项按载荷/环境跳过 |
| 离线原子部署 | 更新脚本成功,部署源码摘要与本机一致,健康和服务重启检查通过 |
| 登记手机连接 | 两轮 `connection` instrumentation 均明确返回 `OK (1 test)`,断开重连及测试辅助文件清理通过 |
| 真实服务重启 | 同 boot、不同进程实例,取消标记仍为 true,提示保持关闭 |
| 两次整机软件重启 | 每次 boot 改变后取消标记均为 false;31 秒延迟到期后提示重新出现;随后手机会话成功即取消 |
| 数据与真实驱动 | 部署后 439 个持久文件摘要不变;两次整机重启后,除既有内核健康记录更新当前 boot 外,其余 438 个文件仍不变;H618 PWM4 后端正常,OE fault 为 0;当前帧接口返回 64×64 PNG |
| 仓库卫生 | 凭据门禁、Git 候选卫生检查和 diff 检查通过,未输出秘密值 |
全过程只读取状态和当前帧接口,未访问首页以免首页取消逻辑掩盖缺陷。两轮真实手机验收前均确认 `dismissed_for_boot=false`、`wifi_indicator_active=true`、模式为 `wifi_connected_indicator`;连接后手机已断开,标记为 true、提示为 false,恢复 `animation`,用户修订仍为 1。恢复后的帧摘要与本轮采样的默认内容一致。
隔离副本不复制 `.git`、私有登记、凭据或原有 venv;Git 专用卫生测试按既有门禁跳过,真实工作区另行执行卫生检查。断网与网络变化分支由本机 mock 验证,本轮未切换真实网络。驱动和底层输出实现未修改,按影响跳过真实无负载基准、实屏图案、摄像头、ADC、无关 Android 页面及 WiFi 传输/切网专项,不将这些项目记为实测通过。
## 测试中发现并修复的问题
- 既有生成文案目录保留旧的未连接文字,与当前页面不一致;通过生成脚本同步,仅更新这一条文字,目录校验与全量回归通过。
- WiFi API 次数断言可能先于后台开机联网完成。增加 mock 激活完成事件,再记录次数基线;原网络业务逻辑未改,完整回归通过。
- 初次隔离副本早于上述目录修复,产生同一失败;同步最终修改后重跑文档入口并通过。
- 初次重定向日志混用了本机代码页与 UTF-8;仅为本次 Python 子进程显式设置编码后复验。首次 Android 临时入口创建失败;更换新入口完成离线构建,新建目录联接仅作入口,材料始终位于项目内。
- 最终全文件摘要核对发现 `kernel-recovery.json` 与部署前不同。经对应源码和实机状态确认,这是既有 `finalize_after_boot()` 更新成功 boot ID、健康状态及时间;要求记录状态成功且对应当前 boot,单独登记这一预期变化,其余文件继续严格比较,不回写旧健康记录。
可重复经验已补充到 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md` 的 `TEST-WIFI-MOBILE-DISMISS`;跨端影响见 `移动端相关内容/开发要求/跨端兼容与变更记录.md`。状态与帧摘要证据见同目录 `验收证据.jsonl`,不包含设备凭据或真实手机标识。
## 清理与边界
本轮远端 `/tmp/qms-wifi-boot-0c0ee7224e06` 已删除,部署日志已拉回本机,原子更新脚本清理 staging、数据候选、测试目录及旧 release;软件重启所用瞬时 systemd 任务随重启清除。
自动审批拒绝删除首次创建但未使用的 `<公共临时目录>/wifi-boot-android`,原因是 `blocked by policy`。该目录只有本轮无用的临时入口材料,可由用户手动删除;未尝试其他删除方式。后续目录联接已正常移除。
最终清理 `测试相关资料/如何测试/本机测试环境/.local/work/wifi-boot/` 也被自动审批以 `blocked by policy` 拒绝;未更换删除手段。该目录仅含本轮部署载荷、隔离副本、临时 Android 构建、项目内下载缓存、脚本和日志,必要摘要与证据已归档,本轮进程已完成,可由用户手动删除。可复用 SSH/Android 环境、私有登记和其他任务目录不在待删范围。
@@ -0,0 +1,10 @@
{
"local": "501 passed, 9 skipped, 8028 warnings in 144.31s (0:02:24)",
"isolated": "493 passed, 10 skipped, 8029 warnings in 86.52s (0:01:26)",
"board": "497 passed, 6 skipped, 381 warnings in 393.47s (0:06:33)",
"frontend_passed": 72,
"phone_connection_rounds_passed": 2,
"user_data_unchanged": true,
"expected_kernel_boot_health_update": true,
"remote_staging_removed": true
}
@@ -0,0 +1,11 @@
{"action": "verify", "time_utc": "2026-10-09T02:44:58.467357+00:00", "status": {"prompt": {"dismissed_for_boot": true, "deadline_monotonic": 54.424020967000004, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 2986, "actual_refresh_rate_hz": 97.308, "software_version": "1.1.3", "feature_updated_at": "2026-10-09T10:26+08:00"}, "persistent_file_count": 439, "persistent_changed_count": 0, "version_unchanged": true, "deployed_sources_match": true}
{"action": "status", "time_utc": "2026-10-09T02:45:48.606426+00:00", "prompt": {"dismissed_for_boot": true, "deadline_monotonic": 54.424020967000004, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 7912, "actual_refresh_rate_hz": 97.355, "software_version": "1.1.3", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "3855e8a74e603e972b3d711a4d5cc0ddfefb775ddc659602bbc13e1bf7f4a902", "frame_sha256": "6240a0c573b31bcba5a889ade2a75dd5f076023087988f7130f2ff23271689ec", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot1-before-phone", "time_utc": "2026-10-09T02:47:27.086554+00:00", "prompt": {"dismissed_for_boot": false, "deadline_monotonic": 53.419300050000004, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 1923, "actual_refresh_rate_hz": 97.461, "software_version": "1.1.3", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "9c060064888cb04bf5e470cdafd004e820c4a38c742bf661affbc00305299b4d", "frame_sha256": "1597045fecacb936132de48480f0a9b9bee0cfe7e9097931a58a233a843b9f36", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot1-prompt", "time_utc": "2026-10-09T02:48:01.842472+00:00", "prompt": {"dismissed_for_boot": false, "deadline_monotonic": 53.419300050000004, "active": true}, "wifi_indicator_active": true, "mode": "wifi_connected_indicator", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 5299, "actual_refresh_rate_hz": 97.41, "software_version": "1.1.3", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "9c060064888cb04bf5e470cdafd004e820c4a38c742bf661affbc00305299b4d", "frame_sha256": "0d87ce1481567743b18a174b909b4618f661ed2d2f0c2fed8a061778ff91e2ca", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot1-after-phone", "time_utc": "2026-10-09T02:48:46.948438+00:00", "prompt": {"dismissed_for_boot": true, "deadline_monotonic": 53.419300050000004, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 9707, "actual_refresh_rate_hz": 97.434, "software_version": "1.1.3", "instance_hash": "e18cce2e216ab3bfcab447a3a3b0d2708502ec81398d1eba31d3dd466be673eb", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "9c060064888cb04bf5e470cdafd004e820c4a38c742bf661affbc00305299b4d", "frame_sha256": "1597045fecacb936132de48480f0a9b9bee0cfe7e9097931a58a233a843b9f36", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot1-after-service-restart", "time_utc": "2026-10-09T02:49:21.351144+00:00", "prompt": {"dismissed_for_boot": true, "deadline_monotonic": 53.419300050000004, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 2053, "actual_refresh_rate_hz": 97.332, "software_version": "1.1.3", "instance_hash": "ce0da260b6c1e58c993c53d5276c05a6f9227c7261dcad76038f39cb3489a1d8", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "9c060064888cb04bf5e470cdafd004e820c4a38c742bf661affbc00305299b4d", "frame_sha256": "1597045fecacb936132de48480f0a9b9bee0cfe7e9097931a58a233a843b9f36", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot2-before-phone", "time_utc": "2026-10-09T02:50:26.351737+00:00", "prompt": {"dismissed_for_boot": false, "deadline_monotonic": 53.062328967, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 2081, "actual_refresh_rate_hz": 97.359, "software_version": "1.1.3", "instance_hash": "9e5a35db4370716684bf9ce640b47ac079f748e0c2f10a1082560b87bd0b2bbe", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "de8854a89b978929f663e0bc002c8e61fd9b1064a815fbc530a48c15c4cb2c1c", "frame_sha256": "1597045fecacb936132de48480f0a9b9bee0cfe7e9097931a58a233a843b9f36", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot2-prompt", "time_utc": "2026-10-09T02:51:41.883796+00:00", "prompt": {"dismissed_for_boot": false, "deadline_monotonic": 53.062328967, "active": true}, "wifi_indicator_active": true, "mode": "wifi_connected_indicator", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 9431, "actual_refresh_rate_hz": 97.313, "software_version": "1.1.3", "instance_hash": "9e5a35db4370716684bf9ce640b47ac079f748e0c2f10a1082560b87bd0b2bbe", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "de8854a89b978929f663e0bc002c8e61fd9b1064a815fbc530a48c15c4cb2c1c", "frame_sha256": "32b497c57175cee1d0f0f0537a8e37bb5f2cbb79d163bd14e7f8690189cdab4e", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "status-boot2-after-phone", "time_utc": "2026-10-09T02:52:34.926190+00:00", "prompt": {"dismissed_for_boot": true, "deadline_monotonic": 53.062328967, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 14615, "actual_refresh_rate_hz": 97.45, "software_version": "1.1.3", "instance_hash": "9e5a35db4370716684bf9ce640b47ac079f748e0c2f10a1082560b87bd0b2bbe", "feature_updated_at": "2026-10-09T10:26+08:00", "boot_hash": "de8854a89b978929f663e0bc002c8e61fd9b1064a815fbc530a48c15c4cb2c1c", "frame_sha256": "1597045fecacb936132de48480f0a9b9bee0cfe7e9097931a58a233a843b9f36", "frame_size": [64, 64], "prompt_delay_seconds": 31}
{"action": "verify", "time_utc": "2026-10-09T02:52:41.064790+00:00", "status": {"prompt": {"dismissed_for_boot": true, "deadline_monotonic": 53.062328967, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 15152, "actual_refresh_rate_hz": 97.352, "software_version": "1.1.3", "instance_hash": "9e5a35db4370716684bf9ce640b47ac079f748e0c2f10a1082560b87bd0b2bbe", "feature_updated_at": "2026-10-09T10:26+08:00"}, "persistent_file_count": 439, "persistent_changed_count": 1, "version_unchanged": true, "deployed_sources_match": true}
{"action": "verify", "time_utc": "2026-10-09T02:55:38.902519+00:00", "status": {"prompt": {"dismissed_for_boot": true, "deadline_monotonic": 53.062328967, "active": false}, "wifi_indicator_active": false, "mode": "animation", "revision": 1, "mobile_connected": false, "mobile_available": true, "driver": "walnutpi-h618-hub75", "oe_backend": "h618-pwm4", "faults": 0, "completed_frames": 32445, "actual_refresh_rate_hz": 97.364, "software_version": "1.1.3", "instance_hash": "9e5a35db4370716684bf9ce640b47ac079f748e0c2f10a1082560b87bd0b2bbe", "feature_updated_at": "2026-10-09T10:26+08:00"}, "persistent_file_count": 439, "persistent_changed_count": 1, "expected_current_boot_health_update": true, "user_data_unchanged": true, "version_unchanged": true, "deployed_sources_match": true}
@@ -1028,7 +1028,7 @@ WebSocket 消息示例:
- `ipv4_mode="dhcp"` 清除静态地址;`manual` 必须提供 IPv4 地址和网关,子网前缀空值使用 `/24`,DNS 空值使用网关。 - `ipv4_mode="dhcp"` 清除静态地址;`manual` 必须提供 IPv4 地址和网关,子网前缀空值使用 `/24`,DNS 空值使用网关。
- `activation="next_boot"` 只更新持久 NetworkManager 配置并返回 `200`;`immediate` 先保存、返回 `202`,再异步激活连接,避免浏览器在响应发出前被切断。 - `activation="next_boot"` 只更新持久 NetworkManager 配置并返回 `200`;`immediate` 先保存、返回 `202`,再异步激活连接,避免浏览器在响应发出前被切断。
- SSID 按 UTF-8 字节限制为 `1..32`,个人热点密码限制为 `8..63` UTF-8 字节;IPv4、前缀 `1..32` 和 DNS 必须严格校验。除 `GET /api/network/wifi` 的 `saved.password` 外,任何响应、错误和日志不得回显密码。 - SSID 按 UTF-8 字节限制为 `1..32`,个人热点密码限制为 `8..63` UTF-8 字节;IPv4、前缀 `1..32` 和 DNS 必须严格校验。除 `GET /api/network/wifi` 的 `saved.password` 外,任何响应、错误和日志不得回显密码。
- `/api/status.network` 暴露同一脱敏活动状态与提示状态;读取状态、静态资源和预览不取消开机提示,只有成功首页访问或成功改屏才取消。 - `/api/status.network` 暴露同一脱敏活动状态与提示状态;读取状态、静态资源和预览不取消开机提示,成功首页访问、成功改屏或手机成功建立加密控制会话均取消本次开机的 WiFi 提示;手机断开、网络变化和服务重启不恢复,整机重启重新计时。
### 3.13.1 FRP 与网络诊断 API(`WEB-FRP-MAINTENANCE`、`CONFIG-FRP`、`WEB-NETWORK-DIAGNOSTICS`) ### 3.13.1 FRP 与网络诊断 API(`WEB-FRP-MAINTENANCE`、`CONFIG-FRP`、`WEB-NETWORK-DIAGNOSTICS`)
@@ -192,6 +192,7 @@ class DisplayService:
### 3.1.1.1 开机 WiFi 提示(`DISPLAY-WIFI-INDICATOR`、`CONFIG-WIFI`) ### 3.1.1.1 开机 WiFi 提示(`DISPLAY-WIFI-INDICATOR`、`CONFIG-WIFI`)
- WiFi 提示是独立输出覆盖层,不写入当前用户帧、`last_mode`、用户亮度、方向或用户状态修订号;提示结束后恢复最新用户动图帧或静态帧。 - WiFi 提示是独立输出覆盖层,不写入当前用户帧、`last_mode`、用户亮度、方向或用户状态修订号;提示结束后恢复最新用户动图帧或静态帧。
- 每次整机开机后,手机 App 完成加密握手及有效 `session.open` 即取消本次 boot 的全部 WiFi 提示,无需改屏;延迟到期前连接也取消后续提示。断开手机、网络变化及服务重启均不得恢复,整机重启后重新计时。扫描、底层蓝牙连接、失败握手或无效会话参数不取消;取消与后台提示提交必须协调,禁止迟到的网络查询恢复提示。
- 仲裁顺序固定为 `临界低电图标 > WiFi 提示 > 开机笑脸 > 用户动图 > 用户静态画面`。WiFi 提示开始时结束当前开机笑脸,但不把提示帧当作用户改屏。 - 仲裁顺序固定为 `临界低电图标 > WiFi 提示 > 开机笑脸 > 用户动图 > 用户静态画面`。WiFi 提示开始时结束当前开机笑脸,但不把提示帧当作用户改屏。
- 连接成功时在黑底上连续左移 `SSID: <实际SSID> IP: http://<IPv4>:8080/`;断开时顶部为静态 WiFi 断开图标,底部连续左移 `SSID: <保存SSID> 密码: <保存密码>`。未配置时显示明确无配置文案,不虚构密码。 - 连接成功时在黑底上连续左移 `SSID: <实际SSID> IP: http://<IPv4>:8080/`;断开时顶部为静态 WiFi 断开图标,底部连续左移 `SSID: <保存SSID> 密码: <保存密码>`。未配置时显示明确无配置文案,不虚构密码。
- 连接成功和断开两种滚动文字都必须把字体栅格化结果转成全亮或全灭的单色像素,不保留小字号抗锯齿灰阶;字体覆盖到的每个像素必须完整保留,避免局域网 IP、SSID、密码提示在网页当前帧和实屏上出现断笔或缺块。 - 连接成功和断开两种滚动文字都必须把字体栅格化结果转成全亮或全灭的单色像素,不保留小字号抗锯齿灰阶;字体覆盖到的每个像素必须完整保留,避免局域网 IP、SSID、密码提示在网页当前帧和实屏上出现断笔或缺块。
@@ -5,6 +5,7 @@
- DEVICE-MOBILE-WIFI-TRANSPORT:BLE 为会话前提;加密 BLE 交付 WiFi 地址与一次性短期凭据,以实际连接和身份验证判定可用,不比较 SSID 或限制同子网。WebSocket 复用公共业务、单所有者与串行执行。 - DEVICE-MOBILE-WIFI-TRANSPORT:BLE 为会话前提;加密 BLE 交付 WiFi 地址与一次性短期凭据,以实际连接和身份验证判定可用,不比较 SSID 或限制同子网。WebSocket 复用公共业务、单所有者与串行执行。
- DEVICE-MOBILE-WIFI-LIFECYCLE:BLE 独立保活;蓝牙断开撤销全部通道,WiFi 失败回退 BLE。只读可重取,结果不明写入不得重放。旧通道与回调隔离。 - DEVICE-MOBILE-WIFI-LIFECYCLE:BLE 独立保活;蓝牙断开撤销全部通道,WiFi 失败回退 BLE。只读可重取,结果不明写入不得重放。旧通道与回调隔离。
- WEB-MOBILE-PRESENCE:顶栏“连接设备:名称 · WiFi/蓝牙”,无会话“连接设备:未连接”;切换不重复通知连接。 - WEB-MOBILE-PRESENCE:顶栏“连接设备:名称 · WiFi/蓝牙”,无会话“连接设备:未连接”;切换不重复通知连接。
- DEVICE-MOBILE-SESSION:手机成功完成加密握手和有效 `session.open` 后,设备取消本次开机的 WiFi 提示(含断网提示及联网 IP)。无需业务写入,断开或服务重启不恢复,整机重启重新计时;协议和 App 不变。
- TEST-MOBILE-WIFI-TRANSPORT:覆盖双端向量、安全、在途故障、实际网络恢复、权限与传输对照;模拟与真实证据分别记录。 - TEST-MOBILE-WIFI-TRANSPORT:覆盖双端向量、安全、在途故障、实际网络恢复、权限与传输对照;模拟与真实证据分别记录。
本文件定义核桃派侧行为,手机产品需求与协议分别见 `../移动端相关内容/开发要求/移动端需求.md`、`../移动端相关内容/开发要求/设备通信协议.md`。当前是已确认的实施契约,不代表已部署。 本文件定义核桃派侧行为,手机产品需求与协议分别见 `../移动端相关内容/开发要求/移动端需求.md`、`../移动端相关内容/开发要求/设备通信协议.md`。当前是已确认的实施契约,不代表已部署。
+1 -1
View File
@@ -1 +1 @@
2026-10-01T10:37+08:00 2026-10-09T10:26+08:00
+1
View File
@@ -870,6 +870,7 @@ def create_app(
apply_resolved_content, effective_default_content, set_default_content, apply_resolved_content, effective_default_content, set_default_content,
read_device_status, storage_monitor.get_status, library_order=library_order) read_device_status, storage_monitor.get_status, library_order=library_order)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity, mobile_sessions = SessionManager(mobile.dispatch, mobile.identity,
on_session_open=network.dismiss_for_boot,
wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address") wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address")
if network.get_cached_status().get("active", {}).get("connected") else None) if network.get_cached_status().get("active", {}).get("connected") else None)
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1") mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
+13 -1
View File
@@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import threading import threading
import time import time
from .protocol import Handshake, ProtocolError, Reassembler from .protocol import Handshake, ProtocolError, Reassembler
logger = logging.getLogger(__name__)
class RpcError(Exception): class RpcError(Exception):
def __init__(self, code, message): def __init__(self, code, message):
@@ -23,10 +26,12 @@ def checked_name(value):
class SessionManager: class SessionManager:
def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None): def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None,
on_session_open=None):
self.dispatch = dispatch self.dispatch = dispatch
self.identity = identity self.identity = identity
self.clock = clock self.clock = clock
self.on_session_open = on_session_open
self.lock = threading.RLock() self.lock = threading.RLock()
self.owner = None self.owner = None
self.receiver = None self.receiver = None
@@ -116,6 +121,13 @@ class SessionManager:
self.client_name = name self.client_name = name
self.opened = True self.opened = True
self.generation += 1 self.generation += 1
if self.on_session_open is not None:
try:
self.on_session_open()
except Exception:
# A failed display notification must not invalidate
# an already established encrypted session.
logger.exception("Mobile session-open notification failed")
elif method == "session.ping": elif method == "session.ping":
result = dict(alive=True) result = dict(alive=True)
elif method == "session.rename": elif method == "session.rename":
@@ -112,6 +112,7 @@ class WifiNetworkService:
self.monotonic = monotonic self.monotonic = monotonic
self.poll_seconds = poll_seconds self.poll_seconds = poll_seconds
self._lock = threading.RLock() self._lock = threading.RLock()
self._indicator_lock = threading.RLock()
self.configuration_lock = self._lock self.configuration_lock = self._lock
self.settings_revision = str(uuid4()) self.settings_revision = str(uuid4())
self._stop = threading.Event() self._stop = threading.Event()
@@ -156,6 +157,7 @@ class WifiNetworkService:
self.display.dismiss_wifi_indicator() self.display.dismiss_wifi_indicator()
def dismiss_for_boot(self) -> None: def dismiss_for_boot(self) -> None:
with self._indicator_lock:
try: try:
changed = self.store.dismiss_for_boot() changed = self.store.dismiss_for_boot()
except Exception: except Exception:
@@ -342,6 +344,8 @@ class WifiNetworkService:
self._stop.wait(self.poll_seconds) self._stop.wait(self.poll_seconds)
def _refresh_indicator(self) -> None: def _refresh_indicator(self) -> None:
if self.store.session["dismissed"]:
return
managed_uuid = self.store.config["managed_connection_uuid"] managed_uuid = self.store.config["managed_connection_uuid"]
try: try:
active = self.backend.read_active() active = self.backend.read_active()
@@ -350,7 +354,7 @@ class WifiNetworkService:
f"SSID: {active['ssid']} " f"SSID: {active['ssid']} "
f"IP: http://{active['ipv4_address']}:8080/" f"IP: http://{active['ipv4_address']}:8080/"
) )
self.display.show_wifi_indicator(connected=True, message=message) self._show_indicator_unless_dismissed(connected=True, message=message)
return return
if managed_uuid: if managed_uuid:
@@ -359,6 +363,13 @@ class WifiNetworkService:
message = f"SSID: {saved.get('ssid') or '未配置'} 密码: {password}" message = f"SSID: {saved.get('ssid') or '未配置'} 密码: {password}"
else: else:
message = "尚未配置 WiFi" message = "尚未配置 WiFi"
self.display.show_wifi_indicator(connected=False, message=message) self._show_indicator_unless_dismissed(connected=False, message=message)
except NetworkManagerError: except NetworkManagerError:
self.display.show_wifi_indicator(connected=False, message="WiFi 状态不可用") self._show_indicator_unless_dismissed(connected=False, message="WiFi 状态不可用")
def _show_indicator_unless_dismissed(self, *, connected: bool, message: str) -> None:
# NetworkManager reads happen outside this lock so opening a mobile
# session never waits for them. Recheck at the actual submission point.
with self._indicator_lock:
if not self.store.session["dismissed"]:
self.display.show_wifi_indicator(connected=connected, message=message)
@@ -3628,7 +3628,7 @@
}, },
"scope": "global", "scope": "global",
"source": "index.html::#topbar-mobile::text[0]", "source": "index.html::#topbar-mobile::text[0]",
"text": "手机未连接" "text": "连接设备:未连接"
}, },
"copy.9a395662799ecd7a": { "copy.9a395662799ecd7a": {
"kind": "html_text", "kind": "html_text",
@@ -1,4 +1,5 @@
import base64 import base64
import threading
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@@ -1160,6 +1161,13 @@ def test_wifi_settings_api_reveals_password_only_on_dedicated_get(tmp_path):
"control_url": "http://192.168.9.93:8080/", "control_url": "http://192.168.9.93:8080/",
}, },
) )
startup_activation = threading.Event()
activate = backend.activate
def record_activation(*args, **kwargs):
result = activate(*args, **kwargs)
startup_activation.set()
return result
backend.activate = record_activation
app = create_app( app = create_app(
project_root=tmp_path, project_root=tmp_path,
driver_kind="mock", driver_kind="mock",
@@ -1174,6 +1182,7 @@ def test_wifi_settings_api_reveals_password_only_on_dedicated_get(tmp_path):
assert initial.json()["active"]["control_url"] == "http://192.168.9.93:8080/" assert initial.json()["active"]["control_url"] == "http://192.168.9.93:8080/"
status_network = client.get("/api/status").json()["network"] status_network = client.get("/api/status").json()["network"]
assert "password" not in status_network["saved"] assert "password" not in status_network["saved"]
assert startup_activation.wait(5), "boot network activation did not complete"
startup_activation_count = len(backend.activations) startup_activation_count = len(backend.activations)
payload = { payload = {
@@ -4,9 +4,11 @@ from app.mobile.protocol import Handshake, ProtocolError, fragments
from app.mobile.session import SessionManager from app.mobile.session import SessionManager
def connection(clock=lambda: 0): def connection(clock=lambda: 0, *, manager=None, on_session_open=None):
calls = [] calls = []
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"}, clock=clock) if manager is None:
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"},
clock=clock, on_session_open=on_session_open)
assert manager.connect('a') assert manager.connect('a')
assert not manager.connect('b') assert not manager.connect('b')
client = Handshake() client = Handshake()
@@ -69,6 +71,42 @@ def test_negotiated_receive_mtu_resets_on_disconnect():
@pytest.mark.parametrize('mtu', [0, 22, 518, True, '247']) @pytest.mark.parametrize('mtu', [0, 22, 518, True, '247'])
def test_invalid_receive_mtu_never_opens_session(mtu): def test_invalid_receive_mtu_never_opens_session(mtu):
manager, cipher, _ = connection() opened = []
manager, cipher, _ = connection(on_session_open=lambda: opened.append(True))
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok'] assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok']
assert not manager.opened assert not manager.opened
assert opened == []
def test_open_notification_only_after_valid_encrypted_session():
opened = []
manager, cipher, _ = connection(on_session_open=lambda: opened.append(manager.status()['connected']))
assert opened == [] # Neither the link nor the handshake is a session.
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': ''})['ok']
assert opened == []
assert request(manager, cipher, 2, '2', 'session.open', {'client_name': '手机'})['ok']
assert opened == [True]
assert not request(manager, cipher, 3, '3', 'session.open', {'client_name': '手机'})['ok']
request(manager, cipher, 4, '4', 'session.ping')
manager.disconnect('a')
assert opened == [True]
def test_failed_handshake_never_notifies():
opened = []
manager = SessionManager(lambda *args: {}, lambda: {}, on_session_open=lambda: opened.append(True))
assert manager.connect('a')
with pytest.raises(ProtocolError):
for part in fragments(1, 0, b'invalid-handshake', 247):
manager.accept('a', part)
assert not manager.opened
assert opened == []
def test_notification_failure_does_not_break_open_session(caplog):
def failed_notification():
raise OSError('test notification failure')
manager, cipher, _ = connection(on_session_open=failed_notification)
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
assert request(manager, cipher, 2, '2', 'session.ping')['ok']
assert 'session-open notification failed' in caplog.text
+76 -1
View File
@@ -1,8 +1,11 @@
import json import json
import time import time
import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
from PIL import Image
from fastapi.testclient import TestClient
from app.config.store import ConfigStore from app.config.store import ConfigStore
from app.display.service import DisplayService from app.display.service import DisplayService
@@ -10,6 +13,8 @@ from app.display.wifi_indicator import render_wifi_indicator
from app.network.manager import MockNetworkManager, NmcliNetworkManager from app.network.manager import MockNetworkManager, NmcliNetworkManager
from app.network.service import WifiNetworkService, validate_wifi_settings from app.network.service import WifiNetworkService, validate_wifi_settings
from app.network.store import WifiConfigError, WifiConfigStore from app.network.store import WifiConfigError, WifiConfigStore
from app.main import create_app
from test_mobile_session import connection, request
@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']) @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'])
@@ -287,9 +292,79 @@ def test_dismissed_network_prompt_does_not_reappear_after_disconnect(tmp_path):
ipv4_address=None, ipv4_address=None,
control_url=None, control_url=None,
) )
time.sleep(0.02) service._refresh_indicator()
assert wifi_store.session["dismissed"] is True assert wifi_store.session["dismissed"] is True
assert display.get_status()["state"]["wifi_indicator_active"] is False assert display.get_status()["state"]["wifi_indicator_active"] is False
finally: finally:
service.close() service.close()
display.close() display.close()
@pytest.mark.parametrize('prompt_state', ['before_delay', 'connected', 'disconnected'])
def test_mobile_open_dismisses_prompt_and_preserves_user_frame(tmp_path, prompt_state):
backend = MockNetworkManager(saved_profile(), active_profile())
app = create_app(project_root=tmp_path, driver_kind='mock', startup_boot_id='boot-a', network_backend=backend)
network = app.state.network_service
display = app.state.display_service
try:
image = Image.new('RGB', (64, 64), (12, 34, 56))
display.show_image(image, notify_activity=False)
before = display.get_status()
config_before = display.config_store.config
assert not TestClient(app).get('/api/status').json()['network']['prompt']['dismissed_for_boot']
if prompt_state != 'before_delay':
backend.active = active_profile(connected=prompt_state == 'connected')
network._refresh_indicator()
assert display.get_status()['state']['wifi_indicator_active']
manager, cipher, _ = connection(manager=app.state.mobile_sessions)
assert not network.store.session['dismissed']
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
manager.disconnect('a')
for connected in [False, True, False]:
backend.active = active_profile(connected=connected)
network._refresh_indicator()
assert not display.get_status()['state']['wifi_indicator_active']
assert network.get_cached_status()['prompt']['dismissed_for_boot']
assert display.get_current_frame().tobytes() == image.tobytes()
after = display.get_status()
for key in ['revision', 'brightness', 'orientation', 'mode', 'current_content']:
assert after['state'][key] == before['state'][key]
assert display.config_store.config == config_before
same = WifiConfigStore(network.store.path.parent, network.store.session_path.parent, boot_id='boot-a')
assert same.session['dismissed']
next_boot = WifiConfigStore(network.store.path.parent, network.store.session_path.parent, boot_id='boot-b')
assert not next_boot.session['dismissed']
finally:
network.close()
display.close()
@pytest.mark.parametrize('connected', [False, True])
def test_late_network_read_cannot_restore_dismissed_prompt(tmp_path, connected):
config = ConfigStore(project_root=tmp_path)
display = DisplayService(config, driver_kind='mock', startup_boot_id='boot')
backend = MockNetworkManager(saved_profile(), active_profile(connected=connected))
store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id='boot')
service = WifiNetworkService(store, backend, display)
entered, release = threading.Event(), threading.Event()
read_active = backend.read_active
def delayed_read():
entered.set()
assert release.wait(5)
return read_active()
backend.read_active = delayed_read
thread = threading.Thread(target=service._refresh_indicator)
try:
thread.start()
assert entered.wait(5)
service.dismiss_for_boot()
release.set()
thread.join(5)
assert not thread.is_alive()
assert store.session['dismissed']
assert not display.get_status()['state']['wifi_indicator_active']
finally:
release.set()
thread.join(5)
service.close()
display.close()
@@ -1,5 +1,17 @@
# 核桃派点阵屏控制服务测试流程 # 核桃派点阵屏控制服务测试流程
## 手机会话取消 WiFi 开机提示(TEST-WIFI-MOBILE-DISMISS)
对应 `DISPLAY-WIFI-INDICATOR`、`CONFIG-WIFI` 和 `DEVICE-MOBILE-SESSION`。直接影响设备会话入口、WiFi 覆盖取消与后台刷新;协议、App 和原生驱动不变。以本机 mock 覆盖延迟到期前连接、联网 IP/断网提示活动时连接、失败握手与无效参数、断开及网络变化、迟到查询并发、同 boot 重建和新 boot 重新计时;验证恢复用户帧,亮度、方向、模式和修订不变。
部署后只读 `/api/status` 和帧接口,**不得访问首页**,因为首页本身会取消提示而掩盖手机入口缺陷。先核对提示未取消,再用登记手机的 `run_ble_test.py --scenario connection` 完成会话及断开重连,不发送改屏命令;应为 `dismissed_for_boot=true`、`wifi_indicator_active=false`。真实 `systemctl restart` 后保持取消;整机软件重启后,在手机尚未连接时应重新计时,等待提示延迟到期,再连接手机复验。网络断开/恢复分支优先 mock,当前任务不为此切换真实网络。
取消路径和提示提交点使用同一独立锁;NetworkManager 查询在锁外,返回后在提交前复查取消状态,避免慢查询阻塞连接或重新显示提示。现有 boot 标记保存在 `/run/matrix-screen-controller`,不得改成跨整机重启的持久设置。板端核对真实驱动与 fault 统计,无驱动改动时按影响跳过无负载基准、实屏图案、摄像头、ADC 和无关 Android 页面;必要但未完成的手机或生命周期验证记“未验证”。
本机 WiFi API 测试应先等待 mock 的开机网络激活完成,再记录激活次数基线,避免后台线程调度影响“下次开机”和“立即生效”的次数断言。文案目录校验失败时用 `scripts/build_ui_copy_catalog.py` 重建并核对实际差异,不绕过校验。中文路径下把 Python 子进程输出重定向为 UTF-8 日志时,仅对本次进程显式设置编码,避免父进程使用 UTF-8 而子进程仍输出本机代码页导致日志混合编码。
比较部署和重启前后持久文件时分别记录两个阶段:普通部署应保持原有内容;既有 `kernel-recovery.json` 在整机重启后可由 `finalize_after_boot()` 更新成功 boot ID 与记录时间。只允许核验为成功且对应当前 boot 的这项预期变化,其余文件继续逐项比较,不把用户配置或资源变化一并忽略,也不恢复过期健康记录。
TEST-MOBILE-WIFI-TRANSPORT 对应 DEVICE-MOBILE-WIFI-TRANSPORT/LIFECYCLE 与 WEB-MOBILE-PRESENCE,步骤见 `移动端相关内容/安卓app/如何安卓测试/WiFi传输测试.md`。合格标准:BLE 为会话前提、实际直连身份验证、独立密钥、无重复写入、WiFi 回退恢复、BLE 断开撤销、真实重启后新会话,以及网页/App 实际链路显示一致;模拟与实机分别记录。 TEST-MOBILE-WIFI-TRANSPORT 对应 DEVICE-MOBILE-WIFI-TRANSPORT/LIFECYCLE 与 WEB-MOBILE-PRESENCE,步骤见 `移动端相关内容/安卓app/如何安卓测试/WiFi传输测试.md`。合格标准:BLE 为会话前提、实际直连身份验证、独立密钥、无重复写入、WiFi 回退恢复、BLE 断开撤销、真实重启后新会话,以及网页/App 实际链路显示一致;模拟与实机分别记录。
本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。 本文件只维护可重复步骤、分层边界和合格标准,不记录某一次已经通过的结果。任何物理操作都按 `AGENTS.md` 的人工停顿规则执行。
@@ -1,5 +1,9 @@
# 跨端兼容与变更记录 # 跨端兼容与变更记录
## 2026-10-09 手机会话取消设备 WiFi 开机提示
需求:`DISPLAY-WIFI-INDICATOR`、`CONFIG-WIFI`、`DEVICE-MOBILE-SESSION`。设备在现有加密 `session.open` 成功后取消本 boot 的 WiFi 提示,包含未联网及联网 IP;无需改屏,断开及服务重启不恢复,整机重启重新计时。会话失败不触发,取消与后台提交协调避免迟到查询恢复提示。通信协议、状态字段、依赖及驱动保持现有契约,无需 App 更新;用户授权设备修改、定向测试和部署,未授权 App 迭代或发布。本机及板端实际验证结果见本轮测试归档,未执行项目不得记为通过。
## WiFi 通信第一阶段 / App 0.3.0 (6) ## WiFi 通信第一阶段 / App 0.3.0 (6)
协议 1.2 增量能力 wifi_transport;原 BLE 握手和命令不变。新增 BLE transport.offer/close、加密 WebSocket /ws/mobile 及 mobile.active_transport(ble/wifi/null)。旧 App 忽略新字段,新 App 对旧设备保持 BLE。App 新增网络与 API 37 局域网权限。设备无需新 Python/Debian 依赖,不改驱动;手工可回滚部署,不导出 OTA/IMG、不推进设备发布版本。 协议 1.2 增量能力 wifi_transport;原 BLE 握手和命令不变。新增 BLE transport.offer/close、加密 WebSocket /ws/mobile 及 mobile.active_transport(ble/wifi/null)。旧 App 忽略新字段,新 App 对旧设备保持 BLE。App 新增网络与 API 37 局域网权限。设备无需新 Python/Debian 依赖,不改驱动;手工可回滚部署,不导出 OTA/IMG、不推进设备发布版本。