M5-T08: add MQTT, HA Discovery, and Modbus polling config fields
This commit is contained in:
@@ -28,3 +28,20 @@ TICKTICK_CLIENT_ID=
|
|||||||
TICKTICK_CLIENT_SECRET=
|
TICKTICK_CLIENT_SECRET=
|
||||||
TICKTICK_TOKEN=
|
TICKTICK_TOKEN=
|
||||||
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
|
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
|
||||||
|
|||||||
+13
-1
@@ -41,9 +41,21 @@ class Settings(BaseSettings):
|
|||||||
auth_trust_forwarded_for: bool = False
|
auth_trust_forwarded_for: bool = False
|
||||||
auth_totp_issuer: str = "" # defaults to app_name when empty
|
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
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -102,6 +102,25 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
"home_assistant_action_task_project_id",
|
"home_assistant_action_task_project_id",
|
||||||
"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_cookie_secure_override": settings.auth_cookie_secure_override,
|
||||||
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
|
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
|
||||||
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
|
"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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。
|
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理;从 payload 取 key 的逻辑容忍缺 key。
|
||||||
|
|
||||||
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS)
|
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS)
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。
|
- **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`
|
- **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` 补齐对应行。
|
- **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` 补齐对应行。
|
||||||
|
|||||||
@@ -410,3 +410,150 @@ def test_smtp_test_response_does_not_echo_smtp_password(client: TestClient) -> N
|
|||||||
assert "message" in body
|
assert "message" in body
|
||||||
# The plaintext password must not appear anywhere in the response body
|
# The plaintext password must not appear anywhere in the response body
|
||||||
assert smtp_password not in response.text
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user