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:
Pouzor
2026-07-10 14:51:01 +02:00
parent a90ca2f039
commit b99450db2f
14 changed files with 1141 additions and 3 deletions
+47
View File
@@ -211,6 +211,39 @@ export const designsApi = {
delete: (id: string) => api.delete(`/designs/${id}`),
}
export interface ZigbeeConfigData {
mqtt_host: string
mqtt_port: number
base_topic: string
mqtt_tls: boolean
sync_enabled: boolean
sync_interval: number
host_configured: boolean
}
export interface ZwaveConfigData {
mqtt_host: string
mqtt_port: number
prefix: string
gateway_name: string
mqtt_tls: boolean
sync_enabled: boolean
sync_interval: number
host_configured: boolean
}
// Shape returned by every background-scan trigger (import-pending / sync-now).
interface ScanRunResult {
id: string
status: string
kind: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}
export const zigbeeApi = {
testConnection: (data: {
mqtt_host: string
@@ -256,6 +289,13 @@ export const zigbeeApi = {
finished_at: string | null
error: string | null
}>('/zigbee/import-pending', data),
getConfig: () => api.get<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 = {
@@ -305,4 +345,11 @@ export const zwaveApi = {
finished_at: string | null
error: string | null
}>('/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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { settingsApi, proxmoxApi, type ProxmoxConfigData } from '@/api/client'
import {
settingsApi,
proxmoxApi,
zigbeeApi,
zwaveApi,
type ProxmoxConfigData,
type ZigbeeConfigData,
type ZwaveConfigData,
} from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner'
import {
@@ -18,6 +26,86 @@ interface SettingsModalProps {
onClose: () => void
}
interface MeshAutoSyncProps {
title: string
accent: string
hostConfigured: boolean
envHostVar: string
enabled: boolean
onEnabledChange: (v: boolean) => void
interval: number
onIntervalChange: (v: number) => void
description: string
syncing: boolean
onSyncNow: () => void
}
/**
* Auto-sync controls for an MQTT mesh import (Zigbee / Z-Wave). Mirrors the
* Proxmox auto-sync block: connection config is env-only, so this only toggles
* the scheduled activation + interval and offers an immediate re-sync. When no
* MQTT host is set in the server env, it shows how to configure one instead.
*/
function MeshAutoSync({
title, accent, hostConfigured, envHostVar, enabled, onEnabledChange,
interval, onIntervalChange, description, syncing, onSyncNow,
}: MeshAutoSyncProps) {
return (
<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) {
const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
@@ -27,6 +115,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
const [pmSyncEnabled, setPmSyncEnabled] = useState(false)
const [pmInterval, setPmInterval] = useState(3600)
const [pmSyncing, setPmSyncing] = useState(false)
const [zbConfig, setZbConfig] = useState<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 hideIp = useCanvasStore((s) => s.hideIp)
const setHideIp = useCanvasStore((s) => s.setHideIp)
@@ -47,6 +143,20 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
setPmInterval(res.data.sync_interval)
})
.catch(() => {/* proxmox not configured */})
zigbeeApi.getConfig()
.then((res) => {
setZbConfig(res.data)
setZbSyncEnabled(res.data.sync_enabled)
setZbInterval(res.data.sync_interval)
})
.catch(() => {/* zigbee not configured */})
zwaveApi.getConfig()
.then((res) => {
setZwConfig(res.data)
setZwSyncEnabled(res.data.sync_enabled)
setZwInterval(res.data.sync_interval)
})
.catch(() => {/* zwave not configured */})
}, [open])
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
@@ -69,6 +179,30 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
}
}
const handleZbSyncNow = async () => {
setZbSyncing(true)
try {
await zigbeeApi.syncNow()
toast.success('Zigbee sync started')
} catch {
toast.error('Failed to start Zigbee sync')
} finally {
setZbSyncing(false)
}
}
const handleZwSyncNow = async () => {
setZwSyncing(true)
try {
await zwaveApi.syncNow()
toast.success('Z-Wave sync started')
} catch {
toast.error('Failed to start Z-Wave sync')
} finally {
setZwSyncing(false)
}
}
const handleSave = async () => {
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
// status-check interval needs an API round-trip.
@@ -91,6 +225,19 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
sync_interval: pmInterval,
})
}
if (zbConfig) {
// MQTT connection config is env-only; only the activation is persisted.
await zigbeeApi.saveConfig({
sync_enabled: zbSyncEnabled,
sync_interval: zbInterval,
})
}
if (zwConfig) {
await zwaveApi.saveConfig({
sync_enabled: zwSyncEnabled,
sync_interval: zwInterval,
})
}
toast.success('Settings saved')
onClose()
} catch {
@@ -222,6 +369,40 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
</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 */}
<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>
@@ -13,9 +13,19 @@ vi.mock('@/api/client', () => ({
saveConfig: vi.fn(),
syncNow: vi.fn(),
},
zigbeeApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
syncNow: vi.fn(),
},
zwaveApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
syncNow: vi.fn(),
},
}))
import { settingsApi, proxmoxApi } from '@/api/client'
import { settingsApi, proxmoxApi, zigbeeApi, zwaveApi } from '@/api/client'
import { toast } from 'sonner'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -26,10 +36,25 @@ describe('SettingsModal', () => {
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(proxmoxApi.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
// Zigbee/Z-Wave default to "not configured" so the mesh sections stay hidden
// unless a test opts in — keeps the single Proxmox "Re-sync now" unambiguous.
vi.mocked(zigbeeApi.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(zigbeeApi.saveConfig).mockResolvedValue({ data: {} } as never)
vi.mocked(zigbeeApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never)
vi.mocked(zwaveApi.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(zwaveApi.saveConfig).mockResolvedValue({ data: {} } as never)
vi.mocked(zwaveApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
const zbConfig = (over = {}) => ({
data: { mqtt_host: 'broker', mqtt_port: 1883, base_topic: 'zigbee2mqtt', mqtt_tls: false, sync_enabled: false, sync_interval: 3600, host_configured: true, ...over },
})
const zwConfig = (over = {}) => ({
data: { mqtt_host: 'broker', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt', mqtt_tls: false, sync_enabled: false, sync_interval: 3600, host_configured: true, ...over },
})
it('loads interval from API when opened', async () => {
render(<SettingsModal open onClose={vi.fn()} />)
await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce())
@@ -144,6 +169,36 @@ describe('SettingsModal', () => {
expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull()
})
it('persists only Zigbee sync fields (not connection config) on Save', async () => {
vi.mocked(zigbeeApi.getConfig).mockResolvedValue(zbConfig({ sync_enabled: true, sync_interval: 1800 }) as never)
render(<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 () => {
const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />)