mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(dashboard): MeshCore transport + per-family routing GUI controls (#10)
* feat(dashboard): MeshCore transport + per-family routing GUI controls
Add Transport mode selector (Meshtastic/MeshCore/Both) and MeshCore
host/port fields to the Config Connection section, and an independent
per-family "MeshCore channel" number input in Notifications (blank = not
broadcast on MeshCore, sends null). Extends the ConnectionConfig and
per-family toggle TS types.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(routing): MeshCore routing by channel name, not index
MeshCore channels are {name,PSK} (up to 40+ slots, not Meshtastic's 0-7).
The send index is a fragile slot position, so store the channel NAME per
family and resolve name->slot against the companion's live channel table
at send time; never blind-send to an unresolved slot. GUI field becomes a
channel-name text box. meshtastic path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routing): thread per-family meshcore_channel through the broadcast send path
MeshBroadcastChannel now carries the rule's meshcore_channel name and
passes it to send_message, so per-family MeshCore routing actually fires
end-to-end (dispatcher -> channel -> composite -> MeshCoreTransport).
Meshtastic path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
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
6bc57709a2
commit
24cb6a31df
14 changed files with 358 additions and 45 deletions
|
|
@ -23,6 +23,9 @@ interface ConnectionConfig {
|
||||||
serial_port: string
|
serial_port: string
|
||||||
tcp_host: string
|
tcp_host: string
|
||||||
tcp_port: number
|
tcp_port: number
|
||||||
|
transport?: string
|
||||||
|
meshcore_host?: string
|
||||||
|
meshcore_port?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResponseConfig {
|
interface ResponseConfig {
|
||||||
|
|
@ -707,9 +710,23 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) {
|
function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) {
|
||||||
|
const transport = data.transport ?? 'meshtastic'
|
||||||
|
const showMeshCore = transport === 'meshcore' || transport === 'both'
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SectionDescription text={SECTION_DESCRIPTIONS.connection} />
|
<SectionDescription text={SECTION_DESCRIPTIONS.connection} />
|
||||||
|
<SelectInput
|
||||||
|
label="Transport Mode"
|
||||||
|
value={transport}
|
||||||
|
onChange={(v) => onChange({ ...data, transport: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'meshtastic', label: 'Meshtastic' },
|
||||||
|
{ value: 'meshcore', label: 'MeshCore' },
|
||||||
|
{ value: 'both', label: 'Both' },
|
||||||
|
]}
|
||||||
|
helper="Which radio transport(s) MeshAI uses"
|
||||||
|
info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation."
|
||||||
|
/>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Connection Type"
|
label="Connection Type"
|
||||||
value={data.type}
|
value={data.type}
|
||||||
|
|
@ -749,6 +766,29 @@ function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChang
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showMeshCore && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-[#1e2a3a]">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<TextInput
|
||||||
|
label="MeshCore Host"
|
||||||
|
value={data.meshcore_host ?? ''}
|
||||||
|
onChange={(v) => onChange({ ...data, meshcore_host: v })}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
helper="IP or hostname of the MeshCore node"
|
||||||
|
info="Address of the MeshCore node to connect to."
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="MeshCore Port"
|
||||||
|
value={data.meshcore_port ?? 5525}
|
||||||
|
onChange={(v) => onChange({ ...data, meshcore_port: v })}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
helper="MeshCore TCP port (default 5525)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ interface NotificationToggle {
|
||||||
regions: string[]
|
regions: string[]
|
||||||
severity_channels: Record<string, string[]>
|
severity_channels: Record<string, string[]>
|
||||||
broadcast_channel: number | null
|
broadcast_channel: number | null
|
||||||
|
meshcore_channel?: string | null
|
||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
smtp_host: string
|
smtp_host: string
|
||||||
smtp_port: number
|
smtp_port: number
|
||||||
|
|
@ -1611,6 +1612,17 @@ function MasterToggles({ toggles, onChange }: {
|
||||||
</table>
|
</table>
|
||||||
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
|
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
|
||||||
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
|
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">MeshCore channel</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
|
||||||
|
onChange={(e) => upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })}
|
||||||
|
placeholder=""
|
||||||
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-600">MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.</p>
|
||||||
|
</div>
|
||||||
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
|
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
|
||||||
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
|
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
|
||||||
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ class ConnectionConfig:
|
||||||
# --- MeshCore transport settings (used when transport="meshcore") ---
|
# --- MeshCore transport settings (used when transport="meshcore") ---
|
||||||
meshcore_host: str = "100.64.0.9" # pyMC companion frame server host
|
meshcore_host: str = "100.64.0.9" # pyMC companion frame server host
|
||||||
meshcore_port: int = 5050 # pyMC companion frame server port
|
meshcore_port: int = 5050 # pyMC companion frame server port
|
||||||
meshcore_channel_index: int = 0 # default channel index for broadcasts
|
|
||||||
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
|
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
|
||||||
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
|
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
|
||||||
|
|
||||||
|
|
@ -555,6 +554,8 @@ class NotificationRuleConfig:
|
||||||
|
|
||||||
# Mesh broadcast fields
|
# Mesh broadcast fields
|
||||||
broadcast_channel: int = 0
|
broadcast_channel: int = 0
|
||||||
|
# Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore.
|
||||||
|
meshcore_channel: Optional[str] = None
|
||||||
|
|
||||||
# Mesh DM fields
|
# Mesh DM fields
|
||||||
node_ids: list = field(default_factory=list)
|
node_ids: list = field(default_factory=list)
|
||||||
|
|
@ -596,6 +597,8 @@ class NotificationToggle:
|
||||||
cooldown_seconds: int = 0 # per (toggle, category, region) throttle window; 0 = disabled
|
cooldown_seconds: int = 0 # per (toggle, category, region) throttle window; 0 = disabled
|
||||||
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
|
# per-channel delivery config (mirrors NotificationRuleConfig channel fields)
|
||||||
broadcast_channel: Optional[int] = None
|
broadcast_channel: Optional[int] = None
|
||||||
|
# Per-family MeshCore channel NAME on the companion; None = not broadcast on MeshCore.
|
||||||
|
meshcore_channel: Optional[str] = None
|
||||||
node_ids: list = field(default_factory=list)
|
node_ids: list = field(default_factory=list)
|
||||||
smtp_host: str = ""
|
smtp_host: str = ""
|
||||||
smtp_port: int = 587
|
smtp_port: int = 587
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,7 @@ class MeshtasticTransport(MeshTransport):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
||||||
meshcore_channel: Optional[int] = None, # per-family MeshCore channel — accepted and IGNORED here
|
meshcore_channel: Optional[str] = None, # per-family MeshCore channel — accepted and IGNORED here
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a text message.
|
"""Send a text message.
|
||||||
|
|
||||||
|
|
@ -364,7 +364,7 @@ class MeshtasticTransport(MeshTransport):
|
||||||
destination: Node ID for DM, or None for broadcast
|
destination: Node ID for DM, or None for broadcast
|
||||||
channel: Channel index to send on
|
channel: Channel index to send on
|
||||||
transport: Optional routing hint (for CompositeTransport); ignored here.
|
transport: Optional routing hint (for CompositeTransport); ignored here.
|
||||||
meshcore_channel: Per-family MeshCore channel index; ignored by Meshtastic.
|
meshcore_channel: Per-family MeshCore channel name; ignored by Meshtastic.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if send was initiated successfully
|
True if send was initiated successfully
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,13 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
|
|
||||||
channel_type = "mesh_broadcast"
|
channel_type = "mesh_broadcast"
|
||||||
|
|
||||||
def __init__(self, connector: "MeshConnector", channel_index: int = 0):
|
def __init__(self, connector: "MeshConnector", channel_index: int = 0,
|
||||||
|
meshcore_channel: Optional[str] = None):
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._channel = channel_index
|
self._channel = channel_index
|
||||||
|
# Per-family MeshCore channel NAME (None = MeshCore child skipped
|
||||||
|
# downstream). Ignored by Meshtastic; behavior-preserving there.
|
||||||
|
self._meshcore_channel = meshcore_channel
|
||||||
_mc = getattr(connector, "max_chars", 200)
|
_mc = getattr(connector, "max_chars", 200)
|
||||||
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
|
self._renderer = MeshRenderer(char_limit=_mc if isinstance(_mc, int) else 200)
|
||||||
|
|
||||||
|
|
@ -79,6 +83,7 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
text=alert.message or "",
|
text=alert.message or "",
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=self._channel,
|
channel=self._channel,
|
||||||
|
meshcore_channel=self._meshcore_channel,
|
||||||
)
|
)
|
||||||
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
|
logger.info("Broadcast pre-chunked alert to channel %d", self._channel)
|
||||||
return True
|
return True
|
||||||
|
|
@ -90,6 +95,7 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
text=chunk,
|
text=chunk,
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=self._channel,
|
channel=self._channel,
|
||||||
|
meshcore_channel=self._meshcore_channel,
|
||||||
)
|
)
|
||||||
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
|
logger.info("Broadcast %d chunk(s) to channel %d", len(chunks), self._channel)
|
||||||
return True
|
return True
|
||||||
|
|
@ -780,6 +786,7 @@ def create_channel(rule: "NotificationRuleConfig", connector=None) -> Notificati
|
||||||
return MeshBroadcastChannel(
|
return MeshBroadcastChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
channel_index=rule.broadcast_channel,
|
channel_index=rule.broadcast_channel,
|
||||||
|
meshcore_channel=getattr(rule, "meshcore_channel", None),
|
||||||
)
|
)
|
||||||
elif delivery_type == "mesh_dm":
|
elif delivery_type == "mesh_dm":
|
||||||
return MeshDMChannel(
|
return MeshDMChannel(
|
||||||
|
|
@ -816,6 +823,7 @@ def create_channel_from_dict(config: dict, connector=None) -> NotificationChanne
|
||||||
return MeshBroadcastChannel(
|
return MeshBroadcastChannel(
|
||||||
connector=connector,
|
connector=connector,
|
||||||
channel_index=config.get("channel_index", 0),
|
channel_index=config.get("channel_index", 0),
|
||||||
|
meshcore_channel=config.get("meshcore_channel"),
|
||||||
)
|
)
|
||||||
elif channel_type == "mesh_dm":
|
elif channel_type == "mesh_dm":
|
||||||
return MeshDMChannel(
|
return MeshDMChannel(
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,7 @@ class Dispatcher:
|
||||||
name=f"toggle:{getattr(tog, 'name', '')}",
|
name=f"toggle:{getattr(tog, 'name', '')}",
|
||||||
enabled=True, trigger_type="condition", delivery_type=ch_type,
|
enabled=True, trigger_type="condition", delivery_type=ch_type,
|
||||||
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
|
broadcast_channel=(getattr(tog, "broadcast_channel", None) or 0),
|
||||||
|
meshcore_channel=getattr(tog, "meshcore_channel", None),
|
||||||
node_ids=list(getattr(tog, "node_ids", []) or []),
|
node_ids=list(getattr(tog, "node_ids", []) or []),
|
||||||
smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587),
|
smtp_host=getattr(tog, "smtp_host", ""), smtp_port=getattr(tog, "smtp_port", 587),
|
||||||
smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""),
|
smtp_user=getattr(tog, "smtp_user", ""), smtp_password=getattr(tog, "smtp_password", ""),
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class MeshTransport(abc.ABC):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None,
|
transport: Optional[str] = None,
|
||||||
meshcore_channel: Optional[int] = None,
|
meshcore_channel: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a text message.
|
"""Send a text message.
|
||||||
|
|
||||||
|
|
@ -49,9 +49,9 @@ class MeshTransport(abc.ABC):
|
||||||
select the child mesh that originated an inbound DM).
|
select the child mesh that originated an inbound DM).
|
||||||
Single-transport implementations accept and IGNORE this
|
Single-transport implementations accept and IGNORE this
|
||||||
parameter; it is always None in non-composite callers.
|
parameter; it is always None in non-composite callers.
|
||||||
meshcore_channel: Per-family MeshCore channel index for broadcasts.
|
meshcore_channel: Per-family MeshCore channel NAME for broadcasts.
|
||||||
MeshtasticTransport ignores this; MeshCoreTransport uses
|
MeshtasticTransport ignores this; MeshCoreTransport
|
||||||
it in place of the global meshcore_channel_index when set.
|
resolves the name to a companion slot at send time.
|
||||||
CompositeTransport uses it to route each child correctly.
|
CompositeTransport uses it to route each child correctly.
|
||||||
None = do not broadcast on MeshCore for this family.
|
None = do not broadcast on MeshCore for this family.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ class CompositeTransport(MeshTransport):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None,
|
transport: Optional[str] = None,
|
||||||
meshcore_channel: Optional[int] = None,
|
meshcore_channel: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a message, routing based on destination + hint.
|
"""Send a message, routing based on destination + hint.
|
||||||
|
|
||||||
|
|
@ -233,14 +233,15 @@ class CompositeTransport(MeshTransport):
|
||||||
# --- Rule 3: unhinted DM ---
|
# --- Rule 3: unhinted DM ---
|
||||||
return self._send_unhinted(text, destination, channel)
|
return self._send_unhinted(text, destination, channel)
|
||||||
|
|
||||||
def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[int] = None) -> bool:
|
def _broadcast(self, text: str, channel: int, meshcore_channel: Optional[str] = None) -> bool:
|
||||||
"""Fan text out to connected children with per-transport channel routing.
|
"""Fan text out to connected children with per-transport channel routing.
|
||||||
|
|
||||||
For the Meshtastic child, ``channel`` (Meshtastic channel index) is used.
|
For the Meshtastic child, ``channel`` (Meshtastic channel index) is used.
|
||||||
For the MeshCore child:
|
For the MeshCore child:
|
||||||
- ``meshcore_channel`` set → use that channel index on MeshCore.
|
- ``meshcore_channel`` set → route that channel NAME to MeshCore,
|
||||||
|
which resolves it to a companion slot at send time.
|
||||||
- ``meshcore_channel`` is None → skip the MeshCore child entirely
|
- ``meshcore_channel`` is None → skip the MeshCore child entirely
|
||||||
(family not configured for MeshCore; no fallback to global index).
|
(family not configured for MeshCore; no fallback to a default).
|
||||||
|
|
||||||
Returns True if at least one child succeeded.
|
Returns True if at least one child succeeded.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ class MeshCoreTransport(MeshTransport):
|
||||||
self._message_callback: Optional[Callable] = None
|
self._message_callback: Optional[Callable] = None
|
||||||
self._callback_loop: Optional[asyncio.AbstractEventLoop] = None
|
self._callback_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._loop_ready: threading.Event = threading.Event()
|
self._loop_ready: threading.Event = threading.Event()
|
||||||
|
# Companion channel table: channel NAME -> slot index, built at
|
||||||
|
# connect time by _enumerate_channels(). Empty until connected.
|
||||||
|
self._chan_name_to_idx: dict[str, int] = {}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
|
|
@ -78,6 +81,63 @@ class MeshCoreTransport(MeshTransport):
|
||||||
self._loop = None
|
self._loop = None
|
||||||
self._loop_thread = None
|
self._loop_thread = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Channel table enumeration
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _enumerate_channels(self) -> None:
|
||||||
|
"""Build ``self._chan_name_to_idx`` from the companion's channel table.
|
||||||
|
|
||||||
|
MeshCore channels are {name, PSK} pairs living in numbered slots (up
|
||||||
|
to 40+, unlike Meshtastic's 0-7). We ask the companion for each slot
|
||||||
|
in turn via ``get_channel(idx)`` and record NAME → slot for every
|
||||||
|
named (non-empty) slot, so send_message can resolve a per-family
|
||||||
|
channel NAME to the right slot at send time.
|
||||||
|
|
||||||
|
Robustness:
|
||||||
|
- Any error yields an empty (or partial) map — never raises out.
|
||||||
|
- Enumeration stops on the first error/None result (end of table)
|
||||||
|
or after 3 consecutive empty slots (contiguous provisioning),
|
||||||
|
with a hard cap of 40 slots.
|
||||||
|
"""
|
||||||
|
self._chan_name_to_idx = {}
|
||||||
|
try:
|
||||||
|
empty_run = 0
|
||||||
|
for idx in range(40):
|
||||||
|
try:
|
||||||
|
event = self._run_coro(self._mc.commands.get_channel(idx))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"MeshCore: get_channel(%d) failed, ending enumeration: %s",
|
||||||
|
idx, exc,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
# Falsy / None / ERROR event → end of enumeration.
|
||||||
|
if not event:
|
||||||
|
break
|
||||||
|
is_err = getattr(event, "is_error", None)
|
||||||
|
if callable(is_err) and event.is_error():
|
||||||
|
break
|
||||||
|
payload = event.payload or {}
|
||||||
|
name = payload.get("channel_name", "")
|
||||||
|
slot = payload.get("channel_idx", idx)
|
||||||
|
if not name:
|
||||||
|
# Empty/unset slot; stop after a contiguous run of empties.
|
||||||
|
empty_run += 1
|
||||||
|
if empty_run >= 3:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
# Named slot: exact, case-sensitive (firmware name is already
|
||||||
|
# null-truncated / utf-8-decoded — do NOT trim or lowercase).
|
||||||
|
self._chan_name_to_idx[name] = slot
|
||||||
|
empty_run = 0
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("MeshCore: channel enumeration error: %s", exc)
|
||||||
|
self._chan_name_to_idx = {}
|
||||||
|
logger.info(
|
||||||
|
"MeshCore: enumerated %d named channel(s)", len(self._chan_name_to_idx)
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal coroutines (run on the dedicated loop)
|
# Internal coroutines (run on the dedicated loop)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -166,6 +226,10 @@ class MeshCoreTransport(MeshTransport):
|
||||||
# Subscribe to inbound events on the dedicated loop.
|
# Subscribe to inbound events on the dedicated loop.
|
||||||
self._run_coro(self._setup_subscriptions())
|
self._run_coro(self._setup_subscriptions())
|
||||||
|
|
||||||
|
# Build the channel NAME → slot map from the live companion table so
|
||||||
|
# per-family broadcasts can resolve their channel name to a slot.
|
||||||
|
self._enumerate_channels()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"MeshCoreTransport: connected as %s (pubkey %s)",
|
"MeshCoreTransport: connected as %s (pubkey %s)",
|
||||||
self._self_info.get("name", "unknown"),
|
self._self_info.get("name", "unknown"),
|
||||||
|
|
@ -194,7 +258,7 @@ class MeshCoreTransport(MeshTransport):
|
||||||
destination: Optional[str] = None,
|
destination: Optional[str] = None,
|
||||||
channel: int = 0,
|
channel: int = 0,
|
||||||
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
transport: Optional[str] = None, # routing hint — accepted and IGNORED by single-transport impl
|
||||||
meshcore_channel: Optional[int] = None,
|
meshcore_channel: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a message via MeshCore.
|
"""Send a message via MeshCore.
|
||||||
|
|
||||||
|
|
@ -204,10 +268,11 @@ class MeshCoreTransport(MeshTransport):
|
||||||
destination: hex pubkey string for a DM, or None for channel send.
|
destination: hex pubkey string for a DM, or None for channel send.
|
||||||
channel: Channel index for channel sends (Meshtastic semantics; ignored here).
|
channel: Channel index for channel sends (Meshtastic semantics; ignored here).
|
||||||
transport: Optional routing hint (for CompositeTransport); ignored here.
|
transport: Optional routing hint (for CompositeTransport); ignored here.
|
||||||
meshcore_channel: Per-family MeshCore channel index for broadcasts.
|
meshcore_channel: Per-family MeshCore channel NAME for broadcasts.
|
||||||
When provided, overrides the global meshcore_channel_index.
|
Resolved to a companion slot via the live channel table.
|
||||||
When None on a broadcast, the send is skipped (family not
|
When None on a broadcast, the send is skipped (family not
|
||||||
configured for MeshCore — no fallback, no default).
|
configured for MeshCore — no fallback, no default).
|
||||||
|
An unknown name is never blind-sent: it warns and returns False.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the send succeeded (not an error event).
|
True if the send succeeded (not an error event).
|
||||||
|
|
@ -231,7 +296,7 @@ class MeshCoreTransport(MeshTransport):
|
||||||
# index 8) that have no relationship to MeshCore's separate
|
# index 8) that have no relationship to MeshCore's separate
|
||||||
# channel table.
|
# channel table.
|
||||||
#
|
#
|
||||||
# Per-family routing: use meshcore_channel when provided.
|
# Per-family routing: meshcore_channel is a channel NAME.
|
||||||
# If meshcore_channel is None, this family is not configured
|
# If meshcore_channel is None, this family is not configured
|
||||||
# for MeshCore → silent no-op (return True).
|
# for MeshCore → silent no-op (return True).
|
||||||
if meshcore_channel is None:
|
if meshcore_channel is None:
|
||||||
|
|
@ -239,8 +304,22 @@ class MeshCoreTransport(MeshTransport):
|
||||||
"MeshCoreTransport: meshcore_channel=None, skipping broadcast"
|
"MeshCoreTransport: meshcore_channel=None, skipping broadcast"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
# Resolve NAME → slot against the live companion channel table.
|
||||||
|
idx = self._chan_name_to_idx.get(meshcore_channel)
|
||||||
|
if idx is None:
|
||||||
|
# One lazy re-enumeration in case the table changed since
|
||||||
|
# connect (e.g. a channel was provisioned after startup).
|
||||||
|
self._enumerate_channels()
|
||||||
|
idx = self._chan_name_to_idx.get(meshcore_channel)
|
||||||
|
if idx is None:
|
||||||
|
# Never blind-send to a guessed slot.
|
||||||
|
logger.warning(
|
||||||
|
"MeshCore channel '%s' not on companion; skipping",
|
||||||
|
meshcore_channel,
|
||||||
|
)
|
||||||
|
return False
|
||||||
result = self._run_coro(
|
result = self._run_coro(
|
||||||
self._mc.commands.send_chan_msg(meshcore_channel, text)
|
self._mc.commands.send_chan_msg(idx, text)
|
||||||
)
|
)
|
||||||
success = not result.is_error()
|
success = not result.is_error()
|
||||||
if not success:
|
if not success:
|
||||||
|
|
|
||||||
|
|
@ -212,3 +212,111 @@ def test_webhook_channel_uses_webhook_renderer():
|
||||||
assert "schema_version" in json_payload
|
assert "schema_version" in json_payload
|
||||||
assert json_payload["schema_version"] == "1.0"
|
assert json_payload["schema_version"] == "1.0"
|
||||||
assert json_payload["message"] == "Test webhook message"
|
assert json_payload["message"] == "Test webhook message"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PER-FAMILY MESHCORE ROUTING — end-to-end threading guard
|
||||||
|
# (regression guard for the broadcast send-path gap)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_broadcast_threads_meshcore_channel_through_factory():
|
||||||
|
"""create_channel(rule) -> MeshBroadcastChannel.deliver must pass BOTH
|
||||||
|
channel=<broadcast_channel> AND meshcore_channel=<name> to send_message.
|
||||||
|
|
||||||
|
This is the regression guard for the gap where the rule's
|
||||||
|
meshcore_channel never reached connector.send_message.
|
||||||
|
"""
|
||||||
|
from meshai.config import NotificationRuleConfig
|
||||||
|
from meshai.notifications.channels import create_channel
|
||||||
|
|
||||||
|
mock_connector = MagicMock()
|
||||||
|
rule = NotificationRuleConfig(
|
||||||
|
name="toggle:fire",
|
||||||
|
delivery_type="mesh_broadcast",
|
||||||
|
broadcast_channel=1,
|
||||||
|
meshcore_channel="AIDA",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = create_channel(rule, mock_connector)
|
||||||
|
|
||||||
|
# Pre-chunked payload => exactly one deterministic send_message call.
|
||||||
|
payload = NotificationPayload(
|
||||||
|
message="fire alert",
|
||||||
|
category="fire",
|
||||||
|
severity="immediate",
|
||||||
|
timestamp=time.time(),
|
||||||
|
event_type="fire",
|
||||||
|
chunk_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert asyncio.run(channel.deliver(payload, rule)) is True
|
||||||
|
|
||||||
|
mock_connector.send_message.assert_called_once()
|
||||||
|
kwargs = mock_connector.send_message.call_args.kwargs
|
||||||
|
assert kwargs.get("channel") == 1
|
||||||
|
# The load-bearing assertion: the name was NOT dropped.
|
||||||
|
assert kwargs.get("meshcore_channel") == "AIDA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_broadcast_meshcore_channel_none_passed_through():
|
||||||
|
"""meshcore_channel=None (family not on MeshCore) => send_message still
|
||||||
|
receives meshcore_channel=None (MeshCore child skipped downstream)."""
|
||||||
|
from meshai.config import NotificationRuleConfig
|
||||||
|
from meshai.notifications.channels import create_channel
|
||||||
|
|
||||||
|
mock_connector = MagicMock()
|
||||||
|
rule = NotificationRuleConfig(
|
||||||
|
name="toggle:weather",
|
||||||
|
delivery_type="mesh_broadcast",
|
||||||
|
broadcast_channel=0,
|
||||||
|
meshcore_channel=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = create_channel(rule, mock_connector)
|
||||||
|
|
||||||
|
payload = NotificationPayload(
|
||||||
|
message="weather alert",
|
||||||
|
category="weather_warning",
|
||||||
|
severity="priority",
|
||||||
|
timestamp=time.time(),
|
||||||
|
event_type="weather_warning",
|
||||||
|
chunk_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert asyncio.run(channel.deliver(payload, rule)) is True
|
||||||
|
|
||||||
|
kwargs = mock_connector.send_message.call_args.kwargs
|
||||||
|
assert kwargs.get("channel") == 0
|
||||||
|
assert "meshcore_channel" in kwargs
|
||||||
|
assert kwargs.get("meshcore_channel") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_broadcast_render_loop_threads_meshcore_channel():
|
||||||
|
"""Non-prechunked path (renderer loop) also threads meshcore_channel
|
||||||
|
on every chunk send."""
|
||||||
|
from meshai.config import NotificationRuleConfig
|
||||||
|
from meshai.notifications.channels import create_channel
|
||||||
|
|
||||||
|
mock_connector = MagicMock()
|
||||||
|
rule = NotificationRuleConfig(
|
||||||
|
name="toggle:fire",
|
||||||
|
delivery_type="mesh_broadcast",
|
||||||
|
broadcast_channel=2,
|
||||||
|
meshcore_channel="AIDA",
|
||||||
|
)
|
||||||
|
channel = create_channel(rule, mock_connector)
|
||||||
|
|
||||||
|
long_message = "This is a very long alert message that exceeds the limit. " * 5
|
||||||
|
payload = NotificationPayload(
|
||||||
|
message=long_message,
|
||||||
|
category="fire",
|
||||||
|
severity="immediate",
|
||||||
|
timestamp=time.time(),
|
||||||
|
event_type="fire",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert asyncio.run(channel.deliver(payload, rule)) is True
|
||||||
|
assert mock_connector.send_message.call_count >= 2
|
||||||
|
for call in mock_connector.send_message.call_args_list:
|
||||||
|
assert call.kwargs.get("channel") == 2
|
||||||
|
assert call.kwargs.get("meshcore_channel") == "AIDA"
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ class TestBroadcast:
|
||||||
mt = FakeChild("meshtastic")
|
mt = FakeChild("meshtastic")
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("hello mesh", meshcore_channel=0)
|
result = comp.send_message("hello mesh", meshcore_channel="AIDA")
|
||||||
assert result is True
|
assert result is True
|
||||||
assert len(mt.send_calls) == 1
|
assert len(mt.send_calls) == 1
|
||||||
assert len(mc.send_calls) == 1
|
assert len(mc.send_calls) == 1
|
||||||
|
|
@ -239,22 +239,22 @@ class TestBroadcast:
|
||||||
assert len(mc.send_calls) == 0 # MeshCore was skipped
|
assert len(mc.send_calls) == 0 # MeshCore was skipped
|
||||||
|
|
||||||
def test_broadcast_meshcore_uses_meshcore_channel(self) -> None:
|
def test_broadcast_meshcore_uses_meshcore_channel(self) -> None:
|
||||||
"""MeshCore child receives meshcore_channel, Meshtastic receives channel."""
|
"""MeshCore child receives meshcore_channel NAME, Meshtastic receives channel."""
|
||||||
mt = FakeChild("meshtastic")
|
mt = FakeChild("meshtastic")
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("hello", channel=1, meshcore_channel=3)
|
result = comp.send_message("hello", channel=1, meshcore_channel="Fire")
|
||||||
assert result is True
|
assert result is True
|
||||||
assert len(mt.send_calls) == 1
|
assert len(mt.send_calls) == 1
|
||||||
assert len(mc.send_calls) == 1
|
assert len(mc.send_calls) == 1
|
||||||
assert mt.send_calls[0]["channel"] == 1 # Meshtastic gets `channel`
|
assert mt.send_calls[0]["channel"] == 1 # Meshtastic gets `channel`
|
||||||
assert mc.send_calls[0]["channel"] == 3 # MeshCore gets `meshcore_channel`
|
assert mc.send_calls[0]["channel"] == "Fire" # MeshCore gets `meshcore_channel` name
|
||||||
|
|
||||||
def test_broadcast_meshtastic_only_when_no_meshcore_child(self) -> None:
|
def test_broadcast_meshtastic_only_when_no_meshcore_child(self) -> None:
|
||||||
"""When there is no MeshCore child, meshcore_channel is irrelevant."""
|
"""When there is no MeshCore child, meshcore_channel is irrelevant."""
|
||||||
mt = FakeChild("meshtastic")
|
mt = FakeChild("meshtastic")
|
||||||
comp = CompositeTransport([mt])
|
comp = CompositeTransport([mt])
|
||||||
result = comp.send_message("hello", channel=2, meshcore_channel=5)
|
result = comp.send_message("hello", channel=2, meshcore_channel="Fire")
|
||||||
assert result is True
|
assert result is True
|
||||||
assert len(mt.send_calls) == 1
|
assert len(mt.send_calls) == 1
|
||||||
assert mt.send_calls[0]["channel"] == 2
|
assert mt.send_calls[0]["channel"] == 2
|
||||||
|
|
@ -264,7 +264,7 @@ class TestBroadcast:
|
||||||
mt = FakeChild("meshtastic", connected_val=False)
|
mt = FakeChild("meshtastic", connected_val=False)
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("hi", meshcore_channel=0)
|
result = comp.send_message("hi", meshcore_channel="AIDA")
|
||||||
assert result is True
|
assert result is True
|
||||||
assert len(mt.send_calls) == 0
|
assert len(mt.send_calls) == 0
|
||||||
assert len(mc.send_calls) == 1
|
assert len(mc.send_calls) == 1
|
||||||
|
|
@ -275,7 +275,7 @@ class TestBroadcast:
|
||||||
mt.send_message = MagicMock(return_value=False)
|
mt.send_message = MagicMock(return_value=False)
|
||||||
mc = FakeChild("meshcore")
|
mc = FakeChild("meshcore")
|
||||||
comp = CompositeTransport([mt, mc])
|
comp = CompositeTransport([mt, mc])
|
||||||
result = comp.send_message("test", meshcore_channel=0)
|
result = comp.send_message("test", meshcore_channel="AIDA")
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ cfg.notifications.rules as raw dicts (which crashed Dispatcher._matching_rules
|
||||||
on rule.enabled). config_loader.load_config uses this same _dict_to_dataclass.
|
on rule.enabled). config_loader.load_config uses this same _dict_to_dataclass.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from meshai.config import Config, NotificationRuleConfig, _dict_to_dataclass
|
from meshai.config import (
|
||||||
|
Config,
|
||||||
|
NotificationRuleConfig,
|
||||||
|
NotificationToggle,
|
||||||
|
_dataclass_to_dict,
|
||||||
|
_dict_to_dataclass,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_multifile_load_coerces_notification_rules():
|
def test_multifile_load_coerces_notification_rules():
|
||||||
|
|
@ -61,3 +67,20 @@ def test_rules_attribute_access_does_not_raise():
|
||||||
_ = r.trigger_type
|
_ = r.trigger_type
|
||||||
_ = r.categories
|
_ = r.categories
|
||||||
_ = r.min_severity
|
_ = r.min_severity
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_meshcore_channel_name_round_trips():
|
||||||
|
"""A NotificationToggle's meshcore_channel NAME survives dict round-trip.
|
||||||
|
|
||||||
|
_dict_to_dataclass drops unknown keys, so this guards that the new
|
||||||
|
meshcore_channel field is a real dataclass field and persists as a str.
|
||||||
|
"""
|
||||||
|
tog = NotificationToggle(name="fire", enabled=True, meshcore_channel="AIDA")
|
||||||
|
d = _dataclass_to_dict(tog)
|
||||||
|
assert d["meshcore_channel"] == "AIDA"
|
||||||
|
restored = _dict_to_dataclass(NotificationToggle, d)
|
||||||
|
assert restored.meshcore_channel == "AIDA"
|
||||||
|
|
||||||
|
# Default stays None when unset.
|
||||||
|
default = _dict_to_dataclass(NotificationToggle, {"name": "weather"})
|
||||||
|
assert default.meshcore_channel is None
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,6 @@ def _mc_config(**overrides):
|
||||||
transport="meshcore",
|
transport="meshcore",
|
||||||
meshcore_host="127.0.0.1",
|
meshcore_host="127.0.0.1",
|
||||||
meshcore_port=5050,
|
meshcore_port=5050,
|
||||||
meshcore_channel_index=0,
|
|
||||||
)
|
)
|
||||||
for k, v in overrides.items():
|
for k, v in overrides.items():
|
||||||
setattr(cfg, k, v)
|
setattr(cfg, k, v)
|
||||||
|
|
@ -117,6 +116,7 @@ def _transport_with_mock_mc(mc_overrides=None):
|
||||||
|
|
||||||
mc = MagicMock()
|
mc = MagicMock()
|
||||||
mc.get_contact_by_key_prefix.return_value = None
|
mc.get_contact_by_key_prefix.return_value = None
|
||||||
|
_install_channel_table(mc)
|
||||||
if mc_overrides:
|
if mc_overrides:
|
||||||
for k, v in mc_overrides.items():
|
for k, v in mc_overrides.items():
|
||||||
setattr(mc, k, v)
|
setattr(mc, k, v)
|
||||||
|
|
@ -156,6 +156,31 @@ def _make_channel_event(text="chan msg", channel_idx=2, **extra):
|
||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
# Default fake companion channel table: NAME -> slot. Slots not present here
|
||||||
|
# report an empty channel_name, so _enumerate_channels stops after the empty run.
|
||||||
|
_FAKE_CHANNEL_TABLE = {2: "AIDA", 3: "Fire"}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_channel_table(mc, table=None):
|
||||||
|
"""Wire ``mc.commands.get_channel`` to enumerate a fake channel table.
|
||||||
|
|
||||||
|
``table`` maps slot index -> channel name. get_channel(idx) returns a
|
||||||
|
non-error event whose payload carries ``channel_name``/``channel_idx``
|
||||||
|
exactly like the real firmware; unlisted slots report an empty name so
|
||||||
|
_enumerate_channels ends on the contiguous-empty run.
|
||||||
|
"""
|
||||||
|
if table is None:
|
||||||
|
table = _FAKE_CHANNEL_TABLE
|
||||||
|
|
||||||
|
async def _get_channel(idx):
|
||||||
|
e = MagicMock()
|
||||||
|
e.is_error.return_value = False
|
||||||
|
e.payload = {"channel_name": table.get(idx, ""), "channel_idx": idx}
|
||||||
|
return e
|
||||||
|
|
||||||
|
mc.commands.get_channel = _get_channel
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Factory / subclass tests
|
# 1. Factory / subclass tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -176,25 +201,25 @@ class TestBuildTransport:
|
||||||
|
|
||||||
class TestSendMessageChannel:
|
class TestSendMessageChannel:
|
||||||
def test_returns_true_on_non_error_event(self):
|
def test_returns_true_on_non_error_event(self):
|
||||||
"""With meshcore_channel set, send_chan_msg is called and True returned."""
|
"""With a resolvable channel name, send_chan_msg is called and True returned."""
|
||||||
t, mc, _ = _transport_with_mock_mc()
|
t, mc, _ = _transport_with_mock_mc()
|
||||||
try:
|
try:
|
||||||
ok = MagicMock()
|
ok = MagicMock()
|
||||||
ok.is_error.return_value = False
|
ok.is_error.return_value = False
|
||||||
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
|
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
|
||||||
assert t.send_message("hello", meshcore_channel=0) is True
|
assert t.send_message("hello", meshcore_channel="AIDA") is True
|
||||||
mc.commands.send_chan_msg.assert_awaited_once()
|
mc.commands.send_chan_msg.assert_awaited_once()
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_returns_false_on_error_event(self):
|
def test_returns_false_on_error_event(self):
|
||||||
"""With meshcore_channel set, an error result returns False."""
|
"""With a resolvable channel name, an error result returns False."""
|
||||||
t, mc, _ = _transport_with_mock_mc()
|
t, mc, _ = _transport_with_mock_mc()
|
||||||
try:
|
try:
|
||||||
err = MagicMock()
|
err = MagicMock()
|
||||||
err.is_error.return_value = True
|
err.is_error.return_value = True
|
||||||
mc.commands.send_chan_msg = AsyncMock(return_value=err)
|
mc.commands.send_chan_msg = AsyncMock(return_value=err)
|
||||||
assert t.send_message("hello", meshcore_channel=0) is False
|
assert t.send_message("hello", meshcore_channel="AIDA") is False
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
|
|
@ -202,7 +227,7 @@ class TestSendMessageChannel:
|
||||||
cfg = _mc_config()
|
cfg = _mc_config()
|
||||||
t = MeshCoreTransport(cfg)
|
t = MeshCoreTransport(cfg)
|
||||||
# _mc is None, no loop started — fails before channel check.
|
# _mc is None, no loop started — fails before channel check.
|
||||||
assert t.send_message("test", meshcore_channel=0) is False
|
assert t.send_message("test", meshcore_channel="AIDA") is False
|
||||||
|
|
||||||
def test_meshcore_channel_none_skips_broadcast(self):
|
def test_meshcore_channel_none_skips_broadcast(self):
|
||||||
"""meshcore_channel=None → silent no-op (True) without calling send_chan_msg."""
|
"""meshcore_channel=None → silent no-op (True) without calling send_chan_msg."""
|
||||||
|
|
@ -227,7 +252,8 @@ class TestSendMessageChannel:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def _transport_with_mock_send_chan_msg(self):
|
def _transport_with_mock_send_chan_msg(self):
|
||||||
"""Build a MeshCoreTransport with a mock mc and async send_chan_msg."""
|
"""Build a MeshCoreTransport with a mock mc, a fake channel table, and
|
||||||
|
an async send_chan_msg recorder."""
|
||||||
cfg = _mc_config()
|
cfg = _mc_config()
|
||||||
t = MeshCoreTransport(cfg)
|
t = MeshCoreTransport(cfg)
|
||||||
ok = MagicMock()
|
ok = MagicMock()
|
||||||
|
|
@ -235,6 +261,7 @@ class TestSendMessageChannel:
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
mc = MagicMock()
|
mc = MagicMock()
|
||||||
|
_install_channel_table(mc) # {"AIDA": 2, "Fire": 3}
|
||||||
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
|
mc.commands.send_chan_msg = AsyncMock(return_value=ok)
|
||||||
t._mc = mc
|
t._mc = mc
|
||||||
t._connected = True
|
t._connected = True
|
||||||
|
|
@ -244,30 +271,43 @@ class TestSendMessageChannel:
|
||||||
t._loop_thread = thread
|
t._loop_thread = thread
|
||||||
return t, mc
|
return t, mc
|
||||||
|
|
||||||
def test_uses_meshcore_channel_for_broadcast(self):
|
def test_resolves_name_to_slot_for_broadcast(self):
|
||||||
"""meshcore_channel=3 → send_chan_msg(3, text)."""
|
"""meshcore_channel='AIDA' → resolves to slot 2 → send_chan_msg(2, text)."""
|
||||||
t, mc = self._transport_with_mock_send_chan_msg()
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
try:
|
try:
|
||||||
t.send_message("hi", meshcore_channel=3)
|
assert t.send_message("hi", meshcore_channel="AIDA") is True
|
||||||
|
mc.commands.send_chan_msg.assert_awaited_once_with(2, "hi")
|
||||||
|
finally:
|
||||||
|
_cleanup(t)
|
||||||
|
|
||||||
|
def test_resolves_second_named_channel(self):
|
||||||
|
"""meshcore_channel='Fire' → resolves to slot 3 → send_chan_msg(3, text)."""
|
||||||
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
|
try:
|
||||||
|
assert t.send_message("hi", meshcore_channel="Fire") is True
|
||||||
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_ignores_meshtastic_channel_uses_meshcore_channel(self):
|
def test_ignores_meshtastic_channel_uses_meshcore_name(self):
|
||||||
"""channel=8 (Meshtastic) is irrelevant; meshcore_channel=3 is authoritative."""
|
"""channel=8 (Meshtastic) is irrelevant; the MeshCore NAME is authoritative."""
|
||||||
t, mc = self._transport_with_mock_send_chan_msg()
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
try:
|
try:
|
||||||
t.send_message("hi", channel=8, meshcore_channel=3)
|
t.send_message("hi", channel=8, meshcore_channel="Fire")
|
||||||
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
mc.commands.send_chan_msg.assert_awaited_once_with(3, "hi")
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
def test_meshcore_channel_zero_is_valid(self):
|
def test_unknown_name_warns_and_never_sends(self, caplog):
|
||||||
"""meshcore_channel=0 is a valid channel (not falsy-skipped)."""
|
"""An unresolved channel name → no send_chan_msg, returns False, warns."""
|
||||||
|
import logging
|
||||||
t, mc = self._transport_with_mock_send_chan_msg()
|
t, mc = self._transport_with_mock_send_chan_msg()
|
||||||
try:
|
try:
|
||||||
t.send_message("hi", meshcore_channel=0)
|
with caplog.at_level(logging.WARNING):
|
||||||
mc.commands.send_chan_msg.assert_awaited_once_with(0, "hi")
|
result = t.send_message("hi", meshcore_channel="Nonexistent")
|
||||||
|
assert result is False
|
||||||
|
mc.commands.send_chan_msg.assert_not_awaited()
|
||||||
|
assert any("Nonexistent" in r.getMessage() for r in caplog.records)
|
||||||
finally:
|
finally:
|
||||||
_cleanup(t)
|
_cleanup(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@ def _mc_config(**overrides):
|
||||||
transport="meshcore",
|
transport="meshcore",
|
||||||
meshcore_host="127.0.0.1",
|
meshcore_host="127.0.0.1",
|
||||||
meshcore_port=5050,
|
meshcore_port=5050,
|
||||||
meshcore_channel_index=0,
|
|
||||||
)
|
)
|
||||||
for k, v in overrides.items():
|
for k, v in overrides.items():
|
||||||
setattr(cfg, k, v)
|
setattr(cfg, k, v)
|
||||||
|
|
@ -178,7 +177,6 @@ class TestTransportMaxChars:
|
||||||
tcp_port=4403,
|
tcp_port=4403,
|
||||||
meshcore_host="127.0.0.1",
|
meshcore_host="127.0.0.1",
|
||||||
meshcore_port=5050,
|
meshcore_port=5050,
|
||||||
meshcore_channel_index=0,
|
|
||||||
)
|
)
|
||||||
comp = build_transport(cfg_both)
|
comp = build_transport(cfg_both)
|
||||||
assert isinstance(comp, CompositeTransport)
|
assert isinstance(comp, CompositeTransport)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue