From aed3b0157a30547466538e6d6053c5cdf8e547c7 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Feb 2026 14:19:09 -0800 Subject: [PATCH] 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. --- lib/connector/meshcore_connector.dart | 264 +++++++++++++++++++- lib/connector/meshcore_protocol.dart | 54 +++++ lib/helpers/cayenne_lpp.dart | 337 ++++++++++++++------------ lib/models/contact.dart | 69 +++--- lib/screens/channels_screen.dart | 5 +- lib/screens/contacts_screen.dart | 143 ++++++----- lib/screens/map_screen.dart | 9 +- lib/screens/neighbours_screen.dart | 121 +++++---- lib/screens/path_trace_map.dart | 207 +++++++++------- lib/widgets/app_bar.dart | 46 ++++ lib/widgets/battery_indicator.dart | 92 ++++++- lib/widgets/snr_indicator.dart | 74 +++--- 12 files changed, 964 insertions(+), 457 deletions(-) create mode 100644 lib/widgets/app_bar.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 5d6c7e6..6b98c48 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -37,6 +37,29 @@ class MeshCoreUuids { 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 { disconnected, scanning, @@ -93,6 +116,7 @@ class MeshCoreConnector extends ChangeNotifier { int? _batteryMillivolts; double? _selfLatitude; double? _selfLongitude; + final List _directRepeaters = List.empty(growable: true); bool _isLoadingContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; @@ -194,6 +218,7 @@ class MeshCoreConnector extends ChangeNotifier { String? get selfName => _selfName; double? get selfLatitude => _selfLatitude; double? get selfLongitude => _selfLongitude; + List get directRepeaters => _directRepeaters; int? get currentTxPower => _currentTxPower; int? get maxTxPower => _maxTxPower; int? get currentFreqHz => _currentFreqHz; @@ -1690,6 +1715,11 @@ class MeshCoreConnector extends ChangeNotifier { _isLoadingContacts = true; notifyListeners(); 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: debugPrint('Got CONTACT'); _handleContact(frame); @@ -1734,6 +1764,7 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeStatusResponse: break; case pushCodeLogRxData: + _handleRxData(frame); _handleLogRxData(frame); break; case respCodeChannelInfo: @@ -1747,6 +1778,7 @@ class MeshCoreConnector extends ChangeNotifier { break; case respCodeCustomVars: _handleCustomVars(frame); + break; default: 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.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 _persistContacts() async { await _contactStore.saveContacts(_contacts); } @@ -3261,7 +3367,11 @@ class MeshCoreConnector extends ChangeNotifier { void _handleCustomVars(Uint8List frame) { final buf = BufferReader(frame.sublist(1)); - _currentCustomVars = _parseKeyValueString(buf.readString()); + try { + _currentCustomVars = _parseKeyValueString(buf.readString()); + } catch (e) { + appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector'); + } } void _setState(MeshCoreConnectionState newState) { @@ -3285,6 +3395,158 @@ class MeshCoreConnector extends ChangeNotifier { 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; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 25359a8..4ee19c2 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -13,12 +13,22 @@ class BufferReader { int readByte() => readBytes(1)[0]; 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); _pointer += count; return data; } 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; } @@ -151,6 +161,7 @@ const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdSendTracePath = 36; +const int cmdSetOtherParams = 38; const int cmdGetRadioSettings = 57; const int cmdGetTelemetryReq = 39; const int cmdGetCustomVar = 40; @@ -212,6 +223,30 @@ const int advTypeRepeater = 2; const int advTypeRoom = 3; 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 const int pubKeySize = 32; const int maxPathSize = 64; @@ -777,3 +812,22 @@ Uint8List buildZeroHopContact(Uint8List pubKey) { writer.writeBytes(pubKey); 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(); +} diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index bf9b8e7..fea05ec 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -1,4 +1,6 @@ import 'dart:typed_data'; +import 'package:meshcore_open/utils/app_logger.dart'; + import '../connector/meshcore_protocol.dart'; class CayenneLpp { @@ -84,180 +86,193 @@ class CayenneLpp { static List> parse(Uint8List bytes) { final buffer = BufferReader(bytes); final telemetry = >[]; + try { + while (buffer.remaining >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); - while (buffer.remaining >= 2) { - final channel = buffer.readUInt8(); - final type = buffer.readUInt8(); + if (channel == 0 && type == 0) { + break; + } - if (channel == 0 && type == 0) { - break; - } - - switch (type) { - case lppGenericSensor: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt32BE(), - }); - break; - case lppLuminosity: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppPresence: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8(), - }); - break; - case lppTemperature: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 10, - }); - break; - case lppRelativeHumidity: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8() / 2, - }); - break; - case lppBarometricPressure: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE() / 10, - }); - break; - case lppVoltage: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 100, - }); - break; - case lppCurrent: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readInt16BE() / 1000, - }); - break; - case lppPercentage: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt8(), - }); - break; - case lppConcentration: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppPower: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': buffer.readUInt16BE(), - }); - break; - case lppGps: - telemetry.add({ - 'channel': channel, - 'type': type, - 'value': { - 'latitude': buffer.readInt24BE() / 10000, - 'longitude': buffer.readInt24BE() / 10000, - 'altitude': buffer.readInt24BE() / 100, - }, - }); - break; - default: - return telemetry; + switch (type) { + case lppGenericSensor: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt32BE(), + }); + break; + case lppLuminosity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPresence: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppTemperature: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 10, + }); + break; + case lppRelativeHumidity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8() / 2, + }); + break; + case lppBarometricPressure: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE() / 10, + }); + break; + case lppVoltage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 100, + }); + break; + case lppCurrent: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 1000, + }); + break; + case lppPercentage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppConcentration: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPower: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppGps: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': { + 'latitude': buffer.readInt24BE() / 10000, + 'longitude': buffer.readInt24BE() / 10000, + 'altitude': buffer.readInt24BE() / 100, + }, + }); + break; + default: + return telemetry; + } } + return telemetry; + } catch (e) { + // Handle parsing errors, possibly due to malformed data + appLogger.error('Error parsing Cayenne LPP data: $e'); + return < + Map + >[]; // Return an empty list on error to avoid crashing the app } - - return telemetry; } static List> parseByChannel(Uint8List bytes) { final buffer = BufferReader(bytes); final Map> channels = {}; + try { + while (buffer.remaining >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); - while (buffer.remaining >= 2) { - final channel = buffer.readUInt8(); - final type = buffer.readUInt8(); + // Optional: stop on padding (00 00) + if (channel == 0 && type == 0) { + break; + } - // Optional: stop on padding (00 00) - if (channel == 0 && type == 0) { - break; + final channelData = channels.putIfAbsent( + channel, + () => {'channel': channel, 'values': {}}, + ); + + switch (type) { + case lppGenericSensor: + channelData['values']['generic'] = buffer.readUInt32BE(); + break; + case lppLuminosity: + channelData['values']['luminosity'] = buffer.readUInt16BE(); + break; + case lppPresence: + channelData['values']['presence'] = buffer.readUInt8() != 0; + break; + case lppTemperature: + channelData['values']['temperature'] = buffer.readInt16BE() / 10.0; + break; + case lppRelativeHumidity: + channelData['values']['humidity'] = buffer.readUInt8() / 2.0; + break; + case lppBarometricPressure: + channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; + break; + case lppVoltage: + channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; + break; + case lppCurrent: + channelData['values']['current'] = buffer.readInt16BE() / 1000.0; + break; + case lppPercentage: + channelData['values']['percentage'] = buffer.readUInt8(); + break; + case lppConcentration: + channelData['values']['concentration'] = buffer.readUInt16BE(); + break; + case lppPower: + channelData['values']['power'] = buffer.readUInt16BE(); + break; + case lppGps: + channelData['values']['gps'] = { + 'latitude': buffer.readInt24BE() / 10000.0, + 'longitude': buffer.readInt24BE() / 10000.0, + 'altitude': buffer.readInt24BE() / 100.0, + }; + break; + // Add more types as needed... + default: + // Unknown type: skip or handle error? + continue; + } } - final channelData = channels.putIfAbsent( - channel, - () => {'channel': channel, 'values': {}}, - ); - - switch (type) { - case lppGenericSensor: - channelData['values']['generic'] = buffer.readUInt32BE(); - break; - case lppLuminosity: - channelData['values']['luminosity'] = buffer.readUInt16BE(); - break; - case lppPresence: - channelData['values']['presence'] = buffer.readUInt8() != 0; - break; - case lppTemperature: - channelData['values']['temperature'] = buffer.readInt16BE() / 10.0; - break; - case lppRelativeHumidity: - channelData['values']['humidity'] = buffer.readUInt8() / 2.0; - break; - case lppBarometricPressure: - channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0; - break; - case lppVoltage: - channelData['values']['voltage'] = buffer.readInt16BE() / 100.0; - break; - case lppCurrent: - channelData['values']['current'] = buffer.readInt16BE() / 1000.0; - break; - case lppPercentage: - channelData['values']['percentage'] = buffer.readUInt8(); - break; - case lppConcentration: - channelData['values']['concentration'] = buffer.readUInt16BE(); - break; - case lppPower: - channelData['values']['power'] = buffer.readUInt16BE(); - break; - case lppGps: - channelData['values']['gps'] = { - 'latitude': buffer.readInt24BE() / 10000.0, - 'longitude': buffer.readInt24BE() / 10000.0, - 'altitude': buffer.readInt24BE() / 100.0, - }; - break; - // Add more types as needed... - default: - // Unknown type: skip or handle error? - continue; - } + final List> channelsOut = channels.values.toList(); + channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); + return channelsOut; + } catch (e) { + // Handle parsing errors, possibly due to malformed data + appLogger.error('Error parsing Cayenne LPP data: $e'); + return < + Map + >[]; // Return an empty list on error to avoid crashing the app } - - final List> channelsOut = channels.values.toList(); - channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); - return channelsOut; } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index a98580f..13f9af5 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -160,43 +160,46 @@ class Contact { } static Contact? fromFrame(Uint8List data) { - if (data.length < contactFrameSize) return null; if (data[0] != respCodeContact) return null; + try { + final pubKey = Uint8List.fromList( + data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), + ); + final type = data[contactTypeOffset]; + final pathLen = data[contactPathLenOffset].toSigned(8); + final safePathLen = pathLen > 0 + ? (pathLen > maxPathSize ? maxPathSize : pathLen) + : 0; + final pathBytes = safePathLen > 0 + ? Uint8List.fromList( + data.sublist(contactPathOffset, contactPathOffset + safePathLen), + ) + : Uint8List(0); + final name = readCString(data, contactNameOffset, maxNameSize); + final lastmod = readUint32LE(data, contactLastmodOffset); - final pubKey = Uint8List.fromList( - data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize), - ); - final type = data[contactTypeOffset]; - final pathLen = data[contactPathLenOffset].toSigned(8); - final safePathLen = pathLen > 0 - ? (pathLen > maxPathSize ? maxPathSize : pathLen) - : 0; - final pathBytes = safePathLen > 0 - ? Uint8List.fromList( - data.sublist(contactPathOffset, contactPathOffset + safePathLen), - ) - : Uint8List(0); - final name = readCString(data, contactNameOffset, maxNameSize); - final lastmod = readUint32LE(data, contactLastmodOffset); + double? lat, lon; + final latRaw = readInt32LE(data, contactLatOffset); + final lonRaw = readInt32LE(data, contactLonOffset); + if (latRaw != 0 || lonRaw != 0) { + lat = latRaw / 1e6; + lon = lonRaw / 1e6; + } - double? lat, lon; - final latRaw = readInt32LE(data, contactLatOffset); - final lonRaw = readInt32LE(data, contactLonOffset); - if (latRaw != 0 || lonRaw != 0) { - lat = latRaw / 1e6; - lon = lonRaw / 1e6; + return Contact( + publicKey: pubKey, + name: name.isEmpty ? 'Unknown' : name, + type: type, + pathLength: pathLen, + path: pathBytes, + latitude: lat, + longitude: lon, + lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), + ); + } catch (e) { + // If parsing fails, return null + return null; } - - return Contact( - publicKey: pubKey, - name: name.isEmpty ? 'Unknown' : name, - type: type, - pathLength: pathLen, - path: pathBytes, - latitude: lat, - longitude: lon, - lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000), - ); } @override diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 6b8b92d..0f9b49b 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; @@ -14,7 +15,6 @@ import '../storage/community_store.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/qr_code_display.dart'; @@ -116,8 +116,7 @@ class _ChannelsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.channels_title), + title: AppBarTitle(context.l10n.channels_title, null), centerTitle: true, automaticallyImplyLeading: false, actions: [ diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 6799d69..0f0c9bf 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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 '../connector/meshcore_connector.dart'; @@ -16,7 +18,6 @@ import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; @@ -90,79 +91,90 @@ class _ContactsScreenState extends State _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); - final code = frameBuffer.readUInt8(); + try { + final code = frameBuffer.readUInt8(); - if (code == respCodeExportContact) { - final advertPacket = frameBuffer.readRemainingBytes(); - // Validate packet has expected minimum size (98+ bytes per protocol) - if (advertPacket.length < 98) { - if (mounted) { + if (code == respCodeExportContact) { + final advertPacket = frameBuffer.readRemainingBytes(); + // Validate packet has expected minimum size (98+ bytes per protocol) + if (advertPacket.length < 98) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_invalidAdvertFormat), + ), + ); + } + _pendingOperations.remove(ContactOperationType.export); + return; + } + final hexString = pubKeyToHex(advertPacket); + Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); + } + + if (code == respCodeOk) { + // Show a snackbar indicating success + if (!mounted) return; + + if (_pendingOperations.contains(ContactOperationType.import)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.contacts_contactImported)), + ); + } + + if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.contacts_invalidAdvertFormat), + content: Text(context.l10n.contacts_zeroHopContactAdvertSent), ), ); } - _pendingOperations.remove(ContactOperationType.export); - return; - } - final hexString = pubKeyToHex(advertPacket); - Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); - } - if (code == respCodeOk) { - // Show a snackbar indicating success - if (!mounted) return; + if (_pendingOperations.contains(ContactOperationType.export)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactAdvertCopied), + ), + ); + } - if (_pendingOperations.contains(ContactOperationType.import)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactImported)), - ); + _pendingOperations.clear(); } - if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_zeroHopContactAdvertSent), - ), - ); + if (code == respCodeErr) { + // Show a snackbar indicating failure + if (!mounted) return; + + if (_pendingOperations.contains(ContactOperationType.import)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactImportFailed), + ), + ); + } + + if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), + ), + ); + } + if (_pendingOperations.contains(ContactOperationType.export)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_contactAdvertCopyFailed), + ), + ); + } + + _pendingOperations.clear(); } - - if (_pendingOperations.contains(ContactOperationType.export)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), - ); - } - - _pendingOperations.clear(); - } - - if (code == respCodeErr) { - // Show a snackbar indicating failure - if (!mounted) return; - - if (_pendingOperations.contains(ContactOperationType.import)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), - ); - } - - if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_zeroHopContactAdvertFailed), - ), - ); - } - if (_pendingOperations.contains(ContactOperationType.export)) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.contacts_contactAdvertCopyFailed), - ), - ); - } - - _pendingOperations.clear(); + } catch (e) { + appLogger.error( + 'Error processing received frame: $e', + tag: 'ContactsScreen', + ); } }); } @@ -229,9 +241,8 @@ class _ContactsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.contacts_title), - centerTitle: true, + //leading: , + title: AppBarTitle(context.l10n.contacts_title, null), automaticallyImplyLeading: false, actions: [ PopupMenuButton( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 552f579..069ec7d 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; +import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; @@ -17,7 +18,6 @@ import '../services/map_marker_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; -import '../widgets/battery_indicator.dart'; import '../widgets/quick_switch_bar.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -262,8 +262,7 @@ class _MapScreenState extends State { canPop: allowBack, child: Scaffold( appBar: AppBar( - leading: BatteryIndicator(connector: connector), - title: Text(context.l10n.map_title), + title: AppBarTitle(context.l10n.map_title, null), centerTitle: true, automaticallyImplyLeading: false, actions: [ @@ -384,8 +383,8 @@ class _MapScreenState extends State { connector.selfLatitude!, connector.selfLongitude!, ), - width: 35, - height: 35, + width: 40, + height: 40, child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( diff --git a/lib/screens/neighbours_screen.dart b/lib/screens/neighbours_screen.dart index b606188..d9eaf28 100644 --- a/lib/screens/neighbours_screen.dart +++ b/lib/screens/neighbours_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/utils/app_logger.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -96,60 +97,75 @@ class _NeighboursScreenState extends State { int resultsCount, ) { final Map> neighbours = {}; - for (var i = 0; i < resultsCount; i++) { - final neighbourData = neighbours.putIfAbsent( - i, - () => { - 'contact': null, - 'publicKey': {}, - 'lastHeard': {}, - 'snr': {}, - }, - ); - neighbourData['publicKey'] = buffer.readBytes(_reqNeighboursKeyLen); - neighbourData['lastHeard'] = buffer.readUInt32LE(); - neighbourData['snr'] = buffer.readInt8() / 4.0; - } + try { + for (var i = 0; i < resultsCount; i++) { + final neighbourData = neighbours.putIfAbsent( + i, + () => { + 'contact': null, + 'publicKey': {}, + 'lastHeard': {}, + 'snr': {}, + }, + ); + neighbourData['publicKey'] = buffer.readBytes(_reqNeighboursKeyLen); + neighbourData['lastHeard'] = buffer.readUInt32LE(); + neighbourData['snr'] = buffer.readInt8() / 4.0; + } - 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) { final buffer = BufferReader(frame); - final neighbourCount = buffer.readUInt16LE(); - final parsedNeighbours = parseNeighboursData(buffer, buffer.readUInt16LE()); - connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( - repeater, - ) { - for (var neighbourData in parsedNeighbours) { - final publicKey = neighbourData['publicKey']; - if (listEquals( - repeater.publicKey.sublist(0, _reqNeighboursKeyLen), - publicKey, - )) { - neighbourData['contact'] = repeater; + try { + final neighbourCount = buffer.readUInt16LE(); + final parsedNeighbours = parseNeighboursData( + buffer, + buffer.readUInt16LE(), + ); + connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( + repeater, + ) { + for (var neighbourData in parsedNeighbours) { + final publicKey = neighbourData['publicKey']; + if (listEquals( + repeater.publicKey.sublist(0, _reqNeighboursKeyLen), + publicKey, + )) { + neighbourData['contact'] = repeater; + } } - } - }); + }); - setState(() { - _parsedNeighbours = parsedNeighbours; - _neighbourCount = neighbourCount; - }); + setState(() { + _parsedNeighbours = parsedNeighbours; + _neighbourCount = neighbourCount; + }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.neighbors_receivedData), - backgroundColor: Colors.green, - ), - ); - _statusTimeout?.cancel(); - if (!mounted) return; - setState(() { - _isLoading = false; - _isLoaded = true; - _hasData = true; - }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.neighbors_receivedData), + backgroundColor: Colors.green, + ), + ); + _statusTimeout?.cancel(); + if (!mounted) return; + setState(() { + _isLoading = false; + _isLoaded = true; + _hasData = true; + }); + } catch (e) { + appLogger.error('Error handling neighbours response: $e'); + } } Contact _resolveRepeater(MeshCoreConnector connector) { @@ -430,6 +446,7 @@ class _NeighboursScreenState extends State { double snr, int spreadingFactor, ) { + final snrUi = snrUiFromSNR(snr, spreadingFactor); return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( @@ -443,9 +460,15 @@ class _NeighboursScreenState extends State { style: const TextStyle(fontWeight: FontWeight.w500), ), subtitle: Text(value), - trailing: SNRIcon( - snr: snr, - snrLevels: getSNRfromSF(spreadingFactor), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(snrUi.icon, color: snrUi.color, size: 18.0), + Text( + snrUi.text!, + style: TextStyle(fontSize: 10, color: snrUi.color), + ), + ], ), ), ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 7677d0d..150c098 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -10,6 +10,7 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/models/contact.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:provider/provider.dart'; @@ -146,42 +147,39 @@ class _PathTraceMapScreenState extends State { _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; final frameBuffer = BufferReader(frame); - final code = frameBuffer.readUInt8(); + try { + final code = frameBuffer.readUInt8(); - if (code == respCodeSent) { - frameBuffer.skipBytes(1); //reserved - tagData = frameBuffer.readBytes(4); - final timeoutSeconds = frameBuffer.readUInt32LE(); + if (code == respCodeSent) { + frameBuffer.skipBytes(1); //reserved + tagData = frameBuffer.readBytes(4); + final timeoutSeconds = frameBuffer.readUInt32LE(); - // Start timeout timer for trace response - _timeoutTimer?.cancel(); - _timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { - if (!mounted) return; - setState(() { - _isLoading = false; - _failed2Loaded = true; + // Start timeout timer for trace response + _timeoutTimer?.cancel(); + _timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () { + if (!mounted) return; + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); }); - }); - } - - if (code == respCodeErr) { - _timeoutTimer?.cancel(); - if (!mounted) return; - setState(() { - _isLoading = false; - _failed2Loaded = true; - }); - } - // Check if it's a binary response - if (frame.length > 8 && - code == pushCodeTraceData && - listEquals(frame.sublist(4, 8), tagData)) { - _timeoutTimer?.cancel(); - if (!mounted) return; - frameBuffer.skipBytes(3); //reserved + path length + flag - if (listEquals(frameBuffer.readBytes(4), tagData)) { - _handleTraceResponse(frame); } + + // Check if it's a binary response + if (frame.length > 8 && + code == pushCodeTraceData && + listEquals(frame.sublist(4, 8), tagData)) { + _timeoutTimer?.cancel(); + if (!mounted) return; + frameBuffer.skipBytes(3); //reserved + path length + flag + if (listEquals(frameBuffer.readBytes(4), tagData)) { + _handleTraceResponse(frame); + } + } + } catch (e) { + // Handle any parsing errors gracefully + appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen'); } }); } @@ -190,63 +188,80 @@ class _PathTraceMapScreenState extends State { final connector = Provider.of(context, listen: false); final buffer = BufferReader(frame); - buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code - Uint8List pathData = buffer.readBytes(pathLength); - Uint8List snrData = buffer.readRemainingBytes(); + try { + buffer.skipBytes(2); // Skip push code and reserved byte + int pathLength = buffer.readUInt8(); + buffer.skipBytes(5); // Skip Flag byte and tag data + buffer.skipBytes(4); // Skip auth code + Uint8List pathData = buffer.readBytes(pathLength); + Uint8List snrData = buffer.readRemainingBytes(); - Map pathContacts = {}; + Map pathContacts = {}; - connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) { - for (var repeaterData in pathData) { - if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([repeaterData]), - )) { - pathContacts[repeaterData] = repeater; + connector.contacts.where((c) => c.type != advTypeChat).forEach(( + repeater, + ) { + for (var repeaterData in pathData) { + if (listEquals( + repeater.publicKey.sublist(0, 1), + Uint8List.fromList([repeaterData]), + )) { + pathContacts[repeaterData] = repeater; + } } - } - }); + }); - setState(() { - _isLoading = false; - _hasData = true; - _traceData = PathTraceData( - pathData: pathData, - snrData: snrData, - pathContacts: pathContacts, - ); - _points = []; - _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); - for (final hop in _traceData!.pathData) { - final contact = _traceData!.pathContacts[hop]; - if (contact != null && - contact.hasLocation && - contact.latitude != null && - contact.longitude != null) { - _points.add(LatLng(contact.latitude!, contact.longitude!)); + setState(() { + _isLoading = false; + _hasData = true; + _traceData = PathTraceData( + pathData: pathData, + snrData: snrData, + pathContacts: pathContacts, + ); + _points = []; + _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); + for (final hop in _traceData!.pathData) { + final contact = _traceData!.pathContacts[hop]; + if (contact != null && + contact.hasLocation && + contact.latitude != null && + contact.longitude != null) { + _points.add(LatLng(contact.latitude!, contact.longitude!)); + } } - } - _polylines = _points.length > 1 - ? [ - Polyline( - points: _points, - strokeWidth: 4, - color: Colors.blueAccent, - ), - ] - : []; + _polylines = _points.length > 1 + ? [ + Polyline( + points: _points, + strokeWidth: 4, + color: Colors.blueAccent, + ), + ] + : []; - _initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0); - _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; - _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; - _mapKey = ValueKey( - '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + _initialCenter = _points.isNotEmpty + ? _points.first + : const LatLng(0, 0); + _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; + _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; + _mapKey = ValueKey( + '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + ); + _pathDistanceMeters = getPathDistanceMeters(_points); + }); + } catch (e) { + appLogger.error( + 'Error handling trace response: $e', + tag: 'PathTraceMapScreen', ); - _pathDistanceMeters = getPathDistanceMeters(_points); - }); + if (mounted) { + setState(() { + _isLoading = false; + _failed2Loaded = true; + }); + } + } } @override @@ -532,6 +547,12 @@ class _PathTraceMapScreenState extends State { itemCount: pathTraceData.pathData.length + 1, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { + final snrUi = snrUiFromSNR( + index < pathTraceData.snrData.length + ? pathTraceData.snrData[index].toDouble() + : null, + context.read().currentSf, + ); return Column( children: [ ListTile( @@ -550,12 +571,22 @@ class _PathTraceMapScreenState extends State { ), style: const TextStyle(fontSize: 14), ), - trailing: SNRIcon( - snr: - pathTraceData.snrData[index].toSigned( - 8, - ) / - 4.0, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + snrUi.icon, + color: snrUi.color, + size: 18.0, + ), + Text( + snrUi.text, + style: TextStyle( + fontSize: 10, + color: snrUi.color, + ), + ), + ], ), onTap: () { // Handle item tap diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart new file mode 100644 index 0000000..1b2ed38 --- /dev/null +++ b/lib/widgets/app_bar.dart @@ -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(); + + 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!, + ], + ); + } +} diff --git a/lib/widgets/battery_indicator.dart b/lib/widgets/battery_indicator.dart index 7837415..f203347 100644 --- a/lib/widgets/battery_indicator.dart +++ b/lib/widgets/battery_indicator.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:meshcore_open/widgets/snr_indicator.dart'; import '../connector/meshcore_connector.dart'; @@ -42,6 +43,7 @@ class _BatteryIndicatorState extends State { Widget build(BuildContext context) { final percent = widget.connector.batteryPercent; final millivolts = widget.connector.batteryMillivolts; + final directRepeaters = widget.connector.directRepeaters; if (millivolts == null) { return const SizedBox.shrink(); @@ -55,6 +57,20 @@ class _BatteryIndicatorState extends State { } 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( onTap: () { @@ -68,19 +84,53 @@ class _BatteryIndicatorState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(batteryUi.icon, size: 18, color: batteryUi.color), - const SizedBox(width: 2), - Flexible( - child: Text( - displayText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: batteryUi.color, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(batteryUi.icon, size: 18, color: batteryUi.color), + const SizedBox(width: 2), + Flexible( + child: Text( + displayText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: batteryUi.color, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - overflow: TextOverflow.visible, - maxLines: 1, - softWrap: false, + ], + ), + 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 { ), ); } + + 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"; + } } diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index da68a65..d6474d0 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -1,5 +1,12 @@ 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 getSNRfromSF(int spreadingFactor) { switch (spreadingFactor) { case 7: @@ -19,44 +26,33 @@ List getSNRfromSF(int spreadingFactor) { } } -class SNRIcon extends StatelessWidget { - final double snr; - final List snrLevels; - - const SNRIcon({ - super.key, - required this.snr, - this.snrLevels = const [4.0, -2.0, -4.0, -6.0], - }); - - @override - Widget build(BuildContext context) { - IconData icon; - Color color; - - if (snr >= snrLevels[0]) { - icon = Icons.signal_cellular_alt; - color = Colors.green; - } else if (snr >= snrLevels[1]) { - icon = Icons.signal_cellular_alt; - color = Colors.lightGreen; - } else if (snr >= snrLevels[2]) { - icon = Icons.signal_cellular_alt; - color = Colors.yellow; - } else if (snr >= snrLevels[3]) { - icon = Icons.signal_cellular_alt_2_bar; - color = Colors.orange; - } else { - icon = Icons.signal_cellular_alt_1_bar; - color = Colors.red; - } - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color), - Text('$snr dB', style: TextStyle(fontSize: 10, color: color)), - ], - ); +SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) { + if (snr == null || spreadingFactor == null) { + return const SNRUi(Icons.signal_cellular_off, Colors.grey, '—'); } + + final snrLevels = getSNRfromSF(spreadingFactor); + + IconData icon; + Color color; + String text = '${snr.toStringAsFixed(1)} dB'; + + if (snr >= snrLevels[0]) { + icon = Icons.signal_cellular_alt; + color = Colors.green; + } else if (snr >= snrLevels[1]) { + icon = Icons.signal_cellular_alt; + color = Colors.lightGreen; + } else if (snr >= snrLevels[2]) { + icon = Icons.signal_cellular_alt; + color = Colors.yellow; + } else if (snr >= snrLevels[3]) { + icon = Icons.signal_cellular_alt_2_bar; + color = Colors.orange; + } else { + icon = Icons.signal_cellular_alt_1_bar; + color = Colors.red; + } + + return SNRUi(icon, color, text); }