"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const bind_decorator_1 = __importDefault(require("bind-decorator"));
const logger_1 = __importDefault(require("../util/logger"));
const settings = __importStar(require("../util/settings"));
const stringify_1 = require("../util/stringify");
const utils_1 = __importDefault(require("../util/utils"));
const extension_1 = __importDefault(require("./extension"));
const SUPPORTED_FORMATS = ["raw", "graphviz", "plantuml"];
/**
 * This extension creates a network map
 */
class NetworkMap extends extension_1.default {
    #topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
    // biome-ignore lint/suspicious/useAwait: API
    async start() {
        this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
    }
    async onMQTTMessage(data) {
        if (data.topic === this.#topic) {
            const message = utils_1.default.parseJSON(data.message, data.message);
            try {
                const type = typeof message === "object" ? message.type : message;
                if (!SUPPORTED_FORMATS.includes(type)) {
                    throw new Error(`Type '${type}' not supported, allowed are: ${SUPPORTED_FORMATS.join(",")}`);
                }
                const routes = typeof message === "object" && message.routes;
                const topology = await this.networkScan(routes);
                let responseData;
                switch (type) {
                    case "raw": {
                        responseData = { type, routes, value: this.raw(topology) };
                        break;
                    }
                    case "graphviz": {
                        responseData = { type, routes, value: this.graphviz(topology) };
                        break;
                    }
                    case "plantuml": {
                        responseData = { type, routes, value: this.plantuml(topology) };
                        break;
                    }
                }
                await this.mqtt.publish("bridge/response/networkmap", (0, stringify_1.stringify)(utils_1.default.getResponse(message, responseData)));
            }
            catch (error) {
                await this.mqtt.publish("bridge/response/networkmap", (0, stringify_1.stringify)(utils_1.default.getResponse(message, {}, error.message)));
            }
        }
    }
    raw(topology) {
        return topology;
    }
    graphviz(topology) {
        const colors = settings.get().map_options.graphviz.colors;
        let text = "digraph G {\nnode[shape=record];\n";
        let style = "";
        for (const node of topology.nodes) {
            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} (${utils_1.default.toNetworkAddressHex(node.networkAddress)})${node.failed?.length ? `failed: ${node.failed.join(",")}` : ""}`);
            // Add the device model
            if (node.type !== "Coordinator") {
                labels.push(`${node.definition?.vendor} ${node.definition?.description} (${node.definition?.model})`);
            }
            // Add the device last_seen timestamp
            let lastSeen = "unknown";
            const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
            if (date) {
                lastSeen = utils_1.default.formatDate(date, "relative");
            }
            labels.push(lastSeen);
            // Escape backslashes and double-quotes to avoid breaking Graphviz .dot files.
            const escapedLabels = labels.map((label) => label.replace(/\\/g, "\\\\").replace(/"/g, '\\"'));
            // 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="{${escapedLabels.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.
             */
            for (const link of topology.links) {
                if (link.source.ieeeAddr === node.ieeeAddr) {
                    const lineStyle = node.type === "EndDevice" ? "penwidth=1, " : !link.routes.length ? "penwidth=0.5, " : "penwidth=2, ";
                    const lineWeight = !link.routes.length
                        ? `weight=0, color="${colors.line.inactive}", `
                        : `weight=1, color="${colors.line.active}", `;
                    const textRoutes = link.routes.map((r) => utils_1.default.toNetworkAddressHex(r.destinationAddress));
                    const lineLabels = !link.routes.length
                        ? `label="${link.linkquality}"`
                        : `label="${link.linkquality} (routes: ${textRoutes.join(",")})"`;
                    text += `  "${node.ieeeAddr}" -> "${link.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");
        for (const node of topology.nodes.sort((a, b) => a.friendlyName.localeCompare(b.friendlyName))) {
            // 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} (${utils_1.default.toNetworkAddressHex(node.networkAddress)})${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;
                text.push(`${definition?.vendor} ${definition?.description} (${definition?.model})`);
            }
            // Add the device last_seen timestamp
            let lastSeen = "unknown";
            const date = node.type === "Coordinator" ? Date.now() : node.lastSeen;
            if (date) {
                lastSeen = utils_1.default.formatDate(date, "relative");
            }
            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.
         */
        for (const link of topology.links) {
            text.push(`${link.sourceIeeeAddr} --> ${link.targetIeeeAddr}: ${link.lqi}`);
        }
        text.push("");
        text.push("@enduml");
        return text.join("\n");
    }
    async networkScan(includeRoutes) {
        logger_1.default.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
        const lqis = new Map();
        const routingTables = new Map();
        const failed = new Map();
        const requestWithRetry = async (request) => {
            try {
                const result = await request();
                return result;
            }
            catch {
                // Network is possibly congested, sleep 5 seconds to let the network settle.
                await utils_1.default.sleep(5);
                return await request();
            }
        };
        for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower" && d.type !== "EndDevice")) {
            if (device.options.disabled) {
                continue;
            }
            const deviceFailures = [];
            failed.set(device, deviceFailures);
            await utils_1.default.sleep(1); // sleep 1 second between each scan to reduce stress on network.
            try {
                const result = await requestWithRetry(async () => await device.zh.lqi());
                lqis.set(device, result);
                logger_1.default.debug(`LQI succeeded for '${device.name}'`);
            }
            catch (error) {
                deviceFailures.push("lqi"); // set above
                logger_1.default.error(`Failed to execute LQI for '${device.name}'`);
                // biome-ignore lint/style/noNonNullAssertion: always Error
                logger_1.default.debug(error.stack);
            }
            if (includeRoutes) {
                try {
                    const result = await requestWithRetry(async () => await device.zh.routingTable());
                    routingTables.set(device, result);
                    logger_1.default.debug(`Routing table succeeded for '${device.name}'`);
                }
                catch (error) {
                    deviceFailures.push("routingTable"); // set above
                    logger_1.default.error(`Failed to execute routing table for '${device.name}'`);
                    // biome-ignore lint/style/noNonNullAssertion: always Error
                    logger_1.default.debug(error.stack);
                }
            }
        }
        logger_1.default.info("Network scan finished");
        const topology = { nodes: [], links: [] };
        // XXX: display GP/disabled devices in the map, better feedback than just hiding them?
        for (const device of this.zigbee.devicesIterator((d) => d.type !== "GreenPower")) {
            if (device.options.disabled) {
                continue;
            }
            // Add nodes
            const definition = device.definition
                ? {
                    model: device.definition.model,
                    vendor: device.definition.vendor,
                    description: device.definition.description,
                    supports: Array.from(new Set(device.exposes().map((e) => {
                        return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(", ")})`;
                    }))).join(", "),
                }
                : undefined;
            topology.nodes.push({
                ieeeAddr: device.ieeeAddr,
                friendlyName: device.name,
                type: device.zh.type,
                networkAddress: device.zh.networkAddress,
                manufacturerName: device.zh.manufacturerName,
                modelID: device.zh.modelID,
                failed: failed.get(device),
                lastSeen: device.zh.lastSeen,
                definition,
            });
        }
        // Add links
        for (const [device, table] of lqis) {
            for (const neighbor of table) {
                if (neighbor.relationship > 3) {
                    // Relationship is not active, skip it
                    continue;
                }
                let neighborEui64 = neighbor.eui64;
                // Some Xiaomi devices return 0x00 as the neighbor ieeeAddr (obviously not correct).
                // Determine the correct ieeeAddr based on the networkAddress.
                if (neighborEui64 === "0x0000000000000000") {
                    const neighborDevice = this.zigbee.deviceByNetworkAddress(neighbor.nwkAddress);
                    if (neighborDevice) {
                        neighborEui64 = neighborDevice.ieeeAddr;
                    }
                }
                const link = {
                    source: { ieeeAddr: neighborEui64, networkAddress: neighbor.nwkAddress },
                    target: { ieeeAddr: device.ieeeAddr, networkAddress: device.zh.networkAddress },
                    deviceType: neighbor.deviceType,
                    rxOnWhenIdle: neighbor.rxOnWhenIdle,
                    relationship: neighbor.relationship,
                    permitJoining: neighbor.permitJoining,
                    depth: neighbor.depth,
                    lqi: neighbor.lqi,
                    routes: [],
                    // below are @deprecated
                    sourceIeeeAddr: neighborEui64,
                    targetIeeeAddr: device.ieeeAddr,
                    sourceNwkAddr: neighbor.nwkAddress,
                    linkquality: neighbor.lqi,
                };
                const routingTable = routingTables.get(device);
                if (routingTable) {
                    for (const entry of routingTable) {
                        if (entry.nextHopAddress === neighbor.nwkAddress) {
                            link.routes.push(entry);
                        }
                    }
                }
                topology.links.push(link);
            }
        }
        return topology;
    }
}
exports.default = NetworkMap;
__decorate([
    bind_decorator_1.default
], NetworkMap.prototype, "onMQTTMessage", null);
//# sourceMappingURL=data:application/json;base64, 