Add ability to send images over lora

This commit is contained in:
Zach
2026-08-10 23:20:46 -07:00
parent 736eb064bf
commit eee72b4c65
129 changed files with 27099 additions and 58 deletions
+158
View File
@@ -24,6 +24,16 @@ import '../services/ble_debug_log_service.dart';
import '../services/linux_ble_error_classifier.dart';
import '../services/linux_ble_pairing_service_stub.dart'
if (dart.library.io) '../services/linux_ble_pairing_service.dart';
import '../services/image_chunk_transport.dart'
show
ImageChunkOutcome,
ImageChunkTransport,
buildSendChannelDataFrame,
dataTypeAeicImage,
outPathUnknown,
respCodeChannelDataRecv,
senderPrefixFromKey;
import '../services/image_codec_service.dart';
import '../services/message_retry_service.dart';
import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
@@ -248,6 +258,13 @@ class MeshCoreConnector extends ChangeNotifier {
bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService;
TranslationService? _translationService;
ImageCodecService? _imageCodecService;
// Holds the ImageReassembler, so partially received images survive across
// frames (chunks of one image arrive in separate GRP_DATA frames, and via the
// firmware offline queue they may be minutes apart).
ImageChunkTransport? _imageTransport;
void Function(ImageChunkOutcome outcome)? _onImageChunk;
void Function(int senderPrefix)? _onImageSenderPrefix;
// Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now();
@@ -460,6 +477,11 @@ class MeshCoreConnector extends ChangeNotifier {
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
Uint8List? get selfPublicKey => _selfPublicKey;
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
/// First 2 bytes of the local public key, big-endian: the `senderPrefix`
/// stamped into every outgoing image chunk header. Null until SELF_INFO.
int? get imageSenderPrefix => senderPrefixFromKey(_selfPublicKey);
String? get selfName => _selfName;
String? get manufacturerName => _manufacturerName;
String? get firmwareVersionString => _firmwareVersionString;
@@ -996,11 +1018,19 @@ class MeshCoreConnector extends ChangeNotifier {
AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
ImageCodecService? imageCodecService,
ImageChunkTransport? imageTransport,
void Function(ImageChunkOutcome outcome)? onImageChunk,
void Function(int senderPrefix)? onImageSenderPrefix,
}) {
_retryService = retryService;
_pathHistoryService = pathHistoryService;
_appSettingsService = appSettingsService;
_translationService = translationService;
_imageCodecService = imageCodecService;
_imageTransport = imageTransport;
_onImageChunk = onImageChunk;
_onImageSenderPrefix = onImageSenderPrefix;
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
@@ -2575,6 +2605,8 @@ class MeshCoreConnector extends ChangeNotifier {
void _resetConnectionHandshakeState() {
_selfPublicKey = null;
// Partially received images belong to the previous session's sender prefix.
_imageTransport?.reassembler.clear();
_selfName = null;
_selfLatitude = null;
_selfLongitude = null;
@@ -2718,6 +2750,19 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncTimeout = null;
_channelSyncRetries = 0;
await _translationService?.releaseModel();
// The decode graph is ~833 MB resident; drop it with the connection. The
// connector never disposes services it does not own (main.dart does).
//
// Cancel first: `releaseModel` serialises behind the codec's exclusive
// lock, so disconnecting mid-decode would otherwise block this teardown
// for the ~1 s the inference takes and then tear the session out from
// under a job whose result nobody is waiting for any more.
_imageCodecService?.cancelCodecJob();
await _imageCodecService?.releaseModel();
// Partial images belong to this session's sender prefixes; a reconnect
// starts over. Dropping them also stops `evictExpired` from firing "image
// incomplete" failures into the store minutes after the radio went away.
_imageTransport?.reassembler.clear();
if (!skipBleDeviceDisconnect) {
try {
@@ -3550,6 +3595,77 @@ class MeshCoreConnector extends ChangeNotifier {
}, region: getChannelRegion(channel.index));
}
/// Minimum companion firmware version code that implements
/// CMD_SEND_CHANNEL_DATA (62) / RESP_CODE_CHANNEL_DATA_RECV (27).
static const int _minFirmwareVerCodeForChannelData = 13;
/// True when the connected device can send/receive GRP_DATA blobs.
bool get supportsChannelData =>
isConnected &&
(_firmwareVerCode ?? 0) >= _minFirmwareVerCodeForChannelData;
/// Sends one image's chunk [blobs] as GRP_DATA packets on [channelIndex],
/// strictly sequentially, inside a single scoped-send scope for the whole
/// image (the flood scope is global, so re-entering it per chunk would let
/// another channel's send interleave mid-image).
///
/// [onProgress] fires after each chunk is acked, so the UI can show
/// "sending 2 of 3".
///
/// IMPORTANT: the command ACK only proves the firmware accepted the blob and
/// queued a packet to the radio. It says nothing about transmission or
/// delivery — GRP_DATA is unacknowledged flood traffic, there is no
/// PUSH_CODE_SEND_CONFIRMED for it, and a chunk may still be lost on air.
/// That is what the XOR parity chunk is for.
///
/// Returns false when the transport is unavailable (disconnected or firmware
/// too old); throws whatever [_sendFrameAndWaitForCommandAck] throws, i.e.
/// Exception('Command failed with error code N') where N is 1
/// (unsupported command), 2 (bad channel index), 3 (packet pool full, worth
/// retrying) or 4 (illegal arg — blob too long), or a TimeoutException after
/// _commandAckTimeout.
Future<bool> sendImageChunks(
List<Uint8List> blobs, {
required int channelIndex,
required Duration interChunkDelay,
void Function(int sent, int total)? onProgress,
}) async {
if (blobs.isEmpty) return false;
if (!supportsChannelData) return false;
// `return` inside the scoped callback only exits the callback, so a
// disconnect mid-send used to fall through to `return true` below and the
// caller would record a complete outgoing image and tell the user "sent"
// after transmitting a prefix of the chunks. Track it explicitly.
var sentAll = true;
await _runScopedChannelSend(() async {
for (var i = 0; i < blobs.length; i++) {
if (!isConnected) {
sentAll = false;
return;
}
if (i > 0) await Future<void>.delayed(interChunkDelay);
// Same collision avoidance as every other channel send. On a quiet mesh
// this returns almost immediately; interChunkDelay is what actually
// paces the chunks so we do not talk over our own previous packet.
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
await _sendFrameAndWaitForCommandAck(
buildSendChannelDataFrame(
channelIndex: channelIndex,
dataType: dataTypeAeicImage,
payload: blobs[i],
pathLen: outPathUnknown, // 0xFF => flood
),
// Code 62 must not be enrolled in the generic-ack queue, and the
// firmware replies with RESP_CODE_OK (writeOKFrame), not RESP_CODE_SENT.
expectsGenericAck: false,
successCode: respCodeOk,
);
onProgress?.call(i + 1, blobs.length);
}
}, region: getChannelRegion(channelIndex));
return sentAll;
}
Future<void> _runScopedChannelSend(
Future<void> Function() action, {
required String region,
@@ -4231,6 +4347,27 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeChannelMsgRecvV3:
_handleIncomingChannelMessage(frame);
break;
case respCodeChannelDataRecv:
// RESP_CODE 27 (GRP_DATA received) is delivered through the firmware
// offline queue in reply to CMD_SYNC_NEXT_MESSAGE, so the queued-message
// sync state machine MUST be advanced here — exactly like
// _handleIncomingChannelMessage does. Leaving code 27 unhandled stalls
// _queueSyncTimeout (5 s) x _maxQueueSyncRetries (3) per chunk and
// blocks every other queued message behind it. A receivedFrames
// listener cannot do this: the sync state is private. Advance exactly
// ONCE, before any async work.
if (_isSyncingQueuedMessages) {
_handleQueuedMessageReceived();
}
// Advance the sync state machine ALWAYS (above), but only reassemble
// when the feature is actually on. Without this the "disabled" feature
// still reassembles, persists and displays received images: the store
// keeps them and channel_chat_screen renders every stored entry, so a
// user who never enabled image messages would see them appear.
if (_appSettingsService?.settings.imageMessagesEnabled ?? false) {
_handleIncomingChannelData(frame);
}
break;
case respCodeSent:
_handleMessageSent(frame);
break;
@@ -4369,6 +4506,14 @@ class MeshCoreConnector extends ChangeNotifier {
_currentCr = reader.readByte();
_selfName = reader.readCString();
// The local public key only exists once SELF_INFO has arrived, so the
// image sender prefix (first 2 bytes, big-endian) is refreshed here.
final prefix = senderPrefixFromKey(_selfPublicKey);
if (prefix != null) {
_imageTransport?.senderPrefix = prefix;
_onImageSenderPrefix?.call(prefix);
}
} catch (e) {
_appDebugLogService?.error(
'Error parsing SELF_INFO frame: $e',
@@ -5493,6 +5638,19 @@ class MeshCoreConnector extends ChangeNotifier {
}());
}
/// Handles RESP_CODE_CHANNEL_DATA_RECV (GRP_DATA). Frames whose dataType is
/// not 0xAE1C — or that arrive before an [ImageChunkTransport] is installed —
/// are ignored without disturbing any other behaviour.
void _handleIncomingChannelData(Uint8List frame) {
// Anchor the post-RX backoff for our own next channel send, exactly as
// _handleIncomingChannelMessage does.
_lastChannelMsgRxTime = DateTime.now();
final outcome = _imageTransport?.handleFrame(frame);
if (outcome == null) return; // Not an AEIC image chunk.
_onImageChunk?.call(outcome);
notifyListeners();
}
void _handleIncomingChannelMessage(Uint8List frame) async {
// If we're syncing the queued messages, advance the queue immediately
// before any potentially long async work (like translation/notifications).