homey
Version:
Command-line interface and type declarations for Homey Apps
1,183 lines (998 loc) • 42.5 kB
JavaScript
'use strict';
/*
Homey Compose 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.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)
/drivers/<id>/driver.pair.compose.json (object with pair views)
/drivers/<id>/driver.repair.compose.json (object with repair views)
/.homeycompose/locales/en.json
/.homeycompose/locales/en.foo.json
*/
const fs = require('fs');
const path = require('path');
const util = require('util');
const url = require('url');
const fse = require('fs-extra');
const deepmerge = require('deepmerge');
const objectPath = require('object-path');
const HomeyLib = require('homey-lib');
const Log = require('./Log');
const readFileAsync = util.promisify(fs.readFile);
const writeFileAsync = util.promisify(fs.writeFile);
const readdirAsync = util.promisify(fs.readdir);
const mkdirAsync = util.promisify(fs.mkdir);
const deepClone = (object) => JSON.parse(JSON.stringify(object));
const FLOW_TYPES = ['triggers', 'conditions', 'actions'];
class HomeyCompose {
// Temporary simpler api
static async build({ appPath, usesModules }) {
const compose = new HomeyCompose(appPath, usesModules);
await compose.run();
}
constructor(appPath, usesModules) {
this._appPath = appPath;
this._usesModules = usesModules;
}
// Create homey compose directory structure
static async createComposeDirectories({ appPath }) {
const dirs = [
'.homeycompose',
path.join('.homeycompose', 'flow'),
path.join('.homeycompose', 'flow', 'triggers'),
path.join('.homeycompose', 'flow', 'conditions'),
path.join('.homeycompose', 'flow', 'actions'),
path.join('.homeycompose', 'drivers'),
path.join('.homeycompose', 'drivers', 'templates'),
path.join('.homeycompose', 'drivers', 'settings'),
path.join('.homeycompose', 'capabilities'),
path.join('.homeycompose', 'discovery'),
path.join('.homeycompose', 'locales'),
path.join('.homeycompose', 'screensavers'),
path.join('.homeycompose', 'signals'),
path.join('.homeycompose', 'signals', '433'),
path.join('.homeycompose', 'signals', '868'),
path.join('.homeycompose', 'signals', 'ir'),
];
for (let i = 0; i < dirs.length; i++) {
const dir = dirs[i];
try {
await mkdirAsync(path.join(appPath, dir));
} catch (err) {
Log(err);
}
}
}
async run() {
this._appPathCompose = path.join(this._appPath, '.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 {
const appJSON = await this._getJsonFile(this._appJsonPathCompose);
this._appJson = {
_comment: 'This file is generated. Please edit .homeycompose/app.json instead.',
...appJSON,
};
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(err);
}
if (this._usesModules) {
this._appJson.esm = true;
}
await this._composeFlow();
await this._composeDrivers();
await this._composeWidgets();
await this._composeCapabilities();
await this._composeDiscovery();
await this._composeSignals();
await this._composeScreensavers();
await this._composeLocales();
await this._saveAppJson();
}
extendSetting(settingsTemplates, settingObj) {
if (settingObj.type === 'group') {
for (const childSettingId of Object.keys(settingObj.children)) {
this.extendSetting(settingsTemplates, settingObj.children[childSettingId]);
}
} else if (settingObj.$extends) {
const templateIds = [].concat(settingObj.$extends);
let settingTemplate = {};
let templateId;
for (const i of Object.keys(templateIds)) {
templateId = templateIds[i];
if (!Object.prototype.hasOwnProperty.call(settingsTemplates, templateId)) {
throw new Error(`Invalid driver setting template for driver: ${templateId}`);
}
settingTemplate = Object.assign(settingTemplate, settingsTemplates[templateId]);
}
Object.assign(settingObj, {
id: settingObj.$id || templateId,
// We need to deep clone the settings template to make sure
// replaceSpecialPropertiesRecursive doesn't mutate references
// shared by multiple extended settings
...deepClone(settingTemplate),
...settingObj,
});
}
}
/*
Find drivers in /drivers/:id/driver.compose.json
*/
async _composeDrivers() {
delete this._appJson.drivers;
// use _getChildFolders to prevent any library or documentation files
// ending up in the driver list.
const drivers = await this._getChildFolders(path.join(this._appPath, 'drivers'));
drivers.sort();
for (let driverIndex = 0; driverIndex < drivers.length; driverIndex++) {
const driverId = drivers[driverIndex];
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];
}
const templates = await this._getJsonFiles(
path.join(this._appPathCompose, 'drivers', 'templates'),
);
let templateJson = {};
// Merge all templates in order to one big template
for (let j = 0; j < driverJson.$extends.length; j++) {
const templateId = driverJson.$extends[j];
templateJson = {
...templateJson,
...templates[templateId],
};
}
driverJson = {
...templateJson,
...driverJson,
// Merge capabilitiesOptions for each capability separately
capabilitiesOptions:
templateJson.capabilitiesOptions || driverJson.capabilitiesOptions
? {
...templateJson.capabilitiesOptions,
...driverJson.capabilitiesOptions,
}
: undefined,
};
}
driverJson.id = driverId;
// 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 {
const settingsTemplates = await this._getJsonFiles(
path.join(this._appPathCompose, 'drivers', 'settings'),
);
if (Array.isArray(driverJson.settings)) {
Object.values(driverJson.settings).forEach((setting) => {
this.extendSetting(settingsTemplates, setting);
});
}
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(err);
}
// merge pair
try {
driverJson.pair = await this._getJsonFile(
path.join(this._appPath, 'drivers', driverId, 'driver.pair.compose.json'),
);
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(err);
}
if (Array.isArray(driverJson.pair)) {
const appPairPath = path.join(this._appPath, 'drivers', driverId, 'pair');
const 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++) {
const driverPairView = driverJson.pair[j];
if (driverPairView.$template) {
const viewId = driverPairView.id;
const 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
const 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 (const [id, options] of Object.entries(driverJson.$pairOptions)) {
const view = driverJson.pair.find((candidate) => candidate.id === id);
if (view) {
view.options = view.options || {};
Object.assign(view.options, options);
}
}
}
}
}
// merge repair
try {
driverJson.repair = await this._getJsonFile(
path.join(this._appPath, 'drivers', driverId, 'driver.repair.compose.json'),
);
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(err);
}
if (Array.isArray(driverJson.repair)) {
const appRepairPath = path.join(this._appPath, 'drivers', driverId, 'repair');
const composeRepairPath = path.join(this._appPathCompose, 'drivers', 'repair');
let composeRepairViews = await this._getFiles(composeRepairPath);
composeRepairViews = composeRepairViews.filter((view) => {
return view.indexOf('.') !== 0;
});
for (let j = 0; j < driverJson.repair.length; j++) {
const driverRepairView = driverJson.repair[j];
if (driverRepairView.$template) {
const viewId = driverRepairView.id;
const templateId = driverRepairView.$template;
if (!composeRepairViews.includes(templateId)) {
throw new Error(`Invalid repair template for driver ${driverId}: ${templateId}`);
}
if (!viewId || typeof viewId !== 'string') {
throw new Error(
`Invalid repair template "id" property for driver ${driverId}: ${templateId}`,
);
}
await fse.ensureDir(appRepairPath);
// copy html
let html = await readFileAsync(path.join(composeRepairPath, templateId, 'index.html'));
html = html.toString();
html = html.replace(/{{assets}}/g, `${viewId}.assets`);
await writeFileAsync(path.join(appRepairPath, `${viewId}.html`), html);
// copy assets
const composeRepairAssetsPath = path.join(composeRepairPath, templateId, 'assets');
if (await fse.exists(composeRepairAssetsPath)) {
await fse.copy(composeRepairAssetsPath, path.join(appRepairPath, `${viewId}.assets`));
}
}
// set repair options
if (driverJson.$repairOptions) {
for (const [id, options] of Object.entries(driverJson.$repairOptions)) {
const view = driverJson.repair.find((candidate) => candidate.id === id);
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);
}
// get drivers flow templates
const flowTemplates = {};
try {
for (let i = 0; i < FLOW_TYPES.length; i++) {
const type = FLOW_TYPES[i];
const 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' &&
driverJson.$flow !== null &&
!Array.isArray(driverJson.$flow)
) {
for (let i = 0; i < FLOW_TYPES.length; i++) {
const type = FLOW_TYPES[i];
const cards = driverJson.$flow[type];
if (!cards) continue;
for (let j = 0; j < cards.length; j++) {
const card = cards[j];
// extend card if possible
if (card.$extends) {
const templateIds = [].concat(card.$extends);
const templateCards = flowTemplates[type];
let flowTemplate = {};
for (const templateId of templateIds) {
if (!templateCards[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 || templateIds[templateIds.length - 1],
...flowTemplate,
...card,
});
}
let filter = '';
if (typeof card.$filter === 'string') {
filter = card.$filter;
} else if (typeof card.$filter === 'object' && card.$filter !== null) {
filter = new url.URLSearchParams(card.$filter).toString();
}
card.args = card.args || [];
card.args.unshift({
type: 'device',
name: card.$deviceName || 'device',
filter: `driver_id=${driverId}${filter ? `&${filter}` : ''}`,
});
await this._addFlowCard({
type,
card,
});
}
}
}
// add driver to app.json
this._appJson.drivers = this._appJson.drivers || [];
this._appJson.drivers.push(driverJson);
Log.info(`Added Driver \`${driverId}\``);
}
}
/*
Find widgets in /widgets/:id/widget.compose.json
*/
async _composeWidgets() {
// dont delete merge with widgets that dont have compose
// delete this._appJson.widgets;
// use _getChildFolders to prevent any library or documentation files
// ending up in the driver list.
const widgets = await this._getChildFolders(path.join(this._appPath, 'widgets'));
for (let widgetIndex = 0; widgetIndex < widgets.length; widgetIndex++) {
const widgetId = widgets[widgetIndex];
if (widgetId.indexOf('.') === 0) continue;
// merge json
const widgetJson = await this._getJsonFile(
path.join(this._appPath, 'widgets', widgetId, 'widget.compose.json'),
).catch((err) => {
if (err.code !== 'ENOENT') throw new Error(err);
return null;
});
if (widgetJson == null) {
continue;
}
widgetJson.id = widgetId;
if (widgetJson.settings == null) {
widgetJson.settings = [];
}
this._appJson.widgets = this._appJson.widgets || {};
this._appJson.widgets[widgetJson.id] = widgetJson;
Log.info(`Added Widget \`${widgetId}\``);
}
}
replaceSpecialPropertiesRecursive(obj, driverId, driverJson, zwaveParameterIndex) {
if (typeof obj !== 'object' || obj === null) return obj;
// store last found zwave parameter index
if (
Object.prototype.hasOwnProperty.call(obj, 'zwave') &&
Object.prototype.hasOwnProperty.call(obj.zwave, 'index')
) {
zwaveParameterIndex = obj.zwave.index;
}
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'string') {
obj[key] = obj[key].replace(/{{driverId}}/g, driverId);
try {
obj[key] = obj[key].replace(/{{driverName}}/g, driverJson.name.en);
for (const locale of HomeyLib.App.getLocales()) {
const replacement = driverJson.name[locale] || driverJson.name.en;
obj[key] = obj[key].replace(
new RegExp(
`{{driverName${locale.charAt(0).toUpperCase()}${locale.charAt(1).toLowerCase()}}}`,
'g',
),
replacement,
);
}
} 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);
}
} else {
obj[key] = this.replaceSpecialPropertiesRecursive(
obj[key],
driverId,
driverJson,
zwaveParameterIndex,
);
}
}
return obj;
}
/*
Find signals in /compose/signals/:frequency/:id
*/
async _composeSignals() {
delete this._appJson.signals;
const frequencies = ['433', '868', 'ir'];
for (let i = 0; i < frequencies.length; i++) {
const frequency = frequencies[i];
const signals = await this._getJsonFiles(
path.join(this._appPathCompose, 'signals', frequency),
);
for (const [_signalId, signal] of Object.entries(signals)) {
const 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;
Log.info(`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++) {
const type = FLOW_TYPES[i];
const typePath = path.join(this._appPathCompose, 'flow', type);
const cards = await this._getJsonFiles(typePath);
for (const [cardId, card] of Object.entries(cards)) {
await this._addFlowCard({
type,
card,
id: path.basename(cardId, '.json'),
});
}
}
}
async _addFlowCard({ type, card, id }) {
const 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);
Log.info(`Added FlowCard \`${cardId}\` for type \`${type}\``);
}
async _composeScreensavers() {
delete this._appJson.screensavers;
const screensavers = await this._getJsonFiles(path.join(this._appPathCompose, 'screensavers'));
for (const [screensaverId, screensaver] of Object.entries(screensavers)) {
screensaver.name = screensaver.$name || screensaver.name || screensaverId;
this._appJson.screensavers = this._appJson.screensavers || [];
this._appJson.screensavers.push(screensaver);
Log.info(`Added Screensaver \`${screensaver.name}\``);
}
}
async _composeCapabilities() {
delete this._appJson.capabilities;
const capabilities = await this._getJsonFiles(path.join(this._appPathCompose, 'capabilities'));
for (const [_capabilityId, capability] of Object.entries(capabilities)) {
const capabilityId = capability.$id || _capabilityId;
this._appJson.capabilities = this._appJson.capabilities || {};
this._appJson.capabilities[capabilityId] = capability;
Log.info(`Added Capability \`${capabilityId}\``);
}
}
async _composeDiscovery() {
delete this._appJson.discovery;
const strategies = await this._getJsonFiles(path.join(this._appPathCompose, 'discovery'));
for (const [strategyId, strategy] of Object.entries(strategies)) {
this._appJson.discovery = this._appJson.discovery || {};
this._appJson.discovery[strategyId] = strategy;
Log.info(`Added Discovery Strategy \`${strategyId}\``);
}
}
/*
Merge locales (deep merge). They are merged from long to small filename.
Example files:
/.homeycompose/locales/en.json (can contain any property, and $app, $drivers, $flow, $widgets)
/.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() {
const appLocalesPath = path.join(this._appPath, 'locales');
const appLocales = await this._getJsonFiles(appLocalesPath);
const appLocalesChanged = [];
const appComposeLocalesPath = path.join(this._appPathCompose, 'locales');
const 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 (const appComposeLocaleId of sortedAppComposeLocaleIds) {
const appComposeLocale = appComposeLocales[appComposeLocaleId];
const appComposeLocaleIdArray = path.basename(appComposeLocaleId, '.json').split('.');
const appComposeLocaleLanguage = appComposeLocaleIdArray.shift();
appLocales[appComposeLocaleLanguage] = appLocales[appComposeLocaleLanguage] || {};
if (appComposeLocaleIdArray.length === 0) {
appLocales[appComposeLocaleLanguage] = deepmerge(
appLocales[appComposeLocaleLanguage],
appComposeLocale,
);
} else {
const value = objectPath.get(appLocales[appComposeLocaleLanguage], appComposeLocaleIdArray);
objectPath.set(
appLocales[appComposeLocaleLanguage],
appComposeLocaleIdArray,
deepmerge(value || {}, appComposeLocale),
);
}
if (!appLocalesChanged.includes(appComposeLocaleLanguage)) {
appLocalesChanged.push(appComposeLocaleLanguage);
}
}
// Merge $drivers, $flow, $capabilities, $widgets into /app.json
for (let i = 0; i < appLocalesChanged.length; i++) {
const appLocaleId = appLocalesChanged[i];
const appLocale = appLocales[appLocaleId];
// App
if (appLocale.$app) {
// App.name
if (appLocale.$app.name) {
this._appJson.name = this._appJson.name ?? {};
this._appJson.name[appLocaleId] = appLocale.$app.name;
}
// App.description
if (appLocale.$app.description) {
this._appJson.description = this._appJson.description ?? {};
this._appJson.description[appLocaleId] = appLocale.$app.description;
}
delete appLocale.$app;
}
// Capabilities
if (appLocale.$capabilities) {
for (const [capabilityId, capability] of Object.entries(appLocale.$capabilities)) {
if (!this._appJson.capabilities?.[capabilityId]) continue;
// Capability.title
if (capability.title) {
this._appJson.capabilities[capabilityId].title =
this._appJson.capabilities[capabilityId].title ?? {};
this._appJson.capabilities[capabilityId].title[appLocaleId] = capability.title;
}
// Capability.units
if (capability.units) {
this._appJson.capabilities[capabilityId].units =
this._appJson.capabilities[capabilityId].units ?? {};
this._appJson.capabilities[capabilityId].units[appLocaleId] = capability.units;
}
}
delete appLocale.$capabilities;
}
// Drivers
if (appLocale.$drivers) {
for (const [driverId, driver] of Object.entries(appLocale.$drivers)) {
const appJsonDriver = this._appJson.drivers.find((driver) => driver.id === driverId);
if (!appJsonDriver) continue;
// Driver.name
if (driver.name) {
appJsonDriver.name = appJsonDriver.name ?? {};
appJsonDriver.name[appLocaleId] = driver.name;
}
// Driver.capabilitiesOptions
if (driver.capabilitiesOptions) {
appJsonDriver.capabilitiesOptions = appJsonDriver.capabilitiesOptions ?? {};
for (const [capabilityId, capabilityOptions] of Object.entries(
driver.capabilitiesOptions,
)) {
// Driver.capabilitiesOptions[<capabilityId>].title
if (capabilityOptions.title) {
appJsonDriver.capabilitiesOptions[capabilityId] =
appJsonDriver.capabilitiesOptions[capabilityId] ?? {};
appJsonDriver.capabilitiesOptions[capabilityId].title =
appJsonDriver.capabilitiesOptions[capabilityId].title ?? {};
appJsonDriver.capabilitiesOptions[capabilityId].title[appLocaleId] =
capabilityOptions.title;
}
// Driver.capabilitiesOptions[<capabilityId>].units
if (capabilityOptions.units) {
appJsonDriver.capabilitiesOptions[capabilityId] =
appJsonDriver.capabilitiesOptions[capabilityId] ?? {};
appJsonDriver.capabilitiesOptions[capabilityId].units =
appJsonDriver.capabilitiesOptions[capabilityId].units ?? {};
appJsonDriver.capabilitiesOptions[capabilityId].units[appLocaleId] =
capabilityOptions.units;
}
}
}
// Driver.pair
// Driver.repair
['pair', 'repair'].forEach((pairType) => {
if (driver[pairType]) {
for (const [viewId, view] of Object.entries(driver[pairType])) {
const appJsonDriverView = appJsonDriver[pairType].find(
(view) => view.id === viewId,
);
if (!appJsonDriverView) continue;
if (view.options) {
for (const [key, value] of Object.entries(view.options)) {
appJsonDriverView.options = appJsonDriverView.options ?? {};
appJsonDriverView.options[key] = appJsonDriverView.options[key] ?? {};
appJsonDriverView.options[key][appLocaleId] = value;
}
}
}
}
});
// Driver.settings
if (driver.settings) {
// Flatten settings
const appJsonDriverSettingsFlat = appJsonDriver.settings.reduce((acc, setting) => {
acc[setting.id] = setting;
if (setting.children) {
setting.children.forEach((child) => {
acc[child.id] = child;
});
}
return acc;
}, {});
// Driver.settings
for (const [settingId, setting] of Object.entries(driver.settings)) {
const appJsonDriverSetting = appJsonDriverSettingsFlat[settingId];
if (!appJsonDriverSetting) continue;
// Driver.settings[].label
if (setting.label) {
appJsonDriverSetting.label = appJsonDriverSetting.label ?? {};
appJsonDriverSetting.label[appLocaleId] = setting.label;
}
// Driver.settings[].hint
if (setting.hint) {
appJsonDriverSetting.hint = appJsonDriverSetting.hint ?? {};
appJsonDriverSetting.hint[appLocaleId] = setting.hint;
}
// Driver.settings[].units
if (setting.units) {
appJsonDriverSetting.units = appJsonDriverSetting.units ?? {};
appJsonDriverSetting.units[appLocaleId] = setting.units;
}
// Driver.settings[].values
if (setting.values) {
for (const [valueId, value] of Object.entries(setting.values)) {
const appJsonDriverSettingValue = appJsonDriverSetting.values.find(
(value) => value.id === valueId,
);
if (!appJsonDriverSettingValue) continue;
// Driver.settings[].values[].label
if (value.label) {
appJsonDriverSettingValue.label = appJsonDriverSettingValue.label ?? {};
appJsonDriverSettingValue.label[appLocaleId] = value.label;
}
}
}
}
// Driver.zwave
if (driver.zwave) {
appJsonDriver.zwave = appJsonDriver.zwave ?? {};
if (driver.zwave.learnmode) {
appJsonDriver.zwave.learnmode = appJsonDriver.zwave.learnmode ?? {};
// Driver.zwave.learnmode.instruction
if (driver.zwave.learnmode?.instruction) {
appJsonDriver.zwave.learnmode.instruction[appLocaleId] =
driver.zwave.learnmode.instruction;
}
}
// Driver.zwave.associationGroupsOptions
if (driver.zwave.associationGroupsOptions) {
appJsonDriver.zwave.associationGroupsOptions =
appJsonDriver.zwave.associationGroupsOptions ?? {};
for (const [associationGroupId, associationGroup] of Object.entries(
driver.zwave.associationGroupsOptions,
)) {
appJsonDriver.zwave.associationGroupsOptions[associationGroupId] =
appJsonDriver.zwave.associationGroupsOptions[associationGroupId] ?? {};
// Driver.zwave.associationGroupsOptions[].hint
if (associationGroup.hint) {
appJsonDriver.zwave.associationGroupsOptions[associationGroupId].hint =
appJsonDriver.zwave.associationGroupsOptions[associationGroupId].hint ?? {};
appJsonDriver.zwave.associationGroupsOptions[associationGroupId].hint[
appLocaleId
] = associationGroup.hint;
}
}
}
// Driver.zwave.multiChannelNodes
if (driver.zwave.multiChannelNodes) {
appJsonDriver.zwave.multiChannelNodes = appJsonDriver.zwave.multiChannelNodes ?? {};
for (const [multiChannelNodeId, multiChannelNode] of Object.entries(
driver.zwave.multiChannelNodes,
)) {
appJsonDriver.zwave.multiChannelNodes[multiChannelNodeId] =
appJsonDriver.zwave.multiChannelNodes[multiChannelNodeId] ?? {};
// Driver.zwave.multiChannelNodes[].name
if (multiChannelNode.name) {
appJsonDriver.zwave.multiChannelNodes[multiChannelNodeId].name =
appJsonDriver.zwave.multiChannelNodes[multiChannelNodeId].name ?? {};
appJsonDriver.zwave.multiChannelNodes[multiChannelNodeId].name[appLocaleId] =
multiChannelNode.name;
}
}
}
}
// Driver.zigbee
if (driver.zigbee) {
appJsonDriver.zigbee = appJsonDriver.zigbee ?? {};
if (driver.zigbee.learnmode) {
appJsonDriver.zigbee.learnmode = appJsonDriver.zigbee.learnmode ?? {};
// Driver.zigbee.learnmode.instruction
if (driver.zigbee.learnmode?.instruction) {
appJsonDriver.zigbee.learnmode.instruction[appLocaleId] =
driver.zigbee.learnmode.instruction;
}
}
}
// Driver.matter
if (driver.matter) {
appJsonDriver.matter = appJsonDriver.matter ?? {};
if (driver.matter.learnmode) {
appJsonDriver.matter.learnmode = appJsonDriver.matter.learnmode ?? {};
// Driver.matter.learnmode.instruction
if (driver.matter.learnmode?.instruction) {
appJsonDriver.matter.learnmode.instruction[appLocaleId] =
driver.matter.learnmode.instruction;
}
}
}
}
}
delete appLocale.$drivers;
}
// Flow
if (appLocale.$flow) {
['triggers', 'conditions', 'actions'].forEach((flowType) => {
if (!appLocale.$flow[flowType]) return;
for (const [cardId, card] of Object.entries(appLocale.$flow[flowType])) {
const appJsonFlowCard = this._appJson.flow?.[flowType]?.find(
(card) => card.id === cardId,
);
if (!appJsonFlowCard) continue;
// Card.title
if (card.title) {
appJsonFlowCard.title = appJsonFlowCard.title ?? {};
appJsonFlowCard.title[appLocaleId] = card.title;
}
// Card.titleFormatted
if (card.titleFormatted) {
appJsonFlowCard.titleFormatted = appJsonFlowCard.titleFormatted ?? {};
appJsonFlowCard.titleFormatted[appLocaleId] = card.titleFormatted;
}
// Card.hint
if (card.hint) {
appJsonFlowCard.hint = appJsonFlowCard.hint ?? {};
appJsonFlowCard.hint[appLocaleId] = card.hint;
}
// Card.args
if (card.args) {
for (const [argId, arg] of Object.entries(card.args)) {
const appJsonFlowCardArg = appJsonFlowCard.args.find((arg) => arg.name === argId);
if (!appJsonFlowCardArg) continue;
// Card.args[].title
if (arg.title) {
appJsonFlowCardArg.title = appJsonFlowCardArg.title ?? {};
appJsonFlowCardArg.title[appLocaleId] = arg.title;
}
// Card.args[].label
if (arg.label) {
appJsonFlowCardArg.label = appJsonFlowCardArg.label ?? {};
appJsonFlowCardArg.label[appLocaleId] = arg.label;
}
// Card.args[].placeholder
if (arg.placeholder) {
appJsonFlowCardArg.placeholder = appJsonFlowCardArg.placeholder ?? {};
appJsonFlowCardArg.placeholder[appLocaleId] = arg.placeholder;
}
// Card.args[].values
if (arg.values) {
for (const [valueId, value] of Object.entries(arg.values)) {
const appJsonFlowCardArgValue = appJsonFlowCardArg.values.find(
(value) => value.id === valueId,
);
if (!appJsonFlowCardArgValue) continue;
// Card.args[].values[].title
if (value.title) {
appJsonFlowCardArgValue.title = appJsonFlowCardArgValue.title ?? {};
appJsonFlowCardArgValue.title[appLocaleId] = value.title;
}
}
}
}
}
// Card.tokens
if (card.tokens) {
for (const [tokenId, token] of Object.entries(card.tokens)) {
const appJsonFlowCardToken = appJsonFlowCard.tokens.find(
(token) => token.name === tokenId,
);
if (!appJsonFlowCardToken) continue;
// Card.tokens[].title
if (token.title) {
appJsonFlowCardToken.title = appJsonFlowCardToken.title ?? {};
appJsonFlowCardToken.title[appLocaleId] = token.title;
}
// Card.tokens[].example
if (token.example) {
appJsonFlowCardToken.example = appJsonFlowCardToken.example ?? {};
appJsonFlowCardToken.example[appLocaleId] = token.example;
}
}
}
}
});
delete appLocale.$flow;
}
// Widgets
if (appLocale.$widgets) {
for (const [widgetId, widget] of Object.entries(appLocale.$widgets)) {
const appJsonWidget = this._appJson.widgets?.[widgetId];
if (!appJsonWidget) continue;
// Widget.name
if (widget.name) {
appJsonWidget.name = appJsonWidget.name ?? {};
appJsonWidget.name[appLocaleId] = widget.name;
}
// Widget.settings
if (widget.settings) {
for (const [settingId, setting] of Object.entries(widget.settings)) {
const appJsonWidgetSetting = appJsonWidget.settings.find(
(setting) => setting.id === settingId,
);
if (!appJsonWidgetSetting) continue;
// Widget.settings[<settingId>].title
if (setting.title) {
appJsonWidgetSetting.title = appJsonWidgetSetting.title ?? {};
appJsonWidgetSetting.title[appLocaleId] = setting.title;
}
// Widget.settings[<settingId>].placeholder
if (setting.placeholder) {
appJsonWidgetSetting.placeholder = appJsonWidgetSetting.placeholder ?? {};
appJsonWidgetSetting.placeholder[appLocaleId] = setting.placeholder;
}
// Widget.settings[<settingId>].values
if (setting.values) {
for (const [valueId, value] of Object.entries(setting.values)) {
const appJsonWidgetSettingValue = appJsonWidgetSetting.values.find(
(value) => value.id === valueId,
);
if (!appJsonWidgetSettingValue) continue;
// Widget.settings[<settingId>].values[<valueId>].title
if (value.title) {
appJsonWidgetSettingValue.title = appJsonWidgetSettingValue.title ?? {};
appJsonWidgetSettingValue.title[appLocaleId] = value.title;
}
}
}
}
}
}
}
delete appLocale.$widgets;
}
// Replace special properties in drivers
if (Array.isArray(this._appJson.drivers)) {
for (let i = 0; i < this._appJson.drivers.length; i++) {
const driver = this._appJson.drivers[i];
this.replaceSpecialPropertiesRecursive(driver, driver.id, driver);
}
}
// Write app locales
for (let i = 0; i < appLocalesChanged.length; i++) {
const appLocaleId = appLocalesChanged[i];
const appLocale = appLocales[appLocaleId];
await writeFileAsync(
path.join(appLocalesPath, `${appLocaleId}.json`),
JSON.stringify(appLocale, false, 2),
);
Log.info(`Added Locale \`${appLocaleId}\``);
}
}
async _saveAppJson() {
function removeDollarPropertiesRecursive(obj) {
if (typeof obj !== 'object' || obj === null) return obj;
for (const key of Object.keys(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, 2));
}
async _getChildFolders(rootPath) {
const childFolders = [];
try {
const pathContents = await readdirAsync(rootPath, { withFileTypes: true });
// Check all paths for dirs
Object.values(pathContents).forEach((pathConent) => {
if (pathConent.isDirectory()) {
childFolders.push(pathConent.name);
}
});
return childFolders;
} catch (err) {
return childFolders;
}
}
async _getFiles(filesPath) {
try {
const files = await readdirAsync(filesPath);
return files
.filter((file) => {
return file.indexOf('.') !== 0;
})
.sort((a, b) => {
a = path.basename(a, path.extname(a)).toLowerCase();
b = path.basename(b, path.extname(b)).toLowerCase();
return a.localeCompare(b);
});
} catch (err) {
return [];
}
}
async _getJsonFiles(filesPath) {
const result = {};
const files = await this._getFiles(filesPath);
for (let i = 0; i < files.length; i++) {
const filePath = files[i];
if (path.extname(filePath) !== '.json') continue;
const fileJson = await this._getJsonFile(path.join(filesPath, filePath));
const fileId = path.basename(filePath, '.json');
result[fileId] = fileJson;
}
return result;
}
async _getJsonFile(filePath) {
let fileJson = await readFileAsync(filePath);
try {
fileJson = JSON.parse(fileJson);
} catch (err) {
throw new Error(`Error in file ${filePath}\n${err.message}`);
}
return fileJson;
}
}
module.exports = HomeyCompose;