fix: Count RX data and speed up Flutter builds #123

This commit is contained in:
Janez T
2026-03-19 10:33:20 +01:00
parent a32d5d2c85
commit bc9e37bd22
6 changed files with 218 additions and 83 deletions

View File

@@ -40,6 +40,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Cache Android NDK - name: Cache Android NDK
uses: actions/cache@v4 uses: actions/cache@v4
@@ -95,6 +98,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Linux desktop - name: Enable Linux desktop
run: flutter config --enable-linux-desktop run: flutter config --enable-linux-desktop
@@ -132,6 +138,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Windows desktop - name: Enable Windows desktop
shell: pwsh shell: pwsh
@@ -174,6 +183,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable macOS desktop - name: Enable macOS desktop
run: flutter config --enable-macos-desktop run: flutter config --enable-macos-desktop
@@ -216,6 +228,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
@@ -272,6 +287,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable channel: stable
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable web - name: Enable web
run: flutter config --enable-web run: flutter config --enable-web

View File

@@ -27,6 +27,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable' channel: 'stable'
cache: true cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get

View File

@@ -27,7 +27,6 @@ T? _maybeProvider<T>(BuildContext context) {
class LiveTrafficScreen extends StatefulWidget { class LiveTrafficScreen extends StatefulWidget {
final List<BlePacketLog> Function() logReader; final List<BlePacketLog> Function() logReader;
final int Function()? rxCountReader;
final Listenable? refreshListenable; final Listenable? refreshListenable;
final DateTime Function() now; final DateTime Function() now;
final VoidCallback? openPacketLogs; final VoidCallback? openPacketLogs;
@@ -35,7 +34,6 @@ class LiveTrafficScreen extends StatefulWidget {
const LiveTrafficScreen({ const LiveTrafficScreen({
super.key, super.key,
required this.logReader, required this.logReader,
this.rxCountReader,
this.refreshListenable, this.refreshListenable,
DateTime Function()? now, DateTime Function()? now,
this.openPacketLogs, this.openPacketLogs,
@@ -49,7 +47,6 @@ class LiveTrafficScreen extends StatefulWidget {
return LiveTrafficScreen( return LiveTrafficScreen(
key: key, key: key,
logReader: () => provider.bleService.packetLogs, logReader: () => provider.bleService.packetLogs,
rxCountReader: () => provider.rxPacketCount,
refreshListenable: provider, refreshListenable: provider,
openPacketLogs: openPacketLogs, openPacketLogs: openPacketLogs,
); );
@@ -118,15 +115,17 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final logs = widget.logReader();
final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs);
final unfilteredSnapshot = LiveTrafficSummary.fromLogs( final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
widget.logReader(), logs,
now: widget.now(), now: widget.now(),
clearedAt: _clearedAt, clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize, preferredHashSize: _preferredHashSize,
window: _selectedWindow, window: _selectedWindow,
); );
final snapshot = LiveTrafficSummary.fromLogs( final snapshot = LiveTrafficSummary.fromLogs(
widget.logReader(), logs,
now: widget.now(), now: widget.now(),
clearedAt: _clearedAt, clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize, preferredHashSize: _preferredHashSize,
@@ -177,7 +176,7 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
children: [ children: [
_SummaryPanel( _SummaryPanel(
snapshot: snapshot, snapshot: snapshot,
totalRxCount: widget.rxCountReader?.call(), totalRxCount: totalRxDataCount,
routeHashCounts: routeHashCounts, routeHashCounts: routeHashCounts,
onWindowTap: () => _showWindowPicker(context), onWindowTap: () => _showWindowPicker(context),
), ),
@@ -306,8 +305,48 @@ class _SummaryPanel extends StatelessWidget {
LiveTrafficBusyness.active => 'Active', LiveTrafficBusyness.active => 'Active',
LiveTrafficBusyness.busy => 'Busy', LiveTrafficBusyness.busy => 'Busy',
}; };
final metrics = [
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rxPackets'),
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rssi'),
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:snr'),
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:multihop'),
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
];
return Container( return Container(
width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -346,44 +385,26 @@ class _SummaryPanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
Wrap( LayoutBuilder(
spacing: 10, builder: (context, constraints) {
runSpacing: 10, const spacing = 10.0;
const minTileWidth = 150.0;
final columns =
((constraints.maxWidth + spacing) / (minTileWidth + spacing))
.floor()
.clamp(1, metrics.length);
final tileWidth =
(constraints.maxWidth - (spacing * (columns - 1))) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [ children: [
_MetricTile( for (final metric in metrics)
label: AppLocalizations.of(context)!.rxPackets, SizedBox(width: tileWidth, child: metric),
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
], ],
);
},
), ),
], ],
), ),
@@ -525,12 +546,14 @@ String _windowSummaryLabel(Duration duration) {
} }
class _MetricTile extends StatelessWidget { class _MetricTile extends StatelessWidget {
final Key? tileKey;
final String label; final String label;
final String value; final String value;
final String subtitle; final String subtitle;
final String? footer; final String? footer;
const _MetricTile({ const _MetricTile({
this.tileKey,
required this.label, required this.label,
required this.value, required this.value,
required this.subtitle, required this.subtitle,
@@ -540,7 +563,7 @@ class _MetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: 160, key: tileKey,
height: 136, height: 136,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -949,7 +972,6 @@ class _LiveTrafficCard extends StatelessWidget {
), ),
); );
} }
} }
class _LiveTrafficPacketDetails { class _LiveTrafficPacketDetails {
@@ -1105,7 +1127,6 @@ class _PacketInfoLine extends StatelessWidget {
} }
} }
class _GeoPoint { class _GeoPoint {
final double latitude; final double latitude;
final double longitude; final double longitude;

View File

@@ -141,6 +141,18 @@ class LiveTrafficSummary {
const LiveTrafficSummary._(); const LiveTrafficSummary._();
static bool isRxDataLog(BlePacketLog log) {
return log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode;
}
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
return logs.where((log) {
return isRxDataLog(log) &&
(since == null || !log.timestamp.isBefore(since));
}).length;
}
static LiveTrafficSnapshot fromLogs( static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, { Iterable<BlePacketLog> logs, {
required DateTime now, required DateTime now,
@@ -154,12 +166,11 @@ class LiveTrafficSummary {
? clearedAt ? clearedAt
: windowStart; : windowStart;
final recentLogs = logs final recentLogs =
logs
.where( .where(
(log) => (log) =>
log.direction == PacketDirection.rx && isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
) )
.toList() .toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp)); ..sort((a, b) => a.timestamp.compareTo(b.timestamp));
@@ -215,7 +226,9 @@ class LiveTrafficSummary {
} }
} }
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList(); final visibleEntries = filteredEntries.reversed
.take(maxVisibleEntries)
.toList();
const txCount = 0; const txCount = 0;
final totalCount = rxCount; final totalCount = rxCount;
final packetsPerMinute = final packetsPerMinute =
@@ -233,7 +246,9 @@ class LiveTrafficSummary {
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount, avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm, latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount, multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples, avgHopCount: hopCountSamples == 0
? null
: hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries, visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute), busyness: _busynessForPacketsPerMinute(packetsPerMinute),
); );

View File

@@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart'; import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
BlePacketLog _log({ BlePacketLog _log({
@@ -50,6 +51,14 @@ List<int> _multiHopRaw({
]; ];
} }
Widget _testApp(Widget child) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
);
}
void main() { void main() {
testWidgets('shows empty state before traffic arrives', (tester) async { testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[]; final logs = <BlePacketLog>[];
@@ -57,8 +66,8 @@ void main() {
DateTime now = DateTime(2026, 3, 12, 12, 0, 0); DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( _testApp(
home: LiveTrafficScreen( LiveTrafficScreen(
logReader: () => logs, logReader: () => logs,
refreshListenable: refresh, refreshListenable: refresh,
now: () => now, now: () => now,
@@ -78,10 +87,9 @@ void main() {
DateTime now = DateTime(2026, 3, 12, 12, 0, 0); DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( _testApp(
home: LiveTrafficScreen( LiveTrafficScreen(
logReader: () => logs, logReader: () => logs,
rxCountReader: () => 7,
refreshListenable: refresh, refreshListenable: refresh,
now: () => now, now: () => now,
), ),
@@ -114,10 +122,9 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('1 pkt/min'), findsOneWidget); expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget); expect(find.text('Device total 1'), findsOneWidget);
expect(find.text('FLOOD RESPONSE'), findsOneWidget); expect(find.text('FLOOD RESPONSE'), findsOneWidget);
expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.text('-84 dBm'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
expect(find.textContaining('Hash:'), findsOneWidget); expect(find.textContaining('Hash:'), findsOneWidget);
expect( expect(
find.textContaining('Path: 3 hops [c010,6301,68d9]'), find.textContaining('Path: 3 hops [c010,6301,68d9]'),
@@ -143,8 +150,8 @@ void main() {
); );
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( _testApp(
home: LiveTrafficScreen( LiveTrafficScreen(
logReader: () => logs, logReader: () => logs,
refreshListenable: refresh, refreshListenable: refresh,
now: () => now, now: () => now,
@@ -152,7 +159,7 @@ void main() {
), ),
); );
expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.text('FLOOD RESPONSE'), findsOneWidget);
await tester.tap(find.byTooltip('Clear live view')); await tester.tap(find.byTooltip('Clear live view'));
await tester.pump(); await tester.pump();
@@ -182,4 +189,43 @@ void main() {
expect(find.text('No packets for this filter'), findsNothing); expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('Size: 3 bytes'), findsOneWidget); expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
}); });
testWidgets('summary metrics expand across wide layouts', (tester) async {
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final now = DateTime(2026, 3, 12, 12, 0, 0);
final logs = [
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
snrDb: 9.5,
rssiDbm: -82,
),
];
await tester.pumpWidget(
_testApp(LiveTrafficScreen(logReader: () => logs, now: () => now)),
);
expect(
tester
.getSize(
find.byKey(const ValueKey('liveTrafficMetric:rxPackets')),
)
.width >
200,
isTrue,
);
expect(
tester
.getSize(find.byKey(const ValueKey('liveTrafficMetric:multihop')))
.width >
200,
isTrue,
);
});
} }

View File

@@ -146,7 +146,8 @@ void main() {
test('supports clearing the live view without mutating source logs', () { test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0); final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8)); final clearAt = now.subtract(const Duration(seconds: 8));
final snapshot = LiveTrafficSummary.fromLogs([ final snapshot = LiveTrafficSummary.fromLogs(
[
_log( _log(
timestamp: now.subtract(const Duration(seconds: 10)), timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx, direction: PacketDirection.rx,
@@ -159,10 +160,41 @@ void main() {
rawData: [0x88, 0x00, 0x00], rawData: [0x88, 0x00, 0x00],
responseCode: 0x88, responseCode: 0x88,
), ),
], now: now, clearedAt: clearAt); ],
now: now,
clearedAt: clearAt,
);
expect(snapshot.totalCount, 1); expect(snapshot.totalCount, 1);
expect(snapshot.visibleEntries, hasLength(1)); expect(snapshot.visibleEntries, hasLength(1));
}); });
test('counts only RX data packets for device totals', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
expect(
LiveTrafficSummary.countRxDataLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.tx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]),
1,
);
});
}); });
} }