sentinel / sentinel_flutter /lib /services /api_client.dart
kswffs's picture
Upload folder using huggingface_hub
b96103d verified
Raw
History Blame Contribute Delete
11.2 kB
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<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',
};
// 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<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");
}
// Both primary routes failed
_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();
}
}