UNPKG

@lucavb/homebridge-shelly-ds9

Version:

Homebridge plugin for the next generation of Shelly devices

1,651 lines (1,631 loc) 70.6 kB
let _lucavb_shellies_ds9 = require("@lucavb/shellies-ds9"); let fs = require("fs"); let path = require("path"); let homebridge = require("homebridge"); //#region src/utils/characteristics.ts /** * Returns a set of custom HomeKit characteristics. * @param api - A reference to the homebridge API. */ const createCharacteristics = (api) => { /** * Current energy consumption, in watts. */ class CurrentConsumption extends api.hap.Characteristic { static UUID = "E863F10D-079E-48FF-8F27-9C2605A29F52"; constructor() { super("Current Consumption", CurrentConsumption.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: "W", minValue: 0, maxValue: 12e3, minStep: .1 }); } } /** * Current measured electric current, in amperes. */ class ElectricCurrent extends api.hap.Characteristic { static UUID = "E863F126-079E-48FF-8F27-9C2605A29F52"; constructor() { super("Electric Current", ElectricCurrent.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: "A", minValue: 0, maxValue: 48, minStep: .1 }); } } /** * Total energy consumption, in kilowatt hours. */ class TotalConsumption extends api.hap.Characteristic { static UUID = "E863F10C-079E-48FF-8F27-9C2605A29F52"; constructor() { super("Total Consumption", TotalConsumption.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: "kWh", minValue: 0, maxValue: 1e6, minStep: .1 }); } } /** * Current measured voltage, in volts. */ class Voltage extends api.hap.Characteristic { static UUID = "E863F10A-079E-48FF-8F27-9C2605A29F52"; constructor() { super("Voltage", Voltage.UUID, { format: api.hap.Formats.FLOAT, perms: [api.hap.Perms.NOTIFY, api.hap.Perms.PAIRED_READ], unit: "V", minValue: -1e3, maxValue: 1e3, minStep: .1 }); } } return { CurrentConsumption, ElectricCurrent, TotalConsumption, Voltage }; }; //#endregion //#region src/utils/services.ts /** * Returns a set of custom HomeKit services. * @param api - A reference to the homebridge API. * @param characteristics - Custom characteristics used with these services. */ const createServices = (api, characteristics) => { /** * Reports power meter readings. */ class PowerMeter extends api.hap.Service { static UUID = "DEDBEA44-11ED-429C-BD75-9A2286AA8707"; constructor(displayName, subtype) { super(displayName, PowerMeter.UUID, subtype); this.addCharacteristic(characteristics.CurrentConsumption); this.addOptionalCharacteristic(characteristics.TotalConsumption); this.addOptionalCharacteristic(characteristics.ElectricCurrent); this.addOptionalCharacteristic(characteristics.Voltage); } } /** * Reports Pm1 readings. */ class Pm1 extends api.hap.Service { static UUID = "DEDBEA44-11ED-429C-BD75-9A2286AA8707"; constructor(displayName, subtype) { super(displayName, Pm1.UUID, subtype); this.addCharacteristic(characteristics.CurrentConsumption); this.addOptionalCharacteristic(characteristics.TotalConsumption); this.addOptionalCharacteristic(characteristics.ElectricCurrent); this.addOptionalCharacteristic(characteristics.Voltage); } } return { PowerMeter, Pm1 }; }; //#endregion //#region src/utils/device-cache.ts const FILENAME = ".shelly-ds9.json"; const LEGACY_FILENAME = ".shelly-ng.json"; const SAVE_DELAY = 1e3; /** * Handles saving and loading device information to a cache file. */ var DeviceCache = class { log; /** * A path to the cache file. */ path; /** * A path to the legacy cache file from the upstream plugin. */ legacyPath; /** * Holds all devices loaded from the cache file. */ devices = new Map(); saveTimeout = null; /** * @param storagePath - A path to the directory that the devices will be stored in. * @param log - A logging device. */ constructor(storagePath, log) { this.log = log; this.path = (0, path.resolve)(storagePath, FILENAME); this.legacyPath = (0, path.resolve)(storagePath, LEGACY_FILENAME); } /** * Loads cached devices. */ async load() { this.devices.clear(); let data; let loadedFromLegacy = false; for (const filePath of [this.path, this.legacyPath]) { try { await fs.promises.access(filePath); data = await fs.promises.readFile(filePath, { encoding: "utf8" }); loadedFromLegacy = filePath === this.legacyPath; break; } catch {} } if (data === undefined) { this.log.debug("Device cache file " + this.path + " not found"); return; } const s = JSON.parse(data); this.log.debug(`Loaded ${s.devices.length} device(s) from cache`); for (const d of s.devices) { this.devices.set(d.id, d); } if (loadedFromLegacy) { this.saveDelayed(); } } /** * Saves the devices to cache. */ async save() { const s = { devices: Array.from(this.devices.values()) }; const data = JSON.stringify(s); this.log.debug(`Saving ${s.devices.length} device(s) to cache`); return fs.promises.writeFile(this.path, data); } /** * Saves the devices to cache after a short delay. * Multiple calls to this method within the delay will be debounced. */ saveDelayed() { if (this.saveTimeout !== null) { clearTimeout(this.saveTimeout); } this.saveTimeout = setTimeout(async () => { this.saveTimeout = null; try { await this.save(); } catch (e) { this.log.error("Failed to save devices to cache:", e instanceof Error ? e.message : e); } }, SAVE_DELAY); } /** * Returns device info for the given device ID. */ get(id) { return this.devices.get(id); } /** * Stores the given device info. * @param d - The device info. * @param autoSave - Whether `saveDelayed()` should be automatically invoked. */ set(d, autoSave = true) { this.devices.set(d.id, d); if (autoSave === true) { this.saveDelayed(); } } /** * Stores info about the given device in cache. * @param device - The device. * @param autoSave - Whether `saveDelayed()` should be automatically invoked. */ storeDevice(device, autoSave = true) { const protocol = device.rpcHandler.protocol; let hostname; if (protocol === "websocket") { hostname = device.rpcHandler.hostname; } this.set({ hostname, id: device.id, model: device.model, protocol }, autoSave); } /** * Deletes info about the device with the given ID from cache. * @param id - The device ID. * @param autoSave - Whether `saveDelayed()` should be automatically invoked. */ delete(id, autoSave = true) { this.devices.delete(id); if (autoSave === true) { this.saveDelayed(); } } /** * Returns a new Iterator object that contains each device. */ [Symbol.iterator]() { return this.devices.values(); } }; //#endregion //#region src/abilities/base.ts /** * Base class for all abilities. * An ability is roughly equivalent to a HomeKit service. */ var Ability = class { serviceName; serviceSubtype; _platformAccessory = null; /** * The associated platform accessory. */ get platformAccessory() { if (this._platformAccessory === null) { throw new Error("Ability has not yet been setup"); } return this._platformAccessory; } _platform = null; /** * A reference to the platform. */ get platform() { if (this._platform === null) { throw new Error("Ability has not yet been setup"); } return this._platform; } /** * A reference to the homebridge API. */ get api() { return this.platform.api; } /** * Shorthand property. */ get Characteristic() { return this.platform.api.hap.Characteristic; } /** * Shorthand property. */ get Service() { return this.platform.api.hap.Service; } /** * Shorthand property. */ get customCharacteristics() { return this.platform.customCharacteristics; } /** * Shorthand property. */ get customServices() { return this.platform.customServices; } _log = null; /** * The logging device to use. */ get log() { if (this._log === null) { throw new Error("Ability has not yet been setup"); } return this._log; } _service = null; /** * The HomeKit service that this ability uses. */ get service() { if (this._service === null) { throw new Error("Ability has not yet been setup"); } return this._service; } _active = true; /** * Whether this ability is active. * Setting an ability to inactive will remove its HomeKit service. */ get active() { return this._active; } set active(value) { if (value === this._active) { return; } this._active = value; this.update(); } /** * @param serviceName - A name of the service. * @param serviceSubtype - A unique identifier for the service. */ constructor(serviceName, serviceSubtype) { this.serviceName = serviceName; this.serviceSubtype = serviceSubtype; } /** * Sets up this ability. * This method is called by the parent accessory every time it becomes active. * @param platformAccessory - The homebridge platform accessory to use. * @param platform - A reference to the platform. * @param log - The logger to use. */ setup(platformAccessory, platform, log) { this._platformAccessory = platformAccessory; this._platform = platform; this._log = log; this.update(); } /** * Sets `active` to the given value. * This method can be used when chaining calls, as it returns a reference to `this`. * @param value - Whether the ability should be active. */ setActive(value) { this.active = value; return this; } /** * Determines whether this ability is active. * The default implementation simply returns the value of the `active` property. * Subclasses can override this method to add more conditions. */ isActive() { return this.active; } /** * Updates this ability based on whether it is active. * If active, its service will be added and initialized. * If inactive, its service will be removed. */ update() { if (this._platformAccessory === null) { return; } if (this.isActive()) { if (this._service === null) { this._service = this.addService(); this.initialize(); } } else { if (this._service !== null) { this.detach(); } this.removeService(); this._service = null; } } /** * Returns a service for this ability. * If the platform accessory has a matching service, it will be returned. Otherwise, the service will be added. */ addService() { let service; if (this.serviceName && this.serviceSubtype) { service = this.platformAccessory.getService(this.serviceName) || this.platformAccessory.addService(this.serviceClass, this.serviceName, this.serviceSubtype); } else { service = this.platformAccessory.getService(this.serviceClass); } return service ?? null; } /** * Removes this ability's service from the platform accessory. */ removeService() { let service; if (this._service !== null) { service = this._service; } else if (this.serviceName && this.serviceSubtype) { service = this.platformAccessory.getService(this.serviceName); } else { service = this.platformAccessory.getService(this.serviceClass); } if (service) { this.platformAccessory.removeService(service); } } /** * Helper method that removes a characteristic based on its class (Service.removeCharacteristic() * only accepts an instance). * @param characteristic - The characteristic to remove. */ removeCharacteristic(characteristic) { const s = this.service; if (s.testCharacteristic(characteristic)) { s.removeCharacteristic(s.getCharacteristic(characteristic)); } } /** * Removes all event listeners and all references to the platform accessory. * This method is called by the parent accessory every time it becomes inactive. * Note that this method doesn't remove the service from the platform accessory as it is assumed that * the entire platform accessory is about to be unregistered and discarded. */ destroy() { this.detach(); this._platformAccessory = null; this._platform = null; this._log = null; this._service = null; } }; //#endregion //#region src/abilities/accessory-information.ts /** * Handles the AccessoryInformation service. */ var AccessoryInformationAbility = class extends Ability { device; /** * @param device - The associated device. */ constructor(device) { super(); this.device = device; } get serviceClass() { return this.Service.AccessoryInformation; } initialize() { this.service.setCharacteristic(this.Characteristic.Name, this.platformAccessory.displayName).setCharacteristic(this.Characteristic.Manufacturer, "Allterco").setCharacteristic(this.Characteristic.Model, this.device.modelName).setCharacteristic(this.Characteristic.SerialNumber, this.device.macAddress).setCharacteristic(this.Characteristic.FirmwareRevision, this.device.firmware.version || "1.0.0"); } detach() {} }; //#endregion //#region src/abilities/cover.ts const names = { door: "Door", window: "Window", windowCovering: "Window Covering" }; var CoverAbility = class extends Ability { component; type; /** * @param component - The cover component to control. * @param type - The type of cover. */ constructor(component, type = "window") { super(`${names[type]} ${component.id + 1}`, `${type}-${component.id}`); this.component = component; this.type = type; } get serviceClass() { if (this.type === "door") { return this.Service.Door; } else if (this.type === "windowCovering") { return this.Service.WindowCovering; } return this.Service.Window; } /** * The current state of the cover. */ get positionState() { const state = this.component.state; if (state === "opening") { return this.Characteristic.PositionState.INCREASING; } else if (state === "closing") { return this.Characteristic.PositionState.DECREASING; } return this.Characteristic.PositionState.STOPPED; } /** * The current position of the cover. */ get currentPosition() { return this.component.current_pos ?? 0; } /** * The target position that the cover is moving towards. */ get targetPosition() { return this.component.target_pos ?? this.currentPosition; } initialize() { if (!this.component.pos_control) { this.log.warn("Only calibrated covers are supported."); return; } this.service.setCharacteristic(this.Characteristic.PositionState, this.positionState).setCharacteristic(this.Characteristic.CurrentPosition, this.currentPosition).setCharacteristic(this.Characteristic.TargetPosition, this.targetPosition); this.service.getCharacteristic(this.Characteristic.TargetPosition).onSet(this.targetPositionSetHandler.bind(this)); this.component.on("change:state", this.stateChangeHandler, this).on("change:current_pos", this.currentPosChangeHandler, this).on("change:target_pos", this.targetPosChangeHandler, this); } detach() { this.component.off("change:state", this.stateChangeHandler, this).off("change:current_pos", this.currentPosChangeHandler, this).off("change:target_pos", this.targetPosChangeHandler, this); } /** * Handles changes to the TargetPosition characteristic. */ async targetPositionSetHandler(value) { if (value === this.component.target_pos) { return; } try { await this.component.goToPosition(value); } catch (e) { this.log.error("Failed to set target position:", e instanceof Error ? e.message : e); throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } /** * Handles changes to the `state` property. */ stateChangeHandler() { this.log.debug(`${this.component.id} state changed to ${this.positionState}`, { target: this.targetPosition, current: this.currentPosition }); this.updateStates(); } /** * Updates all states. * * Shelly does not send all attributes in a single notification. * We apparently need to update all states when any of them change, otherwise HomeKit * gets confused and thinks the cover is in a different state than it actually is. */ updateStates() { this.service.getCharacteristic(this.Characteristic.PositionState).updateValue(this.positionState); this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(this.targetPosition); this.service.getCharacteristic(this.Characteristic.CurrentPosition).updateValue(this.currentPosition); } /** * Handles changes to the `current_pos` property. */ currentPosChangeHandler() { this.log.debug(`${this.component.id} position changed to ${this.currentPosition}`, { target: this.targetPosition, state: this.positionState }); this.updateStates(); this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(this.currentPosition); } /** * Handles changes to the `target_pos` property. */ targetPosChangeHandler() { this.log.debug(`${this.component.id} target position changed to ${this.targetPosition}`, { state: this.positionState, current: this.currentPosition }); this.updateStates(); } }; //#endregion //#region src/abilities/light.ts var LightAbility = class extends Ability { component; /** * @param component - The light component to control. */ constructor(component) { super(`Light ${component.id + 1}`, `light-${component.id}`); this.component = component; } get serviceClass() { return this.Service.Lightbulb; } initialize() { this.service.setCharacteristic(this.Characteristic.On, this.component.output); this.service.getCharacteristic(this.Characteristic.On).onSet(this.onSetHandler.bind(this)); this.service.getCharacteristic(this.Characteristic.Brightness).onSet(this.brightnessSetHandler.bind(this)); this.component.on("change:output", this.outputChangeHandler, this); this.component.on("change:brightness", this.brightnessChangeHandler, this); } detach() { this.component.off("change:output", this.outputChangeHandler, this); this.component.off("change:brightness", this.brightnessChangeHandler, this); } /** * Handles changes to the Light.On characteristic. */ async onSetHandler(value) { if (value === this.component.output) { return; } try { await this.component.set(value); } catch (e) { this.log.error("Failed to set light:", e instanceof Error ? e.message : e); throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } /** * Handles changes to the `output` property. */ outputChangeHandler(value) { if (value) { this.log.info("Light Status(" + this.component.id + "): on"); } else { this.log.info("Light Status(" + this.component.id + "): off"); } this.service.getCharacteristic(this.Characteristic.On).updateValue(value); } /** * Handles changes to the Light.Brightness characteristic. */ async brightnessSetHandler(value) { if (value === this.component.brightness) { return; } try { await this.component.set(undefined, value); } catch (e) { this.log.error("Failed to set light:", e instanceof Error ? e.message : e); throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } /** * Handles changes to the `brightness` property. */ brightnessChangeHandler(value) { this.log.info("Light Status(" + this.component.id + "): " + value); this.service.getCharacteristic(this.Characteristic.Brightness).updateValue(value); } }; //#endregion //#region src/abilities/outlet.ts var OutletAbility = class extends Ability { component; /** * @param component - The switch component to control. */ constructor(component) { super(`Outlet ${component.id + 1}`, `outlet-${component.id}`); this.component = component; } get serviceClass() { return this.Service.Outlet; } initialize() { this.service.setCharacteristic(this.Characteristic.On, this.component.output).setCharacteristic(this.Characteristic.OutletInUse, this.component.apower !== undefined && this.component.apower !== 0); this.service.getCharacteristic(this.Characteristic.On).onSet(this.onSetHandler.bind(this)); this.component.on("change:output", this.outputChangeHandler, this).on("change:apower", this.apowerChangeHandler, this); } detach() { this.component.off("change:output", this.outputChangeHandler, this).off("change:apower", this.apowerChangeHandler, this); } /** * Handles changes to the Outlet.On characteristic. */ async onSetHandler(value) { if (value === this.component.output) { return; } try { await this.component.set(value); } catch (e) { this.log.error("Failed to set switch:", e instanceof Error ? e.message : e); throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } /** * Handles changes to the `output` property. */ outputChangeHandler(value) { if (value) { this.log.info("Switch Status(" + this.component.id + "): on"); } else { this.log.info("Switch Status(" + this.component.id + "): off"); } this.service.getCharacteristic(this.Characteristic.On).updateValue(value); } /** * Handles changes to the `apower` property. */ apowerChangeHandler(value) { this.service.getCharacteristic(this.Characteristic.OutletInUse).updateValue(value); } }; //#endregion //#region src/abilities/power-meter.ts /** * This ability sets up a custom service that reports power meter readings. */ var PowerMeterAbility = class extends Ability { component; /** * @param component - The switch or cover component to get readings from. */ constructor(component) { super(`Power Meter ${component.id + 1}`, `power-meter-${component.id}`); this.component = component; } get serviceClass() { return this.customServices.PowerMeter; } initialize() { const s = this.service; const c = this.component; const cc = this.customCharacteristics; s.setCharacteristic(cc.CurrentConsumption, c.apower ?? 0); c.on("change:apower", this.apowerChangeHandler, this); if (c.voltage !== undefined) { s.setCharacteristic(cc.Voltage, c.voltage); c.on("change:voltage", this.voltageChangeHandler, this); } else { this.removeCharacteristic(cc.Voltage); } if (c.current !== undefined) { s.setCharacteristic(cc.ElectricCurrent, c.current); c.on("change:current", this.currentChangeHandler, this); } else { this.removeCharacteristic(cc.ElectricCurrent); } if (c.aenergy !== undefined) { s.setCharacteristic(cc.TotalConsumption, c.aenergy.total / 1e3); c.on("change:aenergy", this.aenergyChangeHandler, this); } else { this.removeCharacteristic(cc.TotalConsumption); } } detach() { this.component.off("change:apower", this.apowerChangeHandler, this).off("change:voltage", this.voltageChangeHandler, this).off("change:current", this.currentChangeHandler, this).off("change:aenergy", this.aenergyChangeHandler, this); } /** * Handles changes to the `apower` property. */ apowerChangeHandler(value) { /** * Handles changes to the 'apower' property. HomeKit does not support negative values for the 'Current Consumption' characteristic. * For the use case of this plugin, knowing the negative voltage is not necessary, so we ensure that the value is never negative. */ const positiveValue = Math.max(0, value); this.service.updateCharacteristic(this.customCharacteristics.CurrentConsumption, positiveValue); } /** * Handles changes to the `voltage` property. */ voltageChangeHandler(value) { this.service.updateCharacteristic(this.customCharacteristics.Voltage, value); } /** * Handles changes to the `current` property. */ currentChangeHandler(value) { this.service.updateCharacteristic(this.customCharacteristics.ElectricCurrent, value); } /** * Handles changes to the `aenergy` property. */ aenergyChangeHandler(value) { const attr = value; this.service.updateCharacteristic(this.customCharacteristics.TotalConsumption, attr.total / 1e3); } }; //#endregion //#region src/abilities/readonly-switch.ts /** * This ability creates a switch that can't be controlled from HomeKit, it only reflects * the device's input state. */ var ReadonlySwitchAbility = class extends Ability { component; /** * @param component - The input component to represent. */ constructor(component) { super(`Switch ${component.id + 1}`, `readonly-switch-${component.id}`); this.component = component; } get serviceClass() { return this.Service.Switch; } initialize() { this.service.getCharacteristic(this.Characteristic.On).setProps({ perms: [homebridge.Perms.NOTIFY, homebridge.Perms.PAIRED_READ] }).setValue(this.component.state ?? false); this.component.on("change:state", this.stateChangeHandler, this); } detach() { this.component.off("change:state", this.stateChangeHandler, this); } /** * Handles changes to the `state` property. */ stateChangeHandler(value) { const v = value === null ? false : value; if (value) { this.log.info("Switch Status(" + this.component.id + "): on"); } else { this.log.info("Switch Status(" + this.component.id + "): off"); } this.service.getCharacteristic(this.Characteristic.On).updateValue(v); } }; //#endregion //#region src/abilities/service-label.ts var ServiceLabelAbility = class extends Ability { namespace; /** * @param namespace - The naming schema for the accessory. */ constructor(namespace = "arabicNumerals") { super(); this.namespace = namespace; } get serviceClass() { return this.Service.ServiceLabel; } initialize() { const SLN = this.Characteristic.ServiceLabelNamespace; this.service.setCharacteristic(SLN, this.namespace === "dots" ? SLN.DOTS : SLN.ARABIC_NUMERALS); } detach() {} }; //#endregion //#region src/abilities/stateless-programmable-switch.ts var StatelessProgrammableSwitchAbility = class extends Ability { component; /** * @param component - The input component to control. */ constructor(component) { super(`Button ${component.id + 1}`, `stateless-programmable-switch-${component.id}`); this.component = component; } get serviceClass() { return this.Service.StatelessProgrammableSwitch; } initialize() { this.service.setCharacteristic(this.Characteristic.ServiceLabelIndex, this.component.id + 1); this.component.on("singlePush", this.singlePushHandler, this).on("doublePush", this.doublePushHandler, this).on("longPush", this.longPushHandler, this); } detach() { this.component.off("singlePush", this.singlePushHandler, this).off("doublePush", this.doublePushHandler, this).off("longPush", this.longPushHandler, this); } /** * Triggers a button press event. * @param type - The type of button press to trigger. */ triggerPress(type) { this.log.debug(`Input ${this.component.id}: ${type} press`); const PSE = this.Characteristic.ProgrammableSwitchEvent; let value; switch (type) { case "single": value = PSE.SINGLE_PRESS; break; case "double": value = PSE.DOUBLE_PRESS; break; case "long": value = PSE.LONG_PRESS; break; } this.service.getCharacteristic(this.Characteristic.ProgrammableSwitchEvent).updateValue(value); } /** * Handles 'singlePush' events from our input component. */ singlePushHandler() { this.triggerPress("single"); } /** * Handles 'doublePush' events from our input component. */ doublePushHandler() { this.triggerPress("double"); } /** * Handles 'longPush' events from our input component. */ longPushHandler() { this.triggerPress("long"); } }; //#endregion //#region src/abilities/switch.ts var SwitchAbility = class extends Ability { component; /** * @param component - The switch component to control. */ constructor(component) { super(`Switch ${component.id + 1}`, `switch-${component.id}`); this.component = component; } get serviceClass() { return this.Service.Switch; } initialize() { this.service.setCharacteristic(this.Characteristic.On, this.component.output); this.service.getCharacteristic(this.Characteristic.On).onSet(this.onSetHandler.bind(this)); this.component.on("change:output", this.outputChangeHandler, this); } detach() { this.component.off("change:output", this.outputChangeHandler, this); } /** * Handles changes to the Switch.On characteristic. */ async onSetHandler(value) { if (value === this.component.output) { return; } try { await this.component.set(value); } catch (e) { this.log.error("Failed to set switch:", e instanceof Error ? e.message : e); throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } /** * Handles changes to the `output` property. */ outputChangeHandler(value) { if (value) { this.log.info("Switch Status(" + this.component.id + "): on"); } else { this.log.info("Switch Status(" + this.component.id + "): off"); } this.service.getCharacteristic(this.Characteristic.On).updateValue(value); } }; //#endregion //#region src/abilities/pm1.ts /** * This ability sets up a custom service that reports power meter readings. */ var Pm1Ability = class extends Ability { componentPm1; /** * @param componentPm1 - The switch or cover component to get readings from. */ constructor(componentPm1) { super(`Pm1 ${componentPm1.id + 1}`, `Pm1-${componentPm1.id}`); this.componentPm1 = componentPm1; } get serviceClass() { return this.customServices.Pm1; } initialize() { const s = this.service; const cPm1 = this.componentPm1; const cc = this.customCharacteristics; s.setCharacteristic(cc.CurrentConsumption, cPm1.apower ?? 0); s.setCharacteristic(this.Characteristic.On, false); cPm1.on("change:apower", this.apowerChangeHandler, this); if (cPm1.voltage !== undefined) { s.setCharacteristic(cc.Voltage, cPm1.voltage); cPm1.on("change:voltage", this.voltageChangeHandler, this); } else { this.removeCharacteristic(cc.Voltage); } if (cPm1.current !== undefined) { s.setCharacteristic(cc.ElectricCurrent, cPm1.current); cPm1.on("change:current", this.currentChangeHandler, this); } else { this.removeCharacteristic(cc.ElectricCurrent); } if (cPm1.aenergy !== undefined) { s.setCharacteristic(cc.TotalConsumption, cPm1.aenergy.total / 1e3); cPm1.on("change:aenergy", this.aenergyChangeHandler, this); } else { this.removeCharacteristic(cc.TotalConsumption); } } detach() { this.componentPm1.off("change:apower", this.apowerChangeHandler, this).off("change:voltage", this.voltageChangeHandler, this).off("change:current", this.currentChangeHandler, this).off("change:aenergy", this.aenergyChangeHandler, this); } /** * Handles changes to the `apower` property. */ apowerChangeHandler(value) { this.service.updateCharacteristic(this.customCharacteristics.CurrentConsumption, value); if (typeof value === "number") { if (value >= 1) { this.service.updateCharacteristic(this.Characteristic.On, true); } else { this.service.updateCharacteristic(this.Characteristic.On, false); } } } /** * Handles changes to the `voltage` property. */ voltageChangeHandler(value) { this.service.updateCharacteristic(this.customCharacteristics.Voltage, value); } /** * Handles changes to the `current` property. */ currentChangeHandler(value) { this.service.updateCharacteristic(this.customCharacteristics.ElectricCurrent, value); } /** * Handles changes to the `aenergy` property. */ aenergyChangeHandler(value) { const attr = value; this.service.updateCharacteristic(this.customCharacteristics.TotalConsumption, attr.total / 1e3); } }; //#endregion //#region src/abilities/temperature-sensor.ts /** DS18B20 operating range upper bound (°C); HAP CurrentTemperature defaults to 100. */ const DS18B20_MAX_CELSIUS = 125; /** * Exposes a Shelly temperature component as a HomeKit temperature sensor. */ var TemperatureSensorAbility = class extends Ability { component; constructor(component) { super(`Temperature ${component.id}`, `temperature-sensor-${component.id}`); this.component = component; } get serviceClass() { return this.Service.TemperatureSensor; } initialize() { const characteristic = this.service.getCharacteristic(this.Characteristic.CurrentTemperature); characteristic.setProps({ maxValue: DS18B20_MAX_CELSIUS }); this.updateTemperature(this.component.tC); this.component.on("change:tC", this.temperatureChangeHandler, this); } detach() { this.component.off("change:tC", this.temperatureChangeHandler, this); } temperatureChangeHandler(value) { this.updateTemperature(value); } updateTemperature(value) { const characteristic = this.service.getCharacteristic(this.Characteristic.CurrentTemperature); if (value === null || typeof value !== "number" || Number.isNaN(value)) { characteristic.updateValue(new this.api.hap.HapStatusError(this.api.hap.HAPStatus.RESOURCE_BUSY)); return; } characteristic.updateValue(Math.min(value, DS18B20_MAX_CELSIUS)); } }; //#endregion //#region src/abilities/humidity-sensor.ts /** * Exposes a Shelly humidity component as a HomeKit humidity sensor. */ var HumiditySensorAbility = class extends Ability { component; constructor(component) { super(`Humidity ${component.id}`, `humidity-sensor-${component.id}`); this.component = component; } get serviceClass() { return this.Service.HumiditySensor; } initialize() { this.updateHumidity(this.component.rh); this.component.on("change:rh", this.humidityChangeHandler, this); } detach() { this.component.off("change:rh", this.humidityChangeHandler, this); } humidityChangeHandler(value) { this.updateHumidity(value); } updateHumidity(value) { const characteristic = this.service.getCharacteristic(this.Characteristic.CurrentRelativeHumidity); if (value === null || typeof value !== "number" || Number.isNaN(value)) { characteristic.updateValue(new this.api.hap.HapStatusError(this.api.hap.HAPStatus.RESOURCE_BUSY)); return; } characteristic.updateValue(value); } }; //#endregion //#region src/accessory.ts function resolveAccessoryCategory(abilities) { const active = abilities.filter((a) => a.active); for (const a of active) { if (a instanceof OutletAbility) { return homebridge.Categories.OUTLET; } } for (const a of active) { if (a instanceof SwitchAbility || a instanceof ReadonlySwitchAbility) { return homebridge.Categories.SWITCH; } } for (const a of active) { if (a instanceof LightAbility) { return homebridge.Categories.LIGHTBULB; } } for (const a of active) { if (a instanceof CoverAbility) { if (a.type === "door") { return homebridge.Categories.DOOR; } if (a.type === "window") { return homebridge.Categories.WINDOW; } return homebridge.Categories.WINDOW_COVERING; } } for (const a of active) { if (a instanceof StatelessProgrammableSwitchAbility) { return homebridge.Categories.PROGRAMMABLE_SWITCH; } } for (const a of active) { if (a instanceof TemperatureSensorAbility || a instanceof HumiditySensorAbility) { return homebridge.Categories.SENSOR; } } return homebridge.Categories.OTHER; } /** * Represents a HomeKit accessory. */ var Accessory = class { id; deviceId; name; platform; log; /** * The UUID used to identify this accessory with HomeKit. */ uuid; _platformAccessory; /** * The underlying homebridge platform accessory. * This property will be `null` when the accessory is inactive. */ get platformAccessory() { return this._platformAccessory; } /** * Holds this accessory's abilities. */ abilities; _active = true; /** * Whether this accessory is active. * Setting an accessory to inactive will remove it from HoneKit. */ get active() { return this._active; } set active(value) { if (value === this._active) { return; } this._active = value; this.update(); } /** * Timeout used to delay calls to `update()`. */ updateTimeout = null; /** * @param id - The accessory ID. * @param deviceId - The associated device ID. * @param name - A user-friendly name of the accessory. * @param platform - A reference to the homebridge platform. * @param log - The logger to use. * @param abilities - The abilities that this accessory has. */ constructor(id, deviceId, name, platform, log, ...abilities) { this.id = id; this.deviceId = deviceId; this.name = name; this.platform = platform; this.log = log; this.uuid = platform.api.hap.uuid.generate(`${deviceId}-${id}`); this.abilities = abilities; this._platformAccessory = platform.getAccessory(this.uuid) || null; if (this._platformAccessory !== null) { log.debug(`Accessory loaded from cache (ID: ${id})`); } this.update(); } /** * Sets `active` to the given value. * This method can be used when chaining calls, as it returns a reference to `this`. * @param value - Whether the accessory should be active. */ setActive(value) { this.active = value; return this; } /** * Updates this accessory based on whether it is active. */ update() { if (this.updateTimeout !== null) { clearTimeout(this.updateTimeout); } this.updateTimeout = setTimeout(() => { this.updateTimeout = null; if (this.active) { this.activate(); } else { this.deactivate(); } }, 0); } /** * Activates this accessory, by creating a platform accessory and setting up all abilities. */ activate() { if (this._platformAccessory === null) { this._platformAccessory = this.createPlatformAccessory(); this.log.debug(`Accessory activated (ID: ${this.id})`); } for (const a of this.abilities) { try { a.setup(this._platformAccessory, this.platform, this.log); } catch (e) { this.log.error("Failed to setup ability:", e instanceof Error ? e.message : e); this.log.debug("Accessory ID:", this.id); if (e instanceof Error && e.stack) { this.log.debug(e.stack); } } } this.platform.addAccessory(this._platformAccessory); } /** * Deactivates this accessory, by destroying all abilities and the platform accessory. */ deactivate() { for (const a of this.abilities) { try { a.destroy(); } catch (e) { this.log.error("Failed to destroy ability:", e instanceof Error ? e.message : e); this.log.debug("Accessory ID:", this.id); if (e instanceof Error && e.stack) { this.log.debug(e.stack); } } } if (this._platformAccessory !== null) { this.platform.removeAccessory(this._platformAccessory); this._platformAccessory = null; this.log.debug(`Accessory deactivated (ID: ${this.id})`); } } /** * Creates a new platform accessory for this accessory. */ createPlatformAccessory() { const category = resolveAccessoryCategory(this.abilities); const pa = new this.platform.api.platformAccessory(this.name, this.uuid, category); pa.context.device = { id: this.deviceId }; return pa; } /** * Removes all event listeners from this accessory. */ detach() { if (this.updateTimeout !== null) { clearTimeout(this.updateTimeout); this.updateTimeout = null; } for (const a of this.abilities) { try { a.detach(); } catch (e) { this.log.error("Failed to detach ability:", e instanceof Error ? e.message : e); this.log.debug("Accessory ID:", this.id); if (e instanceof Error && e.stack) { this.log.debug(e.stack); } } } } }; //#endregion //#region src/utils/device-logger.ts /** * Utility used to prefix log messages with device IDs. */ var DeviceLogger = class { device; logger; prefix; /** * @param device - The device to use. * @param deviceName - A user-friendly name of the device. * @param logger - The logging device to write to. */ constructor(device, deviceName, logger) { this.device = device; this.logger = logger; this.prefix = `[${deviceName || device.id}] `; } info(message, ...parameters) { this.log(homebridge.LogLevel.INFO, message, ...parameters); } warn(message, ...parameters) { this.log(homebridge.LogLevel.WARN, message, ...parameters); } error(message, ...parameters) { this.log(homebridge.LogLevel.ERROR, message, ...parameters); } debug(message, ...parameters) { this.log(homebridge.LogLevel.DEBUG, message, ...parameters); } log(level, message, ...parameters) { this.logger.log(level, this.prefix + message, ...parameters); } }; //#endregion //#region src/device-delegates/base.ts /** * A DeviceDelegate manages accessories for a device. */ var DeviceDelegate = class DeviceDelegate { device; options; platform; /** * Holds all registered delegates. */ static delegates = new Map(); /** * Registers a device delegate, so that it can later be found based on a device class or model * using the `DeviceDelegate.getDelegate()` method. * @param delegate - A subclass of `DeviceDelegate`. * @param deviceClasses - One or more subclasses of `Device`. */ static registerDelegate(delegate, ...deviceClasses) { for (const deviceCls of deviceClasses) { const mdl = deviceCls.model.toUpperCase(); if (DeviceDelegate.delegates.has(mdl)) { throw new Error(`A device delegate for ${deviceCls.model} has already been registered`); } DeviceDelegate.delegates.set(mdl, delegate); } } /** * Returns the device delegate for the given device class or model, if one has been registered. * @param deviceClsOrModel - The device class or model ID to lookup. */ static getDelegate(deviceClsOrModel) { const mdl = typeof deviceClsOrModel === "string" ? deviceClsOrModel : deviceClsOrModel.model; return DeviceDelegate.delegates.get(mdl.toUpperCase()); } /** * Holds all accessories for this device. */ accessories = new Map(); /** * Logger specific for this device. */ log; /** * Used to keep track of whether a connection had been established when the 'disconnect' event is emitted by our RPC handler. */ connected; /** * @param device - The device to handle. * @param options - Configuration options for the device. * @param platform - A reference to the homebridge platform. */ constructor(device, options, platform) { this.device = device; this.options = options; this.platform = platform; this.log = new DeviceLogger(device, options.name, platform.log); this.log.info("Device added"); this.log.debug(device.rpcHandler.connected ? "Device is connected" : "Device is disconnected"); this.connected = device.rpcHandler.connected; device.rpcHandler.on("connect", this.handleConnect, this).on("disconnect", this.handleDisconnect, this).on("request", this.handleRequest, this); this.setup(); this.setupAddonSensors(); } /** * Creates HomeKit accessories for Shelly Sensor Add-on temperature and humidity components. */ setupAddonSensors() { const temperatures = new Map(); const humidities = new Map(); for (const [, component] of this.device) { if (component instanceof _lucavb_shellies_ds9.Temperature && component.id >= _lucavb_shellies_ds9.ADDON_SENSOR_MIN_ID) { temperatures.set(component.id, component); } else if (component instanceof _lucavb_shellies_ds9.Humidity && component.id >= _lucavb_shellies_ds9.ADDON_SENSOR_MIN_ID) { humidities.set(component.id, component); } } const pairedHumidityIds = new Set(); for (const [id, temperature] of temperatures) { const temperatureOptions = this.getComponentOptions(temperature) ?? {}; const humidity = humidities.get(id); if (temperatureOptions.exclude === true) { if (humidity) { pairedHumidityIds.add(id); } continue; } const abilities = [new TemperatureSensorAbility(temperature)]; let accessoryId = `addon-temperature-${id}`; let nameSuffix = this.formatAddonSensorName(temperature, "Temperature", id); if (humidity) { const humidityOptions = this.getComponentOptions(humidity) ?? {}; if (humidityOptions.exclude !== true) { abilities.push(new HumiditySensorAbility(humidity)); accessoryId = `addon-climate-${id}`; nameSuffix = this.formatAddonClimateName(temperature, humidity, id); pairedHumidityIds.add(id); } } this.createAccessory(accessoryId, nameSuffix, ...abilities); } for (const [id, humidity] of humidities) { if (pairedHumidityIds.has(id)) { continue; } const humidityOptions = this.getComponentOptions(humidity) ?? {}; if (humidityOptions.exclude === true) { continue; } this.createAccessory(`addon-humidity-${id}`, this.formatAddonSensorName(humidity, "Humidity", id), new HumiditySensorAbility(humidity)); } } formatAddonSensorName(component, fallbackLabel, id) { const name = component.config?.name; if (typeof name === "string" && name.length > 0) { return name; } return `${fallbackLabel} ${id}`; } formatAddonClimateName(temperature, humidity, id) { const temperatureName = temperature.config?.name; const humidityName = humidity.config?.name; if (typeof temperatureName === "string" && temperatureName.length > 0) { return temperatureName; } if (typeof humidityName === "string" && humidityName.length > 0) { return humidityName; } return `Climate ${id}`; } /** * Retrieves configuration options for the given component from the device options. * @param component - The component. * @returns A set of options, if found. */ getComponentOptions(component) { return this.options?.[component.key]; } /** * Creates an accessory with the given ID. * If a matching platform accessory is not found in cache, a new one will be created. * @param id - A unique identifier for this accessory. * @param nameSuffix - A string to append to the name of this accessory. * @param abilities - The abilities to add to this accessory. */ createAccessory(id, nameSuffix, ...abilities) { if (this.accessories.has(id)) { throw new Error(`An accessory with ID '${id}' already exists`); } let name = this.options.name || this.device.modelName; if (nameSuffix) { name += " " + nameSuffix; } const accessory = new Accessory(id, this.device.id, name, this.platform, this.log, new AccessoryInformationAbility(this.device), ...abilities); this.accessories.set(id, accessory); return accessory; } /** * Creates an accessory for a switch component. * @param swtch - The switch component to use. * @param opts - Options for the switch. */ addSwitch(swtch, opts) { const o = opts ?? {}; const switchOpts = this.getComponentOptions(swtch) ?? {}; const type = typeof switchOpts.type === "string" ? switchOpts.type.toLowerCase() : "switch"; const isOutlet = type === "outlet"; const id = o.single === true ? "switch" : `switch-${swtch.id}`; const nameSuffix = o.single === true ? null : `Switch ${swtch.id + 1}`; return this.createAccessory(id, nameSuffix, new OutletAbility(swtch).setActive(isOutlet), new SwitchAbility(swtch).setActive(!isOutlet), new PowerMeterAbility(swtch).setActive(swtch.apower !== undefined)).setActive(switchOpts.exclude !== true && o.active !== false); } /** * Creates an accessory for a cover component. * @param cover - The cover component to use. * @param opts - Options for the cover. */ addCover(cover, opts) { const o = opts ?? {}; const coverOpts = this.getComponentOptions(cover) ?? {}; const type = typeof coverOpts.type === "string" ? coverOpts.type.toLowerCase() : "window"; const isDoor = type === "door"; const isWindowCovering = type === "windowcovering"; const id = o.single === true ? "cover" : `cover-${cover.id}`; return this.createAccessory(id, "Cover", new CoverAbility(cover, "door").setActive(isDoor), new CoverAbility(cover, "windowCovering").setActive(isWindowCovering), new CoverAbility(cover, "window").setActive(!isDoor && !isWindowCovering), new PowerMeterAbility(cover)).setActive(coverOpts.exclude !== true && o.active !== false); } /** * Creates an accessory for a light component. * @param light - The light component to use. * @param opts - Options for the light. */ addLight(light, opts) { const o = opts ?? {}; const lightOpts = this.getComponentOptions(light) ?? {}; const id = o.single === true ? "light" : `light-${light.id}`; const nameSuffix = o.single === true ? null : `Light ${light.id + 1}`; return this.createAccessory(id, nameSuffix, new LightAbility(light)).setActive(lightOpts.exclude !== true && o.active !== false); } /** * Handles 'connect' events from the RPC handler. */ handleConnect() { this.log.info("Device connected"); this.connected = true; } /** * Handles 'disconnect' events from the RPC handler. */ handleDisconnect(code, reason, reconnectIn) { const details = reason.length > 0 ? "reason: " + reason : "code: " + code; this.log.warn((this.connected ? "Device disconnected" : "Connection failed") + " (" + details + ")"); if (reconnectIn !== null) { let msg = "Reconnecting in "; if (reconnectIn < 60 * 1e3) { msg += Math.floor(reconnectIn / 1e3) + " second(s)"; } else if (reconnectIn < 60 * 60 * 1e3) { msg += Math.floor(reconnectIn / (60 * 1e3)) + " minute(s)"; } else { msg += Math.floor(reconnectIn / (60 * 60 * 1e3)) + " hour(s)"; } this.log.info(msg); } this.connected = false; } /** * Handles 'request' events from the RPC handler. */ handleRequest(method) { this.log.debug("WebSocket:", method); } /** * Removes all event listeners from this device. */ detach() { this.device.rpcHandler.off("connect", this.handleConnect, this).off("disconnect", this.handleDisconnect, this).off("request", this.handleRequest, this); for (const a of this.accessories.values()) { a.detach(); } } /** * Destroys this device delegate, removing all event listeners and unregistering all accessories. */ destroy() { this.detach(); const pas = Array.from(this.accessories.values()).map((a) => a.platformAccessory).filter((a) => a !== null); if (pas.length > 0) { this.platform.removeAccessory(...pas); } this.log.info("Device removed"); } }; //#endregion //#region src/device-delegates/shelly-plus-1.ts /** * Handles Shelly Plus 1 devices. */ var ShellyPlus1Delegate = class extends DeviceDelegate { setup() { const d = this.device; this.addSwitch(d.switch0, { single: true }); } }; DeviceDelegate.registerDelegate(ShellyPlus1Delegate, _lucavb_shellies_ds9.ShellyPlus1, _lucavb_shellies_ds9.Sh