diff --git a/app/models.py b/app/models.py index cf91209..0d97019 100644 --- a/app/models.py +++ b/app/models.py @@ -681,6 +681,10 @@ class RadioDiscoveryResult(BaseModel): """One mesh node heard during a discovery sweep.""" public_key: str = Field(description="Discovered node public key as hex") + name: str | None = Field( + default=None, + description="Known name for this node from contacts DB, if any", + ) node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class") heard_count: int = Field(default=1, description="How many responses were heard from this node") local_snr: float | None = Field( diff --git a/app/routers/radio.py b/app/routers/radio.py index 795c106..1d8d3f8 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -18,6 +18,7 @@ from app.models import ( from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time from app.repository import ContactRepository +from app.services.contact_reconciliation import promote_prefix_contacts_for_contact from app.services.radio_commands import ( KeystoreRefreshError, PathHashModeUnsupportedError, @@ -197,9 +198,23 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) - on_radio=False, ) await ContactRepository.upsert(contact) + promoted_keys = await promote_prefix_contacts_for_contact( + public_key=result.public_key, + log=logger, + ) created = await ContactRepository.get_by_key(result.public_key) if created is not None: broadcast_event("contact", created.model_dump()) + for old_key in promoted_keys: + broadcast_event("contact_deleted", {"public_key": old_key}) + + +async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None: + """Resolve known contact names for discovery results from the DB.""" + for result in results: + contact = await ContactRepository.get_by_key(result.public_key) + if contact is not None and contact.name: + result.name = contact.name @router.get("/config", response_model=RadioConfigResponse) @@ -365,6 +380,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons ), ) await _persist_new_discovery_contacts(results) + await _attach_known_names(results) return RadioDiscoveryResponse( target=request.target, duration_seconds=DISCOVERY_WINDOW_SECONDS, diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 4b3f68e..58611e4 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -846,11 +846,16 @@ export function SettingsRadioSection({ className="rounded-md border border-input bg-background px-3 py-2" >
{result.node_type}
+ )}{result.public_key}
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 7f6c1a1..aa96825 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -300,6 +300,7 @@ describe('SettingsModal', () => { results: [ { public_key: '11'.repeat(32), + name: null, node_type: 'repeater', heard_count: 2, local_snr: 7.5, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 834ed32..2f1d7e0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all'; export interface RadioDiscoveryResult { public_key: string; + name: string | null; node_type: 'repeater' | 'sensor'; heard_count: number; local_snr: number | null; diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index c4acde8..b7eba08 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -2,7 +2,7 @@ import asyncio from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException @@ -375,6 +375,11 @@ class TestDiscoverMesh: return_value=None, ), patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock), + patch( + "app.routers.radio.promote_prefix_contacts_for_contact", + new_callable=AsyncMock, + return_value=[], + ), patch("app.routers.radio.broadcast_event"), ): response = await discover_mesh(RadioDiscoveryRequest(target="repeaters")) @@ -436,18 +441,27 @@ class TestDiscoverMesh: patch( "app.routers.radio.ContactRepository.get_by_key", new_callable=AsyncMock, - side_effect=[None, created_contact], + # 1st: _persist check (not found), 2nd: _persist re-fetch (created), + # 3rd: _attach_known_names lookup + side_effect=[None, created_contact, created_contact], ) as mock_get_by_key, patch( "app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock ) as mock_upsert, + patch( + "app.routers.radio.promote_prefix_contacts_for_contact", + new_callable=AsyncMock, + return_value=[], + ) as mock_promote, patch("app.routers.radio.broadcast_event") as mock_broadcast, ): response = await discover_mesh(RadioDiscoveryRequest(target="repeaters")) assert len(response.results) == 1 + assert response.results[0].name is None # created_contact has no name mock_get_by_key.assert_awaited() mock_upsert.assert_awaited_once() + mock_promote.assert_awaited_once_with(public_key="44" * 32, log=ANY) upsert_arg = mock_upsert.await_args.args[0] assert upsert_arg.public_key == "44" * 32 assert upsert_arg.type == 2 @@ -542,6 +556,11 @@ class TestDiscoverMesh: return_value=None, ), patch("app.routers.radio.ContactRepository.upsert", new_callable=AsyncMock), + patch( + "app.routers.radio.promote_prefix_contacts_for_contact", + new_callable=AsyncMock, + return_value=[], + ), patch("app.routers.radio.broadcast_event"), ): response = await discover_mesh(RadioDiscoveryRequest(target="all"))