diff --git a/AGENTS.md b/AGENTS.md index b36018c..d46f0ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -444,7 +444,7 @@ mc.subscribe(EventType.ACK, handler) | `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) | | `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Enable periodic `get_msg()` fallback polling if radio events are not reliably surfacing messages | -**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` now means favorite contacts kept loaded on the radio for ACK support, not a recent-contact rotation pool. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`. +**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`. Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. diff --git a/app/AGENTS.md b/app/AGENTS.md index 78146f7..7b6af5f 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -251,7 +251,7 @@ Main tables: Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data. -`max_radio_contacts` now controls how many favorite contacts are kept loaded on the radio for ACK support. The app no longer rotates recent non-favorite contacts onto the radio during normal background maintenance. +`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%. `app_settings` fields in active model: - `max_radio_contacts` diff --git a/app/migrations.py b/app/migrations.py index 32c3438..8ef1859 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -778,7 +778,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) -> Create app_settings table for persistent application preferences. This table stores: - - max_radio_contacts: Max favorite contacts to keep on radio for DM ACKs + - max_radio_contacts: Configured radio contact capacity baseline for maintenance thresholds - favorites: JSON array of favorite conversations [{type, id}, ...] - auto_decrypt_dm_on_advert: Whether to attempt historical DM decryption on new contact - sidebar_sort_order: 'recent' or 'alpha' for sidebar sorting diff --git a/app/models.py b/app/models.py index 09f68a0..b4a052a 100644 --- a/app/models.py +++ b/app/models.py @@ -515,7 +515,10 @@ class AppSettings(BaseModel): max_radio_contacts: int = Field( default=200, - description="Maximum favorite contacts to keep on radio for DM ACKs", + description=( + "Configured radio contact capacity used for maintenance thresholds; " + "favorites reload first, then background fill targets about 80% of this value" + ), ) 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 910f087..9129ae4 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -11,6 +11,7 @@ don't work reliably. import asyncio import logging +import math import time from contextlib import asynccontextmanager @@ -139,8 +140,57 @@ async def pause_polling(): # Background task handle _sync_task: asyncio.Task | None = None -# Sync interval in seconds (10 minutes) -SYNC_INTERVAL = 600 +# Periodic maintenance check interval in seconds (5 minutes) +SYNC_INTERVAL = 300 + +# Reload non-favorite contacts up to 80% of configured radio capacity after offload. +RADIO_CONTACT_REFILL_RATIO = 0.80 + +# Trigger a full offload/reload once occupancy reaches 95% of configured capacity. +RADIO_CONTACT_FULL_SYNC_RATIO = 0.95 + + +def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]: + """Return (refill_target, full_sync_trigger) for the configured capacity.""" + capacity = max(1, max_contacts) + refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5))) + full_sync_trigger = max( + refill_target, + min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)), + ) + return refill_target, full_sync_trigger + + +async def should_run_full_periodic_sync(mc: MeshCore) -> bool: + """Check current radio occupancy and decide whether to offload/reload.""" + app_settings = await AppSettingsRepository.get() + capacity = app_settings.max_radio_contacts + refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity) + + result = await mc.commands.get_contacts() + if result is None or result.type == EventType.ERROR: + logger.warning("Periodic sync occupancy check failed: %s", result) + return False + + current_contacts = len(result.payload or {}) + if current_contacts >= full_sync_trigger: + logger.info( + "Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)", + current_contacts, + capacity, + full_sync_trigger, + refill_target, + ) + return True + + logger.debug( + "Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)", + current_contacts, + capacity, + full_sync_trigger, + refill_target, + ) + return False async def sync_and_offload_contacts(mc: MeshCore) -> dict: @@ -311,9 +361,9 @@ async def sync_and_offload_all(mc: MeshCore) -> dict: # Ensure default channels exist await ensure_default_channels() - # Reload favorites back onto the radio immediately so they do not stay - # in on_radio=False limbo after offload. Pass mc directly since the - # caller already holds the radio operation lock (asyncio.Lock is not + # Reload favorites plus a working-set fill back onto the radio immediately + # so they do not stay in on_radio=False limbo after offload. Pass mc directly + # since the caller already holds the radio operation lock (asyncio.Lock is not # reentrant). reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc) @@ -580,8 +630,8 @@ async def _periodic_sync_loop(): "periodic_sync", blocking=False, ) as mc: - logger.debug("Running periodic radio sync") - await sync_and_offload_all(mc) + if await should_run_full_periodic_sync(mc): + await sync_and_offload_all(mc) await sync_radio_time(mc) except RadioOperationBusyError: logger.debug("Skipping periodic sync: radio busy") @@ -627,12 +677,14 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict: 2. Most recently interacted-with non-repeaters 3. Most recently advert-heard non-repeaters without interaction history - Contacts are loaded up to max_radio_contacts. + Favorite contacts are always reloaded first, up to the configured capacity. + Additional non-favorite fill stops at the refill target (80% of capacity). Caller must hold the radio operation lock and pass a valid MeshCore instance. """ app_settings = await AppSettingsRepository.get() max_contacts = app_settings.max_radio_contacts + refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts) selected_contacts: list[Contact] = [] selected_keys: set[str] = set() @@ -659,7 +711,7 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict: if len(selected_contacts) >= max_contacts: break - if len(selected_contacts) < max_contacts: + if len(selected_contacts) < refill_target: for contact in await ContactRepository.get_recently_contacted_non_repeaters( limit=max_contacts ): @@ -668,10 +720,10 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict: continue selected_keys.add(key) selected_contacts.append(contact) - if len(selected_contacts) >= max_contacts: + if len(selected_contacts) >= refill_target: break - if len(selected_contacts) < max_contacts: + if len(selected_contacts) < refill_target: for contact in await ContactRepository.get_recently_advertised_non_repeaters( limit=max_contacts ): @@ -680,13 +732,14 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict: continue selected_keys.add(key) selected_contacts.append(contact) - if len(selected_contacts) >= max_contacts: + if len(selected_contacts) >= refill_target: break logger.debug( - "Selected %d contacts to sync (%d favorites, limit=%d)", + "Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)", len(selected_contacts), favorite_contacts_loaded, + refill_target, max_contacts, ) return await _load_contacts_to_radio(mc, selected_contacts) @@ -811,7 +864,9 @@ 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 until max_radio_contacts. + 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. Args: diff --git a/app/routers/settings.py b/app/routers/settings.py index f9e2e6b..913a1a8 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -18,7 +18,10 @@ class AppSettingsUpdate(BaseModel): default=None, ge=1, le=1000, - description="Maximum favorite contacts to keep loaded on the radio for ACK support", + description=( + "Configured radio contact capacity used for maintenance thresholds and " + "background refill behavior" + ), ) auto_decrypt_dm_on_advert: bool | None = Field( default=None, diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index ec39396..8a75eaa 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -575,7 +575,8 @@ export function SettingsRadioSection({ onChange={(e) => setMaxRadioContacts(e.target.value)} />
- Favorite contacts stay loaded on the radio for DM ACK support up to this limit (1-1000) + Configured radio contact capacity. Favorites reload first, then background maintenance + refills to about 80% of this value and offloads once occupancy reaches about 95%.
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 93a7f2f..81b571d 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -184,9 +184,7 @@ describe('SettingsModal', () => { openRadioSection(); expect( - screen.getByText( - /Favorite contacts stay loaded on the radio for DM ACK support up to this limit/i - ) + screen.getByText(/Configured radio contact capacity/i) ).toBeInTheDocument(); }); diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index 3a20678..0904ff9 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -218,7 +218,7 @@ class TestSyncRecentContactsToRadio: await _insert_contact("ee" * 32, "Eve", last_advert=2500) await AppSettingsRepository.update( - max_radio_contacts=4, favorites=[Favorite(type="contact", id=KEY_A)] + max_radio_contacts=5, favorites=[Favorite(type="contact", id=KEY_A)] ) mock_mc = MagicMock() @@ -236,6 +236,33 @@ class TestSyncRecentContactsToRadio: ] assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32] + @pytest.mark.asyncio + async def test_favorites_can_exceed_non_favorite_refill_target(self, test_db): + """Favorites are reloaded even when they exceed the 80% background refill target.""" + favorite_keys = ["aa" * 32, "bb" * 32, "cc" * 32, "dd" * 32] + for index, key in enumerate(favorite_keys): + await _insert_contact(key, f"Favorite{index}", last_contacted=2000 - index) + + await AppSettingsRepository.update( + max_radio_contacts=4, + favorites=[Favorite(type="contact", id=key) for key in favorite_keys], + ) + + 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) + + radio_manager._meshcore = mock_mc + result = await sync_recent_contacts_to_radio() + + assert result["loaded"] == 4 + loaded_keys = [ + call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list + ] + assert loaded_keys == favorite_keys + @pytest.mark.asyncio async def test_advert_fill_skips_repeaters(self, test_db): """Recent advert fallback only considers non-repeaters.""" @@ -1287,6 +1314,36 @@ class TestPeriodicAdvertLoopRaces: class TestPeriodicSyncLoopRaces: """Regression tests for disconnect/reconnect race paths in _periodic_sync_loop.""" + @pytest.mark.asyncio + async def test_should_run_full_periodic_sync_at_trigger_threshold(self, test_db): + """Occupancy at 95% of configured capacity triggers a full offload/reload.""" + from app.radio_sync import should_run_full_periodic_sync + + await AppSettingsRepository.update(max_radio_contacts=100) + + mock_mc = MagicMock() + mock_result = MagicMock() + mock_result.type = EventType.NEW_CONTACT + mock_result.payload = {f"{i:064x}": {"adv_name": f"Node{i}"} for i in range(95)} + mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) + + assert await should_run_full_periodic_sync(mock_mc) is True + + @pytest.mark.asyncio + async def test_should_skip_full_periodic_sync_below_trigger_threshold(self, test_db): + """Occupancy below 95% of configured capacity does not trigger offload/reload.""" + from app.radio_sync import should_run_full_periodic_sync + + await AppSettingsRepository.update(max_radio_contacts=100) + + mock_mc = MagicMock() + mock_result = MagicMock() + mock_result.type = EventType.NEW_CONTACT + mock_result.payload = {f"{i:064x}": {"adv_name": f"Node{i}"} for i in range(94)} + mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) + + assert await should_run_full_periodic_sync(mock_mc) is False + @pytest.mark.asyncio async def test_disconnect_race_between_precheck_and_lock(self): """RadioDisconnectedError between is_connected and radio_operation() @@ -1299,12 +1356,14 @@ class TestPeriodicSyncLoopRaces: patch("app.radio_sync.radio_manager", rm), patch("asyncio.sleep", side_effect=mock_sleep), patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup, + patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock) as mock_check, patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync, patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time, ): await _periodic_sync_loop() mock_cleanup.assert_called_once() + mock_check.assert_not_called() mock_sync.assert_not_called() mock_time.assert_not_called() assert len(sleep_calls) == 2 @@ -1321,6 +1380,7 @@ class TestPeriodicSyncLoopRaces: patch("app.radio_sync.radio_manager", rm), patch("asyncio.sleep", side_effect=mock_sleep), patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup, + patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock) as mock_check, patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync, patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time, ): @@ -1329,6 +1389,7 @@ class TestPeriodicSyncLoopRaces: lock.release() mock_cleanup.assert_called_once() + mock_check.assert_not_called() mock_sync.assert_not_called() mock_time.assert_not_called() @@ -1344,6 +1405,7 @@ class TestPeriodicSyncLoopRaces: patch("app.radio_sync.radio_manager", rm), patch("asyncio.sleep", side_effect=mock_sleep), patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup, + patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock, return_value=True), patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync, patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time, ): @@ -1352,3 +1414,23 @@ class TestPeriodicSyncLoopRaces: mock_cleanup.assert_called_once() mock_sync.assert_called_once_with(mock_mc) mock_time.assert_called_once_with(mock_mc) + + @pytest.mark.asyncio + async def test_skips_full_sync_below_threshold_but_still_syncs_time(self): + """Periodic maintenance still does time sync when occupancy is below the trigger.""" + rm, mock_mc = _make_connected_manager() + mock_sleep, _ = _sleep_controller(cancel_after=2) + + with ( + patch("app.radio_sync.radio_manager", rm), + patch("asyncio.sleep", side_effect=mock_sleep), + patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup, + patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock, return_value=False), + patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync, + patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time, + ): + await _periodic_sync_loop() + + mock_cleanup.assert_called_once() + mock_sync.assert_not_called() + mock_time.assert_called_once_with(mock_mc)