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; // Cached Cost Metrics for backward compatibility 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; // Connection/Retry Tracking State 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!)) { // Retry period expired, attempt connection return false; } return true; // Under cooldown, switch to local offline directly } 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 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', }; // 1. Prefer Modal Direct Call (Faster, persistent container) 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."); } } // 2. Fallback to Gradio Space API 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); // Gradio async event returns event_id: 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 dataList = jsonDecode(jsonStr); if (dataList.isNotEmpty) { return FrameAnalysis.fromJson(dataList.first); } } } } } else if (body is Map && body.containsKey('data')) { final List dataList = body['data']; if (dataList.isNotEmpty) { return FrameAnalysis.fromJson(dataList.first); } } } } catch (e) { debugPrint("Gradio Space call failed: $e"); } // Both primary routes failed _recordFailure(); return _generateOfflineAnalysis(sensors, "Connection timeout. Switched to offline."); } Future 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 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 dataList = jsonDecode(jsonStr); if (dataList.isNotEmpty) { final parsed = CostDashboard.fromJson(dataList.first); _updateLocalCostStats(parsed); return parsed; } } } } } else if (body.containsKey('data')) { final List 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 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(); } }