Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7d47ebc
Expose stream controllers for connection and packets
eelco2k Oct 27, 2025
af8782d
Improves position data
flightcom Jan 2, 2026
2cf7601
fix: iOS compatibility — platform-guard requestMtu and Android-only B…
seancmt Apr 9, 2026
497b9fc
fix: Android 12+ uses bluetoothConnect/bluetoothScan, not legacy Perm…
seancmt Apr 9, 2026
c0ed2fc
feat: add sendAdminConfig for device configuration (GPS disable, etc)
seancmt Apr 9, 2026
8a88b32
fix: use write-with-response for BLE packets and non-zero wantConfigId
seancmt Apr 9, 2026
ef25d6b
fix: mark config complete even on read error
seancmt Apr 9, 2026
ea1be4b
feat: add sendData method for custom port packets
seancmt Apr 9, 2026
3ca3a65
fix: restore sendPosition signature, export PortNum
seancmt Apr 9, 2026
f8e0061
fix: sendData checks _toRadioChar instead of isConnected
seancmt Apr 9, 2026
ba243b1
fix: sendData returns silently when not ready instead of throwing
seancmt Apr 9, 2026
1ffa9d0
debug: add print logging to sendData to trace BLE writes
seancmt Apr 9, 2026
44999ca
debug: log _toRadioChar state in connectToDevice
seancmt Apr 10, 2026
39ef608
cleanup: remove debug print() statements from release path
seancmt Apr 10, 2026
ac3e428
Downgrade protobuf dependency version to 3.7.0
eelco2k Jun 5, 2026
04b2c6c
Update protobuf dependency version to 3.7.3
eelco2k Jun 5, 2026
6e8bc59
Downgrade protobuf dependency version to 3.1.0
eelco2k Jun 5, 2026
d8fae04
Merge pull request #1 from seancmt/main
eelco2k Jun 5, 2026
6efcf43
Merge pull request #2 from flightcom/main
eelco2k Jun 5, 2026
bb0a64c
Fix getter syntax for stream controllers
eelco2k Jun 5, 2026
36a36dc
Update protobuf dependency to version 4.2.0
eelco2k Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ dependencies:
meshtastic_flutter: ^0.0.1
```

Complete the setup for ```permission_handler``` plugin as given [here](https://pub.dev/packages/permission_handler)
Complete the setup for `permission_handler` plugin as given [here](https://pub.dev/packages/permission_handler)

## Permissions

Expand Down Expand Up @@ -125,6 +125,16 @@ Wrapper class for MeshPacket with convenience methods.
- `isEncrypted` / `isDecoded` - Encryption status
- `packetTypeDescription` - Human-readable packet type

#### Position Data (from packet payload)

The following properties decode position data directly from the packet payload, ensuring you get the exact position from each specific packet rather than the node's current position:

- `positionData` - Decoded `Position` protobuf object from packet payload
- `latitude` - Latitude in decimal degrees (null if not a position packet)
- `longitude` - Longitude in decimal degrees (null if not a position packet)
- `altitude` - Altitude in meters (null if not available)
- `timestamp` - Timestamp from position data (null if not available)

### NodeInfoWrapper

Wrapper class for NodeInfo with enhanced functionality.
Expand Down Expand Up @@ -174,6 +184,34 @@ await client.sendTextMessage('Private message', destinationId: 0x12345678);
await client.sendTextMessage('Channel message', channel: 1);
```

### Position Tracking

```dart
// Listen for position packets and extract position data from each packet
client.packetStream.listen((packet) {
if (packet.isPosition) {
// Extract position directly from packet payload
// This gives you the exact position from THIS packet, not the node's current position
if (packet.latitude != null && packet.longitude != null) {
print('Position from ${packet.from.toRadixString(16)}:');
print(' Lat: ${packet.latitude}');
print(' Lon: ${packet.longitude}');
print(' Alt: ${packet.altitude}m');
print(' Time: ${packet.timestamp}');

// Save to database with packet-specific position
savePositionToDatabase(
deviceId: packet.from,
latitude: packet.latitude!,
longitude: packet.longitude!,
altitude: packet.altitude,
timestamp: packet.timestamp,
);
}
}
});
```

### Node Monitoring

