M8-R01: add configurable MQTT client identity

This commit is contained in:
2026-08-24 00:44:28 +02:00
parent 2a47dab272
commit e59c192097
12 changed files with 184 additions and 15 deletions
+13 -4
View File
@@ -109,6 +109,7 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
ConfigField("MQTT", "MQTT_USERNAME", "mqtt_username", "MQTT Username"),
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_CLIENT_ID", "mqtt_client_id", "MQTT Client ID"),
ConfigField(
"Home Assistant Discovery",
"HA_DISCOVERY_ENABLED",
@@ -222,7 +223,12 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
else:
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)
get_settings.cache_clear()
reset_db_caches()
@@ -237,7 +243,9 @@ def save_config_value(
) -> None:
current_values = _read_config_values(session)
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)
get_settings.cache_clear()
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}
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)
for field in CONFIG_FIELDS:
if field.env_name in config_values:
payload[field.setting_attr] = config_values[field.env_name]
try:
Settings(_env_file=None, **payload)
return Settings(_env_file=None, **payload)
except Exception as 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_password": settings.mqtt_password,
"mqtt_tls_enabled": settings.mqtt_tls_enabled,
"mqtt_client_id": settings.mqtt_client_id,
"ha_discovery_enabled": settings.ha_discovery_enabled,
"ha_discovery_prefix": settings.ha_discovery_prefix,
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
+19 -4
View File
@@ -39,6 +39,7 @@ class DsmrSourceSnapshot:
_subscriptions: dict[int, DsmrSourceSnapshot] = {}
_subscription_client_ids: dict[int, str] = {}
_subscription_lock = threading.RLock()
# A configuration value is not an ownership identity: disable and re-enable
# can produce an equal snapshot. Each installed handler therefore captures a
@@ -160,13 +161,16 @@ def _enabled_snapshots() -> list[DsmrSourceSnapshot]:
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.
The optional, ignored settings parameter preserves the old config-route
call shape while ensuring flat DSMR settings are no longer read.
``settings`` provides the DB-merged app-wide MQTT identity. Individual
DSMR broker settings continue to come solely from MeterSource records.
"""
from app.integrations.mqtt import mqtt_manager
from app.config import get_settings
base_client_id = (settings or get_settings()).mqtt_client_id
try:
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
for source_id, current in _subscriptions.items()
if desired.get(source_id) != current
or _subscription_client_ids.get(source_id) != base_client_id
]
for source_id in stale_source_ids:
_subscriptions.pop(source_id, None)
_subscription_client_ids.pop(source_id, None)
_subscription_tokens.pop(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():
with _subscription_lock:
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
if current is not None:
# The client went inactive outside reconcile. Invalidate its
@@ -211,6 +222,7 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
with _subscription_lock:
if _subscriptions.get(source_id) == current:
_subscriptions.pop(source_id, None)
_subscription_client_ids.pop(source_id, None)
_subscription_tokens.pop(source_id, None)
mqtt_manager.remove_source(source_id)
token = object()
@@ -227,6 +239,7 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
)
with _subscription_lock:
_subscriptions[source_id] = snapshot
_subscription_client_ids[source_id] = base_client_id
_subscription_tokens[source_id] = token
applied = mqtt_manager.replace_source(
source_id,
@@ -236,11 +249,13 @@ def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
password=snapshot.password,
tls_enabled=snapshot.tls_enabled,
subscriptions=handlers,
base_client_id=base_client_id,
)
if not applied:
with _subscription_lock:
if _subscription_tokens.get(source_id) is token:
_subscriptions.pop(source_id, None)
_subscription_client_ids.pop(source_id, None)
_subscription_tokens.pop(source_id, None)