| import 'dart:convert'; |
| import 'dart:math'; |
| import 'package:flutter/foundation.dart'; |
| import 'package:http/http.dart' as http; |
| import '../models/sensor_snapshot.dart'; |
| import '../models/frame_analysis.dart'; |
| import '../models/cost_dashboard.dart'; |
| import '../models/health_status.dart'; |
|
|
| class ApiClient extends ChangeNotifier { |
| static const String defaultSpaceUrl = "https://com-sentinel-ai.hf.space"; |
|
|
| final http.Client _client; |
| String spaceUrl; |
| String? modalUrl; |
| final String? hfToken; |
|
|
| bool isOnline = true; |
|
|
| |
| int totalFrames = 0; |
| int totalVlmCalls = 0; |
| int totalTokens = 0; |
| double estimatedCost = 0.0; |
| double naiveCost = 0.0; |
| double savingsPct = 0.0; |
| double avgLatency = 0.0; |
|
|
| |
| int _consecutiveFailures = 0; |
| DateTime? _retryTime; |
| int _retryDelaySeconds = 1; |
|
|
| ApiClient({ |
| required this.spaceUrl, |
| this.modalUrl, |
| this.hfToken, |
| }) : _client = http.Client(); |
|
|
| void updateSpaceUrl(String url) { |
| if (url.endsWith('/')) { |
| spaceUrl = url.substring(0, url.length - 1); |
| } else { |
| spaceUrl = url; |
| } |
| notifyListeners(); |
| } |
|
|
| void _recordSuccess() { |
| _consecutiveFailures = 0; |
| _retryDelaySeconds = 1; |
| if (!isOnline) { |
| isOnline = true; |
| notifyListeners(); |
| } |
| } |
|
|
| void _recordFailure() { |
| _consecutiveFailures++; |
| if (_consecutiveFailures >= 3 && isOnline) { |
| isOnline = false; |
| notifyListeners(); |
| } |
| _retryTime = DateTime.now().add(Duration(seconds: _retryDelaySeconds)); |
| _retryDelaySeconds = min(_retryDelaySeconds * 2, 10); |
| } |
|
|
| bool _shouldBypassCall() { |
| if (!isOnline) { |
| if (_retryTime != null && DateTime.now().isAfter(_retryTime!)) { |
| |
| return false; |
| } |
| return true; |
| } |
| return false; |
| } |
|
|
| String buildQuestion(SensorSnapshot sensors) { |
| final headingStr = sensors.heading != null ? "${sensors.heading!.toStringAsFixed(0)}°" : "Unknown"; |
| final lightStr = sensors.lightLevel != null ? "${sensors.lightLevel!.toStringAsFixed(1)} lx" : "Unknown"; |
| final batteryStr = sensors.batteryPct != null ? "${sensors.batteryPct!.toStringAsFixed(0)}%" : "Unknown"; |
|
|
| String motion = "unknown"; |
| if (sensors.accelerometer != null && sensors.accelerometer!.length >= 3) { |
| final double ax = sensors.accelerometer![0]; |
| final double ay = sensors.accelerometer![1]; |
| final double az = sensors.accelerometer![2]; |
| final double mag = sqrt(ax * ax + ay * ay + az * az) - 9.8; |
| if (mag.abs() < 1.0) { |
| motion = "stationary"; |
| } else if (mag.abs() < 3.0) { |
| motion = "walking pace"; |
| } else { |
| motion = "running or shaking"; |
| } |
| } |
|
|
| final String location = (sensors.gps != null) ? "outdoor" : "indoor"; |
| final gpsStr = sensors.gps != null |
| ? "GPS: ${sensors.gps![0].toStringAsFixed(4)}, ${sensors.gps![1].toStringAsFixed(4)}" |
| : "GPS: Unavailable"; |
|
|
| return "Describe what you see and any dangers. " |
| "User heading: $headingStr. Light: $lightStr. Battery: $batteryStr. " |
| "Motion: $motion. Location: $location. $gpsStr."; |
| } |
|
|
| Future<FrameAnalysis> analyzeFrame(String base64Image, SensorSnapshot sensors) async { |
| if (_shouldBypassCall()) { |
| return _generateOfflineAnalysis(sensors, "System offline (consecutive connection failures)."); |
| } |
|
|
| final headers = { |
| 'Content-Type': 'application/json', |
| if (hfToken != null) 'Authorization': 'Bearer $hfToken', |
| }; |
|
|
| |
| if (modalUrl != null && modalUrl!.isNotEmpty) { |
| try { |
| final url = Uri.parse('$modalUrl/see'); |
| final response = await _client.post( |
| url, |
| headers: {'Content-Type': 'application/json'}, |
| body: jsonEncode({ |
| "image_base64": base64Image, |
| "question": buildQuestion(sensors), |
| }), |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (response.statusCode == 200) { |
| _recordSuccess(); |
| final data = jsonDecode(response.body); |
| return FrameAnalysis.fromJson(data); |
| } |
| } catch (e) { |
| debugPrint("Modal see request failed: $e. Falling back to Gradio."); |
| } |
| } |
|
|
| |
| try { |
| final url = Uri.parse('$spaceUrl/call/analyze_frame'); |
| final response = await _client.post( |
| url, |
| headers: headers, |
| body: jsonEncode({ |
| "data": [base64Image, sensors.toJson()] |
| }), |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (response.statusCode == 200) { |
| _recordSuccess(); |
| final body = jsonDecode(response.body); |
|
|
| |
| if (body is Map && body.containsKey('event_id')) { |
| final eventId = body['event_id']; |
| final resultResponse = await _client.get( |
| Uri.parse('$spaceUrl/call/analyze_frame/$eventId'), |
| headers: headers, |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (resultResponse.statusCode == 200) { |
| final lines = resultResponse.body.split('\n'); |
| for (var line in lines) { |
| if (line.startsWith('data: ')) { |
| final jsonStr = line.substring(6).trim(); |
| final List<dynamic> dataList = jsonDecode(jsonStr); |
| if (dataList.isNotEmpty) { |
| return FrameAnalysis.fromJson(dataList.first); |
| } |
| } |
| } |
| } |
| } else if (body is Map && body.containsKey('data')) { |
| final List<dynamic> dataList = body['data']; |
| if (dataList.isNotEmpty) { |
| return FrameAnalysis.fromJson(dataList.first); |
| } |
| } |
| } |
| } catch (e) { |
| debugPrint("Gradio Space call failed: $e"); |
| } |
|
|
| |
| _recordFailure(); |
| return _generateOfflineAnalysis(sensors, "Connection timeout. Switched to offline."); |
| } |
|
|
| Future<String> generateAlertAudio(String text, String level) async { |
| if (modalUrl != null && modalUrl!.isNotEmpty && isOnline) { |
| try { |
| final url = Uri.parse('$modalUrl/generate_alert_audio'); |
| final response = await _client.post( |
| url, |
| headers: {'Content-Type': 'application/json'}, |
| body: jsonEncode({"text": text}), |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (response.statusCode == 200) { |
| final data = jsonDecode(response.body); |
| return data['audio_base64'] ?? ''; |
| } |
| } catch (e) { |
| debugPrint("Error generating alert audio from Modal: $e"); |
| } |
| } |
| return ''; |
| } |
|
|
| Future<CostDashboard> getCostStats() async { |
| final headers = { |
| if (hfToken != null) 'Authorization': 'Bearer $hfToken', |
| }; |
|
|
| if (isOnline) { |
| try { |
| final response = await _client.get( |
| Uri.parse('$spaceUrl/call/get_stats'), |
| headers: headers, |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (response.statusCode == 200) { |
| final body = jsonDecode(response.body); |
| if (body is Map) { |
| if (body.containsKey('event_id')) { |
| final eventId = body['event_id']; |
| final resultResponse = await _client.get( |
| Uri.parse('$spaceUrl/call/get_stats/$eventId'), |
| headers: headers, |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (resultResponse.statusCode == 200) { |
| final lines = resultResponse.body.split('\n'); |
| for (var line in lines) { |
| if (line.startsWith('data: ')) { |
| final jsonStr = line.substring(6).trim(); |
| final List<dynamic> dataList = jsonDecode(jsonStr); |
| if (dataList.isNotEmpty) { |
| final parsed = CostDashboard.fromJson(dataList.first); |
| _updateLocalCostStats(parsed); |
| return parsed; |
| } |
| } |
| } |
| } |
| } else if (body.containsKey('data')) { |
| final List<dynamic> dataList = body['data']; |
| if (dataList.isNotEmpty) { |
| final parsed = CostDashboard.fromJson(dataList.first); |
| _updateLocalCostStats(parsed); |
| return parsed; |
| } |
| } |
| } |
| } |
| } catch (e) { |
| debugPrint("Error fetching cost stats: $e"); |
| } |
| } |
|
|
| return CostDashboard( |
| vlmCalls: totalVlmCalls, |
| totalTokens: totalTokens, |
| totalInferenceMs: (avgLatency * totalVlmCalls).toInt(), |
| estimatedCostUsd: estimatedCost, |
| naiveCostUsd: naiveCost, |
| savingsPct: savingsPct, |
| framesProcessed: totalFrames, |
| uptimeHours: 0.0, |
| ); |
| } |
|
|
| Future<HealthStatus> checkHealth() async { |
| if (modalUrl != null && modalUrl!.isNotEmpty) { |
| try { |
| final response = await _client.get( |
| Uri.parse('$modalUrl/health'), |
| ).timeout(const Duration(seconds: 30)); |
|
|
| if (response.statusCode == 200) { |
| final data = jsonDecode(response.body); |
| return HealthStatus.fromJson(data); |
| } |
| } catch (e) { |
| debugPrint("Modal health check failed: $e"); |
| } |
| } |
| return HealthStatus(status: 'unhealthy', uptime: 0.0); |
| } |
|
|
| void _updateLocalCostStats(CostDashboard stats) { |
| totalFrames = stats.framesProcessed; |
| totalVlmCalls = stats.vlmCalls; |
| totalTokens = stats.totalTokens; |
| estimatedCost = stats.estimatedCostUsd; |
| naiveCost = stats.naiveCostUsd; |
| savingsPct = stats.savingsPct; |
| avgLatency = stats.vlmCalls > 0 ? stats.totalInferenceMs / stats.vlmCalls : 0.0; |
| notifyListeners(); |
| } |
|
|
| FrameAnalysis _generateOfflineAnalysis(SensorSnapshot sensors, String offlineReason) { |
| String level = "none"; |
| String text = "System operating in local offline fallback mode."; |
|
|
| if (sensors.accelerometer != null && sensors.accelerometer!.length >= 3) { |
| final double ax = sensors.accelerometer![0]; |
| final double ay = sensors.accelerometer![1]; |
| final double az = sensors.accelerometer![2]; |
| final double mag = sqrt(ax * ax + ay * ay + az * az); |
| if (mag > 26.0) { |
| level = "critical"; |
| text = "Local Alert: High Impact G-Force detected ($mag m/s²)! Possible fall."; |
| } |
| } |
|
|
| if (level == "none" && sensors.batteryPct != null && sensors.batteryPct! < 15.0) { |
| level = "warning"; |
| text = "Local Warning: Low Battery level (${sensors.batteryPct!.toStringAsFixed(0)}%)."; |
| } |
|
|
| return FrameAnalysis( |
| frameId: -1, |
| timestamp: DateTime.now().millisecondsSinceEpoch / 1000.0, |
| changePct: 0.0, |
| yoloDetections: [], |
| audioClasses: [], |
| vlmTriggered: false, |
| alertLevel: level, |
| alertText: "$text ($offlineReason)", |
| ); |
| } |
|
|
| @override |
| void dispose() { |
| _client.close(); |
| super.dispose(); |
| } |
| } |
|
|