mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-09-01 16:48:31 +00:00
feat: Live loading of external JS (converters/extensions) (#24764)
* feat: Live loading of external JS (converters/extensions) * Fix imports * Improve error message on MQTT save * Handle non-existing base path * Throw on bad converter * Add tests * Fix use of ext conv in network map tests. * More coverage. * Dont mock zhc for basics, tests actual live loading * Update * feat: Live loading of external JS (converters/extensions) * Fix imports * Improve error message on MQTT save * Handle non-existing base path * Throw on bad converter * Add tests * Fix use of ext conv in network map tests. * More coverage. * Dont mock zhc for basics, tests actual live loading * Update * Fix rebase * Fix * Bump zhc * pretty * fix typing * Cleanup `external_converters` setting remnants. --------- Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
committed by
Koen Kanters
co-authored by
Koen Kanters
parent
cc61f67215
commit
9907005256
+4
-7
@@ -15,7 +15,7 @@ import ExtensionBind from './extension/bind';
|
||||
import ExtensionBridge from './extension/bridge';
|
||||
import ExtensionConfigure from './extension/configure';
|
||||
import ExtensionExternalConverters from './extension/externalConverters';
|
||||
import ExtensionExternalExtension from './extension/externalExtension';
|
||||
import ExtensionExternalExtensions from './extension/externalExtensions';
|
||||
// Extensions
|
||||
import ExtensionFrontend from './extension/frontend';
|
||||
import ExtensionGroups from './extension/groups';
|
||||
@@ -47,7 +47,7 @@ const AllExtensions = [
|
||||
ExtensionOTAUpdate,
|
||||
ExtensionExternalConverters,
|
||||
ExtensionFrontend,
|
||||
ExtensionExternalExtension,
|
||||
ExtensionExternalExtensions,
|
||||
ExtensionAvailability,
|
||||
];
|
||||
|
||||
@@ -106,7 +106,8 @@ export class Controller {
|
||||
new ExtensionGroups(...this.extensionArgs),
|
||||
new ExtensionBind(...this.extensionArgs),
|
||||
new ExtensionOTAUpdate(...this.extensionArgs),
|
||||
new ExtensionExternalExtension(...this.extensionArgs),
|
||||
new ExtensionExternalExtensions(...this.extensionArgs),
|
||||
new ExtensionExternalConverters(...this.extensionArgs),
|
||||
new ExtensionAvailability(...this.extensionArgs),
|
||||
];
|
||||
|
||||
@@ -114,10 +115,6 @@ export class Controller {
|
||||
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().external_converters.length) {
|
||||
this.extensions.push(new ExtensionExternalConverters(...this.extensionArgs));
|
||||
}
|
||||
|
||||
if (settings.get().homeassistant) {
|
||||
this.extensions.push(new ExtensionHomeAssistant(...this.extensionArgs));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
import type * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {addDefinition, removeExternalDefinitions} from 'zigbee-herdsman-converters';
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import {loadExternalConverter} from '../util/utils';
|
||||
import Extension from './extension';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
|
||||
export default class ExternalConverters extends Extension {
|
||||
type ModuleExports = zhc.Definition | zhc.Definition[];
|
||||
|
||||
export default class ExternalConverters extends ExternalJSExtension<ModuleExports> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
@@ -16,25 +18,51 @@ export default class ExternalConverters extends Extension {
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
super(
|
||||
zigbee,
|
||||
mqtt,
|
||||
state,
|
||||
publishEntityState,
|
||||
eventBus,
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
'converter',
|
||||
'external_converters',
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of settings.get().external_converters) {
|
||||
try {
|
||||
for (const definition of loadExternalConverter(file)) {
|
||||
zhc.addDefinition(definition);
|
||||
}
|
||||
logger.info(`Loaded external converter '${file}'`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter file '${file}' (${(error as Error).message})`);
|
||||
logger.error(
|
||||
`Probably there is a syntax error in the file or the external converter is not ` +
|
||||
`compatible with the current Zigbee2MQTT version`,
|
||||
);
|
||||
logger.error(
|
||||
`Note that external converters are not meant for long term usage, it's meant for local ` +
|
||||
`testing after which a pull request should be created to add out-of-the-box support for the device`,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
|
||||
try {
|
||||
removeExternalDefinitions(name);
|
||||
|
||||
for (const definition of this.getDefinitions(module)) {
|
||||
definition.externalConverterName = name;
|
||||
|
||||
addDefinition(definition);
|
||||
logger.info(`Loaded external converter '${name}'.`);
|
||||
}
|
||||
|
||||
await this.zigbee.resolveDevicesDefinitions(true);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load external converter '${name}'`);
|
||||
logger.error(`Check the code for syntax error and make sure it is up to date with the current Zigbee2MQTT version.`);
|
||||
logger.error(
|
||||
`External converters are not meant for long term usage, but for local testing after which a pull request should be created to add out-of-the-box support for the device`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getDefinitions(module: ModuleExports): zhc.Definition[] {
|
||||
return Array.isArray(module) ? module : [module];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import data from './../util/data';
|
||||
import logger from './../util/logger';
|
||||
import Extension from './extension';
|
||||
|
||||
const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/extension/(save|remove)`);
|
||||
|
||||
export default class ExternalExtension extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
|
||||
save: this.saveExtension,
|
||||
remove: this.removeExtension,
|
||||
};
|
||||
|
||||
override async start(): Promise<void> {
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.loadUserDefinedExtensions();
|
||||
await this.publishExtensions();
|
||||
}
|
||||
|
||||
private getExtensionsBasePath(): string {
|
||||
return data.joinPath('extension');
|
||||
}
|
||||
|
||||
private getListOfUserDefinedExtensions(): {name: string; code: string}[] {
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
if (fs.existsSync(basePath)) {
|
||||
return fs
|
||||
.readdirSync(basePath)
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((fileName) => {
|
||||
const extensionFilePath = path.join(basePath, fileName);
|
||||
return {name: fileName, code: fs.readFileSync(extensionFilePath, 'utf-8')};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async removeExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name} = message;
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
const extensionToBeRemoved = extensions.find((e) => e.name === name);
|
||||
|
||||
if (extensionToBeRemoved) {
|
||||
await this.enableDisableExtension(false, extensionToBeRemoved.name);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.unlinkSync(extensionFilePath);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} removed`);
|
||||
return utils.getResponse(message, {});
|
||||
} else {
|
||||
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async saveExtension(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name, code} = message;
|
||||
const ModuleConstructor = utils.loadModuleFromText(code, name) as typeof Extension;
|
||||
await this.loadExtension(ModuleConstructor);
|
||||
const basePath = this.getExtensionsBasePath();
|
||||
/* istanbul ignore else */
|
||||
if (!fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(basePath);
|
||||
}
|
||||
const extensionFilePath = path.join(basePath, path.basename(name));
|
||||
fs.writeFileSync(extensionFilePath, code);
|
||||
await this.publishExtensions();
|
||||
logger.info(`Extension ${name} loaded`);
|
||||
return utils.getResponse(message, {});
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(requestRegex);
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
|
||||
await this.mqtt.publish(`bridge/response/extension/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async loadExtension(ConstructorClass: typeof Extension): Promise<void> {
|
||||
await this.enableDisableExtension(false, ConstructorClass.name);
|
||||
// @ts-expect-error `ConstructorClass` is the interface, not the actual passed class
|
||||
await this.addExtension(new ConstructorClass(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, settings, logger));
|
||||
}
|
||||
|
||||
private async loadUserDefinedExtensions(): Promise<void> {
|
||||
for (const extension of this.getListOfUserDefinedExtensions()) {
|
||||
await this.loadExtension(utils.loadModuleFromText(extension.code, extension.name) as typeof Extension);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishExtensions(): Promise<void> {
|
||||
const extensions = this.getListOfUserDefinedExtensions();
|
||||
await this.mqtt.publish(
|
||||
'bridge/extensions',
|
||||
stringify(extensions),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
settings.get().mqtt.base_topic,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type Extension from './extension';
|
||||
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import ExternalJSExtension from './externalJS';
|
||||
|
||||
type ModuleExports = typeof Extension;
|
||||
|
||||
export default class ExternalExtensions extends ExternalJSExtension<ModuleExports> {
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
) {
|
||||
super(
|
||||
zigbee,
|
||||
mqtt,
|
||||
state,
|
||||
publishEntityState,
|
||||
eventBus,
|
||||
enableDisableExtension,
|
||||
restartCallback,
|
||||
addExtension,
|
||||
'extension',
|
||||
'external_extensions',
|
||||
);
|
||||
}
|
||||
|
||||
protected async removeJS(name: string, module: ModuleExports): Promise<void> {
|
||||
await this.enableDisableExtension(false, module.name);
|
||||
}
|
||||
|
||||
protected async loadJS(name: string, module: ModuleExports): Promise<void> {
|
||||
// stop if already started
|
||||
await this.enableDisableExtension(false, module.name);
|
||||
await this.addExtension(
|
||||
// @ts-expect-error `module` is the interface, not the actual passed class
|
||||
new module(
|
||||
this.zigbee,
|
||||
this.mqtt,
|
||||
this.state,
|
||||
this.publishEntityState,
|
||||
this.eventBus,
|
||||
this.enableDisableExtension,
|
||||
this.restartCallback,
|
||||
this.addExtension,
|
||||
settings,
|
||||
logger,
|
||||
),
|
||||
);
|
||||
|
||||
logger.info(`Loaded external extension '${name}'.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {Context, runInNewContext} from 'vm';
|
||||
|
||||
import bind from 'bind-decorator';
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import data from '../util/data';
|
||||
import logger from '../util/logger';
|
||||
import * as settings from '../util/settings';
|
||||
import utils from '../util/utils';
|
||||
import Extension from './extension';
|
||||
|
||||
export default abstract class ExternalJSExtension<M> extends Extension {
|
||||
private requestLookup: {[s: string]: (message: KeyValue) => Promise<MQTTResponse>} = {
|
||||
save: this.save,
|
||||
remove: this.remove,
|
||||
};
|
||||
|
||||
protected mqttTopic: string;
|
||||
protected requestRegex: RegExp;
|
||||
protected basePath: string;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
mqtt: MQTT,
|
||||
state: State,
|
||||
publishEntityState: PublishEntityState,
|
||||
eventBus: EventBus,
|
||||
enableDisableExtension: (enable: boolean, name: string) => Promise<void>,
|
||||
restartCallback: () => Promise<void>,
|
||||
addExtension: (extension: Extension) => Promise<void>,
|
||||
mqttTopic: string,
|
||||
folderName: string,
|
||||
) {
|
||||
super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension);
|
||||
|
||||
this.mqttTopic = mqttTopic;
|
||||
this.requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/${mqttTopic}/(save|remove)`);
|
||||
this.basePath = data.joinPath(folderName);
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
await super.start();
|
||||
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
|
||||
await this.loadFiles();
|
||||
await this.publishExternalJS();
|
||||
}
|
||||
|
||||
private getFilePath(name: string, mkBasePath: boolean = false): string {
|
||||
if (mkBasePath && !fs.existsSync(this.basePath)) {
|
||||
fs.mkdirSync(this.basePath, {recursive: true});
|
||||
}
|
||||
|
||||
return path.join(this.basePath, name);
|
||||
}
|
||||
|
||||
protected getFileCode(name: string): string {
|
||||
return fs.readFileSync(path.join(this.basePath, name), 'utf8');
|
||||
}
|
||||
|
||||
protected *getFiles(): Generator<{name: string; code: string}> {
|
||||
if (!fs.existsSync(this.basePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileName of fs.readdirSync(this.basePath)) {
|
||||
/* istanbul ignore else */
|
||||
if (fileName.endsWith('.js')) {
|
||||
yield {name: fileName, code: this.getFileCode(fileName)};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
|
||||
const match = data.topic.match(this.requestRegex);
|
||||
|
||||
if (match && this.requestLookup[match[1].toLowerCase()]) {
|
||||
const message = utils.parseJSON(data.message, data.message) as KeyValue;
|
||||
|
||||
try {
|
||||
const response = await this.requestLookup[match[1].toLowerCase()](message);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
|
||||
} catch (error) {
|
||||
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
|
||||
|
||||
const response = utils.getResponse(message, {}, `${(error as Error).message}`);
|
||||
|
||||
await this.mqtt.publish(`bridge/response/${this.mqttTopic}/${match[1]}`, stringify(response));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract removeJS(name: string, module: M): Promise<void>;
|
||||
|
||||
protected abstract loadJS(name: string, module: M): Promise<void>;
|
||||
|
||||
@bind private async remove(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name} = message;
|
||||
const toBeRemoved = this.getFilePath(name);
|
||||
|
||||
if (fs.existsSync(toBeRemoved)) {
|
||||
await this.removeJS(name, this.loadModuleFromText(this.getFileCode(name), name));
|
||||
|
||||
fs.rmSync(toBeRemoved, {force: true});
|
||||
logger.info(`${name} (${toBeRemoved}) removed.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
} else {
|
||||
return utils.getResponse(message, {}, `${name} (${toBeRemoved}) doesn't exists`);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private async save(message: KeyValue): Promise<MQTTResponse> {
|
||||
const {name, code} = message;
|
||||
|
||||
try {
|
||||
await this.loadJS(name, this.loadModuleFromText(code, name));
|
||||
|
||||
const filePath = this.getFilePath(name, true);
|
||||
|
||||
fs.writeFileSync(filePath, code, 'utf8');
|
||||
logger.info(`${name} loaded. Contents written to '${filePath}'.`);
|
||||
await this.publishExternalJS();
|
||||
|
||||
return utils.getResponse(message, {});
|
||||
} catch (error) {
|
||||
return utils.getResponse(message, {}, `${name} contains invalid code: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFiles(): Promise<void> {
|
||||
for (const extension of this.getFiles()) {
|
||||
await this.loadJS(extension.name, this.loadModuleFromText(extension.code, extension.name));
|
||||
}
|
||||
}
|
||||
|
||||
private async publishExternalJS(): Promise<void> {
|
||||
await this.mqtt.publish(
|
||||
`bridge/${this.mqttTopic}s`,
|
||||
stringify(Array.from(this.getFiles())),
|
||||
{
|
||||
retain: true,
|
||||
qos: 0,
|
||||
},
|
||||
settings.get().mqtt.base_topic,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private loadModuleFromText(moduleCode: string, name: string): M {
|
||||
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name);
|
||||
const sandbox: Context = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate,
|
||||
clearImmediate,
|
||||
};
|
||||
|
||||
runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -48,7 +48,7 @@ export default class Device {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveDefinition(ignoreCache = false): Promise<void> {
|
||||
async resolveDefinition(ignoreCache: boolean = false): Promise<void> {
|
||||
if (!this.zh.interviewing && (!this.definition || this._definitionModelID !== this.zh.modelID || ignoreCache)) {
|
||||
this.definition = await zhc.findByDevice(this.zh, true);
|
||||
this._definitionModelID = this.zh.modelID;
|
||||
|
||||
Vendored
-1
@@ -116,7 +116,6 @@ declare global {
|
||||
active: {timeout: number};
|
||||
passive: {timeout: number};
|
||||
};
|
||||
external_converters: string[];
|
||||
mqtt: {
|
||||
base_topic: string;
|
||||
include_device_information: boolean;
|
||||
|
||||
@@ -86,16 +86,6 @@
|
||||
"requiresRestart": true,
|
||||
"description": "Checks whether devices are online/offline"
|
||||
},
|
||||
"external_converters": {
|
||||
"type": "array",
|
||||
"title": "External converters",
|
||||
"description": "You can define external converters to e.g. add support for a DiY device",
|
||||
"requiresRestart": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": ["DIYRuZ_FreePad.js"]
|
||||
},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
"title": "MQTT",
|
||||
|
||||
@@ -25,7 +25,6 @@ const ajvRestartRequiredGroupOptions = new Ajv({allErrors: true})
|
||||
.addKeyword({keyword: 'requiresRestart', validate: (s: unknown) => !s})
|
||||
.compile(schemaJson.definitions.group);
|
||||
const defaults: RecursivePartial<Settings> = {
|
||||
external_converters: [],
|
||||
mqtt: {
|
||||
base_topic: 'zigbee2mqtt',
|
||||
include_device_information: false,
|
||||
|
||||
@@ -3,13 +3,10 @@ import type * as zhc from 'zigbee-herdsman-converters';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import vm from 'vm';
|
||||
|
||||
import equals from 'fast-deep-equal/es6';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
|
||||
import data from './data';
|
||||
|
||||
function pad(num: number): string {
|
||||
const norm = Math.floor(Math.abs(num));
|
||||
return (norm < 10 ? '0' : '') + norm;
|
||||
@@ -145,48 +142,6 @@ function parseJSON(value: string, fallback: string): KeyValue | string {
|
||||
}
|
||||
}
|
||||
|
||||
function loadModuleFromText(moduleCode: string, name?: string): unknown {
|
||||
const moduleFakePath = path.join(__dirname, '..', '..', 'data', 'extension', name || 'externally-loaded.js');
|
||||
const sandbox = {
|
||||
require: require,
|
||||
module: {},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
setImmediate,
|
||||
clearImmediate,
|
||||
};
|
||||
vm.runInNewContext(moduleCode, sandbox, moduleFakePath);
|
||||
/* eslint-disable-line */ // @ts-ignore
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
function loadModuleFromFile(modulePath: string): unknown {
|
||||
const moduleCode = fs.readFileSync(modulePath, {encoding: 'utf8'});
|
||||
return loadModuleFromText(moduleCode);
|
||||
}
|
||||
|
||||
export function* loadExternalConverter(moduleName: string): Generator<zhc.Definition> {
|
||||
let converter;
|
||||
|
||||
if (moduleName.endsWith('.js')) {
|
||||
converter = loadModuleFromFile(data.joinPath(moduleName));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
converter = require(moduleName);
|
||||
}
|
||||
|
||||
if (Array.isArray(converter)) {
|
||||
for (const item of converter) {
|
||||
yield item;
|
||||
}
|
||||
} else {
|
||||
yield converter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all keys from passed object that have null/undefined values.
|
||||
*
|
||||
@@ -426,8 +381,6 @@ export default {
|
||||
getObjectProperty,
|
||||
getResponse,
|
||||
parseJSON,
|
||||
loadModuleFromText,
|
||||
loadModuleFromFile,
|
||||
removeNullPropertiesFromObject,
|
||||
toNetworkAddressHex,
|
||||
toSnakeCaseString,
|
||||
|
||||
+7
-3
@@ -72,9 +72,7 @@ export default class Zigbee {
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition();
|
||||
}
|
||||
await this.resolveDevicesDefinitions();
|
||||
|
||||
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
|
||||
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
|
||||
@@ -239,6 +237,12 @@ export default class Zigbee {
|
||||
await this.herdsman.permitJoin(time, device?.zh);
|
||||
}
|
||||
|
||||
async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise<void> {
|
||||
for (const device of this.devicesIterator(utils.deviceNotCoordinator)) {
|
||||
await device.resolveDefinition(ignoreCache);
|
||||
}
|
||||
}
|
||||
|
||||
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
|
||||
if (!this.deviceLookup[ieeeAddr]) {
|
||||
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class Example2 {
|
||||
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
|
||||
this.mqtt = mqtt;
|
||||
this.mqtt.publish('example2/extension', 'call2 from constructor');
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish('example2/extension', 'call2 from start');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish('example/extension', 'call2 from stop');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Example2;
|
||||
+5
-1
@@ -5,7 +5,11 @@ class Example {
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqtt.publish('example/extension', 'test');
|
||||
this.mqtt.publish('example/extension', 'call from start');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.mqtt.publish('example/extension', 'call from stop');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,6 @@ describe('Extension: Bridge', () => {
|
||||
retain: false,
|
||||
},
|
||||
},
|
||||
external_converters: [],
|
||||
groups: {
|
||||
1: {friendly_name: 'group_1', retain: false},
|
||||
11: {friendly_name: 'group_with_tradfri', retain: false},
|
||||
@@ -3781,11 +3780,11 @@ describe('Extension: Bridge', () => {
|
||||
|
||||
it('Change options not valid against schema', async () => {
|
||||
mockMQTT.publishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {external_converters: 'true'}}));
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/options', stringify({options: {advanced: {log_level: 123}}}));
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/options',
|
||||
stringify({data: {}, error: 'external_converters must be array', status: 'error'}),
|
||||
stringify({data: {}, error: 'advanced/log_level must be string', status: 'error'}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,169 +1,306 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTT} from '../mocks/mqtt';
|
||||
import {mockMQTT, events as mockMQTTEvents} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, mockController as mockZHController} from '../mocks/zigbeeHerdsman';
|
||||
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import type Device from '../../lib/model/device';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import * as zhc from 'zigbee-herdsman-converters';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
jest.mock(
|
||||
'mock-external-converter-module',
|
||||
() => {
|
||||
return {
|
||||
mock: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
virtual: true,
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'mock-multiple-external-converter-module',
|
||||
() => {
|
||||
return [
|
||||
{
|
||||
mock: 1,
|
||||
},
|
||||
{
|
||||
mock: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
virtual: true,
|
||||
},
|
||||
);
|
||||
|
||||
const mockZHCAddDefinition = jest.fn();
|
||||
// @ts-expect-error mock
|
||||
zhc.addDefinition = mockZHCAddDefinition;
|
||||
|
||||
const mocksClear = [
|
||||
mockZHCAddDefinition,
|
||||
devices.bulb_color.removeFromNetwork,
|
||||
devices.bulb.removeFromNetwork,
|
||||
mockZHController.permitJoin,
|
||||
mockZHController.stop,
|
||||
mockMQTT.endAsync,
|
||||
mockMQTT.publishAsync,
|
||||
mockLogger.debug,
|
||||
mockLogger.error,
|
||||
];
|
||||
const BASE_DIR = 'external_converters';
|
||||
|
||||
describe('Extension: ExternalConverters', () => {
|
||||
const mockBasePath = path.join(data.mockDir, BASE_DIR);
|
||||
let controller: Controller;
|
||||
|
||||
const resetExtension = async (): Promise<void> => {
|
||||
await controller.enableDisableExtension(false, 'ExternalConverters');
|
||||
await controller.enableDisableExtension(true, 'ExternalConverters');
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync');
|
||||
const readdirSyncSpy = jest.spyOn(fs, 'readdirSync');
|
||||
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
|
||||
const rmSyncSpy = jest.spyOn(fs, 'rmSync');
|
||||
const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const zhcAddDefinitionSpy = jest.spyOn(zhc, 'addDefinition');
|
||||
const zhcRemoveExternalDefinitionsSpy = jest.spyOn(zhc, 'removeExternalDefinitions');
|
||||
|
||||
const mocksClear = [
|
||||
mockMQTT.endAsync,
|
||||
mockMQTT.publishAsync,
|
||||
mockLogger.debug,
|
||||
mockLogger.error,
|
||||
mockZHController.stop,
|
||||
devices.bulb.save,
|
||||
existsSyncSpy,
|
||||
readdirSyncSpy,
|
||||
mkdirSyncSpy,
|
||||
rmSyncSpy,
|
||||
writeFileSyncSpy,
|
||||
zhcAddDefinitionSpy,
|
||||
zhcRemoveExternalDefinitionsSpy,
|
||||
];
|
||||
|
||||
const useAssets = (): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
};
|
||||
|
||||
const getZ2MDevice = (zhDevice: unknown): Device => {
|
||||
// @ts-expect-error private
|
||||
return controller.zigbee.resolveEntity(zhDevice)! as Device;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
await resetExtension();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Does not load external converters', async () => {
|
||||
settings.set(['external_converters'], []);
|
||||
await resetExtension();
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(0);
|
||||
beforeEach(async () => {
|
||||
zhc.removeExternalDefinitions(); // remove all external converters
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
returnDevices.push(devices.external_converter_device.ieeeAddr);
|
||||
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
});
|
||||
|
||||
it('Loads external converters', async () => {
|
||||
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
await resetExtension();
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledWith({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
afterEach(async () => {
|
||||
fs.rmSync(mockBasePath, {recursive: true, force: true});
|
||||
|
||||
await controller?.stop();
|
||||
});
|
||||
|
||||
it('loads nothing from folder', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
|
||||
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('loads from folder', async () => {
|
||||
useAssets();
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'external',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
model: 'external_converter_device',
|
||||
vendor: 'external',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads multiple external converters', async () => {
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'mock-external-converter-multiple.js'),
|
||||
path.join(data.mockDir, 'mock-external-converter-multiple.js'),
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/converters',
|
||||
stringify([
|
||||
{name: 'mock-external-converter-multiple.js', code: getFileCode('mock-external-converter-multiple.js')},
|
||||
{name: 'mock-external-converter.js', code: getFileCode('mock-external-converter.js')},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, 'mock-external-converter-multiple.js');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, 'mock-external-converter.js');
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
}),
|
||||
);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
}),
|
||||
);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
}),
|
||||
);
|
||||
settings.set(['external_converters'], ['mock-external-converter-multiple.js']);
|
||||
await resetExtension();
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(1, {
|
||||
mock: 1,
|
||||
model: 'external_converters_device_1',
|
||||
zigbeeModel: ['external_converter_device_1'],
|
||||
vendor: 'external_1',
|
||||
description: 'external_1',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
});
|
||||
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(2, {
|
||||
mock: 2,
|
||||
model: 'external_converters_device_2',
|
||||
zigbeeModel: ['external_converter_device_2'],
|
||||
vendor: 'external_2',
|
||||
description: 'external_2',
|
||||
fromZigbee: [],
|
||||
toZigbee: [],
|
||||
exposes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Loads external converters from package', async () => {
|
||||
settings.set(['external_converters'], ['mock-external-converter-module']);
|
||||
await resetExtension();
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(1);
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledWith({
|
||||
mock: true,
|
||||
it('saves and removes from MQTT', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = getFileCode('mock-external-converter.js');
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'Automatically generated definition',
|
||||
model: 'external_converter_device',
|
||||
vendor: '',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'external',
|
||||
model: 'external_converter_device',
|
||||
vendor: 'external',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(1, converterName);
|
||||
expect(zhcAddDefinitionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mock: true,
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
vendor: 'external',
|
||||
model: 'external_converter_device',
|
||||
description: 'external',
|
||||
}),
|
||||
);
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([{name: converterName, code: converterCode}]), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(getZ2MDevice(devices.external_converter_device).definition).toMatchObject({
|
||||
description: 'Automatically generated definition',
|
||||
model: 'external_converter_device',
|
||||
vendor: '',
|
||||
zigbeeModel: ['external_converter_device'],
|
||||
});
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenCalledTimes(2);
|
||||
expect(zhcRemoveExternalDefinitionsSpy).toHaveBeenNthCalledWith(2, converterName);
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/converters', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('Loads multiple external converters from package', async () => {
|
||||
settings.set(['external_converters'], ['mock-multiple-external-converter-module']);
|
||||
await resetExtension();
|
||||
expect(mockZHCAddDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(1, {
|
||||
mock: 1,
|
||||
});
|
||||
expect(mockZHCAddDefinition).toHaveBeenNthCalledWith(2, {
|
||||
mock: 2,
|
||||
});
|
||||
it('returns error on invalid code', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = 'definetly not a correct javascript code';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/save',
|
||||
expect.stringContaining(`"error":"foo.js contains invalid code`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
});
|
||||
|
||||
it('Loads external converters with error', async () => {
|
||||
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
mockZHCAddDefinition.mockImplementationOnce(() => {
|
||||
throw new Error('Invalid definition!');
|
||||
it('returns error on invalid removal', async () => {
|
||||
const converterName = 'invalid.js';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/remove',
|
||||
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
|
||||
it('returns error on invalid definition', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = getFileCode('mock-external-converter.js');
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
const errorMsg = `Invalid definition`;
|
||||
|
||||
zhcAddDefinitionSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
await resetExtension();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(`Failed to load external converter file 'mock-external-converter.js' (Invalid definition!)`);
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/converter/save', expect.stringContaining(errorMsg), {
|
||||
retain: false,
|
||||
qos: 0,
|
||||
});
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(converterFilePath, converterCode, 'utf8');
|
||||
});
|
||||
|
||||
it('returns error on failed removal', async () => {
|
||||
const converterName = 'foo.js';
|
||||
const converterCode = getFileCode('mock-external-converter.js');
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/save', stringify({name: converterName, code: converterCode}));
|
||||
await flushPromises();
|
||||
|
||||
const errorMsg = `Failed to remove definition`;
|
||||
|
||||
zhcRemoveExternalDefinitionsSpy.mockImplementationOnce(() => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/converter/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/converter/remove',
|
||||
stringify({data: {}, status: 'error', error: errorMsg}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTT, events as mockMQTTEvents} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
import {rimrafSync} from 'rimraf';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const mocksClear = [
|
||||
mockZHController.permitJoin,
|
||||
mockZHController.stop,
|
||||
devices.bulb_color.removeFromNetwork,
|
||||
devices.bulb.removeFromNetwork,
|
||||
mockMQTT.endAsync,
|
||||
mockMQTT.publishAsync,
|
||||
mockLogger.debug,
|
||||
mockLogger.error,
|
||||
];
|
||||
|
||||
describe('Extension: ExternalExtension', () => {
|
||||
let controller: Controller;
|
||||
let mkdirSyncSpy: jest.SpyInstance;
|
||||
let unlinkSyncSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
|
||||
unlinkSyncSpy = jest.spyOn(fs, 'unlinkSync');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
returnDevices.splice(0);
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeDefaultState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
rimrafSync(extensionPath);
|
||||
});
|
||||
|
||||
it('Load user extension', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = fs.readFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), 'utf-8');
|
||||
fs.mkdirSync(extensionPath);
|
||||
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), path.join(extensionPath, 'exampleExtension.js'));
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('Load user extension from api call', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = fs.readFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), 'utf-8');
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mockMQTT.publishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{name: 'foo.js', code: extensionCode}]), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
|
||||
expect(mkdirSyncSpy).toHaveBeenCalledWith(extensionPath);
|
||||
});
|
||||
|
||||
it('Do not load corrupted extensions', async () => {
|
||||
const extensionCode = 'definetly not a correct javascript code';
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mockMQTT.publishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: 'foo.js', code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/response/extension/save', expect.any(String), {retain: false, qos: 0});
|
||||
const payload = JSON.parse(mockMQTT.publishAsync.mock.calls[0][1]);
|
||||
expect(payload).toEqual(expect.objectContaining({data: {}, status: 'error'}));
|
||||
expect(payload.error).toMatch('Unexpected identifier');
|
||||
});
|
||||
|
||||
it('Removes user extension', async () => {
|
||||
const extensionPath = path.join(data.mockDir, 'extension');
|
||||
const extensionCode = fs.readFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), 'utf-8');
|
||||
fs.mkdirSync(extensionPath);
|
||||
const extensionFilePath = path.join(extensionPath, 'exampleExtension.js');
|
||||
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'exampleExtension.js'), extensionFilePath);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([{name: 'exampleExtension.js', code: extensionCode}]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'exampleExtension.js'}));
|
||||
await flushPromises();
|
||||
expect(unlinkSyncSpy).toHaveBeenCalledWith(extensionFilePath);
|
||||
mockMQTT.publishAsync.mockClear();
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: 'non existing.js'}));
|
||||
await flushPromises();
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: "Extension non existing.js doesn't exists"}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as data from '../mocks/data';
|
||||
import {mockLogger} from '../mocks/logger';
|
||||
import {mockMQTT, events as mockMQTTEvents} from '../mocks/mqtt';
|
||||
import {flushPromises} from '../mocks/utils';
|
||||
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import stringify from 'json-stable-stringify-without-jsonify';
|
||||
|
||||
import {Controller} from '../../lib/controller';
|
||||
import * as settings from '../../lib/util/settings';
|
||||
|
||||
const BASE_DIR = 'external_extensions';
|
||||
|
||||
describe('Extension: ExternalExtensions', () => {
|
||||
let controller: Controller;
|
||||
const mockBasePath = path.join(data.mockDir, BASE_DIR);
|
||||
|
||||
const existsSyncSpy = jest.spyOn(fs, 'existsSync');
|
||||
const readdirSyncSpy = jest.spyOn(fs, 'readdirSync');
|
||||
const mkdirSyncSpy = jest.spyOn(fs, 'mkdirSync');
|
||||
const rmSyncSpy = jest.spyOn(fs, 'rmSync');
|
||||
const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const mocksClear = [
|
||||
mockMQTT.endAsync,
|
||||
mockMQTT.publishAsync,
|
||||
mockLogger.debug,
|
||||
mockLogger.error,
|
||||
mockZHController.stop,
|
||||
devices.bulb.save,
|
||||
existsSyncSpy,
|
||||
readdirSyncSpy,
|
||||
mkdirSyncSpy,
|
||||
rmSyncSpy,
|
||||
writeFileSyncSpy,
|
||||
];
|
||||
|
||||
const useAssets = (): void => {
|
||||
fs.cpSync(path.join(__dirname, '..', 'assets', BASE_DIR), mockBasePath, {recursive: true});
|
||||
};
|
||||
|
||||
const getFileCode = (fileName: string): string => {
|
||||
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, fileName), 'utf8');
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
data.writeDefaultConfiguration();
|
||||
data.writeDefaultState();
|
||||
settings.reRead();
|
||||
returnDevices.splice(0);
|
||||
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(mockBasePath, {recursive: true, force: true});
|
||||
});
|
||||
|
||||
it('loads nothing from folder', async () => {
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(existsSyncSpy).toHaveBeenCalledWith(mockBasePath);
|
||||
expect(readdirSyncSpy).not.toHaveBeenCalledWith(mockBasePath);
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('loads from folder', async () => {
|
||||
useAssets();
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from constructor', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example2/extension', 'call2 from start', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/extensions',
|
||||
stringify([
|
||||
{name: 'example2Extension.js', code: getFileCode('example2Extension.js')},
|
||||
{name: 'exampleExtension.js', code: getFileCode('exampleExtension.js')},
|
||||
]),
|
||||
{retain: true, qos: 0},
|
||||
);
|
||||
});
|
||||
|
||||
it('saves and removes from MQTT', async () => {
|
||||
const extensionName = 'foo.js';
|
||||
const extensionCode = getFileCode('exampleExtension.js');
|
||||
const extensionFilePath = path.join(mockBasePath, extensionName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
//-- SAVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mkdirSyncSpy).toHaveBeenCalledWith(mockBasePath, {recursive: true});
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from constructor', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from start', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([{name: extensionName, code: extensionCode}]), {
|
||||
retain: true,
|
||||
qos: 0,
|
||||
});
|
||||
|
||||
//-- REMOVE
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: extensionName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(rmSyncSpy).toHaveBeenCalledWith(extensionFilePath, {force: true});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'call from stop', {retain: false, qos: 0});
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith('zigbee2mqtt/bridge/extensions', stringify([]), {retain: true, qos: 0});
|
||||
});
|
||||
|
||||
it('returns error on invalid code', async () => {
|
||||
const extensionName = 'foo.js';
|
||||
const extensionCode = 'definetly not a correct javascript code';
|
||||
const extensionFilePath = path.join(mockBasePath, extensionName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/save', stringify({name: extensionName, code: extensionCode}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/save',
|
||||
expect.stringContaining(`"error":"${extensionName} contains invalid code`),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalledWith(extensionFilePath, extensionCode, 'utf8');
|
||||
});
|
||||
|
||||
it('returns error on invalid removal', async () => {
|
||||
const converterName = 'invalid.js';
|
||||
const converterFilePath = path.join(mockBasePath, converterName);
|
||||
|
||||
await controller.start();
|
||||
await flushPromises();
|
||||
mocksClear.forEach((m) => m.mockClear());
|
||||
|
||||
mockMQTTEvents.message('zigbee2mqtt/bridge/request/extension/remove', stringify({name: converterName}));
|
||||
await flushPromises();
|
||||
|
||||
expect(mockMQTT.publishAsync).toHaveBeenCalledWith(
|
||||
'zigbee2mqtt/bridge/response/extension/remove',
|
||||
stringify({data: {}, status: 'error', error: `${converterName} (${converterFilePath}) doesn't exists`}),
|
||||
{retain: false, qos: 0},
|
||||
);
|
||||
expect(rmSyncSpy).not.toHaveBeenCalledWith(converterFilePath, {force: true});
|
||||
});
|
||||
});
|
||||
@@ -102,8 +102,11 @@ describe('Extension: NetworkMap', () => {
|
||||
data.writeDefaultConfiguration();
|
||||
settings.reRead();
|
||||
data.writeEmptyState();
|
||||
fs.copyFileSync(path.join(__dirname, '..', 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js'));
|
||||
settings.set(['external_converters'], ['mock-external-converter.js']);
|
||||
fs.mkdirSync(path.join(data.mockDir, 'external_converters'));
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'external_converters', 'mock-external-converter.js'),
|
||||
path.join(data.mockDir, 'external_converters', 'mock-external-converter.js'),
|
||||
);
|
||||
controller = new Controller(jest.fn(), jest.fn());
|
||||
await controller.start();
|
||||
});
|
||||
@@ -120,6 +123,7 @@ describe('Extension: NetworkMap', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
mockSleep.restore();
|
||||
fs.rmSync(path.join(data.mockDir, 'external_converters'), {recursive: true});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -234,7 +234,6 @@ export function writeDefaultConfiguration(config: unknown = undefined): void {
|
||||
friendly_name: 'ha_discovery_group',
|
||||
},
|
||||
},
|
||||
external_converters: [],
|
||||
};
|
||||
|
||||
yaml.writeIfChanged(path.join(mockDir, 'configuration.yaml'), config);
|
||||
|
||||
@@ -15,7 +15,6 @@ const devicesFile2 = mockedData.joinPath('devices2.yaml');
|
||||
const groupsFile = mockedData.joinPath('groups.yaml');
|
||||
const secretFile = mockedData.joinPath('secret.yaml');
|
||||
const minimalConfig = {
|
||||
external_converters: [],
|
||||
homeassistant: true,
|
||||
mqtt: {base_topic: 'zigbee2mqtt', server: 'localhost'},
|
||||
};
|
||||
@@ -64,13 +63,13 @@ describe('Settings', () => {
|
||||
});
|
||||
|
||||
it('Should return settings', () => {
|
||||
write(configurationFile, {external_converters: ['abcd.js']});
|
||||
write(configurationFile, {serial: {disable_led: true}});
|
||||
const s = settings.get();
|
||||
// @ts-expect-error workaround
|
||||
const expected = objectAssignDeep.noMutate({}, settings.testing.defaults);
|
||||
expected.devices = {};
|
||||
expected.groups = {};
|
||||
expected.external_converters = ['abcd.js'];
|
||||
expected.serial = {disable_led: true};
|
||||
expect(s).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user