node-red-contrib-deconz
Version:
deCONZ connectivity nodes for node-red
679 lines (643 loc) • 21.7 kB
JavaScript
const dotProp = require("dot-prop");
const Utils = require("./Utils");
const Colorspace = require("./Colorspace");
class Attribute {
constructor() {
this.requiredAttribute = [];
this.requiredEventMeta = [];
this.requiredDeviceMeta = [];
this.servicesList = [];
this.valueLimits = {
min: -Infinity,
max: Infinity,
};
}
needAttribute(attribute) {
this.requiredAttribute = this.requiredAttribute.concat(
Utils.sanitizeArray(attribute)
);
return this;
}
needEventMeta(event) {
this.requiredEventMeta = this.requiredEventMeta.concat(
Utils.sanitizeArray(event)
);
return this;
}
needDeviceMeta(meta) {
this.requiredDeviceMeta = this.requiredDeviceMeta.concat(
Utils.sanitizeArray(meta)
);
return this;
}
needColorCapabilities(value) {
this.needDeviceMeta((deviceMeta) =>
Utils.supportColorCapability(deviceMeta, value)
);
return this;
}
to(method) {
this.toMethod = (...attr) => {
let value = method(...attr);
if (typeof value === "number") {
value = Math.max(value, this.valueLimits.min);
value = Math.min(value, this.valueLimits.max);
}
return value;
};
return this;
}
from(method) {
if (this._name !== undefined) {
console.error("Can't use name with from method");
}
this.fromMethod = method;
return this;
}
/**
*
* @param {number} min
* @param {number} max
*/
limit(min, max) {
this.valueLimits.min = min;
this.valueLimits.max = max;
return this;
}
services(services) {
this.servicesList = this.servicesList.concat(Utils.sanitizeArray(services));
return this;
}
name(name) {
if (this.fromMethod !== undefined) {
console.error("Can't use name with from method");
}
this._name = name;
return this;
}
priority(priority) {
this._priority = priority;
return this;
}
targetIsValid(rawEvent, deviceMeta) {
for (const meta of this.requiredEventMeta) {
if (typeof meta === "function") {
if (meta(rawEvent, deviceMeta) === false) return false;
} else if (Attribute._checkProperties(rawEvent, meta) === false) {
return false;
}
}
for (const meta of this.requiredDeviceMeta) {
if (typeof meta === "function") {
if (meta(deviceMeta) === false) return false;
} else if (Attribute._checkProperties(deviceMeta, meta) === false) {
return false;
}
}
return true;
}
attributeIsValid(result) {
let resultList = Array.isArray(result) ? result : Object.keys(result);
for (let attribute of this.requiredAttribute) {
if (!resultList.includes(attribute)) {
return false;
}
}
return true;
}
static _checkProperties(data, propertyMeta) {
switch (typeof propertyMeta) {
case "string":
return dotProp.has(data, propertyMeta);
case "object":
for (const [propertyName, expectedValue] of Object.entries(
propertyMeta
)) {
if (!dotProp.has(data, propertyName)) return false;
const currentValue = dotProp.get(data, propertyName);
if (Array.isArray(expectedValue)) {
if (expectedValue.includes(currentValue) === false) {
return false;
}
} else {
if (currentValue !== expectedValue) return false;
}
}
break;
}
return true;
}
}
const HomeKitFormat = (() => {
let directMap = (direction, path) => {
let attr = new Attribute();
attr.needEventMeta(path);
if (direction.includes("to"))
attr.to((rawEvent, deviceMeta) => dotProp.get(rawEvent, path));
if (direction.includes("from"))
attr.from((value, allValues, result) => dotProp.set(result, path, value));
return attr;
};
const HKF = {};
//#region Switchs
HKF.ServiceLabelIndex = new Attribute()
.services("Stateless Programmable Switch")
.needAttribute("ProgrammableSwitchEvent")
.needEventMeta("state.buttonevent")
.to((rawEvent, deviceMeta) =>
Math.floor(dotProp.get(rawEvent, "state.buttonevent") / 1000)
);
HKF.ProgrammableSwitchEvent = new Attribute()
.services("Stateless Programmable Switch")
.needAttribute("ServiceLabelIndex")
.needEventMeta("state.buttonevent")
.to((rawEvent, deviceMeta) => {
switch (dotProp.get(rawEvent, "state.buttonevent") % 1000) {
case 1: // Hold Down
return 2; // Long Press
case 2: // Short press
return 0; // Single Press
case 4: // Double press
case 5: // Triple press
case 6: // Quadtruple press
case 10: // Many press
/*
* Merge all many press event to 1 because homekit only support double press events.
*/
return 1; // Double Press
}
});
//#endregion
//#region Sensors
HKF.CurrentTemperature = new Attribute()
.services(["Heater Cooler", "Thermostat", "Temperature Sensor"])
.needEventMeta("state.temperature")
.to(
(rawEvent, deviceMeta) => dotProp.get(rawEvent, "state.temperature") / 100
);
HKF.CurrentRelativeHumidity = new Attribute()
.services(["Thermostat", "Humidity Sensor"])
.needEventMeta("state.humidity")
.to((rawEvent, deviceMeta) => dotProp.get(rawEvent, "state.humidity") / 100)
.limit(0, 100);
HKF.CurrentAmbientLightLevel = directMap(["to"], "state.lux")
.services("Light Sensor")
.limit(0.0001, 100000);
HKF.SmokeDetected = directMap(["to"], "state.fire").services("Smoke Sensor");
HKF.OutletInUse = new Attribute()
.services("Outlet")
.needEventMeta("state.power")
.to((rawEvent, deviceMeta) => dotProp.get(rawEvent, "state.power") > 0);
HKF.LeakDetected = new Attribute()
.services("Leak Sensor")
.needEventMeta("state.water")
.to((rawEvent, deviceMeta) =>
dotProp.get(rawEvent, "state.water") ? 1 : 0
);
HKF.MotionDetected = directMap(["to"], "state.presence").services(
"Motion Sensor"
);
HKF.ContactSensorState = new Attribute()
.services("Contact Sensor")
.needDeviceMeta((deviceMeta) => {
return !["Window covering controller", "Window covering device"].includes(
deviceMeta.type
);
})
.needEventMeta(
(rawEvent, deviceMeta) =>
dotProp.has(rawEvent, "state.open") ||
dotProp.has(rawEvent, "state.vibration")
)
.to((rawEvent, deviceMeta) => {
if (dotProp.has(rawEvent, "state.vibration")) {
return dotProp.get(rawEvent, "state.vibration") ? 1 : 0;
}
if (dotProp.has(rawEvent, "state.open")) {
return dotProp.get(rawEvent, "state.open") ? 1 : 0;
}
});
//#endregion
//#region ZHAThermostat
HKF.HeatingThresholdTemperature = new Attribute()
.services(["Heater Cooler", "Thermostat"])
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta("config.heatsetpoint")
.to(
(rawEvent, deviceMeta) =>
dotProp.get(rawEvent, "config.heatsetpoint") / 100
)
.from((value, allValues, result, deviceMeta) => {
dotProp.set(result, "config.heatsetpoint", value * 100);
})
.limit(0, 25);
HKF.CoolingThresholdTemperature = new Attribute()
.services(["Heater Cooler", "Thermostat"])
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta("config.coolsetpoint")
.to(
(rawEvent, deviceMeta) =>
dotProp.get(rawEvent, "config.coolsetpoint") / 100
)
.from((value, allValues, result, deviceMeta) => {
dotProp.set(result, "config.coolsetpoint", value * 100);
})
.limit(10, 35);
HKF.TargetTemperature = new Attribute()
.services("Thermostat")
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta(
(rawEvent, deviceMeta) =>
dotProp.has(rawEvent, "config.heatsetpoint") ||
dotProp.has(rawEvent, "config.coolsetpoint")
)
.to((rawEvent, deviceMeta) => {
// Device have only a heatsetpoint.
if (!dotProp.has(rawEvent, "config.coolsetpoint")) {
return dotProp.get(rawEvent, "config.heatsetpoint") / 100;
}
// Device have only a coolsetpoint.
if (!dotProp.has(rawEvent, "config.heatsetpoint")) {
return dotProp.get(rawEvent, "config.coolsetpoint") / 100;
}
// Device have heat and cool set points.
let currentTemp = HKF.CurrentTemperature.toMethod(rawEvent, deviceMeta);
// It's too cold.
if (currentTemp <= dotProp.get(rawEvent, "config.heatsetpoint")) {
return dotProp.get(rawEvent, "config.heatsetpoint") / 100;
}
// It's too hot.
if (currentTemp >= dotProp.get(rawEvent, "config.coolsetpoint")) {
return dotProp.get(rawEvent, "config.coolsetpoint") / 100;
}
// It's in the range I can't determine what the device is doing.
})
.from((value, allValues, result, deviceMeta) => {
if (!dotProp.has(deviceMeta, "config.coolsetpoint")) {
// Device have only a heatsetpoint.
dotProp.set(result, "config.heatsetpoint", value * 100);
} else if (!dotProp.has(deviceMeta, "config.heatsetpoint")) {
// Device have only a coolsetpoint.
dotProp.set(result, "config.coolsetpoint", value * 100);
} else {
// Don't know what to do with that.
}
})
.limit(10, 38);
HKF.Active = new Attribute()
.services("Heater Cooler")
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta("state.on")
.to((rawEvent, deviceMeta) => {
return dotProp.get(rawEvent, "state.on") === true ? 1 : 0;
})
.from((value, allValues, result, deviceMeta) => {
if (value === 1) dotProp.set(result, "state.on", true);
if (value === 0) dotProp.set(result, "state.on", false);
});
HKF.CurrentHeatingCoolingState = new Attribute()
.services("Thermostat")
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta("state.on")
.to((rawEvent, deviceMeta) => {
if (dotProp.get(rawEvent, "state.on") === false) return 0; // Off.
// Device have only a heatsetpoint.
if (
dotProp.has(deviceMeta, "config.heatsetpoint") &&
!dotProp.has(deviceMeta, "config.coolsetpoint")
)
return 1; // Heat. The Heater is currently on
// Device have only a coolsetpoint.
if (
dotProp.has(deviceMeta, "config.coolsetpoint") &&
!dotProp.has(deviceMeta, "config.heatsetpoint")
)
return 2; // Cool. Cooler is currently on
// Device can heat and cool
let targetTemp = HKF.TargetTemperature.toMethod(rawEvent, deviceMeta);
let currentTemp = HKF.CurrentTemperature.toMethod(rawEvent, deviceMeta);
if (targetTemp === undefined || currentTemp === undefined) return;
if (currentTemp < targetTemp) return 1; // Heat. The Heater is currently on
if (currentTemp > targetTemp) return 2; // Cool. Cooler is currently on
});
HKF.TargetHeatingCoolingState = new Attribute()
.services("Thermostat")
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta("config.mode")
.to((rawEvent, deviceMeta) => {
switch (dotProp.get(rawEvent, "config.mode")) {
case "off":
case "sleep":
case "fan only":
case "dry":
return 0; // Off
case "heat":
case "emergency heating":
return 1; // Heat
case "cool":
case "precooling":
return 2; // Cool
case "auto":
return 3; // Auto
}
});
HKF.LockPhysicalControls = new Attribute()
.services("Heater Cooler")
.needDeviceMeta({ type: "ZHAThermostat" })
.needEventMeta("config.locked")
.to((rawEvent, deviceMeta) =>
dotProp.get(rawEvent, "config.locked") === true ? 1 : 0
)
.from((value, allValues, result, deviceMeta) => {
if (value === 0) dotProp.set(result, "config.locked", false);
if (value === 1) dotProp.set(result, "config.locked", true);
});
HKF.TemperatureDisplayUnits_Celsius = new Attribute()
.services(["Heater Cooler", "Thermostat"])
.name("TemperatureDisplayUnits")
.priority(10)
.needDeviceMeta({ type: "ZHAThermostat" })
.to((rawEvent, deviceMeta) => 0); // Celsius
HKF.TemperatureDisplayUnits_Fahrenheit = new Attribute()
.services(["Heater Cooler", "Thermostat"])
.name("TemperatureDisplayUnits")
.priority(0)
.needDeviceMeta({ type: "ZHAThermostat" })
.to((rawEvent, deviceMeta) => 1); // Fahrenheit
//#endregion
//#region Lights
HKF.On = directMap(["to", "from"], "state.on")
.services(["Lightbulb", "Outlet"])
.needDeviceMeta((deviceMeta) => {
return ![
"Window covering controller",
"Window covering device",
"Door Lock",
].includes(deviceMeta.type);
});
HKF.Brightness = new Attribute()
.services("Lightbulb")
.needEventMeta("state.bri")
.to((rawEvent, deviceMeta) => {
if (dotProp.get(rawEvent, "state.on") !== true) return;
let bri = dotProp.get(rawEvent, "state.bri");
return Utils.convertRange(bri, [0, 255], [0, 100], true, true);
})
.from((value, allValues, result, deviceMeta) => {
let bri = Utils.convertRange(value, [0, 100], [0, 255], true, true);
dotProp.set(result, "state.bri", bri);
dotProp.set(result, "state.on", bri > 0);
});
HKF.Hue = new Attribute()
.services("Lightbulb")
.needEventMeta("state.hue")
.needColorCapabilities(["hs", "unknown"])
.needDeviceMeta({ "state.colormode": "hs" })
.to((rawEvent, deviceMeta) => {
if (dotProp.get(rawEvent, "state.on") !== true) return;
let hue = dotProp.get(rawEvent, "state.hue");
return Utils.convertRange(hue, [0, 65535], [0, 360], true, true);
})
.from((value, allValues, result, deviceMeta) => {
let hue = Utils.convertRange(value, [0, 360], [0, 65535], true, true);
dotProp.set(result, "state.hue", hue);
});
HKF.Saturation = new Attribute()
.services("Lightbulb")
.needEventMeta("state.sat")
.needColorCapabilities(["hs", "unknown"])
.needDeviceMeta({ "state.colormode": "hs" })
.to((rawEvent, deviceMeta) => {
if (dotProp.get(rawEvent, "state.on") !== true) return;
let sat = dotProp.get(rawEvent, "state.sat");
return Utils.convertRange(sat, [0, 255], [0, 100], true, true);
})
.from((value, allValues, result) => {
let sat = Utils.convertRange(value, [0, 100], [0, 255], true, true);
dotProp.set(result, "state.sat", sat);
});
HKF.ColorTemperature = directMap(["from", "to"], "state.ct")
.services("Lightbulb")
.needColorCapabilities(["ct", "unknown"])
.needDeviceMeta({ "state.colormode": "ct" });
//#endregion
//#region Window cover
HKF.CurrentPosition = new Attribute()
.services("Window Covering")
.needEventMeta("state.lift")
.to((rawEvent, deviceMeta) =>
Utils.convertRange(
dotProp.get(rawEvent, "state.lift"),
[0, 100],
[100, 0],
true,
true
)
)
.from((value, allValues, result) =>
dotProp.set(
result,
"state.lift",
Utils.convertRange(value, [100, 0], [0, 100], true, true)
)
);
HKF.TargetPosition = HKF.CurrentPosition;
HKF.CurrentHorizontalTiltAngle = new Attribute()
.services("Window Covering")
.needDeviceMeta({
type: ["Window covering controller", "Window covering device"],
})
.needEventMeta("state.tilt")
.to((rawEvent, deviceMeta) =>
Utils.convertRange(
dotProp.get(rawEvent, "state.tilt"),
[0, 100],
[-90, 90],
true,
true
)
)
.from((value, allValues, result) =>
dotProp.set(
result,
"state.tilt",
Utils.convertRange(value, [-90, 90], [0, 100], true, true)
)
);
HKF.TargetHorizontalTiltAngle = HKF.CurrentHorizontalTiltAngle;
HKF.CurrentVerticalTiltAngle = HKF.CurrentHorizontalTiltAngle;
HKF.TargetVerticalTiltAngle = HKF.CurrentHorizontalTiltAngle;
HKF.PositionState = new Attribute()
.services("Window Covering")
.needDeviceMeta({
type: ["Window covering controller", "Window covering device"],
})
.to((rawEvent, deviceMeta) => 2); // Stopped
//#endregion
//#region Battery
HKF.BatteryLevel = new Attribute()
.services("Battery")
.to((rawEvent, deviceMeta) => {
let battery = dotProp.get(rawEvent, "config.battery");
if (battery === undefined) {
battery = dotProp.get(rawEvent, "state.battery");
}
return battery;
})
.limit(0, 100);
HKF.StatusLowBattery = new Attribute()
.services("Battery")
.needEventMeta(
(rawEvent, deviceMeta) =>
dotProp.has(rawEvent, "config.battery") ||
dotProp.has(rawEvent, "state.battery")
)
.to((rawEvent, deviceMeta) => {
let battery = dotProp.get(rawEvent, "config.battery");
if (battery === undefined) {
battery = dotProp.get(rawEvent, "state.battery");
}
return battery <= 15 ? 1 : 0;
});
//#endregion
//#region Lock Mechanism
HKF.LockTargetState = new Attribute()
.services("Lock Mechanism")
.needDeviceMeta({ type: "Door Lock" })
.to((rawEvent, deviceMeta) => {
const map = {
false: 0,
true: 1,
};
return map[dotProp.get(deviceMeta, "state.on")];
})
.from((value, allValues, result) => {
const map = {
0: false,
1: true,
};
dotProp.set(result, "state.on", map[value]);
});
HKF.LockCurrentState = new Attribute()
.services("Lock Mechanism")
.needDeviceMeta({ type: "Door Lock" })
.needEventMeta("state.on")
.to((rawEvent, deviceMeta) => {
const map = {
false: 0,
true: 1,
};
const result = map[dotProp.get(rawEvent, "state.on")];
return result !== undefined ? result : map.undefined;
});
//#endregion
return HKF;
})();
class BaseFormatter {
constructor(options = {}) {
this.format = HomeKitFormat;
this.propertyList = Object.keys(HomeKitFormat);
this.options = Object.assign(
{
attributeWhitelist: [],
attributeBlacklist: [],
},
options
);
this.options.attributeWhitelist = Utils.sanitizeArray(
this.options.attributeWhitelist
);
if (this.options.attributeWhitelist.length > 0) {
this.propertyList = this.propertyList.filter((property) =>
this.options.attributeWhitelist.includes(property)
);
}
this.options.attributeBlacklist = Utils.sanitizeArray(
this.options.attributeBlacklist
);
if (this.options.attributeBlacklist.length > 0) {
this.propertyList = this.propertyList.filter(
(property) => !this.options.attributeBlacklist.includes(property)
);
}
// Sort properties by name
const get = (property, key) =>
HomeKitFormat[property][key] !== undefined
? HomeKitFormat[property][key]
: property;
const getName = (property) => get(property, "_name");
const getPriority = (property) => get(property, "_priority");
this.propertyList.sort((propertyA, propertyB) => {
const aName = getName(propertyA);
const bName = getName(propertyB);
if (aName === bName) {
const aPriority = getPriority(propertyA);
const bPriority = getPriority(propertyB);
return aPriority === undefined || bPriority === undefined
? 0
: aPriority - bPriority;
} else {
return aName < bName ? -1 : 1;
}
});
}
}
class fromDeconz extends BaseFormatter {
parse(rawEvent, deviceMeta) {
let result = {};
let propertyMap = {};
for (const property of this.propertyList) {
const propertyName =
HomeKitFormat[property]._name !== undefined
? HomeKitFormat[property]._name
: property;
if (!HomeKitFormat[property].targetIsValid(rawEvent, deviceMeta))
continue;
if (HomeKitFormat[property].toMethod === undefined) continue;
propertyMap[propertyName] = property;
const resultValue = HomeKitFormat[property].toMethod(
rawEvent,
deviceMeta
);
if (resultValue !== undefined) result[propertyName] = resultValue;
}
// Cleanup invalid attributes
for (const property of Object.keys(result)) {
if (!HomeKitFormat[propertyMap[property]].attributeIsValid(result)) {
delete result[property];
}
}
return result;
}
getValidPropertiesList(deviceMeta) {
let result = [];
for (const property of this.propertyList) {
if (!HomeKitFormat[property].targetIsValid(deviceMeta, deviceMeta))
continue;
if (HomeKitFormat[property].toMethod === undefined) continue;
result.push(property);
}
// Cleanup invalid attributes
result = result.filter((value) =>
HomeKitFormat[value].attributeIsValid(result)
);
return result;
}
}
class toDeconz extends BaseFormatter {
parse(values, allValues, result, deviceMeta) {
if (result === undefined) result = {};
for (const [property, value] of Object.entries(values)) {
if (!this.propertyList.includes(property)) continue;
if (HomeKitFormat[property].fromMethod === undefined) continue;
HomeKitFormat[property].fromMethod(value, allValues, result, deviceMeta);
}
for (const property of Object.keys(result)) {
if (result[property] === undefined) {
delete result[property];
}
}
return result;
}
}
module.exports = { fromDeconz, toDeconz };