Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/meshcore_console/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ class EventType(StrEnum):
# Telemetry
TELEMETRY_RECEIVED = "telemetry_received"

# Repeater admin
REPEATER_LOGIN = "repeater_login"
REPEATER_LOGOUT = "repeater_logout"
REPEATER_COMMAND_RESPONSE = "repeater_command_response"

# EventService events (mesh.* naming)
MESH_CONTACT_NEW = "mesh.contact.new"
MESH_CHANNEL_MESSAGE_NEW = "mesh.channel.message.new"
Expand Down
11 changes: 11 additions & 0 deletions src/meshcore_console/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ class Message:
rssi: int | None = None


@dataclass(slots=True)
class RepeaterLoginState:
peer_name: str
is_admin: bool = False
is_guest: bool = False
keep_alive_interval: int = 0
acl_permissions: int = 0
firmware_ver_level: int | None = None
logged_in_at: datetime = field(default_factory=lambda: datetime.now(UTC))


@dataclass(slots=True)
class Channel:
channel_id: str
Expand Down
22 changes: 21 additions & 1 deletion src/meshcore_console/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import TYPE_CHECKING, Callable, Protocol

from meshcore_console.core.models import Channel, DeviceStatus, Message, Peer
from meshcore_console.core.models import Channel, DeviceStatus, Message, Peer, RepeaterLoginState
from meshcore_console.core.types import MeshEventDict, SendResultDict

if TYPE_CHECKING:
Expand Down Expand Up @@ -96,3 +96,23 @@ def request_telemetry(self, peer_name: str) -> dict:
def get_self_public_key(self) -> str | None:
"""Return this node's public key as a hex string, or None if unavailable."""
...

# -- Repeater admin --------------------------------------------------------

def login_to_repeater(
self, peer_name: str, password: str, *, save_password: bool = False
) -> dict: ...

def guest_login_to_repeater(self, peer_name: str) -> dict: ...

def logout_from_repeater(self, peer_name: str) -> None: ...

def send_repeater_command(self, peer_name: str, command: str) -> dict: ...

def get_repeater_login_state(self, peer_name: str) -> RepeaterLoginState | None: ...

def list_logged_in_repeaters(self) -> list[str]: ...

def get_saved_repeater_password(self, peer_name: str) -> str | None: ...

def delete_saved_repeater_password(self, peer_name: str) -> None: ...
85 changes: 84 additions & 1 deletion src/meshcore_console/meshcore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from uuid import uuid4

