Merge pull request #270 from Pouzor/feat/zigbee-zwave-autosync

feat: scheduled auto-sync for Zigbee & Z-Wave imports
This commit is contained in:
Pouzor - Rémy Jardient
2026-07-10 16:20:02 +02:00
committed by GitHub
14 changed files with 1202 additions and 51 deletions
+23
View File
@@ -58,3 +58,26 @@ MCP_SERVICE_KEY=svc_changeme
# PROXMOX_HOST=192.168.1.10 # PROXMOX_HOST=192.168.1.10
# PROXMOX_PORT=8006 # PROXMOX_PORT=8006
# PROXMOX_VERIFY_TLS=true # set false only for self-signed certs # 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)
# 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)
+98
View File
@@ -10,16 +10,20 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user 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.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import ( from app.schemas.zigbee import (
ZigbeeConfig,
ZigbeeCoordinatorOut, ZigbeeCoordinatorOut,
ZigbeeEdgeOut, ZigbeeEdgeOut,
ZigbeeImportPendingResponse, ZigbeeImportPendingResponse,
ZigbeeImportRequest, ZigbeeImportRequest,
ZigbeeImportResponse, ZigbeeImportResponse,
ZigbeeNodeOut, ZigbeeNodeOut,
ZigbeeSyncConfig,
ZigbeeTestConnectionRequest, ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse, ZigbeeTestConnectionResponse,
) )
@@ -99,6 +103,53 @@ async def import_zigbee_to_pending(
return run 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 def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
@@ -307,3 +358,50 @@ async def test_zigbee_connection(
except Exception: except Exception:
logger.exception("Unexpected error during connection test") logger.exception("Unexpected error during connection test")
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error") 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()
+100
View File
@@ -10,16 +10,20 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user 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.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse from app.schemas.scan import ScanRunResponse
from app.schemas.zwave import ( from app.schemas.zwave import (
ZwaveConfig,
ZwaveCoordinatorOut, ZwaveCoordinatorOut,
ZwaveEdgeOut, ZwaveEdgeOut,
ZwaveImportPendingResponse, ZwaveImportPendingResponse,
ZwaveImportRequest, ZwaveImportRequest,
ZwaveImportResponse, ZwaveImportResponse,
ZwaveNodeOut, ZwaveNodeOut,
ZwaveSyncConfig,
ZwaveTestConnectionRequest, ZwaveTestConnectionRequest,
ZwaveTestConnectionResponse, ZwaveTestConnectionResponse,
) )
@@ -94,6 +98,54 @@ async def import_zwave_to_pending(
return run 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 def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
@@ -295,3 +347,51 @@ async def test_connection_endpoint(
except Exception: except Exception:
logger.exception("Unexpected error during connection test") logger.exception("Unexpected error during connection test")
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error") 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()
+43
View File
@@ -93,6 +93,32 @@ class Settings(BaseSettings):
proxmox_sync_enabled: bool = False proxmox_sync_enabled: bool = False
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write) 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: def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json" 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"]) self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
if "proxmox_sync_interval" in data: if "proxmox_sync_interval" in data:
self.proxmox_sync_interval = int(data["proxmox_sync_interval"]) 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: except Exception:
pass pass
@@ -148,6 +185,12 @@ class Settings(BaseSettings):
# be written to disk — that is the single source of truth. # be written to disk — that is the single source of truth.
"proxmox_sync_enabled": self.proxmox_sync_enabled, "proxmox_sync_enabled": self.proxmox_sync_enabled,
"proxmox_sync_interval": self.proxmox_sync_interval, "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,
})) }))
+135
View File
@@ -1,7 +1,9 @@
"""APScheduler setup for background scan and status check jobs.""" """APScheduler setup for background scan and status check jobs."""
import asyncio import asyncio
import logging import logging
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select from sqlalchemy import select
@@ -11,6 +13,10 @@ from app.db.database import AsyncSessionLocal
from app.db.models import Node from app.db.models import Node
from app.services.status_checker import check_node, check_services from app.services.status_checker import check_node, check_services
if TYPE_CHECKING:
from app.schemas.zigbee import ZigbeeImportRequest
from app.schemas.zwave import ZwaveImportRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler() scheduler: AsyncIOScheduler = AsyncIOScheduler()
@@ -144,6 +150,59 @@ 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
payload: ZigbeeImportRequest | ZwaveImportRequest
background: Callable[[str, Any], Awaitable[None]]
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
from app.api.routes.zigbee import env_import_request as _zigbee_env_request
host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port
payload = _zigbee_env_request()
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
from app.api.routes.zwave import env_import_request as _zwave_env_request
host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port
payload = _zwave_env_request()
background = _background_zwave_import
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: def _add_service_check_job() -> None:
scheduler.add_job( scheduler.add_job(
_run_service_checks, _run_service_checks,
@@ -166,6 +225,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: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running: if scheduler.running:
@@ -186,6 +267,10 @@ def start_scheduler() -> None:
_add_service_check_job() _add_service_check_job()
if settings.proxmox_sync_enabled: if settings.proxmox_sync_enabled:
_add_proxmox_sync_job() _add_proxmox_sync_job()
if settings.zigbee_sync_enabled:
_add_zigbee_sync_job()
if settings.zwave_sync_enabled:
_add_zwave_sync_job()
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
@@ -251,6 +336,56 @@ def set_proxmox_sync_enabled(enabled: bool) -> None:
logger.info("Proxmox auto-sync disabled") 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: def stop_scheduler() -> None:
if scheduler.running: if scheduler.running:
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
+26
View File
@@ -93,3 +93,29 @@ class ZigbeeImportPendingResponse(BaseModel):
coordinator_already_existed: bool = False coordinator_already_existed: bool = False
links_recorded: int links_recorded: int
device_count: 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)
+27
View File
@@ -83,3 +83,30 @@ class ZwaveImportPendingResponse(BaseModel):
coordinator_already_existed: bool = False coordinator_already_existed: bool = False
links_recorded: int links_recorded: int
device_count: 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)
+158
View File
@@ -9,11 +9,17 @@ from app.core.scheduler import (
_run_proxmox_sync, _run_proxmox_sync,
_run_service_checks, _run_service_checks,
_run_status_checks, _run_status_checks,
_run_zigbee_sync,
_run_zwave_sync,
reschedule_proxmox_sync, reschedule_proxmox_sync,
reschedule_service_checks, reschedule_service_checks,
reschedule_status_checks, reschedule_status_checks,
reschedule_zigbee_sync,
reschedule_zwave_sync,
set_proxmox_sync_enabled, set_proxmox_sync_enabled,
set_service_checks_enabled, set_service_checks_enabled,
set_zigbee_sync_enabled,
set_zwave_sync_enabled,
start_scheduler, start_scheduler,
stop_scheduler, stop_scheduler,
) )
@@ -154,6 +160,8 @@ def test_scheduler_uses_settings_interval():
mock_settings.status_checker_interval = 45 mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler() start_scheduler()
_, kwargs = mock_sched.add_job.call_args _, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45 assert kwargs["seconds"] == 45
@@ -167,6 +175,8 @@ def test_start_and_stop_scheduler():
mock_settings.status_checker_interval = 60 mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler() start_scheduler()
stop_scheduler() stop_scheduler()
mock_sched.add_job.assert_called_once() 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_enabled = True
mock_settings.service_check_interval = 300 mock_settings.service_check_interval = 300
mock_settings.proxmox_sync_enabled = False mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler() start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list] job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids 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 --- # --- reschedule_* validation and not-running guards ---
def test_reschedule_status_checks_rejects_short_interval(): 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 "proxmox_token_secret" not in written
assert written["proxmox_sync_enabled"] is True assert written["proxmox_sync_enabled"] is True
assert written["proxmox_sync_interval"] == 900 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 __future__ import annotations
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from httpx import AsyncClient 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 kwargs = mock_conn.call_args.kwargs
assert kwargs["tls"] is True assert kwargs["tls"] is True
assert kwargs["tls_insecure"] 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: for n in nodes:
model = {p["key"]: p["value"] for p in n.properties}.get("Model") model = {p["key"]: p["value"] for p in n.properties}.get("Model")
assert model == "ZW200" # refreshed on every canvas 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
+47
View File
@@ -211,6 +211,39 @@ export const designsApi = {
delete: (id: string) => api.delete(`/designs/${id}`), 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 = { export const zigbeeApi = {
testConnection: (data: { testConnection: (data: {
mqtt_host: string mqtt_host: string
@@ -256,6 +289,13 @@ export const zigbeeApi = {
finished_at: string | null finished_at: string | null
error: string | null error: string | null
}>('/zigbee/import-pending', data), }>('/zigbee/import-pending', data),
getConfig: () => api.get<ZigbeeConfigData>('/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<ZigbeeConfigData>('/zigbee/config', data),
syncNow: () => api.post<ScanRunResult>('/zigbee/sync-now'),
} }
export const zwaveApi = { export const zwaveApi = {
@@ -305,4 +345,11 @@ export const zwaveApi = {
finished_at: string | null finished_at: string | null
error: string | null error: string | null
}>('/zwave/import-pending', data), }>('/zwave/import-pending', data),
getConfig: () => api.get<ZwaveConfigData>('/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<ZwaveConfigData>('/zwave/config', data),
syncNow: () => api.post<ScanRunResult>('/zwave/sync-now'),
} }
+236 -49
View File
@@ -1,7 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' 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 { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -18,6 +26,86 @@ interface SettingsModalProps {
onClose: () => void 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 (
<div className="pt-3 border-t border-border space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{title}</span>
{!hostConfigured ? (
<p className="text-[10px] text-[#e3b341] leading-tight">
No MQTT host configured. Set <span className="font-mono">{envHostVar}</span> in the server .env to enable auto-sync.
</p>
) : (
<>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Auto-sync {title.replace(' auto-sync', '')} inventory</span>
<input
type="checkbox"
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
className="cursor-pointer"
style={{ accentColor: accent }}
aria-label={`Toggle ${title}`}
/>
</label>
<div className={enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Sync interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={300}
max={86400}
value={interval}
onChange={(e) => { 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`}
/>
<span className="text-xs text-muted-foreground">seconds</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">{description}</p>
</div>
<div className="flex items-center gap-2 pt-1">
<Button
variant="outline"
onClick={onSyncNow}
disabled={syncing}
className="h-7 text-xs"
style={{ borderColor: accent, color: accent }}
>
{syncing ? 'Syncing…' : 'Re-sync now'}
</Button>
<span className="text-[10px] text-muted-foreground leading-tight">
Runs one import immediately using the server .env config.
</span>
</div>
</>
)}
</div>
)
}
export function SettingsModal({ open, onClose }: SettingsModalProps) { export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [interval, setIntervalValue] = useState(60) const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false) const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
@@ -27,6 +115,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [pmSyncEnabled, setPmSyncEnabled] = useState(false) const [pmSyncEnabled, setPmSyncEnabled] = useState(false)
const [pmInterval, setPmInterval] = useState(3600) const [pmInterval, setPmInterval] = useState(3600)
const [pmSyncing, setPmSyncing] = useState(false) const [pmSyncing, setPmSyncing] = useState(false)
const [zbConfig, setZbConfig] = useState<ZigbeeConfigData | null>(null)
const [zbSyncEnabled, setZbSyncEnabled] = useState(false)
const [zbInterval, setZbInterval] = useState(3600)
const [zbSyncing, setZbSyncing] = useState(false)
const [zwConfig, setZwConfig] = useState<ZwaveConfigData | null>(null)
const [zwSyncEnabled, setZwSyncEnabled] = useState(false)
const [zwInterval, setZwInterval] = useState(3600)
const [zwSyncing, setZwSyncing] = useState(false)
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings) const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
const setHideIp = useCanvasStore((s) => s.setHideIp) const setHideIp = useCanvasStore((s) => s.setHideIp)
@@ -47,6 +143,20 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
setPmInterval(res.data.sync_interval) setPmInterval(res.data.sync_interval)
}) })
.catch(() => {/* proxmox not configured */}) .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]) }, [open])
useEffect(() => subscribeAlignmentSettings(setAlignment), []) 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 () => { const handleSave = async () => {
// Canvas prefs (alignment, hide-IP) persist on change; only the backend // Canvas prefs (alignment, hide-IP) persist on change; only the backend
// status-check interval needs an API round-trip. // status-check interval needs an API round-trip.
@@ -91,6 +225,19 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
sync_interval: pmInterval, 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') toast.success('Settings saved')
onClose() onClose()
} catch { } catch {
@@ -102,12 +249,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-md"> <DialogContent className="bg-[#161b22] border-border max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-foreground">Settings</DialogTitle> <DialogTitle className="text-foreground">Settings</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-5 py-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5 py-2">
{/* Left column */}
<div className="space-y-5">
{/* Status checker */} {/* Status checker */}
{!STANDALONE && ( {!STANDALONE && (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -159,6 +308,90 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
</div> </div>
)} )}
{/* Canvas */}
<div className="pt-3 border-t border-border space-y-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Snap to nodes</span>
<input
type="checkbox"
checked={alignment.enabled}
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle alignment guides"
/>
</label>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Hide IP addresses</span>
<input
type="checkbox"
checked={hideIp}
onChange={(e) => setHideIp(e.target.checked)}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle IP address masking"
/>
</label>
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Snap distance</label>
<div className="flex items-center gap-2">
<input
type="range"
min={2}
max={16}
step={1}
value={alignment.threshold}
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
className="flex-1 cursor-pointer accent-[#00d4ff]"
aria-label="Alignment snap threshold"
/>
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
</p>
</div>
</div>
</div>
{/* Right column */}
<div className="space-y-5">
{/* Zigbee auto-sync */}
{!STANDALONE && zbConfig && (
<MeshAutoSync
title="Zigbee auto-sync"
accent="#39d353"
hostConfigured={zbConfig.host_configured}
envHostVar="ZIGBEE_MQTT_HOST"
enabled={zbSyncEnabled}
onEnabledChange={setZbSyncEnabled}
interval={zbInterval}
onIntervalChange={setZbInterval}
description="Re-imports the Zigbee mesh into the pending inventory. Min 300s (5 min)."
syncing={zbSyncing}
onSyncNow={handleZbSyncNow}
/>
)}
{/* Z-Wave auto-sync */}
{!STANDALONE && zwConfig && (
<MeshAutoSync
title="Z-Wave auto-sync"
accent="#a855f7"
hostConfigured={zwConfig.host_configured}
envHostVar="ZWAVE_MQTT_HOST"
enabled={zwSyncEnabled}
onEnabledChange={setZwSyncEnabled}
interval={zwInterval}
onIntervalChange={setZwInterval}
description="Re-imports the Z-Wave network into the pending inventory. Min 300s (5 min)."
syncing={zwSyncing}
onSyncNow={handleZwSyncNow}
/>
)}
{/* Proxmox auto-sync */} {/* Proxmox auto-sync */}
{!STANDALONE && pmConfig && ( {!STANDALONE && pmConfig && (
<div className="pt-3 border-t border-border space-y-2"> <div className="pt-3 border-t border-border space-y-2">
@@ -221,52 +454,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
)} )}
</div> </div>
)} )}
{/* Canvas */}
<div className="pt-3 border-t border-border space-y-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Snap to nodes</span>
<input
type="checkbox"
checked={alignment.enabled}
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle alignment guides"
/>
</label>
<label className="flex items-center justify-between gap-2 cursor-pointer">
<span className="text-xs text-foreground">Hide IP addresses</span>
<input
type="checkbox"
checked={hideIp}
onChange={(e) => setHideIp(e.target.checked)}
className="cursor-pointer accent-[#00d4ff]"
aria-label="Toggle IP address masking"
/>
</label>
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
<label className="text-xs text-muted-foreground">Snap distance</label>
<div className="flex items-center gap-2">
<input
type="range"
min={2}
max={16}
step={1}
value={alignment.threshold}
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
className="flex-1 cursor-pointer accent-[#00d4ff]"
aria-label="Alignment snap threshold"
/>
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
</p>
</div>
</div> </div>
</div> </div>
@@ -13,9 +13,19 @@ vi.mock('@/api/client', () => ({
saveConfig: vi.fn(), saveConfig: vi.fn(),
syncNow: 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 { toast } from 'sonner'
import { useCanvasStore } from '@/stores/canvasStore' 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(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.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never) 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.success).mockReset()
vi.mocked(toast.error).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 () => { it('loads interval from API when opened', async () => {
render(<SettingsModal open onClose={vi.fn()} />) render(<SettingsModal open onClose={vi.fn()} />)
await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce()) await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce())
@@ -144,6 +169,36 @@ describe('SettingsModal', () => {
expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull() 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(<SettingsModal open onClose={vi.fn()} />)
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(<SettingsModal open onClose={vi.fn()} />)
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(<SettingsModal open onClose={vi.fn()} />)
await screen.findByText('ZIGBEE_MQTT_HOST')
expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull()
})
it('calls onClose on Cancel', async () => { it('calls onClose on Cancel', async () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />) render(<SettingsModal open onClose={onClose} />)