diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index a4a463a..fa6b480 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2822,6 +2822,13 @@ class APIEndpoints: "host": str(b["host"]).strip(), "port": port, "format": str(b["format"]).strip(), + # Custom base topic for legacy `mqtt` format. Persist + # None when blank so the handler can apply defaults. + "base_topic": ( + (str(b["base_topic"]).strip() or None) + if "base_topic" in b and b.get("base_topic") is not None + else None + ), "disallowed_packet_types": list(b.get("disallowed_packet_types", [])), "retain_status": bool(b.get("retain_status", False)), # Opt-in per broker; brokers that do not expect the diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index e076775..9aed860 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -2593,13 +2593,22 @@ def test_update_mqtt_config_validation_and_success(cherrypy_ctx): "status_interval": 20, "brokers": [ {"preset": "waev"}, - {"name": "a", "host": "h", "port": 443, "format": "json", "enabled": True}, + { + "name": "a", + "host": "h", + "port": 443, + "format": "mqtt", + "enabled": True, + "base_topic": "meshcore/custom/base", + }, ], } api.config_manager.update_and_save.return_value = {"success": True, "saved": True} out = api.update_mqtt_config() assert out["success"] is True assert out["data"]["restart_required"] is True + updates = api.config_manager.update_and_save.call_args.kwargs["updates"]["mqtt_brokers"] + assert updates["brokers"][1]["base_topic"] == "meshcore/custom/base" api.config_manager.update_and_save.return_value = {"success": False, "error": "save failed"} out2 = api.update_mqtt_config() diff --git a/tests/test_mqtt_publish_integration.py b/tests/test_mqtt_publish_integration.py index 0e42ffb..43826fa 100644 --- a/tests/test_mqtt_publish_integration.py +++ b/tests/test_mqtt_publish_integration.py @@ -236,3 +236,39 @@ def test_mqtt_published_packet_legacy_mqtt_format_uses_singular_packet_topic(): payload_dict = json.loads(captured[0]["payload"]) # Duration still flows through correctly even on the legacy topic assert int(payload_dict["duration"]) > 0 + + +def test_mqtt_published_packet_legacy_mqtt_format_uses_supplied_base_topic(): + """When a broker supplies ``base_topic``, downstream publishing must use + that value instead of the legacy default prefix. + """ + config = _make_config(format_value="mqtt", iata_code="LAX") + config["mqtt_brokers"]["brokers"][0]["base_topic"] = "site/edge/gw1" + public_key_hex = "12" * 32 + identity = _FakeIdentity(public_key_hex) + + pusher = MeshCoreToMqttPusher(local_identity=identity, config=config) + captured = _attach_capturing_client(pusher.connections[0]) + + raw_bytes = bytes(range(16)) + airtime_mgr = AirtimeManager(config) + packet_record = { + "timestamp": 1700000000.0, + "type": 1, + "route": 1, + "rssi": -70, + "snr": 6.0, + "score": 0.2, + "payload_length": 10, + "packet_hash": "C0FFEE00" + "00" * 4, + "raw_packet": raw_bytes.hex(), + "airtime_ms": airtime_mgr.calculate_airtime(len(raw_bytes)), + } + record = PacketRecord.from_packet_record( + packet_record, origin="test-node", origin_id=public_key_hex.upper() + ) + pusher.publish_packet(record.to_dict()) + + assert len(captured) == 1 + # Legacy `mqtt` format still uses singular subtopic, but custom base prefix. + assert captured[0]["topic"] == "site/edge/gw1/packet"