Refactor Cayenne LPP parsing with error handling and logging

- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
This commit is contained in:
Winston Lowe
2026-02-14 14:19:09 -08:00
parent 72f0aa7208
commit aed3b0157a
12 changed files with 964 additions and 457 deletions
+262
View File
@@ -37,6 +37,29 @@ class MeshCoreUuids {
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
} }
class DirectRepeater {
static const int maxAgeMinutes = 30; // Max age for direct repeater info
final int pubkeyFirstByte;
double snr;
DateTime lastUpdated;
DirectRepeater({
required this.pubkeyFirstByte,
required this.snr,
DateTime? lastUpdated,
}) : lastUpdated = lastUpdated ?? DateTime.now();
void update(double newSNR) {
snr = newSNR;
lastUpdated = DateTime.now();
}
bool isStale() {
return DateTime.now().difference(lastUpdated) >
const Duration(minutes: maxAgeMinutes);
}
}
enum MeshCoreConnectionState { enum MeshCoreConnectionState {
disconnected, disconnected,
scanning, scanning,
@@ -93,6 +116,7 @@ class MeshCoreConnector extends ChangeNotifier {
int? _batteryMillivolts; int? _batteryMillivolts;
double? _selfLatitude; double? _selfLatitude;
double? _selfLongitude; double? _selfLongitude;
final List<DirectRepeater> _directRepeaters = List.empty(growable: true);
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
@@ -194,6 +218,7 @@ class MeshCoreConnector extends ChangeNotifier {
String? get selfName => _selfName; String? get selfName => _selfName;
double? get selfLatitude => _selfLatitude; double? get selfLatitude => _selfLatitude;
double? get selfLongitude => _selfLongitude; double? get selfLongitude => _selfLongitude;
List<DirectRepeater> get directRepeaters => _directRepeaters;
int? get currentTxPower => _currentTxPower; int? get currentTxPower => _currentTxPower;
int? get maxTxPower => _maxTxPower; int? get maxTxPower => _maxTxPower;
int? get currentFreqHz => _currentFreqHz; int? get currentFreqHz => _currentFreqHz;
@@ -1690,6 +1715,11 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeNewAdvert:
debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler
_handleContact(frame);
break;
case respCodeContact: case respCodeContact:
debugPrint('Got CONTACT'); debugPrint('Got CONTACT');
_handleContact(frame); _handleContact(frame);
@@ -1734,6 +1764,7 @@ class MeshCoreConnector extends ChangeNotifier {
case pushCodeStatusResponse: case pushCodeStatusResponse:
break; break;
case pushCodeLogRxData: case pushCodeLogRxData:
_handleRxData(frame);
_handleLogRxData(frame); _handleLogRxData(frame);
break; break;
case respCodeChannelInfo: case respCodeChannelInfo:
@@ -1747,6 +1778,7 @@ class MeshCoreConnector extends ChangeNotifier {
break; break;
case respCodeCustomVars: case respCodeCustomVars:
_handleCustomVars(frame); _handleCustomVars(frame);
break;
default: default:
debugPrint('Unknown frame code: $code'); debugPrint('Unknown frame code: $code');
} }
@@ -2002,6 +2034,80 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
void _handleContactAdvert(Contact contact) {
if (contact.publicKey == _selfPublicKey) {
return;
}
if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
}
// Check if this is a new contact
final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
final mergedLastMessageAt =
existing.lastMessageAt.isAfter(contact.lastMessageAt)
? existing.lastMessageAt
: contact.lastMessageAt;
appLogger.info(
'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = contact.copyWith(
lastMessageAt: mergedLastMessageAt,
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
tag: 'Connector',
);
} else {
_contacts.add(contact);
appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
tag: 'Connector',
);
}
_knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex);
// Add path to history if we have a valid path
if (_pathHistoryService != null && contact.pathLength >= 0) {
_pathHistoryService!.handlePathUpdated(contact);
}
notifyListeners();
// Show notification for new contact (advertisement)
if (isNewContact && _appSettingsService != null) {
final settings = _appSettingsService!.settings;
if (settings.notificationsEnabled && settings.notifyOnNewAdvert) {
_notificationService.showAdvertNotification(
contactName: contact.name,
contactType: contact.typeLabel,
contactId: contact.publicKeyHex,
);
}
}
if (!_isLoadingContacts) {
unawaited(_persistContacts());
}
}
Future<void> _persistContacts() async { Future<void> _persistContacts() async {
await _contactStore.saveContacts(_contacts); await _contactStore.saveContacts(_contacts);
} }
@@ -3261,7 +3367,11 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleCustomVars(Uint8List frame) { void _handleCustomVars(Uint8List frame) {
final buf = BufferReader(frame.sublist(1)); final buf = BufferReader(frame.sublist(1));
try {
_currentCustomVars = _parseKeyValueString(buf.readString()); _currentCustomVars = _parseKeyValueString(buf.readString());
} catch (e) {
appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
}
} }
void _setState(MeshCoreConnectionState newState) { void _setState(MeshCoreConnectionState newState) {
@@ -3285,6 +3395,158 @@ class MeshCoreConnector extends ChangeNotifier {
super.dispose(); super.dispose();
} }
void _handleRxData(Uint8List frame) {
final packet = BufferReader(frame);
try {
packet.skipBytes(1); // Skip frame type byte
final snr = packet.readInt8() / 4.0;
packet.skipBytes(1); // Skip RSSI and noise bytes, as SNR is more reliable
//final rssi = packet.readByte();
final header = packet.readByte();
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
//final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte();
final pathBytes = packet.readBytes(pathLen);
final payload = packet.readBytes(packet.remaining);
switch (payloadType) {
case payloadTypeADVERT:
_handlePayloadAdvertReceived(payload, pathBytes, routeType, snr);
break;
default:
}
} catch (e) {
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
}
}
void _handlePayloadAdvertReceived(
Uint8List frame,
Uint8List path,
int routeType,
double snr,
) {
final advert = BufferReader(frame);
double latitude = 0.0;
double longitude = 0.0;
String name = '';
String contactKeyHex = '';
Uint8List publicKey = Uint8List(0);
int type = 0;
int timestamp = 0;
bool hasLocation = false;
bool hasName = false;
try {
publicKey = advert.readBytes(32);
contactKeyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
timestamp = advert.readInt32LE();
//TODO add signature verification
advert.skipBytes(64); // Skip signature for now
final flags = advert.readByte();
type = flags & 0x0F;
hasLocation = (flags & 0x10) != 0;
// For future use:
//final hasFeature1 = (flags & 0x20) != 0;
//final hasFeature2 = (flags & 0x40) != 0;
hasName = (flags & 0x80) != 0;
if (hasLocation && advert.remaining >= 8) {
latitude = advert.readInt32LE() / 1e6;
longitude = advert.readInt32LE() / 1e6;
}
if (hasName && advert.remaining > 0) {
name = advert.readString();
}
} catch (e) {
appLogger.warn('Malformed advert frame: $e', tag: 'Connector');
return;
}
// Check if this is a new contact
final isNewContact = !_knownContactKeys.contains(contactKeyHex);
if (isNewContact) {
final newContect = Contact(
publicKey: publicKey,
name: name,
type: type,
pathLength: path.length,
path: path,
latitude: latitude,
longitude: longitude,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
);
_handleContactAdvert(newContect);
_updateDirectRepeater(newContect, snr, path);
return;
}
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contactKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
final mergedLastMessageAt = existing.lastMessageAt.isAfter(DateTime.now())
? DateTime.now()
: existing.lastMessageAt;
appLogger.info(
'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude,
longitude: hasLocation ? longitude : existing.longitude,
name: hasName ? name : existing.name,
path: path,
pathLength: path.length,
lastMessageAt: mergedLastMessageAt,
lastSeen: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
);
_updateDirectRepeater(_contacts[existingIndex], snr, path);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
tag: 'Connector',
);
}
}
void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
final pubkeyFirstByte = path.isNotEmpty
? path.last
: contact.publicKey.first;
_directRepeaters.removeWhere((r) => r.isStale());
if (contact.type == advTypeChat && contact.type == advTypeSensor) {
notifyListeners();
return;
}
final isTracked = _directRepeaters.where(
(r) => r.pubkeyFirstByte == pubkeyFirstByte,
);
if (isTracked.isNotEmpty) {
final repeater = isTracked.first;
repeater.update(snr);
} else if (isTracked.isEmpty && _directRepeaters.length < 5) {
_directRepeaters.add(
DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr),
);
}
notifyListeners();
}
} }
const int _phRouteMask = 0x03; const int _phRouteMask = 0x03;
+54
View File
@@ -13,12 +13,22 @@ class BufferReader {
int readByte() => readBytes(1)[0]; int readByte() => readBytes(1)[0];
Uint8List readBytes(int count) { Uint8List readBytes(int count) {
if (_pointer + count > _buffer.length) {
throw RangeError(
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
);
}
final data = _buffer.sublist(_pointer, _pointer + count); final data = _buffer.sublist(_pointer, _pointer + count);
_pointer += count; _pointer += count;
return data; return data;
} }
void skipBytes(int count) { void skipBytes(int count) {
if (_pointer + count > _buffer.length) {
throw RangeError(
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
);
}
_pointer += count; _pointer += count;
} }
@@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31; const int cmdGetChannel = 31;
const int cmdSetChannel = 32; const int cmdSetChannel = 32;
const int cmdSendTracePath = 36; const int cmdSendTracePath = 36;
const int cmdSetOtherParams = 38;
const int cmdGetRadioSettings = 57; const int cmdGetRadioSettings = 57;
const int cmdGetTelemetryReq = 39; const int cmdGetTelemetryReq = 39;
const int cmdGetCustomVar = 40; const int cmdGetCustomVar = 40;
@@ -212,6 +223,30 @@ const int advTypeRepeater = 2;
const int advTypeRoom = 3; const int advTypeRoom = 3;
const int advTypeSensor = 4; const int advTypeSensor = 4;
// Payload Types
const int payloadTypeREQ =
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
const int payloadTypeRESPONSE =
0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
const int payloadTypeTXTMSG =
0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
const int payloadTypeACK = 0x03; // a simple ack
const int payloadTypeADVERT = 0x04; // a node advertising its Identity
const int payloadTypeGRPTXT =
0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
const int payloadTypeGRPDATA =
0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
const int payloadTypeANONREQ =
0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
const int payloadTypePATH =
0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
//...
const int payloadTypeRawCustom =
0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
// Sizes // Sizes
const int pubKeySize = 32; const int pubKeySize = 32;
const int maxPathSize = 64; const int maxPathSize = 64;
@@ -777,3 +812,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) {
writer.writeBytes(pubKey); writer.writeBytes(pubKey);
return writer.toBytes(); return writer.toBytes();
} }
// Build CMD_SET_OTHER_PARAMS frame
// Format: [cmd][allowAutoAddContacts][allowTelemetryFlags][advertLocationPolicy][multiAcks]
Uint8List buildSetOtherParamsFrame(
bool allowAutoAddContacts,
int allowTelemetryFlags,
int advertLocationPolicy,
int multiAcks,
) {
final writer = BufferWriter();
writer.writeByte(cmdSetOtherParams);
writer.writeByte(
allowAutoAddContacts ? 0x00 : 0x01,
); // Allow Auto Add Contacts
writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
writer.writeByte(multiAcks); // Multi Acknowledgements
return writer.toBytes();
}
+18 -3
View File
@@ -1,4 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:meshcore_open/utils/app_logger.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
class CayenneLpp { class CayenneLpp {
@@ -84,7 +86,7 @@ class CayenneLpp {
static List<Map<String, dynamic>> parse(Uint8List bytes) { static List<Map<String, dynamic>> parse(Uint8List bytes) {
final buffer = BufferReader(bytes); final buffer = BufferReader(bytes);
final telemetry = <Map<String, dynamic>>[]; final telemetry = <Map<String, dynamic>>[];
try {
while (buffer.remaining >= 2) { while (buffer.remaining >= 2) {
final channel = buffer.readUInt8(); final channel = buffer.readUInt8();
final type = buffer.readUInt8(); final type = buffer.readUInt8();
@@ -186,14 +188,20 @@ class CayenneLpp {
return telemetry; return telemetry;
} }
} }
return telemetry; return telemetry;
} catch (e) {
// Handle parsing errors, possibly due to malformed data
appLogger.error('Error parsing Cayenne LPP data: $e');
return <
Map<String, dynamic>
>[]; // Return an empty list on error to avoid crashing the app
}
} }
static List<Map<String, dynamic>> parseByChannel(Uint8List bytes) { static List<Map<String, dynamic>> parseByChannel(Uint8List bytes) {
final buffer = BufferReader(bytes); final buffer = BufferReader(bytes);
final Map<int, Map<String, dynamic>> channels = {}; final Map<int, Map<String, dynamic>> channels = {};
try {
while (buffer.remaining >= 2) { while (buffer.remaining >= 2) {
final channel = buffer.readUInt8(); final channel = buffer.readUInt8();
final type = buffer.readUInt8(); final type = buffer.readUInt8();
@@ -259,5 +267,12 @@ class CayenneLpp {
final List<Map<String, dynamic>> channelsOut = channels.values.toList(); final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut; return channelsOut;
} catch (e) {
// Handle parsing errors, possibly due to malformed data
appLogger.error('Error parsing Cayenne LPP data: $e');
return <
Map<String, dynamic>
>[]; // Return an empty list on error to avoid crashing the app
}
} }
} }
+5 -2
View File
@@ -160,9 +160,8 @@ class Contact {
} }
static Contact? fromFrame(Uint8List data) { static Contact? fromFrame(Uint8List data) {
if (data.length < contactFrameSize) return null;
if (data[0] != respCodeContact) return null; if (data[0] != respCodeContact) return null;
try {
final pubKey = Uint8List.fromList( final pubKey = Uint8List.fromList(
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
); );
@@ -197,6 +196,10 @@ class Contact {
longitude: lon, longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
); );
} catch (e) {
// If parsing fails, return null
return null;
}
} }
@override @override
+2 -3
View File
@@ -3,6 +3,7 @@ import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -14,7 +15,6 @@ import '../storage/community_store.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/qr_code_display.dart'; import '../widgets/qr_code_display.dart';
@@ -116,8 +116,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), title: AppBarTitle(context.l10n.channels_title, null),
title: Text(context.l10n.channels_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
+17 -6
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -16,7 +18,6 @@ import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
@@ -90,6 +91,7 @@ class _ContactsScreenState extends State<ContactsScreen>
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
final frameBuffer = BufferReader(frame); final frameBuffer = BufferReader(frame);
try {
final code = frameBuffer.readUInt8(); final code = frameBuffer.readUInt8();
if (code == respCodeExportContact) { if (code == respCodeExportContact) {
@@ -130,7 +132,9 @@ class _ContactsScreenState extends State<ContactsScreen>
if (_pendingOperations.contains(ContactOperationType.export)) { if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopied),
),
); );
} }
@@ -143,7 +147,9 @@ class _ContactsScreenState extends State<ContactsScreen>
if (_pendingOperations.contains(ContactOperationType.import)) { if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), SnackBar(
content: Text(context.l10n.contacts_contactImportFailed),
),
); );
} }
@@ -164,6 +170,12 @@ class _ContactsScreenState extends State<ContactsScreen>
_pendingOperations.clear(); _pendingOperations.clear();
} }
} catch (e) {
appLogger.error(
'Error processing received frame: $e',
tag: 'ContactsScreen',
);
}
}); });
} }
@@ -229,9 +241,8 @@ class _ContactsScreenState extends State<ContactsScreen>
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), //leading: ,
title: Text(context.l10n.contacts_title), title: AppBarTitle(context.l10n.contacts_title, null),
centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
PopupMenuButton( PopupMenuButton(
+4 -5
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -17,7 +18,6 @@ import '../services/map_marker_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
@@ -262,8 +262,7 @@ class _MapScreenState extends State<MapScreen> {
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector), title: AppBarTitle(context.l10n.map_title, null),
title: Text(context.l10n.map_title),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
@@ -384,8 +383,8 @@ class _MapScreenState extends State<MapScreen> {
connector.selfLatitude!, connector.selfLatitude!,
connector.selfLongitude!, connector.selfLongitude!,
), ),
width: 35, width: 40,
height: 35, height: 40,
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
+27 -4
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -96,6 +97,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
int resultsCount, int resultsCount,
) { ) {
final Map<int, Map<String, dynamic>> neighbours = {}; final Map<int, Map<String, dynamic>> neighbours = {};
try {
for (var i = 0; i < resultsCount; i++) { for (var i = 0; i < resultsCount; i++) {
final neighbourData = neighbours.putIfAbsent( final neighbourData = neighbours.putIfAbsent(
i, i,
@@ -112,12 +114,23 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
} }
return neighbours.values.toList(); return neighbours.values.toList();
} catch (e) {
appLogger.error(
'Error parsing neighbours data: $e',
tag: 'NeighboursScreen',
);
return [];
}
} }
void _handleNeighboursResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighboursResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
try {
final neighbourCount = buffer.readUInt16LE(); final neighbourCount = buffer.readUInt16LE();
final parsedNeighbours = parseNeighboursData(buffer, buffer.readUInt16LE()); final parsedNeighbours = parseNeighboursData(
buffer,
buffer.readUInt16LE(),
);
connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
repeater, repeater,
) { ) {
@@ -150,6 +163,9 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
_isLoaded = true; _isLoaded = true;
_hasData = true; _hasData = true;
}); });
} catch (e) {
appLogger.error('Error handling neighbours response: $e');
}
} }
Contact _resolveRepeater(MeshCoreConnector connector) { Contact _resolveRepeater(MeshCoreConnector connector) {
@@ -430,6 +446,7 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
double snr, double snr,
int spreadingFactor, int spreadingFactor,
) { ) {
final snrUi = snrUiFromSNR(snr, spreadingFactor);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 3), padding: const EdgeInsets.symmetric(vertical: 3),
child: Row( child: Row(
@@ -443,9 +460,15 @@ class _NeighboursScreenState extends State<NeighboursScreen> {
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
subtitle: Text(value), subtitle: Text(value),
trailing: SNRIcon( trailing: Column(
snr: snr, mainAxisAlignment: MainAxisAlignment.center,
snrLevels: getSNRfromSF(spreadingFactor), children: [
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
Text(
snrUi.text!,
style: TextStyle(fontSize: 10, color: snrUi.color),
),
],
), ),
), ),
), ),
+47 -16
View File
@@ -10,6 +10,7 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/map_tile_cache_service.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -146,6 +147,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
final frameBuffer = BufferReader(frame); final frameBuffer = BufferReader(frame);
try {
final code = frameBuffer.readUInt8(); final code = frameBuffer.readUInt8();
if (code == respCodeSent) { if (code == respCodeSent) {
@@ -164,14 +166,6 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
}); });
} }
if (code == respCodeErr) {
_timeoutTimer?.cancel();
if (!mounted) return;
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
}
// Check if it's a binary response // Check if it's a binary response
if (frame.length > 8 && if (frame.length > 8 &&
code == pushCodeTraceData && code == pushCodeTraceData &&
@@ -183,6 +177,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_handleTraceResponse(frame); _handleTraceResponse(frame);
} }
} }
} catch (e) {
// Handle any parsing errors gracefully
appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen');
}
}); });
} }
@@ -190,6 +188,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
try {
buffer.skipBytes(2); // Skip push code and reserved byte buffer.skipBytes(2); // Skip push code and reserved byte
int pathLength = buffer.readUInt8(); int pathLength = buffer.readUInt8();
buffer.skipBytes(5); // Skip Flag byte and tag data buffer.skipBytes(5); // Skip Flag byte and tag data
@@ -199,7 +198,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) { connector.contacts.where((c) => c.type != advTypeChat).forEach((
repeater,
) {
for (var repeaterData in pathData) { for (var repeaterData in pathData) {
if (listEquals( if (listEquals(
repeater.publicKey.sublist(0, 1), repeater.publicKey.sublist(0, 1),
@@ -239,7 +240,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
] ]
: <Polyline>[]; : <Polyline>[];
_initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0); _initialCenter = _points.isNotEmpty
? _points.first
: const LatLng(0, 0);
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0; _initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
_mapKey = ValueKey( _mapKey = ValueKey(
@@ -247,6 +250,18 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
); );
_pathDistanceMeters = getPathDistanceMeters(_points); _pathDistanceMeters = getPathDistanceMeters(_points);
}); });
} catch (e) {
appLogger.error(
'Error handling trace response: $e',
tag: 'PathTraceMapScreen',
);
if (mounted) {
setState(() {
_isLoading = false;
_failed2Loaded = true;
});
}
}
} }
@override @override
@@ -532,6 +547,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
itemCount: pathTraceData.pathData.length + 1, itemCount: pathTraceData.pathData.length + 1,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final snrUi = snrUiFromSNR(
index < pathTraceData.snrData.length
? pathTraceData.snrData[index].toDouble()
: null,
context.read<MeshCoreConnector>().currentSf,
);
return Column( return Column(
children: [ children: [
ListTile( ListTile(
@@ -550,12 +571,22 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
trailing: SNRIcon( trailing: Column(
snr: mainAxisAlignment: MainAxisAlignment.center,
pathTraceData.snrData[index].toSigned( children: [
8, Icon(
) / snrUi.icon,
4.0, color: snrUi.color,
size: 18.0,
),
Text(
snrUi.text,
style: TextStyle(
fontSize: 10,
color: snrUi.color,
),
),
],
), ),
onTap: () { onTap: () {
// Handle item tap // Handle item tap
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/widgets/battery_indicator.dart';
import 'package:provider/provider.dart';
class AppBarTitle extends StatelessWidget {
final String title;
final TextStyle? style;
final Widget? leading;
final Widget? trailing;
const AppBarTitle(
this.title,
this.style, {
this.leading,
this.trailing,
super.key,
});
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) leading!,
Text(title),
if (connector.isConnected && connector.selfName != null)
Center(
child: Text(
'(${connector.selfName})',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
),
],
),
const SizedBox(width: 8),
BatteryIndicator(connector: connector),
if (trailing != null) trailing!,
],
);
}
}
+70 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
@@ -42,6 +43,7 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final percent = widget.connector.batteryPercent; final percent = widget.connector.batteryPercent;
final millivolts = widget.connector.batteryMillivolts; final millivolts = widget.connector.batteryMillivolts;
final directRepeaters = widget.connector.directRepeaters;
if (millivolts == null) { if (millivolts == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -55,6 +57,20 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
} }
final batteryUi = batteryUiForPercent(percent); final batteryUi = batteryUiForPercent(percent);
final directBestRepeaters = List.of(directRepeaters)
..sort((a, b) {
final dateCompare = b.lastUpdated.compareTo(a.lastUpdated);
if (dateCompare != 0) return dateCompare;
return (b.snr).compareTo(a.snr);
});
final directRepeater = directBestRepeaters.isEmpty
? null
: directBestRepeaters.first;
final snrUi = snrUiFromSNR(
directBestRepeaters.isNotEmpty ? directRepeater!.snr : null,
widget.connector.currentSf,
);
return InkWell( return InkWell(
onTap: () { onTap: () {
@@ -66,6 +82,9 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(batteryUi.icon, size: 18, color: batteryUi.color), Icon(batteryUi.icon, size: 18, color: batteryUi.color),
@@ -78,9 +97,40 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: batteryUi.color, color: batteryUi.color,
), ),
overflow: TextOverflow.visible,
maxLines: 1, maxLines: 1,
softWrap: false, overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(snrUi.icon, size: 18, color: snrUi.color),
Text(
snrUi.text,
style: TextStyle(fontSize: 12, color: snrUi.color),
),
],
),
if (directRepeater != null)
Text(
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
], ],
@@ -88,4 +138,22 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
), ),
); );
} }
String _formatLastUpdated(DateTime lastSeen) {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.isNegative || diff.inMinutes < 1) {
return "${diff.inSeconds}s";
}
if (diff.inMinutes < 60) {
return "${diff.inMinutes}m";
}
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1 ? "1h" : "${hours}hs";
}
final days = diff.inDays;
return days == 1 ? "1d" : "${days}ds";
}
} }
+14 -18
View File
@@ -1,5 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SNRUi {
final IconData icon;
final Color color;
final String text;
const SNRUi(this.icon, this.color, this.text);
}
List<double> getSNRfromSF(int spreadingFactor) { List<double> getSNRfromSF(int spreadingFactor) {
switch (spreadingFactor) { switch (spreadingFactor) {
case 7: case 7:
@@ -19,20 +26,16 @@ List<double> getSNRfromSF(int spreadingFactor) {
} }
} }
class SNRIcon extends StatelessWidget { SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) {
final double snr; if (snr == null || spreadingFactor == null) {
final List<double> snrLevels; return const SNRUi(Icons.signal_cellular_off, Colors.grey, '');
}
const SNRIcon({ final snrLevels = getSNRfromSF(spreadingFactor);
super.key,
required this.snr,
this.snrLevels = const [4.0, -2.0, -4.0, -6.0],
});
@override
Widget build(BuildContext context) {
IconData icon; IconData icon;
Color color; Color color;
String text = '${snr.toStringAsFixed(1)} dB';
if (snr >= snrLevels[0]) { if (snr >= snrLevels[0]) {
icon = Icons.signal_cellular_alt; icon = Icons.signal_cellular_alt;
@@ -51,12 +54,5 @@ class SNRIcon extends StatelessWidget {
color = Colors.red; color = Colors.red;
} }
return Column( return SNRUi(icon, color, text);
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Text('$snr dB', style: TextStyle(fontSize: 10, color: color)),
],
);
}
} }