iobroker.jablotron
Version:
Connects to the Jablotron cloud to access your security system
541 lines (514 loc) • 20.7 kB
JavaScript
;
/*
* Created with @iobroker/create-adapter v2.5.0
*/
// 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 axios = require('axios').default;
const EAI_MAX_ERRORS = 12;
const REQUEST_TIMEOUT = 10000; // default timeout for request: 10 seconds
const FORBIDDEN_CHARS = /[\][*,;'"`<>\\?]/g;
const baseUrl = 'https://api.jablonet.net/api/2.2';
const userAgent =
'Mozilla/5.0 (iPhone13,2; U; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Mobile/15E148 Safari/602.1';
const requestHeader = {
'x-vendor-id': 'JABLOTRON:Jablotron',
'Content-Type': 'application/json',
'x-client-version': 'MYJ-PUB-ANDROID-12',
'accept-encoding': '*',
Accept: 'application/json',
'Accept-Language': 'en',
'User-Agent': userAgent,
'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0',
Pragma: 'no-cache',
Expires: '0',
};
class Jablotron extends utils.Adapter {
/**
* @param [options] - The options for the adapter instance.
*/
constructor(options) {
super({
...options,
name: 'jablotron',
});
this.isConnected = false;
this.sessionId = '';
this.timeout = undefined;
this.states = [];
this.eai_error = 0;
this.isReady = false;
axios.defaults.timeout = REQUEST_TIMEOUT; // set timeout for any request
this.on('ready', this.onReady.bind(this));
this.on('unload', this.onUnload.bind(this));
}
/**
* Getter for the connected property.
*
* @returns The connection status.
*/
get isConnected() {
return this._isConnected || false;
}
/**
* Setter for the connected property.
*
* @param value - The new value for the connected property.
*/
set isConnected(value) {
this._isConnected = value;
// only update the state if the adapter is ready (prevent error messages on startup)
if (this.isReady) {
this.setState('info.connection', { val: value, ack: true });
}
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
try {
// the polling interval should never be less than 5 seconds to prevent possisble bans
if (this.config.pollInterval < 5) {
throw new Error('Poll interval must be at least 5 seconds');
}
// username and password are mandatory
if (!this.config.username || !this.config.password) {
throw new Error('Username and password are mandatory');
}
this.isReady = true;
this.sessionId = (await this.fetchSessionId(this.config.username, this.config.password)) || '';
this.isConnected = this.sessionId !== '';
// create interval for recurring tasks and fetch initial data
if (this.isConnected) {
this.log.debug('Fetching initial data from jablonet.net');
await this.getExtendedData(requestHeader, this.sessionId);
this.log.debug('Setting up recurring refresh');
this.recurringRefresh();
// subscribe to all state changes
// this.subscribeStates('status.alarm');
} else {
throw new Error('Not connect to jablonet.net');
}
} catch (error) {
this.log.error(`Error in onReady: ${error}`);
this.isConnected = false;
}
}
/**
* Login to jablonet.net
*
* @param username - The username for login.
* @param password - The password for login.
*/
async fetchSessionId(username, password) {
try {
const timestamp = new Date().getTime();
const url = `${baseUrl}/userAuthorize.json?timestamp=${timestamp}`;
const data = {
login: username,
password: password,
};
this.log.debug('Fetching new session id');
const response = await axios.post(url, data, { headers: requestHeader });
if (this.config.logResponse) {
this.log.debug(`Response-Header: ${JSON.stringify(response.headers)}`);
}
if (response.headers && response.headers['set-cookie']) {
const cookie = response.headers['set-cookie'];
if (cookie) {
this.log.info('Logged in to jablonet api');
const sessionId = cookie.toString().split(';')[0];
this.log.debug(`Session-ID: ${sessionId}`);
return sessionId;
}
throw new Error('Login failed. No session id received');
}
} catch (error) {
this.log.error(error);
this.isConnected = false;
return '';
}
}
/**
* Refreshes the session ID by fetching it from the server using the provided username and password.
* Updates the isConnected flag and session expiration time accordingly.
*
* @throws {Error} If unable to connect to jablonet.net
*/
async refreshSessionId() {
this.sessionId = (await this.fetchSessionId(this.config.username, this.config.password)) || '';
this.isConnected = this.sessionId !== '';
if (!this.isConnected) {
throw new Error('Error refreshing session id');
}
}
/**
* get data from jablonet cloud
*
* @param header - The request headers to be used for the API call.
* @param cookie - The session cookie for authentication.
*/
async getExtendedData(header, cookie) {
try {
const services = await this.getServices(header, cookie);
if (services && services.length > 0) {
await this.createFolder('services', 'All services related to the account');
for (const key in services) {
await this.createNewChannel(`services.${key}`, `Service ${key}`);
const service = services[key];
const serviceId = service['service-id'];
await this.createNewChannel(`services.${serviceId}`, `Service ${serviceId}`);
for (const state in service) {
await this.createOrUpdateState(
`services.${serviceId}.${state}`,
`${state}`,
true,
false,
service[state],
);
}
if (this.config.readSections) {
await this.getSections(header, cookie, serviceId);
}
if (this.config.readProgrammableGates) {
await this.getProgrammableGates(header, cookie, serviceId);
}
if (this.config.readThermoDevices) {
await this.getThermoDevices(header, cookie, serviceId);
}
}
} else {
this.log.debug('No services found');
}
} catch (error) {
if (error.response && error.response.status == 401) {
this.log.debug('Session expired. Trying to login again');
// wait to fetch new session id
this.setTimeout(() => {
this.refreshSessionId();
}, 30000);
} else if (error.response && error.response.status >= 400) {
this.log.warn(`Communication error ${error.response.status} (${error.code}).`);
} else {
this.log.debug(`Error in getExtendedData: ${error}`);
}
}
}
/**
* Recursively refreshes data at a specified interval.
*
* @returns A promise that resolves when the refresh is complete.
*/
async recurringRefresh() {
this.timeout = this.setTimeout(() => {
//this.log.debug('Fetch data from jablonet.net');
this.getExtendedData(requestHeader, this.sessionId);
this.recurringRefresh();
}, this.config.pollInterval * 1000);
}
/**
* read all services related to the account
*
* @param header - The request headers to be used for the API call.
* @param cookie - The session cookie for authentication.
*/
async getServices(header, cookie) {
try {
const payload = {
'list-type': 'EXTENDED',
visibility: 'DEFAULT',
};
header['Cookie'] = cookie;
const url = `${baseUrl}/JA100/serviceListGet.json`;
const response = await axios.post(url, payload, { headers: header });
this.eai_error = 0;
if (this.config.logResponse) {
this.log.debug(`serviceListGet: ${JSON.stringify(response.data)}`);
}
return response.data['data']['services'];
} catch (error) {
if ((error.response && error.response.status === 504) || error.code === 'ECONNABORTED') {
this.log.debug('Timeout exceeded requesting services');
return null;
} else if (error.code === 'EAI_AGAIN' && this.eai_error < EAI_MAX_ERRORS) {
this.log.debug('DNS-Lookup failed requesting services');
this.eai_error++;
return null;
}
throw error;
}
}
/**
* read all sections related to a given service
*
* @param header - The request headers to be used for the API call.
* @param cookie - The session cookie for authentication.
* @param serviceId - The ID of the service.
*/
async getSections(header, cookie, serviceId) {
try {
const payload = {
'connect-device': true,
'list-type': 'FULL',
'service-id': serviceId,
'service-states': true,
};
header['Cookie'] = cookie;
const url = `${baseUrl}/JA100/sectionsGet.json`;
const response = await axios.post(url, payload, { headers: header });
this.eai_error = 0;
if (this.config.logResponse) {
this.log.debug(`sectionsGet: ${JSON.stringify(response.data)}`);
}
await this.createFolder(`services.${serviceId}.sections`, 'All sections related to the service');
const sections = response.data['data']['sections'];
const states = response.data['data']['states'];
for (const section in sections) {
const id = sections[section]['cloud-component-id'];
await this.createNewChannel(`services.${serviceId}.sections.${id}`, `${id}`);
for (const key in sections[section]) {
await this.createOrUpdateState(
`services.${serviceId}.sections.${id}.${key}`,
`${key}`,
true,
false,
sections[section][key],
);
}
const state = states.find(state => state['cloud-component-id'] === id);
if (state) {
// es wurde ein state zur section gefunden
await this.createOrUpdateState(
`services.${serviceId}.sections.${id}.state`,
'state',
true,
false,
state.state,
);
}
}
} catch (error) {
if ((error.response && error.response.status === 504) || error.code === 'ECONNABORTED') {
this.log.debug('Timeout exceeded requesting sections');
} else if (error.code === 'EAI_AGAIN' && this.eai_error < EAI_MAX_ERRORS) {
this.eai_error++;
this.log.debug('DNS-Lookup failed requesting sections');
} else {
throw error;
}
}
}
/**
* read all programmable gates related to a given service
*
* @param header - The request headers to be used for the API call.
* @param cookie - The session cookie for authentication.
* @param serviceId - The ID of the service.
*/
async getProgrammableGates(header, cookie, serviceId) {
try {
const payload = {
'connect-device': true,
'list-type': 'FULL',
'service-id': serviceId,
'service-states': true,
};
header['Cookie'] = cookie;
const url = `${baseUrl}/JA100/programmableGatesGet.json`;
const response = await axios.post(url, payload, { headers: header });
this.eai_error = 0;
if (this.config.logResponse) {
this.log.debug(`programmableGatesGet: ${JSON.stringify(response.data)}`);
}
await this.createFolder(
`services.${serviceId}.programmable-gates`,
'All programmable gates related to the service',
);
const gates = response.data['data']['programmableGates'];
const states = response.data['data']['states'];
for (const gate in gates) {
const id = gates[gate]['cloud-component-id'];
await this.createNewChannel(`services.${serviceId}.programmable-gates.${id}`, `${id}`);
for (const key in gates[gate]) {
await this.createOrUpdateState(
`services.${serviceId}.programmable-gates.${id}.${key}`,
`${key}`,
true,
false,
gates[gate][key],
);
const state = states.find(state => state['cloud-component-id'] === id);
if (state) {
// es wurde ein state zum gate gefunden
await this.createOrUpdateState(
`services.${serviceId}.programmable-gates.${id}.state`,
'state',
true,
false,
state.state,
);
}
}
}
} catch (error) {
if ((error.response && error.response.status === 504) || error.code === 'ECONNABORTED') {
this.log.debug('Timeout exceeded requesting programmableGates');
} else if (error.code === 'EAI_AGAIN' && this.eai_error < EAI_MAX_ERRORS) {
this.eai_error++;
this.log.debug('DNS-Lookup failed requesting programmableGates');
} else {
throw error;
}
}
}
/**
* read all thermo devices related to a given service
* currently work in prograss due to missing examples
*
* @param header - The request headers to be used for the API call.
* @param cookie - The session cookie for authentication.
* @param serviceId - The ID of the service.
*/
async getThermoDevices(header, cookie, serviceId) {
try {
const payload = {
'connect-device': true,
'list-type': 'FULL',
'service-id': serviceId,
'service-states': true,
};
header['Cookie'] = cookie;
const url = `${baseUrl}/JA100/thermoDevicesGet.json`;
const response = await axios.post(url, payload, { headers: header });
this.eai_error = 0;
if (this.config.logResponse) {
this.log.debug(`thermoDevicesGet: ${JSON.stringify(response.data)}`);
}
} catch (error) {
if ((error.response && error.response.status === 504) || error.code === 'ECONNABORTED') {
this.log.debug('Timeout exceeded requesting thermoDevices');
} else if (error.code === 'EAI_AGAIN' && this.eai_error < EAI_MAX_ERRORS) {
this.eai_error++;
this.log.debug('DNS-Lookup failed requesting thermoDevices');
} else {
throw error;
}
}
}
/**
* create a folder in the object tree
*
* @param id - The ID of the folder to be created.
* @param name - The name of the folder to be created.
*/
async createFolder(id, name) {
const stateId = this.name2id(id);
if (!this.existsState(stateId)) {
this.extendObject(stateId, { type: 'folder', common: { name: `${name}` }, native: {} });
this.states.push(stateId);
}
}
/**
* create a channel in the object tree
*
* @param id - The ID of the channel to be created.
* @param name - The name of the channel to be created.
*/
async createNewChannel(id, name) {
const stateId = this.name2id(id);
if (!this.existsState(stateId)) {
this.extendObject(stateId, { type: 'channel', common: { name: `${name}` }, native: {} });
this.states.push(stateId);
}
}
/**
* create a state in the object tree and set its value
*
* @param id - The ID of the state to be created or updated.
* @param name - The name of the state to be created or updated.
* @param read - Indicates if the state is readable.
* @param write - Indicates if the state is writable.
* @param value - The value to be set for the state.
*/
async createOrUpdateState(id, name, read, write, value) {
let stateType = '';
let stateRole = '';
switch (typeof value) {
case 'object':
stateType = 'string';
stateRole = 'json';
value = JSON.stringify(value);
break;
case 'string':
stateType = 'string';
stateRole = 'text';
break;
case 'boolean':
stateType = 'boolean';
stateRole = 'indicator';
break;
case 'number':
stateType = 'number';
stateRole = 'value';
break;
default:
throw new Error(`Unknown type for value "${name}"`);
}
const stateId = this.name2id(id);
if (!this.existsState(stateId)) {
// @ts-expect-error: False positive. Invalid types will result in an error. There will never be an invalid type here.
this.extendObject(stateId, {
type: 'state',
common: { name: `${name}`, type: `${stateType}`, role: stateRole, read: read, write: write },
native: {},
});
this.states.push(stateId);
}
this.setState(stateId, value, true);
}
/**
* Checks if a state with the given ID exists.
*
* @param id - The ID of the state to check.
* @returns - True if the state exists, false otherwise.
*/
existsState(id) {
return this.states.indexOf(id) >= 0;
}
/**
* Replaces forbidden characters in a name with underscores.
*
* @param name - The name to be processed.
* @returns - The processed name with forbidden characters replaced by underscores.
*/
name2id(name) {
return name.replace(FORBIDDEN_CHARS, '_');
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
*
* @param callback - The callback function to be called when the adapter shuts down.
*/
async onUnload(callback) {
try {
await this.setState('info.connection', { val: false, ack: true });
if (this.timeout) {
await this.clearTimeout(this.timeout);
}
callback();
} catch {
callback();
}
}
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param [options] - The options for the adapter instance.
*/
module.exports = options => new Jablotron(options);
} else {
// otherwise start the instance directly
new Jablotron();
}