From 63fc6a06c4608adbc2a979c52071cfc3c49c307e Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 10 Aug 2026 02:53:50 -0400 Subject: [PATCH] feat(#550): monotonic channel-send timestamp + CoreScope message fields Unifies the previously-duplicated send timestamps (frame builder vs outgoing message each called now() separately) into one monotonic-per-channel value, so every channel send has a unique (ts, channel_idx) key. That is the client-side guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB determinism would otherwise make two same-second messages a wrong-hash query). Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524. Agent: QuietSnow (session 31eaba02) --- lib/connector/meshcore_connector.dart | 35 +++++++++++++++++-- lib/connector/meshcore_protocol.dart | 12 +++++-- lib/helpers/channel_send_timestamp.dart | 17 +++++++++ lib/models/channel_message.dart | 24 ++++++++++++- test/helpers/channel_send_timestamp_test.dart | 34 ++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 lib/helpers/channel_send_timestamp.dart create mode 100644 test/helpers/channel_send_timestamp_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 9ddc6e0..d4ec468 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -17,6 +17,7 @@ import '../models/message.dart'; import '../models/offband_gps_status.dart'; import '../models/path_selection.dart'; import '../models/translation_support.dart'; +import '../helpers/channel_send_timestamp.dart'; import '../helpers/pending_reactions.dart'; import '../helpers/pocketmesh_reaction.dart'; import '../helpers/reaction_helper.dart'; @@ -235,6 +236,10 @@ class MeshCoreConnector extends ChangeNotifier { final List _channels = []; final Map> _conversations = {}; final Map> _channelMessages = {}; + + /// Last send timestamp (seconds) used per channel, to keep every channel + /// send's `(ts, channel_idx)` key unique for 0xC6 correlation (#524). + final Map _lastChannelSendTsSecs = {}; final List _pendingChannelSentQueue = []; final List<_PendingCommandAck> _pendingGenericAckQueue = []; static const String _reactionSendQueuePrefix = '__reaction_send__'; @@ -3771,6 +3776,19 @@ class MeshCoreConnector extends ChangeNotifier { } } + /// A strictly-increasing per-channel send timestamp (seconds), so no two + /// channel sends share a `(ts, channel_idx)` key. Required for 0xC6 hash + /// correlation (#524); harmless otherwise. + int _nextChannelSendTimestampSecs(int channelIndex) { + final nowSecs = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final ts = monotonicChannelSendTs( + nowSecs, + _lastChannelSendTsSecs[channelIndex], + ); + _lastChannelSendTsSecs[channelIndex] = ts; + return ts; + } + Future sendChannelMessage( Channel channel, String text, { @@ -3815,7 +3833,11 @@ class MeshCoreConnector extends ChangeNotifier { await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); try { await sendFrame( - buildSendChannelTextMsgFrame(channel.index, text), + buildSendChannelTextMsgFrame( + channel.index, + text, + timestamp: _nextChannelSendTimestampSecs(channel.index), + ), channelSendQueueId: reactionQueueId, expectsGenericAck: true, ); @@ -3832,10 +3854,15 @@ class MeshCoreConnector extends ChangeNotifier { return; } + // One monotonic timestamp shared by the outgoing message and the frame, so + // the (ts, channel) key is unique and the client can correlate it to the + // firmware packet hash via 0xC6 (#524). + final sendTsSecs = _nextChannelSendTimestampSecs(channel.index); final message = ChannelMessage.outgoing( text, _selfName ?? 'Me', channel.index, + timestampSecs: sendTsSecs, originalText: originalText, translatedLanguageCode: translatedLanguageCode, translationModelId: translationModelId, @@ -3848,7 +3875,11 @@ class MeshCoreConnector extends ChangeNotifier { await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime); try { await sendFrame( - buildSendChannelTextMsgFrame(channel.index, outboundText), + buildSendChannelTextMsgFrame( + channel.index, + outboundText, + timestamp: sendTsSecs, + ), channelSendQueueId: message.messageId, expectsGenericAck: true, ); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index d092b30..b8c6a7f 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -958,13 +958,19 @@ Uint8List buildSendTextMsgFrame( // Build CMD_SEND_CHANNEL_TXT_MSG frame // Format: [cmd][txt_type][channel_idx][timestamp x4][text...] -Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) { - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; +// [timestamp] (seconds) may be supplied by the caller so it can correlate the +// send to the firmware packet hash (0xC6, #524); defaults to now. +Uint8List buildSendChannelTextMsgFrame( + int channelIndex, + String text, { + int? timestamp, +}) { + final ts = timestamp ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; final writer = BufferWriter(); writer.writeByte(cmdSendChannelTxtMsg); writer.writeByte(txtTypePlain); writer.writeByte(channelIndex); - writer.writeUInt32LE(timestamp); + writer.writeUInt32LE(ts); writer.writeString(text); writer.writeByte(0); return writer.toBytes(); diff --git a/lib/helpers/channel_send_timestamp.dart b/lib/helpers/channel_send_timestamp.dart new file mode 100644 index 0000000..07ac5a3 --- /dev/null +++ b/lib/helpers/channel_send_timestamp.dart @@ -0,0 +1,17 @@ +/// Returns a channel-send timestamp (Unix seconds) strictly greater than +/// [lastSecs] for the same channel, so no two sends on one channel ever share a +/// `(ts, channel_idx)` key. +/// +/// This is a correctness requirement for the 0xC6 packet-hash correlation +/// (#524/#611): `msg_timestamp` is only second-resolution and MeshCore channel +/// encryption is deterministic (AES-128-ECB), so two different messages on the +/// same channel within one second would otherwise map to the same key but +/// different hashes, and the client would query CoreScope with the wrong hash. +/// +/// Uses [nowSecs] unless it would collide with or precede [lastSecs], in which +/// case it bumps to `lastSecs + 1`. Mirrors the firmware's own +/// `getCurrentTimeUnique()`. +int monotonicChannelSendTs(int nowSecs, int? lastSecs) { + if (lastSecs != null && nowSecs <= lastSecs) return lastSecs + 1; + return nowSecs; +} diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 092f00a..a7de3a3 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -46,6 +46,16 @@ class ChannelMessage { final int? channelIndex; final String messageId; final String? packetHash; + + /// Firmware-reported on-air packet hash for an outgoing channel message + /// (from the 0xC6 query, #524/#611). Distinct from [packetHash], which for + /// received messages is the content hash used for repeat dedup. Used only to + /// query CoreScope for observer counts. Transient (not persisted). + final String? onAirHash; + + /// Unique observers CoreScope reports for this message's on-air packet + /// (#524). Null until queried / when the feature is off. Transient. + final int? coreScopeObserverCount; final String? replyToMessageId; final String? replyToSenderName; final String? replyToText; @@ -84,6 +94,8 @@ class ChannelMessage { this.channelIndex, String? messageId, this.packetHash, + this.onAirHash, + this.coreScopeObserverCount, this.replyToMessageId, this.replyToSenderName, this.replyToText, @@ -130,6 +142,8 @@ class ChannelMessage { Uint8List? pathBytes, List? pathVariants, String? packetHash, + String? onAirHash, + int? coreScopeObserverCount, String? replyToMessageId, String? replyToSenderName, String? replyToText, @@ -170,6 +184,9 @@ class ChannelMessage { channelIndex: channelIndex, messageId: messageId, packetHash: packetHash ?? this.packetHash, + onAirHash: onAirHash ?? this.onAirHash, + coreScopeObserverCount: + coreScopeObserverCount ?? this.coreScopeObserverCount, replyToMessageId: replyToMessageId ?? this.replyToMessageId, replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToText: replyToText ?? this.replyToText, @@ -261,6 +278,7 @@ class ChannelMessage { String text, String senderName, int channelIndex, { + int? timestampSecs, String? originalText, String? translatedLanguageCode, String? translationModelId, @@ -272,7 +290,11 @@ class ChannelMessage { originalText: originalText, translatedLanguageCode: translatedLanguageCode, translationModelId: translationModelId, - timestamp: DateTime.now(), + // When supplied, match the exact seconds put in the send frame so the + // 0xC6 (ts, channel) key correlates (#524); else fall back to now. + timestamp: timestampSecs != null + ? DateTime.fromMillisecondsSinceEpoch(timestampSecs * 1000) + : DateTime.now(), isOutgoing: true, status: ChannelMessageStatus.pending, pathLength: null, diff --git a/test/helpers/channel_send_timestamp_test.dart b/test/helpers/channel_send_timestamp_test.dart new file mode 100644 index 0000000..1cd3b0a --- /dev/null +++ b/test/helpers/channel_send_timestamp_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/channel_send_timestamp.dart'; + +void main() { + group('monotonicChannelSendTs', () { + test('first send on a channel uses now', () { + expect(monotonicChannelSendTs(1000, null), 1000); + }); + + test('bumps by 1 on a same-second collision', () { + expect(monotonicChannelSendTs(1000, 1000), 1001); + }); + + test('uses now when it has advanced past the last send', () { + expect(monotonicChannelSendTs(1005, 1000), 1005); + }); + + test('bumps past the last send if the clock went backwards', () { + expect(monotonicChannelSendTs(1000, 1005), 1006); + }); + + test('a rapid burst stays strictly increasing', () { + int? last; + final produced = []; + // Same wall-clock second for the whole burst. + for (var i = 0; i < 5; i++) { + last = monotonicChannelSendTs(1000, last); + produced.add(last); + } + expect(produced, [1000, 1001, 1002, 1003, 1004]); + expect(produced.toSet().length, produced.length); // all unique + }); + }); +}