initial commit

This commit is contained in:
Janez T
2025-10-13 22:28:20 +02:00
commit 823a163123
175 changed files with 12697 additions and 0 deletions

174
lib/models/contact.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
import 'contact_telemetry.dart';
/// MeshCore contact types
enum ContactType {
none(0),
chat(1),
repeater(2),
room(3);
const ContactType(this.value);
final int value;
static ContactType fromValue(int value) {
return ContactType.values.firstWhere(
(e) => e.value == value,
orElse: () => ContactType.none,
);
}
String get displayName {
switch (this) {
case ContactType.chat:
return 'Chat';
case ContactType.repeater:
return 'Repeater';
case ContactType.room:
return 'Room';
default:
return 'Unknown';
}
}
}
/// MeshCore contact model
class Contact {
final Uint8List publicKey;
final ContactType type;
final int flags;
final int outPathLen;
final Uint8List outPath;
final String advName;
final int lastAdvert; // Unix timestamp
final int advLat; // Latitude as int32
final int advLon; // Longitude as int32
final int lastMod; // Unix timestamp
// Telemetry data (updated separately)
ContactTelemetry? telemetry;
Contact({
required this.publicKey,
required this.type,
required this.flags,
required this.outPathLen,
required this.outPath,
required this.advName,
required this.lastAdvert,
required this.advLat,
required this.advLon,
required this.lastMod,
this.telemetry,
});
/// Get public key as hex string (first 8 bytes)
String get publicKeyShort {
if (publicKey.length < 8) return '';
return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Get full public key as hex string
String get publicKeyHex {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Convert advLat/advLon to LatLng
LatLng? get advertLocation {
if (advLat == 0 && advLon == 0) return null;
// Convert from int32 to double (degrees)
final lat = advLat / 1e7;
final lon = advLon / 1e7;
return LatLng(lat, lon);
}
/// Get display location (prefer telemetry over advert)
LatLng? get displayLocation {
if (telemetry?.gpsLocation != null && telemetry!.isRecent) {
return telemetry!.gpsLocation;
}
return advertLocation;
}
/// Get display battery (from telemetry or null)
double? get displayBattery {
return telemetry?.batteryPercentage;
}
/// Check if contact is a chat type (team member)
bool get isChat => type == ContactType.chat;
/// Check if contact is a repeater
bool get isRepeater => type == ContactType.repeater;
/// Check if contact is a room/channel
bool get isRoom => type == ContactType.room;
/// Get last seen time
DateTime get lastSeenTime {
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
}
/// Get last modified time
DateTime get lastModifiedTime {
return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000);
}
/// Check if contact was seen recently (within last 10 minutes)
bool get isRecentlySeen {
return DateTime.now().difference(lastSeenTime).inMinutes < 10;
}
/// Get friendly time since last seen
String get timeSinceLastSeen {
final diff = DateTime.now().difference(lastSeenTime);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
Contact copyWith({
Uint8List? publicKey,
ContactType? type,
int? flags,
int? outPathLen,
Uint8List? outPath,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? lastMod,
ContactTelemetry? telemetry,
}) {
return Contact(
publicKey: publicKey ?? this.publicKey,
type: type ?? this.type,
flags: flags ?? this.flags,
outPathLen: outPathLen ?? this.outPathLen,
outPath: outPath ?? this.outPath,
advName: advName ?? this.advName,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
lastMod: lastMod ?? this.lastMod,
telemetry: telemetry ?? this.telemetry,
);
}
@override
String toString() {
return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Contact &&
publicKeyHex == other.publicKeyHex;
}
@override
int get hashCode => publicKeyHex.hashCode;
}

View File

@@ -0,0 +1,76 @@
import 'package:latlong2/latlong.dart';
/// Contact telemetry data from MeshCore device
class ContactTelemetry {
final LatLng? gpsLocation;
final double? batteryPercentage;
final double? batteryMilliVolts;
final double? temperature;
final DateTime timestamp;
// Additional sensor data
final double? humidity;
final double? pressure;
final Map<String, dynamic>? extraSensorData;
ContactTelemetry({
this.gpsLocation,
this.batteryPercentage,
this.batteryMilliVolts,
this.temperature,
required this.timestamp,
this.humidity,
this.pressure,
this.extraSensorData,
});
/// Check if telemetry data is recent (within last 5 minutes)
bool get isRecent {
return DateTime.now().difference(timestamp).inMinutes < 5;
}
/// Check if battery level is low (< 20%)
bool get isLowBattery {
return batteryPercentage != null && batteryPercentage! < 20.0;
}
/// Check if battery level is critical (< 10%)
bool get isCriticalBattery {
return batteryPercentage != null && batteryPercentage! < 10.0;
}
/// Get battery status color indicator
String get batteryStatus {
if (batteryPercentage == null) return 'unknown';
if (batteryPercentage! > 50) return 'good';
if (batteryPercentage! > 20) return 'medium';
return 'low';
}
ContactTelemetry copyWith({
LatLng? gpsLocation,
double? batteryPercentage,
double? batteryMilliVolts,
double? temperature,
DateTime? timestamp,
double? humidity,
double? pressure,
Map<String, dynamic>? extraSensorData,
}) {
return ContactTelemetry(
gpsLocation: gpsLocation ?? this.gpsLocation,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
temperature: temperature ?? this.temperature,
timestamp: timestamp ?? this.timestamp,
humidity: humidity ?? this.humidity,
pressure: pressure ?? this.pressure,
extraSensorData: extraSensorData ?? this.extraSensorData,
);
}
@override
String toString() {
return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperature°C, time: $timestamp)';
}
}

173
lib/models/device_info.dart Normal file
View File

@@ -0,0 +1,173 @@
import 'dart:typed_data';
/// BLE connection state
enum ConnectionState {
disconnected,
connecting,
connected,
disconnecting,
error,
}
/// MeshCore device information
class DeviceInfo {
final String? deviceId;
final String? deviceName;
final ConnectionState connectionState;
final int? batteryMilliVolts;
final double? batteryPercentage;
final int? signalRssi;
final double? signalSnr;
final DateTime? lastUpdate;
// Self info from MeshCore device
final int? deviceType;
final int? txPower;
final int? maxTxPower;
final Uint8List? publicKey;
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
final int? radioCr;
final String? selfName;
// Firmware info
final int? firmwareVersion;
final String? firmwareBuildDate;
final String? manufacturerModel;
DeviceInfo({
this.deviceId,
this.deviceName,
this.connectionState = ConnectionState.disconnected,
this.batteryMilliVolts,
this.batteryPercentage,
this.signalRssi,
this.signalSnr,
this.lastUpdate,
this.deviceType,
this.txPower,
this.maxTxPower,
this.publicKey,
this.advLat,
this.advLon,
this.manualAddContacts,
this.radioFreq,
this.radioBw,
this.radioSf,
this.radioCr,
this.selfName,
this.firmwareVersion,
this.firmwareBuildDate,
this.manufacturerModel,
});
/// Check if device is connected
bool get isConnected => connectionState == ConnectionState.connected;
/// Check if device is connecting
bool get isConnecting => connectionState == ConnectionState.connecting;
/// Check if device has error
bool get hasError => connectionState == ConnectionState.error;
/// Get battery percentage (calculated or provided)
double? get batteryPercent {
if (batteryPercentage != null) return batteryPercentage!;
if (batteryMilliVolts == null) return null;
// Rough conversion from mV to percentage (3.0V = 0%, 4.2V = 100%)
final voltage = batteryMilliVolts! / 1000.0;
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
/// Get battery status
String get batteryStatus {
final percent = batteryPercent;
if (percent == null) return 'Unknown';
if (percent > 80) return 'Excellent';
if (percent > 50) return 'Good';
if (percent > 20) return 'Low';
return 'Critical';
}
/// Get signal strength category
String get signalStrength {
if (signalRssi == null) return 'Unknown';
if (signalRssi! > -60) return 'Excellent';
if (signalRssi! > -70) return 'Good';
if (signalRssi! > -80) return 'Fair';
return 'Poor';
}
/// Get public key as hex string (short)
String? get publicKeyShort {
if (publicKey == null || publicKey!.length < 8) return null;
return publicKey!
.sublist(0, 8)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
DeviceInfo copyWith({
String? deviceId,
String? deviceName,
ConnectionState? connectionState,
int? batteryMilliVolts,
double? batteryPercentage,
int? signalRssi,
double? signalSnr,
DateTime? lastUpdate,
int? deviceType,
int? txPower,
int? maxTxPower,
Uint8List? publicKey,
int? advLat,
int? advLon,
bool? manualAddContacts,
int? radioFreq,
int? radioBw,
int? radioSf,
int? radioCr,
String? selfName,
int? firmwareVersion,
String? firmwareBuildDate,
String? manufacturerModel,
}) {
return DeviceInfo(
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
connectionState: connectionState ?? this.connectionState,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
signalRssi: signalRssi ?? this.signalRssi,
signalSnr: signalSnr ?? this.signalSnr,
lastUpdate: lastUpdate ?? this.lastUpdate,
deviceType: deviceType ?? this.deviceType,
txPower: txPower ?? this.txPower,
maxTxPower: maxTxPower ?? this.maxTxPower,
publicKey: publicKey ?? this.publicKey,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
radioCr: radioCr ?? this.radioCr,
selfName: selfName ?? this.selfName,
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
);
}
@override
String toString() {
return 'DeviceInfo(name: $deviceName, state: $connectionState, battery: ${batteryPercent?.toStringAsFixed(0)}%, signal: $signalRssi dBm)';
}
}

56
lib/models/map_layer.dart Normal file
View File

@@ -0,0 +1,56 @@
enum MapLayerType {
openStreetMap,
openTopoMap,
esriWorldImagery,
}
class MapLayer {
final MapLayerType type;
final String name;
final String urlTemplate;
final String attribution;
final int maxZoom;
const MapLayer({
required this.type,
required this.name,
required this.urlTemplate,
required this.attribution,
required this.maxZoom,
});
static const openStreetMap = MapLayer(
type: MapLayerType.openStreetMap,
name: 'OpenStreetMap',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
);
static const openTopoMap = MapLayer(
type: MapLayerType.openTopoMap,
name: 'OpenTopoMap',
urlTemplate: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
attribution: '© OpenTopoMap (CC-BY-SA)',
maxZoom: 17,
);
static const esriWorldImagery = MapLayer(
type: MapLayerType.esriWorldImagery,
name: 'ESRI Satellite',
urlTemplate:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
maxZoom: 19,
);
static const List<MapLayer> allLayers = [
openStreetMap,
openTopoMap,
esriWorldImagery,
];
static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type);
}
}

171
lib/models/message.dart Normal file
View File

@@ -0,0 +1,171 @@
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
import 'sar_marker.dart';
/// Message text types from MeshCore protocol
enum MessageTextType {
plain(0),
cliData(1),
signedPlain(2);
const MessageTextType(this.value);
final int value;
static MessageTextType fromValue(int value) {
return MessageTextType.values.firstWhere(
(e) => e.value == value,
orElse: () => MessageTextType.plain,
);
}
}
/// Message type (contact or channel)
enum MessageType {
contact,
channel,
}
/// MeshCore message model
class Message {
final String id;
final MessageType messageType;
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
final int? channelIdx; // For channel messages
final int pathLen;
final MessageTextType textType;
final int senderTimestamp; // Unix timestamp
final String text;
// SAR marker data (if this is a SAR message)
final bool isSarMarker;
final SarMarkerType? sarMarkerType;
final LatLng? sarGpsCoordinates;
// Display metadata
final DateTime receivedAt;
final String? senderName;
Message({
required this.id,
required this.messageType,
this.senderPublicKeyPrefix,
this.channelIdx,
required this.pathLen,
required this.textType,
required this.senderTimestamp,
required this.text,
this.isSarMarker = false,
this.sarMarkerType,
this.sarGpsCoordinates,
required this.receivedAt,
this.senderName,
});
/// Get sender public key as hex string
String? get senderKeyShort {
if (senderPublicKeyPrefix == null) return null;
return senderPublicKeyPrefix!
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get sender timestamp as DateTime
DateTime get sentAt {
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
}
/// Check if message is from a channel
bool get isChannelMessage => messageType == MessageType.channel;
/// Check if message is from a contact
bool get isContactMessage => messageType == MessageType.contact;
/// Get friendly time since message was sent
String get timeAgo {
final diff = DateTime.now().difference(sentAt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Get display name for sender
String get displaySender {
if (senderName != null && senderName!.isNotEmpty) {
return senderName!;
}
if (senderKeyShort != null) {
return senderKeyShort!.substring(0, 8);
}
if (isChannelMessage && channelIdx != null) {
return 'Channel $channelIdx';
}
return 'Unknown';
}
/// Convert to SAR marker if applicable
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
return null;
}
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: text,
);
}
Message copyWith({
String? id,
MessageType? messageType,
Uint8List? senderPublicKeyPrefix,
int? channelIdx,
int? pathLen,
MessageTextType? textType,
int? senderTimestamp,
String? text,
bool? isSarMarker,
SarMarkerType? sarMarkerType,
LatLng? sarGpsCoordinates,
DateTime? receivedAt,
String? senderName,
}) {
return Message(
id: id ?? this.id,
messageType: messageType ?? this.messageType,
senderPublicKeyPrefix: senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
channelIdx: channelIdx ?? this.channelIdx,
pathLen: pathLen ?? this.pathLen,
textType: textType ?? this.textType,
senderTimestamp: senderTimestamp ?? this.senderTimestamp,
text: text ?? this.text,
isSarMarker: isSarMarker ?? this.isSarMarker,
sarMarkerType: sarMarkerType ?? this.sarMarkerType,
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
receivedAt: receivedAt ?? this.receivedAt,
senderName: senderName ?? this.senderName,
);
}
@override
String toString() {
if (isSarMarker) {
return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)';
}
return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Message && id == other.id;
}
@override
int get hashCode => id.hashCode;
}

