From f2ee4a7ab6d0b6f93869af39ceff6895aef59993 Mon Sep 17 00:00:00 2001 From: AkazaAkali <478080766@qq.com> Date: Wed, 30 Sep 2026 09:35:50 +0800 Subject: [PATCH 1/3] Add Flutter BLE client and controls --- .github/workflows/flutter-stage-a.yml | 28 + README.md | 2 + docs/protocol/compatibility.json | 10 + docs/protocol/satori-ble-v1.md | 313 +++++ .../satori_ble_v1_1_management_vectors.json | 102 ++ ...atori_ble_v1_2_shared_pairing_vectors.json | 73 + .../satori_ble_v1_golden_vectors.json | 624 +++++++++ flutter_app/.gitignore | 45 + flutter_app/.metadata | 30 + flutter_app/BLE_LINUX.md | 46 + flutter_app/README.md | 19 + flutter_app/analysis_options.yaml | 28 + flutter_app/android/.gitignore | 14 + flutter_app/android/app/build.gradle.kts | 52 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 24 + .../example/satori_manager/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + flutter_app/android/build.gradle.kts | 24 + flutter_app/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + flutter_app/android/settings.gradle.kts | 26 + flutter_app/assets/actions/presets.json | 12 + flutter_app/docs/BLE_IMPLEMENTATION.md | 100 ++ flutter_app/lib/core/ble_protocol.dart | 492 +++++++ flutter_app/lib/core/control_engine.dart | 692 ++++++++++ flutter_app/lib/core/control_status.dart | 38 + flutter_app/lib/core/device_session.dart | 788 +++++++++++ flutter_app/lib/core/legacy_udp_engine.dart | 340 +++++ flutter_app/lib/core/protocol.dart | 151 +++ flutter_app/lib/core/safety_limits.dart | 39 + .../lib/infrastructure/ble_discovery.dart | 13 + .../lib/infrastructure/bluez_ble_link.dart | 182 +++ .../lib/infrastructure/fake_ble_link.dart | 498 +++++++ .../lib/infrastructure/reactive_ble_link.dart | 238 ++++ .../lib/infrastructure/udp_transport.dart | 60 + flutter_app/lib/joystick_pad.dart | 105 ++ flutter_app/lib/main.dart | 1172 +++++++++++++++++ .../lib/runtime/client_generation_guard.dart | 40 + flutter_app/lib/runtime/control_client.dart | 215 +++ flutter_app/lib/runtime/control_task.dart | 299 +++++ flutter_app/pubspec.lock | 538 ++++++++ flutter_app/pubspec.yaml | 24 + flutter_app/test/ble_engine_test.dart | 382 ++++++ flutter_app/test/ble_protocol_test.dart | 375 ++++++ flutter_app/test/ble_ui_test.dart | 90 ++ .../test/client_generation_guard_test.dart | 121 ++ flutter_app/test/control_status_test.dart | 35 + flutter_app/test/device_session_test.dart | 1148 ++++++++++++++++ flutter_app/test/engine_test.dart | 255 ++++ flutter_app/test/eyelid_test.dart | 56 + flutter_app/test/joystick_test.dart | 53 + flutter_app/test/pairing_engine_test.dart | 144 ++ flutter_app/test/pairing_ui_test.dart | 128 ++ flutter_app/test/protocol_test.dart | 92 ++ flutter_app/test/shared_pairing_test.dart | 142 ++ flutter_app/test/udp_integration_test.dart | 62 + flutter_app/tool/ble_probe.dart | 120 ++ flutter_app/tool/ble_simulator.dart | 58 + flutter_app/tool/check_ble_contract.dart | 28 + flutter_app/tool/package_ble_delivery.py | 98 ++ flutter_app/tool/render_previews_test.dart | 122 ++ flutter_app/tool/satori_simulator.dart | 56 + 72 files changed, 11151 insertions(+) create mode 100644 .github/workflows/flutter-stage-a.yml create mode 100644 docs/protocol/compatibility.json create mode 100644 docs/protocol/satori-ble-v1.md create mode 100644 docs/protocol/satori_ble_v1_1_management_vectors.json create mode 100644 docs/protocol/satori_ble_v1_2_shared_pairing_vectors.json create mode 100644 docs/protocol/satori_ble_v1_golden_vectors.json create mode 100644 flutter_app/.gitignore create mode 100644 flutter_app/.metadata create mode 100644 flutter_app/BLE_LINUX.md create mode 100644 flutter_app/README.md create mode 100644 flutter_app/analysis_options.yaml create mode 100644 flutter_app/android/.gitignore create mode 100644 flutter_app/android/app/build.gradle.kts create mode 100644 flutter_app/android/app/src/debug/AndroidManifest.xml create mode 100644 flutter_app/android/app/src/main/AndroidManifest.xml create mode 100644 flutter_app/android/app/src/main/kotlin/com/example/satori_manager/MainActivity.kt create mode 100644 flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 flutter_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/values-night/styles.xml create mode 100644 flutter_app/android/app/src/main/res/values/styles.xml create mode 100644 flutter_app/android/app/src/profile/AndroidManifest.xml create mode 100644 flutter_app/android/build.gradle.kts create mode 100644 flutter_app/android/gradle.properties create mode 100644 flutter_app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 flutter_app/android/settings.gradle.kts create mode 100644 flutter_app/assets/actions/presets.json create mode 100644 flutter_app/docs/BLE_IMPLEMENTATION.md create mode 100644 flutter_app/lib/core/ble_protocol.dart create mode 100644 flutter_app/lib/core/control_engine.dart create mode 100644 flutter_app/lib/core/control_status.dart create mode 100644 flutter_app/lib/core/device_session.dart create mode 100644 flutter_app/lib/core/legacy_udp_engine.dart create mode 100644 flutter_app/lib/core/protocol.dart create mode 100644 flutter_app/lib/core/safety_limits.dart create mode 100644 flutter_app/lib/infrastructure/ble_discovery.dart create mode 100644 flutter_app/lib/infrastructure/bluez_ble_link.dart create mode 100644 flutter_app/lib/infrastructure/fake_ble_link.dart create mode 100644 flutter_app/lib/infrastructure/reactive_ble_link.dart create mode 100644 flutter_app/lib/infrastructure/udp_transport.dart create mode 100644 flutter_app/lib/joystick_pad.dart create mode 100644 flutter_app/lib/main.dart create mode 100644 flutter_app/lib/runtime/client_generation_guard.dart create mode 100644 flutter_app/lib/runtime/control_client.dart create mode 100644 flutter_app/lib/runtime/control_task.dart create mode 100644 flutter_app/pubspec.lock create mode 100644 flutter_app/pubspec.yaml create mode 100644 flutter_app/test/ble_engine_test.dart create mode 100644 flutter_app/test/ble_protocol_test.dart create mode 100644 flutter_app/test/ble_ui_test.dart create mode 100644 flutter_app/test/client_generation_guard_test.dart create mode 100644 flutter_app/test/control_status_test.dart create mode 100644 flutter_app/test/device_session_test.dart create mode 100644 flutter_app/test/engine_test.dart create mode 100644 flutter_app/test/eyelid_test.dart create mode 100644 flutter_app/test/joystick_test.dart create mode 100644 flutter_app/test/pairing_engine_test.dart create mode 100644 flutter_app/test/pairing_ui_test.dart create mode 100644 flutter_app/test/protocol_test.dart create mode 100644 flutter_app/test/shared_pairing_test.dart create mode 100644 flutter_app/test/udp_integration_test.dart create mode 100644 flutter_app/tool/ble_probe.dart create mode 100644 flutter_app/tool/ble_simulator.dart create mode 100644 flutter_app/tool/check_ble_contract.dart create mode 100644 flutter_app/tool/package_ble_delivery.py create mode 100644 flutter_app/tool/render_previews_test.dart create mode 100644 flutter_app/tool/satori_simulator.dart 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/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..f9d933e --- /dev/null +++ b/flutter_app/BLE_LINUX.md @@ -0,0 +1,46 @@ +# 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 +``` + +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/README.md b/flutter_app/README.md new file mode 100644 index 0000000..fd6bc69 --- /dev/null +++ b/flutter_app/README.md @@ -0,0 +1,19 @@ +# 觉瞳 Flutter BLE 技术验证版 + +当前主入口已迁移至 BLE:多手机轮流直连、连接自动启用输出、三通道控制、自动转动/眨眼、预设和暂停。Android 前台服务独占蓝牙会话与动作引擎,页面恢复只同步状态。固件配套实现位于隔壁 ESP-3RDEYE;Qt 不参与此路径。 + +- [BLE 实现、Qt 功能对照与验收](docs/BLE_IMPLEMENTATION.md) +- [本机 Linux 蓝牙调试](BLE_LINUX.md) +- [共同字节协议](../docs/protocol/satori-ble-v1.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 使用开发签名,正式发布签名尚未配置。 + +## 开发 + +在 `flutter_app/` 运行 `flutter pub get`、`flutter analyze`、`flutter test`。Android 调试构建使用 `flutter build apk --debug`;按架构构建安装包使用 `flutter build apk --release --split-per-abi`。保留的 UDP 编解码、模拟器和回归工具仅用于协议测试。 + +当前安装包使用开发签名。机械运动、通知操作和长时间后台行为需要在目标设备上单独验收。 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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..3ff07ab --- /dev/null +++ b/flutter_app/docs/BLE_IMPLEMENTATION.md @@ -0,0 +1,100 @@ +# 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 + +Run `dart run tool/check_ble_contract.dart`, `flutter analyze`, and `flutter test` from `flutter_app/` with the pinned Flutter SDK. Build Android with `flutter build apk --debug` or `flutter build apk --release --split-per-abi`. + +## Pairing and controls + +Firmware protocol 1.2 supports up to eight stored phone bonds and one live connection. An authenticated phone may change the shared pairing code; existing bonds remain valid. New devices initialize with code 123456. Connections authorize output after authenticated CLAIM and ARM; a user pause revokes automatic startup for the current reconnect round. + +Manual joystick and eyelid input use a 50 ms transition and a latest-target slot capped by the device's 20 Hz limit. Preset and automatic actions keep 200 ms transitions. The notification and UI read structured control state to distinguish pausing, paused, unconfirmed pause, and configuration errors. Raw exceptions and logical channel targets are shown only in the collapsed diagnostics section. + +Release APKs currently use a development signing key. Physical position is not measured; a business ACK confirms command acceptance, not mechanical arrival. Device-specific and Android lifecycle checks must be performed before production use. 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..c8e58f0 --- /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.0.2.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.0.2.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.0.2.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..4ad3663 --- /dev/null +++ b/flutter_app/tool/package_ble_delivery.py @@ -0,0 +1,98 @@ +#!/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_IMPLEMENTATION.md": APP / "docs/BLE_IMPLEMENTATION.md", + "docs/BLE_LINUX.md": APP / "BLE_LINUX.md", + "docs/FIRMWARE_SETUP.md": FIRMWARE / "docs/ble/v1/FIRMWARE_SETUP.md", + "protocol/SatoriEye_BLE_Protocol_v1.md": FIRMWARE / "docs/ble/v1/SatoriEye_BLE_Protocol_v1.md", + "protocol/satori_ble_v1_2_shared_pairing_vectors.json": FIRMWARE / "docs/ble/v1/satori_ble_v1_2_shared_pairing_vectors.json", + "protocol/satori_ble_v1_1_management_vectors.json": FIRMWARE / "docs/ble/v1/satori_ble_v1_1_management_vectors.json", + "protocol/satori_ble_v1_golden_vectors.json": FIRMWARE / "docs/ble/v1/satori_ble_v1_golden_vectors.json", + } + # Explicit whitelist: no merged flash, NVS, board configuration, sdkconfig, + # credentials, pairing cards, USB captures or developer build logs. + for profile in ("ble_primary", "legacy_udp"): + for relative in ("app.bin", "bootloader/bootloader.bin", "partition_table/partition-table.bin", "flasher_args.json"): + files[f"firmware/{profile}/{relative}"] = FIRMWARE / "build" / profile / relative + missing = [str(path) for path in files.values() if not path.is_file()] + if missing: + raise SystemExit("Missing build artifacts: " + ", ".join(missing)) + manifest = { + "release": "SatoriEye BLE A0/A1 software candidate", + "app_version": next(line.split(":", 1)[1].strip() for line in (APP / "pubspec.yaml").read_text().splitlines() if line.startswith("version:")) + " release", + "android_abi": "arm64-v8a", + "android_signing": "development key (not a production signing identity)", + "app_base_head": head(REPO), + "firmware_base_head": head(FIRMWARE), + "source_state": "Uncommitted working-tree implementation; HEAD alone does not identify this build", + "hardware_verified": False, + "source_fingerprints": { + "app": source_fingerprint(APP, ("lib", "assets", "android/app/src"), ("pubspec.yaml", "pubspec.lock", "android/app/build.gradle.kts")), + "firmware": source_fingerprint(FIRMWARE, ("main",), ("CMakeLists.txt", "main/Kconfig.projbuild", "dependencies.lock", "sdkconfig.defaults", "sdkconfig.ble_primary.defaults", "sdkconfig.legacy_udp.defaults", "partitions.csv", "build/ble_primary/sdkconfig", "build/legacy_udp/sdkconfig")), + }, + "files": {}, + } + with zipfile.ZipFile(OUTPUT, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=3) as archive: + for name, path in files.items(): + content = path.read_bytes() + archive.writestr(name, content) + manifest["files"][name] = {"bytes": len(content), "sha256": digest(content)} + archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + archive.writestr("README.txt", """SatoriEye BLE A0/A1 development build +Read docs/BLE_IMPLEMENTATION.md and docs/FIRMWARE_SETUP.md before use. +Android APK is an arm64-v8a release build with a development signing key. +Hardware pairing, movement and background tests are pending. +BLE and legacy_udp are mutually exclusive firmware profiles. Do not combine them. +Firmware directories contain app/bootloader/partition table plus flash offset metadata. +No NVS, credentials, identity, pairing card or board configuration is included. +Do not overwrite existing NVS or calibration during upgrade. Back up the device first. +Built-in product defaults enable output after an authenticated App connection. +Existing maintenance overrides and mechanical calibration remain in effect. +Built-in values are not a claim of completed hardware validation. +Code changes and runnable simulators are in the two source workspaces, not this artifact archive. +The documentation's source-relative links refer to those workspaces. +See manifest.json for every included artifact's size and SHA-256. +""") + sidecar = OUTPUT.with_suffix(".manifest.json") + sidecar.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + print(f"Bundle: {OUTPUT}") + print(f"Bytes: {OUTPUT.stat().st_size}") + archive_hash = digest(OUTPUT.read_bytes()) + OUTPUT.with_suffix(".zip.sha256").write_text(archive_hash + " " + OUTPUT.name + "\n") + print(f"SHA-256: {archive_hash}") + + +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..a4e4196 --- /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.0.2.20', 'port': 8888}, + 'battery': 77, + 'batteryAt': DateTime.now().toUtc().toIso8601String(), + 'discovered': [ + {'host': '192.0.2.20', 'port': 8888}, + ], + 'simulators': [ + {'host': '192.0.2.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; +} From b6fd7b8d60964940e57311ef2b78c38704117a2f Mon Sep 17 00:00:00 2001 From: AkazaAkali <478080766@qq.com> Date: Wed, 30 Sep 2026 09:48:56 +0800 Subject: [PATCH 2/3] Add refreshed Flutter UI with system light and dark themes --- design-qa.md | 14 + flutter_app/docs/ui_previews/actions.png | Bin 0 -> 42489 bytes flutter_app/docs/ui_previews/dark_actions.png | Bin 0 -> 44563 bytes flutter_app/docs/ui_previews/dark_device.png | Bin 0 -> 51849 bytes flutter_app/docs/ui_previews/dark_manual.png | Bin 0 -> 54580 bytes flutter_app/docs/ui_previews/device.png | Bin 0 -> 48666 bytes flutter_app/docs/ui_previews/manual.png | Bin 0 -> 50843 bytes flutter_app/lib/joystick_pad.dart | 31 +- flutter_app/lib/main.dart | 1489 +++++++++-------- flutter_app/lib/satori_palette.dart | 101 ++ flutter_app/test/ble_ui_test.dart | 77 +- flutter_app/test/pairing_ui_test.dart | 25 +- flutter_app/tool/render_previews_test.dart | 100 +- 13 files changed, 978 insertions(+), 859 deletions(-) create mode 100644 design-qa.md create mode 100644 flutter_app/docs/ui_previews/actions.png create mode 100644 flutter_app/docs/ui_previews/dark_actions.png create mode 100644 flutter_app/docs/ui_previews/dark_device.png create mode 100644 flutter_app/docs/ui_previews/dark_manual.png create mode 100644 flutter_app/docs/ui_previews/device.png create mode 100644 flutter_app/docs/ui_previews/manual.png create mode 100644 flutter_app/lib/satori_palette.dart diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..df06fb8 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,14 @@ +# SatoriManager 移动端设计验收 + +final result: passed + +- 目标:选定的「柔光仪表」视觉方向,390 × 844 Android 手机尺寸。对照先前确认的控制、动作、设置视觉稿检查。BLE 文案和连接状态按现有实现更新。 +- 浅色与深色:跟随系统亮度切换。两套配色均保留柔和紫色层级;检查背景、文字、摇杆、状态、电量、滑杆、按钮、导航和故障提示。 +- 控制:圆形摇杆、自动/手动切换、松手回中、眼皮滑杆(拖动时发送、松手补发最终值)和眨眼入口均已呈现;没有眼睛图示、摇杆电量环、指令预览、目标数值或发送眼皮按钮。 +- 动作:仅显示轻眨一下与连眨两下两种预设;播放中可取消,持续行为留在控制页。 +- 设置:单设备 BLE 自动发现与连接;没有设备选择列表、可编辑安全范围、Wi-Fi 文案或未实现功能入口。保留配对管理与故障诊断。 +- 截图:`flutter_app/docs/ui_previews/` 收录控制、动作、设置的浅色与深色预览;`flutter_app/build/design_preview/` 下的浅色 `manual.png`、`automatic.png`、`disconnected.png`、`actions.png`、`device.png`、`device_setup.png`,及对应 `dark_` 前缀截图。逐张检查 390 × 844 预览;另检查 320 × 700 布局不溢出。 +- 验证:`flutter analyze` 无问题;Flutter 测试通过;Android debug APK 构建成功。 +- 实机 BLE 连接与机械动作尚未在此环境验证;截图使用预览状态。 + +P3 后续:动作页的行高比视觉稿略紧;设置页在首屏保留了固件相关配对操作,便于现有 BLE 功能继续使用。 diff --git a/flutter_app/docs/ui_previews/actions.png b/flutter_app/docs/ui_previews/actions.png new file mode 100644 index 0000000000000000000000000000000000000000..b84c5c5fa707c68c1b830116d354319e0f51bd7e GIT binary patch literal 42489 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

