mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-16 13:50:48 +10:00
Merge branch 'dev' into update-pacox-multibyte
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:meshcore_open/storage/region_store.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
@@ -35,6 +36,7 @@ import 'meshcore_connector_tcp.dart';
|
||||
import '../storage/channel_message_store.dart';
|
||||
import '../storage/channel_order_store.dart';
|
||||
import '../storage/channel_settings_store.dart';
|
||||
import '../storage/channel_region_store.dart';
|
||||
import '../storage/channel_store.dart';
|
||||
import '../storage/contact_discovery_store.dart';
|
||||
import '../storage/contact_settings_store.dart';
|
||||
@@ -301,6 +303,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
// Serializes path operations (setContactPath/clearContactPath) to prevent
|
||||
// interleaved async calls from leaving in-memory state inconsistent with device.
|
||||
Future<void> _pathOpLock = Future.value();
|
||||
// Flood scope is a global firmware setting, so scoped channel sends must not
|
||||
// overlap or a message may inherit another channel's region.
|
||||
Future<void> _channelScopedSendLock = Future.value();
|
||||
static const Duration _commandAckTimeout = Duration(seconds: 5);
|
||||
Map<String, String>? _currentCustomVars;
|
||||
|
||||
/// Maps repeater pubkey-prefix hex (12 hex chars = first 6 bytes) → the
|
||||
@@ -332,6 +338,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
final MessageStore _messageStore = MessageStore();
|
||||
final ChannelOrderStore _channelOrderStore = ChannelOrderStore();
|
||||
final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore();
|
||||
final ChannelRegionStore _channelRegionStore = ChannelRegionStore();
|
||||
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
|
||||
final ContactStore _contactStore = ContactStore();
|
||||
final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore();
|
||||
@@ -341,6 +348,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
final Map<int, bool> _channelSmazEnabled = {};
|
||||
final Map<int, bool> _channelCyr2LatEnabled = {};
|
||||
final Map<int, String?> _channelCyr2LatProfileId = {};
|
||||
final Map<int, Region> _channelRegions = {};
|
||||
bool _lastSentWasCliCommand =
|
||||
false; // Track if last sent message was a CLI command
|
||||
final Map<String, bool> _contactSmazEnabled = {};
|
||||
@@ -739,6 +747,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
return _contactSmazEnabled[contactKeyHex] ?? false;
|
||||
}
|
||||
|
||||
bool hasChannelRegion(int channelIndex) {
|
||||
return (_channelRegions[channelIndex] ?? '').isNotEmpty;
|
||||
}
|
||||
|
||||
Region getChannelRegion(int channelIndex) {
|
||||
return _channelRegions[channelIndex] ?? '';
|
||||
}
|
||||
|
||||
void ensureContactSmazSettingLoaded(String contactKeyHex) {
|
||||
_ensureContactSmazSettingLoaded(contactKeyHex);
|
||||
}
|
||||
@@ -892,6 +908,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setChannelRegion(int channelIndex, String region) async {
|
||||
// Update in-memory state and notify synchronously so the UI reflects the
|
||||
// change immediately; persistence happens in the background.
|
||||
_channelRegions[channelIndex] = region;
|
||||
notifyListeners();
|
||||
await _channelRegionStore.saveRegion(channelIndex, region);
|
||||
}
|
||||
|
||||
Future<void> _loadChannelOrder() async {
|
||||
_channelOrder = await _channelOrderStore.loadChannelOrder();
|
||||
_applyChannelOrder();
|
||||
@@ -1050,11 +1074,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Future<void> loadChannelSettings({int? maxChannels}) async {
|
||||
_channelSmazEnabled.clear();
|
||||
_channelCyr2LatEnabled.clear();
|
||||
_channelRegions.clear();
|
||||
final channelCount = maxChannels ?? _maxChannels;
|
||||
for (int i = 0; i < channelCount; i++) {
|
||||
_channelSmazEnabled[i] = await _channelSettingsStore.loadSmazEnabled(i);
|
||||
_channelCyr2LatEnabled[i] = await _channelSettingsStore
|
||||
.loadCyr2LatEnabled(i);
|
||||
_channelRegions[i] = await _channelRegionStore.loadRegion(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3464,12 +3490,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
// Send the reaction to the device (don't add as a visible message)
|
||||
final reactionQueueId = _nextReactionSendQueueId();
|
||||
_pendingChannelSentQueue.add(reactionQueueId);
|
||||
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
|
||||
await sendFrame(
|
||||
buildSendChannelTextMsgFrame(channel.index, text),
|
||||
channelSendQueueId: reactionQueueId,
|
||||
expectsGenericAck: true,
|
||||
);
|
||||
await _runScopedChannelSend(() async {
|
||||
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
|
||||
await _sendFrameAndWaitForCommandAck(
|
||||
buildSendChannelTextMsgFrame(channel.index, text),
|
||||
channelSendQueueId: reactionQueueId,
|
||||
expectsGenericAck: true,
|
||||
successCode: respCodeSent,
|
||||
);
|
||||
}, region: getChannelRegion(channel.index));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3486,12 +3515,97 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
final outboundText = prepareChannelOutboundText(channel.index, text);
|
||||
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
|
||||
await sendFrame(
|
||||
buildSendChannelTextMsgFrame(channel.index, outboundText),
|
||||
channelSendQueueId: message.messageId,
|
||||
expectsGenericAck: true,
|
||||
);
|
||||
await _runScopedChannelSend(() async {
|
||||
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
|
||||
await _sendFrameAndWaitForCommandAck(
|
||||
buildSendChannelTextMsgFrame(channel.index, outboundText),
|
||||
channelSendQueueId: message.messageId,
|
||||
expectsGenericAck: true,
|
||||
successCode: respCodeSent,
|
||||
);
|
||||
}, region: getChannelRegion(channel.index));
|
||||
}
|
||||
|
||||
Future<void> _runScopedChannelSend(
|
||||
Future<void> Function() action, {
|
||||
required String region,
|
||||
}) async {
|
||||
final prev = _channelScopedSendLock;
|
||||
final completer = Completer<void>();
|
||||
_channelScopedSendLock = completer.future;
|
||||
await prev;
|
||||
|
||||
try {
|
||||
// Only touch the global flood scope for region-scoped channels. Plain
|
||||
// channels send exactly as before, which also stays compatible with
|
||||
// firmware that predates CMD_SET_FLOOD_SCOPE. The lock is still held so an
|
||||
// unscoped send can't interleave with (and inherit the scope of) a
|
||||
// concurrent scoped send.
|
||||
if (region.isEmpty) {
|
||||
await action();
|
||||
return;
|
||||
}
|
||||
await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame(region));
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (isConnected) {
|
||||
await _sendFrameAndWaitForCommandAck(buildSetFloodScopeFrame(''));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
// Sends [data] and resolves once the device replies. [successCode] is the
|
||||
// response code that signals success for this frame: SET_FLOOD_SCOPE replies
|
||||
// with RESP_CODE_OK, whereas a channel text send replies with RESP_CODE_SENT.
|
||||
// Waiting for the text send's RESP_CODE_SENT before the scope is reset
|
||||
// guarantees the firmware has already built the packet with the active scope.
|
||||
Future<void> _sendFrameAndWaitForCommandAck(
|
||||
Uint8List data, {
|
||||
String? channelSendQueueId,
|
||||
bool expectsGenericAck = false,
|
||||
int successCode = respCodeOk,
|
||||
}) async {
|
||||
final completer = Completer<void>();
|
||||
late final StreamSubscription<Uint8List> subscription;
|
||||
late final Timer timeout;
|
||||
|
||||
void complete() {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
}
|
||||
|
||||
void completeError(Object error) {
|
||||
if (!completer.isCompleted) completer.completeError(error);
|
||||
}
|
||||
|
||||
subscription = receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
if (frame[0] == successCode) {
|
||||
complete();
|
||||
} else if (frame[0] == respCodeErr) {
|
||||
final errCode = frame.length > 1 ? frame[1] : -1;
|
||||
completeError(Exception('Command failed with error code $errCode'));
|
||||
}
|
||||
});
|
||||
|
||||
timeout = Timer(_commandAckTimeout, () {
|
||||
completeError(TimeoutException('Command ACK timed out'));
|
||||
});
|
||||
|
||||
try {
|
||||
await sendFrame(
|
||||
data,
|
||||
channelSendQueueId: channelSendQueueId,
|
||||
expectsGenericAck: expectsGenericAck,
|
||||
);
|
||||
await completer.future;
|
||||
} finally {
|
||||
timeout.cancel();
|
||||
await subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeContact(Contact contact) async {
|
||||
@@ -4103,6 +4217,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
case pushCodePathUpdated:
|
||||
_handlePathUpdated(frame);
|
||||
break;
|
||||
case pushCodeControlData:
|
||||
// Optional feature-specific services listen to receivedFrames directly.
|
||||
break;
|
||||
case pushCodeLoginSuccess:
|
||||
_handleLoginSuccess(frame);
|
||||
break;
|
||||
@@ -4249,6 +4366,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_messageStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelRegionStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_contactStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
@@ -206,6 +207,8 @@ const int cmdSendTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
const int cmdSetCustomVar = 41;
|
||||
const int cmdSendBinaryReq = 50;
|
||||
const int cmdSetFloodScope = 54;
|
||||
const int cmdSendControlData = 55;
|
||||
const int cmdGetStats = 56;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdSetAutoAddConfig = 58;
|
||||
@@ -230,6 +233,12 @@ Uint8List buildTelemetryBinaryPayload() {
|
||||
return Uint8List.fromList([reqTypeGetTelemetry, 0x00, 0x00, 0x00, 0x00]);
|
||||
}
|
||||
|
||||
const int anonReqTypeRegions = 0x01;
|
||||
|
||||
// Control data sub-types used by MeshCore discovery packets.
|
||||
const int controlSubtypeDiscoverReq = 0x08;
|
||||
const int controlSubtypeDiscoverResp = 0x09;
|
||||
|
||||
// Repeater response codes
|
||||
const int respServerLoginOk = 0;
|
||||
|
||||
@@ -272,6 +281,7 @@ const int pushCodeTraceData = 0x89;
|
||||
const int pushCodeNewAdvert = 0x8A;
|
||||
const int pushCodeTelemetryResponse = 0x8B;
|
||||
const int pushCodeBinaryResponse = 0x8C;
|
||||
const int pushCodeControlData = 0x8E;
|
||||
|
||||
// Contact/advertisement types
|
||||
const int advTypeChat = 1;
|
||||
@@ -866,6 +876,67 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List buildSendControlDataFrame(Uint8List payload) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendControlData);
|
||||
writer.writeBytes(payload);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List buildDiscoveryRequestPayload(
|
||||
int tag, {
|
||||
bool prefixOnly = false,
|
||||
int typeMask = 1 << advTypeRepeater,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
// The high bit must be set for CMD_SEND_CONTROL_DATA; DISCOVER_REQ uses
|
||||
// subtype 0x8, with the low bit selecting short/full public keys in replies.
|
||||
writer.writeByte(
|
||||
(controlSubtypeDiscoverReq << 4) | (prefixOnly ? 0x01 : 0x00),
|
||||
);
|
||||
writer.writeByte(typeMask);
|
||||
writer.writeUInt32LE(tag);
|
||||
writer.writeUInt32LE(0); // since=0 asks nearby nodes for any recent advert.
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _reversePathByHop(Uint8List path, int pathHashWidth) {
|
||||
if (path.isEmpty) return Uint8List(0);
|
||||
final width = pathHashWidth.clamp(1, 4).toInt();
|
||||
if (path.length % width != 0) {
|
||||
return Uint8List.fromList(path.reversed.toList());
|
||||
}
|
||||
|
||||
final reversed = Uint8List(path.length);
|
||||
final hops = path.length ~/ width;
|
||||
for (var i = 0; i < hops; i++) {
|
||||
final from = (hops - 1 - i) * width;
|
||||
reversed.setRange(i * width, (i + 1) * width, path, from);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
// Build CMD_SEND_ANON_REQ frame.
|
||||
// Payload format for regions: [anon_req_type][reply_path_len][reply_path...].
|
||||
Uint8List buildSendAnonReqFrame(
|
||||
Uint8List repeaterPubKey, {
|
||||
required int requestType,
|
||||
Uint8List? replyPath,
|
||||
int replyHopCount = 0,
|
||||
int pathHashWidth = pathHashSize,
|
||||
}) {
|
||||
final width = pathHashWidth.clamp(1, 4).toInt();
|
||||
final path = replyPath ?? Uint8List(0);
|
||||
final encodedPathLen = ((width - 1) << 6) | (replyHopCount & 0x3F);
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendAnonReq);
|
||||
writer.writeBytes(repeaterPubKey);
|
||||
writer.writeByte(requestType);
|
||||
writer.writeByte(encodedPathLen);
|
||||
writer.writeBytes(_reversePathByHop(path, width));
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
//Build a trace request frame
|
||||
//[cmd][tag x4][auth x4][flag][payload]
|
||||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
|
||||
@@ -960,3 +1031,18 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
//Build CMD_SET_FLOOD_SCOPE
|
||||
// Format: [cmd][scope]
|
||||
Uint8List buildSetFloodScopeFrame(String region) {
|
||||
if (region == '') {
|
||||
// reset scope
|
||||
return Uint8List.fromList([cmdSetFloodScope, 0]);
|
||||
}
|
||||
|
||||
final name = region.startsWith('#') ? region : '#$region';
|
||||
final hash = crypto.sha256.convert(utf8.encode(name)).bytes;
|
||||
final scope = Uint8List.fromList(hash.sublist(0, 16));
|
||||
|
||||
return Uint8List.fromList([cmdSetFloodScope, 0, ...scope]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user