M6-T02: add DSMR + Tibber flat config fields

- Settings: dsmr_ingest_enabled, dsmr_mqtt_topic, dsmr_sample_interval_s,
  tibber_api_token (secret), tibber_home_id (all opt-in / off by default).
- CONFIG_FIELDS DSMR + Tibber sections; _settings_payload extended.
- .env.example examples; test_api_config coverage (sections, secret, 422).
This commit is contained in:
2026-06-23 20:43:46 +02:00
parent 18fe18281b
commit df54f5518b
5 changed files with 257 additions and 1 deletions
+12
View File
@@ -45,3 +45,15 @@ MQTT_TLS_ENABLED=false
# Requires MQTT_ENABLED=true and a running MQTT broker. # Requires MQTT_ENABLED=true and a running MQTT broker.
HA_DISCOVERY_ENABLED=false HA_DISCOVERY_ENABLED=false
HA_DISCOVERY_PREFIX=homeassistant HA_DISCOVERY_PREFIX=homeassistant
# Optional: DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
# Set DSMR_INGEST_ENABLED=true to subscribe to the DSMR MQTT topic and
# store 10-second downsampled telegram frames in dsmr_reading.
# DSMR_INGEST_ENABLED=false
# DSMR_MQTT_TOPIC=dsmr/json
# DSMR_SAMPLE_INTERVAL_S=10
# Optional: Tibber dynamic pricing credentials (M6).
# Only used when an active energy contract with kind=tibber is configured.
# TIBBER_API_TOKEN=
# TIBBER_HOME_ID=
+10
View File
@@ -58,6 +58,16 @@ class Settings(BaseSettings):
ha_discovery_enabled: bool = False ha_discovery_enabled: bool = False
ha_discovery_prefix: str = "homeassistant" ha_discovery_prefix: str = "homeassistant"
# DSMR smart-meter ingest via MQTT (M6; default off — opt-in).
# Subscribe to dsmr_mqtt_topic when dsmr_ingest_enabled=True.
dsmr_ingest_enabled: bool = False
dsmr_mqtt_topic: str = "dsmr/json"
dsmr_sample_interval_s: int = 10
# Tibber dynamic pricing credentials (M6; only used when active contract kind=tibber).
tibber_api_token: str = ""
tibber_home_id: str = ""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
+28
View File
@@ -123,6 +123,29 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
"HA Discovery Prefix", "HA Discovery Prefix",
), ),
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"), ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
ConfigField(
"DSMR",
"DSMR_INGEST_ENABLED",
"dsmr_ingest_enabled",
"DSMR Ingest Enabled",
input_type="checkbox",
),
ConfigField("DSMR", "DSMR_MQTT_TOPIC", "dsmr_mqtt_topic", "DSMR MQTT Topic"),
ConfigField(
"DSMR",
"DSMR_SAMPLE_INTERVAL_S",
"dsmr_sample_interval_s",
"DSMR Sample Interval (s)",
input_type="number",
),
ConfigField(
"Tibber",
"TIBBER_API_TOKEN",
"tibber_api_token",
"Tibber API Token",
secret=True,
),
ConfigField("Tibber", "TIBBER_HOME_ID", "tibber_home_id", "Tibber Home ID"),
) )
@@ -322,4 +345,9 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"mqtt_tls_enabled": settings.mqtt_tls_enabled, "mqtt_tls_enabled": settings.mqtt_tls_enabled,
"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,
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
"tibber_api_token": settings.tibber_api_token,
"tibber_home_id": settings.tibber_home_id,
} }
+1 -1
View File
@@ -304,7 +304,7 @@ Phase DAPI + 前端)
- **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列;`env.py` 已 import 新模型;迁移 `down_revision` 指当前真实 head;无破坏性操作。 - **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列;`env.py` 已 import 新模型;迁移 `down_revision` 指当前真实 head;无破坏性操作。
### M6-T02 — flat 配置(DSMR + Tibber 凭据) ### M6-T02 — flat 配置(DSMR + Tibber 凭据)
- **Status**: `todo` · **Depends**: none - **Status**: `done` · **Depends**: none
- **Context**: DSMR 订阅设置 + Tibber 凭据接入扁平配置(前端自动渲染)。**价格数值不在这里**(在合同里)。 - **Context**: DSMR 订阅设置 + Tibber 凭据接入扁平配置(前端自动渲染)。**价格数值不在这里**(在合同里)。
- **Files**: `modify app/config.py`、`app/services/config_page.py`CONFIG_FIELDS + `_settings_payload`);`modify .env.example`、`tests/test_api_config.py` - **Files**: `modify app/config.py`、`app/services/config_page.py`CONFIG_FIELDS + `_settings_payload`);`modify .env.example`、`tests/test_api_config.py`
- **Steps**: `Settings` 加 `dsmr_ingest_enabled: bool=False`、`dsmr_mqtt_topic: str="dsmr/json"`、`dsmr_sample_interval_s: int=10`、`tibber_api_token: str=""`、`tibber_home_id: str=""`CONFIG_FIELDS 归「DSMR」「Tibber」section`tibber_api_token` 为 secretbool=checkbox、数值=number`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。 - **Steps**: `Settings` 加 `dsmr_ingest_enabled: bool=False`、`dsmr_mqtt_topic: str="dsmr/json"`、`dsmr_sample_interval_s: int=10`、`tibber_api_token: str=""`、`tibber_home_id: str=""`CONFIG_FIELDS 归「DSMR」「Tibber」section`tibber_api_token` 为 secretbool=checkbox、数值=number`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。
+206
View File
@@ -572,6 +572,7 @@ EXPECTED_CHECKBOX_FIELDS = {
"MQTT_TLS_ENABLED", "MQTT_TLS_ENABLED",
"HA_DISCOVERY_ENABLED", "HA_DISCOVERY_ENABLED",
"MODBUS_POLLING_ENABLED", "MODBUS_POLLING_ENABLED",
"DSMR_INGEST_ENABLED",
} }
@@ -703,6 +704,211 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
) )
# ---------------------------------------------------------------------------
# M6-T02: DSMR + Tibber CONFIG_FIELDS
# ---------------------------------------------------------------------------
def test_get_config_includes_dsmr_section(client: TestClient) -> None:
"""GET /api/config must include a DSMR section with expected fields."""
_login(client)
response = client.get("/api/config")
body = response.json()
section_names = {s["name"] for s in body["sections"]}
assert "DSMR" in section_names, f"DSMR section missing; got {section_names}"
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
env_names = {f["env_name"] for f in dsmr_section["fields"]}
assert "DSMR_INGEST_ENABLED" in env_names
assert "DSMR_MQTT_TOPIC" in env_names
assert "DSMR_SAMPLE_INTERVAL_S" in env_names
def test_get_config_includes_tibber_section(client: TestClient) -> None:
"""GET /api/config must include a Tibber section with expected fields."""
_login(client)
response = client.get("/api/config")
body = response.json()
section_names = {s["name"] for s in body["sections"]}
assert "Tibber" in section_names, f"Tibber section missing; got {section_names}"
tibber_section = next(s for s in body["sections"] if s["name"] == "Tibber")
env_names = {f["env_name"] for f in tibber_section["fields"]}
assert "TIBBER_API_TOKEN" in env_names
assert "TIBBER_HOME_ID" in env_names
def test_get_config_tibber_api_token_is_secret(client: TestClient) -> None:
"""TIBBER_API_TOKEN must be marked secret and return empty string in GET response."""
_login(client)
response = client.get("/api/config")
body = response.json()
tibber_section = next(s for s in body["sections"] if s["name"] == "Tibber")
token_field = next(f for f in tibber_section["fields"] if f["env_name"] == "TIBBER_API_TOKEN")
assert token_field["secret"] is True
assert token_field["value"] == "", (
f"TIBBER_API_TOKEN should be masked (empty string), got {token_field['value']!r}"
)
def test_get_config_dsmr_ingest_enabled_is_checkbox(client: TestClient) -> None:
"""DSMR_INGEST_ENABLED must have input_type='checkbox' for correct frontend rendering."""
_login(client)
response = client.get("/api/config")
body = response.json()
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
enabled_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_INGEST_ENABLED")
assert enabled_field["input_type"] == "checkbox", (
f"DSMR_INGEST_ENABLED input_type should be 'checkbox', got {enabled_field['input_type']!r}"
)
def test_get_config_dsmr_sample_interval_input_type_is_number(client: TestClient) -> None:
"""DSMR_SAMPLE_INTERVAL_S must have input_type='number'."""
_login(client)
response = client.get("/api/config")
body = response.json()
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
interval_field = next(
f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_SAMPLE_INTERVAL_S"
)
assert interval_field["input_type"] == "number", (
f"DSMR_SAMPLE_INTERVAL_S input_type should be 'number', got {interval_field['input_type']!r}"
)
def test_put_config_blank_tibber_api_token_keeps_existing(
client: TestClient, test_database_urls
) -> None:
"""Submitting blank TIBBER_API_TOKEN must NOT overwrite the stored secret."""
_login(client)
# Store a secret via full PUT
payload_with_secret = _full_config_payload({"TIBBER_API_TOKEN": "tibber-secret-token"})
resp1 = client.put(
"/api/config",
json={"updates": payload_with_secret},
headers={"X-CSRF-Token": "token"},
)
assert resp1.status_code == 200, f"First PUT failed: {resp1.status_code} {resp1.text}"
# PUT with blank for that secret (keep-old semantics)
payload_blank_secret = _full_config_payload({"TIBBER_API_TOKEN": ""})
resp2 = client.put(
"/api/config",
json={"updates": payload_blank_secret},
headers={"X-CSRF-Token": "token"},
)
assert resp2.status_code == 200, f"Second PUT failed: {resp2.status_code} {resp2.text}"
# The stored value in the DB should still be the original secret
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows.get("TIBBER_API_TOKEN") == "tibber-secret-token"
def test_put_config_new_tibber_api_token_overwrites_existing(
client: TestClient, test_database_urls
) -> None:
"""Submitting a non-blank TIBBER_API_TOKEN must overwrite the stored secret."""
_login(client)
# Store initial secret
payload_initial = _full_config_payload({"TIBBER_API_TOKEN": "old-tibber-token"})
resp1 = client.put(
"/api/config",
json={"updates": payload_initial},
headers={"X-CSRF-Token": "token"},
)
assert resp1.status_code == 200
# Overwrite with a new non-blank secret
payload_new = _full_config_payload({"TIBBER_API_TOKEN": "new-tibber-token"})
resp2 = client.put(
"/api/config",
json={"updates": payload_new},
headers={"X-CSRF-Token": "token"},
)
assert resp2.status_code == 200
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows.get("TIBBER_API_TOKEN") == "new-tibber-token"
def test_put_config_invalid_dsmr_sample_interval_returns_422_and_does_not_write(
client: TestClient, test_database_urls
) -> None:
"""Non-integer DSMR_SAMPLE_INTERVAL_S must return 422 and not persist the bad value."""
_login(client)
payload = _full_config_payload({"DSMR_SAMPLE_INTERVAL_S": "not-a-number"})
response = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 422
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows.get("DSMR_SAMPLE_INTERVAL_S") != "not-a-number"
def test_get_config_tibber_api_token_value_masked_after_save(
client: TestClient, test_database_urls
) -> None:
"""After saving a TIBBER_API_TOKEN, the value must remain masked (empty string) in GET response."""
_login(client)
# Save a non-blank token
payload = _full_config_payload({"TIBBER_API_TOKEN": "some-tibber-token"})
put_resp = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert put_resp.status_code == 200
# After saving: value must still be masked (empty string), configured must be True
resp_after = client.get("/api/config")
body_after = resp_after.json()
tibber_section_after = next(s for s in body_after["sections"] if s["name"] == "Tibber")
token_field_after = next(
f for f in tibber_section_after["fields"] if f["env_name"] == "TIBBER_API_TOKEN"
)
assert token_field_after["configured"] is True
assert token_field_after["value"] == "", "Secret value must remain masked after save"
# The actual token must not appear anywhere in the response body
assert "some-tibber-token" not in resp_after.text
def test_post_mqtt_test_uses_db_broker_host( def test_post_mqtt_test_uses_db_broker_host(
client: TestClient, test_database_urls client: TestClient, test_database_urls
) -> None: ) -> None: