diff --git a/.gitignore b/.gitignore index c2f9a90..1e059de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,23 @@ /SatoriManagerContent/build *.user *.autosave + +# Local device identities, credentials, signing files and captures stay private. +.local/ +private*/ +artifacts/ +.env +.env.* +*.pem +*.key +*.p12 +*.pfx +*.sota +*.log +__pycache__/ +device-config*.json +usb-config*.json + +*.apk +*.bin +*.zip diff --git a/docs/apk-signing-compatibility.md b/docs/apk-signing-compatibility.md new file mode 100644 index 0000000..eb11299 --- /dev/null +++ b/docs/apk-signing-compatibility.md @@ -0,0 +1,47 @@ +# Existing-install APK signing compatibility + +The latest locally validated `0.2.7+9` ARM64 APK is **unsigned**. Its package is +`com.example.satori_manager`, versionCode `9`. It cannot be installed directly. +`flutter_app/android/app/build.gradle.kts` currently assigns the release build +to the template's `debug` signing configuration. That source setting is not +proof that an output was signed: the actual APK fails `apksigner verify` with +`Missing META-INF/MANIFEST.MF`. Do not describe the current deliverable as a +signed development release, or run a build that implicitly creates a new key. + +An in-place update requires the signing identity compatible with the installed +App. A freshly generated key with the same filename/alias does not recreate +that identity. The minimal approach keeps this package ID and uses the **original +App signing channel and existing key**, including the original debug keystore +if that is how the installed App was signed. Do not confuse the firmware RSA +OTA key with an Android App signing key. See the primary +[Android app signing documentation](https://developer.android.com/studio/publish/app-signing). + +## Minimal preparation and later approved actions + +1. Obtain an existing signed APK from the installation source, or (only after + phone access is approved) read the installed package path/version and pull + its APK for public certificate inspection. Do not uninstall or inspect App + private data. This phase did not access the phone or run ADB. +2. Run `apksigner verify --print-certs` on that baseline APK to obtain its public + certificate SHA256. Confirm package ID and installed versionCode. This needs + no keystore/private key; no claim is made that these are already known. +3. Have the original signing channel use its existing key to sign the new APK. + Android Studio's signed APK workflow can select an existing keystore rather + than "Create new". If signing a prepared APK with apksigner, alignment must + happen before signing. Enter any signing passwords privately in the signing + tool; never put them in chat, logs, tracked `key.properties`, or shell argv. + Do not read, export, regenerate or rotate key material as part of this prep. +4. Verify the resulting APK with apksigner and compare its signer with the + installed baseline. Keep package ID equal, and use a versionCode above the + installed version (9 is only usable if it satisfies that condition). Preserve + local unsigned artifacts, report the new signed artifact's SHA, and never + mistake source `signingConfig` for verification. +5. Only after explicit installation approval, perform a normal in-place update + with the matched signing identity. If the key/channel is unavailable or the + certificate differs, stop and report; do not silently uninstall, replace the + App identity, downgrade or create another debug key. + +The current signing config and old README wording alone cannot establish which +key signed the phone's installed App. This is the remaining compatibility fact, +not a reason to request Wi-Fi passwords or change the network. No private key +was read or used, no credential was created and no APK was installed here. diff --git a/docs/protocol/compatibility.json b/docs/protocol/compatibility.json index df173ac..bc9d5ac 100644 --- a/docs/protocol/compatibility.json +++ b/docs/protocol/compatibility.json @@ -2,9 +2,10 @@ "protocol": "SatoriEye BLE v1.2", "source": "ESP-3RDEYE/docs/ble/v1", "files": { - "satori-ble-v1.md": "13e6c166aa938bb8904dd58e30645c7dcce7678d977afa472c1075a84e1ec462", + "satori-ble-v1.md": "9cd447a2b9258c1001e62665f547c119fb5beec6846eb205547d129a2f899916", "satori_ble_v1_golden_vectors.json": "9f6a9d4b9759c9b36a7ef9c7f6aa0412f15ec8a81cd96128faec62917edbb14e", "satori_ble_v1_1_management_vectors.json": "a8d2869740b370af3e003f086e0f076c1db16ef42ef95222f1bb543493d2ad8a", - "satori_ble_v1_2_shared_pairing_vectors.json": "0550f136fb109da8769a9a32ff3dfeb25f095e85419d9961e87a7c6a8982b9a7" + "satori_ble_v1_2_shared_pairing_vectors.json": "0550f136fb109da8769a9a32ff3dfeb25f095e85419d9961e87a7c6a8982b9a7", + "diagnostics-v1.md": "dadbc2145d279a2e99e1c71e332c0a90514f8a1cdf1a745e0ad8fb496f72ad76" } } diff --git a/docs/protocol/diagnostics-v1.md b/docs/protocol/diagnostics-v1.md new file mode 100644 index 0000000..e4bcbf1 --- /dev/null +++ b/docs/protocol/diagnostics-v1.md @@ -0,0 +1,47 @@ +# Optional BLE diagnostics v1 + +Firmware 0.2.3 adds `4d89f6a0-73b9-4f14-9d3e-63b2145a0006`, an +encrypted, authenticated, bonded read-only characteristic in the existing +service. It has no write/notify/control operation and does not renew a lease. +The DeviceInfo format and all v1.2 control frames, opcodes and reserved bytes are unchanged. +Older Apps ignore the characteristic; Apps may attempt a bounded read on firmware +0.2.3 or later and continue using the existing control protocol if it is absent. + +The 20-byte value uses little-endian integers: + +| Offset | Size | Meaning | +| --- | --- | --- | +| 0 | 1 | Diagnostic schema, 1 | +| 1 | 1 | ESP-IDF `esp_reset_reason()` numeric value | +| 2 | 1 | Last recorded stop: none=0, HALT=1, RELEASE=2, link lost=3, lease expired=4, output fault=5, storage fault=6, work queue fault=7, host reset=8 | +| 3 | 1 | Latched faults: PWM=bit0, bond storage=bit1, work queue=bit2, identity storage=bit3, invalid startup configuration=bit4; bits5–7 zero | +| 4 | 4 | Uptime in seconds, modulo 2^32 | +| 8 | 4 | GAP disconnect count, saturating | +| 12 | 2 | Lease expiry count, saturating; RELEASE ACK-window expiry is excluded | +| 14 | 2 | Notification allocation/send failures, saturating | +| 16 | 2 | Last raw NimBLE GAP disconnect reason; 0 before any disconnect | +| 18 | 2 | Reserved, zero | + +Reference vector: `0103040178563412090000000200050013020000` means reset +reason 3, lease expired, PWM fault, uptime 0x12345678, 9 disconnects, 2 lease +expiries, 5 notification failures, GAP reason 0x213. Both codecs test this vector. + +Records are RAM only and reset on reboot. The record is copied under the existing +session critical section; uptime is sampled separately. This is an observation +of recent/history events, not a claim of physical pose or servo power state. +Loss of diagnostic reads must not affect motion scheduling, CLAIM/ARM or the +2-second heartbeat. App reads are bounded to 500 ms and at most once every +5 seconds; late reads from retired connections are ignored. The App retains the +last sample timestamp after read failures and labels stale data accordingly. + +Faults that prevent BLE advertising or authenticated reads are visible only in +boot/error logs, not remotely through this characteristic. No new diagnostics +log or wire field contains passkeys, tokens, credentials or bond identities. +Boot logs include reset reason, project version and four bytes of the public +application ELF digest to distinguish builds. This is not a signature check. + +Ending filming uses existing RELEASE: cancel local action/reconnect scheduling, +wait for the device's stop/release ACK, then disconnect. Without an ACK, report +local cancellation and unconfirmed device stop; existing disconnect/6-second +lease behavior remains the fallback. HALT, RELEASE, disconnect and lease expiry +do not cut servo power or switch off the entire device. No safe angle is assumed. diff --git a/docs/protocol/satori-ble-v1.md b/docs/protocol/satori-ble-v1.md index 6219c7b..c530cce 100644 --- a/docs/protocol/satori-ble-v1.md +++ b/docs/protocol/satori-ble-v1.md @@ -56,6 +56,8 @@ DeviceIdentity 是初始化时生成并持久化的 16 B 不透明 ID。不是 App 必须检查 major 和必需能力,不只判断服务名。major 不兼容时禁止运动并展示升级说明。minor 在主版本兼容前提下用于新增可选能力,不能静默重定义既有字段。 +当前配套 App 采用三段发布版本:App `0.2.x` 接受固件 `0.2.x`,补丁号不同不影响连接或已保存的 BLE 绑定;同时要求 DeviceInfo 的 BLE 协议版本为 `1.2` 且包含必需能力。检查在读取受保护特征、触发系统配对前完成。不符合时停止建立控制会话,不清除已有绑定。未来若明确支持其他协议次版本,需在 App 中扩展兼容范围并逐项验证能力。 + ## 4. 控制帧:20 字节 | Offset | 类型 | 字段 | diff --git a/docs/wifi-ota-client-design.md b/docs/wifi-ota-client-design.md new file mode 100644 index 0000000..efbb754 --- /dev/null +++ b/docs/wifi-ota-client-design.md @@ -0,0 +1,79 @@ +# 局域网配网维护与浏览器 OTA + +App 0.2.7+9 已接入设置页升级入口、独立认证 BLE 扩展、状态确认和退出流程;内存 package 预检仍不验签或传输。没有添加文件选择器、App Wi-Fi 绑定、新 Android 权限或服务,没有生成正式密钥、刷入签名 seed 或在设备开网。本地闭环模拟不等于无线部署完成。 + +## 简化操作路径(实现/部署边界) + +用户通过已有授权设备上的显式 BLE 请求主动开启限时维护窗口。窗口内设备临时 SoftAP;上传者手动进入 Wi-Fi 设置连接设备,然后用任意设备的浏览器选择并上传包。上传客户端无需 BLE 配对,也不要求 BLE 在上传期间不断线。BLE 开窗控制与浏览器上传授权/验签是不同环节;既有运动租约不得借维护窗口重新 ARM 或补播旧动作。 + +任意浏览器可上传不代表任意固件可安装:只有批准密钥签出的完整镜像通过设备原生验证后才可选为启动槽。窗口超时、取消、上传中断和失败的具体关闭/清理策略由固件维护状态机实现;本文件不启动服务。未批准的安全配置、信任根及实际部署仍需完成后再接入。 + +## IDF 原生签名信任链 + +候选采用 ESP-IDF 5.5.4 C3 原生 Secure Boot v2 RSA3072/RSA-PSS 镜像格式,并使用 signed-app verification without hardware Secure Boot。签名块位于实际 .bin 尾部,不在 JSON 中自创签名/公钥字段。IDF 取当前运行应用第一签名块公钥验证新镜像,因此必须先经受控 USB 安装已签名的初始 seed;当前未签名 0.2.4 不能直接充当信任起点。只使用首个签名块,不采用多 key 方案。 + +此模式不等于启用硬件 Secure Boot,也不抵御可直接改写 flash 的攻击者;不允许据此烧 eFuse、启用 Flash Encryption 或修改 ROM 恢复权限。正式密钥与 seed 构建/安装尚未完成,本 App 预检不具备实际 RSA 验签能力。[ESP-IDF 5.5.4 官方说明](https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32c3/security/secure-boot-v2.html#signed-app-verification-without-hardware-secure-boot) + +## 候选 .sota 容器与接口 + +容器:4 字节 little-endian 无符号 manifest JSON 字节长度(1..1024),随后严格 UTF-8 JSON,最后完整原始 .bin。长度必须精确相符,不接受尾随数据;不使用 ZIP、路径提取或 base64 镜像。`image_length` 包含镜像填充及签名块,最大 1507328 字节;SHA256 声明对应全部 .bin 字节,不是容器、JSON 或 IDF 签名块内的局部 image digest。 + +schema 1 严格只有下列字段,未知、缺失及旧 signature/key_id 字段拒绝: + +```json +{ + "schema": 1, + "board": "satori_c3_v1", + "chip": "esp32c3", + "image_length": 798448, + "sha256": "<64 lowercase hex characters>", + "version": "0.2.4", + "image_format": "esp-idf-sbv2-rsa3072" +} +``` + +示例长度仅用于结构说明,不证明该版本已签名。版本严格为无前导零的 major.minor.patch,每段十进制 0..65535;不接受后缀、空白或 CRLF 注入。manifest 本身并未签名;设备将来还必须核实际 ESP app 描述与声明,真正的来源认证以原生镜像签名为准。 + +`OtaPackageManifest.fromJson` 检查声明;`declaredSha256` 解码不可变的 32 字节;`OtaPackageContainer.decode` 解码内存容器并交出不可变镜像副本;`OtaPackagePreflight.inspect` 校验实际长度。均不读真实文件、计算摘要、解析签名块或验签。结果始终 `mayTransfer=false`、`signatureVerified=false`、`authenticityEstablished=false`。默认阻止原因为无批准信任策略;即便调用者声明策略存在,也因没有真实验签而阻止。不能把格式声明或 hash 当作可信来源证明。 + +## Android 可选集成(不是默认路径) + +未来如集成 App,API29+ `WifiNetworkSpecifier` 可以请求局域网连接,首次通常需系统对话授权;它不提供互联网,secondary STA 要求设备支持和 target API31+,无法保证保留原 Wi-Fi 或跨 OEM 一致。失败、拒绝、取消须处理并释放 callback。[网络请求指南](https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap)、[WifiNetworkSpecifier API](https://developer.android.com/reference/android/net/wifi/WifiNetworkSpecifier) + +App 传输若集成,应通过专用 `Network.openConnection`/socketFactory 绑定请求;不把整个进程绑到维护网络。网络路由不替代信任验证,也不保证 BLE/Wi-Fi 射频共存稳定。[Network API](https://developer.android.com/reference/android/net/Network) + +Android13+ 管理 Wi-Fi 需评估 `NEARBY_WIFI_DEVICES` runtime permission 和 `neverForLocation`;旧版可能需 fine location,扫描类 API 有额外位置要求。还应逐 API 确认 INTERNET/网络状态/更改网络/Wi-Fi 权限,本轮未添加。附近设备权限与 BLE 同组,撤销后须处理。[Wi-Fi 权限指南](https://developer.android.com/develop/connectivity/wifi/wifi-permissions) + +同手机开热点并连设备 AP 依赖 STA/AP 硬件与 OEM 并发支持,不能作普遍替代路径;路由和同频吞吐须实测。[AOSP 并发说明](https://source.android.com/docs/core/connect/wifi-sta-ap-concurrency) + +未来本地选包推荐 SAF `ACTION_OPEN_DOCUMENT` + `CATEGORY_OPENABLE` 单文件 URI,不申请全部存储访问;限制读取长度、暂存 App 私有目录。SAF 可含云提供者,纯离线要选择已下载文件并处理 URI 不可读。[SAF 官方指南](https://developer.android.com/training/data-storage/shared/documents-files) + +## 本地验证与后续边界 + +八项测试涵盖摘要声明、无验签阻止、错误目标/格式/旧字段、槽与文件长度边界、版本/注入、长度前缀容器、畸形 UTF-8/JSON、截断/尾随数据、不可变副本。这不代表签名真实性或真实 OTA 测试。后续需验正确/错误 RSA key、签名损坏/无签名、seed 信任链、错误 app 描述、窗口中断/超时、浏览器/OEM 行为及回滚;设备与正式密钥动作另按批准范围执行。 + +## App 开窗与退出闭环 + +设置页区分旧固件不支持、扩展存在但签名升级未就绪、真实开窗连接资料和未知状态。开启之前撤销自动动作和自动 ARM 意图,等待既有 HALT 确认。独立 UUID `4d89f6a0-73b9-4f14-9d3e-63b2145a0007` 为 encrypted/authenticated READ+WRITE;不改 DeviceInfo 能力或 Control v1.2。命令与控制写共用串行队列;维护中清本机 CLAIM,停旧心跳/控制状态轮询。 + +请求10字节:v1/action(open1,close2)/requestId LE32/windowId LE32。requestId非零;open windowId=0;close windowId非零且匹配当前窗口。状态18字节头+SSID/password ASCII:v1、state0..6、result0..6、reserved0、ACK requestId LE32、windowId LE32、remainingMs LE32、SSID长度、密码长度。SSID≤32、密码≤64。state closed0/opening1/open2/uploading3/closing4/committed5/failed6;result ok0/busy1/signing-not-ready2/invalid3/not-ready4/stale-window5/internal6。 + +写成功不算ACK。App仅匹配requestId/result0/目标最终状态才确认命令;丢失回执同id同字节有界重试,不新开/延长窗口。剩余时限来自设备读回,不凭本机倒计时宣称关闭。committed只称镜像已提交/待重启验证,不能取消冒充撤销或称升级完成。临时SSID/password仅有效UI状态使用,不日志/持久化;读取失败或掉线清除。 + +开窗首次await之前锁住自动恢复,也覆盖HALT中途掉线。维护断线不自动连接/ARM;用户可点“重新连接确认窗口状态”。认证连接先读维护状态,已有窗口跳过CLAIM/ARM,仅管理窗口;浏览器上传不依赖此连接。Closed真实确认后可“退出升级并重新连接”:断开、fresh CLAIM、保持暂停。禁自动ARM意图跨后续断线保留,直到用户明确启用控制成功;普通拍摄原有断线恢复规则保留。 + +当前固件版本来自fresh DeviceInfo,未获得目标版本和启动验证不声称升级成功。可变长度BLE长读与OEM实机行为尚未测,部署时需验证。新增维护测试覆盖HALT断线、旧固件/未ready、丢ACK与超时、重复请求、迟到状态、提交拒取消、断线和freshCLAIM退出;UI测试覆盖连接资料与未知状态恢复入口。它们不代表设备网络或正式签名测试。 + +## LAN 主路径(本地实现,部署仍需实测) + +设置页优先“连接网络并开启维护”:用户每窗输入 2.4GHz WPA2 个人网络 SSID 和密码,密码遮蔽显示、关闭输入法学习、提交即清空输入框。配置仅传入设备本次维护 RAM,不保存手机偏好或设备持久存储,结束清除;没有保存选项、扫描或自动复用旧凭据。电脑保留原网络,与设备同局域网,直接打开 BLE 实读的 IPv4 地址;页面填写窗口令牌,上传完整签名 .sota 包。隔离网络/访客网络可能无法访问,备用 AP 入口仍需明确开启。沿用旧 IP 直连概念,不恢复 UDP 控制或承诺发现扫描。 + +独立认证 read/write UUID `4d89f6a0-73b9-4f14-9d3e-63b2145a0008`,不修改旧 DeviceInfo、Control v1.2 或能力位;缺少扩展时明确不支持并不发送配置。命令12字节头:schema1/action(openLAN1,close2)、requestId LE32、windowId LE32、SSID长度、密码长度,后接 UTF-8 SSID1..32字节及ASCII密码8..63字节。open windowId0;close非零当前windowId、两个长度0。拒绝open/WEP/64位hex密码。本地Android写前请求MTU256,未满足整个命令长度则停止,不分段冒充原子配置。 + +状态24字节头:schema/state/result/detail、ACK requestId LE32、windowId LE32、remainingMs LE32、IPv4四个网络序字节、token长度、3个零reserved,后接令牌。state/result与AP相同;detail none0/invalid-config1/connect-timeout2/wifi-error3/lost-link4。仅Open/Uploading携带32位lowerhex令牌;连接中、失败及关闭不携带。Ready须真实设备Open、有效IP/windowId/令牌;写入或连接中不算联网。窗口120秒包含最多20秒连接阶段。open/close设备确认等待26秒,客户端45秒;连接中关闭可能等待有界连接结束,只有真实Closed才能退出。 + +电脑地址不含令牌;上传使用 `X-Satori-Window` header。令牌不进入公共snapshot、偏好、日志或异常,仅匹配客户端和当前窗口的私有RAM响应/UI使用,断线/窗口变化清除。两个维护路径互斥,另一窗口活动或状态未知时不允许切路径,防止APClosed掩盖LAN仍开启。真实Closed后freshCLAIM仍暂停,不自动ARM。 + +本地模拟覆盖配置字节界限、畸形状态、旧固件、真实Ready/连接失败、MTU拒绝、两路径互斥、凭据快照隔离和暂停退出。未进行真实BLE长写/读、局域网连通、浏览器上传或正式签名部署;本轮未新增Android权限、插件或网络服务。 + +扩展发现使用当前连接GATT列表:仅明确缺失UUID视为不支持;读取超时、断线或格式失败视为未知。连接阶段任一维护扩展未知均进入暂停维护,不发送CLAIM/ARM,也不能开、关或退出冒充另一Closed路径。界面提供重新连接确认,重新发现并读取真实状态后再管理窗口;不依据固件patch猜能力。SSID与固件同样拒绝ASCII控制字节及DEL,保留其余UTF-8原始字节。 diff --git a/flutter_app/README.md b/flutter_app/README.md index fd6bc69..18f3246 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -10,10 +10,10 @@ 每次连接及重连都会自动ARM,并读取设备快照初始化目标;不会恢复上一次的预设或自动动作。默认范围和启动姿态已内置,普通用户无需填写。App沿用Qt的三通道逻辑输入500–2500,固件首次ARM使用[1500,1500,1500]并继续应用原有机械标定与限位;这些是产品默认参数,真机验收仍待完成。已有自定义范围/维护启动配置优先,损坏配置仍拒绝输出。自定义范围放在设备页折叠的“高级维护”中。用户在重连等待或建立期间暂停会撤销整轮恢复的自动启动,后续重试只恢复连接;显式新连接或之后独立发生的新断线恢复仍会自动启动。 -交付安装包使用 `flutter build apk --release --split-per-abi`,ARM64 手机安装 `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk`;32 位 ARM 设备使用对应 `armeabi-v7a` 包。Debug 通用包包含多架构调试引擎,只用于开发,不作为日常安装包。当前 release 使用开发签名,正式发布签名尚未配置。 +交付安装包使用 `flutter build apk --release --split-per-abi`,ARM64 手机安装 `build/app/outputs/flutter-apk/app-arm64-v8a-release.apk`;32 位 ARM 设备使用对应 `armeabi-v7a` 包。Debug 通用包包含多架构调试引擎,只用于开发,不作为日常安装包。Gradle 模板指向 debug 签名,但最新已验证的本地 release APK 实际未签名,不能直接安装;正式签名尚未配置。覆盖安装须复用原 App 的签名身份,见 [签名兼容方案](../docs/apk-signing-compatibility.md)。 ## 开发 在 `flutter_app/` 运行 `flutter pub get`、`flutter analyze`、`flutter test`。Android 调试构建使用 `flutter build apk --debug`;按架构构建安装包使用 `flutter build apk --release --split-per-abi`。保留的 UDP 编解码、模拟器和回归工具仅用于协议测试。 -当前安装包使用开发签名。机械运动、通知操作和长时间后台行为需要在目标设备上单独验收。 +当前本地交付 APK 实查未签名;不能据构建配置声称已签名或可覆盖安装。机械运动、通知操作和长时间后台行为需要在目标设备上单独验收。 diff --git a/flutter_app/lib/core/ble_compatibility.dart b/flutter_app/lib/core/ble_compatibility.dart index 044c46d..3479717 100644 --- a/flutter_app/lib/core/ble_compatibility.dart +++ b/flutter_app/lib/core/ble_compatibility.dart @@ -3,7 +3,7 @@ import 'ble_protocol.dart'; /// App and firmware releases use the same major/minor compatibility line. /// Patch releases may differ without changing pairing or the BLE contract. class BleCompatibility { - static const appVersion = '0.2.4'; + static const appVersion = '0.2.9'; static const appMajor = 0; static const appMinor = 2; static const protocolMajor = 1; diff --git a/flutter_app/lib/core/ble_diagnostics.dart b/flutter_app/lib/core/ble_diagnostics.dart new file mode 100644 index 0000000..82515f9 --- /dev/null +++ b/flutter_app/lib/core/ble_diagnostics.dart @@ -0,0 +1,55 @@ +/// Optional protected read-only diagnostics. No control or power commands. +class BleDiagnostics { + static const uuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0006'; + const BleDiagnostics({ + required this.resetReason, + required this.lastStop, + required this.faults, + required this.uptimeSeconds, + required this.disconnectCount, + required this.leaseExpiryCount, + required this.notificationFailureCount, + required this.lastGapReason, + }); + factory BleDiagnostics.decode(List bytes) { + if (bytes.length != 20 || + bytes.any((x) => x < 0 || x > 255) || + bytes[0] != 1 || + bytes[2] > 8 || + (bytes[3] & ~31) != 0 || + bytes[18] != 0 || + bytes[19] != 0) { + throw const FormatException('Invalid diagnostics'); + } + int u16(int i) => bytes[i] | (bytes[i + 1] << 8); + int u32(int i) => u16(i) | (u16(i + 2) << 16); + return BleDiagnostics( + resetReason: bytes[1], + lastStop: bytes[2], + faults: bytes[3], + uptimeSeconds: u32(4), + disconnectCount: u32(8), + leaseExpiryCount: u16(12), + notificationFailureCount: u16(14), + lastGapReason: u16(16), + ); + } + final int resetReason, + lastStop, + faults, + uptimeSeconds, + disconnectCount, + leaseExpiryCount, + notificationFailureCount, + lastGapReason; + Map toJson() => { + 'resetReason': resetReason, + 'lastStop': lastStop, + 'faults': faults, + 'uptimeSeconds': uptimeSeconds, + 'disconnectCount': disconnectCount, + 'leaseExpiryCount': leaseExpiryCount, + 'notificationFailureCount': notificationFailureCount, + 'lastGapReason': lastGapReason, + }; +} diff --git a/flutter_app/lib/core/control_engine.dart b/flutter_app/lib/core/control_engine.dart index 7f0ddc3..db7dde9 100644 --- a/flutter_app/lib/core/control_engine.dart +++ b/flutter_app/lib/core/control_engine.dart @@ -44,7 +44,12 @@ class ControlEngine { String? incompatibleFirmware; String? incompatibleProtocol; bool pauseInProgress = false; + bool otaMaintenance = false; + bool otaBusy = false; + bool _otaResumeBlocked = false; + String? otaNotice; bool explicitlyPaused = false; + String? endState; String? deviceId; String? _identity; SafetyLimits? safety; @@ -63,6 +68,7 @@ class ControlEngine { Timer? _frame; Timer? _reconnect; int _reconnectAttempt = 0; + Future? _disconnecting; Map snapshot() { final s = session.snapshot; @@ -81,6 +87,16 @@ class ControlEngine { ((s.deviceInfo?.capabilities ?? 0) & 0x80) != 0, 'supportsSharedPairing': s.deviceInfo?.supportsSharedPairing ?? false, 'pairingNotice': pairingNotice, + 'supportsSavedNetwork': s.isConnected && session.supportsSavedNetwork, + 'hasSavedNetwork': s.isConnected && session.hasSavedNetwork, + 'lanSupported': s.isConnected ? session.lanSupported : null, + 'lanWindow': s.isConnected ? session.lanWindow?.toUiJson() : null, + 'maintenancePath': session.maintenancePath, + 'otaSupported': s.isConnected ? session.otaSupported : null, + 'otaWindow': s.isConnected ? session.otaWindow?.toUiJson() : null, + 'otaMaintenance': otaMaintenance, + 'otaBusy': otaBusy, + 'otaNotice': otaNotice, 'maxTargetHz': s.deviceInfo?.maxTargetHz, 'mode': mode, 'playback': playback, @@ -94,6 +110,9 @@ class ControlEngine { 'lastAckSequence': s.lastAckSequence, 'lastAckAt': s.lastAckAt?.toIso8601String(), 'battery': s.state?.batteryPercent, + 'diagnostics': s.diagnostics?.toJson(), + 'diagnosticsAt': s.diagnosticsAt?.toIso8601String(), + 'endState': endState, 'safety': safety?.toJson(), 'error': error ?? s.lastError, 'issueCode': issueCode, @@ -132,13 +151,20 @@ class ControlEngine { _cancelMotion(); outputAuthorized = false; target = null; + if (otaMaintenance) { + connection = 'disconnected'; + otaNotice = '蓝牙已断开,窗口状态未知;浏览器上传不依赖蓝牙。请等窗口自动超时。'; + _autoStartPending = false; + _notify(); + return; + } connection = 'reconnecting'; error = '连接中断,动作已取消'; _reconnectAttempt = 0; // A disconnect creates one recovery auto-start intent. Every retry in // this backoff round shares it, so a pause can revoke it permanently. final autoStartGeneration = ++_autoStartGeneration; - _autoStartPending = true; + _autoStartPending = !_otaResumeBlocked; _scheduleReconnect(_connectionGeneration, autoStartGeneration); } _notify(); @@ -147,6 +173,7 @@ class ControlEngine { Future connect( String id, { SafetyLimits? limits, + bool autoStart = true, String? expectedIdentity, Future Function(String identity)? safetyForIdentity, }) async { @@ -155,14 +182,18 @@ class ControlEngine { connection == 'reconnecting') { throw StateError('请先结束当前连接'); } + autoStart = autoStart && !_otaResumeBlocked; + endState = null; + otaNotice = null; + otaMaintenance = false; _ending = false; _reconnect?.cancel(); final generation = ++_connectionGeneration; final autoStartGeneration = ++_autoStartGeneration; - _autoStartPending = true; + _autoStartPending = autoStart; _cancelMotion(); outputAuthorized = false; - explicitlyPaused = false; + explicitlyPaused = !autoStart; target = null; safety = limits ?? SafetyLimits.builtInSatoriC3; deviceId = id; @@ -189,6 +220,12 @@ class ControlEngine { } connection = 'connected'; _identity = authenticatedIdentity; + if (session.maintenanceMode) { + otaMaintenance = true; + _otaResumeBlocked = true; + _autoStartPending = false; + explicitlyPaused = true; + } await _tryAutoStart(autoStartGeneration); } catch (e) { if (generation == _connectionGeneration) { @@ -224,7 +261,11 @@ class ControlEngine { } Future _tryAutoStart(int generation) async { - if (generation != _autoStartGeneration || !_autoStartPending) return; + if (generation != _autoStartGeneration || + !_autoStartPending || + _otaResumeBlocked) { + return; + } if (connection != 'connected') return; safety ??= SafetyLimits.builtInSatoriC3; _autoStartPending = false; @@ -274,9 +315,11 @@ class ControlEngine { Future arm() async { if (outputAuthorized) return; await _arm(_autoStartGeneration, autoStart: false); + _otaResumeBlocked = false; } Future _arm(int autoStartGeneration, {required bool autoStart}) async { + if (otaMaintenance || otaBusy) throw StateError('请先确认关闭并退出升级模式'); if (_pairingOperation) throw StateError('配对设置处理中,请稍候'); if (connection != 'connected') throw StateError('设备尚未就绪'); safety ??= SafetyLimits.builtInSatoriC3; @@ -315,7 +358,9 @@ class ControlEngine { } void _requireControl() { - if (_pairingOperation || + if (otaMaintenance || + otaBusy || + _pairingOperation || connection != 'connected' || !outputAuthorized || target == null || @@ -561,7 +606,7 @@ class ControlEngine { if (autoStartGeneration == _autoStartGeneration) { // A failed attempt may not have reached ARM. Keep the intent for // the next retry unless pause has advanced the generation. - _autoStartPending = true; + _autoStartPending = !_otaResumeBlocked; } _scheduleReconnect(generation, autoStartGeneration); } @@ -671,7 +716,137 @@ class ControlEngine { } } - Future disconnect() async { + Future openOtaWindow({ + bool lan = false, + String ssid = '', + String password = '', + bool useSavedNetwork = false, + bool rememberNetwork = false, + void Function()? ensureCurrentClient, + }) async { + if (otaBusy) throw StateError('升级操作正在进行'); + if ((lan ? session.lanSupported : session.otaSupported) != true || + connection != 'connected') { + throw StateError('当前固件不支持无线升级窗口'); + } + if ((useSavedNetwork || rememberNetwork) && + (!lan || !session.supportsSavedNetwork)) { + throw StateError('固件不支持已保存网络'); + } + otaBusy = true; + _otaResumeBlocked = true; + otaNotice = null; + final wasMaintenance = otaMaintenance; + otaMaintenance = true; // Also protect a disconnect while HALT is pending. + _autoStartPending = false; + ++_autoStartGeneration; + _reconnect?.cancel(); + try { + if (!wasMaintenance) await stopMotion(); + _autoStartPending = false; + ++_autoStartGeneration; + _reconnect?.cancel(); + ensureCurrentClient?.call(); + if (lan) { + await session.changeLanWindow( + true, + ssid: ssid, + password: password, + useSavedNetwork: useSavedNetwork, + rememberNetwork: rememberNetwork, + ); + } else { + await session.changeOtaWindow(true); + } + otaNotice = lan + ? '设备已确认连接局域网并开启限时维护;电脑可直接打开设备地址。' + : '备用热点窗口已由设备确认开启;按下面指引在浏览器上传签名升级包。'; + } catch (_) { + otaNotice = '开启未确认;请查看设备状态。不要把写入成功当作已开窗。'; + rethrow; + } finally { + otaBusy = false; + _notify(); + } + } + + Future closeOtaWindow({void Function()? ensureCurrentClient}) async { + if (otaBusy) throw StateError('升级操作正在进行'); + otaBusy = true; + final wasMaintenance = otaMaintenance; + otaMaintenance = true; + _otaResumeBlocked = true; + try { + if (!wasMaintenance) await stopMotion(); + ensureCurrentClient?.call(); + if (session.maintenancePath == 'lan') { + await session.changeLanWindow(false); + } else { + await session.changeOtaWindow(false); + } + otaNotice = '设备已确认窗口关闭;控制仍暂停。退出升级后可重新连接。'; + } catch (_) { + otaNotice = session.activeMaintenanceWindow?.isCommitted == true + ? '镜像已提交,不能取消或声称撤销;等待设备重启。' + : '关闭未确认;窗口可能仍有效,请等待自动超时。'; + rethrow; + } finally { + otaBusy = false; + _notify(); + } + } + + Future reconnectOtaMaintenance() async { + final unknown = session.maintenancePath == 'unknown'; + if (otaBusy || + (!unknown && connection != 'disconnected' && connection != 'failed')) { + throw StateError('当前不能重新连接'); + } + final id = deviceId; + if (id == null || !_otaResumeBlocked) throw StateError('没有可恢复的升级会话'); + if (unknown) { + await session.disconnect(); + connection = 'disconnected'; + } + await connect( + id, + expectedIdentity: _identity, + limits: safety, + autoStart: false, + ); + otaNotice = session.activeMaintenanceWindow == null + ? '已连接,但升级窗口状态未确认;控制保持暂停。' + : '已从设备重新读取窗口状态;控制保持暂停。'; + _notify(); + } + + Future exitOtaMaintenance() async { + if (otaBusy) throw StateError('升级操作正在进行'); + if (connection != 'connected' || + session.activeMaintenanceWindow?.isClosed != true) { + throw StateError('必须先由设备确认窗口关闭;蓝牙断开时请等待超时后重新连接'); + } + final id = deviceId, identity = _identity, limits = safety; + if (id == null) throw StateError('设备身份未知'); + _ending = true; + await session.disconnect(); + connection = 'disconnected'; + await connect( + id, + expectedIdentity: identity, + limits: limits, + autoStart: false, + ); + otaNotice = '已退出升级并建立新会话;保持暂停,需手动启用控制。'; + _notify(); + } + + Future disconnect() => _disconnecting ??= _disconnect().whenComplete( + () => _disconnecting = null, + ); + + Future _disconnect() async { + if (_ending && connection == 'disconnected' && endState != null) return; _ending = true; ++_connectionGeneration; ++_autoStartGeneration; @@ -680,12 +855,28 @@ class ControlEngine { _reconnect?.cancel(); _cancelMotion(); outputAuthorized = false; + explicitlyPaused = true; + final hadSession = + session.snapshot.isConnected && session.snapshot.token != 0; + endState = 'ending'; + error = null; + issueCode = null; + _notify(); try { await session.release(); - } catch (e) { - error = '断开确认未收到:$e'; + endState = hadSession ? 'confirmed' : 'localOnly'; + } catch (_) { + endState = 'unconfirmed'; + error = '本机动作已取消,设备停止未确认;设备按原有断线/租约规则处理。'; + issueCode = 'releaseUnconfirmed'; + } + try { + await session.disconnect(); + } catch (_) { + endState = 'unconfirmed'; + error = '本机动作已取消,断开未确认;请检查连接和设备状态。'; + issueCode = 'releaseUnconfirmed'; } - await session.disconnect(); connection = 'disconnected'; target = null; _notify(); diff --git a/flutter_app/lib/core/control_status.dart b/flutter_app/lib/core/control_status.dart index 9041c01..fa4e382 100644 --- a/flutter_app/lib/core/control_status.dart +++ b/flutter_app/lib/core/control_status.dart @@ -1,5 +1,15 @@ /// User-facing control state derived only from structured snapshot fields. String controlStatus(Map state) { + switch (state['endState']) { + case 'ending': + return '正在结束拍摄…'; + case 'confirmed': + return '拍摄已结束,控制已释放'; + case 'unconfirmed': + return '拍摄已结束,设备停止未确认'; + case 'localOnly': + return '本机拍摄已结束'; + } switch (state['connection']) { case 'connected': return switch (state['controlPhase']) { @@ -33,7 +43,39 @@ String controlStatus(Map state) { } bool needsControlAttention(Map state) => + state['endState'] == 'unconfirmed' || state['controlPhase'] == 'pauseUnconfirmed' || state['controlPhase'] == 'configurationError' || state['controlPhase'] == 'outputUnconfirmed' || state['controlPhase'] == 'actionFailed'; + +String diagnosticStopReason(int? reason) => switch (reason) { + 0 => '本次启动尚无停止记录', + 1 => '收到暂停操作', + 2 => '收到结束/释放控制操作', + 3 => '蓝牙连接中断', + 4 => '控制心跳超时', + 5 => '舵机输出驱动异常', + 6 => '配对存储异常', + 7 => '控制队列异常', + 8 => '蓝牙服务重置', + _ => '未知', +}; + +String diagnosticFaults(int flags) { + final faults = [ + if (flags & 1 != 0) '舵机输出驱动异常', + if (flags & 2 != 0) '配对存储异常', + if (flags & 4 != 0) '控制队列异常', + if (flags & 8 != 0) '设备身份存储异常', + if (flags & 16 != 0) '启动姿态配置无效', + ]; + return faults.isEmpty ? '未记录故障' : faults.join('、'); +} + +bool diagnosticsAreFresh(Map state, {DateTime? now}) { + final sampled = DateTime.tryParse(state['diagnosticsAt']?.toString() ?? ''); + if (sampled == null || state['connection'] != 'connected') return false; + final age = (now ?? DateTime.now()).difference(sampled); + return age >= Duration.zero && age <= const Duration(seconds: 15); +} diff --git a/flutter_app/lib/core/device_session.dart b/flutter_app/lib/core/device_session.dart index 5a9a9e9..dd62845 100644 --- a/flutter_app/lib/core/device_session.dart +++ b/flutter_app/lib/core/device_session.dart @@ -2,11 +2,18 @@ import 'dart:async'; import 'ble_protocol.dart'; import 'ble_compatibility.dart'; +import 'ble_diagnostics.dart'; +import 'ota_window.dart'; +import 'lan_window.dart'; enum BleLinkState { disconnected, connecting, connected } /// Small transport contract. Scanning, pairing and platform GATT setup live in /// the concrete adapter; this class owns the protocol and its single writer. +class BleCharacteristicAbsent implements Exception { + const BleCharacteristicAbsent(); +} + abstract class BleLink { Stream get connectionState; Future connect(String id); @@ -22,6 +29,7 @@ enum DeviceSessionPhase { reading, claiming, readyPaused, + maintenance, armed, error, } @@ -37,6 +45,8 @@ class DeviceSessionSnapshot { this.lastAckSequence, this.lastAckAt, this.lastError, + this.diagnostics, + this.diagnosticsAt, }); final DeviceSessionPhase phase; final String? identity; @@ -46,9 +56,12 @@ class DeviceSessionSnapshot { final int? lastAckSequence; final DateTime? lastAckAt; final String? lastError; + final BleDiagnostics? diagnostics; + final DateTime? diagnosticsAt; bool get isConnected => phase == DeviceSessionPhase.readyPaused || - phase == DeviceSessionPhase.armed; + phase == DeviceSessionPhase.armed || + phase == DeviceSessionPhase.maintenance; bool get isArmed => phase == DeviceSessionPhase.armed; bool get targetKnown => isArmed && (state?.targetKnown ?? false); } @@ -129,8 +142,264 @@ class DeviceSession { int _targetGeneration = 0; Duration? _lastTargetSentAt; Timer? _stateRefresh; + Timer? _diagnosticRefresh; + bool _diagnosticReadInProgress = false; bool _stateReadInProgress = false; bool _releaseInProgress = false; + bool maintenanceMode = false; + bool? otaSupported; + OtaWindowStatus? otaWindow; + bool? lanSupported; + bool supportsSavedNetwork = false, hasSavedNetwork = false; + static const savedNetworkUuid = "4d89f6a0-73b9-4f14-9d3e-63b2145a0009"; + Future refreshSavedNetwork() async { + final generation = _generation; + try { + final b = await link.read(savedNetworkUuid).timeout(commandTimeout); + _checkGeneration(generation); + if (b.length != 4 || b[0] != 1 || b[1] != 3 || b[2] > 1 || b[3] != 0) { + throw const FormatException("Invalid optional network feature"); + } + supportsSavedNetwork = true; + hasSavedNetwork = b[2] == 1; + } catch (_) { + if (generation == _generation) { + supportsSavedNetwork = false; + hasSavedNetwork = false; + } + } + if (generation == _generation) _emit(_copy()); + } + + LanWindowStatus? lanWindow; + String? maintenancePath; + OtaWindowStatus? get activeMaintenanceWindow => maintenancePath == 'unknown' + ? null + : maintenancePath == 'lan' + ? lanWindow + : otaWindow; + int? _lanReadGeneration; + Timer? _lanRefresh; + + Future refreshLanWindow({bool probe = false}) async { + final generation = _generation; + if (_lanReadGeneration == generation || + (!_snapshot.isConnected && !probe) || + _disposed) { + return; + } + _lanReadGeneration = generation; + try { + final raw = await link + .read(LanWindowStatus.lanUuid) + .timeout(commandTimeout); + _checkGeneration(generation); + lanWindow = LanWindowStatus.decode(raw); + lanSupported = true; + _emit(_copy()); + } catch (error) { + if (generation == _generation) { + if (probe) { + lanSupported = error is BleCharacteristicAbsent ? false : null; + } + lanWindow = null; + _emit(_copy()); + } + } finally { + if (_lanReadGeneration == generation) _lanReadGeneration = null; + } + } + + Future changeLanWindow( + bool open, { + String ssid = '', + String password = '', + bool useSavedNetwork = false, + bool rememberNetwork = false, + }) { + if (_otaOperation != null) return Future.error(StateError('升级操作正在进行')); + if (useSavedNetwork && supportsSavedNetwork != true) { + return Future.error(StateError('当前固件不支持使用已保存网络')); + } + if (rememberNetwork && !supportsSavedNetwork) { + return Future.error(StateError("固件不支持记住网络")); + } + // Validate locally without mutating the session or sending credentials. + LanWindowStatus.lanRequest( + open: open, + requestId: 1, + windowId: open ? 0 : (lanWindow?.windowId ?? 0), + ssid: ssid, + password: password, + useSavedNetwork: useSavedNetwork, + rememberNetwork: rememberNetwork, + ); + return _otaOperation = + _changeOtaWindow( + open, + lan: true, + ssid: ssid, + password: password, + useSavedNetwork: useSavedNetwork, + rememberNetwork: rememberNetwork, + ) + .whenComplete(() => refreshSavedNetwork()) + .whenComplete(() => _otaOperation = null); + } + + Timer? _otaRefresh; + int? _otaReadGeneration; + int _otaRequestId = DateTime.now().microsecondsSinceEpoch & 0xffffffff; + Future? _otaOperation; + + Future refreshOtaWindow({bool probe = false}) async { + final generation = _generation; + if (_otaReadGeneration == generation || + (!_snapshot.isConnected && !probe) || + _disposed) { + return; + } + _otaReadGeneration = generation; + try { + final raw = await link.read(OtaWindowStatus.uuid).timeout(commandTimeout); + _checkGeneration(generation); + otaWindow = OtaWindowStatus.decode(raw); + otaSupported = true; + _emit(_copy()); + } catch (error) { + if (generation == _generation) { + if (probe) { + otaSupported = error is BleCharacteristicAbsent ? false : null; + } + otaWindow = + null; // A failed read never leaves credentials/status fresh. + _emit(_copy()); + } + } finally { + if (_otaReadGeneration == generation) _otaReadGeneration = null; + } + } + + Future changeOtaWindow(bool open) { + if (_otaOperation != null) return Future.error(StateError('升级操作正在进行')); + return _otaOperation = _changeOtaWindow( + open, + ).whenComplete(() => _otaOperation = null); + } + + Future _changeOtaWindow( + bool open, { + bool lan = false, + String ssid = '', + String password = '', + bool useSavedNetwork = false, + bool rememberNetwork = false, + }) async { + if (maintenancePath == 'unknown') { + throw StateError('维护状态未确认,请重新连接确认'); + } + final current = lan ? lanWindow : otaWindow; + final other = lan ? otaWindow : lanWindow; + if (open && + (other != null && !other.isClosed || + maintenanceMode && + maintenancePath != null && + maintenancePath != (lan ? 'lan' : 'ap'))) { + throw StateError('请先确认并关闭当前维护窗口'); + } + if ((lan ? lanSupported : otaSupported) != true || !_snapshot.isConnected) { + throw StateError('当前固件或连接不支持无线升级'); + } + if (open && current?.signingReady != true) { + throw StateError('固件签名升级尚未就绪'); + } + if (!open && current?.isCommitted == true) { + throw StateError('镜像已提交,不能声称取消升级'); + } + if (open && maintenanceMode && current?.isOpen == true) return; + final window = open ? 0 : current?.windowId; + if (window == null || (!open && window == 0 && current?.isClosed != true)) { + throw StateError('窗口状态未知,无法确认关闭'); + } + final generation = _generation; + maintenanceMode = true; + maintenancePath = lan ? 'lan' : 'ap'; + cancelPendingTargets(); + _keepalive?.cancel(); + _stateRefresh?.cancel(); + _diagnosticRefresh?.cancel(); + _token = 0; + _emit(_copy(phase: DeviceSessionPhase.maintenance, token: 0)); + _otaRequestId = (_otaRequestId + 1) & 0xffffffff; + if (_otaRequestId == 0) _otaRequestId = 1; + final id = _otaRequestId; + final bytes = lan + ? LanWindowStatus.lanRequest( + open: open, + requestId: id, + windowId: window, + ssid: ssid, + password: password, + useSavedNetwork: useSavedNetwork, + rememberNetwork: rememberNetwork, + ) + : OtaWindowStatus.request(open: open, requestId: id, windowId: window); + final uuid = lan ? LanWindowStatus.lanUuid : OtaWindowStatus.uuid; + final done = Completer(); + _writeTail = _writeTail.catchError((Object _) {}).then((_) async { + try { + if (bytes.length > 20 && link is BleLargeWriteLink) { + await (link as BleLargeWriteLink) + .prepareLargeWrite(bytes.length) + .timeout(const Duration(seconds: 3)); + _checkGeneration(generation); + } + final deadline = DateTime.now().add(Duration(seconds: lan ? 26 : 12)); + var acknowledged = false; + for (var attempt = 0; attempt < 4; attempt++) { + _checkGeneration(generation); + if (!_snapshot.isConnected) throw StateError('升级蓝牙连接已断开'); + // A timeout can mean accepted: reconcile via status before retrying. + try { + if (!acknowledged) { + await link.write(uuid, bytes).timeout(commandTimeout); + } + } catch (_) { + _checkGeneration(generation); + } + for (var poll = 0; poll < (lan ? 140 : 4); poll++) { + if (DateTime.now().isAfter(deadline)) break; + if (lan) { + await refreshLanWindow(); + } else { + await refreshOtaWindow(); + } + _checkGeneration(generation); + final status = lan ? lanWindow : otaWindow; + if (status != null && status.ackRequestId == id) { + acknowledged = true; + if (status.result != 0) { + throw StateError('升级请求被设备拒绝(代码 ${status.result})'); + } + if (open ? status.isOpen : status.isClosed) { + if (!done.isCompleted) done.complete(); + return; + } + if (status.isCommitted || status.state == 6) { + throw StateError('设备未完成请求的窗口操作'); + } + } + await Future.delayed(const Duration(milliseconds: 150)); + if (lan && !acknowledged && poll >= 3) break; + } + } + throw StateError(open ? '开启未确认;设备可能已开窗,请查看窗口状态' : '关闭未确认;设备窗口可能仍有效'); + } catch (error, stack) { + if (!done.isCompleted) done.completeError(error, stack); + } + }); + return done.future; + } void _emit(DeviceSessionSnapshot next) { if (_disposed) return; @@ -148,6 +417,8 @@ class DeviceSession { int? lastAckSequence, DateTime? lastAckAt, String? lastError, + BleDiagnostics? diagnostics, + DateTime? diagnosticsAt, }) => DeviceSessionSnapshot( phase: phase ?? _snapshot.phase, identity: identity ?? _snapshot.identity, @@ -158,10 +429,20 @@ class DeviceSession { lastAckSequence: lastAckSequence ?? _snapshot.lastAckSequence, lastAckAt: lastAckAt ?? _snapshot.lastAckAt, lastError: lastError, + diagnostics: diagnostics ?? _snapshot.diagnostics, + diagnosticsAt: diagnosticsAt ?? _snapshot.diagnosticsAt, ); Future connect(String id, {String? expectedIdentity}) async { final generation = ++_generation; + maintenanceMode = false; + otaSupported = null; + otaWindow = null; + lanWindow = null; + lanSupported = null; + maintenancePath = null; + _lanRefresh?.cancel(); + _otaRefresh?.cancel(); _clearPending(); _token = 0; _nextSequence = 1; @@ -219,6 +500,39 @@ class DeviceSession { ); _emit(_copy(identity: identity, deviceInfo: info)); _emit(_copy(phase: DeviceSessionPhase.claiming)); + await refreshSavedNetwork(); + _checkGeneration(generation); + await refreshLanWindow(probe: true); + _checkGeneration(generation); + if (lanSupported == true) { + _lanRefresh = Timer.periodic( + const Duration(seconds: 1), + (_) => refreshLanWindow(), + ); + } + await refreshOtaWindow(probe: true); + _checkGeneration(generation); + if (otaSupported == true) { + _otaRefresh = Timer.periodic( + const Duration(seconds: 1), + (_) => refreshOtaWindow(), + ); + } + if (lanSupported == null || otaSupported == null) { + maintenancePath = 'unknown'; + maintenanceMode = true; + _emit(_copy(phase: DeviceSessionPhase.maintenance, token: 0)); + return; // Unknown optional reads must never grant control. + } + if ((lanWindow != null && !lanWindow!.isClosed) || + (otaWindow != null && !otaWindow!.isClosed)) { + maintenancePath = lanWindow != null && !lanWindow!.isClosed + ? 'lan' + : 'ap'; + maintenanceMode = true; + _emit(_copy(phase: DeviceSessionPhase.maintenance, token: 0)); + return; // Authenticated maintenance needs no CLAIM or ARM. + } final claim = await _command( BleOpcode.claim, token: 0, @@ -258,6 +572,16 @@ class DeviceSession { const Duration(milliseconds: 500), (_) => _refreshState(), ); + // Optional patch-level extension. Reads cannot renew the safety lease; + // a missing/failed characteristic never blocks ARM or the heartbeat. + _diagnosticRefresh?.cancel(); + if (info.firmwarePatch >= 3) { + unawaited(_refreshDiagnostics()); + _diagnosticRefresh = Timer.periodic( + const Duration(seconds: 5), + (_) => _refreshDiagnostics(), + ); + } } catch (e) { if (generation == _generation) { await _eventSub?.cancel(); @@ -448,6 +772,7 @@ class DeviceSession { cancelPendingTargets(); _keepalive?.cancel(); _stateRefresh?.cancel(); + _diagnosticRefresh?.cancel(); try { await _enqueueCommand(BleOpcode.release); } finally { @@ -460,6 +785,7 @@ class DeviceSession { Future disconnect() async { _releaseInProgress = true; _stateRefresh?.cancel(); + _diagnosticRefresh?.cancel(); ++_generation; cancelPendingTargets(); _keepalive?.cancel(); @@ -484,6 +810,7 @@ class DeviceSession { _writeTail = _writeTail.catchError((Object _) {}).then((_) async { try { _checkGeneration(sessionGeneration); + if (maintenanceMode) throw StateError('升级维护中,控制会话已暂停'); if (targetGeneration != null && targetGeneration != _targetGeneration) { throw const TargetCancelledException(); } @@ -645,6 +972,33 @@ class DeviceSession { throw error; } + Future _refreshDiagnostics() async { + if (_releaseInProgress || + _diagnosticReadInProgress || + !_snapshot.isConnected || + _disposed) { + return; + } + _diagnosticReadInProgress = true; + final generation = _generation; + try { + final bytes = await link + .read(BleDiagnostics.uuid) + .timeout(const Duration(milliseconds: 500)); + if (generation != _generation || _releaseInProgress || _disposed) return; + _emit( + _copy( + diagnostics: BleDiagnostics.decode(bytes), + diagnosticsAt: DateTime.now(), + ), + ); + } catch (_) { + // Keep the last timestamp, so old samples can never appear fresh. + } finally { + _diagnosticReadInProgress = false; + } + } + Future _refreshState() async { if (_releaseInProgress || _stateReadInProgress || @@ -758,6 +1112,13 @@ class DeviceSession { _token = 0; _keepalive?.cancel(); _stateRefresh?.cancel(); + _diagnosticRefresh?.cancel(); + _otaRefresh?.cancel(); + _lanRefresh?.cancel(); + lanWindow = null; + supportsSavedNetwork = false; + hasSavedNetwork = false; + otaWindow = null; _clearPending(); cancelPendingTargets(); final notifications = _eventSub; @@ -778,9 +1139,16 @@ class DeviceSession { Future dispose() async { if (_disposed) return; _disposed = true; + _otaRefresh?.cancel(); + _lanRefresh?.cancel(); + lanWindow = null; + supportsSavedNetwork = false; + hasSavedNetwork = false; + otaWindow = null; _generation++; _keepalive?.cancel(); _stateRefresh?.cancel(); + _diagnosticRefresh?.cancel(); await _eventSub?.cancel(); await _linkSub?.cancel(); await _updates.close(); diff --git a/flutter_app/lib/core/lan_window.dart b/flutter_app/lib/core/lan_window.dart new file mode 100644 index 0000000..f38de14 --- /dev/null +++ b/flutter_app/lib/core/lan_window.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'ota_window.dart'; + +/// LAN extension status carries only a RAM token; never log this object. +class LanWindowStatus extends OtaWindowStatus { + static const lanUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0008'; + const LanWindowStatus( + int state, + int result, + int ack, + int window, + int remaining, + this.detail, + this.ip, + this.uploadToken, + ) : super(state, result, ack, window, remaining, '', ''); + final int detail; + final String ip, uploadToken; + String get url => 'http://$ip/'; + factory LanWindowStatus.decode(List b) { + if (b.length < 24 || + b.any((v) => v < 0 || v > 255) || + b[0] != 1 || + b[1] > 6 || + b[2] > 6 || + b[3] > 6 || + (b[20] != 0 && b[20] != 32) || + b.length != 24 + b[20] || + b.sublist(21, 24).any((v) => v != 0)) { + throw const FormatException('Invalid LAN maintenance status'); + } + int u32(int n) => b[n] | b[n + 1] << 8 | b[n + 2] << 16 | b[n + 3] << 24; + final open = b[1] == 2 || b[1] == 3; + final token = String.fromCharCodes(b.sublist(24)); + final ipBytes = b.sublist(16, 20); + if (open + ? (u32(8) == 0 || + b[20] != 32 || + !RegExp(r'^[0-9a-f]{32}$').hasMatch(token) || + ipBytes[0] == 0 || + ipBytes[0] >= 224 || + ipBytes.every((v) => v == 255)) + : b[20] != 0) { + throw const FormatException('Invalid LAN ready endpoint'); + } + return LanWindowStatus( + b[1], + b[2], + u32(4), + u32(8), + u32(12), + b[3], + ipBytes.join('.'), + token, + ); + } + @override + Map toUiJson() => { + 'state': state, 'result': result, 'ackRequestId': ackRequestId, + 'windowId': windowId, 'remainingMs': remainingMs, 'detail': detail, + 'ip': isOpen ? ip : null, 'url': isOpen ? url : null, + // Deliberately exclude uploadToken from public snapshots. + }; + static List lanRequest({ + required bool open, + required int requestId, + required int windowId, + String ssid = '', + String password = '', + bool useSavedNetwork = false, + bool rememberNetwork = false, + }) { + final prefix = OtaWindowStatus.request( + open: open, + requestId: requestId, + windowId: windowId, + ); + if (useSavedNetwork) { + if (!open || rememberNetwork || ssid.isNotEmpty || password.isNotEmpty) { + throw const FormatException( + 'Saved network requires credential-free OPEN', + ); + } + return [2, ...prefix.skip(1), 0, 0]; + } + if (rememberNetwork && !open) { + throw const FormatException("Cannot remember on CLOSE"); + } + if (rememberNetwork) prefix[0] = 3; + final ssidBytes = utf8.encode(ssid), passwordBytes = password.codeUnits; + if (open + ? (ssidBytes.isEmpty || + ssidBytes.length > 32 || + ssidBytes.any((v) => v < 32 || v == 127) || + passwordBytes.length < 8 || + passwordBytes.length > 63 || + passwordBytes.any((v) => v < 32 || v > 126)) + : (ssid.isNotEmpty || password.isNotEmpty)) { + throw const FormatException( + 'Wi-Fi requires a 1–32 byte SSID and 8–63 ASCII password', + ); + } + return [ + ...prefix, + ssidBytes.length, + passwordBytes.length, + ...ssidBytes, + ...passwordBytes, + ]; + } +} + +/// Optional transport prerequisite for an atomic >20-byte BLE command. +abstract interface class BleLargeWriteLink { + Future prepareLargeWrite(int length); +} diff --git a/flutter_app/lib/core/ota_package.dart b/flutter_app/lib/core/ota_package.dart new file mode 100644 index 0000000..7762855 --- /dev/null +++ b/flutter_app/lib/core/ota_package.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; + +/// Offline syntax/target preflight only. No files, network, keys or verification. +class OtaPackageManifest { + OtaPackageManifest._({ + required this.imageLength, + required this.sha256Hex, + required this.version, + }); + static const board = 'satori_c3_v1'; + static const chip = 'esp32c3'; + static const slotBytes = 0x170000; + static const imageFormat = 'esp-idf-sbv2-rsa3072'; + final int imageLength; + final String sha256Hex, version; + + factory OtaPackageManifest.fromJson(Object? value) { + const fields = { + 'schema', + 'board', + 'chip', + 'image_length', + 'sha256', + 'version', + 'image_format', + }; + if (value is! Map || + value.length != fields.length || + !fields.every(value.containsKey)) { + throw const FormatException('Invalid OTA manifest fields'); + } + if (value['schema'] is! int || + value['schema'] != 1 || + value['board'] != board || + value['chip'] != chip || + value['image_format'] != imageFormat) { + throw const FormatException( + 'Unsupported OTA schema, target or image format', + ); + } + final length = value['image_length']; + if (length is! int || length <= 0 || length > slotBytes) { + throw const FormatException( + 'OTA image does not fit the application slot', + ); + } + final hash = value['sha256']; + if (hash is! String || !RegExp(r'^[0-9a-f]{64}$').hasMatch(hash)) { + throw const FormatException('Invalid OTA SHA256 declaration'); + } + final version = value['version']; + if (version is! String || !_validVersion(version)) { + throw const FormatException('Invalid OTA version'); + } + return OtaPackageManifest._( + imageLength: length, + sha256Hex: hash, + version: version, + ); + } + + /// The declared whole .bin digest, including padding and its signature block. + /// This does not hash or authenticate an image. + List get declaredSha256 => List.unmodifiable([ + for (var i = 0; i < sha256Hex.length; i += 2) + int.parse(sha256Hex.substring(i, i + 2), radix: 16), + ]); + + static bool _validVersion(String value) { + if (!RegExp( + r'^(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})$', + ).hasMatch(value)) { + return false; + } + return value.split('.').every((part) => int.parse(part) <= 65535); + } +} + +/// Candidate .sota: uint32 LE manifest length, UTF-8 JSON, then the whole raw .bin. +/// No archive extraction, file access, digest computation or signature parsing. +class OtaPackageContainer { + OtaPackageContainer._(this.manifest, this.imageBytes); + static const maxManifestBytes = 1024; + final OtaPackageManifest manifest; + final List imageBytes; + + factory OtaPackageContainer.decode(List bytes) { + if (bytes.length < 5 || + bytes.length > 4 + maxManifestBytes + OtaPackageManifest.slotBytes || + bytes.any((byte) => byte < 0 || byte > 255)) { + throw const FormatException('Invalid OTA container size or bytes'); + } + final length = + bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24); + if (length <= 0 || + length > maxManifestBytes || + 4 + length >= bytes.length) { + throw const FormatException('Invalid OTA manifest length prefix'); + } + Object? json; + try { + json = jsonDecode(utf8.decode(bytes.sublist(4, 4 + length))); + } on FormatException { + // Do not echo untrusted JSON or file contents in diagnostic messages. + throw const FormatException('Invalid OTA manifest JSON'); + } + final manifest = OtaPackageManifest.fromJson(json); + if (bytes.length - 4 - length != manifest.imageLength) { + throw const FormatException('OTA image length differs from manifest'); + } + return OtaPackageContainer._( + manifest, + List.unmodifiable(bytes.sublist(4 + length)), + ); + } +} + +enum OtaPackageBlock { noApprovedTrustPolicy, signatureVerificationUnavailable } + +class OtaPackagePreflight { + OtaPackagePreflight._(this.manifest, this.block); + final OtaPackageManifest manifest; + final OtaPackageBlock block; + + factory OtaPackagePreflight.inspect( + Object? manifestJson, { + required int actualImageLength, + bool approvedTrustPolicy = false, + }) { + final manifest = OtaPackageManifest.fromJson(manifestJson); + if (actualImageLength != manifest.imageLength) { + throw const FormatException('OTA image length differs from manifest'); + } + return OtaPackagePreflight._( + manifest, + approvedTrustPolicy + ? OtaPackageBlock.signatureVerificationUnavailable + : OtaPackageBlock.noApprovedTrustPolicy, + ); + } + + // A format declaration or caller policy flag never supplies crypto proof. + bool get signatureVerified => false; + bool get authenticityEstablished => false; + bool get mayTransfer => false; +} diff --git a/flutter_app/lib/core/ota_window.dart b/flutter_app/lib/core/ota_window.dart new file mode 100644 index 0000000..6579251 --- /dev/null +++ b/flutter_app/lib/core/ota_window.dart @@ -0,0 +1,78 @@ +/// Optional authenticated OTA extension; frozen Control v1.2 is unchanged. +class OtaWindowStatus { + static const uuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0007'; + static const browserUrl = 'http://192.168.4.1/'; + const OtaWindowStatus( + this.state, + this.result, + this.ackRequestId, + this.windowId, + this.remainingMs, + this.ssid, + this.password, + ); + final int state, result, ackRequestId, windowId, remainingMs; + final String ssid, password; // Ephemeral UI only; never log or persist. + bool get isClosed => state == 0; + bool get isOpen => state == 2 || state == 3; + bool get isCommitted => state == 5; + bool get signingReady => result != 2; + factory OtaWindowStatus.decode(List b) { + if (b.length < 18 || + b.any((v) => v < 0 || v > 255) || + b[0] != 1 || + b[1] > 6 || + b[2] > 6 || + b[3] != 0 || + b[16] > 32 || + b[17] > 64 || + b.length != 18 + b[16] + b[17] || + b.sublist(18).any((v) => v < 32 || v > 126)) { + throw const FormatException('Invalid OTA window status'); + } + int u32(int i) => b[i] | b[i + 1] << 8 | b[i + 2] << 16 | b[i + 3] << 24; + if ((b[1] == 2 || b[1] == 3) && (u32(8) == 0 || b[16] == 0 || b[17] < 8)) { + throw const FormatException('OTA window connection data missing'); + } + return OtaWindowStatus( + b[1], + b[2], + u32(4), + u32(8), + u32(12), + String.fromCharCodes(b.sublist(18, 18 + b[16])), + String.fromCharCodes(b.sublist(18 + b[16])), + ); + } + Map toUiJson() => { + 'state': state, + 'result': result, + 'ackRequestId': ackRequestId, + 'windowId': windowId, + 'remainingMs': remainingMs, + 'ssid': ssid, + 'password': password, + 'url': browserUrl, + }; + static List request({ + required bool open, + required int requestId, + required int windowId, + }) { + if (requestId <= 0 || + requestId > 0xffffffff || + windowId < 0 || + windowId > 0xffffffff || + (open ? windowId != 0 : windowId == 0)) { + throw const FormatException('Invalid OTA window request'); + } + final b = List.filled(10, 0); + b[0] = 1; + b[1] = open ? 1 : 2; + for (var i = 0; i < 4; i++) { + b[2 + i] = (requestId >> (8 * i)) & 255; + b[6 + i] = (windowId >> (8 * i)) & 255; + } + return b; + } +} diff --git a/flutter_app/lib/infrastructure/bluez_ble_link.dart b/flutter_app/lib/infrastructure/bluez_ble_link.dart index abd150d..15e4109 100644 --- a/flutter_app/lib/infrastructure/bluez_ble_link.dart +++ b/flutter_app/lib/infrastructure/bluez_ble_link.dart @@ -126,7 +126,7 @@ class BluezBleLink implements BleLink { } } } - throw StateError('GATT characteristic not found: $uuid'); + throw const BleCharacteristicAbsent(); } @override diff --git a/flutter_app/lib/infrastructure/fake_ble_link.dart b/flutter_app/lib/infrastructure/fake_ble_link.dart index 2309ac4..8dfa983 100644 --- a/flutter_app/lib/infrastructure/fake_ble_link.dart +++ b/flutter_app/lib/infrastructure/fake_ble_link.dart @@ -225,7 +225,7 @@ class FakeBleLink implements BleLink { validChannelMask: _validMask, ); } - throw StateError('Unknown characteristic $uuid'); + throw const BleCharacteristicAbsent(); } @override diff --git a/flutter_app/lib/infrastructure/reactive_ble_link.dart b/flutter_app/lib/infrastructure/reactive_ble_link.dart index ddb240c..d783703 100644 --- a/flutter_app/lib/infrastructure/reactive_ble_link.dart +++ b/flutter_app/lib/infrastructure/reactive_ble_link.dart @@ -7,6 +7,8 @@ import 'package:permission_handler/permission_handler.dart'; import '../core/ble_protocol.dart'; import '../core/device_session.dart'; +import '../core/lan_window.dart'; +import '../core/ota_window.dart'; import 'ble_discovery.dart'; /// Requests only the permissions needed by the current Android release. @@ -41,7 +43,7 @@ Future _androidSdkInt() async { /// Android/iOS BLE transport. The runtime creates one instance and retains it /// for the lifetime of the foreground-service FlutterEngine. -class ReactiveBleLink implements BleLink { +class ReactiveBleLink implements BleLink, BleLargeWriteLink { ReactiveBleLink({FlutterReactiveBle? ble}) : ble = ble ?? FlutterReactiveBle(); @@ -182,6 +184,18 @@ class ReactiveBleLink implements BleLink { } } + @override + Future prepareLargeWrite(int length) async { + final id = _deviceId; + if (id == null || _currentState != BleLinkState.connected) { + throw StateError('BLE link is not connected'); + } + final mtu = await ble.requestMtu(deviceId: id, mtu: 256); + if (mtu - 3 < length) { + throw StateError('BLE MTU insufficient for Wi-Fi configuration'); + } + } + QualifiedCharacteristic _characteristic(String uuid) { final id = _deviceId; if (id == null || _currentState != BleLinkState.connected) { @@ -195,8 +209,22 @@ class ReactiveBleLink implements BleLink { } @override - Future> read(String uuid) => - ble.readCharacteristic(_characteristic(uuid)); + Future> read(String uuid) async { + if (uuid == OtaWindowStatus.uuid || uuid == LanWindowStatus.lanUuid) { + final id = _deviceId; + if (id == null) throw StateError('BLE link is not connected'); + final services = await ble.getDiscoveredServices(id); + final exists = services.any( + (service) => + service.id.toString().toLowerCase() == BleProtocol.serviceUuid && + service.characteristics.any( + (c) => c.id.toString().toLowerCase() == uuid.toLowerCase(), + ), + ); + if (!exists) throw const BleCharacteristicAbsent(); + } + return ble.readCharacteristic(_characteristic(uuid)); + } @override Future write(String uuid, List value) => diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index e5f98c8..d8ea852 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'core/control_status.dart'; import 'infrastructure/reactive_ble_link.dart' show requestBlePermissions; import 'joystick_pad.dart'; +import 'wifi_setup_dialog.dart'; import 'runtime/control_client.dart'; import 'satori_palette.dart'; @@ -237,6 +238,11 @@ class _ControlShellState extends State _automaticConnection = false; try { await client.send('disconnect'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已结束控制。请关闭觉瞳实体电源;软件暂停不等于断电。')), + ); + } } catch (error) { _showError(error); } @@ -393,9 +399,13 @@ class _ControlShellState extends State ), const SizedBox(width: 3), Tooltip( - message: stale ? '电量数据已超过 1 分钟' : '设备电量', + message: battery == null + ? '设备未提供电量采样' + : stale + ? '电量数据已超过 1 分钟' + : '设备电量', child: Text( - battery == null ? '—' : '$battery%', + battery == null ? '未知' : '$battery%', style: TextStyle( fontSize: 11, color: p.ink, @@ -683,6 +693,20 @@ class _ControlShellState extends State ), ), ], + if (connected || state['connection'] == 'reconnecting') ...[ + const SizedBox(height: 12), + Center( + child: TextButton.icon( + onPressed: busy ? null : _disconnect, + icon: const Icon(Icons.stop_circle_outlined), + label: const Text('结束拍摄'), + ), + ), + Text( + '停止动作并释放控制;设备仍需用电源开关关闭。', + style: TextStyle(color: p.muted, fontSize: 12), + ), + ], ]); } @@ -811,6 +835,44 @@ class _ControlShellState extends State await _run(() => client.send('setPairingCode', {'code': code})); } + Future _openLanMaintenance() async { + final input = await showDialog( + context: context, + builder: (_) => WifiSetupDialog( + canRememberNetwork: client.state["supportsSavedNetwork"] == true, + ), + ); + if (input == null || !mounted) return; + await _run(() async { + _automaticConnection = false; + await client.send('openLanWindow', { + 'rememberNetwork': input.rememberNetwork, + 'ssid': input.ssid, + 'password': input.password, + }); + }); + } + + String _lanStatusText(Map? status) { + if (status == null) return '局域网状态未确认'; + if (status['result'] == 2) return '签名维护尚未就绪'; + if (status['state'] == 1) return '正在连接Wi-Fi并启动维护服务…'; + if (status['state'] == 2 || status['state'] == 3) return '已获得设备IP并开启维护'; + if (status['state'] == 5) return '镜像已提交,等待重启后确认版本'; + if (status['state'] == 6 || status['detail'] != 0) { + return switch (status['detail']) { + 1 => 'Wi-Fi配置无效', + 2 => '连接超时,请检查2.4GHz网络和密码', + 3 => 'Wi-Fi启动失败', + 4 => '局域网连接已丢失,维护已停止', + 5 => '没有可用的已记住网络,请重新配网', + 6 => '保存结果未确认,维护已关闭;请读取保存状态或重新配网', + _ => '维护失败,请读取设备状态后重试', + }; + } + return status['state'] == 0 ? '局域网维护未开启或已关闭' : '正在关闭维护…'; + } + Future _openTransfer() async { final confirmed = await showDialog( context: context, @@ -908,12 +970,17 @@ class _ControlShellState extends State SizedBox( width: double.infinity, child: _outlinedAction( - '断开控制会话', + '结束拍摄', Icons.link_off_rounded, busy ? null : _disconnect, danger: true, ), ), + const SizedBox(height: 8), + Text( + '停止自动动作并释放控制。设备仍需用电源开关关闭。', + style: TextStyle(color: p.muted, fontSize: 12), + ), ] else ...[ const SizedBox(height: 16), Text( @@ -981,6 +1048,196 @@ class _ControlShellState extends State ), ], const SizedBox(height: 20), + _section('固件升级'), + const SizedBox(height: 10), + Text( + '局域网升级(推荐)', + style: TextStyle(color: p.ink, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + state['lanSupported'] == true + ? _lanStatusText(state['lanWindow'] as Map?) + : state['lanSupported'] == false + ? '此设备固件不支持局域网配网维护,需升级固件;不会发送网络配置。' + : '局域网维护状态未确认,控制保持暂停;请重新连接确认。', + style: TextStyle(color: p.muted, fontSize: 13), + ), + const SizedBox(height: 8), + _outlinedAction( + '配网或更换网络', + Icons.wifi, + busy || + state['otaBusy'] == true || + state['lanSupported'] != true || + state['lanWindow'] == null || + state['lanWindow']?['result'] == 2 || + state['lanWindow']?['state'] != 0 || + (state['otaWindow']?['state'] ?? 0) != 0 || + (state['otaMaintenance'] == true && + state['maintenancePath'] == 'ap') + ? null + : _openLanMaintenance, + ), + if (state['supportsSavedNetwork'] == true && + state['hasSavedNetwork'] == true) + _outlinedAction( + '使用已记住网络开启升级', + Icons.system_update, + busy || + state['otaBusy'] == true || + state['lanSupported'] != true || + state['lanWindow']?['state'] != 0 || + (state['otaWindow']?['state'] ?? 0) != 0 || + state['maintenancePath'] == 'unknown' + ? null + : () => _run(() async { + _automaticConnection = false; + await client.send('openLanWindow', {'useSavedNetwork': true}); + }), + ), + Text( + state['supportsSavedNetwork'] == true + ? '维护空闲十分钟后关闭,可主动关闭;上传有独立时限。关闭维护后动作保持暂停。' + : '当前维护期限以设备剩余时间为准,可主动关闭;关闭后动作保持暂停。', + ), + if (state['lanWindow'] != null && + (state['lanWindow']['state'] == 2 || + state['lanWindow']['state'] == 3)) ...[ + _settingRow('电脑浏览器地址', '${state['lanWindow']['url']}'), + _settingRow( + '当前阶段剩余时间', + '${((state['lanWindow']['remainingMs'] as num) / 1000).ceil()} 秒', + ), + Text( + '电脑保持原Wi-Fi,只需与设备处于同一局域网。打开以上地址,填写本窗口上传令牌并选择签名包。连接Wi-Fi不代表升级完成。', + style: TextStyle(color: p.muted, fontSize: 13), + ), + if (client.lanUploadToken != null) ...[ + const SizedBox(height: 8), + const Text('上传令牌(仅当前窗口)'), + SelectableText(client.lanUploadToken!), + ] else + TextButton( + onPressed: busy + ? null + : () => _run(() => client.send('readLanUploadToken')), + child: const Text('读取本窗口上传令牌'), + ), + ], + const SizedBox(height: 16), + Text( + '备用设备热点(手动选择)', + style: TextStyle(color: p.ink, fontWeight: FontWeight.w600), + ), + + Text( + state['otaSupported'] == null + ? '备用热点维护状态未确认,控制保持暂停;请重新连接确认。' + : state['otaSupported'] == false + ? '当前固件不支持无线升级窗口。' + : state['otaWindow']?['result'] == 2 + ? '签名升级尚未就绪,暂不能开启窗口。' + : '主动开启限时窗口后,手动连接设备 Wi-Fi,用任意浏览器上传 .sota 签名包。', + style: TextStyle(color: p.muted, fontSize: 13), + ), + if (state['otaNotice'] is String) ...[ + const SizedBox(height: 8), + Text(state['otaNotice'] as String, style: TextStyle(color: p.muted)), + ], + if (state['otaSupported'] == true || state['lanSupported'] == true) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _outlinedAction( + '开启升级窗口', + Icons.system_update, + busy || + state['otaBusy'] == true || + state['otaSupported'] != true || + state['otaWindow'] == null || + state['otaWindow']?['result'] == 2 || + (state['otaWindow']?['state'] ?? 0) != 0 || + (state['lanWindow']?['state'] ?? 0) != 0 || + (state['otaMaintenance'] == true && + state['maintenancePath'] == 'lan') + ? null + : () => _run(() async { + _automaticConnection = false; + await client.send('openOtaWindow'); + }), + ), + _outlinedAction( + '关闭升级窗口', + Icons.close, + busy || + state['otaBusy'] == true || + (state['maintenancePath'] == 'lan' + ? state['lanWindow'] + : state['otaWindow']) == + null || + ((state['maintenancePath'] == 'lan' + ? state['lanWindow'] + : state['otaWindow'])?['windowId'] ?? + 0) == + 0 || + (state['maintenancePath'] == 'lan' + ? state['lanWindow'] + : state['otaWindow'])?['state'] == + 5 + ? null + : () => _run(() => client.send('closeOtaWindow')), + ), + if (state['otaMaintenance'] == true) + _outlinedAction( + '退出升级并重新连接', + Icons.bluetooth, + busy || + state['otaBusy'] == true || + (state['maintenancePath'] == 'lan' + ? state['lanWindow'] + : state['otaWindow'])?['state'] != + 0 + ? null + : () => _run(() => client.send('exitOtaMaintenance')), + ), + ], + ), + ], + if ((!connected || state['maintenancePath'] == 'unknown') && + state['otaMaintenance'] == true) ...[ + const SizedBox(height: 12), + _outlinedAction( + '重新连接确认窗口状态', + Icons.bluetooth_searching, + busy + ? null + : () => _run(() => client.send('reconnectOtaMaintenance')), + ), + Text( + '蓝牙断开不能证明维护已结束;重新连接只确认状态,不自动启用动作。', + style: TextStyle(color: p.muted, fontSize: 13), + ), + ], + if (state['otaWindow'] != null && + (state['otaWindow']['state'] == 2 || + state['otaWindow']['state'] == 3)) ...[ + const SizedBox(height: 12), + _settingRow('升级 Wi-Fi', '${state['otaWindow']['ssid']}'), + _settingRow('临时密码', '${state['otaWindow']['password']}'), + _settingRow('浏览器地址', 'http://192.168.4.1/'), + _settingRow( + '当前阶段剩余时间', + '${((state['otaWindow']['remainingMs'] as num) / 1000).ceil()} 秒', + ), + Text( + '1. 手动连接以上 Wi-Fi(无互联网)。\n2. 浏览器打开以上地址,选择签名升级包。\n3. 等待设备报告结果;上传不需要保持蓝牙。\n关闭窗口不会恢复动作,退出后需手动启用控制。', + style: TextStyle(color: p.muted, fontSize: 13), + ), + ], + const SizedBox(height: 20), ExpansionTile( tilePadding: EdgeInsets.zero, title: Text('设备详情', style: TextStyle(color: p.ink)), @@ -990,6 +1247,38 @@ class _ControlShellState extends State _settingRow('应用 BLE 协议', '${state['appProtocol'] ?? '未知'}'), _settingRow('设备 BLE 协议', '${state['deviceProtocol'] ?? '未知'}'), _settingRow('控制状态', controlStatus(state)), + _settingRow( + '设备电量', + state['battery'] == null ? '未知(未采样)' : '${state['battery']}%', + ), + if (state['diagnostics'] is Map) ...[ + _settingRow( + '诊断样本', + diagnosticsAreFresh(state) ? '最近采样' : '历史数据,已过期', + ), + _settingRow( + '上次停止原因', + diagnosticStopReason(state['diagnostics']['lastStop'] as int?), + ), + _settingRow( + '固件故障记录', + diagnosticFaults(state['diagnostics']['faults'] as int? ?? 0), + ), + _settingRow('启动原因代码', '${state['diagnostics']['resetReason']}'), + _settingRow('本次启动时长', '${state['diagnostics']['uptimeSeconds']} 秒'), + _settingRow('上次蓝牙断开代码', '${state['diagnostics']['lastGapReason']}'), + _settingRow( + '心跳超时次数', + '${state['diagnostics']['leaseExpiryCount']}', + ), + _settingRow('蓝牙断开次数', '${state['diagnostics']['disconnectCount']}'), + _settingRow( + '通知发送失败次数', + '${state['diagnostics']['notificationFailureCount']}', + ), + _settingRow('诊断采样时间', '${state['diagnosticsAt'] ?? '未知'}'), + ] else + _settingRow('固件诊断', '暂无数据(旧固件可正常控制)'), if (state['error'] != null) _settingRow('设备错误', '${state['error']}'), if (client.message != null) _settingRow('后台错误', client.message!), if (_localError != null) _settingRow('界面错误', _localError!), diff --git a/flutter_app/lib/runtime/control_client.dart b/flutter_app/lib/runtime/control_client.dart index 46f5dba..a17257a 100644 --- a/flutter_app/lib/runtime/control_client.dart +++ b/flutter_app/lib/runtime/control_client.dart @@ -17,6 +17,8 @@ class ControlClient extends ChangeNotifier { 'outputAuthorized': false, }; String? message; + String? lanUploadToken; + int? _lanTokenWindow; bool running = false; bool _disposed = false; Future? _starting; @@ -49,6 +51,8 @@ class ControlClient extends ChangeNotifier { _handshaken = false; await _establishHandshake(); } else { + lanUploadToken = null; + _lanTokenWindow = null; _handshaken = false; final old = state['runtimeId']; if (old is String) _retiredRuntimes.add(old); @@ -95,6 +99,14 @@ class ControlClient extends ChangeNotifier { _notify(); } } + if (state['connection'] != 'connected' || + (state['lanWindow']?['state'] != 2 && + state['lanWindow']?['state'] != 3) || + (_lanTokenWindow != null && + state['lanWindow']?['windowId'] != _lanTokenWindow)) { + lanUploadToken = null; + _lanTokenWindow = null; + } if (data['type'] == 'error' || data['type'] == 'rejected') { message = data['message']?.toString(); _notify(); @@ -103,6 +115,22 @@ class ControlClient extends ChangeNotifier { if (id is int && data['clientId'] == _clientId && _pending.containsKey(id)) { + final privateWindow = data['privateLanWindow']; + if ((_pendingOps[id] == 'openLanWindow' || + _pendingOps[id] == 'readLanUploadToken') && + privateWindow is Map && + state['connection'] == 'connected' && + (state['lanWindow']?['state'] == 2 || + state['lanWindow']?['state'] == 3) && + privateWindow['windowId'] == state['lanWindow']?['windowId'] && + privateWindow['token'] is String && + RegExp( + r'^[0-9a-f]{32}$', + ).hasMatch(privateWindow['token'] as String)) { + lanUploadToken = privateWindow['token'] as String; + _lanTokenWindow = privateWindow['windowId'] as int; + _notify(); + } final pending = _pending.remove(id)!; if (data['type'] == 'rejected') { pending.completeError(StateError(data['message'].toString())); @@ -130,7 +158,7 @@ class ControlClient extends ChangeNotifier { notificationText: '正在准备蓝牙连接', notificationButtons: const [ NotificationButton(id: 'stop', text: '暂停控制'), - NotificationButton(id: 'disconnect', text: '断开'), + NotificationButton(id: 'disconnect', text: '结束拍摄'), ], callback: startControlTask, ); @@ -185,7 +213,13 @@ class ControlClient extends ChangeNotifier { }); try { await pending.future.timeout( - Duration(seconds: op == 'select' ? 120 : 15), + Duration( + seconds: op == 'select' + ? 120 + : (op == 'openLanWindow' || op == 'closeOtaWindow') + ? 45 + : 15, + ), ); } on TimeoutException { throw TimeoutException('后台操作尚未确认,请刷新状态或断开'); @@ -204,6 +238,8 @@ class ControlClient extends ChangeNotifier { @override void dispose() { _disposed = true; + lanUploadToken = null; + _lanTokenWindow = null; if (!preview) FlutterForegroundTask.removeTaskDataCallback(_receive); for (final pending in _pending.values) { pending.completeError(StateError('界面已关闭;设备会话仍由后台管理')); diff --git a/flutter_app/lib/runtime/control_task.dart b/flutter_app/lib/runtime/control_task.dart index 6c29b91..048b516 100644 --- a/flutter_app/lib/runtime/control_task.dart +++ b/flutter_app/lib/runtime/control_task.dart @@ -230,6 +230,31 @@ class ControlTask extends TaskHandler { await active.openTransfer(ensureCurrentClient: ensureCurrentClient); case 'cancelTransfer': await active.cancelTransfer(ensureCurrentClient: ensureCurrentClient); + case 'openLanWindow': + await active.openOtaWindow( + lan: true, + ssid: data['ssid'] as String? ?? '', + password: data['password'] as String? ?? '', + useSavedNetwork: data['useSavedNetwork'] == true, + rememberNetwork: data['rememberNetwork'] == true, + ensureCurrentClient: ensureCurrentClient, + ); + case 'readLanUploadToken': + await active.session.refreshLanWindow(); + ensureCurrentClient(); + if (active.session.lanWindow?.isOpen != true) { + throw StateError('局域网维护尚未就绪'); + } + case 'openOtaWindow': + await active.openOtaWindow(ensureCurrentClient: ensureCurrentClient); + case 'closeOtaWindow': + await active.closeOtaWindow(ensureCurrentClient: ensureCurrentClient); + case 'reconnectOtaMaintenance': + ensureCurrentClient(); + await active.reconnectOtaMaintenance(); + case 'exitOtaMaintenance': + ensureCurrentClient(); + await active.exitOtaMaintenance(); case 'arm': await active.arm(); case 'manual': @@ -255,10 +280,19 @@ class ControlTask extends TaskHandler { 'id': data['id'], 'clientId': data['clientId'], 'snapshot': snapshot(), + if ((data['op'] == 'openLanWindow' || + data['op'] == 'readLanUploadToken') && + active.session.lanWindow?.isOpen == true) + 'privateLanWindow': { + 'windowId': active.session.lanWindow!.windowId, + 'token': active.session.lanWindow!.uploadToken, + }, }); } catch (error) { // Pairing credentials must not be echoed by plugin exceptions or replies. - if (data['op'] == 'setPairingCode' || + if (data['op'] == 'openLanWindow' || data['op'] == 'readLanUploadToken') { + reject(data, StateError('局域网配置或维护未确认,请检查设备状态;输入不会保存到手机。')); + } else if (data['op'] == 'setPairingCode' || data['op'] == 'openTransfer' || data['op'] == 'cancelTransfer') { reject(data, StateError(active.error ?? '配对设置未确认,请检查连接后重试')); diff --git a/flutter_app/lib/wifi_setup_dialog.dart b/flutter_app/lib/wifi_setup_dialog.dart new file mode 100644 index 0000000..ab9eabc --- /dev/null +++ b/flutter_app/lib/wifi_setup_dialog.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'core/lan_window.dart'; + +class WifiMaintenanceInput { + const WifiMaintenanceInput( + this.ssid, + this.password, { + this.rememberNetwork = false, + }); + final bool rememberNetwork; + final String ssid, password; +} + +class WifiSetupDialog extends StatefulWidget { + const WifiSetupDialog({super.key, this.canRememberNetwork = false}); + final bool canRememberNetwork; + @override + State createState() => _WifiSetupDialogState(); +} + +class _WifiSetupDialogState extends State { + final _ssid = TextEditingController(), _password = TextEditingController(); + String? _error; + bool _remember = false; + @override + void dispose() { + _ssid.clear(); + _password.clear(); + _ssid.dispose(); + _password.dispose(); + super.dispose(); + } + + void submit() { + try { + LanWindowStatus.lanRequest( + open: true, + requestId: 1, + windowId: 0, + ssid: _ssid.text, + password: _password.text, + ); + final input = WifiMaintenanceInput( + _ssid.text, + _password.text, + rememberNetwork: _remember, + ); + _ssid.clear(); + _password.clear(); + Navigator.pop(context, input); + } on FormatException { + setState(() => _error = 'SSID须为1–32字节,WPA2密码须为8–63个ASCII字符。'); + } + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('连接局域网进行维护'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.canRememberNetwork + ? '仅支持2.4GHz WPA2个人网络。默认只用本次,结束清除;选择记住后仅保存在设备,不保存到手机,以后仍需主动开启维护。' + : '仅用于本次限时维护,结束后从设备内存清除;不保存到手机或设备持久存储。仅支持2.4GHz WPA2个人网络。', + ), + if (widget.canRememberNetwork) + CheckboxListTile( + title: const Text('记住此网络'), + subtitle: const Text('密码存入设备本地配置;手机不保存。换网时可重新配置。'), + value: _remember, + onChanged: (v) => setState(() => _remember = v == true), + ), + TextField( + controller: _ssid, + decoration: const InputDecoration(labelText: 'Wi-Fi名称(SSID)'), + autocorrect: false, + enableSuggestions: false, + ), + TextField( + controller: _password, + obscureText: true, + enableIMEPersonalizedLearning: false, + decoration: const InputDecoration(labelText: 'Wi-Fi密码'), + autocorrect: false, + enableSuggestions: false, + ), + if (_error != null) Text(_error!), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton(onPressed: submit, child: const Text('连接网络并开启维护')), + ], + ); +} diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 25d0df3..6b2b3cd 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -1,7 +1,7 @@ name: satori_manager description: 觉瞳 BLE 控制技术验证版 publish_to: none -version: 0.2.4+6 +version: 0.2.9+11 environment: sdk: ^3.12.2 dependencies: diff --git a/flutter_app/test/ble_diagnostics_test.dart b/flutter_app/test/ble_diagnostics_test.dart new file mode 100644 index 0000000..db6e21e --- /dev/null +++ b/flutter_app/test/ble_diagnostics_test.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/ble_diagnostics.dart'; +import 'package:satori_manager/core/ble_protocol.dart'; +import 'package:satori_manager/core/device_session.dart'; +import 'package:satori_manager/infrastructure/fake_ble_link.dart'; + +class DiagnosticLink extends FakeBleLink { + DiagnosticLink({ + this.extension = true, + this.failure = false, + this.delayed = false, + }); + final bool extension, failure, delayed; + int diagnosticReads = 0; + final pending = Completer>(); + static final sample = '0103040178563412090000000200050013020000'; + static List bytes() => [ + for (var i = 0; i < sample.length; i += 2) + int.parse(sample.substring(i, i + 2), radix: 16), + ]; + @override + Future> read(String uuid) async { + if (uuid == BleDiagnostics.uuid) { + diagnosticReads++; + if (failure) throw StateError('Absent characteristic'); + if (delayed) return pending.future; + return bytes(); + } + final value = await super.read(uuid); + if (uuid == BleProtocol.deviceInfoUuid && extension) value[4] = 3; + return value; + } +} + +void main() { + test( + 'diagnostic vector matches firmware, reserved/unknown fields fail closed', + () { + final d = BleDiagnostics.decode(DiagnosticLink.bytes()); + expect(d.lastStop, 4); + expect(d.faults, 1); + expect(d.uptimeSeconds, 0x12345678); + expect(d.disconnectCount, 9); + expect(d.leaseExpiryCount, 2); + expect(d.notificationFailureCount, 5); + expect(d.lastGapReason, 0x213); + for (final index in [0, 2, 3, 18, 19]) { + final invalid = DiagnosticLink.bytes()..[index] = 255; + expect(() => BleDiagnostics.decode(invalid), throwsFormatException); + } + }, + ); + + test( + 'old firmware connects without optional reads or synthetic battery', + () async { + final link = DiagnosticLink(extension: false); + final session = DeviceSession(link); + addTearDown(() async { + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + expect(link.diagnosticReads, 0); + expect(session.snapshot.diagnostics, isNull); + expect(session.snapshot.state!.batteryPercent, isNull); + }, + ); + + test( + 'new firmware diagnostics do not block ARM; absent extension is harmless', + () async { + for (final failure in [false, true]) { + final link = DiagnosticLink(failure: failure); + final session = DeviceSession(link); + await session.connect('fake'); + await session.arm(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(session.snapshot.isArmed, true); + expect(session.snapshot.diagnostics != null, !failure); + expect(session.snapshot.lastError, isNull); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + } + }, + ); + + test( + 'late diagnostic read after disconnect cannot populate another session', + () async { + final link = DiagnosticLink(delayed: true); + final session = DeviceSession(link); + await session.connect('fake'); + await session.disconnect(); + link.pending.complete(DiagnosticLink.bytes()); + await Future.delayed(const Duration(milliseconds: 10)); + expect(session.snapshot.diagnostics, isNull); + expect(session.snapshot.diagnosticsAt, isNull); + await session.dispose(); + await link.dispose(); + }, + ); +} diff --git a/flutter_app/test/ble_engine_test.dart b/flutter_app/test/ble_engine_test.dart index 3b86ffc..0abba74 100644 --- a/flutter_app/test/ble_engine_test.dart +++ b/flutter_app/test/ble_engine_test.dart @@ -74,6 +74,82 @@ class Harness { } void main() { + test( + 'ending filming cancels all motion, releases once, and never reconnects', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + h.engine.setAutoRotate(true); + h.engine.setAutoWink(true); + await h.flush(); + final count = h.targets.length; + await Future.wait([h.engine.disconnect(), h.engine.disconnect()]); + for (final timer in h.timers) { + timer.fire(); + } + await h.flush(); + expect(h.engine.endState, 'confirmed'); + expect(h.engine.connection, 'disconnected'); + expect(h.engine.autoRotate, false); + expect(h.engine.autoWink, false); + expect(h.engine.outputAuthorized, false); + expect(h.targets.length, count); + expect( + h.link.writes + .map(BleProtocol.decodeControlFrame) + .where((f) => f.opcode == BleOpcode.release.value) + .length, + 1, + ); + await h.engine.disconnect(); + expect(h.engine.endState, 'confirmed'); + }, + ); + + test( + 'ending during reconnect cancels retry and reports only local cancellation', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.link.disconnect(); + await h.flush(); + final arms = h.armCount; + expect(h.engine.connection, 'reconnecting'); + await h.engine.disconnect(); + for (final timer in h.timers) { + timer.fire(); + } + await h.flush(); + expect(h.engine.connection, 'disconnected'); + expect(h.engine.endState, 'localOnly'); + expect(h.armCount, arms); + }, + ); + + test( + 'lost release ACK cancels locally without claiming device stop confirmed', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 20), + maxRetries: 1, + ); + final engine = ControlEngine(session, {}); + await engine.connect('fake'); + link.dropNextReplies = 10; + await engine.disconnect(); + expect(engine.endState, 'unconfirmed'); + expect(engine.issueCode, 'releaseUnconfirmed'); + expect(engine.outputAuthorized, false); + expect(engine.connection, 'disconnected'); + await engine.dispose(); + await link.dispose(); + }, + ); + test('connect auto-ARMs known safe startup channels', () async { final h = Harness(); addTearDown(h.close); diff --git a/flutter_app/test/ble_ui_test.dart b/flutter_app/test/ble_ui_test.dart index 846811b..3e14587 100644 --- a/flutter_app/test/ble_ui_test.dart +++ b/flutter_app/test/ble_ui_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; import 'package:satori_manager/main.dart'; import 'package:satori_manager/joystick_pad.dart'; import 'package:satori_manager/runtime/control_client.dart'; @@ -24,9 +25,16 @@ void main() { await tester.tap(find.text('设置')); await tester.pumpAndSettle(); expect(find.text('蓝牙 · BLE'), findsOneWidget); + expect(find.text('结束拍摄'), findsOneWidget); expect(find.text('安全范围'), findsNothing); + await tester.scrollUntilVisible(find.text('设备详情'), 200); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -180)); + await tester.pumpAndSettle(); await tester.tap(find.text('设备详情')); await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('未知(未采样)'), 100); + expect(find.text('未知(未采样)'), findsOneWidget); + await tester.scrollUntilVisible(find.text('设备实际姿态未回传'), 100); expect(find.text('设备实际姿态未回传'), findsOneWidget); }, ); @@ -66,8 +74,87 @@ void main() { await tester.tap(find.text('设置')); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('设备详情'), 200); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -180)); + await tester.pumpAndSettle(); await tester.tap(find.text('设备详情')); await tester.pumpAndSettle(); expect(find.textContaining('private firmware exception'), findsOneWidget); }); + testWidgets( + 'upgrade guidance distinguishes old firmware, signing-not-ready and real window', + (tester) async { + Future show(Map extra) async { + await tester.pumpWidget( + SatoriApp( + key: UniqueKey(), + previewClient: ControlClient.preview({ + 'connection': 'connected', + 'outputAuthorized': false, + 'mode': 'manual', + 'controlPhase': 'paused', + 'playback': 'idle', + ...extra, + }), + ), + ); + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('固件升级'), 180); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -150)); + await tester.pumpAndSettle(); + } + + await show({'otaSupported': false}); + expect(find.text('当前固件不支持无线升级窗口。'), findsOneWidget); + expect(find.text('开启升级窗口'), findsNothing); + await show({ + 'otaSupported': true, + 'otaWindow': {'state': 0, 'result': 2}, + }); + expect(find.text('签名升级尚未就绪,暂不能开启窗口。'), findsOneWidget); + await show({ + 'otaSupported': true, + 'otaMaintenance': true, + 'otaWindow': { + 'state': 2, + 'result': 0, + 'windowId': 42, + 'remainingMs': 120000, + 'ssid': 'test-maintenance', + 'password': 'synthetic-password', + }, + }); + await tester.scrollUntilVisible(find.text('test-maintenance'), 150); + expect(find.text('synthetic-password'), findsOneWidget); + expect(find.text('http://192.168.4.1/'), findsOneWidget); + await tester.scrollUntilVisible(find.textContaining('上传不需要保持蓝牙'), 150); + expect(find.textContaining('上传不需要保持蓝牙'), findsOneWidget); + expect(find.textContaining('升级完成'), findsNothing); + }, + ); + testWidgets( + 'lost maintenance link has explicit paused reconnect path without credentials', + (tester) async { + await tester.pumpWidget( + SatoriApp( + previewClient: ControlClient.preview({ + 'connection': 'disconnected', + 'outputAuthorized': false, + 'mode': 'manual', + 'controlPhase': 'paused', + 'playback': 'idle', + 'otaMaintenance': true, + 'otaWindow': null, + 'otaNotice': '窗口状态未知', + }), + ), + ); + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('重新连接确认窗口状态'), 180); + expect(find.text('重新连接确认窗口状态'), findsOneWidget); + expect(find.text('临时密码'), findsNothing); + expect(find.textContaining('窗口已关闭'), findsNothing); + }, + ); } diff --git a/flutter_app/test/control_status_test.dart b/flutter_app/test/control_status_test.dart index 56ef332..07f0d00 100644 --- a/flutter_app/test/control_status_test.dart +++ b/flutter_app/test/control_status_test.dart @@ -2,6 +2,32 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:satori_manager/core/control_status.dart'; void main() { + test( + 'old, disconnected and future diagnostic samples cannot appear fresh', + () { + final now = DateTime.utc(2026, 10, 3, 1); + Map sample(int seconds, {String connection = 'connected'}) => { + 'connection': connection, + 'diagnosticsAt': now + .subtract(Duration(seconds: seconds)) + .toIso8601String(), + }; + expect(diagnosticsAreFresh(sample(5), now: now), true); + expect(diagnosticsAreFresh(sample(16), now: now), false); + expect(diagnosticsAreFresh(sample(-1), now: now), false); + expect( + diagnosticsAreFresh(sample(5, connection: 'disconnected'), now: now), + false, + ); + }, + ); + test('ending status is explicit and cannot imply a powered-off device', () { + expect(controlStatus({'endState': 'confirmed'}), '拍摄已结束,控制已释放'); + expect(controlStatus({'endState': 'unconfirmed'}), '拍摄已结束,设备停止未确认'); + expect(needsControlAttention({'endState': 'unconfirmed'}), true); + expect(diagnosticStopReason(4), '控制心跳超时'); + expect(diagnosticFaults(3), '舵机输出驱动异常、配对存储异常'); + }); test('notification and page status follow fields, not error wording', () { expect( controlStatus({ diff --git a/flutter_app/test/lan_window_test.dart b/flutter_app/test/lan_window_test.dart new file mode 100644 index 0000000..7cc0998 --- /dev/null +++ b/flutter_app/test/lan_window_test.dart @@ -0,0 +1,562 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:satori_manager/core/ble_protocol.dart'; +import 'package:satori_manager/core/control_engine.dart'; +import 'package:satori_manager/core/device_session.dart'; +import 'package:satori_manager/core/lan_window.dart'; +import 'package:satori_manager/core/ota_window.dart'; +import 'package:satori_manager/infrastructure/fake_ble_link.dart'; +import 'package:satori_manager/wifi_setup_dialog.dart'; + +class LanLink extends FakeBleLink implements BleLargeWriteLink { + bool extension = true, ready = true, rejectMtu = false, failWifi = false; + bool failLanRead = false, failApRead = false; + bool savedExtension = false, saved = false, failAfterSave = false; + int state = 0, ack = 0, window = 0, detail = 0, connectingReads = 0; + final requests = >[]; + final largeWrites = []; + static final testToken = 'ab' * 16; + List status() { + final open = state == 2 || state == 3; + final b = List.filled(24, 0); + b[0] = 1; + b[1] = state; + b[2] = ready ? 0 : 2; + b[3] = detail; + for (final pair in [(4, ack), (8, window), (12, 120000)]) { + for (var i = 0; i < 4; i++) { + b[pair.$1 + i] = (pair.$2 >> (8 * i)) & 255; + } + } + if (open) { + b.setRange(16, 20, [192, 168, 1, 99]); + b[20] = 32; + } + return [...b, if (open) ...testToken.codeUnits]; + } + + @override + Future prepareLargeWrite(int length) async { + largeWrites.add(length); + if (rejectMtu) throw StateError('Insufficient MTU'); + } + + @override + Future> read(String uuid) async { + if (uuid == DeviceSession.savedNetworkUuid) { + if (!savedExtension) throw const BleCharacteristicAbsent(); + return [1, 3, saved ? 1 : 0, 0]; + } + if (uuid == LanWindowStatus.lanUuid) { + if (failLanRead) throw StateError('No reply'); + if (!extension) throw const BleCharacteristicAbsent(); + if (state == 1 && --connectingReads <= 0) { + state = failWifi || failAfterSave ? 6 : 2; + detail = failWifi ? 2 : 0; + } + return status(); + } + if (uuid == OtaWindowStatus.uuid) { + if (failApRead) throw StateError('No reply'); + final b = List.filled(18, 0); + b[0] = 1; + b[2] = state == 0 ? 0 : 1; + return b; + } + return super.read(uuid); + } + + @override + Future write(String uuid, List bytes) async { + if (uuid != LanWindowStatus.lanUuid) return super.write(uuid, bytes); + requests.add(List.from(bytes)); + int u32(int n) => + bytes[n] | bytes[n + 1] << 8 | bytes[n + 2] << 16 | bytes[n + 3] << 24; + if (ack == u32(2)) return; + ack = u32(2); + if (bytes[1] == 1) { + if (bytes[0] == 3 && !failWifi) saved = true; + state = 1; + window = 61; + connectingReads = 3; + } else { + state = 0; + detail = 0; + } + } + + int get arms => writes.where((b) => b[1] == BleOpcode.arm.value).length; +} + +void main() { + test('explicit temporary remember and saved wire', () { + List request({bool remember = false, bool saved = false}) => + LanWindowStatus.lanRequest( + open: true, + requestId: 1, + windowId: 0, + ssid: saved ? '' : 'synthetic-net', + password: saved ? '' : 'synthetic-password', + rememberNetwork: remember, + useSavedNetwork: saved, + ); + expect(request()[0], 1); + expect(request(remember: true)[0], 3); + expect(request(saved: true), [2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(() => request(remember: true, saved: true), throwsFormatException); + }); + test('optional feature leaves old DeviceInfo compatible', () async { + for (final present in [false, true]) { + final link = LanLink()..savedExtension = present; + final session = DeviceSession(link); + await session.connect('fake'); + expect(session.supportsSavedNetwork, present); + expect(session.snapshot.deviceInfo!.capabilities & ~0x1ff, 0); + if (!present) { + await expectLater( + session.changeLanWindow( + true, + ssid: 'synthetic-net', + password: 'synthetic-password', + rememberNetwork: true, + ), + throwsStateError, + ); + expect(link.requests, isEmpty); + } + await session.dispose(); + await link.dispose(); + } + }); + test('remember and explicitly reopen with no credentials or ARM', () async { + final link = LanLink()..savedExtension = true; + final session = DeviceSession(link); + await session.connect('fake'); + await session.changeLanWindow( + true, + ssid: 'synthetic-net', + password: 'synthetic-password', + rememberNetwork: true, + ); + expect(link.requests.single[0], 3); + expect(session.hasSavedNetwork, isTrue); + await session.changeLanWindow(false); + await session.changeLanWindow(true, useSavedNetwork: true); + expect(link.requests.last.length, 12); + expect(link.requests.last[0], 2); + expect(link.arms, 0); + await session.dispose(); + await link.dispose(); + }); + test('temporary network does not set saved metadata', () async { + final link = LanLink()..savedExtension = true; + final session = DeviceSession(link); + await session.connect('fake'); + await session.changeLanWindow( + true, + ssid: 'synthetic-net', + password: 'synthetic-password', + ); + expect(link.saved, isFalse); + expect(session.hasSavedNetwork, isFalse); + expect(link.requests.single[0], 1); + await session.dispose(); + await link.dispose(); + }); + test('saved metadata refreshes after later HTTP failure', () async { + final link = LanLink() + ..savedExtension = true + ..failAfterSave = true; + final session = DeviceSession(link); + await session.connect('fake'); + await expectLater( + session.changeLanWindow( + true, + ssid: 'synthetic-net', + password: 'synthetic-password', + rememberNetwork: true, + ), + throwsStateError, + ); + expect(session.hasSavedNetwork, isTrue); + expect(link.arms, 0); + await session.dispose(); + await link.dispose(); + }); + testWidgets('remember choice defaults off and is explicit', (tester) async { + WifiMaintenanceInput? input; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + input = await showDialog( + context: context, + builder: (_) => const WifiSetupDialog(canRememberNetwork: true), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(CheckboxListTile)).value, + isFalse, + ); + await tester.enterText(find.byType(TextField).at(0), 'synthetic-net'); + await tester.enterText(find.byType(TextField).at(1), 'synthetic-password'); + await tester.tap(find.text('记住此网络')); + await tester.pump(); + await tester.tap(find.text('连接网络并开启维护')); + await tester.pumpAndSettle(); + expect(input?.rememberNetwork, isTrue); + expect(find.text('synthetic-password'), findsNothing); + }); + + test( + 'engine snapshots preserve unknown capability and disconnected state', + () async { + for (final failAp in [false, true]) { + final link = LanLink() + ..failLanRead = true + ..failApRead = failAp; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + await engine.connect('fake'); + expect(engine.snapshot()['lanSupported'], isNull); + expect(engine.snapshot()['otaSupported'], failAp ? isNull : isTrue); + expect(engine.snapshot()['maintenancePath'], 'unknown'); + expect(engine.outputAuthorized, false); + expect( + link.writes.where( + (b) => b[1] == BleOpcode.claim.value || b[1] == BleOpcode.arm.value, + ), + isEmpty, + ); + await engine.disconnect(); + expect(engine.snapshot()['lanSupported'], isNull); + expect(engine.snapshot()['otaSupported'], isNull); + await engine.dispose(); + await link.dispose(); + } + final link = LanLink()..extension = false; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + await engine.connect('fake'); + expect(engine.snapshot()['lanSupported'], false); + expect(engine.snapshot()['otaSupported'], true); + await engine.dispose(); + await link.dispose(); + }, + ); + test('UTF8 SSID bytes and ASCII WPA2 password validate exact request', () { + final wire = LanWindowStatus.lanRequest( + open: true, + requestId: 0x12345678, + windowId: 0, + ssid: '网络', + password: 'synthetic-password', + ); + expect(wire.sublist(0, 10), [1, 1, 0x78, 0x56, 0x34, 0x12, 0, 0, 0, 0]); + expect(wire[10], 6); + expect(utf8.decode(wire.sublist(12, 18)), '网络'); + expect( + LanWindowStatus.lanRequest( + open: true, + requestId: 1, + windowId: 0, + ssid: 's' * 32, + password: 'p' * 63, + ).length, + 107, + ); + for (final pair in [ + ('', 'abcdefgh'), + ('界' * 11, 'abcdefgh'), + ('s', '短密码'), + ('s', 'a' * 7), + ('s', 'a' * 64), + ('s', 'abcdefgh\n'), + ('s\u0000', 'abcdefgh'), + ('s\n', 'abcdefgh'), + ('s\u007f', 'abcdefgh'), + ]) { + expect( + () => LanWindowStatus.lanRequest( + open: true, + requestId: 1, + windowId: 0, + ssid: pair.$1, + password: pair.$2, + ), + throwsFormatException, + ); + } + expect( + LanWindowStatus.lanRequest(open: false, requestId: 2, windowId: 61), + [1, 2, 2, 0, 0, 0, 61, 0, 0, 0, 0, 0], + ); + expect( + () => LanWindowStatus.lanRequest( + open: false, + requestId: 2, + windowId: 61, + password: 'abcdefgh', + ), + throwsFormatException, + ); + }); + test( + 'ready status requires actual IP and token; public snapshot never includes token', + () { + final link = LanLink() + ..state = 2 + ..window = 61; + final raw = link.status(), parsed = LanWindowStatus.decode(link.status()); + expect(parsed.url, 'http://192.168.1.99/'); + expect(parsed.uploadToken, LanLink.testToken); + expect(jsonEncode(parsed.toUiJson()), isNot(contains(LanLink.testToken))); + for (final bad in [ + raw.sublist(0, 24), + [...raw]..[16] = 0, + [...raw]..[16] = 239, + [...raw]..[21] = 1, + [...raw]..[24] = 0x41, + [...raw]..[3] = 7, + [...raw, 0], + ]) { + expect(() => LanWindowStatus.decode(bad), throwsFormatException); + } + final closed = LanLink().status(); + expect(LanWindowStatus.decode(closed).toUiJson()['url'], isNull); + }, + ); + test( + 'unknown LAN read with AP ClosedBusy never CLAIMs; both unknown stay paused', + () async { + for (final both in [false, true]) { + final link = LanLink() + ..state = 2 + ..window = 61 + ..failLanRead = true + ..failApRead = both; + final session = DeviceSession(link); + await session.connect('fake'); + expect(session.lanSupported, isNull); + expect(session.maintenanceMode, isTrue); + expect(session.maintenancePath, 'unknown'); + expect( + link.writes.where((b) => b[1] == BleOpcode.claim.value), + isEmpty, + ); + await expectLater(session.arm(), throwsStateError); + await expectLater(session.changeOtaWindow(true), throwsStateError); + await expectLater(session.changeOtaWindow(false), throwsStateError); + expect(session.activeMaintenanceWindow, isNull); + expect(session.maintenancePath, 'unknown'); + expect(link.arms, 0); + await session.dispose(); + await link.dispose(); + } + }, + ); + test( + 'connected unknown can reconnect to actual LAN window without ARM', + () async { + final link = LanLink() + ..state = 2 + ..window = 61 + ..failLanRead = true; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + expect(engine.otaMaintenance, isTrue); + await expectLater(engine.exitOtaMaintenance(), throwsStateError); + link.failLanRead = false; + await engine.reconnectOtaMaintenance(); + expect(session.maintenancePath, 'lan'); + expect(session.lanWindow?.isOpen, isTrue); + expect(link.arms, 0); + expect(link.writes.where((b) => b[1] == BleOpcode.claim.value), isEmpty); + }, + ); + test( + 'missing LAN extension is unsupported without affecting AP or old control', + () async { + final link = LanLink()..extension = false; + final session = DeviceSession(link); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + expect(session.lanSupported, isFalse); + await expectLater( + session.changeLanWindow(true, ssid: 's', password: 'abcdefgh'), + throwsStateError, + ); + expect(link.requests, isEmpty); + expect(session.otaSupported, isTrue); + await session.arm(); + }, + ); + test( + 'LAN connecting is polled to real Ready, credentials omitted from snapshots, exit paused', + () async { + final link = LanLink(); + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final arms = link.arms; + final opening = engine.openOtaWindow( + lan: true, + ssid: 'private-network', + password: 'private-password', + ); + await Future.delayed(const Duration(milliseconds: 30)); + expect(engine.outputAuthorized, isFalse); + expect(link.state, 1); + await opening; + expect(session.lanWindow?.isOpen, isTrue); + expect(link.requests.length, 1); + await expectLater(session.changeOtaWindow(true), throwsStateError); + expect(session.maintenancePath, 'lan'); + expect(session.activeMaintenanceWindow?.isOpen, isTrue); + expect(link.largeWrites.single, greaterThan(20)); + final snapshot = jsonEncode(engine.snapshot()); + for (final secret in [ + 'private-network', + 'private-password', + LanLink.testToken, + ]) { + expect(snapshot, isNot(contains(secret))); + } + expect(engine.snapshot()['maintenancePath'], 'lan'); + await engine.closeOtaWindow(); + expect(link.requests.last[1], 2); + await engine.exitOtaMaintenance(); + expect(link.arms, arms); + expect(engine.outputAuthorized, isFalse); + }, + ); + test( + 'low MTU fails before credential write and keeps maintenance stop lock', + () async { + final link = LanLink()..rejectMtu = true; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final arms = link.arms; + await expectLater( + engine.openOtaWindow( + lan: true, + ssid: 's', + password: 'private-password', + ), + throwsStateError, + ); + expect(link.requests, isEmpty); + expect(engine.outputAuthorized, isFalse); + await expectLater(engine.arm(), throwsStateError); + expect(link.arms, arms); + }, + ); + test('structured connection timeout cannot be reported as Ready', () async { + final link = LanLink()..failWifi = true; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + await expectLater( + engine.openOtaWindow(lan: true, ssid: 's', password: 'private-password'), + throwsStateError, + ); + expect(session.lanWindow?.state, 6); + expect(session.lanWindow?.detail, 2); + expect(engine.otaNotice, isNot(contains('已确认连接'))); + expect(engine.outputAuthorized, isFalse); + }); + test( + 'already-open LAN reconnect skips CLAIM and controls until confirmed close', + () async { + final link = LanLink() + ..state = 2 + ..window = 61; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + expect(link.writes, isEmpty); + expect(session.maintenancePath, 'lan'); + await expectLater(engine.arm(), throwsStateError); + await engine.closeOtaWindow(); + await engine.exitOtaMaintenance(); + expect(session.snapshot.token, isNot(0)); + expect(link.arms, 0); + }, + ); + testWidgets( + 'Wi-Fi form is RAM-only, password obscured, no saved password or save option', + (tester) async { + WifiMaintenanceInput? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showDialog( + context: context, + builder: (_) => const WifiSetupDialog(), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.textContaining('不保存到手机'), findsOneWidget); + final fields = tester + .widgetList(find.byType(TextField)) + .toList(); + expect(fields[1].obscureText, isTrue); + expect(fields[1].controller?.text, ''); + expect(find.byType(Checkbox), findsNothing); + await tester.enterText(find.byType(TextField).first, 's'); + await tester.enterText(find.byType(TextField).last, 'short'); + await tester.tap(find.text('连接网络并开启维护')); + await tester.pumpAndSettle(); + expect(find.textContaining('SSID须为'), findsOneWidget); + expect(result, isNull); + await tester.enterText(find.byType(TextField).last, 'synthetic-password'); + await tester.tap(find.text('连接网络并开启维护')); + await tester.pumpAndSettle(); + expect(result?.ssid, 's'); + expect(result?.password, 'synthetic-password'); + expect(find.byType(WifiSetupDialog), findsNothing); + }, + ); +} diff --git a/flutter_app/test/maintenance_ui_test.dart b/flutter_app/test/maintenance_ui_test.dart new file mode 100644 index 0000000..c38edbe --- /dev/null +++ b/flutter_app/test/maintenance_ui_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/main.dart'; +import 'package:satori_manager/runtime/control_client.dart'; + +void main() { + testWidgets('ending a shoot reminds user to switch physical power off', ( + tester, + ) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'sessionPhase': 'readyPaused', + 'outputAuthorized': false, + 'discovered': [], + }); + await tester.pumpWidget(SatoriApp(previewClient: client)); + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + await tester.tap(find.text('结束拍摄')); + await tester.pump(); + expect(find.text('已结束控制。请关闭觉瞳实体电源;软件暂停不等于断电。'), findsOneWidget); + }); + + testWidgets( + 'unknown maintenance shows connection uncertainty, not unsupported firmware', + (tester) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'sessionPhase': 'maintenance', + 'maintenancePath': 'unknown', + 'lanSupported': null, + 'otaSupported': null, + 'outputAuthorized': false, + 'discovered': [], + }); + await tester.pumpWidget(SatoriApp(previewClient: client)); + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('备用设备热点(手动选择)'), 250); + expect(find.textContaining('局域网维护状态未确认'), findsOneWidget); + expect(find.textContaining('备用热点维护状态未确认'), findsOneWidget); + expect(find.textContaining('此设备固件不支持局域网'), findsNothing); + expect(find.text('当前固件不支持无线升级窗口。'), findsNothing); + }, + ); + testWidgets('confirmed missing capability remains unsupported', ( + tester, + ) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'sessionPhase': 'readyPaused', + 'lanSupported': false, + 'otaSupported': false, + 'outputAuthorized': false, + 'discovered': [], + }); + await tester.pumpWidget(SatoriApp(previewClient: client)); + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('备用设备热点(手动选择)'), 250); + expect(find.textContaining('此设备固件不支持局域网'), findsOneWidget); + expect(find.text('当前固件不支持无线升级窗口。'), findsOneWidget); + expect(find.textContaining('维护状态未确认'), findsNothing); + }); +} diff --git a/flutter_app/test/ota_package_test.dart b/flutter_app/test/ota_package_test.dart new file mode 100644 index 0000000..df3b7e1 --- /dev/null +++ b/flutter_app/test/ota_package_test.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/ota_package.dart'; + +Map manifest({int length = 4}) => { + 'schema': 1, + 'board': 'satori_c3_v1', + 'chip': 'esp32c3', + 'image_length': length, + 'sha256': 'ab' * 32, + 'version': '0.2.4', + 'image_format': 'esp-idf-sbv2-rsa3072', +}; +List container(Map json, List image) { + final encoded = utf8.encode(jsonEncode(json)); + final length = encoded.length; + return [ + length & 255, + (length >> 8) & 255, + (length >> 16) & 255, + (length >> 24) & 255, + ...encoded, + ...image, + ]; +} + +void main() { + test('declared whole-bin SHA256 decodes 32 immutable bytes', () { + final parsed = OtaPackageManifest.fromJson(manifest()); + expect(parsed.declaredSha256, List.filled(32, 0xab)); + expect(() => parsed.declaredSha256[0] = 0, throwsUnsupportedError); + }); + test( + 'RSA format declaration is never signature verification or permission', + () { + for (final policy in [false, true]) { + final checked = OtaPackagePreflight.inspect( + manifest(), + actualImageLength: 4, + approvedTrustPolicy: policy, + ); + expect(checked.signatureVerified, isFalse); + expect(checked.authenticityEstablished, isFalse); + expect(checked.mayTransfer, isFalse); + expect( + checked.block, + policy + ? OtaPackageBlock.signatureVerificationUnavailable + : OtaPackageBlock.noApprovedTrustPolicy, + ); + } + }, + ); + test( + 'wrong target, schema, format, unknown and obsolete signature fields fail', + () { + for (final change in >[ + {'board': 'satori_s3'}, + {'chip': 'esp32s3'}, + {'schema': 2}, + {'schema': 1.0}, + {'unknown': true}, + {'image_format': 'unsigned'}, + {'image_format': 'ecdsa-p256-sha256'}, + {'signature': {}}, + {'board': 'satori_c3_v1\nchip=esp32s3'}, + ]) { + expect( + () => OtaPackageManifest.fromJson({...manifest(), ...change}), + throwsFormatException, + ); + } + expect( + () => OtaPackageManifest.fromJson({'schema': 1}), + throwsFormatException, + ); + expect(() => OtaPackageManifest.fromJson(null), throwsFormatException); + }, + ); + test( + 'slot limit includes full signed image; invalid length/mismatch fail', + () { + for (final length in [1, OtaPackageManifest.slotBytes]) { + expect( + OtaPackagePreflight.inspect( + manifest(length: length), + actualImageLength: length, + ).mayTransfer, + isFalse, + ); + } + for (final length in [0, -1, 0x170001, 1.0, '4']) { + expect( + () => OtaPackageManifest.fromJson({ + ...manifest(), + 'image_length': length, + }), + throwsFormatException, + ); + } + for (final actual in [0, -1, 3, 5]) { + expect( + () => OtaPackagePreflight.inspect( + manifest(), + actualImageLength: actual, + ), + throwsFormatException, + ); + } + }, + ); + test( + 'version boundaries, canonical decimal syntax and injection checked', + () { + for (final version in ['0.0.0', '65535.65535.65535']) { + expect( + OtaPackageManifest.fromJson({ + ...manifest(), + 'version': version, + }).version, + version, + ); + } + for (final version in [ + '65536.0.0', + '0.65536.0', + '0.0.65536', + '01.2.3', + '1.2', + '1.2.3.4', + '-1.2.3', + '1.2.3-beta', + '1.2.3\nkey_id=evil', + '1.2.3\r', + ' 1.2.3', + '1.2.3 ', + '1.2.3', + ]) { + expect( + () => + OtaPackageManifest.fromJson({...manifest(), 'version': version}), + throwsFormatException, + ); + } + }, + ); + test('SHA declaration must be exactly 32 lowercase hex bytes', () { + for (final hash in [ + '', + 'ab' * 31, + 'ab' * 33, + 'AB' * 32, + 'gg' * 32, + '${'ab' * 32}\n', + List.filled(32, 0xab), + ]) { + expect( + () => OtaPackageManifest.fromJson({...manifest(), 'sha256': hash}), + throwsFormatException, + ); + } + }); + test( + 'length-prefixed container decodes raw image with immutable ownership', + () { + final raw = container(manifest(), [0xe9, 0, 0, 0]); + final parsed = OtaPackageContainer.decode(raw); + expect(parsed.manifest.version, '0.2.4'); + expect(parsed.imageBytes, [0xe9, 0, 0, 0]); + raw[raw.length - 1] = 1; + expect(parsed.imageBytes.last, 0); + expect(() => parsed.imageBytes[0] = 0, throwsUnsupportedError); + // Tiny unsigned bytes are only structural input, never authenticated. + expect( + OtaPackagePreflight.inspect( + manifest(), + actualImageLength: 4, + ).mayTransfer, + isFalse, + ); + }, + ); + test( + 'truncated/oversized prefix, payload trailing bytes and invalid JSON fail', + () { + final good = container(manifest(), [1, 2, 3, 4]); + for (final raw in >[ + [], + [1, 0, 0], + [0, 0, 0, 0, 1], + [1, 4, 0, 0, 1], + [255, 255, 255, 255, 1], + [5, 0, 0, 0, 1], + [1, 0, 0, 0, 0xff, 1], + [1, 0, 0, 0, 0x7b, 1], + [2, 0, 0, 0, 0x5b, 0x5d, 1], + good.sublist(0, good.length - 1), + [...good, 0], + [...good.sublist(0, good.length - 1), 256], + List.filled(4 + 1024 + OtaPackageManifest.slotBytes + 1, 0), + ]) { + expect(() => OtaPackageContainer.decode(raw), throwsFormatException); + } + }, + ); +} diff --git a/flutter_app/test/ota_window_test.dart b/flutter_app/test/ota_window_test.dart new file mode 100644 index 0000000..d35a07b --- /dev/null +++ b/flutter_app/test/ota_window_test.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/ble_protocol.dart'; +import 'package:satori_manager/core/control_engine.dart'; +import 'package:satori_manager/core/device_session.dart'; +import 'package:satori_manager/core/ota_window.dart'; +import 'package:satori_manager/infrastructure/fake_ble_link.dart'; + +class WindowLink extends FakeBleLink { + bool extension = true, policyReady = true, neverAck = false; + bool disconnectOnHalt = false; + Completer>? nextRead; + int state = 0, ack = 0, window = 0, remaining = 0; + int openCount = 0, staleReads = 0, failConnects = 0; + final otaWrites = >[]; + Future? writeWait; + List status() { + final ssid = state == 2 || state == 3 ? 'Satori-maint' : ''; + final pass = state == 2 || state == 3 ? 'temporary-test-password' : ''; + final b = List.filled(18, 0); + b[0] = 1; + b[1] = state; + b[2] = policyReady ? 0 : 2; + for (final entry in [(4, ack), (8, window), (12, remaining)]) { + for (var i = 0; i < 4; i++) { + b[entry.$1 + i] = (entry.$2 >> (8 * i)) & 255; + } + } + b[16] = ssid.length; + b[17] = pass.length; + return [...b, ...ssid.codeUnits, ...pass.codeUnits]; + } + + @override + Future connect(String id) async { + if (failConnects > 0) { + failConnects--; + throw StateError('Synthetic reconnect failure'); + } + await super.connect(id); + } + + @override + Future> read(String uuid) async { + if (uuid != OtaWindowStatus.uuid) return super.read(uuid); + if (!extension) throw const BleCharacteristicAbsent(); + if (nextRead != null) { + final pending = nextRead!; + nextRead = null; + return pending.future; + } + final saved = ack; + if (neverAck || staleReads-- > 0) ack = 0; + final raw = status(); + ack = saved; + return raw; + } + + @override + Future write(String uuid, List bytes) async { + if (uuid != OtaWindowStatus.uuid) { + if (disconnectOnHalt && bytes[1] == BleOpcode.halt.value) { + await disconnect(); + throw StateError('Link lost while HALT'); + } + return super.write(uuid, bytes); + } + otaWrites.add(List.from(bytes)); + if (writeWait != null) await writeWait; + int u32(int n) => + bytes[n] | bytes[n + 1] << 8 | bytes[n + 2] << 16 | bytes[n + 3] << 24; + final id = u32(2); + if (id == ack) return; + ack = id; + if (bytes[1] == 1) { + openCount++; + state = 2; + window = 42; + remaining = 120000; + } else if (u32(6) == window || state == 0) { + state = 0; + remaining = 0; + } + } + + int get arms => writes.where((b) => b[1] == BleOpcode.arm.value).length; +} + +void main() { + test('extension codec exact wire and malformed status rejects', () { + expect( + OtaWindowStatus.request(open: true, requestId: 0x12345678, windowId: 0), + [1, 1, 0x78, 0x56, 0x34, 0x12, 0, 0, 0, 0], + ); + final link = WindowLink() + ..state = 2 + ..window = 42; + final raw = link.status(); + expect(OtaWindowStatus.decode(raw).password, 'temporary-test-password'); + for (final corrupt in [ + raw.sublist(1), + [...raw, 0], + [...raw]..[3] = 1, + [...raw]..[1] = 7, + [...raw]..[2] = 7, + [...raw]..[18] = 0xff, + ]) { + expect(() => OtaWindowStatus.decode(corrupt), throwsFormatException); + } + expect( + () => OtaWindowStatus.request(open: false, requestId: 1, windowId: 0), + throwsFormatException, + ); + expect( + () => OtaWindowStatus.request(open: true, requestId: 0, windowId: 0), + throwsFormatException, + ); + }); + test( + 'old firmware remains compatible, missing extension never opens', + () async { + final link = WindowLink()..extension = false; + final session = DeviceSession(link); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + expect(session.snapshot.deviceInfo?.protocolVersion, '1.2'); + expect(session.otaSupported, isFalse); + await expectLater(session.changeOtaWindow(true), throwsStateError); + expect(link.otaWrites, isEmpty); + await session.arm(); + }, + ); + test( + 'extension exists but unsigned/signing-not-ready policy blocks open', + () async { + final link = WindowLink()..policyReady = false; + final session = DeviceSession(link); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + expect(session.otaSupported, isTrue); + expect(session.otaWindow?.signingReady, isFalse); + await expectLater(session.changeOtaWindow(true), throwsStateError); + expect(link.otaWrites, isEmpty); + }, + ); + test( + 'lost read ACK retries identical bytes without extending/opening twice', + () async { + final link = WindowLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 20), + ); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + await session.halt(); + link.staleReads = 5; + await session.changeOtaWindow(true); + expect(link.openCount, 1); + expect(link.otaWrites.length, greaterThan(1)); + expect( + link.otaWrites.every( + (b) => b.toString() == link.otaWrites.first.toString(), + ), + isTrue, + ); + expect(session.otaWindow?.isOpen, isTrue); + await session.changeOtaWindow(true); + expect(link.openCount, 1); + await session.changeOtaWindow(false); + expect(session.otaWindow?.isClosed, isTrue); + expect(link.otaWrites.last.sublist(6), [42, 0, 0, 0]); + }, + ); + test( + 'write accepted without matching ACK never reports command confirmed', + () async { + final link = WindowLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 10), + ); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + link.neverAck = true; + await expectLater(session.changeOtaWindow(true), throwsStateError); + expect(link.openCount, 1); + expect(link.otaWrites.length, 4); + expect(session.maintenanceMode, isTrue); + }, + ); + test( + 'close confirmed then exit uses fresh CLAIM and remains paused after loss', + () async { + final link = WindowLink(); + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final arms = link.arms; + await engine.openOtaWindow(); + expect(engine.outputAuthorized, isFalse); + expect(session.snapshot.token, 0); + await expectLater(engine.arm(), throwsStateError); + await engine.closeOtaWindow(); + await engine.exitOtaMaintenance(); + expect(session.snapshot.token, isNot(0)); + expect(engine.outputAuthorized, isFalse); + expect(link.arms, arms); + await link.disconnect(); + await Future.delayed(const Duration(milliseconds: 1250)); + expect(link.arms, arms); + await engine.arm(); + expect(link.arms, arms + 1); + }, + ); + test( + 'maintenance disconnect never reconnects or replays ARM; committed close rejects', + () async { + final link = WindowLink(); + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + await engine.openOtaWindow(); + final arms = link.arms; + link.state = 5; + await session.refreshOtaWindow(); + final count = link.otaWrites.length; + await expectLater(engine.closeOtaWindow(), throwsStateError); + expect(link.otaWrites.length, count); + await link.disconnect(); + await Future.delayed(const Duration(milliseconds: 1250)); + expect(engine.connection, 'disconnected'); + expect(link.arms, arms); + expect(engine.snapshot()['otaWindow'], isNull); + expect(engine.otaNotice, contains('状态未知')); + await expectLater(engine.exitOtaMaintenance(), throwsStateError); + }, + ); + test( + 'disconnect during open rejects late completion without stale credentials', + () async { + final link = WindowLink(); + final session = DeviceSession(link); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + final pending = Completer(); + link.writeWait = pending.future; + final opening = session.changeOtaWindow(true); + final expectation = expectLater(opening, throwsStateError); + await Future.delayed(const Duration(milliseconds: 10)); + await link.disconnect(); + pending.complete(); + await expectation; + expect(session.otaWindow, isNull); + }, + ); + test( + 'HALT disconnect during entry cannot reconnect/ARM, manual reconnect stays paused', + () async { + final link = WindowLink(); + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final arms = link.arms; + link.disconnectOnHalt = true; + await expectLater(engine.openOtaWindow(), throwsA(anything)); + await Future.delayed(const Duration(milliseconds: 1250)); + expect(link.arms, arms); + expect(engine.connection, 'disconnected'); + expect(link.otaWrites, isEmpty); + link.disconnectOnHalt = false; + await engine.connect('fake'); + expect(link.arms, arms); + expect(engine.outputAuthorized, isFalse); + }, + ); + test( + 'reconnect into an existing window skips CLAIM and permits authenticated close', + () async { + final link = WindowLink() + ..state = 2 + ..window = 42 + ..remaining = 120000; + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + expect(link.writes, isEmpty); + expect(session.snapshot.token, 0); + expect(engine.otaMaintenance, isTrue); + await engine.closeOtaWindow(); + await engine.exitOtaMaintenance(); + expect(session.snapshot.token, isNot(0)); + expect(link.arms, 0); + }, + ); + test( + 'old-generation pending read cannot suppress a new connection probe', + () async { + final link = WindowLink(); + final session = DeviceSession(link); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('fake'); + final late = Completer>(); + link.nextRead = late; + final oldRead = session.refreshOtaWindow(); + await Future.delayed(Duration.zero); + await session.disconnect(); + await session.connect('fake'); + expect(session.otaSupported, isTrue); + late.complete(link.status()); + await oldRead; + expect(session.otaSupported, isTrue); + }, + ); + test( + 'exit pause survives failed first reconnect and successful retry', + () async { + final link = WindowLink(); + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final arms = link.arms; + await engine.openOtaWindow(); + await engine.closeOtaWindow(); + await engine.exitOtaMaintenance(); + link.failConnects = 1; + await link.disconnect(); + await Future.delayed(const Duration(milliseconds: 3500)); + expect(engine.connection, 'connected'); + expect(link.arms, arms); + expect(engine.outputAuthorized, isFalse); + }, + ); +} diff --git a/flutter_app/tool/package_ble_delivery.py b/flutter_app/tool/package_ble_delivery.py index 4ad3663..6f5e77a 100644 --- a/flutter_app/tool/package_ble_delivery.py +++ b/flutter_app/tool/package_ble_delivery.py @@ -1,98 +1,105 @@ #!/usr/bin/env python3 -"""Bundle only reviewed public artifacts; never include NVS/config/USB logs.""" +"""Plan a public development bundle; explicit --execute writes a new archive.""" +import argparse import hashlib import json from pathlib import Path +import re +import shutil import subprocess +import tempfile import zipfile APP = Path(__file__).resolve().parents[1] -REPO = APP.parent -FIRMWARE = REPO.parent / "ESP-3RDEYE" -OUTPUT = APP / "build" / "satori-ble-a0-a1.zip" - +PROFILES = ('ble_primary', 'legacy_udp', 'ble_dual_ota', 'ble_wifi_ota_prototype') def digest(data): return hashlib.sha256(data).hexdigest() +class Parser(argparse.ArgumentParser): + def error(self, message): + self.exit(2, 'Invalid arguments; see --help. Never put secrets in argv.\n') -def head(repo): - return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() - +def public_files(firmware, apk, profiles): + files = {'android/app-arm64-v8a-release.apk': apk, + 'docs/BLE_IMPLEMENTATION.md': APP / 'docs/BLE_IMPLEMENTATION.md', + 'docs/BLE_LINUX.md': APP / 'BLE_LINUX.md'} + for name in ('FIRMWARE_SETUP.md', 'SatoriEye_BLE_Protocol_v1.md', + 'satori_ble_v1_2_shared_pairing_vectors.json', + 'satori_ble_v1_1_management_vectors.json', 'satori_ble_v1_golden_vectors.json'): + files['protocol/' + name] = firmware / 'docs/ble/v1' / name + for profile in profiles: + for name in ('app.bin', 'bootloader/bootloader.bin', 'partition_table/partition-table.bin', 'flasher_args.json'): + files['firmware/' + profile + '/' + name] = firmware / 'build' / profile / name + return files -def source_fingerprint(root, directories, extra_files): - paths = set() - for directory in directories: - paths.update(path for path in (root / directory).rglob("*") - if path.is_file() and path.suffix in (".dart", ".cpp", ".hpp", ".h", ".c", ".json", ".xml", ".kt", ".kts", ".yaml", ".txt")) - paths.update(root / item for item in extra_files) - entries = {str(path.relative_to(root)): digest(path.read_bytes()) for path in sorted(paths) if path.is_file()} - return {"sha256": digest(json.dumps(entries, sort_keys=True).encode()), "inputs": entries} +def apk_version(apk, aapt): + result = subprocess.run([aapt, 'dump', 'badging', str(apk)], capture_output=True, text=True, timeout=30) + if result.returncode: + raise ValueError('APK metadata verification failed') + name = re.search(r"versionName='([^']+)'", result.stdout) + code = re.search(r"versionCode='([0-9]+)'", result.stdout) + if not name or not code: + raise ValueError('APK metadata unavailable') + return name[1] + '+' + code[1] +def snapshots(files): + result = {} + for name, path in files.items(): + absolute = path.absolute() + if any(parent.is_symlink() for parent in (absolute, *absolute.parents)): + raise ValueError('Public inputs must not be symbolic links') + result[name] = path.read_bytes() + return result -def main(): - files = { - "android/app-arm64-v8a-release.apk": APP / "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk", - "docs/BLE_IMPLEMENTATION.md": APP / "docs/BLE_IMPLEMENTATION.md", - "docs/BLE_LINUX.md": APP / "BLE_LINUX.md", - "docs/FIRMWARE_SETUP.md": FIRMWARE / "docs/ble/v1/FIRMWARE_SETUP.md", - "protocol/SatoriEye_BLE_Protocol_v1.md": FIRMWARE / "docs/ble/v1/SatoriEye_BLE_Protocol_v1.md", - "protocol/satori_ble_v1_2_shared_pairing_vectors.json": FIRMWARE / "docs/ble/v1/satori_ble_v1_2_shared_pairing_vectors.json", - "protocol/satori_ble_v1_1_management_vectors.json": FIRMWARE / "docs/ble/v1/satori_ble_v1_1_management_vectors.json", - "protocol/satori_ble_v1_golden_vectors.json": FIRMWARE / "docs/ble/v1/satori_ble_v1_golden_vectors.json", - } - # Explicit whitelist: no merged flash, NVS, board configuration, sdkconfig, - # credentials, pairing cards, USB captures or developer build logs. - for profile in ("ble_primary", "legacy_udp"): - for relative in ("app.bin", "bootloader/bootloader.bin", "partition_table/partition-table.bin", "flasher_args.json"): - files[f"firmware/{profile}/{relative}"] = FIRMWARE / "build" / profile / relative - missing = [str(path) for path in files.values() if not path.is_file()] - if missing: - raise SystemExit("Missing build artifacts: " + ", ".join(missing)) - manifest = { - "release": "SatoriEye BLE A0/A1 software candidate", - "app_version": next(line.split(":", 1)[1].strip() for line in (APP / "pubspec.yaml").read_text().splitlines() if line.startswith("version:")) + " release", - "android_abi": "arm64-v8a", - "android_signing": "development key (not a production signing identity)", - "app_base_head": head(REPO), - "firmware_base_head": head(FIRMWARE), - "source_state": "Uncommitted working-tree implementation; HEAD alone does not identify this build", - "hardware_verified": False, - "source_fingerprints": { - "app": source_fingerprint(APP, ("lib", "assets", "android/app/src"), ("pubspec.yaml", "pubspec.lock", "android/app/build.gradle.kts")), - "firmware": source_fingerprint(FIRMWARE, ("main",), ("CMakeLists.txt", "main/Kconfig.projbuild", "dependencies.lock", "sdkconfig.defaults", "sdkconfig.ble_primary.defaults", "sdkconfig.legacy_udp.defaults", "partitions.csv", "build/ble_primary/sdkconfig", "build/legacy_udp/sdkconfig")), - }, - "files": {}, - } - with zipfile.ZipFile(OUTPUT, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=3) as archive: - for name, path in files.items(): - content = path.read_bytes() - archive.writestr(name, content) - manifest["files"][name] = {"bytes": len(content), "sha256": digest(content)} - archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") - archive.writestr("README.txt", """SatoriEye BLE A0/A1 development build -Read docs/BLE_IMPLEMENTATION.md and docs/FIRMWARE_SETUP.md before use. -Android APK is an arm64-v8a release build with a development signing key. -Hardware pairing, movement and background tests are pending. -BLE and legacy_udp are mutually exclusive firmware profiles. Do not combine them. -Firmware directories contain app/bootloader/partition table plus flash offset metadata. -No NVS, credentials, identity, pairing card or board configuration is included. -Do not overwrite existing NVS or calibration during upgrade. Back up the device first. -Built-in product defaults enable output after an authenticated App connection. -Existing maintenance overrides and mechanical calibration remain in effect. -Built-in values are not a claim of completed hardware validation. -Code changes and runnable simulators are in the two source workspaces, not this artifact archive. -The documentation's source-relative links refer to those workspaces. -See manifest.json for every included artifact's size and SHA-256. -""") - sidecar = OUTPUT.with_suffix(".manifest.json") - sidecar.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") - print(f"Bundle: {OUTPUT}") - print(f"Bytes: {OUTPUT.stat().st_size}") - archive_hash = digest(OUTPUT.read_bytes()) - OUTPUT.with_suffix(".zip.sha256").write_text(archive_hash + " " + OUTPUT.name + "\n") - print(f"SHA-256: {archive_hash}") +def create(files, output, version, profiles): + # Exclusive creation; no directory traversal, generated sdkconfig, NVS or logs. + content = snapshots(files) if any(isinstance(item, Path) for item in files.values()) else files + manifest = {'app_version': version, 'profiles': profiles, + 'android_signing': 'not verified by this tool', 'hardware_verified': False, + 'firmware_signature': 'not verified by this bundle tool; use satori_dev package', + 'files': {name: {'bytes': len(data), 'sha256': digest(data)} + for name, data in content.items()}} + with zipfile.ZipFile(output, 'x', compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in content.items(): + archive.writestr(name, data) + archive.writestr('manifest.json', json.dumps(manifest, ensure_ascii=False, indent=2) + '\n') + archive.writestr('README.txt', 'Development artifacts; hardware acceptance pending.\n' + 'Profiles are mutually exclusive. Do not erase NVS or calibration.\n' + 'This archive is not an automatic flash or OTA package.\n') + return {'bundle_written': True, 'sha256': digest(output.read_bytes())} +def main(argv=None): + parser = Parser(description=__doc__) + parser.add_argument('--firmware-root', type=Path, required=True) + parser.add_argument('--apk', type=Path, default=APP / 'build/app/outputs/flutter-apk/app-arm64-v8a-release.apk') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--profile', action='append', choices=PROFILES) + parser.add_argument('--aapt', default=shutil.which('aapt')) + parser.add_argument('--execute', action='store_true') + args = parser.parse_args(argv) + try: + profiles = list(dict.fromkeys(args.profile or ['ble_wifi_ota_prototype'])) + files = public_files(args.firmware_root.absolute(), args.apk, profiles) + if not args.execute: + print(json.dumps({'offline_plan': True, 'bundle_written': False, + 'missing_public_entries': [name for name, path in files.items() if not path.is_file()]})) + return 0 + if any(not path.is_file() for path in files.values()) or not args.aapt: + raise ValueError('Public inputs or aapt unavailable') + expected = next(line.split(':', 1)[1].strip() for line in (APP / 'pubspec.yaml').read_text().splitlines() if line.startswith('version:')) + content = snapshots(files) + with tempfile.TemporaryDirectory(prefix='satori-public-apk-') as directory: + frozen = Path(directory) / 'app.apk' + frozen.write_bytes(content['android/app-arm64-v8a-release.apk']) + actual = apk_version(frozen, args.aapt) + if actual != expected: + raise ValueError('APK does not match current source version') + print(json.dumps(create(content, args.output, actual, profiles))) + return 0 + except Exception as error: + print(json.dumps({'operation_failed': True, 'error_type': type(error).__name__, 'details_withheld': True})) + return 2 -if __name__ == "__main__": - main() +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/flutter_app/tool/tests/test_delivery.py b/flutter_app/tool/tests/test_delivery.py new file mode 100644 index 0000000..39cfad7 --- /dev/null +++ b/flutter_app/tool/tests/test_delivery.py @@ -0,0 +1,55 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile +spec = importlib.util.spec_from_file_location('delivery', Path(__file__).resolve().parents[1] / 'package_ble_delivery.py') +delivery = importlib.util.module_from_spec(spec); spec.loader.exec_module(delivery) +class Tests(unittest.TestCase): + def test_whitelist_excludes_sensitive_inputs(self): + files = delivery.public_files(Path('/synthetic'), Path('/apk'), ['ble_primary']) + self.assertTrue(all(not any(word in name for word in ('sdkconfig','nvs','password','log','key')) for name in files)) + self.assertEqual(len(files), 12) + def test_archive_hashes_and_exclusive_output(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory); item=root/'input'; item.write_bytes(b'public synthetic') + output=root/'out.zip'; delivery.create({'public.txt':item}, output, '0.2.9+11', ['ble_primary']) + with zipfile.ZipFile(output) as archive: + meta=json.loads(archive.read('manifest.json')) + self.assertFalse(meta['hardware_verified']) + self.assertEqual(meta['files']['public.txt']['sha256'], delivery.digest(item.read_bytes())) + with self.assertRaises(FileExistsError): delivery.create({'public.txt':item}, output, 'v', []) + def test_version_reads_actual_apk(self): + import subprocess + result=subprocess.CompletedProcess([],0,"package: name='example' versionCode='11' versionName='0.2.9'") + with patch.object(delivery.subprocess,'run',return_value=result): + self.assertEqual(delivery.apk_version(Path('/synthetic'),'aapt'),'0.2.9+11') + def test_default_plan_never_runs_aapt_or_creates_output(self): + with tempfile.TemporaryDirectory() as directory, patch.object(delivery.subprocess,'run',side_effect=AssertionError): + output=Path(directory)/'out.zip' + self.assertEqual(delivery.main(['--firmware-root',directory,'--output',str(output)]),0) + self.assertFalse(output.exists()) + def test_symlink_inputs_and_parent_links_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);private=root/'private';private.mkdir();secret=private/'secret';secret.write_bytes(b'synthetic private marker') + link=root/'link';link.symlink_to(secret) + parent=root/'public';parent.symlink_to(private,target_is_directory=True) + for path in (link,parent/'secret'): + with self.assertRaises(ValueError):delivery.snapshots({'public':path}) + def test_frozen_bytes_used_for_manifest_and_archive(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);source=root/'file';source.write_bytes(b'first') + frozen=delivery.snapshots({'public':source});source.write_bytes(b'second') + delivery.create(frozen,root/'out.zip','v',[]) + with zipfile.ZipFile(root/'out.zip') as archive: + self.assertEqual(archive.read('public'),b'first') + self.assertEqual(json.loads(archive.read('manifest.json'))['files']['public']['sha256'],delivery.digest(b'first')) + def test_cli_does_not_resolve_away_root_link(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);private=root/'private';private.mkdir();(private/'file').write_bytes(b'synthetic-secret') + link=root/'firmware';link.symlink_to(private,target_is_directory=True) + with patch.object(delivery,'public_files',side_effect=lambda firmware,*a:{'android/app-arm64-v8a-release.apk':firmware/'file'}),patch.object(delivery,'apk_version',side_effect=AssertionError): + self.assertEqual(delivery.main(['--firmware-root',str(link),'--output',str(root/'out.zip'),'--aapt','synthetic','--execute']),2) + self.assertFalse((root/'out.zip').exists())