Add repeater region display. Closes #309.

This commit is contained in:
jkingsman
2026-07-09 16:37:14 -07:00
parent 387c9b0e0a
commit 6c977f9108
10 changed files with 459 additions and 12 deletions
+29
View File
@@ -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."""
+137
View File
@@ -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."""
+5
View File
@@ -33,6 +33,7 @@ import type {
RepeaterNodeInfoResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterRegionsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
TelemetrySchedule,
@@ -438,6 +439,10 @@ export const api = {
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
method: 'POST',
}),
repeaterRegions: (publicKey: string) =>
fetchJson<RepeaterRegionsResponse>(`/contacts/${publicKey}/repeater/regions`, {
method: 'POST',
}),
repeaterLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
+16 -7
View File
@@ -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({
</div>
</div>
{/* Remaining panes: ACL | Owner Info + Actions */}
{/* Remaining panes: ACL + Regions | Owner Info + Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl')}
disabled={anyLoading}
/>
<div className="flex flex-col gap-4">
<AclPane
data={paneData.acl}
state={paneStates.acl}
onRefresh={() => refreshPane('acl')}
disabled={anyLoading}
/>
<RegionsPane
data={paneData.regions}
state={paneStates.regions}
onRefresh={() => refreshPane('regions')}
disabled={anyLoading}
/>
</div>
<div className="flex flex-col gap-4">
<OwnerInfoPane
data={paneData.ownerInfo}
@@ -0,0 +1,74 @@
import { RepeaterPane, NotFetched } from './repeaterPaneShared';
import { cn } from '@/lib/utils';
import type { RepeaterRegionsResponse, PaneState } from '../../types';
export function RegionsPane({
data,
state,
onRefresh,
disabled,
}: {
data: RepeaterRegionsResponse | null;
state: PaneState;
onRefresh: () => 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 (
<RepeaterPane
title="Regions"
state={state}
onRefresh={onRefresh}
disabled={disabled}
headerNote={headerNote}
>
{!data ? (
<NotFetched />
) : data.regions.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No regions returned. The repeater may be unreachable, or full region details require admin
access.
</p>
) : (
<div className="space-y-0.5">
{data.regions.map((region, index) => (
<div
key={`${region.depth}-${region.name}-${index}`}
className="flex items-center gap-2 text-sm py-0.5"
style={{ paddingLeft: `${region.depth * 0.9}rem` }}
>
<span className="font-mono truncate">
{region.name === '*' ? ' (all regions)' : region.name}
</span>
{region.is_home && (
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
Home
</span>
)}
<span
className={cn(
'ml-auto shrink-0 text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded',
region.flood_allowed
? 'bg-success/15 text-success'
: 'bg-muted text-muted-foreground'
)}
title={
region.flood_allowed
? 'Flood is allowed for this region'
: 'Flood is blocked for this region'
}
>
{region.flood_allowed ? 'Flood' : 'Blocked'}
</span>
</div>
))}
</div>
)}
</RepeaterPane>
);
}
@@ -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<PaneName, PaneState> {
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<PaneName, PaneState>): Record<Pa
advertIntervals: { ...paneStates.advertIntervals, loading: false },
ownerInfo: { ...paneStates.ownerInfo, loading: false },
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
regions: { ...paneStates.regions, loading: false },
};
}
@@ -174,6 +179,8 @@ function fetchPaneData(publicKey: string, pane: PaneName) {
return api.repeaterOwnerInfo(publicKey);
case 'lppTelemetry':
return api.repeaterLppTelemetry(publicKey);
case 'regions':
return api.repeaterRegions(publicKey);
}
}
@@ -427,6 +434,7 @@ export function useRepeaterDashboard(
'advertIntervals',
'ownerInfo',
'lppTelemetry',
'regions',
];
// Serial execution — parallel calls just queue behind the radio lock anyway
for (const pane of panes) {
+4 -4
View File
@@ -20,8 +20,8 @@ const mockHook: {
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
regions: null,
},
paneStates: {
status: { loading: false, attempt: 0, error: null },
@@ -31,8 +31,8 @@ const mockHook: {
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 },
},
consoleHistory: [],
consoleLoading: false,
@@ -151,8 +151,8 @@ describe('RepeaterDashboard', () => {
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;
@@ -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 () => {
+17 -1
View File
@@ -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;
+161
View File
@@ -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: