expo-ble-app-demo/ble/services/BleProtocolService.ts

179 lines
6.7 KiB
TypeScript

import {BleClient} from '../core/BleClient';
import {ProtocolManager} from '../protocol/ProtocolManager';
import {BLE_UUIDS, COMMAND_TYPES, FRAME_CONSTANTS} 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: COMMAND_TYPES, data: object | ArrayBuffer | Uint8Array, onProgress?: (progress: number) => void): Promise<void> {
let payload: Uint8Array;
if (data instanceof ArrayBuffer) {
payload = new Uint8Array(data);
} else if (data instanceof Uint8Array) {
payload = data;
} else {
const jsonStr = JSON.stringify(data);
payload = new Uint8Array(Buffer.from(jsonStr));
}
const device = this.client.getConnectedDevice();
const mtu = device?.mtu || 23;
// MTU - 3 bytes (ATT overhead) - Protocol Header - Protocol Footer
const maxPayloadSize = mtu - 3 - FRAME_CONSTANTS.HEADER_SIZE - FRAME_CONSTANTS.FOOTER_SIZE;
// Ensure reasonable bounds (at least 1 byte, max constrained by protocol constant)
const safeMaxDataSize = Math.max(1, Math.min(maxPayloadSize, FRAME_CONSTANTS.MAX_DATA_SIZE));
console.debug(`[BleProtocolService] Sending with MTU=${mtu}, maxDataSize=${safeMaxDataSize}`);
const frames = ProtocolManager.createFrame(type, payload, FRAME_CONSTANTS.HEAD_APP_TO_DEVICE, true, safeMaxDataSize);
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);
await new Promise(resolve => setTimeout(resolve, FRAME_CONSTANTS.FRAME_INTERVAL));
if (onProgress) {
onProgress((i + 1) / total);
}
console.debug("Wrote frame", result);
}
}
}