feat: scheduled auto-sync for Zigbee & Z-Wave imports
Mirror the Proxmox auto-sync pattern for the Zigbee2MQTT and Z-Wave JS UI mesh imports. Connection config + MQTT credentials live in .env only, are never persisted to scan_config.json, and are never returned by any API or shown in the UI (single source of truth). - config: ZIGBEE_* / ZWAVE_* env settings; only sync_enabled+interval are persisted, connection/credentials stay env-only - routes: GET/POST /config, POST /sync-now; auto-sync reuses the exact manual _background_*_import + _persist_pending_import path (fresh import when empty, update-in-place when nodes exist, ScanRun trace) - scheduler: zigbee_sync / zwave_sync jobs with live enable + reschedule - frontend: reusable MeshAutoSync section in Settings (Zigbee, Z-Wave) - .env.example: documented both blocks - tests: scheduler jobs, router config/sync-now/auth, credential-never- persisted, SettingsModal sections Manual Zigbee/Z-Wave import behaviour is unchanged. ha-relevant: no
This commit is contained in:
@@ -58,3 +58,30 @@ 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)
|
||||||
|
# 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
def _add_service_check_job() -> None:
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_service_checks,
|
_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:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
@@ -186,6 +256,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 +325,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -222,6 +369,40 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div className="pt-3 border-t border-border space-y-3">
|
<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>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||||
|
|||||||
@@ -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} />)
|
||||||
|
|||||||
Reference in New Issue
Block a user