2025-12-26 11:42:02 -07:00
|
|
|
|
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;
|
2025-12-26 13:33:03 -07:00
|
|
|
|
// 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; // "<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;
|
|
|
|
|
|
}
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 20:01:16 -07:00
|
|
|
|
// Helper to write int32 little-endian
|
|
|
|
|
|
void writeInt32LE(Uint8List data, int offset, int value) {
|
|
|
|
|
|
writeUint32LE(data, offset, value & 0xFFFFFFFF);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
|
// 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, {
|
|
|
|
|
|
int attempt = 0,
|
|
|
|
|
|
int? timestampSeconds,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
final textBytes = utf8.encode(text);
|
|
|
|
|
|
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
|
|
|
|
|
const prefixSize = 6;
|
2025-12-30 20:04:53 -07:00
|
|
|
|
final safeAttempt = attempt.clamp(0, 3);
|
2025-12-26 11:42:02 -07:00
|
|
|
|
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
|
2025-12-26 13:33:03 -07:00
|
|
|
|
// Format: [cmd][app_ver][reserved x6][app_name...]
|
|
|
|
|
|
Uint8List buildAppStartFrame({
|
|
|
|
|
|
String appName = 'MeshCoreOpen',
|
|
|
|
|
|
int appVersion = 1,
|
|
|
|
|
|
}) {
|
2025-12-26 11:42:02 -07:00
|
|
|
|
final nameBytes = utf8.encode(appName);
|
|
|
|
|
|
final frame = Uint8List(8 + nameBytes.length + 1);
|
|
|
|
|
|
frame[0] = cmdAppStart;
|
2025-12-26 13:33:03 -07:00
|
|
|
|
frame[1] = appVersion;
|
|
|
|
|
|
// bytes 2-7 are reserved (zeros)
|
2025-12-26 11:42:02 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 20:01:16 -07:00
|
|
|
|
// 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 frame = Uint8List(1 + nameLen);
|
|
|
|
|
|
frame[0] = cmdSetAdvertName;
|
|
|
|
|
|
frame.setRange(1, 1 + nameLen, nameBytes.sublist(0, nameLen));
|
|
|
|
|
|
return frame;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build CMD_REBOOT frame
|
|
|
|
|
|
// Format: [cmd]["reboot"]
|
|
|
|
|
|
Uint8List buildRebootFrame() {
|
|
|
|
|
|
return Uint8List.fromList([cmdReboot, ...utf8.encode('reboot')]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|