mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 09:43:03 +02:00
Try using direct admin-binary fetch
This commit is contained in:
+14
-2
@@ -628,10 +628,22 @@ class RepeaterAdvertIntervalsResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RepeaterOwnerInfoResponse(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")
|
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):
|
class LppSensor(BaseModel):
|
||||||
|
|||||||
@@ -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.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||||
from app.routers.server_control import (
|
from app.routers.server_control import (
|
||||||
batch_cli_fetch,
|
batch_cli_fetch,
|
||||||
|
fetch_repeater_owner_info_binary,
|
||||||
prepare_authenticated_contact_connection,
|
prepare_authenticated_contact_connection,
|
||||||
require_server_capable_contact,
|
require_server_capable_contact,
|
||||||
send_contact_cli_command,
|
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)
|
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
|
||||||
async def repeater_owner_info(public_key: str) -> 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()
|
radio_manager.require_connected()
|
||||||
contact = await _resolve_contact_or_404(public_key)
|
contact = await _resolve_contact_or_404(public_key)
|
||||||
_require_repeater(contact)
|
_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,
|
contact,
|
||||||
"repeater_owner_info",
|
"repeater_owner_info",
|
||||||
[
|
[("get guest.password", "guest_password")],
|
||||||
("get owner.info", "owner_info"),
|
)
|
||||||
("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)
|
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -347,6 +348,105 @@ async def batch_cli_fetch(
|
|||||||
return results
|
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(
|
async def send_contact_cli_command(
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
command: str,
|
command: str,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export function OwnerInfoPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabeledBlock label="Owner Info" value={data.owner_info ?? '—'} />
|
<LabeledBlock label="Owner Info" value={data.owner_info ?? '—'} />
|
||||||
|
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
|
||||||
|
{data.name && <KvRow label="Name" value={data.name} />}
|
||||||
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
|
<KvRow label="Guest Password" value={data.guest_password ?? '—'} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ describe('useRepeaterDashboard', () => {
|
|||||||
});
|
});
|
||||||
mockApi.repeaterOwnerInfo.mockResolvedValueOnce({
|
mockApi.repeaterOwnerInfo.mockResolvedValueOnce({
|
||||||
owner_info: null,
|
owner_info: null,
|
||||||
|
firmware_version: null,
|
||||||
|
name: null,
|
||||||
guest_password: null,
|
guest_password: null,
|
||||||
});
|
});
|
||||||
mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] });
|
mockApi.repeaterLppTelemetry.mockResolvedValueOnce({ sensors: [] });
|
||||||
|
|||||||
@@ -503,6 +503,8 @@ export interface RepeaterAdvertIntervalsResponse {
|
|||||||
|
|
||||||
export interface RepeaterOwnerInfoResponse {
|
export interface RepeaterOwnerInfoResponse {
|
||||||
owner_info: string | null;
|
owner_info: string | null;
|
||||||
|
firmware_version: string | null;
|
||||||
|
name: string | null;
|
||||||
guest_password: string | null;
|
guest_password: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ def _mock_mc():
|
|||||||
mc.commands.req_acl_sync = AsyncMock()
|
mc.commands.req_acl_sync = AsyncMock()
|
||||||
mc.commands.req_telemetry_sync = AsyncMock()
|
mc.commands.req_telemetry_sync = AsyncMock()
|
||||||
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
|
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
|
mc.commands.send_binary_req = AsyncMock(
|
||||||
|
return_value=_radio_result(EventType.MSG_SENT, {"expected_ack": b"\xaa\xbb\xcc\xdd"})
|
||||||
|
)
|
||||||
mc.commands.get_msg = AsyncMock()
|
mc.commands.get_msg = AsyncMock()
|
||||||
mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK))
|
mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK))
|
mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
@@ -1338,39 +1341,45 @@ class TestRepeaterAdvertIntervals:
|
|||||||
class TestRepeaterOwnerInfo:
|
class TestRepeaterOwnerInfo:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_success(self, test_db):
|
async def test_success(self, test_db):
|
||||||
|
# Owner info + firmware + name come from the guest-accessible binary
|
||||||
|
# request (0x07); the guest password still comes from the admin CLI.
|
||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||||
|
|
||||||
responses = [
|
owner_payload = "v1.15.0\nRepeater One\nJohn Doe - Contact: john@example.com"
|
||||||
_radio_result(
|
mc.wait_for_event = AsyncMock(
|
||||||
EventType.CONTACT_MSG_RECV,
|
return_value=_radio_result(
|
||||||
{
|
EventType.BINARY_RESPONSE, {"tag": "aabbccdd", "data": owner_payload.encode().hex()}
|
||||||
"pubkey_prefix": KEY_A[:12],
|
)
|
||||||
"text": "John Doe - Contact: john@example.com",
|
)
|
||||||
"txt_type": 1,
|
mc.commands.get_msg = AsyncMock(
|
||||||
},
|
side_effect=[
|
||||||
),
|
_radio_result(
|
||||||
_radio_result(
|
EventType.CONTACT_MSG_RECV,
|
||||||
EventType.CONTACT_MSG_RECV,
|
{"pubkey_prefix": KEY_A[:12], "text": "guestpw123", "txt_type": 1},
|
||||||
{"pubkey_prefix": KEY_A[:12], "text": "guestpw123", "txt_type": 1},
|
),
|
||||||
),
|
]
|
||||||
]
|
)
|
||||||
mc.commands.get_msg = AsyncMock(side_effect=responses)
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
patch("app.routers.repeaters.radio_manager.require_connected", return_value=mc),
|
||||||
patch.object(radio_manager, "_meshcore", mc),
|
patch.object(radio_manager, "_meshcore", mc),
|
||||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||||
|
patch("app.routers.server_control.asyncio.sleep", new_callable=AsyncMock),
|
||||||
):
|
):
|
||||||
response = await repeater_owner_info(KEY_A)
|
response = await repeater_owner_info(KEY_A)
|
||||||
|
|
||||||
assert response.owner_info == "John Doe - Contact: john@example.com"
|
assert response.owner_info == "John Doe - Contact: john@example.com"
|
||||||
|
assert response.firmware_version == "v1.15.0"
|
||||||
|
assert response.name == "Repeater One"
|
||||||
assert response.guest_password == "guestpw123"
|
assert response.guest_password == "guestpw123"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_timeout_returns_none_fields(self, test_db):
|
async def test_timeout_returns_none_fields(self, test_db):
|
||||||
|
# Older firmware / out of range: no binary response and no CLI response.
|
||||||
mc = _mock_mc()
|
mc = _mock_mc()
|
||||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||||
|
mc.wait_for_event = AsyncMock(return_value=None)
|
||||||
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
|
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
|
||||||
|
|
||||||
clock_ticks = []
|
clock_ticks = []
|
||||||
@@ -1387,8 +1396,75 @@ class TestRepeaterOwnerInfo:
|
|||||||
response = await repeater_owner_info(KEY_A)
|
response = await repeater_owner_info(KEY_A)
|
||||||
|
|
||||||
assert response.owner_info is None
|
assert response.owner_info is None
|
||||||
|
assert response.firmware_version is None
|
||||||
|
assert response.name is None
|
||||||
assert response.guest_password is None
|
assert response.guest_password is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_binary_req_sends_owner_info_type_and_no_cli_owner_command(self, test_db):
|
||||||
|
# Regression guard for #306: owner info must NOT go through the admin-only
|
||||||
|
# CLI 'get owner.info'; it must use the binary 0x07 request instead.
|
||||||
|
mc = _mock_mc()
|
||||||
|
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||||
|
mc.wait_for_event = AsyncMock(
|
||||||
|
return_value=_radio_result(
|
||||||
|
EventType.BINARY_RESPONSE,
|
||||||
|
{"tag": "aabbccdd", "data": b"v1.15.0\nRpt\nowner".hex()},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
|
||||||
|
|
||||||
|
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),
|
||||||
|
):
|
||||||
|
await repeater_owner_info(KEY_A)
|
||||||
|
|
||||||
|
# Binary request issued with the OWNER_INFO (0x07) request type.
|
||||||
|
assert mc.commands.send_binary_req.await_count == 1
|
||||||
|
req_type_arg = mc.commands.send_binary_req.await_args.args[1]
|
||||||
|
assert req_type_arg.value == 0x07
|
||||||
|
# Only the admin-only guest.password goes over CLI — never 'get owner.info'.
|
||||||
|
cli_cmds = [call.args[1] for call in mc.commands.send_cmd.await_args_list]
|
||||||
|
assert "get guest.password" in cli_cmds
|
||||||
|
assert "get owner.info" not in cli_cmds
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOwnerInfoPayload:
|
||||||
|
def test_parses_firmware_name_owner(self):
|
||||||
|
from app.routers.server_control import _parse_owner_info_payload
|
||||||
|
|
||||||
|
result = _parse_owner_info_payload(b"v1.15.0\nMy Repeater\nJane Doe".hex())
|
||||||
|
assert result == {
|
||||||
|
"firmware_version": "v1.15.0",
|
||||||
|
"name": "My Repeater",
|
||||||
|
"owner_info": "Jane Doe",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_owner_info_keeps_internal_newlines(self):
|
||||||
|
from app.routers.server_control import _parse_owner_info_payload
|
||||||
|
|
||||||
|
result = _parse_owner_info_payload(b"v1.15.0\nRpt\nline1\nline2".hex())
|
||||||
|
assert result is not None
|
||||||
|
assert result["owner_info"] == "line1\nline2"
|
||||||
|
|
||||||
|
def test_empty_owner_info_is_none(self):
|
||||||
|
from app.routers.server_control import _parse_owner_info_payload
|
||||||
|
|
||||||
|
result = _parse_owner_info_payload(b"v1.15.0\nRpt\n".hex())
|
||||||
|
assert result is not None
|
||||||
|
assert result["firmware_version"] == "v1.15.0"
|
||||||
|
assert result["owner_info"] is None
|
||||||
|
|
||||||
|
def test_empty_and_bad_input_returns_none(self):
|
||||||
|
from app.routers.server_control import _parse_owner_info_payload
|
||||||
|
|
||||||
|
assert _parse_owner_info_payload("") is None
|
||||||
|
assert _parse_owner_info_payload("nothex!!") is None
|
||||||
|
assert _parse_owner_info_payload(b"\x00\x00".hex()) is None
|
||||||
|
|
||||||
|
|
||||||
def _make_contact(
|
def _make_contact(
|
||||||
public_key: str = KEY_A, name: str = "Repeater", contact_type: int = 2
|
public_key: str = KEY_A, name: str = "Repeater", contact_type: int = 2
|
||||||
|
|||||||
Reference in New Issue
Block a user