athom-cli
Version:
Command-line interface for Homey Apps
517 lines (403 loc) • 16.3 kB
JavaScript
'use strict';
/*
Plugin ID: compose
This plugin generates an app.json file based on scattered files,
to make it easier to create apps with lots of functionality.
It finds the following files:
/.homeycompose/app.compose.json
/.homeycompose/capabilities/<id>.json
/.homeycompose/screensavers/<id>.json
/.homeycompose/signals/<433|868|ir>/<id>.json
/.homeycompose/flow/<triggers|conditions|actions>/<id>.json
/.homeycompose/capabilities/<id>.json
/.homeycompose/discovery/<id>.json
/.homeycompose/drivers/templates/<template_id>.json
/.homeycompose/drivers/settings/<setting_id>.json
/.homeycompose/drivers/flow/<triggers|conditions|actions>/<flow_id>.json (flow card object, id and device arg is added automatically)
/drivers/<id>/driver.compose.json (extend with "$extends": [ "<template_id>" ])
/drivers/<id>/driver.settings.compose.json (array with driver settings, extend with "$extends": "<template_id>"))
/drivers/<id>/driver.flow.compose.json (object with flow cards, device arg is added automatically)
/.homeycompose/locales/en.json
/.homeycompose/locales/en.foo.json
Enable the plugin by adding `{ "id": "compose" }` to your /.homeyplugins.json array
Plugin options:
{
"appJsonSpace": 2 | 4 | "\t"
}
*/
const fs = require('fs');
const path = require('path');
const util = require('util');
const fse = require('fs-extra');
const _ = require('underscore');
const deepmerge = require('deepmerge');
const objectPath = require('object-path');
const HomeyLib = require('homey-lib');
const AppPlugin = require('../AppPlugin');
const readFileAsync = util.promisify( fs.readFile );
const writeFileAsync = util.promisify( fs.writeFile );
const copyFileAsync = util.promisify( fs.copyFile );
const FLOW_TYPES = [ 'triggers', 'conditions', 'actions' ];
class AppPluginCompose extends AppPlugin {
async run() {
this._appPath = this._app.path;
this._appPathCompose = path.join(this._app.path, '.homeycompose');
this._appJsonPath = path.join( this._appPath, 'app.json' );
this._appJson = await this._getJsonFile(this._appJsonPath);
this._appJsonPathCompose = path.join( this._appPathCompose, 'app.json' );
try {
this._appJson = await this._getJsonFile(this._appJsonPathCompose);
} catch( err ) {
if( err.code !== 'ENOENT' ) throw new Error(err);
}
await this._composeFlow();
await this._composeDrivers();
await this._composeCapabilities();
await this._composeDiscovery();
await this._composeSignals();
await this._composeScreensavers();
await this._composeLocales();
await this._saveAppJson();
}
/*
Find drivers in /drivers/:id/driver.compose.json
*/
async _composeDrivers() {
delete this._appJson.drivers;
let drivers = await this._getFiles( path.join( this._appPath, 'drivers') );
drivers.sort();
for( let i = 0; i < drivers.length; i++ ) {
let driverId = drivers[i];
if( driverId.indexOf('.') === 0) continue;
// merge json
let driverJson = await this._getJsonFile( path.join(this._appPath, 'drivers', driverId, 'driver.compose.json') );
if( driverJson.$extends ) {
if( !Array.isArray(driverJson.$extends) )
driverJson.$extends = [ driverJson.$extends ];
let templates = await this._getJsonFiles( path.join( this._appPathCompose, 'drivers', 'templates' ) );
let templateJson = {};
for( let j = 0; j < driverJson.$extends.length; j++ ) {
let templateId = driverJson.$extends[j];
templateJson = {
...templateJson,
...templates[templateId],
}
}
driverJson = {
...templateJson,
...driverJson,
}
}
driverJson.id = driverId;
function extendSetting(settingsTemplates, settingObj) {
if (settingObj.type === 'group') {
for (let childSettingId in settingObj.children) {
extendSetting(settingsTemplates, settingObj.children[childSettingId]);
}
} else if (settingObj.$extends) {
let templateIds = [].concat(settingObj.$extends);
let settingTemplate = {};
let templateId;
for (let i in templateIds) {
templateId = templateIds[i];
if (!settingsTemplates.hasOwnProperty(templateId)) {
throw new Error(`Invalid driver setting template for driver ${driverId}: ${templateId}`);
}
settingTemplate = Object.assign(settingTemplate, settingsTemplates[templateId]);
}
Object.assign(settingObj, {
id: settingObj.$id || templateId,
...settingTemplate,
...settingObj,
});
}
}
// merge settings
try {
driverJson.settings = await this._getJsonFile( path.join(this._appPath, 'drivers', driverId, 'driver.settings.compose.json') );
} catch( err ) {
if( err.code !== 'ENOENT' ) throw new Error(err);
}
// merge template settings
try {
let settingsTemplates = await this._getJsonFiles( path.join( this._appPathCompose, 'drivers', 'settings' ) );
if (Array.isArray(driverJson.settings)) {
for (let settingId in driverJson.settings) {
extendSetting(settingsTemplates, driverJson.settings[settingId]);
}
}
} catch (err) {
if( err.code !== 'ENOENT' ) throw new Error(err);
}
// merge pair
if( Array.isArray(driverJson.pair) ) {
let appPairPath = path.join(this._appPath, 'drivers', driverId, 'pair');
let composePairPath = path.join(this._appPathCompose, 'drivers', 'pair');
let composePairViews = await this._getFiles( composePairPath );
composePairViews = composePairViews.filter(view => {
return view.indexOf('.') !== 0;
})
for( let j = 0; j < driverJson.pair.length; j++ ) {
let driverPairView = driverJson.pair[j];
if( driverPairView.$template ) {
let viewId = driverPairView.id;
let templateId = driverPairView.$template;
if( !composePairViews.includes(templateId) )
throw new Error(`Invalid pair template for driver ${driverId}: ${templateId}`);
if( !viewId || typeof viewId !== 'string' )
throw new Error(`Invalid pair template "id" property for driver ${driverId}: ${templateId}`);
await fse.ensureDir(appPairPath);
// copy html
let html = await readFileAsync( path.join(composePairPath, templateId, 'index.html') );
html = html.toString();
html = html.replace(/{{assets}}/g, `${viewId}.assets`);
await writeFileAsync( path.join(appPairPath, `${viewId}.html`), html );
// copy assets
let composePairAssetsPath = path.join(composePairPath, templateId, 'assets');
if( await fse.exists( composePairAssetsPath ) ) {
await fse.copy( composePairAssetsPath, path.join(appPairPath, `${viewId}.assets`) );
}
}
// set pair options
if( driverJson.$pairOptions ) {
for( let viewId in driverJson.$pairOptions ) {
let options = driverJson.$pairOptions[viewId];
let view = _.findWhere(driverJson.pair, { id: viewId });
if( view ) {
view.options = view.options || {};
Object.assign(view.options, options);
}
}
}
}
}
// merge flow
try {
driverJson.$flow = await this._getJsonFile( path.join(this._appPath, 'drivers', driverId, 'driver.flow.compose.json') );
} catch( err ) {
if( err.code !== 'ENOENT' ) throw new Error(err);
}
// replace properties
let zwaveParameterIndex = null;
function replaceSpecialPropertiesRecursive( obj ) {
if( typeof obj !== 'object' || obj === null ) return obj;
// store last found zwave parameter index
if ( obj.hasOwnProperty('zwave') && obj.zwave.hasOwnProperty('index') ) {
zwaveParameterIndex = obj.zwave.index;
}
for( let key in obj ) {
if( typeof obj[key] === 'object' ) {
obj[key] = replaceSpecialPropertiesRecursive(obj[key]);
}
if( typeof obj[key] === 'string' ) {
obj[key] = obj[key].replace(/{{driverId}}/g, driverId);
try {
obj[key] = obj[key].replace(/{{driverName}}/g, driverJson.name.en);
HomeyLib.App.getLocales().forEach(locale => {
obj[key] = obj[key].replace(new RegExp(`{{driverName${locale.charAt(0).toUpperCase()}${locale.charAt(1).toLowerCase()}}}`, 'g'), driverJson.name[locale] || driverJson.name.en);
});
} catch( err ) {
throw new Error(`Missing property \`name\` in driver ${driverId}`);
}
obj[key] = obj[key].replace(/{{driverPath}}/g, `/drivers/${driverId}`);
obj[key] = obj[key].replace(/{{driverAssetsPath}}/g, `/drivers/${driverId}/assets`);
if (zwaveParameterIndex) {
obj[key] = obj[key].replace(/{{zwaveParameterIndex}}/g, zwaveParameterIndex);
}
}
}
return obj;
}
// get drivers flow templates
let flowTemplates = {};
try {
for (let i = 0; i < FLOW_TYPES.length; i++) {
let type = FLOW_TYPES[i];
let typePath = path.join(this._appPathCompose, 'drivers', 'flow', type);
flowTemplates[type] = await this._getJsonFiles(typePath);
}
} catch (err) {
if( err.code !== 'ENOENT' ) throw new Error(err);
}
if(typeof driverJson.$flow === 'object' && !Array.isArray(driverJson.$flow)){
for( let i = 0; i < FLOW_TYPES.length; i++ ) {
let type = FLOW_TYPES[i];
let cards = driverJson.$flow[ type ];
if( !cards ) continue;
for( let i = 0; i < cards.length; i++ ) {
let card = cards[i];
// extend card if possible
if (card.$extends) {
let templateIds = [].concat(card.$extends);
let templateCards = flowTemplates[type];
let flowTemplate = {};
let templateId;
for (let i in templateIds) {
templateId = templateIds[i];
if (!templateCards.hasOwnProperty(templateId)) {
throw new Error(`Invalid driver flow template for driver ${driverId}: ${templateId}`);
}
flowTemplate = Object.assign(flowTemplate, templateCards[templateId]);
}
// assign template to original flow object
Object.assign(card, {
id: card.$id || templateId,
...flowTemplate,
...card
});
}
const filter = card.$filter || '';
card.args = card.args || [];
card.args.unshift({
type: 'device',
name: card.$deviceName || 'device',
filter: `driver_id=${driverId}` + (filter ? `&${filter}` : ''),
})
await this._addFlowCard({
type,
card,
});
}
}
}
driverJson = replaceSpecialPropertiesRecursive(driverJson);
// add driver to app.json
this._appJson.drivers = this._appJson.drivers || [];
this._appJson.drivers.push(driverJson);
this.log(`Added Driver \`${driverId}\``)
}
}
/*
Find signals in /compose/signals/:frequency/:id
*/
async _composeSignals() {
delete this._appJson.signals;
let frequencies = [ '433', '868', 'ir' ];
for( let i = 0; i < frequencies.length; i++ ) {
let frequency = frequencies[i];
let signals = await this._getJsonFiles( path.join( this._appPathCompose, 'signals', frequency ) );
for( let signalId in signals ) {
let signal = signals[signalId];
signalId = signal.$id || path.basename( signalId, '.json' );
this._appJson.signals = this._appJson.signals || {};
this._appJson.signals[ frequency ] = this._appJson.signals[ frequency ] || {};
this._appJson.signals[ frequency ][ signalId ] = signal;
this.log(`Added Signal \`${signalId}\` for frequency \`${frequency}\``)
}
}
}
/*
Find flow cards in /compose/flow/:type/:id
*/
async _composeFlow() {
delete this._appJson.flow;
for( let i = 0; i < FLOW_TYPES.length; i++ ) {
let type = FLOW_TYPES[i];
let typePath = path.join( this._appPathCompose, 'flow', type );
let cards = await this._getJsonFiles( typePath );
for( let cardId in cards ) {
let card = cards[cardId];
await this._addFlowCard({
type,
card,
id: path.basename( cardId, '.json' )
});
}
}
}
async _addFlowCard({ type, card, id }) {
let cardId = card.$id || card.id || id;
card.id = cardId;
this._appJson.flow = this._appJson.flow || {};
this._appJson.flow[ type ] = this._appJson.flow[ type ] || [];
this._appJson.flow[ type ].push( card );
this.log(`Added FlowCard \`${cardId}\` for type \`${type}\``)
}
async _composeScreensavers() {
delete this._appJson.screensavers;
let screensavers = await this._getJsonFiles( path.join(this._appPathCompose, 'screensavers') );
for( let screensaverId in screensavers ) {
let screensaver = screensavers[screensaverId];
screensaver.name = screensaver.$name || screensaver.name || screensaverId;
this._appJson.screensavers = this._appJson.screensavers || [];
this._appJson.screensavers.push(screensaver);
this.log(`Added Screensaver \`${screensaver.name}\``)
}
}
async _composeCapabilities() {
delete this._appJson.capabilities;
let capabilities = await this._getJsonFiles( path.join(this._appPathCompose, 'capabilities') );
for( let capabilityId in capabilities ) {
let capability = capabilities[capabilityId];
capabilityId = capability.$id || capabilityId;
this._appJson.capabilities = this._appJson.capabilities || {};
this._appJson.capabilities[ capabilityId ] = capability;
this.log(`Added Capability \`${capabilityId}\``)
}
}
async _composeDiscovery() {
delete this._appJson.discovery;
let strategies = await this._getJsonFiles( path.join(this._appPathCompose, 'discovery') );
for( let strategyId in strategies ) {
let strategy = strategies[strategyId];
this._appJson.discovery = this._appJson.discovery || {};
this._appJson.discovery[ strategyId ] = strategy;
this.log(`Added Discovery Strategy \`${strategyId}\``)
}
}
/*
Merge locales (deep merge). They are merged from long to small filename.
Example files:
/.homeycompose/locales/en.json
/.homeycompose/locales/en.foo.json (will be placed under property `foo`)
/.homeycompose/locales/en.foo.bar.json (will be placed under property `foo.bar`)
*/
async _composeLocales() {
let appLocalesPath = path.join(this._appPath, 'locales');
let appLocales = await this._getJsonFiles( appLocalesPath );
let appLocalesChanged = [];
let appComposeLocalesPath = path.join(this._appPathCompose, 'locales');
let appComposeLocales = await this._getJsonFiles( appComposeLocalesPath );
// sort locales to merge the longest paths first
const sortedAppComposeLocaleIds = Object.keys(appComposeLocales)
.sort((a, b) => b.split('.').length - a.split('.').length);
for( let appComposeLocaleId of sortedAppComposeLocaleIds) {
let appComposeLocale = appComposeLocales[appComposeLocaleId];
let appComposeLocaleIdArray = path.basename( appComposeLocaleId, '.json').split('.');
let appComposeLocaleLanguage = appComposeLocaleIdArray.shift();
appLocales[appComposeLocaleLanguage] = appLocales[appComposeLocaleLanguage] || {};
if( appComposeLocaleIdArray.length === 0 ) {
appLocales[appComposeLocaleLanguage] = deepmerge( appLocales[appComposeLocaleLanguage], appComposeLocale );
} else {
let value = objectPath.get( appLocales[appComposeLocaleLanguage], appComposeLocaleIdArray ) || {};
objectPath.set( appLocales[appComposeLocaleLanguage], appComposeLocaleIdArray, deepmerge( value, appComposeLocale ) );
}
if( !appLocalesChanged.includes(appComposeLocaleLanguage) ) {
appLocalesChanged.push(appComposeLocaleLanguage)
}
}
for( let i = 0; i < appLocalesChanged.length; i++ ) {
let appLocaleId = appLocalesChanged[i];
let appLocale = appLocales[appLocaleId];
await writeFileAsync( path.join(appLocalesPath, `${appLocaleId}.json`), JSON.stringify(appLocale, false, this._options.appJsonSpace || 2) )
this.log(`Added Locale \`${appLocaleId}\``)
}
}
async _saveAppJson() {
function removeDollarPropertiesRecursive( obj ) {
if( typeof obj !== 'object' ) return obj;
for( let key in obj ) {
if( key.indexOf('$') === 0 ) {
delete obj[key];
} else {
obj[key] = removeDollarPropertiesRecursive(obj[key]);
}
}
return obj;
}
let json = JSON.parse(JSON.stringify(this._appJson));
json = removeDollarPropertiesRecursive(json);
await writeFileAsync( this._appJsonPath, JSON.stringify(json, false, this._options.appJsonSpace || 2) );
}
}
module.exports = AppPluginCompose;