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@@q0|W-^A&VhjhO+044$%!YxiPI;`p4Q#=k
za-F;6dk-j1J*_nBlG2>3KywUtLX8Ua?`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