M8-R01: add configurable MQTT client identity
This commit is contained in:
@@ -40,6 +40,8 @@ MQTT_BROKER_PORT=1883
|
|||||||
MQTT_USERNAME=
|
MQTT_USERNAME=
|
||||||
MQTT_PASSWORD=
|
MQTT_PASSWORD=
|
||||||
MQTT_TLS_ENABLED=false
|
MQTT_TLS_ENABLED=false
|
||||||
|
# MQTT_CLIENT_ID must be a non-empty ASCII slug; use a distinct value per deployment.
|
||||||
|
MQTT_CLIENT_ID=home-automation
|
||||||
|
|
||||||
# Optional: Home Assistant MQTT Discovery.
|
# Optional: Home Assistant MQTT Discovery.
|
||||||
# Requires MQTT_ENABLED=true and a running MQTT broker.
|
# Requires MQTT_ENABLED=true and a running MQTT broker.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.routes.api.deps import require_csrf, require_session
|
from app.api.routes.api.deps import require_csrf, require_session
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.dependencies import get_app_settings, get_db
|
from app.dependencies import get_app_settings, get_db
|
||||||
from app.integrations.mqtt import MQTT_SETTINGS_KEYS, mqtt_manager
|
from app.integrations.mqtt import MQTT_SETTINGS_KEYS, mqtt_manager, mqtt_test_client_id
|
||||||
from app.schemas.config import (
|
from app.schemas.config import (
|
||||||
ConfigField,
|
ConfigField,
|
||||||
ConfigResponse,
|
ConfigResponse,
|
||||||
@@ -233,7 +233,7 @@ def _run_mqtt_test(settings: Settings) -> None:
|
|||||||
|
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
client_id="home-automation-test",
|
client_id=mqtt_test_client_id(settings.mqtt_client_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_connect(
|
def _on_connect(
|
||||||
|
|||||||
+14
-1
@@ -1,7 +1,8 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
from pydantic import computed_field
|
from pydantic import computed_field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ class Settings(BaseSettings):
|
|||||||
mqtt_username: str = ""
|
mqtt_username: str = ""
|
||||||
mqtt_password: str = ""
|
mqtt_password: str = ""
|
||||||
mqtt_tls_enabled: bool = False
|
mqtt_tls_enabled: bool = False
|
||||||
|
mqtt_client_id: str = "home-automation"
|
||||||
|
|
||||||
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
# Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing).
|
||||||
ha_discovery_enabled: bool = False
|
ha_discovery_enabled: bool = False
|
||||||
@@ -81,6 +83,17 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("mqtt_client_id", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_mqtt_client_id(cls, value: object) -> str:
|
||||||
|
"""Normalize a broker-safe base client identity used by every MQTT client."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("MQTT client ID must be a string")
|
||||||
|
normalized = value.strip()
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", normalized):
|
||||||
|
raise ValueError("MQTT client ID must be a non-empty ASCII slug")
|
||||||
|
return normalized
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def is_development(self) -> bool:
|
def is_development(self) -> bool:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ MQTT_SETTINGS_KEYS = {
|
|||||||
"mqtt_username",
|
"mqtt_username",
|
||||||
"mqtt_password",
|
"mqtt_password",
|
||||||
"mqtt_tls_enabled",
|
"mqtt_tls_enabled",
|
||||||
|
"mqtt_client_id",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +66,16 @@ def _is_configured(settings: Settings) -> bool:
|
|||||||
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
|
return bool(settings.mqtt_enabled and settings.mqtt_broker_host)
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_source_client_id(base_client_id: str, source_id: int) -> str:
|
||||||
|
"""Return a stable, deployment-scoped identity for one DSMR source."""
|
||||||
|
return f"{base_client_id}-dsmr-source-{source_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_test_client_id(base_client_id: str) -> str:
|
||||||
|
"""Return a transient test identity that cannot evict a long-lived client."""
|
||||||
|
return f"{base_client_id}-test"
|
||||||
|
|
||||||
|
|
||||||
class MqttManager:
|
class MqttManager:
|
||||||
"""Long-lived MQTT client wrapper.
|
"""Long-lived MQTT client wrapper.
|
||||||
|
|
||||||
@@ -236,6 +247,7 @@ class MqttManager:
|
|||||||
password: str,
|
password: str,
|
||||||
tls_enabled: bool,
|
tls_enabled: bool,
|
||||||
subscriptions: dict[str, Callable[[bytes], None]],
|
subscriptions: dict[str, Callable[[bytes], None]],
|
||||||
|
base_client_id: str = "home-automation",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Replace one source-owned client and its handlers.
|
"""Replace one source-owned client and its handlers.
|
||||||
|
|
||||||
@@ -253,7 +265,7 @@ class MqttManager:
|
|||||||
captured_subscriptions = dict(subscriptions)
|
captured_subscriptions = dict(subscriptions)
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
client_id=f"home-automation-dsmr-{source_id}",
|
client_id=mqtt_source_client_id(base_client_id, source_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_connect(
|
def _on_connect(
|
||||||
@@ -360,7 +372,7 @@ class MqttManager:
|
|||||||
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
|
"""Build a fresh paho Client, configure it, and call loop_start + connect."""
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
client_id="home-automation",
|
client_id=settings.mqtt_client_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Callbacks — VERSION2 on_connect signature:
|
# Callbacks — VERSION2 on_connect signature:
|
||||||
|
|||||||
+1
-1
@@ -305,7 +305,7 @@ async def lifespan(_: FastAPI):
|
|||||||
|
|
||||||
# DSMR sources carry their own runtime configuration and are reconciled
|
# DSMR sources carry their own runtime configuration and are reconciled
|
||||||
# after the MQTT manager is connected.
|
# after the MQTT manager is connected.
|
||||||
apply_dsmr_subscription()
|
apply_dsmr_subscription(_startup_runtime_settings)
|
||||||
# Mark it before reconcile: a partial reconcile can already own a fd or
|
# Mark it before reconcile: a partial reconcile can already own a fd or
|
||||||
# a non-daemon thread and must receive the same orderly shutdown.
|
# a non-daemon thread and must receive the same orderly shutdown.
|
||||||
serial_started = True
|
serial_started = True
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
ConfigField("MQTT", "MQTT_USERNAME", "mqtt_username", "MQTT Username"),
|
ConfigField("MQTT", "MQTT_USERNAME", "mqtt_username", "MQTT Username"),
|
||||||
ConfigField("MQTT", "MQTT_PASSWORD", "mqtt_password", "MQTT Password", secret=True),
|
ConfigField("MQTT", "MQTT_PASSWORD", "mqtt_password", "MQTT Password", secret=True),
|
||||||
ConfigField("MQTT", "MQTT_TLS_ENABLED", "mqtt_tls_enabled", "MQTT TLS Enabled", input_type="checkbox"),
|
ConfigField("MQTT", "MQTT_TLS_ENABLED", "mqtt_tls_enabled", "MQTT TLS Enabled", input_type="checkbox"),
|
||||||
|
ConfigField("MQTT", "MQTT_CLIENT_ID", "mqtt_client_id", "MQTT Client ID"),
|
||||||
ConfigField(
|
ConfigField(
|
||||||
"Home Assistant Discovery",
|
"Home Assistant Discovery",
|
||||||
"HA_DISCOVERY_ENABLED",
|
"HA_DISCOVERY_ENABLED",
|
||||||
@@ -222,7 +223,12 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
|
|||||||
else:
|
else:
|
||||||
merged_values[field.env_name] = submitted_value
|
merged_values[field.env_name] = submitted_value
|
||||||
|
|
||||||
_validate_config_values(merged_values, bootstrap_settings)
|
validated_settings = _validate_config_values(merged_values, bootstrap_settings)
|
||||||
|
# Persist the canonical client identity as well as using it at runtime. A
|
||||||
|
# whitespace-padded value must not survive in app_config and unexpectedly
|
||||||
|
# reappear in another consumer of the stored settings.
|
||||||
|
if "MQTT_CLIENT_ID" in merged_values:
|
||||||
|
merged_values["MQTT_CLIENT_ID"] = validated_settings.mqtt_client_id
|
||||||
_persist_config_values(session, merged_values)
|
_persist_config_values(session, merged_values)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
reset_db_caches()
|
reset_db_caches()
|
||||||
@@ -237,7 +243,9 @@ def save_config_value(
|
|||||||
) -> None:
|
) -> None:
|
||||||
current_values = _read_config_values(session)
|
current_values = _read_config_values(session)
|
||||||
current_values[env_name] = value
|
current_values[env_name] = value
|
||||||
_validate_config_values(current_values, bootstrap_settings)
|
validated_settings = _validate_config_values(current_values, bootstrap_settings)
|
||||||
|
if env_name == "MQTT_CLIENT_ID":
|
||||||
|
current_values[env_name] = validated_settings.mqtt_client_id
|
||||||
_persist_config_values(session, current_values)
|
_persist_config_values(session, current_values)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
reset_db_caches()
|
reset_db_caches()
|
||||||
@@ -256,14 +264,14 @@ def _read_config_values(session: Session) -> dict[str, str]:
|
|||||||
return {row.key: row.value for row in rows}
|
return {row.key: row.value for row in rows}
|
||||||
|
|
||||||
|
|
||||||
def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None:
|
def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> Settings:
|
||||||
payload = _settings_payload(bootstrap_settings)
|
payload = _settings_payload(bootstrap_settings)
|
||||||
for field in CONFIG_FIELDS:
|
for field in CONFIG_FIELDS:
|
||||||
if field.env_name in config_values:
|
if field.env_name in config_values:
|
||||||
payload[field.setting_attr] = config_values[field.env_name]
|
payload[field.setting_attr] = config_values[field.env_name]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Settings(_env_file=None, **payload)
|
return Settings(_env_file=None, **payload)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ConfigSaveError("invalid config submission") from exc
|
raise ConfigSaveError("invalid config submission") from exc
|
||||||
|
|
||||||
@@ -334,6 +342,7 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
|||||||
"mqtt_username": settings.mqtt_username,
|
"mqtt_username": settings.mqtt_username,
|
||||||
"mqtt_password": settings.mqtt_password,
|
"mqtt_password": settings.mqtt_password,
|
||||||
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
|
||||||
|
"mqtt_client_id": settings.mqtt_client_id,
|
||||||
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
||||||
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
||||||
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
|
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class DsmrSourceSnapshot:
|
|||||||
|
|
||||||
|
|
||||||
_subscriptions: dict[int, DsmrSourceSnapshot] = {}
|
_subscriptions: dict[int, DsmrSourceSnapshot] = {}
|
||||||
|
_subscription_client_ids: dict[int, str] = {}
|
||||||
_subscription_lock = threading.RLock()
|
_subscription_lock = threading.RLock()
|
||||||
# A configuration value is not an ownership identity: disable and re-enable
|
# A configuration value is not an ownership identity: disable and re-enable
|
||||||
# can produce an equal snapshot. Each installed handler therefore captures a
|
# can produce an equal snapshot. Each installed handler therefore captures a
|
||||||
@@ -160,13 +161,16 @@ def _enabled_snapshots() -> list[DsmrSourceSnapshot]:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
def apply_dsmr_subscription(settings: "Settings | None" = None) -> None:
|
||||||
"""Reconcile enabled DSMR source subscriptions from the database.
|
"""Reconcile enabled DSMR source subscriptions from the database.
|
||||||
|
|
||||||
The optional, ignored settings parameter preserves the old config-route
|
``settings`` provides the DB-merged app-wide MQTT identity. Individual
|
||||||
call shape while ensuring flat DSMR settings are no longer read.
|
DSMR broker settings continue to come solely from MeterSource records.
|
||||||
"""
|
"""
|
||||||
from app.integrations.mqtt import mqtt_manager
|
from app.integrations.mqtt import mqtt_manager
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
base_client_id = (settings or get_settings()).mqtt_client_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
desired = {snapshot.source_id: snapshot for snapshot in _enabled_snapshots()}
|
desired = {snapshot.source_id: snapshot for snapshot in _enabled_snapshots()}
|
||||||
@@ -191,9 +195,11 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
|||||||
source_id
|
source_id
|
||||||
for source_id, current in _subscriptions.items()
|
for source_id, current in _subscriptions.items()
|
||||||
if desired.get(source_id) != current
|
if desired.get(source_id) != current
|
||||||
|
or _subscription_client_ids.get(source_id) != base_client_id
|
||||||
]
|
]
|
||||||
for source_id in stale_source_ids:
|
for source_id in stale_source_ids:
|
||||||
_subscriptions.pop(source_id, None)
|
_subscriptions.pop(source_id, None)
|
||||||
|
_subscription_client_ids.pop(source_id, None)
|
||||||
_subscription_tokens.pop(source_id, None)
|
_subscription_tokens.pop(source_id, None)
|
||||||
set_current_tariff(source_id, None)
|
set_current_tariff(source_id, None)
|
||||||
|
|
||||||
@@ -203,7 +209,12 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
|||||||
for source_id, snapshot in desired.items():
|
for source_id, snapshot in desired.items():
|
||||||
with _subscription_lock:
|
with _subscription_lock:
|
||||||
current = _subscriptions.get(source_id)
|
current = _subscriptions.get(source_id)
|
||||||
if current == snapshot and mqtt_manager.source_is_active(source_id):
|
current_client_id = _subscription_client_ids.get(source_id)
|
||||||
|
if (
|
||||||
|
current == snapshot
|
||||||
|
and current_client_id == base_client_id
|
||||||
|
and mqtt_manager.source_is_active(source_id)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if current is not None:
|
if current is not None:
|
||||||
# The client went inactive outside reconcile. Invalidate its
|
# The client went inactive outside reconcile. Invalidate its
|
||||||
@@ -211,6 +222,7 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
|||||||
with _subscription_lock:
|
with _subscription_lock:
|
||||||
if _subscriptions.get(source_id) == current:
|
if _subscriptions.get(source_id) == current:
|
||||||
_subscriptions.pop(source_id, None)
|
_subscriptions.pop(source_id, None)
|
||||||
|
_subscription_client_ids.pop(source_id, None)
|
||||||
_subscription_tokens.pop(source_id, None)
|
_subscription_tokens.pop(source_id, None)
|
||||||
mqtt_manager.remove_source(source_id)
|
mqtt_manager.remove_source(source_id)
|
||||||
token = object()
|
token = object()
|
||||||
@@ -227,6 +239,7 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
|||||||
)
|
)
|
||||||
with _subscription_lock:
|
with _subscription_lock:
|
||||||
_subscriptions[source_id] = snapshot
|
_subscriptions[source_id] = snapshot
|
||||||
|
_subscription_client_ids[source_id] = base_client_id
|
||||||
_subscription_tokens[source_id] = token
|
_subscription_tokens[source_id] = token
|
||||||
applied = mqtt_manager.replace_source(
|
applied = mqtt_manager.replace_source(
|
||||||
source_id,
|
source_id,
|
||||||
@@ -236,11 +249,13 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
|||||||
password=snapshot.password,
|
password=snapshot.password,
|
||||||
tls_enabled=snapshot.tls_enabled,
|
tls_enabled=snapshot.tls_enabled,
|
||||||
subscriptions=handlers,
|
subscriptions=handlers,
|
||||||
|
base_client_id=base_client_id,
|
||||||
)
|
)
|
||||||
if not applied:
|
if not applied:
|
||||||
with _subscription_lock:
|
with _subscription_lock:
|
||||||
if _subscription_tokens.get(source_id) is token:
|
if _subscription_tokens.get(source_id) is token:
|
||||||
_subscriptions.pop(source_id, None)
|
_subscriptions.pop(source_id, None)
|
||||||
|
_subscription_client_ids.pop(source_id, None)
|
||||||
_subscription_tokens.pop(source_id, None)
|
_subscription_tokens.pop(source_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ def test_get_config_includes_mqtt_section(client: TestClient) -> None:
|
|||||||
assert "MQTT_USERNAME" in env_names
|
assert "MQTT_USERNAME" in env_names
|
||||||
assert "MQTT_PASSWORD" in env_names
|
assert "MQTT_PASSWORD" in env_names
|
||||||
assert "MQTT_TLS_ENABLED" in env_names
|
assert "MQTT_TLS_ENABLED" in env_names
|
||||||
|
assert "MQTT_CLIENT_ID" in env_names
|
||||||
|
|
||||||
|
|
||||||
def test_get_config_includes_ha_discovery_section(client: TestClient) -> None:
|
def test_get_config_includes_ha_discovery_section(client: TestClient) -> None:
|
||||||
@@ -578,6 +579,18 @@ def test_put_config_invalid_mqtt_port_returns_422_and_does_not_write(
|
|||||||
assert rows.get("MQTT_BROKER_PORT") != "not-a-number"
|
assert rows.get("MQTT_BROKER_PORT") != "not-a-number"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_invalid_mqtt_client_id_returns_422(client: TestClient) -> None:
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": _full_config_payload({"MQTT_CLIENT_ID": "invalid id"})},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# M5-polish2 Area B: bool fields have input_type="checkbox"
|
# M5-polish2 Area B: bool fields have input_type="checkbox"
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -722,6 +735,32 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_config_mqtt_client_id_reconnects_and_trims_value(
|
||||||
|
client: TestClient, test_database_urls
|
||||||
|
) -> None:
|
||||||
|
_login(client)
|
||||||
|
mock_mgr = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.api.routes.api.config.mqtt_manager", mock_mgr):
|
||||||
|
response = client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"updates": _full_config_payload({"MQTT_CLIENT_ID": " home-automation-dev "})},
|
||||||
|
headers={"X-CSRF-Token": "token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
reconnect_settings = mock_mgr.reconnect.call_args.args[0]
|
||||||
|
assert reconnect_settings.mqtt_client_id == "home-automation-dev"
|
||||||
|
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||||
|
try:
|
||||||
|
stored_value = conn.execute(
|
||||||
|
"SELECT value FROM app_config WHERE key = 'MQTT_CLIENT_ID'"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert stored_value == ("home-automation-dev",)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# M6-T02: DSMR + Tibber CONFIG_FIELDS
|
# M6-T02: DSMR + Tibber CONFIG_FIELDS
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va
|
|||||||
assert rows["APP_NAME"] == "Database Owned Name"
|
assert rows["APP_NAME"] == "Database Owned Name"
|
||||||
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
|
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
|
||||||
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
|
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
|
||||||
|
assert rows["MQTT_CLIENT_ID"] == "home-automation"
|
||||||
|
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
reset_db_caches()
|
reset_db_caches()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ def fake_mqtt(monkeypatch):
|
|||||||
fake = _FakeMqtt()
|
fake = _FakeMqtt()
|
||||||
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
|
||||||
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
|
||||||
|
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
|
||||||
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
|
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
|
||||||
return fake
|
return fake
|
||||||
|
|
||||||
@@ -81,6 +83,18 @@ def test_same_snapshot_has_no_subscription_churn(fake_mqtt, monkeypatch):
|
|||||||
assert fake_mqtt.remove_calls == []
|
assert fake_mqtt.remove_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_base_client_id_replaces_all_enabled_sources(fake_mqtt, monkeypatch):
|
||||||
|
snapshot = _source(1, "topic", broker_host="broker.test")
|
||||||
|
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
|
||||||
|
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(SimpleNamespace(mqtt_client_id="home-automation"))
|
||||||
|
dsmr_ingest.apply_dsmr_subscription(SimpleNamespace(mqtt_client_id="home-automation-dev"))
|
||||||
|
|
||||||
|
assert fake_mqtt.remove_calls == [1]
|
||||||
|
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 1]
|
||||||
|
assert fake_mqtt.replace_calls[-1][1]["base_client_id"] == "home-automation-dev"
|
||||||
|
|
||||||
|
|
||||||
def test_same_topic_on_different_brokers_is_allowed(fake_mqtt, monkeypatch):
|
def test_same_topic_on_different_brokers_is_allowed(fake_mqtt, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
dsmr_ingest,
|
dsmr_ingest,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.integrations.mqtt import MqttManager
|
from app.integrations.mqtt import MqttManager, mqtt_source_client_id, mqtt_test_client_id
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -24,6 +24,7 @@ def _make_settings(
|
|||||||
mqtt_username: str = "",
|
mqtt_username: str = "",
|
||||||
mqtt_password: str = "",
|
mqtt_password: str = "",
|
||||||
mqtt_tls_enabled: bool = False,
|
mqtt_tls_enabled: bool = False,
|
||||||
|
mqtt_client_id: str = "home-automation",
|
||||||
ha_discovery_prefix: str = "homeassistant",
|
ha_discovery_prefix: str = "homeassistant",
|
||||||
):
|
):
|
||||||
"""Return a simple namespace that acts like a Settings object for MqttManager tests."""
|
"""Return a simple namespace that acts like a Settings object for MqttManager tests."""
|
||||||
@@ -34,6 +35,7 @@ def _make_settings(
|
|||||||
s.mqtt_username = mqtt_username
|
s.mqtt_username = mqtt_username
|
||||||
s.mqtt_password = mqtt_password
|
s.mqtt_password = mqtt_password
|
||||||
s.mqtt_tls_enabled = mqtt_tls_enabled
|
s.mqtt_tls_enabled = mqtt_tls_enabled
|
||||||
|
s.mqtt_client_id = mqtt_client_id
|
||||||
s.ha_discovery_prefix = ha_discovery_prefix
|
s.ha_discovery_prefix = ha_discovery_prefix
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -120,6 +122,34 @@ def test_connect_creates_paho_client_with_version2() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_uses_configured_client_id() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
settings = _make_settings(mqtt_client_id="home-automation-dev")
|
||||||
|
mock_client = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
|
||||||
|
manager.connect(settings)
|
||||||
|
|
||||||
|
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deployment_client_id_variants_are_distinct() -> None:
|
||||||
|
production_ids = {
|
||||||
|
"home-automation",
|
||||||
|
mqtt_source_client_id("home-automation", 7),
|
||||||
|
mqtt_test_client_id("home-automation"),
|
||||||
|
}
|
||||||
|
development_ids = {
|
||||||
|
"home-automation-dev",
|
||||||
|
mqtt_source_client_id("home-automation-dev", 7),
|
||||||
|
mqtt_test_client_id("home-automation-dev"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(production_ids) == 3
|
||||||
|
assert len(development_ids) == 3
|
||||||
|
assert production_ids.isdisjoint(development_ids)
|
||||||
|
|
||||||
|
|
||||||
def test_connect_sets_credentials_when_username_provided() -> None:
|
def test_connect_sets_credentials_when_username_provided() -> None:
|
||||||
manager = MqttManager()
|
manager = MqttManager()
|
||||||
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
|
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
|
||||||
@@ -431,3 +461,17 @@ def test_run_mqtt_test_raises_connection_error_on_timeout() -> None:
|
|||||||
with patch("threading.Event", side_effect=_make_event):
|
with patch("threading.Event", side_effect=_make_event):
|
||||||
with pytest.raises(_MqttConnectionError, match="timed out"):
|
with pytest.raises(_MqttConnectionError, match="timed out"):
|
||||||
_run_mqtt_test(settings)
|
_run_mqtt_test(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_mqtt_test_uses_deployment_scoped_client_id() -> None:
|
||||||
|
from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError
|
||||||
|
|
||||||
|
settings = _make_settings(mqtt_client_id="home-automation-dev")
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.connect.side_effect = OSError("Connection refused")
|
||||||
|
|
||||||
|
with patch("paho.mqtt.client.Client", return_value=mock_client) as mock_cls:
|
||||||
|
with pytest.raises(_MqttConnectionError):
|
||||||
|
_run_mqtt_test(settings)
|
||||||
|
|
||||||
|
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-test"
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
|
|||||||
password="one-secret",
|
password="one-secret",
|
||||||
tls_enabled=True,
|
tls_enabled=True,
|
||||||
subscriptions={"one/topic": lambda payload: received.append(("one", payload))},
|
subscriptions={"one/topic": lambda payload: received.append(("one", payload))},
|
||||||
|
base_client_id="home-automation-dev",
|
||||||
)
|
)
|
||||||
manager.replace_source(
|
manager.replace_source(
|
||||||
2,
|
2,
|
||||||
@@ -284,6 +285,25 @@ def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
|
|||||||
assert received == [("two", b"two"), ("changed", b"changed")]
|
assert received == [("two", b"two"), ("changed", b"changed")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_client_id_is_scoped_by_deployment_and_source() -> None:
|
||||||
|
manager = MqttManager()
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.integrations.mqtt.mqtt.Client", return_value=client) as mock_cls:
|
||||||
|
assert manager.replace_source(
|
||||||
|
42,
|
||||||
|
host="broker.test",
|
||||||
|
port=1883,
|
||||||
|
username="",
|
||||||
|
password="",
|
||||||
|
tls_enabled=False,
|
||||||
|
subscriptions={"topic": lambda _payload: None},
|
||||||
|
base_client_id="home-automation-dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-dsmr-source-42"
|
||||||
|
|
||||||
|
|
||||||
def test_replaced_source_client_callbacks_cannot_reach_new_generation() -> None:
|
def test_replaced_source_client_callbacks_cannot_reach_new_generation() -> None:
|
||||||
"""A retained old paho client cannot subscribe, mutate state, or dispatch new handlers."""
|
"""A retained old paho client cannot subscribe, mutate state, or dispatch new handlers."""
|
||||||
manager = MqttManager()
|
manager = MqttManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user