176 lines
7.7 KiB
Dart
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';
|
|
}
|