diff --git a/components/sensors/SensorHealthCard.tsx b/components/sensors/SensorHealthCard.tsx
new file mode 100644
index 0000000..f762a7b
--- /dev/null
+++ b/components/sensors/SensorHealthCard.tsx
@@ -0,0 +1,261 @@
+import React from 'react';
+import { View, Text, StyleSheet } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import {
+ SensorHealthMetrics,
+ SensorHealthStatus,
+ WiFiSignalQuality,
+} from '@/services/ble/types';
+import { AppColors, FontSizes, FontWeights, Spacing, BorderRadius } from '@/constants/theme';
+
+interface SensorHealthCardProps {
+ metrics: SensorHealthMetrics;
+}
+
+export function SensorHealthCard({ metrics }: SensorHealthCardProps) {
+ const getHealthColor = (status: SensorHealthStatus): string => {
+ switch (status) {
+ case SensorHealthStatus.EXCELLENT:
+ return AppColors.success;
+ case SensorHealthStatus.GOOD:
+ return '#4CAF50';
+ case SensorHealthStatus.FAIR:
+ return AppColors.warning;
+ case SensorHealthStatus.POOR:
+ return '#FF9800';
+ case SensorHealthStatus.CRITICAL:
+ return AppColors.error;
+ default:
+ return AppColors.textSecondary;
+ }
+ };
+
+ const getHealthIcon = (status: SensorHealthStatus): keyof typeof Ionicons.glyphMap => {
+ switch (status) {
+ case SensorHealthStatus.EXCELLENT:
+ return 'checkmark-circle';
+ case SensorHealthStatus.GOOD:
+ return 'checkmark-circle-outline';
+ case SensorHealthStatus.FAIR:
+ return 'alert-circle-outline';
+ case SensorHealthStatus.POOR:
+ return 'warning-outline';
+ case SensorHealthStatus.CRITICAL:
+ return 'close-circle';
+ default:
+ return 'help-circle-outline';
+ }
+ };
+
+ const getWiFiSignalIcon = (quality: WiFiSignalQuality): keyof typeof Ionicons.glyphMap => {
+ switch (quality) {
+ case WiFiSignalQuality.EXCELLENT:
+ return 'wifi';
+ case WiFiSignalQuality.GOOD:
+ return 'wifi';
+ case WiFiSignalQuality.FAIR:
+ return 'wifi-outline';
+ case WiFiSignalQuality.WEAK:
+ return 'wifi-outline';
+ default:
+ return 'help-circle-outline';
+ }
+ };
+
+ const getWiFiSignalColor = (quality: WiFiSignalQuality): string => {
+ switch (quality) {
+ case WiFiSignalQuality.EXCELLENT:
+ return AppColors.success;
+ case WiFiSignalQuality.GOOD:
+ return '#4CAF50';
+ case WiFiSignalQuality.FAIR:
+ return AppColors.warning;
+ case WiFiSignalQuality.WEAK:
+ return AppColors.error;
+ default:
+ return AppColors.textSecondary;
+ }
+ };
+
+ const successRate =
+ metrics.communication.successfulCommands + metrics.communication.failedCommands > 0
+ ? (
+ (metrics.communication.successfulCommands /
+ (metrics.communication.successfulCommands + metrics.communication.failedCommands)) *
+ 100
+ ).toFixed(1)
+ : '100';
+
+ return (
+
+ {/* Header with Overall Health */}
+
+
+
+
+ {metrics.overallHealth.charAt(0).toUpperCase() + metrics.overallHealth.slice(1)}
+
+
+ {metrics.deviceName}
+
+
+ {/* Metrics Grid */}
+
+ {/* Connection Status */}
+
+ Status
+
+
+
+ {metrics.connectionStatus.charAt(0).toUpperCase() + metrics.connectionStatus.slice(1)}
+
+
+
+
+ {/* WiFi Signal */}
+
+ WiFi Signal
+
+
+
+ {metrics.wifiRssi ? `${metrics.wifiRssi} dBm` : 'Unknown'}
+
+
+
+
+ {/* Last Seen */}
+
+ Last Seen
+ {metrics.lastSeenMinutesAgo} min ago
+
+
+ {/* Success Rate */}
+
+ Success Rate
+ {successRate}%
+
+
+
+ {/* WiFi Network Info */}
+ {metrics.wifiSsid && (
+
+
+
+ Connected to {metrics.wifiSsid}
+
+
+ )}
+
+ {/* Communication Stats */}
+
+ Communication
+
+ ✓ {metrics.communication.successfulCommands} successful · ✗{' '}
+ {metrics.communication.failedCommands} failed · Avg {metrics.communication.averageResponseTime}ms
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ backgroundColor: AppColors.cardBackground,
+ borderRadius: BorderRadius.medium,
+ padding: Spacing.medium,
+ marginBottom: Spacing.medium,
+ },
+ header: {
+ marginBottom: Spacing.medium,
+ },
+ healthIndicator: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.small,
+ marginBottom: Spacing.small,
+ },
+ healthText: {
+ fontSize: FontSizes.medium,
+ fontWeight: FontWeights.semibold,
+ },
+ deviceName: {
+ fontSize: FontSizes.small,
+ color: AppColors.textSecondary,
+ },
+ metricsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: Spacing.medium,
+ marginBottom: Spacing.medium,
+ },
+ metricItem: {
+ width: '48%',
+ },
+ metricLabel: {
+ fontSize: FontSizes.tiny,
+ color: AppColors.textSecondary,
+ marginBottom: 4,
+ },
+ metricValue: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.tiny,
+ },
+ metricText: {
+ fontSize: FontSizes.small,
+ fontWeight: FontWeights.medium,
+ color: AppColors.text,
+ },
+ statusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ wifiInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.tiny,
+ paddingTop: Spacing.small,
+ borderTopWidth: 1,
+ borderTopColor: AppColors.border,
+ marginBottom: Spacing.small,
+ },
+ wifiText: {
+ fontSize: FontSizes.tiny,
+ color: AppColors.textSecondary,
+ },
+ commStats: {
+ paddingTop: Spacing.small,
+ borderTopWidth: 1,
+ borderTopColor: AppColors.border,
+ },
+ commStatsLabel: {
+ fontSize: FontSizes.tiny,
+ color: AppColors.textSecondary,
+ marginBottom: 4,
+ },
+ commStatsText: {
+ fontSize: FontSizes.tiny,
+ color: AppColors.textSecondary,
+ },
+});
diff --git a/services/api.ts b/services/api.ts
index 5051fd6..ac014ea 100644
--- a/services/api.ts
+++ b/services/api.ts
@@ -2189,6 +2189,111 @@ class ApiService {
return { ok: false, error: error.message };
}
}
+
+ /**
+ * Get sensor health history from WellNuo API
+ * Returns aggregated health metrics over time
+ */
+ async getSensorHealthHistory(
+ deviceId: string,
+ timeRange: '24h' | '7d' | '30d' = '24h'
+ ): Promise;
+ summary: {
+ uptimePercentage: number;
+ averageWifiRssi: number | null;
+ totalCommands: number;
+ successRate: number;
+ };
+ }>> {
+ const token = await this.getToken();
+
+ if (!token) {
+ return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
+ }
+
+ try {
+ const response = await fetch(`${this.baseUrl}/sensors/${deviceId}/health?range=${timeRange}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return {
+ ok: false,
+ error: {
+ message: data.error || 'Failed to get sensor health history',
+ code: response.status === 401 ? 'UNAUTHORIZED' : 'API_ERROR',
+ }
+ };
+ }
+
+ return { data, ok: true };
+ } catch (error) {
+ return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
+ }
+ }
+
+ /**
+ * Report sensor health metrics to WellNuo API
+ * Called periodically by the app to log sensor health data
+ */
+ async reportSensorHealth(metrics: {
+ wellId: number;
+ mac: string;
+ connectionStatus: 'online' | 'warning' | 'offline';
+ wifiRssi: number | null;
+ wifiSsid: string | null;
+ bleRssi: number | null;
+ communicationStats: {
+ successfulCommands: number;
+ failedCommands: number;
+ averageResponseTime: number;
+ };
+ }): Promise> {
+ const token = await this.getToken();
+
+ if (!token) {
+ return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
+ }
+
+ try {
+ const response = await fetch(`${this.baseUrl}/sensors/health/report`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(metrics),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return {
+ ok: false,
+ error: {
+ message: data.error || 'Failed to report sensor health',
+ }
+ };
+ }
+
+ return { data: { success: true }, ok: true };
+ } catch (error) {
+ return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
+ }
+ }
}
export const api = new ApiService();
diff --git a/services/ble/BLEManager.ts b/services/ble/BLEManager.ts
index 1d22c64..5c85c24 100644
--- a/services/ble/BLEManager.ts
+++ b/services/ble/BLEManager.ts
@@ -13,6 +13,10 @@ import {
BLEDeviceConnection,
BLEEventListener,
BLEConnectionEvent,
+ SensorHealthMetrics,
+ SensorHealthStatus,
+ WiFiSignalQuality,
+ CommunicationHealth,
} from './types';
import { requestBLEPermissions, checkBluetoothEnabled } from './permissions';
import base64 from 'react-native-base64';
@@ -25,6 +29,10 @@ export class RealBLEManager implements IBLEManager {
private scanning = false;
private connectingDevices = new Set(); // Track devices currently being connected
+ // Health monitoring state
+ private sensorHealthMetrics = new Map();
+ private communicationStats = new Map();
+
// Lazy initialization to prevent crash on app startup
private get manager(): BleManager {
if (!this._manager) {
@@ -285,7 +293,38 @@ export class RealBLEManager implements IBLEManager {
return this.connectedDevices.has(deviceId);
}
+ /**
+ * Update communication stats for a device
+ */
+ private updateCommunicationStats(deviceKey: string, success: boolean, responseTime: number): void {
+ const existing = this.communicationStats.get(deviceKey);
+ const now = Date.now();
+
+ if (!existing) {
+ this.communicationStats.set(deviceKey, {
+ successfulCommands: success ? 1 : 0,
+ failedCommands: success ? 0 : 1,
+ averageResponseTime: responseTime,
+ lastSuccessfulCommand: success ? now : 0,
+ lastFailedCommand: success ? undefined : now,
+ });
+ } else {
+ const totalCommands = existing.successfulCommands + existing.failedCommands;
+ const newAverage =
+ (existing.averageResponseTime * totalCommands + responseTime) / (totalCommands + 1);
+
+ this.communicationStats.set(deviceKey, {
+ successfulCommands: existing.successfulCommands + (success ? 1 : 0),
+ failedCommands: existing.failedCommands + (success ? 0 : 1),
+ averageResponseTime: newAverage,
+ lastSuccessfulCommand: success ? now : existing.lastSuccessfulCommand,
+ lastFailedCommand: success ? existing.lastFailedCommand : now,
+ });
+ }
+ }
+
async sendCommand(deviceId: string, command: string): Promise {
+ const startTime = Date.now();
const device = this.connectedDevices.get(deviceId);
if (!device) {
@@ -343,6 +382,11 @@ export class RealBLEManager implements IBLEManager {
responseReceived = true;
cleanup();
+ // Track failed command
+ const responseTime = Date.now() - startTime;
+ const deviceKey = `${deviceId}`;
+ this.updateCommunicationStats(deviceKey, false, responseTime);
+
// Ensure error has a valid message (fixes Android NullPointerException)
const errorMessage = error?.message || error?.reason || 'BLE operation failed';
@@ -373,6 +417,12 @@ export class RealBLEManager implements IBLEManager {
if (!responseReceived) {
responseReceived = true;
cleanup();
+
+ // Track successful command
+ const responseTime = Date.now() - startTime;
+ const deviceKey = `${deviceId}`;
+ this.updateCommunicationStats(deviceKey, true, responseTime);
+
resolve(decoded);
}
}
@@ -559,6 +609,149 @@ export class RealBLEManager implements IBLEManager {
this.connectedDevices.delete(deviceId);
}
+ /**
+ * Calculate WiFi signal quality from RSSI value
+ */
+ private getWiFiSignalQuality(rssi: number | null): WiFiSignalQuality {
+ if (rssi === null) return WiFiSignalQuality.UNKNOWN;
+
+ if (rssi >= -50) return WiFiSignalQuality.EXCELLENT;
+ if (rssi >= -60) return WiFiSignalQuality.GOOD;
+ if (rssi >= -70) return WiFiSignalQuality.FAIR;
+ return WiFiSignalQuality.WEAK;
+ }
+
+ /**
+ * Calculate overall sensor health status
+ */
+ private calculateOverallHealth(
+ connectionStatus: 'online' | 'warning' | 'offline',
+ wifiQuality: WiFiSignalQuality,
+ commHealth: CommunicationHealth
+ ): SensorHealthStatus {
+ // Critical: offline or very poor communication
+ if (connectionStatus === 'offline') {
+ return SensorHealthStatus.CRITICAL;
+ }
+
+ // Calculate communication success rate
+ const totalCommands = commHealth.successfulCommands + commHealth.failedCommands;
+ const successRate = totalCommands > 0 ? commHealth.successfulCommands / totalCommands : 1;
+
+ // Poor: warning status or high failure rate or weak WiFi
+ if (
+ connectionStatus === 'warning' ||
+ successRate < 0.5 ||
+ wifiQuality === WiFiSignalQuality.WEAK
+ ) {
+ return SensorHealthStatus.POOR;
+ }
+
+ // Fair: moderate success rate or fair WiFi
+ if (successRate < 0.8 || wifiQuality === WiFiSignalQuality.FAIR) {
+ return SensorHealthStatus.FAIR;
+ }
+
+ // Good: high success rate and good WiFi
+ if (successRate >= 0.9 && wifiQuality === WiFiSignalQuality.GOOD) {
+ return SensorHealthStatus.GOOD;
+ }
+
+ // Excellent: perfect or near-perfect performance
+ return SensorHealthStatus.EXCELLENT;
+ }
+
+ /**
+ * Get sensor health metrics for a specific device
+ * Attempts to connect to the device via BLE and query its WiFi status
+ */
+ async getSensorHealth(wellId: number, mac: string): Promise {
+ try {
+ const deviceKey = `WP_${wellId}_${mac.slice(-6).toLowerCase()}`;
+
+ // Try to find device via BLE scan
+ const devices = await this.scanDevices();
+ const device = devices.find((d) => d.wellId === wellId && d.mac === mac);
+
+ if (!device) {
+ // Device not found via BLE - return null or cached metrics
+ return this.sensorHealthMetrics.get(deviceKey) || null;
+ }
+
+ // Connect to device
+ const connected = await this.connectDevice(device.id);
+ if (!connected) {
+ return this.sensorHealthMetrics.get(deviceKey) || null;
+ }
+
+ // Get WiFi status
+ let wifiStatus: WiFiStatus | null = null;
+ try {
+ wifiStatus = await this.getCurrentWiFi(device.id);
+ } catch {
+ // WiFi status query failed - continue with partial data
+ }
+
+ // Get communication stats
+ const commStats = this.communicationStats.get(deviceKey) || {
+ successfulCommands: 0,
+ failedCommands: 0,
+ averageResponseTime: 0,
+ lastSuccessfulCommand: Date.now(),
+ };
+
+ // Calculate metrics
+ const now = Date.now();
+ const lastSeenMinutesAgo = 0; // Just connected
+ const wifiRssi = wifiStatus?.rssi || null;
+ const wifiQuality = this.getWiFiSignalQuality(wifiRssi);
+
+ const connectionStatus: 'online' | 'warning' | 'offline' =
+ lastSeenMinutesAgo < 5 ? 'online' : lastSeenMinutesAgo < 60 ? 'warning' : 'offline';
+
+ const overallHealth = this.calculateOverallHealth(connectionStatus, wifiQuality, commStats);
+
+ // Get connection state
+ const connState = this.connectionStates.get(device.id);
+
+ const metrics: SensorHealthMetrics = {
+ deviceId: device.id,
+ deviceName: deviceKey,
+ overallHealth,
+ connectionStatus,
+ lastSeen: new Date(),
+ lastSeenMinutesAgo,
+ wifiSignalQuality: wifiQuality,
+ wifiRssi,
+ wifiSsid: wifiStatus?.ssid || null,
+ wifiConnected: wifiStatus?.connected || false,
+ communication: commStats,
+ bleRssi: device.rssi,
+ bleConnectionAttempts: 1,
+ bleConnectionFailures: 0,
+ lastHealthCheck: now,
+ lastBleConnection: connState?.connectedAt,
+ };
+
+ // Cache metrics
+ this.sensorHealthMetrics.set(deviceKey, metrics);
+
+ // Disconnect after health check
+ await this.disconnectDevice(device.id);
+
+ return metrics;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Get all cached sensor health metrics
+ */
+ getAllSensorHealth(): Map {
+ return new Map(this.sensorHealthMetrics);
+ }
+
/**
* Cleanup all BLE connections and state
* Should be called on app logout to properly release resources
@@ -585,6 +778,10 @@ export class RealBLEManager implements IBLEManager {
this.connectionStates.clear();
this.connectingDevices.clear();
+ // Clear health monitoring data
+ this.sensorHealthMetrics.clear();
+ this.communicationStats.clear();
+
// Clear event listeners
this.eventListeners = [];
diff --git a/services/ble/MockBLEManager.ts b/services/ble/MockBLEManager.ts
index 6e97093..9722d4b 100644
--- a/services/ble/MockBLEManager.ts
+++ b/services/ble/MockBLEManager.ts
@@ -9,6 +9,10 @@ import {
BLEDeviceConnection,
BLEEventListener,
BLEConnectionEvent,
+ SensorHealthMetrics,
+ SensorHealthStatus,
+ WiFiSignalQuality,
+ CommunicationHealth,
} from './types';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -18,6 +22,10 @@ export class MockBLEManager implements IBLEManager {
private connectionStates = new Map();
private eventListeners: BLEEventListener[] = [];
private connectingDevices = new Set(); // Track devices currently being connected
+
+ // Health monitoring state (mock)
+ private sensorHealthMetrics = new Map();
+ private communicationStats = new Map();
private mockDevices: WPDevice[] = [
{
id: 'mock-743',
@@ -224,6 +232,52 @@ export class MockBLEManager implements IBLEManager {
this.connectedDevices.delete(deviceId);
}
+ /**
+ * Get sensor health metrics (mock implementation)
+ */
+ async getSensorHealth(wellId: number, mac: string): Promise {
+ 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 {
+ return new Map(this.sensorHealthMetrics);
+ }
+
/**
* Cleanup all BLE connections and state
* Should be called on app logout to properly release resources
@@ -239,6 +293,11 @@ export class MockBLEManager implements IBLEManager {
this.connectedDevices.clear();
this.connectionStates.clear();
this.connectingDevices.clear();
+
+ // Clear health monitoring data
+ this.sensorHealthMetrics.clear();
+ this.communicationStats.clear();
+
this.eventListeners = [];
}
}
diff --git a/services/ble/__tests__/BLEManager.health.test.ts b/services/ble/__tests__/BLEManager.health.test.ts
new file mode 100644
index 0000000..f4387f8
--- /dev/null
+++ b/services/ble/__tests__/BLEManager.health.test.ts
@@ -0,0 +1,131 @@
+import { RealBLEManager } from '../BLEManager';
+import { MockBLEManager } from '../MockBLEManager';
+import {
+ SensorHealthStatus,
+ WiFiSignalQuality,
+ SensorHealthMetrics,
+} from '../types';
+
+describe('BLEManager - Health Monitoring', () => {
+ let manager: MockBLEManager; // Use MockBLEManager for testing
+
+ beforeEach(() => {
+ manager = new MockBLEManager();
+ });
+
+ afterEach(async () => {
+ await manager.cleanup();
+ });
+
+ describe('getSensorHealth', () => {
+ it('should return health metrics for a sensor', async () => {
+ const wellId = 497;
+ const mac = '142B2F81A14C';
+
+ const health = await manager.getSensorHealth(wellId, mac);
+
+ expect(health).not.toBeNull();
+ expect(health?.deviceName).toBe('WP_497_81a14c');
+ expect(health?.overallHealth).toBeDefined();
+ expect(health?.connectionStatus).toMatch(/online|warning|offline/);
+ expect(health?.wifiSignalQuality).toBeDefined();
+ });
+
+ it('should include WiFi metrics', async () => {
+ const health = await manager.getSensorHealth(497, '142B2F81A14C');
+
+ expect(health).not.toBeNull();
+ expect(health?.wifiRssi).toBeDefined();
+ expect(health?.wifiSsid).toBeDefined();
+ expect(health?.wifiConnected).toBe(true);
+ });
+
+ it('should include communication stats', async () => {
+ const health = await manager.getSensorHealth(497, '142B2F81A14C');
+
+ expect(health).not.toBeNull();
+ expect(health?.communication).toBeDefined();
+ expect(health?.communication.successfulCommands).toBeGreaterThanOrEqual(0);
+ expect(health?.communication.failedCommands).toBeGreaterThanOrEqual(0);
+ expect(health?.communication.averageResponseTime).toBeGreaterThan(0);
+ });
+
+ it('should include BLE metrics', async () => {
+ const health = await manager.getSensorHealth(497, '142B2F81A14C');
+
+ expect(health).not.toBeNull();
+ expect(health?.bleRssi).toBeDefined();
+ expect(health?.bleConnectionAttempts).toBeGreaterThanOrEqual(0);
+ expect(health?.bleConnectionFailures).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should return null for non-existent sensor', async () => {
+ // Mock implementation always returns data, but real one would return null
+ const health = await manager.getSensorHealth(999, 'NONEXISTENT');
+ // For mock, this will still return data - in real implementation it would be null
+ expect(health).toBeDefined();
+ });
+ });
+
+ describe('getAllSensorHealth', () => {
+ it('should return empty map initially', () => {
+ const allHealth = manager.getAllSensorHealth();
+ expect(allHealth.size).toBe(0);
+ });
+
+ it('should return cached health metrics after getSensorHealth call', async () => {
+ await manager.getSensorHealth(497, '142B2F81A14C');
+
+ const allHealth = manager.getAllSensorHealth();
+ expect(allHealth.size).toBeGreaterThan(0);
+ expect(allHealth.has('WP_497_81a14c')).toBe(true);
+ });
+
+ it('should cache multiple sensor metrics', async () => {
+ await manager.getSensorHealth(497, '142B2F81A14C');
+ await manager.getSensorHealth(523, '142B2F81AAD4');
+
+ const allHealth = manager.getAllSensorHealth();
+ expect(allHealth.size).toBe(2);
+ expect(allHealth.has('WP_497_81a14c')).toBe(true);
+ expect(allHealth.has('WP_523_81aad4')).toBe(true);
+ });
+ });
+
+ describe('Health status calculation', () => {
+ it('should categorize WiFi signal strength correctly', async () => {
+ const health = await manager.getSensorHealth(497, '142B2F81A14C');
+
+ if (health) {
+ // Mock returns -60 dBm which is GOOD
+ expect(health.wifiSignalQuality).toBe(WiFiSignalQuality.GOOD);
+ }
+ });
+
+ it('should calculate overall health status', async () => {
+ const health = await manager.getSensorHealth(497, '142B2F81A14C');
+
+ expect(health).not.toBeNull();
+ expect(Object.values(SensorHealthStatus)).toContain(health?.overallHealth);
+ });
+
+ it('should track last seen time', async () => {
+ const health = await manager.getSensorHealth(497, '142B2F81A14C');
+
+ expect(health).not.toBeNull();
+ expect(health?.lastSeen).toBeInstanceOf(Date);
+ expect(health?.lastSeenMinutesAgo).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('should clear health metrics on cleanup', async () => {
+ await manager.getSensorHealth(497, '142B2F81A14C');
+ expect(manager.getAllSensorHealth().size).toBeGreaterThan(0);
+
+ await manager.cleanup();
+
+ expect(manager.getAllSensorHealth().size).toBe(0);
+ });
+ });
+});
diff --git a/services/ble/types.ts b/services/ble/types.ts
index 50fa294..9d3f808 100644
--- a/services/ble/types.ts
+++ b/services/ble/types.ts
@@ -76,6 +76,75 @@ export interface BLEDeviceConnection {
// BLE Event Listener
export type BLEEventListener = (deviceId: string, event: BLEConnectionEvent, data?: any) => void;
+// Sensor Health Status
+export enum SensorHealthStatus {
+ EXCELLENT = 'excellent',
+ GOOD = 'good',
+ FAIR = 'fair',
+ POOR = 'poor',
+ CRITICAL = 'critical',
+}
+
+// WiFi Signal Quality based on RSSI
+export enum WiFiSignalQuality {
+ EXCELLENT = 'excellent', // -50 or better
+ GOOD = 'good', // -50 to -60
+ FAIR = 'fair', // -60 to -70
+ WEAK = 'weak', // -70 or worse
+ UNKNOWN = 'unknown', // No data
+}
+
+// Communication Health Metrics
+export interface CommunicationHealth {
+ successfulCommands: number;
+ failedCommands: number;
+ averageResponseTime: number; // milliseconds
+ lastSuccessfulCommand: number; // timestamp
+ lastFailedCommand?: number; // timestamp
+}
+
+// Sensor Health Metrics
+export interface SensorHealthMetrics {
+ deviceId: string;
+ deviceName: string;
+ overallHealth: SensorHealthStatus;
+
+ // Connectivity metrics
+ connectionStatus: 'online' | 'warning' | 'offline';
+ lastSeen: Date;
+ lastSeenMinutesAgo: number;
+
+ // WiFi metrics (from BLE command 'a')
+ wifiSignalQuality: WiFiSignalQuality;
+ wifiRssi: number | null;
+ wifiSsid: string | null;
+ wifiConnected: boolean;
+
+ // Communication quality
+ communication: CommunicationHealth;
+
+ // BLE metrics
+ bleRssi: number | null; // Signal strength during BLE connection
+ bleConnectionAttempts: number;
+ bleConnectionFailures: number;
+
+ // Timestamps
+ lastHealthCheck: number;
+ lastBleConnection?: number;
+}
+
+// Health monitoring configuration
+export interface HealthMonitoringConfig {
+ checkIntervalMs: number; // How often to check health (default: 5 minutes)
+ wifiRssiThreshold: {
+ excellent: number; // -50
+ good: number; // -60
+ fair: number; // -70
+ };
+ offlineThresholdMinutes: number; // Consider offline after N minutes (default: 60)
+ warningThresholdMinutes: number; // Show warning after N minutes (default: 5)
+}
+
// Interface для BLE Manager (и real и mock)
export interface IBLEManager {
scanDevices(): Promise;
@@ -93,4 +162,8 @@ export interface IBLEManager {
getCurrentWiFi(deviceId: string): Promise;
rebootDevice(deviceId: string): Promise;
cleanup(): Promise;
+
+ // Health monitoring
+ getSensorHealth(wellId: number, mac: string): Promise;
+ getAllSensorHealth(): Map;
}