Compare commits

...

2 Commits

Author SHA1 Message Date
Janez T
3283d4ca20 docs: Add Windows BLE pairing instructions to README
Document pairing the MeshCore radio via the Windows 11 system Bluetooth
menu (with PIN) before connecting in-app, per user report.

Fixes #41

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:41:35 +02:00
Janez T
bdbceece75 fix: Normalize raw transport path for firmware v1.15 photo/audio transfer
CMD_SEND_RAW_DATA (0x19) was passed the encoded MeshCore route descriptor
(e.g. 0x41 for a 1-hop 2-byte-hash route), but the v1.15 companion firmware
raw-data handler treats the path byte as a legacy literal hop count, not an
encoded descriptor. It misread 0x41 as "65 path bytes" and rejected the
command (ERROR: Unsupported command), so media fetch always failed while
normal messaging worked.

- Add ContactRouteCodec.toLegacyRawPath() converting encoded descriptor +
  path into legacy format (literal hop count + first byte of each hop hash).
- Apply it in ConnectionProvider.sendRawVoicePacket, the single chokepoint
  all raw media (voice/image fragments, swarm, route probes) flows through.
- Pace served fragments 350ms apart to avoid firmware "ERROR: Table full"
  when bursting many raw packets.
- Tests for the descriptor conversion.

Fixes #43

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:38:05 +02:00
5 changed files with 140 additions and 2 deletions

View File

@@ -75,6 +75,24 @@ It uses the MeshCore protocol over LoRa for long-range, infrastructure-free comm
- Incident command and coordination roles - Incident command and coordination roles
- Operators working in weak/no cellular coverage - Operators working in weak/no cellular coverage
## Connecting Your Radio (BLE)
The app pairs with your MeshCore radio over Bluetooth Low Energy.
### Windows
On Windows 11 (tested on 25H2), pair the radio through the system Bluetooth menu **before** connecting in the app:
1. Open **Settings → Bluetooth & devices → Add device → Bluetooth**.
2. Select your MeshCore radio and complete pairing, entering the **PIN** when prompted.
3. Launch MeshCore SAR and connect — the radio now appears as a paired device.
If the radio does not show up or fails to connect in-app, remove it from the Windows Bluetooth menu and repeat the pairing step.
### iOS / Android
Connect directly from within the app — no separate system pairing step is required.
## Permissions (App Use) ## Permissions (App Use)
- Bluetooth: mesh device communication - Bluetooth: mesh device communication

View File

