/**
* @description Homebridge integration for Volume Monitor
* @copyright 2021
* @author Mike Price <dev.grumptech@gmail.com>
* @module VolumeMonitorModule
* @requires debug
* @see {@link https://github.com/debug-js/debug#readme}
*/
/*
* IMPORTANT NOTICE
*
* One thing you need to take care of is, that you never ever ever import anything directly from the
* "homebridge" module (or the "hap-nodejs" module).
* The import block below may seem like we do exactly that, but actually those imports are only used
* for types and interfaces and will disappear once the code is compiled to Javascript.
* In fact you can check that by running `npm run build` and opening the compiled Javascript file in
* the `dist` folder.
* You will notice that the file does not contain a `... = require("homebridge");` statement
* anywhere in the code.
*
* The contents of the import statement below MUST ONLY be used for type annotation or accessing
* things like CONST ENUMS, which is a special case as they get replaced by the actual value and do
* not remain as a reference in the compiled code.
* Meaning normal enums are bad, const enums can be used.
*
* You MUST NOT import anything else which remains as a reference in the code, as this will result
* in a `... = require("homebridge");` to be compiled into the final Javascript code.
* This typically leads to unexpected behavior at runtime, as in many cases it won't be able to
* find the module or will import another instance of homebridge causing collisions.
*
* To mitigate this the {@link API | Homebridge API} exposes the whole suite of HAP-NodeJS inside
* the `hap` property of the api object, which can be acquired for example in the initializer
* function. This reference can be stored and used to access all exported varia bles and classes
* from HAP-NodeJS.
*/
/*
import {
API,
APIEvent,
CharacteristicEventTypes,
CharacteristicSetCallback,
CharacteristicValue,
DynamicPlatformPlugin,
HAP,
Logging,
PlatformAccessory,
PlatformAccessoryEvent,
PlatformConfig,
Service,
} from "homebridge";
*/
// Internal dependencies
import {VolumeInterrogator_darwin as _VolInterrogatorDarwin} from './volumeInterrogator_darwin.mjs';
import {VolumeInterrogator_linux as _VolInterrogatorLinux} from './volumeInterrogator_linux.mjs';
import {VolumeData} from './volumeData.mjs';
// External dependencies and imports.
import _debugModule from 'debug';
/**
* @private
* @description Debugging function pointer for runtime related diagnostics.
*/
const _debug = _debugModule('homebridge');
// Internal Constants
// History:
// unspecified: Initial Release
// v2: Purge Offline and better UUID management.
/**
* @description Version history of the plugin.
* @private
*/
const ACCESSORY_VERSION = 2;
/**
* @description Enumeration of service types.
* @private
*/
const FIXED_ACCESSORY_SERVICE_TYPES = {
Switch: 0,
};
/**
* @description Listing of fixed (dedicated) accessories.
* @private
*/
const FIXED_ACCESSORY_INFO = {
CONTROLS: {
uuid: '2CF5A6C7-8041-4805-8582-821B19589D60',
model: 'Control Switches',
serial_num: '00000001',
service_list: {
MANUAL_REFRESH: {
type: FIXED_ACCESSORY_SERVICE_TYPES.Switch, name: 'Refresh', uuid: '23CB97AC-6F0C-46B5-ACF6-78025632A11F', udst: 'ManualRefresh',
},
PURGE_OFFLINE: {
type: FIXED_ACCESSORY_SERVICE_TYPES.Switch, name: 'Purge', uuid: 'FEE232D5-8E25-4C1A-89AC-5476B778ADEF', udst: 'PurgeOffline',
},
},
},
};
/**
* @description Host Operating System
* @private
*/
const HOST_OPERATING_SYSTEM = process.platform;
/**
* @description Enumeration of supported operating systems.
* @private
*/
const SUPPORTED_OPERATING_SYSTEMS = {
OS_DARWIN: 'darwin',
// eslint-disable-next-line key-spacing
OS_LINUX: 'linux',
};
/**
* @description Package Information
*/
const _PackageInfo = {CONFIG_INFO: PLACEHOLDER_CONFIG_INFO, PLUGIN_VER: 'PLACEHOLDER_VERSION'};
/**
* @description Platform accessory reference
* @private
*/
let _PlatformAccessory;
/**
* @description Reference to the NodeJS Homekit Applicaiton Platform.
* @private
*/
let _hap;
/**
* @description Homebridge platform for managing the Volume Interrogator
* @private
*/
class VolumeInterrogatorPlatform {
/**
* @description Constructor
* @param {object} log - Regerence to the log for logging in the Homebridge Context
* @param {object} config - Reference to the platform configuration (from config.json)
* @param {object} api - Reference to the Homebridge API
* @throws {TypeError} - thrown if the configuration is invalid.
*/
constructor(log, config, api) {
/* Cache the arguments. */
this._log = log;
this._config = config;
this._api = api;
/* My local data */
this._name = this._config.name;
let theSettings;
const viConfig = {};
if (Object.prototype.hasOwnProperty.call(this._config, 'settings')) {
// Get the system configuration,
theSettings = this._config.settings;
}
if (theSettings !== undefined) {
// Polling Interval {Hours}
if (Object.prototype.hasOwnProperty.call(theSettings, 'polling_interval')) {
if (typeof(theSettings.polling_interval) === 'number') {
// Copy the period (in hours)
viConfig.period_hr = theSettings.polling_interval;
}
else {
throw new TypeError(`Configuration item 'polling_interval' must be a number. {${typeof(theSettings.polling_interval)}}`);
}
}
// Default Low Space Alarm Threshold {Percent}
if (Object.prototype.hasOwnProperty.call(theSettings, 'alarm_threshold')) {
if (typeof(theSettings.alarm_threshold) === 'number') {
// Set the period (in hours)
viConfig.default_alarm_threshold = theSettings.alarm_threshold;
}
else {
throw new TypeError(`Configuration item 'alarm_threshold' must be a number. {${typeof(theSettings.alarm_threshold)}}`);
}
}
// Array of exclusion masks
if (Object.prototype.hasOwnProperty.call(theSettings, 'exclusion_masks')) {
if (Array.isArray(theSettings.exclusion_masks)) {
let exclusionMasksValid = true;
// eslint-disable-next-line no-restricted-syntax
for (const mask of theSettings.exclusion_masks) {
exclusionMasksValid = exclusionMasksValid && (typeof(mask) === 'string');
}
if (exclusionMasksValid) {
// Set the exclusion masks.
viConfig.exclusion_masks = theSettings.exclusion_masks;
}
}
else {
throw new TypeError(`Configuration item 'exclusion_masks' must be an array of strings. {${typeof(theSettings.exclusion_masks)}}`);
}
}
// Enable Volume Customizations
if (Object.prototype.hasOwnProperty.call(theSettings, 'enable_volume_customizations')) {
if (typeof(theSettings.enable_volume_customizations) === 'boolean') {
// Are the volume customizations enabled?
if (theSettings.enable_volume_customizations) {
if ((Object.prototype.hasOwnProperty.call(theSettings, 'volume_customizations')) &&
(Array.isArray(theSettings.volume_customizations))) {
viConfig.volume_customizations = theSettings.volume_customizations;
}
}
}
else {
throw new TypeError(`Configuration item 'enable_volume_customizations' must be a boolean. {${typeof(theSettings.enable_volume_customizations)}}`);
}
}
}
// Underlying engine. Operating system dependent.
try {
switch (HOST_OPERATING_SYSTEM.toLowerCase()) {
// OSX & macOS
case SUPPORTED_OPERATING_SYSTEMS.OS_DARWIN: {
this._volumeInterrogator = new _VolInterrogatorDarwin(viConfig);
}
// eslint-disable-next-line indent
break;
// Linux
case SUPPORTED_OPERATING_SYSTEMS.OS_LINUX: {
this._volumeInterrogator = new _VolInterrogatorLinux(viConfig);
}
// eslint-disable-next-line indent
break;
default: {
// Unsupported OS
this._volumeInterrogator = undefined;
this._log(`Operating system not supported. os:${HOST_OPERATING_SYSTEM}`);
}
// eslint-disable-next-line indent
break;
}
}
catch (error) {
this._volumeInterrogator = undefined;
this._log(`Unable to create the VolumeInterrogator. err:'${error.message}`);
}
/* Bind Handlers */
this._bindDoInitialization = this._doInitialization.bind(this);
this._bindDestructorNormal = this._destructor.bind(this, {cleanup: true});
this._bindDestructorAbnormal = this._destructor.bind(this, {exit: true});
this._CB_VolumeIterrrogatorScanning = this._handleVolumeInterrogatorScanning.bind(this);
this._CB_VolumeIterrrogatorReady = this._handleVolumeInterrogatorReady.bind(this);
/* Log our creation */
this._log('Creating VolumeInterrogatorPlatform');
/* Create an empty map for our accessories */
this._accessories = new Map();
/* Create an empty map for our volume data.
Using a Map to allow for easy updates/replacements */
this._volumesData = new Map();
// Register for the Did Finish Launching event
this._api.on('didFinishLaunching', this._bindDoInitialization);
this._api.on('shutdown', this._bindDestructorNormal);
// Register for shutdown events.
// do something when app is closing
process.on('exit', this._bindDestructorNormal);
// catches uncaught exceptions
process.on('uncaughtException', this._bindDestructorAbnormal);
// Register for Volume Interrogator events.
if (this._volumeInterrogator !== undefined) {
this._volumeInterrogator.on('scanning', this._CB_VolumeIterrrogatorScanning);
this._volumeInterrogator.on('ready', this._CB_VolumeIterrrogatorReady);
}
}
/**
* @description Destructor
* @param {object} options - Typically containing a "cleanup" or "exit" member.
* @param {object} err - The source of the event trigger.
* @returns {void}
* @async
* @private
*/
async _destructor(options, err) {
// Is there an indication that the system is either exiting or needs to
// be cleaned up?
if ((options.exit) || (options.cleanup)) {
// Cleanup the volume interrogator.
if (this._volumeInterrogator !== undefined) {
this._log.debug('Terminating the volume interrogator.');
this._log.debug(err);
this._volumeInterrogator.removeListener('scanning', this._CB_VolumeIterrrogatorScanning);
this._volumeInterrogator.removeListener('ready', this._CB_VolumeIterrrogatorReady);
// eslint-disable-next-line new-cap
await this._volumeInterrogator.Terminate();
this._volumeInterrogator = undefined;
}
}
// Lastly eliminate myself.
delete this;
}
/**
* @description Event handler when the system has loaded the platform.
* @returns {void}
* @throws {TypeError} - thrown if the 'polling_interval' configuration item is not a number.
* @throws {RangeError} - thrown if the 'polling_interval' configuration item is outside the allowed bounds.
* @async
* @private
*/
async _doInitialization() {
this._log(`Homebridge Plug-In ${_PackageInfo.CONFIG_INFO.platform} has finished launching.`);
// Abort if there is no interrogator
if (this._volumeInterrogator === undefined) {
this._log('Volume Interrogator not set.');
return;
}
let theSettings;
if (Object.prototype.hasOwnProperty.call(this._config, 'settings')) {
// Get the system configuration,
theSettings = this._config.settings;
}
// Check for Settings
if (theSettings !== undefined) {
// Polling Interval {Hours}
if ((Object.prototype.hasOwnProperty.call(theSettings, 'polling_interval')) &&
(typeof(theSettings.polling_interval) === 'number')) {
if ((theSettings.polling_interval >= this._volumeInterrogator.MinimumPeriod) &&
(theSettings.polling_interval <= this._volumeInterrogator.MaximumPeriod)) {
// Set the period (in hours)
this._volumeInterrogator.Period = theSettings.polling_interval;
}
else {
// eslint-disable-next-line max-len
throw new RangeError(`Configuration item 'polling_interval' must be between ${this._volumeInterrogator.MinimumPeriod} and ${this._volumeInterrogator.MaximumPeriod}. {${theSettings.polling_interval}}`);
}
}
else {
throw new TypeError(`Configuration item 'polling_interval' must be a number. {${typeof(theSettings.polling_interval)}}`);
}
}
// Flush any accessories that are not from this version.
const accessoriesToRemove = [];
for (const accessory of this._accessories.values()) {
if (!Object.prototype.hasOwnProperty.call(accessory.context, 'VERSION') ||
(accessory.context.VERSION !== ACCESSORY_VERSION)) {
this._log(`Accessory ${accessory.displayName} has accessory version ${accessory.context.VERSION}. Version ${ACCESSORY_VERSION} is expected.`);
// This accessory needs to be replaced.
accessoriesToRemove.push(accessory);
}
}
// Perform the cleanup.
accessoriesToRemove.forEach((accessory) => {
this._removeAccessory(accessory);
});
// Create and Configure the Accessory Controls if needed.
if (!this._accessories.has(FIXED_ACCESSORY_INFO.CONTROLS.model)) {
// Control Switches accessory never existed. Make one now.
const accessoryControls = new _PlatformAccessory(FIXED_ACCESSORY_INFO.CONTROLS.model, FIXED_ACCESSORY_INFO.CONTROLS.uuid);
// Add the identifier to the accessory's context. Used for remapping on depersistence.
accessoryControls.context.ID = FIXED_ACCESSORY_INFO.CONTROLS.model;
// Mark the version of the accessory. This is used for depersistence
accessoryControls.context.VERSION = ACCESSORY_VERSION;
// Create accessory persisted settings
accessoryControls.context.SETTINGS = {
SwitchStates: [
{id: FIXED_ACCESSORY_INFO.CONTROLS.service_list.MANUAL_REFRESH.uuid, state: true},
{id: FIXED_ACCESSORY_INFO.CONTROLS.service_list.PURGE_OFFLINE.uuid, state: false},
],
};
// Create & Configure the control services.
for (const serviceItem of Object.values(FIXED_ACCESSORY_INFO.CONTROLS.service_list)) {
const serviceType = this._getAccessoryServiceType(serviceItem.type);
const service = accessoryControls.addService(serviceType, serviceItem.uuid, serviceItem.udst);
if (service !== undefined) {
service.updateCharacteristic(_hap.Characteristic.Name, `${serviceItem.name}`);
}
}
// Update the accessory information.
this._updateAccessoryInfo(accessoryControls, {model: FIXED_ACCESSORY_INFO.CONTROLS.model, serialnum: FIXED_ACCESSORY_INFO.CONTROLS.serial_num});
// configure this accessory.
this._configureAccessory(accessoryControls);
// register the manual refresh switch
this._api.registerPlatformAccessories(_PackageInfo.CONFIG_INFO.plugin, _PackageInfo.CONFIG_INFO.platform, [accessoryControls]);
}
// Start interrogation.
// eslint-disable-next-line new-cap
this._volumeInterrogator.Start();
}
/**
* @description Homebridge API invoked after restoring cached accessorues from disk.
* @param {_PlatformAccessory} accessory - Accessory to be configured.
* @returns {void}
* @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
* @private
*/
configureAccessory(accessory) {
// Validate the argument(s)
if ((accessory === undefined) ||
(!(accessory instanceof _PlatformAccessory))) {
throw new TypeError('accessory must be a PlatformAccessory');
}
// Is this accessory already registered?
let found = false;
for (const acc of this._accessories.values()) {
if (acc === accessory) {
found = true;
break;
}
}
if (!found) {
// Configure the accessory (also registers it.)
try {
this._configureAccessory(accessory);
}
catch (error) {
this._log(`Unable to configure accessory ${accessory.displayName}. Version:${accessory.context.VERSION}. Error:${error}`);
this._accessories.set(accessory.displayName, accessory);
}
}
}
/**
* @description Event handler for the Volume Interrogator 'scanning' event.
* @returns {void}
* @private
*/
_handleVolumeInterrogatorScanning() {
// Decouple from the event.
setImmediate(() => {
this._log.debug('Scanning initiated.');
// If a scanning event has been initiated, Ensure that the the Refresh switch is On.
const accessoryControls = this._accessories.get(FIXED_ACCESSORY_INFO.CONTROLS.model);
if (accessoryControls !== undefined) {
const serviceRefreshSwitch = accessoryControls.getService(FIXED_ACCESSORY_INFO.CONTROLS.service_list.MANUAL_REFRESH.udst);
if (serviceRefreshSwitch !== undefined) {
if (!this._getAccessorySwitchState(serviceRefreshSwitch)) {
this._log.debug('Setting Refresh switch On.');
serviceRefreshSwitch.updateCharacteristic(_hap.Characteristic.On, true);
}
else {
this._log.debug('Refresh switch is already On.');
}
}
else {
this._log.debug('Unable to find Manual Refresh service.');
}
}
else {
this._log.debug('Unable to find CONTROLS accessory');
}
});
}
/**
* @description Event handler for the Volume Interrogator 'ready' event.
* @param {object} theData - container of a 'results' item which is an array of volume data results.
* @returns {void}
* @throws {TypeError} - Thrown when 'results' is not an Array of VolumeData objects.
* @private
*/
_handleVolumeInterrogatorReady(theData) {
// Decouple from the event.
setImmediate((data) => {
// Validate the parameters.
if ((data === undefined) ||
(!Object.prototype.hasOwnProperty.call(data, 'results'))) {
throw new TypeError('\'data\' needs to be an object with a \'results\' field.');
}
if (!Array.isArray(data.results)) {
throw new TypeError('\'data.results\' needs to be an array of VolumeData objects.');
}
for (const result of data.results) {
if (!(result instanceof VolumeData)) {
throw new TypeError('\'results\' needs to be an array of VolumeData objects.');
}
}
// Update the volumes data.
for (const result of data.results) {
if (result.IsMounted) {
// eslint-disable-next-line max-len, new-cap
this._log.debug(`\tName:${result.Name.padEnd(20, ' ')}\tVisible:${result.IsVisible}\tShown:${result.IsShown}\tSize:${VolumeData.ConvertFromBytesToGB(result.Size).toFixed(4)} GB\tUsed:${((result.UsedSpace / result.Size) * 100.0).toFixed(2)}%\tMnt:${result.MountPoint}`);
}
// Update the map of volume data.
this._volumesData.set(result.Name, result);
}
// Loop through the visible volumes and publish/update them.
for (const volData of this._volumesData.values()) {
try {
// Do we know this volume already?
const volIsKnown = this._accessories.has(volData.Name);
// Is this volume visible & new to us?
if ((volData.IsShown) &&
(!volIsKnown)) {
// Does not exist. Add it
this._addBatteryServiceAccessory(volData.Name);
}
// Update the accessory if we know if this volume already
// (i.e. it is currently or was previously shown).
const theAccessory = this._accessories.get(volData.Name);
if (theAccessory !== undefined) {
this._updateBatteryServiceAccessory(theAccessory);
}
}
catch (error) {
this._log.debug(`Error when managing accessory: ${volData.Name}`);
}
}
const accessoryControls = this._accessories.get(FIXED_ACCESSORY_INFO.CONTROLS.model);
if (accessoryControls !== undefined) {
// Cleanup (if purge is enabled)
const servicePurge = accessoryControls.getServiceById(
FIXED_ACCESSORY_INFO.CONTROLS.service_list.PURGE_OFFLINE.uuid,
FIXED_ACCESSORY_INFO.CONTROLS.service_list.PURGE_OFFLINE.udst,
);
if (servicePurge !== undefined) {
const purgeState = this._getAccessorySwitchState(servicePurge);
if (purgeState) {
const purgeList = [];
// Check for Volumes that are no longer Visible or did not have any results
// reported.
for (const volData of this._volumesData.values()) {
// eslint-disable-next-line arrow-body-style
const resultFound = data.results.find((element) => {
return element.Name === volData.Name;
});
if ((this._accessories.has(volData.Name)) &&
((!volData.IsShown) || (resultFound === undefined))) {
purgeList.push(this._accessories.get(volData.Name));
}
}
// Check for accessories whose volumes are unknown.
const excludedAccessoryKeys = [FIXED_ACCESSORY_INFO.CONTROLS.model];
for (const key of this._accessories.keys()) {
if ((!this._volumesData.has(key)) &&
(excludedAccessoryKeys.indexOf(key) === -1)) {
purgeList.push(this._accessories.get(key));
}
}
// Clean up.
purgeList.forEach((accessory) => {
this._volumesData.delete(accessory.displayName);
this._removeAccessory(accessory);
});
}
}
// Get the Manual Refresh service.
const serviceManlRefresh = accessoryControls.getServiceById(
FIXED_ACCESSORY_INFO.CONTROLS.service_list.MANUAL_REFRESH.uuid,
FIXED_ACCESSORY_INFO.CONTROLS.service_list.MANUAL_REFRESH.udst,
);
if ((serviceManlRefresh !== undefined) &&
(this._getAccessorySwitchState(serviceManlRefresh))) {
// Ensure the switch is turned back off.
serviceManlRefresh.updateCharacteristic(_hap.Characteristic.On, false);
}
}
// With the accessories that remain, force an update.
const accessoryList = [];
for (const accessory of this._accessories.values()) {
accessoryList.push(accessory);
}
// Update, if needed.
if (accessoryList.length > 0) {
this._api.updatePlatformAccessories(accessoryList);
}
}, theData);
}
/**
* @description Creates and registers an accessory for the volume name.
* @param {string} name - name of the volume for the accessory.
* @returns {void}
* @throws {TypeError} - Thrown when 'name' is not a string.
* @throws {RangeError} - Thrown when 'name' length is 0
* @throws {Error} - Thrown when an accessory with 'name' is already registered.
* @private
*/
_addBatteryServiceAccessory(name) {
// Validate arguments
if ((name === undefined) || (typeof(name) !== 'string')) {
throw new TypeError('name must be a string');
}
if (name.length <= 0) {
throw new RangeError('name must be a non-zero length string.');
}
if (this._accessories.has(name)) {
throw new Error(`Accessory '${name}' is already registered.`);
}
this._log.debug(`Adding new accessory: name:'${name}'`);
// uuid must be generated from a unique but not changing data source,
// theName should not be used in the most cases. But works in this specific example.
const uuid = _hap.uuid.generate(name);
const accessory = new _PlatformAccessory(name, uuid);
// Create our services.
accessory.addService(_hap.Service.BatteryService, name);
// Mark the version of the accessory. This is used for depersistence
accessory.context.VERSION = ACCESSORY_VERSION;
try {
// Configura the accessory
this._configureAccessory(accessory);
}
catch (error) {
this._log.debug('Error when configuring accessory.');
}
this._api.registerPlatformAccessories(_PackageInfo.CONFIG_INFO.plugin, _PackageInfo.CONFIG_INFO.platform, [accessory]);
}
/**
* @description Performs accessory configuration and internal 'registration' (appending to our list).
* Opportunity to setup event handlers for characteristics and update values (as needed).
* @param {_PlatformAccessory} accessory - Accessory to be configured/registered
* @returns {void}
* @throws {TypeError} - thrown if 'accessory' is not a PlatformAccessory
* @private
*/
_configureAccessory(accessory) {
if ((accessory === undefined) ||
(!(accessory instanceof _PlatformAccessory))) {
throw new TypeError('accessory must be a PlatformAccessory');
}
this._log.debug(`Configuring accessory ${accessory.displayName}`);
// Register to handle the Identify request for the accessory.
accessory.on(_PlatformAccessory.PlatformAccessoryEvent.IDENTIFY, () => {
this._log(`${accessory.displayName} identified!`);
});
let theSwitchStates;
const theSettings = accessory.context.SETTINGS;
if ((theSettings !== undefined) &&
(typeof(theSettings) === 'object') &&
(Object.prototype.hasOwnProperty.call(theSettings, 'SwitchStates')) &&
(Array.isArray(theSettings.SwitchStates))) {
theSwitchStates = theSettings.SwitchStates;
}
// Does this accessory have Switch service(s)?
for (const service of accessory.services) {
if (service instanceof _hap.Service.Switch) {
// Get the persisted switch state.
let switchStateValue = true;
if (Array.isArray(theSwitchStates)) {
for (const switchStateConfig of theSwitchStates) {
if ((typeof(switchStateConfig) === 'object') &&
(Object.prototype.hasOwnProperty.call(switchStateConfig, 'id')) &&
(typeof(switchStateConfig.id) === 'string') &&
(Object.prototype.hasOwnProperty.call(switchStateConfig, 'state')) &&
(typeof(switchStateConfig.state) === 'boolean') &&
(switchStateConfig.id === service.displayName)) {
switchStateValue = switchStateConfig.state;
break;
}
}
}
// Set the switch to the stored setting (the default is on).
service.updateCharacteristic(_hap.Characteristic.On, switchStateValue);
const charOn = service.getCharacteristic(_hap.Characteristic.On);
// Build the identification id
const id = `${service.displayName}.${service.subtype}`;
// Register for the "get" event notification.
// eslint-disable-next-line object-shorthand
charOn.on('get', this._handleOnGet.bind(this, {accessory: accessory, service_id: id}));
// Register for the "set" event notification.
// eslint-disable-next-line object-shorthand
charOn.on('set', this._handleOnSet.bind(this, {accessory: accessory, service_id: id}));
}
}
// Is this accessory new to us?
if (!this._accessories.has(accessory.displayName)) {
// Update our accessory listing
this._log.debug(`Adding accessory '${accessory.displayName} to the accessories list. Count:${this._accessories.size}`);
this._accessories.set(accessory.displayName, accessory);
}
}
/**
* @description Remove/destroy an accessory
* @param {_PlatformAccessory} accessory - accessory to be removed.
* @returns {void}
* @throws {TypeError} - Thrown when 'accessory' is not an instance of _PlatformAccessory.
* @throws {RangeError} - Thrown when a 'accessory' is not registered.
* @private
*/
_removeAccessory(accessory) {
// Validate arguments
if ((accessory === undefined) || !(accessory instanceof _PlatformAccessory)) {
throw new TypeError('Accessory must be a PlatformAccessory');
}
if (!this._accessories.has(accessory.displayName)) {
throw new RangeError(`Accessory '${accessory.displayName}' is not registered.`);
}
this._log.debug(`Removing accessory '${accessory.displayName}'`);
// Event Handler cleanup.
accessory.removeAllListeners(_PlatformAccessory.PlatformAccessoryEvent.IDENTIFY);
// Iterate through all the services on the accessory
for (const service of accessory.services) {
// Is this service a Switch?
if (service instanceof _hap.Service.Switch) {
// Get the On characteristic.
const charOn = service.getCharacteristic(_hap.Characteristic.On);
// Build the identification id
const id = `${service.displayName}.${service.subtype}`;
// Register for the "get" event notification.
// eslint-disable-next-line object-shorthand
charOn.off('get', this._handleOnGet.bind(this, {accessory: accessory, service_id: id}));
// Register for the "get" event notification.
// eslint-disable-next-line object-shorthand
charOn.off('set', this._handleOnSet.bind(this, {accessory: accessory, service_id: id}));
}
}
/* Unregister the accessory */
this._api.unregisterPlatformAccessories(_PackageInfo.CONFIG_INFO.plugin, _PackageInfo.CONFIG_INFO.platform, [accessory]);
/* remove the accessory from our mapping */
this._accessories.delete(accessory.displayName);
}
/**
* @description Update an accessory
* @param {_PlatformAccessory} accessory - accessory to be updated.
* @returns {void}
* @throws {TypeError} - Thrown when 'accessory' is not an instance of _PlatformAccessory.
* @private
*/
_updateBatteryServiceAccessory(accessory) {
// Validate arguments
if ((accessory === undefined) || !(accessory instanceof _PlatformAccessory)) {
throw new TypeError('Accessory must be a PlatformAccessory');
}
this._log.debug(`Updating accessory '${accessory.displayName}'`);
// Create an error to be used to indicate that the accessory is
// not reachable.
const error = new Error(`Volume '${accessory.displayName} is not reachable.`);
let percentFree = error;
let lowAlert = error;
let chargeState = error;
let theModel = error;
let theSerialNumber = error;
// Is the volume associated with this directory known?
if (this._volumesData.has(accessory.displayName)) {
// Get the volume data.
const volData = this._volumesData.get(accessory.displayName);
if (volData.IsShown) {
// Compute the fraction of space remaining.
percentFree = volData.PercentFree.toFixed(0);
// Determine if the remaining space threshold has been exceeded.
lowAlert = volData.LowSpaceAlert;
// The charging state is always 'Not Chargable'.
chargeState = _hap.Characteristic.ChargingState.NOT_CHARGEABLE;
}
// Get Accessory Information
theModel = volData.VolumeType;
theSerialNumber = volData.VolumeUUID;
}
/* Update the accessory */
/* Get the battery service */
const batteryService = accessory.getService(_hap.Service.BatteryService);
if (batteryService !== undefined) {
/* Battery Charging State (Not applicable to this application) */
batteryService.updateCharacteristic(_hap.Characteristic.ChargingState, chargeState);
/* Battery Level Characteristic */
batteryService.updateCharacteristic(_hap.Characteristic.BatteryLevel, percentFree);
/* Low Battery Status (Used to indicate a nearly full volume) */
batteryService.updateCharacteristic(_hap.Characteristic.StatusLowBattery, lowAlert);
}
// Update the accessory information
this._updateAccessoryInfo(accessory, {model: theModel, serialnum: theSerialNumber});
}
/**
* @description Update common information for an accessory
* @param {_PlatformAccessory} accessory - accessory to be updated.
* @param {object} info - accessory information.
* @param {string | Error} info.model - accessory model number
* @param {string | Error} info.serialnum - accessory serial number.
* @returns {void}
* @throws {TypeError} - Thrown when 'accessory' is not an instance of _PlatformAccessory.
* @throws {TypeError} - Thrown when 'info' is not undefined, does not have the 'model' or
* 'serialnum' properties or the properties are not of the expected type.
* @private
*/
_updateAccessoryInfo(accessory, info) {
// Validate arguments
if ((accessory === undefined) || !(accessory instanceof _PlatformAccessory)) {
throw new TypeError('Accessory must be a PlatformAccessory');
}
if ((info === undefined) ||
(!Object.prototype.hasOwnProperty.call(info, 'model')) || ((typeof(info.model) !== 'string') || (info.model instanceof Error)) ||
(!Object.prototype.hasOwnProperty.call(info, 'serialnum')) || ((typeof(info.serialnum) !== 'string') || (info.serialnum instanceof Error))) {
throw new TypeError('info must be an object with properties named \'model\' and \'serialnum\' that are either strings or Error');
}
/* Get the accessory info service. */
const accessoryInfoService = accessory.getService(_hap.Service.AccessoryInformation);
if (accessoryInfoService !== undefined) {
/* Manufacturer */
accessoryInfoService.updateCharacteristic(_hap.Characteristic.Manufacturer, 'GrumpTech');
/* Model */
accessoryInfoService.updateCharacteristic(_hap.Characteristic.Model, info.model);
/* Serial Number */
accessoryInfoService.updateCharacteristic(_hap.Characteristic.SerialNumber, info.serialnum);
}
}
/**
* @description Event handler for the "get" event for the Switch.On characteristic.
* @param {object} eventInfo - accessory and id of the switch service being querried.
* @param {_PlatformAccessory} eventInfo.accessory - Reference to the platform accessory being querried
* @param {string} eventInfo.service_id - UUID of the Switch service being qeurried.
* @param {Function} callback - Function callback for homebridge.
* @returns {void}
* @throws {TypeError} - Thrown when 'event_info' is not an object.
* @throws {TypeError} - Thrown when 'event_info.accessory' is not an instance of _PlatformAccessory.
* @throws {RangeError} - Thrown when 'event_info.service_id' does not belong to 'event_info.accessory'
* @throws {TypeError} - Thrown when 'event_info.service_id' does not correspond to a Switch service.
* @private
*/
_handleOnGet(eventInfo, callback) {
// Validate arguments
if ((eventInfo === undefined) || (typeof(eventInfo) !== 'object') ||
(!Object.prototype.hasOwnProperty.call(eventInfo, 'accessory')) ||
(!Object.prototype.hasOwnProperty.call(eventInfo, 'service_id'))) {
throw new TypeError('event_info must be an object with an \'accessory\' and \'service_id\' field.');
}
if ((eventInfo.accessory === undefined) || !(eventInfo.accessory instanceof _PlatformAccessory)) {
throw new TypeError('\'event_info.accessory\' must be a PlatformAccessory');
}
if ((eventInfo.service_id === undefined) || (typeof(eventInfo.service_id) !== 'string') ||
(eventInfo.service_id.length <= 0)) {
throw new TypeError('\'event_info.service_id\' must be non-null string.');
}
const id = eventInfo.service_id.split('.');
if (!Array.isArray(id) || (id.length !== 2)) {
throw new TypeError(`'event_info.service_id' does not appear to be valid. '${eventInfo.service_id}'`);
}
const theService = eventInfo.accessory.getServiceById(id[0], id[1]);
// Ensure that the Service Id belongs to the Accessory
if (theService === undefined) {
throw new RangeError('\'event_info.service_id\' does not belong to event_info.accessory.');
}
// Ensure that the Service Id belongs to the Accessory
if (!(theService instanceof _hap.Service.Switch)) {
throw new TypeError('\'event_info.service_id\' must correspond to a switch service.');
}
this._log.debug(`Switch '${eventInfo.accessory.displayName}-${theService.displayName}.${theService.subtype}' Get Request.`);
let status = null;
let result;
try {
result = this._getAccessorySwitchState(theService);
}
catch (err) {
this._log.debug(` Unexpected error encountered: ${err.message}`);
result = false;
status = new Error(`Accessory ${eventInfo.accessory.displayName} is not ressponding.`);
}
// Invoke the callback function with our result.
callback(status, result);
}
/**
* @description Event handler for the "set" event for the Switch.On characteristic.
* @param {object} eventInfo - accessory and id of the switch service being querried.
* @param {_PlatformAccessory} eventInfo.accessory - Reference to the platform accessory being querried
* @param {string} eventInfo.service_id - UUID of the Switch service being qeurried.
* @param {boolean} value - new/rewuested state of the switch
* @param {Function} callback - Function callback for homebridge.
* @returns {void}
* @throws {TypeError} - Thrown when 'event_info' is not an object.
* @throws {TypeError} - Thrown when 'event_info.accessory' is not an instance of _PlatformAccessory.
* @throws {TypeError} - Thrown when 'event_info.service_id' is not a valid string.
* @throws {RangeError} - Thrown when 'event_info.service_id' does not belong to 'event_info.accessory'
* @throws {TypeError} - Thrown when 'event_info.service_id' does not correspond to a Switch service.
* @private
*/
_handleOnSet(eventInfo, value, callback) {
// Validate arguments
if ((eventInfo === undefined) || (typeof(eventInfo) !== 'object') ||
(!Object.prototype.hasOwnProperty.call(eventInfo, 'accessory')) ||
(!Object.prototype.hasOwnProperty.call(eventInfo, 'service_id'))) {
throw new TypeError('event_info must be an object with an \'accessory\' and \'service_id\' field.');
}
if ((eventInfo.accessory === undefined) || !(eventInfo.accessory instanceof _PlatformAccessory)) {
throw new TypeError('\'event_info.accessory\' must be a PlatformAccessory');
}
if ((eventInfo.service_id === undefined) || (typeof(eventInfo.service_id) !== 'string') ||
(eventInfo.service_id.length <= 0)) {
throw new TypeError('\'event_info.service_id\' must be non-null string.');
}
const id = eventInfo.service_id.split('.');
if (!Array.isArray(id) || (id.length !== 2)) {
throw new TypeError(`'event_info.service_id' does not appear to be valid. '${eventInfo.service_id}'`);
}
const theService = eventInfo.accessory.getServiceById(id[0], id[1]);
// Ensure that the Service Id belongs to the Accessory
if (theService === undefined) {
throw new RangeError('\'event_info.service_id\' does not belong to event_info.accessory.');
}
// Ensure that the Service Id belongs to the Accessory
if (!(theService instanceof _hap.Service.Switch)) {
throw new TypeError('\'event_info.service_id\' must correspond to a switch service.');
}
this._log.debug(`Switch '${eventInfo.accessory.displayName}-${theService.displayName}.${theService.subtype}' Set Request. New state:${value}`);
let theSwitchState;
const theSettings = eventInfo.accessory.context.SETTINGS;
if ((theSettings !== undefined) &&
(typeof(theSettings) === 'object') &&
(Object.prototype.hasOwnProperty.call(theSettings, 'SwitchStates')) &&
(Array.isArray(theSettings.SwitchStates))) {
for (const candidateSwitchState of theSettings.SwitchStates) {
if (candidateSwitchState.id === theService.displayName) {
theSwitchState = candidateSwitchState;
}
}
}
let status = null;
let finalValue = value;
try {
// The processing of the request to set a switch is context (switch) specific.
// The Manual Refresh switch has special logic.
if ((id[0] === FIXED_ACCESSORY_INFO.CONTROLS.service_list.MANUAL_REFRESH.uuid) &&
(id[1] === FIXED_ACCESSORY_INFO.CONTROLS.service_list.MANUAL_REFRESH.udst)) {
const currentValue = this._getAccessorySwitchState(theService);
// The user is not allowed to turn the switch off.
// It will auto reset when the current check is complete.
if ((!value) && (currentValue)) {
// Attempting to turn the switch from on to off.
// Not permitted.
this._log.debug(`Unable to turn the '${eventInfo.accessory.displayName}' switch off.`);
status = new Error(`Unable to turn the '${eventInfo.accessory.displayName}' switch off.`);
// Decouple setting the switch back on.
setImmediate((evtInfo, resetVal) => {
if (theService !== undefined) {
this._log.debug(`Switch '${theService.displayName}' Restoring state ${resetVal}`);
theService.updateCharacteristic(_hap.Characteristic.On, resetVal);
}
}, eventInfo, currentValue);
finalValue = currentValue;
}
else {
// The change is permitted.
// If the switch was turned on, then intiate a volume refresh.
// eslint-disable-next-line no-lonely-if
if (value) {
// eslint-disable-next-line new-cap
this._volumeInterrogator.Start();
}
}
}
}
catch (err) {
this._log.debug(` Unexpected error encountered: ${err.message}`);
status = new Error(`Accessory ${eventInfo.accessory.displayName} is not ressponding.`);
}
// Persist the value set.
if (theSwitchState !== undefined) {
theSwitchState.state = finalValue;
}
callback(status);
}
/**
* @description Get the value of the Service.Switch.On characteristic value
* @param {object} switchService Switch Service (hap.Service)
* @returns {boolean} the value of the On characteristic (true or false)
* @throws {TypeError} - Thrown when 'switchService' is not an instance of a Switch Service.
* @throws {TypeError} - Thrown when 'event_info.accessory' is not an instance of _PlatformAccessory.
* @throws {TypeError} - Thrown when 'event_info.service_id' is not a valid string.
* @throws {RangeError} - Thrown when 'event_info.service_id' does not belong to 'event_info.accessory'
* @throws {TypeError} - Thrown when 'event_info.service_id' does not correspond to a Switch service.
* @throws {Error} - Thrown when the On characteristic cannot be found on the service.
* @private
*/
_getAccessorySwitchState(switchService) {
// Validate arguments
if ((switchService === undefined) || !(switchService instanceof _hap.Service.Switch)) {
throw new TypeError('\'switchService\' must be a _hap.Service.Switch');
}
let result = false;
const charOn = switchService.getCharacteristic(_hap.Characteristic.On);
if (charOn !== undefined) {
result = charOn.value;
}
else {
throw new Error(`The '${switchService.displayName}.${switchService.udst}' service does not have an On charactristic.`);
}
return result;
}
/**
* @description Helper to specify the HAP Service Type from the FIXED_ACCESSORY_SERICE_TYPES enumeration.
* @param {FIXED_ACCESSORY_SERVICE_TYPES} serviceType - Type of the service to get.
* @returns {object} - the HAP Service type.
* @throws {TypeError} - Thrown if 'service_type' is not a FIXED_ACCESSORY_SERVICE_TYPES value.
* @private
*/
_getAccessoryServiceType(serviceType) {
// Validate arguments
if ((serviceType === undefined) || (typeof(serviceType) !== 'number') ||
(Object.values(FIXED_ACCESSORY_SERVICE_TYPES).indexOf(serviceType) < 0)) {
throw new TypeError(`service_type not a member of FIXED_ACCESSORY_SERVICE_TYPES. ${serviceType}`);
}
let rtnVal;
switch (serviceType) {
case FIXED_ACCESSORY_SERVICE_TYPES.Switch: {
rtnVal = _hap.Service.Switch;
}
// eslint-disable-next-line indent
break;
default: {
// Not handled. Should never happen !!
throw new Error(`This cannot happen !! service_type=${serviceType}`);
}
// eslint-disable-next-line indent, no-unreachable
break;
}
return rtnVal;
}
}
/**
* @description Exported default function for Homebridge integration.
* @param {object} homebridgeAPI - reference to the Homebridge API.
* @returns {void}
*/
export default (homebridgeAPI) => {
_debug(`homebridge API version: v${homebridgeAPI.version}`);
// Accessory must be created from PlatformAccessory Constructor
_PlatformAccessory = homebridgeAPI.platformAccessory;
if (!Object.prototype.hasOwnProperty.call(_PlatformAccessory, 'PlatformAccessoryEvent')) {
// Append the PlatformAccessoryEvent.IDENTITY enum to the platform accessory reference.
// This allows us to not need to import anything from 'homebridge'.
const platformAccessoryEvent = {
IDENTIFY: 'identify',
};
_PlatformAccessory.PlatformAccessoryEvent = platformAccessoryEvent;
}
// Cache the reference to hap-nodejs
_hap = homebridgeAPI.hap;
// Register the paltform.
_debug(`Registering platform: ${_PackageInfo.CONFIG_INFO.platform}`);
homebridgeAPI.registerPlatform(_PackageInfo.CONFIG_INFO.platform, VolumeInterrogatorPlatform);
};