From 41f40802646c55080c558e21480a7c6d275fcfd6 Mon Sep 17 00:00:00 2001
From: Yudi Xiao <463708580@qq.com>
Date: Wed, 10 Dec 2025 10:29:50 +0800
Subject: [PATCH] =?UTF-8?q?expo-ble=E6=A8=A1=E5=9D=97=E6=B5=8B=E8=AF=95dem?=
=?UTF-8?q?o?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...@shuohigh__duooomi-keystore-credentials.md | 10 +
README.md | 301 ++++++++--
app.json | 33 +-
app/(tabs)/explore.tsx | 513 ++++++++++++++----
ble/core/BleClient.ts | 222 ++++++++
ble/core/types.ts | 34 ++
ble/example/BleExampleV2.tsx | 372 +++++++++++++
ble/index.ts | 8 +
ble/manager/BlePeripheralManager.ts | 472 ++++++++++++++++
ble/package.json | 43 ++
ble/protocol/Constants.ts | 52 ++
ble/protocol/ProtocolManager.ts | 155 ++++++
ble/protocol/types.ts | 89 +++
ble/services/BleProtocolService.ts | 165 ++++++
ble/services/DeviceInfoService.ts | 76 +++
ble/services/FileTransferService.ts | 51 ++
ble/utils/CryptoUtils.ts | 80 +++
ble/utils/ProtocolUtilsV2.ts | 306 +++++++++++
bun.lock | 38 ++
eas.json | 37 ++
hooks/useBleExplorer.ts | 493 +++++++++++++++++
hooks/useBlePeripheral.ts | 323 +++++++++++
package.json | 17 +-
23 files changed, 3749 insertions(+), 141 deletions(-)
create mode 100644 @shuohigh__duooomi-keystore-backup/@shuohigh__duooomi-keystore-credentials.md
create mode 100644 ble/core/BleClient.ts
create mode 100644 ble/core/types.ts
create mode 100644 ble/example/BleExampleV2.tsx
create mode 100644 ble/index.ts
create mode 100644 ble/manager/BlePeripheralManager.ts
create mode 100644 ble/package.json
create mode 100644 ble/protocol/Constants.ts
create mode 100644 ble/protocol/ProtocolManager.ts
create mode 100644 ble/protocol/types.ts
create mode 100644 ble/services/BleProtocolService.ts
create mode 100644 ble/services/DeviceInfoService.ts
create mode 100644 ble/services/FileTransferService.ts
create mode 100644 ble/utils/CryptoUtils.ts
create mode 100644 ble/utils/ProtocolUtilsV2.ts
create mode 100644 eas.json
create mode 100644 hooks/useBleExplorer.ts
create mode 100644 hooks/useBlePeripheral.ts
diff --git a/@shuohigh__duooomi-keystore-backup/@shuohigh__duooomi-keystore-credentials.md b/@shuohigh__duooomi-keystore-backup/@shuohigh__duooomi-keystore-credentials.md
new file mode 100644
index 0000000..bf61938
--- /dev/null
+++ b/@shuohigh__duooomi-keystore-backup/@shuohigh__duooomi-keystore-credentials.md
@@ -0,0 +1,10 @@
+# Android Upload Keystore Credentials
+
+These credentials are used in conjunction with your Android upload keystore file to sign your app for distribution.
+
+## Credential Values
+
+- Android upload keystore password: 33ded76b859c481bdbcb2759429ffbd2
+- Android key alias: 803a70040d2ea760c77979a931829601
+- Android key password: b509128efd5326930a1fe943f3f02890
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 48dd63f..eb8e7c3 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,289 @@
-# Welcome to your Expo app 👋
+# 协议三端通信说明
-This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
+## 1. 数据格式
-## Get started
+### 1.1 基本帧结构
-1. Install dependencies
+所有数据包遵循以下格式(通常为大端序):
- ```bash
- npm install
- ```
+| 字段 (Field) | 长度 (Bytes) | 值 / 说明 |
+| :--- | :--- | :--- |
+| **HEADER** | 3 | `FEDCBA` |
+| **CMD** | 2 | 命令字 (如 `E100`) |
+| **DATA** | n | 数据内容 (0 ~ n 字节) |
+| **CHECKSUM** | 1 | 校验和 |
+| **TAIL** | 2 | `00EF` |
-2. Start the app
+### 1.2 错误反馈 (系统级)
- ```bash
- npx expo start
- ```
+当接收到的数据包格式错误时,设备会返回以下错误码:
-In the output, you'll find options to open the app in a
+| 错误类型 | CMD (上报) | 参数 (DATA) | 完整 HEX 示例 |
+| :--- | :--- | :--- | :--- |
+| HEADER 错误 | `E0E0` | `C0` | `FEDCBA E0E0 C0 00EF` |
+| TAIL 错误 | `E0E0` | `C1` | `FEDCBA E0E0 C1 00EF` |
+| CHECKSUM 错误 | `E0E2` | `C0` | `FEDCBA E0E2 C0 00EF` |
+| CMD 不支持 | `E0E3` | `[CMD]` | `FEDCBA E0E3 [CMD] XX 00EF` |
-- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
-- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
-- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
-- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
+---
-You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
+## 2. 蓝牙通道说明
-## Get a fresh project
+* **OTA 通道**:
+ * Write: `ae01`
+ * Notify: `ai02` (或 `ae02`)
+* **APP 通信通道**:
+ * Write: `ae10` (MTU 建议设置为 517)
+ * Notify: `ae02`
-When you're ready, run:
+---
-```bash
-npm run reset-project
-```
+## 3. 命令详解
-This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
+### 3.1 绑定 (0xE100)
+**方向**: APP -> 设备
-## Learn more
+* **发送**: `FEDCBA E100 E1 00EF`
+* **返回**:
-To learn more about developing your project with Expo, look at the following resources:
+| 状态 | CMD | 参数 | 完整 HEX 示例 |
+| :--- | :--- | :--- | :--- |
+| 成功 | `E1A0` | `81` | `FEDCBA E1A0 81 00EF` |
+| 失败 | `E1A1` | `82` | `FEDCBA E1A1 82 00EF` |
+| 异常 | `E1A2` | `83` | `FEDCBA E1A2 83 00EF` |
-- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
-- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
+### 3.2 绑定失败上报 (0xE200)
+**方向**: APP -> 设备 (通常用于APP端绑定失败后通知设备)
-## Join the community
+* **发送**: `FEDCBA E200 XX 00EF`
+* **返回**:
-Join our community of developers creating universal apps.
+| 状态 | CMD | 参数 | 完整 HEX 示例 |
+| :--- | :--- | :--- | :--- |
+| 成功 | `E2A0` | `XX` | `FEDCBA E2A0 XX 00EF` |
+| 失败 | `E2A1` | `XX` | `FEDCBA E2A1 XX 00EF` |
+| 异常 | `E2A2` | `XX` | `FEDCBA E2A2 XX 00EF` |
-- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
-- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
+### 3.3 写用户 ID (0xE300)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA E300 [UserID] XX 00EF`
+ * `UserID`: 用户ID数据
+* **返回**:
+
+| 状态 | CMD | 参数 | 完整 HEX 示例 |
+| :--- | :--- | :--- | :--- |
+| 成功 | `E3A0` | `83` | `FEDCBA E3A0 83 00EF` |
+| 失败 | `E3A1` | `84` | `FEDCBA E3A1 84 00EF` |
+| 异常(空) | `E3A3` | `85` | `FEDCBA E3A3 85 00EF` |
+
+### 3.4 查询密锁 (0xE400)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA E400 E4 00EF`
+* **返回**: `FEDCBA E4A0 [TOKEN] XX 00EF`
+ * `TOKEN`: 32位 (4字节) 密锁数据。
+
+### 3.5 设置时间 (0xE500)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA E500 [Time] XX 00EF`
+ * `Time`: 格式 `yyyymmddhhmmss` (14字节 ASCII)
+* **返回**:
+
+| 状态 | CMD | 参数 | 完整 HEX 示例 |
+| :--- | :--- | :--- | :--- |
+| 成功 | `E5A0` | `85` | `FEDCBA E5A0 85 00EF` |
+| 失败 | `E5A1` | `86` | `FEDCBA E5A1 86 00EF` |
+| 异常 | `E5A2` | `87` | `FEDCBA E5A2 87 00EF` |
+
+### 3.6 查询固件版本号 (0xE600)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA E600 E6 00EF`
+* **返回**: `FEDCBA E6A0 [Version] XX 00EF`
+ * `Version`: 格式 `yyyymmddhh` (10字节 ASCII)
+
+### 3.7 查询资源版本号 (0xE700 / 0xD700)
+**方向**: APP -> 设备
+
+* **版本 < V4.0**:
+ * 发送: `FEDCBA E700 E7 00EF`
+ * 返回: `FEDCBA E7A0 [ResVersion] XX 00EF`
+* **版本 >= V4.0**:
+ * 发送: `FEDCBA D700 E7 00EF`
+ * 返回: `FEDCBA D7A0 [ResVersion] XX 00EF`
+
+### 3.8 设置资源版本号 (0xE800)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA E800 [TT] [ResVersion] XX 00EF`
+* **返回**:
+
+| 状态 | CMD | 参数 | 完整 HEX 示例 |
+| :--- | :--- | :--- | :--- |
+| 成功 | `E8A0` | `88` | `FEDCBA E8A0 88 00EF` |
+| 失败 | `E8A1` | `89` | `FEDCBA E8A1 89 00EF` |
+| 异常 | `E8A2` | `8A` | `FEDCBA E8A2 8A 00EF` |
+
+### 3.9 解绑 (0xE900)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA E900 E0 00EF`
+* **返回**: `FEDCBA E9A0 F3 00EF` (解绑成功)
+
+### 3.10 下载文件 / OTA (0xEA00)
+**方向**: APP -> 设备
+此命令流程包含请求传输、数据传输、传输结束、取消传输等状态。
+
+#### A. 启动传输请求
+* **发送**: `FEDCBA EA00 [Type] [NameLen] [Name] [MD5] [Size] XX 00EF`
+ * `Type`: 文件类型 (1字节)
+ * `NameLen`: 文件名长度 (1字节)
+ * `Name`: 文件名 (N字节)
+ * `MD5`: 文件校验 (8字节)
+ * `Size`: 文件大小 (4字节, 16进制)
+* **返回**:
+
+| 状态 | CMD | 说明 | 附加数据 |
+| :--- | :--- | :--- | :--- |
+| 同意传输 | `EAA0` | 准备接收 | `8A` |
+| 失败 | `EAA1` | 拒绝/错误 | `8B` |
+| 异常 | `EAA2` | 参数错误 | `8C` |
+| 文件相同 | `EAA3` | 无需传输 | `8D` |
+| 断点续传 | `EAA4` | 从Offset开始 | `[Offset(4B)]` |
+| OTA回复 | `EAA5` | OTA 错误码 | `[ErrCode]` |
+
+#### B. 文件数据传输 (Packet)
+* **发送**: `FEDCBA EA01 [Seq] [Len] [Data] XX 00EF`
+ * `Seq`: 序列号 (2字节)
+ * `Len`: 数据长度 (2字节)
+ * `Data`: 数据内容
+* **返回 (每包/批量确认)**: `FEDCBA EAA6 [ErrCode] [Seq] [TotalLen] XX 00EF`
+
+#### C. 传输完成
+* **发送**: `FEDCBA EA02 EC 00EF`
+* **返回**:
+ * 成功: `FEDCBA EAA7 91 00EF`
+ * 失败: `FEDCBA EAA8 [ErrCode] XX 00EF`
+
+#### D. 取消传输
+* **发送**: `FEDCBA EA03 ED 00EF`
+* **返回**: `FEDCBA EAA9 [ErrCode] 8D 00EF`
+
+### 3.11 查询设备 OTA 信息 (0xEB00)
+**方向**: APP -> 设备
+
+* **查询 OTA 信息**:
+ * 发送: `FEDCBA EB00 E0 00EF`
+ * 返回: `FEDCBA EBA0 [Err] [Ver] XX 00EF`
+* **清除 OTA 状态**:
+ * 发送: `FEDCBA EB01 EC 00EF`
+ * 返回: `FEDCBA EBA7 92 00EF`
+
+### 3.12 设备信息查询 (0xED00)
+**方向**: APP -> 设备
+
+* **发送**: `FEDCBA ED00 [TypeMask] XX 00EF`
+ * `TypeMask`: 2字节位掩码,每一位代表请求的信息类型。
+* **返回**:
+ * 成功: `FEDCBA EDA0 [Data] XX 00EF`
+ * 失败: `FEDCBA EDA1 ...`
+ * 异常: `FEDCBA EDA2 ...`
+
+**支持的信息类型 (Bit定义)**:
+
+| Bit | 信息内容 | 长度 (Bytes) | 说明 |
+| :--- | :--- | :--- | :--- |
+| 0 | 当前电量 | 1 | 0~100 |
+| 1 | 当前音量 | 1 | 0~3 |
+| 2 | SD卡挂载状态 | 1 | 0:未挂载, 1:挂载 |
+| 3 | SD卡总容量 | 4 | 单位 KB |
+| 4 | SD卡剩余容量 | 4 | 单位 KB |
+| 5 | BLE MAC 地址 | 6 | Hex format |
+
+---
+
+## 4. JSON 协议 (V2)
+
+新版协议在原有基础上增加了 JSON 数据传输模式,支持更丰富的信息交互。
+
+### 4.1 帧结构 (Frame Structure)
+
+V2 协议使用特定的帧头 `0xC7` (App发) / `0xB0` (设备发) 包裹 JSON 数据。
+
+| 字段 | 长度 (Bytes) | 说明 |
+| :--- | :--- | :--- |
+| **HEAD** | 1 | `0xC7` (App->Dev) / `0xB0` (Dev->App) |
+| **TYPE** | 1 | 命令字 (如 `0x0D`) |
+| **Subpage Total** | 2 | 分包总数 (大端) |
+| **Current Page** | 2 | 当前包序号 (大端) |
+| **Data Len** | 2 | 数据长度 (大端) |
+| **DATA** | N | JSON 字符串 (UTF-8) |
+| **CHECKSUM** | 1 | 校验和 (0 - Sum(0...End-1)) |
+
+### 4.2 命令定义 (JSON Payload)
+
+以下为 Data 字段中的 JSON 内容示例。
+
+#### 4.2.1 激活状态 (0x01)
+* **APP -> 设备**: (查询)
+* **设备 -> APP**:
+ ```json
+ {
+ "type": 0x01,
+ "state": 0x00 // 0:未激活, 1:已激活
+ }
+ ```
+
+#### 4.2.2 协议版本 (0x07)
+* **设备 -> APP**:
+ ```json
+ {
+ "type": 0x07,
+ "version": "2024.01"
+ }
+ ```
+
+#### 4.2.3 时间同步 (0x08)
+* **APP -> 设备**:
+ ```json
+ {
+ "type": 0x08,
+ "year": 2024,
+ "mon": 1,
+ "day": 1,
+ "hour": 12,
+ "min": 0,
+ "mes": 0
+ }
+ ```
+
+#### 4.2.4 设备信息上报 (0x0D)
+* **设备 -> APP**:
+ ```json
+ {
+ "type": 0x0d,
+ "allspace": 1000,
+ "freespace": 500,
+ "devname": "Loom",
+ "size": 0, // 0:圆屏, 1:方屏
+ "brand": 5
+ }
+ ```
+
+#### 4.2.5 身份核对 (0x0E)
+* **APP -> 设备** (发送 12位码):
+ ```json
+ {
+ "type": 0x0e,
+ "IdCheck": "xxxxxxxxxxxx"
+ }
+ ```
+* **设备 -> APP** (返回结果):
+ ```json
+ {
+ "type": 0x0e,
+ "Ret": 1 // 0:错误, 1:正确
+ }
+ ```
diff --git a/app.json b/app.json
index 2fc0a0b..029ee9e 100644
--- a/app.json
+++ b/app.json
@@ -1,6 +1,7 @@
{
"expo": {
"name": "expo-ble-app",
+ "owner": "bowong",
"slug": "expo-ble-app",
"version": "1.0.0",
"orientation": "portrait",
@@ -19,7 +20,14 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
- "predictiveBackGestureEnabled": false
+ "predictiveBackGestureEnabled": false,
+ "permissions": [
+ "android.permission.BLUETOOTH",
+ "android.permission.BLUETOOTH_ADMIN",
+ "android.permission.BLUETOOTH_CONNECT",
+ "android.permission.RECORD_AUDIO"
+ ],
+ "package": "com.shuohigh.expobleapp"
},
"web": {
"output": "static",
@@ -38,11 +46,34 @@
"backgroundColor": "#000000"
}
}
+ ],
+ [
+ "react-native-ble-plx",
+ {
+ "isBackgroundEnabled": true,
+ "modes": [
+ "peripheral",
+ "central"
+ ],
+ "bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to bluetooth devices"
+ }
+ ],
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "The app accesses your photos to let you share them with your friends."
+ }
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
+ },
+ "extra": {
+ "router": {},
+ "eas": {
+ "projectId": "9adec1c5-cf51-4d78-8d4c-ca38a80334e1"
+ }
}
}
}
diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx
index 71518f9..18280aa 100644
--- a/app/(tabs)/explore.tsx
+++ b/app/(tabs)/explore.tsx
@@ -1,112 +1,415 @@
-import { Image } from 'expo-image';
-import { Platform, StyleSheet } from 'react-native';
-
-import { Collapsible } from '@/components/ui/collapsible';
-import { ExternalLink } from '@/components/external-link';
+import React from 'react';
+import {Alert, StyleSheet, Button, Switch, ScrollView} from 'react-native';
+import {ThemedText} from '@/components/themed-text';
+import {ThemedView} from '@/components/themed-view';
+import {useBleExplorer} from '@/hooks/useBleExplorer';
+import {useBlePeripheral} from '@/hooks/useBlePeripheral';
+import {BLE_UUIDS, PROTOCOL_VERSION} from '@/ble';
import ParallaxScrollView from '@/components/parallax-scroll-view';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Fonts } from '@/constants/theme';
+import {IconSymbol} from '@/components/ui/icon-symbol';
+
export default function TabTwoScreen() {
- return (
-
- }>
-
-
- Explore
-
-
- This app includes example code to help you get started.
-
-
- This app has two screens:{' '}
- app/(tabs)/index.tsx and{' '}
- app/(tabs)/explore.tsx
-
-
- The layout file in app/(tabs)/_layout.tsx{' '}
- sets up the tab navigator.
-
-
- Learn more
-
-
-
-
- You can open this project on Android, iOS, and the web. To open the web version, press{' '}
- w in the terminal running this project.
-
-
-
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for
- different screen densities
-
-
-
- Learn more
-
-
-
-
- This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect
- what the user's current color scheme is, and so you can adjust UI colors accordingly.
-
-
- Learn more
-
-
-
-
- This template includes an example of an animated component. The{' '}
- components/HelloWave.tsx component uses
- the powerful{' '}
-
- react-native-reanimated
- {' '}
- library to create a waving hand animation.
-
- {Platform.select({
- ios: (
-
- The components/ParallaxScrollView.tsx{' '}
- component provides a parallax effect for the header image.
-
- ),
- })}
-
-
- );
+ const [deviceMode, setDeviceMode] = React.useState<'central' | 'peripheral'>('central');
+
+
+ const {
+ isScanning,
+ isConnected,
+ connectedDevice,
+ deviceInfo,
+ version,
+ isActivated,
+ transferProgress,
+ isTransferring,
+ discoveredDevices,
+ loading,
+ error,
+ startScan,
+ stopScan,
+ connectToDevice,
+ disconnectDevice,
+ queryActivationStatus,
+ queryDeviceVersion,
+ requestDeviceInfo,
+ updateActivationTime,
+ sendIdentityCheck,
+ transferSampleFile,
+ clearLogs,
+ } = useBleExplorer();
+
+ const {
+ isAdvertising,
+ connectedCentralCount,
+ logs: peripheralLogs,
+ deviceInfo: peripheralDeviceInfo,
+ loading: peripheralLoading,
+ error: peripheralError,
+ startAdvertising,
+ stopAdvertising,
+ getCharacteristicReadValue,
+ updateDeviceInfo,
+ resetDeviceInfo,
+ clearLogs: clearPeripheralLogs,
+ } = useBlePeripheral();
+
+
+ return (
+
+ }>
+
+ BLE Explorer
+
+
+ {/* Device Mode Selection */}
+
+ Device Mode
+
+ Central
+ {
+ if (value) {
+ // Switching to peripheral mode - stop central operations first
+ console.log('Switching to peripheral mode - stopping central operations');
+ if (isScanning) {
+ await stopScan();
+ // Add small delay to ensure cleanup
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ if (isConnected) {
+ await disconnectDevice();
+ // Add small delay to ensure cleanup
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ } else {
+ // Switching to central mode - stop peripheral operations first
+ console.log('Switching to central mode - stopping peripheral operations');
+ if (isAdvertising) {
+ await stopAdvertising();
+ // Add small delay to ensure cleanup
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+ setDeviceMode(value ? 'peripheral' : 'central');
+ }}
+ disabled={isScanning || isConnected || isAdvertising || peripheralLoading.advertising}
+ />
+ Peripheral
+
+
+ {deviceMode === 'central'
+ ? 'Central mode: Scan for and connect to BLE peripherals'
+ : `Peripheral mode: Act as BLE receiver`}
+
+
+
+ {deviceMode === 'central' ? (
+ <>
+ {/* Connection Status */}
+
+ Connection Status
+ Scanning: {isScanning ? 'Yes' : 'No'}
+ Connected: {isConnected ? 'Yes' : 'No'}
+ Device: {connectedDevice?.name || 'None'}
+ {error && Error: {error}}
+
+
+ {/* Discovered Devices */}
+
+ Discovered Devices
+ Total devices
+ found: {discoveredDevices.length}
+ {discoveredDevices.length === 0 ? (
+ No devices discovered yet. Start scanning to find devices.
+ ) : (
+
+ {discoveredDevices.map((item) => (
+
+
+
+ {item.name || 'Unknown Device'}
+
+ {item.id}
+ {item.serviceUUIDs && item.serviceUUIDs.length > 0 && (
+
+ Services: {item.serviceUUIDs.join(', ')}
+
+ )}
+ {item.connected && (
+ Connected
+ )}
+
+
+ ))}
+
+ )}
+
+
+ {/* Device Info */}
+
+ Device Information
+ Activated: {isActivated ? 'Yes' : 'No'}
+ Version: {version || 'Unknown'}
+ {deviceInfo && (
+ <>
+ Name: {deviceInfo.devname}
+ Total Space: {deviceInfo.allspace} KB
+ Free Space: {deviceInfo.freespace} KB
+ Brand: {deviceInfo.brand}
+ >
+ )}
+
+
+ {/* Transfer Status */}
+
+ File Transfer
+ Transferring: {isTransferring ? 'Yes' : 'No'}
+ Progress: {transferProgress}%
+
+
+ {/* Control Buttons */}
+
+ Controls
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ {/* Peripheral Status */}
+
+ Peripheral Status
+ Advertising: {isAdvertising ? 'Yes' : 'No'}
+ Connected Centrals: {connectedCentralCount}
+ {peripheralError && Error: {peripheralError}}
+
+
+ {/* Device Information */}
+
+ Device Information
+ Activated: {peripheralDeviceInfo.activated ? 'Yes' : 'No'}
+ Version: {peripheralDeviceInfo.version || 'Unknown'}
+ Name: {peripheralDeviceInfo.devname || 'Unknown Device'}
+ Total Space: {peripheralDeviceInfo.allspace || 0} KB
+ Free Space: {peripheralDeviceInfo.freespace || 0} KB
+ Brand: {peripheralDeviceInfo.brand || 'Unknown'}
+
+
+ {/* Peripheral Controls */}
+
+ Peripheral Controls
+
+
+
+
+
+
+ getCharacteristicReadValue(BLE_UUIDS.READ_CHARACTERISTIC)}
+ disabled={!isAdvertising}
+ />
+
+
+
+
+ updateDeviceInfo({activated: !(peripheralDeviceInfo.activated ?? false)})}/>
+ updateDeviceInfo({freespace: Math.floor(Math.random() * 1024)})}/>
+
+
+
+ {/* Peripheral Logs */}
+
+
+ Peripheral Logs
+
+
+
+
+ {peripheralLogs.map((log, index) => (
+
+ {log}
+
+ ))}
+
+
+
+ >
+ )}
+
+ {/* Protocol Info - Available in both modes */}
+
+ Protocol Information
+ Version: {PROTOCOL_VERSION}
+ Service UUID: {BLE_UUIDS.SERVICE}
+ Write UUID: {BLE_UUIDS.WRITE_CHARACTERISTIC}
+ Read UUID: {BLE_UUIDS.READ_CHARACTERISTIC}
+
+
+ );
}
const styles = StyleSheet.create({
- headerImage: {
- color: '#808080',
- bottom: -90,
- left: -35,
- position: 'absolute',
- },
- titleContainer: {
- flexDirection: 'row',
- gap: 8,
- },
+ headerImage: {
+ color: '#808080',
+ bottom: -90,
+ left: -35,
+ position: 'absolute',
+ },
+ titleContainer: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ section: {
+ gap: 8,
+ marginBottom: 8,
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 8,
+ gap: 8,
+ },
+ logHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ logContainer: {
+ maxHeight: 200,
+ padding: 8,
+ borderRadius: 4,
+ },
+ logText: {
+ fontSize: 12,
+ marginBottom: 2,
+ },
+ deviceItem: {
+ padding: 12,
+ marginBottom: 8,
+ borderRadius: 8,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ deviceInfo: {
+ flex: 1,
+ marginRight: 12,
+ },
+ deviceId: {
+ fontSize: 12,
+ opacity: 0.7,
+ },
+ deviceCount: {
+ fontSize: 14,
+ opacity: 0.8,
+ marginTop: 4,
+ marginBottom: 8,
+ fontWeight: '500',
+ },
+ serviceUuids: {
+ fontSize: 11,
+ opacity: 0.6,
+ marginTop: 2,
+ fontStyle: 'italic',
+ },
+ connectionStatus: {
+ fontSize: 12,
+ fontWeight: 'bold',
+ marginTop: 4,
+ },
+ connectedDevice: {
+ borderWidth: 1,
+ borderColor: '#4CAF50',
+ },
+ connectedDeviceText: {
+ fontWeight: 'bold',
+ },
+ errorText: {
+ marginTop: 8,
+ fontWeight: 'bold',
+ },
+ modeSwitchContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginVertical: 12,
+ },
+ modeLabel: {
+ fontSize: 16,
+ marginHorizontal: 8,
+ },
+ modeDescription: {
+ fontSize: 12,
+ opacity: 0.7,
+ marginTop: 8,
+ },
});
diff --git a/ble/core/BleClient.ts b/ble/core/BleClient.ts
new file mode 100644
index 0000000..1aea076
--- /dev/null
+++ b/ble/core/BleClient.ts
@@ -0,0 +1,222 @@
+import {BleManager, Device, Characteristic, BleError as PlxError, ScanOptions} from 'react-native-ble-plx';
+import {Platform, PermissionsAndroid} from 'react-native';
+import {BleDevice, ConnectionState, BleError, ScanResult} from './types';
+
+export class BleClient {
+ private static instance: BleClient;
+ private manager: BleManager | null = null;
+ private connectedDevice: Device | null = null;
+
+ // Simple event system
+ private listeners: Map> = new Map();
+
+ private constructor() {
+ if (Platform.OS !== 'web') {
+ this.manager = new BleManager();
+ }
+ }
+
+ public static getInstance(): BleClient {
+ if (!BleClient.instance) {
+ BleClient.instance = new BleClient();
+ }
+ return BleClient.instance;
+ }
+
+ public addListener(event: string, callback: Function) {
+ if (!this.listeners.has(event)) {
+ this.listeners.set(event, new Set());
+ }
+ this.listeners.get(event)!.add(callback);
+ }
+
+ public removeListener(event: string, callback: Function) {
+ if (this.listeners.has(event)) {
+ this.listeners.get(event)!.delete(callback);
+ }
+ }
+
+ private emit(event: string, ...args: any[]) {
+ if (this.listeners.has(event)) {
+ this.listeners.get(event)!.forEach(cb => cb(...args));
+ }
+ }
+
+ public async startScan(
+ serviceUUIDs: string[] | null = null,
+ options: ScanOptions = {},
+ onDeviceFound: (result: ScanResult) => void
+ ): Promise {
+ if (!this.manager) {
+ console.warn('BLE not supported on web');
+ return;
+ }
+ const state = await this.manager.state();
+ if (state !== 'PoweredOn') {
+ throw new Error(`Bluetooth is not powered on. State: ${state}`);
+ }
+
+ this.manager.startDeviceScan(serviceUUIDs, options, (error, device) => {
+ if (error) {
+ this.emit('scanError', error);
+ return;
+ }
+ if (device) {
+ onDeviceFound({
+ device,
+ rssi: device.rssi,
+ localName: device.name
+ });
+ }
+ });
+ }
+
+ public async getConnectedDevices(serviceUUIDs: string[]): Promise {
+ if (!this.manager) return [];
+ try {
+ const devices = await this.manager.connectedDevices(serviceUUIDs);
+ return devices as BleDevice[];
+ } catch (e) {
+ throw this.normalizeError(e);
+ }
+ }
+
+ public stopScan() {
+ if (!this.manager) return;
+ this.manager.stopDeviceScan();
+ }
+
+ public async connect(deviceId: string): Promise {
+ if (!this.manager) throw new Error('BLE not supported on web');
+ try {
+ this.emit('connectionStateChange', {deviceId, state: ConnectionState.CONNECTING});
+ const device = await this.manager.connectToDevice(deviceId);
+ this.connectedDevice = await device.discoverAllServicesAndCharacteristics();
+ this.emit('connectionStateChange', {deviceId, state: ConnectionState.CONNECTED});
+
+ // Handle disconnection monitoring
+ device.onDisconnected((error, disconnectedDevice) => {
+ this.connectedDevice = null;
+ this.emit('connectionStateChange', {
+ deviceId: disconnectedDevice.id,
+ state: ConnectionState.DISCONNECTED
+ });
+ this.emit('disconnected', disconnectedDevice);
+ });
+
+ return this.connectedDevice;
+ } catch (error: any) {
+ this.emit('connectionStateChange', {deviceId, state: ConnectionState.DISCONNECTED});
+ throw this.normalizeError(error);
+ }
+ }
+
+ public async disconnect(deviceId?: string): Promise {
+ const id = deviceId || this.connectedDevice?.id;
+ if (!id) return;
+ if (!this.manager) return;
+
+ try {
+ this.emit('connectionStateChange', {deviceId: id, state: ConnectionState.DISCONNECTING});
+ await this.manager.cancelDeviceConnection(id);
+ // onDisconnected callback will handle the state update
+ } catch (error: any) {
+ throw this.normalizeError(error);
+ }
+ }
+
+ public async read(
+ deviceId: string,
+ serviceUUID: string,
+ characteristicUUID: string
+ ): Promise {
+ if (!this.manager) throw new Error('BLE not supported on web');
+ try {
+ const char = await this.manager.readCharacteristicForDevice(
+ deviceId,
+ serviceUUID,
+ characteristicUUID
+ );
+ return char.value || ''; // Base64 string
+ } catch (e) {
+ throw this.normalizeError(e);
+ }
+ }
+
+ public async write(
+ deviceId: string,
+ serviceUUID: string,
+ characteristicUUID: string,
+ dataBase64: string,
+ response: boolean = true
+ ): Promise {
+ if (!this.manager) throw new Error('BLE not supported on web');
+ let result: Characteristic | null;
+ try {
+ if (response) {
+ result = await this.manager.writeCharacteristicWithResponseForDevice(
+ deviceId, serviceUUID, characteristicUUID, dataBase64
+ );
+ } else {
+ result = await this.manager.writeCharacteristicWithoutResponseForDevice(
+ deviceId, serviceUUID, characteristicUUID, dataBase64
+ );
+ }
+ return result
+ } catch (e) {
+ throw this.normalizeError(e);
+ }
+ }
+
+ public async monitor(
+ deviceId: string,
+ serviceUUID: string,
+ characteristicUUID: string,
+ listener: (error: BleError | null, value: string | null) => void
+ ) {
+ if (!this.manager) {
+ listener({message: 'BLE not supported on web', errorCode: 0, reason: null}, null);
+ return {
+ remove: () => {
+ }
+ };
+ }
+ return this.manager.monitorCharacteristicForDevice(
+ deviceId,
+ serviceUUID,
+ characteristicUUID,
+ (error, char) => {
+ if (error) {
+ listener(this.normalizeError(error), null);
+ } else {
+ listener(null, char?.value || null);
+ }
+ }
+ );
+ }
+
+ public async requestMtu(deviceId: string, mtu: number): Promise {
+ if (!this.manager) return 23;
+ try {
+ const device = await this.manager.requestMTUForDevice(deviceId, mtu);
+ return device.mtu;
+ } catch (e) {
+ console.warn("MTU negotiation failed", e);
+ // iOS doesn't allow explicit MTU request usually, so we might ignore or return default
+ return 23;
+ }
+ }
+
+ public getConnectedDevice(): Device | null {
+ return this.connectedDevice;
+ }
+
+ private normalizeError(e: any): BleError {
+ // wrapper to convert PlxError to our BleError
+ return {
+ errorCode: e.errorCode || 0,
+ message: e.message || 'Unknown error',
+ reason: e.reason
+ };
+ }
+}
diff --git a/ble/core/types.ts b/ble/core/types.ts
new file mode 100644
index 0000000..cbef747
--- /dev/null
+++ b/ble/core/types.ts
@@ -0,0 +1,34 @@
+import { Device, BleError as PlxBleError } from 'react-native-ble-plx';
+
+export interface BleScanInfo {
+ rawData?: ArrayBuffer;
+ rssi: number;
+ isEnableConnect: boolean;
+}
+
+export type BleDevice = Device & {
+ scanInfo?: BleScanInfo;
+ connected?: boolean;
+};
+
+export enum ConnectionState {
+ DISCONNECTED = 'disconnected',
+ CONNECTING = 'connecting',
+ CONNECTED = 'connected',
+ DISCONNECTING = 'disconnecting',
+}
+
+export interface BleError {
+ errorCode: number;
+ message: string;
+ attErrorCode?: number | null;
+ iosErrorCode?: number | null;
+ androidErrorCode?: number | null;
+ reason?: string | null;
+}
+
+export interface ScanResult {
+ device: BleDevice;
+ rssi: number | null;
+ localName: string | null;
+}
diff --git a/ble/example/BleExampleV2.tsx b/ble/example/BleExampleV2.tsx
new file mode 100644
index 0000000..0055f05
--- /dev/null
+++ b/ble/example/BleExampleV2.tsx
@@ -0,0 +1,372 @@
+import React, { useState, useEffect } from 'react';
+import { View, Text, Button, Alert, ScrollView, StyleSheet } from 'react-native';
+import {
+ BleManager,
+ DeviceInfoManagerV2,
+ FileTransferManager,
+ CommandUtils,
+ PROTOCOL_UUIDS,
+ APP_COMMAND_TYPES,
+ DEVICE_RESPONSE_TYPES,
+ BleEventCallback,
+ DeviceInfoListener,
+ FileTransferListener,
+ DeviceInfo,
+ ActivationStatus,
+ VersionInfo,
+ BleDevice,
+} from '../index';
+
+/**
+ * Example component demonstrating BLE V2.1.1 protocol usage
+ * This component shows how to use the new JSON-based BLE protocol
+ */
+const BleExampleV2: React.FC = () => {
+ const [isScanning, setIsScanning] = useState(false);
+ const [isConnected, setIsConnected] = useState(false);
+ const [connectedDevice, setConnectedDevice] = useState(null);
+ const [deviceInfo, setDeviceInfo] = useState(null);
+ const [version, setVersion] = useState('');
+ const [isActivated, setIsActivated] = useState(false);
+ const [transferProgress, setTransferProgress] = useState(0);
+ const [isTransferring, setIsTransferring] = useState(false);
+ const [logs, setLogs] = useState([]);
+
+ // Managers
+ const bleManager = BleManager.getInstance();
+ const deviceInfoManager = DeviceInfoManagerV2.getInstance();
+ const fileTransferManager = FileTransferManager.getInstance();
+
+ // Add log message
+ const addLog = (message: string) => {
+ const timestamp = new Date().toLocaleTimeString();
+ setLogs(prev => [...prev, `[${timestamp}] ${message}`]);
+ };
+
+ // Setup event listeners
+ useEffect(() => {
+ const bleCallback: BleEventCallback = {
+ onDiscoveryStateChange: (scanning: boolean) => {
+ setIsScanning(scanning);
+ addLog(`Scanning: ${scanning ? 'Started' : 'Stopped'}`);
+ },
+ onDeviceDiscovered: (device) => {
+ addLog(`Device discovered: ${device.name || 'Unknown'} (${device.id})`);
+ },
+ onConnectionStateChange: (deviceId, status) => {
+ setIsConnected(status === 'Connected');
+ addLog(`Connection ${status}: ${deviceId}`);
+ if (status === 'Disconnected') {
+ setConnectedDevice(null);
+ }
+ },
+ onDataReceived: (deviceId, serviceUuid, characteristicUuid, data) => {
+ console.log('onDataReceived', deviceId, serviceUuid, characteristicUuid, data);
+ addLog(`Data received from ${deviceId} (${data.byteLength} bytes)`);
+ },
+ };
+
+ const deviceInfoCallback: DeviceInfoListener = {
+ onDeviceInfoReceived: (info) => {
+ setDeviceInfo(info);
+ addLog(`Device info received: ${info.devname}`);
+ },
+ onVersionReceived: (version) => {
+ setVersion(version);
+ addLog(`Version: ${version}`);
+ },
+ onActivationStatusReceived: (activated) => {
+ setIsActivated(activated);
+ addLog(`Activation status: ${activated ? 'Activated' : 'Not activated'}`);
+ },
+ onIdentityCheckReceived: (isValid) => {
+ addLog(`Identity check: ${isValid ? 'Valid' : 'Invalid'}`);
+ },
+ };
+
+ const fileTransferCallback: FileTransferListener = {
+ onStart: () => {
+ setIsTransferring(true);
+ setTransferProgress(0);
+ addLog('File transfer started');
+ },
+ onProgress: (progress) => {
+ setTransferProgress(progress);
+ addLog(`Transfer progress: ${progress}%`);
+ },
+ onComplete: () => {
+ setIsTransferring(false);
+ setTransferProgress(0);
+ addLog('File transfer completed');
+ },
+ onFail: (code, message) => {
+ setIsTransferring(false);
+ setTransferProgress(0);
+ addLog(`Transfer failed: ${message} (${code})`);
+ },
+ };
+
+ bleManager.registerCallback(bleCallback);
+ deviceInfoManager.addListener(deviceInfoCallback);
+ fileTransferManager.addListener(fileTransferCallback);
+
+ return () => {
+ bleManager.unregisterCallback(bleCallback);
+ deviceInfoManager.removeListener(deviceInfoCallback);
+ fileTransferManager.removeListener(fileTransferCallback);
+ };
+ }, []);
+
+ // Start scanning for devices
+ const startScan = async () => {
+ try {
+ addLog('Starting BLE scan...');
+ await bleManager.startScan();
+ } catch (error) {
+ addLog(`Scan failed: ${error}`);
+ }
+ };
+
+ // Stop scanning
+ const stopScan = async () => {
+ try {
+ addLog('Stopping BLE scan...');
+ await bleManager.stopScan();
+ } catch (error) {
+ addLog(`Stop scan failed: ${error}`);
+ }
+ };
+
+ // Connect to device (for demo, connect to first discovered device)
+ const connectToDevice = async () => {
+ try {
+ addLog('Connecting to device...');
+ // In real app, you would select a specific device
+ // For demo, we'll show the connection flow
+ Alert.alert('Connect', 'Please select a device from the scan results first');
+ } catch (error) {
+ addLog(`Connection failed: ${error}`);
+ }
+ };
+
+ // Disconnect from device
+ const disconnectDevice = async () => {
+ try {
+ addLog('Disconnecting from device...');
+ await bleManager.disconnectDevice();
+ } catch (error) {
+ addLog(`Disconnect failed: ${error}`);
+ }
+ };
+
+ // Query activation status
+ const queryActivationStatus = async () => {
+ try {
+ addLog('Querying activation status...');
+ await deviceInfoManager.queryActivationStatus();
+ } catch (error) {
+ addLog(`Activation query failed: ${error}`);
+ }
+ };
+
+ // Query version
+ const queryVersion = async () => {
+ try {
+ addLog('Querying device version...');
+ await deviceInfoManager.queryVersion();
+ } catch (error) {
+ addLog(`Version query failed: ${error}`);
+ }
+ };
+
+ // Request device info
+ const requestDeviceInfo = async () => {
+ try {
+ addLog('Requesting device info...');
+ await deviceInfoManager.requestDeviceInfoReport();
+ } catch (error) {
+ addLog(`Device info request failed: ${error}`);
+ }
+ };
+
+ // Update activation time to current time
+ const updateActivationTime = async () => {
+ try {
+ addLog('Updating activation time...');
+ await CommandUtils.sendCurrentTimeUpdate();
+ } catch (error) {
+ addLog(`Time update failed: ${error}`);
+ }
+ };
+
+ // Send identity check response
+ const sendIdentityCheck = (isValid: boolean) => {
+ try {
+ addLog(`Sending identity check response: ${isValid}`);
+ deviceInfoManager.sendIdentityCheckResponse(isValid);
+ } catch (error) {
+ addLog(`Identity check failed: ${error}`);
+ }
+ };
+
+ // Transfer sample file (demo)
+ const transferSampleFile = async () => {
+ try {
+ addLog('Starting sample file transfer...');
+ // In real app, you would provide actual file path
+ Alert.alert('File Transfer', 'Please provide a valid file path for transfer');
+ } catch (error) {
+ addLog(`File transfer failed: ${error}`);
+ }
+ };
+
+ // Clear logs
+ const clearLogs = () => {
+ setLogs([]);
+ };
+
+ return (
+
+ BLE V2.1.1 Protocol Example
+
+ {/* Connection Status */}
+
+ Connection Status
+ Scanning: {isScanning ? 'Yes' : 'No'}
+ Connected: {isConnected ? 'Yes' : 'No'}
+ Device: {connectedDevice?.name || 'None'}
+
+
+ {/* Device Info */}
+
+ Device Information
+ Activated: {isActivated ? 'Yes' : 'No'}
+ Version: {version || 'Unknown'}
+ {deviceInfo && (
+ <>
+ Name: {deviceInfo.devname}
+ Total Space: {deviceInfo.allspace} KB
+ Free Space: {deviceInfo.freespace} KB
+ Brand: {deviceInfo.brand}
+ >
+ )}
+
+
+ {/* Transfer Status */}
+
+ File Transfer
+ Transferring: {isTransferring ? 'Yes' : 'No'}
+ Progress: {transferProgress}%
+
+
+ {/* Control Buttons */}
+
+ Controls
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sendIdentityCheck(true)} disabled={!isConnected} />
+ sendIdentityCheck(false)} disabled={!isConnected} />
+
+
+
+
+
+
+
+
+ {/* Protocol Info */}
+
+ Protocol Information
+ Version: 2.1.1
+ Service UUID: {PROTOCOL_UUIDS.SERVICE}
+ Write UUID: {PROTOCOL_UUIDS.WRITE_CHARACTERISTIC}
+ Read UUID: {PROTOCOL_UUIDS.READ_CHARACTERISTIC}
+
+
+ {/* Logs */}
+
+
+ Logs
+
+
+
+ {logs.map((log, index) => (
+ {log}
+ ))}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 16,
+ backgroundColor: '#f5f5f5',
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ marginBottom: 20,
+ },
+ section: {
+ backgroundColor: 'white',
+ padding: 16,
+ marginBottom: 16,
+ borderRadius: 8,
+ elevation: 2,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: 'bold',
+ marginBottom: 8,
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 8,
+ },
+ logHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ logContainer: {
+ height: 200,
+ backgroundColor: '#f8f8f8',
+ padding: 8,
+ borderRadius: 4,
+ },
+ logText: {
+ fontSize: 12,
+ fontFamily: 'monospace',
+ marginBottom: 2,
+ },
+});
+
+export default BleExampleV2;
diff --git a/ble/index.ts b/ble/index.ts
new file mode 100644
index 0000000..7fb243b
--- /dev/null
+++ b/ble/index.ts
@@ -0,0 +1,8 @@
+export * from './core/types';
+export * from './core/BleClient';
+export * from './protocol/Constants';
+export * from './protocol/types';
+export * from './protocol/ProtocolManager';
+export * from './services/BleProtocolService';
+export * from './services/DeviceInfoService';
+export * from './services/FileTransferService';
diff --git a/ble/manager/BlePeripheralManager.ts b/ble/manager/BlePeripheralManager.ts
new file mode 100644
index 0000000..4c40d63
--- /dev/null
+++ b/ble/manager/BlePeripheralManager.ts
@@ -0,0 +1,472 @@
+/**
+ * BLE Peripheral Manager for real peripheral functionality
+ * Handles advertising, characteristic setup, and communication with central devices
+ *
+ * SETUP REQUIREMENTS:
+ * 1. npm install buffer react-native-multi-ble-peripheral
+ * 2. npx expo prebuild (to generate native code)
+ * 3. Test on physical device (simulator doesn't support peripheral mode)
+ * 4. Android 12+ requires BLUETOOTH_ADVERTISE permission (handled in app.json)
+ * 5. iOS requires NSBluetoothPeripheralUsageDescription (handled in app.json)
+ */
+
+import {Platform} from 'react-native';
+import Peripheral, {Permission, Property} from 'react-native-multi-ble-peripheral';
+import {
+ BLE_UUIDS as PROTOCOL_UUIDS,
+ COMMAND_TYPES as APP_COMMAND_TYPES,
+ DeviceInfo,
+ FRAME_CONSTANTS as PROTOCOL_FRAME,
+ PeripheralConnectionEvent,
+ PeripheralReadRequest,
+ PeripheralWriteRequest,
+ RESPONSE_TYPES as DEVICE_RESPONSE_TYPES
+} from '../index';
+import {ProtocolUtilsV2} from '../utils/ProtocolUtilsV2';
+import {Buffer} from 'buffer';
+
+// Re-export DeviceInfo for useBlePeripheral hook
+export type {DeviceInfo};
+
+export interface PeripheralEventCallback {
+ onAdvertisingStateChange?: (isAdvertising: boolean, error?: string) => void;
+ onCentralConnected?: (centralId: string) => void;
+ onCentralDisconnected?: (centralId: string) => void;
+ onCharacteristicRead?: (centralId: string, characteristicUuid: string, data: ArrayBuffer) => void;
+ onCharacteristicWrite?: (centralId: string, characteristicUuid: string, data: ArrayBuffer) => void;
+}
+
+export class BlePeripheralManager {
+ private static instance: BlePeripheralManager;
+ private callbacks: Set = new Set();
+ private peripheral: Peripheral | null = null;
+ private isAdvertising = false;
+ private isReady = false;
+ private connectedCentrals = new Set();
+ private deviceInfo: DeviceInfo;
+ private operationQueue: Array<() => Promise> = [];
+
+ // Default device info - matches the DeviceInfo interface from BleTypesV2
+ private readonly DEFAULT_DEVICE_INFO: DeviceInfo = {
+ devname: 'BLE Test Device',
+ allspace: 1024,
+ freespace: 512,
+ size: 1, // Square screen
+ brand: 1, // Vendor
+ version: '2.1.1', // Firmware version
+ activated: true, // Activation status
+ };
+
+ private constructor() {
+ this.deviceInfo = {...this.DEFAULT_DEVICE_INFO};
+ this.initializePeripheral();
+ }
+
+ public static getInstance(): BlePeripheralManager {
+ if (!BlePeripheralManager.instance) {
+ BlePeripheralManager.instance = new BlePeripheralManager();
+ }
+ return BlePeripheralManager.instance;
+ }
+
+ private async initializePeripheral(): Promise {
+ try {
+ if (Platform.OS === 'web') {
+ console.log('BlePeripheralManager: BLE peripheral not supported on web platform');
+ return;
+ }
+
+ this.peripheral = new Peripheral();
+ console.log('BlePeripheralManager: Peripheral instance created');
+
+ // Set up event listeners
+ this.peripheral.on('ready', this.handleReady.bind(this));
+ this.peripheral.on('connect', this.handleCentralConnected.bind(this));
+ this.peripheral.on('disconnect', this.handleCentralDisconnected.bind(this));
+ this.peripheral.on('read', this.handleCharacteristicRead.bind(this));
+ this.peripheral.on('write', this.handleCharacteristicWrite.bind(this));
+
+ console.log('BlePeripheralManager: Event listeners set up');
+ } catch (error) {
+ console.error('BlePeripheralManager: Failed to initialize peripheral', error);
+ this.notifyAdvertisingStateChange(false, error instanceof Error ? error.message : 'Unknown error');
+ }
+ }
+
+ private async handleReady(): Promise {
+ console.log('BlePeripheralManager: Peripheral ready');
+ this.isReady = true;
+
+ // Execute queued operations
+ while (this.operationQueue.length > 0) {
+ const operation = this.operationQueue.shift();
+ if (operation) {
+ try {
+ await operation();
+ } catch (error) {
+ console.error('BlePeripheralManager: Queued operation failed', error);
+ }
+ }
+ }
+ }
+
+ private handleCentralConnected(central: PeripheralConnectionEvent): void {
+ console.log('BlePeripheralManager: Central connected', central);
+ const centralId = central.id || 'unknown';
+ this.connectedCentrals.add(centralId);
+ this.notifyCentralConnected(centralId);
+ }
+
+ private handleCentralDisconnected(central: PeripheralConnectionEvent): void {
+ console.log('BlePeripheralManager: Central disconnected', central);
+ const centralId = central.id || 'unknown';
+ this.connectedCentrals.delete(centralId);
+ this.notifyCentralDisconnected(centralId);
+ }
+
+ private handleCharacteristicRead(request: PeripheralReadRequest): void {
+ console.log('BlePeripheralManager: Characteristic read request', request);
+
+ if (request.characteristicUuid === PROTOCOL_UUIDS.READ_CHARACTERISTIC) {
+ // Return device info as JSON
+ const response = {
+ type: 0x02, // Device info response type
+ data: this.deviceInfo,
+ };
+ const jsonString = JSON.stringify(response);
+
+ // Update the characteristic value with the response
+ if (this.peripheral) {
+ this.peripheral.updateValue(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.READ_CHARACTERISTIC,
+ Buffer.from(jsonString, 'utf-8')
+ );
+ }
+ }
+ }
+
+ private handleCharacteristicWrite(request: PeripheralWriteRequest): void {
+ console.log('BlePeripheralManager: Characteristic write request', request);
+
+ if (request.characteristicUuid === PROTOCOL_UUIDS.WRITE_CHARACTERISTIC) {
+ let buffer: Buffer;
+ try {
+ buffer = Buffer.from(request.value);
+ } catch (e) {
+ console.error('BlePeripheralManager: Failed to convert value to buffer', e);
+ return;
+ }
+
+ const arrayBuffer = ProtocolUtilsV2.uint8ArrayToArrayBuffer(buffer);
+
+ // Check for V2 Binary Protocol (starts with HEAD_APP_TO_DEVICE = 0xc7)
+ if (buffer.length > 0 && buffer[0] === PROTOCOL_FRAME.HEAD_APP_TO_DEVICE) {
+ console.log('BlePeripheralManager: Received V2 binary frame');
+ try {
+ const frame = ProtocolUtilsV2.parseFrame(arrayBuffer);
+ if (frame && frame.data) {
+ const jsonData = ProtocolUtilsV2.parseJsonData(frame.data);
+ if (jsonData) {
+ console.log('BlePeripheralManager: Parsed V2 command:', jsonData);
+
+ // Handle Version Query (0x07)
+ if (jsonData.type === APP_COMMAND_TYPES.VERSION_QUERY) {
+ console.log('BlePeripheralManager: Handling VERSION_QUERY');
+ const response = {
+ type: DEVICE_RESPONSE_TYPES.VERSION_INFO,
+ version: this.deviceInfo.version || '2.1.1'
+ };
+ const jsonString = JSON.stringify(response);
+ // Use HEAD_DEVICE_TO_APP (0xB0) for response
+ const frames = ProtocolUtilsV2.createFrame(
+ DEVICE_RESPONSE_TYPES.VERSION_INFO,
+ jsonString,
+ false,
+ PROTOCOL_FRAME.HEAD_DEVICE_TO_APP
+ );
+
+ if (this.peripheral) {
+ for (const f of frames) {
+ this.peripheral.updateValue(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.READ_CHARACTERISTIC,
+ Buffer.from(f)
+ );
+ }
+ console.log('BlePeripheralManager: Sent VERSION_INFO response');
+ }
+ }
+ this.notifyCharacteristicWrite(request.centralId, request.characteristicUuid, arrayBuffer);
+ return;
+ }
+ }
+ } catch (e) {
+ console.error('BlePeripheralManager: Error processing V2 frame', e);
+ }
+ }
+
+ try {
+ const jsonString = buffer.toString('utf-8');
+ const command = JSON.parse(jsonString);
+ console.log('BlePeripheralManager: Received command type: 0x' + (command.type?.toString(16) || 'unknown'));
+
+ // Process different command types
+ switch (command.type) {
+ case 0x01: // General command
+ console.log('BlePeripheralManager: Processing general command');
+ break;
+ case 0x02: // Device info request
+ console.log('BlePeripheralManager: Device info request received');
+ break;
+ default:
+ console.log('BlePeripheralManager: Unknown command type:', command.type);
+ }
+
+ this.notifyCharacteristicWrite(request.centralId, request.characteristicUuid, arrayBuffer);
+ } catch (error) {
+ console.error('BlePeripheralManager: Failed to process write request', error);
+ }
+ }
+ }
+
+ private async executeOrQueue(operation: () => Promise): Promise {
+ if (this.isReady && this.peripheral) {
+ await operation();
+ } else {
+ this.operationQueue.push(operation);
+ }
+ }
+
+ public async startAdvertising(deviceName?: string): Promise {
+ try {
+ if (Platform.OS === 'web') {
+ throw new Error('BLE peripheral not supported on web platform');
+ }
+
+ if (this.isAdvertising) {
+ console.log('BlePeripheralManager: Already advertising');
+ return;
+ }
+
+ if (!this.peripheral) {
+ throw new Error('Peripheral not initialized');
+ }
+
+ const currentDeviceInfo = this.getDeviceInfo();
+ console.log('BlePeripheralManager: Starting BLE advertising...');
+ console.log('Device Name: ' + currentDeviceInfo.devname);
+ console.log('Advertising with service UUID: ' + PROTOCOL_UUIDS.SERVICE);
+
+ // iOS platform limitation warning
+ if (Platform.OS === 'ios') {
+ console.log('⚠️ iOS: Advertising will stop when app goes to background');
+ }
+
+ await this.executeOrQueue(async () => {
+ // Set up service
+ await this.peripheral!.addService(PROTOCOL_UUIDS.SERVICE, true);
+ console.log('BlePeripheralManager: Service added successfully');
+
+ await this.peripheral!.addCharacteristic(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.SERVICE,
+ Property.BROADCAST,
+ Permission.READABLE
+ )
+
+ // Set up read characteristic
+ await this.peripheral!.addCharacteristic(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.READ_CHARACTERISTIC,
+ Property.READ | Property.NOTIFY,
+ Permission.READABLE
+ );
+ console.log('BlePeripheralManager: Read characteristic added successfully');
+
+ // Set up write characteristic
+ await this.peripheral!.addCharacteristic(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.WRITE_CHARACTERISTIC,
+ Property.WRITE | Property.WRITE_NO_RESPONSE,
+ Permission.WRITEABLE
+ );
+ console.log('BlePeripheralManager: Write characteristic added successfully');
+
+ // Set initial read characteristic value
+ const deviceInfoResponse = {
+ type: 0x02,
+ data: currentDeviceInfo,
+ };
+ await this.peripheral!.updateValue(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.READ_CHARACTERISTIC,
+ Buffer.from(JSON.stringify(deviceInfoResponse), 'utf-8')
+ );
+ console.log('BlePeripheralManager: Initial characteristic value set');
+
+ // Start advertising
+ await this.peripheral!.startAdvertising();
+ console.log('BlePeripheralManager: Advertising started successfully');
+ });
+
+ this.isAdvertising = true;
+ this.notifyAdvertisingStateChange(true);
+
+ } catch (error) {
+ console.error('BlePeripheralManager: Failed to start advertising', error);
+ this.notifyAdvertisingStateChange(false, error instanceof Error ? error.message : 'Unknown error');
+ throw error;
+ }
+ }
+
+ public async stopAdvertising(): Promise {
+ try {
+ if (Platform.OS === 'web') {
+ return;
+ }
+
+ if (!this.isAdvertising) {
+ console.log('BlePeripheralManager: Not advertising');
+ return;
+ }
+
+ if (!this.peripheral) {
+ throw new Error('Peripheral not initialized');
+ }
+
+ await this.executeOrQueue(async () => {
+ await this.peripheral!.stopAdvertising();
+ console.log('BlePeripheralManager: Advertising stopped');
+ });
+
+ this.isAdvertising = false;
+ this.connectedCentrals.clear();
+ this.notifyAdvertisingStateChange(false);
+
+ } catch (error) {
+ console.error('BlePeripheralManager: Failed to stop advertising', error);
+ throw error;
+ }
+ }
+
+ public getAdvertisingState(): boolean {
+ return this.isAdvertising;
+ }
+
+ public getConnectedCentrals(): string[] {
+ return Array.from(this.connectedCentrals);
+ }
+
+ public getConnectedCentralsCount(): number {
+ return this.connectedCentrals.size;
+ }
+
+ public updateDeviceInfo(updates: Partial): void {
+ this.deviceInfo = {...this.deviceInfo, ...updates};
+ console.log('BlePeripheralManager: Device info updated', this.deviceInfo);
+
+ // Update the characteristic value if advertising
+ if (this.isAdvertising && this.peripheral && this.isReady) {
+ const response = {
+ type: 0x02,
+ data: this.deviceInfo,
+ };
+ this.peripheral.updateValue(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.READ_CHARACTERISTIC,
+ Buffer.from(JSON.stringify(response), 'utf-8')
+ ).catch(error => {
+ console.error('BlePeripheralManager: Failed to update characteristic value', error);
+ });
+ }
+ }
+
+ public getDeviceInfo(): DeviceInfo {
+ return {...this.deviceInfo};
+ }
+
+ public resetDeviceInfo(): void {
+ this.deviceInfo = {...this.DEFAULT_DEVICE_INFO};
+ console.log('BlePeripheralManager: Device info reset to defaults');
+
+ // Update the characteristic value if advertising
+ if (this.isAdvertising && this.peripheral && this.isReady) {
+ const response = {
+ type: 0x02,
+ data: this.deviceInfo,
+ };
+ this.peripheral.updateValue(
+ PROTOCOL_UUIDS.SERVICE,
+ PROTOCOL_UUIDS.READ_CHARACTERISTIC,
+ Buffer.from(JSON.stringify(response), 'utf-8')
+ ).catch(error => {
+ console.error('BlePeripheralManager: Failed to update characteristic value', error);
+ });
+ }
+ }
+
+ public addCallback(callback: PeripheralEventCallback): void {
+ this.callbacks.add(callback);
+ }
+
+ public removeCallback(callback: PeripheralEventCallback): void {
+ this.callbacks.delete(callback);
+ }
+
+ private notifyAdvertisingStateChange(isAdvertising: boolean, error?: string): void {
+ this.callbacks.forEach(callback => {
+ try {
+ callback.onAdvertisingStateChange?.(isAdvertising, error);
+ } catch (err) {
+ console.error('BlePeripheralManager: Callback error', err);
+ }
+ });
+ }
+
+ private notifyCentralConnected(centralId: string): void {
+ this.callbacks.forEach(callback => {
+ try {
+ callback.onCentralConnected?.(centralId);
+ } catch (err) {
+ console.error('BlePeripheralManager: Callback error', err);
+ }
+ });
+ }
+
+ private notifyCentralDisconnected(centralId: string): void {
+ this.callbacks.forEach(callback => {
+ try {
+ callback.onCentralDisconnected?.(centralId);
+ } catch (err) {
+ console.error('BlePeripheralManager: Callback error', err);
+ }
+ });
+ }
+
+ private notifyCharacteristicRead(centralId: string, characteristicUuid: string, data: ArrayBuffer): void {
+ this.callbacks.forEach(callback => {
+ try {
+ callback.onCharacteristicRead?.(centralId, characteristicUuid, data);
+ } catch (err) {
+ console.error('BlePeripheralManager: Callback error', err);
+ }
+ });
+ }
+
+ private notifyCharacteristicWrite(centralId: string, characteristicUuid: string, data: ArrayBuffer): void {
+ this.callbacks.forEach(callback => {
+ try {
+ callback.onCharacteristicWrite?.(centralId, characteristicUuid, data);
+ } catch (err) {
+ console.error('BlePeripheralManager: Callback error', err);
+ }
+ });
+ }
+
+ public cleanup(): void {
+ this.stopAdvertising().catch(console.error);
+ this.callbacks.clear();
+ this.operationQueue = [];
+ }
+}
diff --git a/ble/package.json b/ble/package.json
new file mode 100644
index 0000000..a1badf6
--- /dev/null
+++ b/ble/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@loomart/react-native-ble-protocol",
+ "version": "1.0.0",
+ "description": "React Native BLE communication protocol library for LoomArt devices",
+ "main": "index.ts",
+ "types": "index.ts",
+ "scripts": {
+ "build": "tsc",
+ "test": "jest"
+ },
+ "keywords": [
+ "react-native",
+ "ble",
+ "bluetooth",
+ "protocol",
+ "loomart"
+ ],
+ "author": "Bowong",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-native": ">=0.70.0"
+ },
+ "dependencies": {
+ "react-native-ble-plx": "^3.5.0",
+ "buffer": "^6.0.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-native": "^0.70.0",
+ "@types/crypto-js": "^4.1.1",
+ "typescript": "^4.8.0",
+ "jest": "^29.0.0"
+ },
+ "files": [
+ "index.ts",
+ "manager/",
+ "utils/",
+ "hooks/",
+ "types/",
+ "README.md"
+ ]
+}
diff --git a/ble/protocol/Constants.ts b/ble/protocol/Constants.ts
new file mode 100644
index 0000000..4663215
--- /dev/null
+++ b/ble/protocol/Constants.ts
@@ -0,0 +1,52 @@
+export const PROTOCOL_VERSION = "1.0.0";
+
+export const BLE_UUIDS = {
+ SERVICE: '000002c4-0000-1000-8000-00805f9b34fb',
+ SERVICE_SHORT: 'ae00',
+ BROADCAST_CHARACTERISTIC: "000002c1-0000-1000-8000-00805f9b34fb",
+ WRITE_CHARACTERISTIC: '000002c5-0000-1000-8000-00805f9b34fb',
+ READ_CHARACTERISTIC: '000002c6-0000-1000-8000-00805f9b34fb',
+} as const;
+
+export const FRAME_CONSTANTS = {
+ HEAD_DEVICE_TO_APP: 0xb1,
+ HEAD_APP_TO_DEVICE: 0xc7,
+ MAX_DATA_SIZE: 496,
+ HEADER_SIZE: 8,
+ FOOTER_SIZE: 1,
+} as const;
+// 562包 427包 384字节
+// b1 05 02 32 01 ab 01 f0 e2cfbfc2199a5ea27f028e208c0257c40e18e4631c6cb63356db638528f2d82973cb70dc331968fec529061c3761c78ffda94dea8e2f42c8116e569eccc7b5f764c2463f9f922829090f0b6a03341a6956cdb1ab38e1117ee3ea76f75b8516b87cf83c1ba91b010a3eef22a8aa26d5483b68aa2aa680996b77d7ebdc87fd88d565cc2b7134cc90fc6135394a5478527ba31a7d455605344798061797cc1eb78e2c1060f26f86f7d89be5acb6c96de2cb9e91216db74a3c221b1c9ad6afabb587c81ba9720cb2e5851d94b18ca55247191a607993d91c3886368167ab19109809a18f200ecf75738a006832517a0ffa712c4c59c3fd36d0c80800176598aa506faeb26597ac1cf5d7278f3a0bfb40fd2768680efeec6e67dfbbd628b83b1fe91860001917820e3f2b1e14bde5ebd838f6c785f50aee6ed1438bc2e924068038d68340dc5b27dbe8ca38b3c4d00e257d9e2b76ce9546283e988cc505e52a3eeeed2496c4d552826e9473ac9a93af3df73415a2fbddf9bc2d0bbfafa5330ba4a19eda04e034c31809d56f37d8800e0491385477003c436d7a60f3df41a3257fd38bbed251b087ca18ae7071758e65d9cdb907a052bad5b4cbf648627a9d28cbcc19e208891c4f7008469d1ee76032902ba4fee15ab970523a5a0f639ceeefaf59ca16ebe71b96fbc16069ef3719d139e514b0
+
+export const COMMAND_TYPES = {
+ ACTIVATION_QUERY: 0x01,
+ OTA_PACKAGE: 0x02,
+ TRANSFER_BOOT_ANIMATION: 0x03,
+ TRANSFER_AVI_VIDEO: 0x04,
+ TRANSFER_ANI_VIDEO: 0x05,
+ TRANSFER_JPEG_IMAGE: 0x06,
+ VERSION_QUERY: 0x07,
+ UPDATE_ACTIVATION_TIME: 0x08,
+ DEVICE_INFO_SETTINGS: 0x0d,
+ DEVICE_IDENTITY_CHECK: 0x0e,
+} as const;
+
+export const RESPONSE_TYPES = {
+ ACTIVATION_STATUS: 0x01,
+ VERSION_INFO: 0x07,
+ DEVICE_INFO_REPORT: 0x0d,
+ IDENTITY_CHECK_RESULT: 0x0e,
+} as const;
+
+export const ERROR_CODES = {
+ DISCONNECT: -100,
+ TRANSFER_TIMEOUT: -101,
+ INVALID_RESPONSE: -102,
+ PROTOCOL_ERROR: -103,
+ CHECKSUM_MISMATCH: -104,
+ JSON_PARSE_ERROR: -105,
+ DEVICE_NOT_ACTIVATED: -106,
+ INVALID_FILE_FORMAT: -107,
+ TRANSFER_IN_PROGRESS: -108,
+ INSUFFICIENT_SPACE: -109,
+} as const;
diff --git a/ble/protocol/ProtocolManager.ts b/ble/protocol/ProtocolManager.ts
new file mode 100644
index 0000000..34b0d54
--- /dev/null
+++ b/ble/protocol/ProtocolManager.ts
@@ -0,0 +1,155 @@
+import {Buffer} from 'buffer';
+import {ProtocolFrame} from './types';
+import {FRAME_CONSTANTS} from './Constants';
+
+export class ProtocolManager {
+
+ static calculateChecksum(frameData: Uint8Array): number {
+ let sum = 0;
+ // Checksum is calculated on all bytes except the last one (which is the checksum itself)
+ // But here we are calculating FOR the last byte.
+ for (let i = 0; i < frameData.length; i++) {
+ sum += frameData[i];
+ }
+ return sum & 0xff;
+ }
+
+ static verifyChecksum(frameData: Uint8Array, expectedChecksum: number): boolean {
+ const calculated = this.calculateChecksum(frameData);
+ return calculated === expectedChecksum;
+ }
+
+ static createFrame(
+ type: number,
+ data: Uint8Array,
+ head: number = FRAME_CONSTANTS.HEAD_APP_TO_DEVICE,
+ requireFragmentation: boolean = true
+ ): Uint8Array[] {
+ if (data.length <= FRAME_CONSTANTS.MAX_DATA_SIZE || !requireFragmentation) {
+ console.debug(`[ProtocolManager] Frame size ${data.length} is less than max size ${FRAME_CONSTANTS.MAX_DATA_SIZE}, not fragmented`);
+ return [this.createSingleFrame(type, data, head)];
+ } else {
+ console.debug(`[ProtocolManager] Frame size ${data.length} is greater than max size ${FRAME_CONSTANTS.MAX_DATA_SIZE}, fragmented`);
+ return this.createFragmentedFrames(type, data, head);
+ }
+ }
+
+ private static createSingleFrame(type: number, data: Uint8Array, head: number): Uint8Array {
+ const buffer = new Uint8Array(FRAME_CONSTANTS.HEADER_SIZE + data.length + FRAME_CONSTANTS.FOOTER_SIZE);
+ let offset = 0;
+
+ buffer[offset++] = head;
+ buffer[offset++] = type;
+
+ // subpageTotal = 0
+ buffer[offset++] = 0;
+ buffer[offset++] = 0;
+
+ // curPage = 0
+ buffer[offset++] = 0;
+ buffer[offset++] = 0;
+
+ // dataLen
+ buffer[offset++] = (data.length >> 8) & 0xff;
+ buffer[offset++] = data.length & 0xff;
+
+ // data
+ buffer.set(data, offset);
+ offset += data.length;
+
+ // checksum
+ // Logic from ProtocolUtilsV2: calculate sum of everything before checksum byte
+ const checksum = this.calculateChecksum(buffer.slice(0, offset));
+ buffer[offset] = checksum;
+
+ return buffer;
+ }
+
+ private static createFragmentedFrames(type: number, data: Uint8Array, head: number): Uint8Array[] {
+ const frames: Uint8Array[] = [];
+ const totalSize = data.length;
+ const maxDataSize = FRAME_CONSTANTS.MAX_DATA_SIZE;
+ const totalPages = Math.ceil(totalSize / maxDataSize);
+
+ for (let i = 0; i < totalPages; i++) {
+ const start = i * maxDataSize;
+ const end = Math.min(start + maxDataSize, totalSize);
+ const chunk = data.slice(start, end);
+
+ const buffer = new Uint8Array(FRAME_CONSTANTS.HEADER_SIZE + chunk.length + FRAME_CONSTANTS.FOOTER_SIZE);
+ let offset = 0;
+
+ buffer[offset++] = head;
+ buffer[offset++] = type;
+
+ // subpageTotal
+ buffer[offset++] = (totalPages >> 8) & 0xff;
+ buffer[offset++] = totalPages & 0xff;
+
+ // curPage (descending order usually? No, BleManagerV2 comments said: "Protocol specifies: page numbers count down from highest to 0")
+ // Wait, ProtocolUtilsV2 code:
+ // const curPageVal = totalPages - 1 - i;
+ const curPageVal = totalPages - 1 - i;
+
+ buffer[offset++] = (curPageVal >> 8) & 0xff;
+ buffer[offset++] = curPageVal & 0xff;
+
+ // dataLen
+ buffer[offset++] = (chunk.length >> 8) & 0xff;
+ buffer[offset++] = chunk.length & 0xff;
+ console.debug(`chunk length = ${chunk.length}, buffer 8 header = ${buffer.slice(0, 8).toString()}`)
+ // data
+ buffer.set(chunk, offset);
+ offset += chunk.length;
+
+ buffer[offset] = this.calculateChecksum(buffer.slice(0, offset));
+
+ frames.push(buffer);
+ }
+
+ return frames;
+ }
+
+ static parseFrame(data: ArrayBufferLike): ProtocolFrame | null {
+ const bytes = new Uint8Array(data);
+ if (bytes.length < FRAME_CONSTANTS.HEADER_SIZE + FRAME_CONSTANTS.FOOTER_SIZE) {
+ return null;
+ }
+
+ const head = bytes[0];
+ if (head !== FRAME_CONSTANTS.HEAD_DEVICE_TO_APP && head !== FRAME_CONSTANTS.HEAD_APP_TO_DEVICE) {
+ console.warn(`[ProtocolManager] Invalid frame header: 0x${head.toString(16)}`);
+ return null;
+ }
+
+ const type = bytes[1];
+ const subpageTotal = (bytes[2] << 8) | bytes[3];
+ const curPage = (bytes[4] << 8) | bytes[5];
+ const dataLen = (bytes[6] << 8) | bytes[7];
+
+ if (bytes.length < FRAME_CONSTANTS.HEADER_SIZE + dataLen + FRAME_CONSTANTS.FOOTER_SIZE) {
+ // Incomplete
+ return null;
+ }
+
+ const frameData = bytes.slice(FRAME_CONSTANTS.HEADER_SIZE, FRAME_CONSTANTS.HEADER_SIZE + dataLen);
+ const checksum = bytes[FRAME_CONSTANTS.HEADER_SIZE + dataLen];
+
+ // Verify checksum
+ const dataToCheck = bytes.slice(0, FRAME_CONSTANTS.HEADER_SIZE + dataLen);
+ if (!this.verifyChecksum(dataToCheck, checksum)) {
+ console.warn("Checksum mismatch");
+ return null;
+ }
+
+ return {
+ head,
+ type,
+ subpageTotal,
+ curPage,
+ dataLen,
+ data: frameData.buffer as ArrayBuffer,
+ checksum
+ };
+ }
+}
diff --git a/ble/protocol/types.ts b/ble/protocol/types.ts
new file mode 100644
index 0000000..f2d0c46
--- /dev/null
+++ b/ble/protocol/types.ts
@@ -0,0 +1,89 @@
+export interface ProtocolFrame {
+ head: number;
+ type: number;
+ subpageTotal: number;
+ curPage: number;
+ dataLen: number;
+ data: ArrayBuffer;
+ checksum: number;
+}
+
+export interface DeviceInfo {
+ allspace: number;
+ freespace: number;
+ devname: string;
+ size: number;
+ brand: number;
+ version?: string;
+ activated?: boolean;
+}
+
+export interface ActivationStatus {
+ type: number;
+ state: number;
+}
+
+export interface VersionInfo {
+ type: number;
+ version: string;
+}
+
+export interface IdentityCheckResult {
+ type: number;
+ IdCheck: string;
+}
+
+export interface ActivationTimeUpdate {
+ type: number;
+ year: number;
+ mon: number;
+ day: number;
+ hour: number;
+ min: number;
+ mes: number;
+}
+
+export interface PeripheralConnectionEvent {
+ id: string;
+}
+
+export interface PeripheralReadRequest {
+ centralId: string;
+ serviceUuid: string;
+ characteristicUuid: string;
+}
+
+export interface PeripheralWriteRequest {
+ centralId: string;
+ serviceUuid: string;
+ characteristicUuid: string;
+ value: string;
+}
+
+export interface FileTransferData {
+ type: number;
+ data: ArrayBuffer;
+}
+
+export interface DeviceInfoReport {
+ type: number;
+ allspace: number;
+ freespace: number;
+ devname: string;
+ size: number;
+ brand: number;
+}
+
+export interface IdentityCheckRequest {
+ type: number;
+ Ret: number;
+}
+
+export type JsonPayload =
+ | ActivationStatus
+ | VersionInfo
+ | DeviceInfoReport
+ | IdentityCheckResult
+ | ActivationTimeUpdate
+ | IdentityCheckRequest
+ | FileTransferData;
diff --git a/ble/services/BleProtocolService.ts b/ble/services/BleProtocolService.ts
new file mode 100644
index 0000000..8393341
--- /dev/null
+++ b/ble/services/BleProtocolService.ts
@@ -0,0 +1,165 @@
+import {BleClient} from '../core/BleClient';
+import {ProtocolManager} from '../protocol/ProtocolManager';
+import {BLE_UUIDS} from '../protocol/Constants';
+import {ProtocolFrame} from '../protocol/types';
+import {Buffer} from 'buffer';
+import {Subscription} from 'react-native-ble-plx';
+
+export class BleProtocolService {
+ private static instance: BleProtocolService;
+ private client = BleClient.getInstance();
+ private listeners: Map void>> = new Map();
+ private subscription: Subscription | null = null;
+
+ // deviceId_type -> { total: number, frames: ArrayBuffer[] }
+ private fragments: Map = new Map();
+
+ private constructor() {
+ }
+
+ public static getInstance(): BleProtocolService {
+ if (!BleProtocolService.instance) {
+ BleProtocolService.instance = new BleProtocolService();
+ }
+ return BleProtocolService.instance;
+ }
+
+ public addListener(type: number, callback: (data: ArrayBuffer, deviceId: string) => void) {
+ if (!this.listeners.has(type)) {
+ this.listeners.set(type, new Set());
+ }
+ this.listeners.get(type)!.add(callback);
+ }
+
+ public removeListener(type: number, callback: (data: ArrayBuffer, deviceId: string) => void) {
+ if (this.listeners.has(type)) {
+ this.listeners.get(type)!.delete(callback);
+ }
+ }
+
+ private emit(type: number, data: ArrayBuffer, deviceId: string) {
+ if (this.listeners.has(type)) {
+ this.listeners.get(type)!.forEach(cb => cb(data, deviceId));
+ }
+ }
+
+ public async initialize(deviceId: string) {
+ // Clean up previous subscription
+ this.disconnect();
+
+ // Clear fragments for this device
+ this.clearFragments(deviceId);
+
+ this.subscription = await this.client.monitor(
+ deviceId,
+ BLE_UUIDS.SERVICE,
+ BLE_UUIDS.READ_CHARACTERISTIC,
+ (error, value) => {
+ if (error) {
+ // Check for known native crash error and ignore/log as debug
+ if (error.errorCode === 0 && error.message.includes('Unknown error') && error.reason?.includes('PromiseImpl.reject')) {
+ console.debug("Ignored native monitor error", error);
+ return;
+ }
+ console.warn("Monitor error", error);
+ return;
+ }
+ if (value) {
+ const buffer = Buffer.from(value, 'base64');
+ console.log(`[BleProtocol] Received ${buffer.byteLength} bytes:`, buffer.toString('hex'));
+ this.handleRawData(deviceId, buffer);
+ }
+ }
+ );
+ }
+
+ public disconnect() {
+ if (this.subscription) {
+ try {
+ this.subscription.remove();
+ } catch (e) {
+ console.warn("Failed to remove subscription", e);
+ }
+ this.subscription = null;
+ }
+ }
+
+ private handleRawData(deviceId: string, data: Buffer) {
+ const frame = ProtocolManager.parseFrame(data.buffer);
+ if (!frame) return;
+
+ if (frame.subpageTotal > 0) {
+ this.handleFragment(deviceId, frame);
+ } else {
+ this.emit(frame.type, frame.data, deviceId);
+ }
+ }
+
+ private handleFragment(deviceId: string, frame: ProtocolFrame) {
+ const key = `${deviceId}_${frame.type}`;
+
+ if (!this.fragments.has(key)) {
+ this.fragments.set(key, {
+ total: frame.subpageTotal,
+ frames: new Array(frame.subpageTotal).fill(null)
+ });
+ }
+
+ const session = this.fragments.get(key)!;
+ // Basic validation
+ if (frame.curPage >= session.total) return;
+
+ session.frames[frame.curPage] = frame.data;
+
+ // Check if complete
+ if (session.frames.every(f => f !== null)) {
+ const combinedLength = session.frames.reduce((acc, val) => acc + (val ? val.byteLength : 0), 0);
+ const combined = new Uint8Array(combinedLength);
+ let offset = 0;
+
+ // Reassemble from High to Low pages
+ for (let i = session.total - 1; i >= 0; i--) {
+ const part = session.frames[i];
+ if (part) {
+ combined.set(new Uint8Array(part), offset);
+ offset += part.byteLength;
+ }
+ }
+
+ this.fragments.delete(key);
+ this.emit(frame.type, combined.buffer as ArrayBuffer, deviceId);
+ }
+ }
+
+ private clearFragments(deviceId: string) {
+ for (const key of this.fragments.keys()) {
+ if (key.startsWith(deviceId)) {
+ this.fragments.delete(key);
+ }
+ }
+ }
+
+ public async send(deviceId: string, type: number, data: object | ArrayBuffer, onProgress?: (progress: number) => void): Promise {
+ let payload: Uint8Array;
+ if (data instanceof ArrayBuffer) {
+ payload = new Uint8Array(data);
+ } else {
+ const jsonStr = JSON.stringify(data);
+ payload = new Uint8Array(Buffer.from(jsonStr));
+ }
+
+ const frames = ProtocolManager.createFrame(type, payload);
+ const total = frames.length;
+ console.debug(`Sending ${total} frames`);
+ for (let i = 0; i < total; i++) {
+ const frame = frames[i];
+ console.debug(`Writing frame ${i + 1}/${total}, length = ${frame.length}`);
+ const base64 = Buffer.from(frame).toString('base64');
+ const result = await this.client.write(deviceId, BLE_UUIDS.SERVICE, BLE_UUIDS.WRITE_CHARACTERISTIC, base64, false);
+ if (onProgress) {
+ onProgress((i + 1) / total);
+ }
+ console.debug("Wrote frame", result);
+ }
+ }
+}
diff --git a/ble/services/DeviceInfoService.ts b/ble/services/DeviceInfoService.ts
new file mode 100644
index 0000000..3423059
--- /dev/null
+++ b/ble/services/DeviceInfoService.ts
@@ -0,0 +1,76 @@
+import {BleProtocolService} from './BleProtocolService';
+import {COMMAND_TYPES, RESPONSE_TYPES} from '../protocol/Constants';
+import {DeviceInfo, ActivationStatus} from '../protocol/types';
+import {Buffer} from 'buffer';
+
+export class DeviceInfoService {
+ private protocol = BleProtocolService.getInstance();
+ private static instance: DeviceInfoService;
+ private listeners: Map> = new Map();
+
+ private constructor() {
+ this.protocol.addListener(RESPONSE_TYPES.DEVICE_INFO_REPORT, this.onDeviceInfo);
+ this.protocol.addListener(RESPONSE_TYPES.ACTIVATION_STATUS, this.onActivationStatus);
+ }
+
+ public static getInstance(): DeviceInfoService {
+ if (!DeviceInfoService.instance) {
+ DeviceInfoService.instance = new DeviceInfoService();
+ }
+ return DeviceInfoService.instance;
+ }
+
+ private onDeviceInfo = (data: ArrayBuffer, deviceId: string) => {
+ try {
+ const str = Buffer.from(data).toString('utf-8');
+ // Remove null characters if any
+ const cleanStr = str.replace(/\0/g, '');
+ const json = JSON.parse(cleanStr) as DeviceInfo;
+ this.emit('deviceInfo', json);
+ } catch (e) {
+ console.error("Failed to parse device info", e);
+ }
+ }
+
+ private onActivationStatus = (data: ArrayBuffer, deviceId: string) => {
+ try {
+ const str = Buffer.from(data).toString('utf-8');
+ const cleanStr = str.replace(/\0/g, '');
+ const json = JSON.parse(cleanStr) as ActivationStatus;
+ this.emit('activationStatus', json);
+ } catch (e) {
+ console.error("Failed to parse activation status", e);
+ }
+ }
+
+ public addListener(event: string, cb: Function) {
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set());
+ this.listeners.get(event)!.add(cb);
+ }
+
+ public removeListener(event: string, cb: Function) {
+ if (this.listeners.has(event)) {
+ this.listeners.get(event)!.delete(cb);
+ }
+ }
+
+ private emit(event: string, data: any) {
+ this.listeners.get(event)?.forEach(cb => cb(data));
+ }
+
+ public async queryDeviceInfo(deviceId: string) {
+ await this.protocol.send(deviceId, COMMAND_TYPES.DEVICE_INFO_SETTINGS, {
+ type: COMMAND_TYPES.DEVICE_INFO_SETTINGS
+ });
+ }
+
+ public async queryActivationStatus(deviceId: string) {
+ await this.protocol.send(deviceId, COMMAND_TYPES.ACTIVATION_QUERY, {
+ type: COMMAND_TYPES.ACTIVATION_QUERY
+ });
+ }
+
+ public async queryDeviceVersion(deviceId: string) {
+ await this.protocol.send(deviceId, COMMAND_TYPES.VERSION_QUERY, {type: COMMAND_TYPES.VERSION_QUERY})
+ }
+}
diff --git a/ble/services/FileTransferService.ts b/ble/services/FileTransferService.ts
new file mode 100644
index 0000000..c84fa71
--- /dev/null
+++ b/ble/services/FileTransferService.ts
@@ -0,0 +1,51 @@
+import { BleProtocolService } from './BleProtocolService';
+import { COMMAND_TYPES } from '../protocol/Constants';
+
+
+export class FileTransferService {
+ private static instance: FileTransferService;
+ private protocol = BleProtocolService.getInstance();
+
+ private constructor() {}
+
+ public static getInstance(): FileTransferService {
+ if (!FileTransferService.instance) {
+ FileTransferService.instance = new FileTransferService();
+ }
+ return FileTransferService.instance;
+ }
+
+ public async transferFile(deviceId: string, filePath: string, type: number, onProgress?: (progress: number) => void): Promise {
+ try {
+ const response = await fetch(filePath);
+ if (!response.ok) {
+ throw new Error(`Failed to load file: ${response.statusText}`);
+ }
+ const blob = await response.blob();
+
+ const reader = new FileReader();
+ const arrayBuffer = await new Promise((resolve, reject) => {
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
+ reader.onerror = reject;
+ reader.readAsArrayBuffer(blob);
+ });
+
+ await this.protocol.send(deviceId, type, arrayBuffer, onProgress);
+ } catch (e) {
+ console.error("File transfer failed", e);
+ throw e;
+ }
+ }
+
+ public async transferOta(deviceId: string, filePath: string) {
+ return this.transferFile(deviceId, filePath, COMMAND_TYPES.OTA_PACKAGE);
+ }
+
+ public async transferBootAnimation(deviceId: string, filePath: string) {
+ return this.transferFile(deviceId, filePath, COMMAND_TYPES.TRANSFER_BOOT_ANIMATION);
+ }
+
+ public async transferWatchfaceStyle(deviceId: string, filePath: string) {
+ return this.transferFile(deviceId, filePath, COMMAND_TYPES.TRANSFER_AVI_VIDEO);
+ }
+}
diff --git a/ble/utils/CryptoUtils.ts b/ble/utils/CryptoUtils.ts
new file mode 100644
index 0000000..8084fb6
--- /dev/null
+++ b/ble/utils/CryptoUtils.ts
@@ -0,0 +1,80 @@
+import * as Crypto from 'expo-crypto';
+
+/**
+ * Cryptographic utilities for BLE communication
+ * Provides proper MD5 hashing to match Android implementation
+ */
+export class CryptoUtils {
+ /**
+ * Calculate MD5 hash of data
+ * @param data - ArrayBuffer or string to hash
+ * @returns Promise - MD5 hash as hexadecimal string
+ */
+ public static async calculateMD5(data: ArrayBuffer | string): Promise {
+ let dataString: string;
+
+ if (data instanceof ArrayBuffer) {
+ // Convert ArrayBuffer to string
+ const uint8Array = new Uint8Array(data);
+ dataString = Array.from(uint8Array)
+ .map(byte => String.fromCharCode(byte))
+ .join('');
+ } else {
+ dataString = data;
+ }
+
+ const hash = await Crypto.digestStringAsync(
+ Crypto.CryptoDigestAlgorithm.MD5,
+ dataString
+ );
+ return hash.toUpperCase();
+ }
+
+ /**
+ * Calculate MD5 hash of a file
+ * @param file - File object to hash
+ * @returns Promise - MD5 hash as hexadecimal string
+ */
+ public static async calculateFileMD5(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onload = async (event) => {
+ try {
+ const arrayBuffer = event.target?.result as ArrayBuffer;
+ const hash = await this.calculateMD5(arrayBuffer);
+ resolve(hash);
+ } catch (error) {
+ reject(error);
+ }
+ };
+
+ reader.onerror = () => {
+ reject(new Error('Failed to read file for MD5 calculation'));
+ };
+
+ reader.readAsArrayBuffer(file);
+ });
+ }
+
+ /**
+ * Get partial MD5 hash (substring) to match Android implementation
+ * Android uses substring(8, 24) for file transfers
+ * @param data - Data to hash
+ * @returns Promise - Partial MD5 hash
+ */
+ public static async calculatePartialMD5(data: ArrayBuffer | string): Promise {
+ const fullHash = await this.calculateMD5(data);
+ return fullHash.substring(8, 24); // Match Android implementation
+ }
+
+ /**
+ * Get partial file MD5 hash
+ * @param file - File to hash
+ * @returns Promise - Partial MD5 hash
+ */
+ public static async calculatePartialFileMD5(file: File): Promise {
+ const fullHash = await this.calculateFileMD5(file);
+ return fullHash.substring(8, 24); // Match Android implementation
+ }
+}
diff --git a/ble/utils/ProtocolUtilsV2.ts b/ble/utils/ProtocolUtilsV2.ts
new file mode 100644
index 0000000..b3e341c
--- /dev/null
+++ b/ble/utils/ProtocolUtilsV2.ts
@@ -0,0 +1,306 @@
+import { Buffer } from 'buffer';
+import {
+ FRAME_CONSTANTS as PROTOCOL_V2_FRAME,
+ ERROR_CODES as BLE_V2_ERROR_CODES
+} from '../protocol/Constants';
+import {
+ ProtocolFrame,
+ JsonPayload
+} from '../protocol/types';
+
+/**
+ * Protocol utilities for BLE V2.1.1 communication
+ * Handles JSON data parsing, binary framing, and checksum calculation
+ */
+export class ProtocolUtilsV2 {
+
+ /**
+ * Calculate checksum for protocol frame
+ * New algorithm: 0 - (sum of all bytes except checksum)
+ */
+ public static calculateChecksum(frameData: Uint8Array): number {
+ let sum = 0;
+ for (let i = 0; i < frameData.length; i++) {
+ sum += frameData[i];
+ }
+ return (256 - (sum % 256)) % 256; // Equivalent to 0 - sum modulo 256
+ }
+
+ /**
+ * Verify checksum of received frame
+ */
+ public static verifyChecksum(frameData: Uint8Array, expectedChecksum: number): boolean {
+ const calculatedChecksum = this.calculateChecksum(frameData);
+ return calculatedChecksum === expectedChecksum;
+ }
+
+ /**
+ * Convert ArrayBuffer to Uint8Array
+ */
+ public static arrayBufferToUint8Array(buffer: ArrayBuffer): Uint8Array {
+ return new Uint8Array(buffer);
+ }
+
+ /**
+ * Convert Uint8Array to ArrayBuffer
+ */
+ public static uint8ArrayToArrayBuffer(array: Uint8Array): ArrayBuffer {
+ return new Uint8Array(array).buffer;
+ }
+
+ /**
+ * Create protocol frame for sending to device
+ */
+ public static createFrame(
+ type: number,
+ jsonData: string,
+ requireFragmentation: boolean = false,
+ head: number = PROTOCOL_V2_FRAME.HEAD_APP_TO_DEVICE
+ ): ArrayBuffer[] {
+ const dataBytes = Buffer.from(jsonData, 'utf8');
+ const frames: ArrayBuffer[] = [];
+
+ if (!requireFragmentation && dataBytes.length <= PROTOCOL_V2_FRAME.MAX_DATA_SIZE) {
+ // Single frame
+ const frame = this.createSingleFrame(type, dataBytes, head);
+ frames.push(frame);
+ } else {
+ // Fragmented frames
+ const fragmentedFrames = this.createFragmentedFrames(type, dataBytes, head);
+ frames.push(...fragmentedFrames);
+ }
+
+ return frames;
+ }
+
+ /**
+ * Create a single protocol frame
+ */
+ private static createSingleFrame(
+ type: number,
+ dataBytes: Buffer,
+ head: number = PROTOCOL_V2_FRAME.HEAD_APP_TO_DEVICE
+ ): ArrayBuffer {
+ const frameSize = PROTOCOL_V2_FRAME.HEADER_SIZE + dataBytes.length + PROTOCOL_V2_FRAME.FOOTER_SIZE;
+ const frame = new Uint8Array(frameSize);
+
+ let offset = 0;
+
+ // Header
+ frame[offset++] = head;
+ frame[offset++] = type;
+ frame[offset++] = 0x00; // Subpage total (high byte)
+ frame[offset++] = 0x00; // Subpage total (low byte)
+ frame[offset++] = 0x00; // Current page (high byte)
+ frame[offset++] = 0x00; // Current page (low byte)
+ frame[offset++] = (dataBytes.length >> 8) & 0xFF; // Data length (high byte)
+ frame[offset++] = dataBytes.length & 0xFF; // Data length (low byte)
+
+ // Data
+ for (let i = 0; i < dataBytes.length; i++) {
+ frame[offset++] = dataBytes[i];
+ }
+
+ // Calculate and add checksum
+ const checksumData = frame.slice(0, -1); // All bytes except checksum
+ const checksum = this.calculateChecksum(checksumData);
+ frame[offset] = checksum;
+
+ return this.uint8ArrayToArrayBuffer(frame);
+ }
+
+ /**
+ * Create fragmented protocol frames
+ */
+ private static createFragmentedFrames(
+ type: number,
+ dataBytes: Buffer,
+ head: number = PROTOCOL_V2_FRAME.HEAD_APP_TO_DEVICE
+ ): ArrayBuffer[] {
+ const frames: ArrayBuffer[] = [];
+ const totalFrames = Math.ceil(dataBytes.length / PROTOCOL_V2_FRAME.MAX_DATA_SIZE);
+
+ for (let i = 0; i < totalFrames; i++) {
+ const startOffset = i * PROTOCOL_V2_FRAME.MAX_DATA_SIZE;
+ const endOffset = Math.min(startOffset + PROTOCOL_V2_FRAME.MAX_DATA_SIZE, dataBytes.length);
+ const chunkSize = endOffset - startOffset;
+ const chunk = dataBytes.slice(startOffset, endOffset);
+
+ const frameSize = PROTOCOL_V2_FRAME.HEADER_SIZE + chunkSize + PROTOCOL_V2_FRAME.FOOTER_SIZE;
+ const frame = new Uint8Array(frameSize);
+
+ let offset = 0;
+
+ // Header
+ frame[offset++] = head;
+ frame[offset++] = type;
+ frame[offset++] = (totalFrames >> 8) & 0xFF; // Subpage total (high byte)
+ frame[offset++] = totalFrames & 0xFF; // Subpage total (low byte)
+ frame[offset++] = (i >> 8) & 0xFF; // Current page (high byte)
+ frame[offset++] = i & 0xFF; // Current page (low byte)
+ frame[offset++] = (chunkSize >> 8) & 0xFF; // Data length (high byte)
+ frame[offset++] = chunkSize & 0xFF; // Data length (low byte)
+
+ // Data
+ for (let j = 0; j < chunkSize; j++) {
+ frame[offset++] = chunk[j];
+ }
+
+ // Calculate and add checksum
+ const checksumData = frame.slice(0, -1); // All bytes except checksum
+ const checksum = this.calculateChecksum(checksumData);
+ frame[offset] = checksum;
+
+ frames.push(this.uint8ArrayToArrayBuffer(frame));
+ }
+
+ return frames;
+ }
+
+ /**
+ * Parse received protocol frame
+ */
+ public static parseFrame(frameData: ArrayBuffer): ProtocolFrame | null {
+ try {
+ const bytes = this.arrayBufferToUint8Array(frameData);
+
+ if (bytes.length < PROTOCOL_V2_FRAME.HEADER_SIZE + PROTOCOL_V2_FRAME.FOOTER_SIZE) {
+ console.error('ProtocolUtilsV2: Frame too short');
+ return null;
+ }
+
+ let offset = 0;
+
+ // Parse header
+ const head = bytes[offset++];
+ const type = bytes[offset++];
+ const subpageTotal = (bytes[offset++] << 8) | bytes[offset++];
+ const curPage = (bytes[offset++] << 8) | bytes[offset++];
+ const dataLen = (bytes[offset++] << 8) | bytes[offset++];
+
+ // Validate data length - allow extra bytes for BLE MTU padding
+ const expectedFrameSize = PROTOCOL_V2_FRAME.HEADER_SIZE + dataLen + PROTOCOL_V2_FRAME.FOOTER_SIZE;
+ if (bytes.length < expectedFrameSize) {
+ console.error(`ProtocolUtilsV2: Frame too short. Expected: ${expectedFrameSize}, Actual: ${bytes.length}`);
+ return null;
+ }
+
+ // Trim to expected size to remove BLE padding bytes
+ if (bytes.length > expectedFrameSize) {
+ console.log(`ProtocolUtilsV2: Trimming frame from ${bytes.length} to ${expectedFrameSize} bytes (removing BLE padding)`);
+ const trimmedBytes = bytes.slice(0, expectedFrameSize);
+ return this.parseFrame(this.uint8ArrayToArrayBuffer(trimmedBytes));
+ }
+
+ // Extract data
+ const dataBytes = bytes.slice(offset, offset + dataLen);
+ const data = this.uint8ArrayToArrayBuffer(dataBytes);
+ offset += dataLen;
+
+ // Verify checksum
+ const checksum = bytes[offset];
+ const frameWithoutChecksum = bytes.slice(0, -1);
+ if (!this.verifyChecksum(frameWithoutChecksum, checksum)) {
+ console.error(`ProtocolUtilsV2: Checksum mismatch. Expected: ${checksum}, Calculated: ${this.calculateChecksum(frameWithoutChecksum)}`);
+ return null;
+ }
+
+ return {
+ head,
+ type,
+ subpageTotal,
+ curPage,
+ dataLen,
+ data,
+ checksum
+ };
+
+ } catch (error) {
+ console.error('ProtocolUtilsV2: Error parsing frame', error);
+ return null;
+ }
+ }
+
+ /**
+ * Parse JSON data from frame
+ */
+ public static parseJsonData(data: ArrayBuffer): JsonPayload | null {
+ try {
+ const jsonString = Buffer.from(data).toString('utf8');
+ const jsonData = JSON.parse(jsonString);
+ return jsonData;
+ } catch (error) {
+ console.error('ProtocolUtilsV2: Error parsing JSON data', error);
+ return null;
+ }
+ }
+
+ /**
+ * Convert object to JSON string
+ */
+ public static toJsonString(obj: object): string {
+ return JSON.stringify(obj);
+ }
+
+ /**
+ * Convert binary data to base64 for JSON embedding
+ */
+ public static arrayBufferToBase64(buffer: ArrayBuffer): string {
+ return Buffer.from(buffer).toString('base64');
+ }
+
+ /**
+ * Convert base64 string to ArrayBuffer
+ */
+ public static base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const buffer = Buffer.from(base64, 'base64');
+ return this.uint8ArrayToArrayBuffer(buffer);
+ }
+
+ /**
+ * Validate frame header
+ */
+ public static isValidFrameHead(head: number): boolean {
+ return head === PROTOCOL_V2_FRAME.HEAD_DEVICE_TO_APP || head === PROTOCOL_V2_FRAME.HEAD_APP_TO_DEVICE;
+ }
+
+ /**
+ * Check if frame is from device
+ */
+ public static isDeviceFrame(frame: ProtocolFrame): boolean {
+ return frame.head === PROTOCOL_V2_FRAME.HEAD_DEVICE_TO_APP;
+ }
+
+ /**
+ * Check if frame is from app
+ */
+ public static isAppFrame(frame: ProtocolFrame): boolean {
+ return frame.head === PROTOCOL_V2_FRAME.HEAD_APP_TO_DEVICE;
+ }
+
+ /**
+ * Check if frame is fragmented
+ */
+ public static isFragmentedFrame(frame: ProtocolFrame): boolean {
+ return frame.subpageTotal > 0;
+ }
+
+ /**
+ * Get hex string for debugging
+ */
+ public static bytesToHex(bytes: ArrayBuffer): string {
+ const buffer = Buffer.from(bytes);
+ return buffer.toString('hex').toUpperCase();
+ }
+
+ /**
+ * Create error response
+ */
+ public static createErrorResponse(errorCode: number, message: string): JsonPayload {
+ return {
+ type: 0xFF, // Custom error type
+ error: errorCode,
+ message
+ } as any;
+ }
+}
diff --git a/bun.lock b/bun.lock
index bc20d99..c910c35 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,9 +11,13 @@
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.27",
"expo-constants": "~18.0.11",
+ "expo-dev-client": "~6.0.20",
+ "expo-file-system": "~19.0.20",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
+ "expo-image-manipulator": "~14.0.8",
+ "expo-image-picker": "~17.0.9",
"expo-linking": "~8.0.10",
"expo-router": "~6.0.17",
"expo-splash-screen": "~31.0.12",
@@ -24,7 +28,9 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
+ "react-native-ble-plx": "^3.5.0",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-multi-ble-peripheral": "^0.1.8",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@@ -824,6 +830,8 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
+ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
+
"exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="],
"expo": ["expo@54.0.27", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.18", "@expo/config": "~12.0.11", "@expo/config-plugins": "~54.0.3", "@expo/devtools": "0.1.8", "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.1.0", "@expo/metro-config": "54.0.10", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.8", "expo-asset": "~12.0.11", "expo-constants": "~18.0.11", "expo-file-system": "~19.0.20", "expo-font": "~14.0.10", "expo-keep-awake": "~15.0.8", "expo-modules-autolinking": "3.0.23", "expo-modules-core": "3.0.28", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-50BcJs8eqGwRiMUoWwphkRGYtKFS2bBnemxLzy0lrGVA1E6F4Q7L5h3WT6w1ehEZybtOVkfJu4Z6GWo2IJcpEA=="],
@@ -832,6 +840,14 @@
"expo-constants": ["expo-constants@18.0.11", "", { "dependencies": { "@expo/config": "~12.0.11", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-xnfrfZ7lHjb+03skhmDSYeFF7OU2K3Xn/lAeP+7RhkV2xp2f5RCKtOUYajCnYeZesvMrsUxOsbGOP2JXSOH3NA=="],
+ "expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="],
+
+ "expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="],
+
+ "expo-dev-menu": ["expo-dev-menu@7.0.18", "", { "dependencies": { "expo-dev-menu-interface": "2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA=="],
+
+ "expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw=="],
+
"expo-file-system": ["expo-file-system@19.0.20", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-Jr/nNvJmUlptS3cHLKVBNyTyGMHNyxYBKRph1KRe0Nb3RzZza1gZLZXMG5Ky//sO2azTn+OaT0dv/lAyL0vJNA=="],
"expo-font": ["expo-font@14.0.10", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q=="],
@@ -840,10 +856,20 @@
"expo-image": ["expo-image@3.0.11", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA=="],
+ "expo-image-loader": ["expo-image-loader@6.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ=="],
+
+ "expo-image-manipulator": ["expo-image-manipulator@14.0.8", "", { "dependencies": { "expo-image-loader": "~6.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q=="],
+
+ "expo-image-picker": ["expo-image-picker@17.0.9", "", { "dependencies": { "expo-image-loader": "~6.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-v40HcX1fXDlNdc5y88ygvGyZU/QrcybBajYgVVLaWrXXGBqqC8Yu8jOHR99BtIdljiQ6JfYfxbeDp1woiyUrTA=="],
+
+ "expo-json-utils": ["expo-json-utils@0.15.0", "", {}, "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="],
+
"expo-keep-awake": ["expo-keep-awake@15.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ=="],
"expo-linking": ["expo-linking@8.0.10", "", { "dependencies": { "expo-constants": "~18.0.11", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA=="],
+ "expo-manifests": ["expo-manifests@1.0.10", "", { "dependencies": { "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { "expo": "*" } }, "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ=="],
+
"expo-modules-autolinking": ["expo-modules-autolinking@3.0.23", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg=="],
"expo-modules-core": ["expo-modules-core@3.0.28", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-8EDpksNxnN4HXWE+yhYUYAZAWTEDRzK2VpZjPSp+UBF2LtWZicXKLOCODCvsjCkTCVVA2JKKcWtGxWiteV3ueA=="],
@@ -860,6 +886,8 @@
"expo-system-ui": ["expo-system-ui@6.0.9", "", { "dependencies": { "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg=="],
+ "expo-updates-interface": ["expo-updates-interface@2.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg=="],
+
"expo-web-browser": ["expo-web-browser@15.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
@@ -870,6 +898,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+
"fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="],
"fbjs": ["fbjs@3.0.5", "", { "dependencies": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^1.0.35" } }, "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg=="],
@@ -1358,10 +1388,14 @@
"react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="],
+ "react-native-ble-plx": ["react-native-ble-plx@3.5.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-PeSnRswHLwLRVMQkOfDaRICtrGmo94WGKhlSC09XmHlqX2EuYgH+vNJpGcLkd8lyiYpEJyf8wlFAdj9Akliwmw=="],
+
"react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="],
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="],
+ "react-native-multi-ble-peripheral": ["react-native-multi-ble-peripheral@0.1.8", "", { "dependencies": { "eventemitter3": "^5.0.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-DGuh1KGt9jF0lgGC9UViZy3F2cNpcp63m2vrAS7UiG3mtMRNgko7PbgxWRXxpwdSpEEwIPh3F0cD/p3tML2P2A=="],
+
"react-native-reanimated": ["react-native-reanimated@4.1.6", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*", "react-native-worklets": ">=0.5.0" } }, "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
@@ -1800,6 +1834,8 @@
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "expo-dev-launcher/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
+
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
@@ -1946,6 +1982,8 @@
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
+ "expo-dev-launcher/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
diff --git a/eas.json b/eas.json
new file mode 100644
index 0000000..a84bbf3
--- /dev/null
+++ b/eas.json
@@ -0,0 +1,37 @@
+{
+ "cli": {
+ "version": ">= 16.27.0",
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ },
+ "corepack": true
+ },
+ "preview": {
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ },
+ "ios": {
+ "bundleIdentifier": "com.bowong.duooomi",
+ "simulator": false
+ },
+ "corepack": true
+ },
+ "production": {
+ "autoIncrement": true,
+ "android": {
+ "buildType": "apk"
+ },
+ "corepack": true
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/hooks/useBleExplorer.ts b/hooks/useBleExplorer.ts
new file mode 100644
index 0000000..6b8cf9f
--- /dev/null
+++ b/hooks/useBleExplorer.ts
@@ -0,0 +1,493 @@
+import {useCallback, useEffect, useState} from 'react';
+import {Alert, PermissionsAndroid, Platform} from 'react-native';
+import {
+ ActivationStatus,
+ BLE_UUIDS,
+ BleClient,
+ BleDevice,
+ BleError,
+ BleProtocolService, COMMAND_TYPES,
+ ConnectionState,
+ DeviceInfo,
+ DeviceInfoService,
+ FileTransferService
+} from '../ble/index';
+import {ScanMode} from "react-native-ble-plx";
+import * as ImagePicker from 'expo-image-picker';
+import {File, Directory, Paths} from 'expo-file-system';
+import * as ImageManipulator from 'expo-image-manipulator';
+
+interface BleState {
+ isScanning: boolean;
+ isConnected: boolean;
+ connectedDevice: BleDevice | null;
+ deviceInfo: DeviceInfo | null;
+ version: string;
+ isActivated: boolean;
+ transferProgress: number;
+ isTransferring: boolean;
+ logs: string[];
+ discoveredDevices: BleDevice[];
+ loading: {
+ connecting: boolean;
+ querying: boolean;
+ transferring: boolean;
+ };
+ error: string | null;
+}
+
+
+const MAX_LOGS = 100;
+
+export const useBleExplorer = () => {
+ const bleClient = BleClient.getInstance();
+ const deviceInfoService = DeviceInfoService.getInstance();
+ const fileTransferService = FileTransferService.getInstance();
+ const protocolService = BleProtocolService.getInstance();
+
+
+ const [state, setState] = useState({
+ isScanning: false,
+ isConnected: false,
+ connectedDevice: null,
+ deviceInfo: null,
+ version: '',
+ isActivated: false,
+ transferProgress: 0,
+ isTransferring: false,
+ logs: [],
+ discoveredDevices: [],
+ loading: {
+ connecting: false,
+ querying: false,
+ transferring: false,
+ },
+ error: null,
+ });
+
+ const addLog = useCallback((message: string) => {
+ const timestamp = new Date().toLocaleTimeString();
+ console.log(message);
+ setState(prev => {
+ const newLogs = [...prev.logs, `[${timestamp}] ${message}`];
+ const trimmedLogs = newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
+ return {...prev, logs: trimmedLogs};
+ });
+ }, []);
+
+ const setError = useCallback(
+ (error: string | null) => {
+ setState(prev => ({...prev, error}));
+ if (error) {
+ addLog(`ERROR: ${error}`);
+ }
+ },
+ [addLog]
+ );
+
+ const requestBluetoothPermissions = useCallback(async (): Promise => {
+ if (Platform.OS !== 'android') return true;
+ try {
+ const permissions = [
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
+ PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
+ ];
+ const results = await PermissionsAndroid.requestMultiple(permissions);
+ const allGranted = Object.values(results).every(result => result === PermissionsAndroid.RESULTS.GRANTED);
+ if (!allGranted) {
+ const denied = Object.entries(results)
+ .filter(([_, result]) => result !== PermissionsAndroid.RESULTS.GRANTED)
+ .map(([permission]) => permission.split('.').pop());
+ setError(`Bluetooth permissions required: ${denied.join(', ')}`);
+ return false;
+ }
+ return true;
+ } catch (error) {
+ setError(`Permission request failed: ${error}`);
+ return false;
+ }
+ }, [setError]);
+
+ useEffect(() => {
+ if (Platform.OS === 'web') {
+ addLog('BLE not supported on web platform');
+ return;
+ }
+
+ const onConnectionStateChange = ({deviceId, state: connState}: {
+ deviceId: string,
+ state: ConnectionState
+ }) => {
+ const isConnected = connState === ConnectionState.CONNECTED;
+ setState(prev => {
+ const updatedDevices = prev.discoveredDevices.map(d => {
+ if (d.id === deviceId) {
+ const newDevice = Object.assign(Object.create(Object.getPrototypeOf(d)), d) as BleDevice;
+ newDevice.connected = isConnected;
+ return newDevice;
+ }
+ return d;
+ });
+
+ return {
+ ...prev,
+ isConnected,
+ connectedDevice: connState === ConnectionState.DISCONNECTED ? null : prev.connectedDevice,
+ discoveredDevices: updatedDevices,
+ };
+ });
+ addLog(`Connection state ${deviceId}: ${connState}`);
+ };
+
+ const onScanError = (error: BleError) => {
+ setError(`Scan error: ${error.message}`);
+ setState(prev => ({...prev, isScanning: false}));
+ };
+
+ bleClient.addListener('connectionStateChange', onConnectionStateChange);
+ bleClient.addListener('scanError', onScanError);
+
+ const onDeviceInfo = (info: DeviceInfo) => {
+ setState(prev => ({...prev, deviceInfo: info}));
+ addLog(`Device info received: ${info.devname}`);
+ };
+
+ const onActivationStatus = (status: ActivationStatus) => {
+ const activated = status.state === 1;
+ setState(prev => ({...prev, isActivated: activated}));
+ addLog(`Activation status: ${activated ? 'Activated' : 'Not activated'}`);
+ };
+
+ deviceInfoService.addListener('deviceInfo', onDeviceInfo);
+ deviceInfoService.addListener('activationStatus', onActivationStatus);
+
+ return () => {
+ bleClient.removeListener('connectionStateChange', onConnectionStateChange);
+ bleClient.removeListener('scanError', onScanError);
+ deviceInfoService.removeListener('deviceInfo', onDeviceInfo);
+ deviceInfoService.removeListener('activationStatus', onActivationStatus);
+ };
+ }, [bleClient, deviceInfoService, addLog, setError]);
+
+ const startScan = useCallback(async () => {
+ if (Platform.OS === 'web') return;
+ try {
+ setError(null);
+ addLog('Starting scan...');
+ const hasPerms = await requestBluetoothPermissions();
+ if (!hasPerms) return;
+
+ setState(prev => ({...prev, isScanning: true, discoveredDevices: []}));
+
+ // Check for already connected devices
+ try {
+ const relevantDevices = await bleClient.getConnectedDevices([BLE_UUIDS.SERVICE]);
+
+ if (relevantDevices.length > 0) {
+ addLog(`Found ${relevantDevices.length} system-connected devices`);
+ setState(prev => {
+ // Avoid duplicates if any
+ const newDevices = relevantDevices.filter(
+ nd => !prev.discoveredDevices.some(ed => ed.id === nd.id)
+ );
+ newDevices.forEach(d => {
+ d.connected = false;
+ }); // Treat as disconnected until explicit connect
+ return {
+ ...prev,
+ discoveredDevices: [...prev.discoveredDevices, ...newDevices]
+ };
+ });
+ }
+ } catch (e) {
+ console.warn("Failed to check connected devices", e);
+ }
+ await bleClient.startScan(
+ // [BLE_UUIDS.SERVICE],
+ null,
+ {scanMode: ScanMode.LowLatency, allowDuplicates: false},
+ (result) => {
+ setState(prev => {
+ const device = result.device as BleDevice;
+
+ // Filter only for service UUID
+ if (device.name?.startsWith("707")) {
+ // continue
+ } else if (!device.serviceUUIDs) {
+ return prev;
+ }
+
+ if (prev.discoveredDevices.find(d => d.id === device.id)) return prev;
+
+ device.connected = false;
+
+ addLog(`Device found: ${device.name} (${device.id}), serviceUUIDs: ${device.serviceUUIDs}`);
+ return {...prev, discoveredDevices: [...prev.discoveredDevices, device]};
+ });
+ });
+ } catch (e: any) {
+ setError(`Start scan failed: ${e.message}`);
+ }
+ }, [bleClient, requestBluetoothPermissions, addLog, setError]);
+
+ const stopScan = useCallback(() => {
+ bleClient.stopScan();
+ setState(prev => ({...prev, isScanning: false}));
+ addLog('Scan stopped');
+ }, [bleClient, addLog]);
+
+ const connectToDevice = useCallback(async (device: BleDevice) => {
+ try {
+ stopScan();
+ setState(prev => ({...prev, loading: {...prev.loading, connecting: true}}));
+ addLog(`Connecting to ${device.name}...`);
+
+ const connectedDevice = await bleClient.connect(device.id) as BleDevice;
+ connectedDevice.connected = true;
+
+ // Scan and log all services and characteristics
+ try {
+ const services = await connectedDevice.services();
+ for (const service of services) {
+ addLog(`[Service] ${service.uuid}`);
+ const characteristics = await service.characteristics();
+ for (const char of characteristics) {
+ const props = [
+ char.isReadable ? 'Read' : '',
+ char.isWritableWithResponse ? 'Write' : '',
+ char.isWritableWithoutResponse ? 'WriteNoResp' : '',
+ char.isNotifiable ? 'Notify' : '',
+ char.isIndicatable ? 'Indicate' : ''
+ ].filter(Boolean).join(',');
+ addLog(` -> [Char] ${char.uuid} (${props})`);
+ }
+ }
+ } catch (scanError) {
+ addLog(`Error scanning services: ${scanError}`);
+ }
+
+ await protocolService.initialize(device.id);
+
+ setState(prev => ({
+ ...prev,
+ connectedDevice,
+ isConnected: true,
+ loading: {...prev.loading, connecting: false}
+ }));
+ addLog('Connected and Protocol initialized');
+ } catch (e: any) {
+ setError(`Connection failed: ${e.message}`);
+ setState(prev => ({...prev, loading: {...prev.loading, connecting: false}}));
+ }
+ }, [bleClient, protocolService, stopScan, addLog, setError]);
+
+ const disconnectDevice = useCallback(async () => {
+ try {
+ addLog('Disconnecting...');
+ await bleClient.disconnect();
+ protocolService.disconnect();
+ } catch (e: any) {
+ addLog(`Disconnect failed: ${e.message}`);
+ }
+ }, [bleClient, protocolService, addLog]);
+
+ const requestDeviceInfo = useCallback(async () => {
+ if (!state.connectedDevice) return;
+ try {
+ addLog(`[${state.connectedDevice.id}] Requesting Device Info...`);
+ await deviceInfoService.queryDeviceInfo(state.connectedDevice.id);
+ addLog(`[${state.connectedDevice.id}] Device Info query request sent.`);
+ } catch (e: any) {
+ setError(`Request failed: ${e.message}`);
+ }
+ }, [deviceInfoService, state.connectedDevice, addLog, setError]);
+
+ const queryActivationStatus = useCallback(async () => {
+ if (!state.connectedDevice) return;
+ try {
+ addLog('Querying Activation Status...');
+ await deviceInfoService.queryActivationStatus(state.connectedDevice.id);
+ } catch (e: any) {
+ setError(`Query failed: ${e.message}`);
+ }
+ }, [deviceInfoService, state.connectedDevice, addLog, setError]);
+
+ const queryDeviceVersion = useCallback(async () => {
+ if (!state.connectedDevice) return;
+ try {
+ addLog(`[${state.connectedDevice.id}] Requesting Device Version...`);
+ await deviceInfoService.queryDeviceVersion(state.connectedDevice.id);
+ addLog(`[${state.connectedDevice.id}] Device Version query request sent`);
+ } catch (e: any) {
+ setError(`Request failed: ${e.message}`);
+ }
+ }, [state.connectedDevice, deviceInfoService, addLog, setError])
+
+ const pickImage = async () => {
+ // No permissions request is necessary for launching the image library.
+ // Manually request permissions for videos on iOS when `allowsEditing` is set to `false`
+ // and `videoExportPreset` is `'Passthrough'` (the default), ideally before launching the picker
+ // so the app users aren't surprised by a system dialog after picking a video.
+ // See "Invoke permissions for videos" sub section for more details.
+ const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
+
+ if (!permissionResult.granted) {
+ Alert.alert('Permission required', 'Permission to access the media library is required.');
+ return [];
+ }
+
+ let result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ['images', 'videos'],
+ allowsEditing: false,
+ aspect: [1, 1],
+ quality: 1.0,
+ });
+ if (!result.canceled) {
+ return result.assets;
+ } else {
+ return [];
+ }
+ };
+
+ const convertToANI = useCallback(async (tempDir: Directory, media: ImagePicker.ImagePickerAsset): Promise => {
+ const tempFile = new File(tempDir, `${media.fileName}.ani`)
+ const formData = new FormData();
+ formData.append('file', {
+ uri: media.uri,
+ name: media.fileName || 'video.mp4',
+ type: media.mimeType || 'video/mp4',
+ } as any);
+ const response = await fetch("https://bowongai-test--ani-video-converter-fastapi-app.modal.run/api/convert/ani", {
+ method: "POST",
+ body: formData,
+ headers: {'Accept': 'multipart/form-data',}
+ });
+ if (!response.ok) {
+ throw new Error(`Conversion failed with status ${response.status}`);
+ }
+ tempFile.write(await response.bytes());
+ const tempFileInfo = tempFile.info()
+ addLog(`Converted video saved to ${tempFile.uri}, size : ${tempFileInfo.size} bytes`);
+ return tempFile;
+ }, [addLog]);
+
+ const transferSampleFile = useCallback(async () => {
+ if (!state.connectedDevice) {
+ setError('No device connected');
+ return;
+ }
+ const tempDir = new Directory(Paths.cache, 'anis')
+ if (!tempDir.exists) tempDir.create();
+ const medias = await pickImage();
+ if (medias.length === 0) return;
+ addLog(`[${state.connectedDevice.id}] processing ${medias.length} files...`);
+ for (const media of medias) {
+ if (media.type === 'video') {
+ let tempFile: File;
+ addLog(`Converting video: ${media.fileName || 'video'}...`);
+ tempFile = await convertToANI(tempDir, media)
+ addLog(`Transferring converted file to device...`);
+ await fileTransferService.transferFile(
+ state.connectedDevice.id,
+ tempFile.uri,
+ COMMAND_TYPES.TRANSFER_ANI_VIDEO,
+ (progress) => {
+ setState(prev => ({...prev, transferProgress: progress * 100}));
+ // Optional: throttle logs to avoid spam
+ if (Math.round(progress * 100) % 10 === 0) {
+ // addLog(`Transfer progress: ${Math.round(progress * 100)}%`);
+ }
+ }
+ );
+ tempFile.delete();
+ addLog(`Transfer successful`);
+ } else if (media.type === 'image') {
+ try {
+ addLog(`Processing image: ${media.fileName || 'image'}...`);
+
+ // Check if it is a GIF
+ const isGif = media.mimeType === 'image/gif' || media.uri.toLowerCase().endsWith('.gif');
+
+ if (isGif) {
+ addLog(`Converting GIF to ANI: ${media.fileName || 'gif'}...`);
+ const tempFile = await convertToANI(tempDir, media)
+ addLog(`Transferring converted file to device...`);
+ await fileTransferService.transferFile(
+ state.connectedDevice.id,
+ tempFile.uri,
+ COMMAND_TYPES.TRANSFER_ANI_VIDEO,
+ (progress) => {
+ setState(prev => ({...prev, transferProgress: progress * 100}));
+ }
+ );
+ addLog(`Transfer successful`);
+ tempFile.delete();
+ addLog(`Cleaned up temp file`);
+ return;
+ }
+
+ let imageUri = media.uri;
+
+ // Check if conversion is needed (if not jpeg)
+ // Note: mimeType might not always be reliable, so we can also check filename extension if needed,
+ // or just always run manipulator to ensure consistency.
+ // Here we check if it is NOT jpeg/jpg
+ const isJpeg = media.mimeType === 'image/jpeg' || media.mimeType === 'image/jpg' || media.uri.toLowerCase().endsWith('.jpg') || media.uri.toLowerCase().endsWith('.jpeg');
+
+ if (!isJpeg) {
+ addLog(`Converting image to JPEG...`);
+ const context = ImageManipulator.ImageManipulator.manipulate(media.uri);
+ const imageRef = await context.renderAsync();
+ const result = await imageRef.saveAsync({
+ compress: 1,
+ format: ImageManipulator.SaveFormat.JPEG,
+ });
+ imageUri = result.uri;
+ addLog(`Conversion successful: ${imageUri}`);
+ }
+
+ addLog(`Transferring image to device...`);
+ await fileTransferService.transferFile(
+ state.connectedDevice.id,
+ imageUri,
+ COMMAND_TYPES.TRANSFER_JPEG_IMAGE, // Assuming PHOTO_ALBUM is for images, adjust if needed
+ (progress) => {
+ setState(prev => ({...prev, transferProgress: progress * 100}));
+ }
+ );
+
+ // If we created a temporary file via manipulation, we might want to clean it up?
+ // ImageManipulator results are usually in cache, which OS cleans up, but we can't easily delete if we don't own it.
+ // For this flow, we just leave it.
+
+ addLog(`Transfer successful`);
+ } catch (e: any) {
+ addLog(`Error: ${e.message}`);
+ setError(e.message);
+ }
+ } else {
+ console.log(`Unsupported media type: ${media.type}`);
+ }
+ }
+ }, [state.connectedDevice, fileTransferService, convertToANI, setError, addLog]);
+
+ const clearLogs = useCallback(() => setState(prev => ({...prev, logs: []})), []);
+
+ return {
+ ...state,
+ startScan,
+ stopScan,
+ connectToDevice,
+ disconnectDevice,
+ requestDeviceInfo,
+ queryDeviceVersion,
+ queryActivationStatus,
+ transferSampleFile,
+ clearLogs,
+ updateActivationTime: () => {
+ },
+ sendIdentityCheck: () => {
+ },
+ };
+};
diff --git a/hooks/useBlePeripheral.ts b/hooks/useBlePeripheral.ts
new file mode 100644
index 0000000..968c33e
--- /dev/null
+++ b/hooks/useBlePeripheral.ts
@@ -0,0 +1,323 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { Platform, PermissionsAndroid } from 'react-native';
+import { BLE_UUIDS as PROTOCOL_UUIDS } from '../ble/index';
+import { BlePeripheralManager, PeripheralEventCallback, DeviceInfo } from '@/ble/manager/BlePeripheralManager';
+import { Buffer } from 'buffer';
+
+interface PeripheralState {
+ isAdvertising: boolean;
+ connectedCentralCount: number;
+ logs: string[];
+ deviceInfo: DeviceInfo;
+ loading: {
+ advertising: boolean;
+ };
+ error: string | null;
+}
+
+const MAX_LOGS = 100;
+
+const DEFAULT_DEVICE_INFO: DeviceInfo = {
+ devname: 'BLE Test Device',
+ version: '2.1.1',
+ activated: true,
+ brand: 0,
+ size: 1,
+ allspace: 1024,
+ freespace: 512,
+};
+
+export const useBlePeripheral = () => {
+ const [state, setState] = useState({
+ isAdvertising: false,
+ connectedCentralCount: 0,
+ logs: [],
+ deviceInfo: DEFAULT_DEVICE_INFO,
+ loading: {
+ advertising: false,
+ },
+ error: null,
+ });
+
+ // Ref to track peripheral manager
+ const peripheralManagerRef = useRef(BlePeripheralManager.getInstance());
+
+ // Ref to track advertising state for cleanup
+ const isAdvertisingRef = useRef(false);
+
+ // Ref to track component mount state
+ const isMountedRef = useRef(true);
+
+ // Add log message with limit
+ const addLog = useCallback((message: string) => {
+ const timestamp = new Date().toLocaleTimeString();
+ setState(prev => {
+ const newLogs = [...prev.logs, `[${timestamp}] ${message}`];
+ const trimmedLogs = newLogs.length > MAX_LOGS
+ ? newLogs.slice(-MAX_LOGS)
+ : newLogs;
+ return { ...prev, logs: trimmedLogs };
+ });
+ }, []);
+
+ // Set error with user visibility
+ const setError = useCallback((error: string | null) => {
+ setState(prev => ({ ...prev, error }));
+ if (error) {
+ addLog(`ERROR: ${error}`);
+ }
+ }, [addLog]);
+
+ // Initialize peripheral mode
+ useEffect(() => {
+ if (Platform.OS === 'web') {
+ addLog('BLE peripheral mode not supported on web platform');
+ return;
+ }
+
+ addLog('BLE Peripheral mode initialized (Real BLE)');
+ addLog('Service UUID: ' + PROTOCOL_UUIDS.SERVICE);
+ addLog('Write Characteristic UUID: ' + PROTOCOL_UUIDS.WRITE_CHARACTERISTIC);
+ addLog('Read Characteristic UUID: ' + PROTOCOL_UUIDS.READ_CHARACTERISTIC);
+
+ // Set up event callbacks
+ const callbacks: PeripheralEventCallback = {
+ onAdvertisingStateChange: (isAdvertising, error) => {
+ if (isMountedRef.current) {
+ setState(prev => ({
+ ...prev,
+ isAdvertising,
+ loading: { ...prev.loading, advertising: false },
+ error: error || null
+ }));
+ addLog(`Advertising ${isAdvertising ? 'started' : 'stopped'}${error ? ` with error: ${error}` : ''}`);
+ }
+ },
+ onCentralConnected: (centralId) => {
+ if (isMountedRef.current) {
+ setState(prev => ({
+ ...prev,
+ connectedCentralCount: peripheralManagerRef.current.getConnectedCentralsCount()
+ }));
+ addLog(`Central device connected: ${centralId}`);
+ }
+ },
+ onCentralDisconnected: (centralId) => {
+ if (isMountedRef.current) {
+ setState(prev => ({
+ ...prev,
+ connectedCentralCount: peripheralManagerRef.current.getConnectedCentralsCount()
+ }));
+ addLog(`Central device disconnected: ${centralId}`);
+ }
+ },
+ onCharacteristicRead: (centralId, characteristicUuid, data) => {
+ if (isMountedRef.current) {
+ addLog(`Characteristic read by ${centralId}: ${characteristicUuid}`);
+ }
+ },
+ onCharacteristicWrite: (centralId, characteristicUuid, data) => {
+ if (isMountedRef.current) {
+ const jsonString = Buffer.from(data).toString('utf-8');
+ addLog(`Characteristic write by ${centralId}: ${characteristicUuid}`);
+ addLog(`Data: ${jsonString}`);
+ }
+ }
+ };
+
+ peripheralManagerRef.current.addCallback(callbacks);
+
+ return () => {
+ isMountedRef.current = false;
+ if (isAdvertisingRef.current) {
+ stopAdvertising();
+ }
+ peripheralManagerRef.current.removeCallback(callbacks);
+ };
+ }, [addLog]);
+
+ // Check and request necessary permissions for Android 12+
+ const checkPermissions = useCallback(async (): Promise => {
+ if (Platform.OS !== 'android') {
+ return true; // iOS handles permissions differently
+ }
+
+ try {
+ const apiLevel = Platform.Version;
+ if (typeof apiLevel === 'number' && apiLevel >= 31) {
+ // Android 12+ (API 31+) requires multiple Bluetooth permissions for peripheral mode
+ const requiredPermissions = [
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE,
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN
+ ];
+
+ for (const permission of requiredPermissions) {
+ const granted = await PermissionsAndroid.check(permission);
+
+ if (!granted) {
+ const result = await PermissionsAndroid.request(permission, {
+ title: 'Bluetooth Permission Required',
+ message: 'This app needs Bluetooth permissions to act as a BLE peripheral device.',
+ buttonNeutral: 'Ask Me Later',
+ buttonNegative: 'Cancel',
+ buttonPositive: 'OK',
+ });
+
+ if (result !== PermissionsAndroid.RESULTS.GRANTED) {
+ setError(`${permission} is required for peripheral mode`);
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Permission check failed:', error);
+ setError('Failed to check Bluetooth permissions');
+ return false;
+ }
+ }, [setError]);
+
+ // Start advertising as peripheral (real BLE)
+ const startAdvertising = useCallback(async () => {
+ if (Platform.OS === 'web') {
+ setError('BLE peripheral not supported on web');
+ return;
+ }
+
+ if (isAdvertisingRef.current) {
+ addLog('Already advertising as peripheral');
+ return;
+ }
+
+ try {
+ setError(null);
+ setState(prev => ({
+ ...prev,
+ loading: { ...prev.loading, advertising: true }
+ }));
+
+ // Check permissions first
+ const hasPermissions = await checkPermissions();
+ if (!hasPermissions) {
+ if (isMountedRef.current) {
+ setState(prev => ({
+ ...prev,
+ loading: { ...prev.loading, advertising: false }
+ }));
+ }
+ return;
+ }
+
+ const currentDeviceInfo = peripheralManagerRef.current.getDeviceInfo();
+ addLog('Starting BLE advertising...');
+ addLog('Device Name: ' + currentDeviceInfo.devname);
+ addLog('Advertising with service UUID: ' + PROTOCOL_UUIDS.SERVICE);
+
+ // iOS platform limitation warning
+ if (Platform.OS === 'ios') {
+ addLog('⚠️ iOS: Advertising will stop when app goes to background');
+ }
+ peripheralManagerRef
+ await peripheralManagerRef.current.startAdvertising(currentDeviceInfo.devname);
+ isAdvertisingRef.current = true;
+
+ } catch (error) {
+ if (isMountedRef.current) {
+ const errorMsg = `Failed to start advertising: ${error}`;
+ addLog(errorMsg);
+ setError(errorMsg);
+ setState(prev => ({
+ ...prev,
+ loading: { ...prev.loading, advertising: false }
+ }));
+ }
+ }
+ }, [addLog, setError, checkPermissions]);
+
+ // Stop advertising
+ const stopAdvertising = useCallback(async () => {
+ if (!isAdvertisingRef.current) {
+ addLog('Not currently advertising');
+ return;
+ }
+
+ try {
+ addLog('Stopping BLE advertising...');
+
+ await peripheralManagerRef.current.stopAdvertising();
+ isAdvertisingRef.current = false;
+ setState(prev => ({
+ ...prev,
+ isAdvertising: false,
+ connectedCentralCount: 0
+ }));
+
+ addLog('BLE advertising stopped');
+ } catch (error) {
+ addLog(`Failed to stop advertising: ${error}`);
+ }
+ }, [addLog]);
+
+ // Get characteristic read value (for testing/debugging: Shows what response will be sent when central reads this characteristic)
+ const getCharacteristicReadValue = useCallback((characteristicUuid: string) => {
+ if (!isAdvertisingRef.current) {
+ addLog('Cannot get read value - not advertising');
+ return null;
+ }
+
+ addLog(`Getting characteristic read value: ${characteristicUuid}`);
+
+ if (characteristicUuid === PROTOCOL_UUIDS.READ_CHARACTERISTIC) {
+ // Return device info as JSON - use manager to get latest data instead of stale state
+ const response = {
+ type: 0x02, // Device info response type
+ data: peripheralManagerRef.current.getDeviceInfo()
+ };
+ addLog('Returning device info response');
+ return JSON.stringify(response);
+ }
+
+ addLog('Unknown characteristic for read');
+ return null;
+ }, [addLog]);
+
+ // Update device info
+ const updateDeviceInfo = useCallback((updates: Partial) => {
+ peripheralManagerRef.current.updateDeviceInfo(updates);
+ // Sync state with manager to avoid stale state issues
+ setState(prev => ({
+ ...prev,
+ deviceInfo: peripheralManagerRef.current.getDeviceInfo()
+ }));
+ addLog('Device info updated');
+ }, [addLog]);
+
+ // Reset device info
+ const resetDeviceInfo = useCallback(() => {
+ peripheralManagerRef.current.resetDeviceInfo();
+ // Sync state with manager to avoid stale state issues
+ setState(prev => ({
+ ...prev,
+ deviceInfo: peripheralManagerRef.current.getDeviceInfo()
+ }));
+ addLog('Device info reset to defaults');
+ }, [addLog]);
+
+ // Clear logs
+ const clearLogs = useCallback(() => {
+ setState(prev => ({ ...prev, logs: [] }));
+ }, []);
+
+ return {
+ ...state,
+ startAdvertising,
+ stopAdvertising,
+ getCharacteristicReadValue,
+ updateDeviceInfo,
+ resetDeviceInfo,
+ clearLogs,
+ };
+};
diff --git a/package.json b/package.json
index 3c42c78..f629b44 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,12 @@
"version": "1.0.0",
"scripts": {
"start": "expo start",
+ "build:android:dev": "eas build --platform android --profile development",
+ "build:android:preview": "eas build --platform android --profile preview",
+ "prebuild:android": "expo prebuild --platform android",
"reset-project": "node ./scripts/reset-project.js",
- "android": "expo start --android",
- "ios": "expo start --ios",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
@@ -17,9 +20,13 @@
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.27",
"expo-constants": "~18.0.11",
+ "expo-dev-client": "~6.0.20",
+ "expo-file-system": "~19.0.20",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
+ "expo-image-manipulator": "~14.0.8",
+ "expo-image-picker": "~17.0.9",
"expo-linking": "~8.0.10",
"expo-router": "~6.0.17",
"expo-splash-screen": "~31.0.12",
@@ -30,12 +37,14 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
+ "react-native-ble-plx": "^3.5.0",
"react-native-gesture-handler": "~2.28.0",
- "react-native-worklets": "0.5.1",
+ "react-native-multi-ble-peripheral": "^0.1.8",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-web": "~0.21.0"
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",