From 14b846549e261295afc15812395c754161f51534 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 22 Jun 2026 14:27:21 +0200 Subject: [PATCH] M5-T08: add MQTT, HA Discovery, and Modbus polling config fields --- .env.example | 17 ++++ app/config.py | 14 +++- app/services/config_page.py | 28 +++++++ docs/design/m5-iot-energy.md | 2 +- tests/test_api_config.py | 147 +++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index b0297cd..ba192c6 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,20 @@ TICKTICK_CLIENT_ID= TICKTICK_CLIENT_SECRET= TICKTICK_TOKEN= HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= + +# Optional: Modbus polling (global kill-switch; default true). +# MODBUS_POLLING_ENABLED=true + +# Optional: MQTT broker connection. +# Leave MQTT_ENABLED=false (or unset) when MQTT is not needed. +MQTT_ENABLED=false +MQTT_BROKER_HOST= +MQTT_BROKER_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +MQTT_TLS_ENABLED=false + +# Optional: Home Assistant MQTT Discovery. +# Requires MQTT_ENABLED=true and a running MQTT broker. +HA_DISCOVERY_ENABLED=false +HA_DISCOVERY_PREFIX=homeassistant diff --git a/app/config.py b/app/config.py index 6228d4a..2a33b60 100644 --- a/app/config.py +++ b/app/config.py @@ -41,9 +41,21 @@ class Settings(BaseSettings): auth_trust_forwarded_for: bool = False auth_totp_issuer: str = "" # defaults to app_name when empty - # Modbus polling — global kill-switch; T08 will wire this into CONFIG_FIELDS/UI. + # Modbus polling — global kill-switch (CONFIG_FIELDS registered in T08). modbus_polling_enabled: bool = True + # MQTT broker connection (T08 wires into CONFIG_FIELDS/UI; T10 builds the client). + mqtt_enabled: bool = False + mqtt_broker_host: str = "" + mqtt_broker_port: int = 1883 + mqtt_username: str = "" + mqtt_password: str = "" + mqtt_tls_enabled: bool = False + + # Home Assistant MQTT Discovery (T08 wires into CONFIG_FIELDS/UI; T11 does publishing). + ha_discovery_enabled: bool = False + ha_discovery_prefix: str = "homeassistant" + 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 c39d4be..0b00ffb 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -102,6 +102,25 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( "home_assistant_action_task_project_id", "Home Assistant Action Task Project ID", ), + ConfigField("MQTT", "MQTT_ENABLED", "mqtt_enabled", "MQTT Enabled"), + ConfigField("MQTT", "MQTT_BROKER_HOST", "mqtt_broker_host", "MQTT Broker Host"), + ConfigField("MQTT", "MQTT_BROKER_PORT", "mqtt_broker_port", "MQTT Broker Port", input_type="number"), + 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"), + ConfigField( + "Home Assistant Discovery", + "HA_DISCOVERY_ENABLED", + "ha_discovery_enabled", + "HA Discovery Enabled", + ), + ConfigField( + "Home Assistant Discovery", + "HA_DISCOVERY_PREFIX", + "ha_discovery_prefix", + "HA Discovery Prefix", + ), + ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled"), ) @@ -292,4 +311,13 @@ def _settings_payload(settings: Settings) -> dict[str, Any]: "auth_cookie_secure_override": settings.auth_cookie_secure_override, "auth_login_throttle_enabled": settings.auth_login_throttle_enabled, "auth_trust_forwarded_for": settings.auth_trust_forwarded_for, + "modbus_polling_enabled": settings.modbus_polling_enabled, + "mqtt_enabled": settings.mqtt_enabled, + "mqtt_broker_host": settings.mqtt_broker_host, + "mqtt_broker_port": settings.mqtt_broker_port, + "mqtt_username": settings.mqtt_username, + "mqtt_password": settings.mqtt_password, + "mqtt_tls_enabled": settings.mqtt_tls_enabled, + "ha_discovery_enabled": settings.ha_discovery_enabled, + "ha_discovery_prefix": settings.ha_discovery_prefix, } diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index be3e6d0..547830c 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -387,7 +387,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) - **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。 ### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS) -- **Status**: `todo` · **Depends**: none +- **Status**: `done` · **Depends**: none - **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。 - **Files**: `modify app/config.py`(新增 Settings 字段)、`app/services/config_page.py`(追加 CONFIG_FIELDS + `_settings_payload`);`modify .env.example`;`modify tests/test_api_config.py` - **Steps**: 加字段 `mqtt_enabled`、`mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled`、`ha_discovery_enabled`、`ha_discovery_prefix`(默认 `homeassistant`)、`modbus_polling_enabled`;CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Modbus」section;`_settings_payload` 补齐对应行。 diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 57b3c34..1d8eeb4 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -410,3 +410,150 @@ def test_smtp_test_response_does_not_echo_smtp_password(client: TestClient) -> N assert "message" in body # The plaintext password must not appear anywhere in the response body assert smtp_password not in response.text + + +# --------------------------------------------------------------------------- +# M5-T08: MQTT / HA Discovery / Modbus CONFIG_FIELDS +# --------------------------------------------------------------------------- + + +def test_get_config_includes_mqtt_section(client: TestClient) -> None: + """GET /api/config must include an MQTT section with expected fields.""" + _login(client) + + response = client.get("/api/config") + body = response.json() + + section_names = {s["name"] for s in body["sections"]} + assert "MQTT" in section_names, f"MQTT section missing; got {section_names}" + + mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT") + env_names = {f["env_name"] for f in mqtt_section["fields"]} + assert "MQTT_ENABLED" in env_names + assert "MQTT_BROKER_HOST" in env_names + assert "MQTT_BROKER_PORT" in env_names + assert "MQTT_USERNAME" in env_names + assert "MQTT_PASSWORD" in env_names + assert "MQTT_TLS_ENABLED" in env_names + + +def test_get_config_includes_ha_discovery_section(client: TestClient) -> None: + """GET /api/config must include a Home Assistant Discovery section.""" + _login(client) + + response = client.get("/api/config") + body = response.json() + + section_names = {s["name"] for s in body["sections"]} + assert "Home Assistant Discovery" in section_names, ( + f"Home Assistant Discovery section missing; got {section_names}" + ) + + ha_section = next(s for s in body["sections"] if s["name"] == "Home Assistant Discovery") + env_names = {f["env_name"] for f in ha_section["fields"]} + assert "HA_DISCOVERY_ENABLED" in env_names + assert "HA_DISCOVERY_PREFIX" in env_names + + +def test_get_config_includes_modbus_section(client: TestClient) -> None: + """GET /api/config must include a Modbus section with MODBUS_POLLING_ENABLED.""" + _login(client) + + response = client.get("/api/config") + body = response.json() + + section_names = {s["name"] for s in body["sections"]} + assert "Modbus" in section_names, f"Modbus section missing; got {section_names}" + + modbus_section = next(s for s in body["sections"] if s["name"] == "Modbus") + env_names = {f["env_name"] for f in modbus_section["fields"]} + assert "MODBUS_POLLING_ENABLED" in env_names + + +def test_get_config_mqtt_password_is_secret(client: TestClient) -> None: + """MQTT_PASSWORD must be marked secret and return empty string in GET response.""" + _login(client) + + response = client.get("/api/config") + body = response.json() + + mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT") + password_field = next(f for f in mqtt_section["fields"] if f["env_name"] == "MQTT_PASSWORD") + + assert password_field["secret"] is True + assert password_field["value"] == "", ( + f"MQTT_PASSWORD should be masked (empty string), got {password_field['value']!r}" + ) + + +def test_mqtt_broker_port_input_type_is_number(client: TestClient) -> None: + """MQTT_BROKER_PORT must have input_type='number' for correct frontend rendering.""" + _login(client) + + response = client.get("/api/config") + body = response.json() + + mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT") + port_field = next(f for f in mqtt_section["fields"] if f["env_name"] == "MQTT_BROKER_PORT") + + assert port_field["input_type"] == "number", ( + f"MQTT_BROKER_PORT input_type should be 'number', got {port_field['input_type']!r}" + ) + + +def test_put_config_blank_mqtt_password_keeps_existing( + client: TestClient, test_database_urls +) -> None: + """Submitting blank MQTT_PASSWORD must NOT overwrite the stored secret.""" + _login(client) + + # Store a secret via full PUT + payload_with_secret = _full_config_payload({"MQTT_PASSWORD": "mqtt-secret-value"}) + 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({"MQTT_PASSWORD": ""}) + 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("MQTT_PASSWORD") == "mqtt-secret-value" + + +def test_put_config_invalid_mqtt_port_returns_422_and_does_not_write( + client: TestClient, test_database_urls +) -> None: + """Non-numeric MQTT_BROKER_PORT must return 422 and not persist the bad value.""" + _login(client) + + payload = _full_config_payload({"MQTT_BROKER_PORT": "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("MQTT_BROKER_PORT") != "not-a-number"