Correct yet MORE instances of not using a well sourced MC object

This commit is contained in:
Jack Kingsman
2026-02-23 21:46:57 -08:00
parent 31302b4972
commit 2125653978
6 changed files with 69 additions and 193 deletions

View File

@@ -247,23 +247,24 @@ class RadioManager:
if not self._meshcore:
return
self._setup_in_progress = True
mc = self._meshcore
try:
register_event_handlers(self._meshcore)
await export_and_store_private_key(self._meshcore)
register_event_handlers(mc)
await export_and_store_private_key(mc)
# Sync radio clock with system time
await sync_radio_time()
await sync_radio_time(mc)
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all()
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
# Start periodic sync (idempotent)
start_periodic_sync()
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement():
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
logger.debug("Advertisement skipped (disabled or throttled)")
@@ -274,11 +275,11 @@ class RadioManager:
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages()
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
await self._meshcore.start_auto_message_fetching()
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
# Start periodic message polling as fallback (idempotent)

View File

@@ -14,7 +14,7 @@ import logging
import time
from contextlib import asynccontextmanager
from meshcore import EventType
from meshcore import EventType, MeshCore
from app.event_handlers import cleanup_expired_acks
from app.models import Contact
@@ -77,16 +77,11 @@ _sync_task: asyncio.Task | None = None
SYNC_INTERVAL = 300
async def sync_and_offload_contacts() -> dict:
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.warning("Cannot sync contacts: radio not connected")
return {"synced": 0, "removed": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
synced = 0
removed = 0
@@ -137,16 +132,11 @@ async def sync_and_offload_contacts() -> dict:
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels() -> dict:
async def sync_and_offload_channels(mc: MeshCore) -> dict:
"""
Sync channels from radio to database, then clear them from radio.
Returns counts of synced and cleared channels.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.warning("Cannot sync channels: radio not connected")
return {"synced": 0, "cleared": 0, "error": "Radio not connected"}
mc = radio_manager.meshcore
synced = 0
cleared = 0
@@ -227,12 +217,12 @@ async def ensure_default_channels() -> None:
)
async def sync_and_offload_all() -> dict:
async def sync_and_offload_all(mc: MeshCore) -> dict:
"""Sync and offload both contacts and channels, then ensure defaults exist."""
logger.info("Starting full radio sync and offload")
contacts_result = await sync_and_offload_contacts()
channels_result = await sync_and_offload_channels()
contacts_result = await sync_and_offload_contacts(mc)
channels_result = await sync_and_offload_channels(mc)
# Ensure default channels exist
await ensure_default_channels()
@@ -243,17 +233,13 @@ async def sync_and_offload_all() -> dict:
}
async def drain_pending_messages() -> int:
async def drain_pending_messages(mc: MeshCore) -> int:
"""
Drain all pending messages from the radio.
Calls get_msg() repeatedly until NO_MORE_MSGS is received.
Returns the count of messages retrieved.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
return 0
mc = radio_manager.meshcore
count = 0
max_iterations = 100 # Safety limit
@@ -281,7 +267,7 @@ async def drain_pending_messages() -> int:
return count
async def poll_for_messages() -> int:
async def poll_for_messages(mc: MeshCore) -> int:
"""
Poll the radio for any pending messages (single pass).
@@ -290,10 +276,6 @@ async def poll_for_messages() -> int:
Returns the count of messages retrieved.
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
return 0
mc = radio_manager.meshcore
count = 0
try:
@@ -308,7 +290,7 @@ async def poll_for_messages() -> int:
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
count += 1
# If we got a message, there might be more - drain them
count += await drain_pending_messages()
count += await drain_pending_messages(mc)
except asyncio.TimeoutError:
pass
@@ -329,16 +311,14 @@ async def _message_poll_loop():
cleanup_expired_acks()
if radio_manager.is_connected and not is_polling_paused():
mc = radio_manager.meshcore
if mc is not None:
try:
async with radio_manager.radio_operation(
"message_poll_loop",
blocking=False,
):
await poll_for_messages()
except RadioOperationBusyError:
logger.debug("Skipping message poll: radio busy")
try:
async with radio_manager.radio_operation(
"message_poll_loop",
blocking=False,
) as mc:
await poll_for_messages(mc)
except RadioOperationBusyError:
logger.debug("Skipping message poll: radio busy")
except asyncio.CancelledError:
break
@@ -367,21 +347,18 @@ async def stop_message_polling():
logger.info("Stopped periodic message polling")
async def send_advertisement(force: bool = False) -> bool:
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
"""Send an advertisement to announce presence on the mesh.
Respects the configured advert_interval - won't send if not enough time
has elapsed since the last advertisement, unless force=True.
Args:
mc: The MeshCore instance to use for the advertisement.
force: If True, send immediately regardless of interval.
Returns True if successful, False otherwise (including if throttled).
"""
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.debug("Cannot send advertisement: radio not connected")
return False
# Check if enough time has elapsed (unless forced)
if not force:
settings = await AppSettingsRepository.get()
@@ -410,7 +387,7 @@ async def send_advertisement(force: bool = False) -> bool:
return False
try:
result = await radio_manager.meshcore.commands.send_advert(flood=True)
result = await mc.commands.send_advert(flood=True)
if result.type == EventType.OK:
# Update last_advert_time in database
now = int(time.time())
@@ -437,16 +414,14 @@ async def _periodic_advert_loop():
# Try to send - send_advertisement() handles all checks
# (disabled, throttled, not connected)
if radio_manager.is_connected:
mc = radio_manager.meshcore
if mc is not None:
try:
async with radio_manager.radio_operation(
"periodic_advertisement",
blocking=False,
):
await send_advertisement()
except RadioOperationBusyError:
logger.debug("Skipping periodic advertisement: radio busy")
try:
async with radio_manager.radio_operation(
"periodic_advertisement",
blocking=False,
) as mc:
await send_advertisement(mc)
except RadioOperationBusyError:
logger.debug("Skipping periodic advertisement: radio busy")
# Sleep before next check
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
@@ -484,16 +459,11 @@ async def stop_periodic_advert():
logger.info("Stopped periodic advertisement")
async def sync_radio_time() -> bool:
async def sync_radio_time(mc: MeshCore) -> bool:
"""Sync the radio's clock with the system time.
Returns True if successful, False otherwise.
"""
mc = radio_manager.meshcore
if not mc:
logger.debug("Cannot sync time: radio not connected")
return False
try:
now = int(time.time())
await mc.commands.set_time(now)
@@ -509,18 +479,17 @@ async def _periodic_sync_loop():
while True:
try:
await asyncio.sleep(SYNC_INTERVAL)
mc = radio_manager.meshcore
if mc is None:
if not radio_manager.is_connected:
continue
try:
async with radio_manager.radio_operation(
"periodic_sync",
blocking=False,
):
) as mc:
logger.debug("Running periodic radio sync")
await sync_and_offload_all()
await sync_radio_time()
await sync_and_offload_all(mc)
await sync_radio_time(mc)
except RadioOperationBusyError:
logger.debug("Skipping periodic sync: radio busy")
except asyncio.CancelledError:

View File

@@ -104,7 +104,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
)
# Sync time with system clock
await sync_radio_time()
await sync_radio_time(mc)
# Re-fetch self_info so the response reflects the changes we just made.
# Commands like set_name() write to flash but don't update the cached
@@ -168,8 +168,8 @@ async def send_advertisement() -> dict:
require_connected()
logger.info("Sending flood advertisement")
async with radio_manager.radio_operation("manual_advertisement"):
success = await do_send_advertisement(force=True)
async with radio_manager.radio_operation("manual_advertisement") as mc:
success = await do_send_advertisement(mc, force=True)
if not success:
raise HTTPException(status_code=500, detail="Failed to send advertisement")

View File

@@ -452,7 +452,7 @@ class TestPostConnectSetupOrdering:
call_order = []
async def mock_drain():
async def mock_drain(mc):
call_order.append("drain")
return 0
@@ -494,7 +494,7 @@ class TestPostConnectSetupOrdering:
observed_during = None
async def mock_drain():
async def mock_drain(mc):
nonlocal observed_during
observed_during = rm.is_setup_in_progress
return 0

View File

@@ -237,7 +237,7 @@ class TestAdvertise:
active = 0
max_active = 0
async def fake_send(*, force: bool):
async def fake_send(mc, *, force: bool):
nonlocal active, max_active
assert force is True
active += 1

View File

@@ -188,31 +188,21 @@ class TestPollingPause:
class TestSyncRadioTime:
"""Test the radio time sync function."""
@pytest.mark.asyncio
async def test_returns_false_when_not_connected(self):
"""sync_radio_time returns False when radio is not connected."""
with patch("app.radio_sync.radio_manager") as mock_manager:
mock_manager.meshcore = None
result = await sync_radio_time()
assert result is False
@pytest.mark.asyncio
async def test_returns_true_on_success(self):
"""sync_radio_time returns True when time is set successfully."""
mock_mc = MagicMock()
mock_mc.commands.set_time = AsyncMock()
with patch("app.radio_sync.radio_manager") as mock_manager:
mock_manager.meshcore = mock_mc
result = await sync_radio_time()
result = await sync_radio_time(mock_mc)
assert result is True
mock_mc.commands.set_time.assert_called_once()
# Verify timestamp is reasonable (within last few seconds)
call_args = mock_mc.commands.set_time.call_args[0][0]
import time
assert result is True
mock_mc.commands.set_time.assert_called_once()
# Verify timestamp is reasonable (within last few seconds)
call_args = mock_mc.commands.set_time.call_args[0][0]
import time
assert abs(call_args - int(time.time())) < 5
assert abs(call_args - int(time.time())) < 5
@pytest.mark.asyncio
async def test_returns_false_on_exception(self):
@@ -220,11 +210,9 @@ class TestSyncRadioTime:
mock_mc = MagicMock()
mock_mc.commands.set_time = AsyncMock(side_effect=Exception("Radio error"))
with patch("app.radio_sync.radio_manager") as mock_manager:
mock_manager.meshcore = mock_mc
result = await sync_radio_time()
result = await sync_radio_time(mock_mc)
assert result is False
assert result is False
class TestSyncRecentContactsToRadio:
@@ -429,21 +417,6 @@ class TestSyncRecentContactsToRadio:
class TestSyncAndOffloadContacts:
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
@pytest.mark.asyncio
async def test_returns_error_when_not_connected(self):
"""Returns error dict when radio is not connected."""
from app.radio_sync import sync_and_offload_contacts
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
result = await sync_and_offload_contacts()
assert result["synced"] == 0
assert result["removed"] == 0
assert "error" in result
@pytest.mark.asyncio
async def test_syncs_and_removes_contacts(self, test_db):
"""Contacts are upserted to DB and removed from radio."""
@@ -465,11 +438,7 @@ class TestSyncAndOffloadContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_contacts()
result = await sync_and_offload_contacts(mock_mc)
assert result["synced"] == 2
assert result["removed"] == 2
@@ -509,11 +478,7 @@ class TestSyncAndOffloadContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
await sync_and_offload_contacts()
await sync_and_offload_contacts(mock_mc)
# Verify the prefix message was claimed (promoted to full key)
messages = await MessageRepository.get_all(conversation_key=KEY_A)
@@ -546,11 +511,7 @@ class TestSyncAndOffloadContacts:
# First remove fails, second succeeds
mock_mc.commands.remove_contact = AsyncMock(side_effect=[mock_fail_result, mock_ok_result])
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_contacts()
result = await sync_and_offload_contacts(mock_mc)
# Both contacts synced, but only one removed successfully
assert result["synced"] == 2
@@ -571,11 +532,7 @@ class TestSyncAndOffloadContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(side_effect=Exception("Timeout"))
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_contacts()
result = await sync_and_offload_contacts(mock_mc)
assert result["synced"] == 1
assert result["removed"] == 0
@@ -592,11 +549,7 @@ class TestSyncAndOffloadContacts:
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_error_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_contacts()
result = await sync_and_offload_contacts(mock_mc)
assert result["synced"] == 0
assert result["removed"] == 0
@@ -620,11 +573,7 @@ class TestSyncAndOffloadContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
await sync_and_offload_contacts()
await sync_and_offload_contacts(mock_mc)
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
@@ -634,21 +583,6 @@ class TestSyncAndOffloadContacts:
class TestSyncAndOffloadChannels:
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""
@pytest.mark.asyncio
async def test_returns_error_when_not_connected(self):
"""Returns error dict when radio is not connected."""
from app.radio_sync import sync_and_offload_channels
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
result = await sync_and_offload_channels()
assert result["synced"] == 0
assert result["cleared"] == 0
assert "error" in result
@pytest.mark.asyncio
async def test_syncs_valid_channel_and_clears(self, test_db):
"""Valid channel is upserted to DB and cleared from radio."""
@@ -672,11 +606,7 @@ class TestSyncAndOffloadChannels:
clear_result.type = EventType.OK
mock_mc.commands.set_channel = AsyncMock(return_value=clear_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_channels()
result = await sync_and_offload_channels(mock_mc)
assert result["synced"] == 1
assert result["cleared"] == 1
@@ -708,11 +638,7 @@ class TestSyncAndOffloadChannels:
side_effect=[empty_name_result] + [other_result] * 39
)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_channels()
result = await sync_and_offload_channels(mock_mc)
assert result["synced"] == 0
assert result["cleared"] == 0
@@ -737,11 +663,7 @@ class TestSyncAndOffloadChannels:
side_effect=[zero_key_result] + [other_result] * 39
)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_channels()
result = await sync_and_offload_channels(mock_mc)
assert result["synced"] == 0
@@ -767,11 +689,7 @@ class TestSyncAndOffloadChannels:
clear_result.type = EventType.OK
mock_mc.commands.set_channel = AsyncMock(return_value=clear_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
await sync_and_offload_channels()
await sync_and_offload_channels(mock_mc)
channel = await ChannelRepository.get_by_key("8B3387E9C5CDEA6AC9E5EDBAA115CD72")
assert channel is not None
@@ -799,11 +717,7 @@ class TestSyncAndOffloadChannels:
clear_result.type = EventType.OK
mock_mc.commands.set_channel = AsyncMock(return_value=clear_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
await sync_and_offload_channels()
await sync_and_offload_channels(mock_mc)
mock_mc.commands.set_channel.assert_called_once_with(
channel_idx=0,
@@ -841,11 +755,7 @@ class TestSyncAndOffloadChannels:
mock_mc.commands.set_channel = AsyncMock(side_effect=[fail_result, ok_result])
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_channels()
result = await sync_and_offload_channels(mock_mc)
assert result["synced"] == 2
assert result["cleared"] == 1
@@ -861,11 +771,7 @@ class TestSyncAndOffloadChannels:
mock_mc = MagicMock()
mock_mc.commands.get_channel = AsyncMock(return_value=empty_result)
with patch("app.radio_sync.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
result = await sync_and_offload_channels()
result = await sync_and_offload_channels(mock_mc)
assert mock_mc.commands.get_channel.call_count == 40
assert result["synced"] == 0