Show node name if we find it in the DB already. Closes #128.

This commit is contained in:
Jack Kingsman
2026-03-30 12:28:26 -07:00
parent 7aa4f76064
commit db248302e9
6 changed files with 49 additions and 3 deletions

View File

@@ -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(

View File

@@ -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,

View File

@@ -846,11 +846,16 @@ export function SettingsRadioSection({
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium capitalize">{result.node_type}</span>
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>

View File

@@ -300,6 +300,7 @@ describe('SettingsModal', () => {
results: [
{
public_key: '11'.repeat(32),
name: null,
node_type: 'repeater',
heard_count: 2,
local_snr: 7.5,

View File

@@ -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;

View File

@@ -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"))