From 6c977f91080574d588e4909dff9e993d623caadf Mon Sep 17 00:00:00 2001 From: jkingsman Date: Thu, 9 Jul 2026 16:37:14 -0700 Subject: [PATCH] Add repeater region display. Closes #309. --- app/models.py | 29 ++++ app/routers/repeaters.py | 137 +++++++++++++++ frontend/src/api.ts | 5 + frontend/src/components/RepeaterDashboard.tsx | 23 ++- .../repeater/RepeaterRegionsPane.tsx | 74 ++++++++ frontend/src/hooks/useRepeaterDashboard.ts | 8 + frontend/src/test/repeaterDashboard.test.tsx | 8 +- .../src/test/useRepeaterDashboard.test.ts | 8 + frontend/src/types.ts | 18 +- tests/test_repeater_routes.py | 161 ++++++++++++++++++ 10 files changed, 459 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/repeater/RepeaterRegionsPane.tsx diff --git a/app/models.py b/app/models.py index 7b146ef..e8c3153 100644 --- a/app/models.py +++ b/app/models.py @@ -634,6 +634,35 @@ class RepeaterOwnerInfoResponse(BaseModel): guest_password: str | None = Field(default=None, description="Guest password") +class RepeaterRegionEntry(BaseModel): + """One region from a repeater's region hierarchy dump.""" + + name: str = Field(description="Region name ('*' is the wildcard/global root)") + depth: int = Field(description="Indentation depth in the hierarchy (0 = root)") + flood_allowed: bool = Field(description="True if flood is allowed for this region") + is_home: bool = Field(description="True if this is the repeater's home region") + + +class RepeaterRegionsResponse(BaseModel): + """Region hierarchy and flood permissions from a repeater. + + Primary source is the admin `region` CLI dump — an indented tree capped at + ~160 chars, so large region sets can be truncated (``truncated`` flags this). + When the CLI is unavailable (e.g. guest access), ``source`` is ``"anon"`` and + ``regions`` is the guest-accessible anon request's flat list of flood-allowed + region names only — no hierarchy, no blocked regions, no home marker. See + issue #309. + """ + + regions: list[RepeaterRegionEntry] = Field(default_factory=list) + raw: str | None = Field(default=None, description="Raw CLI dump text as received") + truncated: bool = Field(default=False, description="True if the dump was likely truncated") + source: Literal["cli", "anon"] | None = Field( + default=None, + description="'cli' = full admin hierarchy; 'anon' = guest flood-allowed names only", + ) + + class LppSensor(BaseModel): """A single CayenneLPP sensor reading from req_telemetry_sync.""" diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 59e82f3..a094aad 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -21,6 +21,8 @@ from app.models import ( RepeaterNodeInfoResponse, RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, + RepeaterRegionEntry, + RepeaterRegionsResponse, RepeaterStatusResponse, TelemetryHistoryEntry, ) @@ -400,6 +402,141 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse: return RepeaterOwnerInfoResponse(**results) +# The firmware's `region` dump is written into a fixed ~160-char buffer +# (CommonCLI::handleRegionCmd -> RegionMap::exportTo(reply, 160)), so large +# region sets get truncated. Flag when the reply lands close to that ceiling. +_REGION_DUMP_CAP = 160 + + +def _is_region_name(name: str) -> bool: + """Return True if ``name`` is a valid region name (or the wildcard ``*``). + + Mirrors firmware ``RegionMap::is_name_char``: ``-``, ``$``, ``#``, digits, or + any byte ``>= 'A'``. Crucially this excludes spaces, so a firmware that does + not support regions (older than v1.10) and replies to `region` with + ``"Unknown command"`` is rejected here rather than mis-parsed as a region — + which lets the endpoint fall back to the anon path or an empty result. + """ + if name == "*": + return True + if not name: + return False + return all(c in "-$#0123456789" or ord(c) >= 0x41 for c in name) + + +def _parse_region_dump(text: str) -> tuple[list[RepeaterRegionEntry], bool]: + """Parse the repeater `region` CLI dump into a structured hierarchy. + + Firmware emits an indented tree (``RegionMap::printChildRegions``), one entry + per line: ``{depth spaces}{name}{^ if home}{ F if flood-allowed}``. The root + is the wildcard ``*``. Absence of the trailing `` F`` means flood is denied. + + Lines that are not valid region names are dropped, so an unsupported-command + reply parses to no entries. Returns the parsed entries and a best-effort + ``truncated`` flag (the dump is capped at ~160 chars, and a complete dump + ends every line with a newline). + """ + truncated = len(text) >= _REGION_DUMP_CAP - 2 or ( + text.strip() != "" and not text.endswith("\n") + ) + entries: list[RepeaterRegionEntry] = [] + for line in text.split("\n"): + if line.strip() == "": + continue + depth = len(line) - len(line.lstrip(" ")) + content = line.strip() + flood_allowed = content.endswith(" F") + if flood_allowed: + content = content[:-2].rstrip() + is_home = content.endswith("^") + if is_home: + content = content[:-1] + name = content.strip() + if not _is_region_name(name): + continue + entries.append( + RepeaterRegionEntry( + name=name, depth=depth, flood_allowed=flood_allowed, is_home=is_home + ) + ) + return entries, truncated + + +def _parse_anon_region_names(names: str) -> list[RepeaterRegionEntry]: + """Parse the anon regions request's comma-separated flood-allowed names. + + ``req_regions_sync`` returns the firmware's ``exportNamesTo(REGION_DENY_FLOOD)`` + output: a flat, comma-separated list of the region names where flood is + *allowed* (``*`` is the wildcard). There is no hierarchy or blocked-region + information in this guest-accessible view, so every entry is depth 0 and + flood-allowed. + """ + entries: list[RepeaterRegionEntry] = [] + for raw_name in names.split(","): + name = raw_name.strip().strip("\x00") + if not name: + continue + entries.append(RepeaterRegionEntry(name=name, depth=0, flood_allowed=True, is_home=False)) + return entries + + +async def _fetch_anon_flood_allowed_regions(contact: Contact) -> list[RepeaterRegionEntry] | None: + """Guest-accessible fallback: fetch flood-allowed region names via anon request. + + Returns ``None`` when the repeater does not answer (older firmware, out of + range) so the caller can leave the pane empty. + """ + async with radio_manager.radio_operation( + "repeater_regions_anon", pause_polling=True, suspend_auto_fetch=True + ) as mc: + await _ensure_on_radio(mc, contact) + await asyncio.sleep(1.0) # settle after add_contact + try: + names = await mc.commands.req_regions_sync( + contact.public_key, timeout=10, min_timeout=5 + ) + except Exception as exc: + logger.debug("anon regions request failed for %s: %s", contact.public_key[:12], exc) + return None + + if not names: + return None + return _parse_anon_region_names(names) + + +@router.post("/{public_key}/repeater/regions", response_model=RepeaterRegionsResponse) +async def repeater_regions(public_key: str) -> RepeaterRegionsResponse: + """Fetch the repeater's region hierarchy and flood permissions. + + Primary path is the admin CLI `region` dump (full hierarchy + allowed/blocked + + home; may be truncated by the firmware's ~160-char cap). When the CLI + returns nothing — e.g. guest access, which cannot run CLI commands — it falls + back to the guest-accessible anon regions request, which only yields a flat + list of flood-allowed region names. See issue #309. + """ + radio_manager.require_connected() + contact = await _resolve_contact_or_404(public_key) + _require_repeater(contact) + + results = await _batch_cli_fetch(contact, "repeater_regions", [("region", "regions")]) + raw = results.get("regions") + entries, truncated = _parse_region_dump(raw or "") + # The CLI dump always includes the wildcard root, so a non-empty result means + # the CLI answered. Empty means no CLI reply (guest / timeout) -> try anon. + if entries: + return RepeaterRegionsResponse(regions=entries, raw=raw, truncated=truncated, source="cli") + + anon_entries = await _fetch_anon_flood_allowed_regions(contact) + if anon_entries is not None: + return RepeaterRegionsResponse( + regions=anon_entries, raw=raw, truncated=False, source="anon" + ) + + # Nothing usable from either path (unsupported firmware, guest with no anon + # support, or out of range) -> empty, not a truncated dump. + return RepeaterRegionsResponse(regions=[], raw=raw, truncated=False, source="cli") + + @router.post("/{public_key}/command", response_model=CommandResponse) async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse: """Send a CLI command to a repeater or room server.""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 24c7125..c400eb7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -33,6 +33,7 @@ import type { RepeaterNodeInfoResponse, RepeaterOwnerInfoResponse, RepeaterRadioSettingsResponse, + RepeaterRegionsResponse, RepeaterStatusResponse, TelemetryHistoryEntry, TelemetrySchedule, @@ -438,6 +439,10 @@ export const api = { fetchJson(`/contacts/${publicKey}/repeater/owner-info`, { method: 'POST', }), + repeaterRegions: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/repeater/regions`, { + method: 'POST', + }), repeaterLppTelemetry: (publicKey: string) => fetchJson(`/contacts/${publicKey}/repeater/lpp-telemetry`, { method: 'POST', diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index ce91539..f8ce57d 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -21,6 +21,7 @@ import { NodeInfoPane } from './repeater/RepeaterNodeInfoPane'; import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane'; import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane'; import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane'; +import { RegionsPane } from './repeater/RepeaterRegionsPane'; import { ActionsPane } from './repeater/RepeaterActionsPane'; import { ConsolePane } from './repeater/RepeaterConsolePane'; import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane'; @@ -372,14 +373,22 @@ export function RepeaterDashboard({ - {/* Remaining panes: ACL | Owner Info + Actions */} + {/* Remaining panes: ACL + Regions | Owner Info + Actions */}
- refreshPane('acl')} - disabled={anyLoading} - /> +
+ refreshPane('acl')} + disabled={anyLoading} + /> + refreshPane('regions')} + disabled={anyLoading} + /> +
void; + disabled?: boolean; +}) { + const headerNote = data?.truncated + ? 'List truncated by the radio — showing the first regions only' + : data?.source === 'anon' + ? 'Guest view: flood-allowed regions only (log in as admin for the full hierarchy)' + : 'Region hierarchy and flood permissions'; + + return ( + + {!data ? ( + + ) : data.regions.length === 0 ? ( +

+ No regions returned. The repeater may be unreachable, or full region details require admin + access. +

+ ) : ( +
+ {data.regions.map((region, index) => ( +
+ + {region.name === '*' ? '∗ (all regions)' : region.name} + + {region.is_home && ( + + Home + + )} + + {region.flood_allowed ? 'Flood' : 'Blocked'} + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useRepeaterDashboard.ts b/frontend/src/hooks/useRepeaterDashboard.ts index 7609be7..a32f00b 100644 --- a/frontend/src/hooks/useRepeaterDashboard.ts +++ b/frontend/src/hooks/useRepeaterDashboard.ts @@ -13,6 +13,7 @@ import type { RepeaterAdvertIntervalsResponse, RepeaterOwnerInfoResponse, RepeaterLppTelemetryResponse, + RepeaterRegionsResponse, CommandResponse, } from '../types'; import { @@ -41,6 +42,7 @@ interface PaneData { advertIntervals: RepeaterAdvertIntervalsResponse | null; ownerInfo: RepeaterOwnerInfoResponse | null; lppTelemetry: RepeaterLppTelemetryResponse | null; + regions: RepeaterRegionsResponse | null; } interface RepeaterDashboardCacheEntry { @@ -64,6 +66,7 @@ function createInitialPaneStates(): Record { advertIntervals: { ...INITIAL_PANE_STATE }, ownerInfo: { ...INITIAL_PANE_STATE }, lppTelemetry: { ...INITIAL_PANE_STATE }, + regions: { ...INITIAL_PANE_STATE }, }; } @@ -77,6 +80,7 @@ function createInitialPaneData(): PaneData { advertIntervals: null, ownerInfo: null, lppTelemetry: null, + regions: null, }; } @@ -107,6 +111,7 @@ function normalizePaneStates(paneStates: Record): Record { radioSettings: null, advertIntervals: null, ownerInfo: null, - lppTelemetry: null, + regions: null, }; mockHook.paneStates = { status: { loading: false, attempt: 0, error: null }, @@ -162,8 +162,8 @@ describe('RepeaterDashboard', () => { radioSettings: { loading: false, attempt: 0, error: null }, advertIntervals: { loading: false, attempt: 0, error: null }, ownerInfo: { loading: false, attempt: 0, error: null }, - lppTelemetry: { loading: false, attempt: 0, error: null }, + regions: { loading: false, attempt: 0, error: null }, }; mockHook.consoleHistory = []; mockHook.consoleLoading = false; diff --git a/frontend/src/test/useRepeaterDashboard.test.ts b/frontend/src/test/useRepeaterDashboard.test.ts index ae4b35a..b596902 100644 --- a/frontend/src/test/useRepeaterDashboard.test.ts +++ b/frontend/src/test/useRepeaterDashboard.test.ts @@ -19,6 +19,7 @@ vi.mock('../api', () => ({ repeaterAdvertIntervals: vi.fn(), repeaterOwnerInfo: vi.fn(), repeaterLppTelemetry: vi.fn(), + repeaterRegions: vi.fn(), sendRepeaterCommand: vi.fn(), }, })); @@ -351,6 +352,12 @@ describe('useRepeaterDashboard', () => { guest_password: null, }); mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] }); + mockApi.repeaterRegions.mockResolvedValueOnce({ + regions: [], + raw: null, + truncated: false, + source: 'cli', + }); const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation)); @@ -366,6 +373,7 @@ describe('useRepeaterDashboard', () => { expect(mockApi.repeaterAdvertIntervals).toHaveBeenCalledTimes(1); expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1); expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1); + expect(mockApi.repeaterRegions).toHaveBeenCalledTimes(1); }); it('refreshing neighbors fetches node info first', async () => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 611aa40..f127dd1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -506,6 +506,21 @@ export interface RepeaterOwnerInfoResponse { guest_password: string | null; } +export interface RepeaterRegionEntry { + name: string; + depth: number; + flood_allowed: boolean; + is_home: boolean; +} + +export interface RepeaterRegionsResponse { + regions: RepeaterRegionEntry[]; + raw: string | null; + truncated: boolean; + /** 'cli' = full admin hierarchy; 'anon' = guest flood-allowed names only. */ + source: 'cli' | 'anon' | null; +} + export interface LppSensor { channel: number; type_name: string; @@ -536,7 +551,8 @@ export type PaneName = | 'radioSettings' | 'advertIntervals' | 'ownerInfo' - | 'lppTelemetry'; + | 'lppTelemetry' + | 'regions'; export interface PaneState { loading: boolean; diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index e1ae13f..9cff6a8 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -12,6 +12,8 @@ from app.repository import ContactRepository from app.routers.contacts import request_trace from app.routers.repeaters import ( _batch_cli_fetch, + _parse_anon_region_names, + _parse_region_dump, prepare_repeater_connection, repeater_acl, repeater_advert_intervals, @@ -21,6 +23,7 @@ from app.routers.repeaters import ( repeater_node_info, repeater_owner_info, repeater_radio_settings, + repeater_regions, repeater_status, send_repeater_command, ) @@ -102,6 +105,7 @@ def _mock_mc(): mc.commands.fetch_all_neighbours = AsyncMock() mc.commands.req_acl_sync = AsyncMock() mc.commands.req_telemetry_sync = AsyncMock() + mc.commands.req_regions_sync = AsyncMock(return_value=None) mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) mc.commands.get_msg = AsyncMock() mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK)) @@ -1390,6 +1394,163 @@ class TestRepeaterOwnerInfo: assert response.guest_password is None +class TestParseRegionDump: + def test_parses_indented_hierarchy_with_flags(self): + # depth via indentation, ' F' = flood allowed, '^' = home region. + dump = "* F\n us^ F\n ca F\n ny\n eu\n" + entries, truncated = _parse_region_dump(dump) + assert [(e.name, e.depth, e.flood_allowed, e.is_home) for e in entries] == [ + ("*", 0, True, False), + ("us", 1, True, True), + ("ca", 2, True, False), + ("ny", 2, False, False), + ("eu", 1, False, False), + ] + assert truncated is False + + def test_empty_dump_returns_no_entries(self): + entries, truncated = _parse_region_dump("") + assert entries == [] + assert truncated is False + + def test_unsupported_firmware_reply_is_not_parsed_as_region(self): + # Firmware without region support replies "Unknown command" to `region`. + # The space makes it an invalid region name, so it must yield no entries + # (letting the endpoint fall back to anon / empty rather than show junk). + entries, _ = _parse_region_dump("Unknown command") + assert entries == [] + + def test_flags_truncation_when_no_trailing_newline(self): + # A complete dump ends every line with a newline; a mid-line cut does not. + entries, truncated = _parse_region_dump("* F\n us F\n ca") + assert truncated is True + assert entries[-1].name == "ca" + + def test_flags_truncation_when_near_buffer_cap(self): + dump = "* F\n" + "".join(f" region{i:02d} F\n" for i in range(14)) + assert len(dump) >= 158 + _, truncated = _parse_region_dump(dump) + assert truncated is True + + +class TestParseAnonRegionNames: + def test_parses_comma_separated_flood_allowed_names(self): + entries = _parse_anon_region_names("*,us,ca,") + assert [(e.name, e.depth, e.flood_allowed, e.is_home) for e in entries] == [ + ("*", 0, True, False), + ("us", 0, True, False), + ("ca", 0, True, False), + ] + + def test_empty_and_whitespace_yield_no_entries(self): + assert _parse_anon_region_names("") == [] + assert _parse_anon_region_names(",, ,\x00") == [] + + +class TestRepeaterRegions: + @pytest.mark.asyncio + async def test_success_parses_region_tree(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + dump = "* F\n us^ F\n ca F\n eu\n" + mc.commands.get_msg = AsyncMock( + side_effect=[ + _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": dump, "txt_type": 1}, + ), + ] + ) + + with ( + patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_regions(KEY_A) + + assert [(r.name, r.depth, r.flood_allowed, r.is_home) for r in response.regions] == [ + ("*", 0, True, False), + ("us", 1, True, True), + ("ca", 2, True, False), + ("eu", 1, False, False), + ] + assert response.raw == dump + assert response.truncated is False + assert response.source == "cli" + + @pytest.mark.asyncio + async def test_guest_falls_back_to_anon_flood_allowed_names(self, test_db): + # No CLI reply (guest) -> anon regions request returns flood-allowed names. + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) + mc.commands.req_regions_sync = AsyncMock(return_value="*,us,ca,") + + with ( + patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock(step=6.0)), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_regions(KEY_A) + + assert response.source == "anon" + assert [(r.name, r.depth, r.flood_allowed, r.is_home) for r in response.regions] == [ + ("*", 0, True, False), + ("us", 0, True, False), + ("ca", 0, True, False), + ] + mc.commands.req_regions_sync.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_response_and_no_anon_returns_empty_regions(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) + mc.commands.req_regions_sync = AsyncMock(return_value=None) + + with ( + patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock(step=6.0)), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_regions(KEY_A) + + assert response.regions == [] + assert response.source == "cli" + assert response.truncated is False + + @pytest.mark.asyncio + async def test_unsupported_firmware_reply_degrades_to_anon(self, test_db): + # Old firmware answers `region` with "Unknown command"; the endpoint must + # not surface that as a region and should fall back to the anon path. + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.get_msg = AsyncMock( + side_effect=[ + _radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "Unknown command", "txt_type": 1}, + ), + ] + ) + mc.commands.req_regions_sync = AsyncMock(return_value="*,us,") + + with ( + patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch(_MONOTONIC, side_effect=_advancing_clock()), + patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock), + ): + response = await repeater_regions(KEY_A) + + assert response.source == "anon" + assert [r.name for r in response.regions] == ["*", "us"] + + def _make_contact( public_key: str = KEY_A, name: str = "Repeater", contact_type: int = 2 ) -> Contact: