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:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user