mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-28 13:17:35 +10:00
Merge branch 'zjs81:main' into issue-fix-channel-edit-delete-actions
This commit is contained in:
@@ -29,6 +29,7 @@ import '../storage/contact_store.dart';
|
|||||||
import '../storage/message_store.dart';
|
import '../storage/message_store.dart';
|
||||||
import '../storage/unread_store.dart';
|
import '../storage/unread_store.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/battery_utils.dart';
|
||||||
import 'meshcore_protocol.dart';
|
import 'meshcore_protocol.dart';
|
||||||
|
|
||||||
class MeshCoreUuids {
|
class MeshCoreUuids {
|
||||||
@@ -81,6 +82,18 @@ enum MeshCoreConnectionState {
|
|||||||
disconnecting,
|
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 {
|
class MeshCoreConnector extends ChangeNotifier {
|
||||||
// Message windowing to limit memory usage
|
// Message windowing to limit memory usage
|
||||||
static const int _messageWindowSize = 200;
|
static const int _messageWindowSize = 200;
|
||||||
@@ -101,6 +114,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final List<Channel> _channels = [];
|
final List<Channel> _channels = [];
|
||||||
final Map<String, List<Message>> _conversations = {};
|
final Map<String, List<Message>> _conversations = {};
|
||||||
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
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 Set<String> _loadedConversationKeys = {};
|
||||||
final Map<int, Set<String>> _processedChannelReactions =
|
final Map<int, Set<String>> _processedChannelReactions =
|
||||||
{}; // channelIndex -> Set of "targetHash_emoji"
|
{}; // channelIndex -> Set of "targetHash_emoji"
|
||||||
@@ -187,6 +204,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final Map<String, bool> _contactSmazEnabled = {};
|
final Map<String, bool> _contactSmazEnabled = {};
|
||||||
final Set<String> _knownContactKeys = {};
|
final Set<String> _knownContactKeys = {};
|
||||||
final Map<String, int> _contactUnreadCount = {};
|
final Map<String, int> _contactUnreadCount = {};
|
||||||
|
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
|
||||||
bool _unreadStateLoaded = false;
|
bool _unreadStateLoaded = false;
|
||||||
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
|
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
|
||||||
String? _activeContactKey;
|
String? _activeContactKey;
|
||||||
@@ -254,10 +272,32 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
: 0;
|
: 0;
|
||||||
int? get batteryPercent => _batteryMillivolts == null
|
int? get batteryPercent => _batteryMillivolts == null
|
||||||
? null
|
? null
|
||||||
: _estimateBatteryPercent(
|
: estimateBatteryPercentFromMillivolts(
|
||||||
_batteryMillivolts!,
|
_batteryMillivolts!,
|
||||||
_batteryChemistryForDevice(),
|
_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() {
|
String _batteryChemistryForDevice() {
|
||||||
final deviceId = _device?.remoteId.toString();
|
final deviceId = _device?.remoteId.toString();
|
||||||
@@ -265,27 +305,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return _appSettingsService!.batteryChemistryForDevice(deviceId);
|
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) {
|
List<Message> getMessages(Contact contact) {
|
||||||
return _conversations[contact.publicKeyHex] ?? [];
|
return _conversations[contact.publicKeyHex] ?? [];
|
||||||
}
|
}
|
||||||
@@ -961,6 +980,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_clientRepeat = null;
|
_clientRepeat = null;
|
||||||
_firmwareVerCode = null;
|
_firmwareVerCode = null;
|
||||||
_batteryMillivolts = null;
|
_batteryMillivolts = null;
|
||||||
|
_repeaterBatterySnapshots.clear();
|
||||||
_batteryRequested = false;
|
_batteryRequested = false;
|
||||||
_awaitingSelfInfo = false;
|
_awaitingSelfInfo = false;
|
||||||
_maxContacts = _defaultMaxContacts;
|
_maxContacts = _defaultMaxContacts;
|
||||||
@@ -972,6 +992,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_isSyncingChannels = false;
|
_isSyncingChannels = false;
|
||||||
_channelSyncInFlight = false;
|
_channelSyncInFlight = false;
|
||||||
_hasLoadedChannels = false;
|
_hasLoadedChannels = false;
|
||||||
|
_pendingChannelSentQueue.clear();
|
||||||
|
_pendingGenericAckQueue.clear();
|
||||||
|
_reactionSendQueueSequence = 0;
|
||||||
|
|
||||||
_setState(MeshCoreConnectionState.disconnected);
|
_setState(MeshCoreConnectionState.disconnected);
|
||||||
if (!manual) {
|
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) {
|
if (!isConnected || _rxCharacteristic == null) {
|
||||||
throw Exception("Not connected to a MeshCore device");
|
throw Exception("Not connected to a MeshCore device");
|
||||||
}
|
}
|
||||||
@@ -998,6 +1025,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
data.toList(),
|
data.toList(),
|
||||||
withoutResponse: canWriteWithoutResponse,
|
withoutResponse: canWriteWithoutResponse,
|
||||||
);
|
);
|
||||||
|
_trackPendingGenericAck(
|
||||||
|
data,
|
||||||
|
channelSendQueueId: channelSendQueueId,
|
||||||
|
expectsGenericAck: expectsGenericAck,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> requestBatteryStatus({bool force = false}) async {
|
Future<void> requestBatteryStatus({bool force = false}) async {
|
||||||
@@ -1353,7 +1385,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Send the reaction to the device (don't add as a visible message)
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1363,6 +1401,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
channel.index,
|
channel.index,
|
||||||
);
|
);
|
||||||
_addChannelMessage(channel.index, message);
|
_addChannelMessage(channel.index, message);
|
||||||
|
_pendingChannelSentQueue.add(message.messageId);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final trimmed = text.trim();
|
final trimmed = text.trim();
|
||||||
@@ -1372,7 +1411,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
|
(isChannelSmazEnabled(channel.index) && !isStructuredPayload)
|
||||||
? Smaz.encodeIfSmaller(text)
|
? Smaz.encodeIfSmaller(text)
|
||||||
: 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 {
|
Future<void> removeContact(Contact contact) async {
|
||||||
@@ -1719,6 +1762,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
debugPrint('RX frame: code=$code len=${frame.length}');
|
debugPrint('RX frame: code=$code len=${frame.length}');
|
||||||
|
|
||||||
switch (code) {
|
switch (code) {
|
||||||
|
case respCodeOk:
|
||||||
|
_handleOk();
|
||||||
|
break;
|
||||||
case respCodeDeviceInfo:
|
case respCodeDeviceInfo:
|
||||||
_handleDeviceInfo(frame);
|
_handleDeviceInfo(frame);
|
||||||
break;
|
break;
|
||||||
@@ -1813,6 +1859,17 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
'Firmware responded with error code: $errCode',
|
'Firmware responded with error code: $errCode',
|
||||||
tag: 'Protocol',
|
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) {
|
void _handlePathUpdated(Uint8List frame) {
|
||||||
@@ -2595,8 +2652,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_retryService != null) {
|
final retryService = _retryService;
|
||||||
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
|
if (retryService != null &&
|
||||||
|
retryService.updateMessageFromSent(
|
||||||
|
ackHash,
|
||||||
|
timeoutMs,
|
||||||
|
allowQueueFallback: false,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_markNextPendingChannelMessageSent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryService != null) {
|
||||||
|
retryService.updateMessageFromSent(ackHash, timeoutMs);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to old behavior
|
// 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) {
|
void _handleSendConfirmed(Uint8List frame) {
|
||||||
// Frame format from C++:
|
// Frame format from C++:
|
||||||
// [0] = PUSH_CODE_SEND_CONFIRMED
|
// [0] = PUSH_CODE_SEND_CONFIRMED
|
||||||
@@ -3191,18 +3320,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
mergedPathBytes.length,
|
mergedPathBytes.length,
|
||||||
);
|
);
|
||||||
final newRepeatCount = existing.repeatCount + 1;
|
final newRepeatCount = existing.repeatCount + 1;
|
||||||
|
final promotedFromPending =
|
||||||
|
newRepeatCount == 1 &&
|
||||||
|
existing.status == ChannelMessageStatus.pending;
|
||||||
messages[existingIndex] = existing.copyWith(
|
messages[existingIndex] = existing.copyWith(
|
||||||
repeatCount: newRepeatCount,
|
repeatCount: newRepeatCount,
|
||||||
pathLength: mergedPathLength,
|
pathLength: mergedPathLength,
|
||||||
pathBytes: mergedPathBytes,
|
pathBytes: mergedPathBytes,
|
||||||
pathVariants: mergedPathVariants,
|
pathVariants: mergedPathVariants,
|
||||||
// Mark as sent when first repeat is heard
|
// Mark as sent when first repeat is heard
|
||||||
status:
|
status: promotedFromPending
|
||||||
newRepeatCount == 1 &&
|
|
||||||
existing.status == ChannelMessageStatus.pending
|
|
||||||
? ChannelMessageStatus.sent
|
? ChannelMessageStatus.sent
|
||||||
: existing.status,
|
: existing.status,
|
||||||
);
|
);
|
||||||
|
if (promotedFromPending) {
|
||||||
|
_pendingChannelSentQueue.remove(existing.messageId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
messages.add(processedMessage);
|
messages.add(processedMessage);
|
||||||
}
|
}
|
||||||
@@ -3375,11 +3508,37 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_queuedMessageSyncInFlight = false;
|
_queuedMessageSyncInFlight = false;
|
||||||
_isSyncingChannels = false;
|
_isSyncingChannels = false;
|
||||||
_channelSyncInFlight = false;
|
_channelSyncInFlight = false;
|
||||||
|
_pendingChannelSentQueue.clear();
|
||||||
|
_pendingGenericAckQueue.clear();
|
||||||
|
_reactionSendQueueSequence = 0;
|
||||||
|
|
||||||
_setState(MeshCoreConnectionState.disconnected);
|
_setState(MeshCoreConnectionState.disconnected);
|
||||||
_scheduleReconnect();
|
_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) {
|
Map<String, String> _parseKeyValueString(String input) {
|
||||||
final result = <String, String>{};
|
final result = <String, String>{};
|
||||||
|
|
||||||
@@ -3675,3 +3834,10 @@ class _RepeaterAckContext {
|
|||||||
required this.messageBytes,
|
required this.messageBytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PendingCommandAck {
|
||||||
|
final int commandCode;
|
||||||
|
final String? channelSendQueueId;
|
||||||
|
|
||||||
|
_PendingCommandAck({required this.commandCode, this.channelSendQueueId});
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class AppSettings {
|
|||||||
final String? languageOverride; // null = system default
|
final String? languageOverride; // null = system default
|
||||||
final bool appDebugLogEnabled;
|
final bool appDebugLogEnabled;
|
||||||
final Map<String, String> batteryChemistryByDeviceId;
|
final Map<String, String> batteryChemistryByDeviceId;
|
||||||
|
final Map<String, String> batteryChemistryByRepeaterId;
|
||||||
final UnitSystem unitSystem;
|
final UnitSystem unitSystem;
|
||||||
|
|
||||||
AppSettings({
|
AppSettings({
|
||||||
@@ -57,8 +58,10 @@ class AppSettings {
|
|||||||
this.languageOverride,
|
this.languageOverride,
|
||||||
this.appDebugLogEnabled = false,
|
this.appDebugLogEnabled = false,
|
||||||
Map<String, String>? batteryChemistryByDeviceId,
|
Map<String, String>? batteryChemistryByDeviceId,
|
||||||
|
Map<String, String>? batteryChemistryByRepeaterId,
|
||||||
this.unitSystem = UnitSystem.metric,
|
this.unitSystem = UnitSystem.metric,
|
||||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||||
|
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {};
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
@@ -82,6 +85,7 @@ class AppSettings {
|
|||||||
'language_override': languageOverride,
|
'language_override': languageOverride,
|
||||||
'app_debug_log_enabled': appDebugLogEnabled,
|
'app_debug_log_enabled': appDebugLogEnabled,
|
||||||
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
||||||
|
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
||||||
'unit_system': unitSystem.value,
|
'unit_system': unitSystem.value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -124,9 +128,12 @@ class AppSettings {
|
|||||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||||
) ??
|
) ??
|
||||||
{},
|
{},
|
||||||
unitSystem: parseUnitSystem(
|
batteryChemistryByRepeaterId:
|
||||||
json['unit_system'] ?? json['los_unit_system'],
|
(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,
|
Object? languageOverride = _unset,
|
||||||
bool? appDebugLogEnabled,
|
bool? appDebugLogEnabled,
|
||||||
Map<String, String>? batteryChemistryByDeviceId,
|
Map<String, String>? batteryChemistryByDeviceId,
|
||||||
|
Map<String, String>? batteryChemistryByRepeaterId,
|
||||||
UnitSystem? unitSystem,
|
UnitSystem? unitSystem,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
@@ -181,6 +189,8 @@ class AppSettings {
|
|||||||
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
||||||
batteryChemistryByDeviceId:
|
batteryChemistryByDeviceId:
|
||||||
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||||
|
batteryChemistryByRepeaterId:
|
||||||
|
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
||||||
unitSystem: unitSystem ?? this.unitSystem,
|
unitSystem: unitSystem ?? this.unitSystem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,27 +663,24 @@ class _ChannelMessagePathMapScreenState
|
|||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: const Offset(0, -26),
|
offset: const Offset(0, -20),
|
||||||
child: Container(
|
child: FittedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
fit: BoxFit.contain,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: Colors.black54,
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
),
|
color: Colors.black54,
|
||||||
child: SizedBox(
|
borderRadius: BorderRadius.circular(8),
|
||||||
width: 96,
|
),
|
||||||
child: FittedBox(
|
alignment: Alignment.center,
|
||||||
fit: BoxFit.scaleDown,
|
child: Text(
|
||||||
alignment: Alignment.centerLeft,
|
label,
|
||||||
child: Text(
|
maxLines: 1,
|
||||||
label,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxLines: 1,
|
style: const TextStyle(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: Colors.white,
|
||||||
style: const TextStyle(
|
fontSize: 11,
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -183,14 +183,17 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final exportContactFrame = buildExportContactFrame(pubKey);
|
final exportContactFrame = buildExportContactFrame(pubKey);
|
||||||
_pendingOperations.add(ContactOperationType.export);
|
_pendingOperations.add(ContactOperationType.export);
|
||||||
await connector.sendFrame(exportContactFrame);
|
await connector.sendFrame(exportContactFrame, expectsGenericAck: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _contactZeroHop(Uint8List pubKey) async {
|
Future<void> _contactZeroHop(Uint8List pubKey) async {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
|
final exportContactZeroHopFrame = buildZeroHopContact(pubKey);
|
||||||
_pendingOperations.add(ContactOperationType.zeroHopShare);
|
_pendingOperations.add(ContactOperationType.zeroHopShare);
|
||||||
await connector.sendFrame(exportContactZeroHopFrame);
|
await connector.sendFrame(
|
||||||
|
exportContactZeroHopFrame,
|
||||||
|
expectsGenericAck: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _contactImport() async {
|
Future<void> _contactImport() async {
|
||||||
@@ -217,7 +220,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
try {
|
try {
|
||||||
final importContactFrame = buildImportContactFrame(hexString);
|
final importContactFrame = buildImportContactFrame(hexString);
|
||||||
_pendingOperations.add(ContactOperationType.import);
|
_pendingOperations.add(ContactOperationType.import);
|
||||||
await connector.sendFrame(importContactFrame);
|
await connector.sendFrame(importContactFrame, expectsGenericAck: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -479,27 +479,24 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: const Offset(0, -26),
|
offset: const Offset(0, -20),
|
||||||
child: Container(
|
child: FittedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
fit: BoxFit.contain,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: Colors.black54,
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
),
|
color: Colors.black54,
|
||||||
child: SizedBox(
|
borderRadius: BorderRadius.circular(8),
|
||||||
width: 96,
|
),
|
||||||
child: FittedBox(
|
alignment: Alignment.center,
|
||||||
fit: BoxFit.scaleDown,
|
child: Text(
|
||||||
alignment: Alignment.centerLeft,
|
label,
|
||||||
child: Text(
|
maxLines: 1,
|
||||||
label,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxLines: 1,
|
style: const TextStyle(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: Colors.white,
|
||||||
style: const TextStyle(
|
fontSize: 11,
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
import 'repeater_status_screen.dart';
|
import 'repeater_status_screen.dart';
|
||||||
import 'repeater_cli_screen.dart';
|
import 'repeater_cli_screen.dart';
|
||||||
import 'repeater_settings_screen.dart';
|
import 'repeater_settings_screen.dart';
|
||||||
@@ -21,6 +23,10 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final settingsService = context.watch<AppSettingsService>();
|
||||||
|
final chemistry = settingsService.batteryChemistryForRepeater(
|
||||||
|
repeater.publicKeyHex,
|
||||||
|
);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Column(
|
||||||
@@ -107,6 +113,62 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
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(
|
Text(
|
||||||
l10n.repeater_managementTools,
|
l10n.repeater_managementTools,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import '../models/contact.dart';
|
|||||||
import '../models/path_selection.dart';
|
import '../models/path_selection.dart';
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../utils/battery_utils.dart';
|
||||||
import '../widgets/path_management_dialog.dart';
|
import '../widgets/path_management_dialog.dart';
|
||||||
|
|
||||||
class RepeaterStatusScreen extends StatefulWidget {
|
class RepeaterStatusScreen extends StatefulWidget {
|
||||||
@@ -179,6 +181,12 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
_dupDirect = directDups;
|
_dupDirect = directDups;
|
||||||
_dupFlood = floodDups;
|
_dupFlood = floodDups;
|
||||||
});
|
});
|
||||||
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
connector.updateRepeaterBatterySnapshot(
|
||||||
|
widget.repeater.publicKeyHex,
|
||||||
|
batteryMv,
|
||||||
|
source: 'status_binary',
|
||||||
|
);
|
||||||
_recordStatusResult(true);
|
_recordStatusResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +209,18 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
_uptimeSecs = _asInt(data['uptime_secs']);
|
_uptimeSecs = _asInt(data['uptime_secs']);
|
||||||
_queueLen = _asInt(data['queue_len']);
|
_queueLen = _asInt(data['queue_len']);
|
||||||
_debugFlags = _asInt(data['errors']);
|
_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')) {
|
} else if (data.containsKey('noise_floor')) {
|
||||||
_noiseFloor = _asInt(data['noise_floor']);
|
_noiseFloor = _asInt(data['noise_floor']);
|
||||||
_lastRssi = _asInt(data['last_rssi']);
|
_lastRssi = _asInt(data['last_rssi']);
|
||||||
@@ -590,18 +610,24 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _batteryText() {
|
String _batteryText() {
|
||||||
if (_batteryMv == null) return '—';
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final percent = _batteryPercentFromMv(_batteryMv!);
|
final batteryMv =
|
||||||
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
|
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';
|
return '$percent% / ${volts}V';
|
||||||
}
|
}
|
||||||
|
|
||||||
int _batteryPercentFromMv(int millivolts) {
|
String _batteryChemistry() {
|
||||||
const minMv = 3000;
|
final settingsService = context.read<AppSettingsService>();
|
||||||
const maxMv = 4200;
|
return settingsService.batteryChemistryForRepeater(
|
||||||
if (millivolts <= minMv) return 0;
|
widget.repeater.publicKeyHex,
|
||||||
if (millivolts >= maxMv) return 100;
|
);
|
||||||
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _clockText() {
|
String _clockText() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../services/app_settings_service.dart';
|
|||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
import '../widgets/path_management_dialog.dart';
|
import '../widgets/path_management_dialog.dart';
|
||||||
import '../helpers/cayenne_lpp.dart';
|
import '../helpers/cayenne_lpp.dart';
|
||||||
|
import '../utils/battery_utils.dart';
|
||||||
|
|
||||||
class TelemetryScreen extends StatefulWidget {
|
class TelemetryScreen extends StatefulWidget {
|
||||||
final Contact repeater;
|
final Contact repeater;
|
||||||
@@ -74,9 +75,19 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleStatusResponse(Uint8List frame) {
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_parsedTelemetry = CayenneLpp.parseByChannel(frame);
|
_parsedTelemetry = parsedTelemetry;
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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 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;
|
if (batteryMv == null) return l10n.common_notAvailable;
|
||||||
final percent = _batteryPercentFromMv(batteryMv);
|
final chemistry = _batteryChemistry();
|
||||||
final volts = batteryMv.toStringAsFixed(2);
|
final percent = estimateBatteryPercentFromMillivolts(batteryMv, chemistry);
|
||||||
|
final volts = (batteryMv / 1000).toStringAsFixed(2);
|
||||||
return l10n.telemetry_batteryValue(percent, volts);
|
return l10n.telemetry_batteryValue(percent, volts);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _batteryPercentFromMv(double millivolts) {
|
String _batteryChemistry() {
|
||||||
const minMv = 2.800;
|
final settingsService = context.read<AppSettingsService>();
|
||||||
const maxMv = 4.200;
|
return settingsService.batteryChemistryForRepeater(
|
||||||
if (millivolts <= minMv) return 0;
|
widget.repeater.publicKeyHex,
|
||||||
if (millivolts >= maxMv) return 100;
|
);
|
||||||
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _temperatureText(double? tempC, bool isImperialUnits) {
|
String _temperatureText(double? tempC, bool isImperialUnits) {
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class AppSettingsService extends ChangeNotifier {
|
|||||||
return stored ?? 'nmc';
|
return stored ?? 'nmc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String batteryChemistryForRepeater(String repeaterPubKeyHex) {
|
||||||
|
final stored = _settings.batteryChemistryByRepeaterId[repeaterPubKeyHex];
|
||||||
|
if (stored == 'liion') return 'nmc';
|
||||||
|
return stored ?? 'nmc';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadSettings() async {
|
Future<void> loadSettings() async {
|
||||||
final prefs = PrefsManager.instance;
|
final prefs = PrefsManager.instance;
|
||||||
final jsonStr = prefs.getString(_settingsKey);
|
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 {
|
Future<void> setUnitSystem(UnitSystem value) async {
|
||||||
await updateSettings(_settings.copyWith(unitSystem: value));
|
await updateSettings(_settings.copyWith(unitSystem: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setLosUnitSystem(String value) async {
|
|
||||||
await setUnitSystem(
|
|
||||||
value == 'imperial' ? UnitSystem.imperial : UnitSystem.metric,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
final ackHashHex = ackHash
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
@@ -277,7 +281,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||||
if (messageId == null) {
|
if (messageId == null && allowQueueFallback) {
|
||||||
_debugLogService?.warn(
|
_debugLogService?.warn(
|
||||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||||
tag: 'AckHash',
|
tag: 'AckHash',
|
||||||
@@ -320,7 +324,7 @@ class MessageRetryService extends ChangeNotifier {
|
|||||||
|
|
||||||
if (messageId == null || contact == null) {
|
if (messageId == null || contact == null) {
|
||||||
debugPrint('No pending message found for ACK hash: $ackHashHex');
|
debugPrint('No pending message found for ACK hash: $ackHashHex');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the mapping for future lookups (e.g., when ACK arrives)
|
// 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',
|
'Message $messageId no longer pending for ACK hash: $ackHashHex',
|
||||||
);
|
);
|
||||||
_ackHashToMessageId.remove(ackHashHex);
|
_ackHashToMessageId.remove(ackHashHex);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add this ACK hash to the list of expected ACKs for this message (for history)
|
// 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);
|
_startTimeoutTimer(messageId, actualTimeout);
|
||||||
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
|
debugPrint('Updated message $messageId with ACK hash: $ackHashHex');
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get hasPendingMessages => _pendingMessages.isNotEmpty;
|
||||||
|
|
||||||
void _startTimeoutTimer(String messageId, int timeoutMs) {
|
void _startTimeoutTimer(String messageId, int timeoutMs) {
|
||||||
_timeoutTimers[messageId]?.cancel();
|
_timeoutTimers[messageId]?.cancel();
|
||||||
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
|
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+44
-25
@@ -14,35 +14,54 @@ class AppBarTitle extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final selfName = connector.selfName;
|
||||||
|
|
||||||
return Row(
|
return LayoutBuilder(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
builder: (context, constraints) {
|
||||||
children: [
|
final availableWidth = constraints.hasBoundedWidth
|
||||||
leading ?? const SizedBox.shrink(),
|
? constraints.maxWidth
|
||||||
Column(
|
: MediaQuery.sizeOf(context).width;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final compact = availableWidth < 240;
|
||||||
mainAxisSize: MainAxisSize.min,
|
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: [
|
children: [
|
||||||
Text(title, overflow: TextOverflow.ellipsis),
|
leading ?? const SizedBox.shrink(),
|
||||||
if (connector.isConnected && connector.selfName != null)
|
Expanded(
|
||||||
Text(
|
child: Column(
|
||||||
'(${connector.selfName})',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
mainAxisSize: MainAxisSize.min,
|
||||||
overflow: TextOverflow.ellipsis,
|
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(),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user