594 lines
24 KiB
JavaScript
594 lines
24 KiB
JavaScript
/**
|
|
*
|
|
* klf200 adapter
|
|
*
|
|
*
|
|
* file io-package.json comments:
|
|
*
|
|
* {
|
|
* "common": {
|
|
* "name": "klf200", // name has to be set and has to be equal to adapters folder name and main file name excluding extension
|
|
* "version": "0.0.0", // use "Semantic Versioning"! see http://semver.org/
|
|
* "title": "Node.js klf200 Adapter", // Adapter title shown in User Interfaces
|
|
* "authors": [ // Array of authord
|
|
* "name <mail@klf200.com>"
|
|
* ]
|
|
* "desc": "klf200 adapter", // Adapter description shown in User Interfaces. Can be a language object {de:"...",ru:"..."} or a string
|
|
* "platform": "Javascript/Node.js", // possible values "javascript", "javascript/Node.js" - more coming
|
|
* "mode": "daemon", // possible values "daemon", "schedule", "subscribe"
|
|
* "materialize": true, // support of admin3
|
|
* "schedule": "0 0 * * *" // cron-style schedule. Only needed if mode=schedule
|
|
* "loglevel": "info" // Adapters Log Level
|
|
* },
|
|
* "native": { // the native object is available via adapter.config in your adapters code - use it for configuration
|
|
* "test1": true,
|
|
* "test2": 42,
|
|
* "mySelect": "auto"
|
|
* }
|
|
* }
|
|
*
|
|
*/
|
|
|
|
/* jshint -W097 */// jshint strict:false
|
|
/*jslint node: true */
|
|
'use strict';
|
|
|
|
// you have to require the utils module and call adapter function
|
|
const utils = require(__dirname + '/lib/utils'); // Get common adapter utils
|
|
const Promise = require('bluebird');
|
|
const klf200api = require('klf-200-api');
|
|
const mapTypeId = require(__dirname + '/lib/mapTypeId'); // Mapping of typeId values to channel role names
|
|
const klfutils = require(__dirname + '/lib/klfutils');
|
|
|
|
// you have to call the adapter function and pass a options object
|
|
// name has to be set and has to be equal to adapters folder name and main file name excluding extension
|
|
// adapter will be restarted automatically every time as the configuration changed, e.g system.adapter.klf200.0
|
|
let adapter = utils.Adapter('klf200');
|
|
|
|
// Define some constant values
|
|
const deviceScenes = 'scenes';
|
|
const deviceProducts = 'products';
|
|
const run = 'run';
|
|
const category = 'category';
|
|
const scenesCount = 'scenesCount';
|
|
const silent = 'silent';
|
|
const productsCount = 'productsCount';
|
|
const level = 'level';
|
|
const levelTypes = [1, 2, 3, 4, 10, 13, 16, 17, 18, 24];
|
|
const delayBetweenSceneRunsInMS = 30000;
|
|
|
|
// Cash for previous states
|
|
let previousStates = {};
|
|
let sceneIsRunning = {};
|
|
|
|
// Trace unhandled errors
|
|
process.on('unhandledRejection', r => {
|
|
adapter.log.error(`Unhandled promise rejection: ${r}`);
|
|
});
|
|
|
|
// is called when adapter shuts down - callback has to be called under any circumstances!
|
|
adapter.on('unload', function (callback) {
|
|
try {
|
|
adapter.log.info('cleaned everything up...');
|
|
callback();
|
|
} catch (e) {
|
|
callback();
|
|
}
|
|
});
|
|
|
|
// // is called if a subscribed object changes
|
|
// adapter.on('objectChange', function (id, obj) {
|
|
// // Warning, obj can be null if it was deleted
|
|
// adapter.log.info('objectChange ' + id + ' ' + JSON.stringify(obj));
|
|
// });
|
|
|
|
// is called if a subscribed state changes
|
|
adapter.on('stateChange', function (id, state) {
|
|
// // Warning, state can be null if it was deleted
|
|
|
|
// you can use the ack flag to detect if it is status (true) or command (false)
|
|
if (state && !state.ack) {
|
|
// Get previous state (for rollback if state change isn't possible)
|
|
let oldState = null;
|
|
if (id.match(/^klf200\.[0-9]+\.products\.[0-9]+\.level$/)) {
|
|
// Set old state to 0% if no previous state was found
|
|
oldState = 0;
|
|
} else if (id.match(/^klf200\.[0-9]+\.scenes\.[0-9]+\.run$/) && state.val === true)
|
|
{
|
|
// Set old state to false if no previous state was found
|
|
oldState = false;
|
|
}
|
|
if (previousStates[id]) {
|
|
oldState = previousStates[id].state.val;
|
|
}
|
|
|
|
// If scene is still running => abort state change
|
|
if (sceneIsRunning[adapter.instance] === true)
|
|
{
|
|
adapter.log.warn('Adapter is still running a scene, please wait until finished.');
|
|
adapter.setStateAsync(id, oldState, true);
|
|
return;
|
|
}
|
|
|
|
Promise.coroutine(function* () {
|
|
try {
|
|
|
|
// Check for product, e.g. id = klf200.0.products.0.level
|
|
if (id.match(/^klf200\.[0-9]+\.products\.[0-9]+\.level$/)) {
|
|
let sceneId = yield getSceneForProductLevel(id, state.val);
|
|
let stateIdForScene = `${deviceScenes}.${sceneId.sceneId}.${run}`;
|
|
// Set scene to running
|
|
yield adapter.setStateAsync(stateIdForScene, true, true);
|
|
yield runScene(sceneId.sceneId);
|
|
// Set scene to not running
|
|
yield adapter.setStateAsync(stateIdForScene, false, true);
|
|
yield adapter.setStateAsync(id, state.val, true);
|
|
} else if (id.match(/^klf200\.[0-9]+\.scenes\.[0-9]+\.run$/) && state.val === true)
|
|
{
|
|
// Check for scene, e.g. id = klf200.0.scenes.0.run and for set to running
|
|
let idDCS = adapter.idToDCS(id);
|
|
let sceneId = parseInt(idDCS.channel);
|
|
// Get corresponding channel for native object with related products
|
|
let channel = yield adapter.getObjectAsync(`${deviceScenes}.${sceneId}`);
|
|
channel.native = channel.native || {};
|
|
channel.native.products = channel.native.products || [];
|
|
let products = yield adapter.getChannelsOfAsync(deviceProducts);
|
|
// Set scene to running
|
|
yield adapter.setStateAsync(id, true, true);
|
|
yield runScene(sceneId);
|
|
// Set scene to not running
|
|
yield adapter.setStateAsync(id, false, true);
|
|
// Set corresponding products to their correct level
|
|
yield Promise.map(channel.native.products, function(product) {
|
|
let productId = products.reduce(function(prev, prod) {
|
|
if (prod.native.name === product.name) return prod.native.id;
|
|
});
|
|
return adapter.setStateAsync(`${deviceProducts}.${productId}.level`, product.status, true);
|
|
});
|
|
}
|
|
|
|
} catch (err) {
|
|
adapter.log.error(`Error during state change for ${id}: ${err}`);
|
|
yield adapter.setStateAsync(id, oldState, true);
|
|
}
|
|
})();
|
|
} else if (state && state.ack) {
|
|
previousStates[id] = {state: state};
|
|
}
|
|
});
|
|
|
|
// Some message was sent to adapter instance over message box. Used by email, pushover, text2speech, ...
|
|
// adapter.on('message', function (obj) {
|
|
// if (typeof obj == 'object' && obj.message) {
|
|
// if (obj.command == 'send') {
|
|
// // e.g. send email or pushover or whatever
|
|
// console.log('send command');
|
|
|
|
// // Send response in callback if required
|
|
// if (obj.callback) adapter.sendTo(obj.from, obj.command, 'Message received', obj.callback);
|
|
// }
|
|
// }
|
|
// });
|
|
|
|
// is called when databases are connected and adapter received configuration.
|
|
// start here!
|
|
adapter.on('ready', function () {
|
|
main();
|
|
});
|
|
|
|
function initStates () {
|
|
// Connect to KLF interface and read data
|
|
let connection = new klf200api.connection(adapter.config.host);
|
|
Promise.coroutine(function* () {
|
|
try {
|
|
yield connection.loginAsync(adapter.config.password);
|
|
adapter.log.info('Connected to interface.');
|
|
|
|
yield adapter.createDeviceAsync(deviceProducts, { name: deviceProducts, desc: "Product list" });
|
|
yield adapter.setObjectAsync(`${deviceProducts}.productsFound`, {
|
|
type: 'state',
|
|
common: {
|
|
name: 'Number of products found',
|
|
role: 'value',
|
|
type: 'number',
|
|
min: 0,
|
|
def: 0,
|
|
read: true,
|
|
write: false,
|
|
desc: 'Number of products connected to the interface'
|
|
},
|
|
native: []
|
|
});
|
|
yield adapter.setStateAsync(`${deviceProducts}.productsFound`, { val: 0, ack: true, q: 0x42 });
|
|
|
|
yield adapter.createDeviceAsync(deviceScenes, { name: deviceScenes, desc: "Scene list" });
|
|
yield adapter.setObjectAsync(`${deviceScenes}.scenesFound`, {
|
|
type: 'state',
|
|
common: {
|
|
name: 'Number of scenes found',
|
|
role: 'value',
|
|
type: 'number',
|
|
min: 0,
|
|
def: 0,
|
|
read: true,
|
|
write: false,
|
|
desc: 'Number of scenes defined in the interface'
|
|
},
|
|
native: []
|
|
});
|
|
yield adapter.setStateAsync(`${deviceScenes}.scenesFound`, { val: 0, ack: true, q: 0x42 });
|
|
|
|
adapter.log.info('Getting installed products...');
|
|
let products = yield new klf200api.products(connection).getAsync();
|
|
adapter.log.info(`Found ${products.length} product(s).`);
|
|
yield adapter.setStateAsync(`${deviceProducts}.productsFound`, { val: products.length, ack: true });
|
|
let productsFoundObject = yield adapter.getObjectAsync(`${deviceProducts}.productsFound`);
|
|
|
|
let oldProducts = productsFoundObject.native || [];
|
|
productsFoundObject.native = products || {};
|
|
|
|
// Get differences of products
|
|
let productDifferences = klfutils.getKlfProductDifferences(oldProducts, products);
|
|
|
|
// Remove deleted products
|
|
yield Promise.mapSeries(productDifferences.deletedProducts, function (product) {
|
|
adapter.log.debug(`Removing product ${product.name}`);
|
|
return adapter.deleteChannelAsync(deviceProducts, product.id.toString());
|
|
});
|
|
|
|
// Add new products
|
|
yield Promise.mapSeries(productDifferences.newProducts, function (product) {
|
|
adapter.log.debug(`Found new product ${product.name}`);
|
|
return createProductStateAsync(product);
|
|
});
|
|
|
|
// Change products
|
|
yield Promise.mapSeries(productDifferences.changedProducts, function (product) {
|
|
adapter.log.debug(`Found changed product ${product.name}`);
|
|
const productId = product.id.toString();
|
|
return adapter.setStateAsync(`${deviceProducts}.${productId}.${scenesCount}`, { val: product.scenes.length || 0, ack: true })
|
|
.then(
|
|
function () {
|
|
if (levelTypes.find((val) => { return val === product.typeId; }))
|
|
{
|
|
return adapter.setStateAsync(`${deviceProducts}.${productId}.${level}`, { val: 0, ack: true, q: 0x42 }); // Quality issue, because we can only assume the opening level
|
|
}
|
|
}
|
|
);
|
|
});
|
|
|
|
adapter.log.info('Getting scenes...');
|
|
let scenes = yield new klf200api.scenes(connection).getAsync();
|
|
adapter.log.info(`Found ${scenes.length} scene(s).`);
|
|
yield adapter.setStateAsync(`${deviceScenes}.scenesFound`, { val: scenes.length, ack: true });
|
|
let scenesFoundObject = yield adapter.getObjectAsync(`${deviceScenes}.scenesFound`);
|
|
|
|
let oldScenes = scenesFoundObject.native || [];
|
|
scenesFoundObject.native = scenes || {};
|
|
|
|
// Get differences of scenes
|
|
let sceneDifferences = klfutils.getKlfSceneDifferences(oldScenes, scenes);
|
|
|
|
// Remove delete products
|
|
yield Promise.mapSeries(sceneDifferences.deletedScenes, function (scene) {
|
|
adapter.log.debug(`Removing scene ${scene.name}`);
|
|
return adapter.deleteChannelAsync(deviceScenes, scene.id.toString());
|
|
});
|
|
|
|
// Add new scenes
|
|
yield Promise.mapSeries(sceneDifferences.newScenes, function (scene) {
|
|
adapter.log.debug(`Found new scene ${scene.name}`);
|
|
return createSceneStateAsync(scene);
|
|
});
|
|
|
|
// Change scenes
|
|
yield Promise.mapSeries(sceneDifferences.changedScenes, function (scene) {
|
|
adapter.log.debug(`Found changed scene ${scene.name}`);
|
|
const sceneId = scene.id.toString();
|
|
return adapter.setStateAsync(`${deviceScenes}.${sceneId}.${silent}`, { val: scene.silent, ack: true });
|
|
});
|
|
}
|
|
catch (err) {
|
|
adapter.log.error(`Error during initialization occured: ${err}`);
|
|
}
|
|
finally {
|
|
if (connection.token) {
|
|
adapter.log.info('Disconnected from interface.');
|
|
yield connection.logoutAsync();
|
|
}
|
|
}
|
|
})();
|
|
|
|
}
|
|
|
|
function main() {
|
|
// The adapters config (in the instance object everything under the attribute "native") is accessible via
|
|
// adapter.config:
|
|
|
|
// Promisifying has to be done at this time, because some methods are generated during initialization only
|
|
// (e.g. setState)
|
|
if (undefined === adapter.objects.getObjectViewAsync)
|
|
{
|
|
adapter.objects.getObjectViewAsync = Promise.promisify(adapter.objects.getObjectView);
|
|
}
|
|
|
|
adapter.log.info('Host: ' + adapter.config.host);
|
|
adapter.log.info('Polling interval (minutes): ' + adapter.config.pollInterval);
|
|
|
|
// Set internal adapter running state to false
|
|
sceneIsRunning[adapter.instance] = false;
|
|
|
|
initStates();
|
|
|
|
// Subscribe to all level states
|
|
adapter.subscribeStates('*level');
|
|
// Subscribe to all silent states
|
|
adapter.subscribeStates('*silent');
|
|
// Subscribe to all run states
|
|
adapter.subscribeStates('*run');
|
|
|
|
|
|
// /**
|
|
// *
|
|
// * For every state in the system there has to be also an object of type state
|
|
// *
|
|
// * Here a simple klf200 for a boolean variable named "testVariable"
|
|
// *
|
|
// * Because every adapter instance uses its own unique namespace variable names can't collide with other adapters variables
|
|
// *
|
|
// */
|
|
|
|
// adapter.setObject('testVariable', {
|
|
// type: 'state',
|
|
// common: {
|
|
// name: 'testVariable',
|
|
// type: 'boolean',
|
|
// role: 'indicator'
|
|
// },
|
|
// native: {}
|
|
// });
|
|
|
|
// // in this klf200 all states changes inside the adapters namespace are subscribed
|
|
// adapter.subscribeStates('*');
|
|
|
|
|
|
// /**
|
|
// * setState examples
|
|
// *
|
|
// * you will notice that each setState will cause the stateChange event to fire (because of above subscribeStates cmd)
|
|
// *
|
|
// */
|
|
|
|
// // the variable testVariable is set to true as command (ack=false)
|
|
// adapter.setState('testVariable', true);
|
|
|
|
// same thing, but the value is flagged "ack"
|
|
// ack should be always set to true if the value is received from or acknowledged from the target system
|
|
// adapter.setState('testVariable', {val: true, ack: true});
|
|
|
|
// // same thing, but the state is deleted after 30s (getState will return null afterwards)
|
|
// adapter.setState('testVariable', {val: true, ack: true, expire: 30});
|
|
|
|
|
|
|
|
// // examples for the checkPassword/checkGroup functions
|
|
// adapter.checkPassword('admin', 'yunkong2', function (res) {
|
|
// console.log('check user admin pw ioboker: ' + res);
|
|
// });
|
|
|
|
// adapter.checkGroup('admin', 'admin', function (res) {
|
|
// console.log('check group user admin group admin: ' + res);
|
|
// });
|
|
|
|
|
|
|
|
}
|
|
|
|
/**
|
|
* Creates the product channels and states for the given product
|
|
*
|
|
* @param {product} product
|
|
* @returns {Promise} Returns a promise that will fulfill after all steps are finished.
|
|
*/
|
|
function createProductStateAsync(product) {
|
|
if (!product)
|
|
return Promise.reject(new Error('Can\'t create states for empty product.'));
|
|
|
|
const productId = product.id.toString();
|
|
|
|
return adapter.createChannelAsync(deviceProducts, productId, { name: product.name, role: mapTypeId.getRole(product.typeId) }, product)
|
|
.then(function () {
|
|
return adapter.createStateAsync(deviceProducts, productId, category, {
|
|
name: category,
|
|
role: 'text',
|
|
type: 'string',
|
|
read: true,
|
|
write: false,
|
|
desc: 'Category of the registered product'
|
|
});
|
|
})
|
|
.then(function () {
|
|
return adapter.setStateAsync(`${deviceProducts}.${productId}.${category}`, { val: product.category, ack: true });
|
|
})
|
|
.then(function () {
|
|
return adapter.createStateAsync(deviceProducts, productId, scenesCount, {
|
|
name: scenesCount,
|
|
role: 'value',
|
|
type: 'number',
|
|
read: true,
|
|
write: false,
|
|
desc: 'Number of scenes the product is used in'
|
|
});
|
|
})
|
|
.then(function () {
|
|
return adapter.setStateAsync(`${deviceProducts}.${productId}.${scenesCount}`, { val: product.scenes.length || 0, ack: true });
|
|
})
|
|
.then(function () {
|
|
if (levelTypes.find((val) => { return val === product.typeId; }))
|
|
return adapter.createStateAsync(deviceProducts, productId, level, {
|
|
name: level,
|
|
role: mapTypeId.getLevelType(product.typeId),
|
|
type: 'number',
|
|
min: 0,
|
|
max: 100,
|
|
unit: '%',
|
|
read: true,
|
|
write: true,
|
|
desc: 'Opening level in percent'
|
|
})
|
|
.then(function () {
|
|
return adapter.setStateAsync(`${deviceProducts}.${productId}.${level}`, { val: 0, ack: true, q: 0x42 }); // Quality issue, because we can only assume the opening level
|
|
});
|
|
else
|
|
return Promise.resolve();
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates the scene channels and states for the given scene
|
|
*
|
|
* @param {scene} scene
|
|
* @returns {Promise} Returns a promise that will fulfill after all steps are finished.
|
|
*/
|
|
function createSceneStateAsync(scene) {
|
|
if (!scene)
|
|
return Promise.reject(new Error('Can\'t create states for empty scene.'));
|
|
|
|
const sceneId = scene.id.toString();
|
|
|
|
return adapter.createChannelAsync(deviceScenes, sceneId, { name: scene.name, role: 'scene' }, scene)
|
|
.then(function () {
|
|
return adapter.createStateAsync(deviceScenes, sceneId, silent, {
|
|
name: silent,
|
|
role: 'switch',
|
|
type: 'boolean',
|
|
read: true,
|
|
write: true,
|
|
desc: 'Silent mode of the scene'
|
|
});
|
|
})
|
|
.then(function () {
|
|
return adapter.setStateAsync(`${deviceScenes}.${sceneId}.${silent}`, { val: scene.silent, ack: true });
|
|
})
|
|
.then(function () {
|
|
return adapter.createStateAsync(deviceScenes, sceneId, productsCount, {
|
|
name: productsCount,
|
|
role: 'value',
|
|
type: 'number',
|
|
read: true,
|
|
write: false,
|
|
desc: 'Number of products in the scene'
|
|
});
|
|
})
|
|
.then(function () {
|
|
return adapter.setStateAsync(`${deviceScenes}.${sceneId}.${productsCount}`, { val: scene.products.length || 0, ack: true });
|
|
})
|
|
.then(function () {
|
|
return adapter.createStateAsync(deviceScenes, sceneId, run, {
|
|
name: run,
|
|
role: 'button.play',
|
|
type: 'boolean',
|
|
def: false,
|
|
read: false,
|
|
write: true,
|
|
desc: 'Shows the running state of a scene. Set to true to run a scene.'
|
|
})
|
|
.then(function () {
|
|
return adapter.setStateAsync(`${deviceScenes}.${sceneId}.${run}`, { val: false, ack: true, q: 0x42 }); // Quality issue, because we can only assume the running state
|
|
});
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Gets the corresponding scene to run a product to the specified level
|
|
*
|
|
* @param {string} id The id of the object representing the product you want to use.
|
|
* @param {number} level The level to which the product should be driven, usually between 0 and 100.
|
|
* E.g. use 50 to open a window (specified in the id parameter) to 50%.
|
|
* @returns {Promise} Returns a promise that will fulfill after all steps are finished.
|
|
* The resulting object of the fulfilled promise looks like this:
|
|
* <pre>
|
|
* <code language="javascript">
|
|
* {
|
|
* sceneId: 0,
|
|
* sceneName: 'Bath room window 50%'
|
|
* }
|
|
* </code>
|
|
* </pre>
|
|
* If no corresponding scene is found the promise will be rejected.
|
|
*/
|
|
function getSceneForProductLevel(id, level) {
|
|
let productName;
|
|
return Promise.cast(Promise.coroutine(function* () {
|
|
try {
|
|
let idDCS = adapter.idToDCS(id);
|
|
let productChannelId = [idDCS.device, idDCS.channel].join('.');
|
|
|
|
let productChannel = yield adapter.getObjectAsync(productChannelId);
|
|
productName = productChannel.common.name;
|
|
let scenesKey = ['klf200', adapter.instance, 'scenes', ''].join('.');
|
|
|
|
// This will get a list of all scenes with only a single product in it.
|
|
let scenes = yield adapter.objects.getObjectViewAsync(
|
|
'klf200', 'listSingleProductScenes',
|
|
{startkey: scenesKey, endkey: scenesKey + '\u9999'}
|
|
);
|
|
|
|
if (!scenes || !scenes.rows || !scenes.rows.length) return Promise.reject(new Error(`No matching scene for product ${productName} and level ${level}.`));
|
|
|
|
// Reduce array of products with corresponding names
|
|
let scenesReduced = scenes.rows.reduce(
|
|
function (currentResult, currentValue) {
|
|
// Add product name
|
|
currentResult[currentValue.id[0]] = currentResult[currentValue.id[0]] || { levels: {}};
|
|
|
|
// Add level name and scene id and name
|
|
currentResult[currentValue.id[0]].levels[currentValue.id[1]] = currentResult[currentValue.id[0]].levels[currentValue.id[1]] || { sceneId: currentValue.value.id, sceneName: currentValue.value.name };
|
|
|
|
return currentResult;
|
|
},
|
|
{}
|
|
);
|
|
|
|
if (!scenesReduced[productName] || !scenesReduced[productName].levels[level]) return Promise.reject(new Error(`No matching scene for product ${productName} and level ${level}.`));
|
|
|
|
return scenesReduced[productName].levels[level];
|
|
|
|
} catch (err) {
|
|
let errHelper = err;
|
|
if (!(err instanceof Error)) {
|
|
errHelper = JSON.stringify(err);
|
|
}
|
|
adapter.log.error(`Error during lookup scene for product ${productName} and level ${level}: ${errHelper}`);
|
|
}
|
|
})());
|
|
}
|
|
|
|
/**
|
|
* Runs the given scene by name or by id
|
|
*
|
|
* @param {string|number} sceneNameOrId
|
|
* @returns {Promise} Returns a promise that will fulfill after the scene has run.
|
|
*/
|
|
function runScene(sceneNameOrId) {
|
|
return Promise.cast(Promise.coroutine(function* () {
|
|
let connection = new klf200api.connection(adapter.config.host);
|
|
try {
|
|
sceneIsRunning[adapter.instance] = true;
|
|
yield connection.loginAsync(adapter.config.password);
|
|
adapter.log.info('Connected to interface.');
|
|
|
|
yield Promise.all([new klf200api.scenes(connection).runAsync(sceneNameOrId), Promise.delay(delayBetweenSceneRunsInMS)]);
|
|
} catch (err) {
|
|
adapter.log.error(`Error during running scene ${sceneNameOrId} occured: ${err}`);
|
|
}
|
|
finally {
|
|
sceneIsRunning[adapter.instance] = false;
|
|
if (connection.token) {
|
|
adapter.log.info('Disconnected from interface.');
|
|
yield connection.logoutAsync();
|
|
}
|
|
}
|
|
})());
|
|
}
|