feat: Enhance privacy settings and telemetry (#308)

* feat: Enhance privacy settings and telemetry

- Implemented telemetry options for contacts, allowing users to enable or disable telemetry data sharing.
- Introduced a clear chat option in the chat interface for better message management.
- Updated the telemetry screen to handle telemetry data for contacts, including battery level.
- Refactored contact settings to include telemetry options and improved UI for better user experience.

* feat: Refactor repeater resolution logic across multiple screens
This commit is contained in:
Winston Lowe
2026-03-19 22:49:16 -07:00
parent cb63b48b78
commit 1392c2d00f
47 changed files with 2512 additions and 229 deletions
+27
View File
@@ -166,6 +166,33 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'clearChat') {
context.read<MeshCoreConnector>().clearMessagesForChannel(
widget.channel.index,
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'clearChat',
child: Row(
children: [
const Icon(Icons.delete, size: 20, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.contact_clearChat,
style: const TextStyle(color: Colors.red),
),
],
),
),
],
),
],
),
body: SafeArea(
top: false,
+223 -55
View File
@@ -38,6 +38,7 @@ import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart';
import '../l10n/l10n.dart';
import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@@ -246,9 +247,77 @@ class _ChatScreenState extends State<ChatScreen> {
tooltip: context.l10n.chat_pathManagement,
onPressed: () => _showPathHistory(context),
),
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => _showContactInfo(context),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'info') {
_showContactInfo(context);
}
if (value == 'settings') {
_showContactSettings(context);
}
if (value == 'telemetry') {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TelemetryScreen(contact: widget.contact),
),
);
}
if (value == 'clearChat') {
connector.clearMessagesForContact(widget.contact);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'info',
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_info),
],
),
),
PopupMenuItem(
value: 'telemetry',
child: Row(
children: [
const Icon(Icons.bar_chart, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_telemetry),
],
),
),
PopupMenuItem(
value: 'settings',
child: Row(
children: [
const Icon(Icons.settings, size: 20),
const SizedBox(width: 12),
Text(context.l10n.contact_settings),
],
),
),
PopupMenuItem(
value: 'clearChat',
child: Row(
children: [
const Icon(Icons.delete, size: 20, color: Colors.red),
const SizedBox(width: 12),
Text(
context.l10n.contact_clearChat,
style: const TextStyle(color: Colors.red),
),
],
),
),
],
);
},
),
],
),
@@ -895,11 +964,22 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
Contact _resolveContactFrom4Bytes(
@@ -952,59 +1032,127 @@ class _ChatScreenState extends State<ChatScreen> {
void _showContactInfo(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
final contact = _resolveContact(connector);
showDialog(
context: context,
builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog(
title: Text(contact.name),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(
context.l10n.chat_publicKey,
'${contact.publicKeyHex.substring(0, 16)}...',
),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
builder: (context) => AlertDialog(
title: SelectableText(contact.name),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(context.l10n.chat_type, contact.typeLabel),
_buildInfoRow(context.l10n.chat_path, contact.pathLabel),
_buildInfoRow(
context.l10n.contact_lastSeen,
_formatContactLastMessage(contact.lastMessageAt),
),
if (contact.hasLocation)
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.common_close),
),
],
),
);
}
void _showContactSettings(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
final contact = widget.contact;
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
bool teleBaseEnabled = contact.teleBaseEnabled;
bool teleLocEnabled = contact.teleLocEnabled;
bool teleEnvEnabled = contact.teleEnvEnabled;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(context.l10n.contact_settings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.hasLocation) ...[
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
const Divider(height: 8),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
setDialogState(() => smazEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleBase),
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
value: teleBaseEnabled,
onChanged: (value) {
setDialogState(() => teleBaseEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleLoc),
subtitle: Text(context.l10n.contact_teleLocSubtitle),
value: teleLocEnabled,
onChanged: (value) {
setDialogState(() => teleLocEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleEnv),
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
value: teleEnvEnabled,
onChanged: (value) {
setDialogState(() => teleEnvEnabled = value);
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
connector.setContactFlags(
contact,
teleBase: teleBaseEnabled,
teleLoc: teleLocEnabled,
teleEnv: teleEnvEnabled,
);
Navigator.pop(context);
},
child: Text(context.l10n.common_close),
),
],
),
),
);
}
@@ -1019,12 +1167,32 @@ class _ChatScreenState extends State<ChatScreen> {
width: 80,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: Text(value)),
Expanded(child: SelectableText(value)),
],
),
);
}
String _formatContactLastMessage(DateTime timestamp) {
final diff = DateTime.now().difference(timestamp);
if (diff.isNegative || diff.inMinutes < 5) {
return context.l10n.contacts_lastSeenNow;
}
if (diff.inMinutes < 60) {
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
}
if (diff.inHours < 24) {
final hours = diff.inHours;
return hours == 1
? context.l10n.contacts_lastSeenHourAgo
: context.l10n.contacts_lastSeenHoursAgo(hours);
}
final days = diff.inDays;
return days == 1
? context.l10n.contacts_lastSeenDayAgo
: context.l10n.contacts_lastSeenDaysAgo(days);
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
context.read<MeshCoreConnector>().markContactRead(contact.publicKeyHex);
+4 -1
View File
@@ -1354,7 +1354,10 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () async {
Navigator.pop(sheetContext);
await connector.setContactFavorite(contact, !isFavorite);
await connector.setContactFlags(
contact,
isFavorite: !isFavorite,
);
},
),
ListTile(
+18 -7
View File
@@ -44,6 +44,24 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
PathSelection? _pendingStatusSelection;
List<Map<String, dynamic>>? _parsedNeighbors;
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
@override
void initState() {
super.initState();
@@ -163,13 +181,6 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
}
}
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _loadNeighbors() async {
if (_commandService == null) return;
+13 -2
View File
@@ -77,11 +77,22 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
});
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
void _handleTextMessageResponse(Uint8List frame) {
+1 -2
View File
@@ -205,8 +205,7 @@ class RepeaterHubScreen extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
TelemetryScreen(repeater: repeater, password: password),
builder: (context) => TelemetryScreen(contact: repeater),
),
);
},
+13 -2
View File
@@ -129,11 +129,22 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_commandService?.handleResponse(widget.repeater, parsed.text);
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
+13 -2
View File
@@ -91,11 +91,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
});
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
void _handleTextMessageResponse(Uint8List frame) {
+134 -44
View File
@@ -287,10 +287,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: Text(l10n.settings_privacyMode),
subtitle: Text(l10n.settings_privacyModeSubtitle),
title: Text(l10n.settings_privacy),
subtitle: Text(l10n.settings_privacySubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector),
onTap: () => _privacySettings(context, connector),
),
],
),
@@ -657,47 +657,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.settings_privacyMode),
content: Text(l10n.settings_privacyModeToggle),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setPrivacyMode(true);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_privacyModeEnabled)),
);
},
child: Text(l10n.common_enable),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setPrivacyMode(false);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_privacyModeDisabled)),
);
},
child: Text(l10n.common_disable),
),
],
),
);
}
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
connector.sendSelfAdvert(flood: true);
@@ -977,6 +936,137 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
void _privacySettings(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
int telemetryMode = connector.telemetryModeBase;
int telemetryLocMode = connector.telemetryModeLoc;
int telemetryEnvMode = connector.telemetryModeEnv;
bool advertLocPolicy = connector.advertLocationPolicy == 0 ? false : true;
int multiAcks = connector.multiAcks;
final telemModeBase = [
DropdownMenuItem(value: teleModeDeny, child: Text(l10n.settings_denyAll)),
DropdownMenuItem(
value: teleModeAllowFlags,
child: Text(l10n.settings_allowByContact),
),
DropdownMenuItem(
value: teleModeAllowAll,
child: Text(l10n.settings_allowAll),
),
];
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(l10n.settings_privacy),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.settings_privacySettingsDescription),
const SizedBox(height: 16),
FeatureToggleRow(
title: l10n.settings_advertLocation,
subtitle: l10n.settings_advertLocationSubtitle,
value: advertLocPolicy,
onChanged: (value) {
setDialogState(() => advertLocPolicy = value);
advertLocPolicy = value;
},
),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
initialValue: telemetryMode,
decoration: InputDecoration(
labelText: l10n.settings_telemetryBaseMode,
border: const OutlineInputBorder(),
),
items: telemModeBase,
onChanged: (value) {
if (value != null) {
setDialogState(() => telemetryMode = value);
}
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: telemetryLocMode,
decoration: InputDecoration(
labelText: l10n.settings_telemetryLocationMode,
border: const OutlineInputBorder(),
),
items: telemModeBase,
onChanged: (value) {
if (value != null) {
setDialogState(() => telemetryLocMode = value);
}
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: telemetryEnvMode,
decoration: InputDecoration(
labelText: l10n.settings_telemetryEnvironmentMode,
border: const OutlineInputBorder(),
),
items: telemModeBase,
onChanged: (value) {
if (value != null) {
setDialogState(() => telemetryEnvMode = value);
}
},
),
const SizedBox(height: 16),
Text(
l10n.settings_multiAck(multiAcks.toString()),
style: Theme.of(context).textTheme.bodyMedium,
),
Slider(
value: multiAcks.toDouble(),
min: 0,
max: 2,
divisions: 2,
label: multiAcks.toString(),
onChanged: (value) {
setDialogState(() => multiAcks = value.round());
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setTelemetryModeBase(
telemetryMode,
telemetryLocMode,
telemetryEnvMode,
advertLocPolicy ? 1 : 0,
multiAcks,
);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.settings_telemetryModeUpdated)),
);
},
child: Text(l10n.common_save),
),
],
),
),
);
}
class _RadioSettingsDialog extends StatefulWidget {
final MeshCoreConnector connector;
+95 -73
View File
@@ -10,30 +10,22 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../utils/app_logger.dart';
import '../widgets/path_management_dialog.dart';
import '../helpers/cayenne_lpp.dart';
import '../utils/battery_utils.dart';
class TelemetryScreen extends StatefulWidget {
final Contact repeater;
final String password;
final Contact contact;
const TelemetryScreen({
super.key,
required this.repeater,
required this.password,
});
const TelemetryScreen({super.key, required this.contact});
@override
State<TelemetryScreen> createState() => _TelemetryScreenState();
}
class _TelemetryScreenState extends State<TelemetryScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
Uint8List _tagData = Uint8List(4);
int _tagData = 0;
bool _isLoading = false;
bool _isLoaded = false;
@@ -44,6 +36,26 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
PathSelection? _pendingStatusSelection;
List<Map<String, dynamic>>? _parsedTelemetry;
int _tripTime = 0;
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) {
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
@override
void initState() {
super.initState();
@@ -60,27 +72,62 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final reader = BufferReader(frame);
try {
final cmd = reader.readByte();
if (cmd == respCodeSent) {
reader.skipBytes(1); // Skip the reserved byte
_tagData = reader.readUInt32LE();
_tripTime = reader.readUInt32LE();
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: _tripTime), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
);
_recordTelemetryResult(false);
});
}
if (frame[0] == respCodeSent) {
_tagData = frame.sublist(2, 6);
}
// Check if it's a binary response
if (cmd == pushCodeBinaryResponse) {
if (!mounted) return;
reader.skipBytes(1); // Skip the reserved byte
if (reader.readUInt32LE() != _tagData) return;
_handleTelemetryResponse(reader.readRemainingBytes());
}
// Check if it's a binary response
if (frame[0] == pushCodeBinaryResponse &&
listEquals(frame.sublist(2, 6), _tagData)) {
if (!mounted) return;
_handleStatusResponse(frame.sublist(6));
// Check if it's a telemetry response (for chat contacts)
if (cmd == pushCodeTelemetryResponse) {
reader.skipBytes(1); // Skip the reserved byte
final pubkey = reader.readBytes(6);
if (!mounted) return;
if (!listEquals(widget.contact.publicKey.sublist(0, 6), pubkey)) {
return;
}
_handleTelemetryResponse(reader.readRemainingBytes());
}
} catch (e) {
appLogger.error('Error parsing incoming frame: $e');
// If parsing fails, ignore the frame
}
});
}
void _handleStatusResponse(Uint8List frame) {
void _handleTelemetryResponse(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,
widget.contact.publicKeyHex,
batteryMv,
source: 'telemetry',
);
@@ -105,13 +152,6 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
});
}
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _loadTelemetry() async {
if (_commandService == null) return;
@@ -121,41 +161,20 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
});
try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
final selection = await connector.preparePathForContactSend(repeater);
final selection = await connector.preparePathForContactSend(
_resolveContact(connector),
);
_pendingStatusSelection = selection;
final frame = buildSendBinaryReq(
repeater.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]),
);
await connector.sendFrame(frame);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
var messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
}
final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: messageBytes,
);
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return;
setState(() {
_isLoading = false;
_isLoaded = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.telemetry_requestTimeout),
backgroundColor: Colors.red,
),
Uint8List frame;
if (widget.contact.type != advTypeChat) {
frame = buildSendBinaryReq(
widget.contact.publicKey,
payload: Uint8List.fromList([reqTypeGetTelemetry]),
);
_recordStatusResult(false);
});
} else {
frame = buildSendTelemetryReq(widget.contact.publicKey);
}
await connector.sendFrame(frame);
} catch (e) {
if (mounted) {
setState(() {
@@ -173,12 +192,16 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
}
void _recordStatusResult(bool success) {
void _recordTelemetryResult(bool success) {
final selection = _pendingStatusSelection;
if (selection == null) return;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
connector.recordRepeaterPathResult(repeater, selection, success, null);
connector.recordRepeaterPathResult(
widget.contact,
selection,
success,
null,
);
_pendingStatusSelection = null;
}
@@ -196,8 +219,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
final isFloodMode = widget.contact.pathOverride == -1;
return Scaffold(
appBar: AppBar(
@@ -210,7 +232,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
repeater.name,
widget.contact.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
@@ -225,9 +247,9 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
await connector.setPathOverride(widget.contact, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
await connector.setPathOverride(widget.contact, pathLen: null);
}
},
itemBuilder: (context) => [
@@ -283,7 +305,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
PathManagementDialog.show(context, contact: widget.contact),
),
IconButton(
icon: _isLoading
@@ -437,7 +459,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final batteryMv =
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
connector.getRepeaterBatteryMillivolts(widget.contact.publicKeyHex) ??
(telemetryVolts == null ? null : (telemetryVolts * 1000).round());
if (batteryMv == null) return l10n.common_notAvailable;
final chemistry = _batteryChemistry();
@@ -449,7 +471,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
String _batteryChemistry() {
final settingsService = context.read<AppSettingsService>();
return settingsService.batteryChemistryForRepeater(
widget.repeater.publicKeyHex,
widget.contact.publicKeyHex,
);
}