From c306ad798cea31b9ebd3e160d22bd4ca1369be5b Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 7 Jan 2026 00:49:35 -0800 Subject: [PATCH 1/5] Added telemetry to repeater interface. --- lib/connector/meshcore_protocol.dart | 27 +++ lib/helpers/buffer_reader.dart | 103 ++++++++++ lib/helpers/buffer_writer.dart | 57 ++++++ lib/helpers/cayenne_lpp.dart | 191 +++++++++++++++++++ lib/screens/repeater_hub_screen.dart | 21 +++ lib/screens/telemetry_screen.dart | 271 +++++++++++++++++++++++++++ 6 files changed, 670 insertions(+) create mode 100644 lib/helpers/buffer_reader.dart create mode 100644 lib/helpers/buffer_writer.dart create mode 100644 lib/helpers/cayenne_lpp.dart create mode 100644 lib/screens/telemetry_screen.dart diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index ac4b6de..5c6b553 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,4 +1,6 @@ +import 'dart:collection'; import 'dart:convert'; +import 'dart:ffi'; import 'dart:typed_data'; // Command codes (to device) @@ -29,6 +31,8 @@ const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdGetRadioSettings = 57; +const int cmdGetTelemetryReq = 39; +const int cmdSendBinaryReq = 50; // Text message types const int txtTypePlain = 0; @@ -73,6 +77,9 @@ const int pushCodeLoginFail = 0x86; const int pushCodeStatusResponse = 0x87; const int pushCodeLogRxData = 0x88; const int pushCodeNewAdvert = 0x8A; +const int pushCodeTelemetryResponse = 0x8B; +const int pushCodeBinaryResponse = 0x8C; + // Contact/advertisement types const int advTypeChat = 1; @@ -614,3 +621,23 @@ Uint8List buildSendCliCommandFrame( frame[frame.length - 1] = 0; // null terminator return frame; } + +//Build a telemetry request frame +//Format: [cmd][pub_key x32][req_type][payload] +Uint8List buildSendBinaryReq( + Uint8List repeaterPubKey, + { + int attempt = 0, + int? timestampSeconds, + Uint8List? payload, + }) { + int offset = 0; + final frame = Uint8List(1 + 32 + 1 + (payload?.length ?? 0)); + frame[offset++] = cmdSendBinaryReq; + frame.setRange(offset, offset + 32, repeaterPubKey); + if (payload != null && payload.isNotEmpty) { + offset += 32; + frame.setRange(offset, offset + payload.length, payload); + } + return frame; +} \ No newline at end of file diff --git a/lib/helpers/buffer_reader.dart b/lib/helpers/buffer_reader.dart new file mode 100644 index 0000000..6bb4679 --- /dev/null +++ b/lib/helpers/buffer_reader.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class BufferReader { + int pointer = 0; + final Uint8List buffer; + + BufferReader(Uint8List data) : buffer = Uint8List.fromList(data); + + int getRemainingBytesCount() { + return buffer.length - pointer; + } + + int readByte() { + return readBytes(1)[0]; + } + + Uint8List readBytes(int count) { + final data = buffer.sublist(pointer, pointer + count); + pointer += count; + return data; + } + + Uint8List readRemainingBytes() { + return readBytes(getRemainingBytesCount()); + } + + String readString() { + return utf8.decode(readRemainingBytes()); + } + + String readCString(int maxLength) { + final value = []; + final bytes = readBytes(maxLength); + for (final byte in bytes) { + // if we find a null terminator character, we have reached the end of the cstring + if (byte == 0) { + return utf8.decode(Uint8List.fromList(value)); + } + value.add(byte); + } + return utf8.decode(Uint8List.fromList(value)); + } + + int readInt8() { + final bytes = readBytes(1); + return ByteData.view(bytes.buffer).getInt8(0); + } + + int readUInt8() { + final bytes = readBytes(1); + return ByteData.view(bytes.buffer).getUint8(0); + } + + int readUInt16LE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getUint16(0, Endian.little); + } + + int readUInt16BE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getUint16(0, Endian.big); + } + + int readUInt32LE() { + final bytes = readBytes(4); + return ByteData.view(bytes.buffer).getUint32(0, Endian.little); + } + + int readUInt32BE() { + final bytes = readBytes(4); + return ByteData.view(bytes.buffer).getUint32(0, Endian.big); + } + + int readInt16LE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getInt16(0, Endian.little); + } + + int readInt16BE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getInt16(0, Endian.big); + } + + int readInt32LE() { + final bytes = readBytes(4); + return ByteData.view(bytes.buffer).getInt32(0, Endian.little); + } + + int readInt24BE() { + // read 24-bit (3 bytes) big endian integer + var value = (readByte() << 16) | (readByte() << 8) | readByte(); + + // convert 24-bit signed integer to 32-bit signed integer + // 0x800000 is the sign bit for a 24-bit value + // if it's set, value is negative in 24-bit two's complement + if ((value & 0x800000) != 0) { + value -= 0x1000000; + } + + return value; + } +} diff --git a/lib/helpers/buffer_writer.dart b/lib/helpers/buffer_writer.dart new file mode 100644 index 0000000..945acc4 --- /dev/null +++ b/lib/helpers/buffer_writer.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class BufferWriter { + final BytesBuilder _builder = BytesBuilder(); + + Uint8List toBytes() { + return _builder.toBytes(); + } + + void writeBytes(Uint8List bytes) { + _builder.add(bytes); + } + + void writeByte(int byte) { + _builder.addByte(byte); + } + + void writeUInt16LE(int num) { + final bytes = Uint8List(2); + final data = ByteData.view(bytes.buffer); + data.setUint16(0, num, Endian.little); + writeBytes(bytes); + } + + void writeUInt32LE(int num) { + final bytes = Uint8List(4); + final data = ByteData.view(bytes.buffer); + data.setUint32(0, num, Endian.little); + writeBytes(bytes); + } + + void writeInt32LE(int num) { + final bytes = Uint8List(4); + final data = ByteData.view(bytes.buffer); + data.setInt32(0, num, Endian.little); + writeBytes(bytes); + } + + void writeString(String string) { + writeBytes(Uint8List.fromList(utf8.encode(string))); + } + + void writeCString(String string, int maxLength) { + final bytes = Uint8List(maxLength); + final encodedString = utf8.encode(string); + + for (var i = 0; i < maxLength && i < encodedString.length; i++) { + bytes[i] = encodedString[i]; + } + + // ensure the last byte is always a null terminator + bytes[maxLength - 1] = 0; + + writeBytes(bytes); + } +} diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart new file mode 100644 index 0000000..5ee1307 --- /dev/null +++ b/lib/helpers/cayenne_lpp.dart @@ -0,0 +1,191 @@ +import 'dart:typed_data'; +import 'buffer_reader.dart'; +import 'buffer_writer.dart'; + +class CayenneLpp { + static const int lppDigitalInput = 0; // 1 byte + static const int lppDigitalOutput = 1; // 1 byte + static const int lppAnalogInput = 2; // 2 bytes, 0.01 signed + static const int lppAnalogOutput = 3; // 2 bytes, 0.01 signed + static const int lppGenericSensor = 100; // 4 bytes, unsigned + static const int lppLuminosity = 101; // 2 bytes, 1 lux unsigned + static const int lppPresence = 102; // 1 byte, bool + static const int lppTemperature = 103; // 2 bytes, 0.1°C signed + static const int lppRelativeHumidity = 104; // 1 byte, 0.5% unsigned + static const int lppAccelerometer = 113; // 2 bytes per axis, 0.001G + static const int lppBarometricPressure = 115; // 2 bytes 0.1hPa unsigned + static const int lppVoltage = 116; // 2 bytes 0.01V unsigned + static const int lppCurrent = 117; // 2 bytes 0.001A unsigned + static const int lppFrequency = 118; // 4 bytes 1Hz unsigned + static const int lppPercentage = 120; // 1 byte 1-100% unsigned + static const int lppAltitude = 121; // 2 byte 1m signed + static const int lppConcentration = 125; // 2 bytes, 1 ppm unsigned + static const int lppPower = 128; // 2 byte, 1W, unsigned + static const int lppDistance = 130; // 4 byte, 0.001m, unsigned + static const int lppEnergy = 131; // 4 byte, 0.001kWh, unsigned + static const int lppDirection = 132; // 2 bytes, 1deg, unsigned + static const int lppUnixTime = 133; // 4 bytes, unsigned + static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s + static const int lppColour = 135; // 1 byte per RGB Color + static const int lppGps = 136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter + static const int lppSwitch = 142; // 1 byte, 0/1 + static const int lppPolyline = 240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas + + final BufferWriter _writer = BufferWriter(); + + Uint8List toBytes() { + return _writer.toBytes(); + } + + void addDigitalInput(int channel, int value) { + _writer.writeByte(channel); + _writer.writeByte(lppDigitalInput); + _writer.writeByte(value); + } + + void addTemperature(int channel, double value) { + _writer.writeByte(channel); + _writer.writeByte(lppTemperature); + final val = (value * 10).toInt(); + _writer.writeBytes(_int16ToBE(val)); + } + + void addVoltage(int channel, double value) { + _writer.writeByte(channel); + _writer.writeByte(lppVoltage); + final val = (value * 100).toInt(); + _writer.writeBytes(_int16ToBE(val)); + } + + void addGps(int channel, double lat, double lon, double alt) { + _writer.writeByte(channel); + _writer.writeByte(lppGps); + _writer.writeBytes(_int24ToBE((lat * 10000).toInt())); + _writer.writeBytes(_int24ToBE((lon * 10000).toInt())); + _writer.writeBytes(_int24ToBE((alt * 100).toInt())); + } + + Uint8List _int16ToBE(int value) { + final bytes = Uint8List(2); + final data = ByteData.view(bytes.buffer); + data.setInt16(0, value, Endian.big); + return bytes; + } + + Uint8List _int24ToBE(int value) { + final bytes = Uint8List(3); + bytes[0] = (value >> 16) & 0xFF; + bytes[1] = (value >> 8) & 0xFF; + bytes[2] = value & 0xFF; + return bytes; + } + + static List> parse(Uint8List bytes) { + final buffer = BufferReader(bytes); + final telemetry = >[]; + + while (buffer.getRemainingBytesCount() >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); + + 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; + } + } + + return telemetry; + } +} diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index f9ba230..8601daa 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -3,6 +3,7 @@ import '../models/contact.dart'; import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; +import 'telemetry_screen.dart'; class RepeaterHubScreen extends StatelessWidget { final Contact repeater; @@ -100,6 +101,26 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), + const SizedBox(height: 16), + // Status button + _buildManagementCard( + context, + icon: Icons.bar_chart_sharp, + title: 'Telemetry', + subtitle: 'View telemetry of sensors and system stats', + color: Colors.teal, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TelemetryScreen( + repeater: repeater, + password: password, + ), + ), + ); + }, + ), const SizedBox(height: 12), // CLI button _buildManagementCard( diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart new file mode 100644 index 0000000..1d0beff --- /dev/null +++ b/lib/screens/telemetry_screen.dart @@ -0,0 +1,271 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/contact.dart'; +import '../models/path_selection.dart'; +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../services/repeater_command_service.dart'; +import '../widgets/path_management_dialog.dart'; +import '../helpers/cayenne_lpp.dart'; + +class TelemetryScreen extends StatefulWidget { + final Contact repeater; + final String password; + + const TelemetryScreen({ + super.key, + required this.repeater, + required this.password, + }); + + @override + State createState() => _TelemetryScreenState(); +} + +class _TelemetryScreenState extends State { + static const int _statusPayloadOffset = 8; + static const int _statusStatsSize = 52; + static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; + Uint8List _tagData = Uint8List(4); + int _timeEstment = 0; + + bool _isLoading = false; + Timer? _statusTimeout; + StreamSubscription? _frameSubscription; + RepeaterCommandService? _commandService; + PathSelection? _pendingStatusSelection; + + @override + void initState() { + super.initState(); + final connector = Provider.of(context, listen: false); + _commandService = RepeaterCommandService(connector); + _setupMessageListener(); + _loadTelemetry(); + } + + void _setupMessageListener() { + final connector = Provider.of(context, listen: false); + + // Listen for incoming text messages from the repeater + _frameSubscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty) return; + + if(frame[0] == respCodeSent){ + _tagData = frame.sublist(2, 6); + _timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little); + } + + // Check if it's a binary response + if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) { + _handleStatusResponse(context, frame.sublist(6)); + } + }); +} + + void _handleStatusResponse(BuildContext context, Uint8List frame) { + + final parsedTelemetry = CayenneLpp.parse(frame); + for (final entry in parsedTelemetry) { + print('Telemetry - Channel: ${entry['channel']}, Type: ${entry['type']}, Value: ${entry['value']}'); + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Received status response (not implemented).'), + backgroundColor: Colors.green, + ) + ); + _statusTimeout?.cancel(); + if (!mounted) return; + setState(() { + _isLoading = false; + }); + } + + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.repeater.publicKeyHex, + orElse: () => widget.repeater, + ); + } + + Future _loadTelemetry() async { + if (_commandService == null) return; + + setState(() { + _isLoading = true; + }); + try { + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); + final selection = await connector.preparePathForContactSend(repeater); + _pendingStatusSelection = selection; + final frame = buildSendBinaryReq(repeater.publicKey, payload: Uint8List.fromList([reqTypeGetTelemetry])); + await connector.sendFrame(frame); + + final pathLengthValue = selection.useFlood ? -1 : selection.hopCount; + final messageBytes = frame.length >= _statusResponseBytes + ? frame.length + : _statusResponseBytes; + final timeoutMs = connector.calculateTimeout( + pathLength: pathLengthValue, + messageBytes: messageBytes, + ); + _statusTimeout?.cancel(); + _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () { + if (!mounted) return; + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Status request timed out.'), + backgroundColor: Colors.red, + ), + ); + _recordStatusResult(false); + }); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error loading status: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _recordStatusResult(bool success) { + final selection = _pendingStatusSelection; + if (selection == null) return; + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); + connector.recordRepeaterPathResult(repeater, selection, success, null); + _pendingStatusSelection = null; + } + + @override + void dispose() { + _frameSubscription?.cancel(); + _commandService?.dispose(); + _statusTimeout?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Repeater Status'), + Text( + repeater.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), + ), + ], + ), + centerTitle: false, + actions: [ + PopupMenuButton( + icon: Icon(isFloodMode ? Icons.waves : Icons.route), + tooltip: 'Routing mode', + onSelected: (mode) async { + if (mode == 'flood') { + await connector.setPathOverride(repeater, pathLen: -1); + } else { + await connector.setPathOverride(repeater, pathLen: null); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'auto', + child: Row( + children: [ + Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Auto (use saved path)', + style: TextStyle( + fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + PopupMenuItem( + value: 'flood', + child: Row( + children: [ + Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Force Flood Mode', + style: TextStyle( + fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.timeline), + tooltip: 'Path management', + onPressed: () => PathManagementDialog.show(context, contact: repeater), + ), + IconButton( + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: _isLoading ? null : _loadTelemetry, + tooltip: 'Refresh', + ), + ], + ), + body: SafeArea( + top: false, + child: RefreshIndicator( + onRefresh: _loadTelemetry, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text("Not implemented yet", style: Theme.of(context).textTheme.bodyMedium), + //_buildSystemInfoCard(), + //const SizedBox(height: 16), + //_buildRadioStatsCard(), + //const SizedBox(height: 16), + //_buildPacketStatsCard(), + ], + ), + ), + ), + ); + } +} \ No newline at end of file From 2993ec1f499927e9b30fd1b25c39ab7817107a00 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 7 Jan 2026 00:53:56 -0800 Subject: [PATCH 2/5] Add to CayenneLpp parseByChannel function, and got basic ui working. --- lib/helpers/cayenne_lpp.dart | 71 +++++++++++++++++++ lib/screens/telemetry_screen.dart | 111 +++++++++++++++++++++++++++--- 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index 5ee1307..1e9935d 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -188,4 +188,75 @@ class CayenneLpp { return telemetry; } + + static List> parseByChannel(Uint8List bytes) { + final buffer = BufferReader(bytes); + final Map> channels = {}; + + while (buffer.getRemainingBytesCount() >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); + + // 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 List> channelsOut = channels.values.toList(); + channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); + return channelsOut; + } } diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 1d0beff..3f90188 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -34,10 +35,12 @@ class _TelemetryScreenState extends State { int _timeEstment = 0; bool _isLoading = false; + bool _isLoaded = false; Timer? _statusTimeout; StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; PathSelection? _pendingStatusSelection; + List>? _parsedTelemetry; @override void initState() { @@ -68,9 +71,13 @@ class _TelemetryScreenState extends State { } void _handleStatusResponse(BuildContext context, Uint8List frame) { + setState(() { + _isLoading = false; + _isLoaded = true; + _parsedTelemetry = CayenneLpp.parseByChannel(frame); + }); - final parsedTelemetry = CayenneLpp.parse(frame); - for (final entry in parsedTelemetry) { + for (final entry in _parsedTelemetry![1]!.values) { print('Telemetry - Channel: ${entry['channel']}, Type: ${entry['type']}, Value: ${entry['value']}'); } @@ -84,6 +91,7 @@ class _TelemetryScreenState extends State { if (!mounted) return; setState(() { _isLoading = false; + _isLoaded = true; }); } @@ -99,6 +107,7 @@ class _TelemetryScreenState extends State { setState(() { _isLoading = true; + _isLoaded = false; }); try { final connector = Provider.of(context, listen: false); @@ -121,6 +130,7 @@ class _TelemetryScreenState extends State { if (!mounted) return; setState(() { _isLoading = false; + _isLoaded = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -134,6 +144,7 @@ class _TelemetryScreenState extends State { if (mounted) { setState(() { _isLoading = false; + _isLoaded = false; }); ScaffoldMessenger.of(context).showSnackBar( @@ -146,6 +157,7 @@ class _TelemetryScreenState extends State { } finally { setState(() { _isLoading = false; + _isLoaded = false; }); } } @@ -179,7 +191,7 @@ class _TelemetryScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Text('Repeater Status'), + const Text('Repeater Telemetry', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), Text( repeater.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), @@ -256,16 +268,97 @@ class _TelemetryScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ - Text("Not implemented yet", style: Theme.of(context).textTheme.bodyMedium), - //_buildSystemInfoCard(), - //const SizedBox(height: 16), - //_buildRadioStatsCard(), - //const SizedBox(height: 16), - //_buildPacketStatsCard(), + for (final entry in _parsedTelemetry ?? []) + _buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']), ], ), ), ), ); } + + Widget _buildChannelInfoCard(Map channelData, String title, int channel) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + const Divider(), + for (final entry in channelData.entries) + if(entry.key == 'voltage' && channel == 1) + _buildInfoRow('Battery', _batteryText(entry.value)) + else if(entry.key == 'voltage') + _buildInfoRow('Voltage', '${entry.value}V') + else if(entry.key == 'temperature' && channel == 1) + _buildInfoRow('MCU Temperature', _TemperatureText(entry.value)) + else if(entry.key == 'temperature') + _buildInfoRow('Temperature', _TemperatureText(entry.value)) + else if(entry.key == 'current' && channel == 1) + _buildInfoRow('Current', '${entry.value}A') + else + _buildInfoRow(entry.key, entry.value.toString()), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Text( + label, + style: TextStyle( + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ), + ], + ), + ); + } + + String _batteryText(double? _batteryMv) { + if (_batteryMv == null) return '—'; + final percent = _batteryPercentFromMv(_batteryMv); + final volts = _batteryMv.toStringAsFixed(2); + return '$percent% / ${volts}V'; + } + + int _batteryPercentFromMv(double millivolts) { + const minMv = 2.800; + const maxMv = 4.200; + if (millivolts <= minMv) return 0; + if (millivolts >= maxMv) return 100; + return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); + } + + String _TemperatureText(double? tempC) { + if (tempC == null) return '—'; + final tempF = (tempC * 9 / 5) + 32; + return '${tempC.toStringAsFixed(1)}°C / ${tempF.toStringAsFixed(1)}°F'; + } } \ No newline at end of file From 401a3842ca865bb3ad70efa385c4740ffc7a6fea Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 7 Jan 2026 00:59:56 -0800 Subject: [PATCH 3/5] Added loading message --- lib/screens/telemetry_screen.dart | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 3f90188..83b7d5a 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -72,15 +72,9 @@ class _TelemetryScreenState extends State { void _handleStatusResponse(BuildContext context, Uint8List frame) { setState(() { - _isLoading = false; - _isLoaded = true; _parsedTelemetry = CayenneLpp.parseByChannel(frame); }); - for (final entry in _parsedTelemetry![1]!.values) { - print('Telemetry - Channel: ${entry['channel']}, Type: ${entry['type']}, Value: ${entry['value']}'); - } - ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Received status response (not implemented).'), @@ -268,8 +262,16 @@ class _TelemetryScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ - for (final entry in _parsedTelemetry ?? []) - _buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']), + if (_isLoaded && !(_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) + for (final entry in _parsedTelemetry ?? []) + _buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']), + if (!_isLoaded && (_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) + const Center( + child: Text( + 'No telemetry data available.', + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ), ], ), ), @@ -286,7 +288,7 @@ class _TelemetryScreenState extends State { children: [ Row( children: [ - Icon(Icons.info_outline, color: Theme.of(context).primaryColor), + Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color), const SizedBox(width: 8), Text( title, From ffce582b3b30e7ff087d78ef698dd5d4253af24a Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Wed, 7 Jan 2026 10:45:30 -0800 Subject: [PATCH 4/5] Change debug messages that I left and forgot --- lib/screens/telemetry_screen.dart | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 83b7d5a..980c573 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -36,6 +36,7 @@ class _TelemetryScreenState extends State { bool _isLoading = false; bool _isLoaded = false; + bool _hasData = false; Timer? _statusTimeout; StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; @@ -49,6 +50,7 @@ class _TelemetryScreenState extends State { _commandService = RepeaterCommandService(connector); _setupMessageListener(); _loadTelemetry(); + _hasData = false; } void _setupMessageListener() { @@ -77,7 +79,7 @@ class _TelemetryScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Received status response (not implemented).'), + content: Text('Received Telemetry Data'), backgroundColor: Colors.green, ) ); @@ -86,6 +88,7 @@ class _TelemetryScreenState extends State { setState(() { _isLoading = false; _isLoaded = true; + _hasData = true; }); } @@ -128,7 +131,7 @@ class _TelemetryScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Status request timed out.'), + content: Text('Telemetry request timed out.'), backgroundColor: Colors.red, ), ); @@ -143,16 +146,11 @@ class _TelemetryScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error loading status: $e'), + content: Text('Error loading telemetry: $e'), backgroundColor: Colors.red, ), ); } - } finally { - setState(() { - _isLoading = false; - _isLoaded = false; - }); } } @@ -262,16 +260,16 @@ class _TelemetryScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ - if (_isLoaded && !(_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) - for (final entry in _parsedTelemetry ?? []) - _buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']), - if (!_isLoaded && (_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) + if (!_isLoaded && !_hasData && (_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) const Center( child: Text( 'No telemetry data available.', style: TextStyle(fontSize: 16, color: Colors.grey), ), ), + if (_isLoaded || _hasData&& !(_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) + for (final entry in _parsedTelemetry ?? []) + _buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']), ], ), ), From bc6c1f1fabc36ca88b0a9b29d69a57ecca8bd8da Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sun, 11 Jan 2026 13:44:01 -0700 Subject: [PATCH 5/5] Consolidate BufferReader/Writer, add response validation for repeater settings - Move BufferReader/BufferWriter into meshcore_protocol.dart - Refactor build functions to use BufferWriter - Add content-based validation for CLI responses over LoRa - Add individual refresh buttons for TX power and feature toggles - Hide unimplemented features (Privacy Mode, Encrypted Advert Interval) --- lib/connector/meshcore_protocol.dart | 372 +++++++++++--------- lib/helpers/buffer_reader.dart | 103 ------ lib/helpers/buffer_writer.dart | 57 --- lib/helpers/cayenne_lpp.dart | 7 +- lib/screens/repeater_settings_screen.dart | 402 ++++++++++++++++------ lib/screens/telemetry_screen.dart | 40 +-- 6 files changed, 515 insertions(+), 466 deletions(-) delete mode 100644 lib/helpers/buffer_reader.dart delete mode 100644 lib/helpers/buffer_writer.dart diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 5c6b553..c828a1a 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,8 +1,94 @@ -import 'dart:collection'; import 'dart:convert'; -import 'dart:ffi'; import 'dart:typed_data'; +// Buffer Reader - sequential binary data reader with pointer tracking +class BufferReader { + int _pointer = 0; + final Uint8List _buffer; + + BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data); + + int get remaining => _buffer.length - _pointer; + + int readByte() => readBytes(1)[0]; + + Uint8List readBytes(int count) { + final data = _buffer.sublist(_pointer, _pointer + count); + _pointer += count; + return data; + } + + Uint8List readRemainingBytes() => readBytes(remaining); + + String readString() => utf8.decode(readRemainingBytes(), allowMalformed: true); + + String readCString(int maxLength) { + final value = []; + final bytes = readBytes(maxLength); + for (final byte in bytes) { + if (byte == 0) break; + value.add(byte); + } + try { + return utf8.decode(Uint8List.fromList(value), allowMalformed: true); + } catch (e) { + return String.fromCharCodes(value); // Latin-1 fallback + } + } + + int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0); + int readInt8() => readBytes(1).buffer.asByteData().getInt8(0); + int readUInt16LE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.little); + int readUInt16BE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.big); + int readUInt32LE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.little); + int readUInt32BE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.big); + int readInt16LE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.little); + int readInt16BE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.big); + int readInt32LE() => readBytes(4).buffer.asByteData().getInt32(0, Endian.little); + + int readInt24BE() { + var value = (readByte() << 16) | (readByte() << 8) | readByte(); + if ((value & 0x800000) != 0) value -= 0x1000000; + return value; + } +} + +// Buffer Writer - accumulating binary data builder +class BufferWriter { + final BytesBuilder _builder = BytesBuilder(); + + Uint8List toBytes() => _builder.toBytes(); + + void writeByte(int byte) => _builder.addByte(byte); + void writeBytes(Uint8List bytes) => _builder.add(bytes); + + void writeUInt16LE(int num) { + final bytes = Uint8List(2)..buffer.asByteData().setUint16(0, num, Endian.little); + writeBytes(bytes); + } + + void writeUInt32LE(int num) { + final bytes = Uint8List(4)..buffer.asByteData().setUint32(0, num, Endian.little); + writeBytes(bytes); + } + + void writeInt32LE(int num) { + final bytes = Uint8List(4)..buffer.asByteData().setInt32(0, num, Endian.little); + writeBytes(bytes); + } + + void writeString(String string) => writeBytes(Uint8List.fromList(utf8.encode(string))); + + void writeCString(String string, int maxLength) { + final bytes = Uint8List(maxLength); + final encoded = utf8.encode(string); + for (var i = 0; i < maxLength - 1 && i < encoded.length; i++) { + bytes[i] = encoded[i]; + } + writeBytes(bytes); + } +} + // Command codes (to device) const int cmdAppStart = 1; const int cmdSendTxtMsg = 2; @@ -210,19 +296,6 @@ int readInt32LE(Uint8List data, int offset) { return val; } -// Helper to write uint32 little-endian -void writeUint32LE(Uint8List data, int offset, int value) { - data[offset] = value & 0xFF; - data[offset + 1] = (value >> 8) & 0xFF; - data[offset + 2] = (value >> 16) & 0xFF; - data[offset + 3] = (value >> 24) & 0xFF; -} - -// Helper to write int32 little-endian -void writeInt32LE(Uint8List data, int offset, int value) { - writeUint32LE(data, offset, value & 0xFFFFFFFF); -} - // Helper to read null-terminated UTF-8 string String readCString(Uint8List data, int offset, int maxLen) { int end = offset; @@ -253,34 +326,32 @@ Uint8List hexToPubKey(String hex) { // Build CMD_GET_CONTACTS frame Uint8List buildGetContactsFrame({int? since}) { + final writer = BufferWriter(); + writer.writeByte(cmdGetContacts); if (since != null) { - final frame = Uint8List(5); - frame[0] = cmdGetContacts; - writeUint32LE(frame, 1, since); - return frame; + writer.writeUInt32LE(since); } - return Uint8List.fromList([cmdGetContacts]); + return writer.toBytes(); } // Build CMD_SEND_LOGIN frame // Format: [cmd][pub_key x32][password...]\0 Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) { - final passwordBytes = utf8.encode(password); - final frame = Uint8List(1 + pubKeySize + passwordBytes.length + 1); - frame[0] = cmdSendLogin; - frame.setRange(1, 1 + pubKeySize, recipientPubKey); - frame.setRange(1 + pubKeySize, 1 + pubKeySize + passwordBytes.length, passwordBytes); - frame[frame.length - 1] = 0; - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSendLogin); + writer.writeBytes(recipientPubKey); + writer.writeString(password); + writer.writeByte(0); + return writer.toBytes(); } // Build CMD_SEND_STATUS_REQ frame // Format: [cmd][pub_key x32] Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) { - final frame = Uint8List(1 + pubKeySize); - frame[0] = cmdSendStatusReq; - frame.setRange(1, 1 + pubKeySize, recipientPubKey); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSendStatusReq); + writer.writeBytes(recipientPubKey); + return writer.toBytes(); } // Build CMD_SEND_TXT_MSG frame (companion_radio format) @@ -291,48 +362,38 @@ Uint8List buildSendTextMsgFrame( int attempt = 0, int? timestampSeconds, }) { - final textBytes = utf8.encode(text); final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); - const prefixSize = 6; - final safeAttempt = attempt.clamp(0, 3); - final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); - int offset = 0; - - frame[offset++] = cmdSendTxtMsg; - frame[offset++] = txtTypePlain; - frame[offset++] = safeAttempt; - writeUint32LE(frame, offset, timestamp); - offset += 4; - - frame.setRange(offset, offset + prefixSize, recipientPubKey.sublist(0, prefixSize)); - offset += prefixSize; - - frame.setRange(offset, offset + textBytes.length, textBytes); - frame[frame.length - 1] = 0; // null terminator - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSendTxtMsg); + writer.writeByte(txtTypePlain); + writer.writeByte(attempt.clamp(0, 3)); + writer.writeUInt32LE(timestamp); + writer.writeBytes(recipientPubKey.sublist(0, 6)); + writer.writeString(text); + writer.writeByte(0); + return writer.toBytes(); } // Build CMD_SEND_CHANNEL_TXT_MSG frame // Format: [cmd][txt_type][channel_idx][timestamp x4][text...] Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) { - final textBytes = utf8.encode(text); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final frame = Uint8List(1 + 1 + 1 + 4 + textBytes.length + 1); - frame[0] = cmdSendChannelTxtMsg; - frame[1] = 0; // TXT_TYPE_PLAIN - frame[2] = channelIndex; - writeUint32LE(frame, 3, timestamp); - frame.setRange(7, 7 + textBytes.length, textBytes); - frame[frame.length - 1] = 0; // null terminator - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSendChannelTxtMsg); + writer.writeByte(txtTypePlain); + writer.writeByte(channelIndex); + writer.writeUInt32LE(timestamp); + writer.writeString(text); + writer.writeByte(0); + return writer.toBytes(); } // Build CMD_REMOVE_CONTACT frame Uint8List buildRemoveContactFrame(Uint8List pubKey) { - final frame = Uint8List(1 + pubKeySize); - frame[0] = cmdRemoveContact; - frame.setRange(1, 1 + pubKeySize, pubKey); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdRemoveContact); + writer.writeBytes(pubKey); + return writer.toBytes(); } // Build CMD_APP_START frame @@ -341,14 +402,13 @@ Uint8List buildAppStartFrame({ String appName = 'MeshCoreOpen', int appVersion = 1, }) { - final nameBytes = utf8.encode(appName); - final frame = Uint8List(8 + nameBytes.length + 1); - frame[0] = cmdAppStart; - frame[1] = appVersion; - // bytes 2-7 are reserved (zeros) - frame.setRange(8, 8 + nameBytes.length, nameBytes); - frame[frame.length - 1] = 0; // null terminator - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdAppStart); + writer.writeByte(appVersion); + writer.writeBytes(Uint8List(6)); // reserved bytes + writer.writeString(appName); + writer.writeByte(0); + return writer.toBytes(); } // Build CMD_DEVICE_QUERY frame @@ -368,10 +428,10 @@ Uint8List buildGetBattAndStorageFrame() { // Build CMD_SET_DEVICE_TIME frame Uint8List buildSetDeviceTimeFrame(int timestamp) { - final frame = Uint8List(5); - frame[0] = cmdSetDeviceTime; - writeUint32LE(frame, 1, timestamp); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSetDeviceTime); + writer.writeUInt32LE(timestamp); + return writer.toBytes(); } // Build CMD_SEND_SELF_ADVERT frame @@ -385,20 +445,20 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) { Uint8List buildSetAdvertNameFrame(String name) { final nameBytes = utf8.encode(name); final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1; - final frame = Uint8List(1 + nameLen); - frame[0] = cmdSetAdvertName; - frame.setRange(1, 1 + nameLen, nameBytes.sublist(0, nameLen)); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSetAdvertName); + writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen))); + return writer.toBytes(); } // Build CMD_SET_ADVERT_LATLON frame // Format: [cmd][lat x4][lon x4] Uint8List buildSetAdvertLatLonFrame(double lat, double lon) { - final frame = Uint8List(9); - frame[0] = cmdSetAdvertLatLon; - writeInt32LE(frame, 1, (lat * 1000000).round()); - writeInt32LE(frame, 5, (lon * 1000000).round()); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSetAdvertLatLon); + writer.writeInt32LE((lat * 1000000).round()); + writer.writeInt32LE((lon * 1000000).round()); + return writer.toBytes(); } // Build CMD_REBOOT frame @@ -420,21 +480,17 @@ Uint8List buildGetChannelFrame(int channelIndex) { // Build CMD_SET_CHANNEL frame // Format: [cmd][idx][name x32][psk x16] Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) { - final frame = Uint8List(2 + 32 + 16); - frame[0] = cmdSetChannel; - frame[1] = channelIndex; - // Write name (max 32 bytes UTF-8, null-padded) - final nameBytes = utf8.encode(name); - final nameLen = nameBytes.length < 32 ? nameBytes.length : 31; // Reserve 1 byte for null - for (int i = 0; i < nameLen; i++) { - frame[2 + i] = nameBytes[i]; - } - // frame[2 + nameLen] is already 0 (null terminator) - // Write PSK (16 bytes) + final writer = BufferWriter(); + writer.writeByte(cmdSetChannel); + writer.writeByte(channelIndex); + writer.writeCString(name, 32); + // Write PSK (16 bytes, zero-padded) + final pskPadded = Uint8List(16); for (int i = 0; i < 16 && i < psk.length; i++) { - frame[34 + i] = psk[i]; + pskPadded[i] = psk[i]; } - return frame; + writer.writeBytes(pskPadded); + return writer.toBytes(); } // Build CMD_SET_RADIO_PARAMS frame @@ -444,13 +500,13 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) { // sf: spreading factor (5-12) // cr: coding rate (5-8) Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) { - final frame = Uint8List(11); - frame[0] = cmdSetRadioParams; - writeUint32LE(frame, 1, freqHz); - writeUint32LE(frame, 5, bwHz); - frame[9] = sf; - frame[10] = cr; - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSetRadioParams); + writer.writeUInt32LE(freqHz); + writer.writeUInt32LE(bwHz); + writer.writeByte(sf); + writer.writeByte(cr); + return writer.toBytes(); } // Build CMD_SET_RADIO_TX_POWER frame @@ -462,10 +518,10 @@ Uint8List buildSetRadioTxPowerFrame(int powerDbm) { // Build CMD_RESET_PATH frame // Format: [cmd][pub_key x32] Uint8List buildResetPathFrame(Uint8List pubKey) { - final frame = Uint8List(1 + pubKeySize); - frame[0] = cmdResetPath; - frame.setRange(1, 1 + pubKeySize, pubKey); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdResetPath); + writer.writeBytes(pubKey); + return writer.toBytes(); } // Build CMD_ADD_UPDATE_CONTACT frame to set custom path @@ -478,50 +534,40 @@ Uint8List buildUpdateContactPathFrame( int flags = 0, String name = '', }) { - // Frame size: 1 + 32 + 1 + 1 + 1 + 64 + 32 + 4 = 136 bytes minimum - final frame = Uint8List(1 + pubKeySize + 1 + 1 + 1 + maxPathSize + maxNameSize + 4); - int offset = 0; + final writer = BufferWriter(); + writer.writeByte(cmdAddUpdateContact); + writer.writeBytes(pubKey); + writer.writeByte(type); + writer.writeByte(flags); + writer.writeByte(pathLen); - frame[offset++] = cmdAddUpdateContact; - - // Public key (32 bytes) - frame.setRange(offset, offset + pubKeySize, pubKey); - offset += pubKeySize; - - // Type and flags - frame[offset++] = type; - frame[offset++] = flags; - - // Path length and path data - frame[offset++] = pathLen; + // Path data (64 bytes, zero-padded) + final pathPadded = Uint8List(maxPathSize); if (customPath.isNotEmpty && pathLen > 0) { final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize; - frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen)); + for (int i = 0; i < copyLen; i++) { + pathPadded[i] = customPath[i]; + } } - offset += maxPathSize; + writer.writeBytes(pathPadded); // Name (32 bytes, null-padded) - if (name.isNotEmpty) { - final nameBytes = utf8.encode(name); - final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1; - frame.setRange(offset, offset + nameLen, nameBytes.sublist(0, nameLen)); - } - offset += maxNameSize; + writer.writeCString(name, maxNameSize); - // Timestamp (current time) + // Timestamp final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - writeUint32LE(frame, offset, timestamp); + writer.writeUInt32LE(timestamp); - return frame; + return writer.toBytes(); } // Build CMD_GET_CONTACT_BY_KEY frame // Format: [cmd][pub_key x32] Uint8List buildGetContactByKeyFrame(Uint8List pubKey) { - final frame = Uint8List(1 + pubKeySize); - frame[0] = cmdGetContactByKey; - frame.setRange(1, 1 + pubKeySize, pubKey); - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdGetContactByKey); + writer.writeBytes(pubKey); + return writer.toBytes(); } // Build CMD_GET_RADIO_SETTINGS frame @@ -601,43 +647,29 @@ Uint8List buildSendCliCommandFrame( int attempt = 0, int? timestampSeconds, }) { - final textBytes = utf8.encode(command); final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); - const prefixSize = 6; - final safeAttempt = attempt.clamp(0, 3); - final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); - int offset = 0; - - frame[offset++] = cmdSendTxtMsg; - frame[offset++] = txtTypeCliData; - frame[offset++] = safeAttempt; - writeUint32LE(frame, offset, timestamp); - offset += 4; - - frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize)); - offset += prefixSize; - - frame.setRange(offset, offset + textBytes.length, textBytes); - frame[frame.length - 1] = 0; // null terminator - return frame; + final writer = BufferWriter(); + writer.writeByte(cmdSendTxtMsg); + writer.writeByte(txtTypeCliData); + writer.writeByte(attempt.clamp(0, 3)); + writer.writeUInt32LE(timestamp); + writer.writeBytes(repeaterPubKey.sublist(0, 6)); + writer.writeString(command); + writer.writeByte(0); + return writer.toBytes(); } -//Build a telemetry request frame -//Format: [cmd][pub_key x32][req_type][payload] +// Build a telemetry request frame +// Format: [cmd][pub_key x32][payload] Uint8List buildSendBinaryReq( - Uint8List repeaterPubKey, - { - int attempt = 0, - int? timestampSeconds, - Uint8List? payload, - }) { - int offset = 0; - final frame = Uint8List(1 + 32 + 1 + (payload?.length ?? 0)); - frame[offset++] = cmdSendBinaryReq; - frame.setRange(offset, offset + 32, repeaterPubKey); + Uint8List repeaterPubKey, { + Uint8List? payload, +}) { + final writer = BufferWriter(); + writer.writeByte(cmdSendBinaryReq); + writer.writeBytes(repeaterPubKey); if (payload != null && payload.isNotEmpty) { - offset += 32; - frame.setRange(offset, offset + payload.length, payload); + writer.writeBytes(payload); } - return frame; + return writer.toBytes(); } \ No newline at end of file diff --git a/lib/helpers/buffer_reader.dart b/lib/helpers/buffer_reader.dart deleted file mode 100644 index 6bb4679..0000000 --- a/lib/helpers/buffer_reader.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -class BufferReader { - int pointer = 0; - final Uint8List buffer; - - BufferReader(Uint8List data) : buffer = Uint8List.fromList(data); - - int getRemainingBytesCount() { - return buffer.length - pointer; - } - - int readByte() { - return readBytes(1)[0]; - } - - Uint8List readBytes(int count) { - final data = buffer.sublist(pointer, pointer + count); - pointer += count; - return data; - } - - Uint8List readRemainingBytes() { - return readBytes(getRemainingBytesCount()); - } - - String readString() { - return utf8.decode(readRemainingBytes()); - } - - String readCString(int maxLength) { - final value = []; - final bytes = readBytes(maxLength); - for (final byte in bytes) { - // if we find a null terminator character, we have reached the end of the cstring - if (byte == 0) { - return utf8.decode(Uint8List.fromList(value)); - } - value.add(byte); - } - return utf8.decode(Uint8List.fromList(value)); - } - - int readInt8() { - final bytes = readBytes(1); - return ByteData.view(bytes.buffer).getInt8(0); - } - - int readUInt8() { - final bytes = readBytes(1); - return ByteData.view(bytes.buffer).getUint8(0); - } - - int readUInt16LE() { - final bytes = readBytes(2); - return ByteData.view(bytes.buffer).getUint16(0, Endian.little); - } - - int readUInt16BE() { - final bytes = readBytes(2); - return ByteData.view(bytes.buffer).getUint16(0, Endian.big); - } - - int readUInt32LE() { - final bytes = readBytes(4); - return ByteData.view(bytes.buffer).getUint32(0, Endian.little); - } - - int readUInt32BE() { - final bytes = readBytes(4); - return ByteData.view(bytes.buffer).getUint32(0, Endian.big); - } - - int readInt16LE() { - final bytes = readBytes(2); - return ByteData.view(bytes.buffer).getInt16(0, Endian.little); - } - - int readInt16BE() { - final bytes = readBytes(2); - return ByteData.view(bytes.buffer).getInt16(0, Endian.big); - } - - int readInt32LE() { - final bytes = readBytes(4); - return ByteData.view(bytes.buffer).getInt32(0, Endian.little); - } - - int readInt24BE() { - // read 24-bit (3 bytes) big endian integer - var value = (readByte() << 16) | (readByte() << 8) | readByte(); - - // convert 24-bit signed integer to 32-bit signed integer - // 0x800000 is the sign bit for a 24-bit value - // if it's set, value is negative in 24-bit two's complement - if ((value & 0x800000) != 0) { - value -= 0x1000000; - } - - return value; - } -} diff --git a/lib/helpers/buffer_writer.dart b/lib/helpers/buffer_writer.dart deleted file mode 100644 index 945acc4..0000000 --- a/lib/helpers/buffer_writer.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -class BufferWriter { - final BytesBuilder _builder = BytesBuilder(); - - Uint8List toBytes() { - return _builder.toBytes(); - } - - void writeBytes(Uint8List bytes) { - _builder.add(bytes); - } - - void writeByte(int byte) { - _builder.addByte(byte); - } - - void writeUInt16LE(int num) { - final bytes = Uint8List(2); - final data = ByteData.view(bytes.buffer); - data.setUint16(0, num, Endian.little); - writeBytes(bytes); - } - - void writeUInt32LE(int num) { - final bytes = Uint8List(4); - final data = ByteData.view(bytes.buffer); - data.setUint32(0, num, Endian.little); - writeBytes(bytes); - } - - void writeInt32LE(int num) { - final bytes = Uint8List(4); - final data = ByteData.view(bytes.buffer); - data.setInt32(0, num, Endian.little); - writeBytes(bytes); - } - - void writeString(String string) { - writeBytes(Uint8List.fromList(utf8.encode(string))); - } - - void writeCString(String string, int maxLength) { - final bytes = Uint8List(maxLength); - final encodedString = utf8.encode(string); - - for (var i = 0; i < maxLength && i < encodedString.length; i++) { - bytes[i] = encodedString[i]; - } - - // ensure the last byte is always a null terminator - bytes[maxLength - 1] = 0; - - writeBytes(bytes); - } -} diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index 1e9935d..ad5aa8c 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -1,6 +1,5 @@ import 'dart:typed_data'; -import 'buffer_reader.dart'; -import 'buffer_writer.dart'; +import '../connector/meshcore_protocol.dart'; class CayenneLpp { static const int lppDigitalInput = 0; // 1 byte @@ -84,7 +83,7 @@ class CayenneLpp { final buffer = BufferReader(bytes); final telemetry = >[]; - while (buffer.getRemainingBytesCount() >= 2) { + while (buffer.remaining >= 2) { final channel = buffer.readUInt8(); final type = buffer.readUInt8(); @@ -193,7 +192,7 @@ class CayenneLpp { final buffer = BufferReader(bytes); final Map> channels = {}; - while (buffer.getRemainingBytesCount() >= 2) { + while (buffer.remaining >= 2) { final channel = buffer.readUInt8(); final type = buffer.readUInt8(); diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index deaa245..14242ef 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/contact.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../services/app_debug_log_service.dart'; import '../services/repeater_command_service.dart'; import '../widgets/path_management_dialog.dart'; @@ -27,8 +28,11 @@ class _RepeaterSettingsScreenState extends State { bool _hasChanges = false; bool _refreshingBasic = false; bool _refreshingRadio = false; + bool _refreshingTxPower = false; bool _refreshingLocation = false; - bool _refreshingFeatures = false; + bool _refreshingRepeat = false; + bool _refreshingAllowReadOnly = false; + bool _refreshingPrivacy = false; bool _refreshingAdvertisement = false; StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; @@ -141,81 +145,73 @@ class _RepeaterSettingsScreenState extends State { void _updateUIFromFetchedSettings() { if (_fetchedSettings.isEmpty) return; + final appLog = Provider.of(context, listen: false); + appLog.info('Updating UI with keys: ${_fetchedSettings.keys.toList()}', tag: 'RadioSettings'); + setState(() { // Update name if (_fetchedSettings.containsKey('name')) { _nameController.text = _fetchedSettings['name']!; } - // Update radio settings - parse "915.00,250.00,9,7" or unit-labeled variants + // Update radio settings - parse "908.205017,62.5,10,7" format + // Format: freq_mhz,bandwidth_khz,spreading_factor,coding_rate if (_fetchedSettings.containsKey('radio')) { + final appLog = Provider.of(context, listen: false); final radioStr = _fetchedSettings['radio']!; + appLog.info('Raw radio string: "$radioStr"', tag: 'RadioSettings'); final parts = radioStr.split(','); - final parsed = []; - for (final part in parts) { - final trimmed = part.trim(); - if (trimmed.isNotEmpty) { - parsed.add(trimmed); - } - } - if (parsed.isNotEmpty) { - final freqText = parsed.first - .replaceAll('MHz', '') - .replaceAll('mhz', '') - .trim(); + appLog.info('Split into ${parts.length} parts: $parts', tag: 'RadioSettings'); + + if (parts.isNotEmpty) { + final freqText = parts[0].replaceAll(RegExp(r'[^0-9.]'), '').trim(); + appLog.info('Frequency text: "$freqText"', tag: 'RadioSettings'); if (freqText.isNotEmpty) { _freqController.text = freqText; } } - if (parsed.length > 1) { - final bwText = parsed[1] - .replaceAll('kHz', '') - .replaceAll('khz', '') - .trim(); + if (parts.length > 1) { + final bwText = parts[1].replaceAll(RegExp(r'[^0-9.]'), '').trim(); + appLog.info('Bandwidth text: "$bwText"', tag: 'RadioSettings'); final bw = double.tryParse(bwText); if (bw != null) { _bandwidth = (bw * 1000).toInt(); + appLog.info('Bandwidth Hz: $_bandwidth', tag: 'RadioSettings'); if (!_bandwidthOptions.contains(_bandwidth)) { _bandwidthOptions.add(_bandwidth); _bandwidthOptions.sort(); } } } - if (parsed.length > 2) { - final sfText = parsed[2].replaceAll('SF', '').replaceAll('sf', '').trim(); + if (parts.length > 2) { + final sfText = parts[2].replaceAll(RegExp(r'[^0-9]'), '').trim(); + appLog.info('SF text: "$sfText"', tag: 'RadioSettings'); _spreadingFactor = int.tryParse(sfText) ?? _spreadingFactor; } - if (parsed.length > 3) { - final crText = parsed[3].replaceAll('CR', '').replaceAll('cr', '').trim(); + if (parts.length > 3) { + final crText = parts[3].replaceAll(RegExp(r'[^0-9]'), '').trim(); + appLog.info('CR text: "$crText"', tag: 'RadioSettings'); _codingRate = int.tryParse(crText) ?? _codingRate; } + appLog.info('Final values: freq=${_freqController.text}, bw=$_bandwidth, sf=$_spreadingFactor, cr=$_codingRate', tag: 'RadioSettings'); } if (_fetchedSettings.containsKey('tx')) { final txValue = _fetchedSettings['tx']!; - // Extract just the power value if it's part of a larger response - // Handle formats like "10", "10 dBm", or "908.205017,62.5,10,7" - final parts = txValue.split(','); - if (parts.length >= 3) { - // If comma-separated (likely radio format), TX power is typically the 3rd or 4th value - // Format: freq,bandwidth,sf,cr OR freq,bandwidth,power,sf,cr - final powerCandidate = parts.length > 3 ? parts[2].trim() : parts.last.trim(); - final powerInt = int.tryParse(powerCandidate.replaceAll(RegExp(r'[^0-9-]'), '')); - if (powerInt != null && powerInt >= 1 && powerInt <= 30) { - _txPowerController.text = powerInt.toString(); - } else { - _txPowerController.text = txValue.replaceAll(RegExp(r'[^0-9-]'), ''); - } - } else { - // Simple format, just extract the number - _txPowerController.text = txValue.replaceAll(RegExp(r'[^0-9-]'), ''); + // Extract just the power value - format is typically "10" or "10 dBm" + final powerStr = txValue.replaceAll(RegExp(r'[^0-9-]'), ''); + final powerInt = int.tryParse(powerStr); + if (powerInt != null && powerInt >= 1 && powerInt <= 30) { + _txPowerController.text = powerInt.toString(); } } if (_fetchedSettings.containsKey('lat')) { + appLog.info('Setting lat to: "${_fetchedSettings['lat']}"', tag: 'RadioSettings'); _latController.text = _fetchedSettings['lat']!; } if (_fetchedSettings.containsKey('lon')) { + appLog.info('Setting lon to: "${_fetchedSettings['lon']}"', tag: 'RadioSettings'); _lonController.text = _fetchedSettings['lon']!; } @@ -253,8 +249,11 @@ class _RepeaterSettingsScreenState extends State { bool _isAnySectionRefreshing() { return _refreshingBasic || _refreshingRadio || + _refreshingTxPower || _refreshingLocation || - _refreshingFeatures || + _refreshingRepeat || + _refreshingAllowReadOnly || + _refreshingPrivacy || _refreshingAdvertisement; } @@ -279,13 +278,23 @@ class _RepeaterSettingsScreenState extends State { } void _applySettingResponse(String command, String response) { + final appLog = Provider.of(context, listen: false); + appLog.info('Command: "$command", Raw response: "$response"', tag: 'RadioSettings'); final value = _extractCliValue(response); + appLog.info('Extracted value: "$value"', tag: 'RadioSettings'); if (value == null) return; final normalized = command.trim().toLowerCase(); if (!normalized.startsWith('get ')) return; final key = normalized.substring(4); + // Validate response content matches expected format for the command + // This prevents mismatched responses over LoRa where order isn't guaranteed + if (!_validateResponseForCommand(key, value)) { + appLog.warn('Response "$value" does not match expected format for "$key", ignoring', tag: 'RadioSettings'); + return; + } + switch (key) { case 'name': case 'radio': @@ -298,11 +307,73 @@ class _RepeaterSettingsScreenState extends State { case 'advert.interval': case 'flood.advert.interval': case 'priv.advert.interval': + appLog.info('Storing key="$key" value="$value"', tag: 'RadioSettings'); _fetchedSettings[key] = value; break; } } + /// Validates that a response value matches the expected format for a given command. + /// Returns true if the response appears valid for the command type. + bool _validateResponseForCommand(String key, String value) { + switch (key) { + case 'radio': + // Radio format: "freq,bw,sf,cr" e.g., "908.205017,62.5,10,7" + // Must have at least 3 commas and start with a frequency-like number + final parts = value.split(','); + if (parts.length < 4) return false; + final freq = double.tryParse(parts[0].replaceAll(RegExp(r'[^0-9.]'), '')); + // Frequency should be in reasonable LoRa range (300-2500 MHz) + return freq != null && freq >= 300 && freq <= 2500; + + case 'tx': + // TX power: single integer 1-30 + final power = int.tryParse(value.replaceAll(RegExp(r'[^0-9-]'), '')); + // Must NOT contain commas (distinguishes from radio format) + if (value.contains(',')) return false; + return power != null && power >= 1 && power <= 30; + + case 'lat': + // Latitude: decimal number between -90 and 90 + if (value.contains(',')) return false; // Not radio format + final lat = double.tryParse(value.replaceAll(RegExp(r'[^0-9.\-]'), '')); + return lat != null && lat >= -90 && lat <= 90; + + case 'lon': + // Longitude: decimal number between -180 and 180 + if (value.contains(',')) return false; // Not radio format + final lon = double.tryParse(value.replaceAll(RegExp(r'[^0-9.\-]'), '')); + return lon != null && lon >= -180 && lon <= 180; + + case 'repeat': + case 'allow.read.only': + case 'privacy': + // Boolean values: on/off/true/false/1/0/enabled/disabled + final lower = value.toLowerCase().trim(); + return ['on', 'off', 'true', 'false', '1', '0', 'enabled', 'disabled'].contains(lower); + + case 'advert.interval': + case 'flood.advert.interval': + case 'priv.advert.interval': + // Interval: positive integer + if (value.contains(',')) return false; + final interval = int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), '')); + return interval != null && interval > 0; + + case 'name': + // Name: any non-empty string, but should NOT look like radio settings + if (value.isEmpty) return false; + // If it has 3+ commas and looks like numbers, probably radio data + final commaCount = ','.allMatches(value).length; + if (commaCount >= 3 && RegExp(r'^[\d.,\s]+$').hasMatch(value)) return false; + return true; + + default: + // Unknown keys - accept any value + return true; + } + } + String? _extractCliValue(String response) { final lines = response.split('\n'); for (final line in lines) { @@ -388,11 +459,19 @@ class _RepeaterSettingsScreenState extends State { Future _refreshRadioSettings() async { await _refreshSection( label: 'Radio settings', - commands: const ['get radio', 'get tx'], + commands: const ['get radio'], setRefreshing: (value) => _refreshingRadio = value, ); } + Future _refreshTxPower() async { + await _refreshSection( + label: 'TX power', + commands: const ['get tx'], + setRefreshing: (value) => _refreshingTxPower = value, + ); + } + Future _refreshLocationSettings() async { await _refreshSection( label: 'Location settings', @@ -401,11 +480,27 @@ class _RepeaterSettingsScreenState extends State { ); } - Future _refreshFeatureSettings() async { + Future _refreshRepeat() async { await _refreshSection( - label: 'Feature toggles', - commands: const ['get repeat', 'get allow.read.only', 'get privacy'], - setRefreshing: (value) => _refreshingFeatures = value, + label: 'Packet forwarding', + commands: const ['get repeat'], + setRefreshing: (value) => _refreshingRepeat = value, + ); + } + + Future _refreshAllowReadOnly() async { + await _refreshSection( + label: 'Guest access', + commands: const ['get allow.read.only'], + setRefreshing: (value) => _refreshingAllowReadOnly = value, + ); + } + + Future _refreshPrivacy() async { + await _refreshSection( + label: 'Privacy mode', + commands: const ['get privacy'], + setRefreshing: (value) => _refreshingPrivacy = value, ); } @@ -415,7 +510,7 @@ class _RepeaterSettingsScreenState extends State { commands: const [ 'get advert.interval', 'get flood.advert.interval', - 'get priv.advert.interval', + // 'get priv.advert.interval', // Hidden until privacy mode is implemented ], setRefreshing: (value) => _refreshingAdvertisement = value, ); @@ -569,6 +664,28 @@ class _RepeaterSettingsScreenState extends State { ); } + Widget _buildInlineRefreshButton({ + required bool isRefreshing, + required VoidCallback onRefresh, + required String tooltip, + }) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: IconButton( + icon: isRefreshing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: isRefreshing ? null : onRefresh, + tooltip: tooltip, + visualDensity: VisualDensity.compact, + ), + ); + } + @override Widget build(BuildContext context) { final connector = context.watch(); @@ -750,16 +867,29 @@ class _RepeaterSettingsScreenState extends State { onChanged: (_) => _markChanged(), ), const SizedBox(height: 16), - TextField( - controller: _txPowerController, - decoration: const InputDecoration( - labelText: 'TX Power', - helperText: '1-30 dBm', - border: OutlineInputBorder(), - suffixText: 'dBm', - ), - keyboardType: TextInputType.number, - onChanged: (_) => _markChanged(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _txPowerController, + decoration: const InputDecoration( + labelText: 'TX Power', + helperText: '1-30 dBm', + border: OutlineInputBorder(), + suffixText: 'dBm', + ), + keyboardType: TextInputType.number, + onChanged: (_) => _markChanged(), + ), + ), + const SizedBox(width: 8), + _buildInlineRefreshButton( + isRefreshing: _refreshingTxPower, + onRefresh: _refreshTxPower, + tooltip: 'Refresh TX power', + ), + ], ), const SizedBox(height: 16), DropdownButtonFormField( @@ -881,52 +1011,98 @@ class _RepeaterSettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader( - icon: Icons.toggle_on, - title: 'Features', - isRefreshing: _refreshingFeatures, - onRefresh: _refreshFeatureSettings, + Row( + children: [ + Icon(Icons.toggle_on, color: Theme.of(context).textTheme.headlineSmall?.color), + const SizedBox(width: 8), + const Text( + 'Features', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], ), const Divider(), - SwitchListTile( - title: const Text('Packet Forwarding'), - subtitle: const Text('Enable repeater to forward packets'), + _buildFeatureToggleRow( + title: 'Packet Forwarding', + subtitle: 'Enable repeater to forward packets', value: _repeatEnabled, + isRefreshing: _refreshingRepeat, onChanged: (value) { setState(() { _repeatEnabled = value; }); _markChanged(); }, + onRefresh: _refreshRepeat, ), - SwitchListTile( - title: const Text('Guest Access'), - subtitle: const Text('Allow read-only guest access'), + _buildFeatureToggleRow( + title: 'Guest Access', + subtitle: 'Allow read-only guest access', value: _allowReadOnly, + isRefreshing: _refreshingAllowReadOnly, onChanged: (value) { setState(() { _allowReadOnly = value; }); _markChanged(); }, + onRefresh: _refreshAllowReadOnly, ), - SwitchListTile( - title: const Text('Privacy Mode'), - subtitle: const Text('Hide name/location in advertisements'), - value: _privacyMode, - onChanged: (value) { - setState(() { - _privacyMode = value; - }); - _markChanged(); - }, - ), + // Privacy mode - hidden until fully implemented + // _buildFeatureToggleRow( + // title: 'Privacy Mode', + // subtitle: 'Hide name/location in advertisements', + // value: _privacyMode, + // isRefreshing: _refreshingPrivacy, + // onChanged: (value) { + // setState(() { + // _privacyMode = value; + // }); + // _markChanged(); + // }, + // onRefresh: _refreshPrivacy, + // ), ], ), ), ); } + Widget _buildFeatureToggleRow({ + required String title, + required String subtitle, + required bool value, + required bool isRefreshing, + required ValueChanged onChanged, + required VoidCallback onRefresh, + }) { + return Row( + children: [ + Expanded( + child: SwitchListTile( + title: Text(title), + subtitle: Text(subtitle), + value: value, + onChanged: onChanged, + contentPadding: EdgeInsets.zero, + ), + ), + IconButton( + icon: isRefreshing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: isRefreshing ? null : onRefresh, + tooltip: 'Refresh $title', + visualDensity: VisualDensity.compact, + ), + ], + ); + } + Widget _buildAdvertisementSettingsCard() { return Card( child: Padding( @@ -978,27 +1154,28 @@ class _RepeaterSettingsScreenState extends State { _markChanged(); }, ), - if (_privacyMode) ...[ - const SizedBox(height: 16), - ListTile( - title: const Text('Encrypted Advertisement Interval'), - subtitle: Text('$_privAdvertInterval minutes'), - trailing: Text('${_privAdvertInterval}m'), - ), - Slider( - value: _privAdvertInterval.toDouble(), - min: 30, - max: 240, - divisions: 21, - label: '${_privAdvertInterval}m', - onChanged: (value) { - setState(() { - _privAdvertInterval = value.toInt(); - }); - _markChanged(); - }, - ), - ], + // Encrypted advertisement interval - hidden until privacy mode is implemented + // if (_privacyMode) ...[ + // const SizedBox(height: 16), + // ListTile( + // title: const Text('Encrypted Advertisement Interval'), + // subtitle: Text('$_privAdvertInterval minutes'), + // trailing: Text('${_privAdvertInterval}m'), + // ), + // Slider( + // value: _privAdvertInterval.toDouble(), + // min: 30, + // max: 240, + // divisions: 21, + // label: '${_privAdvertInterval}m', + // onChanged: (value) { + // setState(() { + // _privAdvertInterval = value.toInt(); + // }); + // _markChanged(); + // }, + // ), + // ], ], ), ), @@ -1042,19 +1219,20 @@ class _RepeaterSettingsScreenState extends State { () => _sendDangerCommand('reboot'), ), ), - ListTile( - leading: Icon(Icons.vpn_key, color: colorScheme.onErrorContainer), - title: Text('Regenerate Identity Key', style: TextStyle(color: colorScheme.onErrorContainer)), - subtitle: Text( - 'Generate new public/private key pair', - style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)), - ), - onTap: () => _confirmAction( - 'Regenerate Identity', - 'This will generate a new identity for the repeater. Continue?', - () => _sendDangerCommand('regen key'), - ), - ), + // Regenerate identity key - hidden until fully implemented + // ListTile( + // leading: Icon(Icons.vpn_key, color: colorScheme.onErrorContainer), + // title: Text('Regenerate Identity Key', style: TextStyle(color: colorScheme.onErrorContainer)), + // subtitle: Text( + // 'Generate new public/private key pair', + // style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)), + // ), + // onTap: () => _confirmAction( + // 'Regenerate Identity', + // 'This will generate a new identity for the repeater. Continue?', + // () => _sendDangerCommand('regen key'), + // ), + // ), ListTile( leading: Icon(Icons.delete_forever, color: colorScheme.onErrorContainer), title: Text('Erase File System', style: TextStyle(color: colorScheme.onErrorContainer)), diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 980c573..8545e77 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -60,17 +60,17 @@ class _TelemetryScreenState extends State { _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; - if(frame[0] == respCodeSent){ - _tagData = frame.sublist(2, 6); - _timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little); - } + if (frame[0] == respCodeSent) { + _tagData = frame.sublist(2, 6); + _timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little); + } - // Check if it's a binary response - if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) { - _handleStatusResponse(context, frame.sublist(6)); - } - }); -} + // Check if it's a binary response + if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) { + _handleStatusResponse(context, frame.sublist(6)); + } + }); + } void _handleStatusResponse(BuildContext context, Uint8List frame) { setState(() { @@ -267,7 +267,7 @@ class _TelemetryScreenState extends State { style: TextStyle(fontSize: 16, color: Colors.grey), ), ), - if (_isLoaded || _hasData&& !(_parsedTelemetry == null || _parsedTelemetry!.isEmpty)) + if ((_isLoaded || _hasData) && _parsedTelemetry != null && _parsedTelemetry!.isNotEmpty) for (final entry in _parsedTelemetry ?? []) _buildChannelInfoCard(entry['values'], 'Channel ${entry['channel']}', entry['channel']), ], @@ -296,18 +296,18 @@ class _TelemetryScreenState extends State { ), const Divider(), for (final entry in channelData.entries) - if(entry.key == 'voltage' && channel == 1) + if (entry.key == 'voltage' && channel == 1) _buildInfoRow('Battery', _batteryText(entry.value)) - else if(entry.key == 'voltage') + else if (entry.key == 'voltage') _buildInfoRow('Voltage', '${entry.value}V') - else if(entry.key == 'temperature' && channel == 1) - _buildInfoRow('MCU Temperature', _TemperatureText(entry.value)) - else if(entry.key == 'temperature') - _buildInfoRow('Temperature', _TemperatureText(entry.value)) - else if(entry.key == 'current' && channel == 1) + else if (entry.key == 'temperature' && channel == 1) + _buildInfoRow('MCU Temperature', _temperatureText(entry.value)) + else if (entry.key == 'temperature') + _buildInfoRow('Temperature', _temperatureText(entry.value)) + else if (entry.key == 'current' && channel == 1) _buildInfoRow('Current', '${entry.value}A') else - _buildInfoRow(entry.key, entry.value.toString()), + _buildInfoRow(entry.key, entry.value.toString()), ], ), ), @@ -356,7 +356,7 @@ class _TelemetryScreenState extends State { return (((millivolts - minMv) * 100) / (maxMv - minMv)).round(); } - String _TemperatureText(double? tempC) { + String _temperatureText(double? tempC) { if (tempC == null) return '—'; final tempF = (tempC * 9 / 5) + 32; return '${tempC.toStringAsFixed(1)}°C / ${tempF.toStringAsFixed(1)}°F';