from meshcore_console.core.enums import EventType, PayloadType
from meshcore_console.core.models import Channel, DeviceStatus, Message, Peer
from meshcore_console.core.models import Channel, DeviceStatus, Message, Peer, RepeaterLoginState
from meshcore_console.core.radio import rssi_to_signal_percent
from meshcore_console.core.services import MeshcoreService
from meshcore_console.core.types import MeshEventDict, SendResultDict
Expand All @@ -19,6 +19,7 @@
from meshcore_console.meshcore.logging_setup import install_radio_error_handler
from meshcore_console.meshcore.packet_codec import repair_utf8
from meshcore_console.meshcore.packet_store import PacketStore
from meshcore_console.meshcore.repeater_store import RepeaterPasswordStore
from meshcore_console.meshcore.session import PyMCCoreSession
from meshcore_console.meshcore.settings import MeshcoreSettings
from meshcore_console.meshcore.settings_store import SettingsStore
Expand Down Expand Up @@ -69,6 +70,8 @@ def __init__(
self._peer_store = peer_store or PeerStore(self._db)
self._channel_store = channel_store or UIChannelStore(self._db)
self._gps_provider = gps_provider or create_gps_provider()
self._repeater_password_store = RepeaterPasswordStore(self._db)
self._repeater_sessions: dict[str, RepeaterLoginState] = {}
# Load persisted state
self._messages: list[Message] = self._message_store.get_all()
self._channels: dict[str, Channel] = self._channel_store.get_all()
Expand Down Expand Up @@ -193,6 +196,7 @@ def disconnect(self) -> None:
self._session = self._new_session()
self._shutdown_loop()
self._connected = False
self._repeater_sessions.clear()
self._gps_provider.stop()
self._append_event(
{
Expand Down Expand Up @@ -845,6 +849,85 @@ def request_telemetry(self, peer_name: str) -> dict:
)
return result # type: ignore[return-value]

# -- Repeater admin --------------------------------------------------------

def login_to_repeater(
self, peer_name: str, password: str, *, save_password: bool = False
) -> dict:
if not self._connected:
self.connect()
result = self._run_async(
self._session.send_repeater_login(peer_name, password),
timeout=15.0,
)
if result.get("success"):
state = RepeaterLoginState(
peer_name=peer_name,
is_admin=result.get("is_admin", False),
is_guest=password == "",
keep_alive_interval=result.get("keep_alive_interval", 0),
acl_permissions=result.get("acl_permissions", 0),
firmware_ver_level=result.get("firmware_ver_level"),
)
self._repeater_sessions[peer_name] = state
if save_password:
self._repeater_password_store.save_password(peer_name, password)
self._append_event(
{
"type": EventType.REPEATER_LOGIN,
"data": {
"peer_name": peer_name,
"success": result.get("success"),
"is_admin": result.get("is_admin", False),
},
}
)
return result

def guest_login_to_repeater(self, peer_name: str) -> dict:
return self.login_to_repeater(peer_name, password="")

def logout_from_repeater(self, peer_name: str) -> None:
if self._connected:
try:
self._run_async(self._session.send_repeater_logout(peer_name), timeout=5.0)
except (RuntimeError, TimeoutError):
pass
self._repeater_sessions.pop(peer_name, None)
self._append_event({"type": EventType.REPEATER_LOGOUT, "data": {"peer_name": peer_name}})

def send_repeater_command(self, peer_name: str, command: str) -> dict:
if not self._connected:
self.connect()
result = self._run_async(
self._session.send_repeater_cli(peer_name, command),
timeout=20.0,
)
self._append_event(
{
"type": EventType.REPEATER_COMMAND_RESPONSE,
"data": {
"peer_name": peer_name,
"command": command,
"success": result.get("success"),
"response_text": result.get("response_text"),
},
}
)
return result

def get_repeater_login_state(self, peer_name: str) -> RepeaterLoginState | None:
return self._repeater_sessions.get(peer_name)

def list_logged_in_repeaters(self) -> list[str]:
return list(self._repeater_sessions.keys())

def get_saved_repeater_password(self, peer_name: str) -> str | None:
return self._repeater_password_store.get_password(peer_name)

def delete_saved_repeater_password(self, peer_name: str) -> None:
self._repeater_password_store.delete_password(peer_name)

def _get_local_telemetry(self) -> dict:
"""Provide local telemetry data for inbound requests."""
pos = self._resolve_position(use_settings=self._settings.allow_telemetry)
Expand Down
8 changes: 8 additions & 0 deletions src/meshcore_console/meshcore/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@
# Backfill: channels with a peer_name are DM channels
"UPDATE channels SET kind = 'dm' WHERE peer_name IS NOT NULL",
),
# v5 -> v6: add repeater_passwords table
(
"""CREATE TABLE IF NOT EXISTS repeater_passwords (
peer_name TEXT PRIMARY KEY,
password TEXT NOT NULL,
created_at TEXT NOT NULL
)""",
),
]


Expand Down
143 changes: 131 additions & 12 deletions src/meshcore_console/meshcore/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,23 @@
SendResultDict,
)

TXT_TYPE_CLI_DATA = 1

async def send_text(*, node: MeshNodeProtocol, peer_name: str, message: str) -> dict:
"""Send a direct text message to a peer via PacketBuilder."""
from pymc_core.protocol.packet_builder import PacketBuilder

def _resolve_contact(node: MeshNodeProtocol, peer_name: str) -> object:
contacts = node.contacts
contact = None
if contacts is not None:
contact = contacts.get_by_name(peer_name)
if contact is None:
raise RuntimeError(f"Contact '{peer_name}' not found")
if contact is not None:
return contact
raise RuntimeError(f"Contact '{peer_name}' not found")


async def send_text(*, node: MeshNodeProtocol, peer_name: str, message: str) -> dict:
"""Send a direct text message to a peer via PacketBuilder."""
from pymc_core.protocol.packet_builder import PacketBuilder

contact = _resolve_contact(node, peer_name)

