feat(meshcore): route MeshCore cells to room servers (open rooms) (#110)

Extends MeshCore routing so a region_routes `mc` cell can target a room
server, not just a `#`-channel. A room = a contact with type==3; a cell
value of `room:<pubkey>` routes to it via the existing addressed DM path
(send_msg to the room's pubkey), with an optional login for
password-protected rooms; a bare cell value stays a channel broadcast
(unchanged). Adds transport get_rooms()/login_to_room()/send_to_room_async,
a GET /api/meshcore/rooms endpoint, per-room password storage in
secrets_store (env MESHCORE_ROOM_<prefix>_PWD), and a routing-GUI
channel-vs-room picker (rooms shown by name with a path indicator).

Open rooms work end-to-end. Password-protected rooms need a follow-up:
a backend endpoint to SET the per-room password from the GUI (the generic
secrets API is allowlist-gated); the storage + login already exist.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-09 22:14:42 -06:00 committed by GitHub
commit 2e7b3d6934
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1029 additions and 14 deletions

View file

@ -606,6 +606,24 @@ export async function getMeshcoreChannelsDetail(): Promise<MeshcoreChannelsDetai
return fetchJson<MeshcoreChannelsDetail>('/api/meshcore/channels/detail')
}
// MeshCore room servers (type-3 contacts). A routing cell targets a room with
// the value ``room:<pubkey>`` (vs a bare channel name for channel targets).
// ``active:false`` / [] when MeshCore is not connected.
export interface MeshcoreRoom {
name: string | null
pubkey: string
prefix: string
path_established: boolean
}
export interface MeshcoreRooms {
active: boolean
rooms: MeshcoreRoom[]
}
export async function getMeshcoreRooms(): Promise<MeshcoreRooms> {
return fetchJson<MeshcoreRooms>('/api/meshcore/rooms')
}
export interface MeshcoreContact {
name: string | null
pubkey: string

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { useDirty } from '@/context/DirtyContext'
import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink } from 'lucide-react'
import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink, Home, Hash } from 'lucide-react'
import {
SeverityChannelMatrix,
ListInput,
@ -14,6 +14,7 @@ import {
type RegionCell,
type RegionRoutes,
} from './Notifications'
import { getMeshcoreRooms, type MeshcoreRoom } from '@/lib/api'
// Merge only the MeshCore-owned fields of `mine` into `fresh`, preserving every
// other (Meshtastic / Other-channels / general) field on the family. The
@ -89,6 +90,12 @@ function mergeRegionRoutesForMc(
return { mt_enabled, mc_enabled, cells: newCells }
}
// TODO: room password UI needs a backend secrets endpoint. secrets_store.py
// has set/get/clear_room_password() keyed by room pubkey, but NO HTTP route
// exposes them (the generic /api/secrets endpoint is gated to a fixed
// SECRET_LABELS allowlist, so it cannot set a dynamic per-room password).
// Open rooms route fine without a password; password-protected rooms need
// that endpoint before a set/clear affordance can be added here.
export default function MeshCoreRouting() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<NotificationsConfig | null>(null)
@ -99,6 +106,10 @@ export default function MeshCoreRouting() {
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
// MeshCore room servers, for the channel-vs-room cell picker. Rooms are
// targeted by writing the cell value as `room:<pubkey>`; channels stay bare.
const [rooms, setRooms] = useState<MeshcoreRoom[]>([])
const [roomsActive, setRoomsActive] = useState(false)
// Local map: family key -> explicitly toggled on/off for region expand.
const [regionExpandedMap, setRegionExpandedMap] = useState<Record<string, boolean | undefined>>({})
@ -129,6 +140,22 @@ export default function MeshCoreRouting() {
fetchConfig()
}, [fetchConfig])
// Load MeshCore room servers once; degrade quietly if MeshCore is offline.
const fetchRooms = useCallback(async () => {
try {
const res = await getMeshcoreRooms()
setRooms(res.active ? res.rooms : [])
setRoomsActive(res.active)
} catch {
setRooms([])
setRoomsActive(false)
}
}, [])
useEffect(() => {
fetchRooms()
}, [fetchRooms])
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
@ -152,6 +179,14 @@ export default function MeshCoreRouting() {
})
}
// A cell value is either a bare channel NAME or `room:<pubkey>`.
const ROOM_PREFIX = 'room:'
const isRoomValue = (v: string | null | undefined): boolean =>
typeof v === 'string' && v.startsWith(ROOM_PREFIX)
const roomPubkeyOf = (v: string): string => v.slice(ROOM_PREFIX.length)
const roomByPubkey = (pubkey: string): MeshcoreRoom | undefined =>
rooms.find((r) => r.pubkey === pubkey)
const setMcForRegion = (family: string, region: string, mc: string | null) => {
if (!config) return
const existing: RegionCell = config.region_routes?.cells?.[family]?.[region] ?? {
@ -428,19 +463,89 @@ export default function MeshCoreRouting() {
const cell: RegionCell = familyCells[region] ?? {
mt: null, mc: null, min_severity: 'routine', enabled: true,
}
const mcVal = cell.mc ?? ''
const targetsRoom = isRoomValue(mcVal)
const room = targetsRoom ? roomByPubkey(roomPubkeyOf(mcVal)) : undefined
return (
<div key={region} className="flex items-center gap-2">
<span className="text-xs text-slate-400 flex-1 min-w-0 truncate">{region}</span>
{/* Destination type: channel (bare name) vs room (room:<pubkey>) */}
<div className="flex border border-[#1e2a3a] rounded overflow-hidden">
<button
type="button"
title="Target a channel"
onClick={() => {
// Switching to channel: clear a room value, keep a channel value.
if (targetsRoom) setMcForRegion(key, region, null)
}}
className={`px-1.5 py-1 flex items-center ${
!targetsRoom ? 'bg-accent text-white' : 'text-slate-500 hover:text-slate-300'
}`}
>
<Hash size={12} />
</button>
<button
type="button"
title={roomsActive ? 'Target a room server' : 'MeshCore not connected'}
disabled={!roomsActive && !targetsRoom}
onClick={() => {
// Switching to room: clear a channel value so the room <select>
// starts from its placeholder.
if (!targetsRoom) setMcForRegion(key, region, null)
}}
className={`px-1.5 py-1 flex items-center ${
targetsRoom ? 'bg-accent text-white' : 'text-slate-500 hover:text-slate-300'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<Home size={12} />
</button>
</div>
{targetsRoom ? (
roomsActive || room ? (
<div className="flex items-center gap-1 w-40">
<select
value={room ? room.pubkey : ''}
onChange={(e) => {
const pk = e.target.value
setMcForRegion(key, region, pk === '' ? null : ROOM_PREFIX + pk)
}}
className="flex-1 min-w-0 px-1.5 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 focus:outline-none focus:border-accent"
>
<option value="">room</option>
{/* Keep an unknown/offline room's pubkey selectable so its value isn't silently dropped. */}
{!room && targetsRoom && (
<option value={roomPubkeyOf(mcVal)}>{roomPubkeyOf(mcVal).slice(0, 10)}</option>
)}
{rooms.map((r) => (
<option key={r.pubkey} value={r.pubkey}>
{r.name || r.pubkey.slice(0, 10) + '…'}
</option>
))}
</select>
{room && (
<span
title={room.path_established ? 'Path established' : 'No path yet — first send discovers it'}
className={`text-[10px] ${room.path_established ? 'text-green-500' : 'text-slate-600'}`}
>
</span>
)}
</div>
) : (
<span className="w-40 text-xs text-slate-600 italic truncate">MeshCore not connected</span>
)
) : (
<input
type="text"
value={cell.mc ?? ''}
value={mcVal}
onChange={(e) => {
const v = e.target.value
setMcForRegion(key, region, v === '' ? null : v)
}}
placeholder="channel"
className="w-28 px-2 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 font-mono focus:outline-none focus:border-accent"
className="w-40 px-2 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
)}
</div>
)
})}

View file

@ -57,6 +57,25 @@ async def meshcore_channels_detail(request: Request):
return {"active": False, "channels": []}
@router.get("/meshcore/rooms")
async def meshcore_rooms(request: Request):
"""List MeshCore room servers if a meshcore transport is connected.
Returns {"active": bool, "rooms": [{"name", "pubkey", "prefix",
"path_established"}]}. Parallels /meshcore/channels the frontend uses
this to offer room targets for the ``room:<pubkey>`` routing cell.
"""
connector = getattr(request.app.state, "connector", None)
mc = _find_child(connector, "meshcore")
if mc is not None and getattr(mc, "connected", False):
try:
rooms = list(mc.get_rooms())
except Exception:
rooms = []
return {"active": True, "rooms": rooms}
return {"active": False, "rooms": []}
@router.get("/meshcore/contacts")
async def meshcore_contacts(request: Request):
"""Roster of known MeshCore contacts if a meshcore transport is connected."""

View file

@ -21,6 +21,27 @@ from meshai.notifications.renderers import MeshRenderer, EmailRenderer, WebhookR
logger = logging.getLogger(__name__)
# A MeshCore routing cell value of ``room:<pubkey>`` means "route this send to
# the room server with this pubkey" (an addressed send, login-if-password),
# instead of a channel broadcast. A bare value stays a channel NAME. The parse
# lives in ONE place: parse_meshcore_room() below, used by MeshCoreBroadcastChannel.
MESHCORE_ROOM_PREFIX = "room:"
def parse_meshcore_room(cell_value: Optional[str]) -> Optional[str]:
"""Return the room pubkey if *cell_value* is a ``room:<pubkey>`` cell, else None.
A bare (non-prefixed) value is a channel NAME and yields None so callers
fall through to the channel-broadcast path unchanged. An empty pubkey
(``"room:"`` with nothing after) also yields None (nothing to route to).
"""
if not cell_value or not isinstance(cell_value, str):
return None
if not cell_value.startswith(MESHCORE_ROOM_PREFIX):
return None
pubkey = cell_value[len(MESHCORE_ROOM_PREFIX):].strip()
return pubkey or None
class NotificationChannel(ABC):
"""Base class for notification delivery channels."""
@ -198,8 +219,49 @@ class MeshCoreBroadcastChannel(NotificationChannel):
# Single-transport: check for an explicit transport_name tag.
return getattr(self._connector, "transport_name", None) == "meshcore"
@staticmethod
def _room_password(pubkey: str) -> Optional[str]:
"""Look up this room's configured password (or None) via secrets_store.
Kept as a thin wrapper so the lookup convention lives with the secrets
model and the import stays lazy (secrets_store touches the filesystem).
Never raises a missing/unreadable secret degrades to "open room".
"""
try:
from meshai.secrets_store import get_room_password
return get_room_password(pubkey)
except Exception:
logger.debug("meshcore_broadcast: room password lookup failed", exc_info=True)
return None
async def _deliver_to_room(self, pubkey: str, alert: "NotificationPayload") -> bool:
"""Route this alert to a room server (addressed send, login-if-password)."""
password = self._room_password(pubkey)
# If payload already has chunk metadata (from digest), send as-is;
# otherwise render to chunks exactly like the channel-broadcast path.
if alert.chunk_index is not None:
chunks = [alert.message or ""]
else:
chunks = self._renderer.render(alert)
success = True
for chunk in chunks:
ok = await self._connector.send_message_async(
text=chunk,
destination=None,
meshcore_room=pubkey,
meshcore_room_password=password,
transport="meshcore",
)
if not ok:
success = False
logger.info(
"MeshCore room send %d chunk(s) to room %s (success=%s)",
len(chunks), pubkey[:12], success,
)
return success
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
"""Send alert to MeshCore channel."""
"""Send alert to MeshCore channel (or room server if cell is ``room:<pubkey>``)."""
if not self._connector:
logger.warning("No mesh connector available for meshcore_broadcast")
return False
@ -214,6 +276,17 @@ class MeshCoreBroadcastChannel(NotificationChannel):
)
return False
# Room-server routing: a ``room:<pubkey>`` cell delivers via addressed
# send (login-if-password) instead of a channel broadcast. A bare cell
# is a channel name -> None -> the unchanged broadcast path below.
room_pubkey = parse_meshcore_room(self._meshcore_channel)
if room_pubkey is not None:
try:
return await self._deliver_to_room(room_pubkey, alert)
except Exception as e:
logger.error("Failed to MeshCore room-send alert: %s", e)
return False
try:
# If payload already has chunk metadata (from digest), use message directly
if alert.chunk_index is not None:
@ -267,11 +340,25 @@ class MeshCoreBroadcastChannel(NotificationChannel):
}
async def deliver_test(self, message: str) -> tuple[bool, str]:
"""Deliver a specific test message to the MeshCore channel."""
"""Deliver a specific test message to the MeshCore channel (or room)."""
if not self._connector:
return False, "Not connected"
if not self._meshcore_channel:
return False, "No MeshCore channel configured"
room_pubkey = parse_meshcore_room(self._meshcore_channel)
if room_pubkey is not None:
try:
ok = bool(await self._connector.send_message_async(
text=message,
destination=None,
meshcore_room=room_pubkey,
meshcore_room_password=self._room_password(room_pubkey),
transport="meshcore",
))
return ok, (f"Sent to MeshCore room {room_pubkey[:12]}" if ok
else "MeshCore room send returned False")
except Exception as e:
return False, f"MeshCore room send failed: {e}"
try:
ok = bool(await self._connector.send_message_async(
text=message,

View file

@ -140,3 +140,105 @@ def list_secrets(config_dir: Path = Path("/data/config")) -> list[dict]:
}
for var in _managed_vars()
]
# ---------------------------------------------------------------------------
# MeshCore room-server passwords (dynamic, per-room secrets)
# ---------------------------------------------------------------------------
#
# Unlike the fixed SECRET_LABELS allowlist above, room passwords are keyed by
# the room server's public key, so the env var name is DERIVED from the pubkey
# rather than drawn from a static vocabulary. Each password is stored in the
# same secrets .env file under a per-room var name:
#
# MESHCORE_ROOM_<PREFIX>_PWD
#
# where <PREFIX> is the first ROOM_PUBKEY_PREFIX_LEN hex chars of the room's
# public key, uppercased. The transport resolves the password by pubkey at
# send time (login-before-send); the GUI/API sets it via set_room_password().
#
# The routing cell NEVER holds the password — only ``room:<pubkey>``.
ROOM_PUBKEY_PREFIX_LEN = 12
_ROOM_PWD_PREFIX = "MESHCORE_ROOM_"
_ROOM_PWD_SUFFIX = "_PWD"
def room_pwd_env_var(pubkey: str) -> str:
"""Derive the .env var name for a room server's password from its pubkey.
``pubkey`` may be a full 32-byte hex key or a shorter prefix; the first
ROOM_PUBKEY_PREFIX_LEN hex chars are used (uppercased) so a cell holding a
prefix and a cell holding the full key resolve to the SAME secret.
Raises ValueError on an empty pubkey.
"""
pk = (pubkey or "").strip()
if not pk:
raise ValueError("room pubkey must be non-empty")
prefix = pk[:ROOM_PUBKEY_PREFIX_LEN].upper()
return f"{_ROOM_PWD_PREFIX}{prefix}{_ROOM_PWD_SUFFIX}"
def get_room_password(
pubkey: str, config_dir: Path = Path("/data/config")
) -> str | None:
"""Resolve a room server's password by pubkey, or None if none is set.
Resolution mirrors config_loader._interpolate_env_vars: os.environ takes
precedence over the .env file. Returns the raw value for internal use
(transport login-before-send) do NOT expose this via a status API; the
GUI status path must use ``room_password_is_set`` (booleans only).
"""
try:
var = room_pwd_env_var(pubkey)
except ValueError:
return None
val = os.environ.get(var)
if val:
return val
path = secret_env_path(config_dir)
try:
env_file = dotenv_values(path) if path.exists() else {}
except Exception:
env_file = {}
val = env_file.get(var)
return val or None
def room_password_is_set(
pubkey: str, config_dir: Path = Path("/data/config")
) -> bool:
"""True if a password is configured for this room (never returns the value)."""
return get_room_password(pubkey, config_dir) is not None
def set_room_password(
pubkey: str, value: str, config_dir: Path = Path("/data/config")
) -> None:
"""Store a room server's password (GUI/API write path).
Keyed by pubkey via room_pwd_env_var(). Raises ValueError on empty pubkey.
An empty value deletes the entry (mirrors "clear the password").
"""
var = room_pwd_env_var(pubkey)
path = secret_env_path(config_dir)
if value:
set_key(str(path), var, value)
else:
try:
unset_key(str(path), var)
except (KeyError, FileNotFoundError):
pass
def delete_room_password(
pubkey: str, config_dir: Path = Path("/data/config")
) -> None:
"""Remove a room server's password from the secrets .env (no-op if absent)."""
var = room_pwd_env_var(pubkey)
path = secret_env_path(config_dir)
try:
unset_key(str(path), var)
except (KeyError, FileNotFoundError):
pass

View file

@ -97,6 +97,12 @@ class CompositeTransport(MeshTransport):
child = self.meshcore_child()
return child.get_contacts() if child is not None else []
def get_rooms(self) -> List[dict]:
"""Passthrough to the MeshCore child's room-server list; [] if no meshcore child."""
child = self.meshcore_child()
get_rooms = getattr(child, "get_rooms", None) if child is not None else None
return get_rooms() if get_rooms is not None else []
def self_info(self) -> dict:
"""Passthrough to the MeshCore child's self/connection status; {connected: False} if no meshcore child."""
child = self.meshcore_child()
@ -254,13 +260,33 @@ class CompositeTransport(MeshTransport):
channel: int = 0,
transport: Optional[str] = None,
meshcore_channel: Optional[str] = None,
meshcore_room: Optional[str] = None,
meshcore_room_password: Optional[str] = None,
) -> bool:
"""Async send through per-child queues, with the same routing logic as send_message().
Each child's send_message_async() goes through that child's serialized queue,
so Meshtastic and MeshCore sends are each independently paced. For broadcasts
the two are awaited sequentially (Meshtastic first, then MeshCore).
``meshcore_room`` (a room-server pubkey) routes ONLY to the MeshCore
child's room send (login-if-password + addressed send). It never
touches Meshtastic and is mutually exclusive with a channel broadcast.
"""
if meshcore_room:
child = self.meshcore_child()
if child is None or not child.connected:
return False
try:
return await child.send_message_async(
text,
destination=None,
meshcore_room=meshcore_room,
meshcore_room_password=meshcore_room_password,
)
except Exception as exc:
logger.error("CompositeTransport: async room send raised: %s", exc)
return False
if destination is None:
return await self._broadcast_async(text, channel, meshcore_channel=meshcore_channel,
transport=transport)

View file

@ -135,6 +135,12 @@ class MeshCoreTransport(MeshTransport):
# --- per-radio send queue (on the MC dedicated loop) ---
self._mc_send_queue: Optional[asyncio.Queue] = None
self._mc_drain_task: Optional[asyncio.Task] = None
# Room servers we currently hold a LOGIN_SUCCESS session for, keyed by
# the room's pubkey (as passed to login_to_room). A password-protected
# room must be logged into before an addressed send is accepted; we
# login once and remember it, clearing the entry on LOGIN_FAILED so the
# next send re-attempts the login. Populated only by login_to_room.
self._logged_in_rooms: set[str] = set()
# ------------------------------------------------------------------
# Internal helpers
@ -656,16 +662,27 @@ class MeshCoreTransport(MeshTransport):
channel: int = 0,
transport: Optional[str] = None,
meshcore_channel: Optional[str] = None,
meshcore_room: Optional[str] = None,
meshcore_room_password: Optional[str] = None,
) -> bool:
"""Async send through the MC per-radio queue (called from the main loop).
Enqueues the job on the MC loop's queue and awaits the actual send
result via a concurrent.futures.Future bridge.
``meshcore_room`` (a room-server pubkey) routes to send_to_room_async
(login-if-password + addressed send) INSTEAD of a channel broadcast;
it shares the same queue so room sends are paced like every other send.
"""
if self._mc is None or not self._connected:
return False
if self._mc_send_queue is None or self._loop is None or not self._loop.is_running():
# Queue not started yet (e.g. initial advert at connect) — fall back.
# Room sends are only issued post-connect (queue up), so the sync
# fallback covers only DM/broadcast; a room target degrades to no-op.
if meshcore_room:
logger.debug("MC: room send before queue start; skipping")
return False
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
@ -674,7 +691,12 @@ class MeshCoreTransport(MeshTransport):
cfut: concurrent.futures.Future = concurrent.futures.Future()
if destination:
if meshcore_room:
async def _job() -> bool:
return await self.send_to_room_async(
meshcore_room, text, password=meshcore_room_password
)
elif destination:
async def _job() -> bool:
return await self._do_mc_dm_send_async(text, destination)
else:
@ -856,6 +878,120 @@ class MeshCoreTransport(MeshTransport):
})
return roster
# A MeshCore ROOM SERVER is a contact whose ``type`` is ROOM (3) in the
# firmware CONTACT_TYPENAMES table [NONE, CLI, REP, ROOM, SENS]. We route
# to a room via the DM primitive (send_msg to its pubkey), so a room is
# just an addressable contact of this type.
ROOM_CONTACT_TYPE = 3
def get_rooms(self) -> list[dict]:
"""Room servers on the companion: [{name, pubkey, prefix, path_established}].
Filters ``get_contacts()`` (the same roster the DM path resolves
against) to contacts of type ROOM (3). ``prefix`` is the 12-hex-char
pubkey prefix used for routing cells / password keys;
``path_established`` is True when the companion already holds a direct
route (out_path_len >= 0), False when the next send must flood/discover.
Returns [] if not connected. Mirrors ``known_channels()`` in intent
(enumerate routable destinations) but for rooms rather than channels.
"""
rooms: list[dict] = []
for c in self.get_contacts():
if c.get("type") != self.ROOM_CONTACT_TYPE:
continue
pubkey = c.get("pubkey") or ""
try:
out_path_len = int(c.get("out_path_len", -1))
except (TypeError, ValueError):
out_path_len = -1
rooms.append({
"name": c.get("name"),
"pubkey": pubkey,
"prefix": pubkey[:12] if pubkey else "",
"path_established": out_path_len >= 0,
})
return rooms
async def get_rooms_async(self) -> list[dict]:
"""Async variant of get_rooms() for callers already on an event loop.
get_contacts() reads the lib's cached ``contacts`` mirror; the only
blocking step is an optional ensure_contacts() refresh, run on the MC
loop here rather than via _run_coro so there is no cross-loop hop.
Returns [] if not connected.
"""
if self._mc is None or not self._connected:
return []
try:
ensure = getattr(self._mc, "ensure_contacts", None)
if ensure is not None:
await ensure()
except Exception:
pass
return self.get_rooms()
async def login_to_room(self, pubkey: str, pwd: str) -> bool:
"""Log in to a password-protected room server; track the session.
Wraps ``commands.send_login_sync(dst_pubkey, pwd)`` and awaits the
LOGIN_SUCCESS / LOGIN_FAILED outcome. On success the room pubkey is
added to ``self._logged_in_rooms`` so subsequent sends skip re-login;
on failure (or error) the tracked state is cleared so the next send
re-attempts the login. Never raises returns False on any error.
Runs on the MC loop (call from within it, e.g. from send_to_room_async).
"""
if self._mc is None:
return False
contact = await self._resolve_contact_async(pubkey)
if contact is None:
logger.warning("MC: cannot login to room %s — contact not resolved", pubkey)
self._logged_in_rooms.discard(pubkey)
return False
label = contact.get("adv_name") or pubkey
try:
event = await asyncio.wait_for(
self._mc.commands.send_login_sync(contact, pwd), timeout=15
)
except Exception as exc:
logger.warning("MC: room login to %s raised: %s", label, exc)
self._logged_in_rooms.discard(pubkey)
return False
# send_login_sync resolves to the LOGIN_SUCCESS / LOGIN_FAILED event.
is_err = getattr(event, "is_error", None)
if event is None or (callable(is_err) and event.is_error()):
logger.warning("MC: room login to %s FAILED", label)
self._logged_in_rooms.discard(pubkey)
return False
logger.info("MC: room login to %s succeeded", label)
self._logged_in_rooms.add(pubkey)
return True
async def send_to_room_async(
self, pubkey: str, text: str, password: Optional[str] = None
) -> bool:
"""Send *text* to a room server by pubkey (login first if password-protected).
A room send reuses the addressed-send machinery exactly: it delegates
to ``_do_mc_dm_send_async`` (resolve contact -> send_msg -> ACK / path
discovery / resend), so path establishment and ACK handling are shared
with normal DMs and channel broadcast is untouched.
If *password* is provided and we do not already hold a session for this
room, log in first; on a login failure surface it (return False) so the
caller does not silently send to a room that will reject the message.
A password-protected room whose session was cleared (prior LOGIN_FAILED)
re-attempts the login here. Runs on the MC loop.
"""
if self._mc is None:
return False
if password:
if pubkey not in self._logged_in_rooms:
ok = await self.login_to_room(pubkey, password)
if not ok:
return False
return await self._do_mc_dm_send_async(text, pubkey)
def self_info(self) -> dict:
"""Companion self/connection status. {connected: False} if not connected."""
if self._mc is None or not self._connected:

View file

@ -0,0 +1,522 @@
"""Tests for MeshCore room-server routing (backend capability).
Covers, end to end without a radio:
* MeshCoreTransport.get_rooms() filter contacts to type==3, room shape
* MeshCoreTransport.login_to_room() send_login_sync + LOGIN_SUCCESS/FAILED
* MeshCoreTransport.send_to_room_async login-if-password + addressed send
* secrets_store room-password convention (derive / set / get / delete)
* channels.parse_meshcore_room() room:<pubkey> vs bare channel name
* MeshCoreBroadcastChannel routing room cell -> room send (NOT send_chan_msg),
channel cell -> broadcast (regression),
password room -> login before send.
The meshcore lib is mocked via sys.modules (same pattern as the existing
transport test module), so no lib or socket is required.
"""
import asyncio
import sys
import threading
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
# ---------------------------------------------------------------------------
# Fake meshcore module (register before production imports)
# ---------------------------------------------------------------------------
def _build_fake_meshcore():
mod = types.ModuleType("meshcore")
class EventType:
CONTACT_MSG_RECV = "CONTACT_MSG_RECV"
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED"
ACK = "ACK"
NEW_CONTACT = "NEW_CONTACT"
mod.EventType = EventType
# Superset of the fake used by test_meshcore_transport.py: because test
# modules share one interpreter and register via setdefault(), whichever
# module is collected FIRST wins. This fake must therefore satisfy the
# transport module's advert/connect tests too (send_advert, create_tcp,
# auto-fetch, disconnect) — plus send_login_sync for room login here.
class _FakeMeshCore:
self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"}
contacts = {}
async def start_auto_message_fetching(self):
pass
async def stop_auto_message_fetching(self):
pass
async def disconnect(self):
pass
async def ensure_contacts(self, follow=False):
return True
def subscribe(self, event_type, callback):
pass
def get_contact_by_key_prefix(self, prefix):
return None
@classmethod
async def create_tcp(cls, host, port,
auto_reconnect=True, max_reconnect_attempts=5):
return cls()
class commands:
@staticmethod
async def send_chan_msg(chan_idx, text):
r = MagicMock()
r.is_error.return_value = False
return r
@staticmethod
async def send_msg(dst, text):
r = MagicMock()
r.is_error.return_value = False
return r
@staticmethod
async def send_login_sync(dst, pwd):
r = MagicMock()
r.is_error.return_value = False
return r
@staticmethod
async def send_advert(flood=False):
pass
@staticmethod
async def set_autoadd_config(value):
r = MagicMock()
r.is_error.return_value = False
return r
mod.MeshCore = _FakeMeshCore
return mod
sys.modules.setdefault("meshcore", _build_fake_meshcore())
# ---------------------------------------------------------------------------
# Production imports
# ---------------------------------------------------------------------------
from meshai.config import ConnectionConfig # noqa: E402
from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402
from meshai import secrets_store # noqa: E402
from meshai.notifications import channels as ch # noqa: E402
# ---------------------------------------------------------------------------
# Helpers (mirror test_meshcore_transport.py)
# ---------------------------------------------------------------------------
def _mc_config(**overrides):
cfg = ConnectionConfig(meshcore_host="127.0.0.1", meshcore_port=5050)
for k, v in overrides.items():
setattr(cfg, k, v)
return cfg
def _transport_with_mock_mc():
"""MeshCoreTransport with a MagicMock _mc and a real dedicated loop thread."""
t = MeshCoreTransport(_mc_config())
mc = MagicMock()
mc.get_contact_by_key_prefix.return_value = None
t._mc = mc
t._connected = True
loop = asyncio.new_event_loop()
t._loop = loop
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
t._loop_thread = thread
return t, mc, loop
def _cleanup(t):
try:
if t._loop and t._loop.is_running():
t._loop.call_soon_threadsafe(t._loop.stop)
if t._loop_thread and t._loop_thread.is_alive():
t._loop_thread.join(timeout=2.0)
except Exception:
pass
def _run(t, coro):
"""Run a coroutine on the transport's dedicated loop and return its result."""
return asyncio.run_coroutine_threadsafe(coro, t._loop).result(timeout=5)
# A room server contact (type==3) and a couple of non-room contacts.
_ROOM_PUBKEY = "cc11" + "d" * 60 # 64-hex
_SAMPLE_CONTACTS = {
"cc11": {
"adv_name": "Boise Room", "public_key": _ROOM_PUBKEY,
"type": 3, "last_advert": 3000, "adv_lat": 43.6, "adv_lon": -116.2,
"out_path_len": 4,
},
"aa11": {
"adv_name": "Repeater One", "public_key": "aa11" + "e" * 60,
"type": 2, "last_advert": 1000, "out_path_len": 2,
},
"bb22": {
"adv_name": "Chat Node", "public_key": "bb22" + "f" * 60,
"type": 0, "last_advert": 2000, "out_path_len": -1,
},
}
# ===========================================================================
# 1. get_rooms()
# ===========================================================================
class TestGetRooms:
def test_filters_to_type_3_with_room_shape(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.ensure_contacts = AsyncMock(return_value=None)
mc.contacts = dict(_SAMPLE_CONTACTS)
rooms = t.get_rooms()
assert len(rooms) == 1, "only the type==3 contact is a room"
room = rooms[0]
assert room == {
"name": "Boise Room",
"pubkey": _ROOM_PUBKEY,
"prefix": _ROOM_PUBKEY[:12],
"path_established": True, # out_path_len 4 >= 0
}
finally:
_cleanup(t)
def test_path_established_false_when_no_path(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.ensure_contacts = AsyncMock(return_value=None)
mc.contacts = {
"cc11": {
"adv_name": "Flood Room", "public_key": _ROOM_PUBKEY,
"type": 3, "out_path_len": -1,
}
}
rooms = t.get_rooms()
assert rooms[0]["path_established"] is False
finally:
_cleanup(t)
def test_no_rooms_when_none_are_type_3(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.ensure_contacts = AsyncMock(return_value=None)
mc.contacts = {k: v for k, v in _SAMPLE_CONTACTS.items() if k != "cc11"}
assert t.get_rooms() == []
finally:
_cleanup(t)
def test_returns_empty_when_not_connected(self):
t = MeshCoreTransport(_mc_config())
assert t.get_rooms() == []
# ===========================================================================
# 2. login_to_room() + send_to_room_async()
# ===========================================================================
class TestRoomLoginAndSend:
def _wire_room(self, mc):
room = dict(_SAMPLE_CONTACTS["cc11"])
mc.get_contact_by_key_prefix.return_value = room
# Fast path: send_msg carries an expected_ack, and the dispatcher
# returns a matching ACK, so delivery succeeds with a SINGLE send_msg
# (no discovery/resend leg) — keeps the login assertions clean.
ok = MagicMock()
ok.is_error.return_value = False
ok.payload = {"type": 0, "expected_ack": b"\x01\x02\x03\x04"}
mc.commands.send_msg = AsyncMock(return_value=ok)
mc.dispatcher.wait_for_event = AsyncMock(return_value=MagicMock()) # ACK
return room
def test_open_room_send_no_login(self):
"""No password -> send_msg to the room pubkey, send_login_sync NOT called."""
t, mc, _ = _transport_with_mock_mc()
try:
room = self._wire_room(mc)
mc.commands.send_login_sync = AsyncMock()
ok = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "hi room", password=None))
assert ok is True
mc.commands.send_login_sync.assert_not_awaited()
mc.commands.send_msg.assert_awaited_with(room, "hi room")
assert _ROOM_PUBKEY not in t._logged_in_rooms
finally:
_cleanup(t)
def test_password_room_logs_in_before_send(self):
"""A password -> send_login_sync (LOGIN_SUCCESS) THEN send_msg; room tracked."""
t, mc, _ = _transport_with_mock_mc()
try:
room = self._wire_room(mc)
login_ok = MagicMock()
login_ok.is_error.return_value = False
mc.commands.send_login_sync = AsyncMock(return_value=login_ok)
ok = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "secret hi", password="pw"))
assert ok is True
mc.commands.send_login_sync.assert_awaited_once_with(room, "pw")
mc.commands.send_msg.assert_awaited_with(room, "secret hi")
assert _ROOM_PUBKEY in t._logged_in_rooms
finally:
_cleanup(t)
def test_login_reused_on_second_send(self):
"""Already-logged-in room -> no second login on the next send."""
t, mc, _ = _transport_with_mock_mc()
try:
self._wire_room(mc)
login_ok = MagicMock()
login_ok.is_error.return_value = False
mc.commands.send_login_sync = AsyncMock(return_value=login_ok)
_run(t, t.send_to_room_async(_ROOM_PUBKEY, "one", password="pw"))
_run(t, t.send_to_room_async(_ROOM_PUBKEY, "two", password="pw"))
mc.commands.send_login_sync.assert_awaited_once() # login only once
finally:
_cleanup(t)
def test_login_failure_surfaces_and_no_send(self):
"""LOGIN_FAILED -> send_to_room_async returns False and does NOT send_msg."""
t, mc, _ = _transport_with_mock_mc()
try:
self._wire_room(mc)
login_err = MagicMock()
login_err.is_error.return_value = True # LOGIN_FAILED
mc.commands.send_login_sync = AsyncMock(return_value=login_err)
ok = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "nope", password="bad"))
assert ok is False, "login failure must surface as a failed send"
mc.commands.send_msg.assert_not_awaited()
assert _ROOM_PUBKEY not in t._logged_in_rooms
finally:
_cleanup(t)
def test_login_failure_then_retry_relogins(self):
"""After a failed login the state is cleared, so the next send re-logins."""
t, mc, _ = _transport_with_mock_mc()
try:
self._wire_room(mc)
login_err = MagicMock(); login_err.is_error.return_value = True
login_ok = MagicMock(); login_ok.is_error.return_value = False
mc.commands.send_login_sync = AsyncMock(side_effect=[login_err, login_ok])
first = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "a", password="pw"))
second = _run(t, t.send_to_room_async(_ROOM_PUBKEY, "b", password="pw"))
assert first is False
assert second is True
assert mc.commands.send_login_sync.await_count == 2 # re-login attempted
finally:
_cleanup(t)
# ===========================================================================
# 3. send_message_async(meshcore_room=...) routes to room, not channel
# ===========================================================================
class TestSendMessageAsyncRoom:
def test_meshcore_room_calls_send_msg_not_send_chan_msg(self):
"""A room target goes through the queue -> send_msg; send_chan_msg untouched."""
t, mc, _ = _transport_with_mock_mc()
try:
room = dict(_SAMPLE_CONTACTS["cc11"])
mc.get_contact_by_key_prefix.return_value = room
ok = MagicMock(); ok.is_error.return_value = False
ok.payload = {"type": 0, "expected_ack": b"\x01\x02\x03\x04"}
mc.commands.send_msg = AsyncMock(return_value=ok)
mc.commands.send_chan_msg = AsyncMock()
mc.dispatcher.wait_for_event = AsyncMock(return_value=MagicMock())
# Arm the send queue on the MC loop (send_message_async requires it).
asyncio.run_coroutine_threadsafe(
_arm_queue(t), t._loop
).result(timeout=5)
result = _run(
t, t.send_message_async("hi", destination=None, meshcore_room=_ROOM_PUBKEY)
)
assert result is True
mc.commands.send_msg.assert_awaited_with(room, "hi")
mc.commands.send_chan_msg.assert_not_awaited()
finally:
t._cancel_mc_queue() # stop the drain task before the loop closes
_cleanup(t)
async def _arm_queue(t):
t._start_mc_queue()
# ===========================================================================
# 4. secrets_store room-password convention
# ===========================================================================
class TestRoomPasswordSecrets:
def test_env_var_derivation_uses_12_hex_prefix_upper(self):
var = secrets_store.room_pwd_env_var(_ROOM_PUBKEY)
assert var == "MESHCORE_ROOM_" + _ROOM_PUBKEY[:12].upper() + "_PWD"
def test_prefix_and_full_key_resolve_same_var(self):
full = secrets_store.room_pwd_env_var(_ROOM_PUBKEY)
prefix = secrets_store.room_pwd_env_var(_ROOM_PUBKEY[:12])
assert full == prefix
def test_empty_pubkey_raises(self):
with pytest.raises(ValueError):
secrets_store.room_pwd_env_var("")
def test_set_get_delete_roundtrip(self, tmp_path):
cfg_dir = tmp_path / "config"
cfg_dir.mkdir()
assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) is None
assert secrets_store.room_password_is_set(_ROOM_PUBKEY, cfg_dir) is False
secrets_store.set_room_password(_ROOM_PUBKEY, "topsecret", cfg_dir)
assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) == "topsecret"
assert secrets_store.room_password_is_set(_ROOM_PUBKEY, cfg_dir) is True
secrets_store.delete_room_password(_ROOM_PUBKEY, cfg_dir)
assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) is None
def test_set_empty_value_clears(self, tmp_path):
cfg_dir = tmp_path / "config"; cfg_dir.mkdir()
secrets_store.set_room_password(_ROOM_PUBKEY, "x", cfg_dir)
secrets_store.set_room_password(_ROOM_PUBKEY, "", cfg_dir)
assert secrets_store.get_room_password(_ROOM_PUBKEY, cfg_dir) is None
def test_get_missing_pubkey_none(self, tmp_path):
cfg_dir = tmp_path / "config"; cfg_dir.mkdir()
assert secrets_store.get_room_password("", cfg_dir) is None
# ===========================================================================
# 5. channels.parse_meshcore_room()
# ===========================================================================
class TestParseMeshcoreRoom:
def test_room_prefix_extracts_pubkey(self):
assert ch.parse_meshcore_room("room:" + _ROOM_PUBKEY) == _ROOM_PUBKEY
def test_bare_channel_name_is_none(self):
assert ch.parse_meshcore_room("AIDA") is None
def test_empty_room_pubkey_is_none(self):
assert ch.parse_meshcore_room("room:") is None
assert ch.parse_meshcore_room("room: ") is None
def test_none_and_empty_are_none(self):
assert ch.parse_meshcore_room(None) is None
assert ch.parse_meshcore_room("") is None
# ===========================================================================
# 6. MeshCoreBroadcastChannel routing (room vs channel)
# ===========================================================================
class _RecConnector:
"""Records send_message_async kwargs; looks like a single meshcore transport."""
transport_name = "meshcore"
max_chars = 200
def __init__(self):
self.calls = []
async def send_message_async(self, text=None, destination=None, channel=0,
transport=None, meshcore_channel=None,
meshcore_room=None, meshcore_room_password=None):
self.calls.append({
"text": text, "destination": destination,
"meshcore_channel": meshcore_channel,
"meshcore_room": meshcore_room,
"meshcore_room_password": meshcore_room_password,
"transport": transport,
})
return True
def _run_sync(coro):
"""Run a coroutine on a fresh loop, restoring the prior event-loop state.
Creating+closing a loop without restoring leaves a CLOSED loop as this
thread's default, which breaks sibling test modules that call
asyncio.get_event_loop() (e.g. the advert-scheduler tests). We snapshot the
current loop and put it back afterwards so there is no cross-module leak.
"""
try:
prev = asyncio.get_event_loop()
except RuntimeError:
prev = None
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
asyncio.set_event_loop(prev)
def _payload(msg="fire near you"):
p = MagicMock()
p.message = msg
p.chunk_index = 0 # pre-chunked -> single send, no renderer indirection
return p
class TestMeshCoreBroadcastChannelRouting:
def test_room_cell_routes_to_room_send(self, monkeypatch):
"""A room:<pubkey> cell -> send_message_async(meshcore_room=pubkey),
never a channel broadcast."""
monkeypatch.setattr(
"meshai.secrets_store.get_room_password", lambda pk: None
)
conn = _RecConnector()
chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel="room:" + _ROOM_PUBKEY)
ok = _run_sync(chan.deliver(_payload(), MagicMock()))
assert ok is True
assert len(conn.calls) == 1
call = conn.calls[0]
assert call["meshcore_room"] == _ROOM_PUBKEY
assert call["meshcore_channel"] is None, "must NOT route as a channel broadcast"
def test_room_cell_passes_configured_password(self, monkeypatch):
monkeypatch.setattr(
"meshai.secrets_store.get_room_password", lambda pk: "hunter2"
)
conn = _RecConnector()
chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel="room:" + _ROOM_PUBKEY)
_run_sync(chan.deliver(_payload(), MagicMock()))
assert conn.calls[0]["meshcore_room_password"] == "hunter2"
def test_channel_cell_still_broadcasts(self, monkeypatch):
"""Regression: a bare channel name -> meshcore_channel broadcast, no room."""
conn = _RecConnector()
chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel="AIDA")
ok = _run_sync(chan.deliver(_payload(), MagicMock()))
assert ok is True
call = conn.calls[0]
assert call["meshcore_channel"] == "AIDA"
assert call["meshcore_room"] is None, "channel cell must NOT trigger a room send"
def test_no_channel_configured_is_noop(self):
"""No cell set -> nothing sent (unchanged behavior)."""
conn = _RecConnector()
chan = ch.MeshCoreBroadcastChannel(conn, meshcore_channel=None)
ok = _run_sync(chan.deliver(_payload(), MagicMock()))
assert ok is False
assert conn.calls == []