Files
doormile_milderapp/lib/data/device_telemetry.dart
2026-08-28 18:16:28 +05:30

176 lines
7.7 KiB
Dart

import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT THE CONSOLE NEEDS TO SEE ABOUT THE HANDSET
///
/// The dispatcher watching a rider is not only asking *where is he*. When a
/// rider stops reporting, the question is **why** — and the answers are all
/// facts about the phone: the battery died, the signal dropped, he turned
/// location off, the app went to the background and the OS stopped waking it.
/// `POST /miler/logs` has fields for every one of them:
///
/// ```
/// battery is_charging connection location_service accuracy is_background
/// ```
///
/// ── Why the console was showing an em dash for all of it ──
///
/// Not because nothing was measured. Every one of these was already being read
/// on the heartbeat — and published to **MQTT only**, in a block that ran
/// *after* the log had been posted. So the telemetry existed, reached a
/// different transport, and the rider-log row the console actually reads
/// carried nothing but a position.
///
/// It was two lanes for one fact, and the lane the console reads was the empty
/// one. This is the reader both now share: one place that knows how to ask the
/// handset, so the MQTT publish and the log post cannot report different
/// batteries a second apart.
///
/// ── Never throws, never blocks ──
///
/// Telemetry is the least important thing the app does. A permissions prompt, a
/// platform channel that hangs, a plugin missing on a desktop test host — none
/// of them may cost the rider his heartbeat, so every read is guarded
/// individually and an unavailable one simply does not appear in the map.
/// A field that is absent renders as the em dash it already rendered as; a
/// field that is *wrong* would be read as fact by somebody making a decision.
/// ─────────────────────────────────────────────────────────────────────────
class DeviceTelemetry {
/// Battery percentage, or null when the platform will not say.
final int? battery;
/// True while on the charger — a rider at 12% and charging is not the same
/// problem as a rider at 12% and riding.
final bool? isCharging;
/// `WiFi`, `4G` or `none` — the contract's vocabulary, not the plugin's.
/// See [_connectionName].
final String? connection;
/// `enabled` or `disabled`. See [_locationServiceName].
///
/// The one field here that is about a *choice the rider made*, which is why
/// it is worth a column of its own on the console: a disabled location
/// service explains a silent rider completely, and no amount of staring at
/// his last position does.
final String? locationService;
const DeviceTelemetry({
this.battery,
this.isCharging,
this.connection,
this.locationService,
});
/// Reads what the handset will say about itself, right now.
///
/// [locationService] can be supplied by a caller that has just resolved a
/// fix and already knows — the position lookup has to ask the same question
/// to get a fix at all, and asking twice can disagree with itself when the
/// rider toggles the setting between the two calls.
static Future<DeviceTelemetry> read({String? locationService}) async {
int? level;
bool? charging;
try {
final battery = Battery();
level = await battery.batteryLevel;
final state = await battery.batteryState;
charging =
state == BatteryState.charging || state == BatteryState.full;
} catch (e) {
debugPrint('[TELEMETRY] battery unavailable: $e');
}
String? connection;
try {
final result = await Connectivity().checkConnectivity();
connection = _connectionName(result);
} catch (e) {
debugPrint('[TELEMETRY] connectivity unavailable: $e');
}
var service = locationService;
if (service == null || service.isEmpty || service == 'unknown') {
try {
service = await Geolocator.isLocationServiceEnabled()
? 'enabled'
: 'disabled';
} catch (e) {
debugPrint('[TELEMETRY] location service unavailable: $e');
service = null;
}
}
service = _locationServiceName(service);
return DeviceTelemetry(
battery: level,
isCharging: charging,
connection: connection,
locationService: service,
);
}
/// `WiFi` / `4G` / `none`, which is the vocabulary `/miler/logs` documents.
///
/// ── The plugin's own spelling is not the contract's ──
///
/// This was `result.first.toString().split('.').last`, which is the *enum
/// constant* — `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `other`.
/// Two of those happen to look right in lower case and the rest do not
/// appear in the contract at all, so the console's Connection column was
/// rendering whatever `connectivity_plus` happened to call the transport
/// this release.
///
/// `first` was also the wrong pick: the list is every active transport, so a
/// phone on Wi-Fi with mobile data up could report either depending on the
/// order the platform returned them. Wi-Fi wins where both are present,
/// because it is the one that explains a rider whose data has run out.
static String _connectionName(List<ConnectivityResult> results) {
if (results.isEmpty) return 'none';
if (results.contains(ConnectivityResult.wifi)) return 'WiFi';
if (results.contains(ConnectivityResult.mobile)) return '4G';
if (results.contains(ConnectivityResult.ethernet)) return 'ethernet';
if (results.every((r) => r == ConnectivityResult.none)) return 'none';
return 'other';
}
/// `enabled` or `disabled` — the two values the contract names.
///
/// The richer answers the fix lookup produces — `denied`, `denied_forever`,
/// `unknown` — are more useful to a dispatcher, and they are not what this
/// field accepts. On a backend that validates its enums (and this one does
/// silently: see the `Break` / `On_Break` note in [MilerApi]) an unrecognised
/// value risks the whole row, which costs the battery and the connection
/// alongside it.
///
/// So anything that is not `enabled` is `disabled` here, which is the fact
/// the column exists to report — this rider's location is not reaching us.
/// The distinction between *off* and *denied* is not lost: it still rides on
/// the MQTT `location_turned_off` alert as `error_type`.
static String? _locationServiceName(String? raw) {
if (raw == null || raw.isEmpty) return null;
return raw == 'enabled' ? 'enabled' : 'disabled';
}
/// The heartbeat payload's own field names, ready to spread into it.
///
/// Only what was actually read: an absent key is a field the console draws as
/// unknown, which is the truth. A `0` battery or a `none` connection standing
/// in for "could not ask" is a fact somebody would act on.
Map<String, dynamic> toPayload() => <String, dynamic>{
if (battery != null) 'battery': '$battery',
if (isCharging != null) 'is_charging': isCharging,
if (connection != null && connection!.isNotEmpty) 'connection': connection,
if (locationService != null && locationService!.isNotEmpty)
'location_service': locationService,
};
@override
String toString() =>
'battery=$battery charging=$isCharging '
'connection=$connection location=$locationService';
}