Initial version

This commit is contained in:
2026-09-21 18:03:21 -04:00
commit e8e7dc4a81
83 changed files with 7177 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'screens/scan_screen.dart';
import 'services/ble_client.dart';
import 'services/protocol_log.dart';
import 'settings.dart';
import 'theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Future.wait([ProtocolLog.instance.init(), AppSettings.instance.load()]);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: OsColors.background,
));
runApp(const OpenScooterApp());
}
/// Single BLE client for the whole app (one active connection at a time).
final _ble = FlutterBleClient();
/// Logs route changes into the protocol log for field debugging.
class _RouteLogger extends NavigatorObserver {
@override
void didPush(Route route, Route? previous) =>
ProtocolLog.instance.log('NAV', 'push ${route.settings.name ?? route.runtimeType}');
@override
void didPop(Route route, Route? previous) =>
ProtocolLog.instance.log('NAV', 'pop ${route.settings.name ?? route.runtimeType}');
@override
void didRemove(Route route, Route? previous) =>
ProtocolLog.instance.log('NAV', 'remove ${route.settings.name ?? route.runtimeType}');
}
class OpenScooterApp extends StatelessWidget {
const OpenScooterApp({super.key});
@override
Widget build(BuildContext context) {
final settings = AppSettings.instance;
return ListenableBuilder(
listenable: settings,
builder: (context, _) => MaterialApp(
title: 'OpenMotion',
debugShowCheckedModeBanner: false,
theme: buildOsTheme(settings.accent.color),
navigatorObservers: [_RouteLogger()],
home: ScanScreen(ble: _ble),
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
/// A scooter candidate discovered during a BLE scan.
///
/// [id] is the platform peripheral identifier (a MAC address on Android, an
/// opaque UUID on iOS). It is only valid for connecting on this device and
/// must NOT be used as the scooter's persistent identity. Once a vendor
/// protocol exposes a serial number or UID, use that instead.
class ScooterDevice {
final String id;
final String name;
final int rssi;
/// Lowercase 128-bit service UUIDs found in the advertisement.
final Set<String> advertisedServiceUuids;
const ScooterDevice({
required this.id,
required this.name,
required this.rssi,
this.advertisedServiceUuids = const {},
});
@override
bool operator ==(Object other) => other is ScooterDevice && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() => 'ScooterDevice($id, "$name", $rssi dBm)';
}
+193
View File
@@ -0,0 +1,193 @@
enum ScooterConnectionStatus {
disconnected,
connecting,
connected,
authenticating,
authenticated,
ready,
error,
}
/// Observable snapshot of everything the UI needs.
///
/// Numeric protocol values are kept in their native representation. Units
/// (km vs miles, Ah vs mAh, percent) are NOT asserted here until they have
/// been LIVE VERIFIED against a physical scooter.
class ScooterState {
final ScooterConnectionStatus connectionStatus;
final bool authenticated;
final bool canWrite;
/// Human-readable description of the last error, if [connectionStatus] is
/// [ScooterConnectionStatus.error].
final String? errorMessage;
/// Which connection step is in progress (0-based index into
/// [connectionSteps]) while [connectionStatus] is connecting or
/// authenticated-but-waiting. Null when no multi-step work is running.
final int? connectionStep;
/// Steps shown to the rider while a link is being established.
static const connectionSteps = [
'Connecting',
'Setting up services',
'Checking this is a supported device',
'Waiting for telemetry',
'Waiting for scooter confirmation',
];
final double? speed;
final double? voltage;
final double? current;
final double? power;
final double? tripDistance;
final double? odometer;
final int? batteryLevel;
final int? batteryTemperature;
final int? batteryCycles;
final int? motorTemperature;
final int? controllerTemperature;
final int? gear;
final bool? locked;
final bool? headlight;
final bool? atmosphereLight;
final bool? cruiseControl;
final bool? leftTurnSignal;
final bool? rightTurnSignal;
/// Scooter's own unit preference bit (monitor byte 22 bit 5).
final bool? imperial;
/// Speed limit for the current gear and the highest configured limit, in
/// native protocol units. Gear-to-mode mapping is INFERRED.
final int? speedLimit;
final int? maxSpeedLimit;
final String? displayId;
final String? displayVersion;
const ScooterState({
this.connectionStatus = ScooterConnectionStatus.disconnected,
this.authenticated = false,
this.canWrite = false,
this.errorMessage,
this.connectionStep,
this.speed,
this.voltage,
this.current,
this.power,
this.tripDistance,
this.odometer,
this.batteryLevel,
this.batteryTemperature,
this.batteryCycles,
this.motorTemperature,
this.controllerTemperature,
this.gear,
this.locked,
this.headlight,
this.atmosphereLight,
this.cruiseControl,
this.leftTurnSignal,
this.rightTurnSignal,
this.imperial,
this.speedLimit,
this.maxSpeedLimit,
this.displayId,
this.displayVersion,
});
/// Sentinel so callers can explicitly clear a nullable field.
static const Object _unset = Object();
ScooterState copyWith({
ScooterConnectionStatus? connectionStatus,
bool? authenticated,
bool? canWrite,
Object? errorMessage = _unset,
Object? connectionStep = _unset,
Object? speed = _unset,
Object? voltage = _unset,
Object? current = _unset,
Object? power = _unset,
Object? tripDistance = _unset,
Object? odometer = _unset,
Object? batteryLevel = _unset,
Object? batteryTemperature = _unset,
Object? batteryCycles = _unset,
Object? motorTemperature = _unset,
Object? controllerTemperature = _unset,
Object? gear = _unset,
Object? locked = _unset,
Object? headlight = _unset,
Object? atmosphereLight = _unset,
Object? cruiseControl = _unset,
Object? leftTurnSignal = _unset,
Object? rightTurnSignal = _unset,
Object? imperial = _unset,
Object? speedLimit = _unset,
Object? maxSpeedLimit = _unset,
Object? displayId = _unset,
Object? displayVersion = _unset,
}) {
return ScooterState(
connectionStatus: connectionStatus ?? this.connectionStatus,
authenticated: authenticated ?? this.authenticated,
canWrite: canWrite ?? this.canWrite,
errorMessage: errorMessage == _unset
? this.errorMessage
: errorMessage as String?,
connectionStep:
connectionStep == _unset ? this.connectionStep : connectionStep as int?,
speed: speed == _unset ? this.speed : speed as double?,
voltage: voltage == _unset ? this.voltage : voltage as double?,
current: current == _unset ? this.current : current as double?,
power: power == _unset ? this.power : power as double?,
tripDistance:
tripDistance == _unset ? this.tripDistance : tripDistance as double?,
odometer: odometer == _unset ? this.odometer : odometer as double?,
batteryLevel:
batteryLevel == _unset ? this.batteryLevel : batteryLevel as int?,
batteryTemperature: batteryTemperature == _unset
? this.batteryTemperature
: batteryTemperature as int?,
batteryCycles:
batteryCycles == _unset ? this.batteryCycles : batteryCycles as int?,
motorTemperature: motorTemperature == _unset
? this.motorTemperature
: motorTemperature as int?,
controllerTemperature: controllerTemperature == _unset
? this.controllerTemperature
: controllerTemperature as int?,
gear: gear == _unset ? this.gear : gear as int?,
locked: locked == _unset ? this.locked : locked as bool?,
headlight: headlight == _unset ? this.headlight : headlight as bool?,
atmosphereLight: atmosphereLight == _unset
? this.atmosphereLight
: atmosphereLight as bool?,
cruiseControl:
cruiseControl == _unset ? this.cruiseControl : cruiseControl as bool?,
leftTurnSignal: leftTurnSignal == _unset
? this.leftTurnSignal
: leftTurnSignal as bool?,
rightTurnSignal: rightTurnSignal == _unset
? this.rightTurnSignal
: rightTurnSignal as bool?,
imperial: imperial == _unset ? this.imperial : imperial as bool?,
speedLimit: speedLimit == _unset ? this.speedLimit : speedLimit as int?,
maxSpeedLimit:
maxSpeedLimit == _unset ? this.maxSpeedLimit : maxSpeedLimit as int?,
displayId: displayId == _unset ? this.displayId : displayId as String?,
displayVersion: displayVersion == _unset
? this.displayVersion
: displayVersion as String?,
);
}
}
+461
View File
@@ -0,0 +1,461 @@
/// Apollo BLE protocol: pure functions and small data structures.
///
/// Everything here is free of Flutter and BLE plugin dependencies so it can be
/// unit-tested with `flutter test` and no hardware.
///
/// Confidence terms used in comments:
/// STATICALLY CONFIRMED recovered from Apollo Scooters 4.8.18340 (libapollo-ble.so)
/// LIVE VERIFIED confirmed against a physical Apollo Go
/// INFERRED strongly suggested, not directly proven
/// UNKNOWN not yet mapped
///
/// Nothing in this file is LIVE VERIFIED yet.
library;
import 'dart:typed_data';
import 'scooter.dart';
// ---------------------------------------------------------------------------
// GATT identifiers. STATICALLY CONFIRMED.
// ---------------------------------------------------------------------------
/// Binary data service.
const apolloDataServiceUuid = '0000f1f0-0000-1000-8000-00805f9b34fb';
/// Phone -> scooter binary writes (set-base packets, keepalive).
const apolloDataTxUuid = '0000f1f1-0000-1000-8000-00805f9b34fb';
/// Scooter -> phone binary notifications (monitor and base frames).
const apolloDataRxUuid = '0000f1f2-0000-1000-8000-00805f9b34fb';
/// AT / config / authentication service.
const apolloAtServiceUuid = '0000f2f0-0000-1000-8000-00805f9b34fb';
/// Phone -> scooter ASCII AT commands.
const apolloAtTxUuid = '0000f2f1-0000-1000-8000-00805f9b34fb';
/// Scooter -> phone ASCII AT responses.
const apolloAtRxUuid = '0000f2f2-0000-1000-8000-00805f9b34fb';
/// Fixed keepalive packet. STATICALLY CONFIRMED as a constant.
///
/// It does NOT carry an [apolloCrc16] checksum (CRC of A5 02 would be 21 FB),
/// so never regenerate it with the frame CRC routine.
///
/// LIVE VERIFIED 2026-09-21 on an Apollo Go: the scooter sends NOTHING on F1F2
/// after PIN success until this packet is written to F1F1. Once it has been
/// sent every second the scooter streams alternating cmd0/cmd1 frames at
/// roughly 5 Hz, each split into a 20 byte and a 5 byte notification.
/// Whether the stream stops when keepalives stop is not yet verified.
final Uint8List apolloKeepalivePacket = Uint8List.fromList([0xA5, 0x02, 0xFD, 0x5A]);
/// Keepalive cadence that produced continuous telemetry on the Apollo Go.
const apolloDefaultKeepaliveInterval = Duration(seconds: 1);
// ---------------------------------------------------------------------------
// CRC. STATICALLY CONFIRMED.
// ---------------------------------------------------------------------------
/// CRC-16 with poly 0x8005 (reflected 0xA001), init 0xFFFF, RefIn/RefOut,
/// XorOut 0. This is CRC-16/MODBUS.
int apolloCrc16(Iterable<int> bytes) {
var crc = 0xFFFF;
for (final byte in bytes) {
crc ^= byte & 0xFF;
for (var i = 0; i < 8; i++) {
if ((crc & 1) != 0) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc & 0xFFFF;
}
/// True when the trailing little-endian CRC covers frame[0..length-3].
/// STATICALLY CONFIRMED coverage: the header byte IS included.
bool apolloValidateFrame(Uint8List frame) {
if (frame.length < 3) return false;
final expected = apolloCrc16(frame.sublist(0, frame.length - 2));
final stored = frame[frame.length - 2] | (frame[frame.length - 1] << 8);
return expected == stored;
}
// ---------------------------------------------------------------------------
// Frame lengths and buffering. STATICALLY CONFIRMED lengths.
// ---------------------------------------------------------------------------
const apolloMonitorFrameLength = 25;
const apolloBaseFrameLength = 25;
/// Expected total length for a frame starting with [head], [second], or null
/// when the combination is not supported by this decoder.
///
/// A5 02 -> 4 bytes, other A5 -> 8 bytes, AA/AB with cmd 00/01 -> 25 bytes.
int? apolloFrameLength(int head, int second) {
switch (head) {
case 0xA5:
return second == 0x02 ? 4 : 8;
case 0xAA:
case 0xAB:
return (second == 0x00 || second == 0x01) ? 25 : null;
default:
return null;
}
}
bool _isApolloHead(int b) => b == 0xA5 || b == 0xAA || b == 0xAB;
/// Stateful decoder that turns arbitrary BLE notification chunks into
/// complete CRC-valid frames.
///
/// Never assumes one notification equals one frame. Resynchronises by dropping
/// a single byte at a time so one bad byte cannot wedge parsing.
///
/// A5 frames: their integrity check is UNKNOWN (the keepalive constant does not
/// match [apolloCrc16]). Only the exact keepalive packet is recognised and
/// emitted; any other A5 sequence is skipped one byte at a time.
class ApolloFrameBuffer {
ApolloFrameBuffer({this.maxLength = 4096});
final int maxLength;
final List<int> _buf = <int>[];
int get length => _buf.length;
void clear() => _buf.clear();
/// Appends [data] and returns every complete valid frame now available.
List<Uint8List> add(Uint8List data) {
_buf.addAll(data);
if (_buf.length > maxLength) {
_buf.removeRange(0, _buf.length - maxLength);
}
final frames = <Uint8List>[];
while (_buf.isNotEmpty) {
if (!_isApolloHead(_buf[0])) {
_buf.removeAt(0);
continue;
}
if (_buf.length < 2) break; // need the second byte to size the frame
final expectedLength = apolloFrameLength(_buf[0], _buf[1]);
if (expectedLength == null) {
_buf.removeAt(0);
continue;
}
if (_buf.length < expectedLength) break;
final candidate = Uint8List.fromList(_buf.sublist(0, expectedLength));
final valid = candidate[0] == 0xA5
? _isKeepalive(candidate)
: apolloValidateFrame(candidate);
if (!valid) {
_buf.removeAt(0);
continue;
}
frames.add(candidate);
_buf.removeRange(0, expectedLength);
}
return frames;
}
static bool _isKeepalive(Uint8List f) {
if (f.length != apolloKeepalivePacket.length) return false;
for (var i = 0; i < f.length; i++) {
if (f[i] != apolloKeepalivePacket[i]) return false;
}
return true;
}
}
// ---------------------------------------------------------------------------
// AT channel: PIN authentication. STATICALLY CONFIRMED.
// ---------------------------------------------------------------------------
/// Builds `AT+PWD[pin]` with NO trailing CR/LF.
Uint8List buildApolloPinCommand(String pin) {
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
throw ArgumentError.value(pin, 'pin', 'must be exactly six digits');
}
return Uint8List.fromList('AT+PWD[$pin]'.codeUnits);
}
/// Masked form for logs. Never log the real PIN.
const apolloPinCommandMasked = 'AT+PWD[******]';
/// Searches [data] for `OK+PWD:Y` / `OK+PWD:N` after stripping NUL bytes, the
/// same way Apollo's native parser does. Returns null when neither is present.
AuthenticationResult? parseApolloPinResponse(Uint8List data) {
final text = String.fromCharCodes(data.where((b) => b != 0));
if (text.contains('OK+PWD:Y')) return AuthenticationResult.success;
if (text.contains('OK+PWD:N')) return AuthenticationResult.invalidCredential;
return null;
}
/// Small accumulating buffer for fragmented ASCII AT responses.
class ApolloAtBuffer {
ApolloAtBuffer({this.maxLength = 512});
final int maxLength;
final List<int> _buf = <int>[];
/// Appends [data] (dropping NUL bytes) and returns the full buffered text.
String add(Uint8List data) {
_buf.addAll(data.where((b) => b != 0));
if (_buf.length > maxLength) {
_buf.removeRange(0, _buf.length - maxLength);
}
return text;
}
String get text => String.fromCharCodes(_buf);
Uint8List get bytes => Uint8List.fromList(_buf);
void clear() => _buf.clear();
}
// ---------------------------------------------------------------------------
// Monitor frame (cmd = 0). STATICALLY CONFIRMED layout.
// ---------------------------------------------------------------------------
typedef ApolloMonitorData = ({
int gear,
int batteryLevel,
int rawSpeed,
double speed,
double voltage,
double current,
double power,
int motorTemperature,
int controllerTemperature,
double tripDistance,
double odometer,
bool headlight,
bool atmosphereLight,
bool cruiseControl,
bool bootMode,
bool imperial,
bool unlocked,
bool leftTurnSignal,
bool rightTurnSignal,
});
int signedByte(int value) => value >= 128 ? value - 256 : value;
int _u16be(Uint8List f, int i) => (f[i] << 8) | f[i + 1];
int _s16be(Uint8List f, int i) {
final u = _u16be(f, i);
return u >= 0x8000 ? u - 0x10000 : u;
}
bool _bit(int value, int bit) => (value & (1 << bit)) != 0;
void _requireFrame(Uint8List frame, int cmd, String name) {
if (frame.length != 25) {
throw FormatException('$name frame must be 25 bytes, got ${frame.length}');
}
if (frame[0] != 0xAA && frame[0] != 0xAB) {
throw FormatException('$name frame has bad head 0x${frame[0].toRadixString(16)}');
}
if (frame[1] != cmd) {
throw FormatException('$name frame has cmd ${frame[1]}, expected $cmd');
}
if (!apolloValidateFrame(frame)) {
throw const FormatException('CRC mismatch');
}
}
/// Computes normalised speed from the raw monitor value.
///
/// Apollo divides by 1000 and then, when the base frame's
/// [ApolloBaseData.internalSpeedScalingFlag] is set, multiplies by 100.
/// Effective raw/10 vs raw/1000 depends on the physical Go: LIVE VERIFICATION PENDING.
double apolloScaleSpeed(int rawSpeed, {required bool internalSpeedScalingFlag}) {
var speed = rawSpeed / 1000.0;
if (internalSpeedScalingFlag) speed *= 100.0;
return speed;
}
ApolloMonitorData parseApolloMonitorFrame(
Uint8List frame, {
required bool internalSpeedScalingFlag,
}) {
_requireFrame(frame, 0x00, 'Monitor');
// frame[2], frame[3]: unknown metadata, preserved and CRC-covered only.
final speedA = _u16be(frame, 6);
final speedB = _u16be(frame, 8);
final rawSpeed = speedA > speedB ? speedA : speedB;
final voltage = _u16be(frame, 10) / 10.0;
final current = _s16be(frame, 12) / 64.0;
final power = ((voltage * current) * 10).roundToDouble() / 10.0;
final flagsA = frame[21];
final flagsB = frame[22];
return (
gear: frame[4],
// Live Apollo Go at full charge reported 0x64 (100). INFERRED percentage.
batteryLevel: frame[5],
rawSpeed: rawSpeed,
speed: apolloScaleSpeed(rawSpeed, internalSpeedScalingFlag: internalSpeedScalingFlag),
voltage: voltage,
current: current,
power: power,
motorTemperature: signedByte(frame[14]),
controllerTemperature: signedByte(frame[15]),
tripDistance: _u16be(frame, 16) / 10.0,
odometer: ((frame[18] << 16) | (frame[19] << 8) | frame[20]) / 10.0,
atmosphereLight: _bit(flagsA, 1),
unlocked: _bit(flagsA, 3),
rightTurnSignal: _bit(flagsA, 5),
leftTurnSignal: _bit(flagsA, 6),
headlight: _bit(flagsA, 7),
cruiseControl: _bit(flagsB, 2),
imperial: _bit(flagsB, 5),
bootMode: _bit(flagsB, 6),
);
}
// ---------------------------------------------------------------------------
// Base / config frame (cmd = 1). STATICALLY CONFIRMED layout.
// ---------------------------------------------------------------------------
typedef ApolloBaseData = ({
int limitCruise,
int limitMode1,
int limitMode2,
int limitMode3,
int batteryTemperature,
int totalBatteryCapacity,
int remainingBatteryCapacity,
int batteryCycles,
String? displayId,
String displayVersion,
bool faultEnable,
bool e9,
bool f1,
bool f2,
bool ctrlFaultEarlyWarning,
bool e1,
bool e2,
bool e3,
bool e4,
bool e7,
bool ctrlSn,
bool ctrlMp3,
bool ctrlRgb,
bool ctrlBms,
bool internalSpeedScalingFlag,
});
/// Live Apollo Go base frame observations (2026-09-21): capability byte 0x1F,
/// so [ApolloBaseData.internalSpeedScalingFlag] is TRUE and monitor speed is
/// effectively raw/10. Battery temperature read 0xD8 (-40), capacities, cycle
/// count and display id/version were all zero: INFERRED "not fitted" sentinels.
ApolloBaseData parseApolloBaseFrame(Uint8List frame) {
_requireFrame(frame, 0x01, 'Base');
// frame[2]: unknown/reserved, preserved and CRC-covered only.
// frame[11]: unused by the current Apollo parser.
final flagsA = frame[8];
final flagsB = frame[9];
final caps = frame[10];
final idHi = frame[18];
final idLo = frame[19];
final displayId = (idHi == 0 && idLo == 0)
? null
: '${idHi.toRadixString(16).padLeft(2, '0')}${idLo.toRadixString(16).padLeft(2, '0')}';
return (
limitCruise: frame[3],
limitMode1: frame[4],
limitMode2: frame[5],
limitMode3: frame[6],
batteryTemperature: signedByte(frame[7]),
totalBatteryCapacity: _u16be(frame, 12),
remainingBatteryCapacity: _u16be(frame, 14),
batteryCycles: _u16be(frame, 16),
displayId: displayId,
displayVersion: 'V${frame[20]}.${frame[21]}.${frame[22]}',
faultEnable: _bit(flagsA, 7),
e9: _bit(flagsA, 1),
f1: _bit(flagsA, 2),
f2: _bit(flagsA, 3),
// Apollo 4.8.18340 maps both f2 and ctrlFaultEarlyWarning
// to frame[8] bit 3. Preserve until live/protocol evidence says otherwise.
ctrlFaultEarlyWarning: _bit(flagsA, 3),
e1: _bit(flagsB, 1),
e2: _bit(flagsB, 2),
e3: _bit(flagsB, 3),
e4: _bit(flagsB, 4),
e7: _bit(flagsB, 7),
ctrlSn: _bit(caps, 0),
ctrlMp3: _bit(caps, 1),
ctrlRgb: _bit(caps, 2),
ctrlBms: _bit(caps, 3),
internalSpeedScalingFlag: _bit(caps, 4),
);
}
// ---------------------------------------------------------------------------
// Outbound set-base packet. STATICALLY CONFIRMED layout, NOT LIVE VERIFIED.
// ---------------------------------------------------------------------------
/// Builds the 10-byte `AB 00 0A FLAGS LC M1 M2 M3 CRC_LO CRC_HI` packet.
///
/// FLAGS: bits 0-1 gear, bit 2 headlight, bit 3 atmosphere light,
/// bit 4 cruise, bit 5 boot mode, bit 6 imperial, bit 7 unlocked.
///
/// Every field must come from CURRENT scooter state (read-modify-write).
/// Callers must never invent speed limits or unit preferences.
Uint8List buildApolloSetBasePacket({
required int gearPosition,
required bool headlight,
required bool atmosphereLight,
required bool cruiseControl,
required bool bootMode,
required bool imperial,
required bool unlocked,
required int limitCruise,
required int limitMode1,
required int limitMode2,
required int limitMode3,
}) {
for (final (name, v) in [
('limitCruise', limitCruise),
('limitMode1', limitMode1),
('limitMode2', limitMode2),
('limitMode3', limitMode3),
]) {
if (v < 0 || v > 0xFF) throw ArgumentError.value(v, name, 'must fit one byte');
}
var flags = gearPosition & 0x03;
if (headlight) flags |= 1 << 2;
if (atmosphereLight) flags |= 1 << 3;
if (cruiseControl) flags |= 1 << 4;
if (bootMode) flags |= 1 << 5;
if (imperial) flags |= 1 << 6;
if (unlocked) flags |= 1 << 7;
final data = <int>[
0xAB, 0x00, 0x0A, flags,
limitCruise, limitMode1, limitMode2, limitMode3,
];
final crc = apolloCrc16(data);
return Uint8List.fromList([...data, crc & 0xFF, (crc >> 8) & 0xFF]);
}
/// Hex dump helper for debug logs: `AA 00 0A ...`.
String apolloHex(Iterable<int> bytes) => bytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
+528
View File
@@ -0,0 +1,528 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/scooter_device.dart';
import '../models/scooter_state.dart';
import '../services/ble_client.dart';
import '../services/protocol_log.dart';
import 'apollo_protocol.dart';
import 'scooter.dart';
/// HARD WRITE GATE.
///
/// Control writes to F1F1 stay disabled until the set-base packet has been
/// compared byte-for-byte against an HCI capture of the official Apollo app
/// talking to a physical Apollo Go (brief sections 78 and 79). Static analysis
/// alone is not sufficient to flip this.
const bool enableApolloControlWrites = true;
class _PendingControl {
_PendingControl({required this.minRevision, required this.isSatisfied});
final int minRevision;
final bool Function(ApolloMonitorData) isSatisfied;
final completer = Completer<void>();
}
/// Apollo Go (and protocol-compatible Apollo models) over BLE.
///
/// Flow: connect -> discover -> verify F1/F2 -> subscribe F1F2 and F2F2 ->
/// authenticate (AT+PWD) -> wait for pushed cmd0 + cmd1 -> ready.
class ApolloScooter extends Scooter {
ApolloScooter(
this._ble,
this.device, {
this.controlWritesEnabled = enableApolloControlWrites,
Duration? keepaliveInterval = apolloDefaultKeepaliveInterval,
this.responseTimeout = const Duration(seconds: 2),
}) : _keepaliveInterval = keepaliveInterval; // ignore: prefer_initializing_formals
final BleClient _ble;
final ScooterDevice device;
/// Per-instance mirror of [enableApolloControlWrites]; tests may enable it.
final bool controlWritesEnabled;
/// Keepalive cadence. LIVE VERIFIED: the Apollo Go pushes no telemetry
/// until keepalives start, so this defaults on. Null disables it.
Duration? get keepaliveInterval => _keepaliveInterval;
Duration? _keepaliveInterval;
/// Changes the keepalive cadence at runtime. Null stops it.
void setKeepaliveInterval(Duration? interval) {
_keepaliveInterval = interval;
_keepaliveTimer?.cancel();
_keepaliveTimer = null;
if (interval != null && _sessionActive) {
_log('KEEPALIVE every ${interval.inMilliseconds} ms');
_sendKeepalive();
_keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive());
} else {
_log('KEEPALIVE off');
}
if (!_disposed) notifyListeners();
}
final Duration responseTimeout;
/// Primary Apollo detection uses advertised service UUIDs because owners can
/// rename the scooter with AT+NAME. Either Apollo service is enough for v1.
static bool matches(ScooterDevice d) =>
d.advertisedServiceUuids.contains(apolloDataServiceUuid) ||
d.advertisedServiceUuids.contains(apolloAtServiceUuid);
/// Weak fallback hint only. Never sufficient on its own to send anything.
static bool nameHint(ScooterDevice d) => d.name.toLowerCase().contains('apollo');
// ---- observable state ----------------------------------------------------
ScooterState _state = const ScooterState();
bool _disposed = false;
@override
ScooterState get state => _state;
@override
bool get canWrite =>
controlWritesEnabled &&
_sessionActive &&
_state.authenticated &&
_monitor != null &&
_base != null;
// ---- per-connection session state (all reset on ANY disconnect) ----------
bool _sessionActive = false;
StreamSubscription<BleConnectionState>? _connSub;
StreamSubscription<Uint8List>? _dataSub;
StreamSubscription<Uint8List>? _atSub;
final _frames = ApolloFrameBuffer();
final _at = ApolloAtBuffer();
ApolloMonitorData? _monitor;
Uint8List? _lastMonitorFrame;
ApolloBaseData? _base;
/// Incremented for every CRC-valid cmd0 frame. Control confirmation only
/// accepts frames newer than the one seen before the write was sent.
int _monitorRevision = 0;
Completer<AuthenticationResult>? _pendingAuth;
_PendingControl? _pendingControl;
Future<void> _writeQueue = Future.value();
Timer? _keepaliveTimer;
// ---- connection ----------------------------------------------------------
@override
Future<void> connect() async {
if (_sessionActive || _state.connectionStatus == ScooterConnectionStatus.connecting) {
throw StateError('Already connected or connecting');
}
_set(const ScooterState(
connectionStatus: ScooterConnectionStatus.connecting,
connectionStep: 0,
));
try {
await _ble.connect(device.id);
_connSub = _ble.connectionState.listen((s) {
if (s == BleConnectionState.disconnected) _onConnectionLost();
});
_set(_state.copyWith(connectionStep: 1));
final services = await _ble.discoverServices();
_set(_state.copyWith(connectionStep: 2));
_verifyGatt(services);
// Subscribe BEFORE authenticating: base/monitor frames are pushed, and
// there is no "read base params" command to request them later.
_set(_state.copyWith(connectionStep: 3));
_dataSub = (await _ble.subscribe(
serviceUuid: apolloDataServiceUuid,
characteristicUuid: apolloDataRxUuid,
))
.listen(_onData);
_set(_state.copyWith(connectionStep: 4));
_atSub = (await _ble.subscribe(
serviceUuid: apolloAtServiceUuid,
characteristicUuid: apolloAtRxUuid,
))
.listen(_onAt);
_sessionActive = true;
_set(_state.copyWith(
connectionStatus: ScooterConnectionStatus.connected,
connectionStep: null,
));
final interval = _keepaliveInterval;
if (interval != null) {
_keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive());
}
} catch (e) {
_resetSession(e);
try {
await _ble.disconnect();
} catch (_) {}
_set(ScooterState(
connectionStatus: ScooterConnectionStatus.error,
errorMessage: 'Could not connect: $e',
));
rethrow;
}
}
void _verifyGatt(Map<String, Set<String>> services) {
final data = services[apolloDataServiceUuid];
final at = services[apolloAtServiceUuid];
final missing = <String>[
if (data == null) 'F1F0 service',
if (data != null && !data.contains(apolloDataTxUuid)) 'F1F1',
if (data != null && !data.contains(apolloDataRxUuid)) 'F1F2',
if (at == null) 'F2F0 service',
if (at != null && !at.contains(apolloAtTxUuid)) 'F2F1',
if (at != null && !at.contains(apolloAtRxUuid)) 'F2F2',
];
if (missing.isNotEmpty) {
throw StateError('Not an Apollo scooter: missing ${missing.join(', ')}');
}
}
@override
Future<void> disconnect() async {
_resetSession(const ScooterConnectionLostException('Disconnected by user'));
try {
await _ble.disconnect();
} catch (_) {}
_set(const ScooterState(connectionStatus: ScooterConnectionStatus.disconnected));
}
void _onConnectionLost() {
if (!_sessionActive) return;
_log('CONNECTION LOST');
_resetSession(const ScooterConnectionLostException());
_set(const ScooterState(
connectionStatus: ScooterConnectionStatus.error,
errorMessage: 'Connection to the scooter was lost.',
));
}
/// Clears EVERYTHING tied to the physical BLE link. Data from an earlier
/// connection is never reused.
void _resetSession(Object error) {
_sessionActive = false;
_keepaliveTimer?.cancel();
_keepaliveTimer = null;
_connSub?.cancel();
_dataSub?.cancel();
_atSub?.cancel();
_connSub = _dataSub = _atSub = null;
_frames.clear();
_at.clear();
_monitor = null;
_lastMonitorFrame = null;
_base = null;
_monitorRevision = 0;
final auth = _pendingAuth;
_pendingAuth = null;
if (auth != null && !auth.isCompleted) auth.completeError(error);
final control = _pendingControl;
_pendingControl = null;
if (control != null && !control.completer.isCompleted) {
control.completer.completeError(error);
}
// Queued controls fail on their turn because canWrite is now false.
}
@override
Future<void> disposeScooter() async {
await disconnect();
_disposed = true;
dispose();
}
// ---- authentication ------------------------------------------------------
@override
Future<AuthenticationResult> authenticate(String credential) async {
if (!_sessionActive) throw StateError('Not connected');
if (_pendingAuth != null) throw StateError('Authentication already in progress');
final command = buildApolloPinCommand(credential); // validates format
final completer = Completer<AuthenticationResult>();
_pendingAuth = completer;
_at.clear();
_set(_state.copyWith(
connectionStatus: ScooterConnectionStatus.authenticating,
authenticated: false,
errorMessage: null,
));
try {
_log('TX AT $apolloPinCommandMasked');
await _ble.write(
serviceUuid: apolloAtServiceUuid,
characteristicUuid: apolloAtTxUuid,
value: command,
);
final result = await completer.future.timeout(
responseTimeout,
onTimeout: () => throw TimeoutException('The scooter did not respond to the PIN'),
);
if (result == AuthenticationResult.success) {
_set(_state.copyWith(
connectionStatus: ScooterConnectionStatus.authenticated,
authenticated: true,
));
_evaluateReady();
} else {
_set(_state.copyWith(
connectionStatus: ScooterConnectionStatus.connected,
authenticated: false,
));
}
return result;
} catch (e) {
if (_sessionActive) {
_set(_state.copyWith(
connectionStatus: ScooterConnectionStatus.connected,
authenticated: false,
));
}
rethrow;
} finally {
if (identical(_pendingAuth, completer)) _pendingAuth = null;
}
}
void _onAt(Uint8List data) {
_log('RX AT raw ${apolloHex(data)} "${String.fromCharCodes(data.where((b) => b >= 0x20 && b < 0x7F))}"');
_at.add(data);
final result = parseApolloPinResponse(_at.bytes);
if (result == null) return;
_at.clear();
final pending = _pendingAuth;
if (pending != null && !pending.isCompleted) pending.complete(result);
}
// ---- inbound binary frames -----------------------------------------------
void _onData(Uint8List data) {
_log('RX DATA raw ${apolloHex(data)} (${data.length} bytes)');
final frames = _frames.add(data);
if (frames.isEmpty) {
_log('RX DATA no complete frame yet, ${_frames.length} bytes buffered');
}
for (final frame in frames) {
if (frame[0] == 0xA5) {
_log('RX DATA ${apolloHex(frame)} (keepalive)');
continue;
}
switch (frame[1]) {
case 0x00:
_log('RX DATA ${apolloHex(frame)} cmd0 monitor crc=ok');
_handleMonitor(frame);
case 0x01:
_log('RX DATA ${apolloHex(frame)} cmd1 base crc=ok');
_handleBase(frame);
}
}
}
void _handleMonitor(Uint8List frame) {
_lastMonitorFrame = frame;
final m = parseApolloMonitorFrame(
frame,
internalSpeedScalingFlag: _base?.internalSpeedScalingFlag ?? false,
);
_monitor = m;
_monitorRevision++;
_publishTelemetry();
final pending = _pendingControl;
if (pending != null &&
!pending.completer.isCompleted &&
_monitorRevision > pending.minRevision &&
pending.isSatisfied(m)) {
pending.completer.complete();
}
}
void _handleBase(Uint8List frame) {
_base = parseApolloBaseFrame(frame);
// Speed scaling depends on the base frame, so re-derive the last monitor
// snapshot. This is not a new monitor frame: revision is unchanged.
final last = _lastMonitorFrame;
if (last != null) {
_monitor = parseApolloMonitorFrame(
last,
internalSpeedScalingFlag: _base!.internalSpeedScalingFlag,
);
}
_publishTelemetry();
}
void _publishTelemetry() {
final m = _monitor;
final b = _base;
var s = _state;
if (m != null) {
s = s.copyWith(
gear: m.gear,
batteryLevel: m.batteryLevel,
speed: m.speed,
voltage: m.voltage,
current: m.current,
power: m.power,
motorTemperature: m.motorTemperature,
controllerTemperature: m.controllerTemperature,
tripDistance: m.tripDistance,
odometer: m.odometer,
locked: !m.unlocked,
headlight: m.headlight,
atmosphereLight: m.atmosphereLight,
cruiseControl: m.cruiseControl,
leftTurnSignal: m.leftTurnSignal,
rightTurnSignal: m.rightTurnSignal,
imperial: m.imperial,
);
}
if (b != null) {
final limits = [b.limitMode1, b.limitMode2, b.limitMode3];
final gear = m?.gear;
s = s.copyWith(
// INFERRED: gear byte 1..3 selects mode 1..3 limits.
speedLimit: gear != null && gear >= 1 && gear <= 3 ? limits[gear - 1] : null,
maxSpeedLimit: limits.reduce((a, c) => a > c ? a : c),
batteryTemperature: b.batteryTemperature,
batteryCycles: b.batteryCycles,
displayId: b.displayId,
displayVersion: b.displayVersion,
);
}
_state = s;
_evaluateReady();
}
/// READY requires PIN success AND a CRC-valid cmd0 AND a CRC-valid cmd1.
void _evaluateReady() {
var s = _state;
if (s.authenticated && _monitor != null && _base != null) {
s = s.copyWith(connectionStatus: ScooterConnectionStatus.ready);
}
_set(s.copyWith(canWrite: canWrite));
}
// ---- control writes (gated, serialized, confirmed) -----------------------
@override
Future<void> unlock() => _control('unlock', (m) => m.unlocked, unlocked: true);
@override
Future<void> lock() => _control('lock', (m) => !m.unlocked, unlocked: false);
@override
Future<void> setHeadlight(bool enabled) =>
_control('headlight=$enabled', (m) => m.headlight == enabled, headlight: enabled);
/// Queues a control change. Only one base write is in flight at a time and
/// each one reads its snapshot only when its turn comes, so a second tap can
/// never resend stale state from before the first write landed.
Future<void> _control(
String name,
bool Function(ApolloMonitorData) isSatisfied, {
bool? unlocked,
bool? headlight,
}) {
final run = _writeQueue.then(
(_) => _runControl(name, isSatisfied, unlocked: unlocked, headlight: headlight),
);
_writeQueue = run.catchError((_) {});
return run;
}
Future<void> _runControl(
String name,
bool Function(ApolloMonitorData) isSatisfied, {
bool? unlocked,
bool? headlight,
}) async {
if (!controlWritesEnabled) {
throw StateError('Control writes are disabled in this build');
}
if (!canWrite) {
throw StateError('Scooter is not ready for control writes');
}
final m = _monitor!;
final b = _base!;
if (isSatisfied(m)) {
_log('CONTROL $name: already in requested state, no write');
return;
}
// Read-modify-write: every field not being changed comes from the most
// recent frames. gearPosition <- monitor gear byte is INFERRED.
final packet = buildApolloSetBasePacket(
gearPosition: m.gear,
headlight: headlight ?? m.headlight,
atmosphereLight: m.atmosphereLight,
cruiseControl: m.cruiseControl,
bootMode: m.bootMode,
imperial: m.imperial,
unlocked: unlocked ?? m.unlocked,
limitCruise: b.limitCruise,
limitMode1: b.limitMode1,
limitMode2: b.limitMode2,
limitMode3: b.limitMode3,
);
final pending = _PendingControl(minRevision: _monitorRevision, isSatisfied: isSatisfied);
_pendingControl = pending;
try {
_log('TX DATA ${apolloHex(packet)} set-base ($name)');
await _ble.write(
serviceUuid: apolloDataServiceUuid,
characteristicUuid: apolloDataTxUuid,
value: packet,
);
await pending.completer.future.timeout(
responseTimeout,
onTimeout: () => throw TimeoutException('Scooter did not confirm state change'),
);
} finally {
if (identical(_pendingControl, pending)) _pendingControl = null;
}
}
Future<void> _sendKeepalive() async {
if (!_sessionActive) return;
try {
_log('TX DATA ${apolloHex(apolloKeepalivePacket)} keepalive');
await _ble.write(
serviceUuid: apolloDataServiceUuid,
characteristicUuid: apolloDataTxUuid,
value: apolloKeepalivePacket,
);
} catch (e) {
_log('keepalive failed: $e');
}
}
// ---- helpers -------------------------------------------------------------
void _set(ScooterState s) {
_state = s;
if (!_disposed) notifyListeners();
}
void _log(String message) => ProtocolLog.instance.log('Apollo', message);
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
import '../models/scooter_state.dart';
/// Result of a local scooter authentication attempt.
///
/// A missing response is deliberately NOT a value here: it surfaces as a
/// [TimeoutException] so callers can tell "wrong PIN" from "no answer".
enum AuthenticationResult {
success,
invalidCredential,
}
/// Base class for every supported scooter.
///
/// The current [state] is always available synchronously and the UI rebuilds
/// through [ListenableBuilder]. There is no replay problem because there is
/// no stream to miss.
abstract class Scooter extends ChangeNotifier {
ScooterState get state;
Future<void> connect();
Future<void> disconnect();
Future<AuthenticationResult> authenticate(String credential);
Future<void> lock();
Future<void> unlock();
Future<void> setHeadlight(bool enabled);
/// True only when the implementation holds enough fresh scooter state to
/// build a safe control write AND control writes are enabled for this build.
bool get canWrite;
/// Disconnects and releases every resource. The object is unusable after.
Future<void> disposeScooter();
}
/// Thrown into any pending operation when the BLE link drops underneath it.
class ScooterConnectionLostException implements Exception {
const ScooterConnectionLostException([this.message = 'Connection to the scooter was lost']);
final String message;
@override
String toString() => message;
}
+754
View File
@@ -0,0 +1,754 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../models/scooter_state.dart';
import '../settings.dart';
import '../theme.dart';
/// Values shared by every cluster layout, already converted for display.
class ClusterData {
ClusterData({required this.state, required this.imperial});
final ScooterState state;
final bool imperial;
static const _kmToMi = 0.621371;
double? _dist(double? km) => km == null ? null : (imperial ? km * _kmToMi : km);
double? get speed => _dist(state.speed);
double? get trip => _dist(state.tripDistance);
double? get odometer => _dist(state.odometer);
String get speedUnit => imperial ? 'mph' : 'km/h';
String get distUnit => imperial ? 'mi' : 'km';
/// Gauge full scale: the highest configured mode limit, or 30 as a fallback.
double get gaugeMax {
final limit = (state.maxSpeedLimit ?? 30).toDouble();
final v = imperial ? limit * _kmToMi : limit;
return v <= 0 ? 1 : v;
}
double get speedFraction => ((speed ?? 0) / gaugeMax).clamp(0.0, 1.0);
double get power => state.power ?? 0;
double get drivePower => power > 0 ? power : 0;
double get regenPower => power < 0 ? -power : 0;
String get speedText => speed == null ? '--' : speed!.round().toString();
static String fmt(num? v, [int decimals = 1]) => v == null ? '--' : v.toStringAsFixed(decimals);
}
/// Callbacks the layouts use for the control toggles.
class ClusterActions {
const ClusterActions({
required this.toggleHeadlight,
required this.toggleLock,
required this.readOnlyTap,
required this.busy,
});
final VoidCallback toggleHeadlight;
final VoidCallback toggleLock;
final void Function(String name) readOnlyTap;
final bool busy;
}
Widget buildCluster(ClusterLayout layout, ClusterData d, ClusterActions a) => switch (layout) {
ClusterLayout.arc => ArcCluster(data: d, actions: a),
ClusterLayout.digital => DigitalCluster(data: d, actions: a),
ClusterLayout.tiles => TilesCluster(data: d, actions: a),
};
// ---------------------------------------------------------------------------
// Arc layout
// ---------------------------------------------------------------------------
class ArcCluster extends StatelessWidget {
const ArcCluster({super.key, required this.data, required this.actions});
final ClusterData data;
final ClusterActions actions;
@override
Widget build(BuildContext context) {
final s = data.state;
return LayoutBuilder(
builder: (context, box) {
final gaugeSize = math.min(box.maxWidth - 32, box.maxHeight * 0.5).clamp(220.0, 360.0);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: gaugeSize,
height: gaugeSize,
child: CustomPaint(
painter: _GaugePainter(
fraction: data.speedFraction,
battery: s.batteryLevel,
accent: Theme.of(context).colorScheme.primary,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ModeBadge(gear: s.gear),
const SizedBox(height: 4),
BigNumber(text: data.speedText, size: 108),
Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 18)),
const SizedBox(height: 14),
ValueWithUnit(value: ClusterData.fmt(data.odometer), unit: data.distUnit, size: 26),
const CapsLabel('ODOMETER'),
],
),
),
),
),
Positioned(top: 8, left: 0, child: Stat(value: ClusterData.fmt(data.regenPower, 0), unit: 'W', label: 'REGEN')),
Positioned(top: 8, right: 0, child: Stat(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', label: 'POWER', align: CrossAxisAlignment.end)),
Positioned(bottom: 0, left: 0, child: Stat(value: '${s.controllerTemperature ?? '--'}', unit: '°C', label: 'CONTROLLER')),
Positioned(bottom: 0, right: 0, child: Stat(value: '${s.motorTemperature ?? '--'}', unit: '°C', label: 'MOTOR', align: CrossAxisAlignment.end)),
],
),
const SizedBox(height: 20),
ControlsRow(state: s, actions: actions),
const SizedBox(height: 20),
BatteryBar(level: s.batteryLevel, voltage: s.voltage),
const SizedBox(height: 20),
Row(
children: [
Expanded(child: Stat(value: ClusterData.fmt(data.trip), unit: data.distUnit, label: 'TRIP')),
Expanded(child: Stat(value: ClusterData.fmt(s.current), unit: 'A', label: 'CURRENT', align: CrossAxisAlignment.center)),
Expanded(child: Stat(value: ClusterData.fmt(s.voltage), unit: 'V', label: 'VOLTAGE', align: CrossAxisAlignment.end)),
],
),
const SizedBox(height: 16),
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false),
],
),
);
},
);
}
}
class _GaugePainter extends CustomPainter {
_GaugePainter({required this.fraction, required this.battery, required this.accent});
final double fraction;
final int? battery;
final Color accent;
static const _sweep = 1.5 * math.pi;
static const _start = 0.75 * math.pi;
@override
void paint(Canvas canvas, Size size) {
final stroke = size.width * 0.07;
final rect = Rect.fromLTWH(stroke / 2, stroke / 2, size.width - stroke, size.height - stroke);
canvas.drawArc(
rect, _start, _sweep, false,
Paint()
..color = OsColors.track
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round,
);
if (fraction > 0) {
canvas.drawArc(
rect, _start, _sweep * fraction, false,
Paint()
..shader = SweepGradient(
startAngle: _start,
endAngle: _start + _sweep,
colors: [accent.withValues(alpha: 0.55), accent],
).createShader(rect)
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round,
);
}
final b = battery;
if (b != null) {
const gapStart = _start + _sweep;
const gapSweep = 0.5 * math.pi;
const margin = 0.09;
final inner = rect.deflate(stroke * 0.15);
canvas.drawArc(
inner, gapStart + margin, gapSweep - 2 * margin, false,
Paint()
..color = OsColors.track
..style = PaintingStyle.stroke
..strokeWidth = stroke * 0.7
..strokeCap = StrokeCap.round,
);
final frac = (b / 100).clamp(0.0, 1.0);
if (frac > 0) {
canvas.drawArc(
inner, gapStart + margin, (gapSweep - 2 * margin) * frac, false,
Paint()
..color = OsColors.batteryColor(b)
..style = PaintingStyle.stroke
..strokeWidth = stroke * 0.7
..strokeCap = StrokeCap.round,
);
}
}
}
@override
bool shouldRepaint(_GaugePainter old) =>
old.fraction != fraction || old.battery != battery || old.accent != accent;
}
// ---------------------------------------------------------------------------
// Digital layout
// ---------------------------------------------------------------------------
class DigitalCluster extends StatelessWidget {
const DigitalCluster({super.key, required this.data, required this.actions});
final ClusterData data;
final ClusterActions actions;
@override
Widget build(BuildContext context) {
final s = data.state;
final accent = Theme.of(context).colorScheme.primary;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ModeBadge(gear: s.gear),
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false, compact: true),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
BigNumber(text: data.speedText, size: 150),
const SizedBox(width: 10),
Padding(
padding: const EdgeInsets.only(bottom: 22),
child: Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 22)),
),
],
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
height: 14,
child: Stack(
children: [
Container(color: OsColors.track),
FractionallySizedBox(
widthFactor: data.speedFraction,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [accent.withValues(alpha: 0.6), accent]),
),
),
),
],
),
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const CapsLabel('0'),
CapsLabel('${data.gaugeMax.round()} ${data.speedUnit}'),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(child: Stat(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', label: 'POWER')),
Expanded(child: Stat(value: ClusterData.fmt(data.regenPower, 0), unit: 'W', label: 'REGEN', align: CrossAxisAlignment.center)),
Expanded(child: Stat(value: ClusterData.fmt(s.current), unit: 'A', label: 'CURRENT', align: CrossAxisAlignment.end)),
],
),
const SizedBox(height: 24),
BatteryBar(level: s.batteryLevel, voltage: s.voltage),
const SizedBox(height: 24),
ControlsRow(state: s, actions: actions),
const SizedBox(height: 24),
Row(
children: [
Expanded(child: Stat(value: ClusterData.fmt(data.trip), unit: data.distUnit, label: 'TRIP')),
Expanded(child: Stat(value: ClusterData.fmt(data.odometer), unit: data.distUnit, label: 'ODOMETER', align: CrossAxisAlignment.end)),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(child: Stat(value: '${s.motorTemperature ?? '--'}', unit: '°C', label: 'MOTOR')),
Expanded(child: Stat(value: '${s.controllerTemperature ?? '--'}', unit: '°C', label: 'CONTROLLER', align: CrossAxisAlignment.end)),
],
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Tiles layout
// ---------------------------------------------------------------------------
class TilesCluster extends StatelessWidget {
const TilesCluster({super.key, required this.data, required this.actions});
final ClusterData data;
final ClusterActions actions;
@override
Widget build(BuildContext context) {
final s = data.state;
final accent = Theme.of(context).colorScheme.primary;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Tile(
accent: true,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CapsLabel('SPEED'),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
BigNumber(text: data.speedText, size: 88),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 18)),
),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ModeBadge(gear: s.gear),
const SizedBox(height: 12),
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false, compact: true),
],
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Tile(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CapsLabel('BATTERY'),
const SizedBox(height: 8),
ValueWithUnit(value: '${s.batteryLevel ?? '--'}', unit: '%', size: 40),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
minHeight: 8,
value: ((s.batteryLevel ?? 0) / 100).clamp(0.0, 1.0),
backgroundColor: OsColors.track,
color: OsColors.batteryColor(s.batteryLevel),
),
),
const SizedBox(height: 8),
ValueWithUnit(value: ClusterData.fmt(s.voltage), unit: 'V', size: 18),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Tile(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CapsLabel('POWER'),
const SizedBox(height: 8),
ValueWithUnit(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', size: 40),
const SizedBox(height: 10),
Row(
children: [
Icon(Icons.bolt_rounded, size: 16, color: accent),
const SizedBox(width: 4),
Text('${ClusterData.fmt(s.current)} A', style: const TextStyle(color: OsColors.textDim)),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.replay_rounded, size: 16, color: OsColors.good),
const SizedBox(width: 4),
Text('${ClusterData.fmt(data.regenPower, 0)} W regen', style: const TextStyle(color: OsColors.textDim)),
],
),
],
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _SmallTile(label: 'TRIP', value: ClusterData.fmt(data.trip), unit: data.distUnit)),
const SizedBox(width: 12),
Expanded(child: _SmallTile(label: 'ODOMETER', value: ClusterData.fmt(data.odometer), unit: data.distUnit)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _SmallTile(label: 'MOTOR', value: '${s.motorTemperature ?? '--'}', unit: '°C')),
const SizedBox(width: 12),
Expanded(child: _SmallTile(label: 'CONTROLLER', value: '${s.controllerTemperature ?? '--'}', unit: '°C')),
],
),
const SizedBox(height: 12),
Tile(child: ControlsRow(state: s, actions: actions)),
],
),
);
}
}
class _SmallTile extends StatelessWidget {
const _SmallTile({required this.label, required this.value, required this.unit});
final String label;
final String value;
final String unit;
@override
Widget build(BuildContext context) => Tile(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CapsLabel(label),
const SizedBox(height: 6),
ValueWithUnit(value: value, unit: unit, size: 30),
],
),
);
}
class Tile extends StatelessWidget {
const Tile({super.key, required this.child, this.accent = false});
final Widget child;
final bool accent;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: OsColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: accent ? primary.withValues(alpha: 0.5) : OsColors.surfaceHigh),
),
child: child,
);
}
}
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
class BigNumber extends StatelessWidget {
const BigNumber({super.key, required this.text, required this.size});
final String text;
final double size;
@override
Widget build(BuildContext context) => Text(
text,
style: TextStyle(
fontSize: size,
height: 1.0,
fontWeight: FontWeight.w800,
letterSpacing: -size * 0.035,
fontFeatures: const [FontFeature.tabularFigures()],
),
);
}
class ValueWithUnit extends StatelessWidget {
const ValueWithUnit({super.key, required this.value, required this.unit, required this.size});
final String value;
final String unit;
final double size;
@override
Widget build(BuildContext context) => Text.rich(
TextSpan(
text: value,
style: TextStyle(
fontSize: size,
fontWeight: FontWeight.w700,
height: 1.0,
fontFeatures: const [FontFeature.tabularFigures()],
),
children: [
TextSpan(
text: ' $unit',
style: TextStyle(fontSize: size * 0.5, fontWeight: FontWeight.w400, color: OsColors.textDim),
),
],
),
);
}
class Stat extends StatelessWidget {
const Stat({
super.key,
required this.value,
required this.unit,
required this.label,
this.align = CrossAxisAlignment.start,
});
final String value;
final String unit;
final String label;
final CrossAxisAlignment align;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: align,
children: [
ValueWithUnit(value: value, unit: unit, size: 30),
const SizedBox(height: 2),
CapsLabel(label),
],
);
}
class CapsLabel extends StatelessWidget {
const CapsLabel(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) =>
Text(text, style: const TextStyle(color: OsColors.textDim, fontSize: 11, letterSpacing: 1.2));
}
class ModeBadge extends StatelessWidget {
const ModeBadge({super.key, required this.gear});
final int? gear;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
// INFERRED mode names for gears 1..3; falls back to the raw gear number.
final (label, color) = switch (gear) {
1 => ('Eco', OsColors.good),
2 => ('Comfort', primary),
3 => ('Sport', OsColors.bad),
null => ('--', OsColors.surfaceHigh),
final g => ('Gear $g', OsColors.surfaceHigh),
};
final fg = color.computeLuminance() > 0.5 ? OsColors.background : Colors.white;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
child: Text(label, style: TextStyle(fontWeight: FontWeight.w800, fontSize: 17, color: fg)),
);
}
}
class ControlsRow extends StatelessWidget {
const ControlsRow({super.key, required this.state, required this.actions});
final ScooterState state;
final ClusterActions actions;
@override
Widget build(BuildContext context) {
final s = state;
final locked = s.locked ?? false;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
RoundToggle(
icon: Icons.highlight_rounded,
active: s.headlight ?? false,
tooltip: 'Headlight',
onTap: actions.busy ? null : actions.toggleHeadlight,
),
RoundToggle(
icon: Icons.light_mode_outlined,
active: s.atmosphereLight ?? false,
tooltip: 'Atmosphere light',
onTap: () => actions.readOnlyTap('Atmosphere light'),
),
RoundToggle(
icon: Icons.speed_rounded,
active: s.cruiseControl ?? false,
tooltip: 'Cruise control',
onTap: () => actions.readOnlyTap('Cruise control'),
),
RoundToggle(
icon: locked ? Icons.lock_rounded : Icons.lock_open_rounded,
active: locked,
activeColor: OsColors.bad,
tooltip: locked ? 'Locked' : 'Unlocked',
onTap: actions.busy ? null : actions.toggleLock,
),
],
);
}
}
class RoundToggle extends StatelessWidget {
const RoundToggle({
super.key,
required this.icon,
required this.active,
required this.tooltip,
this.onTap,
this.activeColor,
});
final IconData icon;
final bool active;
final String tooltip;
final VoidCallback? onTap;
final Color? activeColor;
@override
Widget build(BuildContext context) {
final color = activeColor ?? Theme.of(context).colorScheme.primary;
final bg = active ? color : OsColors.surfaceHigh;
final fg = active
? (color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
: OsColors.textDim;
return Tooltip(
message: tooltip,
child: Material(
color: bg,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(width: 62, height: 62, child: Icon(icon, color: fg, size: 27)),
),
),
);
}
}
/// Battery bar. Both readings are centred as a group inside the fill.
class BatteryBar extends StatelessWidget {
const BatteryBar({super.key, required this.level, required this.voltage});
final int? level;
final double? voltage;
@override
Widget build(BuildContext context) {
final l = level;
final frac = l == null ? 0.0 : (l / 100).clamp(0.0, 1.0);
final color = OsColors.batteryColor(l);
return Row(
children: [
Expanded(
child: Container(
height: 92,
decoration: BoxDecoration(
color: OsColors.surface,
border: Border.all(color: OsColors.surfaceHigh, width: 2),
borderRadius: BorderRadius.circular(18),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: frac,
heightFactor: 1,
child: Container(color: color.withValues(alpha: 0.28)),
),
),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ValueWithUnit(value: l?.toString() ?? '--', unit: '%', size: 46),
Container(
width: 1,
height: 40,
margin: const EdgeInsets.symmetric(horizontal: 22),
color: OsColors.surfaceHigh,
),
ValueWithUnit(value: ClusterData.fmt(voltage), unit: 'V', size: 38),
],
),
),
],
),
),
),
const SizedBox(width: 5),
Container(
width: 8,
height: 30,
decoration: const BoxDecoration(
color: OsColors.surfaceHigh,
borderRadius: BorderRadius.horizontal(right: Radius.circular(4)),
),
),
],
);
}
}
class SignalRow extends StatelessWidget {
const SignalRow({super.key, required this.left, required this.right, this.compact = false});
final bool left;
final bool right;
final bool compact;
@override
Widget build(BuildContext context) {
final size = compact ? 22.0 : 28.0;
if (!left && !right && !compact) return SizedBox(height: size);
return Row(
mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.arrow_back_rounded, size: size, color: left ? OsColors.good : OsColors.track),
if (compact) const SizedBox(width: 8),
Icon(Icons.arrow_forward_rounded, size: size, color: right ? OsColors.good : OsColors.track),
],
);
}
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/protocol_log.dart';
/// In-app view of the persistent protocol log, for field debugging.
class LogScreen extends StatelessWidget {
const LogScreen({super.key});
@override
Widget build(BuildContext context) {
final log = ProtocolLog.instance;
return Scaffold(
appBar: AppBar(
title: const Text('Protocol log'),
actions: [
IconButton(
tooltip: 'Copy all',
icon: const Icon(Icons.copy),
onPressed: () async {
await Clipboard.setData(ClipboardData(text: log.lines.join('\n')));
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Log copied to clipboard')));
}
},
),
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.delete_outline),
onPressed: log.clear,
),
],
),
body: ListenableBuilder(
listenable: log,
builder: (context, _) {
final lines = log.lines;
return Column(
children: [
if (log.path != null)
Padding(
padding: const EdgeInsets.all(8),
child: SelectableText(
'adb pull ${log.path}',
style: Theme.of(context).textTheme.bodySmall,
),
),
Expanded(
child: ListView.builder(
reverse: true,
itemCount: lines.length,
itemBuilder: (context, i) {
final line = lines[lines.length - 1 - i];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
child: SelectableText(
line,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
);
},
),
),
],
);
},
),
);
}
}
+246
View File
@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import '../models/scooter_device.dart';
import '../scooters/apollo_scooter.dart';
import '../services/ble_client.dart';
import '../services/demo_ble_client.dart';
import '../theme.dart';
import 'scooter_screen.dart';
class ScanScreen extends StatefulWidget {
const ScanScreen({super.key, required this.ble});
final BleClient ble;
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
Stream<List<ScooterDevice>>? _scan;
/// Development path: list every BLE device so a scooter that does not
/// advertise F1F0/F2F0 can still be selected and classified after GATT
/// discovery.
bool _showAll = false;
@override
void initState() {
super.initState();
_scan = widget.ble.scan();
}
void _restart() => setState(() => _scan = widget.ble.scan());
Future<void> _connect(ScooterDevice device, {BleClient? ble}) async {
setState(() => _scan = null);
await widget.ble.stopScan();
if (!mounted) return;
// Create the scooter ONCE. Route builders re-run on every rebuild (for
// example a theme change), so constructing it inside the builder would
// silently swap in a fresh, unconnected instance.
final scooter = ApolloScooter(ble ?? widget.ble, device);
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ScooterScreen(scooter: scooter)),
);
if (mounted) _restart();
}
/// Replays real Apollo Go frames through a fake link so layouts and colours
/// can be previewed without a vehicle nearby. Not linked from the UI for
/// now; kept for development.
// ignore: unused_element
void _openDemo() => _connect(DemoBleClient.device, ble: DemoBleClient());
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 16, 8),
child: Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('OpenMotion',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w800, letterSpacing: -1)),
SizedBox(height: 4),
Text('Open source scooting!',
style: TextStyle(color: OsColors.textDim)),
],
),
),
IconButton(
tooltip: _showAll ? 'Show scooters only' : 'Show all BLE devices',
icon: Icon(_showAll ? Icons.filter_alt_off_rounded : Icons.filter_alt_rounded),
onPressed: () => setState(() => _showAll = !_showAll),
),
IconButton(
tooltip: 'Restart scan',
icon: const Icon(Icons.refresh_rounded),
onPressed: _restart,
),
],
),
),
Expanded(
child: _scan == null
? const SizedBox.shrink()
: StreamBuilder<List<ScooterDevice>>(
stream: _scan,
builder: (context, snap) {
if (snap.hasError) {
return _Empty(
icon: Icons.bluetooth_disabled_rounded,
title: 'Bluetooth scan failed',
message: '${snap.error}',
action: FilledButton(onPressed: _restart, child: const Text('Retry')),
);
}
final all = snap.data ?? const <ScooterDevice>[];
final devices = (_showAll
? all
: all.where((d) => ApolloScooter.matches(d) || ApolloScooter.nameHint(d)))
.toList()
..sort((a, b) => b.rssi.compareTo(a.rssi));
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
child: Row(
children: [
Text(
_showAll ? 'ALL BLE DEVICES' : 'NEARBY SCOOTERS',
style: const TextStyle(
color: OsColors.textDim, fontSize: 12, letterSpacing: 1.2),
),
const SizedBox(width: 12),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
if (devices.isEmpty)
_Empty(
icon: Icons.electric_scooter_rounded,
title: 'Searching',
message: 'Turn the scooter on and keep it nearby.',
),
for (final d in devices) ...[
_DeviceCard(device: d, onConnect: () => _connect(d)),
const SizedBox(height: 10),
],
],
);
},
),
),
],
),
),
);
}
}
class _DeviceCard extends StatelessWidget {
const _DeviceCard({required this.device, required this.onConnect});
final ScooterDevice device;
final VoidCallback onConnect;
@override
Widget build(BuildContext context) {
final isApollo = ApolloScooter.matches(device);
final accent = Theme.of(context).colorScheme.primary;
final bars = device.rssi > -60 ? 4 : (device.rssi > -70 ? 3 : (device.rssi > -80 ? 2 : 1));
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onConnect,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: isApollo ? accent.withValues(alpha: 0.15) : OsColors.surfaceHigh,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
isApollo ? Icons.electric_scooter_rounded : Icons.bluetooth_rounded,
color: isApollo ? accent : OsColors.textDim,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.name.isEmpty ? 'Unnamed device' : device.name,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
const SizedBox(height: 2),
Text(
isApollo
? 'Apollo · ${device.rssi} dBm'
: '${device.id} · ${device.rssi} dBm',
style: const TextStyle(color: OsColors.textDim, fontSize: 12),
),
],
),
),
Icon(
switch (bars) {
4 => Icons.signal_cellular_alt_rounded,
3 => Icons.signal_cellular_alt_2_bar_rounded,
_ => Icons.signal_cellular_alt_1_bar_rounded,
},
color: OsColors.textDim,
),
const SizedBox(width: 6),
const Icon(Icons.chevron_right_rounded, color: OsColors.textDim),
],
),
),
),
);
}
}
class _Empty extends StatelessWidget {
const _Empty({required this.icon, required this.title, required this.message, this.action});
final IconData icon;
final String title;
final String message;
final Widget? action;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
child: Column(
children: [
Icon(icon, size: 56, color: OsColors.track),
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
if (action != null) ...[const SizedBox(height: 16), action!],
],
),
);
}
}
+730
View File
@@ -0,0 +1,730 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../models/scooter_state.dart';
import '../scooters/apollo_protocol.dart';
import '../scooters/apollo_scooter.dart';
import '../scooters/scooter.dart';
import '../services/pin_store.dart';
import '../settings.dart';
import '../theme.dart';
import 'clusters.dart';
import 'log_screen.dart';
class ScooterScreen extends StatefulWidget {
const ScooterScreen({super.key, required this.scooter});
final ApolloScooter scooter;
@override
State<ScooterScreen> createState() => _ScooterScreenState();
}
class _ScooterScreenState extends State<ScooterScreen> {
final _pin = TextEditingController();
final _pinStore = PinStore();
String? _pinError;
bool _busy = false;
bool _hasSavedPin = false;
bool _autoAuthTried = false;
ApolloScooter get scooter => widget.scooter;
@override
void initState() {
super.initState();
scooter.addListener(_onScooterChanged);
_loadSavedPin();
scooter.connect().catchError((_) {});
}
@override
void dispose() {
scooter.removeListener(_onScooterChanged);
_pin.dispose();
scooter.disposeScooter();
super.dispose();
}
// ---- PIN -----------------------------------------------------------------
Future<void> _loadSavedPin() async {
final saved = await _pinStore.read(scooter.device.id);
if (!mounted || saved == null) return;
setState(() {
_pin.text = saved;
_hasSavedPin = true;
});
_maybeAutoAuthenticate();
}
void _onScooterChanged() => _maybeAutoAuthenticate();
void _maybeAutoAuthenticate() {
if (_autoAuthTried || !_hasSavedPin || _busy) return;
if (scooter.state.connectionStatus != ScooterConnectionStatus.connected) return;
_autoAuthTried = true;
_authenticate();
}
Future<void> _forgetPin() async {
await _pinStore.forget(scooter.device.id);
if (!mounted) return;
setState(() {
_hasSavedPin = false;
_pin.clear();
});
}
Future<void> _authenticate() async {
final pin = _pin.text.trim();
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
setState(() => _pinError = 'Enter the six-digit scooter PIN.');
return;
}
setState(() {
_pinError = null;
_busy = true;
});
try {
final result = await scooter.authenticate(pin);
if (result == AuthenticationResult.invalidCredential) {
setState(() => _pinError = 'Incorrect scooter PIN.');
if (_hasSavedPin) await _forgetPin();
} else {
await _pinStore.save(scooter.device.id, pin);
if (mounted) setState(() => _hasSavedPin = true);
}
} on TimeoutException {
setState(() => _pinError = 'The scooter did not respond.');
} on ScooterConnectionLostException {
// Shown by the status overlay.
} catch (e) {
setState(() => _pinError = 'Authentication failed: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _reconnect() async {
_autoAuthTried = false;
setState(() => _busy = true);
try {
await scooter.connect();
} catch (_) {
// Reflected in scooter.state and shown by the overlay.
} finally {
if (mounted) setState(() => _busy = false);
}
}
// ---- controls ------------------------------------------------------------
Future<void> _run(Future<void> Function() action) async {
if (!scooter.controlWritesEnabled) {
_snack('Control writes are currently disabled in this build.');
return;
}
if (!scooter.canWrite) {
_snack('Scooter connection is still initializing... Try this action again shortly.');
return;
}
setState(() => _busy = true);
try {
await action();
} on TimeoutException catch (e) {
_snack(e.message ?? 'The scooter did not confirm the change.');
} catch (e) {
_snack('$e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _snack(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(text)));
}
bool _isImperial(ScooterState s) => switch (AppSettings.instance.units) {
UnitPreference.auto => s.imperial ?? false,
UnitPreference.metric => false,
UnitPreference.imperial => true,
};
// ---- build ---------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ListenableBuilder(
listenable: Listenable.merge([scooter, AppSettings.instance]),
builder: (context, _) {
final s = scooter.state;
final actions = ClusterActions(
busy: _busy,
toggleHeadlight: () => _run(() => scooter.setHeadlight(!(s.headlight ?? false))),
toggleLock: () => _run((s.locked ?? false) ? scooter.unlock : scooter.lock),
readOnlyTap: (name) => _snack('$name is read-only for now.'),
);
return Stack(
children: [
Column(
children: [
_topBar(s),
Expanded(
child: buildCluster(
AppSettings.instance.layout,
ClusterData(state: s, imperial: _isImperial(s)),
actions,
),
),
],
),
?_overlayFor(s),
],
);
},
),
),
);
}
Widget _topBar(ScooterState s) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
scooter.device.name.isEmpty ? 'Scooter' : scooter.device.name,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
_ConnectionPill(status: s.connectionStatus),
],
),
),
IconButton(
tooltip: 'Settings and diagnostics',
icon: const Icon(Icons.tune_rounded),
onPressed: () => _openSettings(s),
),
],
),
);
}
/// Full-screen overlays for the states where the dashboard has nothing
/// meaningful to show yet.
Widget? _overlayFor(ScooterState s) {
switch (s.connectionStatus) {
case ScooterConnectionStatus.connecting:
return _Overlay(child: _ConnectingCard(step: s.connectionStep ?? 0));
case ScooterConnectionStatus.connected:
case ScooterConnectionStatus.authenticating:
return _Overlay(child: _pinCard(s));
case ScooterConnectionStatus.error:
case ScooterConnectionStatus.disconnected:
return _Overlay(
child: _MessageCard(
icon: Icons.bluetooth_disabled_rounded,
title: s.connectionStatus == ScooterConnectionStatus.error
? 'Connection problem'
: 'Disconnected',
message: s.errorMessage ?? 'The scooter is not connected.',
actions: [
FilledButton(
onPressed: _busy ? null : _reconnect,
child: const Text('Reconnect'),
),
],
),
);
case ScooterConnectionStatus.authenticated:
return const _Overlay(
dim: 0.7,
child: _MessageCard(
icon: Icons.podcasts_rounded,
title: 'PIN accepted',
message: 'Waiting for the scooter to start streaming telemetry.',
body: _Spinner(label: 'Usually under a second'),
),
);
case ScooterConnectionStatus.ready:
return null;
}
}
Widget _pinCard(ScooterState s) {
final authenticating = s.connectionStatus == ScooterConnectionStatus.authenticating;
return _MessageCard(
icon: Icons.lock_outline_rounded,
title: 'Enter scooter PIN',
message: 'The six-digit Bluetooth PIN from your scooter.',
body: TextField(
controller: _pin,
enabled: !authenticating && !_busy,
autofocus: !_hasSavedPin,
keyboardType: TextInputType.number,
maxLength: 6,
obscureText: true,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 30, letterSpacing: 14, fontWeight: FontWeight.w700),
decoration: InputDecoration(counterText: '', hintText: '••••••', errorText: _pinError),
onSubmitted: (_) => _authenticate(),
),
actions: [
FilledButton(
onPressed: authenticating || _busy ? null : _authenticate,
child: authenticating
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 8),
Text('Unlocking...'),
],
)
: const Text('Unlock Scooter'),
),
if (_hasSavedPin)
TextButton(onPressed: _forgetPin, child: const Text('Forget saved PIN')),
],
);
}
void _openSettings(ScooterState s) {
showModalBottomSheet<void>(
context: context,
backgroundColor: OsColors.surface,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) => ListenableBuilder(
listenable: Listenable.merge([scooter, AppSettings.instance]),
builder: (ctx, _) => StatefulBuilder(
builder: (ctx, setSheet) => _SettingsSheet(
scooter: scooter,
hasSavedPin: _hasSavedPin,
onForgetPin: () async {
await _forgetPin();
setSheet(() {});
},
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Overlays and shared bits
// ---------------------------------------------------------------------------
class _ConnectionPill extends StatelessWidget {
const _ConnectionPill({required this.status});
final ScooterConnectionStatus status;
@override
Widget build(BuildContext context) {
final (label, color) = switch (status) {
ScooterConnectionStatus.disconnected => ('Disconnected', OsColors.textDim),
ScooterConnectionStatus.connecting => ('Connecting', OsColors.warn),
ScooterConnectionStatus.connected => ('PIN required', OsColors.warn),
ScooterConnectionStatus.authenticating => ('Checking PIN', OsColors.warn),
ScooterConnectionStatus.authenticated => ('Waiting for data', OsColors.warn),
ScooterConnectionStatus.ready => ('Connected', OsColors.good),
ScooterConnectionStatus.error => ('Error', OsColors.bad),
};
return Row(
children: [
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 12)),
],
);
}
}
class _Overlay extends StatelessWidget {
const _Overlay({required this.child, this.dim = 0.92});
final Widget child;
final double dim;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: Container(
color: OsColors.background.withValues(alpha: dim),
alignment: Alignment.center,
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(child: child),
),
);
}
}
/// Step-by-step view of what the app is doing while the link comes up.
class _ConnectingCard extends StatelessWidget {
const _ConnectingCard({required this.step});
final int step;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
final steps = ScooterState.connectionSteps;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(Icons.bluetooth_searching_rounded, size: 40, color: primary),
const SizedBox(height: 12),
const Text('Connecting', textAlign: TextAlign.center,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
const SizedBox(height: 6),
const Text(
'This should only take a few moments.',
textAlign: TextAlign.center,
style: TextStyle(color: OsColors.textDim),
),
const SizedBox(height: 20),
for (var i = 0; i < steps.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 22,
height: 22,
child: i < step
? Icon(Icons.check_circle_rounded, color: OsColors.good, size: 22)
: i == step
? CircularProgressIndicator(strokeWidth: 2.5, color: primary)
: const Icon(Icons.circle_outlined, color: OsColors.track, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Text(
steps[i],
style: TextStyle(
color: i <= step ? OsColors.text : OsColors.textDim,
fontWeight: i == step ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
),
),
],
),
),
);
}
}
class _Spinner extends StatelessWidget {
const _Spinner({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3)),
const SizedBox(height: 20),
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 16)),
],
);
}
}
class _MessageCard extends StatelessWidget {
const _MessageCard({
required this.icon,
required this.title,
required this.message,
this.body,
this.actions = const [],
});
final IconData icon;
final String title;
final String message;
final Widget? body;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(icon, size: 40, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
const SizedBox(height: 6),
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
if (body != null) ...[const SizedBox(height: 20), body!],
const SizedBox(height: 20),
...actions,
],
),
),
);
}
}
class _SettingsSheet extends StatelessWidget {
const _SettingsSheet({
required this.scooter,
required this.hasSavedPin,
required this.onForgetPin,
});
final ApolloScooter scooter;
final bool hasSavedPin;
final VoidCallback onForgetPin;
@override
Widget build(BuildContext context) {
final s = scooter.state;
final settings = AppSettings.instance;
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 12),
child: Text('Settings', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800)),
),
const _SectionLabel('CLUSTER LAYOUT'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
for (final layout in ClusterLayout.values) ...[
Expanded(
child: _LayoutChoice(
layout: layout,
selected: settings.layout == layout,
onTap: () => settings.layout = layout,
),
),
if (layout != ClusterLayout.values.last) const SizedBox(width: 10),
],
],
),
),
const SizedBox(height: 16),
const _SectionLabel('ACCENT COLOR'),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final a in AccentColor.values)
_ColorDot(
color: a.color,
label: a.label,
selected: settings.accent == a,
onTap: () => settings.accent = a,
),
],
),
),
const SizedBox(height: 8),
const _SectionLabel('UNITS'),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: SegmentedButton<UnitPreference>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: UnitPreference.auto, label: Text('Auto')),
ButtonSegment(value: UnitPreference.metric, label: Text('km')),
ButtonSegment(value: UnitPreference.imperial, label: Text('mi')),
],
selected: {settings.units},
onSelectionChanged: (v) => settings.units = v.first,
),
),
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Text(
'Auto follows the scooter. Distances assume native km until a ride confirms it.',
style: TextStyle(color: OsColors.textDim, fontSize: 12),
),
),
const Divider(height: 24),
SwitchListTile(
title: const Text('Keepalive'),
subtitle: const Text('The scooter only streams data while it receives this every second.'),
value: scooter.keepaliveInterval != null,
onChanged: (v) => scooter.setKeepaliveInterval(v ? apolloDefaultKeepaliveInterval : null),
),
ListTile(
title: const Text('Forget saved PIN'),
enabled: hasSavedPin,
trailing: const Icon(Icons.delete_outline_rounded),
onTap: hasSavedPin ? onForgetPin : null,
),
ListTile(
title: const Text('Protocol log'),
subtitle: const Text('Raw BLE traffic for debugging'),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LogScreen()));
},
),
const Divider(height: 24),
const _SectionLabel('SCOOTER DETAILS'),
_kv('Address', scooter.device.id),
_kv('Speed limits', s.maxSpeedLimit == null ? '--' : 'current gear ${s.speedLimit ?? '--'}, max ${s.maxSpeedLimit}'),
_kv('Battery temperature', s.batteryTemperature == null ? '--' : '${s.batteryTemperature} °C'),
_kv('Battery cycles', '${s.batteryCycles ?? '--'}'),
_kv('Display', '${s.displayId ?? 'none'} ${s.displayVersion ?? ''}'),
_kv('Scooter unit bit', s.imperial == null ? '--' : (s.imperial! ? 'imperial' : 'metric')),
_kv('Control writes', scooter.controlWritesEnabled ? (scooter.canWrite ? 'enabled' : 'waiting') : 'read-only build'),
],
),
);
}
Widget _kv(String k, String v) => ListTile(
dense: true,
title: Text(k),
trailing: Text(v, style: const TextStyle(color: OsColors.textDim)),
);
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
child: CapsLabel(text),
);
}
class _LayoutChoice extends StatelessWidget {
const _LayoutChoice({required this.layout, required this.selected, required this.onTap});
final ClusterLayout layout;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: OsColors.surfaceHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: selected ? primary : Colors.transparent, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 44, child: _LayoutPreview(layout: layout, color: selected ? primary : OsColors.textDim)),
const SizedBox(height: 8),
Text(layout.label, style: const TextStyle(fontWeight: FontWeight.w700)),
Text(layout.description, style: const TextStyle(color: OsColors.textDim, fontSize: 11)),
],
),
),
);
}
}
/// Tiny schematic of each layout for the picker.
class _LayoutPreview extends StatelessWidget {
const _LayoutPreview({required this.layout, required this.color});
final ClusterLayout layout;
final Color color;
@override
Widget build(BuildContext context) {
Widget bar(double w, double h) => Container(
width: w,
height: h,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3)),
);
return switch (layout) {
ClusterLayout.arc => Center(
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: color, width: 4)),
),
),
ClusterLayout.digital => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [bar(36, 18), const SizedBox(height: 6), bar(60, 6)],
),
ClusterLayout.tiles => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
const SizedBox(height: 4),
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
],
),
};
}
}
class _ColorDot extends StatelessWidget {
const _ColorDot({required this.color, required this.label, required this.selected, required this.onTap});
final Color color;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: selected ? Colors.white : Colors.transparent, width: 3),
),
child: selected
? Icon(Icons.check_rounded, color: color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
: null,
),
),
);
}
}
+253
View File
@@ -0,0 +1,253 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
import '../models/scooter_device.dart';
import 'protocol_log.dart';
enum BleConnectionState { disconnected, connected }
/// Thin boundary between OpenScooter and the underlying BLE plugin.
///
/// OpenScooter v1 supports EXACTLY ONE active scooter connection at a time.
/// That is why [subscribe], [write] and [discoverServices] take no device
/// identifier: they always act on the device passed to the last [connect].
///
/// Protocol code never imports the BLE plugin directly, so protocol tests run
/// with `flutter test` and a fake subclass, without hardware.
abstract class BleClient {
/// Streams the current set of visible devices. Scanning starts on listen
/// and stops when the subscription is cancelled.
Stream<List<ScooterDevice>> scan();
Future<void> stopScan();
/// Connects to the device with the given platform identifier and makes it
/// the single active device.
Future<void> connect(String deviceId);
Future<void> disconnect();
/// Emits connection changes for the active device, INCLUDING unexpected
/// disconnects. Scooter implementations must reset on `disconnected`.
Stream<BleConnectionState> get connectionState;
/// Runs GATT discovery on the active device. Returns a map of lowercase
/// 128-bit service UUID to the lowercase characteristic UUIDs it contains.
Future<Map<String, Set<String>>> discoverServices();
/// Enables notifications on a characteristic and returns its value stream.
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
});
Future<void> write({
required String serviceUuid,
required String characteristicUuid,
required Uint8List value,
});
/// Normalises any UUID spelling to lowercase 128-bit form for comparisons.
static String normalizeUuid(String uuid) => fbp.Guid(uuid).str128.toLowerCase();
}
/// Production [BleClient] backed by flutter_blue_plus.
class FlutterBleClient extends BleClient {
fbp.BluetoothDevice? _device;
List<fbp.BluetoothService> _services = const [];
StreamSubscription<fbp.BluetoothConnectionState>? _connSub;
final _connState = StreamController<BleConnectionState>.broadcast();
@override
Stream<BleConnectionState> get connectionState => _connState.stream;
@override
Stream<List<ScooterDevice>> scan() {
late StreamController<List<ScooterDevice>> controller;
StreamSubscription<List<fbp.ScanResult>>? sub;
Future<void> start() async {
try {
var adapter = await fbp.FlutterBluePlus.adapterState.first;
if (adapter != fbp.BluetoothAdapterState.on &&
defaultTargetPlatform == TargetPlatform.android) {
await fbp.FlutterBluePlus.turnOn();
}
adapter = await fbp.FlutterBluePlus.adapterState
.where((s) => s == fbp.BluetoothAdapterState.on)
.first
.timeout(const Duration(seconds: 10));
sub = fbp.FlutterBluePlus.scanResults.listen((results) {
controller.add(results.map(_toDevice).toList());
}, onError: controller.addError);
_log('SCAN START');
await fbp.FlutterBluePlus.startScan(
continuousUpdates: true,
continuousDivisor: 2,
removeIfGone: const Duration(seconds: 6),
);
} catch (e) {
controller.addError(e);
}
}
controller = StreamController<List<ScooterDevice>>(
onListen: start,
onCancel: () async {
await sub?.cancel();
await stopScan();
},
);
return controller.stream;
}
ScooterDevice _toDevice(fbp.ScanResult r) {
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: r.device.platformName;
return ScooterDevice(
id: r.device.remoteId.str,
name: name,
rssi: r.rssi,
advertisedServiceUuids: r.advertisementData.serviceUuids
.map((g) => g.str128.toLowerCase())
.toSet(),
);
}
@override
Future<void> stopScan() async {
if (fbp.FlutterBluePlus.isScanningNow) {
_log('SCAN STOP');
await fbp.FlutterBluePlus.stopScan();
}
}
@override
Future<void> connect(String deviceId) async {
await disconnect();
final device = fbp.BluetoothDevice.fromId(deviceId);
_device = device;
_log('BLE CONNECT $deviceId');
_connSub = device.connectionState.skip(1).listen((s) {
_log('CONNECTION STATE $s');
if (s == fbp.BluetoothConnectionState.disconnected) {
_log('BLE DISCONNECTED $deviceId');
_services = const [];
_connState.add(BleConnectionState.disconnected);
} else if (s == fbp.BluetoothConnectionState.connected) {
_connState.add(BleConnectionState.connected);
}
});
try {
// FlutterBluePlus 2.x requires callers to declare which license they
// operate under. OpenScooter is a non-commercial open source project.
await device.connect(license: fbp.License.nonprofit);
_log('CONNECTED mtu=${device.mtuNow}');
} catch (e) {
_log('CONNECT FAILED $e');
await _connSub?.cancel();
_connSub = null;
_device = null;
rethrow;
}
}
@override
Future<void> disconnect() async {
final device = _device;
_device = null;
_services = const [];
await _connSub?.cancel();
_connSub = null;
if (device != null) {
_log('BLE DISCONNECT ${device.remoteId.str}');
try {
await device.disconnect();
} catch (_) {
// Already gone. Nothing to do.
}
_connState.add(BleConnectionState.disconnected);
}
}
@override
Future<Map<String, Set<String>>> discoverServices() async {
final device = _requireDevice();
_services = await device.discoverServices();
final map = <String, Set<String>>{};
for (final s in _services) {
map[s.uuid.str128.toLowerCase()] =
s.characteristics.map((c) => c.uuid.str128.toLowerCase()).toSet();
for (final c in s.characteristics) {
final p = c.properties;
final props = [
if (p.read) 'read',
if (p.write) 'write',
if (p.writeWithoutResponse) 'writeNoRsp',
if (p.notify) 'notify',
if (p.indicate) 'indicate',
].join(',');
_log('DISCOVERED ${_short(s.uuid.str128)}/${_short(c.uuid.str128)} [$props]');
}
}
_log('DISCOVERED ${map.length} services');
return map;
}
@override
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
}) async {
final c = _characteristic(serviceUuid, characteristicUuid);
_log('SUBSCRIBE ${_short(characteristicUuid)}');
final ok = await c.setNotifyValue(true);
_log('SUBSCRIBE ${_short(characteristicUuid)} result=$ok');
return c.onValueReceived.map((v) => Uint8List.fromList(v));
}
@override
Future<void> write({
required String serviceUuid,
required String characteristicUuid,
required Uint8List value,
}) async {
final c = _characteristic(serviceUuid, characteristicUuid);
final withoutResponse =
!c.properties.write && c.properties.writeWithoutResponse;
_log('WRITE ${_short(characteristicUuid)} ${value.length} bytes withoutResponse=$withoutResponse');
await c.write(value, withoutResponse: withoutResponse);
}
fbp.BluetoothDevice _requireDevice() {
final d = _device;
if (d == null) throw StateError('No active BLE device');
return d;
}
fbp.BluetoothCharacteristic _characteristic(String service, String char) {
_requireDevice();
if (_services.isEmpty) {
throw StateError('discoverServices() must run before subscribe/write');
}
final s = BleClient.normalizeUuid(service);
final c = BleClient.normalizeUuid(char);
for (final svc in _services) {
if (svc.uuid.str128.toLowerCase() != s) continue;
for (final ch in svc.characteristics) {
if (ch.uuid.str128.toLowerCase() == c) return ch;
}
}
throw StateError('Characteristic ${_short(char)} not found');
}
static String _short(String uuid) =>
uuid.length >= 8 ? uuid.substring(4, 8).toUpperCase() : uuid;
void _log(String message) => ProtocolLog.instance.log('BLE', message);
}
+109
View File
@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import '../models/scooter_device.dart';
import '../scooters/apollo_protocol.dart';
import 'ble_client.dart';
/// Fake BLE link that behaves like the Apollo Go captured on 2026-09-21.
///
/// Accepts any PIN, streams the real base frame and a monitor frame with an
/// animated speed and current, and only streams while keepalives arrive, just
/// like the physical scooter. Used to preview layouts without hardware.
class DemoBleClient extends BleClient {
static const device = ScooterDevice(
id: 'demo',
name: 'Demo Apollo Go',
rssi: -50,
advertisedServiceUuids: {apolloDataServiceUuid},
);
final _conn = StreamController<BleConnectionState>.broadcast();
final _data = StreamController<Uint8List>.broadcast();
final _at = StreamController<Uint8List>.broadcast();
Timer? _stream;
DateTime _lastKeepalive = DateTime.fromMillisecondsSinceEpoch(0);
double _t = 0;
static final _base = Uint8List.fromList(
[0xAA, 0x01, 0x19, 0x03, 0x0A, 0x0F, 0x1E, 0xD8, 0x80, 0x00, 0x1F, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x68, 0x61]);
@override
Stream<List<ScooterDevice>> scan() => Stream.value([device]);
@override
Future<void> stopScan() async {}
@override
Stream<BleConnectionState> get connectionState => _conn.stream;
@override
Future<void> connect(String deviceId) async {
await Future<void>.delayed(const Duration(milliseconds: 600));
_stream = Timer.periodic(const Duration(milliseconds: 200), (_) => _tick());
}
@override
Future<void> disconnect() async {
_stream?.cancel();
_stream = null;
_conn.add(BleConnectionState.disconnected);
}
@override
Future<Map<String, Set<String>>> discoverServices() async {
await Future<void>.delayed(const Duration(milliseconds: 400));
return {
apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid},
apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid},
};
}
@override
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 250));
return characteristicUuid == apolloDataRxUuid ? _data.stream : _at.stream;
}
@override
Future<void> write({
required String serviceUuid,
required String characteristicUuid,
required Uint8List value,
}) async {
if (characteristicUuid == apolloAtTxUuid) {
Future<void>.delayed(const Duration(milliseconds: 150),
() => _at.add(Uint8List.fromList('OK+PWD:Y'.codeUnits)));
} else if (value.length == 4 && value[0] == 0xA5) {
_lastKeepalive = DateTime.now();
}
}
void _tick() {
if (DateTime.now().difference(_lastKeepalive) > const Duration(seconds: 3)) return;
_t += 0.2;
final speedKmh = 14 + 12 * math.sin(_t / 4); // 2..26 km/h
final rawSpeed = (speedKmh * 10).round();
final currentA = 3 + 8 * math.max(0, math.cos(_t / 4)) - (math.sin(_t / 2) < -0.8 ? 6 : 0);
final rawCurrent = (currentA * 64).round() & 0xFFFF;
final battery = 78;
final flagsA = 0x0E | (math.sin(_t / 6) > 0 ? 0x80 : 0); // headlight blinks slowly
final frame = Uint8List.fromList([
0xAA, 0x00, 0x19, 0x01, 0x02, battery,
rawSpeed >> 8, rawSpeed & 0xFF, 0, 0,
0x01, 0x9F, rawCurrent >> 8, rawCurrent & 0xFF,
0x16 + (speedKmh / 10).round(), 0x19,
0x00, 0x7B, 0x00, 0x0C, 0xC5, flagsA, 0x22, 0, 0,
]);
final crc = apolloCrc16(frame.sublist(0, 23));
frame[23] = crc & 0xFF;
frame[24] = crc >> 8;
// Mimic the real 20 + 5 byte notification split.
_data.add(frame.sublist(0, 20));
_data.add(frame.sublist(20));
_data.add(_base.sublist(0, 20));
_data.add(_base.sublist(20));
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Remembers scooter PINs in platform secure storage (Android Keystore backed
/// storage, iOS Keychain), keyed by the BLE peripheral id.
///
/// The peripheral id is not a permanent scooter identity (see ScooterDevice).
/// Once the Apollo UID is read over AT+UID? this should key by that instead.
class PinStore {
PinStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
String _key(String deviceId) => 'pin:$deviceId';
Future<String?> read(String deviceId) async {
try {
return await _storage.read(key: _key(deviceId));
} catch (_) {
return null;
}
}
Future<void> save(String deviceId, String pin) =>
_storage.write(key: _key(deviceId), value: pin);
Future<void> forget(String deviceId) => _storage.delete(key: _key(deviceId));
}
+67
View File
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Persistent protocol log for field debugging without a USB cable.
///
/// Every line is kept in a bounded in-memory ring (viewable in-app) and, once
/// [init] has run, appended to a file that can be pulled with adb:
///
/// adb pull /sdcard/Android/data/dev.teamhydra.openscooter/files/openscooter.log
///
/// Before [init], or in tests, only the in-memory ring is used.
/// The PIN is never written here; callers mask it first.
class ProtocolLog extends ChangeNotifier {
ProtocolLog._();
static final ProtocolLog instance = ProtocolLog._();
static const maxLines = 2000;
final List<String> _lines = <String>[];
File? _file;
IOSink? _sink;
List<String> get lines => List.unmodifiable(_lines);
String? get path => _file?.path;
Future<void> init() async {
if (_file != null) return;
try {
Directory? dir;
if (Platform.isAndroid) dir = await getExternalStorageDirectory();
dir ??= await getApplicationDocumentsDirectory();
_file = File('${dir.path}/openscooter.log');
_sink = _file!.openWrite(mode: FileMode.append);
log('LOG', 'opened ${_file!.path}');
} catch (e) {
debugPrint('ProtocolLog: could not open log file: $e');
}
}
void log(String tag, String message) {
final line = '${DateTime.now().toIso8601String()} [$tag] $message';
if (kDebugMode) debugPrint(line);
_lines.add(line);
if (_lines.length > maxLines) _lines.removeRange(0, _lines.length - maxLines);
_sink?.writeln(line);
notifyListeners();
}
Future<void> clear() async {
_lines.clear();
await _sink?.flush();
await _sink?.close();
_sink = null;
final f = _file;
if (f != null) {
try {
await f.writeAsString('');
} catch (_) {}
_sink = f.openWrite(mode: FileMode.append);
}
notifyListeners();
}
Future<void> flush() async => _sink?.flush();
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Display units. `auto` follows the scooter's own imperial bit.
///
/// ASSUMPTION (INFERRED, ride verification pending): native protocol speed and
/// distance values are kilometres. Imperial display converts from km.
enum UnitPreference { auto, metric, imperial }
/// Dashboard layouts the rider can pick from.
enum ClusterLayout {
arc('Arc', 'Speed inside a round gauge'),
digital('Digital', 'Big number with a speed bar'),
tiles('Tiles', 'Everything as cards');
const ClusterLayout(this.label, this.description);
final String label;
final String description;
}
/// Accent colour presets. Blue is the OpenScooter default.
enum AccentColor {
blue('Blue', Color(0xFF3D8BFF)),
cyan('Cyan', Color(0xFF22C1D8)),
green('Green', Color(0xFF34C77B)),
lime('Lime', Color(0xFFB4E33D)),
amber('Amber', Color(0xFFFFB020)),
orange('Orange', Color(0xFFFF6B2C)),
red('Red', Color(0xFFFF4D5E)),
pink('Pink', Color(0xFFFF5CA8)),
purple('Purple', Color(0xFF9B6CFF)),
white('White', Color(0xFFF2F4F8));
const AccentColor(this.label, this.color);
final String label;
final Color color;
}
/// App-wide user preferences, persisted with shared_preferences.
class AppSettings extends ChangeNotifier {
AppSettings._();
static final AppSettings instance = AppSettings._();
static const _kAccent = 'accent';
static const _kLayout = 'layout';
static const _kUnits = 'units';
SharedPreferences? _prefs;
AccentColor _accent = AccentColor.blue;
ClusterLayout _layout = ClusterLayout.arc;
UnitPreference _units = UnitPreference.auto;
AccentColor get accent => _accent;
ClusterLayout get layout => _layout;
UnitPreference get units => _units;
Future<void> load() async {
try {
_prefs = await SharedPreferences.getInstance();
_accent = _byName(AccentColor.values, _prefs!.getString(_kAccent)) ?? _accent;
_layout = _byName(ClusterLayout.values, _prefs!.getString(_kLayout)) ?? _layout;
_units = _byName(UnitPreference.values, _prefs!.getString(_kUnits)) ?? _units;
} catch (_) {
// Defaults are fine if preferences are unavailable.
}
notifyListeners();
}
set accent(AccentColor v) {
_accent = v;
_prefs?.setString(_kAccent, v.name);
notifyListeners();
}
set layout(ClusterLayout v) {
_layout = v;
_prefs?.setString(_kLayout, v.name);
notifyListeners();
}
set units(UnitPreference v) {
_units = v;
_prefs?.setString(_kUnits, v.name);
notifyListeners();
}
static T? _byName<T extends Enum>(List<T> values, String? name) {
if (name == null) return null;
for (final v in values) {
if (v.name == name) return v;
}
return null;
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
/// Neutral palette. The accent colour comes from [AppSettings] through the
/// theme, so widgets read `Theme.of(context).colorScheme.primary` for it.
abstract final class OsColors {
static const background = Color(0xFF0A0C10);
static const surface = Color(0xFF13161C);
static const surfaceHigh = Color(0xFF1C2028);
static const good = Color(0xFF34C77B);
static const warn = Color(0xFFFFB020);
static const bad = Color(0xFFFF4D5E);
static const text = Color(0xFFF2F4F8);
static const textDim = Color(0xFF8A93A5);
static const track = Color(0xFF20252E);
static Color batteryColor(int? level) {
if (level == null) return track;
if (level <= 15) return bad;
if (level <= 30) return warn;
return good;
}
}
ThemeData buildOsTheme(Color accent) {
final onAccent = accent.computeLuminance() > 0.5 ? OsColors.background : Colors.white;
final base = ThemeData(
brightness: Brightness.dark,
useMaterial3: true,
colorScheme: ColorScheme.dark(
primary: accent,
onPrimary: onAccent,
secondary: accent,
onSecondary: onAccent,
surface: OsColors.background,
onSurface: OsColors.text,
surfaceContainerHighest: OsColors.surfaceHigh,
error: OsColors.bad,
),
scaffoldBackgroundColor: OsColors.background,
);
return base.copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: OsColors.background,
foregroundColor: OsColors.text,
elevation: 0,
centerTitle: false,
),
cardTheme: const CardThemeData(
color: OsColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
selectedBackgroundColor: accent,
selectedForegroundColor: onAccent,
side: const BorderSide(color: OsColors.surfaceHigh),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(s) => s.contains(WidgetState.selected) ? onAccent : OsColors.textDim),
trackColor: WidgetStateProperty.resolveWith(
(s) => s.contains(WidgetState.selected) ? accent : OsColors.surfaceHigh),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: OsColors.surfaceHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
textTheme: base.textTheme.apply(bodyColor: OsColors.text, displayColor: OsColors.text),
);
}