mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-01 06:30:31 +10:00
Initial commit: MeshCore Open Flutter client
Open-source Flutter client for MeshCore LoRa mesh networking devices. Features: - BLE device scanning and connection - Nordic UART Service (NUS) integration - Material 3 design with system theme support - Provider-based state management - Placeholder screens for chat, contacts, and settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
class AppSettings {
|
||||
final bool clearPathOnMaxRetry;
|
||||
final bool mapShowRepeaters;
|
||||
final bool mapShowChatNodes;
|
||||
final bool mapShowOtherNodes;
|
||||
final double mapTimeFilterHours; // 0 = all time
|
||||
final bool mapKeyPrefixEnabled;
|
||||
final String mapKeyPrefix;
|
||||
final bool mapShowMarkers;
|
||||
final bool notificationsEnabled;
|
||||
final bool notifyOnNewMessage;
|
||||
final bool notifyOnNewAdvert;
|
||||
final bool autoRouteRotationEnabled;
|
||||
final String themeMode;
|
||||
final Map<String, String> batteryChemistryByDeviceId;
|
||||
|
||||
AppSettings({
|
||||
this.clearPathOnMaxRetry = false,
|
||||
this.mapShowRepeaters = true,
|
||||
this.mapShowChatNodes = true,
|
||||
this.mapShowOtherNodes = true,
|
||||
this.mapTimeFilterHours = 0, // Default to all time
|
||||
this.mapKeyPrefixEnabled = false,
|
||||
this.mapKeyPrefix = '',
|
||||
this.mapShowMarkers = true,
|
||||
this.notificationsEnabled = true,
|
||||
this.notifyOnNewMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.themeMode = 'system',
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'clear_path_on_max_retry': clearPathOnMaxRetry,
|
||||
'map_show_repeaters': mapShowRepeaters,
|
||||
'map_show_chat_nodes': mapShowChatNodes,
|
||||
'map_show_other_nodes': mapShowOtherNodes,
|
||||
'map_time_filter_hours': mapTimeFilterHours,
|
||||
'map_key_prefix_enabled': mapKeyPrefixEnabled,
|
||||
'map_key_prefix': mapKeyPrefix,
|
||||
'map_show_markers': mapShowMarkers,
|
||||
'notifications_enabled': notificationsEnabled,
|
||||
'notify_on_new_message': notifyOnNewMessage,
|
||||
'notify_on_new_advert': notifyOnNewAdvert,
|
||||
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||
'theme_mode': themeMode,
|
||||
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false,
|
||||
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
) ??
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
AppSettings copyWith({
|
||||
bool? clearPathOnMaxRetry,
|
||||
bool? mapShowRepeaters,
|
||||
bool? mapShowChatNodes,
|
||||
bool? mapShowOtherNodes,
|
||||
double? mapTimeFilterHours,
|
||||
bool? mapKeyPrefixEnabled,
|
||||
String? mapKeyPrefix,
|
||||
bool? mapShowMarkers,
|
||||
bool? notificationsEnabled,
|
||||
bool? notifyOnNewMessage,
|
||||
bool? notifyOnNewAdvert,
|
||||
bool? autoRouteRotationEnabled,
|
||||
String? themeMode,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
|
||||
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
|
||||
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
|
||||
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
|
||||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Channel {
|
||||
final int index;
|
||||
final String name;
|
||||
final Uint8List psk; // 16 bytes
|
||||
|
||||
Channel({
|
||||
required this.index,
|
||||
required this.name,
|
||||
required this.psk,
|
||||
});
|
||||
|
||||
String get pskBase64 => base64Encode(psk);
|
||||
|
||||
bool get isEmpty => name.isEmpty && psk.every((b) => b == 0);
|
||||
|
||||
bool get isPublicChannel => pskBase64 == publicChannelPsk;
|
||||
|
||||
static Channel? fromFrame(Uint8List data) {
|
||||
// CHANNEL_INFO format:
|
||||
// [0] = RESP_CODE_CHANNEL_INFO (18)
|
||||
// [1] = channel_idx
|
||||
// [2-33] = name (32 bytes, null-terminated)
|
||||
// [34-49] = psk (16 bytes)
|
||||
if (data.length < 50) return null;
|
||||
if (data[0] != respCodeChannelInfo) return null;
|
||||
|
||||
final index = data[1];
|
||||
final name = readCString(data, 2, 32);
|
||||
final psk = Uint8List.fromList(data.sublist(34, 50));
|
||||
|
||||
return Channel(index: index, name: name, psk: psk);
|
||||
}
|
||||
|
||||
static Channel empty(int index) {
|
||||
return Channel(
|
||||
index: index,
|
||||
name: '',
|
||||
psk: Uint8List(16),
|
||||
);
|
||||
}
|
||||
|
||||
static Channel fromPsk(int index, String name, String pskBase64) {
|
||||
final pskBytes = base64Decode(pskBase64);
|
||||
final psk = Uint8List(16);
|
||||
for (int i = 0; i < pskBytes.length && i < 16; i++) {
|
||||
psk[i] = pskBytes[i];
|
||||
}
|
||||
return Channel(index: index, name: name, psk: psk);
|
||||
}
|
||||
|
||||
static const String publicChannelPsk = 'izOH6cXN6mrJ5e26oRXNcg==';
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/smaz.dart';
|
||||
|
||||
enum ChannelMessageStatus { pending, sent, failed }
|
||||
|
||||
class Repeat {
|
||||
final Uint8List? repeaterKey;
|
||||
final String repeaterName;
|
||||
final int tripTimeMs;
|
||||
final List<Uint8List>? path;
|
||||
|
||||
Repeat({
|
||||
this.repeaterKey,
|
||||
required this.repeaterName,
|
||||
required this.tripTimeMs,
|
||||
this.path,
|
||||
});
|
||||
|
||||
String? get repeaterKeyHex =>
|
||||
repeaterKey != null ? pubKeyToHex(repeaterKey!) : null;
|
||||
}
|
||||
|
||||
class ChannelMessage {
|
||||
final Uint8List? senderKey;
|
||||
final String senderName;
|
||||
final String text;
|
||||
final DateTime timestamp;
|
||||
final bool isOutgoing;
|
||||
final ChannelMessageStatus status;
|
||||
final List<Repeat> repeats;
|
||||
final int repeatCount;
|
||||
final int? pathLength;
|
||||
final Uint8List pathBytes;
|
||||
final int? channelIndex;
|
||||
|
||||
ChannelMessage({
|
||||
this.senderKey,
|
||||
required this.senderName,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
required this.isOutgoing,
|
||||
this.status = ChannelMessageStatus.pending,
|
||||
this.repeats = const [],
|
||||
this.repeatCount = 0,
|
||||
this.pathLength,
|
||||
Uint8List? pathBytes,
|
||||
this.channelIndex,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0);
|
||||
|
||||
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
|
||||
ChannelMessage copyWith({
|
||||
ChannelMessageStatus? status,
|
||||
List<Repeat>? repeats,
|
||||
int? repeatCount,
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
}) {
|
||||
return ChannelMessage(
|
||||
senderKey: senderKey,
|
||||
senderName: senderName,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
isOutgoing: isOutgoing,
|
||||
status: status ?? this.status,
|
||||
repeats: repeats ?? this.repeats,
|
||||
repeatCount: repeatCount ?? this.repeatCount,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
channelIndex: channelIndex,
|
||||
);
|
||||
}
|
||||
|
||||
static ChannelMessage? fromFrame(Uint8List data) {
|
||||
// CHANNEL_MSG_RECV format varies by version:
|
||||
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
|
||||
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
|
||||
if (data.length < 8) return null;
|
||||
|
||||
final code = data[0];
|
||||
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
|
||||
Uint8List pathBytes = Uint8List(0);
|
||||
int channelIdx;
|
||||
|
||||
if (code == respCodeChannelMsgRecvV3) {
|
||||
channelIdx = data[4];
|
||||
pathLenOffset = 5;
|
||||
final pathLen = data[pathLenOffset].toSigned(8);
|
||||
var cursor = 6;
|
||||
final hasPathBytesFlag = (data[2] & 0x01) != 0;
|
||||
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
|
||||
final hasValidTxtType =
|
||||
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
|
||||
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
|
||||
cursor += pathLen;
|
||||
}
|
||||
txtTypeOffset = cursor;
|
||||
cursor += 1; // txt_type
|
||||
timestampOffset = cursor;
|
||||
textOffset = cursor + 4;
|
||||
} else {
|
||||
channelIdx = data[1];
|
||||
pathLenOffset = 2;
|
||||
txtTypeOffset = 3;
|
||||
timestampOffset = 4;
|
||||
textOffset = 8;
|
||||
}
|
||||
|
||||
if (data.length < textOffset + 1) return null;
|
||||
|
||||
final txtType = data[txtTypeOffset];
|
||||
if (txtType != txtTypePlain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final pathLen = data[pathLenOffset].toSigned(8);
|
||||
final timestampRaw = readUint32LE(data, timestampOffset);
|
||||
final text = readCString(data, textOffset, data.length - textOffset);
|
||||
|
||||
// Extract sender name and actual message from "name: msg" format
|
||||
String senderName = 'Unknown';
|
||||
String actualText = text;
|
||||
|
||||
final colonIndex = text.indexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
|
||||
final potentialSender = text.substring(0, colonIndex);
|
||||
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
||||
senderName = potentialSender;
|
||||
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
? colonIndex + 2
|
||||
: colonIndex + 1;
|
||||
actualText = text.substring(offset);
|
||||
}
|
||||
}
|
||||
|
||||
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
|
||||
|
||||
return ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: senderName,
|
||||
text: decodedText,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||
isOutgoing: false,
|
||||
status: ChannelMessageStatus.sent,
|
||||
pathLength: pathLen,
|
||||
pathBytes: pathBytes,
|
||||
channelIndex: channelIdx,
|
||||
);
|
||||
}
|
||||
|
||||
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
|
||||
return ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: senderName,
|
||||
text: text,
|
||||
timestamp: DateTime.now(),
|
||||
isOutgoing: true,
|
||||
status: ChannelMessageStatus.pending,
|
||||
pathLength: null,
|
||||
pathBytes: Uint8List(0),
|
||||
channelIndex: channelIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Contact {
|
||||
final Uint8List publicKey;
|
||||
final String name;
|
||||
final int type;
|
||||
final int pathLength; // -1 = flood, 0+ = direct hops
|
||||
final Uint8List path;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final DateTime lastSeen;
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.pathLength,
|
||||
required this.path,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.lastSeen,
|
||||
});
|
||||
|
||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return 'Chat';
|
||||
case advTypeRepeater:
|
||||
return 'Repeater';
|
||||
case advTypeRoom:
|
||||
return 'Room';
|
||||
case advTypeSensor:
|
||||
return 'Sensor';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get pathLabel {
|
||||
if (pathLength < 0) return 'Flood';
|
||||
if (pathLength == 0) return 'Direct';
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
bool get hasLocation => latitude != null && longitude != null;
|
||||
|
||||
String get pathIdList {
|
||||
if (path.isEmpty) return '';
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < path.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length;
|
||||
final chunk = path.sublist(i, end);
|
||||
parts.add(
|
||||
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
||||
);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
static Contact? fromFrame(Uint8List data) {
|
||||
if (data.length < contactFrameSize) return null;
|
||||
if (data[0] != respCodeContact) return null;
|
||||
|
||||
final pubKey = Uint8List.fromList(
|
||||
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
||||
);
|
||||
final type = data[contactTypeOffset];
|
||||
final pathLen = data[contactPathLenOffset].toSigned(8);
|
||||
final safePathLen = pathLen > 0
|
||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||
: 0;
|
||||
final pathBytes = safePathLen > 0
|
||||
? Uint8List.fromList(
|
||||
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
||||
)
|
||||
: Uint8List(0);
|
||||
final name = readCString(data, contactNameOffset, maxNameSize);
|
||||
final lastmod = readUint32LE(data, contactLastmodOffset);
|
||||
|
||||
double? lat, lon;
|
||||
final latRaw = readInt32LE(data, contactLatOffset);
|
||||
final lonRaw = readInt32LE(data, contactLonOffset);
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
}
|
||||
|
||||
return Contact(
|
||||
publicKey: pubKey,
|
||||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
pathLength: pathLen,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Contact && publicKeyHex == other.publicKeyHex;
|
||||
|
||||
@override
|
||||
int get hashCode => publicKeyHex.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
class ContactGroup {
|
||||
final String name;
|
||||
final List<String> memberKeys;
|
||||
|
||||
const ContactGroup({
|
||||
required this.name,
|
||||
required this.memberKeys,
|
||||
});
|
||||
|
||||
ContactGroup copyWith({
|
||||
String? name,
|
||||
List<String>? memberKeys,
|
||||
}) {
|
||||
return ContactGroup(
|
||||
name: name ?? this.name,
|
||||
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'members': memberKeys,
|
||||
};
|
||||
}
|
||||
|
||||
factory ContactGroup.fromJson(Map<String, dynamic> json) {
|
||||
final members = (json['members'] as List?)
|
||||
?.map((value) => value.toString())
|
||||
.toList() ??
|
||||
<String>[];
|
||||
return ContactGroup(
|
||||
name: json['name'] as String? ?? '',
|
||||
memberKeys: members,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
enum MessageStatus { pending, sent, delivered, failed }
|
||||
|
||||
class Message {
|
||||
final Uint8List senderKey;
|
||||
final String text;
|
||||
final DateTime timestamp;
|
||||
final bool isOutgoing;
|
||||
final bool isCli;
|
||||
final MessageStatus status;
|
||||
|
||||
// NEW: Retry logic fields
|
||||
final String? messageId;
|
||||
final int retryCount;
|
||||
final int? estimatedTimeoutMs;
|
||||
final Uint8List? expectedAckHash;
|
||||
final DateTime? sentAt;
|
||||
final DateTime? deliveredAt;
|
||||
final int? tripTimeMs;
|
||||
final bool forceFlood;
|
||||
final int? pathLength;
|
||||
final Uint8List pathBytes;
|
||||
|
||||
Message({
|
||||
required this.senderKey,
|
||||
required this.text,
|
||||
required this.timestamp,
|
||||
required this.isOutgoing,
|
||||
this.isCli = false,
|
||||
this.status = MessageStatus.pending,
|
||||
this.messageId,
|
||||
this.retryCount = 0,
|
||||
this.estimatedTimeoutMs,
|
||||
this.expectedAckHash,
|
||||
this.sentAt,
|
||||
this.deliveredAt,
|
||||
this.tripTimeMs,
|
||||
this.forceFlood = false,
|
||||
this.pathLength,
|
||||
Uint8List? pathBytes,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0);
|
||||
|
||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||
|
||||
Message copyWith({
|
||||
MessageStatus? status,
|
||||
int? retryCount,
|
||||
int? estimatedTimeoutMs,
|
||||
Uint8List? expectedAckHash,
|
||||
DateTime? sentAt,
|
||||
DateTime? deliveredAt,
|
||||
int? tripTimeMs,
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
bool? isCli,
|
||||
}) {
|
||||
return Message(
|
||||
senderKey: senderKey,
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
isOutgoing: isOutgoing,
|
||||
isCli: isCli ?? this.isCli,
|
||||
status: status ?? this.status,
|
||||
messageId: messageId,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
|
||||
expectedAckHash: expectedAckHash ?? this.expectedAckHash,
|
||||
sentAt: sentAt ?? this.sentAt,
|
||||
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||
tripTimeMs: tripTimeMs ?? this.tripTimeMs,
|
||||
forceFlood: forceFlood,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
);
|
||||
}
|
||||
|
||||
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
|
||||
if (data.length < msgTextOffset + 1) return null;
|
||||
|
||||
final code = data[0];
|
||||
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final senderKey = Uint8List.fromList(
|
||||
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
|
||||
);
|
||||
final timestampRaw = readUint32LE(data, msgTimestampOffset);
|
||||
final flags = data[msgFlagsOffset];
|
||||
if ((flags >> 2) != txtTypePlain) {
|
||||
return null;
|
||||
}
|
||||
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
|
||||
|
||||
return Message(
|
||||
senderKey: senderKey,
|
||||
text: text,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
|
||||
isOutgoing: false,
|
||||
isCli: false,
|
||||
status: MessageStatus.delivered,
|
||||
pathBytes: Uint8List(0),
|
||||
);
|
||||
}
|
||||
|
||||
static Message outgoing(
|
||||
Uint8List recipientKey,
|
||||
String text, {
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
}) {
|
||||
return Message(
|
||||
senderKey: recipientKey,
|
||||
text: text,
|
||||
timestamp: DateTime.now(),
|
||||
isOutgoing: true,
|
||||
isCli: false,
|
||||
status: MessageStatus.pending,
|
||||
pathLength: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
class PathRecord {
|
||||
final int hopCount;
|
||||
final int tripTimeMs;
|
||||
final DateTime timestamp;
|
||||
final bool wasFloodDiscovery;
|
||||
final List<int> pathBytes;
|
||||
final int successCount;
|
||||
final int failureCount;
|
||||
|
||||
PathRecord({
|
||||
required this.hopCount,
|
||||
required this.tripTimeMs,
|
||||
required this.timestamp,
|
||||
required this.wasFloodDiscovery,
|
||||
required this.pathBytes,
|
||||
required this.successCount,
|
||||
required this.failureCount,
|
||||
});
|
||||
|
||||
String get displayText =>
|
||||
'$hopCount ${hopCount == 1 ? 'hop' : 'hops'} - ${(tripTimeMs / 1000).toStringAsFixed(2)}s';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'hop_count': hopCount,
|
||||
'trip_time_ms': tripTimeMs,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'was_flood': wasFloodDiscovery,
|
||||
'path_bytes': pathBytes,
|
||||
'success_count': successCount,
|
||||
'failure_count': failureCount,
|
||||
};
|
||||
}
|
||||
|
||||
factory PathRecord.fromJson(Map<String, dynamic> json) {
|
||||
return PathRecord(
|
||||
hopCount: json['hop_count'] as int,
|
||||
tripTimeMs: json['trip_time_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
wasFloodDiscovery: json['was_flood'] as bool,
|
||||
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
successCount: json['success_count'] as int? ?? 0,
|
||||
failureCount: json['failure_count'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ContactPathHistory {
|
||||
final String contactPubKeyHex;
|
||||
final List<PathRecord> recentPaths;
|
||||
|
||||
ContactPathHistory({
|
||||
required this.contactPubKeyHex,
|
||||
required this.recentPaths,
|
||||
});
|
||||
|
||||
PathRecord? get fastest {
|
||||
if (recentPaths.isEmpty) return null;
|
||||
return recentPaths.reduce((a, b) => a.tripTimeMs < b.tripTimeMs ? a : b);
|
||||
}
|
||||
|
||||
PathRecord? get mostRecent {
|
||||
if (recentPaths.isEmpty) return null;
|
||||
return recentPaths.first;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory ContactPathHistory.fromJson(
|
||||
String contactPubKeyHex, Map<String, dynamic> json) {
|
||||
final pathsList = (json['recent_paths'] as List?)
|
||||
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
return ContactPathHistory(
|
||||
contactPubKeyHex: contactPubKeyHex,
|
||||
recentPaths: pathsList,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
class PathSelection {
|
||||
final List<int> pathBytes;
|
||||
final int hopCount;
|
||||
final bool useFlood;
|
||||
|
||||
const PathSelection({
|
||||
required this.pathBytes,
|
||||
required this.hopCount,
|
||||
required this.useFlood,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
enum LoRaBandwidth {
|
||||
bw7_8(7800, '7.8 kHz'),
|
||||
bw10_4(10400, '10.4 kHz'),
|
||||
bw15_6(15600, '15.6 kHz'),
|
||||
bw20_8(20800, '20.8 kHz'),
|
||||
bw31_25(31250, '31.25 kHz'),
|
||||
bw41_7(41700, '41.7 kHz'),
|
||||
bw62_5(62500, '62.5 kHz'),
|
||||
bw125(125000, '125 kHz'),
|
||||
bw250(250000, '250 kHz'),
|
||||
bw500(500000, '500 kHz');
|
||||
|
||||
final int hz;
|
||||
final String label;
|
||||
|
||||
const LoRaBandwidth(this.hz, this.label);
|
||||
}
|
||||
|
||||
enum LoRaSpreadingFactor {
|
||||
sf5(5, 'SF5'),
|
||||
sf6(6, 'SF6'),
|
||||
sf7(7, 'SF7'),
|
||||
sf8(8, 'SF8'),
|
||||
sf9(9, 'SF9'),
|
||||
sf10(10, 'SF10'),
|
||||
sf11(11, 'SF11'),
|
||||
sf12(12, 'SF12');
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
|
||||
const LoRaSpreadingFactor(this.value, this.label);
|
||||
}
|
||||
|
||||
enum LoRaCodingRate {
|
||||
cr4_5(5, '4/5'),
|
||||
cr4_6(6, '4/6'),
|
||||
cr4_7(7, '4/7'),
|
||||
cr4_8(8, '4/8');
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
|
||||
const LoRaCodingRate(this.value, this.label);
|
||||
}
|
||||
|
||||
class RadioSettings {
|
||||
final double frequencyMHz;
|
||||
final LoRaBandwidth bandwidth;
|
||||
final LoRaSpreadingFactor spreadingFactor;
|
||||
final LoRaCodingRate codingRate;
|
||||
final int txPowerDbm;
|
||||
|
||||
RadioSettings({
|
||||
required this.frequencyMHz,
|
||||
required this.bandwidth,
|
||||
required this.spreadingFactor,
|
||||
required this.codingRate,
|
||||
required this.txPowerDbm,
|
||||
});
|
||||
|
||||
// Preset configurations
|
||||
static RadioSettings get preset915MHz => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get preset868MHz => RadioSettings(
|
||||
frequencyMHz: 868.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
);
|
||||
|
||||
static RadioSettings get preset433MHz => RadioSettings(
|
||||
frequencyMHz: 433.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetLongRange => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf12,
|
||||
codingRate: LoRaCodingRate.cr4_8,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetFastSpeed => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw500,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
int get frequencyHz => (frequencyMHz * 1000).round();
|
||||
int get bandwidthHz => bandwidth.hz;
|
||||
int get sf => spreadingFactor.value;
|
||||
int get cr => codingRate.value;
|
||||
}
|
||||
Reference in New Issue
Block a user