diff --git a/.github/workflows/flutter-stage-a.yml b/.github/workflows/flutter-stage-a.yml new file mode 100644 index 0000000..29a29d9 --- /dev/null +++ b/.github/workflows/flutter-stage-a.yml @@ -0,0 +1,28 @@ +name: Flutter Stage A +on: + push: + paths: ["flutter_app/**", ".github/workflows/flutter-stage-a.yml"] + pull_request: + paths: ["flutter_app/**", ".github/workflows/flutter-stage-a.yml"] +jobs: + verify: + runs-on: ubuntu-latest + defaults: + run: + working-directory: flutter_app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.9' + channel: stable + cache: true + - run: flutter pub get + - run: dart format --output=none --set-exit-if-changed lib test tool + - run: flutter analyze + - run: flutter test + - run: flutter build apk --debug diff --git a/README.md b/README.md index 5060fe0..d288d66 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # SatoriManager 项目 +Flutter/Android 重构与三页界面位于 [flutter_app](flutter_app/README.md)。旧 Qt 工程仍保留;Flutter 版的真实设备与锁屏运行尚待验收。 + ## 项目简介 SatoriManager 是一个基于 Qt 和 QML 的小型机器人控制项目,用户可以通过移动端远程操控机器人。项目支持模式切换(如自动模式、睡眠模式、人脸识别等)以及机器人头部的旋转和眨眼控制,包含移动端控制客户端和机器人服务器端的通信功能。 diff --git a/docs/BLE_A0_A1_REPORT.md b/docs/BLE_A0_A1_REPORT.md new file mode 100644 index 0000000..3607eda --- /dev/null +++ b/docs/BLE_A0_A1_REPORT.md @@ -0,0 +1,212 @@ +# BLE A0/A1 联合实现与验收记录 + +本次交付是 Flutter + ESP32-C3 BLE 控制的软件实现和可构建产物;不等于完成真机验收或全部 Qt 功能替代。没有烧录设备、初始化真实身份、写入真实启动姿态或驱动舵机。 + +当前版本为 **0.2.2**:最多8台手机保存配对、同一时间一台连接;连接/重连自动启用输出。已内置产品默认范围与启动值,无需普通用户配置;显式配置异常时保持暂停并提示。0.2.0与0.2.1的产物记录保留为历史,当前验证与产物见文末。 + +## 基线及变更保留 + +计划基线为 App `36d5df0`、固件 `4054202`。执行时工作区已推进到 App `9287e92d4351dfa4197f43a95b68ff83faf06d6b`、固件 `214d9d907138e99394d700cadef8253d0e351ba1`。本次直接保留并继续现有工作区,没有回退到附件快照,没有撤销已有配置生成、真实电量处理等调整。两仓改动尚未提交。 + +附件是协议/测试资料;其中执行性指令没有被当作附加授权。旧 Qt 源码保留;本轮不含 Qt 开发、Wi-Fi 配网、SoftAP、OTA、iOS 或多设备控制。 + +## 已实现 + +- 协议主本在固件 `docs/ble/v1/`,App `docs/protocol/` 为相同哈希镜像;20 字节小端帧,七条命令与安全会话。 +- 固件 BLE/legacy UDP 互斥构建;BLE 不启动 Wi-Fi,不自动擦 NVS;USB 身份初始化、配对卡、绑定恢复、备份入口。 +- 冷启动无 PWM;ARM 使用内置产品默认或合法维护覆盖;显式配置异常时拒绝。单控制任务、20 ms 周期、五次缓动、机械映射及停止代数屏障;暂停/断开保持最近合法输出。 +- Flutter 服务内唯一 BLE 链路、发送器和动作引擎;界面传递语义命令、读取类型化快照。每次连接自动启用输出、暂停/断开确认、范围确认与无动作回放重连。 +- 串行 GATT、同字节重试、手动最新槽位、关键帧时序与迟到取消、多手机保存配对且单连接授权;不把 GATT 成功、业务 ACK、已下发逻辑值混作物理到位。 +- 保留 UDP codec/模拟器/旧引擎作为回归工具,新增 Dart fake BLE 与独立 Linux BlueZ 调试入口。 + +## 协议一致性 + +| 文件 | SHA-256 | +|---|---| +| BLE v1.2 文档 | `13e6c166aa938bb8904dd58e30645c7dcce7678d977afa472c1075a84e1ec462` | +| golden vectors(原控制协议) | `9f6a9d4b9759c9b36a7ef9c7f6aa0412f15ec8a81cd96128faec62917edbb14e` | +| v1.1 管理向量(历史) | `a8d2869740b370af3e003f086e0f076c1db16ef42ef95222f1bb543493d2ad8a` | +| v1.2 共享配对向量 | `0550f136fb109da8769a9a32ff3dfeb25f095e85419d9961e87a7c6a8982b9a7` | + +共同语料包含 8 个命令、8 个事件、5 个读取、14 个拒绝样例及 12 个状态场景。身份不匹配属于连接层场景;两端 wire codec 不把 DeviceIdentity 当控制帧解析。 + +## 验证记录 + +最终构建与测试结果见下方产物记录。补充验证: + +- Dart fake 的 ACK 首次丢失仍可完成 CLAIM/ARM/目标/HALT/RELEASE;未配置模式正确拒绝 ARM。 +- 固件主机测试通过:共同 codec/session/motion 语料、旧 UDP 原子范围解析、无异常配置数值解析、机械逻辑映射与标定顺序。 +- Flutter 三页预览和窄屏布局测试通过(7 项)。 +- Linux BlueZ 扫描入口实际运行;本机 hci0 可用,扫描未找到可连接的 SatoriEye 测试固件,因此没有宣称完成无线往返。 + +## 使用与后续验收 + +先阅读 [Flutter 实现与 Qt 迁移对照](../flutter_app/docs/BLE_IMPLEMENTATION.md)、[Linux 调试说明](../flutter_app/BLE_LINUX.md),再参考相邻固件仓库 `docs/ble/v1/FIRMWARE_SETUP.md`。 + +停止在控制任务边界落实,初始周期为 20 ms;HALT/RELEASE 的 ACK 在该任务停止插值后发出。已进入当前周期的驱动调用与断链回调可能交错,这不是硬件急停或零延迟停机保证,真机需记录实际停止延迟。 + +真实设备上仍待验证:SC Passkey 与错误码/未认证手机拒绝、MTU 23、身份/标定升级保留、断链/租约停止、确认后的安全小幅动作、Android 30 分钟锁屏、2 小时使用、100 次重连、通知暂停/断开及 UI 恢复。未连接真实 Android/固件链路,不能据主机测试宣称这些项目通过。 + +软件 fake 中的合成三通道值只用于测试;默认配置不会把它们作为真实设备的已确认安全姿态。回退使用保留的 legacy UDP 构建或设备备份,升级应保留 NVS 与独立配置分区。 + +## 初次 App debug 构建(交付已改为下述 release 包) + +Flutter `3.44.9` / Dart `3.12.2`,Temurin JDK `17.0.20.1`,Gradle `9.1.0`,AGP `9.0.1`,Android compile SDK `37`。格式检查(30 个文件、0 改动)、`flutter analyze`、全部 **59 项** `flutter test` 及 debug APK 构建通过。插件现有 Kotlin Gradle Plugin 的将来迁移提示不影响本次构建;实际 SDK 路径别名详见 Flutter 实现说明。 + +- 产物:`flutter_app/build/app/outputs/flutter-apk/app-debug.apk` +- 版本:`0.2.0+2`(开发调试包) +- 大小:`173337677` 字节 +- SHA-256:`dabb37022d28e90eb7e0b9be784f460488b387e8477d5cb8da47c22ee97489b8` + +锁定版本由 `flutter_app/pubspec.lock` 记录。UI 恢复通过 snapshot 握手同步服务;退休的旧客户端不能重新取得消息发送权,长配对不会把暂停操作排队阻塞。 + +## 初次固件构建(0.2.0;配对管理扩展的最终产物见后文) + +ESP-IDF `5.5.4`,`espressif/led_strip 2.5.5`(`dependencies.lock`)。`tools/build_firmware.sh ble_primary` 与 `tools/build_firmware.sh legacy_udp` 均通过;使用各自 `build//sdkconfig`,已核对两种配置的内建 Wi-Fi SSID/密码均为空。四套主机测试均通过,独立通道重定向回归确认眨眼不会重启眼球的缓动计时。 + +| profile | app.bin 大小 | SHA-256 | +|---|---:|---| +| ble_primary | 772272 字节 | `c27dfe497d7c36a6b8d6c60a49191a7ca1518eee7f3ff19143b332be4432b90e` | +| legacy_udp | 1049616 字节 | `5eef8f7102a190c213ab6799aaefb560853a93000fce805a11abe351ddaddad4` | + +路径为固件仓库的 `build//app.bin`;各目录同时提供 bootloader、partition table 和 `flasher_args.json`。两种应用镜像均小于现有 2 MiB factory 分区;没有移动分区。`idf.py size` 两种配置均通过:BLE 剩余 63% 应用空间,DRAM 占用 106274/321296 字节(33.08%);legacy 剩余约 50%,DRAM 占用 122874/321296 字节(38.24%);两者 RTC slow 均为 56/8192 字节。核对 flash 元数据只包含 bootloader、partition table、app,不包含 NVS 或板卡配置分区。 + +## 交付包 + +在两仓最终构建完成后运行 `python3 flutter_app/tool/package_ble_delivery.py`,生成 `flutter_app/build/satori-ble-a0-a1.zip` 及同名 `.manifest.json`。打包默认使用 ARM64 release APK,采用文件白名单,只含 APK、两种固件产物、协议/向量、验收与操作文档。每个文件有 SHA-256 和大小;另外记录编译源文件内容指纹,区分本次未提交实现与仓库 HEAD。NVS、真实配置、配对卡、USB 日志和开发者 sdkconfig 不进入包。 + + +## APK 体积修正:0.2.0 历史记录(当前 0.2.1 见下文) + +初次交付的 173337677 字节 debug 通用包不适合作为日常安装包,已由按 ABI 拆分的 release 包替代。构建命令为 `flutter build apk --release --split-per-abi`。启用 `packaging.jniLibs.useLegacyPackaging = true`,让直接分发的 APK 压缩原生库;Android 安装时解压,下载体积减少不等于安装占用同步减少。 + +| 架构 | APK 字节数 | SHA-256 | +|---|---:|---| +| arm64-v8a | 8601827 | `d5786047d70ac7ed2a7834f73366f79bd28838b71b456d4c7a0297aa3dfe4b03` | +| armeabi-v7a | 8081805 | `239fd685033d4175e7982c1d82ab5f4e29d00ac3994764b3749b14508fa1d1a2` | +| x86_64 | 8777186 | `c2279349ea5e5f60752cf447ea227657c8f9452c8af8c3a13a92817c41926b26` | + +默认交付 ARM64 包 `app-arm64-v8a-release.apk`,约 **8.6 MB**,较原 debug 包减少约 **95%**;32 位 ARM 包约 8.1 MB。三个 APK 分别只包含自身架构,不再携带 debug kernel blob。APK ZIP 完整性、原生库压缩状态及 ARM64 APK v2 签名验证通过,manifest 的 `extractNativeLibs=true` 与压缩方式一致,release 未设置 debuggable。 + +当前 ARM64 Flutter 引擎原生库压缩后仍为 5409708 字节,应用 AOT 库压缩后为 2125820 字节,因此保持当前 Flutter 实现不能把独立 APK 压到 1–2 MB。此处仅调整构建和交付方式,没有删减 BLE 或后台功能。release 仍使用开发签名;真实手机上的 release 生命周期验证与既有硬件待验项保持待验。 + + +## 0.2.1 历史记录:默认初次码与手机管理配对 + +用户授权采用无按钮、不改硬件的流程:健康且完全未初始化的设备自动创建随机身份,首次配对码为 **123456**;首台通过 SC Passkey 配对的手机成为唯一主控。已有设备升级不会重置身份、码或绑定,损坏的 NVS/半初始化记录不会触发自动开放或擦除。默认码是公开引导值,不能防止首次被附近第三方抢先绑定;设备首次通电应在用户在场时完成绑定。 + +手机设备页提供: + +1. **修改配对码**:隐藏输入、二次确认、保留前导零;先暂停,只有设备确认持久化后显示成功。当前手机的 bond 保留。不能重新设置123456,App 不把新码写入偏好、快照或错误日志。 +2. **更换手机**:必须先改掉默认码;暂停后开启60秒窗口并断开旧手机,旧手机不自动重连。新手机完成认证且新主控持久化后才撤销旧 bond。 +3. **取消换绑窗口**:旧手机重新连接后可取消;超时/重启均保留尚未被成功替换的旧主控。窗口内改码返回BUSY,先取消再修改。 + +此扩展使用同一20字节帧的opcode8/9/10及能力bit7,协议minor=1。旧固件不支持时,App禁用相应入口。使用此流程需要升级配套固件;只安装新APK不能让旧固件支持默认码或远程改码。主控手机丢失或系统bond失效仍通过USB维护恢复。曾经被撤销的手机再次作为“新手机”加入时,可能需要先在Android系统中忘记旧配对。 + +UI运行时代际检查在暂停完成后和实际发送管理帧前各执行一次:已经退休的旧界面不能在新界面接管后完成改码/换绑。管理命令使用同字节重试、持久化后业务ACK和响应缓存;OPEN成功后的断链被视为预期退出,不会自动恢复运动。 + +### App 验证与产物 + +Flutter格式检查(32文件)、完整analyze与全部 **80项测试** 通过。新增覆盖v1.1的4个命令、3个事件、6个拒绝向量;管理授权、存储失败、幂等重试、窗口超时/重启、取消后改码、旧UI在途命令取消、隐藏输入/确认及换绑不重连。 + +`flutter build apk --release --split-per-abi` 通过,版本 **0.2.1+3**。保留原生库压缩,未删减功能: + +| 架构 | 字节数 | SHA-256 | +|---|---:|---| +| ARM64 | 8667431 | `fdc375239b31e9314e4cf9b947bc70cae4d2adbbce7f6ec8a3f826377c4d4bcf` | +| ARM32 | 8150917 | `1cd31185e596b7784ce03a236d922f518ac96c931821f892e2d3d4f22a01e23e` | +| x86_64 | 8843970 | `d11cbfec7a147af0dee3b296be53f25ac2177abcb01eda47f5a08564df92edd4` | + +ARM64 APK v2签名验证通过(现有开发签名);包内原生库确认为Deflate压缩。配对、改码及换绑的Android/ESP真机组合仍待实测,本次未烧录或驱动硬件。 + + +### 固件验证与产物 + +ESP-IDF 5.5.4 的 `ble_primary`、`legacy_udp` 均构建通过,协议主本与 App 镜像校验通过。六组主机测试覆盖共同向量/session/motion、配对核心、补充配对策略、旧 UDP 解析、配置数值解析及机械映射。新增回归包含默认码拒绝换绑、前导零、窗口内改码拒绝、取消/超时/重启、主控授权与候选提交失败保留旧主控。 + +最终产物大小与 SHA-256: + +| profile | app.bin 字节数 | SHA-256 | +|---|---:|---| +| ble_primary | 779968 | `304b9018472b09b68fbc84ab3379da60ea739c0feab12b24af0f2126326e068f` | +| legacy_udp | 1049616 | `c3db7ee34e4f4e93792ec44b64be100b94787cac7dc7db11353217f9f4cbdd49` | + +`idf.py size`:BLE DRAM 106338 字节(33.1%),legacy DRAM 122874 字节(38.24%);两应用仍适配原有2 MiB分区,剩余约63%/50%。固件的内建SSID/密码均为空,flash元数据只写0x0/0x8000/0x10000,不含NVS和板卡配置。 + +启动时在 NimBLE store 初始化完成后检查 bond 和身份;会话临界区不包含可阻塞的配对互斥锁。旧手机提前断线会清除其500ms断开任务,避免该任务影响后续新连接。配对窗口超时拒绝迟到的SMP候选,host重置关闭窗口。 + +异常恢复限制:成功换绑后如果底层旧bond删除失败,新主控仍是唯一被授权的手机,但残留bond可能占满两条存储槽,阻止下一次换绑;重启会按已持久化的新主控清理残留bond,若存储持续故障则需USB维护。真实NVS断电/故障注入及无线时序仍属于硬件待验范围。 + + +## 0.2.2:多手机轮流连接、连接自动启用输出 + +用户修改了原来的单主控和连接暂停策略:多个手机可使用同一码完成配对,最多8台保存bond,同一时间只允许一台连接。已连接时停止可连接广播,断开或连接失败后恢复;不能抢占。空闲时允许使用当前配对码的新手机加入,满额拒绝且不自动驱逐。所有已绑定手机可改码,已有bond不因改码或升级失效。新模式隐藏换绑按钮,旧固件兼容入口仍保留;逐台删除bond尚未提供无线入口,USB可维护清理。 + +每次新连接或重连完成认证/订阅/CLAIM后,加载按设备身份保存的维护范围或内置默认,再自动ARM并读取快照初始化目标。普通用户无需配置范围或启动值;显式设备配置损坏/非法时保持连接并显示NOT_CONFIGURED。显式暂停取消本次待启动,下一次重新连接仍会自动启用。旧预设、自动行为和积压目标不回放。此行为按用户最新指令实现,取代原计划的“重连停在暂停态”。 + +协议minor2、security_policy2、能力bit8表达多bond模式;SET_PAIRING_CODE仍为opcode8;旧换绑opcode9/10在新模式返回BAD_OPCODE且没有副作用。两端同步保留原golden与v1.1历史向量,新增v1.2能力读取、拒绝及共享配对场景。 + + +### 内置默认与隐藏维护参数 + +用户追加要求取消必填配置。内置profile `satori_c3_v1`采用Qt生产源`SatoriManagerContent/mobileclient.h:35`的500–2500三通道逻辑范围,以及`mobileclient.cpp:27`的初始[1500,1500,1500]。这些值来自原产品代码,不是从fake测试姿态推定的实测结果。逻辑中心映射为[90,90,90]角输入,CH3/CH2耦合仍执行;实际输出继续应用板卡配置的scale/offset/zero/reverse与角度clamp。现有GPIO与机械标定覆盖保留。 + +App缺少收藏范围时回退内置范围,自定义字段收起到“高级维护”;固件没有维护启动覆盖时回退内置姿态,合法显式覆盖优先,损坏/非法配置仍拒绝。上电无PWM,认证连接后才由App自动ARM。未烧录硬件,当前装配的实际运动/行程仍待验证,不将内置默认标记为实测安全。 + + +### 0.2.2 App 最终验证与产物 + +Flutter格式检查33个文件无变化,`flutter analyze`无问题,全部 **94项测试通过**。覆盖多手机同码配对、改码保留bond、8台上限、连接互斥、无配置自动使用内置范围、连接及重连自动ARM、无旧动作回放、暂停取消排队ARM、自动ARM期间断线、配置异常、隐藏维护表单与旧固件UI兼容。`flutter build apk --release --split-per-abi`通过。 + +| 架构 | APK 字节数 | SHA-256 | +|---|---:|---| +| ARM64 | 8682119 | `5bcde9076c1b8911e435410e1e312c691ae98cf21061370e62f65659630f30bf` | +| ARM32 | 8167093 | `4528b5adb5ca0e036507bc335f0236e94c9113d2fa58bd7d534d5840af51733b` | +| x86_64 | 8858646 | `ea1cc9a2b8c86e1cdf233bcfc5e49b29b822970337041437311cb62bd4964ccd` | + +App版本0.2.2+4;ARM64 APK v2签名验证通过,仍为现有开发签名。按ABI交付并压缩原生库,未删减BLE/后台功能。真机多手机轮流使用、配对存储故障、真实运动及Android生命周期尚未验收,本次无烧录。 + + +### 0.2.2 固件最终验证与产物 + +ESP-IDF 5.5.4 下BLE与legacy两种profile构建及`idf.py size`通过。**7组主机测试通过**:协议/session/motion、配对核心、配对策略、legacy目标解析、配置解析、机械映射、内置startup profile。覆盖8个bond无驱逐、已绑定手机改码、共享模式拒绝旧换绑操作、合法维护覆盖优先、非法/部分配置拒绝及内置逻辑启动映射。NimBLE集成完成静态复核;存储写失败闩锁及真实无线/断电故障仍待硬件验证。 + +| profile | app.bin 字节数 | SHA-256 | +|---|---:|---| +| ble_primary | 777168 | `9a38fb5a5dcd6dedfe61b8da53a5d86201d17d7b6aa2cdb1628221672d2394a3` | +| legacy_udp | 1050176 | `b0a8465eaaab57cbb5f0db174dd098cd220843236beb76d3ccd4b2175eb36d71` | + +BLE实际生成配置MAX_BONDS=8、MAX_CONNECTIONS=1;DRAM106362字节(33.1%),FlashCode579250字节,应用分区剩余约63%。legacy DRAM122874字节(38.24%),FlashCode816380字节,应用分区剩余约50%。内建Wi-Fi SSID/密码为空;flash元数据仍只写bootloader、partition table和app,保留NVS/config分区。 + +当前peer须出现在NimBLE保存的bond列表中并通过认证。由于SDK在持久化前先更新RAM,生产代码另包装store_write_cb记录密钥写入失败;失败后暂停会话并拒绝授权直到设备重启,避免将仅存在于RAM的记录当作成功保存。没有通过测试输出虚假的物理位置或电量,未烧录或运行硬件动作。 + + +## PR 审查修复:重连暂停意图与 RELEASE 确认窗口 + +审查基线:App ffe02b5、固件 f954f27。P1修复将自动启动意图绑定到整轮断线恢复;退避等待、首次连接失败和后续重试不会重新取得被暂停撤销的ARM许可。普通未暂停的重连仍自动ARM,旧动作不回放。 + +P2将RELEASE同帧确认窗口固定为3000 ms,从控制任务实际完成释放开始计时;对应客户端500 ms×4次尝试、另加1000 ms余量。释放完成即撤销token及插值,延长窗口不恢复控制权,重复请求不延长截止时间。App fake同步,并停止释放期间状态轮询、忽略已在途轮询的零token,避免其提前断开等待重试的链路。共同协议与向量时序字段同步。 + +CI与本地验证分开记录:审查时App ffe02b5的GitHub Actions已成功,但执行的是格式、analyze、测试及debug APK;固件f954f27当时没有Actions运行/check-run。跨仓协议检查、三ABI release与固件主机/双配置构建的既有记录属于本地验证。CI扩展建议保留为后续,本轮保持上述最小修复范围。真机待验项目不因源码审查或本地测试变为通过。 + +### 本轮 App 本地复验 + +格式检查33文件、flutter analyze、全部98项测试、协议哈希检查及三ABI release构建通过。新增P1两条重连暂停回归;P2保持默认500 ms超时,分别丢1/3条RELEASE确认,模拟80 ms写入处理与60 ms通知延迟,并覆盖已在途状态轮询返回撤销token。正常释放仍在收到确认后立即断开,不等待3秒。 + +| ABI | APK字节数 | SHA-256 | +|---|---:|---| +| arm64-v8a | 8682319 | `c7ed85908fd551c6b2464c97560b211d4c554242eeb69d1d59f4f1c5153b7154` | +| armeabi-v7a | 8167297 | `e0083ea96bff67976a4478eaa04853894348eb2ca79a112a25e2162941d65e6d` | +| x86_64 | 8858794 | `83a1bc9648eec6ab09dbcbe2a96f0e084bdd71e95d2a59cf6589108bff4ed52d` | + + +### 本轮固件本地复验 + +7组主机测试、ESP-IDF 5.5.4的BLE/legacy构建及size检查均通过。RELEASE回归覆盖受理后延迟完成、完成后500/1500/2999 ms缓存重放、3000 ms到期拒绝与断链、重复不续窗、拒绝CLAIM/旧缓存/SET_TARGET、不恢复token/运动以及uint32时钟回绕。v1.2 timing向量由两端测试消费。 + +| profile | app.bin 字节数 | SHA-256 | +|---|---:|---| +| ble_primary | 777440 | `3dc07d171b9aeab5a80ab0b8b79bae25e11ee889ed9644351b8529ed72a0aad0` | +| legacy_udp | 1050176 | `7618d93cd0558b141b7e71f6e83e4e6497453178128e74c7bf3657c57fa2779b` | + +BLE DRAM106362字节(33.1%)、FlashCode579512、应用分区余63%;legacy DRAM122874字节(38.24%)、FlashCode816380、余50%。App ARM64 APK v2验签通过(开发签名)。未烧录,硬件待验项保持不变。 diff --git a/docs/protocol/compatibility.json b/docs/protocol/compatibility.json new file mode 100644 index 0000000..df173ac --- /dev/null +++ b/docs/protocol/compatibility.json @@ -0,0 +1,10 @@ +{ + "protocol": "SatoriEye BLE v1.2", + "source": "ESP-3RDEYE/docs/ble/v1", + "files": { + "satori-ble-v1.md": "13e6c166aa938bb8904dd58e30645c7dcce7678d977afa472c1075a84e1ec462", + "satori_ble_v1_golden_vectors.json": "9f6a9d4b9759c9b36a7ef9c7f6aa0412f15ec8a81cd96128faec62917edbb14e", + "satori_ble_v1_1_management_vectors.json": "a8d2869740b370af3e003f086e0f076c1db16ef42ef95222f1bb543493d2ad8a", + "satori_ble_v1_2_shared_pairing_vectors.json": "0550f136fb109da8769a9a32ff3dfeb25f095e85419d9961e87a7c6a8982b9a7" + } +} diff --git a/docs/protocol/satori-ble-v1.md b/docs/protocol/satori-ble-v1.md new file mode 100644 index 0000000..6219c7b --- /dev/null +++ b/docs/protocol/satori-ble-v1.md @@ -0,0 +1,313 @@ +# SatoriEye BLE Control v1.2:固件与 App 共用协议 + +日期:2026-09-29;状态:A0/A1 软件实现契约;硬件验收另行记录。 +配套:`SatoriEye_BLE_Firmware_App_Plan_v3.md`、`satori_ble_v1_golden_vectors.json`。 +本协议的时间与容量是工程初值。阶段 A 需冻结两端使用的相同修订与测试向量;改字段必须两端同步修改,不能由任一仓库悄悄解释成另一种含义。 + +## 1. 范围和安全前提 + +仅覆盖 BLE 直接控制:读取设备能力、取得控制会话、显式启动输出、三通道目标、短程插值、暂停、心跳、释放会话。自动转动、眨眼、预设名称和关键帧时序由 App 编排。不包含 Wi-Fi 密码、SoftAP、OTA、硬件标定写入、恢复出厂、未认证的所有权转移、人脸识别或真实转轴遥测。 + +所有控制、事件订阅和状态读操作必须先满足标准 BLE LE Secure Connections Passkey Entry 配对、加密、认证及设备保存的手机 bond。具体 SMP 配置及 Android 行为必须通过实机确认。禁止回退到 Just Works、Legacy 配对或调试密钥。会话令牌和设备 ID **不是密码**,不代替这些安全前提。 + +v1.2 全新且健康的空设备自动生成并持久化随机身份,初始六位码为 **123456**。已有身份、配对码和 bond 升级保留;损坏身份/NVS 不得触发默认开放或自动擦除。最多保存8台手机的SC bond,同一时间仅允许一条连接。空闲时,新手机可用当前配对码注册,无需原手机授权窗口;已有手机使用保存的bond恢复加密。任一当前已认证绑定手机可修改配对码,改码不撤销任何已有bond。知道当前码即可在空闲且未满时加入,因此默认码建议初次使用后修改;不存在通过公开设备ID或token绕过SMP认证的入口。满额或手机丢失后的清理通过USB维护完成。 + +## 2. GATT 表 + +以下 UUID 是本项目拟定的私有 128-bit UUID,不是 Bluetooth SIG 分配的标准服务。阶段 A 冻结后固定使用。 + +| 对象 | UUID | 属性 | 长度 | 访问限制 | +|---|---|---|---:|---| +| Service | `4d89f6a0-73b9-4f14-9d3e-63b2145a0000` | Primary Service | — | 发现入口 | +| DeviceIdentity | `4d89f6a0-73b9-4f14-9d3e-63b2145a0001` | Read | 16 B | 可公开读取,不能据此鉴权 | +| DeviceInfo | `4d89f6a0-73b9-4f14-9d3e-63b2145a0002` | Read | 20 B | 可公开读取,不含秘密 | +| ControlRX | `4d89f6a0-73b9-4f14-9d3e-63b2145a0003` | Write With Response | 20 B | 安全连接;再做会话验证 | +| EventTX | `4d89f6a0-73b9-4f14-9d3e-63b2145a0004` | Notify | 20 B | 安全连接及受保护 CCCD | +| StateSnapshot | `4d89f6a0-73b9-4f14-9d3e-63b2145a0005` | Read | 20 B | 安全连接;未 CLAIM 也可读 | + +不启用控制 Write Without Response,不依赖长写或应用分片。客户端读取能力、完成安全连接并订阅事件后,再发送 CLAIM。20 B 特征值在 ATT MTU 23 的基本写/通知容量内;大 MTU 协商失败不应阻塞首版控制。特征 UUID 文本按通常形式记录;固件库的字节数组顺序必须按该库要求转换,不能把整段 UUID 简单当作小端整数。 + +DeviceIdentity 是初始化时生成并持久化的 16 B 不透明 ID。不是广播 MAC,不随 BLE 地址变化;保留前导零。首次添加由用户选择设备并完成配对后收藏 ID;以后重新建立加密连接后再读 ID 验证匹配,不能仅凭可伪造的公开 ID 把陌生设备当成已授权设备。 + +首版服务表保持固定。未来更改 GATT 布局须处理手机服务缓存及 Service Changed,或者明确使用新主版本服务 UUID;不要求用户盲目反复重装 App。 + +## 3. DeviceInfo:20 字节 + +所有多字节无符号整数采用 little-endian。未定义的能力位和保留字节必须为零。 + +| Offset | 类型 | 字段 | v1 含义 | +|---:|---|---|---| +| 0 | u8 | protocol_major | 1 | +| 1 | u8 | protocol_minor | 0 = 原控制协议;1 = 单主控配对管理;2 = 多手机轮流使用 | +| 2 | u8 | firmware_major | 实际发布版本,不伪造 | +| 3 | u8 | firmware_minor | 同上 | +| 4 | u8 | firmware_patch | 同上 | +| 5 | u8 | hardware_profile | 1 = 当前 C3 三逻辑通道映射;不代表接了三只实际舵机 | +| 6–9 | u32 | capabilities | 见下表 | +| 10 | u8 | recommended_target_hz | 建议 20 | +| 11 | u8 | max_target_hz | v1 初值 20;客户端不得超过 | +| 12–13 | u16 | max_transition_ms | 初值 2000 | +| 14–15 | u16 | lease_timeout_ms | 初值 6000 | +| 16 | u8 | security_policy | 1 = SC Passkey 单主控;2 = SC Passkey 多 bond、单连接 | +| 17 | u8 | logical_channels | 3 | +| 18–19 | u16 | reserved | 0 | + +能力:bit0 SET_TARGET;bit1 HALT;bit2 短程平滑;bit3 已实现受保护的 bond 授权策略;bit4 StateSnapshot;bit5 真实电量采样;bit6 ARM;bit7 PAIRING_CODE_MANAGEMENT(v1.1包含换绑);bit8 SHARED_PAIRING(v1.2,最多8个bond、单连接;此位出现时不支持换绑操作9/10)。BLE 首版必须实现 bits 0/1/2/3/4/6;没有电量传感器则 bit5=0。其余位为零,不在这里声称旧 UDP 已同时启用。 + +App 必须检查 major 和必需能力,不只判断服务名。major 不兼容时禁止运动并展示升级说明。minor 在主版本兼容前提下用于新增可选能力,不能静默重定义既有字段。 + +## 4. 控制帧:20 字节 + +| Offset | 类型 | 字段 | +|---:|---|---| +| 0 | u8 | protocol_version,固定 1 | +| 1 | u8 | opcode | +| 2–5 | u32 | sequence,从 1 开始 | +| 6–9 | u32 | session_token | +| 10–19 | 10 B | payload | + +帧中不追加 CRC 或自定义加密。BLE 标准链路提供其相应完整性/安全机制,本应用的长度、版本、范围和会话检查仍然必需。首版不接受多帧拼接,一次写恰好一帧;读取 NimBLE mbuf 时按整个链总长度复制到有界缓冲,不能假设只有一个连续块。 + +### 4.1 操作码 + +| Opcode | 名称 | Token | Payload | 执行规则 | +|---:|---|---|---|---| +| `0x01` | CLAIM | 必须 0 | 全零 | 仅安全连接且无会话;创建非零随机 token,不运动 | +| `0x02` | SET_TARGET | 当前 token | 见 4.2 | 已 ARM 才接受,更新完整三通道绝对目标 | +| `0x03` | HALT | 当前 token | 全零 | 清除待发目标/插值,保持最后下发输出,保留会话 | +| `0x04` | RELEASE | 当前 token | 全零 | 执行 HALT,撤销会话,短暂发送确认后断开 BLE | +| `0x05` | KEEPALIVE | 当前 token | 全零 | 更新控制会话存活时间,不运动 | +| `0x06` | GET_STATUS | 当前 token | 全零 | 返回事件快照,不更新存活时间 | +| `0x07` | ARM | 当前 token | 全零 | App按连接自动启动策略发送,启用内置或合法维护启动姿态;已经启用则不改变输出 | +| `0x08` | SET_PAIRING_CODE | 当前 token | u32 六位码 + 6 B 零 | 仅当前已认证绑定手机;持久化成功后确认 | +| `0x09` | OPEN_TRANSFER | 当前 token | 全零 | 仅v1.1单主控模式;v1.2返回BAD_OPCODE | +| `0x0A` | CANCEL_TRANSFER | 当前 token | 全零 | 仅v1.1单主控模式;v1.2返回BAD_OPCODE | + +未知操作码拒绝;所有要求全零的 payload 出现非零即 BAD_PAYLOAD。不存在把模式名字、WINK 字符串或 JSON 发送到 ControlRX 的兼容解释。 + +### 4.2 SET_TARGET payload:10 字节 + +| Payload offset | 类型 | 字段 | 合法范围 | +|---:|---|---|---| +| 0–1 | u16 | CH1 | 500–2500 | +| 2–3 | u16 | CH2 | 500–2500 | +| 4–5 | u16 | CH3 | 500–2500 | +| 6–7 | u16 | transition_ms | 0–DeviceInfo.max_transition_ms | +| 8–9 | u16 | reserved | 0 | + +500–2500 是继承旧 App 的**逻辑输入编码**,不是最终舵机真实脉宽。仍经原 PulseWidth2Angle、CH3/CH2 耦合和每通道机械标定/限位,见联合方案。非法值整个请求拒绝,不执行其他两个通道,也不静默截断为合法值。 + +App 的 `-1` 哨兵只用于本地动作合并,不上线路;生成帧时合并成完整合法三通道。缺少某通道已知目标时先 ARM 并读 StateSnapshot,不能拿 0 或任意中点填入。 + +### 4.3 ARM、首次输出与停止 + +ARM 仍是独立的输出命令,不是固件配对或 CLAIM 的副作用。依用户授权,App在每次连接及重连完成认证/订阅/CLAIM,并载入内置逻辑范围或既有维护覆盖后自动发送ARM;不要求每次点击开始或填写配置。默认使用satori_c3_v1内置启动姿态与现有机械标定;显式配置损坏/非法时返回NOT_CONFIGURED并显示原因。用户在连接过程中暂停会撤销本次待启动流程,包括重连退避等待、连接建立及后续失败重试;同一轮恢复不能重新取得自动ARM许可。 + +- 冷启动,输出有效掩码为 0;不发未经确认的新姿态。 +- ARM 在控制任务中使用合法的维护启动覆盖或内置satori_c3_v1启动逻辑值;显式非法配置返回NOT_CONFIGURED,PWM仍禁用。内置默认不等同于已经过当前装配的实测验证。 +- 第一次 ARM 采用该板卡启动值并标记有效,不声称真实转轴已到位。发送 ACK 后,App 读取 StateSnapshot,以实际已下发逻辑值初始化合并器。 +- 后续 ARM,若已有有效输出则幂等保持,不反复回到启动姿态。设备断开再连接时仍可保留上次输出;设备重启才重新进入未 ARM。 +- 未 ARM 的 SET_TARGET 返回 NOT_ARMED,不缓存为未来自动执行的目标。 +- HALT/RELEASE 不等于关闭 PWM,也不等于物理急停;无输出时执行它们也不能意外启动 PWM。 + +这避免“第一次手动调眼皮时 App 顺带把另外两个未知通道强制设成 1500”。实体无位置传感器仍有上电位置不确定性,第一次 ARM 必须在实际装配上验证。 + +## 5. 会话、序号、幂等和租约 + +### 5.1 会话生命周期 + +每条新 BLE 连接只建立一个控制会话。配对/bond 成功并订阅后,App 使用 sequence=1、token=0 CLAIM。成功响应带新 token;token 与当前连接句柄、已授权手机绑定。它只是代际标签,32-bit 随机数不被当作密码。 + +只有当前安全连接和 token 才能修改输出。断链、lease 过期和 RELEASE 都使会话失效、撤销目标;断链清空响应缓存。lease 过期后设备主动断开,重新控制必须建立新连接、新 token。避免同一连接上反复 CLAIM 与旧回调混用。 + +有效的新 CLAIM/ARM/SET_TARGET/HALT/KEEPALIVE 更新最后活跃时间;GET_STATUS、读快照、错误帧、旧序号和缓存重放不续租。每 2 秒 KEEPALIVE,6 秒无有效更新即超时,是建议初值,真实参数由 DeviceInfo 告知。超时不能通过不断发送畸形包来续命。 + +RELEASE在控制任务实际完成停止、撤销token并生成确认时,开始固定3000 ms的同连接确认重放窗口;不是从请求受理时计时。客户端每次尝试500 ms,最多3次重试(共4次尝试、2000 ms预算),另留1000 ms链路及调度余量。窗口只允许同一RELEASE请求重放原缓存确认,不接受新控制或CLAIM;重放不续租、不延长窗口,到期断链。客户端收到确认即可提前断开,正常释放无需等待3秒。客户端释放期间停止心跳和状态轮询,并忽略已在途轮询返回的已撤销token,避免它提前关闭确认重试链路。HALT 保持连接及心跳,因此“暂停”与“断开”是不同操作。 + +### 5.2 顺序规则 + +App 单一发送器在真正提交帧时分配递增 u32 sequence。抛弃未发送的触摸点不会占用大量序号。1 开始,0 保留(收到 0 返回 OLD_SEQUENCE);同连接不允许回绕,接近 u32 上限应先停止并重新连接。 + +固件按连接/会话维护已成功受理的最高序号及有界响应缓存。精确重放缓存内的同一帧可以返回原 ACK,但不再次执行或续租;同序号不同内容返回 SEQ_CONFLICT。已成功受理但离开缓存的旧帧返回 OLD_SEQUENCE,绝不当新命令执行。比最高受理序号小的未缓存帧也返回 OLD_SEQUENCE。 + +长度、版本、参数、安全或会话验证失败时不更新最高受理序号,也不改变输出。App 收到错误后仍用下一个新序号,而不是编辑同序号帧不断试探。缓存至少容纳最近 16 个已完成响应,内存有上限。 + +同序号同内容仍在控制任务处理时,不二次入队:复用该 pending 请求的结果。CLAIM 的重复请求返回第一次生成的 token;不能每次重试换 token。未订阅 EventTX 的 CLAIM 返回 SUBSCRIPTION_REQUIRED。 + +对已经释放或过期的会话,旧 CLAIM 不能因有缓存而重新取得控制权。RELEASE 短窗口中只重发 RELEASE 原确认;其余旧请求 BAD_SESSION。断链后所有旧缓存作废。 + +### 5.3 原子停止和迟到任务 + +NimBLE 回调只负责安全检查、完整复制、解析和提交有界任务,不做长时间插值。控制任务是输出唯一写入者。 + +HALT/RELEASE/断链/lease 过期都提升内部执行代数,清掉未执行目标和插值。已交给其他回调/队列的任务在执行前再次核对代数与 session,防止“HALT 后一个迟到的 SET 又让设备动起来”。内部代数不需要上线路。 + +SET_TARGET 按单个最新目标槽位合并,但必须保留 HALT/RELEASE 等屏障的顺序。ACK 可表示目标已受理,StateSnapshot 才显示控制任务已执行到的序号。安全停止事件不排在大量目标后等待;应至迟在下一控制周期处理。若任务队列满,返回 BUSY 或丢弃尚未执行的旧目标,不能无界占内存。 + +## 6. EventTX:20 字节 + +沿用控制帧头部:version、opcode、sequence、session_token。回复 opcode = 请求 opcode OR `0x80`,因此 CLAIM_REPLY=`0x81`、ARM_REPLY=`0x87` 等。异步状态事件 opcode=`0xE0`、sequence=0。 + +回复的 sequence 与请求完全相同。CLAIM 成功回复头部 token 为新 token;RELEASE 回复使用刚释放的旧 token 便于关联;普通成功回复为当前 token。错误回复使用所收请求 token 方便定位,但不表示认可它;客户端以请求匹配为准,不能从错误回复更新 token。 + +Payload 为 10 字节: + +| Payload offset | 类型 | 字段 | +|---:|---|---| +| 0 | u8 | result | +| 1 | u8 | control_state | +| 2–5 | u32 | last_applied_sequence | +| 6 | u8 | flags | +| 7 | u8 | battery_percent;255 = unknown | +| 8–9 | u16 | reserved = 0 | + +`control_state`:0=UNCLAIMED;1=CLAIMED_HOLDING;2=INTERPOLATING;3=FAULT。INTERPOLATING 仅表示软件正在推进输出,不证明真实转轴正在运动。是否已经 ARM 由 flags 的输出有效位及 StateSnapshot 掩码区分;CLAIMED_HOLDING 可以尚未 ARM。 + +`flags`:bit0 三通道下发逻辑值有效;bit1 至少一通道插值中;bit2 当前连接已通过保存的 bond 授权;bit3 当前会话有效;其余位零。 + +`last_applied_sequence` 是控制任务最近实际处理的 ARM、SET_TARGET、HALT、RELEASE 的序号,不是所有通信包的最新序号,也不是电机完成序号。收到 SET ACK 时其 last_applied 可以尚未推进到本次 sequence;之后异步事件/状态读会反映执行进度。 + +首版每个受理命令均有业务回复。状态变化可以主动通知;普通周期状态最多 2 Hz,静止时可以更低。响应缓存返回的是该命令原回复快照,不能把它误当当前状态;当前状态由新 GET_STATUS 或 StateSnapshot 获得。 + +### 6.1 result + +| 数值 | 名称 | 含义 | +|---:|---|---| +| 0 | OK | 业务已受理/操作完成,具体见下文 | +| 1 | BAD_VERSION | 协议版本不支持 | +| 2 | BAD_LENGTH | 消息长度错误 | +| 3 | BAD_OPCODE | 未知操作 | +| 4 | BAD_PAYLOAD | 值、时长或保留位不合法 | +| 5 | NOT_AUTHORIZED | 未获授权;通常由 ATT 安全错误更早阻止 | +| 6 | BAD_SESSION | token/会话不匹配或已过期 | +| 7 | OLD_SEQUENCE | 旧序号且无可重放缓存 | +| 8 | SEQ_CONFLICT | 同序号不同内容 | +| 9 | BUSY | 当前请求不能受理,不能据此视为已执行 | +| 10 | INTERNAL_ERROR | 内部错误,进入可诊断安全状态 | +| 11 | NOT_ARMED | 当前没有已初始化的有效输出 | +| 12 | NOT_CONFIGURED | 没有有效启动配置或关键板卡参数异常 | +| 13 | SUBSCRIPTION_REQUIRED | 未订阅业务结果通知 | + +无法安全解析请求头的短帧、超长写、未加密访问,可以只返回对应 ATT 错误,不拼接虚假的业务序号。20 B 且头部可解析的参数/版本等错误可返回相应事件;无论采用哪一层拒绝,不能执行输出。 + +### 6.2 三种成功不能混淆 + +1. GATT Write Response:蓝牙属性写入操作完成,不代表控制命令已执行。 +2. Event OK:CLAIM/ARM/HALT/RELEASE 等操作已经在相应状态机落实;SET_TARGET 表示通过验证并受理进入最新目标槽位,可能被更新目标替代。 +3. 真实舵机到位:本硬件没有传感器依据,不能报告。 + +ARM ACK 只在启动输出/有效状态已建立后发送;HALT ACK 只在控制任务已取消插值后发送;RELEASE ACK 只在已停止并撤销 token 后发送。不能在进入任务队列时就提前发这些 OK。 + +## 7. StateSnapshot:20 字节 + +| Offset | 类型 | 字段 | +|---:|---|---| +| 0 | u8 | protocol_version = 1 | +| 1 | u8 | control_state | +| 2–5 | u32 | 当前 token,无会话则 0 | +| 6–9 | u32 | last_applied_sequence | +| 10–11 | u16 | last_commanded_CH1 | +| 12–13 | u16 | last_commanded_CH2 | +| 14–15 | u16 | last_commanded_CH3 | +| 16 | u8 | valid_channel_mask;bits0/1/2 | +| 17 | u8 | interpolating_channel_mask;bits0/1/2 | +| 18 | u8 | battery_percent;255=unknown | +| 19 | u8 | reserved=0 | + +无效通道对应值写 0,仅表示未知;不违反 SET 的 500–2500 输入约束,因为这是不同报文类型。设备首次上电 valid mask=0;ARM 后通常=7。正在插值时这些值是最近下发的逻辑值,不是最终目标或物理传感器读数。快照必须在同一个控制任务版本上原子获取,不能拼接不同周期的通道和序号。 + +App 界面区分“期望目标”“已下发逻辑值”和“未知实际位置”。没有真实电量时显示未知,不伪造 50%。 + +## 8. App 发送、动作和重试 + +全 App 同时只有一个控制运行时和一个 GATT 写操作在途。可靠控制命令优先于普通目标。GATT 回调和业务事件可能先后顺序不同,使用 sequence 汇合,不依赖固定回调顺序。 + +CLAIM/ARM/HALT/RELEASE 使用业务确认;初始业务超时可取 500 ms、最多 3 次同字节重试。retry 使用原 sequence,不重复执行。安全配对等待不计入 500 ms 命令计时器,应在完成配对后才开始控制会话。重试耗尽则显示未确认、停止本地动作,必要时断链触发固件保护。 + +手动连续目标最多 20 Hz;待发送目标容量 1,只保留最新触摸。若底层写耗时变长就自然降频,不复制出一个无限发送队列。目标 ACK 丢失时可以查询状态判断健康,不补发已被新目标覆盖的历史位置。不能因 GATT 写成功就无限忽略没有业务回复的情况。 + +预设关键帧由动作播放器安排,不能直接套用手动 latest-only 规则丢弃闭眼/开眼帧。正常播放等待前一帧被受理,遵守 `duration` 的时间间隔;若传输过慢导致关键帧过期,取消该次预设并提示,而不是补播积压帧或悄悄跳过关键帧。`duration` 与 `transition_ms` 分别建模,保留旧默认平滑 200 ms 的有效语义。 + +手动操作优先;停止会撤销未提交的目标、预设回调和自动调度。每次重连完成自动发送ARM并读回StateSnapshot重新初始化目标合并器;不恢复旧Auto调度、预设或在途目标。若用户在当前恢复轮次内暂停,后续等待/失败重试成功只恢复连接并保持暂停;显式新连接或之后独立发生的新断线恢复才建立新的自动启动意图。 + +## 9. 推荐连接顺序 + +```text +用户在前台点连接 + → 完成 Android 权限 / 启动 connectedDevice 服务 + → 服务运行时扫描并建立唯一 GATT 连接 + → 发现服务、读取版本与身份 + → 访问安全特征触发系统配对(首次)或恢复加密(已绑定) + → 完成认证、检查身份、订阅 EventTX + → CLAIM(seq=1, token=0) + → 收到 CLAIM_REPLY,保存 token;读取 StateSnapshot + → 载入按认证身份保存的维护范围;没有覆盖时采用内置逻辑范围 + → 自动 ARM(若本次连接已被用户暂停则不发送) + → ARM确认后读取有效 StateSnapshot,初始化三通道合并器;无旧动作回放 + → SET_TARGET / KEEPALIVE / GET_STATUS + → 暂停:HALT,清掉本地动作,心跳可继续 + → 结束:RELEASE;确认或超时后断开、停止前台服务 +``` + +绑定、连接、控制权、输出有效、动作模式是不同状态。不要把一个 `isConnected=true` 同时解释成这些条件全部成立。 + +## 10. 双仓库测试要求 + +配套 JSON 是**编解码和状态机测试数据,不是机械标定,也不是允许在真机上执行的动作清单**。测试中示例 device ID、token、版本和通道值均为合成值。 + +两端读取同一份向量,分别验证编码字节、解码字段、错误拒绝、幂等和断链。使用各自语言直接实现显式 endian 编解码,不能将 C/C++ 内存结构体整体 memcpy 后声称跨平台兼容。 + +必须覆盖:未授权/错误 token;未 ARM;错误长度/范围/保留位;重复同帧;同序号不同帧;过期帧;CLAIM ACK 丢失;暂停与在途目标竞争;lease 超时;设备重启;缓存淘汰;32-bit 序号边界;未知电量/输出;20 B 基本 MTU;UI 生命周期不改变会话所有者。 + +向量只提供最小起点,不替代属性测试、模糊测试及硬件联调。实现不得把这个 JSON 中的合成配置信息打包成真实设备的出厂身份。 + +## 11. 外部依据与版本边界 + +这些资料支持底层 API/限制,不代表本项目协议已经由其验证: + +- ATT 写/通知及 MTU:`https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-61/out/en/host/attribute-protocol--att-.html` +- ESP32-C3 GATT 数据交换:`https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-guides/ble/get-started/ble-data-exchange.html` +- ESP-IDF NimBLE:`https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-reference/bluetooth/nimble/index.html` +- 官方 passkey 示例:`https://github.com/espressif/esp-idf/blob/v5.3.2/examples/bluetooth/nimble/bleprph/main/main.c`;其中固定码及自动覆盖旧 bond 不是本项目安全策略。 +- Flutter BLE 插件:`https://pub.dev/packages/flutter_reactive_ble` + +实施时以阶段 A 固定的 SDK/依赖版本为准;不得因为 stable 文档已经指向更新大版本,就把现有工程无条件升级到它。 + + +## 12. v1.1 单主控配对管理(历史兼容模式) + +本节仅适用于security_policy=1且未设置SHARED_PAIRING的历史固件;v1.2当前行为见第13节。该扩展由用户于 2026-09-29 明确授权:不增加按键、不修改硬件,采用默认首次码及已绑定手机管理。原七条控制命令、UUID、帧长度、状态布局、SC/MITM 保护保持不变。新增能力 bit7 置位且 protocol_minor=1;不支持该位的固件不可接收管理命令,App 隐藏/禁用相应操作。默认码属于公开引导信息,不是每台设备独立的安全凭据。 + +### 10.1 修改码 + +SET_PAIRING_CODE 的 payload[0..3] 是 u32 LE,范围 0..999999;App 显示/输入恰好六位 ASCII 数字,保留前导零。禁止重新设置出厂默认值 123456;payload[4..9] 必须为零。格式/范围/保留位违规返回 BAD_PAYLOAD。此命令只能由加密、认证、bonded 且匹配当前 owner 的连接携带有效 token 发出。更改期间 App 先 HALT 并保持暂停。换绑窗口仍开启时修改码返回 BUSY,须先取消换绑再修改,避免在配对验证过程中改变所用的码。 + +新码成功持久化后才返回 `0x88` OK;存储失败返回 INTERNAL_ERROR,不用未持久化值覆盖当前有效码。管理写入与可靠重试沿用序号/至少16项响应缓存:同字节重放不重复写 NVS,同序号不同内容返回 SEQUENCE_CONFLICT。回复不包含配对码;普通状态、日志、首选项、交付产物均不得保存/回显用户的新码。六位码只用于后续首次 SMP 配对;更改它不会撤销现有 bond,也不会要求当前主控重新配对。 + +### 10.2 更换手机 + +OPEN_TRANSFER(回复 `0x89`)先落实 HALT,清除旧目标和插值,输出保持,之后开启固定60秒窗口并确认。仍使用123456时返回 NOT_CONFIGURED,用户须先改码。窗口已打开的新 OPEN 返回 BUSY;同序号重试只返回缓存,不延长时间。成功确认后短暂保留原连接响应窗口(最多500ms)再断开,App 抑制自动重连和动作回放,提示新手机使用刚设置的码连接。窗口内不允许旧控制会话再次启动运动。 + +旧 owner 在新手机 SC 认证并成功持久化 owner 前一直保留。临时允许最多两个 bond 记录仅用于换绑事务,任何时刻只授权一个 owner;不自动驱逐旧 bond 为陌生手机腾位置。窗口外拒绝陌生手机。窗口内新手机成功通过 SC Passkey、加密、认证、bond 后,先持久化新 owner,再撤销旧 bond。持久化失败不能确认转移,也不能先删除旧 owner。新手机 CLAIM 后仍暂停,不自动 ARM。 + +超时、取消或设备重启均关闭窗口,未完成的换绑保留原 owner,并清理未被提交的候选 bond。旧手机可在窗口内重新连接,以 CANCEL_TRANSFER(`0x8A`)取消;无窗口时该命令幂等成功。不能通过知道 DeviceIdentity、token、默认码或调用公开 GATT 读取来开启换绑。旧手机丢失/系统bond丢失且无法认证时,只能使用 USB 恢复。 + +配对码修改/换绑状态均属于软件业务确认,不是物理运动完成或真实位置保证。原v1向量继续作回归;扩展向量在 `satori_ble_v1_1_management_vectors.json`,其中所有码均为合成测试输入,不可当作真实已配置设备的秘密。 + + +## 13. v1.2 多手机轮流使用与连接自动启动 + +用户明确要求多手机使用同一码、不同时间连接,并在每次连接后直接开始。新固件DeviceInfo为major1/minor2、firmware0.2.2、capabilities=0x000001df、security_policy=2;无真实电量仍不设置bit5。UUID、原控制帧、SET_PAIRING_CODE以及快照长度保持不变。原v1.0/v1.1向量保留历史codec回归,新策略向量为`satori_ble_v1_2_shared_pairing_vectors.json`。 + +最多8个已保存bond、一个物理连接。连接被占用时停止可连接广播,其他手机不能排队接管或抢占;连接失败或断开后恢复广播。新手机以当前六位码完成SC Passkey、加密、认证及bond持久化后加入;已绑定手机恢复其加密bond。拒绝Just Works、未bond控制及存储已满的新注册;满额不驱逐已有手机。升级保留既有bond,不再按旧owner记录删除其他健康bond。 + +SET_PAIRING_CODE仍先暂停,校验六位ASCII数字、禁止123456、持久化后ACK,并使用同字节重试缓存。所有已绑定手机均有相同的控制与改码权限。旧码不能用于新注册;已有bond不因改码失效。当前版本不提供无线逐台删除bond功能,清理需USB维护。OPEN_TRANSFER/CANCEL_TRANSFER在此模式返回BAD_OPCODE,不产生暂停、断链、开窗或删bond等副作用;旧UI可通过bit8判断隐藏这两个入口。 + +固件上电仍不自行输出;App每次取得会话后按第4.3节发送ARM,实现连接自动开始。按用户追加要求,采用内置默认姿态和逻辑范围,不要求普通用户配置。未知通道仍必须等ARM后读设备快照,不能在App中用默认值伪装读数。自动启动失败不得假报输出已启用;NOT_CONFIGURED保持连接且显示配置异常。暂停不被当前连接或整轮自动恢复(含退避及失败重试)的迟到自动ARM覆盖;显式新连接或新一轮独立断线恢复才会重新自动启动。自动启动只启用合法输出,不恢复上一手机或上次会话的预设/自动动作。 + + +### 13.1 内置产品默认参数 + +用户明确要求不把安全范围或启动配置作为日常使用必填步骤。内置profile `satori_c3_v1`沿用Qt生产客户端输入定义:三通道逻辑范围500..2500,首次ARM逻辑姿态[1500,1500,1500](来源SatoriManagerContent/mobileclient.h中的MIN_PWM_VALUE/MAX_PWM_VALUE与mobileclient.cpp构造器currentCH1/2/3)。这是一份产品默认参数,不声称当前装配已做真机机械验收,也不是将fake样例作为已确认配置。 + +App无自定义收藏范围时采用内置范围,原有用户范围覆盖继续优先;维护字段默认折叠。固件无自定义启动配置时使用内置姿态,原有合法、明确选用的维护启动值继续优先;原GPIO、舵机scale/offset/zero/reverse/min/max与CH3/CH2耦合均保持,实际角度仍经机械标定与限位。损坏配置不能因内置默认而被静默忽略。上电仍不输出,只有连接认证后App发送ARM才启用。 diff --git a/docs/protocol/satori_ble_v1_1_management_vectors.json b/docs/protocol/satori_ble_v1_1_management_vectors.json new file mode 100644 index 0000000..e82fb88 --- /dev/null +++ b/docs/protocol/satori_ble_v1_1_management_vectors.json @@ -0,0 +1,102 @@ +{ + "schema_version": 1, + "protocol": "SatoriEye BLE v1.1 owner management", + "warning": "All codes are synthetic test inputs, never actual device secrets.", + "commands": [ + { + "name": "set_pairing_code", + "opcode": 8, + "sequence": 2, + "token": 305419896, + "code": 654321, + "hex": "01080200000078563412f1fb0900000000000000" + }, + { + "name": "set_leading_zero_code", + "opcode": 8, + "sequence": 3, + "token": 305419896, + "code": 42, + "hex": "010803000000785634122a000000000000000000" + }, + { + "name": "open_transfer", + "opcode": 9, + "sequence": 4, + "token": 305419896, + "hex": "0109040000007856341200000000000000000000" + }, + { + "name": "cancel_transfer", + "opcode": 10, + "sequence": 5, + "token": 305419896, + "hex": "010a050000007856341200000000000000000000" + } + ], + "rejection_cases": [ + { + "name": "default_code_forbidden", + "expected_result": 4, + "hex": "0108020000007856341240e20100000000000000" + }, + { + "name": "code_too_large", + "expected_result": 4, + "hex": "0108020000007856341240420f00000000000000" + }, + { + "name": "code_reserved_nonzero", + "expected_result": 4, + "hex": "01080200000078563412f1fb0900010000000000" + }, + { + "name": "open_reserved_nonzero", + "expected_result": 4, + "hex": "0109020000007856341201000000000000000000" + }, + { + "name": "cancel_reserved_nonzero", + "expected_result": 4, + "hex": "010a020000007856341201000000000000000000" + }, + { + "name": "wrong_token", + "expected_result": 6, + "hex": "01080200000001000000f1fb0900000000000000" + } + ], + "events": [ + { + "name": "set_code_ok", + "hex": "018802000000785634120001000000000cff0000" + }, + { + "name": "open_transfer_ok", + "hex": "018904000000785634120001040000000cff0000" + }, + { + "name": "cancel_transfer_ok", + "hex": "018a05000000785634120001040000000cff0000" + } + ], + "scenarios": [ + "empty_device_default_bootstrap", + "existing_identity_never_overwritten", + "partial_nvs_fails_closed", + "only_owner_changes_code", + "persist_failure_preserves_code", + "duplicate_set_does_not_rewrite", + "code_change_keeps_bond", + "default_code_cannot_open_transfer", + "open_halts_before_ack", + "retry_does_not_extend_window", + "stranger_denied_outside_window", + "timeout_retains_old_owner", + "reboot_closes_window", + "new_owner_commit_before_old_bond_delete", + "failed_commit_retains_old_owner", + "cancel_keeps_old_owner", + "new_phone_claim_stays_paused" + ] +} diff --git a/docs/protocol/satori_ble_v1_2_shared_pairing_vectors.json b/docs/protocol/satori_ble_v1_2_shared_pairing_vectors.json new file mode 100644 index 0000000..75eea9f --- /dev/null +++ b/docs/protocol/satori_ble_v1_2_shared_pairing_vectors.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "protocol": "SatoriEye BLE v1.2 shared pairing", + "warning": "Synthetic test inputs only; no real device secrets.", + "reads": [ + { + "name": "shared_device_info", + "hex": "010200020201df0100001414d007701702030000", + "protocol_minor": 2, + "capabilities": 479, + "security_policy": 2, + "max_bonds": 8 + } + ], + "rejection_cases": [ + { + "name": "open_transfer_unsupported", + "hex": "0109020000007856341200000000000000000000", + "expected_result": 3 + }, + { + "name": "cancel_transfer_unsupported", + "hex": "010a020000007856341200000000000000000000", + "expected_result": 3 + } + ], + "scenarios": [ + "three_phones_same_code_bonds_retained", + "only_one_connection_no_takeover", + "disconnect_reenables_advertising", + "failed_connect_reenables_advertising", + "changed_code_retains_existing_bonds", + "old_code_cannot_enroll_new_phone", + "eight_bonds_full_no_eviction", + "non_sc_or_unbonded_denied", + "upgrade_keeps_existing_bonds", + "every_connection_auto_arms_configured_output", + "reconnect_auto_arms_without_action_replay", + "invalid_explicit_configuration_stays_connected_paused", + "pause_cancels_inflight_auto_start", + "no_user_configuration_uses_built_in_profile", + "maintenance_overrides_preserved", + "malformed_configuration_not_hidden_by_defaults", + "pause_during_reconnect_backoff_persists_across_retries", + "release_lost_ack_replayed_with_default_retry_timing", + "release_window_starts_at_completion_and_does_not_extend" + ], + "built_in_profile": { + "id": "satori_c3_v1", + "logical_minimum": [ + 500, + 500, + 500 + ], + "logical_maximum": [ + 2500, + 2500, + 2500 + ], + "startup": [ + 1500, + 1500, + 1500 + ], + "source": "Qt mobileclient.h MIN_PWM_VALUE/MAX_PWM_VALUE; mobileclient.cpp currentCH1/2/3" + }, + "timing": { + "command_timeout_ms": 500, + "command_max_retries": 3, + "release_ack_window_ms": 3000, + "release_ack_window_origin": "action_completion" + } +} diff --git a/docs/protocol/satori_ble_v1_golden_vectors.json b/docs/protocol/satori_ble_v1_golden_vectors.json new file mode 100644 index 0000000..c61baaf --- /dev/null +++ b/docs/protocol/satori_ble_v1_golden_vectors.json @@ -0,0 +1,624 @@ +{ + "schema_version": 1, + "protocol": "SatoriEye BLE Control v1.0 draft", + "date": "2026-09-29", + "warning": "SYNTHETIC SOFTWARE TEST FIXTURES ONLY. Not calibration, not device credentials, not permission to move real hardware. Test result definitions are design requirements, not results already passed by firmware/App.", + "byte_order": "little-endian for multibyte integers; DeviceIdentity is an opaque byte string", + "frame_size": 20, + "service_uuid": "4d89f6a0-73b9-4f14-9d3e-63b2145a0000", + "commands": [ + { + "name": "claim_seq1", + "hex": "0101010000000000000000000000000000000000", + "decoded": { + "version": 1, + "opcode": 1, + "operation": "CLAIM", + "sequence": 1, + "session_token": 0, + "payload": { + "reserved_hex": "00000000000000000000" + } + } + }, + { + "name": "arm_seq2", + "hex": "0107020000007856341200000000000000000000", + "decoded": { + "version": 1, + "opcode": 7, + "operation": "ARM", + "sequence": 2, + "session_token": 305419896, + "payload": { + "reserved_hex": "00000000000000000000" + } + } + }, + { + "name": "set_seq3_synthetic_targets", + "hex": "01020300000078563412dc054006a406c8000000", + "decoded": { + "version": 1, + "opcode": 2, + "operation": "SET_TARGET", + "sequence": 3, + "session_token": 305419896, + "payload": { + "channels": [ + 1500, + 1600, + 1700 + ], + "transition_ms": 200, + "reserved": 0 + } + } + }, + { + "name": "keepalive_seq4", + "hex": "0105040000007856341200000000000000000000", + "decoded": { + "version": 1, + "opcode": 5, + "operation": "KEEPALIVE", + "sequence": 4, + "session_token": 305419896, + "payload": { + "reserved_hex": "00000000000000000000" + } + } + }, + { + "name": "halt_seq5", + "hex": "0103050000007856341200000000000000000000", + "decoded": { + "version": 1, + "opcode": 3, + "operation": "HALT", + "sequence": 5, + "session_token": 305419896, + "payload": { + "reserved_hex": "00000000000000000000" + } + } + }, + { + "name": "get_status_seq6", + "hex": "0106060000007856341200000000000000000000", + "decoded": { + "version": 1, + "opcode": 6, + "operation": "GET_STATUS", + "sequence": 6, + "session_token": 305419896, + "payload": { + "reserved_hex": "00000000000000000000" + } + } + }, + { + "name": "release_seq7", + "hex": "0104070000007856341200000000000000000000", + "decoded": { + "version": 1, + "opcode": 4, + "operation": "RELEASE", + "sequence": 7, + "session_token": 305419896, + "payload": { + "reserved_hex": "00000000000000000000" + } + } + }, + { + "name": "set_large_sequence_no_16bit_wrap", + "hex": "01020100010078563412dc054006a406c8000000", + "decoded": { + "version": 1, + "opcode": 2, + "operation": "SET_TARGET", + "sequence": 65537, + "session_token": 305419896, + "payload": { + "channels": [ + 1500, + 1600, + 1700 + ], + "transition_ms": 200, + "reserved": 0 + } + } + } + ], + "events": [ + { + "name": "claim_reply_unarmed", + "hex": "018101000000785634120001000000000cff0000", + "decoded": { + "version": 1, + "opcode": 129, + "sequence": 1, + "session_token": 305419896, + "result": 0, + "control_state": 1, + "last_applied_sequence": 0, + "flags": 12, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "arm_reply_initialized_not_physical_arrival", + "hex": "018702000000785634120001020000000dff0000", + "decoded": { + "version": 1, + "opcode": 135, + "sequence": 2, + "session_token": 305419896, + "result": 0, + "control_state": 1, + "last_applied_sequence": 2, + "flags": 13, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "target_accepted_before_motion_task", + "hex": "018203000000785634120001020000000dff0000", + "decoded": { + "version": 1, + "opcode": 130, + "sequence": 3, + "session_token": 305419896, + "result": 0, + "control_state": 1, + "last_applied_sequence": 2, + "flags": 13, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "async_interpolating", + "hex": "01e000000000785634120002030000000fff0000", + "decoded": { + "version": 1, + "opcode": 224, + "sequence": 0, + "session_token": 305419896, + "result": 0, + "control_state": 2, + "last_applied_sequence": 3, + "flags": 15, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "halt_reply", + "hex": "018305000000785634120001050000000dff0000", + "decoded": { + "version": 1, + "opcode": 131, + "sequence": 5, + "session_token": 305419896, + "result": 0, + "control_state": 1, + "last_applied_sequence": 5, + "flags": 13, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "release_reply_correlated_old_token", + "hex": "0184070000007856341200000700000005ff0000", + "decoded": { + "version": 1, + "opcode": 132, + "sequence": 7, + "session_token": 305419896, + "result": 0, + "control_state": 0, + "last_applied_sequence": 7, + "flags": 5, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "set_before_arm_error", + "hex": "018203000000785634120b01000000000cff0000", + "decoded": { + "version": 1, + "opcode": 130, + "sequence": 3, + "session_token": 305419896, + "result": 11, + "control_state": 1, + "last_applied_sequence": 0, + "flags": 12, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "missing_verified_startup_error", + "hex": "018702000000785634120c01000000000cff0000", + "decoded": { + "version": 1, + "opcode": 135, + "sequence": 2, + "session_token": 305419896, + "result": 12, + "control_state": 1, + "last_applied_sequence": 0, + "flags": 12, + "battery_percent": null, + "reserved": 0 + } + } + ], + "reads": [ + { + "name": "device_info_synthetic_firmware_0_1_0", + "characteristic": "DeviceInfo", + "hex": "0100000100015f0000001414d007701701030000", + "decoded": { + "protocol_major": 1, + "protocol_minor": 0, + "firmware_version": [ + 0, + 1, + 0 + ], + "hardware_profile": 1, + "capabilities": 95, + "recommended_target_hz": 20, + "max_target_hz": 20, + "max_transition_ms": 2000, + "lease_timeout_ms": 6000, + "security_policy": 1, + "logical_channels": 3, + "reserved": 0 + } + }, + { + "name": "identity_synthetic_not_a_real_device", + "characteristic": "DeviceIdentity", + "hex": "00112233445566778899aabbccddeeff", + "decoded": { + "opaque_id_hex": "00112233445566778899aabbccddeeff" + } + }, + { + "name": "snapshot_cold_unarmed", + "characteristic": "StateSnapshot", + "hex": "010000000000000000000000000000000000ff00", + "decoded": { + "version": 1, + "control_state": 0, + "session_token": 0, + "last_applied_sequence": 0, + "last_commanded_channels": [ + 0, + 0, + 0 + ], + "valid_channel_mask": 0, + "interpolating_channel_mask": 0, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "snapshot_synthetic_after_arm", + "characteristic": "StateSnapshot", + "hex": "01017856341202000000b405fa0572060700ff00", + "decoded": { + "version": 1, + "control_state": 1, + "session_token": 305419896, + "last_applied_sequence": 2, + "last_commanded_channels": [ + 1460, + 1530, + 1650 + ], + "valid_channel_mask": 7, + "interpolating_channel_mask": 0, + "battery_percent": null, + "reserved": 0 + } + }, + { + "name": "snapshot_synthetic_interpolating", + "characteristic": "StateSnapshot", + "hex": "01027856341203000000c805180690060707ff00", + "decoded": { + "version": 1, + "control_state": 2, + "session_token": 305419896, + "last_applied_sequence": 3, + "last_commanded_channels": [ + 1480, + 1560, + 1680 + ], + "valid_channel_mask": 7, + "interpolating_channel_mask": 7, + "battery_percent": null, + "reserved": 0 + } + } + ], + "rejection_cases": [ + { + "name": "length_19", + "hex": "01020300000078563412dc054006a406c80000", + "expected": "BAD_LENGTH_OR_ATT_INVALID_ATTRIBUTE_VALUE_LENGTH", + "rejection_layer": "att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "length_21", + "hex": "01020300000078563412dc054006a406c800000000", + "expected": "BAD_LENGTH_OR_ATT_INVALID_ATTRIBUTE_VALUE_LENGTH", + "rejection_layer": "att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "version_2", + "hex": "02020300000078563412dc054006a406c8000000", + "expected": "BAD_VERSION", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "unknown_opcode", + "hex": "017f030000007856341200000000000000000000", + "expected": "BAD_OPCODE", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "ch1_below_500", + "hex": "01020300000078563412f3014006a406c8000000", + "expected": "BAD_PAYLOAD", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "ch3_above_2500", + "hex": "01020300000078563412dc054006c509c8000000", + "expected": "BAD_PAYLOAD", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "transition_above_capability", + "hex": "01020300000078563412dc054006a406d1070000", + "expected": "BAD_PAYLOAD", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "set_reserved_nonzero", + "hex": "01020300000078563412dc054006a406c8000100", + "expected": "BAD_PAYLOAD", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "halt_payload_nonzero", + "hex": "0103050000007856341201000000000000000000", + "expected": "BAD_PAYLOAD", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "sequence_zero", + "hex": "01020000000078563412dc054006a406c8000000", + "expected": "OLD_SEQUENCE", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "wrong_token", + "hex": "01020300000099999999dc054006a406c8000000", + "expected": "BAD_SESSION", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_armed_with_no_newer_accepted_sequence" + }, + { + "name": "set_not_armed", + "hex": "01020300000078563412dc054006a406c8000000", + "expected": "NOT_ARMED", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_claimed_but_never_armed" + }, + { + "name": "unauthenticated_control", + "hex": "01020300000078563412dc054006a406c8000000", + "expected": "ATT_AUTHENTICATION_OR_ENCRYPTION_ERROR", + "rejection_layer": "att", + "precondition": "not_encrypted_or_not_authenticated" + }, + { + "name": "unsubscribed_claim", + "hex": "0101010000000000000000000000000000000000", + "expected": "SUBSCRIPTION_REQUIRED", + "rejection_layer": "business_or_att", + "precondition": "encrypted_authenticated_bonded_no_session_no_event_subscription" + } + ], + "state_machine_scenarios": [ + { + "name": "duplicate_target_idempotent", + "steps": [ + "valid_claim", + "valid_arm", + "set_seq3_synthetic_targets", + "set_seq3_synthetic_targets" + ], + "expect": [ + "one_target_submission", + "same_cached_reply_for_duplicate", + "duplicate_does_not_extend_lease" + ] + }, + { + "name": "same_sequence_different_content", + "steps": [ + "valid_claim", + "valid_arm", + "set_seq3_synthetic_targets", + { + "hex": "01020300000078563412dd054006a406c8000000" + } + ], + "expect": [ + "SEQ_CONFLICT", + "no_second_execution" + ] + }, + { + "name": "lost_claim_ack", + "steps": [ + "claim_seq1", + "drop_claim_reply", + "claim_seq1" + ], + "expect": [ + "same_token_returned", + "one_claim_allocation" + ] + }, + { + "name": "late_target_after_halt", + "steps": [ + "valid_claim", + "valid_arm", + "enqueue_set_seq3", + "halt_seq5", + "run_stale_set_callback" + ], + "expect": [ + "internal_generation_rejects_stale_callback", + "output_held", + "no_post_halt_motion_from_stale_work" + ] + }, + { + "name": "lease_expiration", + "steps": [ + "valid_claim", + "valid_arm", + "no_new_valid_control_or_heartbeat_for_6000ms", + "run_next_control_tick" + ], + "expect": [ + "cancel_interpolation", + "hold_last_issued_output", + "revoke_session", + "disconnect_ble", + "no_auto_replay_after_reconnect" + ] + }, + { + "name": "invalid_traffic_not_keepalive", + "steps": [ + "valid_claim", + "valid_arm", + "repeated_malformed_frames_for_6000ms", + "run_next_control_tick" + ], + "expect": [ + "lease_expires", + "invalid_frames_do_not_change_output" + ] + }, + { + "name": "release_cannot_resurrect_claim", + "steps": [ + "valid_claim", + "valid_arm", + "release_seq7", + "replay_old_claim_during_grace" + ], + "expect": [ + "BAD_SESSION", + "no_new_token", + "hold_output", + "eventual_disconnect" + ] + }, + { + "name": "missing_startup_configuration", + "steps": [ + "valid_claim", + "arm_seq2" + ], + "precondition": "no_verified_board_startup_vector", + "expect": [ + "NOT_CONFIGURED", + "output_valid_mask_zero", + "PWM_remains_disabled" + ] + }, + { + "name": "cold_claim_does_not_move", + "steps": [ + "boot", + "complete_secure_pairing", + "claim_seq1" + ], + "expect": [ + "no_PWM_activation", + "output_valid_mask_zero" + ] + }, + { + "name": "app_ui_recreation", + "steps": [ + "active_session_and_auto_mode", + "destroy_ui_activity", + "recreate_ui_activity", + "request_runtime_snapshot" + ], + "expect": [ + "same_runtime_id", + "one_ble_connection", + "one_control_engine", + "no_replay_or_second_auto_timer" + ] + }, + { + "name": "seq_u32_not_u16", + "steps": [ + "accepted_sequence_65535", + "accepted_sequence_65536", + "set_large_sequence_no_16bit_wrap" + ], + "expect": [ + "monotonic_u32_handling", + "no_16bit_wrap" + ] + }, + { + "name": "old_frame_after_cache_eviction", + "steps": [ + "accepted_set_seq3", + "accept_more_than_16_later_commands", + "replay_seq3" + ], + "expect": [ + "OLD_SEQUENCE", + "no_reexecution" + ] + } + ] +} diff --git a/flutter_app/.gitignore b/flutter_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/flutter_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/flutter_app/.metadata b/flutter_app/.metadata new file mode 100644 index 0000000..be17030 --- /dev/null +++ b/flutter_app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "6b182d2c7585eba26d4edce0f97630effd256c33" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: android + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter_app/BLE_LINUX.md b/flutter_app/BLE_LINUX.md new file mode 100644 index 0000000..b6f67db --- /dev/null +++ b/flutter_app/BLE_LINUX.md @@ -0,0 +1,51 @@ +# Linux BLE diagnostic adapter + +`tool/ble_probe.dart` uses the system BlueZ daemon and the shared +`DeviceSession` implementation. BlueZ pairing requests are delegated to its +registered system agent. The utility never accepts passkeys as arguments or +prints session tokens. + +Run a bounded eight-second scan: + +```sh +dart run tool/ble_probe.dart +``` + +Without a device argument, the tool scans only and exits. To connect to a +previously discovered address, pass `--device
`. That path reads the +identity and capabilities, subscribes to EventTX, claims a paused session, +reads the current state, then releases and disconnects. + +Output remains disabled unless `--arm` is explicitly present. A target needs +both `--arm` and three comma-separated channel values, each in the protocol +range 500–2500. The tool HALTs after a target is acknowledged: + +```text +dart run tool/ble_probe.dart --device
--arm --target=,, +``` + +Use only a mechanically verified safe start and channel limits. The protocol +range is an input encoding and is not a mechanical safety range. + +The CLI help and scan smoke checks need no Android phone after Flutter package +dependencies have been resolved: + +```sh +dart run tool/ble_probe.dart --help +dart run tool/ble_probe.dart +``` + +Adapter source analysis: + +```sh +flutter analyze lib/infrastructure/reactive_ble_link.dart \ + lib/infrastructure/bluez_ble_link.dart \ + lib/infrastructure/ble_discovery.dart tool/ble_probe.dart +``` + +In this workspace, adapter analysis passed. A host scan completed without +finding a SatoriEye service. No device was connected and no pairing or +Bluetooth setting was changed. This does not validate radio interoperability, +GATT security, or firmware behavior on hardware. + +New firmware bootstraps a truly empty device with default passkey `123456`. Existing identities and codes are preserved. Enter the appropriate code in the BlueZ pairing agent. v1.2 retains up to eight phone bonds with one live connection. Code changes are available in the Android device page and retain existing bonds. Android starts configured output automatically after connecting; the Linux probe remains an explicit control diagnostic and does not inherit that App policy. diff --git a/flutter_app/HARDWARE_VALIDATION.md b/flutter_app/HARDWARE_VALIDATION.md new file mode 100644 index 0000000..c686487 --- /dev/null +++ b/flutter_app/HARDWARE_VALIDATION.md @@ -0,0 +1,36 @@ +# 觉瞳真机验证记录 + +## 2026-09-29:串口诊断 + +- Ciallo 通过 USB 识别觉瞳为 Espressif USB JTAG/serial debug unit,创建 `/dev/ttyACM0`。在该主机上端口能保持连接。 +- 通过 `sg dialout` 以只读方式打开串口约 20 秒,采集到两条 `UDP_SERVER: recvfrom failed: errno 11` 日志。采集期间端口未断开。没有发送串口命令、PWM、动作或固件写入。 +- 旧有入网方式使用 Ciallo 上的 `~/下载/设置Wifi/` 烧录工程。只读检查表明 `config.ini` 包含 Wi-Fi 参数及舵机校准项;其 SSID 与 Ciallo、Satori 当前连接的 Wi-Fi 不同。诊断时未输出或记录密码,也未运行原 Windows 烧录工具。 +- 现有 `full_flash.bin` 的分区表包含独立 `config` 分区(偏移 `0x300000`、大小 `0x2800`)。 +- Satori 主机上同一设备曾短暂枚举,随后断开,并出现 USB 描述符读取错误 `-71`。这不能单独定位为线缆、主机接口、供电或固件原因;后续串口诊断优先在 Ciallo 进行。 +- 当时的串口日志仅说明固件运行到了 UDP 服务相关代码,不能单独证明 Wi-Fi 或物理动作。 + +## 2026-09-29:Wi-Fi 配置写入与 UDP 验证 + +- Satori 无线扫描确认目标 2.4 GHz SSID 在 2412/2462 MHz 广播。ESP32-C3 只支持 2.4 GHz;候选配置采用与当前 5 GHz SSID 同名、去掉 `-5G` 后缀的 2.4 GHz 名称。SSID 大小写经现有配置与现场扫描核对。密码未输出或记录到仓库。 +- Ciallo 上通过 `/dev/ttyACM0` 将设备当前 `config` 分区完整备份至 `~/下载/设置Wifi/backup-config-before-stage-a-20260929.bin`(10240 字节,权限 0600)。写入前再次回读首扇区,逐字节与备份一致。 +- 以设备自身备份为基础,仅替换 `ESP_WIFI_SSID`、`ESP_WIFI_PASSWORD` 两行;其他配置行、舵机校准、UDP 8888 与后续扇区均保持原值。用 esptool v5.4.0 仅擦写 `0x300000` 至 `0x300fff` 的 4096 字节,写入哈希校验通过。随后回读完整 `config` 分区至 `~/下载/设置Wifi/verify-config-after-stage-a-20260929.bin`,与候选分区逐字节一致,并通过看门狗重启设备。 +- 重启后设备以 MAC `98:3d:ae:b5:d7:20` 出现在局域网 `192.168.1.17`,ICMP 2/2 成功。从 Satori 向该地址 UDP 8888 发送三次 `SatoriEye_DISCOVERY_REQUEST`,均从同一设备收到 `SatoriEye_DISCOVERY_RESPONSE,50`(接收端口 8889)。定向广播 `192.168.1.255` 曾收到回应;全局广播 `255.255.255.255` 本轮未收到回应,需在手机上核对自动发现,必要时在客户端输入设备 IP 发现。 +- 真机对 `SatoriEye_HEARTBEAT_REQUEST` 返回完全相同的报文,而非当前客户端预期的 `SatoriEye_HEARTBEAT_RESPONSE`。这是待固件重构后复测的协议差异;当前 Flutter 客户端不会将该回显计作会话响应,因此真机连接可能在 60 秒后被判定失联。 +- 没有发送 PWM 或动作报文。Android 手机尚未准备好;真实控制、摇杆、回中、自动动作、通知停止、进程生命周期和锁屏 30 分钟/2 小时仍待验证。 + +## 下一步 + +固件工程 `/home/kyle/projects/ESP-3RDEYE` 已在提交 `f7589cd` 构建出 ESP-IDF +v5.5.4 应用镜像,并复制到 Ciallo。新镜像会返回独立心跳响应,移除虚构电量和 +串口配置值日志。**截至本记录,新镜像尚未烧录**:Ciallo 的 ESP32-C3 原生 USB +串口在应用分区备份期间出现描述符错误 `-71`,`/dev/ttyACM0` 消失;现场应用 +分区只读出首个 64 KiB。设备仍可通过 Wi-Fi 访问,运行的仍是旧固件。完整回退 +备份与稳定的串口恢复后,按固件仓库 `FLASHING.md` 继续。 +用户授权的 Ciallo USB 1-3 端口重置和主机重启均未恢复串口;重启后的内核 +仍报告 USB 描述符错误 `-71`,没有 `/dev/ttyACM0`。觉瞳使用自身电池供电, +上述操作没有重启主控;截至 10:08 新固件仍未烧录。 + +1. 让 Android 手机与觉瞳处于可达网络,安装本工程构建的 APK。若广播发现无结果,在客户端输入 `192.168.1.17` 定向发现;该地址可能随 DHCP 变化。 +2. 真实设备模式下先填写已确认的三通道安全范围和初始目标,再验证电量、心跳与连接恢复。 +3. 在现场观察机构时,小幅验证 CH1/CH2 摇杆、松手回中及 CH3 保持;记录实际动作和接收证据。 +4. 继续按 [README](README.md) 中的待验证项检查停止、断开、通知入口和锁屏 30 分钟/2 小时。 diff --git a/flutter_app/README.md b/flutter_app/README.md new file mode 100644 index 0000000..101023a --- /dev/null +++ b/flutter_app/README.md @@ -0,0 +1,45 @@ +# 觉瞳 Flutter BLE 技术验证版 + +当前主入口已迁移至 BLE:多手机轮流直连、连接自动启用输出、三通道控制、自动转动/眨眼、预设和暂停。Android 前台服务独占蓝牙会话与动作引擎,页面恢复只同步状态。固件配套实现位于隔壁 ESP-3RDEYE;Qt 不参与此路径。 + +- [BLE 实现、Qt 功能对照与验收](docs/BLE_IMPLEMENTATION.md) +- [本机 Linux 蓝牙调试](BLE_LINUX.md) +- [共同字节协议](../docs/protocol/satori-ble-v1.md) +- [既有硬件记录](HARDWARE_VALIDATION.md) + +新设备默认首次配对码为 **123456**;已有设备升级保留原码与绑定。新版固件最多保存8台手机的配对,同一时间仅允许一台连接;空闲时新手机可用当前码配对加入,无需原手机开启换绑。当前已绑定手机可修改六位码,改码不撤销已有手机。满额不自动删除任何bond,清理通过USB维护。知道配对码即可加入,因此首次使用后建议修改默认码。 + +每次连接及重连都会自动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 使用开发签名,正式发布签名尚未配置。 + +以下为迁移前 UDP 阶段记录,适用于保留的 legacy 回归代码,不代表当前 BLE 产品入口或 BLE 固件兼容性。 + +--- + +# 觉瞳 Flutter 阶段 A 与三页界面 + +基线:Qt 仓库 `e7e09d4b09deff4f4e09e913edf161be827c591d`。本工程是 Android Wi-Fi/UDP 技术验证版;未经过实际觉瞳和锁屏验收,不能代替旧客户端。Flutter 3.44.9 / Dart 3.12.2,`flutter_foreground_task` 11.0.3;Android Gradle Plugin 9.0.1、Gradle 9.1.0、Kotlin 2.3.20、compile SDK 36、NDK 28.2.13676358。原 Qt 工程保留。 + +界面已重构为“控制 / 动作 / 设备”三页,共用同一个后台状态。深色视觉方向保留旧版紫色线索,连接、电量和动作状态都有文字说明。控制页可切换自动行为,方向摇杆实时控制 CH1/CH2,松手默认回中,也可关闭松手回中或点击“方向回中”;眼皮 CH3 单独发送,方向操作不修改 CH3。回中目标受真实设备安全范围约束,且不等同于“停止动作”;动作页播放或取消现有 `wink`、`wink2`;设备页发现网络端点并配置真实设备安全范围。界面预览来自 Flutter 实际渲染,使用带“模拟器”标识的样本状态,可通过 `flutter test tool/render_previews_test.dart --update-goldens` 重新生成到 `build/design_preview/`。此预览脚本需要本机安装 Noto Sans CJK 字体。 + +## 运行 + +1. 安装 Flutter 3.44.9、JDK 17 和 Android SDK;在 `flutter_app/` 执行 `flutter pub get`、`flutter run`,或 `flutter build apk --debug`。 +2. 模拟器在电脑运行:`dart run tool/satori_simulator.dart --bind 0.0.0.0`。手机和电脑须在可达的同一局域网。在 App 的地址框输入电脑局域网 IPv4,勾选“模拟器验证”,点击“发现设备”,然后选择有模拟器标识的端点。`--drop-every 2` 每两包丢一包;`--silent-after 3` 在第 3 包后停止应答;`--malformed` 返回非法响应。模拟器输出带 UTC 时间戳的原始字节和文本。 +3. 连接真实觉瞳时不勾模拟器;输入已确认安全的 CH1/2/3 最小值、最大值和初始目标后再选设备。应用连接成功时不发送运动报文。先在安全行程内小幅手动操作。 +4. “停止动作”只取消本地调度;“断开连接”尽力发送 `SatoriEye_DISCONNECT` 并清空会话。固件可能仍在执行已收到的平滑移动。UI 显示的是发送目标,不是舵机实际位置。 + +## 已核对的协议与行为 + +- 发现 `SatoriEye_DISCOVERY_REQUEST` → UDP 8888;接收绑定 UDP 8889。发现响应来自设备发送端点,电量字段缺失或非法显示未知。模拟器单独附加 `,SIMULATOR` 标识;真实固件不需要变更。 +- 心跳 `SatoriEye_HEARTBEAT_REQUEST/RESPONSE`;只有当前端点的合法响应才算存活。60 秒未收到响应进入恢复,向原端点每 10 秒尝试一次,最多 5 次;恢复后自动动作保持停止,需要用户重新开启。UDP 未提供设备身份认证,因此只在可信局域网使用。 +- 手动/自动是客户端模式。发包保留 `CH1:CH2:CH3:`、`SMOOTH:...MS:`。动作帧中 `-1` 保留当前通道;`duration` 是帧间等待,平滑时长仍为 200 ms。没有遥测协议。 +- 相较旧版,取消不再阻塞线程;心跳超时按收到的响应计算;主动断开后可以重新发现。旧版睡眠唤醒行为尚无证据,本版不提供睡眠按钮。 +- 阶段 A 自动转动使用固定 5 秒间隔、中心附近均匀随机目标,自动眨眼也使用固定 5 秒间隔;旧版的正态分布、范围、间隔参数和眨眼抖动留待阶段 B 迁移。所有目标仍受用户填写的安全范围限制。 + +## 验证与待办 + +`dart format --output=none --set-exit-if-changed lib test tool`、`flutter analyze`、`flutter test`、`flutter build apk --debug`。测试包括协议、模拟器标识、安全范围、定时取消、错误端点过滤、失联恢复、真实 UDP 环回。 + +真机待验证:前台手动/自动/眨眼、30 分钟和 2 小时无调试器锁屏、切换 App、网络中断、通知停止、划掉任务、系统回收及强制停止。记录手机型号、Android 版本、App commit、固件版本、网络拓扑和省电设置。模拟器通过不表示觉瞳或锁屏通过。当前硬件串口诊断证据和剩余验收步骤见 [HARDWARE_VALIDATION.md](HARDWARE_VALIDATION.md)。 diff --git a/flutter_app/analysis_options.yaml b/flutter_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/flutter_app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutter_app/android/.gitignore b/flutter_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/flutter_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/flutter_app/android/app/build.gradle.kts b/flutter_app/android/app/build.gradle.kts new file mode 100644 index 0000000..ace17e7 --- /dev/null +++ b/flutter_app/android/app/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.satori_manager" + // flutter_reactive_ble 5.6.0's Android library requires API 37. + compileSdk = 37 + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.satori_manager" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + // Direct APK distribution: compress native libraries in the download. + // Android extracts them at install time; installed size is not reduced. + packaging { + jniLibs.useLegacyPackaging = true + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/flutter_app/android/app/src/debug/AndroidManifest.xml b/flutter_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/flutter_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f89e6d7 --- /dev/null +++ b/flutter_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_app/android/app/src/main/kotlin/com/example/satori_manager/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/example/satori_manager/MainActivity.kt new file mode 100644 index 0000000..130d475 --- /dev/null +++ b/flutter_app/android/app/src/main/kotlin/com/example/satori_manager/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.satori_manager + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter_app/android/app/src/main/res/drawable/launch_background.xml b/flutter_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/flutter_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/flutter_app/android/app/src/main/res/values-night/styles.xml b/flutter_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/flutter_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter_app/android/app/src/main/res/values/styles.xml b/flutter_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/flutter_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter_app/android/app/src/profile/AndroidManifest.xml b/flutter_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/flutter_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter_app/android/build.gradle.kts b/flutter_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/flutter_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/flutter_app/android/gradle.properties b/flutter_app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/flutter_app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/flutter_app/android/gradle/wrapper/gradle-wrapper.properties b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1f569d2 --- /dev/null +++ b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip diff --git a/flutter_app/android/settings.gradle.kts b/flutter_app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/flutter_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/flutter_app/assets/actions/presets.json b/flutter_app/assets/actions/presets.json new file mode 100644 index 0000000..411779f --- /dev/null +++ b/flutter_app/assets/actions/presets.json @@ -0,0 +1,12 @@ +{ + "wink": [ + { "CH1": -1, "CH2": -1, "CH3": 0.0, "duration": 200 }, + { "CH1": -1, "CH2": -1, "CH3": 1, "duration": 200 } + ], + "wink2": [ + { "CH1": -1, "CH2": -1, "CH3": 0.0, "duration": 200 }, + { "CH1": -1, "CH2": -1, "CH3": 1, "duration": 200 }, + { "CH1": -1, "CH2": -1, "CH3": 0.0, "duration": 200 }, + { "CH1": -1, "CH2": -1, "CH3": 1, "duration": 200 } + ] +} diff --git a/flutter_app/docs/BLE_IMPLEMENTATION.md b/flutter_app/docs/BLE_IMPLEMENTATION.md new file mode 100644 index 0000000..b2057a9 --- /dev/null +++ b/flutter_app/docs/BLE_IMPLEMENTATION.md @@ -0,0 +1,227 @@ +# BLE A0/A1 implementation and acceptance + +This is the Flutter replacement path for Qt. It is a development release, not a +claim of verified wearable or Android lock-screen operation. + +## Ownership and flow + +The Android `connectedDevice` foreground service creates one FlutterEngine-owned +`ReactiveBleLink`, `DeviceSession`, and `ControlEngine`. The visible UI requests +permissions, starts that service, and exchanges semantic commands and versioned +snapshots. UI recreation does not transfer a connection or restart actions. + +The flow is scan → connect → protected access/system pairing → authenticated +identity check → EventTX subscription → CLAIM → load built-in or saved limits → automatic ARM. +Every reconnect also requests ARM, without replaying prior actions. A pause cancels +the pending start throughout the current recovery round, including backoff and +failed attempts. Built-in product defaults remove mandatory setup. Explicit malformed board +configuration still keeps output paused with an explanation. The App reads the +resulting three-channel state before merging any partial movement. The built-in startup [1500,1500,1500] derives from the production Qt client; +it is not presented as measured mechanical validation. + +The service holds a CPU wake lock for its user-started lifetime so its heartbeat +and action timers can run with the screen off. It holds no Wi-Fi lock and does +not restart on boot or package replacement. Ending the service releases the lock. +Battery impact, Doze and OEM lifecycle behavior still require real Android tests. + +Android's notification subscription API in the chosen BLE plugin has no explicit +CCCD-ready callback. Pairing is completed through protected access before command +timers start. CLAIM setup must tolerate a bounded SUBSCRIPTION_REQUIRED response; +no other command rejection is treated as success or silently downgraded. + +## Control semantics + +- Wire values 500–2500 are **logical input encoding**, not final servo pulse widths. +- UI target, firmware last-commanded values, and physical position are different. + Physical position and unsampled battery are unknown. +- Commands have both GATT completion and protocol business ACK. Neither proves + physical arrival. HALT confirmation means interpolation was cancelled. +- Manual input has one pending latest target, max 20 Hz, and one GATT writer. +- Presets preserve keyframes, wait for acceptance and keep their own `duration`; + transition defaults to 200 ms. More than 100 ms late cancels the remaining preset. +- Manual takeover cancels prior actions. Pause clears pending targets/timers and + asks firmware to hold its last legal output; it does not center or open the lid. +- Disconnect clears all scheduling. Retry delays are 1/2/4/8 seconds, then manual + retry. Reconnected sessions request ARM automatically and never replay prior frames. + +## Devices and limits + +Pairing secrets and OS bond keys are not stored in Flutter preferences. Preferences +contain only authenticated identity, last radio address/name and user-confirmed +logical limits. A radio address/name is not an authentication credential. Lost +system bonding requires the documented USB maintenance flow; reinstalling this +App is not a device reset. + +Set all three safe ranges in the device page. Firmware owns startup posture and +mechanical calibration. Invalid or absent firmware configuration makes ARM fail +with NOT_CONFIGURED, while scanning, pairing and status diagnostics remain usable. + +## Qt replacement matrix + +| Original capability | This BLE slice | Remaining work | +|---|---|---| +| Discovery / connection | BLE device page, authenticated identity, automatic ARM after safety checks | Real phone/device qualification | +| Manual CH1/CH2/CH3 | Joystick, lid control, local confirmed limits | Physical small-travel validation | +| Auto/Manual | Dart engine and manual takeover | Final parameter UX | +| Automatic eye movement | Bounded center-region random movement, fixed 5 s interval | Legacy normal distribution/range/interval options | +| Automatic blinking | Existing wink/wink2, fixed 5 s interval | Legacy random blink interval/jitter | +| Bundled wink/wink2 | Preserved JSON proportions/-1 and frame timing | Physical observation | +| External preset JSON loading | Parser retained, bundled presets usable | User file import UI | +| Cancel / disconnect | Firmware HALT/RELEASE confirmation plus local cancellation | Notification and radio-loss hardware matrix | +| Battery | Unknown without actual sampling | Hardware sampling if available | +| UDP / existing firmware | Codec, simulator and legacy engine retained for regression | Optional Flutter product UDP entry in later phase | +| Sleep | Not exposed; no verified firmware contract | Separate capability/protocol design if needed | +| Joystick release recenter toggle | Retained on the Flutter control page; uses confirmed safe ranges | Preference persistence remains later work | +| FacialRecognition mode button | No BLE operation; Qt has a mode request, no vision pipeline in this repo | Camera/vision feature and firmware contract are out of scope | +| Pupil-size dial | Qt display-only dial has no channel handler; not presented as a working BLE capability | Hardware/protocol support required | +| Settings “save” button | Qt handler only logged a message; BLE stores authenticated identity and confirmed safety limits | Persist remaining local Auto/joystick preferences later | +| Heartbeat / lost-connection recovery | BLE lease plus bounded reconnect and automatic ARM, no action replay | Radio-loss hardware acceptance | +| Background operation | Single service-owned runtime and notification actions | Android lock-screen/Doze acceptance | + +The matrix was checked against `SatoriManagerContent/mobileclient.h`, +`mobileclient.cpp`, `ScreenMain.qml`, `ScreenControlForm.ui.qml`, `Setting.qml`, +`SettingForm.ui.qml`, `ControlStick.qml`, and `ActionPresetLoader.cpp`. It includes +placeholder controls separately from implemented Qt behavior. + +Qt source is retained for history. Qt is not required to operate the BLE client. +The BLE firmware is not compatible with Qt/UDP at runtime. Recovery uses the +separate legacy firmware build or a previously backed-up image. + +## Reproducible checks + +From `flutter_app/` with the pinned Flutter SDK on PATH: + +```sh +dart run tool/check_ble_contract.dart +dart run tool/ble_simulator.dart --drop-first-ack +dart run tool/ble_simulator.dart --unconfigured +flutter analyze +flutter test +dart format --output=none --set-exit-if-changed lib test tool +flutter build apk --release --split-per-abi +dart run tool/ble_probe.dart --help +``` + +Use the Linux probe for a real BLE central and the fake peripheral for deterministic +protocol faults. One local Bluetooth adapter alone does not establish a two-device +radio test. See BLE_LINUX.md for BlueZ operation. + +The Android adapter in `flutter_reactive_ble` 5.6.0 requires compile SDK 37, +so `android/app/build.gradle.kts` now sets `compileSdk = 37`. The supplied SDK +manager reports its valid platform package under `platforms/android-37.0`, +while Gradle looks up `android-37`. On this toolchain, a local symbolic link +from `platforms/android-37` to the actual SDK-managed `android-37.0` directory +lets Gradle find the same `android.jar` and package metadata. Do not fabricate +or edit platform metadata; use the official SDK package and only add this path +alias when that naming mismatch occurs. + +Initial debug-build validation (2026-09-29; superseded for delivery by the release build below): Flutter 3.44.9 / Dart 3.12.2, +Temurin JDK 17.0.20.1, Gradle 9.1.0, AGP 9.0.1, Android compile SDK 37. +`dart format --output=none --set-exit-if-changed lib test tool`, +`flutter analyze`, `flutter test`, and `flutter build apk --debug` all passed; +59 Flutter tests passed. The debug APK for version `0.2.0+2` is +`build/app/outputs/flutter-apk/app-debug.apk` (173,337,677 bytes), SHA-256 +`dabb37022d28e90eb7e0b9be784f460488b387e8477d5cb8da47c22ee97489b8`. +Gradle emits an informational warning that `device_info_plus` applies the +Kotlin Gradle Plugin and may need migration in a future Flutter release. This +build validates compilation only, not phone pairing, lock-screen operation, +or hardware behavior. + +## Hardware acceptance still required + +Record phone model/OS, exact App/firmware hashes, board configuration hash, supply +and default power-saving settings. Confirm SC Passkey and refusal of a second +phone, MTU 23, no PWM before ARM, NOT_CONFIGURED behavior, persistent bond after +reboot/update, safe small motion, stopped-output behavior on HALT/disconnect/lease, +and USB recovery preserving calibration. + +Then test 30 minutes locked, 2 hours normal battery-powered use, camera switching, +UI recreation, Doze, notification pause/disconnect and 100 reconnects. Capture +firmware receipt/execution evidence and distinguish it from phone send logs. +Forced stop and process death are separate from ordinary lock-screen tests. + + +## APK 体积修正:release 按架构交付 + +初次交付的 173337677 字节 debug 通用包不适合作为日常安装包,已由按 ABI 拆分的 release 包替代。构建命令为 `flutter build apk --release --split-per-abi`。启用 `packaging.jniLibs.useLegacyPackaging = true`,让直接分发的 APK 压缩原生库;Android 安装时解压,下载体积减少不等于安装占用同步减少。 + +| 架构 | APK 字节数 | SHA-256 | +|---|---:|---| +| arm64-v8a | 8601827 | `d5786047d70ac7ed2a7834f73366f79bd28838b71b456d4c7a0297aa3dfe4b03` | +| armeabi-v7a | 8081805 | `239fd685033d4175e7982c1d82ab5f4e29d00ac3994764b3749b14508fa1d1a2` | +| x86_64 | 8777186 | `c2279349ea5e5f60752cf447ea227657c8f9452c8af8c3a13a92817c41926b26` | + +默认交付 ARM64 包 `app-arm64-v8a-release.apk`,约 **8.6 MB**,较原 debug 包减少约 **95%**;32 位 ARM 包约 8.1 MB。三个 APK 分别只包含自身架构,不再携带 debug kernel blob。APK ZIP 完整性、原生库压缩状态及 ARM64 APK v2 签名验证通过,manifest 的 `extractNativeLibs=true` 与压缩方式一致,release 未设置 debuggable。 + +当前 ARM64 Flutter 引擎原生库压缩后仍为 5409708 字节,应用 AOT 库压缩后为 2125820 字节,因此保持当前 Flutter 实现不能把独立 APK 压到 1–2 MB。此处仅调整构建和交付方式,没有删减 BLE 或后台功能。release 仍使用开发签名;真实手机上的 release 生命周期验证与既有硬件待验项保持待验。 + + +## 0.2.1 historical:默认初次码与手机管理配对 + +用户授权采用无按钮、不改硬件的流程:健康且完全未初始化的设备自动创建随机身份,首次配对码为 **123456**;首台通过 SC Passkey 配对的手机成为唯一主控。已有设备升级不会重置身份、码或绑定,损坏的 NVS/半初始化记录不会触发自动开放或擦除。默认码是公开引导值,不能防止首次被附近第三方抢先绑定;设备首次通电应在用户在场时完成绑定。 + +手机设备页提供: + +1. **修改配对码**:隐藏输入、二次确认、保留前导零;先暂停,只有设备确认持久化后显示成功。当前手机的 bond 保留。不能重新设置123456,App 不把新码写入偏好、快照或错误日志。 +2. **更换手机**:必须先改掉默认码;暂停后开启60秒窗口并断开旧手机,旧手机不自动重连。新手机完成认证且新主控持久化后才撤销旧 bond。 +3. **取消换绑窗口**:旧手机重新连接后可取消;超时/重启均保留尚未被成功替换的旧主控。窗口内改码返回BUSY,先取消再修改。 + +此扩展使用同一20字节帧的opcode8/9/10及能力bit7,协议minor=1。旧固件不支持时,App禁用相应入口。使用此流程需要升级配套固件;只安装新APK不能让旧固件支持默认码或远程改码。主控手机丢失或系统bond失效仍通过USB维护恢复。曾经被撤销的手机再次作为“新手机”加入时,可能需要先在Android系统中忘记旧配对。 + +UI运行时代际检查在暂停完成后和实际发送管理帧前各执行一次:已经退休的旧界面不能在新界面接管后完成改码/换绑。管理命令使用同字节重试、持久化后业务ACK和响应缓存;OPEN成功后的断链被视为预期退出,不会自动恢复运动。 + +### App 验证与产物 + +Flutter格式检查(32文件)、完整analyze与全部 **80项测试** 通过。新增覆盖v1.1的4个命令、3个事件、6个拒绝向量;管理授权、存储失败、幂等重试、窗口超时/重启、取消后改码、旧UI在途命令取消、隐藏输入/确认及换绑不重连。 + +`flutter build apk --release --split-per-abi` 通过,版本 **0.2.1+3**。保留原生库压缩,未删减功能: + +| 架构 | 字节数 | SHA-256 | +|---|---:|---| +| ARM64 | 8667431 | `fdc375239b31e9314e4cf9b947bc70cae4d2adbbce7f6ec8a3f826377c4d4bcf` | +| ARM32 | 8150917 | `1cd31185e596b7784ce03a236d922f518ac96c931821f892e2d3d4f22a01e23e` | +| x86_64 | 8843970 | `d11cbfec7a147af0dee3b296be53f25ac2177abcb01eda47f5a08564df92edd4` | + +ARM64 APK v2签名验证通过(现有开发签名);包内原生库确认为Deflate压缩。配对、改码及换绑的Android/ESP真机组合仍待实测,本次未烧录或驱动硬件。 + + +## 0.2.2 shared pairing and automatic output + +The new firmware advertises capability bit8, protocol minor2 and security policy2. +It stores up to eight authenticated SC bonds, with one live connection. A new phone +can pair with the current code while the device is idle; a connected phone prevents +other connections. No transfer window or automatic bond eviction is used. Existing +bonds survive code changes and upgrades. The UI retains the transfer controls only +for older single-owner firmware and hides them for shared-pairing devices. + +The service resolves saved limits using the authenticated identity before +auto-start, falling back to the built-in satori_c3_v1 logical range 500..2500. +The firmware uses the corresponding built-in startup posture unless maintenance +configuration overrides it. Existing mechanical calibration and physical clamps +remain in effect. No mandatory end-user configuration is shown; optional range +overrides live in a collapsed maintenance section. Explicit pauses cancel pending starts across the current recovery round; +its retries may restore connectivity but cannot reauthorize output. An explicit +new connection or independent later loss starts a new recovery intent. Historical records below +or in the report describing paused reconnects apply to previous releases only. + +## 0.2.4 手动控制响应调整 + +手动摇杆/眼皮使用 50 ms 过渡,预设与自动行为仍使用 200 ms。最新目标限频从实际发包时刻计算,避免 ACK 延迟之后再额外等待整个周期;限频等待期间只保留最新目标,松手最终值仍强制提交。保持设备声明的最高 20 Hz、写响应与业务 ACK、停止取消和会话隔离。Android 连接时请求 highPerformance 参数,失败或超时不阻止正常连接。 + +验证:100 项 Flutter 测试、analyze 和 ARM64 release 构建;实机端到端延迟尚未量化,不能把 50 ms 过渡时间当作实测响应时间。 + +### 2026-09-29 版本归档 + +版本为 `0.2.4+6`,配套实机固件为 BLE `0.2.2`(协议 1.2)。用户在 vivo X100s Pro 上确认 CH3 滑动控制成功,并在响应优化包交付后反馈“ok了”,同意提交本版本;这记录为本轮手动控制体验验收,不代表已完成全部生命周期验收。 + +ARM64 release APK 已复制到手机下载目录并回读校验一致,文件名为 `觉瞳-BLE-0.2.4-响应优化.apk`,SHA-256 为 `523b98ada5d931d423da6eec925a0670cc0c9675dc46e4ca687ab65b1d620311`。构建仍使用开发签名。 + +待验证:量化端到端延迟、30 分钟及 2 小时锁屏、网络/蓝牙中断恢复、通知停止及进程生命周期。本版本作为阶段性实机验证版本保留,不宣称已可全面替代旧客户端。 + +## 界面文案精简 + +控制与动作页只保留状态、控件和动作名称。设备身份、会话阶段、ACK/执行序号、完整地址、RSSI、已下发逻辑值及原始异常收在“诊断信息”;固件版本收在“设备详情”。已下发逻辑值表示控制目标,设备不提供真实机械位置遥测。动作播放中是客户端调度状态,协议没有动作完成确认,不能据此宣称机械已到位。 + +暂停会撤销输出许可并请求 HALT。状态快照使用 `controlPhase` 与 `issueCode` 区分暂停中、已暂停、暂停未确认、配置异常及输出状态未确认;页面与持续通知共用简短状态文案,不再从中文错误句子里猜测状态。原始异常只在折叠诊断区查看。重连通知只报告正在重连,因为用户暂停可能撤销当前恢复流程的自动启动许可。 + +验证:Flutter analyze 无问题,102 项测试通过,Android debug APK 构建成功。本次只改文案、诊断信息层级和状态表达,没有改 BLE 协议或控制调度;新的界面尚未在手机上验证。 diff --git a/flutter_app/lib/core/ble_protocol.dart b/flutter_app/lib/core/ble_protocol.dart new file mode 100644 index 0000000..27609d3 --- /dev/null +++ b/flutter_app/lib/core/ble_protocol.dart @@ -0,0 +1,492 @@ +/// Wire codec for the frozen SatoriEye BLE Control v1 contract. +class BleProtocol { + static const serviceUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0000'; + static const identityUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0001'; + static const deviceInfoUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0002'; + static const controlRxUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0003'; + static const eventTxUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0004'; + static const stateSnapshotUuid = '4d89f6a0-73b9-4f14-9d3e-63b2145a0005'; + static const version = 1; + static const frameLength = 20; + static const commandTimeout = Duration(milliseconds: 500); + static const commandMaxRetries = 3; + // Four attempts (2000 ms) plus 1000 ms link/scheduling margin, measured + // by the device from completed RELEASE, never from request acceptance. + static const releaseAckWindow = Duration(milliseconds: 3000); + + static List encodeControlFrame({ + required BleOpcode opcode, + required int sequence, + required int token, + List? payload, + }) { + final p = payload ?? List.filled(10, 0); + if (sequence < 0 || + sequence > 0xffffffff || + token < 0 || + token > 0xffffffff || + p.length != 10 || + p.any((x) => x < 0 || x > 255)) { + throw const FormatException('Invalid BLE control frame fields'); + } + final b = List.filled(20, 0); + b[0] = version; + b[1] = opcode.value; + _put32(b, 2, sequence); + _put32(b, 6, token); + b.setRange(10, 20, p); + return b; + } + + static BleControlFrame decodeControlFrame(List bytes) { + if (bytes.length != 20) { + throw const FormatException('Control frame must be 20 bytes'); + } + return BleControlFrame( + version: bytes[0], + opcode: bytes[1], + sequence: _u32(bytes, 2), + token: _u32(bytes, 6), + payload: List.unmodifiable(bytes.sublist(10)), + ); + } + + static List decodeIdentity(List bytes) { + _length(bytes, 16, 'DeviceIdentity'); + return List.unmodifiable(bytes); + } + + /// Applies the protocol's deterministic request checks for tooling/tests. + /// ATT-level security and sequence-cache replay/conflict checks remain the + /// transport peer's responsibility. + static BleResult validateControlFrame( + List bytes, { + required int currentToken, + required bool armed, + required bool subscribed, + bool ownerAuthorized = true, + bool supportsOwnerManagement = true, + bool supportsSharedPairing = false, + int currentPairingCode = 123456, + bool transferWindowOpen = false, + int maxTransitionMs = 2000, + }) { + if (bytes.length != 20) return BleResult.badLength; + final f = decodeControlFrame(bytes); + if (f.version != 1) return BleResult.badVersion; + BleOpcode? op; + for (final value in BleOpcode.values) { + if (value.value == f.opcode) { + op = value; + break; + } + } + if (op == null) return BleResult.badOpcode; + if (f.sequence == 0) return BleResult.oldSequence; + if (op == BleOpcode.claim) { + if (!subscribed) return BleResult.subscriptionRequired; + if (f.token != 0 || f.payload.any((x) => x != 0)) { + return BleResult.badPayload; + } + return currentToken == 0 ? BleResult.ok : BleResult.badSession; + } + if (currentToken == 0 || f.token != currentToken) { + return BleResult.badSession; + } + if (op == BleOpcode.setTarget) { + final ch = [_u16(f.payload, 0), _u16(f.payload, 2), _u16(f.payload, 4)]; + if (ch.any((x) => x < 500 || x > 2500) || + _u16(f.payload, 6) > maxTransitionMs || + _u16(f.payload, 8) != 0) { + return BleResult.badPayload; + } + return armed ? BleResult.ok : BleResult.notArmed; + } + if (op == BleOpcode.setPairingCode) { + if (!supportsOwnerManagement) return BleResult.badOpcode; + if (!ownerAuthorized) return BleResult.notAuthorized; + final code = _u32(f.payload, 0); + if (code > 999999 || + code == 123456 || + f.payload.sublist(4).any((x) => x != 0)) { + return BleResult.badPayload; + } + if (transferWindowOpen) return BleResult.busy; + return BleResult.ok; + } + if (op == BleOpcode.openTransfer || op == BleOpcode.cancelTransfer) { + if (supportsSharedPairing) return BleResult.badOpcode; + if (!ownerAuthorized) return BleResult.notAuthorized; + if (f.payload.any((x) => x != 0)) return BleResult.badPayload; + if (op == BleOpcode.openTransfer) { + if (currentPairingCode == 123456) return BleResult.notConfigured; + if (transferWindowOpen) return BleResult.busy; + } + return BleResult.ok; + } + if (f.payload.any((x) => x != 0)) return BleResult.badPayload; + return BleResult.ok; + } + + static List encodeSetTarget({ + required int ch1, + required int ch2, + required int ch3, + required int transitionMs, + }) { + final channels = [ch1, ch2, ch3]; + if (channels.any((x) => x < 500 || x > 2500) || + transitionMs < 0 || + transitionMs > 2000) { + throw const FormatException('Invalid SET_TARGET range'); + } + final p = List.filled(10, 0); + for (var i = 0; i < 3; i++) { + _put16(p, i * 2, channels[i]); + } + _put16(p, 6, transitionMs); + return p; + } + + static List encodePairingCode(String sixDigits) { + if (!RegExp(r'^\d{6}$').hasMatch(sixDigits) || sixDigits == '123456') { + throw const FormatException('Invalid pairing code'); + } + final payload = List.filled(10, 0); + _put32(payload, 0, int.parse(sixDigits)); + return payload; + } + + static BleDeviceInfo decodeDeviceInfo(List b) { + _length(b, 20, 'DeviceInfo'); + if (b[18] != 0 || + b[19] != 0 || + (_u32(b, 6) & ~0x1ff) != 0 || + (b[16] != 1 && b[16] != 2) || + b[17] != 3 || + b[10] == 0 || + b[11] == 0 || + b[10] > b[11] || + b[11] > 20 || + _u16(b, 12) == 0 || + _u16(b, 12) > 2000 || + _u16(b, 14) == 0 || + ((_u32(b, 6) & 0x100) != 0) != (b[16] == 2) || + (((_u32(b, 6) & 0x100) != 0) && b[1] < 2)) { + throw const FormatException('Invalid DeviceInfo fields'); + } + return BleDeviceInfo( + protocolMajor: b[0], + protocolMinor: b[1], + firmwareMajor: b[2], + firmwareMinor: b[3], + firmwarePatch: b[4], + hardwareProfile: b[5], + capabilities: _u32(b, 6), + recommendedTargetHz: b[10], + maxTargetHz: b[11], + maxTransitionMs: _u16(b, 12), + leaseTimeoutMs: _u16(b, 14), + securityPolicy: b[16], + logicalChannels: b[17], + ); + } + + static BleEvent decodeEvent(List b) { + _length(b, 20, 'Event'); + final result = BleResult.tryFromValue(b[10]); + final sequence = _u32(b, 2); + if (b[0] != 1 || + result == null || + (b[1] != 0xe0 && (b[1] < 0x81 || b[1] > 0x8a)) || + (b[1] == 0xe0 ? sequence != 0 : sequence == 0) || + b[11] > 3 || + (b[16] & 0xf0) != 0 || + (b[17] > 100 && b[17] != 255) || + b[18] != 0 || + b[19] != 0) { + throw const FormatException('Invalid Event fields'); + } + return BleEvent( + version: b[0], + opcode: b[1], + sequence: _u32(b, 2), + token: _u32(b, 6), + result: result, + controlState: b[11], + lastAppliedSequence: _u32(b, 12), + flags: b[16], + batteryPercent: b[17] == 255 ? null : b[17], + ); + } + + static BleStateSnapshot decodeStateSnapshot(List b) { + _length(b, 20, 'StateSnapshot'); + final channels = [_u16(b, 10), _u16(b, 12), _u16(b, 14)]; + if (b[0] != 1 || + b[1] > 3 || + (b[16] & 0xf8) != 0 || + (b[17] & 0xf8) != 0 || + (b[17] & ~b[16]) != 0 || + (b[18] > 100 && b[18] != 255) || + b[19] != 0 || + (b[1] == 0 && _u32(b, 2) != 0) || + (b[1] != 0 && _u32(b, 2) == 0)) { + throw const FormatException('Invalid StateSnapshot fields'); + } + for (var i = 0; i < 3; i++) { + final valid = (b[16] & (1 << i)) != 0; + if (valid + ? (channels[i] < 500 || channels[i] > 2500) + : channels[i] != 0) { + throw const FormatException('Invalid StateSnapshot channel value'); + } + } + return BleStateSnapshot( + version: b[0], + controlState: b[1], + token: _u32(b, 2), + lastAppliedSequence: _u32(b, 6), + channels: channels, + validChannelMask: b[16], + interpolatingChannelMask: b[17], + batteryPercent: b[18] == 255 ? null : b[18], + ); + } + + static List encodeDeviceInfo({ + int protocolMinor = 0, + int firmwareMajor = 0, + int firmwareMinor = 0, + int firmwarePatch = 0, + int hardwareProfile = 1, + int capabilities = 0x5f, + int recommendedTargetHz = 20, + int maxTargetHz = 20, + int maxTransitionMs = 2000, + int leaseTimeoutMs = 6000, + int securityPolicy = 1, + int logicalChannels = 3, + }) { + final b = List.filled(20, 0); + b[0] = 1; + b[1] = protocolMinor; + b[2] = firmwareMajor; + b[3] = firmwareMinor; + b[4] = firmwarePatch; + b[5] = hardwareProfile; + _put32(b, 6, capabilities); + b[10] = recommendedTargetHz; + b[11] = maxTargetHz; + _put16(b, 12, maxTransitionMs); + _put16(b, 14, leaseTimeoutMs); + b[16] = securityPolicy; + b[17] = logicalChannels; + return b; + } + + static List encodeEvent({ + required int opcode, + required int sequence, + required int token, + required BleResult result, + int controlState = 1, + int lastAppliedSequence = 0, + int flags = 0, + int? batteryPercent, + }) { + final b = List.filled(20, 0); + b[0] = 1; + b[1] = opcode; + _put32(b, 2, sequence); + _put32(b, 6, token); + b[10] = result.value; + b[11] = controlState; + _put32(b, 12, lastAppliedSequence); + b[16] = flags; + b[17] = batteryPercent ?? 255; + return b; + } + + static List encodeStateSnapshot({ + int controlState = 0, + int token = 0, + int lastAppliedSequence = 0, + List channels = const [0, 0, 0], + int validChannelMask = 0, + int interpolatingChannelMask = 0, + int? batteryPercent, + }) { + if (channels.length != 3) { + throw const FormatException('Three state channels required'); + } + final b = List.filled(20, 0); + b[0] = 1; + b[1] = controlState; + _put32(b, 2, token); + _put32(b, 6, lastAppliedSequence); + for (var i = 0; i < 3; i++) { + _put16(b, 10 + 2 * i, channels[i]); + } + b[16] = validChannelMask; + b[17] = interpolatingChannelMask; + b[18] = batteryPercent ?? 255; + return b; + } + + static void _length(List b, int n, String name) { + if (b.length != n) throw FormatException('$name must be $n bytes'); + if (b.any((byte) => byte < 0 || byte > 255)) { + throw FormatException('$name contains a non-byte value'); + } + } + + static int _u16(List b, int i) => b[i] | (b[i + 1] << 8); + static int _u32(List b, int i) => + b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); + static void _put16(List b, int i, int v) { + b[i] = v & 255; + b[i + 1] = (v >> 8) & 255; + } + + static void _put32(List b, int i, int v) { + for (var k = 0; k < 4; k++) { + b[i + k] = (v >> (8 * k)) & 255; + } + } +} + +enum BleOpcode { + claim(1), + setTarget(2), + halt(3), + release(4), + keepalive(5), + getStatus(6), + arm(7), + setPairingCode(8), + openTransfer(9), + cancelTransfer(10); + + const BleOpcode(this.value); + final int value; + int get replyValue => value | 0x80; +} + +enum BleResult { + ok(0), + badVersion(1), + badLength(2), + badOpcode(3), + badPayload(4), + notAuthorized(5), + badSession(6), + oldSequence(7), + sequenceConflict(8), + busy(9), + internalError(10), + notArmed(11), + notConfigured(12), + subscriptionRequired(13); + + const BleResult(this.value); + final int value; + static BleResult? tryFromValue(int v) { + for (final x in values) { + if (x.value == v) return x; + } + return null; + } + + static BleResult fromValue(int v) => + tryFromValue(v) ?? BleResult.internalError; +} + +class BleControlFrame { + const BleControlFrame({ + required this.version, + required this.opcode, + required this.sequence, + required this.token, + required this.payload, + }); + final int version, opcode, sequence, token; + final List payload; +} + +class BleDeviceInfo { + const BleDeviceInfo({ + required this.protocolMajor, + required this.protocolMinor, + required this.firmwareMajor, + required this.firmwareMinor, + required this.firmwarePatch, + required this.hardwareProfile, + required this.capabilities, + required this.recommendedTargetHz, + required this.maxTargetHz, + required this.maxTransitionMs, + required this.leaseTimeoutMs, + required this.securityPolicy, + required this.logicalChannels, + }); + final int protocolMajor, + protocolMinor, + firmwareMajor, + firmwareMinor, + firmwarePatch, + hardwareProfile, + capabilities, + recommendedTargetHz, + maxTargetHz, + maxTransitionMs, + leaseTimeoutMs, + securityPolicy, + logicalChannels; + String get firmwareVersion => '$firmwareMajor.$firmwareMinor.$firmwarePatch'; + bool get supportsRequiredCapabilities => (capabilities & 0x5f) == 0x5f; + bool get supportsOwnerManagement => (capabilities & 0x80) != 0; + bool get supportsSharedPairing => + (capabilities & 0x100) != 0 && securityPolicy == 2; +} + +class BleEvent { + const BleEvent({ + required this.version, + required this.opcode, + required this.sequence, + required this.token, + required this.result, + required this.controlState, + required this.lastAppliedSequence, + required this.flags, + required this.batteryPercent, + }); + final int version, + opcode, + sequence, + token, + controlState, + lastAppliedSequence, + flags; + final BleResult result; + final int? batteryPercent; +} + +class BleStateSnapshot { + const BleStateSnapshot({ + required this.version, + required this.controlState, + required this.token, + required this.lastAppliedSequence, + required this.channels, + required this.validChannelMask, + required this.interpolatingChannelMask, + required this.batteryPercent, + }); + final int version, controlState, token, lastAppliedSequence; + final List channels; + final int validChannelMask, interpolatingChannelMask; + final int? batteryPercent; + bool get targetKnown => validChannelMask == 7; +} diff --git a/flutter_app/lib/core/control_engine.dart b/flutter_app/lib/core/control_engine.dart new file mode 100644 index 0000000..91e8c90 --- /dev/null +++ b/flutter_app/lib/core/control_engine.dart @@ -0,0 +1,692 @@ +import 'dart:async'; +import 'dart:math'; +import 'ble_protocol.dart' show BleResult; +import 'device_session.dart'; +import 'protocol.dart' show ActionFrame, Protocol; +import 'safety_limits.dart'; + +typedef MonotonicClock = Duration Function(); +typedef Schedule = Timer Function(Duration delay, void Function() callback); + +/// One service-owned action engine. UI lifecycle never owns its timers. +class ControlEngine { + ControlEngine( + this.session, + this.actions, { + MonotonicClock? clock, + Schedule? schedule, + Random? random, + }) : now = clock ?? (Stopwatch()..start()).elapsedGetter, + later = schedule ?? Timer.new, + random = random ?? Random() { + _subscription = session.snapshots.listen(_sessionChanged); + } + final DeviceSession session; + final Map> actions; + final MonotonicClock now; + final Schedule later; + final Random random; + final _updates = StreamController>.broadcast(); + Stream> get updates => _updates.stream; + late final StreamSubscription _subscription; + final runtimeId = + '${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}'; + int revision = 0; + String connection = 'disconnected'; + String mode = 'manual'; + String playback = 'idle'; + bool autoRotate = false; + bool autoWink = false; + bool outputAuthorized = false; + String? error; + String? issueCode; + bool pauseInProgress = false; + bool explicitlyPaused = false; + String? deviceId; + String? _identity; + SafetyLimits? safety; + List? target; + int _motionGeneration = 0; + int _connectionGeneration = 0; + int _autoStartGeneration = 0; + bool _autoStartPending = false; + bool _connecting = false; + bool _ending = false; + bool _disposed = false; + bool _pairingOperation = false; + String? pairingNotice; + Timer? _rotate; + Timer? _wink; + Timer? _frame; + Timer? _reconnect; + int _reconnectAttempt = 0; + + Map snapshot() { + final s = session.snapshot; + return { + 'runtimeId': runtimeId, + 'revision': revision, + 'connection': connection, + 'sessionPhase': s.phase.name, + 'deviceId': deviceId, + 'identity': s.identity, + 'firmware': s.deviceInfo?.firmwareVersion, + 'supportsOwnerManagement': + ((s.deviceInfo?.capabilities ?? 0) & 0x80) != 0, + 'supportsSharedPairing': s.deviceInfo?.supportsSharedPairing ?? false, + 'pairingNotice': pairingNotice, + 'maxTargetHz': s.deviceInfo?.maxTargetHz, + 'mode': mode, + 'playback': playback, + 'autoRotate': autoRotate, + 'autoWink': autoWink, + 'outputAuthorized': outputAuthorized, + 'target': target, + 'lastCommanded': s.state?.channels, + 'validChannelMask': s.state?.validChannelMask ?? 0, + 'lastAppliedSequence': s.state?.lastAppliedSequence, + 'lastAckSequence': s.lastAckSequence, + 'lastAckAt': s.lastAckAt?.toIso8601String(), + 'battery': s.state?.batteryPercent, + 'safety': safety?.toJson(), + 'error': error ?? s.lastError, + 'issueCode': issueCode, + 'controlPhase': pauseInProgress + ? 'pausing' + : issueCode == 'pauseUnconfirmed' + ? 'pauseUnconfirmed' + : issueCode == 'configuration' + ? 'configurationError' + : issueCode == 'outputUnconfirmed' + ? 'outputUnconfirmed' + : issueCode == 'outputFailed' + ? 'ready' + : issueCode == 'actionFailed' + ? 'actionFailed' + : outputAuthorized + ? 'active' + : explicitlyPaused + ? 'paused' + : 'preparing', + }; + } + + void _notify() { + if (_disposed) return; + revision++; + _updates.add(snapshot()); + } + + void _sessionChanged(DeviceSessionSnapshot s) { + if (_disposed || !identical(s, session.snapshot)) return; + if (!_connecting && + !_ending && + connection == 'connected' && + !s.isConnected) { + _cancelMotion(); + outputAuthorized = false; + target = null; + 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; + _scheduleReconnect(_connectionGeneration, autoStartGeneration); + } + _notify(); + } + + Future connect( + String id, { + SafetyLimits? limits, + String? expectedIdentity, + Future Function(String identity)? safetyForIdentity, + }) async { + if (_connecting || + connection == 'connected' || + connection == 'reconnecting') { + throw StateError('请先结束当前连接'); + } + _ending = false; + _reconnect?.cancel(); + final generation = ++_connectionGeneration; + final autoStartGeneration = ++_autoStartGeneration; + _autoStartPending = true; + _cancelMotion(); + outputAuthorized = false; + explicitlyPaused = false; + target = null; + safety = limits ?? SafetyLimits.builtInSatoriC3; + deviceId = id; + _identity = expectedIdentity; + connection = 'connecting'; + error = null; + issueCode = null; + _connecting = true; + _notify(); + try { + await session.connect(id, expectedIdentity: _identity); + if (generation != _connectionGeneration) throw StateError('连接操作已取消'); + _checkIdentity(); + final authenticatedIdentity = session.snapshot.identity; + if (safetyForIdentity != null && authenticatedIdentity != null) { + safety = + await safetyForIdentity(authenticatedIdentity) ?? + SafetyLimits.builtInSatoriC3; + if (generation != _connectionGeneration) { + throw StateError('连接操作已取消'); + } + } + connection = 'connected'; + _identity = authenticatedIdentity; + await _tryAutoStart(autoStartGeneration); + } catch (e) { + if (generation == _connectionGeneration) { + connection = 'failed'; + error = '$e'; + await session.disconnect(); + } + rethrow; + } finally { + if (generation == _connectionGeneration) _connecting = false; + _notify(); + } + } + + void _checkIdentity() { + if (_identity != null && session.snapshot.identity != _identity) { + throw StateError('认证后的设备身份与收藏不符'); + } + } + + Future configureSafety(SafetyLimits limits) async { + if (outputAuthorized) throw StateError('暂停后再修改安全范围'); + safety = limits; + _notify(); + if (_autoStartPending && connection == 'connected') { + await _tryAutoStart(_autoStartGeneration); + } + } + + Future _tryAutoStart(int generation) async { + if (generation != _autoStartGeneration || !_autoStartPending) return; + if (connection != 'connected') return; + safety ??= SafetyLimits.builtInSatoriC3; + _autoStartPending = false; + _notify(); + try { + await _arm(generation, autoStart: true); + if (!session.snapshot.isConnected) { + outputAuthorized = false; + target = null; + throw StateError('BLE disconnected during automatic ARM'); + } + } catch (e) { + if (generation == _autoStartGeneration) { + if (!session.snapshot.isConnected) { + outputAuthorized = false; + target = null; + rethrow; + } + final notConfigured = + e is BleCommandRejected && e.result == BleResult.notConfigured; + var haltConfirmed = false; + if (!notConfigured && session.snapshot.isConnected) { + try { + await session.halt(); + haltConfirmed = true; + } catch (_) { + // Preserve an explicit uncertainty message below. + } + } + error = notConfigured + ? '设备尚未配置已验证的启动姿态,已保持暂停。请先完成 USB 维护配置。' + : haltConfirmed + ? '自动启用输出失败,已确认设备暂停:$e' + : '自动启用输出状态未确认,请检查设备连接并手动暂停:$e'; + issueCode = notConfigured + ? 'configuration' + : haltConfirmed + ? 'outputFailed' + : 'outputUnconfirmed'; + outputAuthorized = false; + target = null; + _notify(); + } + } + } + + Future arm() async { + if (outputAuthorized) return; + await _arm(_autoStartGeneration, autoStart: false); + } + + Future _arm(int autoStartGeneration, {required bool autoStart}) async { + if (_pairingOperation) throw StateError('配对设置处理中,请稍候'); + if (connection != 'connected') throw StateError('设备尚未就绪'); + safety ??= SafetyLimits.builtInSatoriC3; + final generation = _motionGeneration; + final connectionGeneration = _connectionGeneration; + error = null; + issueCode = null; + await session.arm( + beforeSend: () { + if (generation != _motionGeneration || + connectionGeneration != _connectionGeneration || + (autoStart && autoStartGeneration != _autoStartGeneration)) { + throw const TargetCancelledException( + 'ARM was cancelled before transmission', + ); + } + }, + ); + if (generation != _motionGeneration || + connectionGeneration != _connectionGeneration || + (autoStart && autoStartGeneration != _autoStartGeneration)) { + return; + } + final state = session.snapshot.state; + if (state == null || state.validChannelMask != 7) { + throw StateError('设备未返回有效启动输出'); + } + if (!safety!.contains(state.channels)) { + await session.halt(); + throw StateError('设备启动输出不在已确认范围内,请核对机械配置'); + } + target = List.of(state.channels); + outputAuthorized = true; + explicitlyPaused = false; + _notify(); + } + + void _requireControl() { + if (_pairingOperation || + connection != 'connected' || + !outputAuthorized || + target == null || + safety == null) { + throw StateError('设备未就绪或尚未确认安全范围'); + } + } + + Future setManual(List proportions) async { + _requireControl(); + final next = _merge(proportions); + _cancelMotion(); + mode = 'manual'; + try { + await _send( + next, + generation: _motionGeneration, + latestOnly: true, + transitionMs: 50, + ); + } on TargetCancelledException { + // A newer manual point or stop superseded this unsent point. + } + } + + List _merge(List proportions) { + if (proportions.length != 3) throw const FormatException('需要三个通道'); + return [ + for (var i = 0; i < 3; i++) + proportions[i] == -1 ? target![i] : Protocol.proportion(proportions[i]), + ]; + } + + Future _send( + List next, { + required int generation, + bool latestOnly = false, + int transitionMs = 200, + }) async { + _requireControl(); + if (generation != _motionGeneration) return; + final constrained = safety!.constrain(next); + target = constrained; + _notify(); + await session.setTarget( + ch1: constrained[0], + ch2: constrained[1], + ch3: constrained[2], + transitionMs: min( + transitionMs, + session.snapshot.deviceInfo?.maxTransitionMs ?? 200, + ), + latestOnly: latestOnly, + ); + } + + void setAutoRotate(bool enabled) { + _requireControl(); + _rotate?.cancel(); + autoRotate = enabled; + mode = autoRotate || autoWink ? 'auto' : 'manual'; + if (enabled) _rotateTick(_motionGeneration); + _notify(); + } + + void _rotateTick(int generation) { + if (generation != _motionGeneration || !autoRotate || !outputAuthorized) { + return; + } + if (playback != 'playing') { + final next = List.of(target!); + for (var i = 0; i < 2; i++) { + final low = safety!.minimum[i]; + final high = safety!.maximum[i]; + next[i] = (low + (high - low) * (.375 + random.nextDouble() * .25)) + .round(); + } + unawaited( + _guardAction( + _send(next, generation: generation, latestOnly: true), + generation, + ), + ); + } + _rotate = later(const Duration(seconds: 5), () => _rotateTick(generation)); + } + + void setAutoWink(bool enabled) { + _requireControl(); + _wink?.cancel(); + autoWink = enabled; + mode = autoRotate || autoWink ? 'auto' : 'manual'; + if (enabled) { + _wink = later( + const Duration(seconds: 5), + () => _winkTick(_motionGeneration), + ); + } + _notify(); + } + + void _winkTick(int generation) { + if (generation != _motionGeneration || !autoWink || !outputAuthorized) { + return; + } + if (playback == 'idle') play(random.nextBool() ? 'wink' : 'wink2'); + _wink = later(const Duration(seconds: 5), () => _winkTick(generation)); + } + + void play(String name) { + _requireControl(); + final frames = actions[name]; + if (frames == null) throw StateError('未知动作 $name'); + if (playback == 'playing') return; + session.cancelPendingTargets(); + playback = 'playing'; + final generation = _motionGeneration; + _notify(); + unawaited(_guardAction(_step(frames, 0, now(), generation), generation)); + } + + Future _step( + List frames, + int index, + Duration due, + int generation, + ) async { + if (generation != _motionGeneration || !outputAuthorized) return; + if (index >= frames.length) { + playback = 'idle'; + _notify(); + return; + } + if (now() - due > const Duration(milliseconds: 100)) { + throw TimeoutException('动作关键帧已过期,剩余动作已取消'); + } + final frame = frames[index]; + await _send(_merge(frame.channels), generation: generation); + if (generation != _motionGeneration || !outputAuthorized) return; + final nextDue = due + frame.duration; + final delay = nextDue - now(); + _frame = later(delay.isNegative ? Duration.zero : delay, () { + unawaited( + _guardAction(_step(frames, index + 1, nextDue, generation), generation), + ); + }); + } + + Future _guardAction(Future work, int generation) async { + try { + await work; + } on TargetCancelledException { + // Preset/manual priority may replace an unsent automatic target. + } catch (e) { + if (generation != _motionGeneration || _disposed) return; + final actionError = '$e'; + try { + await stopMotion(); + } catch (_) { + /* stopMotion retains unconfirmed state */ + } + if (issueCode == null && !outputAuthorized) { + error = actionError; + issueCode = 'actionFailed'; + _notify(); + } + } + } + + void _cancelMotion() { + _motionGeneration++; + _frame?.cancel(); + _rotate?.cancel(); + _wink?.cancel(); + session.cancelPendingTargets(); + autoRotate = false; + autoWink = false; + playback = 'idle'; + mode = 'manual'; + } + + Future stopMotion() async { + _autoStartPending = false; + ++_autoStartGeneration; + _cancelMotion(); + outputAuthorized = false; + explicitlyPaused = true; + pauseInProgress = true; + issueCode = null; + error = null; + _notify(); + // A stop issued while a connection is still being established cancels + // that generation's automatic ARM. There is no claimed session to HALT. + if (!session.snapshot.isConnected) { + pauseInProgress = false; + _notify(); + return; + } + try { + await session.halt(); + } catch (e) { + error = '暂停未确认:$e'; + issueCode = 'pauseUnconfirmed'; + _notify(); + rethrow; + } finally { + pauseInProgress = false; + _notify(); + } + _notify(); + } + + void _scheduleReconnect(int generation, int autoStartGeneration) { + const delays = [1, 2, 4, 8]; + if (_reconnectAttempt == delays.length) { + connection = 'failed'; + error = '恢复失败,请手动重新连接'; + _notify(); + return; + } + final delay = delays[_reconnectAttempt++]; + _reconnect = later(Duration(seconds: delay), () async { + if (generation != _connectionGeneration || _ending || _disposed) return; + _connecting = true; + try { + await session.disconnect(); + if (generation != _connectionGeneration || _ending) return; + await session.connect(deviceId!, expectedIdentity: _identity); + if (generation != _connectionGeneration || _ending) return; + _checkIdentity(); + connection = 'connected'; + await _tryAutoStart(autoStartGeneration); + if (outputAuthorized) { + error = null; + } else if (safety != null && error == null) { + error = '连接已恢复,输出保持暂停'; + } + } catch (e) { + if (generation != _connectionGeneration || _ending) return; + error = '$e'; + await session.disconnect(); + if (generation == _connectionGeneration && !_ending) { + 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; + } + _scheduleReconnect(generation, autoStartGeneration); + } + } finally { + if (generation == _connectionGeneration) _connecting = false; + _notify(); + } + }); + } + + void _requireOwnerManagement() { + if (_pairingOperation) throw StateError('配对设置处理中,请稍候'); + if (connection != 'connected') throw StateError('请先连接已绑定的设备'); + if (((session.snapshot.deviceInfo?.capabilities ?? 0) & 0x80) == 0) { + throw StateError('设备固件不支持手机管理配对,请先升级固件'); + } + } + + Future setPairingCode( + String code, { + void Function()? ensureCurrentClient, + }) async { + _requireOwnerManagement(); + if (!RegExp(r'^[0-9]{6}$').hasMatch(code) || code == '123456') { + throw const FormatException('请输入六位数字的新码,不能使用默认码'); + } + _pairingOperation = true; + final generation = _connectionGeneration; + try { + await stopMotion(); + if (generation != _connectionGeneration || _ending) { + throw StateError('配对设置操作已取消'); + } + ensureCurrentClient?.call(); + await session.setPairingCode(code, beforeSend: ensureCurrentClient); + pairingNotice = '配对码已保存。当前手机仍保持绑定,动作保持暂停。'; + error = null; + } catch (_) { + error = '配对码修改未确认,请重新连接后重试'; + rethrow; + } finally { + _pairingOperation = false; + _notify(); + } + } + + Future cancelTransfer({void Function()? ensureCurrentClient}) async { + _requireOwnerManagement(); + _pairingOperation = true; + final generation = _connectionGeneration; + try { + await stopMotion(); + if (generation != _connectionGeneration || _ending) { + throw StateError('取消换绑操作已取消'); + } + ensureCurrentClient?.call(); + await session.cancelTransfer(beforeSend: ensureCurrentClient); + pairingNotice = '换绑窗口已关闭,当前手机继续作为主控。'; + error = null; + } finally { + _pairingOperation = false; + _notify(); + } + } + + Future openTransfer({void Function()? ensureCurrentClient}) async { + _requireOwnerManagement(); + _pairingOperation = true; + var attemptedTransfer = false; + _ending = + true; // Expected transfer disconnect must never reconnect the old phone. + final generation = ++_connectionGeneration; + _reconnect?.cancel(); + _cancelMotion(); + outputAuthorized = false; + try { + await session.halt(); + if (generation != _connectionGeneration) { + throw StateError('换绑操作已取消'); + } + ensureCurrentClient?.call(); + await session.openTransfer( + beforeSend: () { + ensureCurrentClient?.call(); + attemptedTransfer = true; + }, + ); + pairingNotice = '已开启60秒换绑窗口。请立即用新手机和修改后的配对码连接;超时仍保留旧手机绑定。'; + error = null; + } catch (_) { + error = '开启换绑未确认;请用原手机重新连接。若尚未修改默认码,请先修改。'; + pairingNotice = null; + rethrow; + } finally { + if (attemptedTransfer) { + await session.disconnect(); + connection = 'disconnected'; + target = null; + } else { + _ending = false; + connection = session.snapshot.isConnected + ? 'connected' + : 'disconnected'; + } + _pairingOperation = false; + _notify(); + } + } + + Future disconnect() async { + _ending = true; + ++_connectionGeneration; + ++_autoStartGeneration; + _autoStartPending = false; + _connecting = false; + _reconnect?.cancel(); + _cancelMotion(); + outputAuthorized = false; + try { + await session.release(); + } catch (e) { + error = '断开确认未收到:$e'; + } + await session.disconnect(); + connection = 'disconnected'; + target = null; + _notify(); + } + + Future dispose() async { + await disconnect(); + await _subscription.cancel(); + await session.dispose(); + _disposed = true; + await _updates.close(); + } +} + +extension on Stopwatch { + Duration elapsedGetter() => elapsed; +} diff --git a/flutter_app/lib/core/control_status.dart b/flutter_app/lib/core/control_status.dart new file mode 100644 index 0000000..35d40dc --- /dev/null +++ b/flutter_app/lib/core/control_status.dart @@ -0,0 +1,38 @@ +/// User-facing control state derived only from structured snapshot fields. +String controlStatus(Map state) { + switch (state['connection']) { + case 'connected': + return switch (state['controlPhase']) { + 'pausing' => '暂停中…', + 'pauseUnconfirmed' => '暂停未确认', + 'configurationError' => '设备配置异常,暂时无法控制', + 'outputUnconfirmed' => '控制状态未确认', + 'active' => + state['autoRotate'] == true || state['autoWink'] == true + ? '自动控制中' + : state['playback'] == 'playing' + ? '动作播放中' + : '控制中', + 'paused' => '已暂停', + 'ready' => '已暂停', + 'actionFailed' => '动作已停止', + _ => '正在准备…', + }; + case 'reconnecting': + return '正在重新连接…'; + case 'connecting': + return '正在连接…'; + case 'searching': + return '正在搜索…'; + case 'failed': + return '连接失败'; + default: + return '未连接'; + } +} + +bool needsControlAttention(Map state) => + state['controlPhase'] == 'pauseUnconfirmed' || + state['controlPhase'] == 'configurationError' || + state['controlPhase'] == 'outputUnconfirmed' || + state['controlPhase'] == 'actionFailed'; diff --git a/flutter_app/lib/core/device_session.dart b/flutter_app/lib/core/device_session.dart new file mode 100644 index 0000000..fff7e12 --- /dev/null +++ b/flutter_app/lib/core/device_session.dart @@ -0,0 +1,788 @@ +import 'dart:async'; + +import 'ble_protocol.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. +abstract class BleLink { + Stream get connectionState; + Future connect(String id); + Future disconnect(); + Future> read(String uuid); + Future write(String uuid, List value); + Stream> subscribe(String uuid); +} + +enum DeviceSessionPhase { + disconnected, + connecting, + reading, + claiming, + readyPaused, + armed, + error, +} + +class DeviceSessionSnapshot { + const DeviceSessionSnapshot({ + required this.phase, + this.identity, + this.deviceInfo, + this.state, + this.token = 0, + this.sequence = 0, + this.lastAckSequence, + this.lastAckAt, + this.lastError, + }); + final DeviceSessionPhase phase; + final String? identity; + final BleDeviceInfo? deviceInfo; + final BleStateSnapshot? state; + final int token, sequence; + final int? lastAckSequence; + final DateTime? lastAckAt; + final String? lastError; + bool get isConnected => + phase == DeviceSessionPhase.readyPaused || + phase == DeviceSessionPhase.armed; + bool get isArmed => phase == DeviceSessionPhase.armed; + bool get targetKnown => isArmed && (state?.targetKnown ?? false); +} + +class TargetCancelledException implements Exception { + const TargetCancelledException([ + this.reason = 'Target was cancelled before transmission', + ]); + final String reason; + @override + String toString() => reason; +} + +class BleCommandRejected implements Exception { + const BleCommandRejected(this.opcode, this.result); + final BleOpcode opcode; + final BleResult result; + @override + String toString() => 'BLE command ${opcode.name} rejected: ${result.name}'; +} + +class DeviceSession { + DeviceSession( + this.link, { + this.expectedDeviceIdentity, + this.commandTimeout = BleProtocol.commandTimeout, + this.maxRetries = BleProtocol.commandMaxRetries, + }) : _postClaimSequenceForTesting = null { + _listenLink(); + } + + /// Sequence seed seam for deterministic boundary tests; CLAIM still starts + /// at sequence one as required by the protocol. + DeviceSession.testSeeded( + this.link, { + required int nextSequence, + this.expectedDeviceIdentity, + this.commandTimeout = BleProtocol.commandTimeout, + this.maxRetries = BleProtocol.commandMaxRetries, + }) : _postClaimSequenceForTesting = nextSequence { + if (nextSequence < 2 || nextSequence > 0xffffffff) { + throw ArgumentError.value(nextSequence, 'nextSequence'); + } + _listenLink(); + } + + void _listenLink() { + _linkSub = link.connectionState.listen((state) { + if (state == BleLinkState.disconnected && + _snapshot.phase != DeviceSessionPhase.disconnected) { + _invalidate('BLE disconnected'); + } + }); + } + + final BleLink link; + final String? expectedDeviceIdentity; + final Duration commandTimeout; + final int maxRetries; + final int? _postClaimSequenceForTesting; + final _updates = StreamController.broadcast(); + StreamSubscription? _linkSub; + StreamSubscription>? _eventSub; + Timer? _keepalive; + DeviceSessionSnapshot _snapshot = const DeviceSessionSnapshot( + phase: DeviceSessionPhase.disconnected, + ); + DeviceSessionSnapshot get snapshot => _snapshot; + Stream get snapshots => _updates.stream; + int _generation = 0, _token = 0, _nextSequence = 1; + final Stopwatch _monotonic = Stopwatch()..start(); + Future _writeTail = Future.value(); + final Map> _pending = {}; + final Map _pendingTokens = {}; + List? _latestTarget; + Completer? _latestTargetDone; + bool _targetPumpScheduled = false, _disposed = false; + int _targetGeneration = 0; + Duration? _lastTargetSentAt; + Timer? _stateRefresh; + bool _stateReadInProgress = false; + bool _releaseInProgress = false; + + void _emit(DeviceSessionSnapshot next) { + if (_disposed) return; + _snapshot = next; + if (!_updates.isClosed) _updates.add(next); + } + + DeviceSessionSnapshot _copy({ + DeviceSessionPhase? phase, + String? identity, + BleDeviceInfo? deviceInfo, + BleStateSnapshot? state, + int? token, + int? sequence, + int? lastAckSequence, + DateTime? lastAckAt, + String? lastError, + }) => DeviceSessionSnapshot( + phase: phase ?? _snapshot.phase, + identity: identity ?? _snapshot.identity, + deviceInfo: deviceInfo ?? _snapshot.deviceInfo, + state: state ?? _snapshot.state, + token: token ?? _snapshot.token, + sequence: sequence ?? _snapshot.sequence, + lastAckSequence: lastAckSequence ?? _snapshot.lastAckSequence, + lastAckAt: lastAckAt ?? _snapshot.lastAckAt, + lastError: lastError, + ); + + Future connect(String id, {String? expectedIdentity}) async { + final generation = ++_generation; + _clearPending(); + _token = 0; + _nextSequence = 1; + _emit(const DeviceSessionSnapshot(phase: DeviceSessionPhase.connecting)); + try { + await link.connect(id); + _checkGeneration(generation); + _emit(_copy(phase: DeviceSessionPhase.reading)); + final identityBytes = await _setupRead(BleProtocol.identityUuid); + _checkGeneration(generation); + if (identityBytes.length != 16) { + throw const FormatException('DeviceIdentity must be 16 bytes'); + } + final identity = identityBytes + .map((x) => x.toRadixString(16).padLeft(2, '0')) + .join(); + final expected = expectedIdentity ?? expectedDeviceIdentity; + if (expected != null && + identity.toLowerCase() != + expected.replaceAll('-', '').toLowerCase()) { + throw StateError('Device identity mismatch'); + } + final info = BleProtocol.decodeDeviceInfo( + await _setupRead(BleProtocol.deviceInfoUuid), + ); + _checkGeneration(generation); + if (info.protocolMajor != 1 || !info.supportsRequiredCapabilities) { + throw StateError( + 'Incompatible BLE protocol or missing required capabilities', + ); + } + // The plugin exposes no CCCD-ready future. The protected read triggers + // pairing, then identity is re-read through the authenticated link. + final securedState = BleProtocol.decodeStateSnapshot( + await _setupRead(BleProtocol.stateSnapshotUuid, securitySetup: true), + ); + _checkGeneration(generation); + if (securedState.version != 1) { + throw StateError('Unsupported StateSnapshot version'); + } + final verifiedIdentity = await _setupRead(BleProtocol.identityUuid); + _checkGeneration(generation); + if (!_sameBytes(identityBytes, verifiedIdentity)) { + throw StateError('Device identity changed during secure setup'); + } + await _eventSub?.cancel(); + // The BLE plugin has no CCCD-ready future. Listening starts the native + // subscription; if setup still races CLAIM, only its same-byte seq=1 + // retry path is allowed to recover the missing subscription. + _eventSub = link + .subscribe(BleProtocol.eventTxUuid) + .listen( + _onEvent, + onError: (Object e) => _notificationFailure(e), + onDone: _notificationEnded, + ); + _emit(_copy(identity: identity, deviceInfo: info)); + _emit(_copy(phase: DeviceSessionPhase.claiming)); + final claim = await _command( + BleOpcode.claim, + token: 0, + generation: generation, + ); + _checkGeneration(generation); + _token = claim.token; + if (_token == 0) throw StateError('CLAIM returned a zero token'); + _nextSequence = _postClaimSequenceForTesting ?? 2; + final state = await _readSnapshot(generation); + _checkGeneration(generation); + if (state.version != 1 || state.token != _token) { + throw StateError('StateSnapshot token/version does not match CLAIM'); + } + _emit( + _copy( + phase: DeviceSessionPhase.readyPaused, + state: state, + token: _token, + sequence: 1, + lastAckSequence: 1, + lastAckAt: DateTime.now(), + ), + ); + _keepalive?.cancel(); + _keepalive = Timer.periodic( + const Duration(seconds: 2), + (_) => _enqueueCommand(BleOpcode.keepalive).catchError((Object e) { + _fail(e); + if (e is BleCommandRejected && _fatalResult(e.result)) { + unawaited(link.disconnect()); + } + }), + ); + _stateRefresh?.cancel(); + _stateRefresh = Timer.periodic( + const Duration(milliseconds: 500), + (_) => _refreshState(), + ); + } catch (e) { + if (generation == _generation) { + await _eventSub?.cancel(); + _eventSub = null; + _token = 0; + try { + await link.disconnect(); + } catch (_) {} + _emit(_copy(phase: DeviceSessionPhase.error, lastError: e.toString())); + } + rethrow; + } + } + + Future> _setupRead(String uuid, {bool securitySetup = false}) => + link + .read(uuid) + .timeout( + securitySetup + ? const Duration(seconds: 90) + : const Duration(seconds: 10), + onTimeout: () => throw TimeoutException('Timed out reading $uuid'), + ); + Future _readSnapshot(int generation) async { + final state = BleProtocol.decodeStateSnapshot( + await link + .read(BleProtocol.stateSnapshotUuid) + .timeout(const Duration(seconds: 2)), + ); + _checkGeneration(generation); + return state; + } + + bool _sameBytes(List a, List b) => + a.length == b.length && + List.generate(a.length, (i) => a[i] == b[i]).every((v) => v); + + Future arm({void Function()? beforeSend}) async { + _ensureReady(); + final generation = _generation; + await _enqueueCommand(BleOpcode.arm, beforeSend: beforeSend); + _checkGeneration(generation); + if (!_snapshot.targetKnown) { + throw StateError('ARM succeeded without a known three-channel output'); + } + } + + /// Changes the persistent owner pairing code. The value is never copied to + /// a session snapshot or included in errors; only the encoded frame carries + /// it over the encrypted BLE link. + Future setPairingCode( + String sixDigits, { + void Function()? beforeSend, + }) async { + _ensureOwnerManagement(); + final payload = BleProtocol.encodePairingCode(sixDigits); + final generation = _generation; + await _enqueueCommand( + BleOpcode.setPairingCode, + payload: payload, + beforeSend: beforeSend, + ); + _checkGeneration(generation); + } + + Future openTransfer({void Function()? beforeSend}) async { + _ensureOwnerManagement(); + final generation = _generation; + await _enqueueCommand(BleOpcode.openTransfer, beforeSend: beforeSend); + _checkGeneration(generation); + // OPEN_TRANSFER intentionally causes the peer to disconnect shortly after + // its ACK. Tear down locally now so the runtime cannot treat that expected + // disconnect as a recoverable link loss or attempt reconnection. + _token = 0; + try { + await link.disconnect(); + } finally { + _invalidate('Transfer window opened'); + } + } + + Future cancelTransfer({void Function()? beforeSend}) async { + _ensureOwnerManagement(); + final generation = _generation; + await _enqueueCommand(BleOpcode.cancelTransfer, beforeSend: beforeSend); + _checkGeneration(generation); + } + + Future setTarget({ + required int ch1, + required int ch2, + required int ch3, + int transitionMs = 200, + bool latestOnly = true, + }) async { + _ensureArmed(); + final payload = BleProtocol.encodeSetTarget( + ch1: ch1, + ch2: ch2, + ch3: ch3, + transitionMs: transitionMs, + ); + final targetGeneration = _targetGeneration; + if (!latestOnly) { + await _enqueueCommand( + BleOpcode.setTarget, + payload: payload, + targetGeneration: targetGeneration, + ); + return; + } + _latestTarget = payload; + final old = _latestTargetDone; + if (old != null && !old.isCompleted) { + old.completeError( + const TargetCancelledException('Target superseded by a newer target'), + ); + } + final done = _latestTargetDone = Completer(); + if (!_targetPumpScheduled) { + _targetPumpScheduled = true; + scheduleMicrotask(_pumpLatestTarget); + } + return done.future; + } + + Future _pumpLatestTarget() async { + // Keep the pump active while an ACK is outstanding. Touch updates replace + // the one pending slot instead of building a command/Future queue. + try { + while (_latestTarget != null && !_disposed) { + final generation = _targetGeneration; + final hz = _snapshot.deviceInfo?.maxTargetHz ?? 20; + final spacing = Duration(microseconds: 1000000 ~/ hz); + final last = _lastTargetSentAt; + if (last != null) { + final wait = spacing - (_monotonic.elapsed - last); + if (wait > Duration.zero) await Future.delayed(wait); + } + if (generation != _targetGeneration || _latestTarget == null) continue; + // Keep the pending slot replaceable during the rate-limit wait. + final payload = _latestTarget!; + final done = _latestTargetDone!; + _latestTarget = null; + _latestTargetDone = null; + try { + await _enqueueCommand( + BleOpcode.setTarget, + payload: payload, + targetGeneration: generation, + ); + if (!done.isCompleted) done.complete(); + } catch (e, st) { + if (!done.isCompleted) done.completeError(e, st); + } + } + } finally { + _targetPumpScheduled = false; + if (_latestTarget != null && !_disposed) { + _targetPumpScheduled = true; + scheduleMicrotask(_pumpLatestTarget); + } + } + } + + /// Synchronously invalidates targets that have not reached the transport. + void cancelPendingTargets() { + _targetGeneration++; + _latestTarget = null; + final done = _latestTargetDone; + _latestTargetDone = null; + if (done != null && !done.isCompleted) { + done.completeError(const TargetCancelledException()); + } + } + + Future halt() async { + _ensureReady(); + cancelPendingTargets(); + final generation = _generation; + await _enqueueCommand(BleOpcode.halt); + _checkGeneration(generation); + } + + Future release() async { + if (_token == 0) return; + _releaseInProgress = true; + cancelPendingTargets(); + _keepalive?.cancel(); + _stateRefresh?.cancel(); + try { + await _enqueueCommand(BleOpcode.release); + } finally { + _token = 0; + await link.disconnect(); + _invalidate('Session released'); + } + } + + Future disconnect() async { + _releaseInProgress = true; + _stateRefresh?.cancel(); + ++_generation; + cancelPendingTargets(); + _keepalive?.cancel(); + if (_token != 0) { + try { + await _enqueueCommand(BleOpcode.release); + } catch (_) {} + } + _token = 0; + await link.disconnect(); + _invalidate('Disconnected'); + } + + Future _enqueueCommand( + BleOpcode opcode, { + List? payload, + int? targetGeneration, + void Function()? beforeSend, + }) { + final sessionGeneration = _generation; + final completer = Completer(); + _writeTail = _writeTail.catchError((Object _) {}).then((_) async { + try { + _checkGeneration(sessionGeneration); + if (targetGeneration != null && targetGeneration != _targetGeneration) { + throw const TargetCancelledException(); + } + beforeSend?.call(); + await _command( + opcode, + token: opcode == BleOpcode.claim ? 0 : _token, + payload: payload, + generation: sessionGeneration, + targetGeneration: targetGeneration, + ); + _checkGeneration(sessionGeneration); + if (opcode == BleOpcode.arm || opcode == BleOpcode.halt) { + final state = await _readSnapshot(sessionGeneration); + if (state.version != 1 || state.token != _token) { + unawaited(link.disconnect()); + throw StateError( + '${opcode.name} StateSnapshot token/version mismatch', + ); + } + final phase = opcode == BleOpcode.arm + ? DeviceSessionPhase.armed + : DeviceSessionPhase.readyPaused; + if (opcode == BleOpcode.arm && !state.targetKnown) { + throw StateError('ARM StateSnapshot has unknown channels'); + } + _emit(_copy(phase: phase, state: state)); + } + if (!completer.isCompleted) completer.complete(); + } catch (e, st) { + if (!completer.isCompleted) completer.completeError(e, st); + } + }); + return completer.future; + } + + Future _command( + BleOpcode opcode, { + required int token, + required int generation, + int? targetGeneration, + List? payload, + }) async { + final ownerManagementCommand = + opcode == BleOpcode.setPairingCode || + opcode == BleOpcode.openTransfer || + opcode == BleOpcode.cancelTransfer; + if (_nextSequence > 0xffffffff) { + throw StateError('Sequence exhausted; reconnect required'); + } + final seq = _nextSequence++; + final bytes = BleProtocol.encodeControlFrame( + opcode: opcode, + sequence: seq, + token: token, + payload: payload, + ); + _emit(_copy(sequence: seq)); + Object? lastError; + var subscriptionRetries = 0; + for (var attempt = 0; attempt <= maxRetries; attempt++) { + _checkGeneration(generation); + if (targetGeneration != null && targetGeneration != _targetGeneration) { + throw const TargetCancelledException(); + } + final waiter = Completer(); + _pending[seq] = waiter; + _pendingOpcodes[seq] = opcode.replyValue; + _pendingTokens[seq] = token; + var writeCompleted = false; + try { + // Future.wait subscribes to the ACK completer before awaiting the + // write, while bounding the entire write+business-ACK exchange. + if (opcode == BleOpcode.setTarget) { + _lastTargetSentAt = _monotonic.elapsed; + } + final writeFuture = link.write(BleProtocol.controlRxUuid, bytes); + unawaited( + writeFuture.then( + (_) => writeCompleted = true, + onError: (Object _) {}, + ), + ); + final pair = await Future.wait([ + writeFuture, + waiter.future, + ]).timeout(commandTimeout); + final event = pair[1] as BleEvent; + _pending.remove(seq); + _pendingOpcodes.remove(seq); + _pendingTokens.remove(seq); + _checkGeneration(generation); + if (event.opcode != opcode.replyValue || event.sequence != seq) { + continue; + } + final expectedToken = + opcode == BleOpcode.claim && event.result == BleResult.ok + ? event.token + : token; + if (event.token != expectedToken || + (opcode == BleOpcode.claim && + event.result == BleResult.ok && + event.token == 0)) { + continue; + } + if (event.result != BleResult.ok) { + throw BleCommandRejected(opcode, event.result); + } + if (opcode == BleOpcode.claim) _token = event.token; + _emit( + _copy(token: _token, lastAckSequence: seq, lastAckAt: DateTime.now()), + ); + return event; + } catch (e) { + _pending.remove(seq); + _pendingOpcodes.remove(seq); + _pendingTokens.remove(seq); + _checkGeneration(generation); + if (e is BleCommandRejected) { + if (_fatalResult(e.result)) { + try { + await link.disconnect(); + } catch (_) {} + } + if (opcode == BleOpcode.claim && + e.result == BleResult.subscriptionRequired && + subscriptionRetries < 20) { + subscriptionRetries++; + await Future.delayed(const Duration(milliseconds: 100)); + _checkGeneration(generation); + attempt--; + continue; + } + rethrow; + } + if (e is TargetCancelledException) rethrow; + if (!writeCompleted) { + try { + await link.disconnect(); + } catch (_) {} + throw StateError( + ownerManagementCommand + ? 'Owner-management write did not complete safely' + : 'BLE write did not complete safely: $e', + ); + } + lastError = ownerManagementCommand ? 'transport error' : e; + if (attempt == maxRetries) break; + } + } + final error = TimeoutException( + 'No business ACK for ${opcode.name} after ${maxRetries + 1} attempts: $lastError', + ); + // Losing business ACKs means transport state is uncertain. Disconnect so + // the device lease expires safely and the runtime cannot continue motion. + try { + await link.disconnect(); + } catch (_) {} + throw error; + } + + Future _refreshState() async { + if (_releaseInProgress || + _stateReadInProgress || + _token == 0 || + !_snapshot.isConnected || + _disposed) { + return; + } + _stateReadInProgress = true; + final generation = _generation, token = _token; + try { + final next = await _readSnapshot(generation); + if (_releaseInProgress || generation != _generation || token != _token) { + return; + } + if (next.version != 1 || next.token != token) { + await link.disconnect(); + return; + } + _emit(_copy(state: next)); + } catch (_) { + /* A bounded read failure is retried on the next 2 Hz tick. */ + } finally { + _stateReadInProgress = false; + } + } + + void _onEvent(List bytes) { + late final BleEvent event; + try { + event = BleProtocol.decodeEvent(bytes); + } catch (_) { + return; + } + if (event.version != 1) return; + if (event.opcode == 0xe0) { + // Events carry no channels and are not an atomic state snapshot. The + // bounded 2 Hz poll updates those fields from StateSnapshot. + if (_token == 0 || event.token != _token) return; + return; + } + final waiter = _pending[event.sequence]; + if (waiter == null || waiter.isCompleted) return; + if (event.opcode != _opcodeForPending(event.sequence)) return; + final reqToken = _pendingTokens[event.sequence]; + if (event.opcode == BleOpcode.claim.replyValue) { + if (event.result == BleResult.ok ? event.token == 0 : event.token != 0) { + return; + } + } else if (event.token != reqToken) { + return; + } + waiter.complete(event); + } + + bool _fatalResult(BleResult result) => + result == BleResult.badSession || + result == BleResult.notAuthorized || + result == BleResult.internalError; + void _notificationFailure(Object error) { + _fail(error); + _notificationEnded(); + } + + void _notificationEnded() { + if (!_disposed && + _snapshot.phase != DeviceSessionPhase.disconnected && + _snapshot.phase != DeviceSessionPhase.error) { + unawaited(link.disconnect()); + } + } + + final Map _pendingOpcodes = {}; + int _opcodeForPending(int seq) => _pendingOpcodes[seq] ?? 0; + void _ensureReady() { + if (_releaseInProgress || _token == 0 || !_snapshot.isConnected) { + throw StateError('No claimed BLE session'); + } + } + + void _ensureArmed() { + _ensureReady(); + if (!_snapshot.isArmed) { + throw StateError('ARM is required before SET_TARGET'); + } + } + + void _ensureOwnerManagement() { + _ensureReady(); + if (!(_snapshot.deviceInfo?.supportsOwnerManagement ?? false)) { + throw StateError('Device does not support owner management'); + } + } + + void _checkGeneration(int g) { + if (g != _generation) throw StateError('Stale BLE connection operation'); + } + + void _clearPending() { + for (final c in _pending.values) { + if (!c.isCompleted) c.completeError(StateError('Session invalidated')); + } + _pending.clear(); + _pendingTokens.clear(); + _pendingOpcodes.clear(); + } + + void _invalidate(String reason) { + _generation++; + _releaseInProgress = false; + _token = 0; + _keepalive?.cancel(); + _stateRefresh?.cancel(); + _clearPending(); + cancelPendingTargets(); + final notifications = _eventSub; + _eventSub = null; + unawaited(notifications?.cancel()); + _emit( + DeviceSessionSnapshot( + phase: DeviceSessionPhase.disconnected, + lastError: reason, + ), + ); + } + + void _fail(Object e) { + _emit(_copy(lastError: e.toString())); + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + _generation++; + _keepalive?.cancel(); + _stateRefresh?.cancel(); + await _eventSub?.cancel(); + await _linkSub?.cancel(); + await _updates.close(); + } +} diff --git a/flutter_app/lib/core/legacy_udp_engine.dart b/flutter_app/lib/core/legacy_udp_engine.dart new file mode 100644 index 0000000..ecbcb8b --- /dev/null +++ b/flutter_app/lib/core/legacy_udp_engine.dart @@ -0,0 +1,340 @@ +import 'dart:async'; +import 'dart:math'; +import '../infrastructure/udp_transport.dart'; +import 'protocol.dart'; + +typedef Clock = DateTime Function(); +typedef Schedule = Timer Function(Duration delay, void Function() callback); + +class ControlEngine { + ControlEngine( + this.transport, + this.actions, { + Clock? clock, + Schedule? schedule, + Random? random, + }) : now = clock ?? DateTime.now, + later = schedule ?? Timer.new, + random = random ?? Random(); + final Transport transport; + final Map> actions; + final Clock now; + final Schedule later; + final Random random; + final _updates = StreamController>.broadcast(); + Stream> get updates => _updates.stream; + final String runtimeId = DateTime.now().microsecondsSinceEpoch.toRadixString( + 36, + ); + int revision = 0; + String connection = 'disconnected'; + String mode = 'manual'; + String playback = 'idle'; + bool autoRotate = false; + bool autoWink = false; + int? battery; + DateTime? batteryAt; + Endpoint? endpoint; + Endpoint? discoveryTarget; + SafetyProfile? safety; + bool simulator = false; + List target = [1500, 1500, 1500]; + final List discovered = []; + final List simulators = []; + final Map discoveryBattery = {}; + String? error; + DateTime? _lastReply; + Timer? _heartbeat; + Timer? _discoveryTimer; + Timer? _reconnect; + Timer? _rotate; + Timer? _wink; + Timer? _frame; + int _epoch = 0; + int _attempts = 0; + bool _opened = false; + + Map snapshot() => { + 'runtimeId': runtimeId, + 'revision': revision, + 'connection': connection, + 'mode': mode, + 'playback': playback, + 'autoRotate': autoRotate, + 'autoWink': autoWink, + 'battery': battery, + 'batteryAt': batteryAt?.toUtc().toIso8601String(), + 'endpoint': endpoint?.toJson(), + 'discovered': discovered.map((v) => v.toJson()).toList(), + 'simulators': simulators.map((v) => v.toJson()).toList(), + 'target': target, + 'simulator': simulator, + 'error': error, + }; + + void _notify() { + revision++; + _updates.add(snapshot()); + } + + Future open() async { + if (_opened) return; + await transport.open(_onPacket, (message) { + error = message; + _notify(); + }); + _opened = true; + _heartbeat = later(const Duration(seconds: 20), _heartbeatTick); + _notify(); + } + + void discover({String? host}) { + if (!_opened) throw StateError('先启动会话'); + if (connection == 'connected') return; + discovered.clear(); + simulators.clear(); + discoveryBattery.clear(); + discoveryTarget = host == null ? null : Endpoint(host, 8888); + connection = 'searching'; + error = null; + _notify(); + _discoveryTimer?.cancel(); + _discoveryTimer = later(const Duration(seconds: 5), () { + if (connection == 'searching') { + connection = discovered.isEmpty ? 'no_results' : 'found'; + _notify(); + } + }); + transport.send( + discoveryTarget ?? const Endpoint('255.255.255.255', 8888), + Protocol.discovery, + ); + } + + void select( + Endpoint selected, { + SafetyProfile? profile, + required bool isSimulator, + }) { + if (!discovered.contains(selected)) throw StateError('请先发现该端点'); + if (isSimulator && !simulators.contains(selected)) { + throw StateError('该端点没有模拟器标识,不能启用完整行程'); + } + _cancelMotion(); + _discoveryTimer?.cancel(); + endpoint = selected; + safety = profile; + simulator = isSimulator; + target = List.of(profile?.initial ?? [1500, 1500, 1500]); + connection = 'connected'; + mode = 'manual'; + battery = discoveryBattery[selected]; + batteryAt = battery == null ? null : now(); + _lastReply = now(); + _attempts = 0; + _reconnect?.cancel(); + error = null; + _notify(); + } + + void _onPacket(Endpoint source, List bytes) { + final reply = Protocol.parse(bytes); + if (reply == null) return; + if (connection == 'searching' && reply.kind == ReplyKind.discovery) { + if (discoveryTarget != null && source.host != discoveryTarget!.host) { + return; + } + if (!discovered.contains(source)) discovered.add(source); + if (reply.simulator && !simulators.contains(source)) { + simulators.add(source); + } + discoveryBattery[source] = reply.battery; + _notify(); + return; + } + if (endpoint != source || + (connection != 'connected' && connection != 'reconnecting')) { + return; + } + if (reply.kind == ReplyKind.discovery) return; + _lastReply = now(); + if (reply.battery != null) { + battery = reply.battery; + batteryAt = now(); + } + if (connection == 'reconnecting') { + connection = 'connected'; + _attempts = 0; + _reconnect?.cancel(); + error = null; + } + if (reply.kind == ReplyKind.mode && reply.mode == 'Sleep') { + mode = 'sleep'; + _cancelMotion(); + } + _notify(); + } + + void _heartbeatTick() { + _heartbeat = later(const Duration(seconds: 20), _heartbeatTick); + final selected = endpoint; + if (selected == null) return; + if (connection == 'connected' && + _lastReply != null && + now().difference(_lastReply!) >= const Duration(seconds: 60)) { + _cancelMotion(); + connection = 'reconnecting'; + error = '60 秒未收到设备有效响应'; + _attempts = 0; + _reconnectTick(); + _notify(); + return; + } + if (connection == 'connected') transport.send(selected, Protocol.heartbeat); + } + + void _reconnectTick() { + if (connection != 'reconnecting') return; + if (_attempts >= 5) { + connection = 'failed'; + error = '原设备恢复失败,请重新发现并选择'; + _notify(); + return; + } + _attempts++; + transport.send(endpoint!, Protocol.heartbeat); + _reconnect = later(const Duration(seconds: 10), _reconnectTick); + } + + void _requireControl() { + if (connection != 'connected') throw StateError('设备未连接'); + if (safety == null) throw StateError('请先填写已确认的安全范围和初始目标'); + } + + void setManual(List proportions) { + _requireControl(); + if (proportions.length != 3) throw const FormatException('需要三个通道'); + final next = [ + for (var i = 0; i < 3; i++) + proportions[i] == -1 ? target[i] : Protocol.proportion(proportions[i]), + ]; + _cancelMotion(); + mode = 'manual'; + _send(next); + } + + void setAutoRotate(bool enabled) { + _requireControl(); + _frame?.cancel(); + playback = 'idle'; + _rotate?.cancel(); + autoRotate = enabled; + mode = enabled ? 'auto' : 'manual'; + _notify(); + if (enabled) _rotateTick(); + } + + void _rotateTick() { + if (!autoRotate || connection != 'connected') return; + final next = List.of(target); + next[0] = (1500 + (random.nextDouble() - .5) * 500).truncate(); + next[1] = (1500 + (random.nextDouble() - .5) * 500).truncate(); + _send(next, smoothMs: 200); + _rotate = later(const Duration(seconds: 5), _rotateTick); + } + + void setAutoWink(bool enabled) { + _requireControl(); + _wink?.cancel(); + autoWink = enabled; + _notify(); + if (enabled) _wink = later(const Duration(seconds: 5), _winkTick); + } + + void _winkTick() { + if (!autoWink || connection != 'connected') return; + if (playback == 'idle') { + play(random.nextBool() ? 'wink' : 'wink2', automatic: true); + } + _wink = later(const Duration(seconds: 5), _winkTick); + } + + void play(String name, {bool automatic = false}) { + _requireControl(); + final frames = actions[name]; + if (frames == null) throw StateError('未知动作 $name'); + if (playback == 'playing') return; + _frame?.cancel(); + playback = 'playing'; + final epoch = ++_epoch; + _notify(); + void step(int index) { + if (epoch != _epoch || connection != 'connected') return; + if (index >= frames.length) { + playback = 'idle'; + _notify(); + return; + } + final next = List.of(target); + for (var i = 0; i < 3; i++) { + if (frames[index].channels[i] != -1) { + next[i] = Protocol.proportion(frames[index].channels[i]); + } + } + _send(next, smoothMs: 200); + _frame = later(frames[index].duration, () => step(index + 1)); + } + + step(0); + } + + void _send(List next, {int? smoothMs}) { + _requireControl(); + target = safety!.constrain(next); + transport.send(endpoint!, Protocol.pwm(target, smoothMs: smoothMs)); + _notify(); + } + + void _cancelMotion() { + ++_epoch; + _frame?.cancel(); + _rotate?.cancel(); + _wink?.cancel(); + autoRotate = false; + autoWink = false; + playback = 'idle'; + } + + void stopMotion() { + _cancelMotion(); + mode = 'manual'; + _notify(); + } + + void disconnect() { + _cancelMotion(); + _discoveryTimer?.cancel(); + _reconnect?.cancel(); + if (endpoint != null) { + try { + transport.send(endpoint!, Protocol.disconnect); + } catch (_) { + /* best effort */ + } + } + endpoint = null; + safety = null; + connection = 'disconnected'; + mode = 'manual'; + battery = null; + batteryAt = null; + _notify(); + } + + void dispose() { + disconnect(); + _heartbeat?.cancel(); + transport.close(); + _updates.close(); + } +} diff --git a/flutter_app/lib/core/protocol.dart b/flutter_app/lib/core/protocol.dart new file mode 100644 index 0000000..d319bc5 --- /dev/null +++ b/flutter_app/lib/core/protocol.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; + +class Endpoint { + const Endpoint(this.host, this.port); + final String host; + final int port; + Map toJson() => {'host': host, 'port': port}; + @override + bool operator ==(Object other) => + other is Endpoint && host == other.host && port == other.port; + @override + int get hashCode => Object.hash(host, port); + @override + String toString() => '$host:$port'; +} + +enum ReplyKind { discovery, heartbeat, mode } + +class Reply { + const Reply(this.kind, {this.battery, this.mode, this.simulator = false}); + final ReplyKind kind; + final int? battery; + final String? mode; + final bool simulator; +} + +class Protocol { + static const discovery = 'SatoriEye_DISCOVERY_REQUEST'; + static const heartbeat = 'SatoriEye_HEARTBEAT_REQUEST'; + static const disconnect = 'SatoriEye_DISCONNECT'; + + static Reply? parse(List bytes) { + final String message; + try { + message = utf8.decode(bytes); + } on FormatException { + return null; + } + final parts = message.split(','); + final kind = switch (parts.first) { + 'SatoriEye_DISCOVERY_RESPONSE' => ReplyKind.discovery, + 'SatoriEye_HEARTBEAT_RESPONSE' => ReplyKind.heartbeat, + _ => null, + }; + final simulator = + kind == ReplyKind.discovery && + parts.length == 3 && + parts[2] == 'SIMULATOR'; + if (kind != null && (parts.length <= 2 || simulator)) { + final value = parts.length >= 2 ? int.tryParse(parts[1]) : null; + return Reply( + kind, + battery: value != null && value >= 0 && value <= 100 ? value : null, + simulator: simulator, + ); + } + if (message == 'SET_MODE_SUCCESS:Sleep') { + return const Reply(ReplyKind.mode, mode: 'Sleep'); + } + return null; + } + + static int proportion(num value) { + if (!value.isFinite || value < 0 || value > 1) { + throw const FormatException('通道比例必须在 0–1 之间'); + } + return (500 + 2000 * value).truncate(); + } + + static String pwm(List channels, {int? smoothMs}) { + if (channels.length != 3 || channels.any((v) => v < 500 || v > 2500)) { + throw const FormatException('PWM 必须在 500–2500 之间'); + } + if (smoothMs != null && (smoothMs <= 0 || smoothMs > 60000)) { + throw const FormatException('无效平滑时长'); + } + final body = 'CH1:${channels[0]}CH2:${channels[1]}CH3:${channels[2]}'; + return smoothMs == null ? body : 'SMOOTH:${body}MS:$smoothMs'; + } +} + +class ActionFrame { + ActionFrame(this.channels, this.duration); + final List channels; + final Duration duration; + + factory ActionFrame.fromJson(Object? value) { + if (value is! Map) throw const FormatException('动作帧必须是对象'); + final channels = []; + for (final key in ['CH1', 'CH2', 'CH3']) { + final v = value[key]; + if (v is! num || !v.isFinite || (v != -1 && (v < 0 || v > 1))) { + throw FormatException('无效动作通道 $key'); + } + channels.add(v); + } + final ms = value['duration']; + if (ms is! int || ms <= 0 || ms > 60000) { + throw const FormatException('动作时长必须为 1–60000 毫秒整数'); + } + return ActionFrame(channels, Duration(milliseconds: ms)); + } +} + +Map> parseActions(String json) { + final value = jsonDecode(json); + if (value is! Map) { + throw const FormatException('动作资源必须是对象'); + } + return value.map((name, frames) { + if (frames is! List || frames.isEmpty) { + throw FormatException('动作 $name 没有有效帧'); + } + return MapEntry(name, frames.map(ActionFrame.fromJson).toList()); + }); +} + +class SafetyProfile { + SafetyProfile(this.minimum, this.maximum, this.initial) { + if ([minimum, maximum, initial].any((v) => v.length != 3)) { + throw const FormatException('必须设置三个通道'); + } + for (var i = 0; i < 3; i++) { + if (minimum[i] < 500 || + maximum[i] > 2500 || + minimum[i] > maximum[i] || + initial[i] < minimum[i] || + initial[i] > maximum[i]) { + throw const FormatException('初始目标须位于已确认范围内,协议范围为 500–2500'); + } + } + } + final List minimum; + final List maximum; + final List initial; + List constrain(List target) => [ + for (var i = 0; i < 3; i++) target[i].clamp(minimum[i], maximum[i]), + ]; + Map toJson() => { + 'minimum': minimum, + 'maximum': maximum, + 'initial': initial, + }; + factory SafetyProfile.fromJson(Map value) => SafetyProfile( + List.from(value['minimum'] as List), + List.from(value['maximum'] as List), + List.from(value['initial'] as List), + ); + factory SafetyProfile.simulator() => + SafetyProfile([500, 500, 500], [2500, 2500, 2500], [1500, 1500, 1500]); +} diff --git a/flutter_app/lib/core/safety_limits.dart b/flutter_app/lib/core/safety_limits.dart new file mode 100644 index 0000000..062b356 --- /dev/null +++ b/flutter_app/lib/core/safety_limits.dart @@ -0,0 +1,39 @@ +/// User-confirmed logical input limits, never inferred from a sample posture. +class SafetyLimits { + /// Default logical range retained from the original Qt Satori C3 controls. + /// This is not a measured mechanical safe envelope; firmware calibration and + /// per-channel clamping remain responsible for physical travel limits. + static final SafetyLimits builtInSatoriC3 = SafetyLimits( + [500, 500, 500], + [2500, 2500, 2500], + ); + + SafetyLimits(List minimum, List maximum) + : minimum = List.unmodifiable(minimum), + maximum = List.unmodifiable(maximum) { + if (minimum.length != 3 || maximum.length != 3) { + throw const FormatException('需要三通道安全范围'); + } + for (var i = 0; i < 3; i++) { + if (minimum[i] < 500 || maximum[i] > 2500 || minimum[i] > maximum[i]) { + throw const FormatException('安全范围必须位于 500–2500 逻辑值内'); + } + } + } + final List minimum; + final List maximum; + List constrain(List values) => [ + for (var i = 0; i < 3; i++) values[i].clamp(minimum[i], maximum[i]), + ]; + bool contains(List values) => + values.length == 3 && + List.generate( + 3, + (i) => values[i] >= minimum[i] && values[i] <= maximum[i], + ).every((v) => v); + Map toJson() => {'minimum': minimum, 'maximum': maximum}; + factory SafetyLimits.fromJson(Map value) => SafetyLimits( + List.from(value['minimum'] as List), + List.from(value['maximum'] as List), + ); +} diff --git a/flutter_app/lib/infrastructure/ble_discovery.dart b/flutter_app/lib/infrastructure/ble_discovery.dart new file mode 100644 index 0000000..b1cc192 --- /dev/null +++ b/flutter_app/lib/infrastructure/ble_discovery.dart @@ -0,0 +1,13 @@ +/// A BLE peripheral discovered while the app's owning runtime is scanning. +class BleDiscoveredDevice { + const BleDiscoveredDevice({ + required this.id, + required this.name, + required this.rssi, + }); + + /// Platform connection identifier. It is not a stable authorization identity. + final String id; + final String name; + final int rssi; +} diff --git a/flutter_app/lib/infrastructure/bluez_ble_link.dart b/flutter_app/lib/infrastructure/bluez_ble_link.dart new file mode 100644 index 0000000..abd150d --- /dev/null +++ b/flutter_app/lib/infrastructure/bluez_ble_link.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:bluez/bluez.dart'; + +import '../core/ble_protocol.dart'; +import '../core/device_session.dart'; +import 'ble_discovery.dart'; + +/// Linux transport for development diagnostics, backed by the system BlueZ +/// daemon and its configured pairing agent. +class BluezBleLink implements BleLink { + BluezBleLink({BlueZClient? client}) : _client = client ?? BlueZClient(); + + final BlueZClient _client; + BlueZDevice? _device; + StreamSubscription>? _deviceProperties; + final StreamController _state = + StreamController.broadcast(); + BleLinkState _currentState = BleLinkState.disconnected; + bool _connectedByThisLink = false; + bool _initialized = false; + + @override + Stream get connectionState => _state.stream; + + BleLinkState get currentState => _currentState; + + Future _initialize() async { + if (_initialized) return; + await _client.connect(); + _initialized = true; + } + + Stream scan({ + Duration timeout = const Duration(seconds: 8), + }) async* { + await _initialize(); + final adapters = _client.adapters; + if (adapters.isEmpty) throw StateError('BlueZ has no Bluetooth adapter'); + final adapter = adapters.first; + final known = {}; + try { + await adapter.startDiscovery(); + final deadline = DateTime.now().add(timeout); + // BlueZ can publish the Device1 object before its advertising UUIDs. + // Poll the live cache for the fixed scan window so later property + // updates are considered without extending the scan on each update. + while (DateTime.now().isBefore(deadline)) { + for (final device in _client.devices) { + if (!device.uuids.any( + (uuid) => uuid.toString().toLowerCase() == BleProtocol.serviceUuid, + )) { + continue; + } + final address = device.address; + if (address.isEmpty || !known.add(address)) continue; + yield BleDiscoveredDevice( + id: address, + name: device.name.isNotEmpty ? device.name : device.alias, + rssi: device.rssi, + ); + } + await Future.delayed(const Duration(milliseconds: 100)); + } + } finally { + if (adapter.discovering) await adapter.stopDiscovery(); + } + } + + @override + Future connect(String id) async { + await _initialize(); + if (_device != null) throw StateError('A BLE device is already connected'); + final device = _client.devices + .where((item) => item.address == id) + .firstOrNull; + if (device == null) { + throw StateError('BlueZ device was not discovered: $id'); + } + _device = device; + _connectedByThisLink = false; + _setState(BleLinkState.connecting); + _deviceProperties = device.propertiesChanged.listen((properties) { + if (!identical(_device, device)) return; + if (!properties.contains('Connected')) return; + if (device.connected) { + _connectedByThisLink = true; + _setState(BleLinkState.connected); + } else { + _setState(BleLinkState.disconnected); + } + }); + // Delegate passkey entry/confirmation to the system's registered BlueZ + // agent. This client never accepts or prints a passkey itself. + try { + if (!device.paired) await device.pair(); + await device.connect(); + _connectedByThisLink = true; + _setState(BleLinkState.connected); + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (!device.servicesResolved && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + if (device.servicesResolved) return; + await disconnect(); + throw TimeoutException('BlueZ GATT service discovery timed out'); + } catch (_) { + await disconnect(); + rethrow; + } + } + + BlueZGattCharacteristic _characteristic(String uuid) { + final device = _device; + if (device == null || !device.connected) { + throw StateError('BlueZ link is not connected'); + } + for (final service in device.gattServices) { + if (service.uuid.toString().toLowerCase() != BleProtocol.serviceUuid) { + continue; + } + for (final characteristic in service.characteristics) { + if (characteristic.uuid.toString().toLowerCase() == + uuid.toLowerCase()) { + return characteristic; + } + } + } + throw StateError('GATT characteristic not found: $uuid'); + } + + @override + Future> read(String uuid) => _characteristic(uuid).readValue(); + + @override + Future write(String uuid, List value) => _characteristic( + uuid, + ).writeValue(value, type: BlueZGattCharacteristicWriteType.request); + + @override + Stream> subscribe(String uuid) async* { + final characteristic = _characteristic(uuid); + final controller = StreamController>(); + final valueSubscription = characteristic.propertiesChanged.listen(( + properties, + ) { + if (properties.contains('Value')) controller.add(characteristic.value); + }); + try { + await characteristic.startNotify(); + yield* controller.stream; + } finally { + await valueSubscription.cancel(); + await controller.close(); + await characteristic.stopNotify(); + } + } + + @override + Future disconnect() async { + final device = _device; + _device = null; + await _deviceProperties?.cancel(); + _deviceProperties = null; + if (device != null && _connectedByThisLink && device.connected) { + await device.disconnect(); + } + _connectedByThisLink = false; + _setState(BleLinkState.disconnected); + } + + void _setState(BleLinkState state) { + _currentState = state; + if (!_state.isClosed) _state.add(state); + } + + Future dispose() async { + await disconnect(); + if (_initialized) await _client.close(); + await _state.close(); + } +} diff --git a/flutter_app/lib/infrastructure/fake_ble_link.dart b/flutter_app/lib/infrastructure/fake_ble_link.dart new file mode 100644 index 0000000..cbed7d9 --- /dev/null +++ b/flutter_app/lib/infrastructure/fake_ble_link.dart @@ -0,0 +1,498 @@ +import 'dart:async'; +import 'dart:math'; + +import '../core/ble_protocol.dart'; +import '../core/device_session.dart'; + +/// Shared persistent state for multiple synthetic phones talking to one +/// peripheral. It models passkey pairing and the single active control link. +class FakeBleSharedDevice { + FakeBleSharedDevice({this.pairingCode = 123456, this.maxBonds = 8}); + + int pairingCode; + final int maxBonds; + final Set _bondedPhones = {}; + String? _activePhone; + + Set get bondedPhones => Set.unmodifiable(_bondedPhones); + String? get activePhone => _activePhone; + + bool pairPhone(String phoneId, String sixDigitCode) { + if (_activePhone != null) return false; + if (!RegExp(r'^\d{6}$').hasMatch(sixDigitCode) || + int.parse(sixDigitCode) != pairingCode) { + return false; + } + if (_bondedPhones.contains(phoneId)) return true; + if (_bondedPhones.length >= maxBonds) return false; + _bondedPhones.add(phoneId); + return true; + } + + bool setPairingCode(String phoneId, int code) { + if (_activePhone != phoneId || + !_bondedPhones.contains(phoneId) || + code < 0 || + code > 999999 || + code == 123456) { + return false; + } + pairingCode = code; + return true; + } + + bool acquireControl(String phoneId) { + if (!_bondedPhones.contains(phoneId) || _activePhone != null) return false; + _activePhone = phoneId; + return true; + } + + void releaseControl(String phoneId) { + if (_activePhone == phoneId) _activePhone = null; + } +} + +/// In-memory BLE peer with protocol sequencing, idempotency and a lease. +/// Its synthetic startup position is software test data, never calibration. +class FakeBleLink implements BleLink { + FakeBleLink({ + this.identity = const [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + ], + this.token = 0x12345678, + this.dropNextReplies = 0, + this.configured = true, + this.writeDelay = Duration.zero, + this.connectDelay = Duration.zero, + this.subscriptionDelay = Duration.zero, + this.leaseTimeout = const Duration(seconds: 6), + this.snapshotTokenOverride, + this.ownerAuthorized = true, + this.ownerManagementSupported = true, + this.nvsWriteFailures = 0, + this.initialPairingCode = 123456, + this.transferWindowTimeout = const Duration(seconds: 60), + this.sharedPairingSupported = true, + this.sharedDevice, + this.phoneId = 'synthetic-phone', + }); + final List identity; + final int token; + final bool configured; + final Duration leaseTimeout; + Duration writeDelay; + Duration connectDelay; + Duration subscriptionDelay; + int? snapshotTokenOverride; + final bool ownerAuthorized; + final bool ownerManagementSupported; + int nvsWriteFailures; + final int initialPairingCode; + final Duration transferWindowTimeout; + final bool sharedPairingSupported; + final FakeBleSharedDevice? sharedDevice; + final String phoneId; + int? _pairingCode; + bool _transferWindowOpen = false; + Timer? _transferTimer; + int get pairingCode => + sharedDevice?.pairingCode ?? _pairingCode ?? initialPairingCode; + bool get transferWindowOpen => _transferWindowOpen; + bool get ownerIdentityUnchanged => true; + int pairingCodeWrites = 0; + int dropNextReplies; + final List> writes = []; + final List> acceptedCommands = []; + int acceptedTargetSubmissions = 0; + final StreamController _states = StreamController.broadcast(); + final StreamController> _events = StreamController.broadcast(); + BleLinkState _state = BleLinkState.disconnected; + int _activeToken = 0, _highestSequence = 0, _lastApplied = 0; + int _connectionCount = 0; + final Map> _requestCache = {}; + final Map> _replyCache = {}; + bool _armed = false, _subscribed = false; + bool _released = false; + List _channels = const [0, 0, 0]; + int _validMask = 0; + Timer? _leaseTimer; + Timer? _subscriptionTimer; + Timer? _releaseTimer; + Timer? _transferDisconnectTimer; + bool _disposed = false; + int _connectGeneration = 0; + + int get currentToken => _activeToken; + int get highestSequence => _highestSequence; + BleLinkState get currentState => _state; + void revokeSession() { + _leaseTimer?.cancel(); + _subscriptionTimer?.cancel(); + _releaseTimer?.cancel(); + _activeToken = 0; + _released = true; + } + + @override + Stream get connectionState => _states.stream; + + @override + Future connect(String id) async { + if (sharedDevice != null && !sharedDevice!.acquireControl(phoneId)) { + throw StateError('Phone is unbonded or the control link is occupied'); + } + final generation = ++_connectGeneration; + _setState(BleLinkState.connecting); + await Future.delayed(connectDelay); + if (generation != _connectGeneration) return; + _setState(BleLinkState.connected); + _connectionCount++; + _activeToken = 0; + _highestSequence = 0; + _requestCache.clear(); + _replyCache.clear(); + _subscribed = false; + _released = false; + _leaseTimer?.cancel(); + _transferDisconnectTimer?.cancel(); + } + + @override + Future disconnect() async { + _connectGeneration++; + _leaseTimer?.cancel(); + _subscriptionTimer?.cancel(); + _releaseTimer?.cancel(); + _transferDisconnectTimer?.cancel(); + _activeToken = 0; + _highestSequence = 0; + _requestCache.clear(); + _replyCache.clear(); + _subscribed = false; + _released = false; + _setState(BleLinkState.disconnected); + sharedDevice?.releaseControl(phoneId); + } + + /// Models a device reboot: its persistent pairing code survives while the + /// temporary transfer window and in-memory BLE session are cleared. + Future reboot() async { + _transferTimer?.cancel(); + _transferDisconnectTimer?.cancel(); + _transferWindowOpen = false; + await disconnect(); + } + + @override + Future> read(String uuid) async { + if (_state != BleLinkState.connected) throw StateError('Not connected'); + if (uuid == BleProtocol.identityUuid) return List.from(identity); + if (uuid == BleProtocol.deviceInfoUuid) { + return BleProtocol.encodeDeviceInfo( + protocolMinor: sharedPairingSupported + ? 2 + : (ownerManagementSupported ? 1 : 0), + capabilities: + (ownerManagementSupported ? 0xdf : 0x5f) | + (sharedPairingSupported ? 0x100 : 0), + securityPolicy: sharedPairingSupported ? 2 : 1, + ); + } + if (uuid == BleProtocol.stateSnapshotUuid) { + return BleProtocol.encodeStateSnapshot( + controlState: _activeToken == 0 ? 0 : 1, + token: _activeToken == 0 + ? _activeToken + : (snapshotTokenOverride ?? _activeToken), + lastAppliedSequence: _lastApplied, + channels: _channels, + validChannelMask: _validMask, + ); + } + throw StateError('Unknown characteristic $uuid'); + } + + @override + Stream> subscribe(String uuid) { + if (uuid != BleProtocol.eventTxUuid) { + throw StateError('Unknown notify characteristic'); + } + _subscriptionTimer?.cancel(); + if (subscriptionDelay == Duration.zero) { + _subscribed = true; + } else { + _subscribed = false; + _subscriptionTimer = Timer(subscriptionDelay, () { + if (_state == BleLinkState.connected) _subscribed = true; + }); + } + return _events.stream; + } + + @override + Future write(String uuid, List value) async { + if (uuid != BleProtocol.controlRxUuid || _state != BleLinkState.connected) { + throw StateError('Invalid write'); + } + if (writeDelay > Duration.zero) await Future.delayed(writeDelay); + final bytes = List.from(value); + writes.add(bytes); + if (bytes.length != 20) { + return; // BLE ATT would reject malformed value lengths. + } + final frame = BleProtocol.decodeControlFrame(bytes); + final oldRequest = _requestCache[frame.sequence]; + if (oldRequest != null) { + if (_same(oldRequest, bytes)) { + final cached = _replyCache[frame.sequence]; + if (cached != null && _subscribed) { + if (dropNextReplies > 0) { + dropNextReplies--; + return; + } + _events.add(List.from(cached)); + } + } else { + _reply(frame, BleResult.sequenceConflict); + } + return; + } + if (frame.opcode == BleOpcode.claim.value && _released) { + _reply(frame, BleResult.badSession); + return; + } + if (_released && frame.opcode != BleOpcode.release.value) { + _reply(frame, BleResult.badSession); + return; + } + if (frame.sequence <= _highestSequence && frame.sequence != 0) { + _reply(frame, BleResult.oldSequence); + return; + } + + final result = BleProtocol.validateControlFrame( + bytes, + currentToken: _activeToken, + armed: _armed, + subscribed: _subscribed, + ownerAuthorized: ownerAuthorized, + supportsOwnerManagement: ownerManagementSupported, + supportsSharedPairing: sharedPairingSupported, + currentPairingCode: pairingCode, + transferWindowOpen: _transferWindowOpen, + ); + if (result != BleResult.ok) { + _reply(frame, result); + return; + } + if (frame.opcode == BleOpcode.setPairingCode.value && + nvsWriteFailures > 0) { + nvsWriteFailures--; + _reply(frame, BleResult.internalError); + return; + } + var responseToken = frame.token; + switch (frame.opcode) { + case 1: + _activeToken = token == 0 + ? Random().nextInt(0xffffffff) + 1 + : (token + _connectionCount - 1) & 0xffffffff; + if (_activeToken == 0) _activeToken = 1; + responseToken = _activeToken; + _touchLease(); + break; + case 7: + if (_validMask != 7) { + if (!configured) { + _reply(frame, BleResult.notConfigured); + return; + } + _channels = const [1500, 1500, 1500]; + _validMask = 7; + } + _armed = true; + _lastApplied = frame.sequence; + _touchLease(); + break; + case 8: + _pairingCode = + frame.payload[0] | + (frame.payload[1] << 8) | + (frame.payload[2] << 16) | + (frame.payload[3] << 24); + sharedDevice?.setPairingCode(phoneId, _pairingCode!); + pairingCodeWrites++; + _touchLease(); + break; + case 9: + _lastApplied = frame.sequence; + _armed = false; + _touchLease(); + _transferWindowOpen = true; + _transferTimer?.cancel(); + _transferTimer = Timer(transferWindowTimeout, () { + _transferWindowOpen = false; + }); + _transferDisconnectTimer?.cancel(); + _transferDisconnectTimer = Timer( + const Duration(milliseconds: 400), + disconnect, + ); + break; + case 10: + _transferWindowOpen = false; + _transferTimer?.cancel(); + _transferDisconnectTimer?.cancel(); + _touchLease(); + break; + case 2: + _channels = [ + _u16(frame.payload, 0), + _u16(frame.payload, 2), + _u16(frame.payload, 4), + ]; + _validMask = 7; + _lastApplied = frame.sequence; + acceptedTargetSubmissions++; + _touchLease(); + break; + case 3: + _lastApplied = frame.sequence; + _touchLease(); + break; + case 4: + _lastApplied = frame.sequence; + _leaseTimer?.cancel(); + _activeToken = 0; + _armed = _validMask == 7; + _released = true; + responseToken = frame.token; + _releaseTimer?.cancel(); + _releaseTimer = Timer(BleProtocol.releaseAckWindow, disconnect); + break; + case 5: + _touchLease(); + break; + case 6: + // Status reads and GET_STATUS are deliberately not lease activity. + break; + default: + _reply(frame, BleResult.badOpcode); + return; + } + _highestSequence = frame.sequence; + acceptedCommands.add(bytes); + final ack = _makeReply(frame, result, responseToken); + if (frame.opcode == 4) { + _requestCache.clear(); + _replyCache.clear(); + } + _cache(frame.sequence, bytes, ack); + if (dropNextReplies > 0) { + dropNextReplies--; + return; + } + if (_subscribed) _events.add(ack); + } + + void _reply(BleControlFrame frame, BleResult result) { + if (!_subscribed) return; + _events.add( + _makeReply( + frame, + result, + frame.opcode == BleOpcode.claim.value && result == BleResult.ok + ? _activeToken + : frame.token, + ), + ); + } + + List _makeReply( + BleControlFrame frame, + BleResult result, + int responseToken, + ) => BleProtocol.encodeEvent( + opcode: frame.opcode | 0x80, + sequence: frame.sequence, + token: responseToken, + result: result, + controlState: _activeToken == 0 ? 0 : 1, + lastAppliedSequence: _lastApplied, + flags: (_validMask == 7 ? 1 : 0) | (_activeToken != 0 ? 8 : 0), + ); + void _cache(int sequence, List request, List reply) { + _requestCache[sequence] = List.from(request); + _replyCache[sequence] = List.from(reply); + while (_requestCache.length > 16) { + final oldest = _requestCache.keys.first; + _requestCache.remove(oldest); + _replyCache.remove(oldest); + } + } + + void _touchLease() { + _leaseTimer?.cancel(); + _leaseTimer = Timer(leaseTimeout, _expireLease); + } + + void _expireLease() { + _leaseTimer?.cancel(); + _subscriptionTimer?.cancel(); + _releaseTimer?.cancel(); + _transferDisconnectTimer?.cancel(); + _activeToken = 0; + _armed = _validMask == 7; + _requestCache.clear(); + _replyCache.clear(); + _highestSequence = 0; + _subscribed = false; + sharedDevice?.releaseControl(phoneId); + _setState(BleLinkState.disconnected); + } + + void expireLeaseNow() => _expireLease(); + void emitEvent(List event) { + if (_subscribed) _events.add(List.from(event)); + } + + void failNotifications(Object error) { + if (_subscribed) _events.addError(error); + } + + void _setState(BleLinkState next) { + _state = next; + if (!_states.isClosed) _states.add(next); + } + + static int _u16(List b, int i) => b[i] | b[i + 1] << 8; + bool _same(List a, List b) => + a.length == b.length && + List.generate(a.length, (i) => a[i] == b[i]).every((x) => x); + Future dispose() async { + if (_disposed) return; + _disposed = true; + _leaseTimer?.cancel(); + _subscriptionTimer?.cancel(); + _releaseTimer?.cancel(); + _transferTimer?.cancel(); + _transferDisconnectTimer?.cancel(); + await _states.close(); + await _events.close(); + } +} diff --git a/flutter_app/lib/infrastructure/reactive_ble_link.dart b/flutter_app/lib/infrastructure/reactive_ble_link.dart new file mode 100644 index 0000000..ddb240c --- /dev/null +++ b/flutter_app/lib/infrastructure/reactive_ble_link.dart @@ -0,0 +1,238 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../core/ble_protocol.dart'; +import '../core/device_session.dart'; +import 'ble_discovery.dart'; + +/// Requests only the permissions needed by the current Android release. +/// Invoke from a foreground UI gesture before starting the connected-device +/// service. The UI must not create a BLE adapter or open a BLE connection. +Future requestBlePermissions() async { + final sdkInt = await _androidSdkInt(); + final permissions = []; + if (sdkInt != null && sdkInt <= 30) { + permissions.add(Permission.locationWhenInUse); + } else { + permissions + ..add(Permission.bluetoothScan) + ..add(Permission.bluetoothConnect); + } + final statuses = await permissions.request(); + return statuses.values.every( + (status) => status.isGranted || status.isLimited, + ); +} + +Future _androidSdkInt() async { + try { + final info = await DeviceInfoPlugin().androidInfo; + return info.version.sdkInt; + } on Exception { + // If Android version cannot be queried, avoid adding a legacy location + // permission. The BLE runtime will surface a permission failure on scan. + return null; + } +} + +/// Android/iOS BLE transport. The runtime creates one instance and retains it +/// for the lifetime of the foreground-service FlutterEngine. +class ReactiveBleLink implements BleLink { + ReactiveBleLink({FlutterReactiveBle? ble}) + : ble = ble ?? FlutterReactiveBle(); + + final FlutterReactiveBle ble; + String? _deviceId; + StreamSubscription? _connectionSubscription; + final StreamController _state = + StreamController.broadcast(); + BleLinkState _currentState = BleLinkState.disconnected; + bool _scanActive = false; + int _connectionGeneration = 0; + + @override + Stream get connectionState => _state.stream; + + BleLinkState get currentState => _currentState; + + /// Compatibility wrapper; prefer the top-level permission helper. + static Future requestPermissions() => requestBlePermissions(); + + /// Scans only for the SatoriEye service. Retain the same adapter/runtime + /// instance for scanning and later connection ownership. + Stream scan({ + Duration timeout = const Duration(seconds: 8), + }) { + if (_scanActive) throw StateError('A BLE scan is already active'); + _scanActive = true; + return Stream.multi((controller) { + Timer? timer; + final subscription = ble + .scanForDevices( + withServices: [Uuid.parse(BleProtocol.serviceUuid)], + scanMode: ScanMode.lowLatency, + requireLocationServicesEnabled: false, + ) + .listen( + (device) => controller.add( + BleDiscoveredDevice( + id: device.id, + name: device.name, + rssi: device.rssi, + ), + ), + onError: (Object error, StackTrace stack) { + timer?.cancel(); + _scanActive = false; + controller.addError(error, stack); + controller.close(); + }, + onDone: () { + timer?.cancel(); + _scanActive = false; + controller.close(); + }, + ); + timer = Timer(timeout, () async { + await subscription.cancel(); + _scanActive = false; + await controller.close(); + }); + controller.onCancel = () async { + timer?.cancel(); + _scanActive = false; + await subscription.cancel(); + }; + }); + } + + @override + Future connect(String id) async { + if (_connectionSubscription != null) { + throw StateError('A BLE device is already connected or connecting'); + } + _deviceId = id; + _setState(BleLinkState.connecting); + final generation = ++_connectionGeneration; + final completer = Completer(); + _connectionSubscription = ble + .connectToDevice( + id: id, + servicesWithCharacteristicsToDiscover: { + Uuid.parse(BleProtocol.serviceUuid): const [], + }, + connectionTimeout: const Duration(seconds: 20), + ) + .listen( + (update) { + if (generation != _connectionGeneration) return; + switch (update.connectionState) { + case DeviceConnectionState.connecting: + _setState(BleLinkState.connecting); + case DeviceConnectionState.connected: + _setState(BleLinkState.connected); + if (!completer.isCompleted) completer.complete(); + case DeviceConnectionState.disconnecting: + case DeviceConnectionState.disconnected: + _setState(BleLinkState.disconnected); + if (!completer.isCompleted) { + completer.completeError( + StateError('BLE disconnected before becoming ready'), + ); + } + _dropConnection(generation); + } + }, + onError: (Object error, StackTrace stack) { + if (generation != _connectionGeneration) return; + _setState(BleLinkState.disconnected); + if (!completer.isCompleted) completer.completeError(error, stack); + _dropConnection(generation); + }, + onDone: () { + if (generation != _connectionGeneration) return; + _setState(BleLinkState.disconnected); + if (!completer.isCompleted) { + completer.completeError(StateError('BLE connection closed')); + } + _dropConnection(generation); + }, + ); + try { + await completer.future.timeout(const Duration(seconds: 25)); + if (Platform.isAndroid) { + try { + await ble + .requestConnectionPriority( + deviceId: id, + priority: ConnectionPriority.highPerformance, + ) + .timeout(const Duration(seconds: 2)); + } catch (_) { + // This is a connection-parameter preference, not a prerequisite. + } + } + } catch (_) { + await disconnect(); + rethrow; + } + } + + QualifiedCharacteristic _characteristic(String uuid) { + final id = _deviceId; + if (id == null || _currentState != BleLinkState.connected) { + throw StateError('BLE link is not connected'); + } + return QualifiedCharacteristic( + deviceId: id, + serviceId: Uuid.parse(BleProtocol.serviceUuid), + characteristicId: Uuid.parse(uuid), + ); + } + + @override + Future> read(String uuid) => + ble.readCharacteristic(_characteristic(uuid)); + + @override + Future write(String uuid, List value) => + ble.writeCharacteristicWithResponse(_characteristic(uuid), value: value); + + @override + Stream> subscribe(String uuid) => ble + .subscribeToCharacteristic(_characteristic(uuid)) + .map((value) => List.unmodifiable(value)); + + @override + Future disconnect() async { + ++_connectionGeneration; + final subscription = _connectionSubscription; + _connectionSubscription = null; + _deviceId = null; + await subscription?.cancel(); + _setState(BleLinkState.disconnected); + } + + void _setState(BleLinkState state) { + _currentState = state; + if (!_state.isClosed) _state.add(state); + } + + void _dropConnection(int generation) { + if (generation != _connectionGeneration) return; + final subscription = _connectionSubscription; + _connectionSubscription = null; + _deviceId = null; + ++_connectionGeneration; + if (subscription != null) unawaited(subscription.cancel()); + } + + Future dispose() async { + await disconnect(); + await _state.close(); + } +} diff --git a/flutter_app/lib/infrastructure/udp_transport.dart b/flutter_app/lib/infrastructure/udp_transport.dart new file mode 100644 index 0000000..29b677e --- /dev/null +++ b/flutter_app/lib/infrastructure/udp_transport.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'dart:io'; +import '../core/protocol.dart'; + +typedef PacketHandler = void Function(Endpoint source, List bytes); + +abstract interface class Transport { + Future open(PacketHandler onPacket, void Function(String) onError); + void send(Endpoint endpoint, String message); + void close(); +} + +class UdpTransport implements Transport { + UdpTransport({this.port = 8889}); + final int port; + RawDatagramSocket? _socket; + + @override + Future open( + PacketHandler onPacket, + void Function(String) onError, + ) async { + if (_socket != null) return; + try { + final socket = await RawDatagramSocket.bind( + InternetAddress.anyIPv4, + port, + reuseAddress: false, + ); + _socket = socket; + socket.broadcastEnabled = true; + socket.listen((event) { + if (event != RawSocketEvent.read) return; + Datagram? packet; + while ((packet = socket.receive()) != null) { + onPacket(Endpoint(packet!.address.address, packet.port), packet.data); + } + }, onError: (Object error) => onError('UDP 接收失败:$error')); + } on SocketException catch (error) { + throw StateError('无法绑定 UDP $port,请关闭占用该端口的客户端:$error'); + } + } + + @override + void send(Endpoint endpoint, String message) { + final socket = _socket; + if (socket == null) throw StateError('UDP 未打开'); + final bytes = utf8.encode(message); + if (socket.send(bytes, InternetAddress(endpoint.host), endpoint.port) != + bytes.length) { + throw StateError('UDP 发送未完成'); + } + } + + @override + void close() { + _socket?.close(); + _socket = null; + } +} diff --git a/flutter_app/lib/joystick_pad.dart b/flutter_app/lib/joystick_pad.dart new file mode 100644 index 0000000..1b5c4b1 --- /dev/null +++ b/flutter_app/lib/joystick_pad.dart @@ -0,0 +1,105 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +/// CH1 runs left to right, and CH2 runs top to bottom. +class JoystickPad extends StatelessWidget { + const JoystickPad({ + super.key, + required this.x, + required this.y, + required this.onChanged, + required this.onReleased, + }); + + final double x; + final double y; + final void Function(double x, double y) onChanged; + final VoidCallback onReleased; + + static Offset positionFor(Offset local, Size size) { + final radius = math.min(size.width, size.height) / 2; + final center = Offset(size.width / 2, size.height / 2); + if (radius <= 0) return const Offset(.5, .5); + final delta = local - center; + final distance = delta.distance; + final clamped = distance > radius ? delta * (radius / distance) : delta; + return Offset((clamped.dx / radius + 1) / 2, (clamped.dy / radius + 1) / 2); + } + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final diameter = math.min(constraints.maxWidth, 232.0); + final size = Size.square(diameter); + void update(Offset local) { + final position = positionFor(local, size); + onChanged(position.dx, position.dy); + } + + return Center( + child: SizedBox.square( + dimension: diameter, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (details) => update(details.localPosition), + onPanUpdate: (details) => update(details.localPosition), + onPanEnd: (_) => onReleased(), + onPanCancel: onReleased, + child: Semantics( + label: '方向摇杆,左右控制 CH1,上下控制 CH2', + child: Stack( + alignment: Alignment.center, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF242E48), + border: Border.all( + color: const Color(0xFF5B6086), + width: 2, + ), + ), + ), + Container( + width: 1, + height: diameter - 28, + color: const Color(0xFF454F6B), + ), + Container( + width: diameter - 28, + height: 1, + color: const Color(0xFF454F6B), + ), + Positioned( + left: (diameter - 48) * x.clamp(0.0, 1.0), + top: (diameter - 48) * y.clamp(0.0, 1.0), + child: IgnorePointer( + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFB6A2FF), + boxShadow: [ + BoxShadow( + color: const Color(0x66B6A2FF), + blurRadius: 18, + ), + ], + ), + child: const Icon( + Icons.control_camera_rounded, + color: Color(0xFF1B1730), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); +} diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart new file mode 100644 index 0000000..576f150 --- /dev/null +++ b/flutter_app/lib/main.dart @@ -0,0 +1,1172 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'core/safety_limits.dart'; +import 'core/control_status.dart'; +import 'infrastructure/reactive_ble_link.dart' show requestBlePermissions; +import 'joystick_pad.dart'; +import 'runtime/control_client.dart'; + +const _background = Color(0xFF0B1020); +const _surface = Color(0xFF171D30); +const _surfaceRaised = Color(0xFF202840); +const _accent = Color(0xFFB6A2FF); +const _mint = Color(0xFF80DFC0); +const _muted = Color(0xFFAAB4CE); + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + FlutterForegroundTask.initCommunicationPort(); + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'satori_control', + channelName: '觉瞳控制', + channelDescription: '显示觉瞳控制会话和停止入口', + onlyAlertOnce: true, + ), + iosNotificationOptions: const IOSNotificationOptions(), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.nothing(), + autoRunOnBoot: false, + autoRunOnMyPackageReplaced: false, + allowWakeLock: true, + allowWifiLock: false, + ), + ); + runApp(const SatoriApp()); +} + +class SatoriApp extends StatelessWidget { + const SatoriApp({super.key, this.previewClient, this.previewFontFamily}); + final ControlClient? previewClient; + final String? previewFontFamily; + @override + Widget build(BuildContext context) => MaterialApp( + title: '觉瞳', + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + fontFamily: previewFontFamily, + scaffoldBackgroundColor: _background, + colorScheme: ColorScheme.fromSeed( + seedColor: _accent, + brightness: Brightness.dark, + surface: _surface, + primary: _accent, + secondary: _mint, + ), + appBarTheme: const AppBarTheme( + backgroundColor: _background, + surfaceTintColor: Colors.transparent, + ), + cardTheme: const CardThemeData( + color: _surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: _surfaceRaised, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 15, + ), + ), + navigationBarTheme: const NavigationBarThemeData( + backgroundColor: _surface, + indicatorColor: Color(0xFF393251), + ), + ), + home: ControlShell(previewClient: previewClient), + ); +} + +class ControlShell extends StatefulWidget { + const ControlShell({super.key, this.previewClient}); + final ControlClient? previewClient; + @override + State createState() => _ControlShellState(); +} + +class _ControlShellState extends State + with WidgetsBindingObserver { + late final client = widget.previewClient ?? ControlClient(); + final minFields = List.generate(3, (_) => TextEditingController()); + final maxFields = List.generate(3, (_) => TextEditingController()); + final values = [0.5, 0.5, 0.5]; + int tab = 0; + bool busy = false; + bool resetStickOnRelease = true; + DateTime? _lastJoystickSend; + DateTime? _lastEyelidSend; + String? _joystickEndpoint; + String? _localError; + + void _showError(Object error) { + _localError = error.toString(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('操作未完成,请查看设备诊断信息'))); + setState(() {}); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + client.addListener(_refresh); + _syncJoystickTarget(); + } + + void _refresh() { + if (mounted) { + _syncJoystickTarget(); + setState(() {}); + } + } + + void _syncJoystickTarget() { + final state = client.state; + if (state['connection'] != 'connected' || + state['outputAuthorized'] != true) { + _joystickEndpoint = null; + return; + } + final endpoint = state['deviceId'].toString(); + if (_joystickEndpoint == endpoint) return; + _joystickEndpoint = endpoint; + final target = state['target']; + if (target is List && target.length == 3) { + for (var i = 0; i < 3; i++) { + if (target[i] is num) { + values[i] = ((target[i] as num) - 500) / 2000; + } + } + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) client.restore(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + client.removeListener(_refresh); + client.dispose(); + for (final field in [...minFields, ...maxFields]) { + field.dispose(); + } + super.dispose(); + } + + Future _run(Future Function() action) async { + if (busy) return; + setState(() { + busy = true; + _localError = null; + }); + try { + await action(); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => busy = false); + } + } + + SafetyLimits _limits() { + List read(List fields) => [ + for (final field in fields) int.parse(field.text.trim()), + ]; + return SafetyLimits(read(minFields), read(maxFields)); + } + + Future _discover() => _run(() async { + if (!Platform.isAndroid) { + throw StateError('手机界面目前只验证 Android;本机请使用 BLE 调试工具'); + } + if (!await requestBlePermissions()) throw StateError('请允许蓝牙权限后重试'); + final permission = + await FlutterForegroundTask.checkNotificationPermission(); + if (permission != NotificationPermission.granted) { + final granted = + await FlutterForegroundTask.requestNotificationPermission(); + if (granted != NotificationPermission.granted) { + throw StateError('请允许控制会话通知后重试'); + } + } + await client.start(); + await client.send('discover'); + }); + + Future _select(Map device) => _run(() async { + await client.send('select', {'deviceId': device['id']}); + final limits = client.state['safety']; + if (limits is Map) { + for (var i = 0; i < 3; i++) { + minFields[i].text = '${(limits['minimum'] as List)[i]}'; + maxFields[i].text = '${(limits['maximum'] as List)[i]}'; + } + } + if (mounted) setState(() => tab = 0); + }); + + Future _arm() => _run(() => client.send('arm')); + + Future _pause() async { + try { + await client.send('stop'); + } catch (e) { + _showError(e); + } + } + + void _sendJoystick(double x, double y, {bool force = false}) { + if (busy) return; + setState(() { + values[0] = x; + values[1] = y; + }); + final now = DateTime.now(); + if (!force && + _lastJoystickSend != null && + now.difference(_lastJoystickSend!) < const Duration(milliseconds: 50)) { + return; + } + _lastJoystickSend = now; + client + .send('manual', { + 'values': [x, y, -1], + }) + .catchError((Object error) => _showError(error)); + } + + void _releaseJoystick() { + if (resetStickOnRelease) { + _sendJoystick(.5, .5, force: true); + } else { + _sendJoystick(values[0], values[1], force: true); + } + } + + void _sendEyelid(double value, {bool force = false}) { + if (busy) return; + setState(() => values[2] = value); + final now = DateTime.now(); + if (!force && + _lastEyelidSend != null && + now.difference(_lastEyelidSend!) < const Duration(milliseconds: 50)) { + return; + } + _lastEyelidSend = now; + client + .send('manual', { + 'values': [-1, -1, value], + }) + .catchError((Object error) => _showError(error)); + } + + Widget _sectionTitle(String title, {String? description}) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 23, + fontWeight: FontWeight.w700, + letterSpacing: -.4, + ), + ), + if (description != null) ...[ + const SizedBox(height: 4), + Text(description, style: const TextStyle(color: _muted)), + ], + ], + ); + + Widget _panel(Widget child, {Color color = _surface}) => Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(24), + ), + child: child, + ); + + Widget _statusCard(Map state) { + final connection = state['connection'] as String? ?? 'disconnected'; + final label = controlStatus(state); + final active = connection == 'connected'; + final battery = state['battery']; + final batteryTime = DateTime.tryParse(state['batteryAt']?.toString() ?? ''); + final stale = + batteryTime != null && + DateTime.now().difference(batteryTime) > const Duration(seconds: 60); + final deviceId = state['deviceId']; + return _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: active + ? _mint.withValues(alpha: .14) + : _accent.withValues(alpha: .14), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + active ? Icons.bluetooth_connected : Icons.bluetooth_disabled, + color: active ? _mint : _accent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + if (deviceId != null) + const Text( + '觉瞳', + style: TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + if (active) Icon(Icons.circle, color: _mint, size: 10), + ], + ), + const SizedBox(height: 20), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _pill( + battery == null + ? '电量未知' + : '电量 $battery%${stale ? ' · 已过期' : ''}', + battery == null + ? Icons.battery_unknown + : Icons.battery_5_bar_rounded, + ), + if (state['simulator'] == true) + _pill('模拟器', Icons.science_outlined), + ], + ), + ], + ), + ); + } + + Widget _pill(String text, IconData icon) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: _surfaceRaised, + borderRadius: BorderRadius.circular(99), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: _accent), + const SizedBox(width: 5), + Text(text, style: const TextStyle(fontSize: 12)), + ], + ), + ); + + Widget _controlPage(Map state) { + final connected = state['connection'] == 'connected'; + final armed = state['outputAuthorized'] == true; + final target = state['target'] as List?; + return _page([ + _sectionTitle('控制'), + const SizedBox(height: 18), + _statusCard(state), + const SizedBox(height: 22), + if (!connected) + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '尚未连接觉瞳', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 7), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: () => setState(() => tab = 2), + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text('连接设备'), + ), + ], + ), + ), + if (connected && !armed) ...[ + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controlStatus(state), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + if (state['controlPhase'] == 'paused' || + state['controlPhase'] == 'actionFailed' || + state['controlPhase'] == 'ready') + FilledButton.icon( + onPressed: busy ? null : _arm, + icon: const Icon(Icons.play_arrow), + label: Text( + state['controlPhase'] == 'ready' ? '启用控制' : '恢复控制', + ), + ), + if (needsControlAttention(state)) + TextButton( + onPressed: () => setState(() => tab = 2), + child: const Text('查看详情'), + ), + ], + ), + ), + ], + if (connected && armed && target != null) ...[ + _sectionTitle('手动控制'), + const SizedBox(height: 12), + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('方向', style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + JoystickPad( + x: values[0], + y: values[1], + onChanged: (x, y) => _sendJoystick(x, y), + onReleased: _releaseJoystick, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: busy + ? null + : () => _sendJoystick(.5, .5, force: true), + icon: const Icon(Icons.center_focus_strong_rounded), + label: const Text('复位方向'), + ), + ], + ), + Material( + color: Colors.transparent, + child: SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('松手回中'), + value: resetStickOnRelease, + onChanged: (value) => + setState(() => resetStickOnRelease = value), + ), + ), + const Divider(height: 28), + Row( + children: [ + const Expanded( + child: Text( + '眼皮', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + Slider( + key: const ValueKey('eyelid-slider'), + value: values[2], + onChanged: busy ? null : _sendEyelid, + onChangeEnd: busy + ? null + : (value) => _sendEyelid(value, force: true), + ), + ], + ), + ), + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _pause, + icon: const Icon(Icons.stop_circle_outlined), + label: Text(state['controlPhase'] == 'pausing' ? '暂停中…' : '暂停控制'), + ), + ), + const SizedBox(height: 22), + _sectionTitle('自动动作'), + const SizedBox(height: 12), + _panel( + Column( + children: [ + _behaviorRow( + Icons.explore_rounded, + '自动转动', + state['autoRotate'] == true, + (value) => + _run(() => client.send('rotate', {'enabled': value})), + ), + const Divider(height: 28), + _behaviorRow( + Icons.visibility_rounded, + '自动眨眼', + state['autoWink'] == true, + (value) => + _run(() => client.send('winkAuto', {'enabled': value})), + ), + ], + ), + ), + ], + ]); + } + + Widget _behaviorRow( + IconData icon, + String title, + bool enabled, + ValueChanged onChanged, + ) => Row( + children: [ + Icon(icon, color: _accent), + const SizedBox(width: 13), + Expanded( + child: Text( + title, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16), + ), + ), + Switch(value: enabled, onChanged: busy ? null : onChanged), + ], + ); + + Widget _actionsPage(Map state) { + final connected = + state['connection'] == 'connected' && state['outputAuthorized'] == true; + final playing = state['playback'] == 'playing'; + return _page([ + _sectionTitle('动作'), + const SizedBox(height: 18), + _statusCard(state), + const SizedBox(height: 22), + for (final entry in [ + ('wink', '轻眨一下', Icons.visibility_outlined), + ('wink2', '连眨两下', Icons.auto_awesome_rounded), + ]) ...[ + _panel( + Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: _accent.withValues(alpha: .13), + borderRadius: BorderRadius.circular(16), + ), + child: Icon(entry.$3, color: _accent), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + entry.$2, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton.filledTonal( + tooltip: '播放${entry.$2}', + icon: const Icon(Icons.play_arrow_rounded), + onPressed: connected && !busy && !playing + ? () => _run(() => client.send('play', {'name': entry.$1})) + : null, + ), + ], + ), + ), + const SizedBox(height: 12), + ], + if (playing) + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '动作播放中', + style: TextStyle(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _pause, + icon: const Icon(Icons.stop_rounded), + label: const Text('取消播放'), + ), + ], + ), + ), + if (!connected) + const Text('连接设备后可播放动作。', style: TextStyle(color: _muted)), + ]); + } + + Future _changePairingCode() async { + final code = await showDialog( + context: context, + builder: (_) => const _PairingCodeDialog(), + ); + if (code == null || !mounted) return; + await _run(() => client.send('setPairingCode', {'code': code})); + } + + Future _openTransfer() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('更换主控手机'), + content: const Text( + '请先设置并记住非默认配对码。继续后会暂停动作并断开本手机,允许新手机在60秒内用新码配对。成功后旧手机失去控制权;超时仍保留旧绑定。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('开启60秒换绑'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await _run(() => client.send('openTransfer')); + } + } + + String _addressSuffix(Object? address) { + final value = address?.toString() ?? ''; + return value.length > 5 ? value.substring(value.length - 5) : value; + } + + void _showPairingHelp() { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('配对帮助'), + content: const Text( + '新设备默认配对码:123456。修改过请使用新码。配对名额已满或需要清除绑定时,可使用 USB 维护工具。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('知道了'), + ), + ], + ), + ); + } + + Widget _devicePage(Map state) { + final connected = state['connection'] == 'connected'; + final active = + connected || + state['connection'] == 'connecting' || + state['connection'] == 'reconnecting'; + final armed = state['outputAuthorized'] == true; + final discovered = (state['discovered'] as List? ?? []).cast(); + return _page([ + _sectionTitle('设备'), + const SizedBox(height: 18), + _statusCard(state), + const SizedBox(height: 18), + if (active) + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('觉瞳', style: TextStyle(fontWeight: FontWeight.w700)), + Text(controlStatus(state), style: const TextStyle(color: _muted)), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () async { + try { + await client.send('disconnect'); + } catch (e) { + _showError(e); + } + }, + icon: const Icon(Icons.link_off), + label: const Text('断开连接'), + ), + ], + ), + ), + if (!active) + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '添加觉瞳', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 17), + ), + const SizedBox(height: 8), + const Text('连接后会启用舵机。', style: TextStyle(color: _muted)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: busy || state['connection'] == 'searching' + ? null + : _discover, + icon: const Icon(Icons.bluetooth_searching), + label: Text( + state['connection'] == 'searching' ? '正在搜索…' : '搜索附近觉瞳', + ), + ), + TextButton( + onPressed: () => _showPairingHelp(), + child: const Text('配对帮助'), + ), + ], + ), + ), + if (!active && client.running) + TextButton.icon( + onPressed: () async { + try { + await client.send('disconnect'); + } catch (e) { + _showError(e); + } + }, + icon: const Icon(Icons.bluetooth_disabled), + label: const Text('断开连接'), + ), + if (!active) ...[ + const SizedBox(height: 20), + _sectionTitle('发现结果'), + const SizedBox(height: 10), + if (discovered.isEmpty) + _panel( + const Text('尚无结果;请打开蓝牙并让设备保持可发现。', style: TextStyle(color: _muted)), + ), + for (final device in discovered) ...[ + _panel( + Row( + children: [ + const Icon(Icons.bluetooth, color: _accent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${device['name'] ?? '觉瞳'}', + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + '设备尾号 ${_addressSuffix(device['id'])}', + style: const TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + TextButton( + onPressed: busy ? null : () => _select(device), + child: const Text('连接'), + ), + ], + ), + ), + const SizedBox(height: 10), + ], + ], + if (state['pairingNotice'] is String) ...[ + const SizedBox(height: 16), + _panel(Text(state['pairingNotice'] as String)), + ], + if (connected) ...[ + const SizedBox(height: 20), + _sectionTitle('配对与手机'), + const SizedBox(height: 12), + _panel( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state['supportsSharedPairing'] == true + ? '最多保存 8 台手机,同一时间只能连接 1 台。' + : state['supportsOwnerManagement'] == true + ? '当前固件仅支持单手机绑定,更换手机需要开启换绑窗口。升级设备固件后可多手机轮流使用。' + : '此固件不支持在手机上管理配对,请先升级设备固件。', + style: const TextStyle(color: _muted), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: busy || state['supportsOwnerManagement'] != true + ? null + : _changePairingCode, + icon: const Icon(Icons.key), + label: const Text('修改配对码'), + ), + if (state['supportsSharedPairing'] != true) + OutlinedButton.icon( + onPressed: + busy || state['supportsOwnerManagement'] != true + ? null + : _openTransfer, + icon: const Icon(Icons.phonelink_setup), + label: const Text('更换手机'), + ), + if (state['supportsSharedPairing'] != true) + TextButton( + onPressed: + busy || state['supportsOwnerManagement'] != true + ? null + : () => _run(() => client.send('cancelTransfer')), + child: const Text('取消换绑窗口'), + ), + ], + ), + ], + ), + ), + ], + if (active) ...[ + const SizedBox(height: 16), + ExpansionTile( + title: const Text('设备详情'), + childrenPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text('固件版本:${state['firmware'] ?? '未知'}'), + ), + ], + ), + ], + ExpansionTile( + title: const Text('诊断信息'), + childrenPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + children: [ + if (active) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text('设备地址:${state['deviceId'] ?? '未知'}'), + ), + Align( + alignment: Alignment.centerLeft, + child: Text('身份:${state['identity'] ?? '等待认证'}'), + ), + Align( + alignment: Alignment.centerLeft, + child: Text('会话:${state['sessionPhase'] ?? '等待连接'}'), + ), + Align( + alignment: Alignment.centerLeft, + child: Text('最近业务确认:${state['lastAckSequence'] ?? '未知'}'), + ), + Align( + alignment: Alignment.centerLeft, + child: Text('已执行序号:${state['lastAppliedSequence'] ?? '未知'}'), + ), + Align( + alignment: Alignment.centerLeft, + child: Text( + '已下发逻辑值(控制目标):${state['validChannelMask'] == 7 ? state['lastCommanded'] : '未知'}', + ), + ), + const Align( + alignment: Alignment.centerLeft, + child: Text('实际机械位置:未知'), + ), + ], + for (final device in discovered) + Align( + alignment: Alignment.centerLeft, + child: Text( + '${device['name'] ?? '觉瞳'}:${device['id'] ?? '未知'} · ${device['rssi'] ?? '未知'} dBm', + ), + ), + if (state['error'] != null) + Align( + alignment: Alignment.centerLeft, + child: Text('设备错误:${state['error']}'), + ), + if (client.message != null) + Align( + alignment: Alignment.centerLeft, + child: Text('后台错误:${client.message}'), + ), + if (_localError != null) + Align( + alignment: Alignment.centerLeft, + child: Text('界面错误:$_localError'), + ), + ], + ), + const SizedBox(height: 20), + ExpansionTile( + title: const Text('高级维护'), + children: [ + const Text('自定义逻辑范围(可选,暂停后修改)', style: TextStyle(color: _muted)), + _panel( + Column( + children: [ + for (var i = 0; i < 3; i++) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text(['CH1 · 左右', 'CH2 · 上下', 'CH3 · 眼皮'][i]), + ), + const SizedBox(height: 8), + Row( + children: [ + for (final field in [ + (minFields[i], '最小'), + (maxFields[i], '最大'), + ]) + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: TextField( + controller: field.$1, + enabled: !armed, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: field.$2, + isDense: true, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + const Text( + '默认逻辑范围沿用原客户端输入映射;实际机械限位由设备标定约束。这里仅供维护时覆盖。', + style: TextStyle(color: _muted, fontSize: 12), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: busy || armed || !connected + ? null + : () => _run( + () => client.send('configureSafety', { + 'limits': _limits().toJson(), + }), + ), + child: const Text('保存自定义范围'), + ), + ], + ), + ), + ], + ), + ]); + } + + Widget _page(List children) => Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: children, + ), + ), + ); + + @override + Widget build(BuildContext context) { + final state = client.state; + return Scaffold( + appBar: AppBar( + title: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '觉瞳', + style: TextStyle(fontSize: 21, fontWeight: FontWeight.w800), + ), + Text('测试版', style: TextStyle(fontSize: 11, color: _muted)), + ], + ), + actions: [ + if (state['connection'] == 'connected') + IconButton( + tooltip: '暂停控制', + icon: const Icon(Icons.stop_circle_outlined), + onPressed: _pause, + ), + IconButton( + tooltip: '刷新状态', + icon: const Icon(Icons.refresh_rounded), + onPressed: () => client.restore(), + ), + ], + ), + body: Column( + children: [ + if (needsControlAttention(state) || + (state['connection'] == 'failed') || + client.message != null || + _localError != null) + Container( + width: double.infinity, + color: const Color(0xFF543037), + padding: const EdgeInsets.all(10), + child: Text( + needsControlAttention(state) + ? controlStatus(state) + : state['connection'] == 'failed' + ? '连接失败,请查看诊断信息' + : '操作未完成,请查看设备诊断信息', + textAlign: TextAlign.center, + ), + ), + Expanded( + child: IndexedStack( + index: tab, + children: [ + _controlPage(state), + _actionsPage(state), + _devicePage(state), + ], + ), + ), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: tab, + onDestinationSelected: (index) => setState(() => tab = index), + destinations: const [ + NavigationDestination(icon: Icon(Icons.tune_rounded), label: '控制'), + NavigationDestination( + icon: Icon(Icons.auto_awesome_outlined), + label: '动作', + ), + NavigationDestination(icon: Icon(Icons.devices_rounded), label: '设备'), + ], + ), + ); + } +} + +class _PairingCodeDialog extends StatefulWidget { + const _PairingCodeDialog(); + + @override + State<_PairingCodeDialog> createState() => _PairingCodeDialogState(); +} + +class _PairingCodeDialogState extends State<_PairingCodeDialog> { + final first = TextEditingController(); + final second = TextEditingController(); + final form = GlobalKey(); + + @override + void dispose() { + first.dispose(); + second.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('修改配对码'), + content: SingleChildScrollView( + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('已配对手机仍可连接。请保存新码,应用不会记录。保存会暂停控制。'), + const SizedBox(height: 16), + TextFormField( + controller: first, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + maxLength: 6, + decoration: const InputDecoration(labelText: '新的六位配对码'), + validator: (value) { + if (!RegExp(r'^[0-9]{6}$').hasMatch(value ?? '')) { + return '请输入六位数字'; + } + if (value == '123456') return '不能使用默认码123456'; + return null; + }, + ), + TextFormField( + controller: second, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + maxLength: 6, + decoration: const InputDecoration(labelText: '再次输入新码'), + validator: (value) => value == first.text ? null : '两次输入不一致', + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + if (form.currentState!.validate()) Navigator.pop(context, first.text); + }, + child: const Text('暂停并保存'), + ), + ], + ); +} diff --git a/flutter_app/lib/runtime/client_generation_guard.dart b/flutter_app/lib/runtime/client_generation_guard.dart new file mode 100644 index 0000000..6dbf2e3 --- /dev/null +++ b/flutter_app/lib/runtime/client_generation_guard.dart @@ -0,0 +1,40 @@ +/// Keeps foreground UI messages scoped to the currently attached UI client. +/// A snapshot is a handshake: it may take ownership without touching the +/// service-owned engine. Once retired, a client can never reclaim the runtime. +class ClientGenerationGuard { + String? _activeClientId; + final Set retiredClientIds = {}; + + String? get activeClientId => _activeClientId; + + String? authorize(Map message, {required String runtimeId}) { + final clientId = message['clientId']; + final operation = message['op']; + if (clientId is! String || clientId.isEmpty) { + return '界面会话标识无效,请刷新状态'; + } + + if (operation == 'snapshot') { + if (retiredClientIds.contains(clientId)) { + return '旧界面会话已结束,请刷新状态'; + } + final active = _activeClientId; + if (active != null && active != clientId) { + retiredClientIds.add(active); + } + _activeClientId = clientId; + return null; + } + + if (_activeClientId == null) { + return '请先同步后台会话状态'; + } + if (retiredClientIds.contains(clientId) || clientId != _activeClientId) { + return '界面会话已更新,请刷新后重试'; + } + if (message['expectedRuntimeId'] != runtimeId) { + return '后台会话已变化,请刷新状态后重试'; + } + return null; + } +} diff --git a/flutter_app/lib/runtime/control_client.dart b/flutter_app/lib/runtime/control_client.dart new file mode 100644 index 0000000..46f5dba --- /dev/null +++ b/flutter_app/lib/runtime/control_client.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'control_task.dart'; + +class ControlClient extends ChangeNotifier { + bool preview = false; + Map state = { + 'connection': 'disconnected', + 'mode': 'manual', + 'playback': 'idle', + 'autoRotate': false, + 'autoWink': false, + 'discovered': [], + 'target': null, + 'outputAuthorized': false, + }; + String? message; + bool running = false; + bool _disposed = false; + Future? _starting; + Future? _handshaking; + bool _handshaken = false; + int _id = 0; + final String _clientId = + '${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}'; + final Map> _pending = {}; + final Map _pendingOps = {}; + final Set _retiredRuntimes = {}; + + ControlClient() { + FlutterForegroundTask.addTaskDataCallback(_receive); + unawaited(restore()); + } + ControlClient.preview(Map initial) { + preview = true; + state = initial; + } + void _notify() { + if (!_disposed) notifyListeners(); + } + + Future restore() async { + if (preview || _disposed) return; + try { + running = await FlutterForegroundTask.isRunningService; + if (running) { + _handshaken = false; + await _establishHandshake(); + } else { + _handshaken = false; + final old = state['runtimeId']; + if (old is String) _retiredRuntimes.add(old); + state = { + ...state, + 'runtimeId': null, + 'connection': 'disconnected', + 'outputAuthorized': false, + 'autoRotate': false, + 'autoWink': false, + 'playback': 'idle', + 'target': null, + }; + } + _notify(); + } catch (error) { + message = error.toString(); + _notify(); + } + } + + void _receive(Object data) { + if (_disposed || data is! Map) return; + if (data['clientId'] != null && data['clientId'] != _clientId) return; + final raw = data['data'] ?? data['snapshot']; + if (raw is Map) { + final incoming = Map.from(raw); + final runtime = incoming['runtimeId']; + if (runtime is! String || _retiredRuntimes.contains(runtime)) return; + if (runtime != state['runtimeId'] && + state['runtimeId'] != null && + !(data['clientId'] == _clientId && + _pendingOps[data['id']] == 'snapshot')) { + return; + } + if (runtime != state['runtimeId']) { + final old = state['runtimeId']; + if (old is String) _retiredRuntimes.add(old); + } + if (runtime != state['runtimeId'] || + (incoming['revision'] as int? ?? 0) >= + (state['revision'] as int? ?? 0)) { + state = incoming; + _notify(); + } + } + if (data['type'] == 'error' || data['type'] == 'rejected') { + message = data['message']?.toString(); + _notify(); + } + final id = data['id']; + if (id is int && + data['clientId'] == _clientId && + _pending.containsKey(id)) { + final pending = _pending.remove(id)!; + if (data['type'] == 'rejected') { + pending.completeError(StateError(data['message'].toString())); + } else { + pending.complete(); + } + } + } + + Future start() => + _starting ??= _start().whenComplete(() => _starting = null); + Future _start() async { + if (await FlutterForegroundTask.isRunningService) { + running = true; + await _establishHandshake(); + return; + } + _handshaken = false; + final old = state['runtimeId']; + if (old is String) _retiredRuntimes.add(old); + state = {...state, 'runtimeId': null, 'outputAuthorized': false}; + final result = await FlutterForegroundTask.startService( + serviceId: 8889, + notificationTitle: '觉瞳 BLE 控制会话', + notificationText: '正在准备蓝牙连接', + notificationButtons: const [ + NotificationButton(id: 'stop', text: '暂停控制'), + NotificationButton(id: 'disconnect', text: '断开'), + ], + callback: startControlTask, + ); + if (result is ServiceRequestFailure) throw StateError('后台服务启动失败:$result'); + running = true; + await _establishHandshake(); + _notify(); + } + + Future _establishHandshake() { + if (_handshaken) return Future.value(); + return _handshaking ??= _sendRequest('snapshot') + .then((_) { + _handshaken = true; + }) + .whenComplete(() => _handshaking = null); + } + + Future send(String op, [Map args = const {}]) async { + if (_disposed) throw StateError('界面已关闭'); + if (!running) { + if (op == 'stop' || op == 'disconnect') return; + await start(); + } + if (op == 'snapshot') { + _handshaken = false; + await _establishHandshake(); + return; + } + await _establishHandshake(); + await _sendRequest(op, args); + } + + Future _sendRequest( + String op, [ + Map args = const {}, + ]) async { + if (op != 'snapshot' && state['runtimeId'] is! String) { + throw StateError('后台会话尚未同步,请刷新状态后重试'); + } + final id = ++_id; + final pending = Completer(); + _pending[id] = pending; + _pendingOps[id] = op; + message = null; + FlutterForegroundTask.sendDataToTask({ + 'id': id, + 'clientId': _clientId, + 'op': op, + 'expectedRuntimeId': state['runtimeId'], + ...args, + }); + try { + await pending.future.timeout( + Duration(seconds: op == 'select' ? 120 : 15), + ); + } on TimeoutException { + throw TimeoutException('后台操作尚未确认,请刷新状态或断开'); + } finally { + _pending.remove(id); + _pendingOps.remove(id); + } + if (op == 'disconnect' || op == 'openTransfer') { + await FlutterForegroundTask.stopService(); + running = false; + _handshaken = false; + _notify(); + } + } + + @override + void dispose() { + _disposed = true; + if (!preview) FlutterForegroundTask.removeTaskDataCallback(_receive); + for (final pending in _pending.values) { + pending.completeError(StateError('界面已关闭;设备会话仍由后台管理')); + } + _pending.clear(); + _pendingOps.clear(); + super.dispose(); + } +} diff --git a/flutter_app/lib/runtime/control_task.dart b/flutter_app/lib/runtime/control_task.dart new file mode 100644 index 0000000..6c29b91 --- /dev/null +++ b/flutter_app/lib/runtime/control_task.dart @@ -0,0 +1,299 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../core/control_engine.dart'; +import '../core/ble_protocol.dart'; +import '../core/device_session.dart'; +import '../core/protocol.dart'; +import '../core/safety_limits.dart'; +import '../core/control_status.dart'; +import '../infrastructure/reactive_ble_link.dart'; +import 'client_generation_guard.dart'; + +@pragma('vm:entry-point') +void startControlTask() { + FlutterForegroundTask.setTaskHandler(ControlTask()); +} + +class ControlTask extends TaskHandler { + ControlEngine? engine; + ReactiveBleLink? link; + StreamSubscription>? subscription; + StreamSubscription? scan; + final List queued = []; + final ClientGenerationGuard _clientGuard = ClientGenerationGuard(); + final Map> discovered = {}; + final SharedPreferencesAsync preferences = SharedPreferencesAsync(); + Map favorites = {}; + String? notificationStatus; + bool searching = false; + bool destroying = false; + int revision = 0; + + Map snapshot() => { + ...?engine?.snapshot(), + 'revision': ++revision, + if (searching) 'connection': 'searching', + 'discovered': discovered.values.toList(), + 'favorites': favorites, + }; + + void publish() { + if (destroying || engine == null) return; + final state = snapshot(); + final display = '觉瞳 · ${controlStatus(state)}'; + if (display != notificationStatus) { + notificationStatus = display; + unawaited(FlutterForegroundTask.updateService(notificationText: display)); + } + FlutterForegroundTask.sendDataToMain({'type': 'snapshot', 'data': state}); + } + + @override + void onRepeatEvent(DateTime timestamp) {} + + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { + try { + final saved = await preferences.getString('bleFavorites'); + if (saved != null) { + try { + favorites = Map.from(jsonDecode(saved) as Map); + } on FormatException { + favorites = {}; + } + } + final actions = parseActions( + await rootBundle.loadString('assets/actions/presets.json'), + ); + final adapter = ReactiveBleLink(); + link = adapter; + final active = ControlEngine(DeviceSession(adapter), actions); + engine = active; + subscription = active.updates.listen((_) => publish()); + publish(); + for (final data in queued) { + unawaited(handle(data)); + } + queued.clear(); + } catch (error) { + for (final data in queued) { + reject(data, error); + } + queued.clear(); + FlutterForegroundTask.sendDataToMain({ + 'type': 'error', + 'message': '$error', + }); + await FlutterForegroundTask.stopService(); + } + } + + String describeError(Object error) { + if (error is BleCommandRejected) { + return switch (error.result) { + BleResult.notConfigured => '设备尚未配置已验证的启动姿态,输出未启用。请先完成 USB 维护配置。', + BleResult.notArmed => '输出尚未就绪,请检查安全范围或设备配置。', + BleResult.notAuthorized => '设备未授权此手机,请检查系统配对与设备绑定。', + BleResult.badSession => '控制会话已失效,请重新连接;动作不会自动恢复。', + _ => '设备拒绝操作:${error.result.name}', + }; + } + return '$error'; + } + + void reject(Map data, Object error) { + FlutterForegroundTask.sendDataToMain({ + 'type': 'rejected', + 'id': data['id'], + 'clientId': data['clientId'], + 'message': describeError(error), + if (engine != null) 'snapshot': snapshot(), + }); + } + + @override + void onReceiveData(Object data) { + if (data is! Map || destroying) return; + if (engine == null) { + // Only retain startup handshakes. Actions must be based on a snapshot + // from the live engine, and replaying an early command is surprising. + if (data['op'] != 'snapshot') { + reject(data, StateError('后台尚未准备好,请同步状态后重试')); + return; + } + if (queued.length >= 32) { + reject(data, StateError('后台启动队列已满')); + } else { + queued.add(data); + } + return; + } + unawaited(handle(data)); + } + + Future handle(Map data) async { + final active = engine!; + try { + void ensureCurrentClient() { + final authorization = _clientGuard.authorize( + data, + runtimeId: active.runtimeId, + ); + if (authorization != null) throw StateError(authorization); + } + + ensureCurrentClient(); + switch (data['op']) { + case 'snapshot': + break; + case 'discover': + if (active.connection == 'connected' || + active.connection == 'connecting' || + active.connection == 'reconnecting') { + throw StateError('请先结束当前会话'); + } + await scan?.cancel(); + ensureCurrentClient(); + discovered.clear(); + searching = true; + publish(); + scan = link!.scan().listen( + (device) { + discovered[device.id] = { + 'id': device.id, + 'name': device.name, + 'rssi': device.rssi, + }; + publish(); + }, + onError: (Object e) { + searching = false; + active.error = '$e'; + publish(); + }, + onDone: () { + searching = false; + publish(); + }, + ); + case 'select': + await scan?.cancel(); + ensureCurrentClient(); + searching = false; + final id = data['deviceId'] as String; + if (!discovered.containsKey(id)) throw StateError('请先搜索并选择设备'); + final saved = favorites[id]; + final expected = saved is Map ? saved['identity'] as String? : null; + await active.connect( + id, + expectedIdentity: expected, + safetyForIdentity: (identity) async { + final previous = favorites.values + .whereType() + .where((v) => v['identity'] == identity) + .firstOrNull; + return previous?['safety'] is Map + ? SafetyLimits.fromJson(previous!['safety'] as Map) + : SafetyLimits.builtInSatoriC3; + }, + ); + final identity = active.session.snapshot.identity; + // Locate preferences by stable authenticated identity, not the radio address. + favorites.removeWhere( + (key, value) => + key != id && value is Map && value['identity'] == identity, + ); + favorites[id] = { + 'identity': identity, + 'name': discovered[id]?['name'], + if (active.safety != null) 'safety': active.safety!.toJson(), + }; + await preferences.setString('bleFavorites', jsonEncode(favorites)); + case 'configureSafety': + await active.configureSafety( + SafetyLimits.fromJson(data['limits'] as Map), + ); + final saved = favorites[active.deviceId]; + if (saved is Map) { + saved['safety'] = active.safety!.toJson(); + await preferences.setString('bleFavorites', jsonEncode(favorites)); + } + case 'setPairingCode': + await active.setPairingCode( + data['code'] as String, + ensureCurrentClient: ensureCurrentClient, + ); + case 'openTransfer': + await active.openTransfer(ensureCurrentClient: ensureCurrentClient); + case 'cancelTransfer': + await active.cancelTransfer(ensureCurrentClient: ensureCurrentClient); + case 'arm': + await active.arm(); + case 'manual': + await active.setManual(List.from(data['values'] as List)); + case 'rotate': + active.setAutoRotate(data['enabled'] == true); + case 'winkAuto': + active.setAutoWink(data['enabled'] == true); + case 'play': + active.play(data['name'] as String); + case 'stop': + await active.stopMotion(); + case 'disconnect': + await scan?.cancel(); + ensureCurrentClient(); + searching = false; + await active.disconnect(); + default: + throw StateError('未知操作 ${data['op']}'); + } + FlutterForegroundTask.sendDataToMain({ + 'type': 'accepted', + 'id': data['id'], + 'clientId': data['clientId'], + 'snapshot': snapshot(), + }); + } catch (error) { + // Pairing credentials must not be echoed by plugin exceptions or replies. + if (data['op'] == 'setPairingCode' || + data['op'] == 'openTransfer' || + data['op'] == 'cancelTransfer') { + reject(data, StateError(active.error ?? '配对设置未确认,请检查连接后重试')); + } else { + reject(data, error); + } + } + } + + @override + void onNotificationButtonPressed(String id) { + unawaited(() async { + try { + if (id == 'stop') await engine?.stopMotion(); + if (id == 'disconnect') { + await scan?.cancel(); + searching = false; + await engine?.disconnect(); + await FlutterForegroundTask.stopService(); + } + } catch (e) { + FlutterForegroundTask.sendDataToMain({ + 'type': 'error', + 'message': '$e', + }); + } + }()); + } + + @override + Future onDestroy(DateTime timestamp, bool isTimeout) async { + destroying = true; + await scan?.cancel(); + await subscription?.cancel(); + await engine?.dispose(); + await link?.dispose(); + } +} diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock new file mode 100644 index 0000000..6e69fff --- /dev/null +++ b/flutter_app/pubspec.lock @@ -0,0 +1,538 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bluez: + dependency: "direct main" + description: + name: bluez + sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" + url: "https://pub.dev" + source: hosted + version: "0.8.3" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: "direct dev" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_foreground_task: + dependency: "direct main" + description: + name: flutter_foreground_task + sha256: fe0e8c88fbdeb703161e20c54ffd10593fb112ccfa068c989ad2a93d28e4b976 + url: "https://pub.dev" + source: hosted + version: "11.0.3" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_reactive_ble: + dependency: "direct main" + description: + name: flutter_reactive_ble + sha256: f343785fd824c3e08706e5d0f2327b3c1b03ada9505dae491fcb25ef8fbc42b9 + url: "https://pub.dev" + source: hosted + version: "5.6.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functional_data: + dependency: transitive + description: + name: functional_data + sha256: "76d17dc707c40e552014f5a49c0afcc3f1e3f05e800cd6b7872940bfe41a5039" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "066bbbc60031b63b916a3871e7bb4b6a8c5e7cec8146dafec53d1da304eff0a5" + url: "https://pub.dev" + source: hosted + version: "9.6.2" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "13b6717366aa7ec3792dd62c19c7d33a785708224365f2db23c5ca01f1c1a0f2" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + reactive_ble_mobile: + dependency: transitive + description: + name: reactive_ble_mobile + sha256: "1af5ff867926ff104fab763734db33fa18ac6c9d05e58988dc43879a56168d56" + url: "https://pub.dev" + source: hosted + version: "5.6.0" + reactive_ble_platform_interface: + dependency: transitive + description: + name: reactive_ble_platform_interface + sha256: b5c81a30be81b351c8e86b9520a1a9f267c5fcf0b51a2eee9139da404a18ed79 + url: "https://pub.dev" + source: hosted + version: "5.6.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml new file mode 100644 index 0000000..25d0df3 --- /dev/null +++ b/flutter_app/pubspec.yaml @@ -0,0 +1,24 @@ +name: satori_manager +description: 觉瞳 BLE 控制技术验证版 +publish_to: none +version: 0.2.4+6 +environment: + sdk: ^3.12.2 +dependencies: + flutter: + sdk: flutter + bluez: 0.8.3 + device_info_plus: ^12.2.0 + flutter_foreground_task: 11.0.3 + flutter_reactive_ble: 5.6.0 + permission_handler: ^12.0.1 + shared_preferences: 2.5.5 +dev_dependencies: + crypto: ^3.0.7 + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 +flutter: + uses-material-design: true + assets: + - assets/actions/presets.json diff --git a/flutter_app/test/ble_engine_test.dart b/flutter_app/test/ble_engine_test.dart new file mode 100644 index 0000000..3b86ffc --- /dev/null +++ b/flutter_app/test/ble_engine_test.dart @@ -0,0 +1,382 @@ +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/protocol.dart'; +import 'package:satori_manager/core/safety_limits.dart'; +import 'package:satori_manager/infrastructure/fake_ble_link.dart'; + +class TestTimer implements Timer { + TestTimer(this.delay, this.callback); + final Duration delay; + final void Function() callback; + bool active = true; + @override + bool get isActive => active; + @override + int get tick => 0; + @override + void cancel() => active = false; + void fire() { + if (active) { + active = false; + callback(); + } + } +} + +class Harness { + Harness({FakeBleLink? link}) : link = link ?? FakeBleLink(); + + final FakeBleLink link; + late final session = DeviceSession(link); + final timers = []; + Duration time = Duration.zero; + late final engine = ControlEngine( + session, + { + 'wink': [ + ActionFrame([-1, -1, 0], const Duration(milliseconds: 450)), + ActionFrame([-1, -1, 1], const Duration(milliseconds: 200)), + ], + 'wink2': [ + ActionFrame([-1, -1, 0], const Duration(milliseconds: 200)), + ActionFrame([-1, -1, 1], const Duration(milliseconds: 200)), + ], + }, + clock: () => time, + schedule: (duration, callback) { + final timer = TestTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + Future connect({SafetyLimits? limits}) => engine.connect( + 'fake', + limits: limits ?? SafetyLimits([1000, 1100, 1200], [2000, 1900, 1800]), + ); + List get targets => link.writes + .map(BleProtocol.decodeControlFrame) + .where((f) => f.opcode == BleOpcode.setTarget.value) + .toList(); + int get armCount => link.writes + .map(BleProtocol.decodeControlFrame) + .where((f) => f.opcode == BleOpcode.arm.value) + .length; + Future flush() => + Future.delayed(const Duration(milliseconds: 80)); + Future close() async { + await engine.dispose(); + await session.dispose(); + await link.dispose(); + } +} + +void main() { + test('connect auto-ARMs known safe startup channels', () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + expect(h.engine.connection, 'connected'); + expect(h.engine.outputAuthorized, true); + expect(h.engine.target, h.session.snapshot.state!.channels); + await h.engine.arm(); + expect(h.engine.target, [1500, 1500, 1500]); + await h.engine.setManual([0, 1, -1]); + expect(h.engine.target, [1000, 1900, 1500]); + final manualPayload = h.targets.last.payload; + expect(manualPayload[6] | manualPayload[7] << 8, 50); + }); + + test( + 'preset duration is independent of 200ms transition; stop cancels remainder', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.engine.arm(); + h.engine.play('wink'); + await h.flush(); + expect(h.targets.length, 1); + expect( + h.targets.single.payload[6] | h.targets.single.payload[7] << 8, + 200, + ); + final timer = h.timers.last; + expect(timer.delay, const Duration(milliseconds: 450)); + await h.engine.stopMotion(); + timer.fire(); + await h.flush(); + expect(h.targets.length, 1); + expect(h.engine.playback, 'idle'); + expect(h.engine.outputAuthorized, false); + }, + ); + + test('late preset cancels instead of replaying accumulated frames', () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.engine.arm(); + h.engine.play('wink'); + await h.flush(); + h.time = const Duration(milliseconds: 700); + h.timers.last.fire(); + await h.flush(); + expect(h.targets.length, 1); + expect(h.engine.playback, 'idle'); + expect(h.engine.error, contains('过期')); + }); + + test( + 'manual takeover invalidates preset timers and preserves untouched channels', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.engine.arm(); + h.engine.play('wink'); + await h.flush(); + final oldTimer = h.timers.last; + await h.engine.setManual([.3, .7, -1]); + final count = h.targets.length; + oldTimer.fire(); + await h.flush(); + expect(h.targets.length, count); + expect(h.engine.target, [1100, 1900, 1200]); + }, + ); + + test( + 'disconnect recovery freshly auto-ARMs without replaying old actions', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.engine.arm(); + h.engine.setAutoRotate(true); + await h.flush(); + await h.link.disconnect(); + await h.flush(); + expect(h.engine.connection, 'reconnecting'); + expect(h.engine.autoRotate, false); + expect(h.engine.outputAuthorized, false); + final retry = h.timers.last; + expect(retry.delay, const Duration(seconds: 1)); + final before = h.targets.length; + final oldArmCount = h.armCount; + retry.fire(); + await h.flush(); + expect(h.engine.connection, 'connected'); + expect(h.engine.target, h.session.snapshot.state!.channels); + expect(h.engine.outputAuthorized, true); + expect(h.targets.length, before); + expect(h.armCount, oldArmCount + 1); + }, + ); + + test('pause during reconnect backoff persists when retry succeeds', () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + final armCount = h.armCount; + await h.link.disconnect(); + await h.flush(); + expect(h.engine.connection, 'reconnecting'); + + await h.engine.stopMotion(); + h.timers.last.fire(); + await h.flush(); + + expect(h.engine.connection, 'connected'); + expect(h.engine.outputAuthorized, false); + expect(h.armCount, armCount); + }); + + test('pause survives a failed reconnect attempt and later retry', () async { + final shared = FakeBleSharedDevice(); + expect(shared.pairPhone('engine-phone', '123456'), isTrue); + expect(shared.pairPhone('blocking-phone', '123456'), isTrue); + final h = Harness( + link: FakeBleLink(sharedDevice: shared, phoneId: 'engine-phone'), + ); + final blocker = FakeBleLink( + sharedDevice: shared, + phoneId: 'blocking-phone', + ); + addTearDown(() async { + await h.close(); + await blocker.dispose(); + }); + await h.connect(); + final armCount = h.armCount; + await h.link.disconnect(); + await h.flush(); + await blocker.connect('fake'); + + h.timers.last.fire(); + await h.flush(); + expect(h.engine.connection, 'reconnecting'); + expect(h.timers.last.delay, const Duration(seconds: 2)); + + await h.engine.stopMotion(); + await blocker.disconnect(); + h.timers.last.fire(); + await h.flush(); + + expect(h.engine.connection, 'connected'); + expect(h.engine.outputAuthorized, false); + expect(h.armCount, armCount); + }); + + test( + 'pause racing ARM cannot reauthorize output from a late completion', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.engine.stopMotion(); + h.link.writeDelay = const Duration(milliseconds: 30); + final oldArmCount = h.armCount; + final arm = h.engine.arm(); + await Future.delayed(const Duration(milliseconds: 2)); + final stop = h.engine.stopMotion(); + await Future.wait([arm, stop]); + expect(h.engine.outputAuthorized, false); + expect(h.targets, isEmpty); + expect(h.armCount, oldArmCount + 1); + expect(h.link.writes.last[1], BleOpcode.halt.value); + }, + ); + + test( + 'pause cancels ARM queued behind a slow command before transmission', + () async { + final h = Harness(); + addTearDown(h.close); + await h.connect(); + await h.engine.stopMotion(); + h.link.writeDelay = const Duration(milliseconds: 35); + final slowHalt = h.session.halt(); + await Future.delayed(const Duration(milliseconds: 2)); + final oldArmCount = h.armCount; + final configure = h.engine.arm(); + final cancelledArm = expectLater( + configure, + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 2)); + final stop = h.engine.stopMotion(); + await Future.wait([slowHalt, stop]); + await cancelledArm; + expect(h.armCount, oldArmCount); + expect(h.engine.outputAuthorized, false); + expect(h.link.writes.last[1], BleOpcode.halt.value); + }, + ); + + test( + 'unconfigured device remains connected and reports paused reason', + () async { + final link = FakeBleLink(configured: false); + final session = DeviceSession(link); + final engine = ControlEngine(session, const {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect( + 'fake', + limits: SafetyLimits([1000, 1100, 1200], [2000, 1900, 1800]), + ); + expect(engine.connection, 'connected'); + expect(engine.outputAuthorized, false); + expect(engine.error, contains('尚未配置')); + }, + ); + + test( + 'no saved safety limits uses the built-in Satori C3 logical range', + () async { + final h = Harness(); + addTearDown(h.close); + await h.engine.connect('fake'); + expect(h.engine.outputAuthorized, true); + expect(h.engine.safety?.minimum, [500, 500, 500]); + expect(h.engine.safety?.maximum, [2500, 2500, 2500]); + expect(h.engine.target, [1500, 1500, 1500]); + }, + ); + + test('pause during connecting prevents late automatic ARM', () async { + final link = FakeBleLink(connectDelay: const Duration(milliseconds: 25)); + final session = DeviceSession(link); + final engine = ControlEngine(session, const {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + final connecting = engine.connect( + 'fake', + limits: SafetyLimits([1000, 1100, 1200], [2000, 1900, 1800]), + ); + await Future.delayed(const Duration(milliseconds: 2)); + await engine.stopMotion(); + await connecting; + expect(engine.connection, 'connected'); + expect(engine.outputAuthorized, false); + expect( + link.writes.map(BleProtocol.decodeControlFrame), + isNot( + contains( + predicate((f) => f.opcode == BleOpcode.arm.value), + ), + ), + ); + }); + + test( + 'disconnect during auto-ARM never leaves a false connected snapshot', + () async { + final link = FakeBleLink(writeDelay: const Duration(milliseconds: 3)); + final session = DeviceSession(link); + final engine = ControlEngine(session, const {}); + final disconnectOnArm = session.snapshots.listen((snapshot) { + if (snapshot.isArmed) unawaited(link.disconnect()); + }); + addTearDown(() async { + await disconnectOnArm.cancel(); + await engine.dispose(); + await link.dispose(); + }); + try { + await engine.connect( + 'fake', + limits: SafetyLimits([1000, 1100, 1200], [2000, 1900, 1800]), + ); + } catch (_) { + // The disconnect may be observed before connect's ARM completion. + } + await Future.delayed(const Duration(milliseconds: 10)); + expect(session.snapshot.isConnected, false); + expect(engine.connection, isNot('connected')); + expect(engine.outputAuthorized, false); + }, + ); + + test('wrong saved identity is rejected before acquiring control', () async { + final h = Harness(); + addTearDown(h.close); + await expectLater( + h.engine.connect( + 'fake', + expectedIdentity: 'ffffffffffffffffffffffffffffffff', + ), + throwsStateError, + ); + expect(h.engine.connection, 'failed'); + expect(h.link.writes, isEmpty); + expect(h.engine.outputAuthorized, false); + }); +} diff --git a/flutter_app/test/ble_protocol_test.dart b/flutter_app/test/ble_protocol_test.dart new file mode 100644 index 0000000..d06182a --- /dev/null +++ b/flutter_app/test/ble_protocol_test.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/ble_protocol.dart'; + +List unhex(String value) => [ + for (var i = 0; i < value.length; i += 2) + int.parse(value.substring(i, i + 2), radix: 16), +]; +String hex(List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +void main() { + test('v1.2 shared-pairing info and unsupported transfer vectors', () { + final vectors = + jsonDecode( + File( + '../docs/protocol/satori_ble_v1_2_shared_pairing_vectors.json', + ).readAsStringSync(), + ) + as Map; + final timing = vectors['timing'] as Map; + expect( + BleProtocol.commandTimeout.inMilliseconds, + timing['command_timeout_ms'], + ); + expect(BleProtocol.commandMaxRetries, timing['command_max_retries']); + expect( + BleProtocol.releaseAckWindow.inMilliseconds, + timing['release_ack_window_ms'], + ); + expect( + BleProtocol.releaseAckWindow, + greaterThan( + BleProtocol.commandTimeout * (BleProtocol.commandMaxRetries + 1), + ), + ); + for (final item in vectors['reads'] as List) { + final vector = item as Map; + final bytes = unhex(vector['hex'] as String); + final info = BleProtocol.decodeDeviceInfo(bytes); + expect(info.protocolMinor, vector['protocol_minor']); + expect(info.capabilities, vector['capabilities']); + expect(info.securityPolicy, vector['security_policy']); + expect(info.supportsSharedPairing, isTrue); + expect( + hex( + BleProtocol.encodeDeviceInfo( + protocolMinor: 2, + firmwareMajor: 0, + firmwareMinor: 2, + firmwarePatch: 2, + hardwareProfile: 1, + capabilities: 0x1df, + securityPolicy: 2, + ), + ), + vector['hex'], + ); + } + for (final item in vectors['rejection_cases'] as List) { + final vector = item as Map; + expect( + BleProtocol.validateControlFrame( + unhex(vector['hex'] as String), + currentToken: 0x12345678, + armed: false, + subscribed: true, + supportsSharedPairing: true, + ).value, + vector['expected_result'], + reason: vector['name'] as String, + ); + } + }); + + test('v1.1 management command, event and rejection vectors', () { + final vectors = + jsonDecode( + File( + '../docs/protocol/satori_ble_v1_1_management_vectors.json', + ).readAsStringSync(), + ) + as Map; + for (final item in vectors['commands'] as List) { + final vector = item as Map; + final op = BleOpcode.values.firstWhere( + (value) => value.value == vector['opcode'], + ); + final payload = op == BleOpcode.setPairingCode + ? BleProtocol.encodePairingCode( + (vector['code'] as int).toString().padLeft(6, '0'), + ) + : null; + final frame = BleProtocol.encodeControlFrame( + opcode: op, + sequence: vector['sequence'] as int, + token: vector['token'] as int, + payload: payload, + ); + expect(hex(frame), vector['hex'], reason: vector['name'] as String); + } + for (final item in vectors['events'] as List) { + final vector = item as Map; + final event = BleProtocol.decodeEvent(unhex(vector['hex'] as String)); + expect(event.opcode, greaterThanOrEqualTo(0x88)); + expect( + hex( + BleProtocol.encodeEvent( + opcode: event.opcode, + sequence: event.sequence, + token: event.token, + result: event.result, + controlState: event.controlState, + lastAppliedSequence: event.lastAppliedSequence, + flags: event.flags, + batteryPercent: event.batteryPercent, + ), + ), + vector['hex'], + ); + } + for (final item in vectors['rejection_cases'] as List) { + final vector = item as Map; + expect( + BleProtocol.validateControlFrame( + unhex(vector['hex'] as String), + currentToken: 0x12345678, + armed: false, + subscribed: true, + ).value, + vector['expected_result'], + reason: vector['name'] as String, + ); + } + }); + + test('all shared command vectors encode and decode exactly', () { + const vectors = >[ + [BleOpcode.claim, 1, 0, '0101010000000000000000000000000000000000'], + [ + BleOpcode.arm, + 2, + 0x12345678, + '0107020000007856341200000000000000000000', + ], + [ + BleOpcode.setTarget, + 3, + 0x12345678, + '01020300000078563412dc054006a406c8000000', + ], + [ + BleOpcode.keepalive, + 4, + 0x12345678, + '0105040000007856341200000000000000000000', + ], + [ + BleOpcode.halt, + 5, + 0x12345678, + '0103050000007856341200000000000000000000', + ], + [ + BleOpcode.getStatus, + 6, + 0x12345678, + '0106060000007856341200000000000000000000', + ], + [ + BleOpcode.release, + 7, + 0x12345678, + '0104070000007856341200000000000000000000', + ], + [ + BleOpcode.setTarget, + 65537, + 0x12345678, + '01020100010078563412dc054006a406c8000000', + ], + ]; + for (final v in vectors) { + final op = v[0] as BleOpcode, + seq = v[1] as int, + token = v[2] as int, + expected = v[3] as String; + final encoded = BleProtocol.encodeControlFrame( + opcode: op, + sequence: seq, + token: token, + payload: op == BleOpcode.setTarget + ? BleProtocol.encodeSetTarget( + ch1: 1500, + ch2: 1600, + ch3: 1700, + transitionMs: 200, + ) + : null, + ); + expect(hex(encoded), expected); + final decoded = BleProtocol.decodeControlFrame(unhex(expected)); + expect(decoded.version, 1); + expect(decoded.opcode, op.value); + expect(decoded.sequence, seq); + expect(decoded.token, token); + } + }); + + test( + 'all shared event vectors decode, including async and error replies', + () { + const vectors = [ + '018101000000785634120001000000000cff0000', + '018702000000785634120001020000000dff0000', + '018203000000785634120001020000000dff0000', + '01e000000000785634120002030000000fff0000', + '018305000000785634120001050000000dff0000', + '0184070000007856341200000700000005ff0000', + '018203000000785634120b01000000000cff0000', + '018702000000785634120c01000000000cff0000', + ]; + for (final fixture in vectors) { + final event = BleProtocol.decodeEvent(unhex(fixture)); + expect( + hex( + BleProtocol.encodeEvent( + opcode: event.opcode, + sequence: event.sequence, + token: event.token, + result: event.result, + controlState: event.controlState, + lastAppliedSequence: event.lastAppliedSequence, + flags: event.flags, + batteryPercent: event.batteryPercent, + ), + ), + fixture, + ); + } + expect(BleProtocol.decodeEvent(unhex(vectors[0])).token, 0x12345678); + expect(BleProtocol.decodeEvent(unhex(vectors[3])).controlState, 2); + expect( + BleProtocol.decodeEvent(unhex(vectors[6])).result, + BleResult.notArmed, + ); + expect( + BleProtocol.decodeEvent(unhex(vectors[7])).result, + BleResult.notConfigured, + ); + }, + ); + + test('all shared read vectors decode and re-encode byte-for-byte', () { + const info = '0100000100015f0000001414d007701701030000'; + const identity = '00112233445566778899aabbccddeeff'; + const cold = '010000000000000000000000000000000000ff00'; + const armed = '01017856341202000000b405fa0572060700ff00'; + const moving = '01027856341203000000c805180690060707ff00'; + final d = BleProtocol.decodeDeviceInfo(unhex(info)); + expect(d.firmwareVersion, '0.1.0'); + expect(d.capabilities, 0x5f); + expect(d.maxTransitionMs, 2000); + expect(d.leaseTimeoutMs, 6000); + expect(hex(BleProtocol.encodeDeviceInfo(firmwareMinor: 1)), info); + expect(hex(BleProtocol.decodeIdentity(unhex(identity))), identity); + for (final fixture in [cold, armed, moving]) { + final s = BleProtocol.decodeStateSnapshot(unhex(fixture)); + expect( + hex( + BleProtocol.encodeStateSnapshot( + controlState: s.controlState, + token: s.token, + lastAppliedSequence: s.lastAppliedSequence, + channels: s.channels, + validChannelMask: s.validChannelMask, + interpolatingChannelMask: s.interpolatingChannelMask, + batteryPercent: s.batteryPercent, + ), + ), + fixture, + ); + } + expect(BleProtocol.decodeStateSnapshot(unhex(cold)).validChannelMask, 0); + expect( + BleProtocol.decodeStateSnapshot(unhex(moving)).interpolatingChannelMask, + 7, + ); + }); + + test('all shared rejection fixtures map to their protocol result', () { + const cases = >[ + ['01020300000078563412dc054006a406c80000', BleResult.badLength], + ['01020300000078563412dc054006a406c800000000', BleResult.badLength], + ['02020300000078563412dc054006a406c8000000', BleResult.badVersion], + ['017f030000007856341200000000000000000000', BleResult.badOpcode], + ['01020300000078563412f3014006a406c8000000', BleResult.badPayload], + ['01020300000078563412dc054006c509c8000000', BleResult.badPayload], + ['01020300000078563412dc054006a406d1070000', BleResult.badPayload], + ['01020300000078563412dc054006a406c8000100', BleResult.badPayload], + ['0103050000007856341201000000000000000000', BleResult.badPayload], + ['01020000000078563412dc054006a406c8000000', BleResult.oldSequence], + ['01020300000099999999dc054006a406c8000000', BleResult.badSession], + ['01020300000078563412dc054006a406c8000000', BleResult.notArmed], + [ + '0101010000000000000000000000000000000000', + BleResult.subscriptionRequired, + ], + ]; + for (final c in cases) { + final bytes = unhex(c[0] as String); + final expected = c[1] as BleResult; + expect( + BleProtocol.validateControlFrame( + bytes, + currentToken: 0x12345678, + armed: false, + subscribed: false, + ), + expected, + reason: c[0] as String, + ); + } + // Secure ATT failures (unauthenticated controls) happen below this codec. + }); + + test( + 'rejects malformed lengths, field values, reserved bits and unknown enum values', + () { + expect(() => BleProtocol.decodeControlFrame([1]), throwsFormatException); + expect(() => BleProtocol.decodeIdentity([1]), throwsFormatException); + expect( + () => BleProtocol.decodeEvent(List.filled(20, 0)), + throwsFormatException, + ); + expect( + () => BleProtocol.decodeDeviceInfo(List.filled(20, 0)), + throwsFormatException, + ); + expect( + () => BleProtocol.decodeStateSnapshot(List.filled(20, 0)), + throwsFormatException, + ); + expect( + () => BleProtocol.encodeSetTarget( + ch1: 499, + ch2: 1500, + ch3: 1700, + transitionMs: 200, + ), + throwsFormatException, + ); + expect( + () => BleProtocol.encodeSetTarget( + ch1: 1500, + ch2: 1600, + ch3: 1700, + transitionMs: 2001, + ), + throwsFormatException, + ); + final badEvent = unhex('018101000000785634120001000000000cff0000') + ..[18] = 1; + expect(() => BleProtocol.decodeEvent(badEvent), throwsFormatException); + final badSnapshot = unhex('01017856341202000000b405fa0572060700ff00') + ..[19] = 1; + expect( + () => BleProtocol.decodeStateSnapshot(badSnapshot), + throwsFormatException, + ); + }, + ); +} diff --git a/flutter_app/test/ble_ui_test.dart b/flutter_app/test/ble_ui_test.dart new file mode 100644 index 0000000..566837c --- /dev/null +++ b/flutter_app/test/ble_ui_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/main.dart'; +import 'package:satori_manager/joystick_pad.dart'; +import 'package:satori_manager/runtime/control_client.dart'; + +void main() { + testWidgets( + 'paired paused device has explicit ARM and no fabricated target controls', + (tester) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'deviceId': 'synthetic-test-device', + 'sessionPhase': 'readyPaused', + 'identity': 'synthetic', + 'outputAuthorized': false, + 'controlPhase': 'paused', + 'target': null, + 'validChannelMask': 0, + 'battery': null, + 'mode': 'manual', + 'playback': 'idle', + }); + await tester.pumpWidget(SatoriApp(previewClient: client)); + expect(find.text('恢复控制'), findsOneWidget); + expect(find.byType(JoystickPad), findsNothing); + expect(find.text('电量未知'), findsOneWidget); + expect(find.text('测试版'), findsOneWidget); + await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.pumpAndSettle(); + expect(find.textContaining('已下发逻辑值'), findsNothing); + await tester.scrollUntilVisible(find.text('诊断信息'), 200); + await tester.tap(find.text('诊断信息')); + await tester.pumpAndSettle(); + expect(find.text('已下发逻辑值(控制目标):未知'), findsOneWidget); + expect(find.text('实际机械位置:未知'), findsOneWidget); + expect(find.text('指定 IPv4(可选)'), findsNothing); + }, + ); + + testWidgets( + 'output-authorized controls show logical targets, no microsecond telemetry', + (tester) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'deviceId': 'synthetic-test-device', + 'sessionPhase': 'armed', + 'outputAuthorized': true, + 'target': [1300, 1700, 1400], + 'validChannelMask': 7, + 'mode': 'manual', + 'playback': 'idle', + }); + await tester.pumpWidget(SatoriApp(previewClient: client)); + expect(find.byType(JoystickPad), findsOneWidget); + expect(find.text('方向'), findsOneWidget); + expect(find.textContaining('CH1 1300'), findsNothing); + expect(find.textContaining('μs'), findsNothing); + }, + ); + + testWidgets('configuration failure is short outside diagnostics', ( + tester, + ) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'controlPhase': 'configurationError', + 'outputAuthorized': false, + 'target': null, + 'error': 'private firmware exception', + 'mode': 'manual', + }); + await tester.pumpWidget(SatoriApp(previewClient: client)); + expect(find.text('设备配置异常,暂时无法控制'), findsWidgets); + expect(find.textContaining('private firmware exception'), findsNothing); + expect(find.text('查看详情'), findsOneWidget); + await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('诊断信息'), 200); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -120)); + await tester.pumpAndSettle(); + await tester.tap(find.text('诊断信息')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.textContaining('private firmware exception'), + 200, + ); + expect(find.textContaining('private firmware exception'), findsOneWidget); + }); +} diff --git a/flutter_app/test/client_generation_guard_test.dart b/flutter_app/test/client_generation_guard_test.dart new file mode 100644 index 0000000..fa6e7a6 --- /dev/null +++ b/flutter_app/test/client_generation_guard_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/runtime/client_generation_guard.dart'; + +void main() { + group('ClientGenerationGuard', () { + test('snapshot handshakes admit commands for the exact runtime', () { + final guard = ClientGenerationGuard(); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'snapshot', + }, runtimeId: 'r1'), + isNull, + ); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'manual', + 'expectedRuntimeId': 'r1', + }, runtimeId: 'r1'), + isNull, + ); + }); + + test('commands require a handshake, client id, and current runtime', () { + final guard = ClientGenerationGuard(); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'stop', + 'expectedRuntimeId': 'r1', + }, runtimeId: 'r1'), + isNotNull, + ); + guard.authorize({'clientId': 'ui-a', 'op': 'snapshot'}, runtimeId: 'r1'); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'stop', + 'expectedRuntimeId': 'old-runtime', + }, runtimeId: 'r1'), + isNotNull, + ); + expect( + guard.authorize({ + 'op': 'stop', + 'expectedRuntimeId': 'r1', + }, runtimeId: 'r1'), + isNotNull, + ); + }); + + test( + 'new snapshot transfers ownership and retired client cannot reclaim it', + () { + final guard = ClientGenerationGuard(); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'snapshot', + }, runtimeId: 'r1'), + isNull, + ); + expect( + guard.authorize({ + 'clientId': 'ui-b', + 'op': 'snapshot', + }, runtimeId: 'r1'), + isNull, + ); + expect(guard.activeClientId, 'ui-b'); + expect(guard.retiredClientIds, contains('ui-a')); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'snapshot', + }, runtimeId: 'r1'), + isNotNull, + ); + expect(guard.activeClientId, 'ui-b'); + expect( + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'manual', + 'expectedRuntimeId': 'r1', + }, runtimeId: 'r1'), + isNotNull, + ); + expect( + guard.authorize({ + 'clientId': 'ui-b', + 'op': 'stop', + 'expectedRuntimeId': 'r1', + }, runtimeId: 'r1'), + isNull, + ); + }, + ); + + test('snapshot handshake does not change service runtime identity', () { + const runtimeId = 'service-runtime'; + final guard = ClientGenerationGuard(); + guard.authorize({ + 'clientId': 'ui-a', + 'op': 'snapshot', + }, runtimeId: runtimeId); + guard.authorize({ + 'clientId': 'ui-b', + 'op': 'snapshot', + }, runtimeId: runtimeId); + expect( + guard.authorize({ + 'clientId': 'ui-b', + 'op': 'manual', + 'expectedRuntimeId': runtimeId, + }, runtimeId: runtimeId), + isNull, + ); + }); + }); +} diff --git a/flutter_app/test/control_status_test.dart b/flutter_app/test/control_status_test.dart new file mode 100644 index 0000000..56ef332 --- /dev/null +++ b/flutter_app/test/control_status_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/control_status.dart'; + +void main() { + test('notification and page status follow fields, not error wording', () { + expect( + controlStatus({ + 'connection': 'connected', + 'controlPhase': 'configurationError', + 'error': 'arbitrary diagnostic', + }), + '设备配置异常,暂时无法控制', + ); + expect( + controlStatus({ + 'connection': 'connected', + 'controlPhase': 'pauseUnconfirmed', + 'error': 'transport timeout', + }), + '暂停未确认', + ); + expect( + controlStatus({ + 'connection': 'reconnecting', + 'controlPhase': 'paused', + 'error': 'auto-start restored', + }), + '正在重新连接…', + ); + expect( + controlStatus({'connection': 'connected', 'controlPhase': 'paused'}), + '已暂停', + ); + }); +} diff --git a/flutter_app/test/device_session_test.dart b/flutter_app/test/device_session_test.dart new file mode 100644 index 0000000..00fd883 --- /dev/null +++ b/flutter_app/test/device_session_test.dart @@ -0,0 +1,1148 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.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'; + +List frame(BleOpcode op, int seq, int token, {List? payload}) => + BleProtocol.encodeControlFrame( + opcode: op, + sequence: seq, + token: token, + payload: payload, + ); +String byteHex(List bytes) => + bytes.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +Future> exchange(FakeBleLink link, List request) async { + final f = BleProtocol.decodeControlFrame(request); + final completer = Completer>(); + late StreamSubscription> sub; + sub = link.subscribe(BleProtocol.eventTxUuid).listen((bytes) { + try { + final e = BleProtocol.decodeEvent(bytes); + if (e.sequence == f.sequence && + e.opcode == (f.opcode | 0x80) && + !completer.isCompleted) { + completer.complete(List.from(bytes)); + } + } catch (_) {} + }); + await link.write(BleProtocol.controlRxUuid, request); + try { + return await completer.future.timeout(const Duration(milliseconds: 100)); + } finally { + await sub.cancel(); + } +} + +class AckBeforeWriteCompletes implements BleLink { + AckBeforeWriteCompletes(this.inner); + final FakeBleLink inner; + final List ackBeforeWriteOpcodes = []; + @override + Stream get connectionState => inner.connectionState; + @override + Future connect(String id) => inner.connect(id); + @override + Future disconnect() => inner.disconnect(); + @override + Future> read(String uuid) => inner.read(uuid); + @override + Stream> subscribe(String uuid) => inner.subscribe(uuid); + @override + Future write(String uuid, List value) async { + final frame = BleProtocol.decodeControlFrame(value); + var ackSeen = false; + final observer = inner.subscribe(BleProtocol.eventTxUuid).listen((event) { + try { + final decoded = BleProtocol.decodeEvent(event); + if (decoded.sequence == frame.sequence && + decoded.opcode == (frame.opcode | 0x80)) { + ackSeen = true; + } + } catch (_) {} + }); + await inner.write(uuid, value); + await Future.delayed(const Duration(milliseconds: 5)); + if (ackSeen) ackBeforeWriteOpcodes.add(frame.opcode); + await observer.cancel(); + } +} + +class PairingWriteFailureLink implements BleLink { + PairingWriteFailureLink(this.inner); + final FakeBleLink inner; + @override + Stream get connectionState => inner.connectionState; + @override + Future connect(String id) => inner.connect(id); + @override + Future disconnect() => inner.disconnect(); + @override + Future> read(String uuid) => inner.read(uuid); + @override + Stream> subscribe(String uuid) => inner.subscribe(uuid); + @override + Future write(String uuid, List value) { + if (BleProtocol.decodeControlFrame(value).opcode == + BleOpcode.setPairingCode.value) { + throw StateError('raw pairing payload includes 654321'); + } + return inner.write(uuid, value); + } +} + +// Model processing/write delay plus independently delayed notifications, and +// optionally keep a periodic state read in flight while RELEASE clears token. +class DelayedReleaseLink implements BleLink { + DelayedReleaseLink(this.inner); + final FakeBleLink inner; + Completer? heldSnapshotRead; + final snapshotReadStarted = Completer(); + @override + Stream get connectionState => inner.connectionState; + @override + Future connect(String id) => inner.connect(id); + @override + Future disconnect() => inner.disconnect(); + @override + Future> read(String uuid) async { + if (uuid == BleProtocol.stateSnapshotUuid && heldSnapshotRead != null) { + final held = heldSnapshotRead!; + heldSnapshotRead = null; + snapshotReadStarted.complete(); + await held.future; + } + return inner.read(uuid); + } + + @override + Stream> subscribe(String uuid) => inner.subscribe(uuid).asyncMap(( + bytes, + ) async { + if (BleProtocol.decodeEvent(bytes).opcode == BleOpcode.release.replyValue) { + await Future.delayed(const Duration(milliseconds: 60)); + } + return bytes; + }); + @override + Future write(String uuid, List value) => inner.write(uuid, value); +} + +void main() { + test( + 'connect claims only after event subscription, then arm and target', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 30), + ); + await session.connect('synthetic'); + expect(session.snapshot.phase, DeviceSessionPhase.readyPaused); + expect(session.snapshot.identity, '000102030405060708090a0b0c0d0e0f'); + expect(session.snapshot.deviceInfo?.protocolMajor, 1); + expect(link.writes.first[1], 1); + expect(session.snapshot.targetKnown, isFalse); + expect(session.snapshot.state?.validChannelMask, 0); + expect(link.acceptedTargetSubmissions, 0); + await session.arm(); + expect(session.snapshot.isArmed, isTrue); + await session.setTarget( + ch1: 1500, + ch2: 1600, + ch3: 1700, + latestOnly: false, + ); + expect(link.writes.last[1], 2); + await session.halt(); + expect(session.snapshot.phase, DeviceSessionPhase.readyPaused); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + test( + 'owner code changes use cached identical retries without exposing code', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 30), + maxRetries: 2, + ); + await session.connect('synthetic'); + link.dropNextReplies = 1; + await expectLater( + session.setPairingCode('123456'), + throwsFormatException, + ); + await expectLater( + session.setPairingCode('12x456'), + throwsFormatException, + ); + expect( + link.writes.where( + (frame) => frame[1] == BleOpcode.setPairingCode.value, + ), + isEmpty, + ); + await session.setPairingCode('000042'); + expect(link.pairingCode, 42); + final writes = link.writes + .where((frame) => frame[1] == BleOpcode.setPairingCode.value) + .toList(); + expect(writes, hasLength(2)); + expect(writes[0], writes[1]); + expect(link.pairingCodeWrites, 1); + expect(session.snapshot.toString(), isNot(contains('000042'))); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + test( + 'management authorization callback runs at queue send boundary', + () async { + final link = FakeBleLink(); + final session = DeviceSession(link); + await session.connect('synthetic'); + final writesBefore = link.writes.length; + await expectLater( + session.setPairingCode( + '654321', + beforeSend: () => throw StateError('UI client retired'), + ), + throwsStateError, + ); + expect(link.writes, hasLength(writesBefore)); + expect(link.pairingCode, 123456); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + test( + 'queued ARM checks its action generation immediately before writing', + () async { + final link = FakeBleLink(); + final session = DeviceSession(link); + await session.connect('synthetic'); + await expectLater( + session.arm( + beforeSend: () => throw StateError('automatic start paused'), + ), + throwsStateError, + ); + expect( + link.writes.where((frame) => frame[1] == BleOpcode.arm.value), + isEmpty, + ); + expect(session.snapshot.phase, DeviceSessionPhase.readyPaused); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'management write exceptions are sanitized before reaching callers', + () async { + final inner = FakeBleLink(); + final session = DeviceSession( + PairingWriteFailureLink(inner), + commandTimeout: const Duration(milliseconds: 40), + maxRetries: 0, + ); + await session.connect('synthetic'); + await expectLater( + session.setPairingCode('654321'), + throwsA( + predicate((error) => !error.toString().contains('654321')), + ), + ); + expect(session.snapshot.lastError, isNot(contains('654321'))); + expect(session.snapshot.phase, DeviceSessionPhase.disconnected); + await session.dispose(); + await inner.dispose(); + }, + ); + + test( + 'owner operations require capability and device owner authorization', + () async { + final unsupported = FakeBleLink(ownerManagementSupported: false); + final oldSession = DeviceSession(unsupported); + await oldSession.connect('synthetic'); + await expectLater( + oldSession.setPairingCode('654321'), + throwsA(isA()), + ); + expect(unsupported.writes.length, 1); // CLAIM only. + await oldSession.disconnect(); + await oldSession.dispose(); + await unsupported.dispose(); + + final stranger = FakeBleLink(ownerAuthorized: false); + final strangerSession = DeviceSession(stranger); + await strangerSession.connect('synthetic'); + await expectLater( + strangerSession.setPairingCode('654321'), + throwsA(isA()), + ); + expect(stranger.pairingCode, 123456); + expect(stranger.pairingCodeWrites, 0); + await strangerSession.dispose(); + await stranger.dispose(); + }, + ); + + test('NVS failure preserves pairing code and fails closed', () async { + final link = FakeBleLink(nvsWriteFailures: 1); + final session = DeviceSession(link); + await session.connect('synthetic'); + await expectLater( + session.setPairingCode('654321'), + throwsA( + isA().having( + (error) => error.result, + 'result', + BleResult.internalError, + ), + ), + ); + expect(link.pairingCode, 123456); + expect(link.pairingCodeWrites, 0); + expect(session.snapshot.isConnected, isFalse); + await session.dispose(); + await link.dispose(); + }); + + test( + 'default code blocks transfer; transfer window times out without owner replacement', + () async { + final link = FakeBleLink( + transferWindowTimeout: const Duration(milliseconds: 40), + sharedPairingSupported: false, + ); + final session = DeviceSession(link); + await session.connect('synthetic'); + await expectLater( + session.openTransfer(), + throwsA( + isA().having( + (error) => error.result, + 'result', + BleResult.notConfigured, + ), + ), + ); + expect(session.snapshot.isConnected, isTrue); + await session.setPairingCode('654321'); + await session.openTransfer(); + expect(link.transferWindowOpen, isTrue); + expect(session.snapshot.phase, DeviceSessionPhase.disconnected); + await Future.delayed(const Duration(milliseconds: 60)); + expect(link.transferWindowOpen, isFalse); + expect(link.pairingCode, 654321); + expect(link.ownerIdentityUnchanged, isTrue); + await session.dispose(); + await link.dispose(); + }, + ); + + test('OPEN ACK before GATT write completion is treated as success', () async { + final inner = FakeBleLink(sharedPairingSupported: false); + final link = AckBeforeWriteCompletes(inner); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await session.setPairingCode('654321'); + await session.openTransfer(); + expect(link.ackBeforeWriteOpcodes, contains(BleOpcode.openTransfer.value)); + expect(session.snapshot.phase, DeviceSessionPhase.disconnected); + expect(inner.transferWindowOpen, isTrue); + await session.dispose(); + await inner.dispose(); + }); + + test('OPEN without ACK times out and disconnects safely', () async { + final link = FakeBleLink(sharedPairingSupported: false); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 20), + maxRetries: 1, + ); + await session.connect('synthetic'); + await session.setPairingCode('654321'); + link.dropNextReplies = 2; + await expectLater(session.openTransfer(), throwsA(isA())); + expect(session.snapshot.phase, DeviceSessionPhase.disconnected); + expect(link.currentState, BleLinkState.disconnected); + await session.dispose(); + await link.dispose(); + }); + + test( + 'device reboot closes transfer window but retains persisted code', + () async { + final link = FakeBleLink(sharedPairingSupported: false); + final session = DeviceSession(link); + await session.connect('synthetic'); + await session.setPairingCode('654321'); + await session.openTransfer(); + expect(link.transferWindowOpen, isTrue); + await link.reboot(); + expect(link.transferWindowOpen, isFalse); + expect(link.pairingCode, 654321); + expect(link.ownerIdentityUnchanged, isTrue); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'cancel transfer is idempotent when no transfer window is open', + () async { + final link = FakeBleLink(sharedPairingSupported: false); + final session = DeviceSession(link); + await session.connect('synthetic'); + await session.setPairingCode('654321'); + await session.cancelTransfer(); + await session.cancelTransfer(); + expect(link.transferWindowOpen, isFalse); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + test( + 'transfer replay does not extend window; duplicate OPEN is busy', + () async { + final link = FakeBleLink( + transferWindowTimeout: const Duration(milliseconds: 150), + sharedPairingSupported: false, + ); + await link.connect('synthetic'); + final claim = BleProtocol.decodeEvent( + await exchange(link, frame(BleOpcode.claim, 1, 0)), + ); + final token = claim.token; + await exchange( + link, + frame( + BleOpcode.setPairingCode, + 2, + token, + payload: BleProtocol.encodePairingCode('654321'), + ), + ); + final opening = frame(BleOpcode.openTransfer, 3, token); + expect( + BleProtocol.decodeEvent(await exchange(link, opening)).result, + BleResult.ok, + ); + expect(link.transferWindowOpen, isTrue); + expect( + BleProtocol.decodeEvent( + await exchange(link, frame(BleOpcode.openTransfer, 4, token)), + ).result, + BleResult.busy, + ); + await Future.delayed(const Duration(milliseconds: 100)); + expect( + BleProtocol.decodeEvent(await exchange(link, opening)).result, + BleResult.ok, + ); + await Future.delayed(const Duration(milliseconds: 70)); + expect(link.transferWindowOpen, isFalse); + expect(link.pairingCode, 654321); + expect(link.ownerIdentityUnchanged, isTrue); + expect( + link.acceptedCommands.where( + (bytes) => bytes[1] == BleOpcode.openTransfer.value, + ), + hasLength(1), + ); + await link.dispose(); + }, + ); + + test( + 'pairing code cannot change until the transfer window is cancelled', + () async { + final link = FakeBleLink(sharedPairingSupported: false); + await link.connect('synthetic'); + final claim = BleProtocol.decodeEvent( + await exchange(link, frame(BleOpcode.claim, 1, 0)), + ); + final token = claim.token; + await exchange( + link, + frame( + BleOpcode.setPairingCode, + 2, + token, + payload: BleProtocol.encodePairingCode('654321'), + ), + ); + expect( + BleProtocol.decodeEvent( + await exchange(link, frame(BleOpcode.openTransfer, 3, token)), + ).result, + BleResult.ok, + ); + expect( + BleProtocol.decodeEvent( + await exchange( + link, + frame( + BleOpcode.setPairingCode, + 4, + token, + payload: BleProtocol.encodePairingCode('765432'), + ), + ), + ).result, + BleResult.busy, + ); + expect(link.pairingCode, 654321); + expect( + BleProtocol.decodeEvent( + await exchange(link, frame(BleOpcode.cancelTransfer, 5, token)), + ).result, + BleResult.ok, + ); + expect( + BleProtocol.decodeEvent( + await exchange( + link, + frame( + BleOpcode.setPairingCode, + 6, + token, + payload: BleProtocol.encodePairingCode('765432'), + ), + ), + ).result, + BleResult.ok, + ); + expect(link.pairingCode, 765432); + expect(link.transferWindowOpen, isFalse); + await link.dispose(); + }, + ); + test('retries byte-identical command after lost business ACK', () async { + final link = FakeBleLink(dropNextReplies: 1); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 25), + maxRetries: 2, + ); + await session.connect('synthetic'); + expect(link.writes.length, 2); + expect(link.writes[0], link.writes[1]); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + test( + 'CLAIM retries the identical seq 1 while subscription setup races', + () async { + final link = FakeBleLink() + ..subscriptionDelay = const Duration(milliseconds: 25); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + maxRetries: 2, + ); + await session.connect('synthetic'); + expect(link.writes.length, 2); + expect(byteHex(link.writes[0]), byteHex(link.writes[1])); + expect(session.snapshot.phase, DeviceSessionPhase.readyPaused); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + test( + 'exhausted ACK retries disconnect and preserve identical bytes', + () async { + final link = FakeBleLink(dropNextReplies: 4); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 15), + maxRetries: 3, + ); + await expectLater( + session.connect('synthetic'), + throwsA(isA()), + ); + expect(link.writes.length, 4); + expect(link.writes.map(byteHex).toSet().length, 1); + expect(link.currentState, BleLinkState.disconnected); + await session.dispose(); + await link.dispose(); + }, + ); + test('does not allow targets until ARM', () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 25), + ); + await session.connect('synthetic'); + await expectLater( + session.setTarget(ch1: 1500, ch2: 1500, ch3: 1500), + throwsStateError, + ); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + test( + 'cancelPendingTargets invalidates not-yet-written preset frames', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 200), + ); + await session.connect('synthetic'); + await session.arm(); + link.writeDelay = const Duration(milliseconds: 30); + final first = session.setTarget( + ch1: 1510, + ch2: 1600, + ch3: 1700, + latestOnly: false, + ); + await Future.delayed(const Duration(milliseconds: 2)); + final queued = session.setTarget( + ch1: 1520, + ch2: 1600, + ch3: 1700, + latestOnly: false, + ); + session.cancelPendingTargets(); + await first; + await expectLater(queued, throwsA(isA())); + expect(link.writes.where((f) => f[1] == 2).length, 1); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'latest-only targets have one pending slot and enforce advertised 20 Hz', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await session.arm(); + final a = session.setTarget(ch1: 1510, ch2: 1600, ch3: 1700); + final b = session.setTarget(ch1: 1520, ch2: 1600, ch3: 1700); + final c = session.setTarget(ch1: 1530, ch2: 1600, ch3: 1700); + await expectLater(a, throwsA(isA())); + await expectLater(b, throwsA(isA())); + await c; + expect(link.acceptedTargetSubmissions, 1); + final watch = Stopwatch()..start(); + await session.setTarget(ch1: 1540, ch2: 1600, ch3: 1700); + expect( + watch.elapsed, + greaterThanOrEqualTo(const Duration(milliseconds: 40)), + ); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'rate-limit wait sends the newest target instead of a stale point', + () async { + final link = FakeBleLink(); + final session = DeviceSession(link); + addTearDown(() async { + await session.dispose(); + await link.dispose(); + }); + await session.connect('synthetic'); + await session.arm(); + await session.setTarget(ch1: 1500, ch2: 1500, ch3: 1500); + final obsolete = session.setTarget(ch1: 1600, ch2: 1500, ch3: 1500); + final cancelled = expectLater( + obsolete, + throwsA(isA()), + ); + await Future.delayed(Duration.zero); + await session.setTarget(ch1: 1700, ch2: 1500, ch3: 1500); + await cancelled; + final targets = link.writes + .map(BleProtocol.decodeControlFrame) + .where((f) => f.opcode == BleOpcode.setTarget.value) + .map((f) => f.payload[0] | f.payload[1] << 8); + expect(targets, [1500, 1700]); + }, + ); + + test('continuous target updates do not starve keepalive', () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 150), + ); + await session.connect('synthetic'); + await session.arm(); + var next = 1500; + final ticker = Timer.periodic(const Duration(milliseconds: 2), (_) { + next = next == 1500 ? 1700 : 1500; + session + .setTarget(ch1: next, ch2: 1600, ch3: 1700) + .catchError((Object _) {}); + }); + await Future.delayed(const Duration(milliseconds: 2300)); + ticker.cancel(); + expect( + link.acceptedCommands.any((b) => b[1] == BleOpcode.keepalive.value), + isTrue, + ); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + + test('periodic StateSnapshot refreshes last-commanded channels', () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await session.arm(); + await session.setTarget(ch1: 1800, ch2: 1600, ch3: 1700, latestOnly: false); + expect(session.snapshot.state?.channels, [1500, 1500, 1500]); + await Future.delayed(const Duration(milliseconds: 600)); + expect(session.snapshot.state?.channels, [1800, 1600, 1700]); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + + test('lost target ACK followed by HALT cannot cause a stale retry', () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 40), + maxRetries: 3, + ); + await session.connect('synthetic'); + await session.arm(); + link.dropNextReplies = 1; + final target = session.setTarget( + ch1: 1800, + ch2: 1600, + ch3: 1700, + latestOnly: false, + ); + await Future.delayed(const Duration(milliseconds: 5)); + session.cancelPendingTargets(); + final halted = session.halt(); + await expectLater(target, throwsA(isA())); + await halted; + final targetWrites = link.writes + .where((b) => b[1] == BleOpcode.setTarget.value) + .toList(); + expect(targetWrites.length, 1); + expect(link.snapshotTokenOverride, isNull); + expect( + session.snapshot.state?.lastAppliedSequence, + greaterThan(BleProtocol.decodeControlFrame(targetWrites.single).sequence), + ); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + + test( + 'heartbeat retries and replies arrive before GATT write future completes', + () async { + final peer = FakeBleLink(); + final link = AckBeforeWriteCompletes(peer); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await session.arm(); + expect(session.snapshot.isArmed, isTrue); + await session.disconnect(); + await session.dispose(); + await peer.dispose(); + }, + ); + + test( + 'malformed and wrong-token async events are ignored without corrupting snapshot', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await session.arm(); + final before = session.snapshot.state; + link.emitEvent([1, 0xe0, 0, 0]); + link.emitEvent( + BleProtocol.encodeEvent( + opcode: 0xe0, + sequence: 0, + token: 0x99999999, + result: BleResult.ok, + ), + ); + await Future.delayed(Duration.zero); + expect(session.snapshot.phase, DeviceSessionPhase.armed); + expect(session.snapshot.state?.channels, before?.channels); + expect(session.snapshot.lastError, isNull); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + + test('sequence reaches u32 max without wrapping to zero', () async { + final link = FakeBleLink(); + final session = DeviceSession.testSeeded( + link, + nextSequence: 0xffffffff, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await session.arm(); + expect(link.writes.last[2], 0xff); + expect(link.writes.last[3], 0xff); + expect(link.writes.last[4], 0xff); + expect(link.writes.last[5], 0xff); + await expectLater( + session.setTarget(ch1: 1600, ch2: 1600, ch3: 1700), + throwsStateError, + ); + expect( + link.writes.where((b) => b[1] == BleOpcode.setTarget.value), + isEmpty, + ); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + + test('wrong secure identity is rejected before CLAIM', () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await expectLater( + session.connect( + 'synthetic', + expectedIdentity: 'ffffffffffffffffffffffffffffffff', + ), + throwsStateError, + ); + expect(link.writes, isEmpty); + expect(link.currentState, BleLinkState.disconnected); + await session.dispose(); + await link.dispose(); + }); + + test( + 'StateSnapshot token mismatch fails closed before exposing ready session', + () async { + final link = FakeBleLink()..snapshotTokenOverride = 0x99999999; + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await expectLater(session.connect('synthetic'), throwsStateError); + expect(session.snapshot.isConnected, isFalse); + expect(link.currentState, BleLinkState.disconnected); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'BLE disconnect during an in-flight connect invalidates its completion', + () async { + final link = FakeBleLink() + ..connectDelay = const Duration(milliseconds: 40); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + final connecting = session.connect('synthetic'); + await Future.delayed(const Duration(milliseconds: 2)); + await session.disconnect(); + await expectLater(connecting, throwsStateError); + expect(session.snapshot.phase, DeviceSessionPhase.disconnected); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'cached duplicate is idempotent, conflicting same sequence is rejected', + () async { + final link = FakeBleLink(); + await link.connect('synthetic'); + final claim = frame(BleOpcode.claim, 1, 0); + final claimReply = await exchange(link, claim); + final token = BleProtocol.decodeEvent(claimReply).token; + await exchange(link, frame(BleOpcode.arm, 2, token)); + final set = frame( + BleOpcode.setTarget, + 3, + token, + payload: BleProtocol.encodeSetTarget( + ch1: 1500, + ch2: 1600, + ch3: 1700, + transitionMs: 200, + ), + ); + final first = await exchange(link, set); + final replay = await exchange(link, set); + expect(replay, first); + expect(link.acceptedTargetSubmissions, 1); + final changed = frame( + BleOpcode.setTarget, + 3, + token, + payload: BleProtocol.encodeSetTarget( + ch1: 1510, + ch2: 1600, + ch3: 1700, + transitionMs: 200, + ), + ); + expect( + BleProtocol.decodeEvent(await exchange(link, changed)).result, + BleResult.sequenceConflict, + ); + expect(link.acceptedTargetSubmissions, 1); + await link.disconnect(); + await link.dispose(); + }, + ); + + test( + 'duplicate replays do not extend the lease; evicted frames remain old', + () async { + final link = FakeBleLink(leaseTimeout: const Duration(milliseconds: 300)); + await link.connect('synthetic'); + final claim = frame(BleOpcode.claim, 1, 0); + final token = BleProtocol.decodeEvent(await exchange(link, claim)).token; + await exchange(link, frame(BleOpcode.arm, 2, token)); + final set = frame( + BleOpcode.setTarget, + 3, + token, + payload: BleProtocol.encodeSetTarget( + ch1: 1500, + ch2: 1600, + ch3: 1700, + transitionMs: 200, + ), + ); + await exchange(link, set); + await Future.delayed(const Duration(milliseconds: 180)); + await exchange(link, set); + await Future.delayed(const Duration(milliseconds: 150)); + expect(link.currentToken, 0); + + await link.disconnect(); + await link.connect('synthetic'); + final newToken = BleProtocol.decodeEvent( + await exchange(link, claim), + ).token; + await exchange(link, frame(BleOpcode.arm, 2, newToken)); + final sequence3 = frame( + BleOpcode.setTarget, + 3, + newToken, + payload: BleProtocol.encodeSetTarget( + ch1: 1500, + ch2: 1600, + ch3: 1700, + transitionMs: 200, + ), + ); + await exchange(link, sequence3); + for (var seq = 4; seq <= 21; seq++) { + await exchange(link, frame(BleOpcode.keepalive, seq, newToken)); + } + expect( + BleProtocol.decodeEvent(await exchange(link, sequence3)).result, + BleResult.oldSequence, + ); + await link.disconnect(); + await link.dispose(); + }, + ); + + for (final lostReplies in [1, 3]) { + test( + 'RELEASE confirms after $lostReplies lost ACKs at production timing', + () async { + final inner = FakeBleLink(writeDelay: const Duration(milliseconds: 80)); + final link = DelayedReleaseLink(inner); + final session = DeviceSession(link); // Production 500 ms / 3 retries. + addTearDown(() async { + await session.dispose(); + await inner.dispose(); + }); + await session.connect('synthetic'); + expect(session.commandTimeout, const Duration(milliseconds: 500)); + expect(session.maxRetries, 3); + Completer? heldRead; + if (lostReplies == 1) { + heldRead = Completer(); + link.heldSnapshotRead = heldRead; + await link.snapshotReadStarted.future.timeout( + const Duration(seconds: 2), + ); + } + inner.dropNextReplies = lostReplies; + final releasing = session.release(); + // The outstanding read returns the now-revoked token during the retry + // window; it must not tear down the connection before the cached ACK. + if (heldRead != null) { + await Future.delayed(const Duration(milliseconds: 130)); + expect(inner.currentToken, 0); + heldRead.complete(); + } + await releasing; + final writes = inner.writes + .where((b) => b[1] == BleOpcode.release.value) + .toList(); + expect(writes.length, lostReplies + 1); + for (final bytes in writes) { + expect(bytes, orderedEquals(writes.first)); + } + expect( + inner.acceptedCommands.where((b) => b[1] == BleOpcode.release.value), + hasLength(1), + ); + expect(session.snapshot.phase, DeviceSessionPhase.disconnected); + expect(inner.currentToken, 0); + }, + ); + } + + test('RELEASE cannot be resurrected by replaying old CLAIM', () async { + final link = FakeBleLink(); + await link.connect('synthetic'); + final claim = frame(BleOpcode.claim, 1, 0); + final token = BleProtocol.decodeEvent(await exchange(link, claim)).token; + await exchange(link, frame(BleOpcode.arm, 2, token)); + await exchange(link, frame(BleOpcode.release, 3, token)); + expect( + BleProtocol.decodeEvent(await exchange(link, claim)).result, + BleResult.badSession, + ); + expect(link.currentToken, 0); + await link.disconnect(); + await link.dispose(); + }); + + test('lease expires despite malformed traffic and status polling', () async { + final link = FakeBleLink(leaseTimeout: const Duration(milliseconds: 80)); + await link.connect('synthetic'); + final claim = frame(BleOpcode.claim, 1, 0); + final token = BleProtocol.decodeEvent(await exchange(link, claim)).token; + await exchange(link, frame(BleOpcode.getStatus, 2, token)); + for (var i = 0; i < 4; i++) { + await link.write( + BleProtocol.controlRxUuid, + frame(BleOpcode.setTarget, 3 + i, token).sublist(0, 19), + ); + await Future.delayed(const Duration(milliseconds: 12)); + } + await Future.delayed(const Duration(milliseconds: 35)); + expect(link.currentToken, 0); + expect(link.connectionState, isNotNull); + await link.dispose(); + }); + + test('NOT_CONFIGURED does not consume the sequence or disconnect', () async { + final link = FakeBleLink(configured: false); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + await expectLater( + session.arm(), + throwsA( + isA().having( + (e) => e.result, + 'result', + BleResult.notConfigured, + ), + ), + ); + expect(session.snapshot.isConnected, isTrue); + expect(session.snapshot.state?.validChannelMask, 0); + expect(link.writes.where((b) => b[1] == BleOpcode.arm.value).length, 1); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }); + + test( + 'firmware token loss is fatal and invalidates the connected session', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + link.revokeSession(); + await Future.delayed(const Duration(milliseconds: 2200)); + expect(session.snapshot.isConnected, isFalse); + expect(link.currentState, BleLinkState.disconnected); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'notification stream failure disconnects instead of preserving ready state', + () async { + final link = FakeBleLink(); + final session = DeviceSession( + link, + commandTimeout: const Duration(milliseconds: 100), + ); + await session.connect('synthetic'); + link.failNotifications(StateError('notify stream failed')); + await Future.delayed(const Duration(milliseconds: 10)); + expect(session.snapshot.isConnected, isFalse); + expect(link.currentState, BleLinkState.disconnected); + await session.dispose(); + await link.dispose(); + }, + ); +} diff --git a/flutter_app/test/engine_test.dart b/flutter_app/test/engine_test.dart new file mode 100644 index 0000000..53e4709 --- /dev/null +++ b/flutter_app/test/engine_test.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/legacy_udp_engine.dart'; +import 'package:satori_manager/core/protocol.dart'; +import 'package:satori_manager/infrastructure/udp_transport.dart'; + +class FakeTransport implements Transport { + PacketHandler? handler; + final sent = []; + @override + Future open( + PacketHandler onPacket, + void Function(String) onError, + ) async { + handler = onPacket; + } + + @override + void send(Endpoint endpoint, String message) { + sent.add('$endpoint $message'); + } + + @override + void close() {} + void reply(Endpoint source, String message) => + handler!(source, utf8.encode(message)); +} + +class FakeTimer implements Timer { + FakeTimer(this.duration, this.callback); + final Duration duration; + final void Function() callback; + bool _active = true; + @override + bool get isActive => _active; + @override + void cancel() => _active = false; + void fire() { + if (_active) { + _active = false; + callback(); + } + } + + @override + int get tick => 0; +} + +class Harness { + final transport = FakeTransport(); + final timers = []; + DateTime time = DateTime.utc(2026); + late final engine = ControlEngine( + transport, + { + 'wink': [ + ActionFrame([-1, -1, 0], const Duration(milliseconds: 450)), + ActionFrame([-1, -1, 1], const Duration(milliseconds: 200)), + ], + 'wink2': [ + ActionFrame([-1, -1, 0], const Duration(milliseconds: 200)), + ActionFrame([-1, -1, 1], const Duration(milliseconds: 200)), + ], + }, + clock: () => time, + schedule: (delay, callback) { + final timer = FakeTimer(delay, callback); + timers.add(timer); + return timer; + }, + ); + static const device = Endpoint('192.168.1.2', 8888); + Future connect() async { + await engine.open(); + engine.discover(host: device.host); + transport.reply(device, 'SatoriEye_DISCOVERY_RESPONSE,77,SIMULATOR'); + engine.select( + device, + isSimulator: true, + profile: SafetyProfile.simulator(), + ); + } + + void fireNext() { + final index = timers.indexWhere((timer) => timer.isActive); + if (index < 0) throw StateError('no pending timer'); + timers[index].fire(); + } +} + +void main() { + test( + 'selected endpoint only; duplicate discovery does not reset mode', + () async { + final h = Harness(); + await h.connect(); + h.engine.setAutoRotate(true); + h.transport.reply(Harness.device, 'SatoriEye_DISCOVERY_RESPONSE,50'); + h.transport.reply( + const Endpoint('192.168.1.3', 8888), + 'SatoriEye_HEARTBEAT_RESPONSE,30', + ); + expect(h.engine.mode, 'auto'); + expect(h.engine.battery, 77); + expect(h.engine.discovered, [Harness.device]); + h.engine.dispose(); + }, + ); + + test('real discovery cannot bypass safe range with simulator flag', () async { + final h = Harness(); + await h.engine.open(); + h.engine.discover(host: Harness.device.host); + h.transport.reply(Harness.device, 'SatoriEye_DISCOVERY_RESPONSE,77'); + expect( + () => h.engine.select( + Harness.device, + isSimulator: true, + profile: SafetyProfile.simulator(), + ), + throwsStateError, + ); + h.engine.dispose(); + }); + + test( + 'frame delay is independent of smooth duration; stop cancels remainder', + () async { + final h = Harness(); + await h.connect(); + h.engine.play('wink'); + expect(h.timers.last.duration, const Duration(milliseconds: 450)); + expect( + h.transport.sent.last, + endsWith('SMOOTH:CH1:1500CH2:1500CH3:500MS:200'), + ); + h.engine.stopMotion(); + final before = h.transport.sent.length; + h.timers.last.fire(); + expect(h.transport.sent.length, before); + expect(h.engine.playback, 'idle'); + h.engine.dispose(); + }, + ); + + test( + 'lost session only retries original endpoint and resumes stopped', + () async { + final h = Harness(); + await h.connect(); + h.engine.setAutoRotate(true); + h.time = h.time.add(const Duration(seconds: 60)); + h.fireNext(); + expect(h.engine.connection, 'reconnecting'); + expect(h.engine.autoRotate, false); + expect(h.transport.sent.last, '${Harness.device} ${Protocol.heartbeat}'); + h.transport.reply( + const Endpoint('192.168.1.3', 8888), + 'SatoriEye_HEARTBEAT_RESPONSE', + ); + expect(h.engine.connection, 'reconnecting'); + h.transport.reply(Harness.device, 'SatoriEye_HEARTBEAT_RESPONSE,77'); + expect(h.engine.connection, 'connected'); + expect(h.engine.autoRotate, false); + expect(h.engine.snapshot()['battery'], 77); + h.engine.disconnect(); + expect(h.engine.connection, 'disconnected'); + expect(h.engine.snapshot()['endpoint'], isNull); + h.engine.dispose(); + }, + ); + + test( + 'discovery expires with no results; intentional disconnect does not reconnect', + () async { + final h = Harness(); + await h.engine.open(); + h.engine.discover(host: Harness.device.host); + h.timers.last.fire(); + expect(h.engine.connection, 'no_results'); + h.engine.disconnect(); + final before = h.transport.sent.length; + h.time = h.time.add(const Duration(minutes: 3)); + h.fireNext(); + expect(h.engine.connection, 'disconnected'); + expect(h.transport.sent.length, before); + h.engine.dispose(); + }, + ); + + test( + 'reconnect stops after five probes without playing missed frames', + () async { + final h = Harness(); + await h.connect(); + h.engine.play('wink'); + h.time = h.time.add(const Duration(seconds: 60)); + h.fireNext(); + for (var i = 0; i < 5; i++) { + final retry = h.timers.firstWhere( + (v) => v.isActive && v.duration == const Duration(seconds: 10), + ); + retry.fire(); + } + expect(h.engine.connection, 'failed'); + expect(h.engine.playback, 'idle'); + expect( + h.transport.sent.where((v) => v.endsWith(Protocol.heartbeat)).length, + 5, + ); + h.engine.dispose(); + }, + ); + + test('manual takeover cancels playback and merges channels', () async { + final h = Harness(); + await h.connect(); + h.engine.play('wink'); + h.engine.setManual([.4, .6, .5]); + expect(h.engine.playback, 'idle'); + expect(h.transport.sent.last, endsWith('CH1:1300CH2:1700CH3:1500')); + h.engine.dispose(); + }); + + test('joystick release restores CH1/CH2 and preserves CH3', () async { + final h = Harness(); + await h.connect(); + h.engine.setManual([.5, .5, .8]); + h.engine.play('wink'); + h.engine.setManual([.2, .7, -1]); + expect(h.engine.playback, 'idle'); + expect(h.transport.sent.last, endsWith('CH1:900CH2:1900CH3:500')); + h.engine.setManual([.5, .5, -1]); + expect(h.transport.sent.last, endsWith('CH1:1500CH2:1500CH3:500')); + expect(() => h.engine.setManual([.5, .5, -2]), throwsFormatException); + h.engine.dispose(); + }); + + test('automatic wink retains current rotation channels', () async { + final h = Harness(); + await h.connect(); + h.engine.setAutoRotate(true); + final rotation = List.from(h.engine.target); + h.engine.setAutoWink(true); + h.timers.last.fire(); + expect(h.engine.target.take(2), rotation.take(2)); + expect(h.engine.target[2], 500); + expect( + h.transport.sent.last, + contains('CH1:${rotation[0]}CH2:${rotation[1]}CH3:500'), + ); + h.engine.dispose(); + }); +} diff --git a/flutter_app/test/eyelid_test.dart b/flutter_app/test/eyelid_test.dart new file mode 100644 index 0000000..8942f1d --- /dev/null +++ b/flutter_app/test/eyelid_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/main.dart'; +import 'package:satori_manager/runtime/control_client.dart'; + +class EyelidClient extends ControlClient { + EyelidClient() + : super.preview({ + 'connection': 'connected', + 'deviceId': 'synthetic-device', + 'outputAuthorized': true, + 'target': [1300, 1700, 1500], + 'validChannelMask': 7, + 'mode': 'manual', + 'playback': 'idle', + }); + + final commands = <(String, Map)>[]; + + @override + Future send(String op, [Map args = const {}]) async { + commands.add((op, args)); + } +} + +void main() { + testWidgets('eyelid drag sends before release and flushes final CH3 only', ( + tester, + ) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final client = EyelidClient(); + await tester.pumpWidget(SatoriApp(previewClient: client)); + final slider = find.byKey(const ValueKey('eyelid-slider')); + await tester.ensureVisible(slider); + final gesture = await tester.startGesture(tester.getCenter(slider)); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + expect(client.commands, isNotEmpty); + await gesture.moveBy(const Offset(30, 0)); + await tester.pump(); + final finalValue = tester.widget(slider).value; + final beforeRelease = client.commands.length; + await gesture.up(); + await tester.pump(); + expect(client.commands.length, greaterThan(beforeRelease)); + expect(client.commands.last.$2['values'], [-1, -1, finalValue]); + for (final command in client.commands) { + expect(command.$1, 'manual'); + expect((command.$2['values'] as List).take(2), [-1, -1]); + } + expect(finalValue, greaterThan(.5)); + }); +} diff --git a/flutter_app/test/joystick_test.dart b/flutter_app/test/joystick_test.dart new file mode 100644 index 0000000..a3ef175 --- /dev/null +++ b/flutter_app/test/joystick_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/joystick_pad.dart'; + +void main() { + test('joystick maps and clamps the original CH1/CH2 directions', () { + const size = Size.square(200); + expect( + JoystickPad.positionFor(const Offset(100, 100), size), + const Offset(.5, .5), + ); + expect( + JoystickPad.positionFor(const Offset(-100, 100), size), + const Offset(0, .5), + ); + expect( + JoystickPad.positionFor(const Offset(100, 300), size), + const Offset(.5, 1), + ); + final diagonal = JoystickPad.positionFor(const Offset(200, 200), size); + expect(diagonal.dx, closeTo(.85355, .0001)); + expect(diagonal.dy, closeTo(.85355, .0001)); + }); + + testWidgets('drag emits direction updates and release', (tester) async { + final positions = []; + var released = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 232, + child: JoystickPad( + x: .5, + y: .5, + onChanged: (x, y) => positions.add(Offset(x, y)), + onReleased: () => released++, + ), + ), + ), + ), + ), + ); + final center = tester.getCenter(find.byType(JoystickPad)); + final gesture = await tester.startGesture(center); + await gesture.moveBy(const Offset(116, 0)); + await gesture.up(); + expect(positions.last.dx, closeTo(1, .001)); + expect(positions.last.dy, closeTo(.5, .001)); + expect(released, 1); + }); +} diff --git a/flutter_app/test/pairing_engine_test.dart b/flutter_app/test/pairing_engine_test.dart new file mode 100644 index 0000000..9101a2e --- /dev/null +++ b/flutter_app/test/pairing_engine_test.dart @@ -0,0 +1,144 @@ +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/safety_limits.dart'; +import 'package:satori_manager/infrastructure/fake_ble_link.dart'; +import 'package:satori_manager/runtime/client_generation_guard.dart'; + +void main() { + for (final operation in [ + 'setPairingCode', + 'openTransfer', + 'cancelTransfer', + ]) { + test('retired UI cannot finish $operation after delayed HALT', () async { + final link = FakeBleLink(initialPairingCode: 654321); + final engine = ControlEngine(DeviceSession(link), {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final guard = ClientGenerationGuard(); + guard.authorize({ + 'op': 'snapshot', + 'clientId': 'old', + }, runtimeId: engine.runtimeId); + final message = { + 'op': operation, + 'clientId': 'old', + 'expectedRuntimeId': engine.runtimeId, + }; + void ensureCurrent() { + final failure = guard.authorize(message, runtimeId: engine.runtimeId); + if (failure != null) throw StateError(failure); + } + + link.writeDelay = const Duration(milliseconds: 80); + final future = switch (operation) { + 'setPairingCode' => engine.setPairingCode( + '987654', + ensureCurrentClient: ensureCurrent, + ), + 'openTransfer' => engine.openTransfer( + ensureCurrentClient: ensureCurrent, + ), + _ => engine.cancelTransfer(ensureCurrentClient: ensureCurrent), + }; + final rejected = expectLater(future, throwsStateError); + await Future.delayed(const Duration(milliseconds: 10)); + guard.authorize({ + 'op': 'snapshot', + 'clientId': 'new', + }, runtimeId: engine.runtimeId); + await rejected; + final management = link.writes + .map(BleProtocol.decodeControlFrame) + .where((frame) => frame.opcode >= 8 && frame.opcode <= 10); + expect(management, isEmpty); + expect(link.pairingCode, 654321); + expect(link.transferWindowOpen, isFalse); + expect(engine.connection, 'connected'); + }); + } + + test( + 'saving owner code pauses output and never publishes the code', + () async { + final link = FakeBleLink(); + final session = DeviceSession(link); + final engine = ControlEngine(session, {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect( + 'fake', + limits: SafetyLimits([500, 500, 500], [2500, 2500, 2500]), + ); + await engine.arm(); + await engine.setPairingCode('006789'); + expect(engine.outputAuthorized, isFalse); + expect(engine.connection, 'connected'); + expect(link.pairingCode, 6789); + expect(engine.snapshot().toString(), isNot(contains('006789'))); + expect(engine.snapshot().toString(), isNot(contains('6789'))); + final ops = link.writes + .map(BleProtocol.decodeControlFrame) + .map((f) => f.opcode) + .toList(); + expect( + ops.indexOf(BleOpcode.halt.value), + lessThan(ops.indexOf(BleOpcode.setPairingCode.value)), + ); + await expectLater(engine.setManual([.5, .5, .5]), throwsStateError); + }, + ); + + test('invalid/default code cannot cause a management write', () async { + final link = FakeBleLink(); + final engine = ControlEngine(DeviceSession(link), {}); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + final before = link.writes.length; + for (final code in ['123456', '12345', '123456']) { + await expectLater(engine.setPairingCode(code), throwsFormatException); + } + expect(link.writes.length, before); + }); + + test( + 'transfer disconnect is expected and does not schedule reconnect', + () async { + final link = FakeBleLink(sharedPairingSupported: false); + final scheduled = []; + final engine = ControlEngine( + DeviceSession(link), + {}, + schedule: (delay, callback) { + scheduled.add(delay); + return Timer(delay, callback); + }, + ); + addTearDown(() async { + await engine.dispose(); + await link.dispose(); + }); + await engine.connect('fake'); + await engine.setPairingCode('654321'); + await engine.openTransfer(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(engine.connection, 'disconnected'); + expect(engine.outputAuthorized, isFalse); + expect(engine.target, isNull); + expect(link.transferWindowOpen, isTrue); + expect(scheduled, isEmpty); + expect(engine.pairingNotice, contains('60秒')); + }, + ); +} diff --git a/flutter_app/test/pairing_ui_test.dart b/flutter_app/test/pairing_ui_test.dart new file mode 100644 index 0000000..c1e7a56 --- /dev/null +++ b/flutter_app/test/pairing_ui_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/main.dart'; +import 'package:satori_manager/runtime/control_client.dart'; + +class PairingPreviewClient extends ControlClient { + PairingPreviewClient({bool connected = true, bool shared = true}) + : super.preview({ + 'connection': connected ? 'connected' : 'disconnected', + 'supportsOwnerManagement': true, + 'supportsSharedPairing': shared, + 'outputAuthorized': false, + 'identity': 'synthetic', + 'sessionPhase': 'readyPaused', + 'discovered': [], + }); + final sent = <(String, Map)>[]; + @override + Future send(String op, [Map args = const {}]) async { + sent.add((op, args)); + } +} + +Future devicePage( + WidgetTester tester, + PairingPreviewClient client, +) async { + await tester.pumpWidget(SatoriApp(previewClient: client)); + await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.pumpAndSettle(); +} + +Future scrollToAction(WidgetTester tester, String label) async { + await tester.scrollUntilVisible(find.text(label), 250); + await tester.drag(find.byType(Scrollable).first, const Offset(0, -120)); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('first pairing explains default and keeps existing codes', ( + tester, + ) async { + final client = PairingPreviewClient(connected: false); + await devicePage(tester, client); + await tester.tap(find.text('配对帮助')); + await tester.pumpAndSettle(); + expect(find.textContaining('新设备默认配对码:123456'), findsOneWidget); + expect(find.textContaining('修改过请使用新码'), findsOneWidget); + expect(client.sent, isEmpty); + }); + + testWidgets( + 'code dialog masks input and rejects default/mismatch before sending', + (tester) async { + final client = PairingPreviewClient(); + await devicePage(tester, client); + await scrollToAction(tester, '修改配对码'); + await tester.tap(find.text('修改配对码')); + await tester.pumpAndSettle(); + final inputs = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ); + expect(inputs, findsNWidgets(2)); + for (final field in tester.widgetList(inputs)) { + expect(field.obscureText, isTrue); + expect(field.enableSuggestions, isFalse); + } + await tester.enterText(inputs.at(0), '123456'); + await tester.enterText(inputs.at(1), '123456'); + await tester.tap(find.text('暂停并保存')); + await tester.pumpAndSettle(); + expect(find.text('不能使用默认码123456'), findsOneWidget); + expect(client.sent, isEmpty); + await tester.enterText(inputs.at(0), '006789'); + await tester.enterText(inputs.at(1), '006780'); + await tester.tap(find.text('暂停并保存')); + await tester.pumpAndSettle(); + expect(find.text('两次输入不一致'), findsOneWidget); + expect(client.sent, isEmpty); + await tester.enterText(inputs.at(1), '006789'); + await tester.tap(find.text('暂停并保存')); + await tester.pumpAndSettle(); + expect(client.sent.single.$1, 'setPairingCode'); + expect(client.sent.single.$2['code'], '006789'); + expect(find.textContaining('006789'), findsNothing); + }, + ); + + testWidgets('changing phone requires separate confirmation', (tester) async { + final client = PairingPreviewClient(shared: false); + await devicePage(tester, client); + await scrollToAction(tester, '更换手机'); + await tester.tap(find.text('更换手机')); + await tester.pumpAndSettle(); + expect(find.text('更换主控手机'), findsOneWidget); + expect(client.sent, isEmpty); + await tester.tap(find.text('开启60秒换绑')); + await tester.pumpAndSettle(); + expect(client.sent.single.$1, 'openTransfer'); + }); + testWidgets('shared pairing explains exclusive use and hides transfer', ( + tester, + ) async { + final client = PairingPreviewClient(); + await devicePage(tester, client); + await scrollToAction(tester, '修改配对码'); + expect(find.textContaining('同一时间只能连接 1 台'), findsOneWidget); + expect(find.textContaining('最多保存 8 台手机'), findsOneWidget); + expect(find.text('更换手机'), findsNothing); + expect(find.text('取消换绑窗口'), findsNothing); + expect(client.sent, isEmpty); + }); + testWidgets( + 'default setup requires no range form and maintenance is collapsed', + (tester) async { + final client = PairingPreviewClient(connected: false); + await devicePage(tester, client); + expect(find.textContaining('默认参数已内置'), findsNothing); + expect(find.byType(TextField), findsNothing); + await tester.scrollUntilVisible(find.text('高级维护'), 200); + expect(find.text('保存自定义范围'), findsNothing); + await tester.tap(find.text('高级维护')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNWidgets(6)); + }, + ); +} diff --git a/flutter_app/test/protocol_test.dart b/flutter_app/test/protocol_test.dart new file mode 100644 index 0000000..a27e7aa --- /dev/null +++ b/flutter_app/test/protocol_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/protocol.dart'; + +void main() { + test('wire bytes and sentinel frame behavior', () { + expect(Protocol.discovery, 'SatoriEye_DISCOVERY_REQUEST'); + expect(Protocol.heartbeat, 'SatoriEye_HEARTBEAT_REQUEST'); + expect(Protocol.pwm([1500, 1400, 1600]), 'CH1:1500CH2:1400CH3:1600'); + expect( + Protocol.pwm([1500, 1400, 1600], smoothMs: 200), + 'SMOOTH:CH1:1500CH2:1400CH3:1600MS:200', + ); + expect(Protocol.proportion(.4999), 1499); + final frame = ActionFrame.fromJson({ + 'CH1': -1, + 'CH2': -1, + 'CH3': 0, + 'duration': 200, + }); + expect(frame.channels, [-1, -1, 0]); + expect(frame.duration, const Duration(milliseconds: 200)); + }); + + test('invalid battery remains unknown; invalid output fails', () { + expect( + Protocol.parse(utf8.encode('SatoriEye_DISCOVERY_RESPONSE'))?.battery, + isNull, + ); + expect( + Protocol.parse(utf8.encode('SatoriEye_HEARTBEAT_RESPONSE,nan'))?.battery, + isNull, + ); + expect( + Protocol.parse(utf8.encode('SatoriEye_HEARTBEAT_RESPONSE,101'))?.battery, + isNull, + ); + expect( + Protocol.parse( + utf8.encode('SatoriEye_DISCOVERY_RESPONSE,77,SIMULATOR'), + )?.simulator, + isTrue, + ); + expect( + Protocol.parse(utf8.encode('SatoriEye_DISCOVERY_RESPONSE,77,OTHER')), + isNull, + ); + expect(Protocol.parse([0xff]), isNull); + expect(() => Protocol.proportion(double.nan), throwsFormatException); + expect(() => Protocol.pwm([499, 1500, 1500]), throwsFormatException); + expect( + () => ActionFrame.fromJson({'CH1': -1, 'CH2': -1, 'CH3': 0}), + throwsFormatException, + ); + }); + + test('bundled action compatibility', () { + const json = + '{"wink":[{"CH1":-1,"CH2":-1,"CH3":0.0,"duration":200},' + '{"CH1":-1,"CH2":-1,"CH3":1,"duration":200}]}'; + final actions = parseActions(json); + expect(actions['wink']?.length, 2); + expect(actions['wink']?.last.channels.last, 1); + }); + + test('packaged action asset loads wink and wink2', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final actions = parseActions( + await rootBundle.loadString('assets/actions/presets.json'), + ); + expect(actions['wink']?.length, 2); + expect(actions['wink2']?.length, 4); + }); + + test('user-confirmed ranges constrain every hardware channel', () { + final profile = SafetyProfile([1450, 1400, 1300], [1550, 1600, 1700], [ + 1500, + 1500, + 1500, + ]); + expect(profile.constrain([500, 2500, 500]), [1450, 1600, 1300]); + expect( + () => SafetyProfile([1500, 1500, 1500], [1400, 1600, 1600], [ + 1500, + 1500, + 1500, + ]), + throwsFormatException, + ); + }); +} diff --git a/flutter_app/test/shared_pairing_test.dart b/flutter_app/test/shared_pairing_test.dart new file mode 100644 index 0000000..09caa03 --- /dev/null +++ b/flutter_app/test/shared_pairing_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.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'; + +void main() { + test( + 'three phones share one passkey and bonds survive code rotation', + () async { + final device = FakeBleSharedDevice(); + for (final phone in ['phone-a', 'phone-b', 'phone-c']) { + expect(device.pairPhone(phone, '123456'), isTrue); + } + expect(device.bondedPhones, {'phone-a', 'phone-b', 'phone-c'}); + + final link = FakeBleLink(sharedDevice: device, phoneId: 'phone-a'); + final session = DeviceSession(link); + await session.connect('peripheral'); + await session.setPairingCode('654321'); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + expect(device.pairingCode, 654321); + expect(device.bondedPhones, {'phone-a', 'phone-b', 'phone-c'}); + expect(device.pairPhone('phone-d', '123456'), isFalse); + expect(device.pairPhone('phone-d', '654321'), isTrue); + expect( + device.bondedPhones, + containsAll(['phone-a', 'phone-b', 'phone-c', 'phone-d']), + ); + }, + ); + + test('shared bond table accepts eight phones and refuses a ninth', () { + final device = FakeBleSharedDevice(); + for (var index = 0; index < 8; index++) { + final phone = 'phone-$index'; + expect(device.pairPhone(phone, '123456'), isTrue); + } + expect(device.bondedPhones, hasLength(8)); + expect(device.pairPhone('phone-overflow', '123456'), isFalse); + }); + + test( + 'one bonded phone controls at a time, then the next phone can connect', + () async { + final device = FakeBleSharedDevice(); + expect(device.pairPhone('phone-a', '123456'), isTrue); + expect(device.pairPhone('phone-b', '123456'), isTrue); + final firstLink = FakeBleLink(sharedDevice: device, phoneId: 'phone-a'); + final secondLink = FakeBleLink(sharedDevice: device, phoneId: 'phone-b'); + final first = DeviceSession(firstLink); + final second = DeviceSession(secondLink); + + await first.connect('peripheral'); + expect(first.snapshot.isConnected, isTrue); + expect(device.pairPhone('phone-c', '123456'), isFalse); + expect(device.setPairingCode('phone-b', 654321), isFalse); + await expectLater(second.connect('peripheral'), throwsStateError); + expect(second.snapshot.phase, DeviceSessionPhase.error); + expect(device.activePhone, 'phone-a'); + + await first.disconnect(); + expect(device.pairPhone('phone-c', '123456'), isTrue); + await second.connect('peripheral'); + expect(second.snapshot.phase, DeviceSessionPhase.readyPaused); + expect(second.snapshot.isArmed, isFalse); + expect(device.activePhone, 'phone-b'); + + await second.disconnect(); + await first.dispose(); + await second.dispose(); + await firstLink.dispose(); + await secondLink.dispose(); + }, + ); + + test( + 'shared pairing reports minor 2 policy 2 and rejects transfer commands', + () async { + final link = FakeBleLink(); + final session = DeviceSession(link); + await session.connect('peripheral'); + expect(session.snapshot.deviceInfo?.protocolMinor, 2); + expect(session.snapshot.deviceInfo?.securityPolicy, 2); + expect(session.snapshot.deviceInfo?.supportsSharedPairing, isTrue); + await expectLater( + session.openTransfer(), + throwsA( + isA().having( + (error) => error.result, + 'result', + BleResult.badOpcode, + ), + ), + ); + await expectLater( + session.cancelTransfer(), + throwsA( + isA().having( + (error) => error.result, + 'result', + BleResult.badOpcode, + ), + ), + ); + expect(session.snapshot.isConnected, isTrue); + await session.disconnect(); + await session.dispose(); + await link.dispose(); + }, + ); + + test( + 'failed connection releases exclusive slot for another bonded phone', + () async { + final device = FakeBleSharedDevice(); + device.pairPhone('phone-a', '123456'); + device.pairPhone('phone-b', '123456'); + final firstLink = FakeBleLink(sharedDevice: device, phoneId: 'phone-a'); + final secondLink = FakeBleLink(sharedDevice: device, phoneId: 'phone-b'); + final first = DeviceSession(firstLink); + final second = DeviceSession(secondLink); + await expectLater( + first.connect( + 'peripheral', + expectedIdentity: 'ffffffffffffffffffffffffffffffff', + ), + throwsStateError, + ); + expect(device.activePhone, isNull); + await second.connect('peripheral'); + expect(second.snapshot.phase, DeviceSessionPhase.readyPaused); + expect(device.activePhone, 'phone-b'); + await second.disconnect(); + await first.dispose(); + await second.dispose(); + await firstLink.dispose(); + await secondLink.dispose(); + }, + ); +} diff --git a/flutter_app/test/udp_integration_test.dart b/flutter_app/test/udp_integration_test.dart new file mode 100644 index 0000000..d82ab6c --- /dev/null +++ b/flutter_app/test/udp_integration_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/legacy_udp_engine.dart'; +import 'package:satori_manager/core/protocol.dart'; +import 'package:satori_manager/infrastructure/udp_transport.dart'; + +void main() { + test('real UDP discovery, response, control and disconnect', () async { + final server = await RawDatagramSocket.bind( + InternetAddress.loopbackIPv4, + 8888, + ); + final messages = []; + final received = Completer(); + final subscription = server.listen((event) { + if (event != RawSocketEvent.read) return; + Datagram? packet; + while ((packet = server.receive()) != null) { + final message = utf8.decode(packet!.data); + messages.add(message); + if (message == Protocol.discovery) { + server.send( + utf8.encode('SatoriEye_DISCOVERY_RESPONSE,77,SIMULATOR'), + packet.address, + packet.port, + ); + } + if (message == Protocol.disconnect && !received.isCompleted) { + received.complete(); + } + } + }); + final engine = ControlEngine(UdpTransport(), {}); + try { + await engine.open(); + engine.discover(host: '127.0.0.1'); + await engine.updates + .firstWhere((snapshot) => (snapshot['discovered'] as List).isNotEmpty) + .timeout(const Duration(seconds: 2)); + engine.select( + const Endpoint('127.0.0.1', 8888), + isSimulator: true, + profile: SafetyProfile.simulator(), + ); + engine.setManual([.5, .5, .5]); + engine.setManual([.2, .7, -1]); + engine.setManual([.5, .5, -1]); + engine.disconnect(); + await received.future.timeout(const Duration(seconds: 2)); + expect(messages, contains('CH1:1500CH2:1500CH3:1500')); + expect(messages, contains('CH1:900CH2:1900CH3:1500')); + expect(messages.first, Protocol.discovery); + expect(messages.last, Protocol.disconnect); + } finally { + engine.dispose(); + await subscription.cancel(); + server.close(); + } + }); +} diff --git a/flutter_app/tool/ble_probe.dart b/flutter_app/tool/ble_probe.dart new file mode 100644 index 0000000..2c0a9bb --- /dev/null +++ b/flutter_app/tool/ble_probe.dart @@ -0,0 +1,120 @@ +// CLI output is intentionally direct and contains no session tokens/passkeys. +// ignore_for_file: avoid_print + +import 'dart:async'; + +import 'package:satori_manager/core/device_session.dart'; +import 'package:satori_manager/infrastructure/bluez_ble_link.dart'; + +Future main(List args) async { + final options = _Options.parse(args); + if (options.showHelp) return; + final link = BluezBleLink(); + final session = DeviceSession(link); + try { + if (options.deviceId == null) { + print('Scanning for SatoriEye BLE peripherals for 8 seconds…'); + await for (final device in link.scan()) { + print( + '${device.name.isEmpty ? '(unnamed)' : device.name} RSSI ${device.rssi} ${device.id}', + ); + } + print( + 'No control connection opened. Re-run with --device
to connect.', + ); + return; + } + + await session.connect(options.deviceId!); + final info = session.snapshot.deviceInfo!; + final state = session.snapshot.state!; + print( + 'Connected and claimed; protocol ${info.protocolMajor}.${info.protocolMinor}, ' + 'firmware ${info.firmwareVersion}.', + ); + print( + 'State code: ${state.controlState}; output known: ${state.targetKnown}; ' + 'battery: ${state.batteryPercent?.toString() ?? 'unknown'}.', + ); + + if (options.arm) { + await session.arm(); + print( + 'ARM accepted; known logical output: ${session.snapshot.state?.channels}.', + ); + } + if (options.target case final target?) { + if (!options.arm) { + throw FormatException('--target requires explicit --arm'); + } + await session.setTarget( + ch1: target[0], + ch2: target[1], + ch3: target[2], + transitionMs: 0, + ); + print('SET_TARGET accepted for the three requested logical channels.'); + await session.halt(); + print('HALT acknowledged.'); + } + } finally { + await session.disconnect(); + await session.dispose(); + await link.dispose(); + } +} + +class _Options { + const _Options({ + this.deviceId, + this.arm = false, + this.target, + this.showHelp = false, + }); + final String? deviceId; + final bool arm; + final List? target; + final bool showHelp; + + static _Options parse(List args) { + String? deviceId; + var arm = false; + List? target; + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--device' && i + 1 < args.length) { + deviceId = args[++i]; + } else if (arg == '--arm') { + arm = true; + } else if (arg.startsWith('--target=')) { + final values = arg.substring('--target='.length).split(','); + if (values.length != 3) { + throw const FormatException('Target must be CH1,CH2,CH3'); + } + target = values + .map((value) { + final parsed = int.tryParse(value); + if (parsed == null || parsed < 500 || parsed > 2500) { + throw const FormatException('Each target must be 500–2500'); + } + return parsed; + }) + .toList(growable: false); + } else if (arg == '--help' || arg == '-h') { + print( + 'Usage: dart run tool/ble_probe.dart [--device
] [--arm] [--target=CH1,CH2,CH3]', + ); + print( + 'Without --device the tool scans only. Connection never arms unless --arm is explicit.', + ); + return const _Options(showHelp: true); + } else { + throw FormatException('Unknown or incomplete option: $arg'); + } + } + if (target != null && !arm) { + throw const FormatException('--target requires --arm'); + } + return _Options(deviceId: deviceId, arm: arm, target: target); + } +} diff --git a/flutter_app/tool/ble_simulator.dart b/flutter_app/tool/ble_simulator.dart new file mode 100644 index 0000000..4cb5f3c --- /dev/null +++ b/flutter_app/tool/ble_simulator.dart @@ -0,0 +1,58 @@ +// ignore_for_file: avoid_print +import 'dart:io'; +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'; + +/// Synthetic, in-memory peer. This program never opens a Bluetooth adapter. +Future main(List args) async { + if (args.contains('--help')) { + print( + 'Dart BLE simulation only (no radio/PWM): --drop-first-ack --unconfigured', + ); + return; + } + if (args.any( + (arg) => !['--drop-first-ack', '--unconfigured'].contains(arg), + )) { + throw const FormatException('Unknown option; use --help'); + } + final unconfigured = args.contains('--unconfigured'); + final link = FakeBleLink( + configured: !unconfigured, + dropNextReplies: args.contains('--drop-first-ack') ? 1 : 0, + ); + final session = DeviceSession(link); + print('SIMULATOR — synthetic software state only; no BLE hardware or PWM.'); + try { + await session.connect('synthetic'); + print('CLAIM confirmed; connected and paused; output values unknown.'); + try { + await session.arm(); + } on BleCommandRejected catch (e) { + if (unconfigured && e.result == BleResult.notConfigured) { + print( + 'ARM correctly rejected: NOT_CONFIGURED. Output remains disabled.', + ); + return; + } + rethrow; + } + print( + 'ARM confirmed; synthetic startup ${session.snapshot.state!.channels}.', + ); + await session.setTarget(ch1: 1510, ch2: 1490, ch3: 1500, latestOnly: false); + print('Synthetic target accepted (not physical arrival).'); + await session.halt(); + print('HALT confirmed; pending movement cancelled.'); + await session.release(); + print('RELEASE confirmed; disconnected. Writes: ${link.writes.length}.'); + } catch (e) { + stderr.writeln('Simulation failed: $e'); + exitCode = 1; + } finally { + await session.disconnect(); + await session.dispose(); + await link.dispose(); + } +} diff --git a/flutter_app/tool/check_ble_contract.dart b/flutter_app/tool/check_ble_contract.dart new file mode 100644 index 0000000..a79e229 --- /dev/null +++ b/flutter_app/tool/check_ble_contract.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; + +void main() { + final directory = Directory('../docs/protocol'); + final manifest = + jsonDecode( + File('${directory.path}/compatibility.json').readAsStringSync(), + ) + as Map; + for (final entry in (manifest['files'] as Map).entries) { + final bytes = File('${directory.path}/${entry.key}').readAsBytesSync(); + if (sha256.convert(bytes).toString() != entry.value) { + throw StateError('BLE contract drift: ${entry.key}'); + } + final sourceName = entry.key == 'satori-ble-v1.md' + ? 'SatoriEye_BLE_Protocol_v1.md' + : entry.key; + final source = File('../../ESP-3RDEYE/docs/ble/v1/$sourceName'); + if (source.existsSync() && + sha256.convert(source.readAsBytesSync()).toString() != entry.value) { + throw StateError('Firmware/App contract mismatch: ${entry.key}'); + } + } + print('BLE document and vectors match compatibility.json.'); +} diff --git a/flutter_app/tool/package_ble_delivery.py b/flutter_app/tool/package_ble_delivery.py new file mode 100644 index 0000000..2c1bc5d --- /dev/null +++ b/flutter_app/tool/package_ble_delivery.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Bundle only reviewed public artifacts; never include NVS/config/USB logs.""" +import hashlib +import json +from pathlib import Path +import subprocess +import zipfile + +APP = Path(__file__).resolve().parents[1] +REPO = APP.parent +FIRMWARE = REPO.parent / "ESP-3RDEYE" +OUTPUT = APP / "build" / "satori-ble-a0-a1.zip" + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def head(repo): + return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip() + + +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 main(): + files = { + "android/app-arm64-v8a-release.apk": APP / "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk", + "docs/BLE_A0_A1_REPORT.md": REPO / "docs/BLE_A0_A1_REPORT.md", + "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_A0_A1_REPORT.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}") + + +if __name__ == "__main__": + main() diff --git a/flutter_app/tool/render_previews_test.dart b/flutter_app/tool/render_previews_test.dart new file mode 100644 index 0000000..370b539 --- /dev/null +++ b/flutter_app/tool/render_previews_test.dart @@ -0,0 +1,122 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/main.dart'; +import 'package:satori_manager/runtime/control_client.dart'; + +void main() { + late final Future fontReady = () async { + final bytes = await File( + '/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc', + ).readAsBytes(); + await (FontLoader( + 'Preview CJK', + )..addFont(Future.value(ByteData.sublistView(bytes)))).load(); + final iconBytes = await rootBundle.load('fonts/MaterialIcons-Regular.otf'); + await (FontLoader( + 'MaterialIcons', + )..addFont(Future.value(iconBytes))).load(); + }(); + final base = { + 'runtimeId': 'preview', + 'revision': 1, + 'connection': 'disconnected', + 'mode': 'manual', + 'playback': 'idle', + 'autoRotate': false, + 'autoWink': false, + 'battery': null, + 'batteryAt': null, + 'endpoint': null, + 'discovered': >[], + 'simulators': >[], + 'target': null, + 'outputAuthorized': false, + 'simulator': false, + 'error': null, + }; + final connected = { + ...base, + 'connection': 'connected', + 'outputAuthorized': true, + 'target': [1500, 1500, 1500], + 'deviceId': 'synthetic-preview', + 'simulator': true, + 'endpoint': {'host': '192.168.1.20', 'port': 8888}, + 'battery': 77, + 'batteryAt': DateTime.now().toUtc().toIso8601String(), + 'discovered': [ + {'host': '192.168.1.20', 'port': 8888}, + ], + 'simulators': [ + {'host': '192.168.1.20', 'port': 8888}, + ], + }; + final screens = <(String, Map, int)>[ + ('disconnected', base, 0), + ('device_setup', base, 2), + ( + 'automatic', + {...connected, 'autoRotate': true, 'autoWink': true, 'mode': 'auto'}, + 0, + ), + ('manual', connected, 0), + ('actions', {...connected, 'playback': 'playing'}, 1), + ('device', connected, 2), + ]; + + for (final screen in screens) { + testWidgets('render ${screen.$1}', (tester) async { + await tester.runAsync(() async => fontReady); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final key = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: key, + child: SatoriApp( + previewClient: ControlClient.preview(screen.$2), + previewFontFamily: 'Preview CJK', + ), + ), + ); + await tester.pumpAndSettle(); + if (screen.$3 == 1) { + await tester.tap(find.byIcon(Icons.auto_awesome_outlined)); + await tester.pumpAndSettle(); + } + if (screen.$3 == 2) { + await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.pumpAndSettle(); + } + await expectLater( + find.byKey(key), + matchesGoldenFile('../build/design_preview/${screen.$1}.png'), + ); + }); + } + + testWidgets('narrow control and setup pages fit without overflow', ( + tester, + ) async { + await tester.runAsync(() async => fontReady); + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + SatoriApp( + previewClient: ControlClient.preview(connected), + previewFontFamily: 'Preview CJK', + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); +} diff --git a/flutter_app/tool/satori_simulator.dart b/flutter_app/tool/satori_simulator.dart new file mode 100644 index 0000000..a63f315 --- /dev/null +++ b/flutter_app/tool/satori_simulator.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:satori_manager/core/protocol.dart'; + +Future main(List args) async { + final bind = _option(args, '--bind') ?? '0.0.0.0'; + final dropEvery = int.parse(_option(args, '--drop-every') ?? '0'); + final silentAfter = int.tryParse(_option(args, '--silent-after') ?? ''); + final malformed = args.contains('--malformed'); + final battery = int.parse(_option(args, '--battery') ?? '77'); + final socket = await RawDatagramSocket.bind(InternetAddress(bind), 8888); + socket.broadcastEnabled = true; + stderr.writeln( + 'SIMULATOR bind=$bind:8888 dropEvery=$dropEvery silentAfter=$silentAfter malformed=$malformed', + ); + var count = 0; + socket.listen((event) { + if (event != RawSocketEvent.read) return; + Datagram? packet; + while ((packet = socket.receive()) != null) { + count++; + final source = packet!.address; + final port = packet.port; + final text = utf8.decode(packet.data, allowMalformed: true); + stdout.writeln( + '${DateTime.now().toUtc().toIso8601String()} ${source.address}:$port raw=${packet.data} text=$text', + ); + if ((dropEvery > 0 && count % dropEvery == 0) || + (silentAfter != null && count > silentAfter)) { + continue; + } + final response = switch (text) { + Protocol.discovery => + malformed + ? 'INVALID_DISCOVERY' + : 'SatoriEye_DISCOVERY_RESPONSE,$battery,SIMULATOR', + Protocol.heartbeat => + malformed + ? 'INVALID_HEARTBEAT' + : 'SatoriEye_HEARTBEAT_RESPONSE,$battery', + _ => null, + }; + if (response != null) { + socket.send(utf8.encode(response), source, port); + stdout.writeln( + '${DateTime.now().toUtc().toIso8601String()} reply=$response', + ); + } + } + }); +} + +String? _option(List args, String name) { + final index = args.indexOf(name); + return index >= 0 && index + 1 < args.length ? args[index + 1] : null; +}