mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-11 18:26:27 +10:00
Add ability to send images over lora
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user