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
@@ -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} />)