expo-ble模块测试demo

This commit is contained in:
Yudi Xiao
2025-12-10 10:29:50 +08:00
parent 709c466792
commit 41f4080264
23 changed files with 3749 additions and 141 deletions

222
ble/core/BleClient.ts Normal file
View File

@@ -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<string, Set<Function>> = 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<void> {
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<BleDevice[]> {
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<BleDevice> {
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<void> {
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<string> {
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<Characteristic> {
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<number> {
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
};
}
}

34
ble/core/types.ts Normal file
View File

@@ -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;
}

View File

@@ -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<BleDevice | null>(null);
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo | null>(null);
const [version, setVersion] = useState<string>('');
const [isActivated, setIsActivated] = useState<boolean>(false);
const [transferProgress, setTransferProgress] = useState<number>(0);
const [isTransferring, setIsTransferring] = useState<boolean>(false);
const [logs, setLogs] = useState<string[]>([]);
// 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 (
<ScrollView style={styles.container}>
<Text style={styles.title}>BLE V2.1.1 Protocol Example</Text>
{/* Connection Status */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Connection Status</Text>
<Text>Scanning: {isScanning ? 'Yes' : 'No'}</Text>
<Text>Connected: {isConnected ? 'Yes' : 'No'}</Text>
<Text>Device: {connectedDevice?.name || 'None'}</Text>
</View>
{/* Device Info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Device Information</Text>
<Text>Activated: {isActivated ? 'Yes' : 'No'}</Text>
<Text>Version: {version || 'Unknown'}</Text>
{deviceInfo && (
<>
<Text>Name: {deviceInfo.devname}</Text>
<Text>Total Space: {deviceInfo.allspace} KB</Text>
<Text>Free Space: {deviceInfo.freespace} KB</Text>
<Text>Brand: {deviceInfo.brand}</Text>
</>
)}
</View>
{/* Transfer Status */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>File Transfer</Text>
<Text>Transferring: {isTransferring ? 'Yes' : 'No'}</Text>
<Text>Progress: {transferProgress}%</Text>
</View>
{/* Control Buttons */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Controls</Text>
<View style={styles.buttonRow}>
<Button
title={isScanning ? "Stop Scan" : "Start Scan"}
onPress={isScanning ? stopScan : startScan}
/>
<Button
title={isConnected ? "Disconnect" : "Connect"}
onPress={isConnected ? disconnectDevice : connectToDevice}
disabled={!isConnected && !isScanning}
/>
</View>
<View style={styles.buttonRow}>
<Button title="Query Activation" onPress={queryActivationStatus} disabled={!isConnected} />
<Button title="Query Version" onPress={queryVersion} disabled={!isConnected} />
</View>
<View style={styles.buttonRow}>
<Button title="Device Info" onPress={requestDeviceInfo} disabled={!isConnected} />
<Button title="Update Time" onPress={updateActivationTime} disabled={!isConnected} />
</View>
<View style={styles.buttonRow}>
<Button title="Identity Check (Valid)" onPress={() => sendIdentityCheck(true)} disabled={!isConnected} />
<Button title="Identity Check (Invalid)" onPress={() => sendIdentityCheck(false)} disabled={!isConnected} />
</View>
<View style={styles.buttonRow}>
<Button title="Transfer File" onPress={transferSampleFile} disabled={!isConnected} />
<Button title="Clear Logs" onPress={clearLogs} />
</View>
</View>
{/* Protocol Info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Protocol Information</Text>
<Text>Version: 2.1.1</Text>
<Text>Service UUID: {PROTOCOL_UUIDS.SERVICE}</Text>
<Text>Write UUID: {PROTOCOL_UUIDS.WRITE_CHARACTERISTIC}</Text>
<Text>Read UUID: {PROTOCOL_UUIDS.READ_CHARACTERISTIC}</Text>
</View>
{/* Logs */}
<View style={styles.section}>
<View style={styles.logHeader}>
<Text style={styles.sectionTitle}>Logs</Text>
<Button title="Clear" onPress={clearLogs} />
</View>
<ScrollView style={styles.logContainer} nestedScrollEnabled={true}>
{logs.map((log, index) => (
<Text key={index} style={styles.logText}>{log}</Text>
))}
</ScrollView>
</View>
</ScrollView>
);
};
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;

8
ble/index.ts Normal file
View File

@@ -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';

View File

@@ -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<PeripheralEventCallback> = new Set();
private peripheral: Peripheral | null = null;
private isAdvertising = false;
private isReady = false;
private connectedCentrals = new Set<string>();
private deviceInfo: DeviceInfo;
private operationQueue: Array<() => Promise<void>> = [];
// 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<void> {
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<void> {
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<void>): Promise<void> {
if (this.isReady && this.peripheral) {
await operation();
} else {
this.operationQueue.push(operation);
}
}
public async startAdvertising(deviceName?: string): Promise<void> {
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<void> {
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<DeviceInfo>): 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 = [];
}
}

43
ble/package.json Normal file
View File

@@ -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"
]
}

52
ble/protocol/Constants.ts Normal file
View File

@@ -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;

View File

@@ -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
};
}
}

89
ble/protocol/types.ts Normal file
View File

@@ -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;

View File

@@ -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<number, Set<(data: ArrayBuffer, deviceId: string) => void>> = new Map();
private subscription: Subscription | null = null;
// deviceId_type -> { total: number, frames: ArrayBuffer[] }
private fragments: Map<string, { total: number, frames: (ArrayBuffer | null)[] }> = 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<void> {
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);
}
}
}

View File

@@ -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<string, Set<Function>> = 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})
}
}

View File

@@ -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<void> {
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<ArrayBuffer>((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);
}
}

80
ble/utils/CryptoUtils.ts Normal file
View File

@@ -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<string> - MD5 hash as hexadecimal string
*/
public static async calculateMD5(data: ArrayBuffer | string): Promise<string> {
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<string> - MD5 hash as hexadecimal string
*/
public static async calculateFileMD5(file: File): Promise<string> {
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<string> - Partial MD5 hash
*/
public static async calculatePartialMD5(data: ArrayBuffer | string): Promise<string> {
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<string> - Partial MD5 hash
*/
public static async calculatePartialFileMD5(file: File): Promise<string> {
const fullHash = await this.calculateFileMD5(file);
return fullHash.substring(8, 24); // Match Android implementation
}
}

View File

@@ -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;
}
}