diff --git a/.env.example b/.env.example index cf61e22..9a81dfb 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,15 @@ MQTT_TLS_ENABLED=false # Requires MQTT_ENABLED=true and a running MQTT broker. HA_DISCOVERY_ENABLED=false 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= diff --git a/app/config.py b/app/config.py index 5e2a838..d5b872e 100644 --- a/app/config.py +++ b/app/config.py @@ -58,6 +58,16 @@ class Settings(BaseSettings): ha_discovery_enabled: bool = False 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( env_file=".env", env_file_encoding="utf-8", diff --git a/app/services/config_page.py b/app/services/config_page.py index 57a7490..7ea6a2a 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -123,6 +123,29 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( "HA Discovery Prefix", ), 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, "ha_discovery_enabled": settings.ha_discovery_enabled, "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, } diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index 44c94e2..23b775c 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -304,7 +304,7 @@ Phase D(API + 前端) - **Reviewer checklist**: 列/约束/FK 与 §3.5 一致;`recorded_at`/`period_start`/`starts_at` 真实索引或唯一列;`env.py` 已 import 新模型;迁移 `down_revision` 指当前真实 head;无破坏性操作。 ### M6-T02 — flat 配置(DSMR + Tibber 凭据) -- **Status**: `todo` · **Depends**: none +- **Status**: `done` · **Depends**: none - **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` - **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` 为 secret,bool=checkbox、数值=number;`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。 diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 673e017..18f18fa 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -572,6 +572,7 @@ EXPECTED_CHECKBOX_FIELDS = { "MQTT_TLS_ENABLED", "HA_DISCOVERY_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( client: TestClient, test_database_urls ) -> None: