diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index 29a8887..8d67408 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -61,6 +61,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: @@ -363,7 +364,7 @@ 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"] = "/" + 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/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 3008ad5..d1f13c5 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,44 @@ 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_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()