mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-05 04:52:59 +02:00
Always load contacts on radio first
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -751,7 +751,8 @@ export function SettingsModal({
|
||||
onChange={(e) => setMaxRadioContacts(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user