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:
Pouzor
2026-06-26 11:19:37 +02:00
parent c6076d133a
commit 8faf5c1c79
23 changed files with 2638 additions and 53 deletions
@@ -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())