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
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>
86 lines
3 KiB
Python
86 lines
3 KiB
Python
"""Phase 2.16.1: lock in notification-rule coercion in the config loader path.
|
|
|
|
Regression guard for the bug where the generic nested-dataclass handler in
|
|
_dict_to_dataclass shadowed the explicit 'notifications' branch, leaving
|
|
cfg.notifications.rules as raw dicts (which crashed Dispatcher._matching_rules
|
|
on rule.enabled). config_loader.load_config uses this same _dict_to_dataclass.
|
|
"""
|
|
|
|
from meshai.config import (
|
|
Config,
|
|
NotificationRuleConfig,
|
|
NotificationToggle,
|
|
_dataclass_to_dict,
|
|
_dict_to_dataclass,
|
|
)
|
|
|
|
|
|
def test_multifile_load_coerces_notification_rules():
|
|
"""notifications.rules dicts are coerced to NotificationRuleConfig."""
|
|
data = {
|
|
"notifications": {
|
|
"enabled": True,
|
|
"rules": [
|
|
{
|
|
"name": "Test Rule",
|
|
"enabled": True,
|
|
"trigger_type": "condition",
|
|
"categories": ["earthquake_event"],
|
|
"min_severity": "routine",
|
|
"delivery_type": "mesh_broadcast",
|
|
},
|
|
{
|
|
"name": "Second Rule",
|
|
"enabled": False,
|
|
"trigger_type": "condition",
|
|
"categories": ["wildfire_incident"],
|
|
"delivery_type": "email",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
cfg = _dict_to_dataclass(Config, data)
|
|
rules = cfg.notifications.rules
|
|
assert len(rules) == 2
|
|
# Coerced to the dataclass, NOT left as dicts.
|
|
assert all(isinstance(r, NotificationRuleConfig) for r in rules)
|
|
# Attribute access (what Dispatcher._matching_rules needs) works.
|
|
assert rules[0].enabled is True
|
|
assert rules[0].name == "Test Rule"
|
|
assert rules[1].enabled is False
|
|
|
|
|
|
def test_rules_attribute_access_does_not_raise():
|
|
"""Dispatcher-style attribute access on every rule succeeds."""
|
|
data = {
|
|
"notifications": {
|
|
"rules": [
|
|
{"name": "R", "enabled": True, "trigger_type": "condition",
|
|
"categories": ["earthquake_event"], "min_severity": "immediate"},
|
|
]
|
|
}
|
|
}
|
|
cfg = _dict_to_dataclass(Config, data)
|
|
for r in cfg.notifications.rules:
|
|
# These are the accesses Dispatcher._matching_rules performs.
|
|
_ = r.enabled
|
|
_ = r.trigger_type
|
|
_ = r.categories
|
|
_ = 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
|