diff --git a/docs/testing/pro2-ble-performance.md b/docs/testing/pro2-ble-performance.md index 54e3eae82..a494b4c8e 100644 --- a/docs/testing/pro2-ble-performance.md +++ b/docs/testing/pro2-ble-performance.md @@ -1,26 +1,287 @@ # Pro2 BLE 传输测速记录 -> - 文档状态:历史性能基线,不代表所有固件与手机组合 -> - 测试日期:2026-05-11 -> - 适用范围:当日 OneKey Pro2、iOS 真机、React Native Demo 与 `react-native-ble-plx` 组合 -> - 维护要求:BLE 固件、SDK pacing、chunk 大小或文件写入 ACK 模型变化后重新测试。 - -本文记录 React Native Demo 在 iOS 真机上针对 OneKey Pro2 BLE 传输速率的两轮调试结果,并给出当时结论。测试目标是区分三类瓶颈: - -- BLE GATT 写入本身的上行能力。 -- React Native / `react-native-ble-plx` 写入队列能力。 -- `firmwareUpdateV4` 中 `FilesystemFileWrite` 每块等待设备回包的协议层耗时。 - -## 测试环境 - -- 设备:OneKey Pro2。 -- 连接方式:React Native Demo,iOS 真机,BLE。 -- 固件升级方法:`firmwareUpdateV4`。 -- SDK 层文件写入:`FilesystemFileWrite`。 -- 当前 BLE 固件升级 chunk:`1800B`。 -- BLE GATT 写入方式:`writeWithoutResponse` 为主,`writeWithResponse` 只作 baseline。 - -## 第一轮:SDK 层 speed profile +> 文档类型:核心机制 +> 适用读者:Hardware SDK、App Hardware、Firmware 与 QA 工程师 +> 内容状态:当前实现 + 分日期历史基线 +> 代码范围:`hd-transport`、`hd-transport-*`、Core Protocol V2 文件写入与 Pro2 host-asset package +> 最后代码核验:2026-08-26 +> 前置阅读:[Protocol V1/V2 传输协议](../protocol/protocol-v1-v2.md)、[Pro2 设备管理](../business/pro2-device-management.md) + +## 本页解决什么问题 + +- 给出当前 SDK 的 BLE packet、file chunk、write mode 和无损压缩结论。 +- 区分已完成的代码/单元测试与真正跑过的物理平台,禁止跨平台外推。 +- 用分段耗时判断瓶颈位于 Host 写入、BLE 链路还是 firmware 串行 ACK。 + +## 当前实现结论 + +Protocol V2 BLE 的生产默认值如下: + +| 范围 | 当前行为 | 边界 | +| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| Protocol 选择 | method 明确只支持 V2 时直接按 `expected V2` 探测 | 双协议与 V1-only method 不改变原顺序;不根据名称/PID 推导 BLE 协议 | +| 普通 `FilesystemFileWrite` | `1800B` | 保留最长 127-byte filesystem path 的 frame 空间 | +| firmware 固定 staging path | `1960B` | 最长当前路径的完整 frame 低于 `2048B` | +| firmware `1.0.1+` 固定 `wallpaper.okpkg` path | `1960B` | 仅通过内部 BLE-only override 放宽;WebUSB 和 legacy wallpaper path 不受影响 | +| Protocol V2 BLE packet | 协商值,生产上限 `244B` | LowLevel 未报告能力时回退 `192B`;React Native 缺失 MTU 时不猜测高容量 | +| Write mode | 支持时默认 `withoutResponse` | characteristic 仅支持 acknowledged write 或调用方显式设置 `writeWithResponse: true` 时例外 | +| Host-asset compression | dependency-free raw LZ4,优先 `16KiB` block、超限回退 `8KiB` | 解压后的 RGB565 bytes 不变,保留现有 dithering;兼容 firmware compressed-buffer 上限 | +| File response | 每个 chunk 等待 `FilesystemFile` 与 `processed_byte` | Protocol V2 响应仍按串行 session 管理,不并发发送有副作用的 file-write | + +### 为什么不能把所有 BLE file chunk 统一为 1960B + +`2048B` 限制作用于完整 Protocol V2 frame,而不是只作用于 file data。完整 frame 还包含 protobuf +中的 path、offset、total size、flags,以及 Protocol V2 header/CRC。使用生产 schema、最大 uint32 +offset/total size 和完整 flags 编码后的边界如下: + +| 场景 | UTF-8 path | `1960B` data 对应完整 frame | 结论 | +| ---------------------------------- | ---------: | --------------------------: | ---------------- | +| `vol1:/wallpapers/wallpaper.okpkg` | `32B` | `2028B` | 安全,余量 `20B` | +| 最长 firmware staging path | `24B` | `2020B` | 安全,余量 `28B` | +| SDK 允许的最长 filesystem path | `127B` | `2123B` | 超限 `75B` | + +最长合法 path 下,`1885B` data 编码后已经恰好是 `2048B`。物理测试中,完整 frame 恰好占满 +firmware UART FIFO 的配置会卡住,因此不能使用该理论极值作为生产默认。通用值保留 `1800B`; +只有 path 由 SDK 固定、且完整 frame 已通过生产 schema 边界测试的调用方才放宽到 `1960B`。 + +### FileWrite 场景审计 + +| SDK 场景 | Path 来源 | `1960B` 最坏 frame | 当前 BLE chunk | 判断 | +| ------------------------------------ | --------------------------------------------------------------- | ----------------------: | -------------: | ----------------------------------------- | +| Firmware 主组件 | SDK 固定 staging path,最长 `24B` | `2020B` | `1960B` | 已启用并有 frame test | +| Wallpaper host-asset package | SDK 固定 `32B` path | `2028B` | `1960B` | 已启用、frame test 与 macOS CLI 实测通过 | +| Firmware resource archive | 签名 package header,最多 `64B`;boot resource staging 为 `52B` | boot staging 为 `2048B` | `1800B` | 不能提升;会命中已知卡死边界 | +| NFT image/thumbnail/metadata/package | SDK 根据 hash 与 safe-integer timestamp 生成,最长 `45B` | `2041B` | `1800B` | 协议边界可容纳,但仅余 `7B`,尚无真机结果 | +| Portfolio pending package | SDK 固定 `39B` path | `2035B` | `1800B` | 协议边界可容纳,但尚无真机结果 | +| 公共 `fileWrite` | 调用方提供,最长 `127B` | `2123B` | `1800B` | 必须保持通用安全值 | + +NFT 与 Portfolio 的数值是生产 schema 静态编码结果,不是物理平台验证结果。若后续放宽,应使用 +caller-specific BLE limit、增加固定 path frame test,并分别做真机上传与应用验证;不能修改公共 +`fileWrite` 默认值,也不能把结果外推到 firmware resource archive。 + +### 平台 MTU 与 packet capacity + +不同原生库对 `mtu` 字段的语义并不一致,SDK 先归一化再取 `244B` 上限: + +| 平台链路 | 原生上报 | SDK 计算 | +| -------------------- | -------------------------------------------------------------- | -------------------------------------------------------- | +| React Native iOS | `maximumWriteValueLength(.withoutResponse) + 3` | 减 3 后取不超过 `244B` | +| React Native Android | ATT MTU | 减 3 后取不超过 `244B` | +| Electron macOS | Noble 上报 `maximumWriteValueLength(.withoutResponse)` payload | Electron main process 先补 3 为 ATT MTU,renderer 再减 3 | +| Electron Windows | Noble 上报 `MaxPduSize` payload | Electron main process 先补 3 为 ATT MTU,renderer 再减 3 | +| Electron Linux | Noble HCI 上报 ATT MTU | 保持原值,再减 3 | +| CLI LowLevel | 插件直接报告单次 characteristic write payload | 直接使用并限制到 `244B`,缺失时回退 `192B` | + +Electron 的归一化发生在 main process,`getDevice()` 与 MTU change event 对 renderer 始终暴露 +ATT MTU;main process 自己分包时也使用同一归一化值。此设计不依赖 Noble 升级。 + +## 2026-08-26 macOS CLI 当前实现验证 + +### 测试环境 + +- 设备:OneKey Pro 2 4B8F,firmware `1.0.1`。 +- Host:macOS,BLE,SDK worktree `perf/pro2-ble-transfer`。 +- 测试入口:本地构建 CLI `upload-wallpaper`;正式 CLI `1.2.1` 仅用于版本 preflight。 +- 输入:`604x1024` JPEG,文件大小 `106869B`,SHA-256 + `d34dcf3ad944e4c415bc81e9b4f3380561c3ef3fd34add7b7a56f8d9caa5a182`。 +- 设备条件:屏幕保持点亮、设备已解锁、流程不需要 PIN/确认。 +- 指标口径:Core `[FileWrite]` 日志为传输分段事实;CLI metrics 用于核对最终总量与进度。 + +### Packet 与 file chunk A/B + +前四行使用相同的 `882528B` host-asset package,仅改变 transport 参数;后两行依次记录 +`4KiB` block 的上一版 LZ4 encoder,以及当前自适应 `16KiB/8KiB` block encoder。 + +| Packet capacity | File chunk | Package bytes | Core transfer | Throughput | 结果状态 | +| --------------: | ---------: | ------------: | -----------------------------: | -------------------: | -------------- | +| `64B` | `1800B` | `882528` | `97.83s` | `8.81 KiB/s` | baseline 成功 | +| `192B` | `1800B` | `882528` | `69.81s` | `12.35 KiB/s` | 成功 | +| `192B` | `1960B` | `882528` | `64.05s` | `13.46 KiB/s` | 成功 | +| `244B` | `1960B` | `882528` | `50.64s` / `52.53s` | 平均 `16.72 KiB/s` | 两次成功 | +| `244B` | `1960B` | `832131` | `49.20s` | `16.34 KiB/s` | 上一版成功应用 | +| `244B` | `1960B` | `750295` | `51.30s` / `47.40s` / `52.13s` | 中位数 `15.12 KiB/s` | 三次成功应用 | + +当前自适应版本的 CLI 独立口径为 `51.12s` / `47.28s` / `51.96s`,中位数 `51.12s`;它与 +Core 的计时起止点略有差异,不应混在同一列做 A/B。相对最初 `64B/1800B` baseline,当前 +实现的 Core 中位传输时间缩短约 `47.6%`,中位吞吐提升约 `71.6%`。 + +`16KiB` 版本相对 `4KiB` 版本把 package 减少 `81836B`(`9.8%`),file-write ACK 从 `425` +次降为 `383` 次(`9.9%`)。但 `4KiB` 只记录过一次 `49.20s`,`16KiB` 三次结果受 BLE +response latency 波动影响,其中两次比该单次结果慢,不能据此宣称端到端稳定提升 `9.8%`。 +可确认的收益是相同链路条件下减少发送 bytes 和串行 ACK 数;耗时收益需要更多交错 A/B 才能 +从无线链路噪声中分离。 + +### V2-first A/B + +同一台设备的 `get-state` 冷连接对比结果: + +| Probe 顺序 | 总耗时 | +| ---------------------------------------------- | --------: | +| V1-first,失败后重连并探测 V2 | `13.913s` | +| method contract 明确 V2-only,直接 expected V2 | `10.572s` | + +V2-first 节省 `3.341s`。该优化来源是 `BaseMethod.getSupportedProtocols()` 的明确契约,不使用 +BLE name、设备型号或 PID 推导协议。 + +### 分段耗时与当前瓶颈 + +当前 `750295B` package 三次上传的 Core 日志为: + +| 指标 | 结果 | +| ------------------------------- | -------------------------------------------------: | +| File transfer | `51.30s` / `47.40s` / `52.13s` | +| Host complete-frame write total | `0.90s` / `0.90s` / `1.02s` | +| Firmware response wait total | `50.24s` / `46.31s` / `50.92s` | +| Measured attempts | 每次均为 `383` | +| Timeout retry | 三次均为 `0` | +| 最终状态 | 三次均完成接收、校验、解包并应用为 `wallpaper.bin` | + +response wait 占传输时间约 `98%`。每次 ACK 的平均 response wait 在三轮之间约为 +`121-133ms`,足以覆盖减少 42 次 ACK 所带来的理论收益。因此在当前 firmware 的串行 +`FilesystemFileWrite -> FilesystemFile(processed_byte)` 模型下,继续减少 JS write delay 的收益很小; +显著提升需要改变 ACK 粒度、允许安全的多请求关联,或扩大 firmware frame/FIFO 边界。 + +### BLE firmware connection-interval A/B + +This experiment kept the SDK packet capacity (`244B`), file chunk (`1960B`), package +(`750295B`), PHY, DLE, and serial request/response model unchanged. The only production candidate +in the BLE firmware was changing the preferred maximum connection interval from `30ms` to `15ms`. +The firmware was built with the CI-matching Arm GNU Toolchain `15.2.1`, loaded through J-Link at +`4000kHz`, and started through the existing MBR/SoftDevice vector. The tested image was an unsigned +debug image; it is test evidence, not a signed release artifact. + +RTT established the link-level facts: + +| Link event | `30ms` baseline | `15ms` candidate | +| --------------------------------- | ---------------------------------------------------- | ------------------------------------------------ | +| Initial central-selected interval | `24` units (`30ms`) | `24` units (`30ms`) | +| Peripheral parameter update | Not required because `30ms` was inside the old range | After about `5.28s`: min/max `12` units (`15ms`) | +| Slave latency | `0` | `0` | +| PHY | `2M` TX / `2M` RX | `2M` TX / `2M` RX | +| Effective data length | `251B` TX / `251B` RX, `2120us` | `251B` TX / `251B` RX, `2120us` | + +The same CLI command and wallpaper package produced: + +| Firmware setting | CLI transfer time | CLI throughput | Core response wait | Attempts | Result | +| -------------------------- | ----------------: | -------------: | -----------------: | -------: | ------- | +| max interval `30ms` | `51.51s` | `14.22 KiB/s` | `51.52s` | `383` | Success | +| max interval `15ms`, run 1 | `35.96s` | `20.38 KiB/s` | `35.98s` | `383` | Success | +| max interval `15ms`, run 2 | `36.23s` | `20.22 KiB/s` | `36.28s` | `383` | Success | + +The two `15ms` runs had a median transfer time of about `36.10s` and median throughput of about +`20.30 KiB/s`. Against the single `30ms` control run, this reduced transfer time by about `29.9%` +and increased throughput by about `42.8%`. Mean response wait per request fell from about `134.5ms` +to `94.0-94.7ms`; host writes remained below `0.3s`, so the improvement came from link scheduling, +not JavaScript write throughput. + +Reducing `FIRST_CONN_PARAMS_UPDATE_DELAY` from `5s` to `1s` was also tested. RTT confirmed that the +link reached `15ms` about `1.27s` after connection, but completed runs ranged from `35.82s` to +`39.71s`, with no stable improvement over the two `5s` runs. The `1s` change was therefore reverted +to avoid overlapping the connection-parameter procedure with security, DLE, and PHY negotiation. + +This is a macOS CLI result only. The `15ms` GAP preference is firmware-wide, but each iOS, Android, +Windows, or macOS central may select or reject connection parameters differently; those platforms +still require physical regression testing. A `7.5ms` fixed interval was not selected because it +would materially increase cross-platform compatibility and power-risk without evidence from those +centrals. With `15ms`, `2M PHY`, `251B` DLE, zero slave latency, and a `15ms` BLE event-length budget, +the next major limit remains the one-response-per-`FilesystemFileWrite` firmware/main-MCU path. + +### Protocol V2 bootloader firmware-update baseline and metric contract + +The same Pro 2 4B8F was placed in bootloader mode and updated over BLE with locally built and +signed P1/P2 application artifacts. The device started and ended the run on firmware `1.0.1`, +bootloader `1.0.0`, and BLE firmware `1.0.20`. + +| Metric | Result | +| --------------------------- | -----------------------------: | +| Total staged bytes | `2,440,562B` | +| Transfer phase | `145.62s` | +| Average transfer throughput | `16.37 KiB/s` | +| Device install phase | `13.65s` | +| End-to-end CLI duration | `211.96s` | +| SDK transfer retries | `0` | +| Final state | Normal mode; versions verified | + +The first progress sample was about `9.16 KiB/s`; later samples stabilized around +`15.7-17.0 KiB/s`. Consumers should therefore wait for at least `2s` and `64KiB` before showing an +ETA. This avoids presenting the connection and first-write warm-up as a stable estimate. + +`FIRMWARE_PROGRESS` now treats `transferredBytes`, `totalBytes`, `rateBytesPerSecond`, and +`elapsedMs` as one batch-level metric stream. The clock and byte numerator do not reset when the +update moves from P1 to P2, another component, or a resource package. Recovery and retry time stays +inside the elapsed value because it is time the user actually waits. The terminal `100%` event also +carries the final metric snapshot, so App consumers do not lose the completed transfer when the +install phase begins. + +The SDK reports measured transfer facts only. ETA formatting, warm-up policy, and the distinction +between active duration and wall-clock workflow duration belong to the App layer. This keeps the +same metric contract available to iOS, Android, Windows, and macOS without adding platform-specific +transport behavior. + +### 无损压缩与画质边界 + +第一阶段的 LZ4 best-match encoder 把同一 RGB565 wallpaper package 从 `882528B` 降到 +`832131B`,减少 `50397B`(约 `5.7%`)。在此基础上,扩大 independently compressed block +可以利用跨 `4KiB` 边界的重复像素,并减少 block index 开销: + +| LZ4 block | Package bytes | Block count | Package build median | 相对 `4KiB` | +| --------: | ------------: | ----------: | -------------------: | ----------: | +| `4KiB` | `832131` | `303` | `53.60ms` | baseline | +| `8KiB` | `787184` | `152` | `54.83ms` | `-5.4%` | +| `16KiB` | `750295` | `76` | `51.55ms` | `-9.8%` | + +`16KiB` 离线 package 的独立 raw-LZ4 decoder 输出与原始 `1237004B` RGB565 data 逐字节一致, +二者 SHA-256 均为 `f315fef8198114ef1d037fc8a2fa3f03d940150b96f6d2b26402bc3787fe57c4`。 +基准图的最大 compressed block 为 `13401B`,低于 firmware 的 `16384B` buffer 上限。 + +不可压缩的 `16KiB` raw block 可能编码成大于 `16384B` 的 LZ4 block。当前 SDK 因此先尝试 +`16KiB`,只要该 entry 的任一 compressed block 超限,就把该 entry 整体重新编码为 `8KiB`。 +Firmware `1.0.1` 接受 `block_size_log2=9..14`,且 work buffer 已按 `16KiB` 分配;`8KiB` +fallback 的 LZ4 worst-case 大小仍低于该 buffer。自动化测试覆盖 preferred、fallback 和两条路径的 +byte-for-byte round trip。 + +以下是 search depth 的补充对比;它不如 block size 有效: + +| Search depth | Package bytes | Package build | +| -----------: | ------------: | ------------: | +| `64` | `832131` | 约 `61ms` | +| `256` | `831944` | 约 `66-69ms` | + +`4KiB` 下 `256` 只再减少 `187B`;`16KiB` 下把 depth 从 `64` 提升到 `1024` 也只再减少 +`945B`,没有足够收益,当前保留 `64`。这项优化只改变 raw LZ4 token 和 block 边界选择, +firmware 解压后的 RGB565 bytes 与压缩前逐字节一致。 + +断开 USB 后,Pro2 4B8F firmware `1.0.1` 对 `16KiB` package 连续三次完成 BLE 上传、设备端 +校验、解包和应用;没有 timeout/retry。该结果验证了 firmware compatibility 和数据完整流程, +不代表 iOS、Android 或 Windows 的无线性能结果。 + +当前实现没有关闭 dithering。Dithering 是 RGB888 转 RGB565 前的误差扩散,用细小的像素变化 +减少渐变色带;关闭后虽然更容易压缩,但可能出现天空、阴影等区域的 banding,不能称为同画质方案。 + +### 平台验证矩阵 + +| 平台 | 当前代码覆盖 | 自动化验证 | 物理验证 | +| -------------------- | ----------------------------------------------------------------------------------- | --------------------------------------- | ---------------- | +| macOS CLI LowLevel | `244B`、`1960B` 固定 wallpaper path、V2-first、无损 LZ4 | 已覆盖 packet/fallback 与 Core 文件写入 | 已完成,本节结果 | +| React Native iOS | MTU refresh、`244B` ceiling、默认 `withoutResponse`、共享 Core 优化 | RN strategy/link tests 已通过 | 本轮未执行 | +| React Native Android | MTU `517` request、`244B` ceiling、High connection priority、默认 `withoutResponse` | RN strategy/link tests 已通过 | 本轮未执行 | +| Electron macOS | Noble payload 到 ATT MTU 归一化、`244B`、零 high-throughput pacing | platform normalization test 已通过 | 本轮未执行 | +| Electron Windows | MaxPduSize 到 ATT MTU 归一化、已配对后 `withoutResponse` | platform normalization test 已通过 | 本轮未执行 | +| Electron Linux | ATT MTU 保持原值 | platform normalization test 已通过 | 本轮未执行 | + +当前自动化记录:传输/RN/CLI 聚焦集合 `6 suites / 158 tests` 全通过;Core host package、Protocol V2 +frame/file-write 与 Electron MTU 集合 `6 suites / 384 passed / 4 skipped`。`hd-transport`、LowLevel、Core、 +React Native transport、Electron transport、Web Device transport 与 CLI 均已完成 build。构建只出现 +仓库既有的 external dependency、source map、mixed exports、circular dependency,以及 Electron +未使用参数 warning,没有 build error。App package 发布、安装和 App 真机验证不属于本页已完成结论。 + +## 2026-05-11 iOS React Native 历史基线 + +以下结果只代表当日 OneKey Pro2、iOS 真机、React Native Demo 与 `react-native-ble-plx` 组合。 +当时 SDK 普通 BLE file chunk 为 `1800B`,测试目标是区分 GATT raw write、RN 写队列与 +`FilesystemFileWrite` 串行 ACK。 + +### 第一轮:SDK 层 speed profile 这一轮通过 RN Demo 的 Pro2 BLE 固件升级入口测试不同 transport pacing 参数。测试结果如下: @@ -47,7 +308,7 @@ 也就是说,当前速率更像是每次 `FilesystemFileWrite` 都要等待设备完成处理并返回 `processed_byte`,下一块才能继续发送。这个串行 ACK 模型会把吞吐限制在“单块大小 / 单轮回包耗时”。 -## 第二轮:BLEDiag raw write 测试 +### 第二轮:BLEDiag raw write 测试 这一轮绕过 SDK,仅使用 `react-native-ble-plx` 直接向 OneKey BLE write characteristic 写入数据,用于测 BLE GATT 上行写入天花板。 @@ -95,10 +356,10 @@ write mode: writeWithoutResponse 固定 flush pause: 0ms ``` -当前极限测试配置不再为缺失 MTU 提供兼容分包回退。iOS 连接后通过 -`requestMTU(247)` 刷新 `react-native-ble-plx` 的设备快照;该调用不会要求 iOS 重新协商, -但会返回由 CoreBluetooth 最大无响应写长度换算出的 MTU。刷新失败时应直接暴露连接错误, -避免用推测容量掩盖问题。 +React Native 只在取得有效 MTU 后计算 packet capacity;缺失或无效 MTU 不猜测 `244B`。 +iOS 连接后通过 `requestMTU(247)` 刷新 `react-native-ble-plx` 的设备快照;该调用不会要求 iOS +重新协商,但会返回由 CoreBluetooth 最大无响应写长度换算出的 MTU。CLI LowLevel 不具备平台 +MTU 快照时使用经过当前设备验证的 `192B` fallback,而不是旧的 `64B` fallback。 Android 默认同样使用经过验证的 `244B` 上限。更大的包长只能通过显式 BLE tuning 配置用于 特定手机、固件和设备组合的真机实验,不能作为生产默认值。 @@ -116,7 +377,11 @@ writeWithResponse 作为提速方案 目前更值得关注的是协议层 ACK 粒度,而不是继续微调 pacing。 -`FirmwareUpdateV4` 的固定 staging 路径已按生产 Protocol V2 schema 测量 protobuf 和帧头开销。最长路径 `vol0:/application_p1.bin` 在 uint32 最大 offset/total_size 下最多容纳 `1988B` 数据且完整帧正好为 `2048B`;SDK 使用 `1960B`,保留 `28B` 余量。普通 FileWrite 和资源路径仍保持 `1800B`,因为允许的路径最长可达 `127B`。 +`FirmwareUpdateV4` 的固定 staging 路径已按生产 Protocol V2 schema 测量 protobuf 和帧头开销。 +最长路径 `vol0:/application_p1.bin` 在 uint32 最大 offset/total size 下最多容纳 `1988B` data, +且完整 frame 正好为 `2048B`;SDK 使用 `1960B`,保留 `28B` 余量。固定 wallpaper package +path 使用 `1960B` 时完整 frame 为 `2028B`,保留 `20B`。普通 FileWrite 和未知资源路径仍保持 +`1800B`,因为允许的 path 最长可达 `127B`,此时 `1960B` data 会生成 `2123B` frame。 不能直接测试 `2400B`、`3072B` 或 `4096B`:这些数据块加上 protobuf 和 V2 帧开销后必然超过当前 Transport 的 `2048B` 限制。 diff --git a/packages/core/__tests__/device-lifecycle-events.test.ts b/packages/core/__tests__/device-lifecycle-events.test.ts index 26a38907c..8ab01822f 100644 --- a/packages/core/__tests__/device-lifecycle-events.test.ts +++ b/packages/core/__tests__/device-lifecycle-events.test.ts @@ -8,6 +8,7 @@ import { isProtocolV2PeerRemovedPairingError, isRetryableBleConnectionError, isRetryableBleProtocolV2ProbeError, + resolveBleConnectProtocol, } from '../src/core'; import { DataManager } from '../src/data-manager'; import TransportManager from '../src/data-manager/TransportManager'; @@ -71,6 +72,19 @@ describe('public device lifecycle events', () => { jest.restoreAllMocks(); }); + test('prefers Protocol V2 only when the method contract is explicitly V2-only', () => { + const createMethod = (protocols: readonly ('V1' | 'V2')[], connectProtocol?: 'V1' | 'V2') => + ({ + payload: { connectProtocol }, + getSupportedProtocols: () => protocols, + } as never); + + expect(resolveBleConnectProtocol(createMethod(['V2']))).toBe('V2'); + expect(resolveBleConnectProtocol(createMethod(['V1']))).toBeUndefined(); + expect(resolveBleConnectProtocol(createMethod(['V1', 'V2']))).toBeUndefined(); + expect(resolveBleConnectProtocol(createMethod(['V2'], 'V1'))).toBe('V1'); + }); + test('registers the shared device lifecycle listeners exactly once', async () => { jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never); core = initCore(); diff --git a/packages/core/__tests__/pro2HostAssetPackage.test.ts b/packages/core/__tests__/pro2HostAssetPackage.test.ts index 446e1ed08..6ed0468fb 100644 --- a/packages/core/__tests__/pro2HostAssetPackage.test.ts +++ b/packages/core/__tests__/pro2HostAssetPackage.test.ts @@ -5,6 +5,82 @@ import { supportsPro2HostAssetPackage, } from '../src/utils/pro2HostAssetPackage'; +const decodeRawLz4Block = (compressed: Uint8Array, expectedLength: number) => { + const output = new Uint8Array(expectedLength); + let inputOffset = 0; + let outputOffset = 0; + + const readLength = (initialLength: number) => { + let length = initialLength; + if (length === 15) { + let extension = 255; + while (extension === 255) { + extension = compressed[inputOffset]; + inputOffset += 1; + length += extension; + } + } + return length; + }; + + while (inputOffset < compressed.byteLength) { + const token = compressed[inputOffset]; + inputOffset += 1; + const literalLength = readLength(token >>> 4); + output.set(compressed.subarray(inputOffset, inputOffset + literalLength), outputOffset); + inputOffset += literalLength; + outputOffset += literalLength; + if (inputOffset >= compressed.byteLength) break; + + const matchOffset = compressed[inputOffset] | (compressed[inputOffset + 1] << 8); + inputOffset += 2; + const matchLength = readLength(token & 0x0f) + 4; + for (let index = 0; index < matchLength; index += 1) { + output[outputOffset] = output[outputOffset - matchOffset]; + outputOffset += 1; + } + } + + expect(outputOffset).toBe(expectedLength); + return output; +}; + +const decodeFirstPackageEntry = (packageData: Uint8Array, rawLength: number) => { + const containerHeaderSize = 0x5f90; + const archive = packageData.subarray(containerHeaderSize); + const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); + const compressedOffset = archiveView.getUint32(42 + 0x100, true); + const compressed = archive.subarray(compressedOffset); + const compressedView = new DataView( + compressed.buffer, + compressed.byteOffset, + compressed.byteLength + ); + const blockCount = compressedView.getUint16(0, true); + const blockSize = 1 << compressedView.getUint16(2, true); + let blockOffset = 8 + blockCount * 4; + const decodedBlocks: Uint8Array[] = []; + + for (let index = 0; index < blockCount; index += 1) { + const compressedLength = compressedView.getUint32(8 + index * 4, true); + const expectedLength = Math.min(blockSize, rawLength - index * blockSize); + decodedBlocks.push( + decodeRawLz4Block( + compressed.subarray(blockOffset, blockOffset + compressedLength), + expectedLength + ) + ); + blockOffset += compressedLength; + } + + const decoded = new Uint8Array(rawLength); + decodedBlocks.reduce((offset, block) => { + decoded.set(block, offset); + return offset + block.byteLength; + }, 0); + return decoded; +}; + describe('Pro2 host asset package', () => { test('builds the unsigned RESOURCE container and LZ4-blocked archive expected by firmware', () => { const raw = new TextEncoder().encode('123456789'); @@ -40,12 +116,42 @@ describe('Pro2 host asset package', () => { const compressedOffset = archiveView.getUint32(42 + 0x100, true); expect(archiveView.getUint16(compressedOffset, true)).toBe(1); - expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(12); + expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(14); expect(archiveView.getUint32(compressedOffset + 4, true)).toBe(0); expect(archiveView.getUint32(compressedOffset + 8, true)).toBe(10); expect(payload.subarray(compressedOffset + 12)).toEqual(new Uint8Array([0x90, ...raw])); }); + test('round-trips multi-block data byte-for-byte with best-match compression', () => { + const raw = Uint8Array.from({ length: 16_384 * 3 + 137 }, (_, index) => { + const column = index % 604; + const row = Math.floor(index / 604); + return (column * 31 + row * 17) & 0xff; + }); + + const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]); + + expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw); + }); + + test('falls back to 8 KiB blocks when a compressed 16 KiB block exceeds firmware capacity', () => { + let state = 0x12345678; + const raw = Uint8Array.from({ length: 16_384 }, () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return state & 0xff; + }); + + const packageData = buildPro2HostAssetPackage([{ name: 'wallpaper.bin', data: raw }]); + const archive = packageData.subarray(0x5f90); + const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); + const compressedOffset = archiveView.getUint32(42 + 0x100, true); + + expect(archiveView.getUint16(compressedOffset + 2, true)).toBe(13); + expect(decodeFirstPackageEntry(packageData, raw.byteLength)).toEqual(raw); + }); + test.each([ ['1.0.0', false], ['1.0.1-beta.1', false], diff --git a/packages/core/__tests__/protocol-v2.test.ts b/packages/core/__tests__/protocol-v2.test.ts index 774eb1665..8b18badd9 100644 --- a/packages/core/__tests__/protocol-v2.test.ts +++ b/packages/core/__tests__/protocol-v2.test.ts @@ -255,6 +255,9 @@ describe('DeviceUploadWallpaper', () => { }); test('uploads and applies the fixed wallpaper package on firmware 1.0.1', async () => { + const getSettingsSpy = jest + .spyOn(DataManager, 'getSettings') + .mockReturnValue('react-native' as any); const typedCall = jest.fn().mockImplementation((request, _response, params) => { if (request === 'FilesystemDirMake') return { message: {} }; if (request === 'FilesystemFileWrite') { @@ -282,8 +285,13 @@ describe('DeviceUploadWallpaper', () => { (method as any).device = device; method.postMessage = jest.fn(); - method.init(); - const result = await method.run(); + let result; + try { + method.init(); + result = await method.run(); + } finally { + getSettingsSpy.mockRestore(); + } const fileWrites = typedCall.mock.calls.filter(call => call[0] === 'FilesystemFileWrite'); expect(new Set(fileWrites.map(call => call[2].file.path))).toEqual( @@ -292,6 +300,7 @@ describe('DeviceUploadWallpaper', () => { expect(fileWrites[0][2].file.data.subarray(0, 4)).toEqual( new Uint8Array([0x4f, 0x4b, 0x50, 0x50]) ); + expect(fileWrites[0][2].file.data).toHaveLength(1960); expect(typedCall).toHaveBeenLastCalledWith('DeviceSettingsSet', 'Success', { settings: { wallpaper_path: 'vol1:/wallpapers/wallpaper.okpkg' }, }); @@ -7184,7 +7193,14 @@ describe('Protocol V2 firmware update targets', () => { true ); expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled(); - expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData'); + expect(method.postProgressMessage).toHaveBeenCalledWith( + 100, + 'transferData', + expect.objectContaining({ + transferredBytes: 5, + totalBytes: 5, + }) + ); expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1); }); @@ -7258,7 +7274,14 @@ describe('Protocol V2 firmware update targets', () => { expect.objectContaining({ processedSize: 2, totalSize: 3 }) ); expect(method.postProgressMessage).toHaveBeenCalledTimes(1); - expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData'); + expect(method.postProgressMessage).toHaveBeenCalledWith( + 100, + 'transferData', + expect.objectContaining({ + transferredBytes: 3, + totalBytes: 3, + }) + ); expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1); expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }], @@ -8578,26 +8601,43 @@ describe('Protocol V2 firmware update targets', () => { (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined); (method as any).protocolV2StartFirmwareUpdate = jest.fn(); (method as any).waitForProtocolV2FirmwareUpdateComplete = jest.fn(); + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(5_000); - await (method as any).executeProtocolV2SourceUpdate({ - installSources: [], - resourceSources: [ - { - name: 'images.okpkg', - source: { - size: 3, - readAt: jest.fn(), - close: jest.fn(), + try { + await (method as any).executeProtocolV2SourceUpdate({ + installSources: [], + resourceSources: [ + { + name: 'images.okpkg', + source: { + size: 3, + readAt: jest.fn(), + close: jest.fn(), + }, + devicePath: 'vol0:/bundles/images/images.okpkg', }, - devicePath: 'vol0:/bundles/images/images.okpkg', - }, - ], - }); + ], + }); + } finally { + dateNowSpy.mockRestore(); + } expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1); expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith( - expect.objectContaining({ filePath: 'vol0:/bundles/images/images.okpkg' }) + expect.objectContaining({ + filePath: 'vol0:/bundles/images/images.okpkg', + transferStartedAt: 1_000, + }) ); + expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'transferData', { + transferredBytes: 3, + totalBytes: 3, + rateBytesPerSecond: 1, + elapsedMs: 4_000, + }); expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith( 'vol0:/bundles/images/images.okpkg', 3 diff --git a/packages/core/__tests__/protocolV2FileWrite.test.ts b/packages/core/__tests__/protocolV2FileWrite.test.ts index 235aa5450..f83c3fb08 100644 --- a/packages/core/__tests__/protocolV2FileWrite.test.ts +++ b/packages/core/__tests__/protocolV2FileWrite.test.ts @@ -10,6 +10,46 @@ jest.mock('../src/data/config', () => ({ })); describe('writeProtocolV2File', () => { + test('allows a verified caller-specific BLE chunk limit', async () => { + const getSettingsSpy = jest + .spyOn(DataManager, 'getSettings') + .mockReturnValue('react-native' as any); + const isBleConnectSpy = jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true); + const data = new Uint8Array(1961); + const typedCall = jest.fn().mockResolvedValue({ message: {} }); + + try { + await writeProtocolV2File({ + commands: { typedCall } as any, + path: 'vol1:/wallpapers/wallpaper.okpkg', + data, + bleChunkSizeLimit: 1960, + }); + } finally { + getSettingsSpy.mockRestore(); + isBleConnectSpy.mockRestore(); + } + + expect(typedCall).toHaveBeenCalledTimes(2); + expect(typedCall.mock.calls[0][2].file.data).toEqual(data.slice(0, 1960)); + expect(typedCall.mock.calls[1][2].file.data).toEqual(data.slice(1960)); + }); + + test('does not apply the BLE-only limit to WebUSB', async () => { + const data = new Uint8Array(1961); + const typedCall = jest.fn().mockResolvedValue({ message: {} }); + + await writeProtocolV2File({ + commands: { typedCall } as any, + path: 'vol1:/wallpapers/wallpaper.okpkg', + data, + bleChunkSizeLimit: 1960, + }); + + expect(typedCall).toHaveBeenCalledTimes(1); + expect(typedCall.mock.calls[0][2].file.data).toEqual(data); + }); + test('按分片写入并只在首片设置 overwrite', async () => { const data = new Uint8Array(4097); const typedCall = jest.fn().mockResolvedValue({ message: {} }); diff --git a/packages/core/src/api/FirmwareUpdateV4.ts b/packages/core/src/api/FirmwareUpdateV4.ts index dbad6aa2a..2116ac495 100644 --- a/packages/core/src/api/FirmwareUpdateV4.ts +++ b/packages/core/src/api/FirmwareUpdateV4.ts @@ -2114,6 +2114,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod 0) { - this.postProgressMessage(100, 'transferData'); + const elapsedMs = Math.max(Date.now() - transferStartedAt, 0); + this.postProgressMessage(100, 'transferData', { + transferredBytes: totalSize, + totalBytes: totalSize, + rateBytesPerSecond: elapsedMs > 0 ? Math.round((totalSize / elapsedMs) * 1000) : undefined, + elapsedMs, + }); } if (stagedInstallTargets.length === 0) { return; @@ -2173,16 +2182,17 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod 0 ? Math.round((chunkEnd / elapsedMs) * 1000) : undefined, + elapsedMs > 0 ? Math.round((transferredBytes / elapsedMs) * 1000) : undefined, elapsedMs, }); } diff --git a/packages/core/src/api/helpers/protocolV2FileWrite.ts b/packages/core/src/api/helpers/protocolV2FileWrite.ts index 800e76457..1fb8df861 100644 --- a/packages/core/src/api/helpers/protocolV2FileWrite.ts +++ b/packages/core/src/api/helpers/protocolV2FileWrite.ts @@ -36,6 +36,8 @@ export type ProtocolV2FileWriteOptions = { chunkSize?: number; chunkLen?: number; chunkSizeLimit?: number; + /** BLE-only limit for a caller whose fixed short path has a verified larger frame budget. */ + bleChunkSizeLimit?: number; overwrite?: boolean; append?: boolean; uiPercentage?: number; @@ -114,11 +116,15 @@ export function isProtocolV2ResponseTimeout(error: unknown) { ); } -function getProtocolV2FileChunkLimit() { +function getProtocolV2FileChunkLimit(bleChunkSizeLimit?: number) { const env = DataManager.getSettings('env'); - return env && DataManager.isBleConnect(env) - ? PROTOCOL_V2_BLE_FILE_CHUNK_SIZE - : PROTOCOL_V2_WEBUSB_FILE_CHUNK_SIZE; + if (env && DataManager.isBleConnect(env)) { + const configuredLimit = Number(bleChunkSizeLimit); + return Number.isFinite(configuredLimit) && configuredLimit > 0 + ? Math.floor(configuredLimit) + : PROTOCOL_V2_BLE_FILE_CHUNK_SIZE; + } + return PROTOCOL_V2_WEBUSB_FILE_CHUNK_SIZE; } async function dataToUint8Array(data: ProtocolV2FileWriteData): Promise { @@ -178,7 +184,7 @@ export async function writeProtocolV2File(options: ProtocolV2FileWriteOptions) { ); } - const defaultChunkSizeLimit = getProtocolV2FileChunkLimit(); + const defaultChunkSizeLimit = getProtocolV2FileChunkLimit(options.bleChunkSizeLimit); const configuredChunkSizeLimit = Number(options.chunkSizeLimit); const chunkSizeLimit = Number.isFinite(configuredChunkSizeLimit) && configuredChunkSizeLimit > 0 diff --git a/packages/core/src/api/protocol-v2/DeviceUploadWallpaper.ts b/packages/core/src/api/protocol-v2/DeviceUploadWallpaper.ts index eed5095a8..b9172dfe5 100644 --- a/packages/core/src/api/protocol-v2/DeviceUploadWallpaper.ts +++ b/packages/core/src/api/protocol-v2/DeviceUploadWallpaper.ts @@ -49,6 +49,7 @@ const SAFE_FILE_NAME = /^[A-Za-z0-9_-]+(?:\.bin)?$/; const DEVICE_SETTINGS_SET_MESSAGE_TYPE = 60412; const FILESYSTEM_FILE_WRITE_MESSAGE_TYPE = 60805; const FILESYSTEM_DIR_MAKE_MESSAGE_TYPE = 60809; +const WALLPAPER_PACKAGE_BLE_CHUNK_SIZE = 1960; const Log = getLogger(LoggerNames.Method); function normalizeFileName(fileName: string | undefined, data: Uint8Array): string { @@ -130,7 +131,7 @@ export default class DeviceUploadWallpaper extends BaseMethod = (time?: number) => T; // eslint-disable-next-line @typescript-eslint/require-await const ensureConnected = async ( diff --git a/packages/core/src/utils/pro2HostAssetPackage.ts b/packages/core/src/utils/pro2HostAssetPackage.ts index 413288d47..363593314 100644 --- a/packages/core/src/utils/pro2HostAssetPackage.ts +++ b/packages/core/src/utils/pro2HostAssetPackage.ts @@ -37,7 +37,9 @@ const ARCHIVE_ENTRY_SIZE = 296; const ARCHIVE_ENTRY_NAME_MAX_LENGTH = 255; const ARCHIVE_COMPRESS_LZ4_BLOCKED = 1; const ARCHIVE_ALIGNMENT = 4; -const LZ4_BLOCK_SIZE_LOG2 = 12; +const LZ4_PREFERRED_BLOCK_SIZE_LOG2 = 14; +const LZ4_FALLBACK_BLOCK_SIZE_LOG2 = 13; +const LZ4_COMPRESSED_BLOCK_SIZE_MAX = 1 << 14; export type Pro2HostAssetPackageEntry = { name: string; @@ -101,7 +103,7 @@ const LZ4_MAX_OFFSET = 0xffff; const LZ4_LENGTH_MASK = 15; const LZ4_HASH_LOG = 16; const LZ4_HASH_MULTIPLIER = 2654435761; -const LZ4_SKIP_TRIGGER = 6; +const LZ4_MAX_SEARCH_DEPTH = 64; function writeExtendedLength(output: Uint8Array, offset: number, length: number): number { let remaining = length; @@ -178,7 +180,11 @@ function emitLastLiterals( return copyBytes(output, nextOffset, input, anchor, literalLength); } -function compressRawLz4Block(input: Uint8Array, hashTable: Uint32Array): Uint8Array { +function compressRawLz4Block( + input: Uint8Array, + hashTable: Uint32Array, + matchChain: Int32Array +): Uint8Array { const output = new Uint8Array(input.byteLength + Math.floor(input.byteLength / 255) + 16); const inputView = new DataView(input.buffer, input.byteOffset, input.byteLength); const matchFindLimit = input.byteLength - LZ4_MATCH_FIND_LIMIT; @@ -186,42 +192,65 @@ function compressRawLz4Block(input: Uint8Array, hashTable: Uint32Array): Uint8Ar let anchor = 0; let inputOffset = 0; let outputOffset = 0; - let searchMatchCount = 1 << LZ4_SKIP_TRIGGER; hashTable.fill(0); while (inputOffset < matchFindLimit) { const sequence = inputView.getUint32(inputOffset, true); const hash = Math.imul(sequence, LZ4_HASH_MULTIPLIER) >>> (32 - LZ4_HASH_LOG); - const candidate = hashTable[hash] - 1; + let candidate = hashTable[hash] - 1; + matchChain[inputOffset] = candidate; hashTable[hash] = inputOffset + 1; - const hasMatch = !( - candidate < 0 || - inputOffset - candidate > LZ4_MAX_OFFSET || - inputView.getUint32(candidate, true) !== sequence - ); - if (!hasMatch) { - inputOffset += searchMatchCount >> LZ4_SKIP_TRIGGER; - searchMatchCount += 1; - } else { - searchMatchCount = 1 << LZ4_SKIP_TRIGGER; - let matchEnd = inputOffset + LZ4_MIN_MATCH; - let reference = candidate + LZ4_MIN_MATCH; - while (matchEnd < matchExtendLimit && input[matchEnd] === input[reference]) { - matchEnd += 1; - reference += 1; + let bestCandidate = -1; + let bestMatchEnd = inputOffset; + let searchDepth = 0; + while ( + candidate >= 0 && + inputOffset - candidate <= LZ4_MAX_OFFSET && + searchDepth < LZ4_MAX_SEARCH_DEPTH + ) { + if (inputView.getUint32(candidate, true) === sequence) { + let matchEnd = inputOffset + LZ4_MIN_MATCH; + let reference = candidate + LZ4_MIN_MATCH; + while (matchEnd < matchExtendLimit && input[matchEnd] === input[reference]) { + matchEnd += 1; + reference += 1; + } + if (matchEnd > bestMatchEnd) { + bestCandidate = candidate; + bestMatchEnd = matchEnd; + } } + candidate = matchChain[candidate]; + searchDepth += 1; + } + + if (bestCandidate < 0) { + inputOffset += 1; + } else { + const matchStart = inputOffset; outputOffset = emitSequence( output, outputOffset, input, anchor, inputOffset - anchor, - inputOffset - candidate, - matchEnd - inputOffset - LZ4_MIN_MATCH + inputOffset - bestCandidate, + bestMatchEnd - inputOffset - LZ4_MIN_MATCH ); - inputOffset = matchEnd; + inputOffset = bestMatchEnd; anchor = inputOffset; + + for ( + let skippedOffset = matchStart + 1; + skippedOffset < inputOffset && skippedOffset < matchFindLimit; + skippedOffset += 1 + ) { + const skippedSequence = inputView.getUint32(skippedOffset, true); + const skippedHash = Math.imul(skippedSequence, LZ4_HASH_MULTIPLIER) >>> (32 - LZ4_HASH_LOG); + matchChain[skippedOffset] = hashTable[skippedHash] - 1; + hashTable[skippedHash] = skippedOffset + 1; + } } } @@ -234,27 +263,44 @@ function compressRawLz4Block(input: Uint8Array, hashTable: Uint32Array): Uint8Ar // The archive stores an 8-byte descriptor, one compressed-size value per // block, then the concatenated raw blocks. Blocks are independent so firmware // can validate and decompress them with bounded memory. -function encodeLz4Blocked(data: Uint8Array): Uint8Array { - const blockSize = 1 << LZ4_BLOCK_SIZE_LOG2; +function encodeLz4BlockedWithBlockSize( + data: Uint8Array, + blockSizeLog2: number +): Uint8Array | undefined { + const blockSize = 1 << blockSizeLog2; const blockCount = Math.ceil(data.byteLength / blockSize); const hashTable = new Uint32Array(1 << LZ4_HASH_LOG); + const matchChain = new Int32Array(blockSize); const blocks: Uint8Array[] = []; const header = new Uint8Array(8 + blockCount * 4); const headerView = new DataView(header.buffer); headerView.setUint16(0, blockCount, true); - headerView.setUint16(2, LZ4_BLOCK_SIZE_LOG2, true); + headerView.setUint16(2, blockSizeLog2, true); for (let index = 0; index < blockCount; index += 1) { const block = compressRawLz4Block( data.subarray(index * blockSize, Math.min((index + 1) * blockSize, data.byteLength)), - hashTable + hashTable, + matchChain ); + if (block.byteLength > LZ4_COMPRESSED_BLOCK_SIZE_MAX) return undefined; headerView.setUint32(8 + index * 4, block.byteLength, true); blocks.push(block); } return concatBytes([header, ...blocks]); } +function encodeLz4Blocked(data: Uint8Array): Uint8Array { + const preferred = encodeLz4BlockedWithBlockSize(data, LZ4_PREFERRED_BLOCK_SIZE_LOG2); + if (preferred) return preferred; + + const fallback = encodeLz4BlockedWithBlockSize(data, LZ4_FALLBACK_BLOCK_SIZE_LOG2); + if (!fallback) { + throw new Error('Pro2 host asset package LZ4 block exceeds the firmware buffer limit.'); + } + return fallback; +} + // OKAR integrity helpers // ---------------------- const CRC32_TABLE = (() => { diff --git a/packages/hd-cli/src/__tests__/noble-ble-plugin.test.ts b/packages/hd-cli/src/__tests__/noble-ble-plugin.test.ts index 5af6c55eb..d14cf0234 100644 --- a/packages/hd-cli/src/__tests__/noble-ble-plugin.test.ts +++ b/packages/hd-cli/src/__tests__/noble-ble-plugin.test.ts @@ -31,6 +31,7 @@ const createPeripheral = (id: string) => { const peripheral = Object.assign(new EventEmitter(), { id, state: 'connected', + mtu: null as number | null, advertisement: { localName: `OneKey Pro 2 ${id}`, serviceUuids: ['0001'], @@ -54,6 +55,15 @@ describe('Noble BLE plugin notification routing', () => { jest.clearAllMocks(); }); + test('normalizes the platform-specific Noble MTU to a safe write capacity', async () => { + const { resolveNobleProtocolV2PacketCapacity } = await import('../transports/nobleBlePlugin'); + + expect(resolveNobleProtocolV2PacketCapacity(null, 'darwin')).toBe(192); + expect(resolveNobleProtocolV2PacketCapacity(244, 'darwin')).toBe(244); + expect(resolveNobleProtocolV2PacketCapacity(247, 'linux')).toBe(244); + expect(resolveNobleProtocolV2PacketCapacity(512, 'win32')).toBe(244); + }); + test('does not enumerate Find My advertisements that expose FFFD', async () => { jest.useFakeTimers({ doNotFake: ['performance'] }); const oneKey = createPeripheral('onekey-device'); @@ -376,6 +386,33 @@ describe('Noble BLE plugin notification routing', () => { expect(wait).not.toHaveBeenCalled(); }); + test('uses the connected peripheral write capacity without padding', async () => { + const device = createPeripheral('device-a'); + device.peripheral.mtu = 244; + const noble = new EventEmitter() as EventEmitter & { + state: string; + startScanning: jest.Mock; + stopScanning: jest.Mock; + }; + noble.state = 'poweredOn'; + noble.startScanning = jest.fn((_services, _duplicates, callback) => { + callback?.(); + noble.emit('discover', device.peripheral); + }); + noble.stopScanning = jest.fn(callback => callback?.()); + jest.doMock('@stoprocent/noble', () => noble); + + const { createNobleBlePlugin } = await import('../transports/nobleBlePlugin'); + const plugin = createNobleBlePlugin(); + await plugin.init(); + await plugin.connect('device-a'); + + await plugin.send('device-a', 'aa'.repeat(245)); + + expect(plugin.getProtocolV2PacketCapacity?.('device-a')).toBe(244); + expect(device.write.write.mock.calls.map(([packet]) => packet.length)).toEqual([244, 1]); + }); + test('preserves a short final BLE packet without padding', async () => { const device = createPeripheral('device-a'); const noble = new EventEmitter() as EventEmitter & { diff --git a/packages/hd-cli/src/transports/nobleBlePlugin.ts b/packages/hd-cli/src/transports/nobleBlePlugin.ts index 8769f8a86..200201018 100644 --- a/packages/hd-cli/src/transports/nobleBlePlugin.ts +++ b/packages/hd-cli/src/transports/nobleBlePlugin.ts @@ -58,9 +58,27 @@ const DEVICE_SCAN_TIMEOUT = 8_000; const CONNECTION_TIMEOUT = 8_000; const SERVICE_DISCOVERY_TIMEOUT = 10_000; const BLE_CLEANUP_TIMEOUT = 100; -const BLE_PACKET_SIZE = 192; +const BLE_PACKET_SIZE_FALLBACK = 192; +const BLE_PACKET_SIZE_MAX = 244; +const ATT_WRITE_HEADER_SIZE = 3; const BLE_ENCRYPTION_ERROR_PATTERNS = [/encryption is insufficient/i, /insufficient encryption/i]; +export function resolveNobleProtocolV2PacketCapacity( + mtu: number | null | undefined, + platform: NodeJS.Platform = process.platform +) { + if (typeof mtu !== 'number' || !Number.isFinite(mtu) || mtu <= 0) { + return BLE_PACKET_SIZE_FALLBACK; + } + const reportedCapacity = Math.floor(mtu); + const payloadCapacity = + platform === 'linux' ? reportedCapacity - ATT_WRITE_HEADER_SIZE : reportedCapacity; + if (payloadCapacity <= 0) { + return BLE_PACKET_SIZE_FALLBACK; + } + return Math.min(payloadCapacity, BLE_PACKET_SIZE_MAX); +} + let noble: NobleModule | null = null; let nobleReadyPromise: Promise | null = null; const discoveredDevices = new Map(); @@ -479,6 +497,10 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin { await disconnectDevice(uuid); }, + getProtocolV2PacketCapacity(uuid: string) { + return resolveNobleProtocolV2PacketCapacity(connectedDevices.get(uuid)?.mtu); + }, + async send(uuid: string, data: string, options?: { withoutResponse?: boolean }) { const characteristics = deviceCharacteristics.get(uuid); if (!characteristics) { @@ -490,8 +512,9 @@ export function createNobleBlePlugin(): LowlevelTransportSharedPlugin { const buffer = Buffer.from(data, 'hex'); const withoutResponse = options?.withoutResponse ?? true; - for (let offset = 0; offset < buffer.length; offset += BLE_PACKET_SIZE) { - const chunk = buffer.subarray(offset, Math.min(offset + BLE_PACKET_SIZE, buffer.length)); + const packetCapacity = resolveNobleProtocolV2PacketCapacity(connectedDevices.get(uuid)?.mtu); + for (let offset = 0; offset < buffer.length; offset += packetCapacity) { + const chunk = buffer.subarray(offset, Math.min(offset + packetCapacity, buffer.length)); await writeCharacteristic(characteristics.write, chunk, withoutResponse); } }, diff --git a/packages/hd-transport-electron/src/__tests__/ble-packet-capacity.test.ts b/packages/hd-transport-electron/src/__tests__/ble-packet-capacity.test.ts index cda802c48..15aaa3da3 100644 --- a/packages/hd-transport-electron/src/__tests__/ble-packet-capacity.test.ts +++ b/packages/hd-transport-electron/src/__tests__/ble-packet-capacity.test.ts @@ -1,6 +1,13 @@ -import { resolveBlePacketCapacity } from '../ble-packet-capacity'; +import { resolveBlePacketCapacity, resolveNobleAttMtu } from '../ble-packet-capacity'; describe('resolveBlePacketCapacity', () => { + test('normalizes Noble platform reports to ATT MTU', () => { + expect(resolveNobleAttMtu(244, 'darwin')).toBe(247); + expect(resolveNobleAttMtu(244, 'win32')).toBe(247); + expect(resolveNobleAttMtu(247, 'linux')).toBe(247); + expect(resolveNobleAttMtu(null, 'darwin')).toBeUndefined(); + }); + test('uses ATT MTU payload capacity with an upper bound', () => { expect(resolveBlePacketCapacity(247, 244, 192)).toBe(244); expect(resolveBlePacketCapacity(185, 244, 192)).toBe(182); diff --git a/packages/hd-transport-electron/src/ble-packet-capacity.ts b/packages/hd-transport-electron/src/ble-packet-capacity.ts index 170167770..0455b2542 100644 --- a/packages/hd-transport-electron/src/ble-packet-capacity.ts +++ b/packages/hd-transport-electron/src/ble-packet-capacity.ts @@ -1,5 +1,19 @@ const BLE_ATT_HEADER_BYTES = 3; +export function resolveNobleAttMtu( + reportedMtu: number | null | undefined, + platform: NodeJS.Platform = process.platform +): number | undefined { + if (typeof reportedMtu !== 'number' || !Number.isFinite(reportedMtu) || reportedMtu <= 0) { + return undefined; + } + + const normalizedMtu = Math.floor(reportedMtu); + return platform === 'darwin' || platform === 'win32' + ? normalizedMtu + BLE_ATT_HEADER_BYTES + : normalizedMtu; +} + export function resolveBlePacketCapacity( mtu: number | null | undefined, maximumPacketCapacity: number, diff --git a/packages/hd-transport-electron/src/noble-ble-handler.ts b/packages/hd-transport-electron/src/noble-ble-handler.ts index cd85550ba..a04364664 100644 --- a/packages/hd-transport-electron/src/noble-ble-handler.ts +++ b/packages/hd-transport-electron/src/noble-ble-handler.ts @@ -22,7 +22,7 @@ import { } from '@onekeyfe/hd-shared'; import pRetry from 'p-retry'; -import { resolveBlePacketCapacity } from './ble-packet-capacity'; +import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capacity'; import { safeLog } from './types/noble-extended'; import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops'; import { @@ -574,12 +574,13 @@ function setupMtuListener( } const listener = (mtu: number) => { - if (!Number.isFinite(mtu) || mtu <= 0) return; + const normalizedMtu = resolveNobleAttMtu(mtu); + if (normalizedMtu === undefined) return; // A kept-alive link can outlive the renderer this closure captured. if (webContents.isDestroyed()) return; webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, { id: deviceId, - mtu, + mtu: normalizedMtu, }); }; deviceMtuListeners.set(deviceId, { peripheral, listener }); @@ -727,7 +728,7 @@ async function transmitHexDataToDevice( const toBuffer = Buffer.from(hexData, 'hex'); const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write; const packetCapacity = resolveBlePacketCapacity( - peripheral.mtu, + resolveNobleAttMtu(peripheral.mtu), BLE_PACKET_SIZE_MAXIMUM, BLE_PACKET_SIZE_FALLBACK ); @@ -1047,12 +1048,13 @@ function getDevice(deviceId: string): DeviceInfo | null { const peripheral = discoveredDevices.get(deviceId); if (peripheral) { const deviceName = peripheral.advertisement?.localName || 'Unknown Device'; + const mtu = resolveNobleAttMtu(peripheral.mtu); return { commType: 'electron-ble', id: peripheral.id, name: deviceName, state: peripheral.state || 'disconnected', - ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}), + ...(mtu === undefined ? {} : { mtu }), }; } @@ -1060,12 +1062,13 @@ function getDevice(deviceId: string): DeviceInfo | null { const connectedPeripheral = connectedDevices.get(deviceId); if (connectedPeripheral) { const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device'; + const mtu = resolveNobleAttMtu(connectedPeripheral.mtu); return { commType: 'electron-ble', id: connectedPeripheral.id, name: deviceName, state: connectedPeripheral.state || 'connected', - ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}), + ...(mtu === undefined ? {} : { mtu }), }; } diff --git a/packages/hd-transport-lowlevel/__tests__/protocol-v2.test.js b/packages/hd-transport-lowlevel/__tests__/protocol-v2.test.js index fddbac5ea..a83beb921 100644 --- a/packages/hd-transport-lowlevel/__tests__/protocol-v2.test.js +++ b/packages/hd-transport-lowlevel/__tests__/protocol-v2.test.js @@ -198,7 +198,7 @@ describe('LowlevelTransport protocol framing', () => { }); }); - test('uses the Protocol V2 BLE writer with the lowlevel compatibility packet size', async () => { + test('uses 192-byte packets for Protocol V2 BLE writes', async () => { const plugin = createPlugin({ devices: [], responses: [] }); const lowlevel = configureTransport(plugin); const context = { @@ -209,10 +209,30 @@ describe('LowlevelTransport protocol framing', () => { signal: new AbortController().signal, }; - await lowlevel.writeProtocolV2Frame('pro2-id', new Uint8Array(130), context, jest.fn()); + await lowlevel.writeProtocolV2Frame('pro2-id', new Uint8Array(386), context, jest.fn()); expect(plugin.send).toHaveBeenCalledTimes(3); - expect(plugin.send.mock.calls.map(([, hex]) => hex.length / 2)).toEqual([64, 64, 2]); + expect(plugin.send.mock.calls.map(([, hex]) => hex.length / 2)).toEqual([192, 192, 2]); + }); + + test('uses the packet capacity reported after BLE connection', async () => { + const plugin = createPlugin({ devices: [], responses: [] }); + plugin.getProtocolV2PacketCapacity = jest.fn().mockResolvedValue(244); + const lowlevel = configureTransport(plugin); + lowlevel.detectProtocol = jest.fn().mockResolvedValue('V2'); + const context = { + messageName: 'Ping', + timeoutMs: 1000, + highThroughput: false, + generation: 1, + signal: new AbortController().signal, + }; + + await lowlevel.acquire({ uuid: 'pro2-id', expectedProtocol: 'V2' }); + await lowlevel.writeProtocolV2Frame('pro2-id', new Uint8Array(386), context, jest.fn()); + + expect(plugin.getProtocolV2PacketCapacity).toHaveBeenCalledWith('pro2-id'); + expect(plugin.send.mock.calls.map(([, hex]) => hex.length / 2)).toEqual([244, 142]); }); test('rejects calls before protocol detection', async () => { diff --git a/packages/hd-transport-lowlevel/src/index.ts b/packages/hd-transport-lowlevel/src/index.ts index 7dc277847..9afdd79a4 100644 --- a/packages/hd-transport-lowlevel/src/index.ts +++ b/packages/hd-transport-lowlevel/src/index.ts @@ -26,7 +26,8 @@ const { check, ProtocolV1, parseConfigure } = transport; const PROTOCOL_PROBE_TIMEOUT_MS = 1000; const PROTOCOL_V2_PROBE_TIMEOUT_MS = 5000; -const LOWLEVEL_PROTOCOL_V2_PACKET_LENGTH = 64; +const LOWLEVEL_PROTOCOL_V2_PACKET_LENGTH_FALLBACK = 192; +const LOWLEVEL_PROTOCOL_V2_PACKET_LENGTH_MAX = 244; const FIRMWARE_UPLOAD_LOG_PERCENT_STEP = 5; const FIRMWARE_UPLOAD_LOG_INTERVAL_MS = 10_000; @@ -52,6 +53,13 @@ export function getProtocolV1SendOptions(name: string) { return name === 'FirmwareUpload' ? { withoutResponse: false } : undefined; } +export function resolveLowlevelProtocolV2PacketCapacity(capacity?: number | null) { + if (typeof capacity !== 'number' || !Number.isFinite(capacity) || capacity <= 0) { + return LOWLEVEL_PROTOCOL_V2_PACKET_LENGTH_FALLBACK; + } + return Math.min(Math.floor(capacity), LOWLEVEL_PROTOCOL_V2_PACKET_LENGTH_MAX); +} + function isProtocolV1TransportChunk(data: Uint8Array) { return data.length >= 9 && data[0] === 0x3f && data[1] === 0x23 && data[2] === 0x23; } @@ -83,6 +91,8 @@ export default class LowlevelTransport { private connectedDevices: Set = new Set(); + private protocolV2PacketCapacities: Map = new Map(); + private protocolV2Links = new ProtocolV2LinkManager({ getSchemas: () => { if (!this._messages || !this._messagesV2) { @@ -109,6 +119,7 @@ export default class LowlevelTransport { ); } finally { this.connectedDevices.delete(uuid); + this.protocolV2PacketCapacities.delete(uuid); } } }, @@ -163,6 +174,7 @@ export default class LowlevelTransport { const alreadyConnected = this.connectedDevices.has(input.uuid); try { await this.plugin.connect(input.uuid); + await this.refreshProtocolV2PacketCapacity(input.uuid); if (!alreadyConnected) { this.connectedDevices.add(input.uuid); this.advanceProtocolV2Generation(input.uuid); @@ -204,6 +216,7 @@ export default class LowlevelTransport { this.connectedDevices.delete(input.uuid); this.deviceProtocol.delete(input.uuid); this.protocolV2Assemblers.delete(input.uuid); + this.protocolV2PacketCapacities.delete(input.uuid); this.advanceProtocolV2Generation(input.uuid); } throw error; @@ -218,6 +231,7 @@ export default class LowlevelTransport { this.deviceProtocol.delete(uuid); // Confirmed protocol stays on the device endpoint; BLE names are not a probe hint. this.protocolV2Assemblers.delete(uuid); + this.protocolV2PacketCapacities.delete(uuid); return true; } catch (error) { this.Log.debug('lowlelvel transport disconnect error: ', error); @@ -428,6 +442,7 @@ export default class LowlevelTransport { try { await this.plugin.connect(uuid); + await this.refreshProtocolV2PacketCapacity(uuid); this.connectedDevices.add(uuid); this.advanceProtocolV2Generation(uuid); } catch (error) { @@ -545,7 +560,9 @@ export default class LowlevelTransport { ) { await writeProtocolV2BleFrame({ frame, - packetCapacity: LOWLEVEL_PROTOCOL_V2_PACKET_LENGTH, + packetCapacity: resolveLowlevelProtocolV2PacketCapacity( + this.protocolV2PacketCapacities.get(uuid) + ), assertActive: assertCurrentGeneration, signal: context.signal, abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`, @@ -553,6 +570,22 @@ export default class LowlevelTransport { }); } + private async refreshProtocolV2PacketCapacity(uuid: string) { + let reportedCapacity: number | undefined; + try { + reportedCapacity = await this.plugin.getProtocolV2PacketCapacity?.(uuid); + } catch (error) { + this.Log?.debug( + `[LowlevelTransport] read Protocol V2 packet capacity failed: ${uuid}`, + error + ); + } + this.protocolV2PacketCapacities.set( + uuid, + resolveLowlevelProtocolV2PacketCapacity(reportedCapacity) + ); + } + private async callProtocolV2( uuid: string, name: string, diff --git a/packages/hd-transport-react-native/src/__tests__/bleStrategy.test.ts b/packages/hd-transport-react-native/src/__tests__/bleStrategy.test.ts index 0793ec5cb..519b79957 100644 --- a/packages/hd-transport-react-native/src/__tests__/bleStrategy.test.ts +++ b/packages/hd-transport-react-native/src/__tests__/bleStrategy.test.ts @@ -90,7 +90,7 @@ describe('React Native BLE strategy', () => { ).toBe(182); }); - test('uses withoutResponse for a high-volume write unless explicitly overridden', () => { + test('uses withoutResponse by default unless explicitly overridden', () => { const characteristic = { isWritableWithResponse: true, isWritableWithoutResponse: true, @@ -99,8 +99,7 @@ describe('React Native BLE strategy', () => { expect( shouldWriteProtocolV2WithResponse({ platform: 'ios', - highThroughput: true, - requestedWithResponse: false, + highThroughput: false, characteristic, }) ).toBe(false); diff --git a/packages/hd-transport-react-native/src/__tests__/protocolV2Link.test.ts b/packages/hd-transport-react-native/src/__tests__/protocolV2Link.test.ts index 19edf8450..ecef85c09 100644 --- a/packages/hd-transport-react-native/src/__tests__/protocolV2Link.test.ts +++ b/packages/hd-transport-react-native/src/__tests__/protocolV2Link.test.ts @@ -436,7 +436,8 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { protocolType: 'V2', }); expect(device.requestMTU).toHaveBeenCalledWith(247); - expect(writeCharacteristic.writeWithResponse.mock.calls.length).toBeGreaterThan(1); + expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled(); await expect( transport.call(uuid, 'Ping', { message: 'first-core-command' }) @@ -795,7 +796,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { }); expect(device.requestMTU).toHaveBeenCalledTimes(3); expect((transport as any).getCachedTransport(uuid).mtuSize).toBeUndefined(); - expect(writeCharacteristic.writeWithResponse).toHaveBeenCalled(); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled(); await transport.release(uuid, true); }); @@ -804,6 +805,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { device.mtu = undefined; await transport.acquire({ uuid, expectedProtocol: 'V2' }); + const writesBeforeFileWrite = writeCharacteristic.writeWithoutResponse.mock.calls.length; device.requestMTU.mockImplementationOnce(() => { device.mtu = 247; return Promise.resolve(device); @@ -811,7 +813,9 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { await expect(transport.call(uuid, 'FileWrite', {})).resolves.toBeDefined(); expect(device.requestMTU).toHaveBeenCalledTimes(4); - expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes( + writesBeforeFileWrite + 1 + ); await transport.release(uuid, true); }); @@ -820,12 +824,13 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { device.mtu = undefined; await transport.acquire({ uuid, expectedProtocol: 'V2' }); + const writesBeforeFileWrite = writeCharacteristic.writeWithoutResponse.mock.calls.length; await expect(transport.call(uuid, 'FileWrite', {})).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError, }); expect(device.requestMTU).toHaveBeenCalledTimes(4); - expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(writesBeforeFileWrite); await transport.release(uuid, true); }); @@ -945,19 +950,19 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { await transport.release(uuid, true); }); - test('uses withResponse for consecutive iOS Protocol V2 control calls without releasing', async () => { + test('uses withoutResponse for consecutive iOS Protocol V2 control calls without releasing', async () => { const { transport, uuid, writeCharacteristic } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); const releaseNative = jest.spyOn(transport as any, 'releaseNative'); - expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); - expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); + expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); await transport.call(uuid, 'DeviceInfoGet', {}); await transport.call(uuid, 'ProtocolInfoRequest', {}); - expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(3); - expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(3); + expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(releaseNative).not.toHaveBeenCalled(); await transport.release(uuid, true); @@ -970,8 +975,8 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { await transport.call(uuid, 'FileWrite', {}); await transport.call(uuid, 'FileWrite', {}); - expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2); - expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(3); + expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect( logger.debug.mock.calls.filter( ([message]) => @@ -1004,8 +1009,8 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'FileWrite', {}, { writeWithResponse: true }); - expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2); - expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); + expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); + expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); await transport.release(uuid, true); }); diff --git a/packages/hd-transport-react-native/src/bleStrategy.ts b/packages/hd-transport-react-native/src/bleStrategy.ts index 066d82deb..8dca740a2 100644 --- a/packages/hd-transport-react-native/src/bleStrategy.ts +++ b/packages/hd-transport-react-native/src/bleStrategy.ts @@ -34,8 +34,6 @@ export function shouldRefreshNegotiatedMtu(mtu?: number | null) { } export function shouldWriteProtocolV2WithResponse({ - platform, - highThroughput, requestedWithResponse, characteristic, }: { @@ -46,5 +44,5 @@ export function shouldWriteProtocolV2WithResponse({ }) { if (!characteristic.isWritableWithResponse) return false; if (!characteristic.isWritableWithoutResponse) return true; - return requestedWithResponse === true || (platform === 'ios' && !highThroughput); + return requestedWithResponse === true; } diff --git a/packages/hd-transport/__tests__/protocol-v2.test.js b/packages/hd-transport/__tests__/protocol-v2.test.js index d0b84118c..1bf2df471 100644 --- a/packages/hd-transport/__tests__/protocol-v2.test.js +++ b/packages/hd-transport/__tests__/protocol-v2.test.js @@ -13,6 +13,7 @@ const { } = require('../src/protocols/v2/session'); const protocolV2 = require('../src/protocols/v2'); const { + PROTOCOL_V2_BLE_FILE_CHUNK_SIZE, PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE, PROTOCOL_V2_BLE_FRAME_MAX_BYTES, PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS, @@ -452,12 +453,13 @@ describe('Protocol V2 framing and session', () => { ).toThrow('Protocol V2 frame too large: 4201 > 4200'); }); - test('keeps optimized BLE firmware chunks inside the transport frame boundary', () => { + test('keeps optimized BLE fixed-path chunks inside the transport frame boundary', () => { const productionSchemas = { protocolV1: protocolV1Messages, protocolV2: productionProtocolV2Messages, }; - const stagingPaths = [ + const fixedPaths = [ + 'vol1:/wallpapers/wallpaper.okpkg', 'vol0:/bootloader.bin', 'vol0:/application_p1.bin', 'vol0:/application_p2.bin', @@ -468,7 +470,7 @@ describe('Protocol V2 framing and session', () => { 'vol0:/se04.bin', ]; - for (const path of stagingPaths) { + for (const path of fixedPaths) { const frame = ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', { file: { path, @@ -485,6 +487,35 @@ describe('Protocol V2 framing and session', () => { } }); + test('keeps the generic BLE chunk safe for the longest valid filesystem path', () => { + const productionSchemas = { + protocolV1: protocolV1Messages, + protocolV2: productionProtocolV2Messages, + }; + const longestValidPath = `vol0:/${'a'.repeat(121)}`; + const encodeFileWrite = dataLength => + ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', { + file: { + path: longestValidPath, + offset: 0xffffffff, + total_size: 0xffffffff, + data: new Uint8Array(dataLength), + }, + overwrite: true, + append: true, + ui_percentage: 100, + }); + + expect(Buffer.byteLength(longestValidPath, 'utf8')).toBe(127); + expect(encodeFileWrite(PROTOCOL_V2_BLE_FILE_CHUNK_SIZE).length).toBeLessThanOrEqual( + PROTOCOL_V2_BLE_FRAME_MAX_BYTES + ); + expect(encodeFileWrite(PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE).length).toBeGreaterThan( + PROTOCOL_V2_BLE_FRAME_MAX_BYTES + ); + expect(encodeFileWrite(1885)).toHaveLength(PROTOCOL_V2_BLE_FRAME_MAX_BYTES); + }); + test('keeps bytes after the first complete frame for the next read', () => { const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', { version: 1, diff --git a/packages/hd-transport/src/constants.ts b/packages/hd-transport/src/constants.ts index b39d9a851..134423e1a 100644 --- a/packages/hd-transport/src/constants.ts +++ b/packages/hd-transport/src/constants.ts @@ -26,7 +26,7 @@ export const PROTOCOL_V2_FRAME_MAX_BYTES = 4200; /** FilesystemFileWrite chunk size over WebUSB. */ export const PROTOCOL_V2_WEBUSB_FILE_CHUNK_SIZE = 4000; -/** FilesystemFileWrite chunk size over BLE. */ +/** Generic FilesystemFileWrite chunk size over BLE, including support for long filesystem paths. */ export const PROTOCOL_V2_BLE_FILE_CHUNK_SIZE = 1800; /** diff --git a/packages/hd-transport/src/types/transport.ts b/packages/hd-transport/src/types/transport.ts index b2533bea2..dc695a9f6 100644 --- a/packages/hd-transport/src/types/transport.ts +++ b/packages/hd-transport/src/types/transport.ts @@ -145,6 +145,8 @@ export type LowlevelTransportSharedPlugin = { receive: (uuid?: string) => Promise; connect: (uuid: string) => Promise; disconnect: (uuid: string) => Promise; + /** Maximum Protocol V2 bytes accepted by one BLE characteristic write. */ + getProtocolV2PacketCapacity?: (uuid: string) => number | undefined | Promise; init: () => Promise; version: string;