homebridge-ups
Version:
Homebridge plugin for Network UPS Tools
443 lines (405 loc) • 13 kB
JavaScript
// homebridge-ups/lib/UpsClient.js
// Copyright © 2022-2025 Erik Baauw. All rights reserved.
//
// Homebridge plugin for UPS.
import { EventEmitter, once } from 'node:events'
import { createConnection } from 'node:net'
import { timeout } from 'homebridge-lib'
import { OptionParser } from 'homebridge-lib/OptionParser'
/* Possible values for `ups.state`:
* OL - On line (mains is present)
* OB - On battery (mains is not present)
* LB - Low battery
* HB - High battery
* RB - The battery needs to be replaced
* CHRG - The battery is charging
* DISCHRG - The battery is discharging (inverter is providing load power)
* BYPASS - UPS bypass circuit is active - no battery protection is available
* CAL - UPS is currently performing runtime calibration (on battery)
* OFF - UPS is offline and is not supplying power to the load
* OVER - UPS is overloaded
* TRIM - UPS is trimming incoming voltage (called "buck" in some hardware)
* BOOST - UPS is boosting incoming voltage
* FSD - Forced Shutdown (restricted use, see the note below)
*/
/** UPS error.
* @hideconstructor
* @extends Error
* @memberof UpsClient
*/
class UpsError extends Error {
constructor (message, request) {
super(message)
/** @member {UpsClient.UpsRequest} - The request that caused the error.
*/
this.request = request
}
}
/** UPS request.
* @hideconstructor
* @memberof UpsClient
*/
class UpsRequest {
constructor (name, id, command) {
/** @member {string} - The server name.
*/
this.name = name
/** @member {integer} - The request ID.
*/
this.id = id
/** @member {string} - The request command.
*/
this.command = command
}
}
/** UPS response.
* @hideconstructor
* @memberof UpsClient
*/
class UpsResponse {
constructor (request, body) {
/** @member {UpsClient.UpsRequest} - The request that generated the response.
*/
this.request = request
/** @member {?string} - The response body.
*/
this.body = body
}
}
/** Delegate class for a UPS device.
* @memberof UpsClient
*/
class UpsDevice {
/** Create a new instance of a UPS device delegate.
*
* @param {object} params - Parameters.
* @param {UpsClient} params.client - The UPS client instance.
* @param {string} params.name - The device name
* (as returned by {@link UpsClient.list list()}.
*/
constructor (params) {
this._client = params.client
this._name = params.name
}
/** Name of the device.
* @type {string}
*/
get name () { return this._name }
/** Get a list of connected UPS clients.
* @return {string[]} list - A list of IP addresses.
* @throws {UpcClient.UpsError} In case of error.
*/
async clients () {
const list = await this._client._getList('CLIENT ' + this._name)
return list.sort()
}
/** Get a list of supported NUT commands.
* @return {string[]} list - A list of IP addresses.
* @throws {UpcClient.UpsError} In case of error.
*/
async commands () {
return this._client._getList('CMD ' + this._name)
}
/** Get a map of supported NUT read-only variables and their values.
* @return {Object<string, string|integer>} mao - A map of read-only
* variables with their values.
* @throws {UpcClient.UpsError} In case of error.
*/
async constants () {
return this._client._getMap('VAR ' + this._name)
}
/** Get a map of supported NUT read/write variables and their values.
* @return {Object<string, string|integer>} mao - A map of read/write
* variables with their current values.
* @throws {UpcClient.UpsError} In case of error.
*/
async variables () {
return this._client._getMap('RW ' + this._name)
}
/** Get the number of connected UPS clients.
* @return {integer} nClients - The number of UPS clients.
* @throws {UpcClient.UpsError} In case of error.
*/
async nClients () {
const response = await this._client._send(
`GET NUMLOGINS ${this._name}`, `NUMLOGINS ${this._name}`
)
const a = /NUMLOGINS .* (.*)/.exec(response)
if (a != null && a[1] != null) {
return a[1]
}
}
/** Get the value of a NUT variable.
* @param {string} key - The variable key.
* @return {string|integer} value - The variable value.
* @throws {UpcClient.UpsError} In case of error.
*/
async value (key) {
return this._client._get('VAR ' + this._name, key)
}
/** Set the value of a NUT variable.
* @param {string} key - The variable key.
* @param {string} value - The new value.
* @throws {UpcClient.UpsError} In case of error.
*/
async set (key, value) {
return this._client._send(
'SET VAR ' + this._name + ' ' + key + ' "' + value + '"', 'OK'
)
}
/** Get the description of a NUT variable.
*
* Note: descriptions are empty on Synology.
* @params {?string} key - The variable key.
* @return {string|integer} value - The variable value.
* @throws {UpcClient.UpsError} In case of error.
*/
async description (key) {
if (key == null) {
return this._client._get('UPSDESC ' + this._name)
}
return this._client._get('DESC ' + this._name, key)
}
/** Get the type of a NUT variable.
*
* Note: this doesn't seem to return correct values.
* @params {string} key - The variable key.
* @return {string|integer} value - The variable value.
* @throws {UpcClient.UpsError} In case of error.
*/
async type (key) {
return this._client._get('TYPE ' + this._name, key)
}
/** Execute a NUT command.
* @params {string} key - The command key.
* @throws {UpcClient.UpsError} In case of error.
*/
async command (key) {
await this._client._send(`INSTCMD ${this._name} ${key}`, 'OK')
}
/** Execute a NUT command.
*
* Note: descriptions are empty on Synology.
* @params {string} key - The command key.
* @throws {UpcClient.UpsError} In case of error.
*/
async commandDescription (key) {
return this._client._get('CMDDESC ' + this._name, key)
}
}
/** Delegate class for a UPS daemon, `upsd`.
* @extends EventEmitter
*/
class UpsClient extends EventEmitter {
static get UpsDevice () { return UpsDevice }
static get UpsError () { return UpsError }
static get UpsRequest () { return UpsRequest }
static get UpsResponse () { return UpsResponse }
/** Create a new instance of a UPS Daemon delegate.
*
* @param {object} params - Parameters.
* @param {string} params.name - The name of the `upsd`.
* @param {string} params.host - The `upsd` hostname and port (default: `localhost:3493`).
* @param {string} params.username - The username (as specified in `upsd.users`).
* @param {string} params.password - The password (as specified in `upsd.users`).
*/
constructor (params) {
super()
this._params = {
hostname: 'localhost',
port: 3493,
timeout: 15
}
const optionParser = new OptionParser(this._params)
optionParser
.hostKey()
.stringKey('name')
.stringKey('username')
.stringKey('password')
.intKey('timeout', 1, 60)
.parse(params)
this._host = this._params.hostname + ':' + this._params.port
this._requestId = 0
this._devices = {}
}
/** The name of the UPS Daemon.
* @type {string}
*/
get name () { return this._params.name }
/** Return whether delegate is connected to `upsd`.
* @type {boolean}
*/
get connected () { return this._client != null }
/** Connect to `upsd`.
* @throws {UpsClient.UpsError} In case of error.
*/
async connect () {
if (this._client != null) {
return
}
this._client = createConnection({
port: this._params.port, host: this._params.hostname, family: 4
})
this._client
.on('connect', () => {
/** Emitted when client has connected to `upsd`.
* @event UpsClient#connect
* @param {string} host - The hostname and port.
*/
this.emit('connect', this._host)
})
.on('close', () => {
/** Emitted when client has disconnected from `upsd`.
* @event UpsClient#disconnect
* @param {string} host - The hostname and port.
*/
this.emit('disconnect', this._host)
this._client = null
})
.on('data', (buffer) => { this._onData(buffer) })
.on('error', (error) => {
/** Emitted in case of error.
* @event UpsClient#error
* @param {UpsClient.UpsError} error - The error.
*/
this.emit('error', new UpsError(error.message, this._request))
})
try {
await once(this._client, 'connect')
} catch (error) {
throw new UpsError(error.message, this._request)
}
if (this._params.username != null && this._params.username !== '') {
await this._send(`USERNAME ${this._params.username}`, 'OK')
await this._send(`PASSWORD ${this._params.password}`, 'OK')
}
}
/** Disconnect from `upsd`.
*/
async disconnect () {
for (const id in this._devices) {
delete this._devices[id]
}
if (this._client != null) {
await this._client.destroy()
}
this._client = null
}
_onData (buffer) {
const s = buffer.toString('utf8')
this._response += s
const lines = this._response.split('\n')
for (const line of lines) {
if (line.startsWith(this._expectedResponse)) {
/** Emitted when a valid response has been received from `upsd`.
* @event UpsClient#response
* @param {UpsClient.UpsResponse} response - The response.
*/
this.emit('response', new UpsResponse(
this._request, this._response.slice(0, -1))
)
break
}
}
}
async _send (command, expectedResponse, nRetries = 0) {
await this.connect()
if (this._expectedResponse != null) {
if (nRetries < 5) {
await timeout(500)
return this._send(command, expectedResponse, ++nRetries)
}
const error = new UpsError(
'send in progress',
new UpsRequest(this._params.name, ++this._requestId, command)
)
this.emit('error', error)
throw error
}
this._request = new UpsRequest(this._params.name, ++this._requestId, command)
this._expectedResponse = expectedResponse
this._response = ''
/** Emitted when a request has been sent to `upsd`.
* @event UpsClient#request
* @param {UpsClient.UpsRequest} request - The request.
*/
this.emit('request', this._request)
await this._client.write(command + '\n')
const _timeout = setTimeout(() => {
this.emit('error', new UpsError(
`timeout after ${this._params.timeout}s`, this._request
))
}, this._params.timeout * 1000)
try {
const response = await once(this, 'response')
clearTimeout(_timeout)
return response[0].body
} finally {
this._expectedResponse = null
}
}
async _get (type, key) {
if (key != null) {
type += ' ' + key
}
const s = await this._send('GET ' + type, type)
const regexp = new RegExp(`^${type} "?([^"]*)"?$`)
const a = regexp.exec(s)
if (a != null && a[1] != null) {
return a[1].trim()
}
}
async _getList (key) {
const list = []
const s = await this._send('LIST ' + key, 'END LIST ' + key)
const regexp = new RegExp(`^${key} ([^ ]*)$`)
const lines = s.slice(0, -1).split('\n')
for (const line of lines) {
const a = regexp.exec(line)
if (a != null && a[1] != null) {
list.push(a[1].trim())
}
}
return list
}
async _getMap (key) {
const map = {}
const s = await this._send('LIST ' + key, 'END LIST ' + key)
const regexp = new RegExp(`^${key} ([^ ]*) "(.*)"$`)
const lines = s.slice(0, -1).split('\n')
for (const line of lines) {
const a = regexp.exec(line)
if (a != null && a[1] != null && a[2] != null) {
map[a[1]] = a[2].trim()
}
}
return map
}
/** Get the version of `upsd`.
* @return {string} version - The `upsd` version.
* @throws {UpsClient.UpsError} In case of error.
*/
async version () {
return this._send('VER', '')
}
/** Get the API version of `upsd`.
* @return {string} version - The API `upsd` version.
* @throws {UpsClient.UpsError} In case of error.
*/
async apiVersion () {
return this._send('NETVER', '')
}
/** Get a map of UPS devices.
* @return {Object<string, UpsClient.UpsDevice>} deviceMap - Map of UPS devices by name.
* @throws {UpsClient.UpsError} In case of error.
*/
async devices () {
const map = {}
const descriptionByName = await this._getMap('UPS')
for (const name of Object.keys(descriptionByName).sort()) {
map[name] = new UpsDevice({ client: this, name })
}
return map
}
}
export { UpsClient }