energy-manager-iot
Version:
Library for energy management in IoT devices via MQTT protocol. Documentation: https://jonhvmp.github.io/energy-manager-iot-docs/
670 lines (669 loc) • 25.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EnergyManager = void 0;
const events_1 = require("events");
const mqtt_handler_1 = require("./mqtt-handler");
const device_registry_1 = require("./device-registry");
const command_1 = require("../types/command");
const status_1 = require("../types/status");
const error_handler_1 = require("../utils/error-handler");
const logger_1 = __importDefault(require("../utils/logger"));
/**
* Main class that manages IoT devices energy via MQTT
*
* This class serves as the central entry point for the library, providing
* device management, command control, and status monitoring functionality.
*
* @remarks
* EnergyManager extends EventEmitter to provide event-based notifications
* for important system events like status updates or connection changes.
*
* @example
* ```ts
* const manager = new EnergyManager({
* topicPrefix: 'home/devices/',
* statusInterval: 30000
* });
*
* await manager.connect('mqtt://broker.example.com');
* manager.registerDevice('temp01', 'Temperature Sensor', DeviceType.SENSOR);
* ```
*/
class EnergyManager extends events_1.EventEmitter {
mqtt;
registry;
topicPrefix;
statusCheckInterval;
autoReconnect;
statusUpdateInterval;
logger = logger_1.default.child('EnergyManager');
/**
* Creates a new Energy Manager instance
*
* @param options - Configuration options for the manager
*/
constructor(options = {}) {
super();
// Default settings
this.topicPrefix = options.topicPrefix || 'device/';
this.autoReconnect = options.autoReconnect !== false;
this.statusUpdateInterval = options.statusInterval || 60000; // 1 minute
// Initialize components
this.mqtt = new mqtt_handler_1.MqttHandler(options.mqttOptions);
this.registry = new device_registry_1.DeviceRegistry();
// Set up MQTT event listeners
this.setupMqttEventListeners();
}
/**
* Connects to the MQTT broker
*
* @param brokerUrl - URL of the MQTT broker to connect to
* @param options - Additional MQTT connection options
* @throws {EnergyManagerError} If connection fails
*/ async connect(brokerUrl, options) {
const connectionId = `conn_${Date.now()}`;
const connLogger = this.logger.withCorrelationId(connectionId);
try {
connLogger.info('Connecting to MQTT broker', { brokerUrl, options });
await this.mqtt.connect(brokerUrl, options);
connLogger.info('Successfully connected to MQTT broker');
// Subscribe to status topics for existing devices
await this.subscribeToAllDeviceStatuses();
// Start periodic status check
this.startStatusCheck();
this.emit('connected');
}
catch (error) {
connLogger.error('Failed to connect to MQTT broker', error);
throw error;
}
}
/**
* Disconnects from the MQTT broker
*
* @throws {EnergyManagerError} If disconnection fails
*/ async disconnect() {
// Stop status checking
this.stopStatusCheck();
try {
this.logger.info('Disconnecting from MQTT broker');
await this.mqtt.disconnect();
this.logger.info('Successfully disconnected from MQTT broker');
this.emit('disconnected');
}
catch (error) {
this.logger.error('Failed to disconnect from MQTT broker', error);
throw error;
}
}
/**
* Registers a new device in the system
*
* @param id - Unique identifier for the device
* @param name - Human-readable device name
* @param type - Type of device from the DeviceType enum
* @param config - Optional device configuration parameters
* @param groups - Optional initial groups to assign the device to
* @returns The newly registered device object
* @throws {EnergyManagerError} If device ID is invalid or already exists
*/ registerDevice(id, name, type, config = {}, groups = []) {
// Create device-specific logger with correlation ID
const deviceLogger = this.logger.withCorrelationId(id);
deviceLogger.info('Registering new device', { id, name, type, groupCount: groups.length });
const device = this.registry.registerDevice(id, name, type, config, groups);
// Subscribe to status topic if connected
if (this.mqtt.isClientConnected()) {
this.subscribeToDeviceStatus(id).catch(err => {
deviceLogger.error('Failed to subscribe to device status topic', err);
});
}
this.emit('deviceRegistered', device);
return device;
}
/**
* Updates an existing device's properties
*
* @param id - ID of the device to update
* @param updates - Object containing properties to update
* @returns The updated device object
* @throws {EnergyManagerError} If device not found or configuration is invalid
*/
updateDevice(id, updates) {
const device = this.registry.updateDevice(id, updates);
this.emit('deviceUpdated', device);
return device;
}
/**
* Removes a device from the system
*
* @param id - ID of the device to remove
* @returns True if device was successfully removed
*/ removeDevice(id) {
const deviceLogger = this.logger.withCorrelationId(id);
deviceLogger.info('Removing device', { deviceId: id });
// Unsubscribe from status topic
if (this.mqtt.isClientConnected()) {
const statusTopic = this.getStatusTopic(id);
deviceLogger.debug('Unsubscribing from device status topic', { topic: statusTopic });
this.mqtt.unsubscribe(statusTopic).catch(err => {
deviceLogger.error('Failed to unsubscribe from status topic', err);
});
}
const result = this.registry.removeDevice(id);
if (result) {
this.emit('deviceRemoved', id);
deviceLogger.info('Device successfully removed');
}
else {
deviceLogger.warn('Device removal failed - device may not exist');
}
return result;
}
/**
* Retrieves a device by its ID
*
* @param id - ID of the device to retrieve
* @returns The device object
* @throws {EnergyManagerError} If device not found
*/
getDevice(id) {
return this.registry.getDevice(id);
}
/**
* Sends a command to a specific device
*
* @param deviceId - ID of the destination device
* @param command - Type of command to send
* @param payload - Optional data to include with the command
* @throws {EnergyManagerError} If device not found or not connected to MQTT
*/ async sendCommand(deviceId, command, payload) {
// Generate request ID for tracking the command through the system
const requestId = this.generateRequestId();
const cmdLogger = this.logger.withCorrelationId(requestId);
cmdLogger.info('Sending command to device', {
deviceId,
commandType: command,
hasPayload: payload !== undefined,
requestId
});
if (!this.registry.hasDevice(deviceId)) {
cmdLogger.warn('Command failed - device not found', { deviceId });
throw new error_handler_1.EnergyManagerError(`Device not found: ${deviceId}`, error_handler_1.ErrorType.DEVICE_NOT_FOUND);
}
if (!this.mqtt.isClientConnected()) {
cmdLogger.error('Command failed - not connected to MQTT broker');
throw new error_handler_1.EnergyManagerError('Not connected to MQTT broker', error_handler_1.ErrorType.CONNECTION);
}
const commandTopic = this.getCommandTopic(deviceId);
const commandObject = {
type: command,
payload,
timestamp: Date.now(),
requestId
};
try {
await this.mqtt.publish(commandTopic, commandObject, { qos: 1 });
cmdLogger.info('Command successfully sent', {
deviceId,
topic: commandTopic,
timestamp: commandObject.timestamp
});
this.emit('commandSent', deviceId, commandObject);
}
catch (error) {
cmdLogger.error('Failed to send command', error);
throw error;
}
}
/**
* Sends a command to a group of devices
*
* @param groupName - Name of the target device group
* @param command - Type of command to send
* @param payload - Optional data to include with the command
* @throws {EnergyManagerError} If group not found or command delivery fails
*/ async sendCommandToGroup(groupName, command, payload) {
// Generate a group operation ID for the entire command
const groupOpId = `group_cmd_${Date.now()}`;
const groupLogger = this.logger.withCorrelationId(groupOpId);
groupLogger.info('Sending command to device group', {
groupName,
commandType: command,
hasPayload: payload !== undefined
});
const deviceIds = this.registry.getDeviceIdsInGroup(groupName);
if (deviceIds.length === 0) {
groupLogger.warn('Group command skipped - group has no devices', { groupName });
return;
}
groupLogger.debug('Preparing to send command to devices in group', {
groupName,
deviceCount: deviceIds.length,
deviceIds
});
const promises = [];
for (const deviceId of deviceIds) {
promises.push(this.sendCommand(deviceId, command, payload));
}
try {
await Promise.all(promises);
groupLogger.info('Command successfully sent to all devices in group', {
groupName,
commandType: command,
deviceCount: deviceIds.length
});
}
catch (error) {
groupLogger.error('Failed to send command to all devices in group', {
groupName,
error,
commandType: command
});
throw new error_handler_1.EnergyManagerError(`Failed to send command to group ${groupName}`, error_handler_1.ErrorType.COMMAND_FAILED, error);
}
}
/**
* Creates a new device group
*
* @param name - Name for the new group
* @returns True if group was created, false if it already exists
* @throws {EnergyManagerError} If group name is invalid
*/
createGroup(name) {
return this.registry.createGroup(name);
}
/**
* Adds a device to a group
*
* @param deviceId - ID of the device to add
* @param groupName - Name of the group to add the device to
* @returns True if the device was added to the group
* @throws {EnergyManagerError} If group name is invalid or device not found
*/
addDeviceToGroup(deviceId, groupName) {
return this.registry.addDeviceToGroup(deviceId, groupName);
}
/**
* Removes a device from a group
*
* @param deviceId - ID of the device to remove
* @param groupName - Name of the group to remove the device from
* @returns True if the device was removed from the group
* @throws {EnergyManagerError} If group not found
*/
removeDeviceFromGroup(deviceId, groupName) {
return this.registry.removeDeviceFromGroup(deviceId, groupName);
}
/**
* Removes a group and disassociates all devices from it
*
* @param name - Name of the group to remove
* @returns True if group was found and removed
*/
removeGroup(name) {
return this.registry.removeGroup(name);
}
/**
* Retrieves all devices in a group
*
* @param groupName - Name of the group to query
* @returns Array of devices in the group
* @throws {EnergyManagerError} If group not found
*/
getDevicesInGroup(groupName) {
return this.registry.getDevicesInGroup(groupName);
}
/**
* Calculates statistics for a group of devices
*
* @param groupName - Name of the group to analyze
* @returns Statistical analysis of the group's devices
* @throws {EnergyManagerError} If group not found
*/
getGroupStatistics(groupName) {
const devices = this.registry.getDevicesInGroup(groupName);
// Initialize statistics
const statistics = {
averageBatteryLevel: 0,
powerModeDistribution: {
[status_1.PowerMode.NORMAL]: 0,
[status_1.PowerMode.LOW_POWER]: 0,
[status_1.PowerMode.SLEEP]: 0,
[status_1.PowerMode.CRITICAL]: 0
},
onlineCount: 0,
offlineCount: 0,
totalDevices: devices.length
};
// If no devices, return empty statistics
if (devices.length === 0) {
return statistics;
}
// Calculate statistics
let batterySum = 0;
let batteryCount = 0;
for (const device of devices) {
// Count online/offline devices
if (device.status) {
if (device.status.connectionStatus === status_1.ConnectionStatus.ONLINE) {
statistics.onlineCount++;
}
else {
statistics.offlineCount++;
}
// Add to power mode
if (device.status.powerMode) {
statistics.powerModeDistribution[device.status.powerMode]++;
}
// Add to battery average calculation
if (typeof device.status.batteryLevel === 'number') {
batterySum += device.status.batteryLevel;
batteryCount++;
}
}
else {
statistics.offlineCount++;
}
}
// Calculate battery average
statistics.averageBatteryLevel = batteryCount > 0 ? batterySum / batteryCount : 0;
return statistics;
}
/**
* Sets the topic prefix for MQTT communications
*
* @param prefix - New prefix to use for all MQTT topics
* @remarks This will resubscribe to all device status topics if prefix changes
*/ setTopicPrefix(prefix) {
this.logger.info('Updating topic prefix', {
oldPrefix: this.topicPrefix,
newPrefix: prefix
});
// Ensure prefix ends with /
if (!prefix.endsWith('/')) {
prefix = prefix + '/';
}
// If prefix changed and connected, resubscribe to all topics
const resubscribe = prefix !== this.topicPrefix && this.mqtt.isClientConnected();
this.topicPrefix = prefix;
this.logger.info('Topic prefix updated', { prefix: this.topicPrefix });
if (resubscribe) {
this.logger.info('Resubscribing to status topics with new prefix');
this.subscribeToAllDeviceStatuses().catch(err => {
this.logger.error('Failed to resubscribe to status topics with new prefix', err);
});
}
}
/**
* Checks if connected to the MQTT broker
*
* @returns True if connected to MQTT broker
*/
isConnected() {
return this.mqtt.isClientConnected();
}
/**
* Retrieves all registered devices
*
* @returns Array of all devices
*/
getAllDevices() {
return this.registry.getAllDevices();
}
/**
* Retrieves all defined groups
*
* @returns Array of all group names
*/
getAllGroups() {
return this.registry.getAllGroups();
}
/**
* Puts a device into sleep mode to conserve energy
*
* @param deviceId - ID of the device to put to sleep
* @param duration - Optional sleep duration in seconds
* @throws {EnergyManagerError} If device not found or command fails
*/
async sleepDevice(deviceId, duration) {
await this.sendCommand(deviceId, command_1.CommandType.SLEEP, { duration });
}
/**
* Wakes a device from sleep mode
*
* @param deviceId - ID of the device to wake
* @throws {EnergyManagerError} If device not found or command fails
*/
async wakeDevice(deviceId) {
await this.sendCommand(deviceId, command_1.CommandType.WAKE);
}
/**
* Puts an entire group of devices into sleep mode
*
* @param groupName - Name of the group to put to sleep
* @param duration - Optional sleep duration in seconds
* @throws {EnergyManagerError} If group not found or command fails
*/
async sleepGroup(groupName, duration) {
await this.sendCommandToGroup(groupName, command_1.CommandType.SLEEP, { duration });
}
/**
* Wakes an entire group of devices from sleep mode
*
* @param groupName - Name of the group to wake
* @throws {EnergyManagerError} If group not found or command fails
*/
async wakeGroup(groupName) {
await this.sendCommandToGroup(groupName, command_1.CommandType.WAKE);
}
/**
* Sets up MQTT event listeners
* @private
*/
setupMqttEventListeners() {
this.mqtt.on('message', (topic, message) => {
this.handleIncomingMessage(topic, message);
});
this.mqtt.on('reconnect', () => {
this.emit('reconnecting');
});
this.mqtt.on('offline', () => {
this.emit('disconnected');
});
this.mqtt.on('error', (error) => {
this.emit('error', error);
});
}
/**
* Processes incoming MQTT messages
* @private
*/ handleIncomingMessage(topic, message) {
// Check if it's a status topic
const deviceId = this.extractDeviceIdFromStatusTopic(topic);
if (!deviceId) {
this.logger.trace('Ignoring non-status message', { topic });
return;
}
// Create device-specific logger
const deviceLogger = this.logger.withCorrelationId(deviceId);
try {
// Parse message as JSON
const messageStr = message.toString();
deviceLogger.trace('Status message received', {
topic,
messageSize: messageStr.length
});
const statusData = JSON.parse(messageStr);
// Check if it's a registered device
if (this.registry.hasDevice(deviceId)) {
const timestamp = Date.now();
// Update device status
const device = this.registry.updateDeviceStatus(deviceId, {
deviceId,
...statusData,
lastSeen: timestamp
});
// Emit status update event
this.emit('statusUpdate', deviceId, device.status);
deviceLogger.debug('Device status updated', {
connectionStatus: device.status?.connectionStatus,
powerMode: device.status?.powerMode,
timestamp
});
}
else {
deviceLogger.debug('Status received for unregistered device', {
suggestedAction: 'Register the device to process its status updates'
});
}
}
catch (err) {
deviceLogger.error('Failed to process device status message', err);
}
}
/**
* Extracts device ID from a status topic
* @private
*/
extractDeviceIdFromStatusTopic(topic) {
const prefix = this.topicPrefix;
const suffix = '/status';
if (topic.startsWith(prefix) && topic.endsWith(suffix)) {
return topic.substring(prefix.length, topic.length - suffix.length);
}
return null;
}
/**
* Gets the status topic for a device
* @private
*/
getStatusTopic(deviceId) {
return `${this.topicPrefix}${deviceId}/status`;
}
/**
* Gets the command topic for a device
* @private
*/
getCommandTopic(deviceId) {
return `${this.topicPrefix}${deviceId}/command`;
}
/**
* Subscribes to a device's status topic
* @private
*/
async subscribeToDeviceStatus(deviceId) {
const statusTopic = this.getStatusTopic(deviceId);
await this.mqtt.subscribe(statusTopic);
}
/**
* Subscribes to all device status topics
* @private
*/ async subscribeToAllDeviceStatuses() {
if (!this.mqtt.isClientConnected()) {
this.logger.debug('Skipping subscription to device statuses - not connected to MQTT broker');
return;
}
const deviceIds = this.registry.getAllDeviceIds();
this.logger.info('Subscribing to device status topics', {
deviceCount: deviceIds.length,
topicPrefix: this.topicPrefix
});
const promises = [];
for (const deviceId of deviceIds) {
promises.push(this.subscribeToDeviceStatus(deviceId));
}
await Promise.all(promises);
this.logger.info('Successfully subscribed to device status topics', {
deviceCount: deviceIds.length,
topics: deviceIds.map(id => this.getStatusTopic(id))
});
}
/**
* Starts periodic status checking
* @private
*/ startStatusCheck() {
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
}
this.statusCheckInterval = setInterval(() => {
this.checkDevicesStatus();
}, this.statusUpdateInterval);
this.logger.debug('Status checking started', {
intervalMs: this.statusUpdateInterval,
nextCheckAt: new Date(Date.now() + this.statusUpdateInterval).toISOString()
});
}
/**
* Stops periodic status checking
* @private
*/
stopStatusCheck() {
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval);
this.statusCheckInterval = undefined;
this.logger.debug('Status checking stopped');
}
}
/**
* Checks status of all devices and marks them offline if not responsive
* @private
*/ checkDevicesStatus() {
this.logger.trace('Running periodic device status check');
const devices = this.registry.getAllDevices();
const now = Date.now();
const threshold = 2 * this.statusUpdateInterval;
// Keep statistics for logging
let checkedCount = 0;
let offlineCount = 0;
let markedOfflineCount = 0;
for (const device of devices) {
checkedCount++;
const deviceLogger = this.logger.withCorrelationId(device.id);
// Check if we have status
if (device.status) {
const lastSeenDiff = now - device.status.lastSeen;
// Check if status is outdated (2x status interval)
if (lastSeenDiff > threshold) {
// Mark as offline if was online
if (device.status.connectionStatus === status_1.ConnectionStatus.ONLINE) {
deviceLogger.info('Device connection lost - marking as offline', {
deviceId: device.id,
deviceName: device.name,
lastSeen: new Date(device.status.lastSeen).toISOString(),
timeSinceLastSeenMs: lastSeenDiff
});
const updatedStatus = {
...device.status,
connectionStatus: status_1.ConnectionStatus.OFFLINE,
lastSeen: device.status.lastSeen // keep last timestamp
};
// Update status
this.registry.updateDeviceStatus(device.id, updatedStatus);
this.emit('deviceOffline', device.id);
markedOfflineCount++;
}
else {
offlineCount++;
}
}
}
}
this.logger.debug('Device status check completed', {
devicesChecked: checkedCount,
alreadyOffline: offlineCount,
newlyMarkedOffline: markedOfflineCount,
nextCheckAt: new Date(now + this.statusUpdateInterval).toISOString()
});
}
/**
* Generates a unique request ID
* @private
*/
generateRequestId() {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
exports.EnergyManager = EnergyManager;