feat(#550): CoreScope orchestration + Experimental toggle + observer badge

After a channel send, when firmware advertises 0xC6 (cap2 0x08 + ver>=22) AND
the owner enabled the feature, the connector queries the packet hash, then
CoreScope for observer_count, and stamps it on the message (background,
best-effort). Adds the coreScopeObserverCountEnabled AppSettings flag, a gated
switch in the #509 Experimental section (disabled until the radio supports the
capability), and a distinct cloud badge next to the radio-heard count. All
inert until the firmware PR (#611) lands. Completes the client side of #524.

Agent: QuietSnow (session 31eaba02)
pull/556/head
Strycher 2 months ago
parent 63fc6a06c4
commit 2919329265

@ -19,6 +19,7 @@ import '../models/path_selection.dart';
import '../models/translation_support.dart';
import '../helpers/channel_send_timestamp.dart';
import '../helpers/pending_reactions.dart';
import '../services/corescope_service.dart';
import '../helpers/pocketmesh_reaction.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/time_anomaly.dart';
@ -296,6 +297,11 @@ class MeshCoreConnector extends ChangeNotifier {
bool _caplogAwaitingStart = false;
Completer<CaplogAck>? _caplogAckCompleter;
Completer<CaplogDeviceStatus>? _caplogStatusCompleter;
/// Outstanding 0xC6 packet-hash queries, keyed "ts_chan" so a reply matches
/// its request without relying on ordering (#524).
final Map<String, Completer<OffbandPktHash?>> _pendingPktHashCompleters = {};
final CoreScopeService _coreScopeService = CoreScopeService();
int _caplogChunks = 0; // CHUNK frames in the active download (diagnostic)
String? _firmwareVersion;
String? _deviceModel;
@ -631,6 +637,16 @@ class MeshCoreConnector extends ChangeNotifier {
/// Whether this radio exposes a settable device notification scope (#475).
bool get supportsNotifyScope => firmwareSupportsNotifyScope(_offbandCaps2);
/// Whether this radio answers the 0xC6 packet-hash query (#524/#611).
bool get supportsPktHash =>
firmwareSupportsPktHash(_offbandCaps2, _firmwareVerCode);
/// Whether to fetch CoreScope observer counts for outgoing channel messages:
/// firmware supports the hash query AND the owner enabled the feature.
bool get _coreScopeQueryActive =>
supportsPktHash &&
(_appSettingsService?.settings.coreScopeObserverCountEnabled ?? false);
/// Whether the radio can actually be QUERIED, as opposed to merely reporting
/// that it has the feature. False until firmware lands the get/set command,
/// so no frame is emitted that nothing will answer. The distinction is real
@ -3883,6 +3899,17 @@ class MeshCoreConnector extends ChangeNotifier {
channelSendQueueId: message.messageId,
expectsGenericAck: true,
);
// Owner-only CoreScope observer count (#524): background, gated, and
// best-effort. Never blocks or fails the send.
if (_coreScopeQueryActive) {
unawaited(
_fetchAndStoreCoreScopeCount(
channel.index,
sendTsSecs,
message.messageId,
),
);
}
} catch (e) {
// Clear the stuck queue id and surface the failure in the chat bubble
// instead of leaving the message pending and blocking the queue (#395).
@ -4679,6 +4706,90 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// Ask firmware for the on-air packet hash of a channel message we sent,
/// keyed by (ts, chan) (0xC6, #524). Returns null on timeout / error frame.
Future<OffbandPktHash?> _queryPacketHash(
int ts,
int chan, {
Duration timeout = const Duration(seconds: 5),
}) async {
final key = '${ts}_$chan';
final completer = Completer<OffbandPktHash?>();
_pendingPktHashCompleters[key] = completer;
final timer = Timer(timeout, () {
if (!completer.isCompleted) completer.complete(null);
});
try {
await sendFrame(buildOffbandPktHashGetFrame(ts, chan));
return await completer.future;
} catch (e) {
appLogger.warn('0xC6 query failed for $key: $e', tag: 'CoreScope');
return null;
} finally {
timer.cancel();
_pendingPktHashCompleters.remove(key);
}
}
/// Routes a 0xC6 reply to its waiting query. Success replies echo the key;
/// error/malformed frames are logged and left for the query to time out (the
/// error frame carries no key, so it cannot be correlated).
void _handleOffbandPktHashFrame(Uint8List frame) {
final parsed = parseOffbandPktHashReply(frame);
if (parsed == null) {
appLogger.warn('0xC6 error or malformed reply', tag: 'CoreScope');
return;
}
final key = '${parsed.timestamp}_${parsed.channelIdx}';
final completer = _pendingPktHashCompleters[key];
if (completer != null && !completer.isCompleted) {
completer.complete(parsed);
}
}
/// After sending a channel message, fetch its firmware hash then CoreScope's
/// observer count and stamp both on the message. Best-effort and gated; any
/// failure leaves the message showing radio-only.
Future<void> _fetchAndStoreCoreScopeCount(
int channelIndex,
int ts,
String messageId,
) async {
try {
final pkt = await _queryPacketHash(ts, channelIndex);
if (pkt == null) return;
_updateChannelMessageById(
channelIndex,
messageId,
(m) => m.copyWith(onAirHash: pkt.hashHex),
);
final count = await _coreScopeService.fetchObserverCount(pkt.hashHex);
if (count == null) return;
_updateChannelMessageById(
channelIndex,
messageId,
(m) => m.copyWith(coreScopeObserverCount: count),
);
notifyListeners();
} catch (e) {
appLogger.warn(
'CoreScope observer-count fetch failed: $e',
tag: 'CoreScope',
);
}
}
void _updateChannelMessageById(
int channelIndex,
String messageId,
ChannelMessage Function(ChannelMessage) transform,
) {
final messages = _channelMessages[channelIndex];
if (messages == null) return;
final i = messages.indexWhere((m) => m.messageId == messageId);
if (i >= 0) messages[i] = transform(messages[i]);
}
/// Called when a BLOCK_LIST dump ends. A truncated dump (early-END) is
/// re-requested once the link settles; never derive removals from a partial
/// pull. The union reconcile against the local list is wired in B4.
@ -4871,6 +4982,9 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeOffbandCaplog:
_handleOffbandCaplogFrame(frame);
break;
case cmdOffbandPktHash:
_handleOffbandPktHashFrame(frame);
break;
case respCodeOffbandDeviceUi:
_handleDeviceUiReply(frame);
break;

@ -218,6 +218,9 @@
"settings_experimental": "Experimental",
"settings_experimentalSubtitle": "Advanced and in-development features",
"settings_experimentalDescription": "These features are experimental and may change or be removed at any time. They default to off.",
"settings_coreScopeObserverCount": "CoreScope observer counts",
"settings_coreScopeObserverCountSubtitle": "On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.",
"settings_coreScopeObserverCountUnsupported": "Requires a firmware build with packet-hash support (v22+).",
"settings_experimentalHide": "Hide Experimental section",
"settings_experimentalHideSubtitle": "Unlock again anytime with 7 taps on the version row.",
"settings_experimentalUnlocked": "Experimental features unlocked",
@ -873,6 +876,14 @@
}
}
},
"channel_coreScopeTooltip": "Seen by {count, plural, =1{1 CoreScope observer} other{{count} CoreScope observers}}",
"@channel_coreScopeTooltip": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"chat_successes": "successes",
"chat_score": "Score",
"chat_removePath": "Remove path",

