import 'dart:convert'; import 'dart:typed_data'; // Command codes (to device) const int cmdAppStart = 1; const int cmdSendTxtMsg = 2; const int cmdSendChannelTxtMsg = 3; const int cmdGetContacts = 4; const int cmdGetDeviceTime = 5; const int cmdSetDeviceTime = 6; const int cmdSendSelfAdvert = 7; const int cmdSetAdvertName = 8; const int cmdAddUpdateContact = 9; const int cmdSyncNextMessage = 10; const int cmdSetRadioParams = 11; const int cmdSetRadioTxPower = 12; const int cmdResetPath = 13; const int cmdSetAdvertLatLon = 14; const int cmdRemoveContact = 15; const int cmdShareContact = 16; const int cmdExportContact = 17; const int cmdImportContact = 18; const int cmdReboot = 19; const int cmdGetBattAndStorage = 20; const int cmdDeviceQuery = 22; const int cmdSendLogin = 26; const int cmdSendStatusReq = 27; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdGetRadioSettings = 57; // Text message types const int txtTypePlain = 0; const int txtTypeCliData = 1; // Repeater request types (for server requests) const int reqTypeGetStatus = 0x01; const int reqTypeKeepAlive = 0x02; const int reqTypeGetTelemetry = 0x03; const int reqTypeGetAccessList = 0x05; const int reqTypeGetNeighbours = 0x06; // Repeater response codes const int respServerLoginOk = 0; // Response codes (from device) const int respCodeOk = 0; const int respCodeErr = 1; const int respCodeContactsStart = 2; const int respCodeContact = 3; const int respCodeEndOfContacts = 4; const int respCodeSelfInfo = 5; const int respCodeSent = 6; const int respCodeContactMsgRecv = 7; const int respCodeChannelMsgRecv = 8; const int respCodeCurrTime = 9; const int respCodeNoMoreMessages = 10; const int respCodeBattAndStorage = 12; const int respCodeDeviceInfo = 13; const int respCodeContactMsgRecvV3 = 16; const int respCodeChannelMsgRecvV3 = 17; const int respCodeChannelInfo = 18; const int respCodeRadioSettings = 25; // Push codes (async from device) const int pushCodeAdvert = 0x80; const int pushCodePathUpdated = 0x81; const int pushCodeSendConfirmed = 0x82; const int pushCodeMsgWaiting = 0x83; const int pushCodeLoginSuccess = 0x85; const int pushCodeLoginFail = 0x86; const int pushCodeStatusResponse = 0x87; const int pushCodeLogRxData = 0x88; const int pushCodeNewAdvert = 0x8A; // Contact/advertisement types const int advTypeChat = 1; const int advTypeRepeater = 2; const int advTypeRoom = 3; const int advTypeSensor = 4; // Sizes const int pubKeySize = 32; const int maxPathSize = 64; const int pathHashSize = 1; const int maxNameSize = 32; const int maxFrameSize = 172; const int appProtocolVersion = 3; // Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE). const int maxTextPayloadBytes = 160; const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1; const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1; int maxContactMessageBytes() { final byFrame = maxFrameSize - _sendTextMsgOverheadBytes; return _minPositive(byFrame, maxTextPayloadBytes); } int maxChannelMessageBytes(String? senderName) { final nameLength = _senderNameBytes(senderName); final prefixBytes = nameLength + 2; // ": " final byPayload = maxTextPayloadBytes - prefixBytes; final byFrame = maxFrameSize - _sendChannelTextMsgOverheadBytes; return _minPositive(byPayload, byFrame); } int _senderNameBytes(String? senderName) { if (senderName == null || senderName.isEmpty) return maxNameSize - 1; final bytes = utf8.encode(senderName); final maxBytes = maxNameSize - 1; return bytes.length > maxBytes ? maxBytes : bytes.length; } int _minPositive(int a, int b) { final minValue = a < b ? a : b; return minValue < 0 ? 0 : minValue; } // Contact frame offsets const int contactPubKeyOffset = 1; const int contactTypeOffset = 33; const int contactFlagsOffset = 34; const int contactPathLenOffset = 35; const int contactPathOffset = 36; const int contactNameOffset = 100; const int contactTimestampOffset = 132; const int contactLatOffset = 136; const int contactLonOffset = 140; const int contactLastmodOffset = 144; const int contactFrameSize = 148; // Message frame offsets const int msgPubKeyOffset = 1; const int msgTimestampOffset = 33; const int msgFlagsOffset = 37; const int msgTextOffset = 38; class ParsedContactText { final Uint8List senderPrefix; final String text; const ParsedContactText({ required this.senderPrefix, required this.text, }); } ParsedContactText? parseContactMessageText(Uint8List frame) { if (frame.isEmpty) return null; final code = frame[0]; if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) { return null; } // Companion radio layout: // [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...] final isV3 = code == respCodeContactMsgRecvV3; final prefixOffset = isV3 ? 4 : 1; const prefixLen = 6; final txtTypeOffset = prefixOffset + prefixLen + 1; final timestampOffset = txtTypeOffset + 1; final baseTextOffset = timestampOffset + 4; if (frame.length <= baseTextOffset) return null; final flags = frame[txtTypeOffset]; final shiftedType = flags >> 2; final rawType = flags; final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; if (!isPlain && !isCli) { return null; } var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim(); if (text.isEmpty && frame.length > baseTextOffset + 4) { text = readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim(); } if (text.isEmpty) return null; final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen); return ParsedContactText(senderPrefix: senderPrefix, text: text); } // Helper to read uint32 little-endian int readUint32LE(Uint8List data, int offset) { return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); } // Helper to read uint16 little-endian int readUint16LE(Uint8List data, int offset) { return data[offset] | (data[offset + 1] << 8); } // Helper to read int32 little-endian int readInt32LE(Uint8List data, int offset) { int val = readUint32LE(data, offset); if (val >= 0x80000000) val -= 0x100000000; 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 read null-terminated UTF-8 string String readCString(Uint8List data, int offset, int maxLen) { int end = offset; while (end < offset + maxLen && end < data.length && data[end] != 0) { end++; } try { return utf8.decode(data.sublist(offset, end), allowMalformed: true); } catch (e) { // Fallback to Latin-1 if UTF-8 decoding fails return String.fromCharCodes(data.sublist(offset, end)); } } // Helper to convert public key to hex string String pubKeyToHex(Uint8List pubKey) { return pubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } // Helper to convert hex string to public key Uint8List hexToPubKey(String hex) { final result = Uint8List(pubKeySize); for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) { result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); } return result; } // Build CMD_GET_CONTACTS frame Uint8List buildGetContactsFrame({int? since}) { if (since != null) { final frame = Uint8List(5); frame[0] = cmdGetContacts; writeUint32LE(frame, 1, since); return frame; } return Uint8List.fromList([cmdGetContacts]); } // 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; } // 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; } // Build CMD_SEND_TXT_MSG frame (companion_radio format) // Format: [cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0 Uint8List buildSendTextMsgFrame( Uint8List recipientPubKey, String text, { bool forceFlood = false, int attempt = 0, int? timestampSeconds, }) { final textBytes = utf8.encode(text); final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); const prefixSize = 6; final safeAttempt = forceFlood ? 3 : (attempt & 0xFF); 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; } // 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; } // 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; } // Build CMD_APP_START frame // Format: [cmd][app_ver][reserved x6][app_name...] 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; } // Build CMD_DEVICE_QUERY frame Uint8List buildDeviceQueryFrame({int appVersion = appProtocolVersion}) { return Uint8List.fromList([cmdDeviceQuery, appVersion]); } // Build CMD_GET_DEVICE_TIME frame Uint8List buildGetDeviceTimeFrame() { return Uint8List.fromList([cmdGetDeviceTime]); } // Build CMD_GET_BATT_AND_STORAGE frame Uint8List buildGetBattAndStorageFrame() { return Uint8List.fromList([cmdGetBattAndStorage]); } // Build CMD_SET_DEVICE_TIME frame Uint8List buildSetDeviceTimeFrame(int timestamp) { final frame = Uint8List(5); frame[0] = cmdSetDeviceTime; writeUint32LE(frame, 1, timestamp); return frame; } // Build CMD_SYNC_NEXT_MESSAGE frame Uint8List buildSyncNextMessageFrame() { return Uint8List.fromList([cmdSyncNextMessage]); } // Build CMD_GET_CHANNEL frame Uint8List buildGetChannelFrame(int channelIndex) { return Uint8List.fromList([cmdGetChannel, 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) for (int i = 0; i < 16 && i < psk.length; i++) { frame[34 + i] = psk[i]; } return frame; } // Build CMD_SET_RADIO_PARAMS frame // Format: [cmd][freq x4][bw x4][sf][cr] // freq: frequency in Hz (300000-2500000) // bw: bandwidth in Hz (7000-500000) // 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; } // Build CMD_SET_RADIO_TX_POWER frame // Format: [cmd][power_dbm] Uint8List buildSetRadioTxPowerFrame(int powerDbm) { return Uint8List.fromList([cmdSetRadioTxPower, 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; } // Build CMD_ADD_UPDATE_CONTACT frame to set custom path // Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] Uint8List buildUpdateContactPathFrame( Uint8List pubKey, Uint8List customPath, int pathLen, { int type = 1, // ADV_TYPE_CHAT 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; 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; if (customPath.isNotEmpty && pathLen > 0) { final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize; frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen)); } offset += maxPathSize; // 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; // Timestamp (current time) final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; writeUint32LE(frame, offset, timestamp); return frame; } // Build CMD_GET_RADIO_SETTINGS frame Uint8List buildGetRadioSettingsFrame() { return Uint8List.fromList([cmdGetRadioSettings]); } // Calculate LoRa airtime for a packet // Based on Semtech SX127x datasheet formula // Returns airtime in milliseconds int calculateLoRaAirtime({ required int payloadBytes, required int spreadingFactor, required int bandwidthHz, required int codingRate, int preambleSymbols = 8, bool lowDataRateOptimize = false, bool explicitHeader = true, }) { // Symbol duration (Ts) in milliseconds final symbolDuration = (1 << spreadingFactor) / (bandwidthHz / 1000.0); // Preamble time final preambleTime = (preambleSymbols + 4.25) * symbolDuration; // Payload symbol count final headerBytes = explicitHeader ? 0 : 20; final crc = 1; // CRC enabled final de = lowDataRateOptimize ? 1 : 0; final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes; final denominator = 4 * (spreadingFactor - 2 * de); var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4); if (payloadSymbols < 0) { payloadSymbols = 8; } final payloadTime = payloadSymbols * symbolDuration; return (preambleTime + payloadTime).ceil(); } // Calculate timeout for a message based on radio settings // Returns timeout in milliseconds int calculateMessageTimeout({ required int freqHz, required int bwHz, required int sf, required int cr, required int pathLength, int messageBytes = 100, // Average message size }) { // Calculate airtime for one packet final airtime = calculateLoRaAirtime( payloadBytes: messageBytes, spreadingFactor: sf, bandwidthHz: bwHz, codingRate: cr, lowDataRateOptimize: sf >= 11, ); if (pathLength < 0) { // Flood mode: Base delay + 16× airtime return 500 + (16 * airtime); } else { // Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1)) return 500 + ((airtime * 6 + 250) * (pathLength + 1)); } } // Build CLI command text message frame (companion_radio format) // Format: [cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0 Uint8List buildSendCliCommandFrame( Uint8List repeaterPubKey, String command, { int attempt = 0, }) { final textBytes = utf8.encode(command); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; const prefixSize = 6; final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); int offset = 0; frame[offset++] = cmdSendTxtMsg; frame[offset++] = txtTypeCliData; frame[offset++] = attempt & 0xFF; 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; }