diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/assets/Screenshot1.png b/assets/Screenshot1.png new file mode 100644 index 0000000..e87e6e6 Binary files /dev/null and b/assets/Screenshot1.png differ diff --git a/assets/Screenshot2.png b/assets/Screenshot2.png new file mode 100644 index 0000000..c46fec0 Binary files /dev/null and b/assets/Screenshot2.png differ diff --git a/assets/Screenshot3.png b/assets/Screenshot3.png new file mode 100644 index 0000000..83f08b4 Binary files /dev/null and b/assets/Screenshot3.png differ diff --git a/lib/bike.dart b/lib/bike.dart index a125f23..b32d4d1 100644 --- a/lib/bike.dart +++ b/lib/bike.dart @@ -66,7 +66,7 @@ class Bike extends _$Bike { _resetReadTimer(); var data = await ref .read(bluetoothRepositoryProvider) - .readCurrentState(state.id); + .readCurrentState(state.id, 'SETTING'); if (data == null || data.isEmpty) { return; } @@ -87,10 +87,27 @@ class Bike extends _$Bike { newState = newState.copyWith(assist: state.assist); } writeStateData(newState); + + // Read the ride state to update the battery level + var rideData = await ref + .read(bluetoothRepositoryProvider) + .readCurrentState(state.id, 'RIDE'); + if (rideData != null && rideData.isNotEmpty) { + //update Battery State + state = newState.updateRideFromData(rideData); + } + // Read the ride state to update the battery level + var totalData = await ref + .read(bluetoothRepositoryProvider) + .readCurrentState(state.id, 'TOTAL'); + if (totalData != null && totalData.isNotEmpty) { + //update Battery State + state = newState.updateTotalFromData(totalData); + } }); } - void writeStateData(BikeState newState, {saveToBike = true}) async { + void writeStateData(BikeState newState, {bool saveToBike = true}) async { _resetDebounce(); if (state.id != newState.id) { throw Exception('Bike id mismatch'); @@ -235,9 +252,8 @@ class BikePageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.end, children: [ConnectionWidget(bike: bike)], ), - LightControlWidget( - bike: bike, - ), + BatteryODOControlWidget(bike: bike), + LightControlWidget(bike: bike), ModeControlWidget(bike: bike), AssistControlWidget(bike: bike), if (Platform.isAndroid) @@ -292,8 +308,7 @@ class ConnectionWidget extends ConsumerWidget { if (connectionStatus == BluetoothConnectionState.connected) { text = 'Connected'; disabled = true; - } else if (connectionStatus == BluetoothConnectionState.disconnected && - !isScanning) { + } else if (connectionStatus == BluetoothConnectionState.disconnected && !isScanning) { text = 'Connect'; disabled = false; } @@ -327,6 +342,66 @@ class LockWidget extends StatelessWidget { ); } } +class BatteryODOControlWidget extends ConsumerWidget { + const BatteryODOControlWidget({super.key, required this.bike}); + final BikeState bike; + + @override + Widget build(BuildContext context, WidgetRef ref) { + var bikeControl = ref.watch(bikeProvider(bike.id).notifier); + return Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: DiscoverCard( + colorIndex: bike.color, + title: "", + metric: bike.speedMetric == 'metric' + ? '${bike.odometer.toStringAsFixed(0)} km' + : '${(bike.odometer * 0.621371).toStringAsFixed(0)} mi', + titleIcon: Icons.pedal_bike, + selected: false, + onTap: () { + final newMetric = bike.speedMetric == 'metric' ? 'imperial' : 'metric'; + bikeControl.writeStateData(bike.copyWith(speedMetric: newMetric), saveToBike: false); + }, + ), + ), + SizedBox(width: 16.0), // Add padding between the cards + Expanded( + child: DiscoverCard( + height: 2, + colorIndex: bike.color, + title: "", + metric: '${bike.battery.toDouble()} %', + titleIcon: _getBatteryIcon(bike.battery), + selected: false, + onTap: () { + //todo: show battery info + }, + ), + ), + ], + ), + ); + } +} + +IconData _getBatteryIcon(double batteryPercentage) { + return switch (batteryPercentage) { + >= 95 => Icons.battery_full, + >= 80 => Icons.battery_6_bar, + >= 60 => Icons.battery_5_bar, + >= 50 => Icons.battery_4_bar, + >= 35 => Icons.battery_3_bar, + >= 20 => Icons.battery_2_bar, + >= 5 => Icons.battery_1_bar, + >= 0 => Icons.battery_0_bar, + _ => Icons.battery_alert, + }; +} class LightControlWidget extends ConsumerWidget { const LightControlWidget({super.key, required this.bike}); diff --git a/lib/bike.g.dart b/lib/bike.g.dart index 0a7ef60..5b8b273 100644 --- a/lib/bike.g.dart +++ b/lib/bike.g.dart @@ -6,7 +6,7 @@ part of 'bike.dart'; // RiverpodGenerator // ************************************************************************** -String _$bikeHash() => r'4cda66c01088582973f147ce5856d4a5e5274613'; +String _$bikeHash() => r'00ab46df99ea09e5cd643111502cb4943e2a837c'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/debug.dart b/lib/debug.dart index 95bb06b..accb2b2 100644 --- a/lib/debug.dart +++ b/lib/debug.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:superduper/help.dart'; +import 'dart:math'; import 'bike.dart'; import 'edit_bike.dart'; @@ -41,7 +42,7 @@ class DebugPage extends StatelessWidget { context: context, builder: (BuildContext context) { return BikeSettingsWidget( - bike: BikeState.defaultState('1')); + bike: BikeState.defaultState(generateRandomMacAddress())); }); }, child: const Text('Form'), @@ -50,4 +51,14 @@ class DebugPage extends StatelessWidget { ), ); } + + String generateRandomMacAddress() { + final Random random = Random(); + return '${_randomHex(random)}:${_randomHex(random)}:${_randomHex(random)}:${_randomHex(random)}:${_randomHex(random)}:${_randomHex(random)}'; + } + + String _randomHex(Random random) { + return random.nextInt(16).toRadixString(16).padLeft(2, '0'); + } + } diff --git a/lib/models.dart b/lib/models.dart index 97171e3..3796365 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -24,6 +24,10 @@ class BikeState with _$BikeState { @Assert('assist >= 0') @Assert('assist <= 4') @Assert('color >= 0') + @Assert('battery >= 0') + @Assert('battery <= 100') + @Assert('odometer >= 0') + @Assert('speedMetric == "metric" || speedMetric == "imperial"') const factory BikeState( {required String id, required int mode, @@ -35,14 +39,18 @@ class BikeState with _$BikeState { required String name, BikeRegion? region, @Default(false) bool modeLock, - @Default(0) int color}) = _BikeState; + @Default(0) int color, + @Default(0.0) double battery, + @Default(0) double odometer, + @Default('metric') String speedMetric + }) = _BikeState; factory BikeState.fromJson(Map json) => _$BikeStateFromJson(json); factory BikeState.defaultState(String id) { return BikeState( - id: id, mode: 0, light: false, assist: 0, name: getName(seed: id)); + id: id, mode: 0, light: false, assist: 0, name: getName(seed: id), battery: 0.0, odometer: 0); } BikeState updateFromData(List data) { @@ -58,6 +66,20 @@ class BikeState with _$BikeState { region: region); } + BikeState updateRideFromData(List data) { + const cadenceIdx = 3; + const rangeIdx = 8; + final batteryPercentage = _batteryPercentage(data[rangeIdx]); + return copyWith(battery: batteryPercentage); + } + + BikeState updateTotalFromData(List data) { + const total1Idx = 6; + const total2Idx = 7; + final totalodometer = _getodometer(data[total1Idx], data[total2Idx]); + return copyWith(odometer: totalodometer); + } + BikeRegion _guessRegion(int mode) { if (region != null) { return region!; @@ -90,6 +112,16 @@ class BikeState with _$BikeState { return (mode + 1) % 4; } + double _getodometer(int total1, int total2) { + return (((total2 * 256) + total1) / 10); + } + + double _batteryPercentage(int range) { + // All current Super73 max range is 60km + double batteryPercentage = range / 60.0 * 100; + return double.parse(batteryPercentage.toStringAsFixed(1)); + } + List toWriteData() { return [0, 209, light ? 1 : 0, assist, _modeToWrite(), 0, 0, 0, 0, 0]; } diff --git a/lib/models.freezed.dart b/lib/models.freezed.dart index 24dc6d5..898e457 100644 --- a/lib/models.freezed.dart +++ b/lib/models.freezed.dart @@ -31,6 +31,9 @@ mixin _$BikeState { BikeRegion? get region => throw _privateConstructorUsedError; bool get modeLock => throw _privateConstructorUsedError; int get color => throw _privateConstructorUsedError; + double get battery => throw _privateConstructorUsedError; + double get odometer => throw _privateConstructorUsedError; + String get speedMetric => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -54,7 +57,10 @@ abstract class $BikeStateCopyWith<$Res> { String name, BikeRegion? region, bool modeLock, - int color}); + int color, + double battery, + double odometer, + String speedMetric}); } /// @nodoc @@ -81,6 +87,9 @@ class _$BikeStateCopyWithImpl<$Res, $Val extends BikeState> Object? region = freezed, Object? modeLock = null, Object? color = null, + Object? battery = null, + Object? odometer = null, + Object? speedMetric = null, }) { return _then(_value.copyWith( id: null == id @@ -127,6 +136,18 @@ class _$BikeStateCopyWithImpl<$Res, $Val extends BikeState> ? _value.color : color // ignore: cast_nullable_to_non_nullable as int, + battery: null == battery + ? _value.battery + : battery // ignore: cast_nullable_to_non_nullable + as double, + odometer: null == odometer + ? _value.odometer + : odometer // ignore: cast_nullable_to_non_nullable + as double, + speedMetric: null == speedMetric + ? _value.speedMetric + : speedMetric // ignore: cast_nullable_to_non_nullable + as String, ) as $Val); } } @@ -150,7 +171,10 @@ abstract class _$$BikeStateImplCopyWith<$Res> String name, BikeRegion? region, bool modeLock, - int color}); + int color, + double battery, + double odometer, + String speedMetric}); } /// @nodoc @@ -175,6 +199,9 @@ class __$$BikeStateImplCopyWithImpl<$Res> Object? region = freezed, Object? modeLock = null, Object? color = null, + Object? battery = null, + Object? odometer = null, + Object? speedMetric = null, }) { return _then(_$BikeStateImpl( id: null == id @@ -221,6 +248,18 @@ class __$$BikeStateImplCopyWithImpl<$Res> ? _value.color : color // ignore: cast_nullable_to_non_nullable as int, + battery: null == battery + ? _value.battery + : battery // ignore: cast_nullable_to_non_nullable + as double, + odometer: null == odometer + ? _value.odometer + : odometer // ignore: cast_nullable_to_non_nullable + as double, + speedMetric: null == speedMetric + ? _value.speedMetric + : speedMetric // ignore: cast_nullable_to_non_nullable + as String, )); } } @@ -239,12 +278,19 @@ class _$BikeStateImpl extends _BikeState { required this.name, this.region, this.modeLock = false, - this.color = 0}) + this.color = 0, + this.battery = 0.0, + this.odometer = 0, + this.speedMetric = 'metric'}) : assert(mode <= 3), assert(mode >= 0), assert(assist >= 0), assert(assist <= 4), assert(color >= 0), + assert(battery >= 0), + assert(battery <= 100), + assert(odometer >= 0), + assert(speedMetric == "metric" || speedMetric == "imperial"), super._(); factory _$BikeStateImpl.fromJson(Map json) => @@ -277,10 +323,19 @@ class _$BikeStateImpl extends _BikeState { @override @JsonKey() final int color; + @override + @JsonKey() + final double battery; + @override + @JsonKey() + final double odometer; + @override + @JsonKey() + final String speedMetric; @override String toString() { - return 'BikeState(id: $id, mode: $mode, modeLocked: $modeLocked, light: $light, lightLocked: $lightLocked, assist: $assist, assistLocked: $assistLocked, name: $name, region: $region, modeLock: $modeLock, color: $color)'; + return 'BikeState(id: $id, mode: $mode, modeLocked: $modeLocked, light: $light, lightLocked: $lightLocked, assist: $assist, assistLocked: $assistLocked, name: $name, region: $region, modeLock: $modeLock, color: $color, battery: $battery, odometer: $odometer, speedMetric: $speedMetric)'; } @override @@ -302,13 +357,32 @@ class _$BikeStateImpl extends _BikeState { (identical(other.region, region) || other.region == region) && (identical(other.modeLock, modeLock) || other.modeLock == modeLock) && - (identical(other.color, color) || other.color == color)); + (identical(other.color, color) || other.color == color) && + (identical(other.battery, battery) || other.battery == battery) && + (identical(other.odometer, odometer) || + other.odometer == odometer) && + (identical(other.speedMetric, speedMetric) || + other.speedMetric == speedMetric)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash(runtimeType, id, mode, modeLocked, light, - lightLocked, assist, assistLocked, name, region, modeLock, color); + int get hashCode => Object.hash( + runtimeType, + id, + mode, + modeLocked, + light, + lightLocked, + assist, + assistLocked, + name, + region, + modeLock, + color, + battery, + odometer, + speedMetric); @JsonKey(ignore: true) @override @@ -336,7 +410,10 @@ abstract class _BikeState extends BikeState { required final String name, final BikeRegion? region, final bool modeLock, - final int color}) = _$BikeStateImpl; + final int color, + final double battery, + final double odometer, + final String speedMetric}) = _$BikeStateImpl; const _BikeState._() : super._(); factory _BikeState.fromJson(Map json) = @@ -365,6 +442,12 @@ abstract class _BikeState extends BikeState { @override int get color; @override + double get battery; + @override + double get odometer; + @override + String get speedMetric; + @override @JsonKey(ignore: true) _$$BikeStateImplCopyWith<_$BikeStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/models.g.dart b/lib/models.g.dart index bdc9287..f393bd3 100644 --- a/lib/models.g.dart +++ b/lib/models.g.dart @@ -19,6 +19,9 @@ _$BikeStateImpl _$$BikeStateImplFromJson(Map json) => region: $enumDecodeNullable(_$BikeRegionEnumMap, json['region']), modeLock: json['modeLock'] as bool? ?? false, color: (json['color'] as num?)?.toInt() ?? 0, + battery: (json['battery'] as num?)?.toDouble() ?? 0.0, + odometer: (json['odometer'] as num?)?.toDouble() ?? 0, + speedMetric: json['speedMetric'] as String? ?? 'metric', ); Map _$$BikeStateImplToJson(_$BikeStateImpl instance) => @@ -34,6 +37,9 @@ Map _$$BikeStateImplToJson(_$BikeStateImpl instance) => 'region': _$BikeRegionEnumMap[instance.region], 'modeLock': instance.modeLock, 'color': instance.color, + 'battery': instance.battery, + 'odometer': instance.odometer, + 'speedMetric': instance.speedMetric, }; const _$BikeRegionEnumMap = { diff --git a/lib/repository.dart b/lib/repository.dart index de3479a..055ac58 100644 --- a/lib/repository.dart +++ b/lib/repository.dart @@ -80,7 +80,6 @@ BluetoothRepository bluetoothRepository(BluetoothRepositoryRef ref) => BluetoothRepository(ref); class BluetoothRepository { - final currentStateId = [3, 0]; Ref ref; BluetoothRepository(this.ref) { @@ -246,7 +245,34 @@ class BluetoothRepository { return null; } - Future?> readCurrentState(String deviceId) async { + Future?> readCurrentState(String deviceId, String stateName) async { + List currentStateId; + + //https://www.reverse.bike/documentation/bluetooth-ble/metrics-service + switch (stateName) { + case 'MOTION': + currentStateId = [2, 1]; + break; + case 'TOTAL': + currentStateId = [2, 2]; + break; + case 'RIDE': + currentStateId = [2, 3]; + break; + case 'SETTING': + currentStateId = [3, 0]; + break; + case 'POWER': + currentStateId = [4, 1]; + break; + case 'MYSTERY': + currentStateId = [0, 0]; + break; + default: + currentStateId = [3, 0]; + break; + } + // Set the char register to the right mode to get the current state. await write(deviceId, data: currentStateId,