@ -1108,6 +1108,24 @@ abstract class AppLocalizations {
/// **'These features are experimental and may change or be removed at any time. They default to off.'**
String get settings_experimentalDescription;
/// No description provided for @settings_coreScopeObserverCount.
///
/// In en, this message translates to:
/// **'CoreScope observer counts'**
String get settings_coreScopeObserverCount;
/// No description provided for @settings_coreScopeObserverCountSubtitle.
///
/// In en, this message translates to:
/// **'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.'**
String get settings_coreScopeObserverCountSubtitle;
/// No description provided for @settings_coreScopeObserverCountUnsupported.
///
/// In en, this message translates to:
/// **'Requires a firmware build with packet-hash support (v22+).'**
String get settings_coreScopeObserverCountUnsupported;
/// No description provided for @settings_experimentalHide.
///
/// In en, this message translates to:
@ -3064,6 +3082,12 @@ abstract class AppLocalizations {
/// **'Heard {count, plural, =1{1 repeat} other{{count} repeats}}'**
String channel_heardTooltip(int count);
/// No description provided for @channel_coreScopeTooltip.
///
/// In en, this message translates to:
/// **'Seen by {count, plural, =1{1 CoreScope observer} other{{count} CoreScope observers}}'**
String channel_coreScopeTooltip(int count);
/// No description provided for @chat_successes.
///
/// In en, this message translates to:

@ -551,6 +551,17 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1691,6 +1702,17 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Успехи';

@ -548,6 +548,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1689,6 +1700,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Erfolgreich';

@ -539,6 +539,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1659,6 +1670,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'successes';

@ -549,6 +549,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1686,6 +1697,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Éxitos';

@ -553,6 +553,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1693,6 +1704,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Succès';

@ -551,6 +551,17 @@ class AppLocalizationsHu extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1698,6 +1709,17 @@ class AppLocalizationsHu extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'sikerek';

@ -551,6 +551,17 @@ class AppLocalizationsIt extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1688,6 +1699,17 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'successi';

@ -525,6 +525,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1623,6 +1634,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => '成功事例';

@ -525,6 +525,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1619,6 +1630,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => '성공 사례';

@ -545,6 +545,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1676,6 +1687,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Succesvol';

@ -553,6 +553,17 @@ class AppLocalizationsPl extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1701,6 +1712,17 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Sukcesy';

@ -551,6 +551,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1685,6 +1696,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Sucessos';

@ -550,6 +550,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1689,6 +1700,17 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'успешно';

@ -545,6 +545,17 @@ class AppLocalizationsSk extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1678,6 +1689,17 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Úspechy';

@ -543,6 +543,17 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1674,6 +1685,17 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Uspešni';

@ -540,6 +540,17 @@ class AppLocalizationsSv extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1669,6 +1680,17 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'framgångar';

@ -547,6 +547,17 @@ class AppLocalizationsUk extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1684,6 +1695,17 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => 'Успішно';

@ -518,6 +518,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get settings_experimentalDescription =>
'These features are experimental and may change or be removed at any time. They default to off.';
@override
String get settings_coreScopeObserverCount => 'CoreScope observer counts';
@override
String get settings_coreScopeObserverCountSubtitle =>
'On outgoing channel messages, show how many CoreScope observers heard the packet, alongside radio-heard repeats. Needs internet.';
@override
String get settings_coreScopeObserverCountUnsupported =>
'Requires a firmware build with packet-hash support (v22+).';
@override
String get settings_experimentalHide => 'Hide Experimental section';
@ -1598,6 +1609,17 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Heard $_temp0';
}
@override
String channel_coreScopeTooltip(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count CoreScope observers',
one: '1 CoreScope observer',
);
return 'Seen by $_temp0';
}
@override
String get chat_successes => '成功';

