diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 6791901..60ebe95 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -66,15 +66,20 @@ class DirectRepeater { int get ranking { if (isStale()) { - return -1; // Stale repeaters get lowest rank + return -1; // Stale repeaters sort last; every live ranking below is >= 0. } - // Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties. + // SNR-first: each dB of SNR is worth 1000 rank points; recency only breaks + // ties between repeaters within ~1 dB of each other (a 0-999 bonus). SX126x + // SNR is an int8 x 0.25 dB value spanning -32.0 .. +31.75 dB, so the +32 + // offset keeps every live ranking >= 0 (above the -1 stale sentinel) while + // preserving SNR order. final ageMs = DateTime.now().millisecondsSinceEpoch - lastUpdated.millisecondsSinceEpoch; final maxAgeMs = maxAgeMinutes * 60 * 1000; - final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs); - return ((snr - 31.75) * 1000).round() + recencyScore; + final recencyFraction = ((maxAgeMs - ageMs) / maxAgeMs).clamp(0.0, 1.0); + final recencyTieBreak = (recencyFraction * 999).round(); + return ((snr + 32) * 1000).round() + recencyTieBreak; } bool isStale() { diff --git a/test/connector/direct_repeater_ranking_test.dart b/test/connector/direct_repeater_ranking_test.dart new file mode 100644 index 0000000..caaec69 --- /dev/null +++ b/test/connector/direct_repeater_ranking_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; + +void main() { + group('DirectRepeater.ranking (SNR-first)', () { + test('a stronger signal outranks a more-recent but weaker one', () { + // 5 dB better SNR, but heard 24 min earlier — signal margin must win. + final strongOld = DirectRepeater( + pubkeyFirstByte: 1, + snr: 10.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 25)), + ); + final weakRecent = DirectRepeater( + pubkeyFirstByte: 2, + snr: 5.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), + ); + expect(strongOld.ranking, greaterThan(weakRecent.ranking)); + }); + + test('recency breaks ties between equal-SNR repeaters', () { + final older = DirectRepeater( + pubkeyFirstByte: 1, + snr: 10.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 20)), + ); + final newer = DirectRepeater( + pubkeyFirstByte: 2, + snr: 10.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), + ); + expect(newer.ranking, greaterThan(older.ranking)); + }); + + test('a stale repeater ranks -1', () { + final stale = DirectRepeater( + pubkeyFirstByte: 1, + snr: 30.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)), + ); + expect(stale.ranking, -1); + }); + + test('a live repeater always outranks a stale one', () { + // Weak but live vs strong but stale — the live one must win, so the + // stale sentinel (-1) has to sit below every live ranking. + final weakLive = DirectRepeater( + pubkeyFirstByte: 1, + snr: -30.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), + ); + final strongStale = DirectRepeater( + pubkeyFirstByte: 2, + snr: 30.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)), + ); + expect(weakLive.ranking, greaterThan(strongStale.ranking)); + }); + }); +}