mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
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
This commit is contained in:
parent
304c389669
commit
061b715694
8 changed files with 253 additions and 51 deletions
|
|
@ -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<String, bool> _contactSmazEnabled = {};
|
||||
final Set<String> _knownContactKeys = {};
|
||||
final Map<String, int> _contactUnreadCount = {};
|
||||
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
|
||||
bool _unreadStateLoaded = false;
|
||||
final Map<String, _RepeaterAckContext> _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<Message> 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;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class AppSettings {
|
|||
final String? languageOverride; // null = system default
|
||||
final bool appDebugLogEnabled;
|
||||
final Map<String, String> batteryChemistryByDeviceId;
|
||||
final Map<String, String> batteryChemistryByRepeaterId;
|
||||
final UnitSystem unitSystem;
|
||||
|
||||
AppSettings({
|
||||
|
|
@ -57,8 +58,10 @@ class AppSettings {
|
|||
this.languageOverride,
|
||||
this.appDebugLogEnabled = false,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
Map<String, String>? batteryChemistryByRepeaterId,
|
||||
this.unitSystem = UnitSystem.metric,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {};
|
||||
|
||||
Map<String, dynamic> 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<String, String>? batteryChemistryByDeviceId,
|
||||
Map<String, String>? 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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppSettingsService>();
|
||||
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<String>(
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<RepeaterStatusScreen> {
|
|||
_dupDirect = directDups;
|
||||
_dupFlood = floodDups;
|
||||
});
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.updateRepeaterBatterySnapshot(
|
||||
widget.repeater.publicKeyHex,
|
||||
batteryMv,
|
||||
source: 'status_binary',
|
||||
);
|
||||
_recordStatusResult(true);
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +209,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||
_uptimeSecs = _asInt(data['uptime_secs']);
|
||||
_queueLen = _asInt(data['queue_len']);
|
||||
_debugFlags = _asInt(data['errors']);
|
||||
final batteryMv = _batteryMv;
|
||||
if (batteryMv != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(
|
||||
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<RepeaterStatusScreen> {
|
|||
}
|
||||
|
||||
String _batteryText() {
|
||||
if (_batteryMv == null) return '—';
|
||||
final percent = _batteryPercentFromMv(_batteryMv!);
|
||||
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
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<AppSettingsService>();
|
||||
return settingsService.batteryChemistryForRepeater(
|
||||
widget.repeater.publicKeyHex,
|
||||
);
|
||||
}
|
||||
|
||||
String _clockText() {
|
||||
|
|
|
|||
|
|
@ -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<TelemetryScreen> {
|
|||
}
|
||||
|
||||
void _handleStatusResponse(Uint8List frame) {
|
||||
final parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
||||
final batteryMv = _extractTelemetryBatteryMillivolts(parsedTelemetry);
|
||||
if (batteryMv != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(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<TelemetryScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
String _batteryText(double? batteryMv) {
|
||||
int? _extractTelemetryBatteryMillivolts(List<Map<String, dynamic>> entries) {
|
||||
for (final entry in entries) {
|
||||
if (entry['channel'] != 1) continue;
|
||||
final values = entry['values'];
|
||||
if (values is! Map<String, dynamic>) 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<MeshCoreConnector>();
|
||||
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<AppSettingsService>();
|
||||
return settingsService.batteryChemistryForRepeater(
|
||||
widget.repeater.publicKeyHex,
|
||||
);
|
||||
}
|
||||
|
||||
String _temperatureText(double? tempC, bool isImperialUnits) {
|
||||
|
|
|
|||
|
|
@ -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<void> loadSettings() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_settingsKey);
|
||||
|
|
@ -133,13 +139,20 @@ class AppSettingsService extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> setBatteryChemistryForRepeater(
|
||||
String repeaterPubKeyHex,
|
||||
String chemistry,
|
||||
) async {
|
||||
final updated = Map<String, String>.from(
|
||||
_settings.batteryChemistryByRepeaterId,
|
||||
);
|
||||
updated[repeaterPubKeyHex] = chemistry;
|
||||
await updateSettings(
|
||||
_settings.copyWith(batteryChemistryByRepeaterId: updated),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setUnitSystem(UnitSystem value) async {
|
||||
await updateSettings(_settings.copyWith(unitSystem: value));
|
||||
}
|
||||
|
||||
Future<void> setLosUnitSystem(String value) async {
|
||||
await setUnitSystem(
|
||||
value == 'imperial' ? UnitSystem.imperial : UnitSystem.metric,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
lib/utils/battery_utils.dart
Normal file
26
lib/utils/battery_utils.dart
Normal file
|
|
@ -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);
|
||||
}
|
||||
23
test/utils/battery_utils_test.dart
Normal file
23
test/utils/battery_utils_test.dart
Normal file
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue