WellNuo/services/ble/MockBLEManager.ts
Sergei f8156b2dc7 Add BLE auto-reconnect with exponential backoff
- Add ReconnectConfig and ReconnectState types for configurable reconnect behavior
- Implement auto-reconnect in BLEManager with exponential backoff (default: 3 attempts, 1.5x multiplier)
- Add connection monitoring via device.onDisconnected() for unexpected disconnections
- Update BLEContext with reconnectingDevices state and reconnect actions
- Create ConnectionStatusIndicator component for visual connection feedback
- Enhance device settings screen with reconnect UI and manual reconnect capability
- Add comprehensive tests for reconnect logic and UI component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:31:15 -08:00

537 lines
14 KiB
TypeScript

// Mock BLE Manager для iOS Simulator (Bluetooth недоступен)
import {
IBLEManager,
WPDevice,
WiFiNetwork,
WiFiStatus,
BLEConnectionState,
BLEDeviceConnection,
BLEEventListener,
BLEConnectionEvent,
SensorHealthMetrics,
SensorHealthStatus,
WiFiSignalQuality,
CommunicationHealth,
BulkOperationResult,
BulkWiFiResult,
ReconnectConfig,
ReconnectState,
DEFAULT_RECONNECT_CONFIG,
} from './types';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export class MockBLEManager implements IBLEManager {
private connectedDevices = new Set<string>();
private connectionStates = new Map<string, BLEDeviceConnection>();
private eventListeners: BLEEventListener[] = [];
private connectingDevices = new Set<string>(); // Track devices currently being connected
// Health monitoring state (mock)
private sensorHealthMetrics = new Map<string, SensorHealthMetrics>();
private communicationStats = new Map<string, CommunicationHealth>();
// Reconnect state (mock)
private reconnectConfig: ReconnectConfig = { ...DEFAULT_RECONNECT_CONFIG };
private reconnectStates = new Map<string, ReconnectState>();
private mockDevices: WPDevice[] = [
{
id: 'mock-743',
name: 'WP_497_81a14c',
mac: '142B2F81A14C',
rssi: -55,
wellId: 497,
},
{
id: 'mock-769',
name: 'WP_523_81aad4',
mac: '142B2F81AAD4',
rssi: -67,
wellId: 523,
},
];
/**
* Update connection state and notify listeners
*/
private updateConnectionState(
deviceId: string,
state: BLEConnectionState,
deviceName?: string,
error?: string
): void {
const existing = this.connectionStates.get(deviceId);
const now = Date.now();
const connection: BLEDeviceConnection = {
deviceId,
deviceName: deviceName || existing?.deviceName || deviceId,
state,
error,
connectedAt: state === BLEConnectionState.CONNECTED ? now : existing?.connectedAt,
lastActivity: now,
};
this.connectionStates.set(deviceId, connection);
this.emitEvent(deviceId, 'state_changed', { state, error });
}
/**
* Emit event to all registered listeners
*/
private emitEvent(deviceId: string, event: BLEConnectionEvent, data?: any): void {
this.eventListeners.forEach((listener) => {
try {
listener(deviceId, event, data);
} catch {
// Listener error should not crash the app
}
});
}
/**
* Get current connection state for a device
*/
getConnectionState(deviceId: string): BLEConnectionState {
const connection = this.connectionStates.get(deviceId);
return connection?.state || BLEConnectionState.DISCONNECTED;
}
/**
* Get all active connections
*/
getAllConnections(): Map<string, BLEDeviceConnection> {
return new Map(this.connectionStates);
}
/**
* Add event listener
*/
addEventListener(listener: BLEEventListener): void {
if (!this.eventListeners.includes(listener)) {
this.eventListeners.push(listener);
}
}
/**
* Remove event listener
*/
removeEventListener(listener: BLEEventListener): void {
const index = this.eventListeners.indexOf(listener);
if (index > -1) {
this.eventListeners.splice(index, 1);
}
}
async scanDevices(): Promise<WPDevice[]> {
await delay(2000); // Simulate scan delay
return this.mockDevices;
}
stopScan(): void {
}
async connectDevice(deviceId: string): Promise<boolean> {
try {
// Check if connection is already in progress
if (this.connectingDevices.has(deviceId)) {
throw new Error('Connection already in progress for this device');
}
// Check if already connected
if (this.connectedDevices.has(deviceId)) {
this.updateConnectionState(deviceId, BLEConnectionState.READY);
this.emitEvent(deviceId, 'ready');
return true;
}
// Mark device as connecting
this.connectingDevices.add(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTING);
await delay(500);
this.updateConnectionState(deviceId, BLEConnectionState.CONNECTED);
await delay(300);
this.updateConnectionState(deviceId, BLEConnectionState.DISCOVERING);
await delay(200);
this.connectedDevices.add(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.READY);
this.emitEvent(deviceId, 'ready');
return true;
} catch (error: any) {
const errorMessage = error?.message || 'Connection failed';
this.updateConnectionState(deviceId, BLEConnectionState.ERROR, undefined, errorMessage);
this.emitEvent(deviceId, 'connection_failed', { error: errorMessage });
return false;
} finally {
// Always remove from connecting set when done (success or failure)
this.connectingDevices.delete(deviceId);
}
}
async disconnectDevice(deviceId: string): Promise<void> {
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTING);
await delay(500);
this.connectedDevices.delete(deviceId);
this.updateConnectionState(deviceId, BLEConnectionState.DISCONNECTED);
this.emitEvent(deviceId, 'disconnected');
}
isDeviceConnected(deviceId: string): boolean {
return this.connectedDevices.has(deviceId);
}
async sendCommand(deviceId: string, command: string): Promise<string> {
await delay(500);
// Simulate responses
if (command === 'pin|7856') {
return 'pin|ok';
}
if (command === 'w') {
return 'mac,142b2f81a14c|w|3|FrontierTower,-55|HomeNetwork,-67|TP-Link_5G,-75';
}
if (command === 'a') {
return 'mac,142b2f81a14c|a|FrontierTower,-67';
}
if (command.startsWith('W|')) {
return 'mac,142b2f81a14c|W|ok';
}
return 'ok';
}
async getWiFiList(deviceId: string): Promise<WiFiNetwork[]> {
await delay(1500);
return [
{ ssid: 'FrontierTower', rssi: -55 },
{ ssid: 'HomeNetwork', rssi: -67 },
{ ssid: 'TP-Link_5G', rssi: -75 },
{ ssid: 'Office-WiFi', rssi: -80 },
];
}
async setWiFi(
deviceId: string,
ssid: string,
password: string
): Promise<boolean> {
await delay(2000);
return true;
}
async getCurrentWiFi(deviceId: string): Promise<WiFiStatus | null> {
await delay(1000);
return {
ssid: 'FrontierTower',
rssi: -67,
connected: true,
};
}
async rebootDevice(deviceId: string): Promise<void> {
await delay(500);
this.connectedDevices.delete(deviceId);
}
/**
* Get sensor health metrics (mock implementation)
*/
async getSensorHealth(wellId: number, mac: string): Promise<SensorHealthMetrics | null> {
await delay(1000);
const deviceKey = `WP_${wellId}_${mac.slice(-6).toLowerCase()}`;
// Generate mock health metrics
const now = Date.now();
const mockMetrics: SensorHealthMetrics = {
deviceId: `mock-${wellId}`,
deviceName: deviceKey,
overallHealth: SensorHealthStatus.GOOD,
connectionStatus: 'online',
lastSeen: new Date(),
lastSeenMinutesAgo: 2,
wifiSignalQuality: WiFiSignalQuality.GOOD,
wifiRssi: -60,
wifiSsid: 'FrontierTower',
wifiConnected: true,
communication: {
successfulCommands: 45,
failedCommands: 2,
averageResponseTime: 350,
lastSuccessfulCommand: now - 120000, // 2 minutes ago
lastFailedCommand: now - 3600000, // 1 hour ago
},
bleRssi: -55,
bleConnectionAttempts: 10,
bleConnectionFailures: 1,
lastHealthCheck: now,
lastBleConnection: now - 120000,
};
this.sensorHealthMetrics.set(deviceKey, mockMetrics);
return mockMetrics;
}
/**
* Get all cached sensor health metrics (mock)
*/
getAllSensorHealth(): Map<string, SensorHealthMetrics> {
return new Map(this.sensorHealthMetrics);
}
/**
* Cleanup all BLE connections and state
* Should be called on app logout to properly release resources
*/
async cleanup(): Promise<void> {
// Disconnect all connected devices
const deviceIds = Array.from(this.connectedDevices);
for (const deviceId of deviceIds) {
await this.disconnectDevice(deviceId);
}
this.connectedDevices.clear();
this.connectionStates.clear();
this.connectingDevices.clear();
// Clear health monitoring data
this.sensorHealthMetrics.clear();
this.communicationStats.clear();
this.eventListeners = [];
}
/**
* Bulk disconnect multiple devices (mock)
*/
async bulkDisconnect(deviceIds: string[]): Promise<BulkOperationResult[]> {
const results: BulkOperationResult[] = [];
for (const deviceId of deviceIds) {
await delay(100); // Simulate disconnect time
const mockDevice = this.mockDevices.find(d => d.id === deviceId);
const deviceName = mockDevice?.name || deviceId;
try {
await this.disconnectDevice(deviceId);
results.push({
deviceId,
deviceName,
success: true,
});
} catch (error: any) {
results.push({
deviceId,
deviceName,
success: false,
error: error?.message || 'Disconnect failed',
});
}
}
return results;
}
/**
* Bulk reboot multiple devices (mock)
*/
async bulkReboot(deviceIds: string[]): Promise<BulkOperationResult[]> {
const results: BulkOperationResult[] = [];
for (const deviceId of deviceIds) {
await delay(500); // Simulate reboot time
const mockDevice = this.mockDevices.find(d => d.id === deviceId);
const deviceName = mockDevice?.name || deviceId;
try {
// Mock reboot success
await this.rebootDevice(deviceId);
results.push({
deviceId,
deviceName,
success: true,
});
} catch (error: any) {
results.push({
deviceId,
deviceName,
success: false,
error: error?.message || 'Reboot failed',
});
}
}
return results;
}
/**
* Bulk WiFi configuration for multiple devices (mock)
*/
async bulkSetWiFi(
devices: { id: string; name: string }[],
ssid: string,
password: string,
onProgress?: (deviceId: string, status: 'connecting' | 'configuring' | 'rebooting' | 'success' | 'error', error?: string) => void
): Promise<BulkWiFiResult[]> {
const results: BulkWiFiResult[] = [];
for (const device of devices) {
const { id: deviceId, name: deviceName } = device;
try {
// Step 1: Connect (mock)
onProgress?.(deviceId, 'connecting');
await delay(800);
await this.connectDevice(deviceId);
// Step 2: Set WiFi (mock)
onProgress?.(deviceId, 'configuring');
await delay(1200);
await this.setWiFi(deviceId, ssid, password);
// Step 3: Reboot (mock)
onProgress?.(deviceId, 'rebooting');
await delay(600);
await this.rebootDevice(deviceId);
// Success
onProgress?.(deviceId, 'success');
results.push({
deviceId,
deviceName,
success: true,
});
} catch (error: any) {
const errorMessage = error?.message || 'WiFi configuration failed';
onProgress?.(deviceId, 'error', errorMessage);
results.push({
deviceId,
deviceName,
success: false,
error: errorMessage,
});
}
}
return results;
}
// ==================== RECONNECT FUNCTIONALITY (Mock) ====================
/**
* Set reconnect configuration (mock)
*/
setReconnectConfig(config: Partial<ReconnectConfig>): void {
this.reconnectConfig = { ...this.reconnectConfig, ...config };
}
/**
* Get current reconnect configuration (mock)
*/
getReconnectConfig(): ReconnectConfig {
return { ...this.reconnectConfig };
}
/**
* Enable auto-reconnect for a device (mock - no-op in simulator)
*/
enableAutoReconnect(deviceId: string, deviceName?: string): void {
const device = this.mockDevices.find(d => d.id === deviceId);
this.reconnectStates.set(deviceId, {
deviceId,
deviceName: deviceName || device?.name || deviceId,
attempts: 0,
lastAttemptTime: 0,
isReconnecting: false,
});
}
/**
* Disable auto-reconnect for a device (mock)
*/
disableAutoReconnect(deviceId: string): void {
this.reconnectStates.delete(deviceId);
}
/**
* Cancel a pending reconnect attempt (mock)
*/
cancelReconnect(deviceId: string): void {
const state = this.reconnectStates.get(deviceId);
if (state && state.isReconnecting) {
this.reconnectStates.set(deviceId, {
...state,
isReconnecting: false,
nextAttemptTime: undefined,
});
}
}
/**
* Manually trigger a reconnect attempt (mock)
*/
async manualReconnect(deviceId: string): Promise<boolean> {
const state = this.reconnectStates.get(deviceId);
const device = this.mockDevices.find(d => d.id === deviceId);
const deviceName = state?.deviceName || device?.name || deviceId;
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 0,
lastAttemptTime: Date.now(),
isReconnecting: true,
});
try {
const success = await this.connectDevice(deviceId);
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 0,
lastAttemptTime: Date.now(),
isReconnecting: false,
});
return success;
} catch (error: any) {
this.reconnectStates.set(deviceId, {
deviceId,
deviceName,
attempts: 1,
lastAttemptTime: Date.now(),
isReconnecting: false,
lastError: error?.message || 'Reconnection failed',
});
return false;
}
}
/**
* Get reconnect state for a device (mock)
*/
getReconnectState(deviceId: string): ReconnectState | undefined {
return this.reconnectStates.get(deviceId);
}
/**
* Get all reconnect states (mock)
*/
getAllReconnectStates(): Map<string, ReconnectState> {
return new Map(this.reconnectStates);
}
}