pkt, ack_crc = PacketBuilder.create_text_message(
contact=contact,
Expand Down Expand Up @@ -61,12 +67,7 @@ async def request_telemetry(
"""Request telemetry data from a remote peer via PacketBuilder."""
from pymc_core.protocol.packet_builder import PacketBuilder

contacts = node.contacts
contact = None
if contacts is not None:
contact = contacts.get_by_name(contact_name)
if contact is None:
raise RuntimeError(f"Contact '{contact_name}' not found")
contact = _resolve_contact(node, contact_name)

pkt, _ts = PacketBuilder.create_telem_request(
contact=contact,
Expand Down Expand Up @@ -125,6 +126,124 @@ def _on_response(success: bool, text: str, parsed: dict) -> None:
}


async def send_login(
*,
node: MeshNodeProtocol,
peer_name: str,
password: str,
timeout: float = 10.0,
) -> dict:
"""Send a login request to a repeater and wait for the response."""
from pymc_core.protocol.packet_builder import PacketBuilder

contact = _resolve_contact(node, peer_name)

login_handler = node.dispatcher.login_response_handler
dest_hash = bytes.fromhex(contact.public_key)[0]
login_handler.store_login_password(dest_hash, password)

login_event = asyncio.Event()
login_result: dict = {"success": False, "data": {}}

def _on_login(success: bool, data: dict) -> None:
login_result["success"] = success
login_result["data"] = data
login_event.set()

login_handler.set_login_callback(_on_login)
try:
pkt = PacketBuilder.create_login_packet(
contact=contact,
local_identity=node.identity,
password=password,
)
await node.dispatcher.send_packet(pkt, wait_for_ack=False)
await asyncio.wait_for(login_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
return {"success": False, "reason": "Login response timeout"}
finally:
login_handler.set_login_callback(None)
login_handler.clear_login_password(dest_hash)

data = login_result["data"]
return {
"success": login_result["success"],
"repeater": peer_name,
"is_admin": data.get("is_admin", False),
"keep_alive_interval": data.get("keep_alive_interval", 0),
"acl_permissions": data.get("reserved", data.get("permissions", 0)),
"firmware_ver_level": data.get("firmware_ver_level"),
"reason": "Login successful" if login_result["success"] else "Login failed",
}


async def send_logout(*, node: MeshNodeProtocol, peer_name: str) -> dict:
"""Send a logout/disconnect to a repeater."""
from pymc_core.protocol.packet_builder import PacketBuilder

contact = _resolve_contact(node, peer_name)

pkt, _crc = PacketBuilder.create_logout_packet(
contact=contact,
local_identity=node.identity,
)
await node.dispatcher.send_packet(pkt, wait_for_ack=False)
return {"success": True, "repeater": peer_name}


async def send_repeater_command(
*,
node: MeshNodeProtocol,
peer_name: str,
command: str,
timeout: float = 15.0,
) -> dict:
"""Send a CLI command to a repeater and wait for the response."""
from pymc_core.protocol.packet_builder import PacketBuilder

contact = _resolve_contact(node, peer_name)

text_handler = node.dispatcher.text_message_handler
response_event = asyncio.Event()
response_data: dict = {"text": None}

expected_key = contact.public_key

def _on_response(message_text: str, sender_contact: object) -> None:
sender_key = getattr(sender_contact, "public_key", None)
if sender_key is not None and sender_key != expected_key:
return
response_data["text"] = message_text
response_event.set()
Comment on lines +212 to +217
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter command responses by originating repeater

send_repeater_command() installs a global command-response callback and resolves the wait on the first callback invocation, but _on_response ignores the provided sender_contact. If another repeater emits a command response while this request is in flight, the wrong payload can satisfy the wait and be attributed to peer_name, producing cross-repeater response mixups.

Useful? React with πŸ‘Β / πŸ‘Ž.


text_handler.set_command_response_callback(_on_response)
try:
pkt, _crc = PacketBuilder.create_text_message(
contact=contact,
local_identity=node.identity,
message=command,
txt_type=TXT_TYPE_CLI_DATA,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove unsupported txt_type argument from create_text_message

send_repeater_command() passes txt_type=TXT_TYPE_CLI_DATA into PacketBuilder.create_text_message, but the pyMC_core 1.0.10 API for create_text_message only accepts contact, local_identity, message, attempt, message_type, and out_path. On real hardware this call path will raise a TypeError before any packet is sent, so every CLI command from the new Admin view fails at runtime.

Useful? React with πŸ‘Β / πŸ‘Ž.

)
await node.dispatcher.send_packet(pkt, wait_for_ack=False)
await asyncio.wait_for(response_event.wait(), timeout=timeout)
return {
"success": True,
"repeater": peer_name,
"command": command,
"response_text": response_data["text"],
}
except asyncio.TimeoutError:
return {
"success": False,
"repeater": peer_name,
"command": command,
"response_text": None,
"reason": "Command response timeout",
}
finally:
text_handler.set_command_response_callback(None)


async def send_advert(
*,
node: MeshNodeProtocol,
Expand Down
Loading
Loading