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:
@@ -10,16 +10,20 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeConfig,
|
||||
ZigbeeCoordinatorOut,
|
||||
ZigbeeEdgeOut,
|
||||
ZigbeeImportPendingResponse,
|
||||
ZigbeeImportRequest,
|
||||
ZigbeeImportResponse,
|
||||
ZigbeeNodeOut,
|
||||
ZigbeeSyncConfig,
|
||||
ZigbeeTestConnectionRequest,
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
@@ -99,6 +103,53 @@ async def import_zigbee_to_pending(
|
||||
return run
|
||||
|
||||
|
||||
def env_import_request() -> ZigbeeImportRequest:
|
||||
"""Build an import request from the server env config (for auto-sync).
|
||||
|
||||
MQTT credentials live in the env only — never in the request body or any
|
||||
API response. The scheduled auto-sync job and ``/sync-now`` both source
|
||||
their connection settings here so there is a single source of truth."""
|
||||
return ZigbeeImportRequest(
|
||||
mqtt_host=settings.zigbee_mqtt_host,
|
||||
mqtt_port=settings.zigbee_mqtt_port,
|
||||
mqtt_username=settings.zigbee_mqtt_username or None,
|
||||
mqtt_password=settings.zigbee_mqtt_password or None,
|
||||
base_topic=settings.zigbee_base_topic,
|
||||
mqtt_tls=settings.zigbee_mqtt_tls,
|
||||
mqtt_tls_insecure=settings.zigbee_mqtt_tls_insecure,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
||||
async def sync_zigbee_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Trigger an immediate Zigbee import using the server env config.
|
||||
|
||||
Same background flow as ``/import-pending`` but sources the MQTT connection
|
||||
from ``settings`` (env) rather than the request body — the manual
|
||||
counterpart to the scheduled auto-sync job. Requires the env host to be set.
|
||||
"""
|
||||
if not settings.zigbee_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot sync: no Zigbee MQTT host configured on the server.",
|
||||
)
|
||||
payload = env_import_request()
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="zigbee",
|
||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
||||
return run
|
||||
|
||||
|
||||
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
@@ -307,3 +358,50 @@ async def test_zigbee_connection(
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during connection test")
|
||||
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
||||
|
||||
|
||||
@router.get("/config", response_model=ZigbeeConfig)
|
||||
async def get_zigbee_config(_: str = Depends(get_current_user)) -> ZigbeeConfig:
|
||||
"""Return non-secret Zigbee config. Never includes MQTT credentials — only
|
||||
whether a host is configured on the server for auto-sync."""
|
||||
return ZigbeeConfig(
|
||||
mqtt_host=settings.zigbee_mqtt_host,
|
||||
mqtt_port=settings.zigbee_mqtt_port,
|
||||
base_topic=settings.zigbee_base_topic,
|
||||
mqtt_tls=settings.zigbee_mqtt_tls,
|
||||
sync_enabled=settings.zigbee_sync_enabled,
|
||||
sync_interval=settings.zigbee_sync_interval,
|
||||
host_configured=bool(settings.zigbee_mqtt_host),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ZigbeeConfig)
|
||||
async def save_zigbee_config(
|
||||
payload: ZigbeeSyncConfig,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeConfig:
|
||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
||||
|
||||
This is the ONLY Zigbee config the app writes. Connection settings
|
||||
(host/port/credentials/topic/tls) are env-only and are never accepted or
|
||||
persisted here — enabling auto-sync requires the MQTT host already set in
|
||||
the server env, since the scheduled job reads it from there.
|
||||
"""
|
||||
if payload.sync_enabled and not settings.zigbee_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot enable auto-sync: no Zigbee MQTT host configured in the server env.",
|
||||
)
|
||||
try:
|
||||
settings.zigbee_sync_enabled = payload.sync_enabled
|
||||
settings.zigbee_sync_interval = payload.sync_interval
|
||||
settings.save_overrides()
|
||||
set_zigbee_sync_enabled(payload.sync_enabled)
|
||||
if payload.sync_enabled:
|
||||
reschedule_zigbee_sync(payload.sync_interval)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_zigbee_config()
|
||||
|
||||
@@ -10,16 +10,20 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zwave import (
|
||||
ZwaveConfig,
|
||||
ZwaveCoordinatorOut,
|
||||
ZwaveEdgeOut,
|
||||
ZwaveImportPendingResponse,
|
||||
ZwaveImportRequest,
|
||||
ZwaveImportResponse,
|
||||
ZwaveNodeOut,
|
||||
ZwaveSyncConfig,
|
||||
ZwaveTestConnectionRequest,
|
||||
ZwaveTestConnectionResponse,
|
||||
)
|
||||
@@ -94,6 +98,54 @@ async def import_zwave_to_pending(
|
||||
return run
|
||||
|
||||
|
||||
def env_import_request() -> ZwaveImportRequest:
|
||||
"""Build an import request from the server env config (for auto-sync).
|
||||
|
||||
MQTT credentials live in the env only — never in the request body or any
|
||||
API response. The scheduled auto-sync job and ``/sync-now`` both source
|
||||
their connection settings here so there is a single source of truth."""
|
||||
return ZwaveImportRequest(
|
||||
mqtt_host=settings.zwave_mqtt_host,
|
||||
mqtt_port=settings.zwave_mqtt_port,
|
||||
mqtt_username=settings.zwave_mqtt_username or None,
|
||||
mqtt_password=settings.zwave_mqtt_password or None,
|
||||
prefix=settings.zwave_prefix,
|
||||
gateway_name=settings.zwave_gateway_name,
|
||||
mqtt_tls=settings.zwave_mqtt_tls,
|
||||
mqtt_tls_insecure=settings.zwave_mqtt_tls_insecure,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
||||
async def sync_zwave_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Trigger an immediate Z-Wave import using the server env config.
|
||||
|
||||
Same background flow as ``/import-pending`` but sources the MQTT connection
|
||||
from ``settings`` (env) rather than the request body — the manual
|
||||
counterpart to the scheduled auto-sync job. Requires the env host to be set.
|
||||
"""
|
||||
if not settings.zwave_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot sync: no Z-Wave MQTT host configured on the server.",
|
||||
)
|
||||
payload = env_import_request()
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="zwave",
|
||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_zwave_import, run.id, payload)
|
||||
return run
|
||||
|
||||
|
||||
async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
@@ -295,3 +347,51 @@ async def test_connection_endpoint(
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during connection test")
|
||||
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
|
||||
|
||||
|
||||
@router.get("/config", response_model=ZwaveConfig)
|
||||
async def get_zwave_config(_: str = Depends(get_current_user)) -> ZwaveConfig:
|
||||
"""Return non-secret Z-Wave config. Never includes MQTT credentials — only
|
||||
whether a host is configured on the server for auto-sync."""
|
||||
return ZwaveConfig(
|
||||
mqtt_host=settings.zwave_mqtt_host,
|
||||
mqtt_port=settings.zwave_mqtt_port,
|
||||
prefix=settings.zwave_prefix,
|
||||
gateway_name=settings.zwave_gateway_name,
|
||||
mqtt_tls=settings.zwave_mqtt_tls,
|
||||
sync_enabled=settings.zwave_sync_enabled,
|
||||
sync_interval=settings.zwave_sync_interval,
|
||||
host_configured=bool(settings.zwave_mqtt_host),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ZwaveConfig)
|
||||
async def save_zwave_config(
|
||||
payload: ZwaveSyncConfig,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZwaveConfig:
|
||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
||||
|
||||
This is the ONLY Z-Wave config the app writes. Connection settings
|
||||
(host/port/credentials/prefix/gateway/tls) are env-only and are never
|
||||
accepted or persisted here — enabling auto-sync requires the MQTT host
|
||||
already set in the server env, since the scheduled job reads it from there.
|
||||
"""
|
||||
if payload.sync_enabled and not settings.zwave_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot enable auto-sync: no Z-Wave MQTT host configured in the server env.",
|
||||
)
|
||||
try:
|
||||
settings.zwave_sync_enabled = payload.sync_enabled
|
||||
settings.zwave_sync_interval = payload.sync_interval
|
||||
settings.save_overrides()
|
||||
set_zwave_sync_enabled(payload.sync_enabled)
|
||||
if payload.sync_enabled:
|
||||
reschedule_zwave_sync(payload.sync_interval)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_zwave_config()
|
||||
|
||||
@@ -93,6 +93,32 @@ class Settings(BaseSettings):
|
||||
proxmox_sync_enabled: bool = False
|
||||
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
# Zigbee2MQTT auto-sync import.
|
||||
# MQTT credentials are secrets → env/.env ONLY, never persisted by the app to
|
||||
# scan_config.json and never returned by the API. Only the auto-sync
|
||||
# activation (enabled + interval) is persisted; connection config is env-only.
|
||||
zigbee_mqtt_host: str = ""
|
||||
zigbee_mqtt_port: int = 1883
|
||||
zigbee_mqtt_username: str = ""
|
||||
zigbee_mqtt_password: str = ""
|
||||
zigbee_base_topic: str = "zigbee2mqtt"
|
||||
zigbee_mqtt_tls: bool = False
|
||||
zigbee_mqtt_tls_insecure: bool = False
|
||||
zigbee_sync_enabled: bool = False
|
||||
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
|
||||
zwave_mqtt_host: str = ""
|
||||
zwave_mqtt_port: int = 1883
|
||||
zwave_mqtt_username: str = ""
|
||||
zwave_mqtt_password: str = ""
|
||||
zwave_prefix: str = "zwave"
|
||||
zwave_gateway_name: str = "zwavejs2mqtt"
|
||||
zwave_mqtt_tls: bool = False
|
||||
zwave_mqtt_tls_insecure: bool = False
|
||||
zwave_sync_enabled: bool = False
|
||||
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
@@ -129,6 +155,17 @@ class Settings(BaseSettings):
|
||||
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
|
||||
if "proxmox_sync_interval" in data:
|
||||
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
|
||||
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
||||
# connection config (host, port, credentials, topic, tls) is env-only
|
||||
# by design — never read from or written to this file.
|
||||
if "zigbee_sync_enabled" in data:
|
||||
self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"])
|
||||
if "zigbee_sync_interval" in data:
|
||||
self.zigbee_sync_interval = int(data["zigbee_sync_interval"])
|
||||
if "zwave_sync_enabled" in data:
|
||||
self.zwave_sync_enabled = bool(data["zwave_sync_enabled"])
|
||||
if "zwave_sync_interval" in data:
|
||||
self.zwave_sync_interval = int(data["zwave_sync_interval"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -148,6 +185,12 @@ class Settings(BaseSettings):
|
||||
# be written to disk — that is the single source of truth.
|
||||
"proxmox_sync_enabled": self.proxmox_sync_enabled,
|
||||
"proxmox_sync_interval": self.proxmox_sync_interval,
|
||||
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
||||
# connection config (host/port/credentials/topic/tls) is env-only.
|
||||
"zigbee_sync_enabled": self.zigbee_sync_enabled,
|
||||
"zigbee_sync_interval": self.zigbee_sync_interval,
|
||||
"zwave_sync_enabled": self.zwave_sync_enabled,
|
||||
"zwave_sync_interval": self.zwave_sync_interval,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -144,6 +144,54 @@ async def _run_proxmox_sync() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _run_mesh_sync(kind: str) -> None:
|
||||
"""Shared auto-sync for the MQTT mesh imports (Zigbee / Z-Wave).
|
||||
|
||||
Records a ScanRun (kind=zigbee|zwave) so the scheduled sync shows in Scan
|
||||
history, then delegates to the exact same background import the manual
|
||||
/sync-now and /import-pending paths use. Connection config comes from the
|
||||
server env only (credentials never leave it).
|
||||
"""
|
||||
from app.db.models import ScanRun
|
||||
|
||||
if kind == "zigbee":
|
||||
if not settings.zigbee_sync_enabled:
|
||||
return
|
||||
if not settings.zigbee_mqtt_host:
|
||||
logger.warning("Zigbee auto-sync enabled but MQTT host not configured — skipping")
|
||||
return
|
||||
from app.api.routes.zigbee import _background_zigbee_import, env_import_request
|
||||
host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port
|
||||
background = _background_zigbee_import
|
||||
else:
|
||||
if not settings.zwave_sync_enabled:
|
||||
return
|
||||
if not settings.zwave_mqtt_host:
|
||||
logger.warning("Z-Wave auto-sync enabled but MQTT host not configured — skipping")
|
||||
return
|
||||
from app.api.routes.zwave import _background_zwave_import, env_import_request
|
||||
host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port
|
||||
background = _background_zwave_import
|
||||
|
||||
payload = env_import_request()
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = ScanRun(status="running", kind=kind, ranges=[f"{host}:{port}"])
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
run_id = run.id
|
||||
|
||||
await background(run_id, payload)
|
||||
|
||||
|
||||
async def _run_zigbee_sync() -> None:
|
||||
await _run_mesh_sync("zigbee")
|
||||
|
||||
|
||||
async def _run_zwave_sync() -> None:
|
||||
await _run_mesh_sync("zwave")
|
||||
|
||||
|
||||
def _add_service_check_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_service_checks,
|
||||
@@ -166,6 +214,28 @@ def _add_proxmox_sync_job() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _add_zigbee_sync_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_zigbee_sync,
|
||||
"interval",
|
||||
seconds=settings.zigbee_sync_interval,
|
||||
id="zigbee_sync",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def _add_zwave_sync_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_zwave_sync,
|
||||
"interval",
|
||||
seconds=settings.zwave_sync_interval,
|
||||
id="zwave_sync",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global scheduler
|
||||
if scheduler.running:
|
||||
@@ -186,6 +256,10 @@ def start_scheduler() -> None:
|
||||
_add_service_check_job()
|
||||
if settings.proxmox_sync_enabled:
|
||||
_add_proxmox_sync_job()
|
||||
if settings.zigbee_sync_enabled:
|
||||
_add_zigbee_sync_job()
|
||||
if settings.zwave_sync_enabled:
|
||||
_add_zwave_sync_job()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||
|
||||
@@ -251,6 +325,56 @@ def set_proxmox_sync_enabled(enabled: bool) -> None:
|
||||
logger.info("Proxmox auto-sync disabled")
|
||||
|
||||
|
||||
def reschedule_zigbee_sync(interval_seconds: int) -> None:
|
||||
"""Update the Zigbee auto-sync interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 300:
|
||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("zigbee_sync"):
|
||||
scheduler.reschedule_job("zigbee_sync", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Zigbee auto-sync rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_zigbee_sync_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the Zigbee auto-sync job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("zigbee_sync")
|
||||
if enabled and not job:
|
||||
_add_zigbee_sync_job()
|
||||
logger.info("Zigbee auto-sync enabled — every %ds", settings.zigbee_sync_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("zigbee_sync")
|
||||
logger.info("Zigbee auto-sync disabled")
|
||||
|
||||
|
||||
def reschedule_zwave_sync(interval_seconds: int) -> None:
|
||||
"""Update the Z-Wave auto-sync interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 300:
|
||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("zwave_sync"):
|
||||
scheduler.reschedule_job("zwave_sync", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Z-Wave auto-sync rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_zwave_sync_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the Z-Wave auto-sync job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("zwave_sync")
|
||||
if enabled and not job:
|
||||
_add_zwave_sync_job()
|
||||
logger.info("Z-Wave auto-sync enabled — every %ds", settings.zwave_sync_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("zwave_sync")
|
||||
logger.info("Z-Wave auto-sync disabled")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
@@ -93,3 +93,29 @@ class ZigbeeImportPendingResponse(BaseModel):
|
||||
coordinator_already_existed: bool = False
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZigbeeConfig(BaseModel):
|
||||
"""Non-secret Zigbee connection + auto-sync config (GET response).
|
||||
|
||||
MQTT connection fields (host/port/base_topic/tls) are env-only and
|
||||
read-only here — surfaced for display. ``host_configured`` reflects whether
|
||||
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
||||
(username/password) are never carried."""
|
||||
|
||||
mqtt_host: str = ""
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||
base_topic: str = "zigbee2mqtt"
|
||||
mqtt_tls: bool = False
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
host_configured: bool = False
|
||||
|
||||
|
||||
class ZigbeeSyncConfig(BaseModel):
|
||||
"""User-editable auto-sync config (POST body). The ONLY persisted Zigbee
|
||||
settings. Connection fields (host/port/credentials/topic/tls) are env-only
|
||||
and are deliberately not accepted here."""
|
||||
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
|
||||
@@ -83,3 +83,30 @@ class ZwaveImportPendingResponse(BaseModel):
|
||||
coordinator_already_existed: bool = False
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZwaveConfig(BaseModel):
|
||||
"""Non-secret Z-Wave connection + auto-sync config (GET response).
|
||||
|
||||
MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and
|
||||
read-only here — surfaced for display. ``host_configured`` reflects whether
|
||||
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
||||
(username/password) are never carried."""
|
||||
|
||||
mqtt_host: str = ""
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||
prefix: str = "zwave"
|
||||
gateway_name: str = "zwavejs2mqtt"
|
||||
mqtt_tls: bool = False
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
host_configured: bool = False
|
||||
|
||||
|
||||
class ZwaveSyncConfig(BaseModel):
|
||||
"""User-editable auto-sync config (POST body). The ONLY persisted Z-Wave
|
||||
settings. Connection fields (host/port/credentials/prefix/gateway/tls) are
|
||||
env-only and are deliberately not accepted here."""
|
||||
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
|
||||
@@ -9,11 +9,17 @@ from app.core.scheduler import (
|
||||
_run_proxmox_sync,
|
||||
_run_service_checks,
|
||||
_run_status_checks,
|
||||
_run_zigbee_sync,
|
||||
_run_zwave_sync,
|
||||
reschedule_proxmox_sync,
|
||||
reschedule_service_checks,
|
||||
reschedule_status_checks,
|
||||
reschedule_zigbee_sync,
|
||||
reschedule_zwave_sync,
|
||||
set_proxmox_sync_enabled,
|
||||
set_service_checks_enabled,
|
||||
set_zigbee_sync_enabled,
|
||||
set_zwave_sync_enabled,
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
)
|
||||
@@ -154,6 +160,8 @@ def test_scheduler_uses_settings_interval():
|
||||
mock_settings.status_checker_interval = 45
|
||||
mock_settings.service_check_enabled = False
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
mock_settings.zwave_sync_enabled = False
|
||||
start_scheduler()
|
||||
_, kwargs = mock_sched.add_job.call_args
|
||||
assert kwargs["seconds"] == 45
|
||||
@@ -167,6 +175,8 @@ def test_start_and_stop_scheduler():
|
||||
mock_settings.status_checker_interval = 60
|
||||
mock_settings.service_check_enabled = False
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
mock_settings.zwave_sync_enabled = False
|
||||
start_scheduler()
|
||||
stop_scheduler()
|
||||
mock_sched.add_job.assert_called_once()
|
||||
@@ -256,6 +266,8 @@ def test_start_scheduler_adds_service_job_when_enabled():
|
||||
mock_settings.service_check_enabled = True
|
||||
mock_settings.service_check_interval = 300
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
mock_settings.zwave_sync_enabled = False
|
||||
start_scheduler()
|
||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||
assert "status_checks" in job_ids
|
||||
@@ -343,6 +355,152 @@ async def test_run_proxmox_sync_records_scan_run(mem_db):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_set_zigbee_sync_enabled_adds_and_removes_job():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = True
|
||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||
patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zigbee_sync_interval = 3600
|
||||
mock_sched.get_job.return_value = None
|
||||
set_zigbee_sync_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_zigbee_sync_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
|
||||
|
||||
|
||||
def test_set_zwave_sync_enabled_adds_and_removes_job():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = True
|
||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||
patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zwave_sync_interval = 3600
|
||||
mock_sched.get_job.return_value = None
|
||||
set_zwave_sync_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_zwave_sync_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("zwave_sync")
|
||||
|
||||
|
||||
def test_reschedule_zigbee_sync_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_zigbee_sync(60)
|
||||
|
||||
|
||||
def test_reschedule_zwave_sync_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_zwave_sync(60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zigbee_sync_skips_when_disabled():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
await _run_zigbee_sync() # returns before importing/fetching anything
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zigbee_sync_skips_when_no_host():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zigbee_sync_enabled = True
|
||||
mock_settings.zigbee_mqtt_host = ""
|
||||
await _run_zigbee_sync() # no exception, no fetch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zwave_sync_skips_when_no_host():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zwave_sync_enabled = True
|
||||
mock_settings.zwave_mqtt_host = ""
|
||||
await _run_zwave_sync()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zigbee_sync_records_scan_run(mem_db):
|
||||
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
|
||||
then delegate to the shared background import with the run id + env payload."""
|
||||
from app.db.models import ScanRun
|
||||
from app.schemas.zigbee import ZigbeeImportRequest
|
||||
|
||||
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
|
||||
|
||||
with (
|
||||
patch("app.core.scheduler.settings") as mock_settings,
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
|
||||
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
|
||||
):
|
||||
mock_settings.zigbee_sync_enabled = True
|
||||
mock_settings.zigbee_mqtt_host = "broker"
|
||||
mock_settings.zigbee_mqtt_port = 1883
|
||||
await _run_zigbee_sync()
|
||||
|
||||
async with mem_db() as db:
|
||||
from sqlalchemy import select
|
||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||
assert run.kind == "zigbee"
|
||||
assert run.ranges == ["broker:1883"]
|
||||
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zwave_sync_records_scan_run(mem_db):
|
||||
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
|
||||
background import with the run id + env payload."""
|
||||
from app.db.models import ScanRun
|
||||
from app.schemas.zwave import ZwaveImportRequest
|
||||
|
||||
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
|
||||
|
||||
with (
|
||||
patch("app.core.scheduler.settings") as mock_settings,
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
|
||||
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
|
||||
):
|
||||
mock_settings.zwave_sync_enabled = True
|
||||
mock_settings.zwave_mqtt_host = "broker"
|
||||
mock_settings.zwave_mqtt_port = 1883
|
||||
await _run_zwave_sync()
|
||||
|
||||
async with mem_db() as db:
|
||||
from sqlalchemy import select
|
||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||
assert run.kind == "zwave"
|
||||
assert run.ranges == ["broker:1883"]
|
||||
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
||||
|
||||
|
||||
def test_reschedule_zigbee_sync_noop_when_not_running():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||
reschedule_zigbee_sync(600)
|
||||
mock_sched.reschedule_job.assert_not_called()
|
||||
|
||||
|
||||
def test_start_scheduler_adds_mesh_jobs_when_enabled():
|
||||
mock_sched = MagicMock()
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||
mock_settings.status_checker_interval = 60
|
||||
mock_settings.service_check_enabled = False
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = True
|
||||
mock_settings.zigbee_sync_interval = 3600
|
||||
mock_settings.zwave_sync_enabled = True
|
||||
mock_settings.zwave_sync_interval = 3600
|
||||
start_scheduler()
|
||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||
assert "zigbee_sync" in job_ids
|
||||
assert "zwave_sync" in job_ids
|
||||
|
||||
|
||||
# --- reschedule_* validation and not-running guards ---
|
||||
|
||||
def test_reschedule_status_checks_rejects_short_interval():
|
||||
|
||||
@@ -123,3 +123,47 @@ def test_save_overrides_omits_proxmox_connection_config(tmp_path):
|
||||
assert "proxmox_token_secret" not in written
|
||||
assert written["proxmox_sync_enabled"] is True
|
||||
assert written["proxmox_sync_interval"] == 900
|
||||
|
||||
|
||||
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
|
||||
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
|
||||
env-only: stale values in scan_config.json must be ignored. Only the
|
||||
auto-sync activation is read back."""
|
||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
||||
s.zigbee_mqtt_host = "broker.local"
|
||||
s.zwave_mqtt_host = "broker.local"
|
||||
(tmp_path / "scan_config.json").write_text(json.dumps({
|
||||
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
|
||||
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
|
||||
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
|
||||
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
|
||||
}))
|
||||
s.load_overrides()
|
||||
assert s.zigbee_mqtt_host == "broker.local"
|
||||
assert s.zwave_mqtt_host == "broker.local"
|
||||
assert s.zigbee_sync_enabled is True
|
||||
assert s.zigbee_sync_interval == 600
|
||||
assert s.zwave_sync_enabled is True
|
||||
assert s.zwave_sync_interval == 700
|
||||
|
||||
|
||||
def test_save_overrides_omits_mesh_credentials(tmp_path):
|
||||
"""save_overrides must never write MQTT host/credentials — only the sync
|
||||
activation. Prevents the dual source of truth and leaking secrets to disk."""
|
||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
||||
s.zigbee_mqtt_host = "broker.local"
|
||||
s.zigbee_mqtt_password = "secret"
|
||||
s.zigbee_sync_enabled = True
|
||||
s.zigbee_sync_interval = 900
|
||||
s.zwave_mqtt_password = "secret"
|
||||
s.zwave_sync_interval = 1200
|
||||
s.save_overrides()
|
||||
raw = (tmp_path / "scan_config.json").read_text()
|
||||
assert "secret" not in raw
|
||||
written = json.loads(raw)
|
||||
assert "zigbee_mqtt_host" not in written
|
||||
assert "zigbee_mqtt_password" not in written
|
||||
assert "zwave_mqtt_password" not in written
|
||||
assert written["zigbee_sync_enabled"] is True
|
||||
assert written["zigbee_sync_interval"] == 900
|
||||
assert written["zwave_sync_interval"] == 1200
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
@@ -740,3 +740,106 @@ async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> N
|
||||
kwargs = mock_conn.call_args.kwargs
|
||||
assert kwargs["tls"] is True
|
||||
assert kwargs["tls_insecure"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zigbee/config + /sync-now (auto-sync)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _restore_zigbee_env():
|
||||
"""Snapshot + restore the env-only Zigbee connection/activation settings."""
|
||||
keys = (
|
||||
"zigbee_mqtt_host", "zigbee_mqtt_port", "zigbee_mqtt_username",
|
||||
"zigbee_mqtt_password", "zigbee_base_topic", "zigbee_sync_enabled",
|
||||
"zigbee_sync_interval",
|
||||
)
|
||||
saved = {k: getattr(settings, k) for k in keys}
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
setattr(settings, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_omits_credentials(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = "broker"
|
||||
settings.zigbee_mqtt_username = "user"
|
||||
settings.zigbee_mqtt_password = "supersecret"
|
||||
res = await client.get("/api/v1/zigbee/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert "supersecret" not in res.text
|
||||
assert "user" not in res.text
|
||||
assert res.json()["host_configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/zigbee/config")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_sync_without_host_rejected(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = ""
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/config",
|
||||
json={"sync_enabled": True, "sync_interval": 600},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_config_persists_only_sync_fields(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = "broker"
|
||||
saved: dict = {}
|
||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
||||
host=self.zigbee_mqtt_host, enabled=self.zigbee_sync_enabled,
|
||||
interval=self.zigbee_sync_interval,
|
||||
)), patch("app.api.routes.zigbee.set_zigbee_sync_enabled"), \
|
||||
patch("app.api.routes.zigbee.reschedule_zigbee_sync"):
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/config",
|
||||
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert settings.zigbee_mqtt_host == "broker" # body host ignored
|
||||
assert saved == {"host": "broker", "enabled": True, "interval": 900}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_creates_scan_run(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = "broker"
|
||||
with patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock):
|
||||
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["kind"] == "zigbee"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_rejected_without_host(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = ""
|
||||
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/zigbee/sync-now")
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -492,3 +492,108 @@ async def test_persist_device_on_multiple_canvases(db_session) -> None:
|
||||
for n in nodes:
|
||||
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
|
||||
assert model == "ZW200" # refreshed on every canvas
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zwave/config + /sync-now (auto-sync)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from unittest.mock import AsyncMock # noqa: E402
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _restore_zwave_env():
|
||||
"""Snapshot + restore the env-only Z-Wave connection/activation settings."""
|
||||
keys = (
|
||||
"zwave_mqtt_host", "zwave_mqtt_port", "zwave_mqtt_username",
|
||||
"zwave_mqtt_password", "zwave_prefix", "zwave_gateway_name",
|
||||
"zwave_sync_enabled", "zwave_sync_interval",
|
||||
)
|
||||
saved = {k: getattr(settings, k) for k in keys}
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
setattr(settings, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_omits_credentials(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = "broker"
|
||||
settings.zwave_mqtt_username = "user"
|
||||
settings.zwave_mqtt_password = "supersecret"
|
||||
res = await client.get("/api/v1/zwave/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert "supersecret" not in res.text
|
||||
assert "user" not in res.text
|
||||
assert res.json()["host_configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/zwave/config")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_sync_without_host_rejected(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = ""
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/config",
|
||||
json={"sync_enabled": True, "sync_interval": 600},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_config_persists_only_sync_fields(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = "broker"
|
||||
saved: dict = {}
|
||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
||||
host=self.zwave_mqtt_host, enabled=self.zwave_sync_enabled,
|
||||
interval=self.zwave_sync_interval,
|
||||
)), patch("app.api.routes.zwave.set_zwave_sync_enabled"), \
|
||||
patch("app.api.routes.zwave.reschedule_zwave_sync"):
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/config",
|
||||
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert settings.zwave_mqtt_host == "broker" # body host ignored
|
||||
assert saved == {"host": "broker", "enabled": True, "interval": 900}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_creates_scan_run(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = "broker"
|
||||
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
||||
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["kind"] == "zwave"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_rejected_without_host(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = ""
|
||||
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/zwave/sync-now")
|
||||
assert res.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user