134 lines
5.5 KiB
Dart
134 lines
5.5 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`, `mobile`, `none` … as the platform names it.
|
|
final String? connection;
|
|
|
|
/// `enabled`, `disabled`, `denied`, `denied_forever`, `unknown`.
|
|
///
|
|
/// 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 = result.isNotEmpty
|
|
? result.first.toString().split('.').last
|
|
: 'none';
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
return DeviceTelemetry(
|
|
battery: level,
|
|
isCharging: charging,
|
|
connection: connection,
|
|
locationService: service,
|
|
);
|
|
}
|
|
|
|
/// 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';
|
|
}
|