From 25190cded56d578c3106cd6b4515accd7a4fe99d Mon Sep 17 00:00:00 2001 From: Martin Fournier Date: Mon, 11 May 2026 02:11:55 -0400 Subject: [PATCH 1/2] Add WebSocket path config for community MQTT Path hardcoded to "/". Brokers like analyzer.montrealmesh.ca need non-root path (e.g. /mqtt). Expose field in fanout config + UI. --- app/fanout/community_mqtt.py | 4 +++- app/fanout/mqtt_community.py | 1 + .../settings/SettingsFanoutSection.tsx | 18 ++++++++++++++++++ tests/test_community_mqtt.py | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index 1c8228d..b728971 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -59,6 +59,7 @@ class CommunityMqttSettings(Protocol): community_mqtt_iata: str community_mqtt_email: str community_mqtt_token_audience: str + community_mqtt_websocket_path: str def _base64url_encode(data: bytes) -> str: @@ -361,7 +362,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): kwargs["username"] = s.community_mqtt_username or None kwargs["password"] = s.community_mqtt_password or None if transport == "websockets": - kwargs["websocket_path"] = "/" + ws_path = getattr(s, "community_mqtt_websocket_path", None) + kwargs["websocket_path"] = ws_path.strip() if ws_path and ws_path.strip() else "/" return kwargs def _on_connected(self, settings: object) -> tuple[str, str]: diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py index 9c4dc13..fdd833c 100644 --- a/app/fanout/mqtt_community.py +++ b/app/fanout/mqtt_community.py @@ -62,6 +62,7 @@ def _config_to_settings(config: dict) -> SimpleNamespace: community_mqtt_iata=config.get("iata", ""), community_mqtt_email=config.get("email", ""), community_mqtt_token_audience=config.get("token_audience", ""), + community_mqtt_websocket_path=config.get("websocket_path", "/"), ) diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 3efe9c1..78e89a3 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -1548,6 +1548,24 @@ function MqttCommunityConfigEditor({ + {((config.transport as string) || DEFAULT_COMMUNITY_TRANSPORT) === 'websockets' && ( +
+
+ + onChange({ ...config, websocket_path: e.target.value })} + /> +

+ Defaults to / — use /mqtt for brokers that require a path +

+
+
+ )} +

LetsMesh uses token auth. MeshRank uses none.

diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index 04e5e99..ed44be9 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -70,6 +70,7 @@ def _make_community_settings(**overrides) -> SimpleNamespace: "community_mqtt_iata": "", "community_mqtt_email": "", "community_mqtt_token_audience": "mqtt-us-v1.letsmesh.net", + "community_mqtt_websocket_path": "/", } defaults.update(overrides) return SimpleNamespace(**defaults) @@ -738,6 +739,24 @@ class TestLwtAndStatusPublish: assert kwargs["tls_context"] is not None assert kwargs["username"] == f"v1_{pubkey_hex}" + def test_build_client_kwargs_custom_websocket_path(self): + pub = CommunityMqttPublisher() + private_key, public_key = _make_test_keys() + settings = _make_community_settings( + community_mqtt_iata="MTL", + community_mqtt_websocket_path="/mqtt", + ) + + 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") as mock_radio, + ): + mock_radio.meshcore = None + kwargs = pub._build_client_kwargs(settings) + + assert kwargs["websocket_path"] == "/mqtt" + def test_build_client_kwargs_supports_tcp_transport_and_custom_audience(self): pub = CommunityMqttPublisher() private_key, public_key = _make_test_keys() From 5033beacc96db2a09857f5c70d65e7ada6cf6be8 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 13 May 2026 16:36:45 -0700 Subject: [PATCH 2/2] Add test and simplify strip logic --- app/fanout/community_mqtt.py | 3 +-- tests/test_community_mqtt.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index b728971..42c870f 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -362,8 +362,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): kwargs["username"] = s.community_mqtt_username or None kwargs["password"] = s.community_mqtt_password or None if transport == "websockets": - ws_path = getattr(s, "community_mqtt_websocket_path", None) - kwargs["websocket_path"] = ws_path.strip() if ws_path and ws_path.strip() else "/" + kwargs["websocket_path"] = (s.community_mqtt_websocket_path or "").strip() or "/" return kwargs def _on_connected(self, settings: object) -> tuple[str, str]: diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index ed44be9..aa7755d 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -757,6 +757,26 @@ class TestLwtAndStatusPublish: assert kwargs["websocket_path"] == "/mqtt" + def test_build_client_kwargs_empty_websocket_path_defaults_to_root(self): + pub = CommunityMqttPublisher() + private_key, public_key = _make_test_keys() + + for empty_value in ("", " ", None): + settings = _make_community_settings( + community_mqtt_iata="MTL", + community_mqtt_websocket_path=empty_value, + ) + + 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") as mock_radio, + ): + mock_radio.meshcore = None + kwargs = pub._build_client_kwargs(settings) + + assert kwargs["websocket_path"] == "/", f"Failed for {empty_value!r}" + def test_build_client_kwargs_supports_tcp_transport_and_custom_audience(self): pub = CommunityMqttPublisher() private_key, public_key = _make_test_keys()