feat: add Zigbee2MQTT network map importer
- Backend: async MQTT service (aiomqtt) to fetch Z2M networkmap via bridge API - Backend: FastAPI router at /api/v1/zigbee with /import and /test-connection - Backend: Pydantic v2 schemas for request/response validation - Backend: coordinator → router → end-device parent_id hierarchy builder - Frontend: ZigbeeImportModal with MQTT config form, Test Connection, Fetch Devices - Frontend: device list grouped by type (coordinator/router/enddevice) with checkboxes - Frontend: ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode canvas nodes - Frontend: Zigbee Import button in sidebar alongside Scan Network - Frontend: handleZigbeeAddToCanvas wires selected devices + edges onto canvas - Tests: full unit test suite for parser, hierarchy builder, MQTT mocks - Tests: API endpoint tests for /zigbee/import and /zigbee/test-connection - Tests: Vitest component tests for ZigbeeImportModal - Docs: docs/zigbee-import.md with full usage, MQTT config, troubleshooting guide - Docs: README.md Zigbee2MQTT Import section Co-authored-by: CyberKeys <noreply@openclaw.ai>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
import { useState } from 'react'
|
||||
import { Network, Router, 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 { zigbeeApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from './types'
|
||||
|
||||
interface ZigbeeImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||
}
|
||||
|
||||
interface ConnectionForm {
|
||||
mqtt_host: string
|
||||
mqtt_port: string
|
||||
mqtt_username: string
|
||||
mqtt_password: string
|
||||
base_topic: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: ConnectionForm = {
|
||||
mqtt_host: '',
|
||||
mqtt_port: '1883',
|
||||
mqtt_username: '',
|
||||
mqtt_password: '',
|
||||
base_topic: 'zigbee2mqtt',
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_ICON = {
|
||||
zigbee_coordinator: Network,
|
||||
zigbee_router: Router,
|
||||
zigbee_enddevice: Cpu,
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_LABEL = {
|
||||
zigbee_coordinator: 'Coordinator',
|
||||
zigbee_router: 'Router',
|
||||
zigbee_enddevice: 'End Device',
|
||||
} as const
|
||||
|
||||
const DEVICE_TYPE_COLOR = {
|
||||
zigbee_coordinator: '#00d4ff',
|
||||
zigbee_router: '#39d353',
|
||||
zigbee_enddevice: '#e3b341',
|
||||
} as const
|
||||
|
||||
export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImportModalProps) {
|
||||
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<ZigbeeNode[]>([])
|
||||
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
|
||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
|
||||
const buildPayload = () => ({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || 1883,
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
base_topic: form.base_topic.trim() || 'zigbee2mqtt',
|
||||
})
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setConnectionStatus('testing')
|
||||
try {
|
||||
const res = await zigbeeApi.testConnection({
|
||||
mqtt_host: form.mqtt_host.trim(),
|
||||
mqtt_port: Number(form.mqtt_port) || 1883,
|
||||
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||
mqtt_password: form.mqtt_password || undefined,
|
||||
})
|
||||
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 handleFetchDevices = async () => {
|
||||
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await zigbeeApi.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 Zigbee devices found in the network map')
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: undefined
|
||||
toast.error(msg ?? 'Failed to fetch Zigbee 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('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const groupedDevices = {
|
||||
zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'),
|
||||
zigbee_router: devices.filter((d) => d.type === 'zigbee_router'),
|
||||
zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_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">
|
||||
<Network size={16} className="text-[#00d4ff]" />
|
||||
Zigbee2MQTT 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">Base Topic</Label>
|
||||
<Input
|
||||
value={form.base_topic}
|
||||
onChange={(e) => updateField('base_topic', e.target.value)}
|
||||
placeholder="zigbee2mqtt"
|
||||
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"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</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 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: '#00d4ff', color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
onClick={handleFetchDevices}
|
||||
disabled={loading || connectionStatus === 'testing'}
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||
Fetch Devices
|
||||
</Button>
|
||||
</div>
|
||||
</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 accent-[#00d4ff] cursor-pointer"
|
||||
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, ZigbeeNode[]][])
|
||||
.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-[#00d4ff]/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 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||
/>
|
||||
<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>
|
||||
{device.lqi != null && (
|
||||
<span
|
||||
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
|
||||
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
|
||||
>
|
||||
LQI {device.lqi}
|
||||
</span>
|
||||
)}
|
||||
</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: '#00d4ff', color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add {checked.size} to Canvas
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ZigbeeImportModal } from '../ZigbeeImportModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
zigbeeApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
import { zigbeeApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddToCanvas: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleNodes = [
|
||||
{
|
||||
id: '0x0000',
|
||||
label: 'Coordinator',
|
||||
type: 'zigbee_coordinator' as const,
|
||||
ieee_address: '0x0000',
|
||||
friendly_name: 'Coordinator',
|
||||
device_type: 'Coordinator',
|
||||
model: null,
|
||||
vendor: null,
|
||||
lqi: null,
|
||||
parent_id: null,
|
||||
},
|
||||
{
|
||||
id: '0x0001',
|
||||
label: 'router_1',
|
||||
type: 'zigbee_router' as const,
|
||||
ieee_address: '0x0001',
|
||||
friendly_name: 'router_1',
|
||||
device_type: 'Router',
|
||||
model: 'CC2530',
|
||||
vendor: 'TI',
|
||||
lqi: 200,
|
||||
parent_id: '0x0000',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ZigbeeImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||
vi.mocked(zigbeeApi.importNetwork).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(<ZigbeeImportModal {...defaultProps} open={false} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the modal with form fields when open', () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
expect(screen.getByText('Zigbee2MQTT Import')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('192.168.1.x or mqtt.local')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('1883')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('zigbee2mqtt')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error toast when testing connection without a host', async () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||
})
|
||||
expect(zigbeeApi.testConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success status when connection test passes', async () => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||
data: { connected: true, message: 'Connection successful' },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows failure status when connection test fails', async () => {
|
||||
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||
data: { connected: false, message: 'Connection refused' },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection refused')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches devices and renders them grouped by type', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Coordinator')).toBeDefined()
|
||||
expect(screen.getByText('router_1')).toBeDefined()
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||
})
|
||||
|
||||
it('shows info toast when no devices found', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: [], edges: [], device_count: 0 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
|
||||
} as never)
|
||||
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||
|
||||
await waitFor(() => screen.getByText('Coordinator'))
|
||||
|
||||
// Click "Add N to Canvas" button
|
||||
const addBtn = screen.getByRole('button', { name: /add.*canvas/i })
|
||||
fireEvent.click(addBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(<ZigbeeImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Shared Zigbee type definitions for the frontend. */
|
||||
|
||||
export interface ZigbeeNode {
|
||||
id: string
|
||||
label: string
|
||||
type: 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_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 ZigbeeEdge {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface ZigbeeImportResponse {
|
||||
nodes: ZigbeeNode[]
|
||||
edges: ZigbeeEdge[]
|
||||
device_count: number
|
||||
}
|
||||
|
||||
export interface ZigbeeTestConnectionRequest {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
}
|
||||
|
||||
export interface ZigbeeTestConnectionResponse {
|
||||
connected: boolean
|
||||
message: string
|
||||
}
|
||||
Reference in New Issue
Block a user