Plumb through total neighbor count from radio and expose that in the UI. Closes #310.

This commit is contained in:
Jack Kingsman
2026-07-08 14:09:08 -07:00
parent 7fe15d0fd4
commit a6c64279e8
5 changed files with 50 additions and 2 deletions
+8
View File
@@ -672,6 +672,14 @@ class RepeaterNeighborsResponse(BaseModel):
neighbors: list[NeighborInfo] = Field(
default_factory=list, description="List of neighbors seen by repeater"
)
reported_count: int | None = Field(
default=None,
description=(
"Total neighbor count reported by the repeater firmware, independent of "
"how many entries were actually returned. May exceed len(neighbors) when a "
"multi-chunk fetch is incomplete (dropped follow-up query / duty-cycle throttle)."
),
)
class RepeaterAclResponse(BaseModel):
+2 -1
View File
@@ -265,7 +265,8 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
)
)
return RepeaterNeighborsResponse(neighbors=neighbors)
reported_count = neighbors_data.get("neighbours_count") if neighbors_data else None
return RepeaterNeighborsResponse(neighbors=neighbors, reported_count=reported_count)
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
@@ -203,7 +203,13 @@ export function NeighborsPane({
return (
<RepeaterPane
title="Neighbors"
title={
!data
? 'Neighbors'
: data.reported_count != null && data.reported_count !== data.neighbors.length
? `Neighbors (${data.neighbors.length} of ${data.reported_count})`
: `Neighbors (${data.reported_count ?? data.neighbors.length})`
}
headerNote={headerNote}
state={state}
onRefresh={onRefresh}
+4
View File
@@ -467,6 +467,10 @@ export interface RepeaterStatusResponse {
export interface RepeaterNeighborsResponse {
neighbors: NeighborInfo[];
// Total neighbor count reported by the repeater firmware, independent of how many
// entries were actually returned. Exceeds neighbors.length when a multi-chunk fetch
// is incomplete. Null on older firmware / failed fetches.
reported_count?: number | null;
}
export interface RepeaterAclResponse {
+29
View File
@@ -873,6 +873,35 @@ class TestRepeaterNeighbors:
assert response.neighbors[0].snr == 9.0
assert response.neighbors[1].name is None
assert response.neighbors[1].last_heard_seconds == 120
# No firmware-reported total in this payload → reported_count stays None.
assert response.reported_count is None
@pytest.mark.asyncio
async def test_reported_count_captured_on_partial_fetch(self, test_db):
"""Firmware neighbours_count is surfaced even when fewer entries return."""
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
# Repeater claims 30 neighbours but only 2 came back this fetch (dropped
# multi-chunk follow-up); reported_count must reflect the true total.
mc.commands.fetch_all_neighbours = AsyncMock(
return_value={
"neighbours_count": 30,
"results_count": 2,
"neighbours": [
{"pubkey": "aaaaaaaaaaaa", "snr": 9.0, "secs_ago": 5},
{"pubkey": "cccccccccccc", "snr": 3.0, "secs_ago": 120},
],
}
)
with (
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await repeater_neighbors(KEY_A)
assert len(response.neighbors) == 2
assert response.reported_count == 30
@pytest.mark.asyncio
async def test_empty_neighbors(self, test_db):