diff --git a/app/models.py b/app/models.py index 7b146ef..0021ef7 100644 --- a/app/models.py +++ b/app/models.py @@ -628,10 +628,22 @@ class RepeaterAdvertIntervalsResponse(BaseModel): class RepeaterOwnerInfoResponse(BaseModel): - """Owner info and guest password from a repeater.""" + """Owner info, firmware, and guest password from a repeater. + + ``owner_info``, ``firmware_version``, and ``name`` come from the + guest-accessible binary owner-info request (REQ_TYPE_GET_OWNER_INFO / 0x07). + ``guest_password`` is admin-only and still comes from the CLI, so guests see + ``None`` for it. + """ owner_info: str | None = Field(default=None, description="Owner info string") - guest_password: str | None = Field(default=None, description="Guest password") + firmware_version: str | None = Field( + default=None, description="Firmware version string (from binary owner-info request)" + ) + name: str | None = Field( + default=None, description="Repeater name (from binary owner-info request)" + ) + guest_password: str | None = Field(default=None, description="Guest password (admin only)") class LppSensor(BaseModel): diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 59e82f3..0fe3aaa 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -28,6 +28,7 @@ from app.repository import ContactRepository, RepeaterTelemetryRepository from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 from app.routers.server_control import ( batch_cli_fetch, + fetch_repeater_owner_info_binary, prepare_authenticated_contact_connection, require_server_capable_contact, send_contact_cli_command, @@ -384,20 +385,32 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR @router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse) async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse: - """Fetch owner info and guest password from a repeater via CLI commands.""" + """Fetch owner info, firmware, and guest password from a repeater. + + Owner info + firmware + name come from the guest-accessible binary request + (REQ_TYPE_GET_OWNER_INFO / 0x07), which the firmware serves to any logged-in + client. The guest password is admin-only and still comes from the CLI, so a + guest sees it blank. See issue #306. + """ radio_manager.require_connected() contact = await _resolve_contact_or_404(public_key) _require_repeater(contact) - results = await _batch_cli_fetch( + owner = await fetch_repeater_owner_info_binary(contact) or {} + + # Guest password is admin-only; still fetched via CLI (guests get None). + cli = await _batch_cli_fetch( contact, "repeater_owner_info", - [ - ("get owner.info", "owner_info"), - ("get guest.password", "guest_password"), - ], + [("get guest.password", "guest_password")], + ) + + return RepeaterOwnerInfoResponse( + owner_info=owner.get("owner_info"), + firmware_version=owner.get("firmware_version"), + name=owner.get("name"), + guest_password=cli.get("guest_password"), ) - return RepeaterOwnerInfoResponse(**results) @router.post("/{public_key}/command", response_model=CommandResponse) diff --git a/app/routers/server_control.py b/app/routers/server_control.py index fdd715d..831da18 100644 --- a/app/routers/server_control.py +++ b/app/routers/server_control.py @@ -1,6 +1,7 @@ import asyncio import logging import time +from enum import Enum from typing import TYPE_CHECKING from fastapi import HTTPException @@ -347,6 +348,105 @@ async def batch_cli_fetch( return results +class _RepeaterBinaryReqType(Enum): + """Binary request types not (yet) wrapped by the installed meshcore library. + + ``REQ_TYPE_GET_OWNER_INFO`` (0x07) was added at repeater ``FIRMWARE_VER_LEVEL >= 2``. + The firmware serves it from ``handleRequest`` with no admin gate, so any + logged-in client — including a guest — can fetch it, unlike the CLI + ``get owner.info`` / ``ver`` path which the firmware only routes for admins. + """ + + OWNER_INFO = 0x07 + + +def _parse_owner_info_payload(data_hex: str) -> dict[str, str | None] | None: + """Parse a REQ_TYPE_GET_OWNER_INFO (0x07) binary response payload. + + The repeater replies with ``"{firmware}\\n{name}\\n{owner_info}"`` (the 4-byte + request tag is already stripped by the library's frame parser, so + ``payload["data"]`` starts at the firmware string). ``owner_info`` may itself + contain newlines, so only the first two separators are split. + """ + if not data_hex: + return None + try: + raw = bytes.fromhex(data_hex) + except ValueError: + return None + text = raw.decode("utf-8", "ignore").strip("\x00") + if not text.strip(): + return None + parts = text.split("\n", 2) + firmware = parts[0].strip() if len(parts) > 0 else "" + name = parts[1].strip() if len(parts) > 1 else "" + owner = parts[2].strip() if len(parts) > 2 else "" + return { + "firmware_version": firmware or None, + "name": name or None, + "owner_info": owner or None, + } + + +async def fetch_repeater_owner_info_binary( + contact: Contact, + *, + operation_name: str = "repeater_owner_info_binary", + timeout: float = 10.0, + min_timeout: float = 5.0, +) -> dict[str, str | None] | None: + """Fetch firmware/name/owner via the guest-accessible binary request (0x07). + + This is the path Liam and other apps use to show owner info + firmware for a + guest: a binary ``REQ_TYPE_GET_OWNER_INFO`` request rather than an admin-only + CLI command. Returns ``None`` when the repeater does not answer — older + firmware (level < 2), not logged in, or out of range — so callers can fall + back or leave the fields blank. See issue #306. + """ + async with radio_manager.radio_operation( + operation_name, pause_polling=True, suspend_auto_fetch=True + ) as mc: + # Ensure contact is on radio for reply routing. + await _ensure_on_radio(mc, contact) + await asyncio.sleep(1.0) # settle after add_contact + + send_result = await mc.commands.send_binary_req( + contact.public_key, + _RepeaterBinaryReqType.OWNER_INFO, + timeout=timeout, + min_timeout=min_timeout, + ) + if send_result.type == EventType.ERROR: + logger.debug("owner-info binary req send error: %s", send_result.payload) + return None + + expected_ack = send_result.payload.get("expected_ack") + if expected_ack is None: + logger.debug("owner-info binary req missing expected_ack: %s", send_result.payload) + return None + exp_tag = expected_ack.hex() + + wait_timeout = ( + timeout if timeout > 0 else send_result.payload.get("suggested_timeout", 4000) / 800 + ) + wait_timeout = max(wait_timeout, min_timeout) + + response = await mc.wait_for_event( + EventType.BINARY_RESPONSE, + attribute_filters={"tag": exp_tag}, + timeout=wait_timeout, + ) + if response is None: + logger.info( + "No owner-info binary response from %s within %.1fs", + contact.public_key[:12], + wait_timeout, + ) + return None + + return _parse_owner_info_payload(response.payload.get("data", "")) + + async def send_contact_cli_command( contact: Contact, command: str, diff --git a/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx b/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx index 402c349..5244347 100644 --- a/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx +++ b/frontend/src/components/repeater/RepeaterOwnerInfoPane.tsx @@ -28,6 +28,8 @@ export function OwnerInfoPane({ ) : (