Merge pull request #241 from ajquick/dev

Add configurable PacketCapture observer name

Adds an optional [PacketCapture] observer_name used as the MQTT `origin`
for packet and status reporting, letting the observer identity differ
from the connected MeshCore device name. Unset keeps the previous
behavior. The public key backing origin_id, authentication, and topic
resolution is unchanged.

CHANGELOG conflict resolved by keeping both Unreleased entries.
This commit is contained in:
agessaman
2026-08-21 20:33:42 -07:00
6 changed files with 146 additions and 3 deletions
+5
View File
@@ -23,6 +23,11 @@ semantic versioning.
- Migration 23: nullable `snr` / `rssi` columns on `observed_paths` for
zero-hop advert rows.
- `[PacketCapture] observer_name` — an optional name reported as the `origin` of
MQTT packet and status payloads. It lets the observer/analyzer identity differ
from the MeshCore RF node, which is useful when one bot name is already taken
by the radio's advertised name. Unset (the default) keeps the previous
behavior: the connected device name, falling back to `[Bot] bot_name`.
## [1.0.0] — 2026-08-07
+4
View File
@@ -1860,6 +1860,10 @@ auth_token_method = device
# Default: XYZ (invalid if not set)
iata = XYZ
# Optional observer name used in MQTT packet/status payloads.
# If unset, the connected device/bot name is used.
observer_name =
# MQTT Broker Configuration
# You can configure multiple MQTT brokers by using mqtt1_*, mqtt2_*, mqtt3_*, etc.
# Each broker can have independent settings for transport, TLS, authentication, and topics.
+4
View File
@@ -50,6 +50,10 @@ mqtt_skip_unparseable_packets = true # Skip MQTT when content hash is all zero
# Optional: skip MQTT for ADVERT packets whose Ed25519 signature does not verify (damaged or spoofed mesh payload).
# Does not affect file/JSONL capture.
advert_require_valid_signature = false
# Optional name reported as the MQTT observer "origin".
# Defaults to the connected MeshCore device/bot name.
observer_name = CustomObserverBot
```
### Authentication
@@ -1212,7 +1212,7 @@ class PacketCaptureService(BaseServicePlugin):
payload_len = str(max(0, packet_len - 1 - transport_bytes - 1 - path_bytes))
# Get device name and public key
device_name = self._get_bot_name()
device_name = self._get_observer_name()
if not device_name:
device_name = "MeshCore Device"
@@ -1556,6 +1556,23 @@ class PacketCaptureService(BaseServicePlugin):
self.logger.debug(f"Decode error traceback: {traceback.format_exc()}")
return None
def _get_observer_name(self) -> str:
"""Get observer name for PacketCapture MQTT reporting.
Allows the observer/analyzer identity to differ from the
MeshCore RF node and bot name.
"""
observer_name = self.bot.config.get(
"PacketCapture",
"observer_name",
fallback=""
).strip()
if observer_name:
return observer_name
return self._get_bot_name()
def _get_bot_name(self) -> str:
"""Get bot name from device or config.
@@ -2767,7 +2784,7 @@ class PacketCaptureService(BaseServicePlugin):
firmware_info = await self.get_firmware_info()
# Get device name and public key
device_name = self._get_bot_name()
device_name = self._get_observer_name()
if not device_name:
device_name = "MeshCore Device"
@@ -0,0 +1,110 @@
"""Tests for PacketCapture observer_name override."""
from __future__ import annotations
import configparser
import logging
import types
from unittest.mock import MagicMock
from modules.service_plugins.packet_capture_service import PacketCaptureService
LOGGER = logging.getLogger("test-packet-capture-observer-name")
DEVICE_KEY = "ab" * 32
def build_service(
observer_name=None,
device_name="DeviceNode",
bot_name="BotNode",
):
"""Build a minimal PacketCaptureService without running __init__."""
config = configparser.ConfigParser()
config["Bot"] = {
"bot_name": bot_name,
}
config["PacketCapture"] = {}
if observer_name is not None:
config["PacketCapture"]["observer_name"] = observer_name
bot = MagicMock()
bot.config = config
bot.meshcore = types.SimpleNamespace(
self_info={
"name": device_name,
"public_key": DEVICE_KEY,
}
)
service = object.__new__(PacketCaptureService)
service.bot = bot
service.logger = LOGGER
service.debug = False
service.decode_payloads = False
service.channel_key_store = None
return service
def test_observer_name_override():
service = build_service(
observer_name="ObserverStation",
device_name="DeviceNode",
)
assert service._get_observer_name() == "ObserverStation"
def test_observer_name_falls_back_to_device_name():
service = build_service(
device_name="DeviceNode",
)
assert service._get_observer_name() == "DeviceNode"
def test_blank_observer_name_falls_back_to_device_name():
service = build_service(
observer_name=" ",
device_name="DeviceNode",
)
assert service._get_observer_name() == "DeviceNode"
def test_packet_payload_uses_observer_name():
service = build_service(
observer_name="ObserverStation",
device_name="DeviceNode",
)
packet_info = {
"route_type": "FLOOD",
"payload_type": "ADVERT",
"payload_type_value": 4,
"payload_bytes": 1,
"path_len": 0,
"path_byte_length": 0,
"path": [],
"packet_hash": "0123456789ABCDEF",
"has_transport_codes": False,
}
payload = {
"snr": 5.0,
"rssi": -90,
}
result = service._format_packet_data(
"00AA",
packet_info,
payload,
)
assert result["origin"] == "ObserverStation"
assert result["origin_id"] == DEVICE_KEY.upper()
@@ -132,7 +132,10 @@ def _service_for_format(decode_payloads: bool) -> PacketCaptureService:
svc.debug = False
svc.logger = logging.getLogger("test-packet-capture")
svc.bot = None
svc._get_bot_name = lambda: "TestBot" # type: ignore[method-assign]
# _format_packet_data resolves the reported origin through _get_observer_name,
# which falls back to _get_bot_name; stub the entry point so these tests stay
# about payload decoding and not name resolution.
svc._get_observer_name = lambda: "TestBot" # type: ignore[method-assign]
if decode_payloads:
svc.channel_key_store = ChannelKeyStore()
svc.channel_key_store.add_secret(BOT_KEY, "#bot")