@@ -196,6 +196,54 @@ class ContactRouteCodec {
final hopCount = raw & 0x3F; final hopCount = raw & 0x3F;
return hopCount * hashSize <= maxPathBytes; return hopCount * hashSize <= maxPathBytes;
} }
/// Converts an encoded MeshCore route descriptor + path into the legacy raw
/// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in
/// companion firmware v1.15.
///
/// The raw-data handler treats the path-length byte as a *literal* hop count
/// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route
/// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as
/// "65 path bytes" and rejected with `ERROR: Unsupported command`. We
/// down-sample every hop to its first hash byte and emit a plain hop count.
///
/// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass
/// through unchanged. Unknown/flood descriptors collapse to a zero-hop
/// direct path (raw transport does not support flood routing).
static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) {
final raw = toUnsignedDescriptor(descriptor);
if (raw == _unknownDescriptor) {
return const LegacyRawPath(length: 0, path: <int>[]);
}
if (raw < 0x40) {
// Hash size 1: the encoded path is already one byte per hop.
final hopCount = raw;
final available = math.min(hopCount, path.length);
return LegacyRawPath(
length: hopCount,
path: Uint8List.fromList(path.sublist(0, available)),
);
}
final hopCount = raw & 0x3F;
final hashSize = ((raw >> 6) & 0x03) + 1;
final legacy = Uint8List(hopCount);
for (var hop = 0; hop < hopCount; hop++) {
final srcIndex = hop * hashSize;
if (srcIndex < path.length) {
legacy[hop] = path[srcIndex];
}
}
return LegacyRawPath(length: hopCount, path: legacy);
}
}
/// Legacy raw-transport path: a literal hop count plus one byte per hop, as
/// expected by the firmware `CMD_SEND_RAW_DATA` handler.
class LegacyRawPath {
const LegacyRawPath({required this.length, required this.path});
final int length;
final List<int> path;
} }
extension ContactLocalization on Contact { extension ContactLocalization on Contact {

View File

@@ -1833,15 +1833,23 @@ class ConnectionProvider with ChangeNotifier {
/// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25). /// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25).
/// Only works for contacts with a known direct route (outPathLen >= 0). /// Only works for contacts with a known direct route (outPathLen >= 0).
///
/// Callers pass the contact's *encoded* route descriptor ([routeEncodedPathLen])
/// and encoded [outPath]. The `CMD_SEND_RAW_DATA` handler in companion firmware
/// v1.15 misinterprets encoded descriptors as a literal path-byte count, so we
/// normalise to the legacy raw path format (literal hop count + one byte per
/// hop) before handing the frame to the transport. See
/// [ContactRouteCodec.toLegacyRawPath].
Future<void> sendRawVoicePacket({ Future<void> sendRawVoicePacket({
required Uint8List contactPath, required Uint8List contactPath,
required int contactPathLen, required int contactPathLen,
required Uint8List payload, required Uint8List payload,
}) async { }) async {
if (!_activeService.isConnected) return; if (!_activeService.isConnected) return;
final legacy = ContactRouteCodec.toLegacyRawPath(contactPathLen, contactPath);
await _activeService.sendRawVoicePacket( await _activeService.sendRawVoicePacket(
contactPathLen: contactPathLen, contactPathLen: legacy.length,
contactPath: contactPath, contactPath: Uint8List.fromList(legacy.path),
payload: payload, payload: payload,
); );
} }

View File

@@ -9,6 +9,11 @@ typedef RawPacketSender =
required Uint8List payload, required Uint8List payload,
}); });
/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of
/// raw packets with `ERROR: Table full` when fragments are blasted too quickly,
/// so we space them out to keep the radio's raw transmit queue from overflowing.
const Duration _interFragmentDelay = Duration(milliseconds: 350);
Future<bool> serveCachedSessionFragments<T>({ Future<bool> serveCachedSessionFragments<T>({
required String providerLabel, required String providerLabel,
required String sessionId, required String sessionId,
@@ -19,6 +24,7 @@ Future<bool> serveCachedSessionFragments<T>({
required Uint8List Function(T fragment) encodeBinary, required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket, required RawPacketSender? sendRawPacket,
Set<int>? requestedIndices, Set<int>? requestedIndices,
Duration interFragmentDelay = _interFragmentDelay,
}) async { }) async {
if (fragments.isEmpty) { if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId'); debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
@@ -56,6 +62,9 @@ Future<bool> serveCachedSessionFragments<T>({
continue; continue;
} }
try { try {
if (servedCount > 0 && interFragmentDelay > Duration.zero) {
await Future<void>.delayed(interFragmentDelay);
}
await sendRawPacket( await sendRawPacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.routeEncodedPathLen, contactPathLen: requester.routeEncodedPathLen,

View File

@@ -114,4 +114,59 @@ void main() {
expect(contact.routeSummary, 'Flood/Unknown'); expect(contact.routeSummary, 'Flood/Unknown');
}); });
}); });
group('ContactRouteCodec.toLegacyRawPath', () {
test('zero-hop direct (0x40) → empty legacy path', () {
final legacy = ContactRouteCodec.toLegacyRawPath(0x40, Uint8List(0));
expect(legacy.length, 0);
expect(legacy.path, isEmpty);
});
test('1-hop 2-byte hash (0x41) → first byte of the hash', () {
final legacy = ContactRouteCodec.toLegacyRawPath(
0x41,
Uint8List.fromList([0x02, 0x62]),
);
expect(legacy.length, 1);
expect(legacy.path, [0x02]);
});
test('2-hop 2-byte hash (0x42) → first byte of each hash', () {
final legacy = ContactRouteCodec.toLegacyRawPath(
0x42,
Uint8List.fromList([0xAA, 0xBB, 0xCC, 0xDD]),
);
expect(legacy.length, 2);
expect(legacy.path, [0xAA, 0xCC]);
});
test('signed 3-byte descriptor (-126 / 0x82) down-samples each hop', () {
final outPath = Uint8List(64)
..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
final legacy = ContactRouteCodec.toLegacyRawPath(-126, outPath);
expect(legacy.length, 2);
expect(legacy.path, [0xAA, 0xDD]);
});
test('legacy 1-byte hops (< 0x40) pass through trimmed', () {
final legacy = ContactRouteCodec.toLegacyRawPath(
0x03,
Uint8List.fromList([0xAA, 0xBB, 0xCC, 0x00, 0x00]),
);
expect(legacy.length, 3);
expect(legacy.path, [0xAA, 0xBB, 0xCC]);
});
test('unknown descriptor (0xFF) collapses to direct', () {
final legacy = ContactRouteCodec.toLegacyRawPath(-1, Uint8List(0));
expect(legacy.length, 0);
expect(legacy.path, isEmpty);
});
});
} }