# 奇妙小屏幕 BLE 控制协议 1.0 本文是首版实现契约。Python/Kotlin 加密黄金向量、分片和 Android 13 真实 BLE 已有通过记录;各业务验收范围见测试结果归档,不能据此推断所有场景通过。命令只能进入设备公共业务服务,不调用网页 DOM,不提供 shell、任意路径或原生驱动地址。变化登记到跨端兼容记录。 ## 1. GATT 与广播 | 项目 | 固定 UUID/行为 | |---|---| | Service | `9f57a001-6c31-4c58-bc22-1f728b641001` | | RX | `9f57a002-6c31-4c58-bc22-1f728b641001`,Write with response;手机到设备 | | TX | `9f57a003-6c31-4c58-bc22-1f728b641001`,Notify;设备到手机 | 使用传统可连接 BLE 广播,包含服务 UUID;扫描响应提供 `QMS-` 加短编号。完整昵称与持久 UUID 在握手后取得并缓存;首次扫描尚无昵称时显示产品名与短编号。改名不改变 ID。持久 UUID 在设备首次启动生成,放 `/var/lib/matrix-screen-controller/mobile/identity.json`,镜像构建不得携带已生成身份。短编号为 UUID 去连字符后的前 8 位,仅展示用。 禁用经典蓝牙配对及不必要的音频/文件传输 profile;GATT 不要求系统 bonding。不能保证其他工具完全无法建立物理连接;保证未完成本协议握手不得执行控制。手机先订阅 TX,再发送握手。连接后停止广播;服务端另外检查并拒绝第二个连接,不以“不广播”代替互斥。 10 秒内未完成握手释放连接。活动会话最多间隔 5 秒发送业务请求或 ping;当前 Android 前台每 2 秒轮询状态,后台释放前每 2 秒 ping。设备连续 20 秒未收到有效消息释放。单次普通命令超时 10 秒,WiFi 扫描 20 秒;耗时切网作为任务立即返回。设备检测断开后目标 5 秒内恢复广播,蓝牙硬件不可用时显示故障并有界退避重试,不阻止显示服务。 ## 2. 分片与消息 所有整数使用网络字节序。每条 RX/TX 特征值是一个分片:`magic:uint16=0x514d, wire:uint8=1, kind:uint8, message_id:uint32, index:uint16, count:uint16, total_length:uint32, payload:bytes`。头长 16 字节,payload 上限为 `ATT_MTU - 3 - 16`;必须在 MTU 23 时也可工作,不能强制大 MTU。 kind:1 为 client hello,2 为 server hello,3 为加密记录,4 为握手拒绝。message_id 在当前方向逐条递增,重连归零。每方向仅允许一个重组消息,index 从 0 连续递增,count 与 total_length 必须一致;总消息不超过 65536 字节。重复、乱序、超长、类型未知或 15 秒未完成重组均丢弃并关闭会话,不允许部分消息触发业务。两端操作队列串行;已开始的帧消息完整发完,控制在下一条消息优先,缩略图和预览不得积压。 业务载荷为 UTF-8 JSON 对象。请求:`{"id":"1","method":"status.get","params":{}}`;成功:`{"id":"1","ok":true,"result":{}}`;失败:`{"id":"1","ok":false,"error":{"code":"CONFLICT","message":"内容已改变,请刷新","retryable":false}}`。id 为当前会话递增的十进制字符串,最大 64 字符;服务端只接受新的请求编号,重复编号返回错误而不再次执行变更。连接中断后不得自动重放变更;先回读状态,必要时请用户重试。 通用错误:`BAD_REQUEST`、`NOT_READY`、`BUSY`、`UNSUPPORTED_VERSION`、`UNSUPPORTED_CAPABILITY`、`NOT_FOUND`、`CONFLICT`、`TIMEOUT`、`DEVICE_ERROR`。message 不包含密码、内部路径、命令行、异常堆栈或设备调试身份。握手拒绝只含固定错误码。 ## 3. 自动加密 传输上限补充:ServerHello 在未建立会话时一律按 MTU 23 分片。`session.open` 可携带整数 `receive_mtu`(23~517,缺省 23),表示手机当前实际可接收的 ATT MTU;后续下行取该值与 BlueZ 写入回调 MTU 的较小值。此字段只限制分片大小,不影响加密参数。针对实际手机报告 247、BlueZ 回调报告 517 的不一致情况,禁止仅依据 BlueZ 数值发送大通知;断线重置上限为 23。 使用成熟库的 P-256 ECDH、HKDF-SHA256 与 AES-256-GCM,不手写椭圆曲线/AES。客户端和服务端每次连接都生成新的临时密钥和 32 字节随机数,公钥使用 65 字节 SEC1 非压缩编码。JSON 内二进制为标准 Base64,包含必要 padding。 ClientHello 字段:`protocol_major:1, protocol_minor:0, public_key, random`。ServerHello 字段相同。协商拒绝未知主版本,不建立降级明文会话。握手 JSON 不包含手机名称或 WiFi 信息。 设 `C`、`S` 为收到的两个完整原始 hello JSON UTF-8 字节(不重新序列化),`T=SHA256(uint32(len(C)) || C || uint32(len(S)) || S)`。salt 为 `SHA256(client_random || server_random)`,IKM 为 ECDH 共享秘密,info 为 ASCII `QMS-BLE-1` 加 T。HKDF 输出 72 字节:前 32 为 c2s key,接着 32 为 s2c key,最后两个 4 字节分别为 c2s/s2c nonce prefix。 加密记录为 `sequence:uint64 || ciphertext || tag:16bytes`。每方向 sequence 从 0 开始且必须等于期望值;nonce 为该方向 4 字节 prefix 加 sequence;AAD 为 ASCII `QMS-BLE-1`、T、方向字节(c2s=0,s2c=1)和 sequence。序号不回绕;鉴别失败、重放、跳序、非法公钥立即释放会话。会话密钥只存内存,断开销毁。 第一条加密请求必须为 `session.open`,带手机昵称;设备成功回复才算控制会话建立、才通知网页。应用层加密防被动窃听,不认证正版 App,不抵御主动中间人;UUID、昵称和 APK 内置常量都不是认证凭据。 ## 4. 业务接口 响应包含协议主次版本与 capabilities。客户端忽略未知可选字段;缺少必需字段为协议错误。不存在某能力时隐藏或禁用相关操作并解释,不猜测默认支持。时间、大小和单位固定在字段名及模型中;不把网页内部 config 整包当作移动端契约。 | method | params | result 与业务边界 | |---|---|---| | `session.open` | `client_name` | `device_id, device_name, short_id, protocol_major, protocol_minor, capabilities, limits`;名称规则见需求。 | | `session.ping` | 空 | `alive:true`;刷新有效会话活跃时间。 | | `session.rename` | `client_name` | 回读当前名称并同步网页连接状态。 | | `device.rename` | `name` | 更新持久设备昵称,返回身份元数据,不改变 UUID。 | | `status.get` | 空 | 当前结构复用设备状态:`state, screen, service, power, resources, network, frp, ui, system, mobile` 等字段;手机使用的路径列于下文。不可用原因沿用各子对象定义,不能假设所有子对象均有 available 字段。 | | `storage.get` | 空 | 设备与软件容量统计,复用现有有界缓存,单位 bytes。 | | `settings.get` | 空 | 当前设置、可写字段、允许值及设置修订;不得带 WiFi 密码。 | | `settings.patch` | `expected_revision, changes` | 仅允许亮度、方向、刷新率、性能模式、网络提示延迟;校验冲突后复用现有业务并回读。 | | `wifi.scan` | 空 | `networks:[{ssid,security,signal_percent,connected}], scanned_at_ms`;时间为 Unix 毫秒;同 SSID/安全类型去重取最强信号,隐藏 SSID 用手动输入。 | | `wifi.get` | 空 | 已保存配置(不含密码)、`password_configured`、实际连接状态、待生效状态。 | | `wifi.set` | `expected_revision, ssid, security, password_action, password?, ipv4_mode, address?, prefix?, gateway?, dns_servers, activation` | password_action 为 keep/replace/none;none 仅开放网络;activation 为 immediate/next_boot;返回配置修订与可选 task_id。 | | `task.get` | `task_id` | `state:pending/running/succeeded/failed, stage, error?`;只允许查询本协议产生的有界任务,不提供任意系统任务入口。 | | `library.list` | `cursor?, limit` | 默认 20、最大 50;返回库修订、条目和下一游标。条目含 type、id、revision、name、is_demo、playable、thumbnail_revision。翻页库变化返回 CONFLICT。 | | `library.summary` | 空 | 返回 `library_revision,item_count`;与 `library.list` 使用同一目录视图和修订,供新版 App 判断缓存是否可复用。 | | `library.preview` | `type,id,revision` | 返回 `mime:image/png, data_base64`,16×16 静态预览;动画使用首帧。 | | `library.thumbnail` | `type,id,revision` | 返回 `mime:image/png, data_base64`,64×64 静态缩略图;动画使用首帧。 | | `content.play` | `type,id,revision` | 使用既有内容解析与显示服务;过期拒绝,不能顺便修改默认内容。 | | `content.default.get` | 空 | 当前默认内容元数据。 | | `content.default.set` | `type,id,revision` | 保存并立即播放;失败按既有默认内容事务回滚,返回最终状态。 | | `playback.patch` | `session_id,paused?,position_ms?,speed?` | 操作既有动画会话;过期会话 CONFLICT,速度限设备已有枚举。 | | `frame.get` | `known_revision?` | 未变返回 `unchanged:true`;变化返回逻辑 RGB 帧的 PNG Base64、frame_revision、采样时间。不传完整动图。 | settings 与 wifi 的 revision 为设备生成的不透明字符串,相关状态变化即更新;网页修改也必须更新同一修订。配置检查与提交在公共控制服务内串行执行,防止“检查通过后被另一个客户端覆盖”。错误不得回传旧密码或完整输入。 ## 5. 生命周期与兼容 系统状态每 2 秒读取,容量进入设备页时读取,库按进入/刷新读取,缩略图仅为可见项排队且按设备 ID、条目 ID、修订缓存。后台立即停预览,30 秒关闭会话。重新连接获取完整状态,不把本地缓存写回设备。 BLE 只接收白名单方法。旧客户端遇新能力忽略;旧设备缺某能力时客户端明确提示。不兼容主版本停止控制并返回列表。蓝牙关闭、主服务重启、OTA 维护或适配器消失时失效会话不可复用;恢复后重新握手,不恢复旧密钥。 协议 1.1 增加 `library_progressive` capability;仅声明该能力的设备可调用 `library.summary` 和 `library.preview`。协议 1.0 的 `library.list`、`library.thumbnail` 和当前帧接口保持原义;旧 App 忽略新能力,新 App 遇旧设备读取完整目录并沿用 64×64 图。内容缓存不参与设备设置或控制写入。 ## 6. 契约测试要求 共享固定的测试私钥、hello 原始字节、HKDF 输出、双向密文与分片向量;测试密钥明确仅为公开夹具,不能来自设备。Python 与 Kotlin 双向互验,覆盖 MTU 23、大 MTU、边界分片、非 ASCII 昵称、错误公钥、篡改、重放、断线、库翻页冲突、任务失败和主版本拒绝。接口最终字段模型及黄金向量必须随实现提交,未经测试不能写成协议验收通过。 ## 7. 当前状态模型的维护边界 `status.get` 当前复用设备只读状态构造函数,不依赖 REST URL 或网页控件;它仍与设备状态字段存在结构耦合,不能宣称只有驱动改变才需要检查手机兼容。移动端当前使用 `state.animation_playback`(active、session_id、paused、position_ms、total_duration_ms、speed、supported_speeds);其余状态作为详情展示。任何这些字段的删除、重命名、类型或含义变化必须按协议变更登记,不可随网页重构破坏旧 App。 `frame.get` 变化时提供 sampled_at_ms;unchanged 回复仅有 unchanged 与 frame_revision。App 用本机成功收到回复的时间表示新鲜度,避免设备与手机时钟偏差。缩略图缓存按当前连接隔离、按 type/id/revision 标识;断线清理,不把手机缓存写回设备。 ## 2026-09-26 兼容扩展 library.list 的用户条目遵循设备持久混合顺序,顺序变化影响 library_revision 和游标。演示条目固定在用户条目之前。 task.get 可附加脱敏 error_code(AUTH_FAILED、NETWORK_UNAVAILABLE、IP_CONFIG_FAILED、UNKNOWN);旧客户端可忽略。没有可靠原因时用 UNKNOWN。wifi.get 仍不含密码。协议主版本保持 1。