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:
@@ -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} />)
|
||||
|
||||
Reference in New Issue
Block a user