@ -144,6 +144,11 @@ class AppSettings {
/// the version row (#509). Not a security boundary, just keeps advanced/
/// me-centric toggles out of the default UI. Persists across restarts.
final bool experimentalUnlocked;
/// Owner-only: show CoreScope-observed repeat counts on outgoing channel
/// messages (#524). Lives under the Experimental section, default off, and
/// only takes effect when the firmware supports the 0xC6 packet-hash query.
final bool coreScopeObserverCountEnabled;
final Map<String, String> batteryChemistryByDeviceId;
final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem;
@ -223,6 +228,7 @@ class AppSettings {
this.languageOverride,
this.appDebugLogEnabled = false,
this.experimentalUnlocked = false,
this.coreScopeObserverCountEnabled = false,
Map<String, String>? batteryChemistryByDeviceId,
Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric,
@ -290,6 +296,7 @@ class AppSettings {
'language_override': languageOverride,
'app_debug_log_enabled': appDebugLogEnabled,
'experimental_unlocked': experimentalUnlocked,
'corescope_observer_count_enabled': coreScopeObserverCountEnabled,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value,
@ -388,6 +395,8 @@ class AppSettings {
languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
experimentalUnlocked: json['experimental_unlocked'] as bool? ?? false,
coreScopeObserverCountEnabled:
json['corescope_observer_count_enabled'] as bool? ?? false,
batteryChemistryByDeviceId:
(json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
@ -506,6 +515,7 @@ class AppSettings {
Object? languageOverride = _unset,
bool? appDebugLogEnabled,
bool? experimentalUnlocked,
bool? coreScopeObserverCountEnabled,
Map<String, String>? batteryChemistryByDeviceId,
Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem,
@ -566,6 +576,8 @@ class AppSettings {
: languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
experimentalUnlocked: experimentalUnlocked ?? this.experimentalUnlocked,
coreScopeObserverCountEnabled:
coreScopeObserverCountEnabled ?? this.coreScopeObserverCountEnabled,
batteryChemistryByDeviceId:
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
batteryChemistryByRepeaterId:

@ -1517,6 +1517,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
),
],
// CoreScope observer count (#524), shown parallel to the radio-heard
// count with a distinct icon, never merged into one number.
if (isOutgoing && (message.coreScopeObserverCount ?? 0) > 0) ...[
dot,
Tooltip(
message: context.l10n.channel_coreScopeTooltip(
message.coreScopeObserverCount!,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_outlined, size: 12, color: metaColor),
const SizedBox(width: 4),
Text('${message.coreScopeObserverCount}', style: metaStyle),
],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 6),
Icon(

@ -233,7 +233,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget _experimentalPane(BuildContext context) {
final l10n = context.l10n;
final settingsService = context.read<AppSettingsService>();
final settingsService = context.watch<AppSettingsService>();
final supportsPktHash = context.select<MeshCoreConnector, bool>(
(c) => c.supportsPktHash,
);
return ListView(
padding: const EdgeInsets.all(16),
children: [
@ -244,8 +247,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 16),
// Future me-centric/experimental toggles land here (e.g. Fast Sync
// #118, CoreScope repeats #524). Empty of features until they arrive.
// CoreScope observer counts (#524). Disabled until the connected radio
// advertises the 0xC6 packet-hash capability.
Card(
child: SwitchListTile(
secondary: const Icon(Icons.cloud_outlined),
title: Text(l10n.settings_coreScopeObserverCount),
subtitle: Text(
supportsPktHash
? l10n.settings_coreScopeObserverCountSubtitle
: l10n.settings_coreScopeObserverCountUnsupported,
),
value: settingsService.settings.coreScopeObserverCountEnabled,
onChanged: supportsPktHash
? (value) =>
settingsService.setCoreScopeObserverCountEnabled(value)
: null,
),
),
const SizedBox(height: 16),
// A plain action (not a switch): "hide" is a one-shot, and a switch
// whose ON state means "hidden" read backwards.
Card(

@ -186,6 +186,12 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(experimentalUnlocked: value));
}
Future<void> setCoreScopeObserverCountEnabled(bool value) async {
await updateSettings(
_settings.copyWith(coreScopeObserverCountEnabled: value),
);
}
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}

@ -15,6 +15,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -48,6 +51,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -111,6 +115,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -144,6 +151,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -207,6 +215,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -240,6 +251,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -303,6 +315,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -336,6 +351,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -399,6 +415,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -432,6 +451,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -495,6 +515,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -528,6 +551,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -591,6 +615,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -624,6 +651,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -687,6 +715,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -720,6 +751,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -783,6 +815,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -816,6 +851,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -879,6 +915,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -912,6 +951,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -975,6 +1015,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1008,6 +1051,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -1071,6 +1115,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1104,6 +1151,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -1167,6 +1215,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1200,6 +1251,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -1263,6 +1315,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1296,6 +1351,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -1359,6 +1415,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1392,6 +1451,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -1455,6 +1515,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1488,6 +1551,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",
@ -1551,6 +1615,9 @@
"settings_experimental",
"settings_experimentalSubtitle",
"settings_experimentalDescription",
"settings_coreScopeObserverCount",
"settings_coreScopeObserverCountSubtitle",
"settings_coreScopeObserverCountUnsupported",
"settings_experimentalHide",
"settings_experimentalHideSubtitle",
"settings_experimentalUnlocked",
@ -1584,6 +1651,7 @@
"debugLog_logSaved",
"debugLog_logUnavailable",
"channel_heardTooltip",
"channel_coreScopeTooltip",
"map_alwaysShowNames",
"path_suggestedRoute",
"path_searchRepeaters",

Loading…
Cancel
Save

Powered by TurnKey Linux.