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:
@@ -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=
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ Phase D(API + 前端)
|
|||||||
- **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` 为 secret,bool=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` 为 secret,bool=checkbox、数值=number;`_settings_payload` 补齐;`.env.example` 加注释样例(默认 off)。
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user