iobroker.cloudless-homeconnect
Version:
Adapter für Homeconnect-Geräte ohne Cloud-Kommunikation
725 lines (649 loc) • 20.9 kB
JavaScript
"use strict";
// The adapter-core module gives you access to the core ioBroker functions
// you need to create an adapter
const utils = require("@iobroker/adapter-core");
const Socket = require("./lib/Socket.js");
const Device = require("./lib/Device.js");
const ConfigService = require("./lib/ConfigService.js");
const util = require("./lib/util.js");
const events = require("events");
/**
* Implementation of Homeconnect-Adapter with only local network communication.
* Ported from https://github.com/osresearch/hcpy
*/
class CloudlessHomeconnect extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
constructor(options) {
super({
...options,
name: "cloudless-homeconnect",
});
this.on("ready", this.onReady.bind(this));
this.on("stateChange", this.onStateChange.bind(this));
this.on("unload", this.onUnload.bind(this));
this.eventEmitter = new events.EventEmitter();
this.startingErrors = 0;
this.configJson = undefined;
this.devMap = new Map();
this.configService = new ConfigService(this.eventEmitter, utils.getAbsoluteInstanceDataDir(this));
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
this.setState("info.connection", { val: false, ack: true });
this.subscribeStates("*");
this.registerEvents();
const configJsonObj = await this.getStateAsync("info.config");
if (configJsonObj && !util.isConfigJson(configJsonObj.val)) {
// @ts-ignore
if (!this.config.username || !this.config.password) {
this.log.warn("Please enter homeconnect app username and password in the instance settings");
return;
}
this.configService.logLevel = this.log.level;
this.configService.config = this.config;
this.configService.iob = this;
const loadedConfig = await this.configService.loadConfig();
if (loadedConfig) {
if (loadedConfig["waitForProfileZip"]) {
return;
}
this.setState("info.config", JSON.stringify(loadedConfig), true);
}
this.configJson = loadedConfig;
}
if (!this.configJson) {
if (configJsonObj && util.isConfigJson(configJsonObj.val)) {
// @ts-ignore
this.configJson = JSON.parse(configJsonObj.val);
} else {
this.log.error(
"JSON in info.config nicht valide. Bitte Adapterkonfiguration checken und Inhalt löschen.",
);
return;
}
}
await this.startWithConfig();
}
async startWithConfig() {
await this.createDatapoints();
if (this.startingErrors === 0) {
//Socketverbindung für alle Geräte in der Config, die überwacht werden sollen, herstellen
Object.values(this.configJson).forEach(async (device) => {
const observe = await this.getStateAsync(device.id + ".observe");
if ((observe && observe.val) || !observe) {
this.connectDevice(device.id);
}
});
this.log.info("Adapter started successfully");
}
}
registerEvents() {
this.eventEmitter.on("log", (type, msg, e) => {
if (type === "debug") {
this.log.debug(msg);
} else if (type === "error") {
if (e) {
msg += ": " + e;
if (e instanceof Error && e.stack) {
this.log.debug(e.stack);
}
}
this.log.error(msg);
} else if (type === "warn") {
this.log.warn(msg);
} else {
this.log.info(msg);
}
});
this.eventEmitter.on("message", (devId, data) => {
this.handleMessage(devId, data);
});
this.eventEmitter.on("socketClose", (devId, event) => {
if (this.devMap.has(devId)) {
this.clearInterval(this.devMap.get(devId).refreshInterval);
}
this.setDPConnected(devId, false);
this.log.debug("Closed connection to " + devId + "; reason: " + event);
});
this.eventEmitter.on("socketError", async (devId, e) => {
this.log.debug("Connection interrupted for device " + devId + ": " + e);
if (this.devMap.has(devId)) {
this.clearInterval(this.devMap.get(devId).refreshInterval);
}
const observe = await this.getStateAsync(devId + ".observe");
if (observe && observe.val) {
this.setStateChanged("info.connection", { val: false, ack: true });
this.setDPConnected(devId, false);
}
});
this.eventEmitter.on("socketOpen", (devId) => {
this.log.debug("Connection to device " + devId + " established.");
this.setStateChanged("info.connection", { val: true, ack: true });
this.setDPConnected(devId, true);
});
this.eventEmitter.on("recreateSocket", async (devId) => {
this.log.debug("Recreate Socket for device " + devId + " requested.");
this.setDPConnected(devId, false);
if (this.devMap.has(devId)) {
const device = this.devMap.get(devId);
this.clearInterval(device.refreshInterval);
device.ws.close();
await util.sleep(2000); //Give sockets a little time to close connections
this.connectDevice(devId);
}
});
}
async setDPConnected(devId, isConnected) {
if (await this.objectExists(devId + ".General.connected")) {
this.setStateChanged(devId + ".General.connected", isConnected, true);
}
}
async createDatapoints() {
this.configJson.forEach(async (dev) => {
const id = dev.id.replace(this.FORBIDDEN_CHARS, "_");
if (!dev.features) {
this.log.error("Konfiguration unvollständig");
this.startingErrors++;
return;
}
//Root-Knoten
await this.setObjectNotExistsAsync(id, {
type: "device",
common: {
name: dev.name,
},
native: {},
});
//DP zum Deaktivieren eines Geräts
await this.setObjectNotExistsAsync(id + ".observe", {
type: "state",
common: {
type: "boolean",
role: "switch",
name: "Control the device via adapter",
write: true,
read: true,
def: true,
},
native: {},
});
//DP zum Einstellen ob Programmoptionen beim Start einzeln zum Gerät gesendet werden sollen
await this.setObjectNotExistsAsync(id + ".sendOptionsSeparately", {
type: "state",
common: {
type: "boolean",
role: "switch",
name: "Send program options seperately to device at starting a program ",
write: true,
read: true,
def: false,
},
native: {},
});
//Generelles
await this.setObjectNotExistsAsync(id + ".General", {
type: "channel",
common: {
name: "General information about the device",
},
native: {},
});
await this.setObjectNotExistsAsync(id + ".General.connected", {
type: "state",
common: {
name: "Indicates whether a socket connection exists",
type: "boolean",
role: "indicator",
def: false,
write: false,
read: true,
},
native: {},
});
["name", "id", "mac", "serialnumber"].forEach(async (key) => {
await this.setObjectNotExistsAsync(id + ".General." + key, {
type: "state",
common: {
name: key,
type: "string",
role: "text",
write: false,
read: true,
},
native: {},
});
this.setStateChanged(id + ".General." + key, dev[key], true);
});
["brand", "model"].forEach(async (key) => {
await this.setObjectNotExistsAsync(id + ".General." + key, {
type: "state",
common: {
name: key,
type: "string",
role: "text",
write: false,
read: true,
},
native: {},
});
this.setStateChanged(id + ".General." + key, dev.description[key], true);
});
//Features
Object.entries(dev.features).forEach(async ([uid, feature]) => {
const subFolder = this.getSubfolderByName(feature.name, true);
const subFolderName = this.getSubfolderByName(feature.name);
if (
//Nur Optionen beachten, die nur lesbar sind
(subFolderName.toLowerCase() === "option" && feature.access === "read") ||
(["program", "command", "setting", "status", "event"].includes(subFolderName.toLowerCase()) &&
//Kein Programm "SubsequentMode", weil diese für das Fortsetzen eines bereits beendeten Programms vorgesehen sind
!feature.name.includes("SubsequentMode")) ||
feature.name.endsWith("Program")
) {
await this.setObjectNotExistsAsync(id + subFolder, {
type: "channel",
common: {
name: subFolderName,
},
native: {},
});
if (!(await this.objectExists(this.getDpByUid(dev, uid)))) {
const common = this.getCommonObj(feature, uid);
if (subFolderName.toLowerCase() === "program") {
common.read = false;
common.write = true;
common.role = "button";
common.type = "boolean";
common.def = false;
await this.setObjectNotExistsAsync(this.getDpByUid(dev, uid), {
type: "channel",
common: {
name: "",
},
native: {},
});
await this.setObjectNotExistsAsync(this.getDpByUid(dev, uid) + ".Start", {
type: "state",
common: common,
native: {},
});
//Bei Programmen müssen die zu konfigurierenden Optionen angelegt werden
if (feature.options) {
feature.options.forEach(async (optionUid) => {
const option = dev.features[optionUid];
const common = this.getCommonObj(option, optionUid);
await this.setObjectNotExistsAsync(
this.getDpByUid(dev, uid) +
"." +
option.name
.split(".")
.slice(3)
.join("_")
.replace(this.FORBIDDEN_CHARS, "_"),
{
type: "state",
common: common,
native: {},
},
);
});
}
}
//Datenpunkte initial anlegen
await this.setObjectNotExistsAsync(this.getDpByUid(dev, uid), {
type: "state",
common: common,
native: {},
});
}
}
});
});
}
getCommonObj(feature, uid) {
const typeStr = feature.type ? feature.type : "string";
const common = {
name: uid.toString(),
type: typeStr,
role: "text",
write: (feature.access && feature.access.toLowerCase().includes("write")) || false,
read: (feature.access && feature.access.toLowerCase().includes("read")) || true,
};
if (typeStr === "number") {
common.role = "level";
if (feature.unit) {
common.unit = feature.unit;
}
common.def = 0;
if (feature.default) {
common.def = parseInt(feature.default);
} else if (feature.initValue) {
common.def = parseInt(feature.initValue);
}
if (feature.min) {
common.min = parseInt(feature.min);
common.def = common.def < common.min ? common.min : common.def;
}
if (feature.max) {
common.max = parseInt(feature.max);
}
if (feature.states) {
common.def = Math.min(...Object.keys(feature.states).map((obj) => parseInt(obj)));
common.states = feature.states;
}
} else if (typeStr === "boolean") {
common.role = "switch";
common.def = false;
if (feature.default) {
common.def = feature.default === "true";
} else if (feature.initValue) {
common.def = feature.initValue === "true";
}
} else {
common.def = "";
}
return common;
}
/**
* Delegates messages from Websocket to right device
*/
handleMessage(devId, msg) {
try {
this.log.debug(devId + ": " + msg);
if (this.devMap.has(devId)) {
const device = this.devMap.get(devId);
const values = device.handleMessage(JSON.parse(msg));
if (values.error) {
if (values.error === 400) {
this.log.debug(
"Implausible value was previously sent. Device response: " +
values.error +
" at service " +
values.resource,
);
} else if (values.error > 5000) {
this.log.debug(
"Implausible value was received (" +
values.error +
"): " +
values.info +
" ; Value: " +
values.resource,
);
} else {
const msg = "Communication error " + values.error + " at service " + values.resource;
this.config.communicationError ? this.log.debug(msg) : this.log.error(msg);
}
return;
}
if (Object.keys(values).length > 0) {
this.updateDatapoints(device, values);
}
}
} catch (e) {
this.log.debug("Error handling a message from " + devId + ": " + msg);
this.log.debug("Error message: " + e);
}
}
updateDatapoints(device, values) {
Object.keys(values)
.filter((uid) => device.features[uid])
.forEach(async (uid) => {
let value = typeof values[uid] === "object" ? JSON.stringify(values[uid]) : values[uid];
const oid = this.getDpByUid(device, uid);
//Optionen werden nicht aktualisiert
if (this.getSubfolderByDp(oid).toLowerCase() === "option" && device.features[uid].access !== "read") {
return;
}
//Objekt holen, um richtigen Typ zu ermitteln
const obj = await this.getObjectAsync(oid);
if (obj) {
const typ = obj.common.type;
if (typ === "string" && typeof value !== "string") {
value = value.toString();
} else if (typ === "number" && typeof value !== "number") {
value = parseInt(value);
} else if (typ === "boolean" && typeof value === "string") {
value = value === "true";
} else if (typ === "boolean" && typeof value === "number") {
value = value === 1;
}
}
this.setStateChanged(oid, value, true);
});
}
/**
* @param {string} deviceID
*/
connectDevice(deviceID) {
Object.values(this.configJson)
.filter((val) => val.id === deviceID)
.forEach((device) => {
//Socketverbindung zu den Geräten herstellen
const socket = new Socket(
device.id,
device.host,
device.key,
device.iv,
this.eventEmitter,
this.config,
);
const dev = new Device(socket, device);
socket.connect();
//Ruft reglmäßig die aktuellen Werte des Geräts ab. Damit kann das Gerät auch über andere Wege gesteuert werden und der Adapter bleibt aktuell
dev.refreshInterval = this.setInterval(() => {
if (dev.ws.isConnected()) {
dev.send("/ro/allMandatoryValues");
}
}, 60 * 1000);
//Die erzeugten Devices cachen
this.devMap.set(device.id, dev);
});
}
getSubfolderByName(name, withLeadingPoint = false) {
const splittedKey = name.split(".");
if (splittedKey[2].toLowerCase() === "root") {
return "";
}
if (withLeadingPoint) {
return "." + splittedKey[2].replace(this.FORBIDDEN_CHARS, "_");
}
return splittedKey[2].replace(this.FORBIDDEN_CHARS, "_");
}
getSubfolderByDp(oid) {
return oid.split(".")[1];
}
getDpByUid(device, uid) {
const name = device.features[uid].name;
const key = name.split(".").slice(3).join("_").replace(this.FORBIDDEN_CHARS, "_");
const subFolder = this.getSubfolderByName(name, true);
return device.id + subFolder + "." + key;
}
async getUidByDp(oid) {
if (!oid.includes(this.namespace)) {
oid = this.namespace + "." + oid;
}
const obj = await this.getObjectAsync(oid);
if (obj) {
// @ts-ignore
return parseInt(obj.common.name);
}
}
closeConnections() {
this.devMap.forEach((device) => {
device.ws.close();
this.clearInterval(device.refreshInterval);
});
}
/**
*
* @param {ioBroker.Object|null|undefined} powerStateObj
* @returns
*/
async getOffOrStandbyValue(powerStateObj) {
const keys = Object.keys(powerStateObj?.common.states);
const min = powerStateObj?.common.min;
const max =
powerStateObj?.common.max !== undefined ? powerStateObj.common.max : parseInt(keys[keys.length - 1]);
let ret;
Object.entries(powerStateObj?.common.states).forEach(([key, value]) => {
const keyInt = parseInt(key);
if (keyInt >= min && keyInt <= max && (value === "Off" || value === "Standby")) {
ret = keyInt;
return;
}
});
return ret;
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
* @param {() => void} callback
*/
onUnload(callback) {
try {
this.closeConnections();
callback();
} catch (e) {
this.log.debug("Exception while unload: " + e);
callback();
}
}
/**
* Is called if a subscribed state changes
* @param {string} oid
* @param {ioBroker.State | null | undefined} state
*/
async onStateChange(oid, state) {
oid = oid.replaceAll(this.namespace + ".", "");
const systemAdapterId = "system.adapter." + this.namespace;
if (state && !state.ack && state.from !== systemAdapterId) {
// The state was changed not by adapter itself
this.log.debug("state " + oid + " changed: " + state.val + " (ack = " + state.ack + ")");
//Wird DP info.config geleert, soll diese durch Neustart des Adapters neu geladen werden
if (oid === "info.config") {
if (typeof state.val === "string" && state.val.trim().length === 0) {
this.log.info("Adapter restarts to update configuration.");
this.closeConnections();
await util.sleep(2000); //Give sockets a little time to close connections
const adapterObj = await this.getForeignObjectAsync(systemAdapterId);
if (adapterObj) {
//Stoppen
adapterObj.common.enabled = false;
await this.setForeignObjectAsync(systemAdapterId, adapterObj);
//Starten
adapterObj.common.enabled = true;
await this.setForeignObjectAsync(systemAdapterId, adapterObj);
}
}
return;
}
//Keine Optionen direkt an Device senden. Diese werden bei Programmen ausgelesen und mitgesendet.
if (
(this.getSubfolderByDp(oid).toLowerCase() === "program" ||
this.getSubfolderByDp(oid).toLowerCase() === "option") &&
!oid.endsWith("Start")
) {
return;
}
const devId = oid.split(".")[0];
//Wenn Gerät überwacht werden soll, dieses verbinden
if (oid.includes("observe") && state.val) {
this.connectDevice(devId);
this.log.info("Device with ID " + devId + " can be controlled via the adapter.");
return;
}
if (!this.devMap.has(devId)) {
this.log.error("Device " + devId + " not found. Please restart adapter and try again.");
return;
}
const uid = await this.getUidByDp(oid);
const device = this.devMap.get(devId);
//Wenn Gerät nicht überwacht werden soll, Verbindung schließen und aus der Devicemap entfernen
if (oid.includes("observe") && !state.val) {
if (device.refreshInterval) {
this.clearInterval(device.refreshInterval);
}
if (device.ws.isConnected()) {
device.ws.close();
}
if (this.devMap.has(devId)) {
this.devMap.delete(devId);
}
this.setStateChanged("info.connection", { val: true, ack: true });
this.log.info("Device with ID " + devId + " is no longer controlled via the adapter.");
return;
}
if (uid) {
let resource = "/ro/values";
const data = {};
if (this.getSubfolderByDp(oid).toLowerCase() === "program" && oid.endsWith("Start")) {
//Wenn ein Programm bereits aktiv ist, dieses zunächst beenden
const isAktiv = await this.getStateAsync(devId + ".ActiveProgram");
if (isAktiv && isAktiv.val !== "0") {
const powerObj = await this.getObjectAsync(devId + ".Setting.PowerState");
device.send(resource, 1, "POST", {
// @ts-ignore
uid: parseInt(powerObj.common.name),
value: this.getOffOrStandbyValue(powerObj),
});
await util.sleep(2000);
}
//Programme haben u.U. Optionen, die auch übertragen werden müssen
data.program = uid;
if (device.isSendSelectedProgram) {
device.send("/ro/selectedProgram", 1, "POST", data);
}
const options = await this.getStatesAsync(oid.substring(0, oid.lastIndexOf(".")) + ".*");
data.options = await Promise.all(
Object.entries(options)
.filter(([oid]) => !oid.endsWith("Start"))
.map(async ([oid, state]) => {
const obj = await this.getObjectAsync(oid);
return {
// @ts-ignore
uid: parseInt(obj.common.name),
value: state.val,
};
}),
);
//Bei manchen Geräten müssen die Optionen der Programme einzeln und nicht in Verbindung mit activeProgram gesetzt werden.
const isSendOptionsSeperately = await this.getStateAsync(devId + ".sendOptionsSeparately");
if (isSendOptionsSeperately && isSendOptionsSeperately.val) {
data.options.forEach((option) => {
device.send("/ro/values", 1, "POST", option);
});
delete data.options;
}
await util.sleep(1000);
resource = "/ro/activeProgram";
} else {
data.uid = uid;
let val = state.val;
if (typeof val === "string") {
try {
val = JSON.parse(val);
} catch (e) {
this.log.debug("Parsing error: " + e);
}
}
data.value = val;
}
device.send(resource, 1, "POST", data);
}
}
}
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
module.exports = (options) => new CloudlessHomeconnect(options);
} else {
// otherwise start the instance directly
new CloudlessHomeconnect();
}