Try using direct admin-binary fetch

This commit is contained in:
jkingsman
2026-07-09 14:33:59 -07:00
parent 1b83616db4
commit ee32a266db
7 changed files with 231 additions and 24 deletions
+14 -2
View File
@@ -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):
+20 -7
View File
@@ -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)
+100
View File
@@ -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,