```dart
Expand Down
3 changes: 3 additions & 0 deletions lib/meshtastic_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ export 'generated/mesh.pb.dart';
export 'generated/mesh.pbenum.dart';
export 'generated/config.pb.dart';
export 'generated/module_config.pb.dart';
export 'generated/admin.pb.dart';
export 'generated/config.pbenum.dart';
export 'generated/portnums.pbenum.dart';
117 changes: 94 additions & 23 deletions lib/src/meshtastic_client.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
Expand All @@ -9,6 +10,7 @@ import 'package:permission_handler/permission_handler.dart';
import '../generated/mesh.pb.dart';
import '../generated/config.pb.dart';
import '../generated/module_config.pb.dart';
import '../generated/admin.pb.dart';
import '../generated/channel.pb.dart';
import '../generated/portnums.pb.dart';
import 'models/connection_state.dart';
Expand Down Expand Up @@ -64,6 +66,11 @@ class MeshtasticClient {
Stream<MeshPacketWrapper> get packetStream => _packetController.stream;
Stream<NodeInfoWrapper> get nodeStream => _nodeController.stream;

// Controllers
StreamController<ConnectionStatus> get connectionController => _connectionController;
StreamController<MeshPacketWrapper> get packetController => _packetController;
StreamController<NodeInfoWrapper> get nodeController => _nodeController;

// Getters for current state
Map<int, NodeInfoWrapper> get nodes => Map.unmodifiable(_nodes);
MyNodeInfo? get myNodeInfo => _myNodeInfo;
Expand Down Expand Up @@ -102,19 +109,28 @@ class MeshtasticClient {

/// Request necessary permissions for BLE
Future<void> _requestPermissions() async {
final permissions = [
Permission.bluetooth,
Permission.bluetoothConnect,
Permission.bluetoothScan,
Permission.locationWhenInUse,
];

for (final permission in permissions) {
final status = await permission.request();
if (!status.isGranted) {
throw PermissionException('Permission denied: $permission');
if (!kIsWeb && Platform.isAndroid) {
// Android 12+ (API 31+): use specific BLE permissions, not legacy Permission.bluetooth
final connectStatus = await Permission.bluetoothConnect.request();
if (!connectStatus.isGranted) {
throw const PermissionException('Permission denied: Permission.bluetoothConnect');
}
final scanStatus = await Permission.bluetoothScan.request();
if (!scanStatus.isGranted) {
throw const PermissionException('Permission denied: Permission.bluetoothScan');
}
} else if (!kIsWeb && Platform.isIOS) {
// iOS: Permission.bluetooth maps to CBManagerAuthorization
final bluetoothStatus = await Permission.bluetooth.request();
if (!bluetoothStatus.isGranted) {
throw const PermissionException('Permission denied: Permission.bluetooth');
}
}

final locationStatus = await Permission.locationWhenInUse.request();
if (!locationStatus.isGranted) {
throw const PermissionException('Permission denied: Permission.locationWhenInUse');
}
}

/// Scan for nearby Meshtastic devices
Expand Down Expand Up @@ -229,8 +245,10 @@ class MeshtasticClient {
'notify=${_fromNumChar!.properties.notify}',
);

// Set MTU to 512
await device.requestMtu(512);
// Set MTU to 512 (Android only — iOS negotiates MTU automatically)
if (!kIsWeb && Platform.isAndroid) {
await device.requestMtu(512);
}

// Enable notifications on FromNum
await _fromNumChar!.setNotifyValue(true);
Expand Down Expand Up @@ -322,6 +340,32 @@ class MeshtasticClient {
await _sendPacket(packet);
}

/// Send raw data on a custom port (e.g., PRIVATE_APP) — not intercepted by firmware modules
/// Returns silently if not ready. If BLE is down, the write throws a
/// platform exception ("device is disconnected") which the caller handles.
Future<void> sendData(
List<int> payload, {
int portnum = 256, // PRIVATE_APP
}) async {
if (_toRadioChar == null) return; // Not ready — skip silently

final packetId = DateTime.now().millisecondsSinceEpoch & 0xFFFFFFFF;

final packet = MeshPacket(
to: 0xFFFFFFFF, // Broadcast
id: packetId,
decoded: Data(
portnum: PortNum.valueOf(portnum) ?? PortNum.PRIVATE_APP,
payload: payload,
),
hopLimit: 3,
priority: MeshPacket_Priority.RELIABLE,
);

_logger.info('Sending custom data: ${payload.length} bytes on port $portnum');
await _sendPacket(packet);
}

/// Send a position update
Future<void> sendPosition(
double latitude,
Expand Down Expand Up @@ -364,6 +408,32 @@ class MeshtasticClient {
await _sendPacket(packet);
}

/// Send an admin config message to the device (e.g., to disable device GPS)
Future<void> sendAdminConfig(Config config) async {
if (!isConnected) {
throw const ConnectionException('Not connected to a device');
}

final adminMessage = AdminMessage(setConfig: config);
final packetId = DateTime.now().millisecondsSinceEpoch & 0xFFFFFFFF;

final packet = MeshPacket(
from: _myNodeInfo?.myNodeNum ?? 0,
to: _myNodeInfo?.myNodeNum ?? 0, // Send to self (local device)
id: packetId,
decoded: Data(
portnum: PortNum.ADMIN_APP,
payload: adminMessage.writeToBuffer(),
wantResponse: true,
),
hopLimit: 0, // Local only
priority: MeshPacket_Priority.RELIABLE,
);

_logger.info('Sending admin config');
await _sendPacket(packet);
}

/// Send a packet to the device
Future<void> _sendPacket(MeshPacket packet) async {
if (_toRadioChar == null) {
Expand All @@ -382,13 +452,11 @@ class MeshtasticClient {
'id=${packet.id}, portnum=${packet.decoded.portnum}, size=${data.length} bytes',
);

// Check if characteristic supports write without response
final supportsWriteWithoutResponse =
_toRadioChar!.properties.writeWithoutResponse;

// Always write WITH response — write-without-response can silently drop packets.
// The official Python library uses response=True for all ToRadio writes.
await _toRadioChar!.write(
data,
withoutResponse: supportsWriteWithoutResponse,
withoutResponse: false,
);

_logger.fine('Packet sent successfully');
Expand All @@ -399,13 +467,12 @@ class MeshtasticClient {
_logger.info('Starting configuration process');

// Send wantConfigId to start configuration download
final wantConfig = ToRadio(wantConfigId: 0);
// Check if characteristic supports write without response
final supportsWriteWithoutResponse =
_toRadioChar!.properties.writeWithoutResponse;
// Use a non-zero random ID — firmware sends back matching configCompleteId
final configId = DateTime.now().millisecondsSinceEpoch & 0xFFFFFFFF;
final wantConfig = ToRadio(wantConfigId: configId);
await _toRadioChar!.write(
wantConfig.writeToBuffer(),
withoutResponse: supportsWriteWithoutResponse,
withoutResponse: false,
);

// Start reading configuration data
Expand All @@ -432,6 +499,10 @@ class MeshtasticClient {
await Future.delayed(const Duration(milliseconds: 50));
} catch (e) {
_logger.warning('Error reading configuration: $e');
// Mark config as complete anyway — BLE is connected, services discovered,
// notifications enabled. Config read errors shouldn't block packet sending.
_configComplete = true;
_emitConnectionState(MeshtasticConnectionState.connected);
break;
}
}
Expand Down
43 changes: 43 additions & 0 deletions lib/src/models/mesh_packet_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,49 @@ class MeshPacketWrapper {
}
}

/// Get the position data from this packet (if this is a position packet)
/// Returns a Position object decoded directly from the packet payload
Position? get positionData {
if (!isPosition || decoded == null || decoded!.payload.isEmpty) return null;
try {
return Position.fromBuffer(decoded!.payload);
} catch (e) {
return null;
}
}

/// Get latitude in decimal degrees from position packet
/// Returns null if not a position packet or position data unavailable
double? get latitude {
final pos = positionData;
if (pos == null || !pos.hasLatitudeI()) return null;
return pos.latitudeI / 1e7;
}

/// Get longitude in decimal degrees from position packet
/// Returns null if not a position packet or position data unavailable
double? get longitude {
final pos = positionData;
if (pos == null || !pos.hasLongitudeI()) return null;
return pos.longitudeI / 1e7;
}

/// Get altitude in meters from position packet
/// Returns null if not a position packet or altitude unavailable
int? get altitude {
final pos = positionData;
if (pos == null || !pos.hasAltitude()) return null;
return pos.altitude;
}

/// Get timestamp from position packet
/// Returns null if not a position packet or time unavailable
int? get timestamp {
final pos = positionData;
if (pos == null || !pos.hasTime()) return null;
return pos.time;
}

/// Get the JSON payload as a string (if applicable)
String? get jsonPayload {
if (decoded == null) return null;
Expand Down