mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Do some utterly disgusting MC library munging to deal with contacts coming out of sync
This commit is contained in:
@@ -116,6 +116,30 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
remove_result = await mc.commands.remove_contact(contact_data)
|
||||
if remove_result.type == EventType.OK:
|
||||
removed += 1
|
||||
|
||||
# LIBRARY INTERNAL FIXUP: The MeshCore library's
|
||||
# commands.remove_contact() sends the remove command over
|
||||
# the wire but does NOT update the library's in-memory
|
||||
# contact cache (mc._contacts). This is a gap in the
|
||||
# library — there's no public API to clear a single
|
||||
# contact from the cache, and the library only refreshes
|
||||
# it on a full get_contacts() call.
|
||||
#
|
||||
# Why this matters: sync_recent_contacts_to_radio() uses
|
||||
# mc.get_contact_by_key_prefix() to check whether a
|
||||
# contact is already loaded on the radio. That method
|
||||
# searches mc._contacts. If we don't evict the removed
|
||||
# contact from the cache here, get_contact_by_key_prefix()
|
||||
# will still find it and skip the add_contact() call —
|
||||
# meaning contacts never get loaded back onto the radio
|
||||
# after offload. The result: no DM ACKs, degraded routing
|
||||
# for potentially minutes until the next periodic sync
|
||||
# refreshes the cache from the (now-empty) radio.
|
||||
#
|
||||
# We access mc._contacts directly because the library
|
||||
# exposes it as a read-only property (mc.contacts) with
|
||||
# no removal API. The dict is keyed by public_key string.
|
||||
mc._contacts.pop(public_key, None)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
|
||||
|
||||
@@ -555,6 +555,79 @@ class TestSyncAndOffloadContacts:
|
||||
assert contact is not None
|
||||
assert contact.on_radio is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evicts_removed_contacts_from_library_cache(self, test_db):
|
||||
"""Successfully removed contacts are evicted from mc._contacts.
|
||||
|
||||
The MeshCore library's remove_contact() command does not update the
|
||||
library's in-memory _contacts cache. If we don't evict manually,
|
||||
sync_recent_contacts_to_radio() will find stale entries via
|
||||
get_contact_by_key_prefix() and skip re-adding contacts to the radio.
|
||||
"""
|
||||
from app.radio_sync import sync_and_offload_contacts
|
||||
|
||||
contact_payload = {
|
||||
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
|
||||
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
|
||||
}
|
||||
|
||||
mock_get_result = MagicMock()
|
||||
mock_get_result.type = EventType.NEW_CONTACT
|
||||
mock_get_result.payload = contact_payload
|
||||
|
||||
mock_remove_result = MagicMock()
|
||||
mock_remove_result.type = EventType.OK
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
||||
# Seed the library's in-memory cache with the same contacts —
|
||||
# simulating what happens after get_contacts() populates it.
|
||||
mock_mc._contacts = {
|
||||
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
|
||||
KEY_B: {"public_key": KEY_B, "adv_name": "Bob"},
|
||||
}
|
||||
|
||||
await sync_and_offload_contacts(mock_mc)
|
||||
|
||||
# Both contacts should have been evicted from the library cache
|
||||
assert KEY_A not in mock_mc._contacts
|
||||
assert KEY_B not in mock_mc._contacts
|
||||
assert mock_mc._contacts == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_remove_does_not_evict_from_library_cache(self, test_db):
|
||||
"""Contacts that fail to remove from radio stay in mc._contacts.
|
||||
|
||||
We only evict from the cache on successful removal — if the radio
|
||||
still has the contact, the cache should reflect that.
|
||||
"""
|
||||
from app.radio_sync import sync_and_offload_contacts
|
||||
|
||||
contact_payload = {
|
||||
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
|
||||
}
|
||||
|
||||
mock_get_result = MagicMock()
|
||||
mock_get_result.type = EventType.NEW_CONTACT
|
||||
mock_get_result.payload = contact_payload
|
||||
|
||||
mock_fail_result = MagicMock()
|
||||
mock_fail_result.type = EventType.ERROR
|
||||
mock_fail_result.payload = {"error": "busy"}
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_fail_result)
|
||||
mock_mc._contacts = {
|
||||
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
|
||||
}
|
||||
|
||||
await sync_and_offload_contacts(mock_mc)
|
||||
|
||||
# Contact should still be in the cache since removal failed
|
||||
assert KEY_A in mock_mc._contacts
|
||||
|
||||
|
||||
class TestSyncAndOffloadChannels:
|
||||
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""
|
||||
|
||||
Reference in New Issue
Block a user