*~d-eOTQNeB8{se$OjGt6NAZo40dpE14S2 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 literal 0 HcmV?d00001 diff --git a/flutter_app/docs/ui_previews/dark_device.png b/flutter_app/docs/ui_previews/dark_device.png new file mode 100644 index 0000000000000000000000000000000000000000..23c251c7e8cfc6d98b42d8ef9c124bcb00457546 GIT binary patch literal 51849 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

Xe6FMhps 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 Gfd2uY$oH_a4yqcNc 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 XaSfpElM2K^z#j#9Wx0GA!!Q2}3tsF~ literal 0 HcmV?d00001 diff --git a/flutter_app/docs/ui_previews/manual.png b/flutter_app/docs/ui_previews/manual.png new file mode 100644 index 0000000000000000000000000000000000000000..010bfa4e8bb06cc76a7f734a2e89b71a899ede77 GIT binary patch literal 50843 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 LayoutBuilder( builder: (context, constraints) { + final palette = SatoriPalette.of(context); final diameter = math.min(constraints.maxWidth, 232.0); final size = Size.square(diameter); void update(Offset local) { @@ -53,22 +55,22 @@ class JoystickPad extends StatelessWidget { Container( decoration: BoxDecoration( shape: BoxShape.circle, - color: const Color(0xFF242E48), + color: palette.joystickSurface, border: Border.all( - color: const Color(0xFF5B6086), - width: 2, + color: palette.joystickBorder, + width: 1.5, ), ), ), Container( width: 1, - height: diameter - 28, - color: const Color(0xFF454F6B), + height: diameter - 12, + color: palette.joystickLine, ), Container( - width: diameter - 28, + width: diameter - 12, height: 1, - color: const Color(0xFF454F6B), + color: palette.joystickLine, ), Positioned( left: (diameter - 48) * x.clamp(0.0, 1.0), @@ -79,18 +81,19 @@ class JoystickPad extends StatelessWidget { height: 48, decoration: BoxDecoration( shape: BoxShape.circle, - color: const Color(0xFFB6A2FF), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [palette.knobStart, palette.knobEnd], + ), boxShadow: [ BoxShadow( - color: const Color(0x66B6A2FF), - blurRadius: 18, + color: palette.knobShadow, + blurRadius: 15, + offset: Offset(0, 6), ), ], ), - child: const Icon( - Icons.control_camera_rounded, - color: Color(0xFF1B1730), - ), ), ), ), diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 576f150..20d8484 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -1,19 +1,13 @@ +import 'dart:async'; 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); +import 'satori_palette.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -41,51 +35,57 @@ 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, - ), + + ThemeData _theme(SatoriPalette p, Brightness brightness) => ThemeData( + useMaterial3: true, + brightness: brightness, + fontFamily: previewFontFamily, + scaffoldBackgroundColor: p.background, + colorScheme: ColorScheme.fromSeed( + seedColor: p.button, + brightness: brightness, + surface: p.background, + primary: p.button, + onSurface: p.ink, + ), + textTheme: + (brightness == Brightness.dark + ? ThemeData.dark().textTheme + : ThemeData.light().textTheme) + .apply( + bodyColor: p.ink, + displayColor: p.ink, + fontFamily: previewFontFamily, + ), + appBarTheme: AppBarTheme( + backgroundColor: p.background, + surfaceTintColor: Colors.transparent, + ), + sliderTheme: SliderThemeData( + trackHeight: 5, + activeTrackColor: p.purple, + inactiveTrackColor: p.line, + thumbColor: p.sliderThumb, + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => + states.contains(WidgetState.selected) ? Colors.white : p.muted, ), - navigationBarTheme: const NavigationBarThemeData( - backgroundColor: _surface, - indicatorColor: Color(0xFF393251), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) ? p.button : p.line, ), + trackOutlineColor: const WidgetStatePropertyAll(Colors.transparent), ), + ); + + @override + Widget build(BuildContext context) => MaterialApp( + title: '觉之瞳', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.system, + theme: _theme(SatoriPalette.light, Brightness.light), + darkTheme: _theme(SatoriPalette.dark, Brightness.dark), home: ControlShell(previewClient: previewClient), ); } @@ -99,27 +99,19 @@ class ControlShell extends StatefulWidget { class _ControlShellState extends State with WidgetsBindingObserver { - late final client = widget.previewClient ?? ControlClient(); - final minFields = List.generate(3, (_) => TextEditingController()); - final maxFields = List.generate(3, (_) => TextEditingController()); + late final ControlClient client = widget.previewClient ?? ControlClient(); + SatoriPalette get p => SatoriPalette.of(context); final values = [0.5, 0.5, 0.5]; int tab = 0; bool busy = false; bool resetStickOnRelease = true; - DateTime? _lastJoystickSend; - DateTime? _lastEyelidSend; + bool? autoView; + bool _automaticConnection = true; + String? _selectionPending; String? _joystickEndpoint; String? _localError; - - void _showError(Object error) { - _localError = error.toString(); - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('操作未完成,请查看设备诊断信息'))); - setState(() {}); - } - } + DateTime? _lastJoystickSend; + DateTime? _lastEyelidSend; @override void initState() { @@ -127,13 +119,33 @@ class _ControlShellState extends State WidgetsBinding.instance.addObserver(this); client.addListener(_refresh); _syncJoystickTarget(); + if (!client.preview) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && client.state['connection'] == 'disconnected') { + _discover(); + } + }); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) client.restore(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + client.removeListener(_refresh); + client.dispose(); + super.dispose(); } void _refresh() { - if (mounted) { - _syncJoystickTarget(); - setState(() {}); - } + if (!mounted) return; + _syncJoystickTarget(); + setState(() {}); + _tryAutoSelect(); } void _syncJoystickTarget() { @@ -143,33 +155,27 @@ class _ControlShellState extends State _joystickEndpoint = null; return; } - final endpoint = state['deviceId'].toString(); + 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; + values[i] = (((target[i] as num) - 500) / 2000).clamp(0.0, 1.0); } } } } - @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(); + void _showError(Object error) { + _localError = error.toString(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_localError!))); + setState(() {}); } - super.dispose(); } Future _run(Future Function() action) async { @@ -183,21 +189,17 @@ class _ControlShellState extends State } catch (error) { _showError(error); } finally { - if (mounted) setState(() => busy = false); + if (mounted) { + setState(() => busy = false); + _tryAutoSelect(); + } } } - 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 调试工具'); - } + _automaticConnection = true; + _selectionPending = null; + if (!Platform.isAndroid) throw StateError('目前仅支持 Android 手机连接觉瞳'); if (!await requestBlePermissions()) throw StateError('请允许蓝牙权限后重试'); final permission = await FlutterForegroundTask.checkNotificationPermission(); @@ -209,33 +211,47 @@ class _ControlShellState extends State } } await client.start(); + if (client.state['connection'] == 'connected') return; 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]}'; + void _tryAutoSelect() { + if (!_automaticConnection || busy || client.preview) return; + final state = client.state; + if (state['connection'] != 'searching') return; + final discovered = state['discovered']; + if (discovered is! List || discovered.isEmpty) return; + final device = discovered.first; + if (device is! Map || device['id'] is! String) return; + final id = device['id'] as String; + if (_selectionPending == id) return; + _selectionPending = id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _automaticConnection) { + _run(() => client.send('select', {'deviceId': id})); } - } - if (mounted) setState(() => tab = 0); - }); + }); + } - Future _arm() => _run(() => client.send('arm')); + Future _disconnect() async { + _automaticConnection = false; + try { + await client.send('disconnect'); + } catch (error) { + _showError(error); + } + } Future _pause() async { try { await client.send('stop'); - } catch (e) { - _showError(e); + } catch (error) { + _showError(error); } } void _sendJoystick(double x, double y, {bool force = false}) { - if (busy) return; + if (busy || client.state['outputAuthorized'] != true) return; setState(() { values[0] = x; values[1] = y; @@ -254,16 +270,8 @@ class _ControlShellState extends State .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; + if (busy || client.state['outputAuthorized'] != true) return; setState(() => values[2] = value); final now = DateTime.now(); if (!force && @@ -279,365 +287,518 @@ class _ControlShellState extends State .catchError((Object error) => _showError(error)); } - Widget _sectionTitle(String title, {String? description}) => Column( + Future _setAutoView(bool value) async { + if (value == (autoView ?? (client.state['mode'] == 'auto'))) return; + if (!value && + (client.state['autoRotate'] == true || + client.state['autoWink'] == true)) { + await _run(() async { + if (client.state['autoRotate'] == true) { + await client.send('rotate', {'enabled': false}); + } + if (client.state['autoWink'] == true) { + await client.send('winkAuto', {'enabled': false}); + } + }); + if (client.state['autoRotate'] == true || + client.state['autoWink'] == true) { + return; + } + } + if (mounted) setState(() => autoView = value); + } + + Widget _page(List children) => Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680), + child: ListView( + padding: const EdgeInsets.fromLTRB(26, 4, 26, 24), + children: children, + ), + ), + ); + + Widget _divider() => Divider(height: 1, thickness: 1, color: p.line); + + Widget _section(String title, {String? subtitle}) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, - style: const TextStyle( - fontSize: 23, + style: TextStyle( + fontSize: 21, fontWeight: FontWeight.w700, - letterSpacing: -.4, + color: p.ink, ), ), - if (description != null) ...[ + if (subtitle != null) ...[ const SizedBox(height: 4), - Text(description, style: const TextStyle(color: _muted)), + Text(subtitle, style: TextStyle(color: p.muted, fontSize: 14)), ], ], ); - 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) { + Widget _statusChip(Map state) { final connection = state['connection'] as String? ?? 'disconnected'; - final label = controlStatus(state); - final active = connection == 'connected'; final battery = state['battery']; + final active = connection == 'connected'; 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), - ), - ], + return InkWell( + onTap: () => setState(() => tab = 2), + borderRadius: BorderRadius.circular(99), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: p.surface, + borderRadius: BorderRadius.circular(99), + border: Border.all(color: p.line), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.circle, + color: active + ? p.success + : connection == 'searching' || connection == 'connecting' + ? p.purple + : p.muted, + size: 8, + ), + const SizedBox(width: 7), + Flexible( + child: Text( + active ? '已连接 · 觉瞳 01' : controlStatus(state), + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: p.ink, + fontSize: 11, + fontWeight: FontWeight.w600, ), ), - if (active) Icon(Icons.circle, color: _mint, size: 10), - ], - ), - const SizedBox(height: 20), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _pill( - battery == null - ? '电量未知' - : '电量 $battery%${stale ? ' · 已过期' : ''}', + ), + if (active) ...[ + const SizedBox(width: 8), + Container(width: 1, height: 16, color: p.line), + const SizedBox(width: 8), + Icon( battery == null - ? Icons.battery_unknown - : Icons.battery_5_bar_rounded, + ? Icons.battery_unknown_outlined + : Icons.battery_full_rounded, + color: p.purple, + size: 16, + ), + const SizedBox(width: 3), + Tooltip( + message: stale ? '电量数据已超过 1 分钟' : '设备电量', + child: Text( + battery == null ? '—' : '$battery%', + style: TextStyle( + fontSize: 11, + color: p.ink, + fontWeight: FontWeight.w600, + ), + ), ), - 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 _header(String title, String subtitle, Map state) => + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 27, + fontWeight: FontWeight.w800, + color: p.ink, + height: 1.08, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: TextStyle(color: p.muted, fontSize: 14), + ), + ], + ), + ), + const SizedBox(width: 8), + _statusChip(state), + ], + ), + ); + + Widget _outlinedAction( + String label, + IconData icon, + VoidCallback? onPressed, { + bool danger = false, + }) => SizedBox( + height: 44, + child: OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 20), + label: Text(label), + style: OutlinedButton.styleFrom( + foregroundColor: danger ? p.pink : p.purple, + backgroundColor: danger ? p.dangerBackground : p.purpleSoft, + side: BorderSide(color: danger ? p.pink : p.softBorder), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(99)), + ), ), ); Widget _controlPage(Map state) { final connected = state['connection'] == 'connected'; final armed = state['outputAuthorized'] == true; - final target = state['target'] as List?; + final automatic = autoView ?? (state['mode'] == 'auto'); return _page([ - _sectionTitle('控制'), - const SizedBox(height: 18), - _statusCard(state), - const SizedBox(height: 22), - if (!connected) - _panel( - Column( - crossAxisAlignment: CrossAxisAlignment.start, + _header('觉之瞳', '控制', state), + if (!connected || !armed) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: p.purpleSoft, + borderRadius: BorderRadius.circular(16), + ), + child: Row( children: [ - const Text( - '尚未连接觉瞳', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + Expanded( + child: Text( + connected + ? controlStatus(state) + : state['connection'] == 'searching' + ? '正在寻找觉瞳设备' + : state['connection'] == 'connecting' + ? '正在连接觉瞳设备' + : state['connection'] == 'failed' + ? '连接失败,请在设置中重试' + : '尚未连接觉瞳设备', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), - const SizedBox(height: 7), - const SizedBox(height: 14), - FilledButton.icon( - onPressed: () => setState(() => tab = 2), - icon: const Icon(Icons.arrow_forward_rounded), - label: const Text('连接设备'), + TextButton( + onPressed: busy + ? null + : connected + ? () => _run(() => client.send('arm')) + : () => setState(() => tab = 2), + child: Text(connected ? '启用控制' : '查看连接'), ), ], ), ), - if (connected && !armed) ...[ - _panel( - Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 18), + ], + Center( + child: Container( + height: 40, + width: 205, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: p.segment, + border: Border.all(color: p.line), + borderRadius: BorderRadius.circular(99), + ), + child: Row( 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' ? '启用控制' : '恢复控制', + for (final option in [(true, '自动'), (false, '手动')]) + Expanded( + child: InkWell( + onTap: connected && armed && !busy + ? () => _setAutoView(option.$1) + : null, + borderRadius: BorderRadius.circular(99), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: automatic == option.$1 + ? p.button + : Colors.transparent, + borderRadius: BorderRadius.circular(99), + ), + child: Text( + option.$2, + style: TextStyle( + color: automatic == option.$1 + ? Colors.white + : p.muted, + fontWeight: FontWeight.w600, + ), + ), + ), ), ), - 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: 25), + if (!automatic) ...[ + Center(child: _section('方向摇杆', subtitle: '拖动控制左右与上下')), + const SizedBox(height: 18), + IgnorePointer( + ignoring: !armed || busy, + child: Opacity( + opacity: armed ? 1 : .55, + child: JoystickPad( + x: values[0], + y: values[1], + onChanged: (x, y) => _sendJoystick(x, y), + onReleased: () => _sendJoystick( + resetStickOnRelease ? .5 : values[0], + resetStickOnRelease ? .5 : values[1], + force: true, ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, + ), + ), + ), + const SizedBox(height: 20), + _divider(), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextButton.icon( - onPressed: busy - ? null - : () => _sendJoystick(.5, .5, force: true), - icon: const Icon(Icons.center_focus_strong_rounded), - label: const Text('复位方向'), + const Text( + '松手回中', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), ), - ], - ), - 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), - ), + Text( + '松开摇杆后自动回到中心', + style: TextStyle(color: p.muted, fontSize: 11), ), ], ), - Slider( - key: const ValueKey('eyelid-slider'), - value: values[2], - onChanged: busy ? null : _sendEyelid, - onChangeEnd: busy - ? null - : (value) => _sendEyelid(value, force: true), + ), + Switch( + value: resetStickOnRelease, + onChanged: (value) => setState(() => resetStickOnRelease = value), + ), + const SizedBox(width: 8), + _outlinedAction( + '方向回中', + Icons.center_focus_strong_rounded, + armed && !busy ? () => _sendJoystick(.5, .5, force: true) : null, + ), + ], + ), + const SizedBox(height: 12), + _divider(), + const SizedBox(height: 14), + Row( + children: [ + const Expanded( + child: Text( + '眼皮开合', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), - ], - ), + ), + Text( + '${(values[2] * 100).round()}%', + style: TextStyle( + color: p.purple, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ], ), - const SizedBox(height: 18), + Slider( + key: const ValueKey('eyelid-slider'), + value: values[2], + onChanged: armed && !busy ? _sendEyelid : null, + onChangeEnd: armed && !busy + ? (value) => _sendEyelid(value, force: true) + : null, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('闭合', style: TextStyle(color: p.muted, fontSize: 12)), + Text('张开', style: TextStyle(color: p.muted, fontSize: 12)), + ], + ), + const SizedBox(height: 14), SizedBox( width: double.infinity, - child: OutlinedButton.icon( - onPressed: _pause, - icon: const Icon(Icons.stop_circle_outlined), - label: Text(state['controlPhase'] == 'pausing' ? '暂停中…' : '暂停控制'), + child: _outlinedAction( + '眨眼', + Icons.motion_photos_on_rounded, + armed && !busy && state['playback'] != 'playing' + ? () => _run(() => client.send('play', {'name': 'wink'})) + : null, ), ), - 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})), - ), - ], + ] else ...[ + _section('自动控制', subtitle: '选择设备持续执行的动作'), + const SizedBox(height: 20), + _autoRow( + '自动转动', + '间隔改变方向', + Icons.refresh_rounded, + state['autoRotate'] == true, + armed + ? (value) => _run(() => client.send('rotate', {'enabled': value})) + : null, + ), + _divider(), + _autoRow( + '自动眨眼', + '间隔播放眨眼动作', + Icons.motion_photos_on_rounded, + state['autoWink'] == true, + armed + ? (value) => + _run(() => client.send('winkAuto', {'enabled': value})) + : null, + ), + const SizedBox(height: 20), + Text('持续行为由本地控制会话调度。', style: TextStyle(color: p.muted, fontSize: 12)), + ], + if (connected && armed) ...[ + const SizedBox(height: 24), + Center( + child: TextButton.icon( + onPressed: _pause, + icon: const Icon(Icons.pause_circle_outline), + label: const Text('暂停控制'), ), ), ], ]); } - Widget _behaviorRow( - IconData icon, + Widget _autoRow( 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), + String subtitle, + IconData icon, + bool value, + ValueChanged? onChanged, + ) => Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: p.purpleSoft, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: p.purple), ), - ), - Switch(value: enabled, onChanged: busy ? null : onChanged), - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + Text(subtitle, style: TextStyle(color: p.muted, fontSize: 12)), + ], + ), + ), + Switch(value: value, onChanged: busy ? null : onChanged), + ], + ), ); Widget _actionsPage(Map state) { - final connected = + final ready = state['connection'] == 'connected' && state['outputAuthorized'] == true; final playing = state['playback'] == 'playing'; return _page([ - _sectionTitle('动作'), + _header('动作', '选择并播放预设', state), + _divider(), + const SizedBox(height: 20), + _section('预设动作', subtitle: '当前可用的动作'), const SizedBox(height: 18), - _statusCard(state), - const SizedBox(height: 22), for (final entry in [ - ('wink', '轻眨一下', Icons.visibility_outlined), - ('wink2', '连眨两下', Icons.auto_awesome_rounded), + ('wink', '轻眨一下', '闭合 → 睁开'), + ('wink2', '连眨两下', '闭合 → 睁开 × 2'), ]) ...[ - _panel( - Row( + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: 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, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.$2, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + entry.$3, + style: TextStyle(color: p.muted, fontSize: 13), + ), + ], ), ), - IconButton.filledTonal( - tooltip: '播放${entry.$2}', - icon: const Icon(Icons.play_arrow_rounded), - onPressed: connected && !busy && !playing + FilledButton.icon( + onPressed: ready && !busy && !playing ? () => _run(() => client.send('play', {'name': entry.$1})) : null, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('播放'), + style: FilledButton.styleFrom( + backgroundColor: p.button, + foregroundColor: Colors.white, + minimumSize: const Size(100, 42), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(99), + ), + ), ), ], ), ), - const SizedBox(height: 12), + _divider(), ], - 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('取消播放'), - ), - ], - ), + const SizedBox(height: 28), + Text( + playing + ? '动作播放中' + : ready + ? '当前未播放动作' + : '连接并启用控制后可播放动作', + style: TextStyle( + color: p.muted, + fontSize: 15, + fontWeight: FontWeight.w600, ), - if (!connected) - const Text('连接设备后可播放动作。', style: TextStyle(color: _muted)), + ), + const SizedBox(height: 4), + Text('播放状态来自本地调度', style: TextStyle(color: p.muted, fontSize: 12)), + if (playing) ...[ + const SizedBox(height: 14), + _outlinedAction('取消播放', Icons.stop_rounded, _pause), + ], ]); } @@ -675,11 +836,6 @@ class _ControlShellState extends State } } - String _addressSuffix(Object? address) { - final value = address?.toString() ?? ''; - return value.length > 5 ? value.substring(value.length - 5) : value; - } - void _showPairingHelp() { showDialog( context: context, @@ -698,308 +854,147 @@ class _ControlShellState extends State ); } - Widget _devicePage(Map state) { + Widget _settingRow(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Row( + children: [ + Text(label, style: TextStyle(color: p.muted, fontSize: 15)), + const Spacer(), + Flexible( + child: Text( + value, + textAlign: TextAlign.right, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + + Widget _settingsPage(Map state) { final connected = state['connection'] == 'connected'; - final active = - connected || + final searching = + state['connection'] == 'searching' || 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('断开连接'), - ), - ], - ), + _header('设置', '连接与设备', state), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: p.purpleSoft, + borderRadius: BorderRadius.circular(12), ), - 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('配对帮助'), - ), - ], + child: const Text( + '当前控制会话', + style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700), + ), + ), + _settingRow('连接方式', '蓝牙 · BLE'), + _divider(), + _settingRow( + '当前设备', + connected + ? '觉瞳 01' + : searching + ? '正在连接…' + : '未连接', + ), + _divider(), + if (connected) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: _outlinedAction( + '断开控制会话', + Icons.link_off_rounded, + busy ? null : _disconnect, + danger: true, ), ), - 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('断开连接'), + ] else ...[ + const SizedBox(height: 16), + Text( + searching ? '正在自动发现并连接觉瞳设备…' : '未发现设备,请确认设备已开启且手机蓝牙可用。', + style: TextStyle(color: p.muted, fontSize: 13), ), - 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: 16), + SizedBox( + width: double.infinity, + child: _outlinedAction( + '重新搜索', + Icons.bluetooth_searching, + busy || searching ? null : _discover, ), - const SizedBox(height: 10), - ], + ), + TextButton(onPressed: _showPairingHelp, child: const Text('配对帮助')), ], 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('取消换绑窗口'), - ), - ], - ), - ], - ), + Text( + state['pairingNotice'] as String, + style: TextStyle(color: p.muted), ), ], - if (active) ...[ - const SizedBox(height: 16), - ExpansionTile( - title: const Text('设备详情'), - childrenPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), + if (connected) ...[ + const SizedBox(height: 28), + _section('配对与手机'), + const SizedBox(height: 10), + Text( + state['supportsSharedPairing'] == true + ? '最多保存 8 台手机,同一时间只能连接 1 台。' + : state['supportsOwnerManagement'] == true + ? '当前固件仅支持单手机绑定,更换手机需要开启换绑窗口。' + : '此固件不支持在手机上管理配对。', + style: TextStyle(color: p.muted, fontSize: 13), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Align( - alignment: Alignment.centerLeft, - child: Text('固件版本:${state['firmware'] ?? '未知'}'), + _outlinedAction( + '修改配对码', + Icons.key_rounded, + busy || state['supportsOwnerManagement'] != true + ? null + : _changePairingCode, ), + if (state['supportsSharedPairing'] != true) + _outlinedAction( + '更换手机', + Icons.phonelink_setup_rounded, + busy || state['supportsOwnerManagement'] != true + ? null + : _openTransfer, + ), + if (state['supportsSharedPairing'] != true) + TextButton( + onPressed: busy || state['supportsOwnerManagement'] != true + ? null + : () => _run(() => client.send('cancelTransfer')), + child: const Text('取消换绑窗口'), + ), ], ), ], - 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('高级维护'), + tilePadding: EdgeInsets.zero, + title: Text('设备详情', style: TextStyle(color: p.ink)), 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('保存自定义范围'), - ), - ], + _settingRow('固件版本', '${state['firmware'] ?? '未知'}'), + _settingRow('控制状态', controlStatus(state)), + if (state['error'] != null) _settingRow('设备错误', '${state['error']}'), + if (client.message != null) _settingRow('后台错误', client.message!), + if (_localError != null) _settingRow('界面错误', _localError!), + Align( + alignment: Alignment.centerLeft, + child: Text( + '设备实际姿态未回传', + style: TextStyle(color: p.muted, fontSize: 12), ), ), ], @@ -1007,12 +1002,58 @@ class _ControlShellState extends State ]); } - Widget _page(List children) => Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680), - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), - children: children, + Widget _bottomNav() => Container( + decoration: BoxDecoration( + color: p.background, + border: Border(top: BorderSide(color: p.line)), + ), + child: SafeArea( + top: false, + child: SizedBox( + height: 64, + child: Row( + children: [ + for (final item in [ + (0, '控制', Icons.tune_rounded), + (1, '动作', Icons.play_arrow_rounded), + (2, '设置', Icons.settings_outlined), + ]) + Expanded( + child: InkWell( + onTap: () => setState(() => tab = item.$1), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 35, + height: 35, + decoration: BoxDecoration( + color: tab == item.$1 ? p.button : Colors.transparent, + shape: BoxShape.circle, + ), + child: Icon( + item.$3, + color: tab == item.$1 ? Colors.white : p.muted, + size: 23, + ), + ), + const SizedBox(height: 2), + Text( + item.$2, + style: TextStyle( + color: tab == item.$1 ? p.purple : p.muted, + fontSize: 12, + fontWeight: tab == item.$1 + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), ), ), ); @@ -1021,74 +1062,34 @@ class _ControlShellState extends State Widget build(BuildContext context) { final state = client.state; return Scaffold( - appBar: AppBar( - title: const Column( - crossAxisAlignment: CrossAxisAlignment.start, + body: SafeArea( + child: Column( children: [ - Text( - '觉瞳', - style: TextStyle(fontSize: 21, fontWeight: FontWeight.w800), + if (needsControlAttention(state) || state['connection'] == 'failed') + Container( + width: double.infinity, + color: p.warningBackground, + padding: const EdgeInsets.all(9), + child: Text( + controlStatus(state), + textAlign: TextAlign.center, + style: TextStyle(color: p.pink), + ), + ), + Expanded( + child: IndexedStack( + index: tab, + children: [ + _controlPage(state), + _actionsPage(state), + _settingsPage(state), + ], + ), ), - 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: '设备'), - ], ), + bottomNavigationBar: _bottomNav(), ); } } diff --git a/flutter_app/lib/satori_palette.dart b/flutter_app/lib/satori_palette.dart new file mode 100644 index 0000000..199f608 --- /dev/null +++ b/flutter_app/lib/satori_palette.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +/// App-owned surfaces and control colors for the two system brightness modes. +class SatoriPalette { + const SatoriPalette({ + required this.background, + required this.ink, + required this.muted, + required this.line, + required this.purple, + required this.button, + required this.purpleSoft, + required this.softBorder, + required this.surface, + required this.segment, + required this.pink, + required this.warningBackground, + required this.dangerBackground, + required this.success, + required this.joystickSurface, + required this.joystickBorder, + required this.joystickLine, + required this.knobStart, + required this.knobEnd, + required this.knobShadow, + required this.sliderThumb, + }); + + final Color background; + final Color ink; + final Color muted; + final Color line; + final Color purple; + final Color button; + final Color purpleSoft; + final Color softBorder; + final Color surface; + final Color segment; + final Color pink; + final Color warningBackground; + final Color dangerBackground; + final Color success; + final Color joystickSurface; + final Color joystickBorder; + final Color joystickLine; + final Color knobStart; + final Color knobEnd; + final Color knobShadow; + final Color sliderThumb; + + static const light = SatoriPalette( + background: Color(0xFFFCFBFF), + ink: Color(0xFF15104C), + muted: Color(0xFF8583A7), + line: Color(0xFFE6E4F1), + purple: Color(0xFF7957F2), + button: Color(0xFF7957F2), + purpleSoft: Color(0xFFF4F0FF), + softBorder: Color(0xFFDDD5FF), + surface: Color(0xFFF7F6FC), + segment: Color(0xFFF4F3F9), + pink: Color(0xFFFF3D83), + warningBackground: Color(0xFFFFEEF5), + dangerBackground: Color(0xFFFFF7FA), + success: Color(0xFF2CC566), + joystickSurface: Color(0xFFF7F6FC), + joystickBorder: Color(0xFFD8D8E8), + joystickLine: Color(0xFFD9D8E7), + knobStart: Color(0xFFB6A2FF), + knobEnd: Color(0xFF7052ED), + knobShadow: Color(0x557963EB), + sliderThumb: Colors.white, + ); + + static const dark = SatoriPalette( + background: Color(0xFF171624), + ink: Color(0xFFF6F3FF), + muted: Color(0xFFB5B1CD), + line: Color(0xFF3B3952), + purple: Color(0xFFB49AFF), + button: Color(0xFF7050D7), + purpleSoft: Color(0xFF2B2543), + softBorder: Color(0xFF5B4C82), + surface: Color(0xFF242236), + segment: Color(0xFF29263E), + pink: Color(0xFFFF87B1), + warningBackground: Color(0xFF3A2433), + dangerBackground: Color(0xFF392431), + success: Color(0xFF62D69B), + joystickSurface: Color(0xFF242236), + joystickBorder: Color(0xFF605B80), + joystickLine: Color(0xFF4A4665), + knobStart: Color(0xFFB6A2FF), + knobEnd: Color(0xFF795CE6), + knobShadow: Color(0x664D39A6), + sliderThumb: Color(0xFFF6F3FF), + ); + + static SatoriPalette of(BuildContext context) => + Theme.of(context).brightness == Brightness.dark ? dark : light; +} diff --git a/flutter_app/test/ble_ui_test.dart b/flutter_app/test/ble_ui_test.dart index 566837c..846811b 100644 --- a/flutter_app/test/ble_ui_test.dart +++ b/flutter_app/test/ble_ui_test.dart @@ -1,4 +1,3 @@ -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'; @@ -6,60 +5,51 @@ import 'package:satori_manager/runtime/control_client.dart'; void main() { testWidgets( - 'paired paused device has explicit ARM and no fabricated target controls', + 'paused device requires explicit ARM and shows no position feedback', (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)); + expect(find.text('启用控制'), findsOneWidget); + expect(find.byType(JoystickPad), findsOneWidget); + expect(find.text('指令预览'), findsNothing); + await tester.tap(find.text('设置')); await tester.pumpAndSettle(); - expect(find.textContaining('已下发逻辑值'), findsNothing); - await tester.scrollUntilVisible(find.text('诊断信息'), 200); - await tester.tap(find.text('诊断信息')); + expect(find.text('蓝牙 · BLE'), findsOneWidget); + expect(find.text('安全范围'), findsNothing); + await tester.tap(find.text('设备详情')); await tester.pumpAndSettle(); - expect(find.text('已下发逻辑值(控制目标):未知'), findsOneWidget); - expect(find.text('实际机械位置:未知'), findsOneWidget); - expect(find.text('指定 IPv4(可选)'), findsNothing); + expect(find.text('设备实际姿态未回传'), findsOneWidget); }, ); - 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('active control keeps joystick and omits numeric targets', ( + tester, + ) async { + final client = ControlClient.preview({ + 'connection': 'connected', + 'deviceId': 'synthetic-test-device', + 'outputAuthorized': true, + 'target': [1300, 1700, 1400], + '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', ( + testWidgets('configuration error remains short outside device details', ( tester, ) async { final client = ControlClient.preview({ @@ -73,18 +63,11 @@ void main() { 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.tap(find.text('设置')); await tester.pumpAndSettle(); - await tester.tap(find.text('诊断信息')); + await tester.scrollUntilVisible(find.text('设备详情'), 200); + 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/pairing_ui_test.dart b/flutter_app/test/pairing_ui_test.dart index c1e7a56..e40aabd 100644 --- a/flutter_app/test/pairing_ui_test.dart +++ b/flutter_app/test/pairing_ui_test.dart @@ -26,7 +26,7 @@ Future devicePage( PairingPreviewClient client, ) async { await tester.pumpWidget(SatoriApp(previewClient: client)); - await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.tap(find.text('设置')); await tester.pumpAndSettle(); } @@ -111,18 +111,13 @@ void main() { 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)); - }, - ); + testWidgets('firmware limits have no editable UI', (tester) async { + final client = PairingPreviewClient(connected: false); + await devicePage(tester, client); + expect(find.text('连接方式'), findsOneWidget); + expect(find.text('蓝牙 · BLE'), findsOneWidget); + expect(find.textContaining('安全范围'), findsNothing); + expect(find.text('高级维护'), findsNothing); + expect(find.byType(TextField), findsNothing); + }); } diff --git a/flutter_app/tool/render_previews_test.dart b/flutter_app/tool/render_previews_test.dart index a4e4196..01123e5 100644 --- a/flutter_app/tool/render_previews_test.dart +++ b/flutter_app/tool/render_previews_test.dart @@ -28,9 +28,8 @@ void main() { 'autoWink': false, 'battery': null, 'batteryAt': null, - 'endpoint': null, + 'deviceId': null, 'discovered': >[], - 'simulators': >[], 'target': null, 'outputAuthorized': false, 'simulator': false, @@ -43,15 +42,9 @@ void main() { 'target': [1500, 1500, 1500], 'deviceId': 'synthetic-preview', 'simulator': true, - 'endpoint': {'host': '192.0.2.20', 'port': 8888}, 'battery': 77, 'batteryAt': DateTime.now().toUtc().toIso8601String(), - 'discovered': [ - {'host': '192.0.2.20', 'port': 8888}, - ], - 'simulators': [ - {'host': '192.0.2.20', 'port': 8888}, - ], + 'discovered': >[], }; final screens = <(String, Map, int)>[ ('disconnected', base, 0), @@ -66,39 +59,68 @@ void main() { ('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', + for (final brightness in [Brightness.light, Brightness.dark]) { + for (final screen in screens) { + testWidgets('render ${brightness.name} ${screen.$1}', (tester) async { + await tester.runAsync(() async => fontReady); + tester.platformDispatcher.platformBrightnessTestValue = brightness; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + 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'), - ); - }); + if (screen.$3 == 1) { + await tester.tap(find.text('动作')); + await tester.pumpAndSettle(); + } + if (screen.$3 == 2) { + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + } + await expectLater( + find.byKey(key), + matchesGoldenFile( + '../build/design_preview/${brightness == Brightness.dark ? 'dark_' : ''}${screen.$1}.png', + ), + ); + }); + } } + testWidgets('theme follows a system brightness change', (tester) async { + await tester.runAsync(() async => fontReady); + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + await tester.pumpWidget( + SatoriApp( + previewClient: ControlClient.preview(connected), + previewFontFamily: 'Preview CJK', + ), + ); + await tester.pumpAndSettle(); + expect( + Theme.of(tester.element(find.text('方向摇杆'))).brightness, + Brightness.light, + ); + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pumpAndSettle(); + expect( + Theme.of(tester.element(find.text('方向摇杆'))).brightness, + Brightness.dark, + ); + }); + testWidgets('narrow control and setup pages fit without overflow', ( tester, ) async { @@ -115,7 +137,7 @@ void main() { ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - await tester.tap(find.byIcon(Icons.devices_rounded)); + await tester.tap(find.text('设置')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); From 7cbfeb3b9642bee04c395c1f63053a8468d8a013 Mon Sep 17 00:00:00 2001 From: AkazaAkali <478080766@qq.com> Date: Wed, 30 Sep 2026 10:23:27 +0800 Subject: [PATCH 3/3] Check BLE release compatibility before pairing --- flutter_app/lib/core/ble_compatibility.dart | 35 ++++++++ flutter_app/lib/core/ble_protocol.dart | 1 + flutter_app/lib/core/control_engine.dart | 15 +++- flutter_app/lib/core/control_status.dart | 1 + flutter_app/lib/core/device_session.dart | 8 +- .../lib/infrastructure/fake_ble_link.dart | 7 +- flutter_app/lib/main.dart | 3 + flutter_app/test/ble_compatibility_test.dart | 84 +++++++++++++++++++ 8 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 flutter_app/lib/core/ble_compatibility.dart create mode 100644 flutter_app/test/ble_compatibility_test.dart diff --git a/flutter_app/lib/core/ble_compatibility.dart b/flutter_app/lib/core/ble_compatibility.dart new file mode 100644 index 0000000..044c46d --- /dev/null +++ b/flutter_app/lib/core/ble_compatibility.dart @@ -0,0 +1,35 @@ +import 'ble_protocol.dart'; + +/// App and firmware releases use the same major/minor compatibility line. +/// Patch releases may differ without changing pairing or the BLE contract. +class BleCompatibility { + static const appVersion = '0.2.4'; + static const appMajor = 0; + static const appMinor = 2; + static const protocolMajor = 1; + static const protocolMinor = 2; + static const protocolVersion = '$protocolMajor.$protocolMinor'; + + static String? incompatibility(BleDeviceInfo info) { + if (info.firmwareMajor != appMajor || info.firmwareMinor != appMinor) { + return '固件版本 ${info.firmwareVersion} 与应用 $appVersion 不兼容'; + } + if (info.protocolMajor != protocolMajor || + info.protocolMinor != protocolMinor) { + return 'BLE 协议 ${info.protocolMajor}.${info.protocolMinor} 与应用支持的 $protocolVersion 不兼容'; + } + if (!info.supportsRequiredCapabilities) { + return '固件缺少当前应用所需的 BLE 功能'; + } + return null; + } +} + +class BleVersionMismatch implements Exception { + const BleVersionMismatch(this.deviceInfo, this.reason); + final BleDeviceInfo deviceInfo; + final String reason; + + @override + String toString() => reason; +} diff --git a/flutter_app/lib/core/ble_protocol.dart b/flutter_app/lib/core/ble_protocol.dart index 27609d3..0905d04 100644 --- a/flutter_app/lib/core/ble_protocol.dart +++ b/flutter_app/lib/core/ble_protocol.dart @@ -444,6 +444,7 @@ class BleDeviceInfo { securityPolicy, logicalChannels; String get firmwareVersion => '$firmwareMajor.$firmwareMinor.$firmwarePatch'; + String get protocolVersion => '$protocolMajor.$protocolMinor'; bool get supportsRequiredCapabilities => (capabilities & 0x5f) == 0x5f; bool get supportsOwnerManagement => (capabilities & 0x80) != 0; bool get supportsSharedPairing => diff --git a/flutter_app/lib/core/control_engine.dart b/flutter_app/lib/core/control_engine.dart index 91e8c90..7f0ddc3 100644 --- a/flutter_app/lib/core/control_engine.dart +++ b/flutter_app/lib/core/control_engine.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; import 'ble_protocol.dart' show BleResult; +import 'ble_compatibility.dart'; import 'device_session.dart'; import 'protocol.dart' show ActionFrame, Protocol; import 'safety_limits.dart'; @@ -40,6 +41,8 @@ class ControlEngine { bool outputAuthorized = false; String? error; String? issueCode; + String? incompatibleFirmware; + String? incompatibleProtocol; bool pauseInProgress = false; bool explicitlyPaused = false; String? deviceId; @@ -70,7 +73,10 @@ class ControlEngine { 'sessionPhase': s.phase.name, 'deviceId': deviceId, 'identity': s.identity, - 'firmware': s.deviceInfo?.firmwareVersion, + 'firmware': s.deviceInfo?.firmwareVersion ?? incompatibleFirmware, + 'deviceProtocol': s.deviceInfo?.protocolVersion ?? incompatibleProtocol, + 'appVersion': BleCompatibility.appVersion, + 'appProtocol': BleCompatibility.protocolVersion, 'supportsOwnerManagement': ((s.deviceInfo?.capabilities ?? 0) & 0x80) != 0, 'supportsSharedPairing': s.deviceInfo?.supportsSharedPairing ?? false, @@ -164,6 +170,8 @@ class ControlEngine { connection = 'connecting'; error = null; issueCode = null; + incompatibleFirmware = null; + incompatibleProtocol = null; _connecting = true; _notify(); try { @@ -186,6 +194,11 @@ class ControlEngine { if (generation == _connectionGeneration) { connection = 'failed'; error = '$e'; + if (e is BleVersionMismatch) { + issueCode = 'versionMismatch'; + incompatibleFirmware = e.deviceInfo.firmwareVersion; + incompatibleProtocol = e.deviceInfo.protocolVersion; + } await session.disconnect(); } rethrow; diff --git a/flutter_app/lib/core/control_status.dart b/flutter_app/lib/core/control_status.dart index 35d40dc..9041c01 100644 --- a/flutter_app/lib/core/control_status.dart +++ b/flutter_app/lib/core/control_status.dart @@ -25,6 +25,7 @@ String controlStatus(Map state) { case 'searching': return '正在搜索…'; case 'failed': + if (state['issueCode'] == 'versionMismatch') return '版本不兼容'; return '连接失败'; default: return '未连接'; diff --git a/flutter_app/lib/core/device_session.dart b/flutter_app/lib/core/device_session.dart index fff7e12..5a9a9e9 100644 --- a/flutter_app/lib/core/device_session.dart +++ b/flutter_app/lib/core/device_session.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'ble_protocol.dart'; +import 'ble_compatibility.dart'; enum BleLinkState { disconnected, connecting, connected } @@ -187,10 +188,9 @@ class DeviceSession { await _setupRead(BleProtocol.deviceInfoUuid), ); _checkGeneration(generation); - if (info.protocolMajor != 1 || !info.supportsRequiredCapabilities) { - throw StateError( - 'Incompatible BLE protocol or missing required capabilities', - ); + final mismatch = BleCompatibility.incompatibility(info); + if (mismatch != null) { + throw BleVersionMismatch(info, mismatch); } // The plugin exposes no CCCD-ready future. The protected read triggers // pairing, then identity is re-read through the authenticated link. diff --git a/flutter_app/lib/infrastructure/fake_ble_link.dart b/flutter_app/lib/infrastructure/fake_ble_link.dart index cbed7d9..2309ac4 100644 --- a/flutter_app/lib/infrastructure/fake_ble_link.dart +++ b/flutter_app/lib/infrastructure/fake_ble_link.dart @@ -204,9 +204,10 @@ class FakeBleLink implements BleLink { if (uuid == BleProtocol.identityUuid) return List.from(identity); if (uuid == BleProtocol.deviceInfoUuid) { return BleProtocol.encodeDeviceInfo( - protocolMinor: sharedPairingSupported - ? 2 - : (ownerManagementSupported ? 1 : 0), + protocolMinor: 2, + firmwareMajor: 0, + firmwareMinor: 2, + firmwarePatch: 2, capabilities: (ownerManagementSupported ? 0xdf : 0x5f) | (sharedPairingSupported ? 0x100 : 0), diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 20d8484..e5f98c8 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -985,7 +985,10 @@ class _ControlShellState extends State tilePadding: EdgeInsets.zero, title: Text('设备详情', style: TextStyle(color: p.ink)), children: [ + _settingRow('应用版本', '${state['appVersion'] ?? '未知'}'), _settingRow('固件版本', '${state['firmware'] ?? '未知'}'), + _settingRow('应用 BLE 协议', '${state['appProtocol'] ?? '未知'}'), + _settingRow('设备 BLE 协议', '${state['deviceProtocol'] ?? '未知'}'), _settingRow('控制状态', controlStatus(state)), if (state['error'] != null) _settingRow('设备错误', '${state['error']}'), if (client.message != null) _settingRow('后台错误', client.message!), diff --git a/flutter_app/test/ble_compatibility_test.dart b/flutter_app/test/ble_compatibility_test.dart new file mode 100644 index 0000000..c3a7201 --- /dev/null +++ b/flutter_app/test/ble_compatibility_test.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:satori_manager/core/ble_compatibility.dart'; +import 'package:satori_manager/core/ble_protocol.dart'; +import 'package:satori_manager/core/device_session.dart'; +import 'package:satori_manager/infrastructure/fake_ble_link.dart'; + +class VersionedLink implements BleLink { + VersionedLink(this.inner, this.info); + final FakeBleLink inner; + final List info; + bool protectedRead = false; + + @override + Stream get connectionState => inner.connectionState; + @override + Future connect(String id) => inner.connect(id); + @override + Future disconnect() => inner.disconnect(); + @override + Future> read(String uuid) { + if (uuid == BleProtocol.deviceInfoUuid) return Future.value(info); + if (uuid == BleProtocol.stateSnapshotUuid) protectedRead = true; + return inner.read(uuid); + } + + @override + Future write(String uuid, List value) => inner.write(uuid, value); + @override + Stream> subscribe(String uuid) => inner.subscribe(uuid); +} + +void main() { + test('declared app version stays in sync with pubspec', () { + expect( + File('pubspec.yaml').readAsStringSync(), + contains('version: ${BleCompatibility.appVersion}+'), + ); + }); + test( + 'patch differences remain compatible; major, minor and protocol do not', + () { + BleDeviceInfo decode({ + int major = 0, + int minor = 2, + int patch = 99, + int protocol = 2, + }) => BleProtocol.decodeDeviceInfo( + BleProtocol.encodeDeviceInfo( + protocolMinor: protocol, + firmwareMajor: major, + firmwareMinor: minor, + firmwarePatch: patch, + ), + ); + expect(BleCompatibility.incompatibility(decode()), isNull); + expect(BleCompatibility.incompatibility(decode(major: 1)), isNotNull); + expect(BleCompatibility.incompatibility(decode(minor: 3)), isNotNull); + expect(BleCompatibility.incompatibility(decode(protocol: 1)), isNotNull); + }, + ); + + test( + 'incompatible version is rejected before protected pairing read', + () async { + final inner = FakeBleLink(); + final link = VersionedLink( + inner, + BleProtocol.encodeDeviceInfo(protocolMinor: 2, firmwareMinor: 3), + ); + final session = DeviceSession(link); + await expectLater( + session.connect('synthetic'), + throwsA(isA()), + ); + expect(link.protectedRead, isFalse); + expect(inner.writes, isEmpty); + await session.dispose(); + await inner.dispose(); + }, + ); +}