From 061b7156946e51ae3e98acfdb9b85cfdb2ba5bdf Mon Sep 17 00:00:00 2001 From: just_stuff_tm <133525672+just-stuff-tm@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:54:39 -0500 Subject: [PATCH] Fix repeater battery % inconsistency and add configurable repeater battery chemistry (#199) * fix(repeater): unify battery percentage math and add repeater chemistry setting - Add shared battery percent utility used by connector, repeater status, and telemetry - Add repeater-specific battery chemistry persistence and service accessors - Add repeater chemistry selector in Repeater Hub - Ensure telemetry and status compute percentages consistently from same chemistry - Add focused battery utility tests Refs #116 Refs #174 * fix: Flutter Analyzer Errors fixed Recent Merge Compatible * Unify repeater battery source across status and telemetry --- lib/connector/meshcore_connector.dart | 60 +++++++++++++++--------- lib/models/app_settings.dart | 18 +++++-- lib/screens/repeater_hub_screen.dart | 62 +++++++++++++++++++++++++ lib/screens/repeater_status_screen.dart | 44 ++++++++++++++---- lib/screens/telemetry_screen.dart | 46 ++++++++++++++---- lib/services/app_settings_service.dart | 25 +++++++--- lib/utils/battery_utils.dart | 26 +++++++++++ test/utils/battery_utils_test.dart | 23 +++++++++ 8 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 lib/utils/battery_utils.dart create mode 100644 test/utils/battery_utils_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 9b256e2..10ee15b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -29,6 +29,7 @@ import '../storage/contact_store.dart'; import '../storage/message_store.dart'; import '../storage/unread_store.dart'; import '../utils/app_logger.dart'; +import '../utils/battery_utils.dart'; import 'meshcore_protocol.dart'; class MeshCoreUuids { @@ -81,6 +82,18 @@ enum MeshCoreConnectionState { disconnecting, } +class RepeaterBatterySnapshot { + final int millivolts; + final DateTime updatedAt; + final String source; + + const RepeaterBatterySnapshot({ + required this.millivolts, + required this.updatedAt, + required this.source, + }); +} + class MeshCoreConnector extends ChangeNotifier { // Message windowing to limit memory usage static const int _messageWindowSize = 200; @@ -187,6 +200,7 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactSmazEnabled = {}; final Set _knownContactKeys = {}; final Map _contactUnreadCount = {}; + final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; @@ -254,10 +268,32 @@ class MeshCoreConnector extends ChangeNotifier { : 0; int? get batteryPercent => _batteryMillivolts == null ? null - : _estimateBatteryPercent( + : estimateBatteryPercentFromMillivolts( _batteryMillivolts!, _batteryChemistryForDevice(), ); + RepeaterBatterySnapshot? getRepeaterBatterySnapshot(String contactKeyHex) => + _repeaterBatterySnapshots[contactKeyHex]; + int? getRepeaterBatteryMillivolts(String contactKeyHex) => + _repeaterBatterySnapshots[contactKeyHex]?.millivolts; + + void updateRepeaterBatterySnapshot( + String contactKeyHex, + int millivolts, { + String source = 'unknown', + }) { + if (contactKeyHex.isEmpty || millivolts <= 0) return; + final previous = _repeaterBatterySnapshots[contactKeyHex]; + final snapshot = RepeaterBatterySnapshot( + millivolts: millivolts, + updatedAt: DateTime.now(), + source: source, + ); + _repeaterBatterySnapshots[contactKeyHex] = snapshot; + if (previous?.millivolts != millivolts) { + notifyListeners(); + } + } String _batteryChemistryForDevice() { final deviceId = _device?.remoteId.toString(); @@ -265,27 +301,6 @@ class MeshCoreConnector extends ChangeNotifier { return _appSettingsService!.batteryChemistryForDevice(deviceId); } - int _estimateBatteryPercent(int millivolts, String chemistry) { - final range = _batteryVoltageRange(chemistry); - final minMv = range.$1; - final maxMv = range.$2; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); - } - - (int, int) _batteryVoltageRange(String chemistry) { - switch (chemistry) { - case 'lifepo4': - return (2600, 3650); - case 'lipo': - return (3000, 4200); - case 'nmc': - default: - return (3000, 4200); - } - } - List getMessages(Contact contact) { return _conversations[contact.publicKeyHex] ?? []; } @@ -961,6 +976,7 @@ class MeshCoreConnector extends ChangeNotifier { _clientRepeat = null; _firmwareVerCode = null; _batteryMillivolts = null; + _repeaterBatterySnapshots.clear(); _batteryRequested = false; _awaitingSelfInfo = false; _maxContacts = _defaultMaxContacts; diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 229a7a6..71cafa0 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -34,6 +34,7 @@ class AppSettings { final String? languageOverride; // null = system default final bool appDebugLogEnabled; final Map batteryChemistryByDeviceId; + final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; AppSettings({ @@ -57,8 +58,10 @@ class AppSettings { this.languageOverride, this.appDebugLogEnabled = false, Map? batteryChemistryByDeviceId, + Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, - }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; + }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, + batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}; Map toJson() { return { @@ -82,6 +85,7 @@ class AppSettings { 'language_override': languageOverride, 'app_debug_log_enabled': appDebugLogEnabled, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, + 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, }; } @@ -124,9 +128,12 @@ class AppSettings { (key, value) => MapEntry(key.toString(), value.toString()), ) ?? {}, - unitSystem: parseUnitSystem( - json['unit_system'] ?? json['los_unit_system'], - ), + batteryChemistryByRepeaterId: + (json['battery_chemistry_by_repeater_id'] as Map?)?.map( + (key, value) => MapEntry(key.toString(), value.toString()), + ) ?? + {}, + unitSystem: parseUnitSystem(json['unit_system']), ); } @@ -151,6 +158,7 @@ class AppSettings { Object? languageOverride = _unset, bool? appDebugLogEnabled, Map? batteryChemistryByDeviceId, + Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, }) { return AppSettings( @@ -181,6 +189,8 @@ class AppSettings { appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, + batteryChemistryByRepeaterId: + batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, ); } diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index a5f503d..fd2da8e 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../services/app_settings_service.dart'; import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; @@ -21,6 +23,10 @@ class RepeaterHubScreen extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final settingsService = context.watch(); + final chemistry = settingsService.batteryChemistryForRepeater( + repeater.publicKeyHex, + ); return Scaffold( appBar: AppBar( title: Column( @@ -107,6 +113,62 @@ class RepeaterHubScreen extends StatelessWidget { ), ), const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.battery_full), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.appSettings_batteryChemistry, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: chemistry, + isExpanded: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + if (value == null) return; + settingsService.setBatteryChemistryForRepeater( + repeater.publicKeyHex, + value, + ); + }, + items: [ + DropdownMenuItem( + value: 'nmc', + child: Text(l10n.appSettings_batteryNmc), + ), + DropdownMenuItem( + value: 'lifepo4', + child: Text(l10n.appSettings_batteryLifepo4), + ), + DropdownMenuItem( + value: 'lipo', + child: Text(l10n.appSettings_batteryLipo), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 24), Text( l10n.repeater_managementTools, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index 472b013..95254f4 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -8,7 +8,9 @@ import '../models/contact.dart'; import '../models/path_selection.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; +import '../utils/battery_utils.dart'; import '../widgets/path_management_dialog.dart'; class RepeaterStatusScreen extends StatefulWidget { @@ -179,6 +181,12 @@ class _RepeaterStatusScreenState extends State { _dupDirect = directDups; _dupFlood = floodDups; }); + final connector = Provider.of(context, listen: false); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'status_binary', + ); _recordStatusResult(true); } @@ -201,6 +209,18 @@ class _RepeaterStatusScreenState extends State { _uptimeSecs = _asInt(data['uptime_secs']); _queueLen = _asInt(data['queue_len']); _debugFlags = _asInt(data['errors']); + final batteryMv = _batteryMv; + if (batteryMv != null) { + final connector = Provider.of( + context, + listen: false, + ); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'status_text', + ); + } } else if (data.containsKey('noise_floor')) { _noiseFloor = _asInt(data['noise_floor']); _lastRssi = _asInt(data['last_rssi']); @@ -590,18 +610,24 @@ class _RepeaterStatusScreenState extends State { } String _batteryText() { - if (_batteryMv == null) return '—'; - final percent = _batteryPercentFromMv(_batteryMv!); - final volts = (_batteryMv! / 1000.0).toStringAsFixed(2); + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + _batteryMv; + if (batteryMv == null) return '—'; + final percent = estimateBatteryPercentFromMillivolts( + batteryMv, + _batteryChemistry(), + ); + final volts = (batteryMv / 1000.0).toStringAsFixed(2); return '$percent% / ${volts}V'; } - int _batteryPercentFromMv(int millivolts) { - const minMv = 3000; - const maxMv = 4200; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + String _batteryChemistry() { + final settingsService = context.read(); + return settingsService.batteryChemistryForRepeater( + widget.repeater.publicKeyHex, + ); } String _clockText() { diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 88c204d..3f95ccd 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -12,6 +12,7 @@ import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; import '../helpers/cayenne_lpp.dart'; +import '../utils/battery_utils.dart'; class TelemetryScreen extends StatefulWidget { final Contact repeater; @@ -74,9 +75,19 @@ class _TelemetryScreenState extends State { } void _handleStatusResponse(Uint8List frame) { + final parsedTelemetry = CayenneLpp.parseByChannel(frame); + final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry); + if (batteryMv != null) { + final connector = Provider.of(context, listen: false); + connector.updateRepeaterBatterySnapshot( + widget.repeater.publicKeyHex, + batteryMv, + source: 'telemetry', + ); + } if (!mounted) return; setState(() { - _parsedTelemetry = CayenneLpp.parseByChannel(frame); + _parsedTelemetry = parsedTelemetry; }); ScaffoldMessenger.of(context).showSnackBar( @@ -411,20 +422,35 @@ class _TelemetryScreenState extends State { ); } - String _batteryText(double? batteryMv) { + int? _extractTelemetryBatteryMillivolts(List> entries) { + for (final entry in entries) { + if (entry['channel'] != 1) continue; + final values = entry['values']; + if (values is! Map) continue; + final voltage = values['voltage']; + if (voltage is num) return (voltage.toDouble() * 1000).round(); + } + return null; + } + + String _batteryText(double? telemetryVolts) { final l10n = context.l10n; + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + (telemetryVolts == null ? null : (telemetryVolts * 1000).round()); if (batteryMv == null) return l10n.common_notAvailable; - final percent = _batteryPercentFromMv(batteryMv); - final volts = batteryMv.toStringAsFixed(2); + final chemistry = _batteryChemistry(); + final percent = estimateBatteryPercentFromMillivolts(batteryMv, chemistry); + final volts = (batteryMv / 1000).toStringAsFixed(2); return l10n.telemetry_batteryValue(percent, volts); } - int _batteryPercentFromMv(double millivolts) { - const minMv = 2.800; - const maxMv = 4.200; - if (millivolts <= minMv) return 0; - if (millivolts >= maxMv) return 100; - return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + String _batteryChemistry() { + final settingsService = context.read(); + return settingsService.batteryChemistryForRepeater( + widget.repeater.publicKeyHex, + ); } String _temperatureText(double? tempC, bool isImperialUnits) { diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index a85ab92..e131eb8 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -17,6 +17,12 @@ class AppSettingsService extends ChangeNotifier { return stored ?? 'nmc'; } + String batteryChemistryForRepeater(String repeaterPubKeyHex) { + final stored = _settings.batteryChemistryByRepeaterId[repeaterPubKeyHex]; + if (stored == 'liion') return 'nmc'; + return stored ?? 'nmc'; + } + Future loadSettings() async { final prefs = PrefsManager.instance; final jsonStr = prefs.getString(_settingsKey); @@ -133,13 +139,20 @@ class AppSettingsService extends ChangeNotifier { ); } + Future setBatteryChemistryForRepeater( + String repeaterPubKeyHex, + String chemistry, + ) async { + final updated = Map.from( + _settings.batteryChemistryByRepeaterId, + ); + updated[repeaterPubKeyHex] = chemistry; + await updateSettings( + _settings.copyWith(batteryChemistryByRepeaterId: updated), + ); + } + Future setUnitSystem(UnitSystem value) async { await updateSettings(_settings.copyWith(unitSystem: value)); } - - Future setLosUnitSystem(String value) async { - await setUnitSystem( - value == 'imperial' ? UnitSystem.imperial : UnitSystem.metric, - ); - } } diff --git a/lib/utils/battery_utils.dart b/lib/utils/battery_utils.dart new file mode 100644 index 0000000..2bf4a5d --- /dev/null +++ b/lib/utils/battery_utils.dart @@ -0,0 +1,26 @@ +typedef BatteryVoltageRange = ({int minMv, int maxMv}); + +BatteryVoltageRange batteryVoltageRange(String chemistry) { + switch (chemistry) { + case 'lifepo4': + return (minMv: 2600, maxMv: 3650); + case 'lipo': + return (minMv: 3000, maxMv: 4200); + case 'nmc': + default: + return (minMv: 3000, maxMv: 4200); + } +} + +int estimateBatteryPercentFromMillivolts(int millivolts, String chemistry) { + final range = batteryVoltageRange(chemistry); + if (millivolts <= range.minMv) return 0; + if (millivolts >= range.maxMv) return 100; + return (((millivolts - range.minMv) * 100) / (range.maxMv - range.minMv)) + .round(); +} + +int estimateBatteryPercentFromVolts(double volts, String chemistry) { + final millivolts = (volts * 1000).round(); + return estimateBatteryPercentFromMillivolts(millivolts, chemistry); +} diff --git a/test/utils/battery_utils_test.dart b/test/utils/battery_utils_test.dart new file mode 100644 index 0000000..65dec1e --- /dev/null +++ b/test/utils/battery_utils_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/utils/battery_utils.dart'; + +void main() { + group('battery utils', () { + test('nmc range maps 3.0V to 0% and 4.2V to 100%', () { + expect(estimateBatteryPercentFromVolts(3.0, 'nmc'), 0); + expect(estimateBatteryPercentFromVolts(4.2, 'nmc'), 100); + }); + + test('lifepo4 range maps 2.6V to 0% and 3.65V to 100%', () { + expect(estimateBatteryPercentFromVolts(2.6, 'lifepo4'), 0); + expect(estimateBatteryPercentFromVolts(3.65, 'lifepo4'), 100); + }); + + test('unknown chemistry falls back to nmc mapping', () { + expect( + estimateBatteryPercentFromMillivolts(3600, 'unknown'), + estimateBatteryPercentFromMillivolts(3600, 'nmc'), + ); + }); + }); +}