iobroker.gira-iot
Version:
Integrate your Gira X1 or HomeServer
704 lines (608 loc) • 30.7 kB
JavaScript
;
const utils = require('@iobroker/adapter-core');
const axios = require('axios').default;
const https = require('node:https');
const giraTypes = require(`${__dirname}/lib/gira-types`);
class GiraIot extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options]
*/
constructor(options) {
super({
...options,
name: 'gira-iot',
});
this.apiConnected = false;
this.webHooksBaseUrl = null;
this.webHooksRegistered = false;
this.giraApiClient = null;
this.uiConfigId = null;
this.uidCache = {};
this.refreshStateTimeout = null;
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('message', this.onMessage.bind(this));
this.on('unload', this.onUnload.bind(this));
}
async onReady() {
if (!this.config.serverIp) {
this.log.error(`Server IP is empty - please check instance configuration of ${this.namespace}`);
return;
}
if (!this.config.userName || !this.config.userPassword) {
this.log.error(
`User name and/or user password empty - please check instance configuration of ${this.namespace}`,
);
return;
}
if (!this.config.webInstance) {
this.log.error(`Web instance is not configured - please check instance configuration of ${this.namespace}`);
return;
}
this.log.info(
`Configured server: "${this.config.serverIp}:${this.config.serverPort}" - Connecting with user: "${this.config.userName}"`,
);
await this.setApiConnection(false);
await this.setStateChangedAsync('info.callbacksRegistered', { val: false, ack: true });
this.giraApiClient = axios.create({
baseURL: `https://${this.config.serverIp}:${this.config.serverPort}/api`,
timeout: 1000,
responseType: 'json',
responseEncoding: 'utf8',
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
validateStatus: status => {
return [200, 201, 401].includes(status);
},
});
await this.subscribeStatesAsync('*');
this.refreshState();
}
async refreshState() {
let nextRefreshSec = 60;
try {
if (this.giraApiClient) {
const deviceInfoResponse = await this.giraApiClient.get('/v2/');
this.log.debug(
`deviceInfoResponse ${deviceInfoResponse.status}: ${JSON.stringify(deviceInfoResponse.data)}`,
);
if (deviceInfoResponse.status === 200) {
const deviceInfo = deviceInfoResponse.data;
await this.setStateChangedAsync('deviceInfo.name', { val: deviceInfo.deviceName, ack: true });
await this.setStateChangedAsync('deviceInfo.type', { val: deviceInfo.deviceType, ack: true });
await this.setStateChangedAsync('deviceInfo.version', { val: deviceInfo.deviceVersion, ack: true });
let clientToken = await this.getClientToken();
if (!clientToken) {
this.log.info(`Client token doesn't exist - creating a new client`);
const clientIdentifier = `net.iobroker.clients.${this.namespace}`;
await this.setStateChangedAsync('client.identifier', { val: clientIdentifier, ack: true });
const registerClientResponse = await this.giraApiClient.post(
'/clients',
{
client: clientIdentifier,
},
{
auth: {
username: this.config.userName,
password: this.config.userPassword,
},
},
);
this.log.debug(
`registerClientResponse ${registerClientResponse.status}: ${JSON.stringify(registerClientResponse.data)}`,
);
/*
200 Created (Home Server - not documented?!)
201 Created
400 Bad Request
401 Unauthorized
423 Locked
*/
if (registerClientResponse.status === 201 || registerClientResponse.status === 200) {
if (registerClientResponse?.data?.token) {
clientToken = registerClientResponse.data.token;
await this.setStateAsync('client.token', { val: clientToken, ack: true });
this.log.info(`Registered new client - received token: "${clientToken}"`);
// Set device online
await this.setApiConnection(true);
}
} else {
this.log.error(
`Unable to register client. Device responded with code ${registerClientResponse.status}`,
);
await this.setStateAsync('client.token', { val: null, ack: true });
// Set device offline
await this.setApiConnection(false);
nextRefreshSec = 60 * 5;
}
} else {
// Set device online
await this.setApiConnection(true);
}
if (this.apiConnected) {
const uiConfigIdResponse = await this.giraApiClient.get(`/uiconfig/uid?token=${clientToken}`);
this.log.debug(
`uiConfigIdResponse ${uiConfigIdResponse.status}: ${JSON.stringify(uiConfigIdResponse.data)}`,
);
if (uiConfigIdResponse.status === 200) {
if (uiConfigIdResponse?.data?.uid) {
if (uiConfigIdResponse.data.uid !== this.uiConfigId) {
if (this.uiConfigId) {
this.log.info(
`UI config ID changed from "${this.uiConfigId}" to "${uiConfigIdResponse.data.uid}" - refreshing functions`,
);
}
await this.refreshDevices();
this.uiConfigId = uiConfigIdResponse.data.uid;
}
}
} else if (uiConfigIdResponse.status === 401) {
this.log.warn(
`Unable to get UI config ID - looks like your client token is invalid. Will be deleted and recreated automatically`,
);
await this.setStateAsync('client.token', { val: null, ack: true });
nextRefreshSec = 2; // Call refresh function again to create a new token
}
}
}
} else {
this.log.error(`API client is not defined`);
}
} catch (err) {
// Set device offline
await this.setApiConnection(false);
if (err.name === 'AxiosError') {
this.log.error(
`Request to ${err?.config?.url} failed with code ${err?.status} (${err?.code}): ${err.message}`,
);
this.log.debug(`Complete error object: ${JSON.stringify(err)}`);
} else {
this.log.error(err);
}
} finally {
// Delete old timer
if (this.refreshStateTimeout) {
this.clearTimeout(this.refreshStateTimeout);
}
this.refreshStateTimeout = this.setTimeout(() => {
this.refreshStateTimeout = null;
this.refreshState();
}, nextRefreshSec * 1000);
this.log.debug(`refreshStateTimeout: re-created refresh timeout: id ${this.refreshStateTimeout}`);
}
}
async refreshDevices() {
if (this.apiConnected && this.giraApiClient) {
const clientToken = await this.getClientToken();
const uiConfigResponse = await this.giraApiClient.get(
`/uiconfig?expand=locations,trades&token=${clientToken}`,
);
this.log.debug(`uiConfigResponse ${uiConfigResponse.status}: ${JSON.stringify(uiConfigResponse.data)}`);
if (uiConfigResponse.status === 200) {
let functionCount = 0;
let stateCount = 0;
const keepFunctions = [];
const allFunctions = (await this.getChannelsOfAsync('functions')).map(obj => {
return this.removeNamespace(obj._id);
});
for (const func of uiConfigResponse.data.functions) {
keepFunctions.push(`functions.${func.uid}`);
this.log.debug(`Found device with UID ${func.uid} and name "${func.displayName}"`);
functionCount++;
await this.extendObjectAsync(`functions.${func.uid}`, {
type: 'channel',
common: {
name: func.displayName,
},
native: {
uid: func.uid,
channelType: func.channelType,
functionType: func.functionType,
},
});
for (const dp of func.dataPoints) {
if (giraTypes.channels?.[func.channelType]?.[dp.name]) {
this.log.debug(`Creating state "functions.${func.uid}.${dp.name}"`);
const stateObjId = `functions.${func.uid}.${dp.name}`;
stateCount++;
await this.extendObjectAsync(stateObjId, {
type: 'state',
common: giraTypes.channels[func.channelType][dp.name].common,
native: {
uid: dp.uid,
parentChannelType: func.channelType,
type: giraTypes.channels[func.channelType][dp.name].type,
mandatory: giraTypes.channels[func.channelType][dp.name].mandatory,
eventing: giraTypes.channels[func.channelType][dp.name].eventing,
},
});
this.uidCache[dp.uid] = `functions.${func.uid}.${dp.name}`;
if (giraTypes.channels[func.channelType][dp.name].common.read) {
try {
// Try to get current value
const getValueResponse = await this.giraApiClient.get(
`/values/${dp.uid}?token=${clientToken}`,
);
this.log.debug(
`getValueResponse ${getValueResponse.status}: ${JSON.stringify(getValueResponse.data)}`,
);
if (getValueResponse.status === 200) {
for (const value of getValueResponse.data.values) {
const newValue = giraTypes.convertValueForState(
value.value,
giraTypes.channels[func.channelType][dp.name].common.type,
giraTypes.channels[func.channelType][dp.name].type,
);
await this.setStateChangedAsync(stateObjId, {
val: newValue,
ack: true,
c: 'Init value',
});
}
}
} catch (err) {
this.log.error(
`unable to get current value for "${stateObjId}" / UID "${dp.uid}" - failed with ${err}`,
);
}
}
} else {
this.log.warn(
`Data point "${dp.name}" of channel type "${func.channelType}" is missing in ioBroker description`,
);
}
}
}
this.log.info(`Updated (or created) ${functionCount} functions with ${stateCount} states`);
// Delete non existent functions
for (const func of allFunctions) {
if (!keepFunctions.includes(func)) {
await this.delObjectAsync(func, { recursive: true });
this.log.debug(`Function deleted: ${func}`);
}
}
if (this.config.createRoomsAndFunctions) {
if (uiConfigResponse.data?.locations) {
await this.createRooms(uiConfigResponse.data.locations);
}
if (uiConfigResponse.data?.trades) {
await this.createFunctions(uiConfigResponse.data.trades);
}
}
}
}
}
async createRooms(locations) {
for (const location of locations) {
if (location?.locationType === 'Room') {
const enumId = this.cleanNamespace(location.displayName);
this.log.debug(`Creating room "${location.displayName}" with enum id "enum.rooms.${enumId}"`);
if (enumId) {
await this.setForeignObjectNotExistsAsync(`enum.rooms.${enumId}`, {
type: 'enum',
common: {
name: location.displayName,
color: false,
members: [],
},
native: {},
});
if (location?.functions) {
for (const func of location.functions) {
await this.addChannelToEnumAsync('rooms', enumId, 'functions', func);
}
}
} else {
this.log.warn(
`[createRooms] Found room with empty name: "${location.displayName}" was converted to "enum.rooms.${enumId}"`,
);
}
}
if (location?.locations) {
await this.createRooms(location.locations);
}
}
}
async createFunctions(trades) {
for (const trade of trades) {
if (trade?.functions && trade.functions.length > 0) {
const enumId = this.cleanNamespace(trade.tradeType);
this.log.debug(`Creating function "${trade.displayName}" with enum id "enum.functions.${enumId}"`);
if (enumId) {
await this.setForeignObjectNotExistsAsync(`enum.functions.${enumId}`, {
type: 'enum',
common: {
name: trade.displayName,
color: false,
members: [],
},
native: {},
});
for (const func of trade.functions) {
await this.addChannelToEnumAsync('functions', enumId, 'functions', func);
}
} else {
this.log.warn(
`[createFunctions] Found function with empty name: "${trade.tradeType}" was converted to "enum.functions.${enumId}"`,
);
}
}
}
}
async updateValueOf(uid, value) {
if (this.uidCache?.[uid]) {
const stateObj = await this.getObjectAsync(this.uidCache[uid]);
// Just update "eventing" states
if (stateObj?.type === 'state' && stateObj?.native?.eventing) {
const newValue = giraTypes.convertValueForState(value, stateObj.common.type, stateObj.native.type);
this.log.debug(
`Received value event for state "${this.uidCache[uid]}" / UID "${uid}": ${value} (${typeof value}) was converted to ${newValue} (${typeof newValue})`,
);
await this.setStateChangedAsync(this.uidCache[uid], { val: newValue, ack: true, c: 'Value callback' });
} else {
this.log.warn(`Received value event for invalid state with UID "${uid}": ${value}`);
}
} else {
this.log.info(`Received value event for unknown state with UID "${uid}": ${value}`);
}
}
async registerCallbacks(baseUrl) {
if (this.apiConnected && this.giraApiClient) {
if (!this.webHooksRegistered) {
this.webHooksBaseUrl = baseUrl;
const serviceCallbackUri = `${this.webHooksBaseUrl}/service`;
const valueCallbackUri = `${this.webHooksBaseUrl}/value`;
this.log.debug(`Registering callback urls to ${serviceCallbackUri} and ${valueCallbackUri}`);
try {
const clientToken = await this.getClientToken();
const registerCallbacksReponse = await this.giraApiClient.post(
`/clients/${clientToken}/callbacks`,
{
serviceCallback: serviceCallbackUri,
valueCallback: valueCallbackUri,
testCallbacks: false,
},
);
this.log.debug(
`registerCallbacksReponse ${registerCallbacksReponse.status}: ${JSON.stringify(registerCallbacksReponse.data)}`,
);
if (registerCallbacksReponse.status == 200) {
this.log.info(
`Registered callback urls to ${serviceCallbackUri} and ${valueCallbackUri} (web extension)`,
);
this.webHooksRegistered = true;
await this.setStateAsync('info.callbacksRegistered', {
val: this.webHooksRegistered,
ack: true,
});
}
} catch (err) {
this.log.error(`registerCallbacks failed with ${err}`);
}
} else {
this.log.debug(
`Unable to register callbacks - webHooksRegistered: ${this.webHooksRegistered}, webHooksBaseUrl: ${this.webHooksBaseUrl}`,
);
}
} else {
this.log.debug(`Unable to register callbacks - API not connected`);
}
}
async unregisterCallbacks() {
if (this.apiConnected && this.giraApiClient) {
if (this.webHooksRegistered) {
this.log.debug(`Unregister callback urls`);
try {
const clientToken = await this.getClientToken();
const unregisterCallbacksReponse = await this.giraApiClient.delete(
`/clients/${clientToken}/callbacks`,
);
this.log.debug(
`unregisterCallbacksReponse ${unregisterCallbacksReponse.status}: ${JSON.stringify(unregisterCallbacksReponse.data)}`,
);
if (unregisterCallbacksReponse.status === 200) {
this.log.info(`Unregistered callback urls to ${this.webHooksBaseUrl} (web extension)`);
this.webHooksBaseUrl = null;
this.webHooksRegistered = false;
await this.setStateAsync('info.callbacksRegistered', {
val: this.webHooksRegistered,
ack: true,
c: this.webHooksBaseUrl,
});
}
} catch (err) {
this.log.error(`unregisterCallbacks failed with ${err}`);
}
} else {
this.log.debug(
`Unable to unregister callbacks - webHooksRegistered: ${this.webHooksRegistered}, webHooksBaseUrl: ${this.webHooksBaseUrl}`,
);
}
} else {
this.log.debug(`Unable to unregister callbacks - API not connected`);
}
}
async getClientToken() {
const clientTokenState = await this.getStateAsync('client.token');
if (clientTokenState && clientTokenState.val) {
return clientTokenState.val;
}
return null;
}
async setApiConnection(status) {
this.apiConnected = status;
await this.setStateChangedAsync('info.connection', { val: status, ack: true });
}
cleanNamespace(id) {
return id
.trim()
.replace(/\s/g, '_') // Replace whitespaces with underscores
.replace(/[^\p{Ll}\p{Lu}\p{Nd}]+/gu, '_') // Replace not allowed chars with underscore
.replace(/[_]+$/g, '') // Remove underscores end
.replace(/^[_]+/g, '') // Remove underscores beginning
.replace(/_+/g, '_') // Replace multiple underscores with one
.toLowerCase()
.replace(/_([a-z])/g, (m, w) => {
return w.toUpperCase();
});
}
removeNamespace(id) {
const re = new RegExp(`${this.namespace}*\\.`, 'g');
return id.replace(re, '');
}
/**
* @param {string} id
* @param {ioBroker.State | null | undefined} state
*/
onStateChange(id, state) {
if (state && !state.ack) {
const idNoNamespace = this.removeNamespace(id);
if (idNoNamespace.startsWith('functions.')) {
this.getObject(id, async (err, stateObj) => {
if (err) {
this.log.error(`Unable to get object for ${idNoNamespace}: ${err}`);
} else if (stateObj?.common?.write) {
const uid = stateObj?.native?.uid;
const clientToken = await this.getClientToken();
if (uid && clientToken && this.apiConnected && this.giraApiClient) {
const newValue = giraTypes.convertValueForGira(state.val, stateObj.common.type);
this.log.debug(
`Sending new value for state "${idNoNamespace}" / UID "${uid}": ${state.val} (${typeof state.val}) was converted to ${newValue} (${typeof newValue})`,
);
try {
const putValueResponse = await this.giraApiClient.put(
`/values/${uid}?token=${clientToken}`,
{
value: newValue,
},
);
this.log.debug(
`putValueResponse ${putValueResponse.status}: ${JSON.stringify(putValueResponse.data)}`,
);
if (putValueResponse.status === 200) {
// Confirm new value (if sent)
await this.setStateAsync(idNoNamespace, { val: state.val, ack: true });
}
} catch (err) {
this.log.error(
`Unable to update value of "${idNoNamespace}" / UID "${uid}" - failed with ${err}`,
);
}
}
}
});
}
}
}
/**
* @param {ioBroker.Message} obj
*/
async onMessage(obj) {
if (obj) {
this.log.debug(`[onMessage] Received message: ${JSON.stringify(obj.message)}`);
if (obj.command === 'updateValueOf') {
this.updateValueOf(obj.message.uid, obj.message.value);
} else if (obj.command === 'registerCallbacks') {
const newWebHooksBaseUrl = obj.message.baseUrl;
if (newWebHooksBaseUrl !== this.webHooksBaseUrl) {
this.log.debug(
`[onMessage] Received new webHooksBaseUrl: ${newWebHooksBaseUrl} - register callbacks now`,
);
await this.unregisterCallbacks();
await this.registerCallbacks(newWebHooksBaseUrl);
}
} else if (obj.command === 'unregisterCallbacks') {
await this.unregisterCallbacks();
} else if (obj.command === 'getWebUrl' && typeof obj.message === 'object') {
if (obj.message?.webInstance) {
this.log.debug(
`[onMessage] Try to get instance configuration of system.adapter.${obj.message.webInstance}`,
);
this.getForeignObjectAsync(`system.adapter.${obj.message.webInstance}`)
.then(webObj => {
const isSecure = webObj?.native?.secure;
const protocol = isSecure ? 'https' : 'http';
const port = webObj?.native?.port;
const bind = webObj?.native?.bind;
if (!isSecure) {
this.sendTo(
obj.from,
obj.command,
`Error: https (secure mode) is required in ${obj.message.webInstance}`,
obj.callback,
);
} else if (bind && bind.includes('0.0.0.0')) {
this.sendTo(
obj.from,
obj.command,
`Error: Configure specific interface in ${obj.message.webInstance} (or use manual mode)`,
obj.callback,
);
} else {
if (obj.message?.webCustomUrlActive) {
const customWebUrl = obj.message?.webCustomUrl;
if (customWebUrl) {
this.sendTo(
obj.from,
obj.command,
`${protocol}://${customWebUrl}:${port}/${this.namespace}/`,
obj.callback,
);
} else {
this.sendTo(
obj.from,
obj.command,
`Error: Custom web url is not defined`,
obj.callback,
);
}
} else {
this.sendTo(
obj.from,
obj.command,
`${protocol}://${bind}:${port}/${this.namespace}/`,
obj.callback,
);
}
}
})
.catch(err => {
this.sendTo(obj.from, obj.command, `Error: ${err}`, obj.callback);
});
} else {
this.sendTo(obj.from, obj.command, 'Please select a web instance for url preview', obj.callback);
}
}
}
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
*
* @param {() => void} callback
*/
onUnload(callback) {
try {
this.setApiConnection(false);
this.unregisterCallbacks();
// Delete old timer
if (this.refreshStateTimeout) {
this.log.debug('refreshStateTimeout: UNLOAD');
this.clearTimeout(this.refreshStateTimeout);
}
callback();
} catch {
callback();
}
}
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options]
*/
module.exports = options => new GiraIot(options);
} else {
// otherwise start the instance directly
new GiraIot();
}