diff --git a/CHANGELOG.md b/CHANGELOG.md index d01cf14..917f2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config.ini.example b/config.ini.example index 864db17..430af1d 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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. diff --git a/docs/packet-capture.md b/docs/packet-capture.md index 462c42d..0bc1d43 100644 --- a/docs/packet-capture.md +++ b/docs/packet-capture.md @@ -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 diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index 509d535..1c683c1 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -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" diff --git a/tests/unit/test_packet_capture_observer_name.py b/tests/unit/test_packet_capture_observer_name.py new file mode 100644 index 0000000..67c346f --- /dev/null +++ b/tests/unit/test_packet_capture_observer_name.py @@ -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() diff --git a/tests/unit/test_packet_capture_payload_decode.py b/tests/unit/test_packet_capture_payload_decode.py index c0cc1a8..c45d360 100644 --- a/tests/unit/test_packet_capture_payload_decode.py +++ b/tests/unit/test_packet_capture_payload_decode.py @@ -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")