zigbee2mqtt/lib/extension/networkMap.js
Kosta a2ea520b0c
add support for plantuml based network graphs (#3742)
Add support for [PlantUML](http://plantuml.com/guide) based network graphs, which can be pasted into [this online editor](https://www.planttext.com/).

The test configuration used for unit testing for example produces this output:

![output.svg](https://www.planttext.com/api/plantuml/svg/j5LTRzem57tVhxYFGSFbEbyG7gOoOBHggrMCQPsg2Zc9KwmwSPeufTjVsyD-qlx20dICZuJni4PHPDdtd7lkyRNpwyTFU7bESyqoNSJopP-PW6KXu3CYRNSwID7cjkEO3jok8_ONrqk4a0DXOBYEvJOWOaBoi0loKJGpLMYHt4-OBtabpNl58qYK_bIagSeq3wzKoENve3AOUcqaICjtWhIBs4NwTZbOHzojH_iLe--qeoZYWSiStzoes2-aNrlZ3igmqNAzHcNu-SMm1vDnR_1XV0wNmy6I68QcsngQRV0w2BA8UTA4KCJnmf4cp6T2SyXJ7kYiY9kWKYPV9esIWJgsCw9cTRh_w8QruyOqK59bntbYBJtnQZovdWafqXpE2WuZ1KQRsYwwU7rM7Lua3ucQ9qTLiDLzTAi2hLKo3LLHUZnzuD-EQs2wRE1EjR0RmLGieFV8CWOhPYYPOIuoBdcUmvn92VbSw606nfURYn6QbrUliN6RcXrle-mWC1qBfuiZn-ltRWTUbcnZjSn-aMiLGyYVHC1pC0RYQmDk-_r55bXbkJDcMgVTLYlLJ-f4995ghLGGd-Ky9D_5lSyv2PJAGf4mhJB2af2im8HIizK0OyLuClxuUJ8SlufZfkqllaXjc_4Dn_f_TTNxjGqsQipQKqZntmehpX8XxohITg4S7RgMwc2UUKooRR8o6SL3FLCBmtfOURhM6hVOIfY5-zQSyYHS_BT-0m00__y30000)

The generated script looks like this:

```
' paste into: https://www.planttext.com/

@startuml
card 0x0017880104e45525 [
0x0017880104e45525
---
0x0017880104e45525 (6536) failed: lqi,routingTable
---
Boef notSupportedModelID
---
1970-01-01T01:00:01+01:00
]

card 0x000b57fffec6a5b2 [
bulb
---
0x000b57fffec6a5b2 (40369)
---
IKEA TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white (LED1545G12)
---
1970-01-01T01:00:01+01:00
]

card 0x000b57fffec6a5b3 [
bulb_color
---
0x000b57fffec6a5b3 (40399)
---
Philips Hue Go (7146060PH)
---
unknown
]

card 0x0017880104e45521 [
button_double_key
---
0x0017880104e45521 (6538)
---
Xiaomi Aqara double key wireless wall switch (WXKG02LM)
---
1970-01-01T01:00:01+01:00
]

card 0x0017880104e45559 [
cc2530_router
---
0x0017880104e45559 (6540)
---
Custom devices (DiY) [CC2530 router](http://ptvo.info/cc2530-based-zigbee-coordinator-and-router-112/) (CC2530.ROUTER)
---
1970-01-01T01:00:01+01:00
]

card 0x00124b00120144ae [
Coordinator
---
0x00124b00120144ae (0)
---
1970-01-01T01:00:10+01:00
]

0x000b57fffec6a5b3 --> 0x00124b00120144ae: 120
0x000b57fffec6a5b2 --> 0x00124b00120144ae: 92
0x000b57fffec6a5b3 --> 0x000b57fffec6a5b2: 110
0x0017880104e45559 --> 0x000b57fffec6a5b2: 100
0x0017880104e45521 --> 0x0017880104e45559: 130

@enduml
```

Co-authored-by: Konstantin Baumann <konstantin.baumann@autodesk.com>
2020-06-14 15:48:50 +02:00

266 lines
11 KiB
JavaScript

const settings = require('../util/settings');
const utils = require('../util/utils');
const logger = require('../util/logger');
const Extension = require('./extension');
/**
* This extension creates a network map
*/
class NetworkMap extends Extension {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
super(zigbee, mqtt, state, publishEntityState, eventBus);
this.legacyApi = settings.get().advanced.legacy_api;
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
this.legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
// Bind
this.raw = this.raw.bind(this);
this.graphviz = this.graphviz.bind(this);
this.plantuml = this.plantuml.bind(this);
// Set supported formats
this.supportedFormats = {
'raw': this.raw,
'graphviz': this.graphviz,
'plantuml': this.plantuml,
};
}
onMQTTConnected() {
/* istanbul ignore else */
if (this.legacyApi) {
this.mqtt.subscribe(this.legacyTopic);
this.mqtt.subscribe(this.legacyTopicRoutes);
}
}
async onMQTTMessage(topic, message) {
/* istanbul ignore else */
if (this.legacyApi) {
if ((topic === this.legacyTopic || topic === this.legacyTopicRoutes) &&
this.supportedFormats.hasOwnProperty(message)) {
const includeRoutes = topic === this.legacyTopicRoutes;
const topology = await this.networkScan(includeRoutes);
const converted = this.supportedFormats[message](topology);
this.mqtt.publish(`bridge/networkmap/${message}`, converted, {});
}
}
}
raw(topology) {
return JSON.stringify(topology);
}
graphviz(topology) {
const colors = settings.get().map_options.graphviz.colors;
let text = 'digraph G {\nnode[shape=record];\n';
let style = '';
topology.nodes.forEach((node) => {
const labels = [];
// Add friendly name
labels.push(`${node.friendlyName}`);
// Add the device short network address, ieeaddr and scan note (if any)
labels.push(
`${node.ieeeAddr} (${node.networkAddress})` +
((node.failed && node.failed.length) ? `failed: ${node.failed.join(',')}` : ''),
);
// Add the device model
if (node.type !== 'Coordinator') {
const definition = this.zigbee.resolveEntity(node.ieeeAddr).definition;
if (definition) {
labels.push(`${definition.vendor} ${definition.description} (${definition.model})`);
} else {
// This model is not supported by zigbee-herdsman-converters, add zigbee model information
labels.push(`${node.manufacturerName} ${node.modelID}`);
}
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'ISO_8601_local');
}
labels.push(lastSeen);
// Shape the record according to device type
if (node.type == 'Coordinator') {
style = `style="bold, filled", fillcolor="${colors.fill.coordinator}", ` +
`fontcolor="${colors.font.coordinator}"`;
} else if (node.type == 'Router') {
style = `style="rounded, filled", fillcolor="${colors.fill.router}", ` +
`fontcolor="${colors.font.router}"`;
} else {
style = `style="rounded, dashed, filled", fillcolor="${colors.fill.enddevice}", ` +
`fontcolor="${colors.font.enddevice}"`;
}
// Add the device with its labels to the graph as a node.
text += ` "${node.ieeeAddr}" [`+style+`, label="{${labels.join('|')}}"];\n`;
/**
* Add an edge between the device and its child to the graph
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/
topology.links.filter((e) => (e.source.ieeeAddr === node.ieeeAddr)).forEach((e) => {
const lineStyle = (node.type=='EndDevice') ? 'penwidth=1, ' :
(!e.routes.length) ? 'penwidth=0.5, ' : 'penwidth=2, ';
const lineWeight = (!e.routes.length) ? `weight=0, color="${colors.line.inactive}", ` :
`weight=1, color="${colors.line.active}", `;
const textRoutes = e.routes.map((r) => r.destinationAddress);
const lineLabels = (!e.routes.length) ? `label="${e.linkquality}"` :
`label="${e.linkquality} (routes: ${textRoutes.join(',')})"`;
text += ` "${node.ieeeAddr}" -> "${e.target.ieeeAddr}"`;
text += ` [${lineStyle}${lineWeight}${lineLabels}]\n`;
});
});
text += '}';
return text.replace(/\0/g, '');
}
plantuml(topology) {
const text = [];
text.push(`' paste into: https://www.planttext.com/`);
text.push(``);
text.push('@startuml');
topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)).forEach((node) => {
// Add friendly name
text.push(`card ${node.ieeeAddr} [`);
text.push(`${node.friendlyName}`);
text.push(`---`);
// Add the device short network address, ieeaddr and scan note (if any)
text.push(
`${node.ieeeAddr} (${node.networkAddress})` +
((node.failed && node.failed.length) ? ` failed: ${node.failed.join(',')}` : ''),
);
// Add the device model
if (node.type !== 'Coordinator') {
text.push(`---`);
const definition = this.zigbee.resolveEntity(node.ieeeAddr).definition;
if (definition) {
text.push(`${definition.vendor} ${definition.description} (${definition.model})`);
} else {
// This model is not supported by zigbee-herdsman-converters, add zigbee model information
text.push(`${node.manufacturerName} ${node.modelID}`);
}
}
// Add the device last_seen timestamp
let lastSeen = 'unknown';
const date = node.type === 'Coordinator' ? Date.now() : node.lastSeen;
if (date) {
lastSeen = utils.formatDate(date, 'ISO_8601_local');
}
text.push(`---`);
text.push(lastSeen);
text.push(`]`);
text.push(``);
});
/**
* Add edges between the devices
* NOTE: There are situations where a device is NOT in the topology, this can be e.g.
* due to not responded to the lqi scan. In that case we do not add an edge for this device.
*/
topology.links.forEach((link) => {
text.push(`${link.sourceIeeeAddr} --> ${link.targetIeeeAddr}: ${link.lqi}`);
});
text.push('');
text.push(`@enduml`);
return text.join(`\n`);
}
async networkScan(includeRoutes) {
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
const devices = this.zigbee.getDevices().filter((d) => d.type !== 'GreenPower');
const lqis = new Map();
const routingTables = new Map();
const failed = new Map();
for (const device of devices.filter((d) => d.type != 'EndDevice')) {
failed.set(device, []);
const resolvedEntity = this.zigbee.resolveEntity(device);
try {
const result = await device.lqi();
lqis.set(device, result);
logger.debug(`LQI succeeded for '${resolvedEntity.name}'`);
} catch (error) {
failed.get(device).push('lqi');
logger.error(`Failed to execute LQI for '${resolvedEntity.name}'`);
}
if (includeRoutes) {
try {
const result = await device.routingTable();
routingTables.set(device, result);
logger.debug(`Routing table succeeded for '${resolvedEntity.name}'`);
} catch (error) {
failed.get(device).push('routingTable');
logger.error(`Failed to execute routing table for '${resolvedEntity.name}'`);
}
}
}
logger.info(`Network scan finished`);
const networkMap = {nodes: [], links: []};
// Add nodes
for (const device of devices) {
const resolvedEntity = this.zigbee.resolveEntity(device);
networkMap.nodes.push({
ieeeAddr: device.ieeeAddr, friendlyName: resolvedEntity.name, type: device.type,
networkAddress: device.networkAddress, manufacturerName: device.manufacturerName,
modelID: device.modelID, failed: failed.get(device), lastSeen: device.lastSeen,
});
}
// Add links
lqis.forEach((lqi, device) => {
for (const neighbor of lqi.neighbors) {
if (neighbor.relationship > 3) {
// Relationship is not active, skip it
continue;
}
const link = {
source: {ieeeAddr: neighbor.ieeeAddr, networkAddress: neighbor.networkAddress},
target: {ieeeAddr: device.ieeeAddr, networkAddress: device.networkAddress},
linkquality: neighbor.linkquality, depth: neighbor.depth, routes: [],
// DEPRECATED:
sourceIeeeAddr: neighbor.ieeeAddr, targetIeeeAddr: device.ieeeAddr,
sourceNwkAddr: neighbor.networkAddress, lqi: neighbor.linkquality,
relationship: neighbor.relationship,
};
const routingTable = routingTables.get(device);
if (routingTable) {
link.routes = routingTable.table
.filter((t) => t.status === 'ACTIVE' && t.nextHop === neighbor.networkAddress);
}
networkMap.links.push(link);
}
});
return networkMap;
}
}
module.exports = NetworkMap;