From b99450db2f2db5db6352cdf64a6bf7750e572ffb Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 10 Jul 2026 14:51:01 +0200 Subject: [PATCH] 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 --- .env.example | 27 +++ backend/app/api/routes/zigbee.py | 98 ++++++++++ backend/app/api/routes/zwave.py | 100 ++++++++++ backend/app/core/config.py | 43 ++++ backend/app/core/scheduler.py | 124 ++++++++++++ backend/app/schemas/zigbee.py | 26 +++ backend/app/schemas/zwave.py | 27 +++ backend/tests/test_scheduler.py | 158 +++++++++++++++ backend/tests/test_settings.py | 44 +++++ backend/tests/test_zigbee_router.py | 105 +++++++++- backend/tests/test_zwave_router.py | 105 ++++++++++ frontend/src/api/client.ts | 47 +++++ .../src/components/modals/SettingsModal.tsx | 183 +++++++++++++++++- .../modals/__tests__/SettingsModal.test.tsx | 57 +++++- 14 files changed, 1141 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index a53f833..322a8b5 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,30 @@ MCP_SERVICE_KEY=svc_changeme # PROXMOX_HOST=192.168.1.10 # PROXMOX_PORT=8006 # PROXMOX_VERIFY_TLS=true # set false only for self-signed certs + +# Zigbee2MQTT auto-sync — pull the mesh from an MQTT broker on a schedule. +# MQTT credentials are secrets: kept in memory only, never written to disk by +# the app, never returned by any API. Manual imports (the Zigbee dialog) are +# unaffected — this block only powers Settings → Zigbee auto-sync and /sync-now. +# Required only for auto-sync; one-off imports pass their config in the dialog. +# ZIGBEE_MQTT_HOST=192.168.1.20 +# ZIGBEE_MQTT_PORT=1883 +# ZIGBEE_MQTT_USERNAME=mqttuser # optional +# ZIGBEE_MQTT_PASSWORD=mqttpass # optional +# ZIGBEE_BASE_TOPIC=zigbee2mqtt +# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883) +# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS) +# ZIGBEE_SYNC_ENABLED=false # turn scheduled auto-sync on +# ZIGBEE_SYNC_INTERVAL=3600 # seconds between syncs (min 300) + +# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee. +# ZWAVE_MQTT_HOST=192.168.1.20 +# ZWAVE_MQTT_PORT=1883 +# ZWAVE_MQTT_USERNAME=mqttuser # optional +# ZWAVE_MQTT_PASSWORD=mqttpass # optional +# ZWAVE_PREFIX=zwave +# ZWAVE_GATEWAY_NAME=zwavejs2mqtt +# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883) +# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS) +# ZWAVE_SYNC_ENABLED=false # turn scheduled auto-sync on +# ZWAVE_SYNC_INTERVAL=3600 # seconds between syncs (min 300) diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py index d5d47cb..474666e 100644 --- a/backend/app/api/routes/zigbee.py +++ b/backend/app/api/routes/zigbee.py @@ -10,16 +10,20 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user +from app.core.config import settings +from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled from app.db.database import AsyncSessionLocal, get_db from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun from app.schemas.scan import ScanRunResponse from app.schemas.zigbee import ( + ZigbeeConfig, ZigbeeCoordinatorOut, ZigbeeEdgeOut, ZigbeeImportPendingResponse, ZigbeeImportRequest, ZigbeeImportResponse, ZigbeeNodeOut, + ZigbeeSyncConfig, ZigbeeTestConnectionRequest, ZigbeeTestConnectionResponse, ) @@ -99,6 +103,53 @@ async def import_zigbee_to_pending( return run +def env_import_request() -> ZigbeeImportRequest: + """Build an import request from the server env config (for auto-sync). + + MQTT credentials live in the env only — never in the request body or any + API response. The scheduled auto-sync job and ``/sync-now`` both source + their connection settings here so there is a single source of truth.""" + return ZigbeeImportRequest( + mqtt_host=settings.zigbee_mqtt_host, + mqtt_port=settings.zigbee_mqtt_port, + mqtt_username=settings.zigbee_mqtt_username or None, + mqtt_password=settings.zigbee_mqtt_password or None, + base_topic=settings.zigbee_base_topic, + mqtt_tls=settings.zigbee_mqtt_tls, + mqtt_tls_insecure=settings.zigbee_mqtt_tls_insecure, + ) + + +@router.post("/sync-now", response_model=ScanRunResponse) +async def sync_zigbee_now( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> ScanRun: + """Trigger an immediate Zigbee import using the server env config. + + Same background flow as ``/import-pending`` but sources the MQTT connection + from ``settings`` (env) rather than the request body — the manual + counterpart to the scheduled auto-sync job. Requires the env host to be set. + """ + if not settings.zigbee_mqtt_host: + raise HTTPException( + status_code=400, + detail="Cannot sync: no Zigbee MQTT host configured on the server.", + ) + payload = env_import_request() + run = ScanRun( + status="running", + kind="zigbee", + ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"], + ) + db.add(run) + await db.commit() + await db.refresh(run) + background_tasks.add_task(_background_zigbee_import, run.id, payload) + return run + + async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None: async with AsyncSessionLocal() as db: try: @@ -307,3 +358,50 @@ async def test_zigbee_connection( except Exception: logger.exception("Unexpected error during connection test") return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error") + + +@router.get("/config", response_model=ZigbeeConfig) +async def get_zigbee_config(_: str = Depends(get_current_user)) -> ZigbeeConfig: + """Return non-secret Zigbee config. Never includes MQTT credentials — only + whether a host is configured on the server for auto-sync.""" + return ZigbeeConfig( + mqtt_host=settings.zigbee_mqtt_host, + mqtt_port=settings.zigbee_mqtt_port, + base_topic=settings.zigbee_base_topic, + mqtt_tls=settings.zigbee_mqtt_tls, + sync_enabled=settings.zigbee_sync_enabled, + sync_interval=settings.zigbee_sync_interval, + host_configured=bool(settings.zigbee_mqtt_host), + ) + + +@router.post("/config", response_model=ZigbeeConfig) +async def save_zigbee_config( + payload: ZigbeeSyncConfig, + _: str = Depends(get_current_user), +) -> ZigbeeConfig: + """Persist the auto-sync activation (enabled + interval) and apply it live. + + This is the ONLY Zigbee config the app writes. Connection settings + (host/port/credentials/topic/tls) are env-only and are never accepted or + persisted here — enabling auto-sync requires the MQTT host already set in + the server env, since the scheduled job reads it from there. + """ + if payload.sync_enabled and not settings.zigbee_mqtt_host: + raise HTTPException( + status_code=400, + detail="Cannot enable auto-sync: no Zigbee MQTT host configured in the server env.", + ) + try: + settings.zigbee_sync_enabled = payload.sync_enabled + settings.zigbee_sync_interval = payload.sync_interval + settings.save_overrides() + set_zigbee_sync_enabled(payload.sync_enabled) + if payload.sync_enabled: + reschedule_zigbee_sync(payload.sync_interval) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return await get_zigbee_config() diff --git a/backend/app/api/routes/zwave.py b/backend/app/api/routes/zwave.py index add3e9f..46b874f 100644 --- a/backend/app/api/routes/zwave.py +++ b/backend/app/api/routes/zwave.py @@ -10,16 +10,20 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user +from app.core.config import settings +from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled from app.db.database import AsyncSessionLocal, get_db from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun from app.schemas.scan import ScanRunResponse from app.schemas.zwave import ( + ZwaveConfig, ZwaveCoordinatorOut, ZwaveEdgeOut, ZwaveImportPendingResponse, ZwaveImportRequest, ZwaveImportResponse, ZwaveNodeOut, + ZwaveSyncConfig, ZwaveTestConnectionRequest, ZwaveTestConnectionResponse, ) @@ -94,6 +98,54 @@ async def import_zwave_to_pending( return run +def env_import_request() -> ZwaveImportRequest: + """Build an import request from the server env config (for auto-sync). + + MQTT credentials live in the env only — never in the request body or any + API response. The scheduled auto-sync job and ``/sync-now`` both source + their connection settings here so there is a single source of truth.""" + return ZwaveImportRequest( + mqtt_host=settings.zwave_mqtt_host, + mqtt_port=settings.zwave_mqtt_port, + mqtt_username=settings.zwave_mqtt_username or None, + mqtt_password=settings.zwave_mqtt_password or None, + prefix=settings.zwave_prefix, + gateway_name=settings.zwave_gateway_name, + mqtt_tls=settings.zwave_mqtt_tls, + mqtt_tls_insecure=settings.zwave_mqtt_tls_insecure, + ) + + +@router.post("/sync-now", response_model=ScanRunResponse) +async def sync_zwave_now( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> ScanRun: + """Trigger an immediate Z-Wave import using the server env config. + + Same background flow as ``/import-pending`` but sources the MQTT connection + from ``settings`` (env) rather than the request body — the manual + counterpart to the scheduled auto-sync job. Requires the env host to be set. + """ + if not settings.zwave_mqtt_host: + raise HTTPException( + status_code=400, + detail="Cannot sync: no Z-Wave MQTT host configured on the server.", + ) + payload = env_import_request() + run = ScanRun( + status="running", + kind="zwave", + ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"], + ) + db.add(run) + await db.commit() + await db.refresh(run) + background_tasks.add_task(_background_zwave_import, run.id, payload) + return run + + async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None: async with AsyncSessionLocal() as db: try: @@ -295,3 +347,51 @@ async def test_connection_endpoint( except Exception: logger.exception("Unexpected error during connection test") return ZwaveTestConnectionResponse(connected=False, message="Unexpected error") + + +@router.get("/config", response_model=ZwaveConfig) +async def get_zwave_config(_: str = Depends(get_current_user)) -> ZwaveConfig: + """Return non-secret Z-Wave config. Never includes MQTT credentials — only + whether a host is configured on the server for auto-sync.""" + return ZwaveConfig( + mqtt_host=settings.zwave_mqtt_host, + mqtt_port=settings.zwave_mqtt_port, + prefix=settings.zwave_prefix, + gateway_name=settings.zwave_gateway_name, + mqtt_tls=settings.zwave_mqtt_tls, + sync_enabled=settings.zwave_sync_enabled, + sync_interval=settings.zwave_sync_interval, + host_configured=bool(settings.zwave_mqtt_host), + ) + + +@router.post("/config", response_model=ZwaveConfig) +async def save_zwave_config( + payload: ZwaveSyncConfig, + _: str = Depends(get_current_user), +) -> ZwaveConfig: + """Persist the auto-sync activation (enabled + interval) and apply it live. + + This is the ONLY Z-Wave config the app writes. Connection settings + (host/port/credentials/prefix/gateway/tls) are env-only and are never + accepted or persisted here — enabling auto-sync requires the MQTT host + already set in the server env, since the scheduled job reads it from there. + """ + if payload.sync_enabled and not settings.zwave_mqtt_host: + raise HTTPException( + status_code=400, + detail="Cannot enable auto-sync: no Z-Wave MQTT host configured in the server env.", + ) + try: + settings.zwave_sync_enabled = payload.sync_enabled + settings.zwave_sync_interval = payload.sync_interval + settings.save_overrides() + set_zwave_sync_enabled(payload.sync_enabled) + if payload.sync_enabled: + reschedule_zwave_sync(payload.sync_interval) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return await get_zwave_config() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a092ae7..12b85b7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -93,6 +93,32 @@ class Settings(BaseSettings): proxmox_sync_enabled: bool = False proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write) + # Zigbee2MQTT auto-sync import. + # MQTT credentials are secrets → env/.env ONLY, never persisted by the app to + # scan_config.json and never returned by the API. Only the auto-sync + # activation (enabled + interval) is persisted; connection config is env-only. + zigbee_mqtt_host: str = "" + zigbee_mqtt_port: int = 1883 + zigbee_mqtt_username: str = "" + zigbee_mqtt_password: str = "" + zigbee_base_topic: str = "zigbee2mqtt" + zigbee_mqtt_tls: bool = False + zigbee_mqtt_tls_insecure: bool = False + zigbee_sync_enabled: bool = False + zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write) + + # Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules. + zwave_mqtt_host: str = "" + zwave_mqtt_port: int = 1883 + zwave_mqtt_username: str = "" + zwave_mqtt_password: str = "" + zwave_prefix: str = "zwave" + zwave_gateway_name: str = "zwavejs2mqtt" + zwave_mqtt_tls: bool = False + zwave_mqtt_tls_insecure: bool = False + zwave_sync_enabled: bool = False + zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write) + def _override_path(self) -> Path: return Path(self.sqlite_path).parent / "scan_config.json" @@ -129,6 +155,17 @@ class Settings(BaseSettings): self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"]) if "proxmox_sync_interval" in data: self.proxmox_sync_interval = int(data["proxmox_sync_interval"]) + # Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT + # connection config (host, port, credentials, topic, tls) is env-only + # by design — never read from or written to this file. + if "zigbee_sync_enabled" in data: + self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"]) + if "zigbee_sync_interval" in data: + self.zigbee_sync_interval = int(data["zigbee_sync_interval"]) + if "zwave_sync_enabled" in data: + self.zwave_sync_enabled = bool(data["zwave_sync_enabled"]) + if "zwave_sync_interval" in data: + self.zwave_sync_interval = int(data["zwave_sync_interval"]) except Exception: pass @@ -148,6 +185,12 @@ class Settings(BaseSettings): # be written to disk — that is the single source of truth. "proxmox_sync_enabled": self.proxmox_sync_enabled, "proxmox_sync_interval": self.proxmox_sync_interval, + # Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT + # connection config (host/port/credentials/topic/tls) is env-only. + "zigbee_sync_enabled": self.zigbee_sync_enabled, + "zigbee_sync_interval": self.zigbee_sync_interval, + "zwave_sync_enabled": self.zwave_sync_enabled, + "zwave_sync_interval": self.zwave_sync_interval, })) diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index 55e028e..9d5d8b6 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -144,6 +144,54 @@ async def _run_proxmox_sync() -> None: ) +async def _run_mesh_sync(kind: str) -> None: + """Shared auto-sync for the MQTT mesh imports (Zigbee / Z-Wave). + + Records a ScanRun (kind=zigbee|zwave) so the scheduled sync shows in Scan + history, then delegates to the exact same background import the manual + /sync-now and /import-pending paths use. Connection config comes from the + server env only (credentials never leave it). + """ + from app.db.models import ScanRun + + if kind == "zigbee": + if not settings.zigbee_sync_enabled: + return + if not settings.zigbee_mqtt_host: + logger.warning("Zigbee auto-sync enabled but MQTT host not configured — skipping") + return + from app.api.routes.zigbee import _background_zigbee_import, env_import_request + host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port + background = _background_zigbee_import + else: + if not settings.zwave_sync_enabled: + return + if not settings.zwave_mqtt_host: + logger.warning("Z-Wave auto-sync enabled but MQTT host not configured — skipping") + return + from app.api.routes.zwave import _background_zwave_import, env_import_request + host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port + background = _background_zwave_import + + payload = env_import_request() + async with AsyncSessionLocal() as db: + run = ScanRun(status="running", kind=kind, ranges=[f"{host}:{port}"]) + db.add(run) + await db.commit() + await db.refresh(run) + run_id = run.id + + await background(run_id, payload) + + +async def _run_zigbee_sync() -> None: + await _run_mesh_sync("zigbee") + + +async def _run_zwave_sync() -> None: + await _run_mesh_sync("zwave") + + def _add_service_check_job() -> None: scheduler.add_job( _run_service_checks, @@ -166,6 +214,28 @@ def _add_proxmox_sync_job() -> None: ) +def _add_zigbee_sync_job() -> None: + scheduler.add_job( + _run_zigbee_sync, + "interval", + seconds=settings.zigbee_sync_interval, + id="zigbee_sync", + max_instances=1, + coalesce=True, + ) + + +def _add_zwave_sync_job() -> None: + scheduler.add_job( + _run_zwave_sync, + "interval", + seconds=settings.zwave_sync_interval, + id="zwave_sync", + max_instances=1, + coalesce=True, + ) + + def start_scheduler() -> None: global scheduler if scheduler.running: @@ -186,6 +256,10 @@ def start_scheduler() -> None: _add_service_check_job() if settings.proxmox_sync_enabled: _add_proxmox_sync_job() + if settings.zigbee_sync_enabled: + _add_zigbee_sync_job() + if settings.zwave_sync_enabled: + _add_zwave_sync_job() scheduler.start() logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) @@ -251,6 +325,56 @@ def set_proxmox_sync_enabled(enabled: bool) -> None: logger.info("Proxmox auto-sync disabled") +def reschedule_zigbee_sync(interval_seconds: int) -> None: + """Update the Zigbee auto-sync interval on the running scheduler (if enabled).""" + if interval_seconds < 300: + raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}") + if not scheduler.running: + logger.warning("Scheduler not running, skipping reschedule") + return + if scheduler.get_job("zigbee_sync"): + scheduler.reschedule_job("zigbee_sync", trigger="interval", seconds=interval_seconds) + logger.info("Zigbee auto-sync rescheduled to every %ds", interval_seconds) + + +def set_zigbee_sync_enabled(enabled: bool) -> None: + """Add or remove the Zigbee auto-sync job on the running scheduler.""" + if not scheduler.running: + return + job = scheduler.get_job("zigbee_sync") + if enabled and not job: + _add_zigbee_sync_job() + logger.info("Zigbee auto-sync enabled — every %ds", settings.zigbee_sync_interval) + elif not enabled and job: + scheduler.remove_job("zigbee_sync") + logger.info("Zigbee auto-sync disabled") + + +def reschedule_zwave_sync(interval_seconds: int) -> None: + """Update the Z-Wave auto-sync interval on the running scheduler (if enabled).""" + if interval_seconds < 300: + raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}") + if not scheduler.running: + logger.warning("Scheduler not running, skipping reschedule") + return + if scheduler.get_job("zwave_sync"): + scheduler.reschedule_job("zwave_sync", trigger="interval", seconds=interval_seconds) + logger.info("Z-Wave auto-sync rescheduled to every %ds", interval_seconds) + + +def set_zwave_sync_enabled(enabled: bool) -> None: + """Add or remove the Z-Wave auto-sync job on the running scheduler.""" + if not scheduler.running: + return + job = scheduler.get_job("zwave_sync") + if enabled and not job: + _add_zwave_sync_job() + logger.info("Z-Wave auto-sync enabled — every %ds", settings.zwave_sync_interval) + elif not enabled and job: + scheduler.remove_job("zwave_sync") + logger.info("Z-Wave auto-sync disabled") + + def stop_scheduler() -> None: if scheduler.running: scheduler.shutdown(wait=False) diff --git a/backend/app/schemas/zigbee.py b/backend/app/schemas/zigbee.py index fe5a270..9ff4f38 100644 --- a/backend/app/schemas/zigbee.py +++ b/backend/app/schemas/zigbee.py @@ -93,3 +93,29 @@ class ZigbeeImportPendingResponse(BaseModel): coordinator_already_existed: bool = False links_recorded: int device_count: int + + +class ZigbeeConfig(BaseModel): + """Non-secret Zigbee connection + auto-sync config (GET response). + + MQTT connection fields (host/port/base_topic/tls) are env-only and + read-only here — surfaced for display. ``host_configured`` reflects whether + a server-side MQTT host is set (required for auto-sync). MQTT credentials + (username/password) are never carried.""" + + mqtt_host: str = "" + mqtt_port: int = Field(1883, ge=1, le=65535) + base_topic: str = "zigbee2mqtt" + mqtt_tls: bool = False + sync_enabled: bool = False + sync_interval: int = Field(3600, ge=300) + host_configured: bool = False + + +class ZigbeeSyncConfig(BaseModel): + """User-editable auto-sync config (POST body). The ONLY persisted Zigbee + settings. Connection fields (host/port/credentials/topic/tls) are env-only + and are deliberately not accepted here.""" + + sync_enabled: bool = False + sync_interval: int = Field(3600, ge=300) diff --git a/backend/app/schemas/zwave.py b/backend/app/schemas/zwave.py index ec6a0ed..34d1b0b 100644 --- a/backend/app/schemas/zwave.py +++ b/backend/app/schemas/zwave.py @@ -83,3 +83,30 @@ class ZwaveImportPendingResponse(BaseModel): coordinator_already_existed: bool = False links_recorded: int device_count: int + + +class ZwaveConfig(BaseModel): + """Non-secret Z-Wave connection + auto-sync config (GET response). + + MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and + read-only here — surfaced for display. ``host_configured`` reflects whether + a server-side MQTT host is set (required for auto-sync). MQTT credentials + (username/password) are never carried.""" + + mqtt_host: str = "" + mqtt_port: int = Field(1883, ge=1, le=65535) + prefix: str = "zwave" + gateway_name: str = "zwavejs2mqtt" + mqtt_tls: bool = False + sync_enabled: bool = False + sync_interval: int = Field(3600, ge=300) + host_configured: bool = False + + +class ZwaveSyncConfig(BaseModel): + """User-editable auto-sync config (POST body). The ONLY persisted Z-Wave + settings. Connection fields (host/port/credentials/prefix/gateway/tls) are + env-only and are deliberately not accepted here.""" + + sync_enabled: bool = False + sync_interval: int = Field(3600, ge=300) diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index f6ce7c2..4cc18a3 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -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(): diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index d2951ed..963b321 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -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 diff --git a/backend/tests/test_zigbee_router.py b/backend/tests/test_zigbee_router.py index 57657c1..49facb0 100644 --- a/backend/tests/test_zigbee_router.py +++ b/backend/tests/test_zigbee_router.py @@ -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 diff --git a/backend/tests/test_zwave_router.py b/backend/tests/test_zwave_router.py index 2d9b81b..506dd55 100644 --- a/backend/tests/test_zwave_router.py +++ b/backend/tests/test_zwave_router.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2486b85..d792c0d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -211,6 +211,39 @@ export const designsApi = { delete: (id: string) => api.delete(`/designs/${id}`), } +export interface ZigbeeConfigData { + mqtt_host: string + mqtt_port: number + base_topic: string + mqtt_tls: boolean + sync_enabled: boolean + sync_interval: number + host_configured: boolean +} + +export interface ZwaveConfigData { + mqtt_host: string + mqtt_port: number + prefix: string + gateway_name: string + mqtt_tls: boolean + sync_enabled: boolean + sync_interval: number + host_configured: boolean +} + +// Shape returned by every background-scan trigger (import-pending / sync-now). +interface ScanRunResult { + id: string + status: string + kind: string + ranges: string[] + devices_found: number + started_at: string + finished_at: string | null + error: string | null +} + export const zigbeeApi = { testConnection: (data: { mqtt_host: string @@ -256,6 +289,13 @@ export const zigbeeApi = { finished_at: string | null error: string | null }>('/zigbee/import-pending', data), + + getConfig: () => api.get('/zigbee/config'), + // Only the auto-sync activation is persisted. MQTT connection config + // (host/port/credentials/topic/tls) is env-only and never sent. + saveConfig: (data: { sync_enabled: boolean; sync_interval: number }) => + api.post('/zigbee/config', data), + syncNow: () => api.post('/zigbee/sync-now'), } export const zwaveApi = { @@ -305,4 +345,11 @@ export const zwaveApi = { finished_at: string | null error: string | null }>('/zwave/import-pending', data), + + getConfig: () => api.get('/zwave/config'), + // Only the auto-sync activation is persisted. MQTT connection config + // (host/port/credentials/prefix/gateway/tls) is env-only and never sent. + saveConfig: (data: { sync_enabled: boolean; sync_interval: number }) => + api.post('/zwave/config', data), + syncNow: () => api.post('/zwave/sync-now'), } diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx index 541a292..448f3b9 100644 --- a/frontend/src/components/modals/SettingsModal.tsx +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -1,7 +1,15 @@ import { useState, useEffect } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { settingsApi, proxmoxApi, type ProxmoxConfigData } from '@/api/client' +import { + settingsApi, + proxmoxApi, + zigbeeApi, + zwaveApi, + type ProxmoxConfigData, + type ZigbeeConfigData, + type ZwaveConfigData, +} from '@/api/client' import { useCanvasStore } from '@/stores/canvasStore' import { toast } from 'sonner' import { @@ -18,6 +26,86 @@ interface SettingsModalProps { onClose: () => void } +interface MeshAutoSyncProps { + title: string + accent: string + hostConfigured: boolean + envHostVar: string + enabled: boolean + onEnabledChange: (v: boolean) => void + interval: number + onIntervalChange: (v: number) => void + description: string + syncing: boolean + onSyncNow: () => void +} + +/** + * Auto-sync controls for an MQTT mesh import (Zigbee / Z-Wave). Mirrors the + * Proxmox auto-sync block: connection config is env-only, so this only toggles + * the scheduled activation + interval and offers an immediate re-sync. When no + * MQTT host is set in the server env, it shows how to configure one instead. + */ +function MeshAutoSync({ + title, accent, hostConfigured, envHostVar, enabled, onEnabledChange, + interval, onIntervalChange, description, syncing, onSyncNow, +}: MeshAutoSyncProps) { + return ( +
+ {title} + {!hostConfigured ? ( +

+ No MQTT host configured. Set {envHostVar} in the server .env to enable auto-sync. +

+ ) : ( + <> + +
+ +
+ { const v = Number(e.target.value); if (!isNaN(v)) onIntervalChange(v) }} + className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none" + aria-label={`${title} interval`} + /> + seconds +
+

{description}

+
+
+ + + Runs one import immediately using the server .env config. + +
+ + )} +
+ ) +} + export function SettingsModal({ open, onClose }: SettingsModalProps) { const [interval, setIntervalValue] = useState(60) const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false) @@ -27,6 +115,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { const [pmSyncEnabled, setPmSyncEnabled] = useState(false) const [pmInterval, setPmInterval] = useState(3600) const [pmSyncing, setPmSyncing] = useState(false) + const [zbConfig, setZbConfig] = useState(null) + const [zbSyncEnabled, setZbSyncEnabled] = useState(false) + const [zbInterval, setZbInterval] = useState(3600) + const [zbSyncing, setZbSyncing] = useState(false) + const [zwConfig, setZwConfig] = useState(null) + const [zwSyncEnabled, setZwSyncEnabled] = useState(false) + const [zwInterval, setZwInterval] = useState(3600) + const [zwSyncing, setZwSyncing] = useState(false) const [alignment, setAlignment] = useState(readAlignmentSettings) const hideIp = useCanvasStore((s) => s.hideIp) const setHideIp = useCanvasStore((s) => s.setHideIp) @@ -47,6 +143,20 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { setPmInterval(res.data.sync_interval) }) .catch(() => {/* proxmox not configured */}) + zigbeeApi.getConfig() + .then((res) => { + setZbConfig(res.data) + setZbSyncEnabled(res.data.sync_enabled) + setZbInterval(res.data.sync_interval) + }) + .catch(() => {/* zigbee not configured */}) + zwaveApi.getConfig() + .then((res) => { + setZwConfig(res.data) + setZwSyncEnabled(res.data.sync_enabled) + setZwInterval(res.data.sync_interval) + }) + .catch(() => {/* zwave not configured */}) }, [open]) useEffect(() => subscribeAlignmentSettings(setAlignment), []) @@ -69,6 +179,30 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { } } + const handleZbSyncNow = async () => { + setZbSyncing(true) + try { + await zigbeeApi.syncNow() + toast.success('Zigbee sync started') + } catch { + toast.error('Failed to start Zigbee sync') + } finally { + setZbSyncing(false) + } + } + + const handleZwSyncNow = async () => { + setZwSyncing(true) + try { + await zwaveApi.syncNow() + toast.success('Z-Wave sync started') + } catch { + toast.error('Failed to start Z-Wave sync') + } finally { + setZwSyncing(false) + } + } + const handleSave = async () => { // Canvas prefs (alignment, hide-IP) persist on change; only the backend // status-check interval needs an API round-trip. @@ -91,6 +225,19 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { sync_interval: pmInterval, }) } + if (zbConfig) { + // MQTT connection config is env-only; only the activation is persisted. + await zigbeeApi.saveConfig({ + sync_enabled: zbSyncEnabled, + sync_interval: zbInterval, + }) + } + if (zwConfig) { + await zwaveApi.saveConfig({ + sync_enabled: zwSyncEnabled, + sync_interval: zwInterval, + }) + } toast.success('Settings saved') onClose() } catch { @@ -222,6 +369,40 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { )} + {/* Zigbee auto-sync */} + {!STANDALONE && zbConfig && ( + + )} + + {/* Z-Wave auto-sync */} + {!STANDALONE && zwConfig && ( + + )} + {/* Canvas */}
Canvas diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx index c4d7082..41f34fb 100644 --- a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx +++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx @@ -13,9 +13,19 @@ vi.mock('@/api/client', () => ({ saveConfig: vi.fn(), syncNow: vi.fn(), }, + zigbeeApi: { + getConfig: vi.fn(), + saveConfig: vi.fn(), + syncNow: vi.fn(), + }, + zwaveApi: { + getConfig: vi.fn(), + saveConfig: vi.fn(), + syncNow: vi.fn(), + }, })) -import { settingsApi, proxmoxApi } from '@/api/client' +import { settingsApi, proxmoxApi, zigbeeApi, zwaveApi } from '@/api/client' import { toast } from 'sonner' import { useCanvasStore } from '@/stores/canvasStore' @@ -26,10 +36,25 @@ describe('SettingsModal', () => { vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never) vi.mocked(proxmoxApi.getConfig).mockRejectedValue(new Error('not configured')) vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never) + // Zigbee/Z-Wave default to "not configured" so the mesh sections stay hidden + // unless a test opts in — keeps the single Proxmox "Re-sync now" unambiguous. + vi.mocked(zigbeeApi.getConfig).mockRejectedValue(new Error('not configured')) + vi.mocked(zigbeeApi.saveConfig).mockResolvedValue({ data: {} } as never) + vi.mocked(zigbeeApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never) + vi.mocked(zwaveApi.getConfig).mockRejectedValue(new Error('not configured')) + vi.mocked(zwaveApi.saveConfig).mockResolvedValue({ data: {} } as never) + vi.mocked(zwaveApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never) vi.mocked(toast.success).mockReset() vi.mocked(toast.error).mockReset() }) + const zbConfig = (over = {}) => ({ + data: { mqtt_host: 'broker', mqtt_port: 1883, base_topic: 'zigbee2mqtt', mqtt_tls: false, sync_enabled: false, sync_interval: 3600, host_configured: true, ...over }, + }) + const zwConfig = (over = {}) => ({ + data: { mqtt_host: 'broker', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt', mqtt_tls: false, sync_enabled: false, sync_interval: 3600, host_configured: true, ...over }, + }) + it('loads interval from API when opened', async () => { render() await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce()) @@ -144,6 +169,36 @@ describe('SettingsModal', () => { expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull() }) + it('persists only Zigbee sync fields (not connection config) on Save', async () => { + vi.mocked(zigbeeApi.getConfig).mockResolvedValue(zbConfig({ sync_enabled: true, sync_interval: 1800 }) as never) + render() + await screen.findByDisplayValue('60') + await screen.findByText('Zigbee auto-sync') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => { + expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 }) + }) + }) + + it('triggers an immediate Z-Wave sync from its Re-sync now button', async () => { + vi.mocked(zwaveApi.getConfig).mockResolvedValue(zwConfig() as never) + render() + await screen.findByText('Z-Wave auto-sync') + const btn = await screen.findByRole('button', { name: 'Re-sync now' }) + fireEvent.click(btn) + await waitFor(() => { + expect(zwaveApi.syncNow).toHaveBeenCalledOnce() + expect(toast.success).toHaveBeenCalledWith('Z-Wave sync started') + }) + }) + + it('shows an env-var hint instead of the section controls when mesh host is unset', async () => { + vi.mocked(zigbeeApi.getConfig).mockResolvedValue(zbConfig({ host_configured: false, mqtt_host: '' }) as never) + render() + await screen.findByText('ZIGBEE_MQTT_HOST') + expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull() + }) + it('calls onClose on Cancel', async () => { const onClose = vi.fn() render()