@lucavb/homebridge-shelly-ds9
Version:
Homebridge plugin for the next generation of Shelly devices
1,651 lines (1,631 loc) • 70.6 kB
JavaScript
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