UNPKG

homebridge-plugin-utils

Version:

Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.

307 lines 11.8 kB
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved. * * mqttclient.ts: MQTT connectivity class for Homebridge plugins. */ import { connect } from "mqtt"; import util from "node:util"; const MQTT_DEFAULT_RECONNECT_INTERVAL = 60; /** * MQTT connectivity and topic management class for Homebridge plugins. * * This class manages connection, publishing, subscription, and message handling for an MQTT broker, and provides convenience methods for Homebridge accessories to * interact with MQTT topics using a standard topic prefix. * * @example * * ```ts * const mqtt = new MqttClient("mqtt://localhost:1883", "homebridge", log); * * // Publish a message to a topic. * mqtt.publish("device1", "status", "on"); * * // Subscribe to a topic. * mqtt.subscribe("device1", "status", (msg) => { * * console.log(msg.toString()); * }); * * // Subscribe to a 'get' topic and automatically publish a value in response. * mqtt.subscribeGet("device1", "temperature", "Temperature", () => "21.5"); * * // Subscribe to a 'set' topic and handle value changes. * mqtt.subscribeSet("device1", "switch", "Switch", (value) => { * * console.log("Switch set to", value); * }); * * // Unsubscribe from a topic. * mqtt.unsubscribe("device1", "status"); * ``` */ export class MqttClient { brokerUrl; isConnected; reconnectInterval; log; mqtt; subscriptions; topicPrefix; /** * Creates a new MQTT client for connecting to a broker and managing topics with a given prefix. * * @param brokerUrl - The MQTT broker URL (e.g., "mqtt://localhost:1883"). * @param topicPrefix - Prefix to use for all MQTT topics (e.g., "homebridge"). * @param log - Logger for debug and info messages. * @param reconnectInterval - Optional. Interval (in seconds) to wait between reconnection attempts. Defaults to 60 seconds. * * @example * * ```ts * const mqtt = new MqttClient("mqtt://localhost", "homebridge", log); * ``` * * @remarks URL must conform to formats supported by {@link https://github.com/mqttjs/MQTT.js | MQTT.js}. */ constructor(brokerUrl, topicPrefix, log, reconnectInterval = MQTT_DEFAULT_RECONNECT_INTERVAL) { this.brokerUrl = brokerUrl; this.isConnected = false; this.log = log; this.mqtt = null; this.reconnectInterval = reconnectInterval; this.subscriptions = {}; this.topicPrefix = topicPrefix; this.configure(); } /** * Initializes and connects the MQTT client to the broker, setting up event handlers for connection, messages, and errors. * * Catches invalid broker URLs and logs errors. Handles all major MQTT client events internally. */ configure() { // Try to connect to the MQTT broker and make sure we catch any URL errors. try { this.mqtt = connect(this.brokerUrl, { reconnectPeriod: this.reconnectInterval * 1000, rejectUnauthorized: false }); } catch (error) { if (error instanceof Error) { switch (error.message) { case "Missing protocol": this.log.error("MQTT Broker: Invalid URL provided: %s.", this.brokerUrl); break; default: this.log.error("MQTT Broker: Error: %s.", error.message); break; } } } // We've been unable to even attempt to connect. It's likely we have a configuration issue - we're done here. if (!this.mqtt) { return; } // Notify the user when we connect to the broker. this.mqtt.on("connect", () => { this.isConnected = true; // Inform users, while redacting authentication credentials. this.log.info("MQTT Broker: Connected to %s (topic: %s).", this.brokerUrl.replace(/^(.*:\/\/.*:)(.*)(@.*)$/, "$1REDACTED$3"), this.topicPrefix); }); // Notify the user when we've disconnected. this.mqtt.on("close", () => { // We only inform users if we're already connected. Otherwise, we're likely in an error state and that's logged elsewhere. if (!this.isConnected) { return; } this.isConnected = false; // Inform users. this.log.info("MQTT Broker: Connection closed."); }); // Process inbound messages and pass it to the right message handler. this.mqtt.on("message", (topic, message) => { this.subscriptions[topic]?.(message); }); // Notify the user when there's a connectivity error. this.mqtt.on("error", (error) => { const logError = (message) => this.log.error("MQTT Broker: %s. Will retry again in %s minute%s.", message, this.reconnectInterval / 60, this.reconnectInterval / 60 > 1 ? "s" : ""); switch (error.code) { case "ECONNREFUSED": logError("Connection refused"); break; case "ECONNRESET": logError("Connection reset"); break; case "ENOTFOUND": this.mqtt?.end(true); this.log.error("MQTT Broker: Hostname or IP address not found."); break; default: logError(util.inspect(error, { sorted: true })); break; } }); } /** * Publishes a message to a topic for a specific device. * * Expands the topic using the topic prefix and device ID, then publishes the provided message string. * * @param id - The device or accessory identifier. * @param topic - The topic name to publish to. * @param message - The message payload to publish. * * @example * * ```ts * mqtt.publish("device1", "status", "on"); * ``` */ publish(id, topic, message) { const expandedTopic = this.expandTopic(id, topic); // No valid topic returned, we're done. if (!expandedTopic) { return; } this.log.debug("MQTT publish: %s Message: %s.", expandedTopic, message); // By default, we publish as: pluginTopicPrefix/id/topic this.mqtt?.publish(expandedTopic, message); } /** * Subscribes to a topic for a specific device and registers a handler for incoming messages. * * The topic is expanded using the prefix and device ID, and the callback will be called for each message received. * * @param id - The device or accessory identifier. * @param topic - The topic name to subscribe to. * @param callback - Handler function called with the message buffer. * * @example * * ```ts * mqtt.subscribe("device1", "status", (msg) => { * * console.log(msg.toString()); * }); * ``` */ subscribe(id, topic, callback) { const expandedTopic = this.expandTopic(id, topic); // No valid topic returned, we're done. if (!expandedTopic) { return; } this.log.debug("MQTT subscribe: %s.", expandedTopic); // Add to our callback list. this.subscriptions[expandedTopic] = callback; // Tell MQTT we're subscribing to this event. // By default, we subscribe as: pluginTopicPrefix/id/topic this.mqtt?.subscribe(expandedTopic); } /** * Subscribes to a '<topic>/get' topic and publishes a value in response to "true" messages. * * When a message "true" is received on the '<topic>/get' topic, this method will publish the result of `getValue()` on the main topic. The log will record each status * publication event. * * @param id - The device or accessory identifier. * @param topic - The topic name to use. * @param type - A human-readable label for log messages (e.g., "Temperature"). * @param getValue - Function to get the value to publish as a string. * @param log - Optional logger for status output. Defaults to the class logger. * * @example * * ```ts * mqtt.subscribeGet("device1", "temperature", "Temperature", () => "21.5"); * ``` */ subscribeGet(id, topic, type, getValue, log = this.log) { // Return the current status of a given sensor. this.subscribe(id, topic + "/get", (message) => { const value = message.toString().toLowerCase(); // When we get the right message, we return the system information JSON. if (value !== "true") { return; } this.publish(id, topic, getValue()); log.info("MQTT: %s status published.", type); }); } /** * Subscribes to a '<topic>/set' topic and calls a setter when a message is received. * * The `setValue` function is called with both a normalized value and the raw string. Handles both synchronous and promise-based setters. Logs when set messages are * received and when errors occur. * * @param id - The device or accessory identifier. * @param topic - The topic name to use. * @param type - A human-readable label for log messages (e.g., "Switch"). * @param setValue - Function to call when a value is set. Can be synchronous or return a Promise. * @param log - Optional logger for status output. Defaults to the class logger. * * @example * * ```ts * mqtt.subscribeSet("device1", "switch", "Switch", (value) => { * * console.log("Switch set to", value); * }); * ``` */ subscribeSet(id, topic, type, setValue, log = this.log) { // Return the current status of a given sensor. this.subscribe(id, topic + "/set", async (message) => { const value = message.toString().toLowerCase(); // Set our value and inform the user. try { await setValue(value, message.toString()); log.info("MQTT: set message received for %s: %s.", type, value); } catch (error) { log.error("MQTT: error setting message received for %s: %s. %s", type, value, error); } }); } /** * Unsubscribes from a topic for a specific device, removing its message handler. * * @param id - The device or accessory identifier. * @param topic - The topic name to unsubscribe from. * * @example * * ```ts * mqtt.unsubscribe("device1", "status"); * ``` */ unsubscribe(id, topic) { const expandedTopic = this.expandTopic(id, topic); // No valid topic returned, we're done. if (!expandedTopic) { return; } delete this.subscriptions[expandedTopic]; } /** * Expands a topic string into a fully-formed topic path including the prefix and device ID. * * Returns `null` if the device ID is missing or empty. * * @param id - The device or accessory identifier. * @param topic - The topic name to expand. * * @returns The expanded topic string, or `null` if the ID is missing. * * @example * * ```ts * const topic = mqtt['expandTopic']("device1", "status"); * // topic = "homebridge/device1/status" * ``` */ expandTopic(id, topic) { // No id, we're done. if (!id) { return null; } return this.topicPrefix + "/" + id + "/" + topic; } } //# sourceMappingURL=mqttclient.js.map