From 485df05372e190c6ed79b0379582961fe0b2fbe8 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 12 Apr 2026 23:45:43 -0700 Subject: [PATCH] Modify radio contact fill logic to use sent OR received messages as recency queue for loadin selection after favorites --- app/radio_sync.py | 18 ++++-- app/repository/contacts.py | 22 +++++++ tests/test_radio_sync.py | 129 +++++++++++++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 13 deletions(-) diff --git a/app/radio_sync.py b/app/radio_sync.py index 652dc65..4e8e7c3 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -1295,7 +1295,13 @@ async def stop_background_contact_reconciliation() -> None: async def get_contacts_selected_for_radio_sync() -> list[Contact]: - """Return the contacts that would be loaded onto the radio right now.""" + """Return the contacts that would be loaded onto the radio right now. + + Fill order: + 1. Favorites (up to full capacity) + 2. Most recently DM-active non-repeaters (sent or received, up to 80% refill target) + 3. Most recently advertised non-repeaters (up to 80% refill target) + """ app_settings = await AppSettingsRepository.get() max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts) refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts) @@ -1315,7 +1321,7 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]: break if len(selected_contacts) < refill_target: - for contact in await ContactRepository.get_recently_contacted_non_repeaters( + for contact in await ContactRepository.get_recently_dm_active_non_repeaters( limit=max_contacts ): key = contact.public_key.lower() @@ -1354,8 +1360,8 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict: Fill order is: 1. Favorite contacts - 2. Most recently interacted-with non-repeaters - 3. Most recently advert-heard non-repeaters without interaction history + 2. Most recently DM-active non-repeaters (sent or received) + 3. Most recently advert-heard non-repeaters Favorite contacts are always reloaded first, up to the configured capacity. Additional non-favorite fill stops at the refill target (80% of capacity). @@ -1489,8 +1495,8 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None """ Load contacts to the radio for DM ACK support. - Fill order is favorites, then recently contacted non-repeaters, - then recently advert-heard non-repeaters. Favorites are always reloaded + Fill order is favorites, then recently DM-active non-repeaters (sent or + received), then recently advert-heard non-repeaters. Favorites are always reloaded up to the configured capacity; additional non-favorite fill stops at the 80% refill target. Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced. diff --git a/app/repository/contacts.py b/app/repository/contacts.py index c350d78..dd64a96 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -294,6 +294,28 @@ class ContactRepository: rows = await cursor.fetchall() return [ContactRepository._row_to_contact(row) for row in rows] + @staticmethod + async def get_recently_dm_active_non_repeaters(limit: int = 200) -> list[Contact]: + """Get non-repeater contacts with the most recent DM activity (sent or received).""" + cursor = await db.conn.execute( + """ + SELECT c.* + FROM contacts c + INNER JOIN ( + SELECT conversation_key, MAX(received_at) AS last_dm + FROM messages + WHERE type = 'PRIV' + GROUP BY conversation_key + ) m ON c.public_key = m.conversation_key + WHERE c.type != 2 AND length(c.public_key) = 64 + ORDER BY m.last_dm DESC + LIMIT ? + """, + (limit,), + ) + rows = await cursor.fetchall() + return [ContactRepository._row_to_contact(row) for row in rows] + @staticmethod async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]: """Get recently advert-heard non-repeater contacts.""" diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index 7e1ef07..2449800 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -377,14 +377,22 @@ class TestSyncRecentContactsToRadio: assert result["loaded"] == 2 @pytest.mark.asyncio - async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db): - """Fill order is favorites, then recent contacts, then recent adverts.""" - await _insert_contact(KEY_A, "Alice", last_contacted=100) - await _insert_contact(KEY_B, "Bob", last_contacted=2000) - await _insert_contact("cc" * 32, "Carol", last_contacted=1000) + async def test_fills_remaining_slots_with_dm_active_then_advertised(self, test_db): + """Fill order is favorites, then DM-active contacts, then recent adverts.""" + await _insert_contact(KEY_A, "Alice") + await _insert_contact(KEY_B, "Bob") + await _insert_contact("cc" * 32, "Carol") await _insert_contact("dd" * 32, "Dave", last_advert=3000) await _insert_contact("ee" * 32, "Eve", last_advert=2500) + # Create DM activity for Alice (oldest), Bob (most recent), Carol (middle) + for key, ts in [(KEY_A, 100), (KEY_B, 2000), ("cc" * 32, 1000)]: + await test_db.conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hi', ?)", + (key, ts), + ) + await test_db.conn.commit() + await AppSettingsRepository.update(max_radio_contacts=5) await ContactRepository.set_favorite(KEY_A, True) @@ -401,6 +409,7 @@ class TestSyncRecentContactsToRadio: loaded_keys = [ call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list ] + # Alice (favorite), then Bob & Carol (DM-active, most recent first), then Dave (advert) assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32] @pytest.mark.asyncio @@ -509,8 +518,15 @@ class TestSyncAndOffloadAll: @pytest.mark.asyncio async def test_duplicate_favorite_not_loaded_twice(self, test_db): """Duplicate favorite entries still load the contact only once.""" - await _insert_contact(KEY_A, "Alice", last_contacted=2000) - await _insert_contact(KEY_B, "Bob", last_contacted=1000) + await _insert_contact(KEY_A, "Alice") + await _insert_contact(KEY_B, "Bob") + + # Bob has DM activity so he appears in tier 2 + await test_db.conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hi', 1000)", + (KEY_B,), + ) + await test_db.conn.commit() await AppSettingsRepository.update(max_radio_contacts=2) await ContactRepository.set_favorite(KEY_A, True) @@ -1862,3 +1878,102 @@ class TestCollectRepeaterTelemetryLpp: await _collect_repeater_telemetry(mc, contact) assert "lpp_sensors" not in recorded_data + + +# --------------------------------------------------------------------------- +# get_contacts_selected_for_radio_sync — DM-active prioritization +# --------------------------------------------------------------------------- + + +class TestContactSelectionDmActive: + """Verify that tier 2 prioritizes contacts with recent DM activity.""" + + @pytest.mark.asyncio + async def test_incoming_dm_contact_selected_over_advert_only(self, test_db): + """A contact who sent us a DM should be prioritized over one who only advertised.""" + from app.radio_sync import get_contacts_selected_for_radio_sync + + # Create two non-repeater contacts + dm_sender_key = "aa" * 32 + advert_only_key = "bb" * 32 + + await test_db.conn.execute( + "INSERT INTO contacts (public_key, name, type, last_seen, last_advert) VALUES (?, ?, 1, 100, 100)", + (dm_sender_key, "DM Sender"), + ) + await test_db.conn.execute( + "INSERT INTO contacts (public_key, name, type, last_seen, last_advert) VALUES (?, ?, 1, 200, 200)", + (advert_only_key, "Advert Only"), + ) + + # DM Sender sent us a message (incoming DM) + await test_db.conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hello', 300)", + (dm_sender_key,), + ) + await test_db.conn.commit() + + with patch( + "app.radio_sync.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]), + ): + selected = await get_contacts_selected_for_radio_sync() + + keys = [c.public_key for c in selected] + assert dm_sender_key in keys + assert advert_only_key in keys + # DM Sender should come before Advert Only (tier 2 before tier 3) + assert keys.index(dm_sender_key) < keys.index(advert_only_key) + + @pytest.mark.asyncio + async def test_outgoing_dm_contact_also_selected(self, test_db): + """A contact we sent a DM to should also appear via DM-active tier.""" + from app.radio_sync import get_contacts_selected_for_radio_sync + + contact_key = "cc" * 32 + await test_db.conn.execute( + "INSERT INTO contacts (public_key, name, type) VALUES (?, ?, 1)", + (contact_key, "Outgoing Target"), + ) + await test_db.conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES ('PRIV', ?, 'hey', 300, 1)", + (contact_key,), + ) + await test_db.conn.commit() + + with patch( + "app.radio_sync.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]), + ): + selected = await get_contacts_selected_for_radio_sync() + + keys = [c.public_key for c in selected] + assert contact_key in keys + + @pytest.mark.asyncio + async def test_repeaters_excluded_from_dm_active_tier(self, test_db): + """Repeater contacts should not appear in tier 2 even with DM activity.""" + from app.radio_sync import get_contacts_selected_for_radio_sync + + repeater_key = "dd" * 32 + await test_db.conn.execute( + "INSERT INTO contacts (public_key, name, type) VALUES (?, ?, 2)", + (repeater_key, "Repeater"), + ) + await test_db.conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'cmd', 300)", + (repeater_key,), + ) + await test_db.conn.commit() + + with patch( + "app.radio_sync.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]), + ): + selected = await get_contacts_selected_for_radio_sync() + + keys = [c.public_key for c in selected] + assert repeater_key not in keys