feat: ui rework and gear generator
This commit is contained in:
91
lib/model/gear_configurator.dart
Normal file
91
lib/model/gear_configurator.dart
Normal file
@ -0,0 +1,91 @@
|
||||
import 'package:abawo_bt_app/model/gear_ratio_codec.dart';
|
||||
|
||||
enum GearRetentionMode { keepHighest, keepAll }
|
||||
|
||||
class GearConfiguratorCalculation {
|
||||
const GearConfiguratorCalculation({
|
||||
required this.ratios,
|
||||
required this.discardedBelowRange,
|
||||
required this.discardedAboveRange,
|
||||
required this.duplicateCount,
|
||||
required this.truncatedCount,
|
||||
});
|
||||
|
||||
final List<double> ratios;
|
||||
final int discardedBelowRange;
|
||||
final int discardedAboveRange;
|
||||
final int duplicateCount;
|
||||
final int truncatedCount;
|
||||
}
|
||||
|
||||
GearConfiguratorCalculation calculateGearRatios({
|
||||
required List<int> chainrings,
|
||||
required List<int> sprockets,
|
||||
required GearRetentionMode mode,
|
||||
int maxRatios = 32,
|
||||
}) {
|
||||
final sortedChainrings = List<int>.from(chainrings)..sort();
|
||||
final sortedSprockets = List<int>.from(sprockets)..sort((a, b) => b - a);
|
||||
|
||||
var discardedBelowRange = 0;
|
||||
var discardedAboveRange = 0;
|
||||
var duplicateCount = 0;
|
||||
final encoded = <int>{};
|
||||
final ratios = <double>[];
|
||||
|
||||
void addRatio(double ratio) {
|
||||
if (ratio < gearRatioMin) {
|
||||
discardedBelowRange++;
|
||||
return;
|
||||
}
|
||||
if (ratio > gearRatioMax) {
|
||||
discardedAboveRange++;
|
||||
return;
|
||||
}
|
||||
|
||||
final raw = encodeGearRatioByte(ratio);
|
||||
if (!encoded.add(raw)) {
|
||||
duplicateCount++;
|
||||
return;
|
||||
}
|
||||
ratios.add(decodeGearRatioByte(raw));
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case GearRetentionMode.keepAll:
|
||||
for (final chainring in sortedChainrings) {
|
||||
for (final sprocket in sortedSprockets) {
|
||||
addRatio(chainring / sprocket);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case GearRetentionMode.keepHighest:
|
||||
var threshold = double.negativeInfinity;
|
||||
for (final chainring in sortedChainrings) {
|
||||
final beforeLength = ratios.length;
|
||||
for (final sprocket in sortedSprockets) {
|
||||
final ratio = chainring / sprocket;
|
||||
if (ratio >= threshold) {
|
||||
addRatio(ratio);
|
||||
}
|
||||
}
|
||||
if (ratios.length > beforeLength) {
|
||||
threshold = ratios.reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
ratios.sort();
|
||||
final truncatedCount =
|
||||
ratios.length > maxRatios ? ratios.length - maxRatios : 0;
|
||||
final limited = ratios.take(maxRatios).toList(growable: false);
|
||||
|
||||
return GearConfiguratorCalculation(
|
||||
ratios: limited,
|
||||
discardedBelowRange: discardedBelowRange,
|
||||
discardedAboveRange: discardedAboveRange,
|
||||
duplicateCount: duplicateCount,
|
||||
truncatedCount: truncatedCount,
|
||||
);
|
||||
}
|
||||
31
lib/model/gear_ratio_codec.dart
Normal file
31
lib/model/gear_ratio_codec.dart
Normal file
@ -0,0 +1,31 @@
|
||||
const double gearRatioMin = 0.25;
|
||||
const double gearRatioMax = 5.75;
|
||||
const int gearRatioEmptyRaw = 0;
|
||||
const int gearRatioMinRaw = 1;
|
||||
const int gearRatioMaxRaw = 255;
|
||||
|
||||
const double gearRatioStep =
|
||||
(gearRatioMax - gearRatioMin) / (gearRatioMaxRaw - gearRatioMinRaw);
|
||||
|
||||
int encodeGearRatioByte(double value) {
|
||||
if (value <= 0) {
|
||||
return gearRatioEmptyRaw;
|
||||
}
|
||||
final clamped = value.clamp(gearRatioMin, gearRatioMax);
|
||||
final scaled = ((clamped - gearRatioMin) / gearRatioStep).round();
|
||||
return (gearRatioMinRaw + scaled)
|
||||
.clamp(gearRatioMinRaw, gearRatioMaxRaw)
|
||||
.toInt();
|
||||
}
|
||||
|
||||
double decodeGearRatioByte(int raw) {
|
||||
if (raw <= gearRatioEmptyRaw) {
|
||||
return 0;
|
||||
}
|
||||
final clamped = raw.clamp(gearRatioMinRaw, gearRatioMaxRaw).toInt();
|
||||
return gearRatioMin + (clamped - gearRatioMinRaw) * gearRatioStep;
|
||||
}
|
||||
|
||||
double quantizeGearRatio(double value) {
|
||||
return decodeGearRatioByte(encodeGearRatioByte(value));
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cbor/simple.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
const String universalShifterControlServiceUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
|
||||
@ -19,6 +17,13 @@ const String universalShifterDfuDataCharacteristicUuid =
|
||||
const String universalShifterDfuAckCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||
const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb';
|
||||
const String batteryLevelCharacteristicUuid =
|
||||
'00002a19-0000-1000-8000-00805f9b34fb';
|
||||
const String deviceInformationServiceUuid =
|
||||
'0000180a-0000-1000-8000-00805f9b34fb';
|
||||
const String firmwareRevisionCharacteristicUuid =
|
||||
'00002a26-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
const int universalShifterDfuOpcodeStart = 0x01;
|
||||
const int universalShifterDfuOpcodeFinish = 0x02;
|
||||
@ -239,6 +244,49 @@ enum UniversalShifterCommand {
|
||||
final int value;
|
||||
}
|
||||
|
||||
class ShifterDeviceTelemetry {
|
||||
const ShifterDeviceTelemetry({
|
||||
this.batteryPercent,
|
||||
this.firmwareRevision,
|
||||
});
|
||||
|
||||
final int? batteryPercent;
|
||||
final String? firmwareRevision;
|
||||
|
||||
String get batteryLabel => batteryPercent == null ? '--' : '$batteryPercent%';
|
||||
|
||||
String get firmwareLabel => firmwareRevision ?? '--';
|
||||
|
||||
ShifterDeviceTelemetry merge(ShifterDeviceTelemetry next) {
|
||||
return ShifterDeviceTelemetry(
|
||||
batteryPercent: next.batteryPercent ?? batteryPercent,
|
||||
firmwareRevision: next.firmwareRevision ?? firmwareRevision,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int parseBatteryLevelPercent(List<int> payload) {
|
||||
if (payload.isEmpty) {
|
||||
throw const FormatException('Battery level payload is empty.');
|
||||
}
|
||||
|
||||
final value = payload.first;
|
||||
if (value < 0 || value > 100) {
|
||||
throw FormatException('Battery level out of range: $value.');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
String? parseGattUtf8String(List<int> payload) {
|
||||
if (payload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final value = utf8.decode(payload).replaceAll('\u0000', '').trim();
|
||||
return value.isEmpty ? null : value;
|
||||
}
|
||||
|
||||
enum ControlConnectionState {
|
||||
disconnected,
|
||||
connected;
|
||||
@ -412,58 +460,50 @@ class CentralStatus {
|
||||
);
|
||||
}
|
||||
|
||||
static CentralStatus fromCborBytes(List<int> bytes) {
|
||||
static CentralStatus fromBytes(List<int> bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
throw const FormatException('Status payload is empty.');
|
||||
}
|
||||
|
||||
final decoded = cbor.decode(bytes);
|
||||
if (decoded is! Map) {
|
||||
if (bytes.length != 12) {
|
||||
throw FormatException(
|
||||
'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.',
|
||||
'Status payload must be 12 bytes, got ${bytes.length}.',
|
||||
);
|
||||
}
|
||||
|
||||
final controlRaw = _readMapValue(decoded, [0, 'control']);
|
||||
final trainerRaw = _readMapValue(decoded, [1, 'trainer']);
|
||||
final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']);
|
||||
final connectedTrainerAddrRaw =
|
||||
_readMapValue(decoded, [3, 'connected_trainer_addr']);
|
||||
final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']);
|
||||
final protocolVersion = bytes[0];
|
||||
if (protocolVersion != 1) {
|
||||
throw FormatException(
|
||||
'Unsupported status protocol version: $protocolVersion.',
|
||||
);
|
||||
}
|
||||
|
||||
final flags = bytes[4];
|
||||
final hasSavedBond = (flags & 0x01) != 0;
|
||||
final hasConnectedTrainerAddr = (flags & 0x02) != 0;
|
||||
final hasLastFailure = (flags & 0x04) != 0;
|
||||
final trainerErrorCode = bytes[3] == 0 ? null : bytes[3];
|
||||
final trainer = bytes[2] == 6
|
||||
? TrainerStatus(
|
||||
state: TrainerConnectionState.error,
|
||||
errorCode: trainerErrorCode,
|
||||
)
|
||||
: TrainerStatus.fromRaw(bytes[2]);
|
||||
final connectedTrainerAddr = hasConnectedTrainerAddr
|
||||
? bytes.sublist(5, 11).toList(growable: false)
|
||||
: null;
|
||||
final lastFailure = hasLastFailure && bytes[11] != 0 ? bytes[11] : null;
|
||||
|
||||
return CentralStatus(
|
||||
control: ControlConnectionState.fromRaw(controlRaw),
|
||||
trainer: TrainerStatus.fromRaw(trainerRaw),
|
||||
hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false,
|
||||
connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw),
|
||||
lastFailure: lastFailureRaw is int ? lastFailureRaw : null,
|
||||
raw: decoded,
|
||||
control: ControlConnectionState.fromRaw(bytes[1]),
|
||||
trainer: trainer,
|
||||
hasSavedBond: hasSavedBond,
|
||||
connectedTrainerAddr: connectedTrainerAddr,
|
||||
lastFailure: lastFailure,
|
||||
raw: bytes.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _readMapValue(Map<dynamic, dynamic> map, List<dynamic> keys) {
|
||||
for (final key in keys) {
|
||||
if (map.containsKey(key)) {
|
||||
return map[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<int>? _toByteList(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is Uint8List) {
|
||||
return value.toList(growable: false);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.whereType<int>().toList(growable: false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<int> parseMacToLittleEndianBytes(String macAddress) {
|
||||
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
|
||||
if (compact.length != 12) {
|
||||
|
||||
Reference in New Issue
Block a user