Merge branch 'zjs81:main' into issue-fix-channel-edit-delete-actions

This commit is contained in:
just_stuff_tm 2026-02-22 00:04:28 -05:00 committed by GitHub
commit bb8ad70cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 508 additions and 133 deletions

View file

@ -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;
@ -101,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier {
final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {};
final List<String> _pendingChannelSentQueue = [];
final List<_PendingCommandAck> _pendingGenericAckQueue = [];
static const String _reactionSendQueuePrefix = '__reaction_send__';
int _reactionSendQueueSequence = 0;
final Set<String> _loadedConversationKeys = {};
final Map<int, Set<String>> _processedChannelReactions =
{}; // channelIndex -> Set of "targetHash_emoji"
@ -187,6 +204,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 +272,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 +305,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 +980,7 @@ class MeshCoreConnector extends ChangeNotifier {
_clientRepeat = null;
_firmwareVerCode = null;
_batteryMillivolts = null;
_repeaterBatterySnapshots.clear();
_batteryRequested = false;
_awaitingSelfInfo = false;
_maxContacts = _defaultMaxContacts;
@ -972,6 +992,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isSyncingChannels = false;
_channelSyncInFlight = false;
_hasLoadedChannels = false;
_pendingChannelSentQueue.clear();
_pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected);
if (!manual) {
@ -979,7 +1002,11 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<void> sendFrame(Uint8List data) async {
Future<void> sendFrame(
Uint8List data, {
String? channelSendQueueId,
bool expectsGenericAck = false,
}) async {
if (!isConnected || _rxCharacteristic == null) {
throw Exception("Not connected to a MeshCore device");
}
@ -998,6 +1025,11 @@ class MeshCoreConnector extends ChangeNotifier {
data.toList(),
withoutResponse: canWriteWithoutResponse,
);
_trackPendingGenericAck(
data,
channelSendQueueId: channelSendQueueId,
expectsGenericAck: expectsGenericAck,
);
}
Future<void> requestBatteryStatus({bool force = false}) async {
@ -1353,7 +1385,13 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
// Send the reaction to the device (don't add as a visible message)
await sendFrame(buildSendChannelTextMsgFrame(channel.index, text));
final reactionQueueId = _nextReactionSendQueueId();
_pendingChannelSentQueue.add(reactionQueueId);
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, text),
channelSendQueueId: reactionQueueId,
expectsGenericAck: true,
);
return;
}
@ -1363,6 +1401,7 @@ class MeshCoreConnector extends ChangeNotifier {
channel.index,
);
_addChannelMessage(channel.index, message);
_pendingChannelSentQueue.add(message.messageId);
notifyListeners();
final trimmed = text.trim();
@ -1372,7 +1411,11 @@ class MeshCoreConnector extends ChangeNotifier {
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
? Smaz.encodeIfSmaller(text)
: text;
await sendFrame(buildSendChannelTextMsgFrame(channel.index, outboundText));
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, outboundText),
channelSendQueueId: message.messageId,
expectsGenericAck: true,
);
}
Future<void> removeContact(Contact contact) async {
@ -1719,6 +1762,9 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('RX frame: code=$code len=${frame.length}');
switch (code) {
case respCodeOk:
_handleOk();
break;
case respCodeDeviceInfo:
_handleDeviceInfo(frame);
break;
@ -1813,6 +1859,17 @@ class MeshCoreConnector extends ChangeNotifier {
'Firmware responded with error code: $errCode',
tag: 'Protocol',
);
if (_pendingGenericAckQueue.isEmpty) {
return;
}
final failedAck = _pendingGenericAckQueue.removeAt(0);
if (failedAck.commandCode != cmdSendChannelTxtMsg ||
failedAck.channelSendQueueId == null) {
return;
}
_pendingChannelSentQueue.remove(failedAck.channelSendQueueId);
}
void _handlePathUpdated(Uint8List frame) {
@ -2595,8 +2652,22 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
if (_retryService != null) {
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
final retryService = _retryService;
if (retryService != null &&
retryService.updateMessageFromSent(
ackHash,
timeoutMs,
allowQueueFallback: false,
)) {
return;
}
if (_markNextPendingChannelMessageSent()) {
return;
}
if (retryService != null) {
retryService.updateMessageFromSent(ackHash, timeoutMs);
}
} else {
// Fallback to old behavior
@ -2613,6 +2684,64 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
bool _markNextPendingChannelMessageSent() {
while (_pendingChannelSentQueue.isNotEmpty) {
final queuedMessageId = _pendingChannelSentQueue.removeAt(0);
if (_isReactionSendQueueId(queuedMessageId)) {
return true;
}
if (_markPendingChannelMessageSentById(queuedMessageId)) {
return true;
}
}
return false;
}
bool _markPendingChannelMessageSentById(String messageId) {
for (final entry in _channelMessages.entries) {
final channelMessages = entry.value;
for (int i = channelMessages.length - 1; i >= 0; i--) {
final message = channelMessages[i];
if (message.messageId != messageId) {
continue;
}
if (!message.isOutgoing ||
message.status != ChannelMessageStatus.pending) {
return false;
}
channelMessages[i] = message.copyWith(
status: ChannelMessageStatus.sent,
);
_pendingChannelSentQueue.remove(messageId);
unawaited(
_channelMessageStore.saveChannelMessages(entry.key, channelMessages),
);
notifyListeners();
return true;
}
}
return false;
}
void _handleOk() {
if (_pendingGenericAckQueue.isEmpty) {
return;
}
final pendingAck = _pendingGenericAckQueue.removeAt(0);
if (pendingAck.commandCode != cmdSendChannelTxtMsg ||
pendingAck.channelSendQueueId == null) {
return;
}
final queueId = pendingAck.channelSendQueueId!;
_pendingChannelSentQueue.remove(queueId);
if (_isReactionSendQueueId(queueId)) {
return;
}
_markPendingChannelMessageSentById(queueId);
}
void _handleSendConfirmed(Uint8List frame) {
// Frame format from C++:
// [0] = PUSH_CODE_SEND_CONFIRMED
@ -3191,18 +3320,22 @@ class MeshCoreConnector extends ChangeNotifier {
mergedPathBytes.length,
);
final newRepeatCount = existing.repeatCount + 1;
final promotedFromPending =
newRepeatCount == 1 &&
existing.status == ChannelMessageStatus.pending;
messages[existingIndex] = existing.copyWith(
repeatCount: newRepeatCount,
pathLength: mergedPathLength,
pathBytes: mergedPathBytes,
pathVariants: mergedPathVariants,
// Mark as sent when first repeat is heard
status:
newRepeatCount == 1 &&
existing.status == ChannelMessageStatus.pending
status: promotedFromPending
? ChannelMessageStatus.sent
: existing.status,
);
if (promotedFromPending) {
_pendingChannelSentQueue.remove(existing.messageId);
}
} else {
messages.add(processedMessage);
}
@ -3375,11 +3508,37 @@ class MeshCoreConnector extends ChangeNotifier {
_queuedMessageSyncInFlight = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
_pendingChannelSentQueue.clear();
_pendingGenericAckQueue.clear();
_reactionSendQueueSequence = 0;
_setState(MeshCoreConnectionState.disconnected);
_scheduleReconnect();
}
void _trackPendingGenericAck(
Uint8List data, {
String? channelSendQueueId,
required bool expectsGenericAck,
}) {
if (!expectsGenericAck || data.isEmpty) return;
_pendingGenericAckQueue.add(
_PendingCommandAck(
commandCode: data[0],
channelSendQueueId: channelSendQueueId,
),
);
}
String _nextReactionSendQueueId() {
_reactionSendQueueSequence++;
return '$_reactionSendQueuePrefix$_reactionSendQueueSequence';
}
bool _isReactionSendQueueId(String queueId) {
return queueId.startsWith(_reactionSendQueuePrefix);
}
Map<String, String> _parseKeyValueString(String input) {
final result = <String, String>{};
@ -3675,3 +3834,10 @@ class _RepeaterAckContext {
required this.messageBytes,
});
}
class _PendingCommandAck {
final int commandCode;
final String? channelSendQueueId;
_PendingCommandAck({required this.commandCode, this.channelSendQueueId});
}

View file

@ -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,
);
}

