meshai/work/tests/test_mesh_send_api.py

361 lines
12 KiB
Python
Raw Normal View History

"""API tests for the dashboard 'send test message' routes.
Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector``
(MagicMock-based fakes). The connector fakes mimic a CompositeTransport:
``transport_name=None`` and an explicit iterable ``children`` list.
"""
from __future__ import annotations
feat(transport): per-radio serialized+paced outbound send queue (#93) * feat(transport): per-radio serialized+paced outbound send queue Prevents simultaneous LoRa transmissions when N events arrive at once. ## Mechanism Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue with a long-running drain task. The MT queue drains on the main asyncio loop; the MC queue drains on MeshCore's dedicated event-loop thread. - MT sends: `run_in_executor` offloads the blocking `sendText` call; queue started in `set_message_callback`, cancelled in `disconnect`. - MC sends: drain loop runs pure-async MC lib coroutines directly on the MC loop (no `_run_coro` deadlock); cross-loop callers bridge via `concurrent.futures.Future` + `asyncio.wrap_future`. - Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live from config per iteration; floor clamped to 0.25 s. - Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and `meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`. ## Send sites rerouted All callers now `await connector.send_message_async(...)`: - `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/ MeshCoreDM deliver(), test_connection(), deliver_test() - `responder.py` — DM replies in send_response() - `transport/meshcore_transport.py` — periodic_advert_loop, telemetry poll loop, send_advert() → send_advert_async(), req_telemetry() → req_telemetry_async() (all queue-routed from main loop) - `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll ## Audit accuracy `deliver()` now returns the actual bool from the radio send (not optimistic True), so `mesh_broadcasts_out` reflects the real result. ## Tests 17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops, pacing gap, pacing floor enforcement, event-loop non-blocking, serialization, lifecycle, MT fallback, config round-trip. Existing test stubs updated to wire `send_message_async = AsyncMock(side_effect=send_message)` so prior call_count / call_args assertions remain valid without changes. Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py): _req_telemetry_async was calling _enqueue_mc_loop_send inside itself; when _telem_job_outer ran inside the drain it nested another enqueue+await on the same single-threaded drain — permanent deadlock on first telemetry poll. Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send). _telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization. req_telemetry_async's outer job calls _req_telemetry_async inline (safe). BLOCKER 2 — pending futures abandoned on teardown/reconnect: RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had their concurrent.futures.Futures left unresolved, causing wrap_future() callers to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait() and cancels every pending cfut. _cancel_mc_queue() schedules the same drain- and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task and drains old queue cfuts before arming the new queue (reconnect path). connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget. SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py): _broadcast_async no-hint loop was calling send_message_async(channel=child_channel) for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed. NIT 5 — false success on zero-channel MC send (meshcore_transport.py): send_message_async returned True when meshcore_channel is None (nothing sent). Now returns False so audit does not record a success for a no-op. NIT 7 — config comment contradiction (config.py): meshtastic_send_pacing_seconds comment said "0 disables the floor" while simultaneously stating "still floored at 0.25". Removed the contradiction. Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression): - test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real _mc_send_queue with fake MC commands; times out on pre-fix code (deadlock). - test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain, asserts every task resolves promptly; hangs on pre-fix code. - test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old cfuts are cancelled; pre-fix leaves them unresolved. All 17 pre-existing send-queue tests still pass (20 total now). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:38:08 -06:00
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from meshai.dashboard.api.mesh_send_routes import router
def _child(transport_name, connected=True, known=None):
"""Build a fake child transport (meshtastic/meshcore)."""
c = MagicMock()
c.transport_name = transport_name
c.connected = connected
if known is not None:
c.known_channels.return_value = list(known)
feat(transport): per-radio serialized+paced outbound send queue (#93) * feat(transport): per-radio serialized+paced outbound send queue Prevents simultaneous LoRa transmissions when N events arrive at once. ## Mechanism Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue with a long-running drain task. The MT queue drains on the main asyncio loop; the MC queue drains on MeshCore's dedicated event-loop thread. - MT sends: `run_in_executor` offloads the blocking `sendText` call; queue started in `set_message_callback`, cancelled in `disconnect`. - MC sends: drain loop runs pure-async MC lib coroutines directly on the MC loop (no `_run_coro` deadlock); cross-loop callers bridge via `concurrent.futures.Future` + `asyncio.wrap_future`. - Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live from config per iteration; floor clamped to 0.25 s. - Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and `meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`. ## Send sites rerouted All callers now `await connector.send_message_async(...)`: - `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/ MeshCoreDM deliver(), test_connection(), deliver_test() - `responder.py` — DM replies in send_response() - `transport/meshcore_transport.py` — periodic_advert_loop, telemetry poll loop, send_advert() → send_advert_async(), req_telemetry() → req_telemetry_async() (all queue-routed from main loop) - `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll ## Audit accuracy `deliver()` now returns the actual bool from the radio send (not optimistic True), so `mesh_broadcasts_out` reflects the real result. ## Tests 17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops, pacing gap, pacing floor enforcement, event-loop non-blocking, serialization, lifecycle, MT fallback, config round-trip. Existing test stubs updated to wire `send_message_async = AsyncMock(side_effect=send_message)` so prior call_count / call_args assertions remain valid without changes. Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py): _req_telemetry_async was calling _enqueue_mc_loop_send inside itself; when _telem_job_outer ran inside the drain it nested another enqueue+await on the same single-threaded drain — permanent deadlock on first telemetry poll. Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send). _telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization. req_telemetry_async's outer job calls _req_telemetry_async inline (safe). BLOCKER 2 — pending futures abandoned on teardown/reconnect: RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had their concurrent.futures.Futures left unresolved, causing wrap_future() callers to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait() and cancels every pending cfut. _cancel_mc_queue() schedules the same drain- and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task and drains old queue cfuts before arming the new queue (reconnect path). connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget. SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py): _broadcast_async no-hint loop was calling send_message_async(channel=child_channel) for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed. NIT 5 — false success on zero-channel MC send (meshcore_transport.py): send_message_async returned True when meshcore_channel is None (nothing sent). Now returns False so audit does not record a success for a no-op. NIT 7 — config comment contradiction (config.py): meshtastic_send_pacing_seconds comment said "0 disables the floor" while simultaneously stating "still floored at 0.25". Removed the contradiction. Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression): - test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real _mc_send_queue with fake MC commands; times out on pre-fix code (deadlock). - test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain, asserts every task resolves promptly; hangs on pre-fix code. - test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old cfuts are cancelled; pre-fix leaves them unresolved. All 17 pre-existing send-queue tests still pass (20 total now). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:38:08 -06:00
# Wire async variants so routes can await them; side_effect preserves the
# sync mock's return_value and call recording for existing assertions.
c.send_advert_async = AsyncMock(side_effect=lambda: c.send_advert())
c.req_telemetry_async = AsyncMock(side_effect=lambda cid: c.req_telemetry(cid))
return c
def _composite(children, send_result=True):
"""Build a fake CompositeTransport connector wrapping *children*.
A bare MagicMock's auto-attrs are truthy and ``children`` is not
iterable, so set both explicitly.
"""
connector = MagicMock()
connector.transport_name = None
connector.children = list(children)
connector.send_message.return_value = send_result
feat(transport): per-radio serialized+paced outbound send queue (#93) * feat(transport): per-radio serialized+paced outbound send queue Prevents simultaneous LoRa transmissions when N events arrive at once. ## Mechanism Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue with a long-running drain task. The MT queue drains on the main asyncio loop; the MC queue drains on MeshCore's dedicated event-loop thread. - MT sends: `run_in_executor` offloads the blocking `sendText` call; queue started in `set_message_callback`, cancelled in `disconnect`. - MC sends: drain loop runs pure-async MC lib coroutines directly on the MC loop (no `_run_coro` deadlock); cross-loop callers bridge via `concurrent.futures.Future` + `asyncio.wrap_future`. - Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live from config per iteration; floor clamped to 0.25 s. - Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and `meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`. ## Send sites rerouted All callers now `await connector.send_message_async(...)`: - `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/ MeshCoreDM deliver(), test_connection(), deliver_test() - `responder.py` — DM replies in send_response() - `transport/meshcore_transport.py` — periodic_advert_loop, telemetry poll loop, send_advert() → send_advert_async(), req_telemetry() → req_telemetry_async() (all queue-routed from main loop) - `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll ## Audit accuracy `deliver()` now returns the actual bool from the radio send (not optimistic True), so `mesh_broadcasts_out` reflects the real result. ## Tests 17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops, pacing gap, pacing floor enforcement, event-loop non-blocking, serialization, lifecycle, MT fallback, config round-trip. Existing test stubs updated to wire `send_message_async = AsyncMock(side_effect=send_message)` so prior call_count / call_args assertions remain valid without changes. Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py): _req_telemetry_async was calling _enqueue_mc_loop_send inside itself; when _telem_job_outer ran inside the drain it nested another enqueue+await on the same single-threaded drain — permanent deadlock on first telemetry poll. Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send). _telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization. req_telemetry_async's outer job calls _req_telemetry_async inline (safe). BLOCKER 2 — pending futures abandoned on teardown/reconnect: RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had their concurrent.futures.Futures left unresolved, causing wrap_future() callers to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait() and cancels every pending cfut. _cancel_mc_queue() schedules the same drain- and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task and drains old queue cfuts before arming the new queue (reconnect path). connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget. SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py): _broadcast_async no-hint loop was calling send_message_async(channel=child_channel) for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed. NIT 5 — false success on zero-channel MC send (meshcore_transport.py): send_message_async returned True when meshcore_channel is None (nothing sent). Now returns False so audit does not record a success for a no-op. NIT 7 — config comment contradiction (config.py): meshtastic_send_pacing_seconds comment said "0 disables the floor" while simultaneously stating "still floored at 0.25". Removed the contradiction. Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression): - test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real _mc_send_queue with fake MC commands; times out on pre-fix code (deadlock). - test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain, asserts every task resolves promptly; hangs on pre-fix code. - test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old cfuts are cancelled; pre-fix leaves them unresolved. All 17 pre-existing send-queue tests still pass (20 total now). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:38:08 -06:00
# Wire the async variant — routes now call send_message_async; side_effect
# delegates to the sync mock so existing call_args assertions still pass.
connector.send_message_async = AsyncMock(
side_effect=lambda *a, **kw: connector.send_message(*a, **kw)
)
return connector
def _client(connector):
app = FastAPI()
app.include_router(router, prefix="/api")
app.state.connector = connector
return TestClient(app)
# ============================================================================
# POST /api/mesh/test-send — meshcore
# ============================================================================
def test_meshcore_success():
mc = _child("meshcore", connected=True, known=["aida", "emergency"])
connector = _composite([mc], send_result=True)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
assert r.status_code == 200
body = r.json()
assert body["sent"] is True
assert "aida" in body["detail"]
connector.send_message.assert_called_once()
_, kwargs = connector.send_message.call_args
assert kwargs["meshcore_channel"] == "aida"
assert kwargs["transport"] == "meshcore"
assert kwargs["destination"] is None
def test_meshcore_unknown_channel():
mc = _child("meshcore", connected=True, known=["aida"])
connector = _composite([mc], send_result=False)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "ghost"})
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert "not on companion" in body["detail"]
assert "aida" in body["detail"]
def test_meshcore_inactive():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert body["detail"] == "meshcore not connected"
# ============================================================================
# POST /api/mesh/test-send — meshtastic
# ============================================================================
def test_meshtastic_success():
mt = _child("meshtastic", connected=True)
connector = _composite([mt], send_result=True)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshtastic", "channel": 0})
assert r.status_code == 200
body = r.json()
assert body["sent"] is True
connector.send_message.assert_called_once()
_, kwargs = connector.send_message.call_args
assert kwargs["channel"] == 0
assert kwargs["transport"] == "meshtastic"
# ============================================================================
# Default text
# ============================================================================
def test_default_text_when_omitted():
mc = _child("meshcore", connected=True, known=["aida"])
connector = _composite([mc], send_result=True)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
assert r.status_code == 200
assert r.json()["sent"] is True
args, kwargs = connector.send_message.call_args
sent_text = kwargs["text"] if "text" in kwargs else args[0]
assert isinstance(sent_text, str)
assert sent_text.startswith("🧪 MeshAI test")
# ============================================================================
# GET /api/meshcore/channels
# ============================================================================
def test_meshcore_channels_active():
mc = _child("meshcore", connected=True, known=["aida", "emergency"])
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/channels")
assert r.status_code == 200
assert r.json() == {"active": True, "channels": ["aida", "emergency"]}
def test_meshcore_channels_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/channels")
assert r.status_code == 200
assert r.json() == {"active": False, "channels": []}
# ============================================================================
# GET /api/meshcore/contacts
# ============================================================================
_SAMPLE_ROSTER = [
{
"name": "Repeater One",
"pubkey": "aa11deadbeef",
"type": "repeater",
"last_advert": 1000,
"lat": 43.6,
"lon": -116.2,
"out_path_len": 2,
},
{
"name": "Sensor Two",
"pubkey": "bb22cafef00d",
"type": "sensor",
"last_advert": 2000,
"lat": None,
"lon": None,
"out_path_len": -1,
},
]
def test_meshcore_contacts_active():
mc = _child("meshcore", connected=True)
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
def test_meshcore_contacts_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": False, "contacts": []}
def test_meshcore_contacts_disconnected():
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": False, "contacts": []}
# ============================================================================
# GET /api/meshcore/self
# ============================================================================
def test_meshcore_self_active():
mc = _child("meshcore", connected=True)
mc.self_info.return_value = {
"name": "AIDA",
"pubkey": "deadbeef1234",
"connected": True,
"host": "100.64.0.9",
"port": 5050,
"channel_count": 2,
}
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
body = r.json()
assert body["connected"] is True
assert body["pubkey"] == "deadbeef1234"
assert body["name"] == "AIDA"
assert body["channel_count"] == 2
def test_meshcore_self_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
assert r.json() == {"connected": False}
def test_meshcore_self_disconnected():
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
assert r.json() == {"connected": False}
# ============================================================================
# POST /api/meshcore/advert
# ============================================================================
def test_meshcore_advert_connected_returns_sent_true():
"""POST /api/meshcore/advert → {sent: true} when meshcore is connected."""
mc = _child("meshcore", connected=True)
mc.send_advert.return_value = True
connector = _composite([mc])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is True
assert "detail" in body
mc.send_advert.assert_called_once()
def test_meshcore_advert_connected_send_returns_false():
"""POST /api/meshcore/advert → {sent: false} when send_advert() returns False."""
mc = _child("meshcore", connected=True)
mc.send_advert.return_value = False
connector = _composite([mc])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
def test_meshcore_advert_not_connected():
"""POST /api/meshcore/advert → {sent: false, detail: 'MeshCore not connected'}."""
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert body["detail"] == "MeshCore not connected"
def test_meshcore_advert_no_meshcore_child():
"""POST /api/meshcore/advert → {sent: false} when there is no meshcore transport."""
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert body["detail"] == "MeshCore not connected"
# ============================================================================
# Config round-trip: meshcore_advert_interval_seconds
# ============================================================================
def test_connection_config_advert_interval_default():
"""meshcore_advert_interval_seconds defaults to 10800 (3 h)."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig()
assert cfg.meshcore_advert_interval_seconds == 10800
def test_connection_config_advert_interval_zero():
"""meshcore_advert_interval_seconds = 0 disables periodic advert."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig(meshcore_advert_interval_seconds=0)
assert cfg.meshcore_advert_interval_seconds == 0
def test_connection_config_advert_interval_round_trips_yaml():
"""meshcore_advert_interval_seconds survives YAML serialize → deserialize."""
from meshai.config import ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass
cfg = ConnectionConfig(meshcore_advert_interval_seconds=7200)
data = _dataclass_to_dict(cfg)
assert data["meshcore_advert_interval_seconds"] == 7200
cfg2 = _dict_to_dataclass(ConnectionConfig, data)
assert cfg2.meshcore_advert_interval_seconds == 7200