mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
77e057ae86
commit
2e7b3d6934
8 changed files with 1029 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<input
|
||||
type="text"
|
||||
value={cell.mc ?? ''}
|
||||
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"
|
||||
/>
|
||||
{/* 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={mcVal}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setMcForRegion(key, region, v === '' ? null : v)
|
||||
}}
|
||||
placeholder="channel"
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
522
work/tests/test_meshcore_rooms.py
Normal file
522
work/tests/test_meshcore_rooms.py
Normal 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 == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue