mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
957 lines
29 KiB
Dart
957 lines
29 KiB
Dart
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:flutter/widgets.dart';
|
||
|
||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||
class BufferReader {
|
||
int _pointer = 0;
|
||
int _lastPointer = 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) {
|
||
_lastPointer = _pointer;
|
||
if (_pointer + count > _buffer.length) {
|
||
throw RangeError(
|
||
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||
);
|
||
}
|
||
final data = _buffer.sublist(_pointer, _pointer + count);
|
||
_pointer += count;
|
||
return data;
|
||
}
|
||
|
||
void skipBytes(int count) {
|
||
_lastPointer = _pointer;
|
||
if (_pointer + count > _buffer.length) {
|
||
throw RangeError(
|
||
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||
);
|
||
}
|
||
_pointer += count;
|
||
}
|
||
|
||
Uint8List readRemainingBytes() => readBytes(remaining);
|
||
|
||
String readCStringGreedy(int maxLength) {
|
||
_lastPointer = _pointer;
|
||
final value = <int>[];
|
||
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
|
||
}
|
||
}
|
||
|
||
String readCString({int maxLength = -1}) {
|
||
final backupPointer = _pointer;
|
||
final value = <int>[];
|
||
int counter = 0;
|
||
final maxLen = maxLength >= 0 ? maxLength : remaining;
|
||
while (counter < maxLen) {
|
||
final byte = readByte();
|
||
if (byte == 0) break;
|
||
value.add(byte);
|
||
counter++;
|
||
}
|
||
_lastPointer = backupPointer;
|
||
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;
|
||
}
|
||
|
||
void resetPointer() => _pointer = 0;
|
||
void rewind() => _pointer = _lastPointer;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
void writeHex(String hex) {
|
||
writeBytes(hex2Uint8List(hex));
|
||
}
|
||
|
||
void writeBytesPadded(Uint8List bytes, int totalLength) {
|
||
// Path data (64 bytes, zero-padded)
|
||
final bytesPadded = Uint8List(totalLength);
|
||
final len = bytes.length < totalLength ? bytes.length : totalLength;
|
||
if (bytes.isNotEmpty && len > 0) {
|
||
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
|
||
for (int i = 0; i < copyLen; i++) {
|
||
bytesPadded[i] = bytes[i];
|
||
}
|
||
}
|
||
writeBytes(bytesPadded);
|
||
}
|
||
}
|
||
|
||
Uint8List hex2Uint8List(String hex) {
|
||
// Validate hex string length is even and not empty
|
||
if (hex.isEmpty || hex.length % 2 != 0) {
|
||
throw FormatException('Invalid hex string length: ${hex.length}');
|
||
}
|
||
List<int> result = [];
|
||
for (int i = 0; i < hex.length ~/ 2; i++) {
|
||
final hexByte = hex.substring(i * 2, i * 2 + 2);
|
||
final byte = int.tryParse(hexByte, radix: 16);
|
||
if (byte == null) {
|
||
throw FormatException('Invalid hex characters at position $i: $hexByte');
|
||
}
|
||
result.add(byte);
|
||
}
|
||
return Uint8List.fromList(result);
|
||
}
|
||
|
||
// 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 cmdGetContactByKey = 30;
|
||
const int cmdGetChannel = 31;
|
||
const int cmdSetChannel = 32;
|
||
const int cmdSendTracePath = 36;
|
||
const int cmdSetOtherParams = 38;
|
||
const int cmdSendTelemetryReq = 39;
|
||
const int cmdGetCustomVar = 40;
|
||
const int cmdSetCustomVar = 41;
|
||
const int cmdSendBinaryReq = 50;
|
||
const int cmdGetStats = 56;
|
||
const int cmdSendAnonReq = 57;
|
||
const int cmdSetAutoAddConfig = 58;
|
||
const int cmdGetAutoAddConfig = 59;
|
||
const int cmdSetPathHashMode = 61;
|
||
|
||
// Text message types
|
||
const int txtTypePlain = 0;
|
||
const int txtTypeCliData = 1;
|
||
const int txtTypeSigned = 2;
|
||
|
||
// Repeater request types (for server requests)
|
||
const int reqTypeGetStatus = 0x01;
|
||
const int reqTypeKeepAlive = 0x02;
|
||
const int reqTypeGetTelemetry = 0x03;
|
||
const int reqTypeGetAccessList = 0x05;
|
||
const int reqTypeGetNeighbors = 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 respCodeExportContact = 11;
|
||
const int respCodeBattAndStorage = 12;
|
||
const int respCodeDeviceInfo = 13;
|
||
const int respCodeContactMsgRecvV3 = 16;
|
||
const int respCodeChannelMsgRecvV3 = 17;
|
||
const int respCodeChannelInfo = 18;
|
||
const int respCodeCustomVars = 21;
|
||
const int respCodeAutoAddConfig = 25;
|
||
const int respCodeStats = 24;
|
||
|
||
const int statsTypeCore = 0;
|
||
const int statsTypeRadio = 1;
|
||
const int statsTypePackets = 2;
|
||
|
||
// 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 pushCodeTraceData = 0x89;
|
||
const int pushCodeNewAdvert = 0x8A;
|
||
const int pushCodeTelemetryResponse = 0x8B;
|
||
const int pushCodeBinaryResponse = 0x8C;
|
||
|
||
// Contact/advertisement types
|
||
const int advTypeChat = 1;
|
||
const int advTypeRepeater = 2;
|
||
const int advTypeRoom = 3;
|
||
const int advTypeSensor = 4;
|
||
|
||
const int teleModeDeny = 0;
|
||
const int teleModeAllowFlags = 1; // use contact.flags
|
||
const int teleModeAllowAll = 2;
|
||
|
||
// Payload Types
|
||
const int payloadTypeREQ =
|
||
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||
const int payloadTypeRESPONSE =
|
||
0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||
const int payloadTypeTXTMSG =
|
||
0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
|
||
const int payloadTypeACK = 0x03; // a simple ack
|
||
const int payloadTypeADVERT = 0x04; // a node advertising its Identity
|
||
const int payloadTypeGRPTXT =
|
||
0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
|
||
const int payloadTypeGRPDATA =
|
||
0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
|
||
const int payloadTypeANONREQ =
|
||
0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
|
||
const int payloadTypePATH =
|
||
0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
|
||
const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
|
||
const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
|
||
const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
|
||
//...
|
||
const int payloadTypeRawCustom =
|
||
0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
|
||
|
||
//auto-add flags
|
||
const int autoAddOverwriteOldestFlag =
|
||
1 << 0; // 0x01 - overwrite oldest non-favourite when full
|
||
const int autoAddChatFlag =
|
||
1 << 1; // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
|
||
const int autoAddRepeaterFlag =
|
||
1 << 2; // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
|
||
const int autoAddRoomServerFlag =
|
||
1 << 3; // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
|
||
const int autoAddSensorFlag =
|
||
1 << 4; // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
|
||
|
||
// Sizes
|
||
const int pubKeySize = 32;
|
||
const int signatureSize = 64;
|
||
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 + 2; // +2 safety margin
|
||
const int _sendChannelTextMsgOverheadBytes =
|
||
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
|
||
|
||
int maxContactMessageBytes() {
|
||
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
|
||
return _minPositive(byFrame, maxTextPayloadBytes);
|
||
}
|
||
|
||
int maxChannelMessageBytes(String? senderName) {
|
||
final nameLength = _senderNameBytes(senderName);
|
||
final prefixBytes = nameLength + 2; // "<name>: "
|
||
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 contactFlagFavorite = 0x01;
|
||
const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
|
||
const int contactFlagTeleLoc = 0x04;
|
||
const int contactFlagTeleEnv = 0x08; //access environment sensors
|
||
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 message = BufferReader(frame);
|
||
try {
|
||
final code = message.readByte();
|
||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||
return null;
|
||
}
|
||
|
||
// Companion radio layout:
|
||
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
|
||
if (code == respCodeContactMsgRecvV3) {
|
||
// Skip SNR and reserved bytes in v3 layout
|
||
message.skipBytes(3);
|
||
}
|
||
final senderPrefix = message.readBytes(6); // public key
|
||
message.skipBytes(1); // path length
|
||
final textType = message.readByte();
|
||
message.skipBytes(4); // timestamp (4 bytes)
|
||
|
||
final shiftedType = textType >> 2;
|
||
final isSigned = shiftedType == txtTypeSigned || textType == txtTypeSigned;
|
||
if (isSigned) {
|
||
// Signed messages have a 4-byte signature after the timestamp, before the text
|
||
message.skipBytes(4);
|
||
}
|
||
final text = message.readCString();
|
||
if (text.isEmpty) return null;
|
||
|
||
return ParsedContactText(senderPrefix: senderPrefix, text: text);
|
||
} catch (e) {
|
||
debugPrint('Error parsing contact message text: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 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 convert uint32 to hex string
|
||
String ackHashToHex(int ackHash) {
|
||
return ackHash.toRadixString(16).padLeft(8, '0');
|
||
}
|
||
|
||
// 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}) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdGetContacts);
|
||
if (since != null) {
|
||
writer.writeUInt32LE(since);
|
||
}
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_SEND_LOGIN frame
|
||
// Format: [cmd][pub_key x32][password...]\0
|
||
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
|
||
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 writer = BufferWriter();
|
||
writer.writeByte(cmdSendStatusReq);
|
||
writer.writeBytes(recipientPubKey);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// 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, {
|
||
int attempt = 0,
|
||
int? timestampSeconds,
|
||
}) {
|
||
final timestamp =
|
||
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSendTxtMsg);
|
||
writer.writeByte(txtTypePlain);
|
||
writer.writeByte(attempt.clamp(0, 255));
|
||
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 timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||
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 writer = BufferWriter();
|
||
writer.writeByte(cmdRemoveContact);
|
||
writer.writeBytes(pubKey);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_APP_START frame
|
||
// Format: [cmd][app_ver][reserved x6][app_name...]
|
||
Uint8List buildAppStartFrame({
|
||
String appName = 'MeshCoreOpen',
|
||
int appVersion = 1,
|
||
}) {
|
||
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
|
||
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]);
|
||
}
|
||
|
||
/// Companion radio stats: [56][statsType] where statsType is statsTypeCore/Radio/Packets.
|
||
Uint8List buildGetStatsFrame(int statsType) {
|
||
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
|
||
}
|
||
|
||
/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash.
|
||
Uint8List buildSetPathHashModeFrame(int mode) {
|
||
final m = mode.clamp(0, 2);
|
||
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
|
||
}
|
||
|
||
// Build CMD_SET_DEVICE_TIME frame
|
||
Uint8List buildSetDeviceTimeFrame(int timestamp) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSetDeviceTime);
|
||
writer.writeUInt32LE(timestamp);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_SEND_SELF_ADVERT frame
|
||
// Format: [cmd][flood_flag]
|
||
Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
|
||
return Uint8List.fromList([cmdSendSelfAdvert, flood ? 1 : 0]);
|
||
}
|
||
|
||
// Build CMD_SET_ADVERT_NAME frame
|
||
// Format: [cmd][name...]
|
||
Uint8List buildSetAdvertNameFrame(String name) {
|
||
final nameBytes = utf8.encode(name);
|
||
final nameLen = nameBytes.length < maxNameSize
|
||
? nameBytes.length
|
||
: maxNameSize - 1;
|
||
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 writer = BufferWriter();
|
||
writer.writeByte(cmdSetAdvertLatLon);
|
||
writer.writeInt32LE((lat * 1000000).round());
|
||
writer.writeInt32LE((lon * 1000000).round());
|
||
return writer.toBytes();
|
||
}
|
||
|
||
Uint8List buildSetCustomVarFrame(String value) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSetCustomVar);
|
||
writer.writeString(value);
|
||
writer.writeByte(0);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_REBOOT frame
|
||
// Format: [cmd]["reboot"]
|
||
Uint8List buildRebootFrame() {
|
||
return Uint8List.fromList([cmdReboot, ...utf8.encode('reboot')]);
|
||
}
|
||
|
||
// 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 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++) {
|
||
pskPadded[i] = psk[i];
|
||
}
|
||
writer.writeBytes(pskPadded);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_SET_RADIO_PARAMS frame
|
||
// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
|
||
// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
|
||
// freq: frequency in Hz (300000-2500000)
|
||
// bw: bandwidth in Hz (7000-500000)
|
||
// sf: spreading factor (5-12)
|
||
// cr: coding rate (5-8)
|
||
// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
|
||
Uint8List buildSetRadioParamsFrame(
|
||
int freqHz,
|
||
int bwHz,
|
||
int sf,
|
||
int cr, {
|
||
bool? clientRepeat,
|
||
}) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSetRadioParams);
|
||
writer.writeUInt32LE(freqHz);
|
||
writer.writeUInt32LE(bwHz);
|
||
writer.writeByte(sf);
|
||
writer.writeByte(cr);
|
||
if (clientRepeat != null) {
|
||
writer.writeByte(clientRepeat ? 1 : 0);
|
||
}
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// 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 writer = BufferWriter();
|
||
writer.writeByte(cmdResetPath);
|
||
writer.writeBytes(pubKey);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
|
||
Uint8List buildUpdateContactPathFrame(
|
||
Uint8List pubKey,
|
||
Uint8List path,
|
||
int pathLen, {
|
||
int type = 1, // ADV_TYPE_CHAT
|
||
int flags = 0,
|
||
String name = '',
|
||
double? lat,
|
||
double? lon,
|
||
DateTime? lastModified,
|
||
}) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdAddUpdateContact);
|
||
writer.writeBytes(pubKey);
|
||
writer.writeByte(type);
|
||
writer.writeByte(flags);
|
||
writer.writeByte(pathLen);
|
||
|
||
writer.writeBytesPadded(path, maxPathSize);
|
||
|
||
// Name (32 bytes, null-padded)
|
||
writer.writeCString(name, maxNameSize);
|
||
|
||
// Timestamp
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||
writer.writeUInt32LE(timestamp);
|
||
|
||
if ((lat == null || lon == null) && lastModified != null) {
|
||
// If lat/lon not provided, write zeros
|
||
writer.writeInt32LE(0);
|
||
writer.writeInt32LE(0);
|
||
} else {
|
||
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
|
||
// Latitude
|
||
final latitude = lat ?? 0.0;
|
||
writer.writeInt32LE((latitude * 1e6).round());
|
||
|
||
// Longitude
|
||
final longitude = lon ?? 0.0;
|
||
writer.writeInt32LE((longitude * 1e6).round());
|
||
}
|
||
|
||
if (lastModified != null) {
|
||
// Last modified
|
||
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
|
||
writer.writeUInt32LE(lastModifiedTimestamp);
|
||
}
|
||
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_GET_CONTACT_BY_KEY frame
|
||
// Format: [cmd][pub_key x32]
|
||
Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdGetContactByKey);
|
||
writer.writeBytes(pubKey);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
//Build CMD_GET_CUSTOM_VARS frame
|
||
Uint8List buildGetCustomVarsFrame() {
|
||
return Uint8List.fromList([cmdGetCustomVar]);
|
||
}
|
||
|
||
Uint8List buildGetAutoAddFlagsFrame() {
|
||
return Uint8List.fromList([cmdGetAutoAddConfig]);
|
||
}
|
||
|
||
// 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,
|
||
int? timestampSeconds,
|
||
}) {
|
||
final timestamp =
|
||
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSendTxtMsg);
|
||
writer.writeByte(txtTypeCliData);
|
||
writer.writeByte(attempt.clamp(0, 255));
|
||
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][payload]
|
||
Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSendBinaryReq);
|
||
writer.writeBytes(repeaterPubKey);
|
||
if (payload != null && payload.isNotEmpty) {
|
||
writer.writeBytes(payload);
|
||
}
|
||
return writer.toBytes();
|
||
}
|
||
|
||
//Build a trace request frame
|
||
//[cmd][tag x4][auth x4][flag][payload]
|
||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSendTracePath);
|
||
writer.writeUInt32LE(tag);
|
||
writer.writeUInt32LE(auth);
|
||
writer.writeByte(flag);
|
||
if (payload != null && payload.isNotEmpty) {
|
||
writer.writeBytes(payload);
|
||
}
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build a export contact frame
|
||
// [cmd][pub_key x32 / if empty exports your contact info]
|
||
Uint8List buildExportContactFrame(Uint8List pubKey) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdExportContact);
|
||
writer.writeBytes(pubKey);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build a import contact frame
|
||
// [cmd][contact_frame x98+]
|
||
Uint8List buildImportContactFrame(Uint8List contactFrame) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdImportContact);
|
||
writer.writeBytes(contactFrame);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build a export contact frame
|
||
// [cmd][pub_key x32]
|
||
Uint8List buildZeroHopContact(Uint8List pubKey) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdShareContact);
|
||
writer.writeBytes(pubKey);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_SET_OTHER_PARAMS frame
|
||
// Format: [cmd][allowTelemetryFlags][advertLocationPolicy][multiAcks]
|
||
Uint8List buildSetOtherParamsFrame(
|
||
int allowTelemetryFlags,
|
||
int advertLocationPolicy,
|
||
int multiAcks,
|
||
) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSetOtherParams);
|
||
//Going forward the app will just set Auto Add Contacts to disabled, and use the filter flags
|
||
//Allow Auto Add Contacts use inverted logic (0x01 = disabled, 0x00 = enabled).
|
||
writer.writeByte(0x01);
|
||
writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
|
||
writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
|
||
writer.writeByte(multiAcks); // Multi Acknowledgements
|
||
return writer.toBytes();
|
||
}
|
||
|
||
// Build CMD_SET_AUTO_ADD_CONFIG frame
|
||
// Format: [cmd][flags]
|
||
Uint8List buildSetAutoAddConfigFrame({
|
||
required bool autoAddChat,
|
||
required bool autoAddRepeater,
|
||
required bool autoAddRoomServer,
|
||
required bool autoAddSensor,
|
||
required bool overwriteOldest,
|
||
}) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSetAutoAddConfig);
|
||
int flags = 0;
|
||
if (autoAddChat) flags |= autoAddChatFlag;
|
||
if (autoAddRepeater) flags |= autoAddRepeaterFlag;
|
||
if (autoAddRoomServer) flags |= autoAddRoomServerFlag;
|
||
if (autoAddSensor) flags |= autoAddSensorFlag;
|
||
if (overwriteOldest) flags |= autoAddOverwriteOldestFlag;
|
||
writer.writeByte(flags);
|
||
return writer.toBytes();
|
||
}
|
||
|
||
//Build CMD_SEND_TELEMETRY_REQ
|
||
// Format: [cmd][reserved x3][pub_key? x32]
|
||
Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
|
||
final writer = BufferWriter();
|
||
writer.writeByte(cmdSendTelemetryReq);
|
||
|
||
if (pubKey != null && pubKey.length == pubKeySize) {
|
||
writer.writeBytes(Uint8List(3)); // reserved bytes
|
||
writer.writeBytes(pubKey);
|
||
} else {
|
||
writer.writeBytes(Uint8List(4)); // reserved bytes
|
||
}
|
||
return writer.toBytes();
|
||
}
|