iobroker.cloudless-homeconnect
Version:
Adapter für Homeconnect-Geräte ohne Cloud-Kommunikation
507 lines (444 loc) • 14.7 kB
JavaScript
const util = require("./util.js");
const xml2jsonConverter = require("./Xml2JsonConverter.js");
const fs = require("node:fs/promises");
const path = require("node:path");
const tough = require("tough-cookie");
const axios = require("axios");
const { HttpsCookieAgent } = require("http-cookie-agent/http");
const AdmZip = require("adm-zip");
/**
* Homeconnect-Device with its socket connection @see Socket.js
*/
class ConfigService {
#TYPES_URL;
#BASE_URL;
#ASSET_URL;
#SINGLEKEY_URL;
#LOGIN_URL;
#TOKEN_URL;
#APP_ID;
#CLIENT_ID;
#REGEX_SESSION;
#REGEX_TOKEN;
#REDIRECT_DOMAIN;
#REDIRECT_URL;
#VERIFIER;
#eventEmitter;
#instanceDir;
#cookieJar;
constructor(eventEmitter, instanceDir) {
this.#TYPES_URL = "https://www.home-connect.com/schemas/DeviceDescription/20140417/HC_INT_BSH_CTD.xml";
this.#BASE_URL = "https://api.home-connect.com/security/oauth/";
this.#ASSET_URL = "https://prod.reu.rest.homeconnectegw.com/";
this.#SINGLEKEY_URL = "https://singlekey-id.com";
this.#LOGIN_URL = this.#BASE_URL + "authorize";
this.#TOKEN_URL = this.#BASE_URL + "token";
this.#APP_ID = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3";
this.#CLIENT_ID = "11F75C04-21C2-4DA9-A623-228B54E9A256";
this.#REGEX_SESSION = /"sessionId" value="(.*?)"/;
this.#REGEX_TOKEN = /__RequestVerificationToken.*value="(.*?)"/;
this.#REDIRECT_DOMAIN = "hcauth://";
this.#REDIRECT_URL = this.#REDIRECT_DOMAIN + "auth/prod";
this.#VERIFIER = util.b64random(32);
this.#eventEmitter = eventEmitter;
this.#instanceDir = instanceDir;
this.logLevel = "info";
this.config = {};
this.iob = undefined;
this.isLoginSuccessful = false;
this.#cookieJar = new tough.CookieJar();
this.requestClient = axios.create({
withCredentials: true,
httpsAgent: new HttpsCookieAgent({
cookies: {
jar: this.#cookieJar,
},
}),
headers: {
"user-agent": "iobroker/1.0",
Accept: "*/*",
"Accept-Encoding": "gzip, deflate",
Connection: "keep-alive",
"content-type": "application/x-www-form-urlencoded",
},
timeout: 300000, //Global timeout 5 min
});
}
async loadConfig() {
let configJson = undefined;
let files = [];
try {
files = await fs.readdir(this.#instanceDir);
files = files.filter((name) => name.startsWith("homeconnectdirect") && name.endsWith(".zip"));
} catch (err) {
if (err.code === "ENOENT") {
await fs.mkdir(this.#instanceDir);
this.#eventEmitter.emit("log", "debug", err.message + " - Creating it.");
} else {
this.#eventEmitter.emit("log", "error", "Error while reading instance directory", err);
}
}
//Wenn im Instanzverzeichnis eine Datei vom Profil Downloader liegt, diese nehmen und den normalen Loginprozess ignorieren
if (files.length === 0) {
const token = await this.#getToken();
if (token && this.requestClient) {
configJson = [];
this.requestClient.defaults.headers.common["Authorization"] = "Bearer " + token;
const account = await this.#getAccountInfo();
if (account) {
this.requestClient.defaults.responseType = "arraybuffer";
//Für jedes Gerät wird ein Eintrag im ConfigJsonArray hinzugefügt
this.#eventEmitter.emit(
"log",
"info",
"Found " + account.data.homeAppliances.length + " device(s).",
);
for (const app of account.data.homeAppliances) {
const devID = app.identifier;
const config = this.#getPreConfig(app);
configJson.push(config);
//Fetch the XML zip file for this device
const res = await this.#request(this.#ASSET_URL + "api/iddf/v1/iddf/" + devID);
// open zip and read entries ....
const zips = this.#unzip(res.data);
if (Object.keys(zips).length === 2) {
const machine = await this.#parseDevice(zips.feature, zips.description);
config.description = machine.description;
config.features = machine.features;
}
}
}
}
if (!this.isLoginSuccessful) {
return {
waitForProfileZip: true,
};
}
} else {
configJson = [];
this.#eventEmitter.emit("log", "info", "Found " + files.length + " device(s).");
for (const file of files) {
const zip = await fs.readFile(path.join(this.#instanceDir, file));
const zips = this.#unzip(zip);
if (Object.keys(zips).length === 3) {
//Umschreiben auf originales JSON
const app = JSON.parse(zips.app);
app.identifier = app.haId;
app.serialnumber = app.serialNumber;
if (app.connectionType === "TLS") {
app.tls = {};
app.tls.key = app.key;
} else {
app.aes = {};
app.aes.key = app.key;
app.aes.iv = app.iv;
}
const config = this.#getPreConfig(app);
configJson.push(config);
const machine = await this.#parseDevice(zips.feature, zips.description);
config.description = machine.description;
config.features = machine.features;
}
}
}
return configJson;
}
/**
* @param {any} app
* @returns
*/
#getPreConfig(app) {
const config = {
name: app.type.toLowerCase(),
id: app.identifier,
mac: app.mac,
serialnumber: app.serialnumber,
};
if (app.tls) {
// fancy machine with TLS support
config.host = app.brand + "-" + app.type + "-" + app.identifier;
config.key = app.tls.key;
} else {
// less fancy machine with HTTP support
config.host = app.identifier;
config.key = app.aes.key;
config.iv = app.aes.iv;
}
return config;
}
/**
* @param {Buffer} dataBuffer
*/
#unzip(dataBuffer) {
const zips = {};
try {
const zip = new AdmZip(dataBuffer);
zip.getEntries().forEach((zipEntry) => {
if (zipEntry.entryName.indexOf("..") == -1) {
this.#eventEmitter.emit("log", "info", "Found file: " + zipEntry.entryName);
let newFilePath = "";
//Gefundene Dateien für Debugzwecke ablegen in z.B. /opt/iobroker/iobroker-data/cloudless-homeconnect.0
if (this.isLoginSuccessful) {
newFilePath = path.join(this.#instanceDir, zipEntry.entryName);
this.#eventEmitter.emit("log", "debug", "Creating file: " + newFilePath);
}
if (zipEntry.entryName.includes("_FeatureMapping.xml")) {
zips.feature = zip.readAsText(zipEntry);
if (this.isLoginSuccessful && this.logLevel === "debug") {
fs.writeFile(newFilePath, zips.feature);
}
}
if (zipEntry.entryName.includes("_DeviceDescription.xml")) {
zips.description = zip.readAsText(zipEntry);
if (this.isLoginSuccessful && this.logLevel === "debug") {
fs.writeFile(newFilePath, zips.description);
}
}
if (zipEntry.entryName.endsWith(".json")) {
zips.app = zip.readAsText(zipEntry);
}
}
});
} catch (e) {
this.#eventEmitter.emit("log", "error", "Unparsable zips received. Please try again later", e);
}
return zips;
}
/**
* @param {any} feature
* @param {any} description
*/
async #parseDevice(feature, description) {
const types = await this.#request(this.#TYPES_URL);
return await xml2jsonConverter.xml2json(feature, description, types.data, this.#eventEmitter);
}
async #getToken() {
this.#eventEmitter.emit("log", "debug", "Start normal login");
const loginpageUrl =
this.#LOGIN_URL +
"?" +
util.urlEncode({
response_type: "code",
prompt: "login",
code_challenge: util.b64UrlEncode(util.sha256(this.#VERIFIER)),
code_challenge_method: "S256",
client_id: this.#APP_ID,
scope: "ReadOrigApi",
nonce: util.b64random(16),
state: util.b64random(16),
redirect_uri: this.#REDIRECT_URL,
redirect_target: "icore",
});
const deviceAuth = await this.#request(loginpageUrl)
.then((res) => {
this.#eventEmitter.emit("log", "debug", res.data.toString());
return res.data.toString();
})
.catch((error) => {
this.#eventEmitter.emit("log", "error", error);
if (error.response) {
this.#eventEmitter.emit("log", "error", JSON.stringify(error.response.data));
}
});
//Extract SessionID
const arr = deviceAuth.match(this.#REGEX_SESSION);
if (arr == null) {
this.#eventEmitter.emit("log", "warn", "Unable to find session id in login page");
return;
}
const sessionId = arr[1];
this.#eventEmitter.emit("log", "debug", "--------");
// now that we have a session id, contact the single key host to start the new login flow
const preauthQuery = {
client_id: this.#CLIENT_ID,
redirect_uri: this.#BASE_URL + "redirect_target",
response_type: "code",
scope: "openid email profile offline_access homeconnect.general",
prompt: "login",
style_id: "bsh_hc_01",
state: '{"session_id":"' + sessionId + '"}', // important: no spaces!
};
// fetch the preauth state to get the final callback url
let preauthUrl = this.#SINGLEKEY_URL + "/auth/connect/authorize?" + util.urlEncode(preauthQuery);
let preauthData = "";
// loop until we have the callback url
do {
this.#eventEmitter.emit("log", "debug", "next preauth_url=" + preauthUrl);
const response = await this.#request(preauthUrl, false);
if (response.status === 200) {
preauthData = response.data;
}
if (response.status > 300 && response.status < 400) {
preauthUrl = this.#addSinglekeyHost(response.headers["location"]);
}
if (response.status === 403) {
this.#eventEmitter.emit("log", "error", "Server has a temporary problem. Try again later.");
return;
}
} while (!preauthData);
if (!preauthData) {
return;
}
let returnUrl = this.#addSinglekeyHost(util.getUrlParams(preauthUrl).get("ReturnUrl"));
this.#eventEmitter.emit("log", "debug", "return_url: " + returnUrl);
this.#eventEmitter.emit("log", "debug", "--------");
//Do login with determined data and url
this.isLoginSuccessful = await this.#doLogin(preauthData.toString(), preauthUrl);
if (this.isLoginSuccessful) {
this.#eventEmitter.emit("log", "info", "Login sucessfull. Trying to catch token...");
do {
const response = await this.#request(returnUrl, false);
this.#eventEmitter.emit("log", "debug", "next return_url=" + returnUrl);
if (response.status !== 302 && response.status !== 307) {
break;
}
returnUrl = this.#addSinglekeyHost(response.headers["location"]);
} while (!returnUrl.startsWith(this.#REDIRECT_DOMAIN));
this.#eventEmitter.emit("log", "debug", "return_url=" + returnUrl);
this.#eventEmitter.emit("log", "debug", "--------");
const params = util.getUrlParams(returnUrl);
const code = params.get("code");
const state = params.get("state");
const grantType = params.get("grant_type");
this.#eventEmitter.emit("log", "debug", "code=" + code + " grant_type=" + grantType + " state=" + state);
const tokenQuery = {
grant_type: grantType,
client_id: this.#APP_ID,
code_verifier: this.#VERIFIER,
code: code,
redirect_uri: this.#REDIRECT_URL,
};
this.#eventEmitter.emit(
"log",
"debug",
"tokenUrl: " + this.#TOKEN_URL + " tokenFields: " + JSON.stringify(tokenQuery),
);
const tokenPage = await this.#request(this.#TOKEN_URL, false, tokenQuery, "post")
.then((res) => {
this.#eventEmitter.emit("log", "debug", JSON.stringify(res.data));
return res.data;
})
.catch((error) => {
this.#eventEmitter.emit("log", "error", "Bad code? " + error);
if (error.response) {
this.#eventEmitter.emit("log", "error", JSON.stringify(error.response.data));
}
});
if (tokenPage.error) {
this.#eventEmitter.emit("log", "error", JSON.stringify(tokenPage));
return;
}
this.#eventEmitter.emit("log", "debug", "--------- got token page ----------");
this.#eventEmitter.emit("log", "debug", "Received access token: " + tokenPage.access_token);
return tokenPage.access_token;
}
this.#eventEmitter.emit(
"log",
"warn",
"Login not successful. Please put the zip from homeconnect-profile-downloader as described in docs manually into path " +
this.#instanceDir +
" and restart adapter. See https://github.com/bruestel/homeconnect-profile-downloader also.",
);
}
async #doLogin(preauthData, preauthUrl) {
let arr = preauthData.match(this.#REGEX_TOKEN);
if (arr) {
const response = await this.#request(
preauthUrl,
false,
{
// @ts-ignore
"UserIdentifierInput.EmailInput.StringValue": this.config.username,
__RequestVerificationToken: arr[1],
},
"post",
);
const passwortUrl = this.#addSinglekeyHost(response.headers["location"]);
if (!passwortUrl.includes("password")) {
return false;
}
const responseData = await this.#request(passwortUrl, false)
.then((res) => {
return res.data.toString();
})
.catch((error) => {
this.#eventEmitter.emit("log", "error", error);
if (error.response) {
this.#eventEmitter.emit("log", "error", JSON.stringify(error.response.data));
}
});
arr = responseData.match(this.#REGEX_TOKEN);
if (arr) {
await this.#request(
passwortUrl,
false,
{
// @ts-ignore
Password: this.config.password,
RememberMe: "false",
__RequestVerificationToken: arr[1],
},
"post",
);
return true;
}
}
return false;
}
async #getAccountInfo() {
// now we can fetch the rest of the account info
const account = await this.#request(this.#ASSET_URL + "account/details")
.then((res) => {
this.#eventEmitter.emit("log", "debug", JSON.stringify(res.data));
return res.data;
})
.catch((error) => {
this.#eventEmitter.emit("log", "error", "Unable to fetch account details " + error);
if (error.response) {
this.#eventEmitter.emit("log", "error", JSON.stringify(error.response.data));
}
});
if (account.error) {
this.#eventEmitter.emit("log", "error", JSON.stringify(account));
return;
}
this.#eventEmitter.emit("log", "debug", JSON.stringify(account));
return account;
}
#addSinglekeyHost(url) {
if (url.startsWith(this.#REDIRECT_DOMAIN)) {
return url;
}
// Make relative locations absolute
if (!url.startsWith("http")) {
url = this.#SINGLEKEY_URL + url;
}
return url;
}
async #request(url, allowRedirects = true, data, method = "get") {
let options = {
method: "get",
url: url,
maxRedirects: allowRedirects ? 5 : 0,
};
if ("post" === method.toLowerCase()) {
options = {
method: "post",
url: url,
maxRedirects: allowRedirects ? 5 : 0,
data: data,
};
}
if (this.requestClient) {
const response = await this.requestClient(options)
.then((res) => {
return res;
})
.catch((error) => {
return error.response;
});
return response;
}
}
}
module.exports = ConfigService;