mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Core Features Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges Message Deletion: Users can now long-press to delete individual messages in chats and channels SMAZ Compression: Added per-contact compression settings (previously only channels) UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly Channel Message Paths: New screen to visualize packet routing through repeater network with map view Protocol Updates Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation Changed channel PSK format from Base64 to Hexadecimal (breaking change) Added app version field to connection handshake frame UI Improvements Unread badges on all contact and channel list items Enhanced message bubbles with path visualization for channel messages Character count displays in message input fields Improved repeater CLI screen functionality New Files lib/storage/unread_store.dart - Unread tracking persistence lib/storage/contact_settings_store.dart - Per-contact SMAZ settings lib/widgets/unread_badge.dart - Unread count indicator lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter lib/screens/channel_message_path_screen.dart - Packet path visualization
567 lines
17 KiB
Dart
567 lines
17 KiB
Dart
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; // "<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 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;
|
||
}
|