feat: add Z-Wave network scan via MQTT gateway
Import a Z-Wave JS UI (zwavejs2mqtt) network over the MQTT gateway API, mirroring the existing Zigbee pipeline: - New Z-Wave Import modal + sidebar entry (broker, prefix, gateway name) - coordinator/router/end-device typing with mesh tree from node neighbors - import to Pending section or straight to canvas - Pending Devices gains a Z-Wave source filter - shared mqtt_common helpers extracted from the zigbee service ha-relevant: yes
This commit is contained in:
@@ -22,6 +22,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
@@ -39,6 +40,7 @@ import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
@@ -77,6 +79,7 @@ export default function App() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||
// Returns true on success, false on failure — the design-switch effect relies
|
||||
@@ -541,6 +544,50 @@ export default function App() {
|
||||
markUnsaved()
|
||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||
|
||||
const handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => {
|
||||
snapshotHistory()
|
||||
const COLS = 4
|
||||
const SPACING_X = 170
|
||||
const SPACING_Y = 100
|
||||
zwaveNodes.forEach((zn, i) => {
|
||||
const id = zn.id
|
||||
const col = i % COLS
|
||||
const row = Math.floor(i / COLS)
|
||||
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
||||
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||
id,
|
||||
type: zn.type,
|
||||
position,
|
||||
data: {
|
||||
label: zn.friendly_name,
|
||||
type: zn.type as NodeData['type'],
|
||||
status: 'unknown' as const,
|
||||
services: [],
|
||||
...(zn.model ? { os: zn.model } : {}),
|
||||
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||
},
|
||||
}
|
||||
addNode(newNode)
|
||||
})
|
||||
// Add IoT edges between Z-Wave devices: parent bottom -> child top
|
||||
zwaveEdges.forEach((ze) => {
|
||||
onConnect({
|
||||
source: ze.source,
|
||||
sourceHandle: 'bottom',
|
||||
target: ze.target,
|
||||
targetHandle: 'top-t',
|
||||
type: 'iot',
|
||||
} as unknown as import('@xyflow/react').Connection)
|
||||
})
|
||||
const importedIds = new Set(zwaveNodes.map((zn) => zn.id))
|
||||
useCanvasStore.setState((state) => ({
|
||||
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||
selectedNodeIds: Array.from(importedIds),
|
||||
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||
}))
|
||||
markUnsaved()
|
||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||
|
||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||
setPendingConnection(connection)
|
||||
}, [])
|
||||
@@ -615,6 +662,7 @@ export default function App() {
|
||||
onAddText={() => setAddTextOpen(true)}
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||
onZwaveImport={() => setZwaveImportOpen(true)}
|
||||
onSave={handleSave}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||
@@ -733,6 +781,17 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ZwaveImportModal
|
||||
open={zwaveImportOpen}
|
||||
onClose={() => setZwaveImportOpen(false)}
|
||||
onAddToCanvas={handleZwaveAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
toast.success('Z-Wave import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
|
||||
@@ -217,4 +217,14 @@ describe('api/client', () => {
|
||||
mod.zigbeeApi.importToPending(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
||||
})
|
||||
|
||||
it('zwaveApi.testConnection/importNetwork/importToPending', () => {
|
||||
const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
|
||||
mod.zwaveApi.testConnection(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg)
|
||||
mod.zwaveApi.importNetwork(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg)
|
||||
mod.zwaveApi.importToPending(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,3 +164,52 @@ export const zigbeeApi = {
|
||||
error: string | null
|
||||
}>('/zigbee/import-pending', data),
|
||||
}
|
||||
|
||||
export const zwaveApi = {
|
||||
testConnection: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{ connected: boolean; message: string }>('/zwave/test-connection', data),
|
||||
|
||||
importNetwork: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
prefix?: string
|
||||
gateway_name?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
nodes: import('@/components/zwave/types').ZwaveNode[]
|
||||
edges: import('@/components/zwave/types').ZwaveEdge[]
|
||||
device_count: number
|
||||
}>('/zwave/import', data),
|
||||
|
||||
importToPending: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
prefix?: string
|
||||
gateway_name?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
id: string
|
||||
status: string
|
||||
kind: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}>('/zwave/import-pending', data),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { toast } from 'sonner'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||
import { buildMacProperty } from '@/utils/macProperty'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
@@ -67,11 +68,13 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
||||
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||
type StatusFilter = 'pending' | 'hidden'
|
||||
|
||||
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
||||
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
||||
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
|
||||
if (d.discovery_source === 'zwave') return 'zwave'
|
||||
if (d.discovery_source === 'zigbee') return 'zigbee'
|
||||
if (d.ieee_address) return 'zigbee'
|
||||
return 'ip'
|
||||
}
|
||||
|
||||
@@ -264,15 +267,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
try {
|
||||
const fallbackLabel = deviceLabel(device)
|
||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||
const zigbee = isZigbeeType(type)
|
||||
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
|
||||
const zwave = isZwaveType(type)
|
||||
const wireless = isZigbeeType(type) || zwave
|
||||
const properties = zwave
|
||||
? buildZwaveProperties(device)
|
||||
: isZigbeeType(type)
|
||||
? buildZigbeeProperties(device)
|
||||
: buildMacProperty(device.mac)
|
||||
const nodeData = {
|
||||
label: fallbackLabel,
|
||||
type,
|
||||
ip: device.ip ?? undefined,
|
||||
mac: device.mac ?? undefined,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: zigbee ? 'online' : 'unknown',
|
||||
status: wireless ? 'online' : 'unknown',
|
||||
services: (device.services ?? []) as ServiceInfo[],
|
||||
properties,
|
||||
}
|
||||
@@ -282,7 +290,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
id: nodeId,
|
||||
type: nodeData.type,
|
||||
position: { x: 400, y: 300 },
|
||||
data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
|
||||
data: { ...nodeData, status: wireless ? ('online' as const) : ('unknown' as const) },
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
@@ -327,7 +335,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
const nodeId = deviceToNode[d.id]
|
||||
if (!nodeId) return
|
||||
const type = (d.suggested_type ?? 'generic') as NodeType
|
||||
const zigbee = isZigbeeType(type)
|
||||
const zwave = isZwaveType(type)
|
||||
const wireless = isZigbeeType(type) || zwave
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type,
|
||||
@@ -338,9 +347,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
ip: d.ip ?? undefined,
|
||||
mac: d.mac ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||
status: wireless ? ('online' as const) : ('unknown' as const),
|
||||
services: (d.services ?? []) as ServiceInfo[],
|
||||
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
|
||||
properties: zwave
|
||||
? buildZwaveProperties(d)
|
||||
: isZigbeeType(type)
|
||||
? buildZigbeeProperties(d)
|
||||
: buildMacProperty(d.mac),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -473,6 +486,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
>
|
||||
Zigbee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('zwave')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zwave' ? 'bg-[#ff6e00]/20 text-[#ff6e00]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Z-Wave
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
@@ -631,8 +650,11 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
const source = inferSource(device)
|
||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
const label = deviceLabel(device)
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
||||
const sourceLabel =
|
||||
source === 'zigbee' ? 'ZIGBEE'
|
||||
: source === 'zwave' ? 'Z-WAVE'
|
||||
: (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const services = device.services ?? []
|
||||
const visibleServices = services.slice(0, 4)
|
||||
const moreServices = services.length - visibleServices.length
|
||||
|
||||
@@ -67,6 +67,23 @@ const DEVICE_ZIGBEE = {
|
||||
discovered_at: '2026-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_ZWAVE = {
|
||||
id: 'dev-c',
|
||||
ip: null,
|
||||
hostname: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'zwave_router',
|
||||
status: 'pending',
|
||||
discovery_source: 'zwave',
|
||||
ieee_address: 'zwave-0xh-2',
|
||||
friendly_name: 'wall-plug',
|
||||
vendor: 'Aeotec',
|
||||
model: 'ZW100',
|
||||
discovered_at: '2026-01-03T00:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
@@ -129,6 +146,22 @@ describe('PendingDevicesModal', () => {
|
||||
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows source chip Z-WAVE for zwave device', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument())
|
||||
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by source (zwave only)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by suggested type', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -25,13 +25,14 @@ interface SidebarProps {
|
||||
onAddText: () => void
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onZwaveImport: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
@@ -217,6 +218,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
|
||||
@@ -70,6 +70,7 @@ const defaultProps = {
|
||||
onAddText: vi.fn(),
|
||||
onScan: vi.fn(),
|
||||
onZigbeeImport: vi.fn(),
|
||||
onZwaveImport: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenHistory: vi.fn(),
|
||||
@@ -184,6 +185,12 @@ describe('Sidebar', () => {
|
||||
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onZwaveImport when Z-Wave Import is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Z-Wave Import'))
|
||||
expect(defaultProps.onZwaveImport).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSave when Save Canvas is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Save Canvas'))
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState } from 'react'
|
||||
import { RadioTower, Share2, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { zwaveApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import type { ZwaveNode, ZwaveEdge } from './types'
|
||||
|
||||
interface ZwaveImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ZwaveNode[], edges: ZwaveEdge[]) => void
|
||||
onPendingImported?: (
|
||||
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
type ImportMode = 'pending' | 'canvas'
|
||||
|
||||
const ACCENT = '#ff6e00'
|
||||
|
||||
interface ConnectionForm {
|
||||
mqtt_host: string
|
||||
mqtt_port: string
|
||||
mqtt_username: string
|
||||
mqtt_password: string
|
||||
prefix: string
|
||||
gateway_name: string
|
||||
mqtt_tls: boolean
|
||||
mqtt_tls_insecure: boolean
|
||||
port_user_edited: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: ConnectionForm = {
|
||||
mqtt_host: '',
|
||||
mqtt_port: '1883',
|
||||
mqtt_username: '',
|
||||
mqtt_password: '',
|
||||
prefix: 'zwave',
|
||||
gateway_name: 'zwavejs2mqtt',
|
||||
mqtt_tls: false,
|
||||
mqtt_tls_insecure: false,
|
||||
port_user_edited: false,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_ICON = {
|
||||
zwave_coordinator: RadioTower,
|
||||
zwave_router: Share2,
|
||||
zwave_enddevice: Cpu,
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_LABEL = {
|
||||
zwave_coordinator: 'Controller',
|
||||
zwave_router: 'Router',
|
||||
zwave_enddevice: 'End Device',
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_COLOR = {
|
||||
zwave_coordinator: '#ff6e00',
|
||||
zwave_router: '#39d353',
|
||||
zwave_enddevice: '#e3b341',
|
||||
} as const
|
||||
|
||||
export function ZwaveImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZwaveImportModalProps) {
|
||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||
const [connectionMsg, setConnectionMsg] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<ZwaveNode[]>([])
|
||||
const [edges, setEdges] = useState<ZwaveEdge[]>([])
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||
|
||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
[field]: value,
|
||||
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||
}))
|
||||
|
||||
const toggleTls = (next: boolean) =>
|
||||
setForm((f) => {
|
||||
const port = f.port_user_edited
|
||||
? f.mqtt_port
|
||||
: next
|
||||
? '8883'
|
||||
: '1883'
|
||||
return {
|
||||
...f,
|
||||
mqtt_tls: next,
|
||||
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||
mqtt_port: port,
|
||||
}
|
||||
})
|
||||
|
||||
const buildPayload = () => ({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
prefix: form.prefix.trim() || 'zwave',
|
||||
gateway_name: form.gateway_name.trim() || 'zwavejs2mqtt',
|
||||
mqtt_tls: form.mqtt_tls,
|
||||
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||
})
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setConnectionStatus('testing')
|
||||
try {
|
||||
const res = await zwaveApi.testConnection({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
mqtt_tls: form.mqtt_tls,
|
||||
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||
})
|
||||
if (res.data.connected) {
|
||||
setConnectionStatus('ok')
|
||||
setConnectionMsg(res.data.message)
|
||||
} else {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg(res.data.message)
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg('Request failed — check broker address')
|
||||
}
|
||||
}
|
||||
|
||||
const extractError = (err: unknown): string | undefined => {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const handleFetchDevices = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
if (importMode === 'pending') {
|
||||
await zwaveApi.importToPending(buildPayload())
|
||||
toast.success('Z-Wave import started — track progress in Scan History')
|
||||
onPendingImported?.(null)
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await zwaveApi.importNetwork(buildPayload())
|
||||
setDevices(res.data.nodes)
|
||||
setEdges(res.data.edges)
|
||||
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||
if (res.data.device_count === 0) {
|
||||
toast.info('No Z-Wave devices found')
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(extractError(err) ?? 'Failed to fetch Z-Wave devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCheck = (id: string) =>
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleAll = () => {
|
||||
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const handleAddToCanvas = () => {
|
||||
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||
onAddToCanvas(selectedDevices, selectedEdges)
|
||||
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setDevices([])
|
||||
setEdges([])
|
||||
setChecked(new Set())
|
||||
setConnectionStatus('idle')
|
||||
setConnectionMsg('')
|
||||
setImportMode('pending')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const groupedDevices = {
|
||||
zwave_coordinator: devices.filter((d) => d.type === 'zwave_coordinator'),
|
||||
zwave_router: devices.filter((d) => d.type === 'zwave_router'),
|
||||
zwave_enddevice: devices.filter((d) => d.type === 'zwave_enddevice'),
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<RadioTower size={16} style={{ color: ACCENT }} />
|
||||
Z-Wave Import
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||
{/* Connection Form */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||
<Input
|
||||
value={form.mqtt_host}
|
||||
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||
placeholder="192.168.1.x or mqtt.local"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
value={form.mqtt_port}
|
||||
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||
placeholder="1883"
|
||||
type="number"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">MQTT Prefix</Label>
|
||||
<Input
|
||||
value={form.prefix}
|
||||
onChange={(e) => updateField('prefix', e.target.value)}
|
||||
placeholder="zwave"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Gateway Name</Label>
|
||||
<Input
|
||||
value={form.gateway_name}
|
||||
onChange={(e) => updateField('gateway_name', e.target.value)}
|
||||
placeholder="zwavejs2mqtt"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||
<Input
|
||||
value={form.mqtt_username}
|
||||
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||
placeholder="mqtt_user"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||
<Input
|
||||
value={form.mqtt_password}
|
||||
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.mqtt_tls}
|
||||
onChange={(e) => toggleTls(e.target.checked)}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Use TLS (port 8883)
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.mqtt_tls_insecure}
|
||||
disabled={!form.mqtt_tls}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||
}
|
||||
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
Skip cert verify (self-signed only)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
{connectionStatus !== 'idle' && (
|
||||
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||
connectionStatus === 'ok'
|
||||
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||
: connectionStatus === 'fail'
|
||||
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||
}`}>
|
||||
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Send devices to:</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="zwave-import-mode"
|
||||
checked={importMode === 'pending'}
|
||||
onChange={() => setImportMode('pending')}
|
||||
className="cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Pending section
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="zwave-import-mode"
|
||||
checked={importMode === 'canvas'}
|
||||
onChange={() => setImportMode('canvas')}
|
||||
className="cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Canvas directly
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||
onClick={handleTestConnection}
|
||||
disabled={connectionStatus === 'testing' || loading}
|
||||
>
|
||||
{connectionStatus === 'testing'
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <CheckCircle2 size={13} />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
onClick={handleFetchDevices}
|
||||
disabled={loading || connectionStatus === 'testing'}
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <RadioTower size={13} />}
|
||||
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
Make sure the gateway name matches your Z-Wave JS UI configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device List */}
|
||||
{devices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.size === devices.length}
|
||||
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||
onChange={toggleAll}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
title="Select all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Devices ({checked.size}/{devices.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZwaveNode[]][])
|
||||
.filter(([, group]) => group.length > 0)
|
||||
.map(([type, group]) => {
|
||||
const Icon = DEVICE_TYPE_ICON[type]
|
||||
const color = DEVICE_TYPE_COLOR[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon size={11} style={{ color }} />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||
</span>
|
||||
</div>
|
||||
{group.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||
checked.has(device.id)
|
||||
? 'bg-[#21262d] border-[#ff6e00]/40'
|
||||
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||
}`}
|
||||
onClick={() => toggleCheck(device.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(device.id)}
|
||||
onChange={() => toggleCheck(device.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||
{(device.model || device.vendor) && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||
{devices.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAddToCanvas}
|
||||
disabled={checked.size === 0}
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add {checked.size} to Canvas
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ZwaveImportModal } from '../ZwaveImportModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
zwaveApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
importToPending: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
import { zwaveApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddToCanvas: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleNodes = [
|
||||
{
|
||||
id: 'zwave-0xh-1',
|
||||
label: 'Controller',
|
||||
type: 'zwave_coordinator' as const,
|
||||
ieee_address: 'zwave-0xh-1',
|
||||
friendly_name: 'Controller',
|
||||
device_type: 'Controller',
|
||||
model: null,
|
||||
vendor: null,
|
||||
lqi: null,
|
||||
parent_id: null,
|
||||
},
|
||||
{
|
||||
id: 'zwave-0xh-2',
|
||||
label: 'Wall Plug',
|
||||
type: 'zwave_router' as const,
|
||||
ieee_address: 'zwave-0xh-2',
|
||||
friendly_name: 'Wall Plug',
|
||||
device_type: 'Router',
|
||||
model: 'ZW100',
|
||||
vendor: 'Aeotec',
|
||||
lqi: null,
|
||||
parent_id: 'zwave-0xh-1',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ZwaveImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zwaveApi.testConnection).mockReset()
|
||||
vi.mocked(zwaveApi.importNetwork).mockReset()
|
||||
vi.mocked(zwaveApi.importToPending).mockReset()
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
vi.mocked(toast.info).mockReset()
|
||||
defaultProps.onClose.mockReset()
|
||||
defaultProps.onAddToCanvas.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ZwaveImportModal {...defaultProps} open={false} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the modal with prefix and gateway fields when open', () => {
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
expect(screen.getByText('Z-Wave Import')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('zwave')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('zwavejs2mqtt')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error toast when testing connection without a host', async () => {
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||
})
|
||||
expect(zwaveApi.testConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success status when connection test passes', async () => {
|
||||
vi.mocked(zwaveApi.testConnection).mockResolvedValue({
|
||||
data: { connected: true, message: 'Connection successful' },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
const selectCanvasMode = () => {
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
}
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Controller')).toBeDefined()
|
||||
expect(screen.getByText('Wall Plug')).toBeDefined()
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||
})
|
||||
|
||||
it('passes prefix and gateway_name to importNetwork', async () => {
|
||||
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: [], edges: [], device_count: 0 },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '10.0.0.5' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('zwave'), { target: { value: 'myzw' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('zwavejs2mqtt'), { target: { value: 'gw1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => expect(zwaveApi.importNetwork).toHaveBeenCalled())
|
||||
const payload = vi.mocked(zwaveApi.importNetwork).mock.calls[0][0]
|
||||
expect(payload.prefix).toBe('myzw')
|
||||
expect(payload.gateway_name).toBe('gw1')
|
||||
})
|
||||
|
||||
it('imports to pending by default and notifies parent', async () => {
|
||||
vi.mocked(zwaveApi.importToPending).mockResolvedValue({
|
||||
data: {
|
||||
id: 'run-1',
|
||||
status: 'running',
|
||||
kind: 'zwave',
|
||||
ranges: ['192.168.1.100:1883'],
|
||||
devices_found: 0,
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
finished_at: null,
|
||||
error: null,
|
||||
},
|
||||
} as never)
|
||||
const onPendingImported = vi.fn()
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(zwaveApi.importToPending).toHaveBeenCalled()
|
||||
expect(onPendingImported).toHaveBeenCalled()
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
expect(zwaveApi.importNetwork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||
vi.mocked(zwaveApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [{ source: 'zwave-0xh-1', target: 'zwave-0xh-2' }], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
selectCanvasMode()
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), {
|
||||
target: { value: '192.168.1.100' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => screen.getByText('Controller'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add.*canvas/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(<ZwaveImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Shared Z-Wave type definitions for the frontend. */
|
||||
|
||||
export interface ZwaveNode {
|
||||
id: string
|
||||
label: string
|
||||
type: 'zwave_coordinator' | 'zwave_router' | 'zwave_enddevice'
|
||||
ieee_address: string
|
||||
friendly_name: string
|
||||
device_type: string
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
parent_id?: string | null
|
||||
}
|
||||
|
||||
export interface ZwaveEdge {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface ZwaveImportResponse {
|
||||
nodes: ZwaveNode[]
|
||||
edges: ZwaveEdge[]
|
||||
device_count: number
|
||||
}
|
||||
|
||||
export interface ZwaveTestConnectionRequest {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
}
|
||||
|
||||
export interface ZwaveTestConnectionResponse {
|
||||
connected: boolean
|
||||
message: string
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export type NodeType =
|
||||
| 'zigbee_coordinator'
|
||||
| 'zigbee_router'
|
||||
| 'zigbee_enddevice'
|
||||
| 'zwave_coordinator'
|
||||
| 'zwave_router'
|
||||
| 'zwave_enddevice'
|
||||
| 'grid'
|
||||
| 'ups'
|
||||
| 'battery'
|
||||
@@ -187,6 +190,9 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
zigbee_coordinator: 'Zigbee Coordinator',
|
||||
zigbee_router: 'Zigbee Router',
|
||||
zigbee_enddevice: 'Zigbee End Device',
|
||||
zwave_coordinator: 'Z-Wave Controller',
|
||||
zwave_router: 'Z-Wave Router',
|
||||
zwave_enddevice: 'Z-Wave End Device',
|
||||
grid: 'Grid Connection',
|
||||
ups: 'UPS',
|
||||
battery: 'Battery',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isZwaveType, buildZwaveProperties } from '../zwaveProperties'
|
||||
|
||||
describe('isZwaveType', () => {
|
||||
it('returns true for zwave types', () => {
|
||||
expect(isZwaveType('zwave_coordinator')).toBe(true)
|
||||
expect(isZwaveType('zwave_router')).toBe(true)
|
||||
expect(isZwaveType('zwave_enddevice')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-zwave types', () => {
|
||||
expect(isZwaveType('zigbee_router')).toBe(false)
|
||||
expect(isZwaveType('server')).toBe(false)
|
||||
expect(isZwaveType(undefined)).toBe(false)
|
||||
expect(isZwaveType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildZwaveProperties', () => {
|
||||
it('builds Z-Wave ID / Vendor / Model rows, all hidden', () => {
|
||||
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: 'Aeotec', model: 'ZW100' })
|
||||
expect(props).toEqual([
|
||||
{ key: 'Z-Wave ID', value: 'zwave-0xh-2', icon: null, visible: false },
|
||||
{ key: 'Vendor', value: 'Aeotec', icon: null, visible: false },
|
||||
{ key: 'Model', value: 'ZW100', icon: null, visible: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits empty fields', () => {
|
||||
const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: null, model: undefined })
|
||||
expect(props.map((p) => p.key)).toEqual(['Z-Wave ID'])
|
||||
})
|
||||
|
||||
it('never adds an LQI row', () => {
|
||||
const props = buildZwaveProperties({ ieee_address: 'x', vendor: 'v', model: 'm' })
|
||||
expect(props.some((p) => p.key === 'LQI')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
// Security & Auth
|
||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||
// Automation & IoT
|
||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
|
||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, RadioTower, Share2, BotMessageSquare, Webhook,
|
||||
// Smart Home / Sensors
|
||||
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
|
||||
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
|
||||
@@ -178,6 +178,9 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
zigbee_coordinator: Radio,
|
||||
zigbee_router: Zap,
|
||||
zigbee_enddevice: Lightbulb,
|
||||
zwave_coordinator: RadioTower,
|
||||
zwave_router: Share2,
|
||||
zwave_enddevice: Lightbulb,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeProperty, NodeType } from '@/types'
|
||||
|
||||
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
|
||||
|
||||
export function isZwaveType(type: NodeType | string | undefined | null): boolean {
|
||||
return !!type && (ZWAVE_TYPES as string[]).includes(type)
|
||||
}
|
||||
|
||||
/** Build the Z-Wave ID/Vendor/Model property rows shown in the right panel.
|
||||
* Matches backend `build_zwave_properties` (no LQI — Z-Wave has none). */
|
||||
export function buildZwaveProperties(input: {
|
||||
ieee_address?: string | null
|
||||
vendor?: string | null
|
||||
model?: string | null
|
||||
}): NodeProperty[] {
|
||||
const props: NodeProperty[] = []
|
||||
if (input.ieee_address) props.push({ key: 'Z-Wave ID', value: input.ieee_address, icon: null, visible: false })
|
||||
if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false })
|
||||
if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false })
|
||||
return props
|
||||
}
|
||||
Reference in New Issue
Block a user