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()