fix: accept Zigbee2MQTT raw network maps

Issue: #450
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 14:58:04 +00:00
committed by claude[bot]
parent 411a0a20a9
commit 8935a736cb
33 changed files with 482 additions and 340 deletions
@@ -0,0 +1,68 @@
{
"data": {
"routes": false,
"type": "raw",
"value": {
"links": [
{
"depth": 15,
"deviceType": 1,
"linkquality": 182,
"lqi": 182,
"relationship": 2,
"source": {
"ieeeAddr": "0xc02cedfffe000001",
"networkAddress": 216
},
"sourceIeeeAddr": "0xc02cedfffe000001",
"sourceNwkAddr": 216,
"target": {
"ieeeAddr": "0x187a3efffe000002",
"networkAddress": 0
},
"targetIeeeAddr": "0x187a3efffe000002"
},
{
"depth": 1,
"deviceType": 2,
"lqi": 97,
"source": {
"networkAddress": 43110
},
"target": {
"networkAddress": 216
}
}
],
"nodes": [
{
"failed": [],
"friendlyName": "Coordinator",
"ieeeAddr": "0x187a3efffe000002",
"lastSeen": 1788531603120,
"networkAddress": 0,
"type": "Coordinator"
},
{
"failed": [],
"friendlyName": "Router",
"ieeeAddr": "0xc02cedfffe000001",
"lastSeen": 1788531603000,
"networkAddress": 216,
"type": "Router"
},
{
"failed": [
"routingTable"
],
"friendlyName": "End device",
"ieeeAddr": "0x00158d0000000003",
"lastSeen": 1788531602000,
"networkAddress": 43110,
"type": "EndDevice"
}
]
}
},
"status": "ok"
}
+40 -7
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
mapTopologyNodes, normalizeIeee, normalizeZ2mTopology,
normalizeZhaTopology, resolveTopologyHover,
@@ -11,6 +12,11 @@ import {
readZhaTopology, refreshZ2mTopology, zigbeeTopologyRuntimeSnapshot,
} from '../test-build/zigbee-topology-runtime.js';
const z2mNetworkmapFixture = JSON.parse(readFileSync(
new URL('./fixtures/zigbee2mqtt-networkmap-real-anonymized.json', import.meta.url),
'utf8',
));
const registry = {
revision: 1, authoritative: true, access: 'full', lastSuccess: 1,
devices: {
@@ -63,19 +69,42 @@ test('ZHA normalization keeps directional observations and never infers route ed
assert.equal(topology.obtainedAt, 123);
});
test('Z2M normalization accepts a nested raw JSON value, drops self links and preserves unknown LQI', () => {
test('Z2M normalization accepts a real anonymized camelCase raw network map', () => {
const topology = normalizeZ2mTopology(z2mNetworkmapFixture, 'zigbee2mqtt', 456);
assert.deepEqual(topology.nodes.map(({ ieee, role, available }) => ({ ieee, role, available })), [
{ ieee: '187a3efffe000002', role: 'coordinator', available: undefined },
{ ieee: 'c02cedfffe000001', role: 'router', available: undefined },
{ ieee: '00158d0000000003', role: 'end', available: undefined },
]);
assert.ok(topology.nodes.every((node) => !Object.hasOwn(node, 'available')));
assert.equal(topology.links.length, 2);
assert.deepEqual(
topology.links.map((link) => link.aToB?.lqi ?? link.bToA?.lqi).sort((a, b) => a - b),
[97, 182],
);
assert.deepEqual(topology.warnings, []);
});
test('Z2M normalization keeps snake_case compatibility and prefers flat link IEEE fields', () => {
const topology = normalizeZ2mTopology({ data: { value: JSON.stringify({
nodes: [
{ ieee_address: '00124b0000000001', type: 'Coordinator' },
{ ieee_address: '00124b0000000001', network_address: 1, type: 'Coordinator', failed: true },
{ ieee_address: '00124b0000000002', type: 'Router' },
{ ieee_address: '00124b0000000003', type: 'Router' },
],
links: [
{ source: { ieee_address: '00124b0000000001' }, target: { ieee_address: '00124b0000000002' }, linkquality: 'bad' },
{ sourceIeeeAddr: '00124b0000000001', targetIeeeAddr: '00124b0000000002',
source: { ieee_address: '00124b0000000003' }, target: { ieee_address: '00124b0000000003' }, linkquality: 'bad' },
{ source: { ieee_address: '00124b0000000001' }, target: { ieee_address: '00124b0000000001' }, linkquality: 255 },
],
}) } }, 'zigbee2mqtt', 456);
assert.equal(topology.links.length, 1);
assert.deepEqual([topology.links[0].a, topology.links[0].b], [
'z2m:zigbee2mqtt:00124b0000000001',
'z2m:zigbee2mqtt:00124b0000000002',
]);
assert.equal(topology.links[0].aToB.lqi, undefined);
assert.equal(topology.nodes.find((node) => node.ieee === '00124b0000000001')?.available, false);
assert.ok(topology.warnings.some((item) => item.code === 'self_link'));
});
@@ -161,15 +190,19 @@ test('Z2M runtime verifies retained bridge info, correlates transaction and clea
payload: JSON.stringify({ status: 'ok', transaction: 'foreign' }) });
queueMicrotask(() => listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({
retain: false,
payload: JSON.stringify({ status: 'ok', transaction: request.transaction, data: { value: {
nodes: [{ ieee_address: '00124b0000000001', type: 'Coordinator' }], links: [],
} } }),
payload: JSON.stringify({
...z2mNetworkmapFixture,
transaction: request.transaction,
}),
}));
},
};
await refreshZ2mTopology(hass, 'zigbee2mqtt', 100);
assert.equal(cleanups, 2);
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).states['z2m:zigbee2mqtt'].phase, 'ready');
const snapshot = zigbeeTopologyRuntimeSnapshot(hass);
assert.equal(snapshot.states['z2m:zigbee2mqtt'].phase, 'ready');
assert.equal(snapshot.topologies[0].nodes.length, 3);
assert.equal(snapshot.topologies[0].links.length, 2);
});
test('Z2M runtime rejects a malformed response immediately instead of timing out', async () => {