Get closer to parity with meshcore packet capture

This commit is contained in:
Jack Kingsman
2026-03-03 09:31:24 -08:00
parent 662e84adbe
commit be21b434cf
4 changed files with 57 additions and 30 deletions

View File

@@ -196,10 +196,9 @@ On connect and every 5 minutes thereafter, the community publisher sends a retai
"timestamp": "2024-01-15T10:30:00.000000",
"origin": "NodeName",
"origin_id": "PUBKEY_HEX_UPPER",
"client": "RemoteTerm (github.com/...)",
"model": "T-Deck",
"firmware_version": "v2.2.2 (Build: 2025-01-15)",
"radio": "915.0MHz BW250.0 SF10 CR8",
"radio": "915.0,250.0,10,8",
"client_version": "RemoteTerm/2.4.0",
"stats": {
"battery_mv": 4200,
@@ -216,7 +215,7 @@ On connect and every 5 minutes thereafter, the community publisher sends a retai
```
- `model` and `firmware_version` are fetched once per connection via `send_device_query()` (requires firmware version >= 3)
- `radio` is formatted from `self_info` radio parameters (freq, BW, SF, CR)
- `radio` is comma-separated raw values from `self_info` (freq, BW, SF, CR) matching the reference format
- `client_version` is read from Python package metadata (`remoteterm-meshcore`)
- `stats` is fetched from `get_stats_core()` + `get_stats_radio()` every 5 minutes; omitted if firmware doesn't support stats commands
- All radio queries use `blocking=False` — if the radio is busy, cached values are used. No user-facing operations are ever blocked.

View File

@@ -265,21 +265,24 @@ def _build_status_topic(settings: AppSettings, pubkey_hex: str) -> str:
def _build_radio_info() -> str:
"""Format the radio parameters string from self_info, or 'unknown'."""
"""Format the radio parameters string from self_info.
Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw
values). Falls back to ``"0,0,0,0"`` when unavailable.
"""
from app.radio import radio_manager
try:
if radio_manager.meshcore and radio_manager.meshcore.self_info:
info = radio_manager.meshcore.self_info
freq = info.get("radio_freq")
bw = info.get("radio_bw")
sf = info.get("radio_sf")
cr = info.get("radio_cr")
if freq is not None and bw is not None and sf is not None and cr is not None:
return f"{freq}MHz BW{bw} SF{sf} CR{cr}"
freq = info.get("radio_freq", 0)
bw = info.get("radio_bw", 0)
sf = info.get("radio_sf", 0)
cr = info.get("radio_cr", 0)
return f"{freq},{bw},{sf},{cr}"
except Exception:
pass
return "unknown"
return "0,0,0,0"
def _get_client_version() -> str:
@@ -340,6 +343,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
from app.keystore import get_private_key, get_public_key
from app.radio import radio_manager
private_key = get_private_key()
public_key = get_public_key()
@@ -357,12 +361,17 @@ class CommunityMqttPublisher(BaseMqttPublisher):
tls_context = ssl.create_default_context()
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
status_topic = _build_status_topic(settings, pubkey_hex)
offline_payload = json.dumps(
{
"status": "offline",
"timestamp": datetime.now().isoformat(),
"origin": device_name or "MeshCore Device",
"origin_id": pubkey_hex,
"client": _CLIENT_ID,
}
)
@@ -494,7 +503,6 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"timestamp": datetime.now().isoformat(),
"origin": device_name or "MeshCore Device",
"origin_id": pubkey_hex,
"client": _CLIENT_ID,
"model": device_info.get("model", "unknown"),
"firmware_version": device_info.get("firmware_version", "unknown"),
"radio": _build_radio_info(),

View File

@@ -341,7 +341,16 @@ export function SettingsMqttSection({
<div className="px-4 pb-4 space-y-3 border-t border-input">
<p className="text-xs text-muted-foreground pt-3">
Share raw packet data with the MeshCore community for coverage mapping and network
analysis. Only raw RF packets are shared never decrypted messages.
analysis. Only raw RF packets are shared never decrypted messages. General parity
with{' '}
<a
href="https://github.com/agessaman/meshcore-packet-capture"
target="_blank"
rel="noopener noreferrer"
>
meshcore-packet-capture
</a>
.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input

View File

@@ -463,9 +463,14 @@ class TestLwtAndStatusPublish:
community_mqtt_iata="SFO",
)
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
with (
patch("app.keystore.get_private_key", return_value=private_key),
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
):
kwargs = pub._build_client_kwargs(settings)
@@ -475,8 +480,10 @@ class TestLwtAndStatusPublish:
assert will.retain is True
payload = json.loads(will.payload)
assert payload["status"] == "offline"
assert payload["origin"] == "TestNode"
assert payload["origin_id"] == pubkey_hex
assert payload["client"] == _CLIENT_ID
assert "timestamp" in payload
assert "client" not in payload
@pytest.mark.asyncio
async def test_on_connected_async_publishes_online_status(self):
@@ -505,7 +512,7 @@ class TestLwtAndStatusPublish:
patch.object(
pub, "_fetch_stats", new_callable=AsyncMock, return_value={"battery_mv": 4200}
),
patch("app.community_mqtt._build_radio_info", return_value="915.0MHz BW250.0 SF10 CR8"),
patch("app.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -521,11 +528,11 @@ class TestLwtAndStatusPublish:
assert payload["status"] == "online"
assert payload["origin"] == "TestNode"
assert payload["origin_id"] == pubkey_hex
assert payload["client"] == _CLIENT_ID
assert "client" not in payload
assert "timestamp" in payload
assert payload["model"] == "T-Deck"
assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)"
assert payload["radio"] == "915.0MHz BW250.0 SF10 CR8"
assert payload["radio"] == "915.0,250.0,10,8"
assert payload["client_version"] == "RemoteTerm/2.4.0"
assert payload["stats"] == {"battery_mv": 4200}
@@ -539,9 +546,13 @@ class TestLwtAndStatusPublish:
community_mqtt_iata="JFK",
)
mock_radio = MagicMock()
mock_radio.meshcore = None
with (
patch("app.keystore.get_private_key", return_value=private_key),
patch("app.keystore.get_public_key", return_value=public_key),
patch("app.radio.radio_manager", mock_radio),
):
kwargs = pub._build_client_kwargs(settings)
@@ -583,7 +594,7 @@ class TestLwtAndStatusPublish:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="unknown"),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/unknown"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -822,7 +833,7 @@ class TestFetchStats:
class TestBuildRadioInfo:
def test_formatted_string(self):
"""Should return formatted radio info string."""
"""Should return comma-separated radio info matching reference format."""
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {
@@ -835,20 +846,20 @@ class TestBuildRadioInfo:
with patch("app.radio.radio_manager", mock_radio):
result = _build_radio_info()
assert result == "915.0MHz BW250.0 SF10 CR8"
assert result == "915.0,250.0,10,8"
def test_fallback_when_no_meshcore(self):
"""Should return 'unknown' when meshcore is None."""
"""Should return '0,0,0,0' when meshcore is None."""
mock_radio = MagicMock()
mock_radio.meshcore = None
with patch("app.radio.radio_manager", mock_radio):
result = _build_radio_info()
assert result == "unknown"
assert result == "0,0,0,0"
def test_fallback_when_self_info_missing_fields(self):
"""Should return 'unknown' when self_info lacks radio fields."""
"""Should use 0 defaults when self_info lacks radio fields."""
mock_radio = MagicMock()
mock_radio.meshcore = MagicMock()
mock_radio.meshcore.self_info = {"name": "TestNode"}
@@ -856,7 +867,7 @@ class TestBuildRadioInfo:
with patch("app.radio.radio_manager", mock_radio):
result = _build_radio_info()
assert result == "unknown"
assert result == "0,0,0,0"
class TestGetClientVersion:
@@ -905,7 +916,7 @@ class TestPublishStatus:
return_value={"model": "T-Deck", "firmware_version": "v2.2.2 (Build: 2025-01-15)"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=stats),
patch("app.community_mqtt._build_radio_info", return_value="915.0MHz BW250.0 SF10 CR8"),
patch("app.community_mqtt._build_radio_info", return_value="915.0,250.0,10,8"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/2.4.0"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -915,10 +926,10 @@ class TestPublishStatus:
assert payload["status"] == "online"
assert payload["origin"] == "TestNode"
assert payload["origin_id"] == pubkey_hex
assert payload["client"] == _CLIENT_ID
assert "client" not in payload
assert payload["model"] == "T-Deck"
assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)"
assert payload["radio"] == "915.0MHz BW250.0 SF10 CR8"
assert payload["radio"] == "915.0,250.0,10,8"
assert payload["client_version"] == "RemoteTerm/2.4.0"
assert payload["stats"] == stats
@@ -942,7 +953,7 @@ class TestPublishStatus:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="unknown"),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/unknown"),
patch.object(pub, "publish", new_callable=AsyncMock) as mock_publish,
):
@@ -973,7 +984,7 @@ class TestPublishStatus:
return_value={"model": "unknown", "firmware_version": "unknown"},
),
patch.object(pub, "_fetch_stats", new_callable=AsyncMock, return_value=None),
patch("app.community_mqtt._build_radio_info", return_value="unknown"),
patch("app.community_mqtt._build_radio_info", return_value="0,0,0,0"),
patch("app.community_mqtt._get_client_version", return_value="RemoteTerm/unknown"),
patch.object(pub, "publish", new_callable=AsyncMock),
):