feat: scheduled auto-sync for Zigbee & Z-Wave imports

Mirror the Proxmox auto-sync pattern for the Zigbee2MQTT and Z-Wave JS
UI mesh imports. Connection config + MQTT credentials live in .env only,
are never persisted to scan_config.json, and are never returned by any
API or shown in the UI (single source of truth).

- config: ZIGBEE_* / ZWAVE_* env settings; only sync_enabled+interval
  are persisted, connection/credentials stay env-only
- routes: GET/POST /config, POST /sync-now; auto-sync reuses the exact
  manual _background_*_import + _persist_pending_import path (fresh
  import when empty, update-in-place when nodes exist, ScanRun trace)
- scheduler: zigbee_sync / zwave_sync jobs with live enable + reschedule
- frontend: reusable MeshAutoSync section in Settings (Zigbee, Z-Wave)
- .env.example: documented both blocks
- tests: scheduler jobs, router config/sync-now/auth, credential-never-
  persisted, SettingsModal sections

Manual Zigbee/Z-Wave import behaviour is unchanged.

ha-relevant: no
This commit is contained in:
Pouzor
2026-07-10 14:51:01 +02:00
parent a90ca2f039
commit b99450db2f
14 changed files with 1141 additions and 3 deletions
+158
View File
@@ -9,11 +9,17 @@ from app.core.scheduler import (
_run_proxmox_sync,
_run_service_checks,
_run_status_checks,
_run_zigbee_sync,
_run_zwave_sync,
reschedule_proxmox_sync,
reschedule_service_checks,
reschedule_status_checks,
reschedule_zigbee_sync,
reschedule_zwave_sync,
set_proxmox_sync_enabled,
set_service_checks_enabled,
set_zigbee_sync_enabled,
set_zwave_sync_enabled,
start_scheduler,
stop_scheduler,
)
@@ -154,6 +160,8 @@ def test_scheduler_uses_settings_interval():
mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
_, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45
@@ -167,6 +175,8 @@ def test_start_and_stop_scheduler():
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
stop_scheduler()
mock_sched.add_job.assert_called_once()
@@ -256,6 +266,8 @@ def test_start_scheduler_adds_service_job_when_enabled():
mock_settings.service_check_enabled = True
mock_settings.service_check_interval = 300
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids
@@ -343,6 +355,152 @@ async def test_run_proxmox_sync_records_scan_run(mem_db):
)
# ---------------------------------------------------------------------------
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
# ---------------------------------------------------------------------------
def test_set_zigbee_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_interval = 3600
mock_sched.get_job.return_value = None
set_zigbee_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_zigbee_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
def test_set_zwave_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zwave_sync_interval = 3600
mock_sched.get_job.return_value = None
set_zwave_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_zwave_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("zwave_sync")
def test_reschedule_zigbee_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_zigbee_sync(60)
def test_reschedule_zwave_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_zwave_sync(60)
@pytest.mark.asyncio
async def test_run_zigbee_sync_skips_when_disabled():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_enabled = False
await _run_zigbee_sync() # returns before importing/fetching anything
@pytest.mark.asyncio
async def test_run_zigbee_sync_skips_when_no_host():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_mqtt_host = ""
await _run_zigbee_sync() # no exception, no fetch
@pytest.mark.asyncio
async def test_run_zwave_sync_skips_when_no_host():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_mqtt_host = ""
await _run_zwave_sync()
@pytest.mark.asyncio
async def test_run_zigbee_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
then delegate to the shared background import with the run id + env payload."""
from app.db.models import ScanRun
from app.schemas.zigbee import ZigbeeImportRequest
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
):
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_mqtt_host = "broker"
mock_settings.zigbee_mqtt_port = 1883
await _run_zigbee_sync()
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "zigbee"
assert run.ranges == ["broker:1883"]
mock_bg.assert_awaited_once_with(run.id, fake_payload)
@pytest.mark.asyncio
async def test_run_zwave_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
background import with the run id + env payload."""
from app.db.models import ScanRun
from app.schemas.zwave import ZwaveImportRequest
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
):
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_mqtt_host = "broker"
mock_settings.zwave_mqtt_port = 1883
await _run_zwave_sync()
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "zwave"
assert run.ranges == ["broker:1883"]
mock_bg.assert_awaited_once_with(run.id, fake_payload)
def test_reschedule_zigbee_sync_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_zigbee_sync(600)
mock_sched.reschedule_job.assert_not_called()
def test_start_scheduler_adds_mesh_jobs_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_sync_interval = 3600
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_sync_interval = 3600
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "zigbee_sync" in job_ids
assert "zwave_sync" in job_ids
# --- reschedule_* validation and not-running guards ---
def test_reschedule_status_checks_rejects_short_interval():
+44
View File
@@ -123,3 +123,47 @@ def test_save_overrides_omits_proxmox_connection_config(tmp_path):
assert "proxmox_token_secret" not in written
assert written["proxmox_sync_enabled"] is True
assert written["proxmox_sync_interval"] == 900
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
env-only: stale values in scan_config.json must be ignored. Only the
auto-sync activation is read back."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.zigbee_mqtt_host = "broker.local"
s.zwave_mqtt_host = "broker.local"
(tmp_path / "scan_config.json").write_text(json.dumps({
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
}))
s.load_overrides()
assert s.zigbee_mqtt_host == "broker.local"
assert s.zwave_mqtt_host == "broker.local"
assert s.zigbee_sync_enabled is True
assert s.zigbee_sync_interval == 600
assert s.zwave_sync_enabled is True
assert s.zwave_sync_interval == 700
def test_save_overrides_omits_mesh_credentials(tmp_path):
"""save_overrides must never write MQTT host/credentials — only the sync
activation. Prevents the dual source of truth and leaking secrets to disk."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.zigbee_mqtt_host = "broker.local"
s.zigbee_mqtt_password = "secret"
s.zigbee_sync_enabled = True
s.zigbee_sync_interval = 900
s.zwave_mqtt_password = "secret"
s.zwave_sync_interval = 1200
s.save_overrides()
raw = (tmp_path / "scan_config.json").read_text()
assert "secret" not in raw
written = json.loads(raw)
assert "zigbee_mqtt_host" not in written
assert "zigbee_mqtt_password" not in written
assert "zwave_mqtt_password" not in written
assert written["zigbee_sync_enabled"] is True
assert written["zigbee_sync_interval"] == 900
assert written["zwave_sync_interval"] == 1200
+104 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
@@ -740,3 +740,106 @@ async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> N
kwargs = mock_conn.call_args.kwargs
assert kwargs["tls"] is True
assert kwargs["tls_insecure"] is True
# ---------------------------------------------------------------------------
# /api/v1/zigbee/config + /sync-now (auto-sync)
# ---------------------------------------------------------------------------
from app.core.config import settings # noqa: E402
@pytest.fixture
def _restore_zigbee_env():
"""Snapshot + restore the env-only Zigbee connection/activation settings."""
keys = (
"zigbee_mqtt_host", "zigbee_mqtt_port", "zigbee_mqtt_username",
"zigbee_mqtt_password", "zigbee_base_topic", "zigbee_sync_enabled",
"zigbee_sync_interval",
)
saved = {k: getattr(settings, k) for k in keys}
yield
for k, v in saved.items():
setattr(settings, k, v)
@pytest.mark.asyncio
async def test_config_omits_credentials(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
settings.zigbee_mqtt_username = "user"
settings.zigbee_mqtt_password = "supersecret"
res = await client.get("/api/v1/zigbee/config", headers=headers)
assert res.status_code == 200
assert "supersecret" not in res.text
assert "user" not in res.text
assert res.json()["host_configured"] is True
@pytest.mark.asyncio
async def test_config_requires_auth(client: AsyncClient) -> None:
res = await client.get("/api/v1/zigbee/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_enable_sync_without_host_rejected(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = ""
res = await client.post(
"/api/v1/zigbee/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.zigbee_mqtt_host, enabled=self.zigbee_sync_enabled,
interval=self.zigbee_sync_interval,
)), patch("app.api.routes.zigbee.set_zigbee_sync_enabled"), \
patch("app.api.routes.zigbee.reschedule_zigbee_sync"):
res = await client.post(
"/api/v1/zigbee/config",
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
assert settings.zigbee_mqtt_host == "broker" # body host ignored
assert saved == {"host": "broker", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
with patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock):
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "zigbee"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_host(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = ""
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/zigbee/sync-now")
assert res.status_code == 401
+105
View File
@@ -492,3 +492,108 @@ async def test_persist_device_on_multiple_canvases(db_session) -> None:
for n in nodes:
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
assert model == "ZW200" # refreshed on every canvas
# ---------------------------------------------------------------------------
# /api/v1/zwave/config + /sync-now (auto-sync)
# ---------------------------------------------------------------------------
from unittest.mock import AsyncMock # noqa: E402
from app.core.config import settings # noqa: E402
@pytest.fixture
def _restore_zwave_env():
"""Snapshot + restore the env-only Z-Wave connection/activation settings."""
keys = (
"zwave_mqtt_host", "zwave_mqtt_port", "zwave_mqtt_username",
"zwave_mqtt_password", "zwave_prefix", "zwave_gateway_name",
"zwave_sync_enabled", "zwave_sync_interval",
)
saved = {k: getattr(settings, k) for k in keys}
yield
for k, v in saved.items():
setattr(settings, k, v)
@pytest.mark.asyncio
async def test_config_omits_credentials(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
settings.zwave_mqtt_username = "user"
settings.zwave_mqtt_password = "supersecret"
res = await client.get("/api/v1/zwave/config", headers=headers)
assert res.status_code == 200
assert "supersecret" not in res.text
assert "user" not in res.text
assert res.json()["host_configured"] is True
@pytest.mark.asyncio
async def test_config_requires_auth(client: AsyncClient) -> None:
res = await client.get("/api/v1/zwave/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_enable_sync_without_host_rejected(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = ""
res = await client.post(
"/api/v1/zwave/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.zwave_mqtt_host, enabled=self.zwave_sync_enabled,
interval=self.zwave_sync_interval,
)), patch("app.api.routes.zwave.set_zwave_sync_enabled"), \
patch("app.api.routes.zwave.reschedule_zwave_sync"):
res = await client.post(
"/api/v1/zwave/config",
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
assert settings.zwave_mqtt_host == "broker" # body host ignored
assert saved == {"host": "broker", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "zwave"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_host(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = ""
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/zwave/sync-now")
assert res.status_code == 401