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';