diff --git a/app/mqtt.py b/app/mqtt.py index 5323654..79a6e3f 100644 --- a/app/mqtt.py +++ b/app/mqtt.py @@ -20,8 +20,12 @@ class MqttPublisher(BaseMqttPublisher): _log_prefix = "MQTT" def _is_configured(self) -> bool: - """Check if MQTT is configured (broker host is set).""" - return bool(self._settings and self._settings.mqtt_broker_host) + """Check if MQTT is configured and has something to publish.""" + return bool( + self._settings + and self._settings.mqtt_broker_host + and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets) + ) def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]: return { diff --git a/frontend/src/components/settings/SettingsMqttSection.tsx b/frontend/src/components/settings/SettingsMqttSection.tsx index f2a32c0..c259343 100644 --- a/frontend/src/components/settings/SettingsMqttSection.tsx +++ b/frontend/src/components/settings/SettingsMqttSection.tsx @@ -34,6 +34,9 @@ export function SettingsMqttSection({ const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443'); const [communityMqttEmail, setCommunityMqttEmail] = useState(''); + const [privateExpanded, setPrivateExpanded] = useState(false); + const [communityExpanded, setCommunityExpanded] = useState(false); + const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -86,266 +89,303 @@ export function SettingsMqttSection({ return (
-
-

Private MQTT Broker

-

- Forward all mesh data to your own MQTT broker for home automation, logging, or alerting. - Publishes both decrypted messages and raw packets to your broker. -

+
+ MQTT support is an experimental feature in open beta. All publishing uses QoS 0 + (at-most-once delivery). Please report any bugs on the{' '} + + GitHub issues page + + .
-
- - {health?.mqtt_status === 'connected' ? ( -
-
- Connected -
- ) : health?.mqtt_status === 'disconnected' ? ( -
-
- Disconnected -
- ) : ( -
-
- Disabled -
- )} -
- - - -
-
- - setMqttBrokerHost(e.target.value)} - /> -
- -
- - setMqttBrokerPort(e.target.value)} - /> -
-
- -
-
- - setMqttUsername(e.target.value)} - /> -
- -
- - setMqttPassword(e.target.value)} - /> -
-
- - - - {mqttUseTls && ( - <> - -

- Allow self-signed or untrusted broker certificates -

- - )} - - - -
- - setMqttTopicPrefix(e.target.value)} - /> -
-
-

Decrypted messages {'{'}id, type, conversation_key, text, sender_timestamp, received_at, paths, outgoing, acked{'}'}

-
-
{mqttTopicPrefix || 'meshcore'}/dm:<contact_key>
-
{mqttTopicPrefix || 'meshcore'}/gm:<channel_key>
-
-
-
-

Raw packets {'{'}id, observation_id, timestamp, data, payload_type, snr, rssi, decrypted, decrypted_info{'}'}

-
-
{mqttTopicPrefix || 'meshcore'}/raw/dm:<contact_key>
-
{mqttTopicPrefix || 'meshcore'}/raw/gm:<channel_key>
-
{mqttTopicPrefix || 'meshcore'}/raw/unrouted
-
-
-
-
- - - - -

- Forward decrypted DM and channel messages -

- - -

Forward all RF packets

- - - -
-

Community Analytics

-

- Share raw packet data with the MeshCore community for coverage mapping and network - analysis. Only raw RF packets are shared — never decrypted messages. -

-
- {health?.community_mqtt_status === 'connected' ? ( + {/* Private MQTT Broker */} +
+ - {communityMqttEnabled && ( -
-
-
- - setCommunityMqttBrokerHost(e.target.value)} - /> -
-
- - setCommunityMqttBrokerPort(e.target.value)} - /> -
-
- - setCommunityMqttIata(e.target.value.toUpperCase())} - className="w-32" - /> -

- Your nearest airport's{' '} - - IATA code - {' '} - (required) + {privateExpanded && ( +

+

+ Forward mesh data to your own MQTT broker for home automation, logging, or alerting.

- {communityMqttIata && ( -

- Topic: meshcore/{communityMqttIata}/<pubkey>/packets -

+ + +

+ Forward decrypted DM and channel messages +

+ + +

Forward all RF packets

+ + {(mqttPublishMessages || mqttPublishRawPackets) && ( +
+ + +
+
+ + setMqttBrokerHost(e.target.value)} + /> +
+ +
+ + setMqttBrokerPort(e.target.value)} + /> +
+
+ +
+
+ + setMqttUsername(e.target.value)} + /> +
+ +
+ + setMqttPassword(e.target.value)} + /> +
+
+ + + + {mqttUseTls && ( + <> + +

+ Allow self-signed or untrusted broker certificates +

+ + )} + + + +
+ + setMqttTopicPrefix(e.target.value)} + /> +
+
+

Decrypted messages {'{'}id, type, conversation_key, text, sender_timestamp, received_at, paths, outgoing, acked{'}'}

+
+
{mqttTopicPrefix || 'meshcore'}/dm:<contact_key>
+
{mqttTopicPrefix || 'meshcore'}/gm:<channel_key>
+
+
+
+

Raw packets {'{'}id, observation_id, timestamp, data, payload_type, snr, rssi, decrypted, decrypted_info{'}'}

+
+
{mqttTopicPrefix || 'meshcore'}/raw/dm:<contact_key>
+
{mqttTopicPrefix || 'meshcore'}/raw/gm:<channel_key>
+
{mqttTopicPrefix || 'meshcore'}/raw/unrouted
+
+
+
+
+
)} - - setCommunityMqttEmail(e.target.value)} - /> -