126
lib/models/sar_marker.dart Normal file
View File

@@ -0,0 +1,126 @@
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
/// SAR (Search & Rescue) marker types
enum SarMarkerType {
foundPerson('🧑', 'Found Person'),
fire('🔥', 'Fire'),
stagingArea('🏕️', 'Staging Area'),
unknown('', 'Unknown');
const SarMarkerType(this.emoji, this.displayName);
final String emoji;
final String displayName;
static SarMarkerType fromEmoji(String emoji) {
switch (emoji) {
case '🧑':
case '👤':
return SarMarkerType.foundPerson;
case '🔥':
return SarMarkerType.fire;
case '🏕️':
case '':
return SarMarkerType.stagingArea;
default:
return SarMarkerType.unknown;
}
}
/// Get map marker color
String get markerColor {
switch (this) {
case SarMarkerType.foundPerson:
return '#4CAF50'; // Green
case SarMarkerType.fire:
return '#F44336'; // Red
case SarMarkerType.stagingArea:
return '#2196F3'; // Blue
default:
return '#9E9E9E'; // Gray
}
}
}
/// SAR marker from special messages
class SarMarker {
final String id;
final SarMarkerType type;
final LatLng location;
final DateTime timestamp;
final Uint8List? senderPublicKey;
final String? senderName;
final String? notes;
SarMarker({
required this.id,
required this.type,
required this.location,
required this.timestamp,
this.senderPublicKey,
this.senderName,
this.notes,
});
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
return senderPublicKey!
.sublist(0, 8)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get friendly time since marker was created
String get timeAgo {
final diff = DateTime.now().difference(timestamp);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
/// Check if marker is recent (within last hour)
bool get isRecent {
return DateTime.now().difference(timestamp).inHours < 1;
}
/// Get display name
String get displayName {
return '${type.emoji} ${type.displayName}';
}
SarMarker copyWith({
String? id,
SarMarkerType? type,
LatLng? location,
DateTime? timestamp,
Uint8List? senderPublicKey,
String? senderName,
String? notes,
}) {
return SarMarker(
id: id ?? this.id,
type: type ?? this.type,
location: location ?? this.location,
timestamp: timestamp ?? this.timestamp,
senderPublicKey: senderPublicKey ?? this.senderPublicKey,
senderName: senderName ?? this.senderName,
notes: notes ?? this.notes,
);
}
@override
String toString() {
return 'SarMarker(type: ${type.displayName}, location: $location, sender: $senderName, time: $timeAgo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SarMarker && id == other.id;
}
@override
int get hashCode => id.hashCode;
}