View file

@ -663,27 +663,24 @@ class _ChannelMessagePathMapScreenState
alignment: Alignment.topCenter,
child: IgnorePointer(
child: Transform.translate(
offset: const Offset(0, -26),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: SizedBox(
width: 96,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
offset: const Offset(0, -20),
child: FittedBox(
fit: BoxFit.contain,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),

View file

@ -183,14 +183,17 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactFrame = buildExportContactFrame(pubKey);
_pendingOperations.add(ContactOperationType.export);
await connector.sendFrame(exportContactFrame);
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
}
Future<void> _contactZeroHop(Uint8List pubKey) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
_pendingOperations.add(ContactOperationType.zeroHopShare);
await connector.sendFrame(exportContactZeroHopFrame);
await connector.sendFrame(
exportContactZeroHopFrame,
expectsGenericAck: true,
);
}
Future<void> _contactImport() async {
@ -217,7 +220,7 @@ class _ContactsScreenState extends State<ContactsScreen>
try {
final importContactFrame = buildImportContactFrame(hexString);
_pendingOperations.add(ContactOperationType.import);
await connector.sendFrame(importContactFrame);
await connector.sendFrame(importContactFrame, expectsGenericAck: true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(

View file

@ -479,27 +479,24 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
alignment: Alignment.topCenter,
child: IgnorePointer(
child: Transform.translate(
offset: const Offset(0, -26),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: SizedBox(
width: 96,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
offset: const Offset(0, -20),
child: FittedBox(
fit: BoxFit.contain,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),

View file

@ -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),

View file

@ -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() {

View file

@ -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) {

View file

@ -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,
);
}
}

View file

@ -234,7 +234,11 @@ class MessageRetryService extends ChangeNotifier {
}
}
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
bool updateMessageFromSent(
Uint8List ackHash,
int timeoutMs, {
bool allowQueueFallback = true,
}) {
final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
@ -277,7 +281,7 @@ class MessageRetryService extends ChangeNotifier {
}
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
if (messageId == null) {
if (messageId == null && allowQueueFallback) {
_debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash',
@ -320,7 +324,7 @@ class MessageRetryService extends ChangeNotifier {
if (messageId == null || contact == null) {
debugPrint('No pending message found for ACK hash: $ackHashHex');
return;
return false;
}
// Store the mapping for future lookups (e.g., when ACK arrives)
@ -339,7 +343,7 @@ class MessageRetryService extends ChangeNotifier {
'Message $messageId no longer pending for ACK hash: $ackHashHex',
);
_ackHashToMessageId.remove(ackHashHex);
return;
return false;
}
// Add this ACK hash to the list of expected ACKs for this message (for history)
@ -389,8 +393,11 @@ class MessageRetryService extends ChangeNotifier {
_startTimeoutTimer(messageId, actualTimeout);
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
return true;
}
bool get hasPendingMessages => _pendingMessages.isNotEmpty;
void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {

View 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);
}

View file

@ -14,35 +14,54 @@ class AppBarTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final selfName = connector.selfName;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
leading ?? const SizedBox.shrink(),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final compact = availableWidth < 240;
final showSubtitle =
!compact && connector.isConnected && selfName != null;
final showBattery = availableWidth >= 60;
final showSnr = availableWidth >= 110;
final showIndicators = showBattery || showSnr;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(title, overflow: TextOverflow.ellipsis),
if (connector.isConnected && connector.selfName != null)
Text(
'(${connector.selfName})',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
overflow: TextOverflow.ellipsis,
leading ?? const SizedBox.shrink(),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
if (showSubtitle)
Text(
'($selfName)',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (showIndicators) const SizedBox(width: 6),
if (showIndicators)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (showBattery) BatteryIndicator(connector: connector),
if (showSnr) SNRIndicator(connector: connector),
],
),
trailing ?? const SizedBox.shrink(),
],
),
const SizedBox(width: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
BatteryIndicator(connector: connector),
SNRIndicator(connector: connector),
],
),
trailing ?? const SizedBox.shrink(),
],
);
},
);
}
}

View 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'),
);
});
});
}