- Used to claim your node on the community aggregator +

+ )} +
+ + {/* Community Analytics */} +
+ + + {communityExpanded && ( +
+

+ Share raw packet data with the MeshCore community for coverage mapping and network + analysis. Only raw RF packets are shared — never decrypted messages.

+ + + {communityMqttEnabled && ( +
+
+
+ + setCommunityMqttBrokerHost(e.target.value)} + /> +
+
+ + setCommunityMqttBrokerPort(e.target.value)} + /> +
+
+
+ + setCommunityMqttIata(e.target.value.toUpperCase())} + className="w-32" + /> +

+ Your nearest airport's{' '} + + IATA code + {' '} + (required) +

+ {communityMqttIata && ( +

+ Topic: meshcore/{communityMqttIata}/<pubkey>/packets +

+ )} +
+
+ + setCommunityMqttEmail(e.target.value)} + /> +

+ Used to claim your node on the community aggregator +

+
+
+ )}
)}
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 99cff7d..43d4386 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -155,6 +155,14 @@ function openMqttSection() { fireEvent.click(mqttToggle); } +function expandPrivateMqtt() { + fireEvent.click(screen.getByText('Private MQTT Broker')); +} + +function expandCommunityMqtt() { + fireEvent.click(screen.getByText('Community Analytics')); +} + function openDatabaseSection() { const databaseToggle = screen.getByRole('button', { name: /Database/i }); fireEvent.click(databaseToggle); @@ -414,19 +422,30 @@ describe('SettingsModal', () => { it('renders MQTT section with form inputs', () => { renderModal(); openMqttSection(); + expandPrivateMqtt(); + // Publish checkboxes always visible + expect(screen.getByText('Publish Messages')).toBeInTheDocument(); + expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument(); + + // Broker config hidden until a publish option is enabled + expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument(); + + // Enable one publish option to reveal broker config + fireEvent.click(screen.getByText('Publish Messages')); expect(screen.getByLabelText('Broker Host')).toBeInTheDocument(); expect(screen.getByLabelText('Broker Port')).toBeInTheDocument(); expect(screen.getByLabelText('Username')).toBeInTheDocument(); expect(screen.getByLabelText('Password')).toBeInTheDocument(); expect(screen.getByLabelText('Topic Prefix')).toBeInTheDocument(); - expect(screen.getByText('Publish Messages')).toBeInTheDocument(); - expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument(); }); it('saves MQTT settings through onSaveAppSettings', async () => { - const { onSaveAppSettings } = renderModal(); + const { onSaveAppSettings } = renderModal({ + appSettings: { ...baseSettings, mqtt_publish_messages: true }, + }); openMqttSection(); + expandPrivateMqtt(); const hostInput = screen.getByLabelText('Broker Host'); fireEvent.change(hostInput, { target: { value: 'mqtt.example.com' } }); @@ -476,6 +495,7 @@ describe('SettingsModal', () => { it('renders community sharing section in MQTT tab', () => { renderModal(); openMqttSection(); + expandCommunityMqtt(); expect(screen.getByText('Community Analytics')).toBeInTheDocument(); expect(screen.getByText('Enable Community Analytics')).toBeInTheDocument(); @@ -489,6 +509,7 @@ describe('SettingsModal', () => { }, }); openMqttSection(); + expandCommunityMqtt(); expect(screen.queryByLabelText('Region Code (IATA)')).not.toBeInTheDocument(); diff --git a/tests/test_health_mqtt_status.py b/tests/test_health_mqtt_status.py index 6cd44f5..c504d18 100644 --- a/tests/test_health_mqtt_status.py +++ b/tests/test_health_mqtt_status.py @@ -34,6 +34,30 @@ class TestHealthMqttStatus: mqtt_publisher._settings = original_settings mqtt_publisher.connected = original_connected + @pytest.mark.asyncio + async def test_mqtt_disabled_when_nothing_to_publish(self, test_db): + """MQTT status is 'disabled' when broker host is set but no publish options enabled.""" + from app.mqtt import mqtt_publisher + + original_settings = mqtt_publisher._settings + original_connected = mqtt_publisher.connected + try: + from app.models import AppSettings + + mqtt_publisher._settings = AppSettings( + mqtt_broker_host="broker.local", + mqtt_publish_messages=False, + mqtt_publish_raw_packets=False, + ) + mqtt_publisher.connected = False + + data = await build_health_data(True, "TCP: 1.2.3.4:4000") + + assert data["mqtt_status"] == "disabled" + finally: + mqtt_publisher._settings = original_settings + mqtt_publisher.connected = original_connected + @pytest.mark.asyncio async def test_mqtt_connected_when_publisher_connected(self, test_db): """MQTT status is 'connected' when publisher is connected.""" @@ -44,7 +68,9 @@ class TestHealthMqttStatus: try: from app.models import AppSettings - mqtt_publisher._settings = AppSettings(mqtt_broker_host="broker.local") + mqtt_publisher._settings = AppSettings( + mqtt_broker_host="broker.local", mqtt_publish_messages=True + ) mqtt_publisher.connected = True data = await build_health_data(True, "TCP: 1.2.3.4:4000") @@ -64,7 +90,9 @@ class TestHealthMqttStatus: try: from app.models import AppSettings - mqtt_publisher._settings = AppSettings(mqtt_broker_host="broker.local") + mqtt_publisher._settings = AppSettings( + mqtt_broker_host="broker.local", mqtt_publish_raw_packets=True + ) mqtt_publisher.connected = False data = await build_health_data(False, None)