From cf6df506d1a3e61d6608009bed134b7c134a395b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 10 Feb 2026 16:19:42 -0800 Subject: [PATCH] Always load contacts on radio first --- app/models.py | 5 +- app/radio_sync.py | 49 ++++++++-- app/routers/settings.py | 4 +- frontend/src/components/SettingsModal.tsx | 3 +- tests/test_radio_sync.py | 107 +++++++++++++++++++++- 5 files changed, 158 insertions(+), 10 deletions(-) diff --git a/app/models.py b/app/models.py index 443f14e..019ff13 100644 --- a/app/models.py +++ b/app/models.py @@ -258,7 +258,10 @@ class AppSettings(BaseModel): max_radio_contacts: int = Field( default=200, - description="Maximum non-repeater contacts to keep on radio for DM ACKs", + description=( + "Maximum contacts to keep on radio for DM ACKs " + "(favorite contacts first, then recent non-repeaters)" + ), ) favorites: list[Favorite] = Field( default_factory=list, description="List of favorited conversations" diff --git a/app/radio_sync.py b/app/radio_sync.py index b6a2ebb..2ee10ad 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -515,9 +515,10 @@ CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds async def sync_recent_contacts_to_radio(force: bool = False) -> dict: """ - Load recent non-repeater contacts to the radio for DM ACK support. + Load contacts to the radio for DM ACK support. - This ensures the radio can auto-ACK incoming DMs from recent contacts. + Favorite contacts are prioritized first, then recent non-repeater contacts + fill remaining slots up to max_radio_contacts. Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced. Returns counts of contacts loaded. @@ -538,17 +539,53 @@ async def sync_recent_contacts_to_radio(force: bool = False) -> dict: _last_contact_sync = now try: - # Get recent non-repeater contacts from database + # Build prioritized contact list: + # 1) favorite contacts, in favorite order + # 2) most recent non-repeater contacts (excluding already-selected favorites) app_settings = await AppSettingsRepository.get() max_contacts = app_settings.max_radio_contacts - contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts) - logger.debug("Found %d recent non-repeater contacts to sync", len(contacts)) + selected_contacts: list[Contact] = [] + selected_keys: set[str] = set() + + favorite_contacts_loaded = 0 + for favorite in app_settings.favorites: + if favorite.type != "contact": + continue + contact = await ContactRepository.get_by_key_or_prefix(favorite.id) + if not contact: + continue + key = contact.public_key.lower() + if key in selected_keys: + continue + selected_keys.add(key) + selected_contacts.append(contact) + favorite_contacts_loaded += 1 + if len(selected_contacts) >= max_contacts: + break + + if len(selected_contacts) < max_contacts: + recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts) + for contact in recent_contacts: + key = contact.public_key.lower() + if key in selected_keys: + continue + selected_keys.add(key) + selected_contacts.append(contact) + if len(selected_contacts) >= max_contacts: + break + + logger.debug( + "Selected %d contacts to sync (%d favorite contacts first, limit=%d)", + len(selected_contacts), + favorite_contacts_loaded, + max_contacts, + ) loaded = 0 already_on_radio = 0 failed = 0 - for contact in contacts: + for contact in selected_contacts: # Check if already on radio radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12]) if radio_contact: diff --git a/app/routers/settings.py b/app/routers/settings.py index cbce52c..ce02db6 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -37,7 +37,9 @@ class AppSettingsUpdate(BaseModel): default=None, ge=1, le=1000, - description="Maximum non-repeater contacts to keep on radio (1-1000)", + description=( + "Maximum contacts to keep on radio (favorites first, then recent non-repeaters)" + ), ) auto_decrypt_dm_on_advert: bool | None = Field( default=None, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b1917e5..67fb743 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -751,7 +751,8 @@ export function SettingsModal({ onChange={(e) => setMaxRadioContacts(e.target.value)} />

- Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000) + Favorite contacts load first, then recent non-repeater contacts until this + limit is reached (1-1000)

diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index ff502cf..4af007f 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from meshcore import EventType -from app.models import Contact +from app.models import Contact, Favorite from app.radio_sync import ( is_polling_paused, pause_polling, @@ -206,6 +206,7 @@ class TestSyncRecentContactsToRadio: mock_settings = MagicMock() mock_settings.max_radio_contacts = 200 + mock_settings.favorites = [] with ( patch("app.radio_sync.radio_manager") as mock_rm, @@ -232,6 +233,105 @@ class TestSyncRecentContactsToRadio: assert result["loaded"] == 2 assert mock_set_on_radio.call_count == 2 + @pytest.mark.asyncio + async def test_favorites_loaded_before_recent_contacts(self): + """Favorite contacts are loaded first, then recents until limit.""" + favorite_contact = _make_contact(KEY_A, "Alice") + recent_contacts = [_make_contact(KEY_B, "Bob"), _make_contact("cc" * 32, "Carol")] + + mock_mc = MagicMock() + mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None) + mock_result = MagicMock() + mock_result.type = EventType.OK + mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) + + mock_settings = MagicMock() + mock_settings.max_radio_contacts = 2 + mock_settings.favorites = [Favorite(type="contact", id=KEY_A)] + + with ( + patch("app.radio_sync.radio_manager") as mock_rm, + patch( + "app.radio_sync.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=favorite_contact, + ) as mock_get_by_key_or_prefix, + patch( + "app.radio_sync.ContactRepository.get_recent_non_repeaters", + new_callable=AsyncMock, + return_value=recent_contacts, + ), + patch( + "app.radio_sync.ContactRepository.set_on_radio", + new_callable=AsyncMock, + ), + patch( + "app.radio_sync.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=mock_settings, + ), + ): + mock_rm.is_connected = True + mock_rm.meshcore = mock_mc + + result = await sync_recent_contacts_to_radio() + + assert result["loaded"] == 2 + mock_get_by_key_or_prefix.assert_called_once_with(KEY_A) + loaded_keys = [ + call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list + ] + assert loaded_keys == [KEY_A, KEY_B] + + @pytest.mark.asyncio + async def test_favorite_contact_not_loaded_twice_if_also_recent(self): + """A favorite contact that is also recent is loaded only once.""" + favorite_contact = _make_contact(KEY_A, "Alice") + recent_contacts = [favorite_contact, _make_contact(KEY_B, "Bob")] + + mock_mc = MagicMock() + mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None) + mock_result = MagicMock() + mock_result.type = EventType.OK + mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) + + mock_settings = MagicMock() + mock_settings.max_radio_contacts = 2 + mock_settings.favorites = [Favorite(type="contact", id=KEY_A)] + + with ( + patch("app.radio_sync.radio_manager") as mock_rm, + patch( + "app.radio_sync.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=favorite_contact, + ), + patch( + "app.radio_sync.ContactRepository.get_recent_non_repeaters", + new_callable=AsyncMock, + return_value=recent_contacts, + ), + patch( + "app.radio_sync.ContactRepository.set_on_radio", + new_callable=AsyncMock, + ), + patch( + "app.radio_sync.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=mock_settings, + ), + ): + mock_rm.is_connected = True + mock_rm.meshcore = mock_mc + + result = await sync_recent_contacts_to_radio() + + assert result["loaded"] == 2 + loaded_keys = [ + call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list + ] + assert loaded_keys == [KEY_A, KEY_B] + @pytest.mark.asyncio async def test_skips_contacts_already_on_radio(self): """Contacts already on radio are counted but not re-added.""" @@ -243,6 +343,7 @@ class TestSyncRecentContactsToRadio: mock_settings = MagicMock() mock_settings.max_radio_contacts = 200 + mock_settings.favorites = [] with ( patch("app.radio_sync.radio_manager") as mock_rm, @@ -278,6 +379,7 @@ class TestSyncRecentContactsToRadio: mock_settings = MagicMock() mock_settings.max_radio_contacts = 200 + mock_settings.favorites = [] with ( patch("app.radio_sync.radio_manager") as mock_rm, @@ -311,6 +413,7 @@ class TestSyncRecentContactsToRadio: mock_settings = MagicMock() mock_settings.max_radio_contacts = 200 + mock_settings.favorites = [] with ( patch("app.radio_sync.radio_manager") as mock_rm, @@ -357,6 +460,7 @@ class TestSyncRecentContactsToRadio: mock_settings = MagicMock() mock_settings.max_radio_contacts = 200 + mock_settings.favorites = [] with ( patch("app.radio_sync.radio_manager") as mock_rm, @@ -398,6 +502,7 @@ class TestSyncRecentContactsToRadio: mock_settings = MagicMock() mock_settings.max_radio_contacts = 200 + mock_settings.favorites = [] with ( patch("app.radio_sync.radio_manager") as mock_rm,