feat: refactor MQTT from global config to multi-instance entity model
Lint & Test / test (push) Successful in 1m32s

MQTT broker connections are now managed as entities (like HA sources)
instead of a single global config. Each MQTTSource gets its own
runtime with auto-reconnect, ref-counted via MQTTManager.

Backend:
- MQTTSource dataclass + MQTTSourceStore (SQLite)
- MQTTRuntime (per-broker connection, refactored from MQTTService)
- MQTTManager (ref-counted pool, same pattern as HAManager)
- CRUD API at /api/v1/mqtt/sources + test + status endpoints
- MQTTRule gains mqtt_source_id field for source selection
- Automation engine acquires/releases MQTT runtimes automatically
- Legacy MQTTService kept for backward compat during transition

Frontend:
- MQTT source cards in Streams > Integrations tab
- Create/edit modal with test button
- Dashboard integration cards with health-dot indicators
- Removed MQTT tab from settings modal
This commit is contained in:
2026-03-31 18:02:19 +03:00
parent e7c9a568dc
commit c59107c7c7
26 changed files with 1636 additions and 124 deletions
@@ -213,6 +213,7 @@
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/weather-source-editor.html' %}
{% include 'modals/ha-source-editor.html' %}
{% include 'modals/mqtt-source-editor.html' %}
{% include 'modals/ha-light-editor.html' %}
{% include 'modals/asset-upload.html' %}
{% include 'modals/asset-editor.html' %}
@@ -0,0 +1,98 @@
<!-- MQTT Source Editor Modal -->
<div id="mqtt-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="mqtt-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="mqtt-source-modal-title" data-i18n="mqtt_source.add">Add MQTT Source</h2>
<button class="modal-close-btn" onclick="closeMQTTSourceModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="mqtt-source-form" onsubmit="return false;">
<input type="hidden" id="mqtt-source-id">
<div id="mqtt-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-name" data-i18n="mqtt_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.name.hint">A descriptive name for this MQTT broker connection</small>
<input type="text" id="mqtt-source-name" data-i18n-placeholder="mqtt_source.name.placeholder" placeholder="My MQTT Broker" required>
<div id="mqtt-source-tags-container"></div>
</div>
<!-- Broker Host -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-host" data-i18n="mqtt_source.broker_host">Broker Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.broker_host.hint">MQTT broker hostname or IP address, e.g. 192.168.1.100</small>
<input type="text" id="mqtt-source-host" placeholder="192.168.1.100" required>
</div>
<!-- Broker Port -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-port" data-i18n="mqtt_source.broker_port">Port:</label>
</div>
<input type="number" id="mqtt-source-port" value="1883" min="1" max="65535">
</div>
<!-- Username -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-username" data-i18n="mqtt_source.username">Username (optional):</label>
</div>
<input type="text" id="mqtt-source-username" placeholder="">
</div>
<!-- Password -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-password" data-i18n="mqtt_source.password">Password (optional):</label>
</div>
<small id="mqtt-source-password-hint" class="input-hint" style="display:none" data-i18n="mqtt_source.password.edit_hint">Leave blank to keep the current password</small>
<input type="password" id="mqtt-source-password" placeholder="">
</div>
<!-- Client ID -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-client-id" data-i18n="mqtt_source.client_id">Client ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.client_id.hint">Unique MQTT client identifier. Change if running multiple instances.</small>
<input type="text" id="mqtt-source-client-id" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Base Topic -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-base-topic" data-i18n="mqtt_source.base_topic">Base Topic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-description" data-i18n="mqtt_source.description">Description (optional):</label>
</div>
<input type="text" id="mqtt-source-description" placeholder="">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeMQTTSourceModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-secondary" id="mqtt-source-test-btn" onclick="testMQTTSource()" title="Test" data-i18n-title="mqtt_source.test" style="display:none">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
</button>
<button class="btn btn-icon btn-primary" onclick="saveMQTTSource()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>
@@ -10,7 +10,6 @@
<div class="settings-tab-bar">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" data-i18n="settings.tab.about">About</button>
@@ -149,60 +148,6 @@
</div>
</div>
<!-- ═══ MQTT tab ═══ -->
<div id="settings-panel-mqtt" class="settings-panel">
<form onsubmit="saveMqttSettings(); return false;" autocomplete="off">
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="mqtt-enabled">
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
</div>
<div style="width:90px">
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
<div style="flex:1">
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div>
</form>
</div>
<!-- ═══ Appearance tab ═══ -->
<div id="settings-panel-appearance" class="settings-panel">
<!-- Rendered dynamically by renderAppearanceTab() -->