mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
initial commit
This commit is contained in:
139
lib/services/buffer_reader.dart
Normal file
139
lib/services/buffer_reader.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
/// Buffer reader for parsing MeshCore protocol binary data
|
||||
class BufferReader {
|
||||
final Uint8List _buffer;
|
||||
int _offset = 0;
|
||||
|
||||
BufferReader(this._buffer);
|
||||
|
||||
/// Get remaining bytes count
|
||||
int get remainingBytesCount => _buffer.length - _offset;
|
||||
|
||||
/// Check if there are bytes remaining
|
||||
bool get hasRemaining => _offset < _buffer.length;
|
||||
|
||||
/// Get current offset
|
||||
int get offset => _offset;
|
||||
|
||||
/// Set offset
|
||||
set offset(int value) => _offset = value;
|
||||
|
||||
/// Read a single byte (uint8)
|
||||
int readByte() {
|
||||
if (_offset >= _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
return _buffer[_offset++];
|
||||
}
|
||||
|
||||
/// Read a signed byte (int8)
|
||||
int readInt8() {
|
||||
final value = readByte();
|
||||
return value > 127 ? value - 256 : value;
|
||||
}
|
||||
|
||||
/// Read unsigned 16-bit integer (little-endian)
|
||||
int readUInt16LE() {
|
||||
if (_offset + 2 > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final value = _buffer[_offset] | (_buffer[_offset + 1] << 8);
|
||||
_offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Read signed 16-bit integer (little-endian)
|
||||
int readInt16LE() {
|
||||
final value = readUInt16LE();
|
||||
return value > 32767 ? value - 65536 : value;
|
||||
}
|
||||
|
||||
/// Read unsigned 32-bit integer (little-endian)
|
||||
int readUInt32LE() {
|
||||
if (_offset + 4 > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final value = _buffer[_offset] |
|
||||
(_buffer[_offset + 1] << 8) |
|
||||
(_buffer[_offset + 2] << 16) |
|
||||
(_buffer[_offset + 3] << 24);
|
||||
_offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Read signed 32-bit integer (little-endian)
|
||||
int readInt32LE() {
|
||||
final value = readUInt32LE();
|
||||
return value > 2147483647 ? value - 4294967296 : value;
|
||||
}
|
||||
|
||||
/// Read a fixed number of bytes
|
||||
Uint8List readBytes(int length) {
|
||||
if (_offset + length > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
final bytes = _buffer.sublist(_offset, _offset + length);
|
||||
_offset += length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Read remaining bytes
|
||||
Uint8List readRemainingBytes() {
|
||||
final bytes = _buffer.sublist(_offset);
|
||||
_offset = _buffer.length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Read null-terminated string (C-string) with max length
|
||||
String readCString(int maxLength) {
|
||||
if (_offset + maxLength > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to read beyond buffer length');
|
||||
}
|
||||
|
||||
final bytes = _buffer.sublist(_offset, _offset + maxLength);
|
||||
_offset += maxLength;
|
||||
|
||||
// Find null terminator
|
||||
int nullIndex = bytes.indexOf(0);
|
||||
if (nullIndex == -1) {
|
||||
nullIndex = maxLength;
|
||||
}
|
||||
|
||||
// Decode string up to null terminator
|
||||
return utf8.decode(bytes.sublist(0, nullIndex));
|
||||
}
|
||||
|
||||
/// Read length-prefixed string (remaining bytes as UTF-8)
|
||||
String readString() {
|
||||
final bytes = readRemainingBytes();
|
||||
return utf8.decode(bytes);
|
||||
}
|
||||
|
||||
/// Peek at next byte without advancing offset
|
||||
int peekByte() {
|
||||
if (_offset >= _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to peek beyond buffer length');
|
||||
}
|
||||
return _buffer[_offset];
|
||||
}
|
||||
|
||||
/// Skip bytes
|
||||
void skip(int count) {
|
||||
if (_offset + count > _buffer.length) {
|
||||
throw Exception('Buffer overflow: attempting to skip beyond buffer length');
|
||||
}
|
||||
_offset += count;
|
||||
}
|
||||
|
||||
/// Reset offset to beginning
|
||||
void reset() {
|
||||
_offset = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BufferReader(length: ${_buffer.length}, offset: $_offset, remaining: $remainingBytesCount)';
|
||||
}
|
||||
}
|
||||
129
lib/services/buffer_writer.dart
Normal file
129
lib/services/buffer_writer.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
/// Buffer writer for creating MeshCore protocol binary data
|
||||
class BufferWriter {
|
||||
final List<int> _buffer = [];
|
||||
|
||||
/// Get current buffer length
|
||||
int get length => _buffer.length;
|
||||
|
||||
/// Write a single byte (uint8)
|
||||
void writeByte(int value) {
|
||||
if (value < 0 || value > 255) {
|
||||
throw ArgumentError('Byte value must be between 0 and 255');
|
||||
}
|
||||
_buffer.add(value);
|
||||
}
|
||||
|
||||
/// Write a signed byte (int8)
|
||||
void writeInt8(int value) {
|
||||
if (value < -128 || value > 127) {
|
||||
throw ArgumentError('Int8 value must be between -128 and 127');
|
||||
}
|
||||
_buffer.add(value < 0 ? value + 256 : value);
|
||||
}
|
||||
|
||||
/// Write unsigned 16-bit integer (little-endian)
|
||||
void writeUInt16LE(int value) {
|
||||
if (value < 0 || value > 65535) {
|
||||
throw ArgumentError('UInt16 value must be between 0 and 65535');
|
||||
}
|
||||
_buffer.add(value & 0xFF);
|
||||
_buffer.add((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
/// Write signed 16-bit integer (little-endian)
|
||||
void writeInt16LE(int value) {
|
||||
if (value < -32768 || value > 32767) {
|
||||
throw ArgumentError('Int16 value must be between -32768 and 32767');
|
||||
}
|
||||
final unsigned = value < 0 ? value + 65536 : value;
|
||||
writeUInt16LE(unsigned);
|
||||
}
|
||||
|
||||
/// Write unsigned 32-bit integer (little-endian)
|
||||
void writeUInt32LE(int value) {
|
||||
if (value < 0 || value > 4294967295) {
|
||||
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
|
||||
}
|
||||
_buffer.add(value & 0xFF);
|
||||
_buffer.add((value >> 8) & 0xFF);
|
||||
_buffer.add((value >> 16) & 0xFF);
|
||||
_buffer.add((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
/// Write signed 32-bit integer (little-endian)
|
||||
void writeInt32LE(int value) {
|
||||
if (value < -2147483648 || value > 2147483647) {
|
||||
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
|
||||
}
|
||||
final unsigned = value < 0 ? value + 4294967296 : value;
|
||||
writeUInt32LE(unsigned);
|
||||
}
|
||||
|
||||
/// Write bytes from Uint8List
|
||||
void writeBytes(Uint8List bytes) {
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Write bytes from List<int>
|
||||
void writeBytesFromList(List<int> bytes) {
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Write null-terminated string (C-string) with fixed length
|
||||
/// Pads with zeros if string is shorter than maxLength
|
||||
void writeCString(String str, int maxLength) {
|
||||
final bytes = utf8.encode(str);
|
||||
|
||||
// Ensure we don't exceed max length
|
||||
final length = bytes.length < maxLength ? bytes.length : maxLength;
|
||||
|
||||
// Write string bytes
|
||||
for (int i = 0; i < length; i++) {
|
||||
_buffer.add(bytes[i]);
|
||||
}
|
||||
|
||||
// Pad with zeros
|
||||
for (int i = length; i < maxLength; i++) {
|
||||
_buffer.add(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write length-prefixed string
|
||||
void writeString(String str) {
|
||||
final bytes = utf8.encode(str);
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Write string with length prefix (1 byte)
|
||||
void writeLengthPrefixedString(String str) {
|
||||
final bytes = utf8.encode(str);
|
||||
if (bytes.length > 255) {
|
||||
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
|
||||
}
|
||||
writeByte(bytes.length);
|
||||
_buffer.addAll(bytes);
|
||||
}
|
||||
|
||||
/// Get buffer as Uint8List
|
||||
Uint8List toBytes() {
|
||||
return Uint8List.fromList(_buffer);
|
||||
}
|
||||
|
||||
/// Clear the buffer
|
||||
void clear() {
|
||||
_buffer.clear();
|
||||
}
|
||||
|
||||
/// Get buffer as hex string (for debugging)
|
||||
String toHexString() {
|
||||
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BufferWriter(length: $length, hex: ${toHexString()})';
|
||||
}
|
||||
}
|
||||
186
lib/services/cayenne_lpp_parser.dart
Normal file
186
lib/services/cayenne_lpp_parser.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import 'buffer_reader.dart';
|
||||
import 'meshcore_constants.dart';
|
||||
|
||||
/// Cayenne LPP (Low Power Payload) data parser
|
||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||
class CayenneLppParser {
|
||||
/// Parse Cayenne LPP data into ContactTelemetry
|
||||
static ContactTelemetry parse(Uint8List data) {
|
||||
final reader = BufferReader(data);
|
||||
|
||||
LatLng? gpsLocation;
|
||||
double? batteryPercentage;
|
||||
double? batteryMilliVolts;
|
||||
double? temperature;
|
||||
double? humidity;
|
||||
double? pressure;
|
||||
final extraSensorData = <String, dynamic>{};
|
||||
|
||||
while (reader.hasRemaining) {
|
||||
try {
|
||||
final channel = reader.readByte();
|
||||
final type = reader.readByte();
|
||||
|
||||
switch (type) {
|
||||
case MeshCoreConstants.lppDigitalInput:
|
||||
final value = reader.readByte();
|
||||
extraSensorData['digital_input_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppDigitalOutput:
|
||||
final value = reader.readByte();
|
||||
extraSensorData['digital_output_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogInput:
|
||||
final value = reader.readInt16LE() / 100.0;
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
// If this is a battery reading
|
||||
if (channel == 0 || channel == 1) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogOutput:
|
||||
final value = reader.readInt16LE() / 100.0;
|
||||
extraSensorData['analog_output_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
final value = reader.readUInt16LE();
|
||||
extraSensorData['illuminance_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppPresenceSensor:
|
||||
final value = reader.readByte();
|
||||
extraSensorData['presence_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
temperature = reader.readInt16LE() / 10.0;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
humidity = reader.readByte() / 2.0;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
final x = reader.readInt16LE() / 1000.0;
|
||||
final y = reader.readInt16LE() / 1000.0;
|
||||
final z = reader.readInt16LE() / 1000.0;
|
||||
extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
pressure = reader.readUInt16LE() / 10.0;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
final x = reader.readInt16LE() / 100.0;
|
||||
final y = reader.readInt16LE() / 100.0;
|
||||
final z = reader.readInt16LE() / 100.0;
|
||||
extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGps:
|
||||
final lat = reader.readInt32LE() / 10000.0;
|
||||
final lon = reader.readInt32LE() / 10000.0;
|
||||
final alt = reader.readInt32LE() / 100.0;
|
||||
gpsLocation = LatLng(lat, lon);
|
||||
extraSensorData['altitude_$channel'] = alt;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown type, skip remaining to avoid parsing errors
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we encounter a parsing error, break and return what we have
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ContactTelemetry(
|
||||
gpsLocation: gpsLocation,
|
||||
batteryPercentage: batteryPercentage,
|
||||
batteryMilliVolts: batteryMilliVolts,
|
||||
temperature: temperature,
|
||||
humidity: humidity,
|
||||
pressure: pressure,
|
||||
timestamp: DateTime.now(),
|
||||
extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate battery percentage from voltage (V)
|
||||
static double _calculateBatteryPercentage(double voltage) {
|
||||
// Standard lithium battery curve: 3.0V = 0%, 4.2V = 100%
|
||||
if (voltage <= 3.0) return 0.0;
|
||||
if (voltage >= 4.2) return 100.0;
|
||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for GPS location
|
||||
static Uint8List createGpsData({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
double altitude = 0.0,
|
||||
int channel = 0,
|
||||
}) {
|
||||
final buffer = <int>[];
|
||||
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppGps);
|
||||
|
||||
// Latitude (3 bytes, signed, 0.0001° precision)
|
||||
final lat = (latitude * 10000).round();
|
||||
buffer.add((lat >> 16) & 0xFF);
|
||||
buffer.add((lat >> 8) & 0xFF);
|
||||
buffer.add(lat & 0xFF);
|
||||
|
||||
// Longitude (3 bytes, signed, 0.0001° precision)
|
||||
final lon = (longitude * 10000).round();
|
||||
buffer.add((lon >> 16) & 0xFF);
|
||||
buffer.add((lon >> 8) & 0xFF);
|
||||
buffer.add(lon & 0xFF);
|
||||
|
||||
// Altitude (3 bytes, signed, 0.01m precision)
|
||||
final alt = (altitude * 100).round();
|
||||
buffer.add((alt >> 16) & 0xFF);
|
||||
buffer.add((alt >> 8) & 0xFF);
|
||||
buffer.add(alt & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for temperature
|
||||
static Uint8List createTemperatureData(double celsius, {int channel = 0}) {
|
||||
final buffer = <int>[];
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppTemperatureSensor);
|
||||
|
||||
final temp = (celsius * 10).round();
|
||||
buffer.add((temp >> 8) & 0xFF);
|
||||
buffer.add(temp & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for battery voltage
|
||||
static Uint8List createBatteryData(double voltage, {int channel = 0}) {
|
||||
final buffer = <int>[];
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppAnalogInput);
|
||||
|
||||
final volts = (voltage * 100).round();
|
||||
buffer.add((volts >> 8) & 0xFF);
|
||||
buffer.add(volts & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
}
|
||||
394
lib/services/meshcore_ble_service.dart
Normal file
394
lib/services/meshcore_ble_service.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_telemetry.dart';
|
||||
import '../models/message.dart';
|
||||
import 'buffer_reader.dart';
|
||||
import 'buffer_writer.dart';
|
||||
import 'meshcore_constants.dart';
|
||||
|
||||
/// Callback types for MeshCore events
|
||||
typedef OnContactCallback = void Function(Contact contact);
|
||||
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
|
||||
typedef OnMessageCallback = void Function(Message message);
|
||||
typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData);
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
|
||||
/// MeshCore BLE Service - handles all BLE communication
|
||||
class MeshCoreBleService {
|
||||
BluetoothDevice? _device;
|
||||
BluetoothCharacteristic? _rxCharacteristic;
|
||||
BluetoothCharacteristic? _txCharacteristic;
|
||||
StreamSubscription? _txSubscription;
|
||||
|
||||
// Event callbacks
|
||||
OnConnectionStateCallback? onConnectionStateChanged;
|
||||
OnContactCallback? onContactReceived;
|
||||
OnContactsCompleteCallback? onContactsComplete;
|
||||
OnMessageCallback? onMessageReceived;
|
||||
OnTelemetryCallback? onTelemetryReceived;
|
||||
OnErrorCallback? onError;
|
||||
|
||||
// Internal state
|
||||
final List<Contact> _pendingContacts = [];
|
||||
bool _isConnected = false;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Scan for MeshCore devices
|
||||
Stream<BluetoothDevice> scanForDevices({Duration timeout = const Duration(seconds: 10)}) async* {
|
||||
try {
|
||||
// Start scanning
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: timeout,
|
||||
withServices: [Guid(MeshCoreConstants.bleServiceUuid)],
|
||||
);
|
||||
|
||||
// Listen to scan results
|
||||
await for (final scanResult in FlutterBluePlus.scanResults) {
|
||||
for (final result in scanResult) {
|
||||
if (result.advertisementData.serviceUuids
|
||||
.contains(Guid(MeshCoreConstants.bleServiceUuid))) {
|
||||
yield result.device;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.call('Scan error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a MeshCore device
|
||||
Future<bool> connect(BluetoothDevice device) async {
|
||||
try {
|
||||
_device = device;
|
||||
|
||||
// Connect to device
|
||||
await device.connect(
|
||||
license: License.free,
|
||||
timeout: const Duration(seconds: 15),
|
||||
mtu: 512,
|
||||
);
|
||||
|
||||
// Discover services
|
||||
final services = await device.discoverServices();
|
||||
|
||||
// Find MeshCore service
|
||||
BluetoothService? meshCoreService;
|
||||
for (final service in services) {
|
||||
if (service.uuid.toString().toLowerCase() ==
|
||||
MeshCoreConstants.bleServiceUuid.toLowerCase()) {
|
||||
meshCoreService = service;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (meshCoreService == null) {
|
||||
throw Exception('MeshCore service not found');
|
||||
}
|
||||
|
||||
// Find RX and TX characteristics
|
||||
for (final characteristic in meshCoreService.characteristics) {
|
||||
final uuid = characteristic.uuid.toString().toLowerCase();
|
||||
if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) {
|
||||
_rxCharacteristic = characteristic;
|
||||
} else if (uuid ==
|
||||
MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) {
|
||||
_txCharacteristic = characteristic;
|
||||
}
|
||||
}
|
||||
|
||||
if (_rxCharacteristic == null || _txCharacteristic == null) {
|
||||
throw Exception('Required characteristics not found');
|
||||
}
|
||||
|
||||
// Enable notifications on TX characteristic
|
||||
await _txCharacteristic!.setNotifyValue(true);
|
||||
|
||||
// Listen to TX characteristic
|
||||
_txSubscription = _txCharacteristic!.lastValueStream.listen(
|
||||
_onDataReceived,
|
||||
onError: (error) => onError?.call('TX notification error: $error'),
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
onConnectionStateChanged?.call(true);
|
||||
|
||||
// Send initial device query
|
||||
await _sendDeviceQuery();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
onError?.call('Connection error: $e');
|
||||
_isConnected = false;
|
||||
onConnectionStateChanged?.call(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from device
|
||||
Future<void> disconnect() async {
|
||||
try {
|
||||
await _txSubscription?.cancel();
|
||||
await _device?.disconnect();
|
||||
_isConnected = false;
|
||||
_device = null;
|
||||
_rxCharacteristic = null;
|
||||
_txCharacteristic = null;
|
||||
onConnectionStateChanged?.call(false);
|
||||
} catch (e) {
|
||||
onError?.call('Disconnect error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Write data to RX characteristic
|
||||
Future<void> _writeData(Uint8List data) async {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception('Not connected');
|
||||
}
|
||||
try {
|
||||
await _rxCharacteristic!.write(data, withoutResponse: true);
|
||||
} catch (e) {
|
||||
onError?.call('Write error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming data from TX characteristic
|
||||
void _onDataReceived(List<int> data) {
|
||||
try {
|
||||
final reader = BufferReader(Uint8List.fromList(data));
|
||||
final responseCode = reader.readByte();
|
||||
|
||||
switch (responseCode) {
|
||||
case MeshCoreConstants.respContactsStart:
|
||||
_handleContactsStart(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respContact:
|
||||
_handleContact(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respEndOfContacts:
|
||||
_handleEndOfContacts(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respContactMsgRecv:
|
||||
_handleContactMessage(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respChannelMsgRecv:
|
||||
_handleChannelMessage(reader);
|
||||
break;
|
||||
case MeshCoreConstants.pushTelemetryResponse:
|
||||
_handleTelemetryResponse(reader);
|
||||
break;
|
||||
case MeshCoreConstants.respOk:
|
||||
case MeshCoreConstants.respErr:
|
||||
// Handle OK/Error responses if needed
|
||||
break;
|
||||
default:
|
||||
// Unknown response code
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.call('Data parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ContactsStart response
|
||||
void _handleContactsStart(BufferReader reader) {
|
||||
_pendingContacts.clear();
|
||||
final count = reader.readUInt32LE();
|
||||
// Optional: notify about expected count
|
||||
}
|
||||
|
||||
/// Handle Contact response
|
||||
void _handleContact(BufferReader reader) {
|
||||
try {
|
||||
final publicKey = reader.readBytes(32);
|
||||
final type = ContactType.fromValue(reader.readByte());
|
||||
final flags = reader.readByte();
|
||||
final outPathLen = reader.readInt8();
|
||||
final outPath = reader.readBytes(64);
|
||||
final advName = reader.readCString(32);
|
||||
final lastAdvert = reader.readUInt32LE();
|
||||
final advLat = reader.readInt32LE();
|
||||
final advLon = reader.readInt32LE();
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
final contact = Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: flags,
|
||||
outPathLen: outPathLen,
|
||||
outPath: outPath,
|
||||
advName: advName,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: advLat,
|
||||
advLon: advLon,
|
||||
lastMod: lastMod,
|
||||
);
|
||||
|
||||
_pendingContacts.add(contact);
|
||||
onContactReceived?.call(contact);
|
||||
} catch (e) {
|
||||
onError?.call('Contact parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle EndOfContacts response
|
||||
void _handleEndOfContacts(BufferReader reader) {
|
||||
onContactsComplete?.call(List.from(_pendingContacts));
|
||||
_pendingContacts.clear();
|
||||
}
|
||||
|
||||
/// Handle ContactMsgRecv response
|
||||
void _handleContactMessage(BufferReader reader) {
|
||||
try {
|
||||
final pubKeyPrefix = reader.readBytes(6);
|
||||
final pathLen = reader.readByte();
|
||||
final txtType = MessageTextType.fromValue(reader.readByte());
|
||||
final senderTimestamp = reader.readUInt32LE();
|
||||
final text = reader.readString();
|
||||
|
||||
final message = Message(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: pubKeyPrefix,
|
||||
pathLen: pathLen,
|
||||
textType: txtType,
|
||||
senderTimestamp: senderTimestamp,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
onMessageReceived?.call(message);
|
||||
} catch (e) {
|
||||
onError?.call('Contact message parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ChannelMsgRecv response
|
||||
void _handleChannelMessage(BufferReader reader) {
|
||||
try {
|
||||
final channelIdx = reader.readInt8();
|
||||
final pathLen = reader.readByte();
|
||||
final txtType = MessageTextType.fromValue(reader.readByte());
|
||||
final senderTimestamp = reader.readUInt32LE();
|
||||
final text = reader.readString();
|
||||
|
||||
final message = Message(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: channelIdx,
|
||||
pathLen: pathLen,
|
||||
textType: txtType,
|
||||
senderTimestamp: senderTimestamp,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
onMessageReceived?.call(message);
|
||||
} catch (e) {
|
||||
onError?.call('Channel message parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle TelemetryResponse push
|
||||
void _handleTelemetryResponse(BufferReader reader) {
|
||||
try {
|
||||
reader.readByte(); // reserved
|
||||
final pubKeyPrefix = reader.readBytes(6);
|
||||
final lppSensorData = reader.readRemainingBytes();
|
||||
|
||||
onTelemetryReceived?.call(pubKeyPrefix, lppSensorData);
|
||||
} catch (e) {
|
||||
onError?.call('Telemetry parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Send AppStart command
|
||||
Future<void> _sendAppStart() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdAppStart);
|
||||
writer.writeByte(1); // appVer
|
||||
writer.writeBytes(Uint8List(6)); // reserved
|
||||
writer.writeString('MeshCore SAR'); // appName
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send DeviceQuery command
|
||||
Future<void> _sendDeviceQuery() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdDeviceQuery);
|
||||
writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion);
|
||||
await _writeData(writer.toBytes());
|
||||
await _sendAppStart();
|
||||
}
|
||||
|
||||
/// Get contacts from device
|
||||
Future<void> getContacts() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdGetContacts);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send text message to contact
|
||||
Future<void> sendTextMessage({
|
||||
required Uint8List contactPublicKey,
|
||||
required String text,
|
||||
}) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg);
|
||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
||||
writer.writeByte(0); // attempt
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
writer.writeBytes(contactPublicKey.sublist(0, 6));
|
||||
writer.writeString(text);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Send channel text message
|
||||
Future<void> sendChannelMessage({
|
||||
required int channelIdx,
|
||||
required String text,
|
||||
}) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg);
|
||||
writer.writeByte(MeshCoreConstants.txtTypePlain);
|
||||
writer.writeByte(channelIdx);
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
writer.writeString(text);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Request telemetry from contact
|
||||
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeByte(0); // reserved
|
||||
writer.writeBytes(contactPublicKey);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Get battery voltage
|
||||
Future<void> getBatteryVoltage() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Set device time
|
||||
Future<void> setDeviceTime() async {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(MeshCoreConstants.cmdSetDeviceTime);
|
||||
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
await _writeData(writer.toBytes());
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_txSubscription?.cancel();
|
||||
_pendingContacts.clear();
|
||||
}
|
||||
}
|
||||
138
lib/services/meshcore_constants.dart
Normal file
138
lib/services/meshcore_constants.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
/// MeshCore BLE and Protocol Constants
|
||||
class MeshCoreConstants {
|
||||
// Supported protocol version
|
||||
static const int supportedCompanionProtocolVersion = 1;
|
||||
|
||||
// BLE Service and Characteristic UUIDs
|
||||
static const String bleServiceUuid =
|
||||
'6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
|
||||
static const String bleCharacteristicRxUuid =
|
||||
'6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // Write
|
||||
static const String bleCharacteristicTxUuid =
|
||||
'6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // Notify
|
||||
|
||||
// Command Codes (App -> Device)
|
||||
static const int cmdAppStart = 1;
|
||||
static const int cmdSendTxtMsg = 2;
|
||||
static const int cmdSendChannelTxtMsg = 3;
|
||||
static const int cmdGetContacts = 4;
|
||||
static const int cmdGetDeviceTime = 5;
|
||||
static const int cmdSetDeviceTime = 6;
|
||||
static const int cmdSendSelfAdvert = 7;
|
||||
static const int cmdSetAdvertName = 8;
|
||||
static const int cmdAddUpdateContact = 9;
|
||||
static const int cmdSyncNextMessage = 10;
|
||||
static const int cmdSetRadioParams = 11;
|
||||
static const int cmdSetTxPower = 12;
|
||||
static const int cmdResetPath = 13;
|
||||
static const int cmdSetAdvertLatLon = 14;
|
||||
static const int cmdRemoveContact = 15;
|
||||
static const int cmdShareContact = 16;
|
||||
static const int cmdExportContact = 17;
|
||||
static const int cmdImportContact = 18;
|
||||
static const int cmdReboot = 19;
|
||||
static const int cmdGetBatteryVoltage = 20;
|
||||
static const int cmdSetTuningParams = 21;
|
||||
static const int cmdDeviceQuery = 22;
|
||||
static const int cmdExportPrivateKey = 23;
|
||||
static const int cmdImportPrivateKey = 24;
|
||||
static const int cmdSendRawData = 25;
|
||||
static const int cmdSendLogin = 26;
|
||||
static const int cmdSendStatusReq = 27;
|
||||
static const int cmdGetChannel = 31;
|
||||
static const int cmdSetChannel = 32;
|
||||
static const int cmdSignStart = 33;
|
||||
static const int cmdSignData = 34;
|
||||
static const int cmdSignFinish = 35;
|
||||
static const int cmdSendTracePath = 36;
|
||||
static const int cmdSetOtherParams = 38;
|
||||
static const int cmdSendTelemetryReq = 39;
|
||||
static const int cmdSendBinaryReq = 50;
|
||||
|
||||
// Response Codes (Device -> App)
|
||||
static const int respOk = 0;
|
||||
static const int respErr = 1;
|
||||
static const int respContactsStart = 2;
|
||||
static const int respContact = 3;
|
||||
static const int respEndOfContacts = 4;
|
||||
static const int respSelfInfo = 5;
|
||||
static const int respSent = 6;
|
||||
static const int respContactMsgRecv = 7;
|
||||
static const int respChannelMsgRecv = 8;
|
||||
static const int respCurrTime = 9;
|
||||
static const int respNoMoreMessages = 10;
|
||||
static const int respExportContact = 11;
|
||||
static const int respBatteryVoltage = 12;
|
||||
static const int respDeviceInfo = 13;
|
||||
static const int respPrivateKey = 14;
|
||||
static const int respDisabled = 15;
|
||||
static const int respChannelInfo = 18;
|
||||
static const int respSignStart = 19;
|
||||
static const int respSignature = 20;
|
||||
|
||||
// Push Codes (Device -> App, unsolicited)
|
||||
static const int pushAdvert = 0x80;
|
||||
static const int pushPathUpdated = 0x81;
|
||||
static const int pushSendConfirmed = 0x82;
|
||||
static const int pushMsgWaiting = 0x83;
|
||||
static const int pushRawData = 0x84;
|
||||
static const int pushLoginSuccess = 0x85;
|
||||
static const int pushLoginFail = 0x86;
|
||||
static const int pushStatusResponse = 0x87;
|
||||
static const int pushLogRxData = 0x88;
|
||||
static const int pushTraceData = 0x89;
|
||||
static const int pushNewAdvert = 0x8A;
|
||||
static const int pushTelemetryResponse = 0x8B;
|
||||
static const int pushBinaryResponse = 0x8C;
|
||||
|
||||
// Error Codes
|
||||
static const int errUnsupportedCmd = 1;
|
||||
static const int errNotFound = 2;
|
||||
static const int errTableFull = 3;
|
||||
static const int errBadState = 4;
|
||||
static const int errFileIoError = 5;
|
||||
static const int errIllegalArg = 6;
|
||||
|
||||
// Advert Types
|
||||
static const int advTypeNone = 0;
|
||||
static const int advTypeChat = 1;
|
||||
static const int advTypeRepeater = 2;
|
||||
static const int advTypeRoom = 3;
|
||||
|
||||
// Self Advert Types
|
||||
static const int selfAdvertZeroHop = 0;
|
||||
static const int selfAdvertFlood = 1;
|
||||
|
||||
// Text Types
|
||||
static const int txtTypePlain = 0;
|
||||
static const int txtTypeCliData = 1;
|
||||
static const int txtTypeSignedPlain = 2;
|
||||
|
||||
// Binary Request Types
|
||||
static const int binaryReqGetTelemetryData = 0x03;
|
||||
static const int binaryReqGetAvgMinMax = 0x04;
|
||||
static const int binaryReqGetAccessList = 0x05;
|
||||
static const int binaryReqGetNeighbours = 0x06;
|
||||
|
||||
// Cayenne LPP Data Types
|
||||
static const int lppDigitalInput = 0;
|
||||
static const int lppDigitalOutput = 1;
|
||||
static const int lppAnalogInput = 2;
|
||||
static const int lppAnalogOutput = 3;
|
||||
static const int lppIlluminanceSensor = 101;
|
||||
static const int lppPresenceSensor = 102;
|
||||
static const int lppTemperatureSensor = 103;
|
||||
static const int lppHumiditySensor = 104;
|
||||
static const int lppAccelerometer = 113;
|
||||
static const int lppBarometer = 115;
|
||||
static const int lppGyrometer = 134;
|
||||
static const int lppGps = 136;
|
||||
|
||||
// MTU and timing
|
||||
static const int maxMtuSize = 512;
|
||||
static const int defaultTimeout = 5000; // 5 seconds
|
||||
static const int reconnectDelay = 2000; // 2 seconds
|
||||
static const int telemetryUpdateInterval = 300000; // 5 minutes
|
||||
|
||||
MeshCoreConstants._(); // Private constructor to prevent instantiation
|
||||
}
|
||||
170
lib/services/tile_cache_service.dart
Normal file
170
lib/services/tile_cache_service.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
||||
import 'package:flutter_map_tile_caching/custom_backend_api.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/map_layer.dart';
|
||||
|
||||
class TileCacheService {
|
||||
static const String _storeName = 'meshcore_sar_tiles';
|
||||
|
||||
late final FMTCStore _store;
|
||||
bool _isInitialized = false;
|
||||
bool _isDownloading = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
await FMTCObjectBoxBackend().initialise();
|
||||
_store = FMTCStore(_storeName);
|
||||
await _store.manage.create();
|
||||
_isInitialized = true;
|
||||
} catch (e) {
|
||||
// Store might already exist
|
||||
_store = FMTCStore(_storeName);
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
FMTCTileProvider getTileProvider(MapLayer layer) {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
return _store.getTileProvider(
|
||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
||||
cachedValidDuration: const Duration(days: 30),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> downloadRegion({
|
||||
required MapLayer layer,
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
if (_isDownloading) {
|
||||
throw StateError('A download is already in progress. Cancel it first.');
|
||||
}
|
||||
|
||||
_isDownloading = true;
|
||||
|
||||
try {
|
||||
final region = RectangleRegion(bounds);
|
||||
|
||||
final downloadable = region.toDownloadable(
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
options: TileLayer(
|
||||
urlTemplate: layer.urlTemplate,
|
||||
),
|
||||
);
|
||||
|
||||
final download = _store.download.startForeground(
|
||||
region: downloadable,
|
||||
);
|
||||
|
||||
await for (final progress in download.downloadProgress) {
|
||||
if (onProgress != null && progress.maxTilesCount > 0) {
|
||||
// Use attemptedTilesCount instead of successfulTilesCount
|
||||
// attemptedTilesCount includes successful + buffered + skipped tiles
|
||||
final percentage = progress.percentageProgress;
|
||||
print('Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})');
|
||||
onProgress(percentage);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelDownload() async {
|
||||
if (!_isInitialized) return;
|
||||
await _store.download.cancel();
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
if (!_isInitialized) return;
|
||||
await _store.manage.delete();
|
||||
await _store.manage.create();
|
||||
}
|
||||
|
||||
Future<int> getCachedTileCount() async {
|
||||
if (!_isInitialized) return 0;
|
||||
final stats = await _store.stats.length;
|
||||
return stats;
|
||||
}
|
||||
|
||||
Future<double> getCacheSizeMB() async {
|
||||
if (!_isInitialized) return 0.0;
|
||||
final stats = await _store.stats.size;
|
||||
return stats / (1024 * 1024);
|
||||
}
|
||||
|
||||
Future<String> exportCache() async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final exportPath = '${directory.path}/meshcore_maps_$timestamp.fmtc';
|
||||
|
||||
// Export using FMTCBackendAccess
|
||||
await FMTCBackendAccess.internal.exportStores(
|
||||
storeNames: [_storeName],
|
||||
path: exportPath,
|
||||
);
|
||||
|
||||
return exportPath;
|
||||
}
|
||||
|
||||
Future<void> importCache(String filePath) async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('Import file not found: $filePath');
|
||||
}
|
||||
|
||||
// Import using FMTCBackendAccess
|
||||
await FMTCBackendAccess.internal.importStores(
|
||||
storeNames: [_storeName],
|
||||
path: filePath,
|
||||
strategy: ImportConflictStrategy.rename,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> getAvailableStores() async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
final stores = await FMTCRoot.stats.storesAvailable;
|
||||
return stores.map((store) => store.storeName).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getStoreStats() async {
|
||||
if (!_isInitialized) return {};
|
||||
|
||||
final length = await _store.stats.length;
|
||||
final size = await _store.stats.size;
|
||||
|
||||
return {
|
||||
'tileCount': length,
|
||||
'sizeMB': size / (1024 * 1024),
|
||||
'storeName': _storeName,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user