fix: Enforce TS strict type checking (#23601)

* Enforce TS `strict` type checking.

* updates

* updates

* updates

* Updates

* Updates

* pretty

* u

* u

* u

* Updates

* updates

* Updates

* Updates

* `ReadonlyArray`

* scenesChanged

* objectID

* Improve coverage

* u

* u

* process feedback

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
This commit is contained in:
Nerivec
2024-09-07 11:40:50 +00:00
committed by GitHub
co-authored by Koen Kanters
parent 63c3bb4166
commit 48d77e4b5e
42 changed files with 1899 additions and 1480 deletions
+29 -13
View File
@@ -119,13 +119,28 @@ export class Controller {
new ExtensionReport(...this.extensionArgs),
new ExtensionExternalExtension(...this.extensionArgs),
new ExtensionAvailability(...this.extensionArgs),
settings.get().frontend && new ExtensionFrontend(...this.extensionArgs),
settings.get().advanced.legacy_api && new ExtensionBridgeLegacy(...this.extensionArgs),
settings.get().external_converters.length && new ExtensionExternalConverters(...this.extensionArgs),
settings.get().homeassistant && new ExtensionHomeAssistant(...this.extensionArgs),
/* istanbul ignore next */
settings.get().advanced.soft_reset_timeout !== 0 && new ExtensionSoftReset(...this.extensionArgs),
].filter((n) => n);
];
if (settings.get().frontend) {
this.extensions.push(new ExtensionFrontend(...this.extensionArgs));
}
if (settings.get().advanced.legacy_api) {
this.extensions.push(new ExtensionBridgeLegacy(...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));
}
/* istanbul ignore next */
if (settings.get().advanced.soft_reset_timeout !== 0) {
this.extensions.push(new ExtensionSoftReset(...this.extensionArgs));
}
}
async start(): Promise<void> {
@@ -143,7 +158,7 @@ export class Controller {
logger.error('Failed to start zigbee');
logger.error('Check https://www.zigbee2mqtt.io/guide/installation/20_zigbee2mqtt-fails-to-start.html for possible solutions');
logger.error('Exiting...');
logger.error(error.stack);
logger.error((error as Error).stack!);
return this.exit(1);
}
@@ -160,8 +175,9 @@ export class Controller {
let deviceCount = 0;
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
// `definition` validated by `isSupported`
const model = device.isSupported
? `${device.definition.model} - ${device.definition.vendor} ${device.definition.description}`
? `${device.definition!.model} - ${device.definition!.vendor} ${device.definition!.description}`
: 'Not supported';
logger.info(`${device.name} (${device.ieeeAddr}): ${model} (${device.zh.type})`);
@@ -180,14 +196,14 @@ export class Controller {
await this.zigbee.permitJoin(settings.get().permit_join);
} catch (error) {
logger.error(`Failed to set permit join to ${settings.get().permit_join} (${error.message})`);
logger.error(`Failed to set permit join to ${settings.get().permit_join} (${(error as Error).message})`);
}
// MQTT
try {
await this.mqtt.connect();
} catch (error) {
logger.error(`MQTT failed to connect, exiting... (${error.message})`);
logger.error(`MQTT failed to connect, exiting... (${(error as Error).message})`);
await this.zigbee.stop();
return this.exit(1);
}
@@ -252,7 +268,7 @@ export class Controller {
await this.zigbee.stop();
logger.info('Stopped Zigbee2MQTT');
} catch (error) {
logger.error(`Failed to stop Zigbee2MQTT (${error.message})`);
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`);
code = 1;
}
@@ -377,7 +393,7 @@ export class Controller {
await extension[method]?.();
} catch (error) {
/* istanbul ignore next */
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${error.stack})`);
logger.error(`Failed to call '${extension.constructor.name}' '${method}' (${(error as Error).stack})`);
}
}
}
+42 -8
View File
@@ -5,9 +5,39 @@ import logger from './util/logger';
// eslint-disable-next-line
type ListenerKey = object;
interface EventBusMap {
adapterDisconnected: [];
permitJoinChanged: [data: eventdata.PermitJoinChanged];
publishAvailability: [];
deviceRenamed: [data: eventdata.EntityRenamed];
deviceRemoved: [data: eventdata.EntityRemoved];
lastSeenChanged: [data: eventdata.LastSeenChanged];
deviceNetworkAddressChanged: [data: eventdata.DeviceNetworkAddressChanged];
deviceAnnounce: [data: eventdata.DeviceAnnounce];
deviceInterview: [data: eventdata.DeviceInterview];
deviceJoined: [data: eventdata.DeviceJoined];
entityOptionsChanged: [data: eventdata.EntityOptionsChanged];
exposesChanged: [data: eventdata.ExposesChanged];
deviceLeave: [data: eventdata.DeviceLeave];
deviceMessage: [data: eventdata.DeviceMessage];
mqttMessage: [data: eventdata.MQTTMessage];
mqttMessagePublished: [data: eventdata.MQTTMessagePublished];
publishEntityState: [data: eventdata.PublishEntityState];
groupMembersChanged: [data: eventdata.GroupMembersChanged];
devicesChanged: [];
scenesChanged: [data: eventdata.ScenesChanged];
reconfigure: [data: eventdata.Reconfigure];
stateChange: [data: eventdata.StateChange];
}
type EventBusListener<K> = K extends keyof EventBusMap
? EventBusMap[K] extends unknown[]
? (...args: EventBusMap[K]) => Promise<void> | void
: never
: never;
export default class EventBus {
private callbacksByExtension: {[s: string]: {event: string; callback: (...args: unknown[]) => void}[]} = {};
private emitter = new events.EventEmitter();
private callbacksByExtension: {[s: string]: {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]} = {};
private emitter = new events.EventEmitter<EventBusMap>();
constructor() {
this.emitter.setMaxListeners(100);
@@ -167,18 +197,22 @@ export default class EventBus {
this.on('stateChange', callback, key);
}
private on(event: string, callback: (...args: unknown[]) => Promise<void> | void, key: ListenerKey): void {
if (!this.callbacksByExtension[key.constructor.name]) this.callbacksByExtension[key.constructor.name] = [];
const wrappedCallback = async (...args: unknown[]): Promise<void> => {
private on<K extends keyof EventBusMap>(event: K, callback: EventBusListener<K>, key: ListenerKey): void {
if (!this.callbacksByExtension[key.constructor.name]) {
this.callbacksByExtension[key.constructor.name] = [];
}
const wrappedCallback = async (...args: never[]): Promise<void> => {
try {
await callback(...args);
} catch (error) {
logger.error(`EventBus error '${key.constructor.name}/${event}': ${error.message}`);
logger.debug(error.stack);
logger.error(`EventBus error '${key.constructor.name}/${event}': ${(error as Error).message}`);
logger.debug((error as Error).stack!);
}
};
this.callbacksByExtension[key.constructor.name].push({event, callback: wrappedCallback});
this.emitter.on(event, wrappedCallback);
this.emitter.on(event, wrappedCallback as EventBusListener<K>);
}
public removeListeners(key: ListenerKey): void {
+18 -11
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import debounce from 'debounce';
import * as zhc from 'zigbee-herdsman-converters';
@@ -41,9 +42,12 @@ export default class Availability extends Extension {
}
private isAvailable(entity: Device | Group): boolean {
return entity.isDevice()
? Date.now() - entity.zh.lastSeen < this.getTimeout(entity)
: entity.membersDevices().length === 0 || entity.membersDevices().some((d) => this.availabilityCache[d.ieeeAddr]);
if (entity.isDevice()) {
return Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0) < this.getTimeout(entity);
} else {
const membersDevices = entity.membersDevices();
return membersDevices.length === 0 || membersDevices.some((d) => this.availabilityCache[d.ieeeAddr]);
}
}
private resetTimer(device: Device): void {
@@ -92,7 +96,7 @@ export default class Availability extends Extension {
logger.debug(`Successfully pinged '${device.name}' (attempt ${i}/${attempts})`);
break;
} catch (error) {
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${error.message})`);
logger.warning(`Failed to ping '${device.name}' (attempt ${i}/${attempts}, ${(error as Error).message})`);
// Try again in 3 seconds.
if (i !== attempts) {
@@ -125,7 +129,7 @@ export default class Availability extends Extension {
this.eventBus.onEntityRenamed(this, async (data) => {
if (utils.isAvailabilityEnabledForEntity(data.entity, settings.get())) {
await this.mqtt.publish(`${data.from}/availability`, null, {retain: true, qos: 1});
await this.mqtt.publish(`${data.from}/availability`, '', {retain: true, qos: 1});
await this.publishAvailability(data.entity, false, true);
}
});
@@ -162,7 +166,8 @@ export default class Availability extends Extension {
private async publishAvailability(entity: Device | Group, logLastSeen: boolean, forcePublish = false, skipGroups = false): Promise<void> {
if (logLastSeen && entity.isDevice()) {
const ago = Date.now() - entity.zh.lastSeen;
const ago = Date.now() - (entity.zh.lastSeen ?? /* istanbul ignore next */ 0);
if (this.isActiveDevice(entity)) {
logger.debug(`Active device '${entity.name}' was last seen '${(ago / utils.minutes(1)).toFixed(2)}' minutes ago.`);
} else {
@@ -230,22 +235,24 @@ export default class Availability extends Extension {
continue;
}
const converter = device.definition.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
const converter = device.definition!.toZigbee.find((c) => !c.key || c.key.find((k) => item.keys.includes(k)));
const options: KeyValue = device.options;
const state = this.state.get(device);
const meta: zhc.Tz.Meta = {
message: this.state.get(device),
mapped: device.definition,
endpoint_name: null,
mapped: device.definition!,
endpoint_name: undefined,
options,
state,
device: device.zh,
};
try {
await converter?.convertGet?.(device.endpoint(), item.keys[0], meta);
const endpoint = device.endpoint();
assert(endpoint);
await converter?.convertGet?.(endpoint, item.keys[0], meta);
} catch (error) {
logger.error(`Failed to read state of '${device.name}' after reconnect (${error.message})`);
logger.error(`Failed to read state of '${device.name}' after reconnect (${(error as Error).message})`);
}
await utils.sleep(500);
+44 -30
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -87,7 +88,7 @@ const REPORT_CLUSTERS: Readonly<
type PollOnMessage = {
cluster: Readonly<Partial<Record<ClusterName, {type: string; data: KeyValue}[]>>>;
read: Readonly<{cluster: string; attributes: string[]; attributesForEndpoint?: (endpoint: zh.Endpoint) => Promise<string[]>}>;
manufacturerIDs: readonly number[];
manufacturerIDs: readonly Zcl.ManufacturerCode[];
manufacturerNames: readonly string[];
}[];
@@ -196,10 +197,17 @@ interface ParsedMQTTMessage {
type: 'bind' | 'unbind';
sourceKey: string;
targetKey: string;
clusters: string[];
clusters?: string[];
skipDisableReporting: boolean;
}
interface DataMessage {
from: ParsedMQTTMessage['sourceKey'];
to: ParsedMQTTMessage['targetKey'];
clusters: ParsedMQTTMessage['clusters'];
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
}
export default class Bind extends Extension {
private pollDebouncers: {[s: string]: () => void} = {};
@@ -209,11 +217,11 @@ export default class Bind extends Extension {
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
}
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage {
let type: ParsedMQTTMessage['type'] = null;
let sourceKey: ParsedMQTTMessage['sourceKey'] = null;
let targetKey: ParsedMQTTMessage['targetKey'] = null;
let clusters: ParsedMQTTMessage['clusters'] = null;
private parseMQTTMessage(data: eventdata.MQTTMessage): ParsedMQTTMessage | undefined {
let type: ParsedMQTTMessage['type'] | undefined;
let sourceKey: ParsedMQTTMessage['sourceKey'] | undefined;
let targetKey: ParsedMQTTMessage['targetKey'] | undefined;
let clusters: ParsedMQTTMessage['clusters'] | undefined;
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
if (LEGACY_API && data.topic.match(LEGACY_TOPIC_REGEX)) {
@@ -223,26 +231,29 @@ export default class Bind extends Extension {
targetKey = data.message;
} else if (data.topic.match(TOPIC_REGEX)) {
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
const message = JSON.parse(data.message);
const message: DataMessage = JSON.parse(data.message);
sourceKey = message.from;
targetKey = message.to;
clusters = message.clusters;
skipDisableReporting = 'skip_disable_reporting' in message ? message.skip_disable_reporting : false;
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
} else {
return undefined;
}
return {type, sourceKey, targetKey, clusters, skipDisableReporting};
}
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = this.parseMQTTMessage(data);
const parsed = this.parseMQTTMessage(data);
if (!type) {
return null;
if (!parsed || !parsed.type) {
return;
}
const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed;
const message = utils.parseJSON(data.message, data.message);
let error = null;
let error: string | undefined;
const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey);
const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey);
const source = parsedSource.entity;
@@ -262,9 +273,11 @@ export default class Bind extends Extension {
const failedClusters = [];
const attemptedClusters = [];
const bindSource: zh.Endpoint = parsedSource.endpoint;
const bindTarget: number | zh.Group | zh.Endpoint =
target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
const bindSource = parsedSource.endpoint;
const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
assert(bindSource != undefined && bindTarget != undefined);
// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters.
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
@@ -274,7 +287,7 @@ export default class Bind extends Extension {
const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
if (!anyClusterValid && utils.isEndpoint(bindTarget)) {
if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) {
matchingClusters =
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
@@ -383,7 +396,7 @@ export default class Bind extends Extension {
}
getSetupReportingEndpoints(bind: zh.Bind, coordinatorEp: zh.Endpoint): zh.Endpoint[] {
const endpoints = utils.isEndpoint(bind.target) ? [bind.target] : bind.target.members;
const endpoints = utils.isZHEndpoint(bind.target) ? [bind.target] : bind.target.members;
return endpoints.filter((e) => {
if (!e.supportsInputCluster(bind.cluster.name)) {
@@ -409,14 +422,14 @@ export default class Bind extends Extension {
/* istanbul ignore else */
if (bind.cluster.name in REPORT_CLUSTERS) {
for (const endpoint of this.getSetupReportingEndpoints(bind, coordinatorEndpoint)) {
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice()).name}/${endpoint.ID}`;
const entity = `${this.zigbee.resolveEntity(endpoint.getDevice())!.name}/${endpoint.ID}`;
try {
await endpoint.bind(bind.cluster.name, coordinatorEndpoint);
const items = [];
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]) {
for (const c of REPORT_CLUSTERS[bind.cluster.name as ClusterName]!) {
/* istanbul ignore else */
if (!c.condition || (await c.condition(endpoint))) {
const i = {...c};
@@ -429,7 +442,7 @@ export default class Bind extends Extension {
await endpoint.configureReporting(bind.cluster.name, items);
logger.info(`Successfully setup reporting for '${entity}' cluster '${bind.cluster.name}'`);
} catch (error) {
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${error.message})`);
logger.warning(`Failed to setup reporting for '${entity}' cluster '${bind.cluster.name}' (${(error as Error).message})`);
}
}
}
@@ -440,7 +453,7 @@ export default class Bind extends Extension {
async disableUnnecessaryReportings(target: zh.Group | zh.Endpoint): Promise<void> {
const coordinator = this.zigbee.firstCoordinatorEndpoint();
const endpoints = utils.isEndpoint(target) ? [target] : target.members;
const endpoints = utils.isZHEndpoint(target) ? [target] : target.members;
const allBinds: zh.Bind[] = [];
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
@@ -458,7 +471,7 @@ export default class Bind extends Extension {
const boundClusters: string[] = [];
for (const bind of allBinds) {
if (utils.isEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
if (utils.isZHEndpoint(bind.target) ? bind.target === endpoint : bind.target.members.includes(endpoint)) {
requiredClusters.push(bind.cluster.name);
}
}
@@ -476,7 +489,7 @@ export default class Bind extends Extension {
const items = [];
for (const item of REPORT_CLUSTERS[cluster as ClusterName]) {
for (const item of REPORT_CLUSTERS[cluster as ClusterName]!) {
/* istanbul ignore else */
if (!item.condition || (await item.condition(endpoint))) {
const i = {...item};
@@ -489,7 +502,7 @@ export default class Bind extends Extension {
await endpoint.configureReporting(cluster, items);
logger.info(`Successfully disabled reporting for '${entity}' cluster '${cluster}'`);
} catch (error) {
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${error.message})`);
logger.warning(`Failed to disable reporting for '${entity}' cluster '${cluster}' (${(error as Error).message})`);
}
}
@@ -516,7 +529,7 @@ export default class Bind extends Extension {
// Add bound devices
for (const endpoint of data.device.zh.endpoints) {
for (const bind of endpoint.binds) {
if (utils.isEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
if (utils.isZHEndpoint(bind.target) && bind.target.getDevice().type !== 'Coordinator') {
toPoll.add(bind.target);
}
}
@@ -532,10 +545,11 @@ export default class Bind extends Extension {
}
for (const endpoint of toPoll) {
const device = endpoint.getDevice();
for (const poll of polls) {
// XXX: manufacturerID/manufacturerName can be undefined and won't match `includes`, but TS enforces same-type
if (
(!poll.manufacturerIDs.includes(endpoint.getDevice().manufacturerID) &&
!poll.manufacturerNames.includes(endpoint.getDevice().manufacturerName)) ||
(!poll.manufacturerIDs.includes(device.manufacturerID!) && !poll.manufacturerNames.includes(device.manufacturerName!)) ||
!endpoint.supportsInputCluster(poll.read.cluster)
) {
continue;
@@ -548,7 +562,7 @@ export default class Bind extends Extension {
readAttrs = [...poll.read.attributes, ...attrsForEndpoint];
}
const key = `${endpoint.getDevice().ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
const key = `${device.ieeeAddr}_${endpoint.ID}_${POLL_ON_MESSAGE.indexOf(poll)}`;
if (!this.pollDebouncers[key]) {
this.pollDebouncers[key] = debounce(async () => {
@@ -556,7 +570,7 @@ export default class Bind extends Extension {
await endpoint.read(poll.read.cluster, readAttrs);
} catch (error) {
logger.error(
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(endpoint.getDevice()).name} (${error.message})`,
`Failed to poll ${readAttrs} from ${this.zigbee.resolveEntity(device)!.name} (${(error as Error).message})`,
);
}
}, 1000);
+106 -59
View File
@@ -27,18 +27,24 @@ type DefinitionPayload = {
exposes: zhc.Expose[];
supports_ota: boolean;
icon: string;
options: zhc.Expose[];
options: zhc.Option[];
};
export default class Bridge extends Extension {
private zigbee2mqttVersion: {commitHash: string; version: string};
// @ts-expect-error initialized in `start`
private zigbee2mqttVersion: {commitHash?: string; version: string};
// @ts-expect-error initialized in `start`
private zigbeeHerdsmanVersion: {version: string};
// @ts-expect-error initialized in `start`
private zigbeeHerdsmanConvertersVersion: {version: string};
// @ts-expect-error initialized in `start`
private coordinatorVersion: zh.CoordinatorVersion;
private restartRequired = false;
private lastJoinedDeviceIeeeAddr: string;
private lastBridgeLoggingPayload: string;
private lastJoinedDeviceIeeeAddr?: string;
private lastBridgeLoggingPayload?: string;
// @ts-expect-error initialized in `start`
private logTransport: winston.transport;
// @ts-expect-error initialized in `start`
private requestLookup: {[key: string]: (message: KeyValue | string) => Promise<MQTTResponse>};
override async start(): Promise<void> {
@@ -111,10 +117,22 @@ export default class Bridge extends Extension {
this.zigbeeHerdsmanConvertersVersion = await utils.getDependencyVersion('zigbee-herdsman-converters');
this.coordinatorVersion = await this.zigbee.getCoordinatorVersion();
this.eventBus.onEntityRenamed(this, () => this.publishInfo());
this.eventBus.onGroupMembersChanged(this, () => this.publishGroups());
this.eventBus.onDevicesChanged(this, () => this.publishDevices() && this.publishInfo() && this.publishDefinitions());
this.eventBus.onPermitJoinChanged(this, () => !this.zigbee.isStopping() && this.publishInfo());
this.eventBus.onEntityRenamed(this, async () => {
await this.publishInfo();
});
this.eventBus.onGroupMembersChanged(this, async () => {
await this.publishGroups();
});
this.eventBus.onDevicesChanged(this, async () => {
await this.publishDevices();
await this.publishInfo();
await this.publishDefinitions();
});
this.eventBus.onPermitJoinChanged(this, async () => {
if (!this.zigbee.isStopping()) {
await this.publishInfo();
}
});
this.eventBus.onScenesChanged(this, async () => {
await this.publishDevices();
await this.publishGroups();
@@ -133,14 +151,18 @@ export default class Bridge extends Extension {
await this.publishDefinitions();
await publishEvent('device_leave', {ieee_address: data.ieeeAddr, friendly_name: data.name});
});
this.eventBus.onDeviceNetworkAddressChanged(this, () => this.publishDevices());
this.eventBus.onDeviceNetworkAddressChanged(this, async () => {
await this.publishDevices();
});
this.eventBus.onDeviceInterview(this, async (data) => {
await this.publishDevices();
const payload: KeyValue = {friendly_name: data.device.name, status: data.status, ieee_address: data.device.ieeeAddr};
if (data.status === 'successful') {
payload.supported = data.device.isSupported;
payload.definition = this.getDefinitionPayload(data.device);
}
await publishEvent('device_interview', payload);
});
this.eventBus.onDeviceAnnounce(this, async (data) => {
@@ -163,7 +185,13 @@ export default class Bridge extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(requestRegex);
const key = match?.[1]?.toLowerCase();
if (!match) {
return;
}
const key = match[1].toLowerCase();
if (key in this.requestLookup) {
const message = utils.parseJSON(data.message, data.message);
@@ -171,9 +199,9 @@ export default class Bridge extends Extension {
const response = await this.requestLookup[key](message);
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
} catch (error) {
logger.error(`Request '${data.topic}' failed with error: '${error.message}'`);
logger.debug(error.stack);
const response = utils.getResponse(message, {}, error.message);
logger.error(`Request '${data.topic}' failed with error: '${(error as Error).message}'`);
logger.debug((error as Error).stack!);
const response = utils.getResponse(message, {}, (error as Error).message);
await this.mqtt.publish(`bridge/response/${match[1]}`, stringify(response));
}
}
@@ -223,7 +251,7 @@ export default class Bridge extends Extension {
logger.info('Successfully changed options');
await this.publishInfo();
return utils.getResponse(message, {restart_required: this.restartRequired}, null);
return utils.getResponse(message, {restart_required: this.restartRequired});
}
@bind async deviceRemove(message: string | KeyValue): Promise<MQTTResponse> {
@@ -235,7 +263,7 @@ export default class Bridge extends Extension {
}
@bind async healthCheck(message: string | KeyValue): Promise<MQTTResponse> {
return utils.getResponse(message, {healthy: true}, null);
return utils.getResponse(message, {healthy: true});
}
@bind async coordinatorCheck(message: string | KeyValue): Promise<MQTTResponse> {
@@ -243,7 +271,7 @@ export default class Bridge extends Extension {
const missingRouters = result.missingRouters.map((d) => {
return {ieee_address: d.ieeeAddr, friendly_name: d.name};
});
return utils.getResponse(message, {missing_routers: missingRouters}, null);
return utils.getResponse(message, {missing_routers: missingRouters});
}
@bind async groupAdd(message: string | KeyValue): Promise<MQTTResponse> {
@@ -256,7 +284,7 @@ export default class Bridge extends Extension {
const group = settings.addGroup(friendlyName, ID);
this.zigbee.createGroup(group.ID);
await this.publishGroups();
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID}, null);
return utils.getResponse(message, {friendly_name: group.friendly_name, id: group.ID});
}
@bind async deviceRename(message: string | KeyValue): Promise<MQTTResponse> {
@@ -271,7 +299,7 @@ export default class Bridge extends Extension {
// Wait 500 ms before restarting so response can be send.
setTimeout(this.restartCallback, 500);
logger.info('Restarting Zigbee2MQTT');
return utils.getResponse(message, {}, null);
return utils.getResponse(message, {});
}
@bind async backup(message: string | KeyValue): Promise<MQTTResponse> {
@@ -284,7 +312,7 @@ export default class Bridge extends Extension {
const zip = new JSZip();
files.forEach((f) => zip.file(f[1], fs.readFileSync(f[0])));
const base64Zip = await zip.generateAsync({type: 'base64'});
return utils.getResponse(message, {zip: base64Zip}, null);
return utils.getResponse(message, {zip: base64Zip});
}
@bind async installCodeAdd(message: KeyValue | string): Promise<MQTTResponse> {
@@ -295,7 +323,7 @@ export default class Bridge extends Extension {
const value = typeof message === 'object' ? message.value : message;
await this.zigbee.addInstallCode(value);
logger.info('Successfully added new install code');
return utils.getResponse(message, {value}, null);
return utils.getResponse(message, {value});
}
@bind async permitJoin(message: KeyValue | string): Promise<MQTTResponse> {
@@ -304,13 +332,16 @@ export default class Bridge extends Extension {
}
let value: boolean | string;
let time: number;
let device: Device = null;
let time: number | undefined;
let device: Device | undefined;
if (typeof message === 'object') {
value = message.value;
time = message.time;
if (message.device) {
const resolved = this.zigbee.resolveEntity(message.device);
if (resolved instanceof Device) {
device = resolved;
} else {
@@ -326,10 +357,20 @@ export default class Bridge extends Extension {
}
await this.zigbee.permitJoin(value, device, time);
const response: {value: boolean; device?: string; time?: number} = {value};
if (device && typeof message === 'object') response.device = message.device;
if (time && typeof message === 'object') response.time = message.time;
return utils.getResponse(message, response, null);
if (typeof message === 'object') {
if (device) {
response.device = message.device;
}
if (time != undefined) {
response.time = message.time;
}
}
return utils.getResponse(message, response);
}
// Deprecated
@@ -342,7 +383,7 @@ export default class Bridge extends Extension {
settings.set(['advanced', 'last_seen'], value);
await this.publishInfo();
return utils.getResponse(message, {value}, null);
return utils.getResponse(message, {value});
}
// Deprecated
@@ -353,10 +394,10 @@ export default class Bridge extends Extension {
throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`);
}
await this.enableDisableExtension(value, 'HomeAssistant');
settings.set(['homeassistant'], value);
await this.enableDisableExtension(value, 'HomeAssistant');
await this.publishInfo();
return utils.getResponse(message, {value}, null);
return utils.getResponse(message, {value});
}
// Deprecated
@@ -369,7 +410,7 @@ export default class Bridge extends Extension {
settings.set(['advanced', 'elapsed'], value);
await this.publishInfo();
return utils.getResponse(message, {value}, null);
return utils.getResponse(message, {value});
}
// Deprecated
@@ -381,7 +422,7 @@ export default class Bridge extends Extension {
logger.setLevel(value);
await this.publishInfo();
return utils.getResponse(message, {value}, null);
return utils.getResponse(message, {value});
}
@bind async touchlinkIdentify(message: KeyValue | string): Promise<MQTTResponse> {
@@ -391,7 +432,7 @@ export default class Bridge extends Extension {
logger.info(`Start Touchlink identify of '${message.ieee_address}' on channel ${message.channel}`);
await this.zigbee.touchlinkIdentify(message.ieee_address, message.channel);
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel}, null);
return utils.getResponse(message, {ieee_address: message.ieee_address, channel: message.channel});
}
@bind async touchlinkFactoryReset(message: KeyValue | string): Promise<MQTTResponse> {
@@ -409,7 +450,7 @@ export default class Bridge extends Extension {
if (result) {
logger.info('Successfully factory reset device through Touchlink');
return utils.getResponse(message, payload, null);
return utils.getResponse(message, payload);
} else {
logger.error('Failed to factory reset device through Touchlink');
throw new Error('Failed to factory reset device through Touchlink');
@@ -423,7 +464,7 @@ export default class Bridge extends Extension {
return {ieee_address: r.ieeeAddr, channel: r.channel};
});
logger.info('Finished Touchlink scan');
return utils.getResponse(message, {found}, null);
return utils.getResponse(message, {found});
}
/**
@@ -467,7 +508,7 @@ export default class Bridge extends Extension {
logger.info(`Changed config for ${entityType} ${ID}`);
this.eventBus.emitEntityOptionsChanged({from: oldOptions, to: newOptions, entity});
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired}, null);
return utils.getResponse(message, {from: oldOptions, to: newOptions, id: ID, restart_required: this.restartRequired});
}
@bind async deviceConfigureReporting(message: string | KeyValue): Promise<MQTTResponse> {
@@ -484,10 +525,12 @@ export default class Bridge extends Extension {
}
const device = this.zigbee.resolveEntityAndEndpoint(message.id);
if (!device.entity) throw new Error(`Device '${message.id}' does not exist`);
if (!device.entity) {
throw new Error(`Device '${message.id}' does not exist`);
}
const endpoint = device.endpoint;
if (device.endpointID && !endpoint) {
if (!endpoint) {
throw new Error(`Device '${device.ID}' does not have endpoint '${device.endpointID}'`);
}
@@ -511,18 +554,14 @@ export default class Bridge extends Extension {
logger.info(`Configured reporting for '${message.id}', '${message.cluster}.${message.attribute}'`);
return utils.getResponse(
message,
{
id: message.id,
cluster: message.cluster,
maximum_report_interval: message.maximum_report_interval,
minimum_report_interval: message.minimum_report_interval,
reportable_change: message.reportable_change,
attribute: message.attribute,
},
null,
);
return utils.getResponse(message, {
id: message.id,
cluster: message.cluster,
maximum_report_interval: message.maximum_report_interval,
minimum_report_interval: message.minimum_report_interval,
reportable_change: message.reportable_change,
attribute: message.attribute,
});
}
@bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> {
@@ -545,7 +584,7 @@ export default class Bridge extends Extension {
this.eventBus.emitDevicesChanged();
this.eventBus.emitExposesChanged({device});
return utils.getResponse(message, {id: message.id}, null);
return utils.getResponse(message, {id: message.id});
}
@bind async deviceGenerateExternalDefinition(message: string | KeyValue): Promise<MQTTResponse> {
@@ -554,15 +593,19 @@ export default class Bridge extends Extension {
}
const device = this.zigbee.resolveEntityAndEndpoint(message.id).entity as Device;
if (!device) throw new Error(`Device '${message.id}' does not exist`);
if (!device) {
throw new Error(`Device '${message.id}' does not exist`);
}
const source = await zhc.generateExternalDefinitionSource(device.zh);
return utils.getResponse(message, {id: message.id, source}, null);
return utils.getResponse(message, {id: message.id, source});
}
async renameEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
const deviceAndHasLast = entityType === 'device' && typeof message === 'object' && message.last === true;
if (typeof message !== 'object' || (!message.hasOwnProperty('from') && !deviceAndHasLast) || !message.hasOwnProperty('to')) {
throw new Error(`Invalid payload`);
}
@@ -594,7 +637,7 @@ export default class Bridge extends Extension {
// Republish entity state
await this.publishEntityState(entity, {});
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename}, null);
return utils.getResponse(message, {from: oldFriendlyName, to, homeassistant_rename: homeAssisantRename});
}
async removeEntity(entityType: 'group' | 'device', message: string | KeyValue): Promise<MQTTResponse> {
@@ -665,10 +708,10 @@ export default class Bridge extends Extension {
await this.publishDevices();
// Refresh Cluster definition
await this.publishDefinitions();
return utils.getResponse(message, {id: ID, block, force}, null);
return utils.getResponse(message, {id: ID, block, force});
} else {
await this.publishGroups();
return utils.getResponse(message, {id: ID, force: force}, null);
return utils.getResponse(message, {id: ID, force: force});
}
} catch (error) {
throw new Error(`Failed to remove ${entityType} '${friendlyName}'${blockForceLog} (${error})`);
@@ -685,11 +728,14 @@ export default class Bridge extends Extension {
async publishInfo(): Promise<void> {
const config = objectAssignDeep({}, settings.get());
// @ts-expect-error hidden from publish
delete config.advanced.network_key;
delete config.mqtt.password;
if (config.frontend) {
delete config.frontend.auth_token;
}
const payload = {
version: this.zigbee2mqttVersion.version,
commit: this.zigbee2mqttVersion.commitHash,
@@ -743,7 +789,7 @@ export default class Bridge extends Extension {
};
for (const bind of endpoint.binds) {
const target = utils.isEndpoint(bind.target)
const target = utils.isZHEndpoint(bind.target)
? {type: 'endpoint', ieee_address: bind.target.getDevice().ieeeAddr, endpoint: bind.target.ID}
: {type: 'group', id: bind.target.groupID};
data.bindings.push({cluster: bind.cluster.name, target});
@@ -826,9 +872,9 @@ export default class Bridge extends Extension {
await this.mqtt.publish('bridge/definitions', stringify(data), {retain: true, qos: 0}, settings.get().mqtt.base_topic, true);
}
getDefinitionPayload(device: Device): DefinitionPayload | null {
getDefinitionPayload(device: Device): DefinitionPayload | undefined {
if (!device.definition) {
return null;
return undefined;
}
// TODO: better typing to avoid @ts-expect-error
@@ -837,7 +883,8 @@ export default class Bridge extends Extension {
let icon = device.options.icon ?? definitionIcon;
if (icon) {
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID));
/* istanbul ignore next */
icon = icon.replace('${zigbeeModel}', utils.sanitizeImageParameter(device.zh.modelID ?? ''));
icon = icon.replace('${model}', utils.sanitizeImageParameter(device.definition.model));
}
@@ -847,7 +894,7 @@ export default class Bridge extends Extension {
description: device.definition.description,
exposes: device.exposes(),
supports_ota: !!device.definition.ota,
options: device.definition.options,
options: device.definition.options ?? [],
icon,
};
+8 -4
View File
@@ -44,7 +44,7 @@ export default class Configure extends Extension {
} else if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message);
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
let error = null;
let error: string | undefined;
const device = this.zigbee.resolveEntity(ID);
if (!device || !(device instanceof Device)) {
@@ -55,7 +55,7 @@ export default class Configure extends Extension {
try {
await this.configure(device, 'mqtt_message', true, true);
} catch (e) {
error = `Failed to configure (${e.message})`;
error = `Failed to configure (${(e as Error).message})`;
}
}
@@ -95,8 +95,12 @@ export default class Configure extends Extension {
force = false,
throwError = false,
): Promise<void> {
if (!device.definition?.configure) {
return;
}
if (!force) {
if (device.options.disabled || !device.definition?.configure || !device.zh.interviewCompleted) {
if (device.options.disabled || !device.zh.interviewCompleted) {
return;
}
@@ -130,7 +134,7 @@ export default class Configure extends Extension {
} catch (error) {
this.attempts[device.ieeeAddr]++;
const attempt = this.attempts[device.ieeeAddr];
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${(error as Error).stack})`;
logger.error(msg);
if (throwError) {
+1 -1
View File
@@ -27,7 +27,7 @@ export default class ExternalConverters extends Extension {
}
logger.info(`Loaded external converter '${file}'`);
} catch (error) {
logger.error(`Failed to load external converter file '${file}' (${error.message})`);
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`,
+8 -6
View File
@@ -12,11 +12,13 @@ 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>};
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);
this.requestLookup = {save: this.saveExtension, remove: this.removeExtension};
await this.loadUserDefinedExtensions();
await this.publishExtensions();
}
@@ -52,7 +54,7 @@ export default class ExternalExtension extends Extension {
fs.unlinkSync(extensionFilePath);
await this.publishExtensions();
logger.info(`Extension ${name} removed`);
return utils.getResponse(message, {}, null);
return utils.getResponse(message, {});
} else {
return utils.getResponse(message, {}, `Extension ${name} doesn't exists`);
}
@@ -71,7 +73,7 @@ export default class ExternalExtension extends Extension {
fs.writeFileSync(extensionFilePath, code);
await this.publishExtensions();
logger.info(`Extension ${name} loaded`);
return utils.getResponse(message, {}, null);
return utils.getResponse(message, {});
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
@@ -82,8 +84,8 @@ export default class ExternalExtension extends Extension {
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.message}'`);
const response = utils.getResponse(message, {}, error.message);
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));
}
}
+40 -18
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
import finalhandler from 'finalhandler';
@@ -19,16 +20,37 @@ import Extension from './extension';
* This extension servers the frontend
*/
export default class Frontend extends Extension {
private mqttBaseTopic = settings.get().mqtt.base_topic;
private host = settings.get().frontend.host;
private port = settings.get().frontend.port;
private sslCert = settings.get().frontend.ssl_cert;
private sslKey = settings.get().frontend.ssl_key;
private authToken = settings.get().frontend.auth_token;
private server: http.Server;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private fileServer: RequestHandler;
private wss: WebSocket.Server = null;
private mqttBaseTopic: string;
private host: string | undefined;
private port: number;
private sslCert: string | undefined;
private sslKey: string | undefined;
private authToken: string | undefined;
private server: http.Server | undefined;
private fileServer: RequestHandler | undefined;
private wss: WebSocket.Server | undefined;
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);
const frontendSettings = settings.get().frontend;
assert(frontendSettings, 'Frontend extension created without having frontend settings');
this.host = frontendSettings.host;
this.port = frontendSettings.port;
this.sslCert = frontendSettings.ssl_cert;
this.sslKey = frontendSettings.ssl_key;
this.authToken = frontendSettings.auth_token;
this.mqttBaseTopic = settings.get().mqtt.base_topic;
}
private isHttpsConfigured(): boolean {
if (this.sslCert && this.sslKey) {
@@ -44,8 +66,8 @@ export default class Frontend extends Extension {
override async start(): Promise<void> {
if (this.isHttpsConfigured()) {
const serverOptions = {
key: fs.readFileSync(this.sslKey),
cert: fs.readFileSync(this.sslCert),
key: fs.readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
cert: fs.readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
};
this.server = https.createServer(serverOptions, this.onRequest);
} else {
@@ -90,7 +112,7 @@ export default class Frontend extends Extension {
this.wss?.close();
/* istanbul ignore else */
if (this.server) {
return new Promise((cb: () => void) => this.server.close(cb));
return new Promise((cb: () => void) => this.server!.close(cb));
}
}
@@ -100,15 +122,15 @@ export default class Frontend extends Extension {
}
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = url.parse(request.url, true);
const {query} = url.parse(request.url!, true);
cb(!this.authToken || this.authToken === query.token);
}
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss!.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthenticated) => {
if (isAuthenticated) {
this.wss.emit('connection', ws, request);
this.wss!.emit('connection', ws, request);
} else {
ws.close(4401, 'Unauthorized');
}
@@ -144,7 +166,7 @@ export default class Frontend extends Extension {
const lastSeen = settings.get().advanced.last_seen;
/* istanbul ignore if */
if (lastSeen !== 'disable') {
payload.last_seen = utils.formatDate(device.zh.lastSeen, lastSeen);
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? 0, lastSeen);
}
if (device.zh.linkquality !== undefined) {
@@ -162,7 +184,7 @@ export default class Frontend extends Extension {
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
const payload = utils.parseJSON(data.payload, data.payload);
for (const client of this.wss.clients) {
for (const client of this.wss!.clients) {
/* istanbul ignore else */
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));
+49 -37
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import equals from 'fast-deep-equal/es6';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -7,7 +8,7 @@ import Device from '../model/device';
import Group from '../model/group';
import logger from '../util/logger';
import * as settings from '../util/settings';
import utils from '../util/utils';
import utils, {isLightExpose} from '../util/utils';
import Extension from './extension';
const TOPIC_REGEX = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/group/members/(remove|add|remove_all)$`);
@@ -16,27 +17,27 @@ const LEGACY_TOPIC_REGEX_REMOVE_ALL = new RegExp(`^${settings.get().mqtt.base_to
const STATE_PROPERTIES: Readonly<Record<string, (value: string, exposes: zhc.Expose[]) => boolean>> = {
state: () => true,
brightness: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'brightness')),
color_temp: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_temp')),
color: (value, exposes) => exposes.some((e) => e.type === 'light' && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
brightness: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'brightness')),
color_temp: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_temp')),
color: (value, exposes) => exposes.some((e) => isLightExpose(e) && e.features.some((f) => f.name === 'color_xy' || f.name === 'color_hs')),
color_mode: (value, exposes) =>
exposes.some(
(e) =>
e.type === 'light' &&
isLightExpose(e) &&
(e.features.some((f) => f.name === `color_${value}`) || (value === 'color_temp' && e.features.some((f) => f.name === 'color_temp'))),
),
};
interface ParsedMQTTMessage {
type: 'remove' | 'add' | 'remove_all';
resolvedEntityGroup: Group;
resolvedEntityGroup?: Group;
resolvedEntityDevice: Device;
error: string;
groupKey: string;
deviceKey: string;
error?: string;
groupKey?: string;
deviceKey?: string;
triggeredViaLegacyApi: boolean;
skipDisableReporting: boolean;
resolvedEntityEndpoint: zh.Endpoint;
resolvedEntityEndpoint?: zh.Endpoint;
}
export default class Groups extends Extension {
@@ -69,7 +70,7 @@ export default class Groups extends Extension {
}
} catch (error) {
logger.error(`Failed to ${action} '${deviceName}' from '${groupName}'`);
logger.debug(error.stack);
logger.debug((error as Error).stack!);
}
};
@@ -105,7 +106,7 @@ export default class Groups extends Extension {
// In zigbee but not in settings
for (const endpoint of zigbeeGroup.zh.members) {
if (!settingsEndpoints.includes(endpoint)) {
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name;
await addRemoveFromGroup('remove', deviceName, settingGroup.friendly_name, endpoint, zigbeeGroup);
}
@@ -114,7 +115,7 @@ export default class Groups extends Extension {
for (const zigbeeGroup of this.zigbee.groupsIterator((zg) => !settingsGroups.some((sg) => sg.ID === zg.groupID))) {
for (const endpoint of zigbeeGroup.zh.members) {
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr).friendly_name;
const deviceName = settings.getDevice(endpoint.getDevice().ieeeAddr)!.friendly_name;
await addRemoveFromGroup('remove', deviceName, zigbeeGroup.ID, endpoint, zigbeeGroup);
}
@@ -129,7 +130,7 @@ export default class Groups extends Extension {
}
const payload: KeyValue = {};
let endpointName: string = null;
let endpointName: string | undefined;
const endpointNames: string[] = data.entity instanceof Device ? data.entity.getEndpointNames() : [];
for (let prop of Object.keys(data.update)) {
@@ -159,15 +160,20 @@ export default class Groups extends Extension {
}
if (entity instanceof Device) {
for (const group of groups) {
if (
group.zh.hasMember(entity.endpoint(endpointName)) &&
!equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)
) {
this.lastOptimisticState[group.ID] = payload;
const endpoint = entity.endpoint(endpointName);
await this.publishEntityState(group, payload, reason);
/* istanbul ignore else */
if (endpoint) {
for (const group of groups) {
if (
group.zh.hasMember(endpoint) &&
!equals(this.lastOptimisticState[group.ID], payload) &&
this.shouldPublishPayloadForGroup(group, payload)
) {
this.lastOptimisticState[group.ID] = payload;
await this.publishEntityState(group, payload, reason);
}
}
}
} else {
@@ -225,7 +231,7 @@ export default class Groups extends Extension {
private areAllMembersOff(group: Group): boolean {
for (const member of group.zh.members) {
const device = this.zigbee.resolveEntity(member.getDevice());
const device = this.zigbee.resolveEntity(member.getDevice())!;
if (this.state.exists(device)) {
const state = this.state.get(device);
@@ -239,14 +245,14 @@ export default class Groups extends Extension {
return true;
}
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage> {
let type: ParsedMQTTMessage['type'] = null;
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] = null;
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] = null;
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] = null;
let error: ParsedMQTTMessage['error'] = null;
let groupKey: ParsedMQTTMessage['groupKey'] = null;
let deviceKey: ParsedMQTTMessage['deviceKey'] = null;
private async parseMQTTMessage(data: eventdata.MQTTMessage): Promise<ParsedMQTTMessage | undefined> {
let type: ParsedMQTTMessage['type'] | undefined;
let resolvedEntityGroup: ParsedMQTTMessage['resolvedEntityGroup'] | undefined;
let resolvedEntityDevice: ParsedMQTTMessage['resolvedEntityDevice'] | undefined;
let resolvedEntityEndpoint: ParsedMQTTMessage['resolvedEntityEndpoint'] | undefined;
let error: ParsedMQTTMessage['error'] | undefined;
let groupKey: ParsedMQTTMessage['groupKey'] | undefined;
let deviceKey: ParsedMQTTMessage['deviceKey'] | undefined;
let triggeredViaLegacyApi: ParsedMQTTMessage['triggeredViaLegacyApi'] = false;
let skipDisableReporting: ParsedMQTTMessage['skipDisableReporting'] = false;
@@ -272,7 +278,7 @@ export default class Groups extends Extension {
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
}
return null;
return undefined;
}
} else {
type = 'remove_all';
@@ -286,19 +292,19 @@ export default class Groups extends Extension {
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
const message = {friendly_name: data.message, group: legacyTopicRegexMatch[1], error: "entity doesn't exists"};
const message = {friendly_name: data.message, group: legacyTopicRegexMatch![1], error: "entity doesn't exists"};
await this.mqtt.publish('bridge/log', stringify({type: `device_group_${type}_failed`, message}));
}
return null;
return undefined;
}
resolvedEntityEndpoint = parsedEntity.endpoint;
if (parsedEntity.endpointID && !resolvedEntityEndpoint) {
logger.error(`Device '${parsedEntity.ID}' does not have endpoint '${parsedEntity.endpointID}'`);
return null;
return undefined;
}
} else if (topicRegexMatch) {
type = topicRegexMatch[1] as 'remove' | 'add' | 'remove_all';
@@ -329,6 +335,8 @@ export default class Groups extends Extension {
error = `Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`;
}
}
} else {
return undefined;
}
return {
@@ -365,6 +373,7 @@ export default class Groups extends Extension {
const changedGroups: Group[] = [];
if (!error) {
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
try {
const keys = [
`${resolvedEntityDevice.ieeeAddr}/${resolvedEntityEndpoint.ID}`,
@@ -383,6 +392,7 @@ export default class Groups extends Extension {
}
if (type === 'add') {
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
logger.info(`Adding '${resolvedEntityDevice.name}' to '${resolvedEntityGroup.name}'`);
await resolvedEntityEndpoint.addToGroup(resolvedEntityGroup.zh);
settings.addDeviceToGroup(resolvedEntityGroup.ID.toString(), keys);
@@ -395,6 +405,7 @@ export default class Groups extends Extension {
await this.mqtt.publish('bridge/log', stringify({type: `device_group_add`, message}));
}
} else if (type === 'remove') {
assert(resolvedEntityGroup, '`resolvedEntityGroup` is missing');
logger.info(`Removing '${resolvedEntityDevice.name}' from '${resolvedEntityGroup.name}'`);
await resolvedEntityEndpoint.removeFromGroup(resolvedEntityGroup.zh);
settings.removeDeviceFromGroup(resolvedEntityGroup.ID.toString(), keys);
@@ -428,8 +439,8 @@ export default class Groups extends Extension {
}
}
} catch (e) {
error = `Failed to ${type} from group (${e.message})`;
logger.debug(e.stack);
error = `Failed to ${type} from group (${(e as Error).message})`;
logger.debug((e as Error).stack!);
}
}
@@ -447,6 +458,7 @@ export default class Groups extends Extension {
if (error) {
logger.error(error);
} else {
assert(resolvedEntityEndpoint, '`resolvedEntityEndpoint` is missing');
for (const group of changedGroups) {
this.eventBus.emitGroupMembersChanged({group, action: type, endpoint: resolvedEntityEndpoint, skipDisableReporting});
}
File diff suppressed because it is too large Load Diff
+36 -33
View File
@@ -10,7 +10,8 @@ import Extension from '../extension';
const configRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/config/((?:\\w+/get)|(?:\\w+/factory_reset)|(?:\\w+))`);
export default class BridgeLegacy extends Extension {
private lastJoinedDeviceName: string = null;
private lastJoinedDeviceName?: string;
// @ts-expect-error initialized in `start`
private supportedOptions: {[s: string]: (topic: string, message: string) => Promise<void> | void};
override async start(): Promise<void> {
@@ -39,7 +40,7 @@ export default class BridgeLegacy extends Extension {
this.eventBus.onDeviceJoined(this, (data) => this.onZigbeeEvent_('deviceJoined', data, data.device));
this.eventBus.onDeviceInterview(this, (data) => this.onZigbeeEvent_('deviceInterview', data, data.device));
this.eventBus.onDeviceAnnounce(this, (data) => this.onZigbeeEvent_('deviceAnnounce', data, data.device));
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, null));
this.eventBus.onDeviceLeave(this, (data) => this.onZigbeeEvent_('deviceLeave', data, undefined));
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
await this.publish();
@@ -206,11 +207,11 @@ export default class BridgeLegacy extends Extension {
async _renameInternal(from: string, to: string): Promise<void> {
try {
const isGroup = settings.getGroup(from) !== null;
const isGroup = settings.getGroup(from) != undefined;
settings.changeFriendlyName(from, to);
logger.info(`Successfully renamed - ${from} to ${to} `);
const entity = this.zigbee.resolveEntity(to);
if (entity.isDevice()) {
if (entity?.isDevice()) {
this.eventBus.emitEntityRenamed({homeAssisantRename: false, from, to, entity});
}
@@ -333,11 +334,13 @@ export default class BridgeLegacy extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const {topic, message} = data;
if (!topic.match(configRegex)) {
const match = topic.match(configRegex);
if (!match) {
return;
}
const option = topic.match(configRegex)[1];
const option = match[1];
if (!this.supportedOptions.hasOwnProperty(option)) {
return;
@@ -364,36 +367,36 @@ export default class BridgeLegacy extends Extension {
await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0});
}
async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device): Promise<void> {
if (type === 'deviceJoined' && resolvedEntity) {
this.lastJoinedDeviceName = resolvedEntity.name;
}
if (type === 'deviceJoined') {
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
} else if (type === 'deviceInterview') {
if (data.status === 'successful') {
if (resolvedEntity.isSupported) {
const {vendor, description, model} = resolvedEntity.definition;
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
} else {
const meta = {friendly_name: resolvedEntity.name, supported: false};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
}
} else if (data.status === 'failed') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
} else {
/* istanbul ignore else */
if (data.status === 'started') {
async onZigbeeEvent_(type: string, data: KeyValue, resolvedEntity: Device | undefined): Promise<void> {
if (resolvedEntity) {
/* istanbul ignore else */
if (type === 'deviceJoined') {
this.lastJoinedDeviceName = resolvedEntity.name;
await this.mqtt.publish('bridge/log', stringify({type: `device_connected`, message: {friendly_name: resolvedEntity.name}}));
} else if (type === 'deviceInterview') {
if (data.status === 'successful') {
if (resolvedEntity.isSupported) {
const {vendor, description, model} = resolvedEntity.definition!; // checked by `isSupported`
const log = {friendly_name: resolvedEntity.name, model, vendor, description, supported: true};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta: log}));
} else {
const meta = {friendly_name: resolvedEntity.name, supported: false};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_successful', meta}));
}
} else if (data.status === 'failed') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_failed', meta}));
} else {
/* istanbul ignore else */
if (data.status === 'started') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish('bridge/log', stringify({type: `pairing`, message: 'interview_started', meta}));
}
}
} else if (type === 'deviceAnnounce') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
}
} else if (type === 'deviceAnnounce') {
const meta = {friendly_name: resolvedEntity.name};
await this.mqtt.publish('bridge/log', stringify({type: `device_announced`, message: 'announce', meta}));
} else {
/* istanbul ignore else */
if (type === 'deviceLeave') {
@@ -1,4 +1,5 @@
/* istanbul ignore file */
import assert from 'assert';
import bind from 'bind-decorator';
import Device from '../../model/device';
@@ -15,23 +16,27 @@ export default class DeviceGroupMembership extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const match = data.topic.match(topicRegex);
if (!match) {
return null;
return;
}
const parsed = this.zigbee.resolveEntityAndEndpoint(match[1]);
const device = parsed?.entity as Device;
if (!device || !(device instanceof Device)) {
logger.error(`Device '${match[1]}' does not exist`);
return;
}
const endpoint = parsed.endpoint;
if (parsed.endpointID && !endpoint) {
logger.error(`Device '${parsed.ID}' does not have endpoint '${parsed.endpointID}'`);
return;
}
assert(endpoint !== undefined);
const response = await endpoint.command(`genGroups`, 'getMembership', {groupcount: 0, grouplist: []}, {});
if (!response) {
+13 -8
View File
@@ -74,7 +74,7 @@ export default class Report extends Extension {
private failed: Set<string> = new Set();
private enabled = settings.get().advanced.report;
shouldIgnoreClusterForDevice(cluster: string, definition: zhc.Definition): boolean {
shouldIgnoreClusterForDevice(cluster: string, definition?: zhc.Definition): boolean {
if (definition === ZNLDP12LM && cluster === 'closuresWindowCovering') {
// Device announces it but doesn't support it
// https://github.com/Koenkk/zigbee2mqtt/issues/2611
@@ -99,7 +99,7 @@ export default class Report extends Extension {
const items = [];
for (const entry of configuration) {
if (!entry.hasOwnProperty('condition') || (await entry.condition(ep))) {
if (entry.condition == undefined || (await entry.condition(ep))) {
const toAdd = {...entry};
if (!this.enabled) toAdd.maximumReportInterval = 0xffff;
items.push(toAdd);
@@ -128,7 +128,7 @@ export default class Report extends Extension {
this.eventBus.emitDevicesChanged();
} catch (error) {
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${error.stack}`);
logger.error(`Failed to ${term1.toLowerCase()} reporting for '${device.ieeeAddr}' - ${(error as Error).stack}`);
this.failed.add(device.ieeeAddr);
}
@@ -137,7 +137,7 @@ export default class Report extends Extension {
this.queue.delete(device.ieeeAddr);
}
shouldSetupReporting(device: Device, messageType: string): boolean {
shouldSetupReporting(device: Device, messageType?: string): boolean {
if (!device || !device.zh || !device.definition) return false;
// Handle messages of type endDeviceAnnce and devIncoming.
@@ -157,10 +157,15 @@ export default class Report extends Extension {
return true;
}
// These do not support reproting.
// These do not support reporting.
// https://github.com/Koenkk/zigbee-herdsman/issues/110
const philipsIgnoreSw = ['5.127.1.26581', '5.130.1.30000'];
if (device.zh.manufacturerName === 'Philips' && philipsIgnoreSw.includes(device.zh.softwareBuildID)) return false;
if (
device.zh.manufacturerName === 'Philips' &&
/* istanbul ignore next */
(device.zh.softwareBuildID === '5.127.1.26581' || device.zh.softwareBuildID === '5.130.1.30000')
) {
return false;
}
if (device.zh.interviewing === true) return false;
if (device.zh.type !== 'Router' || device.zh.powerSource === 'Battery') return false;
@@ -180,7 +185,7 @@ export default class Report extends Extension {
override async start(): Promise<void> {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
if (this.shouldSetupReporting(device, null)) {
if (this.shouldSetupReporting(device, undefined)) {
await this.setupReporting(device);
}
}
+5 -7
View File
@@ -9,7 +9,7 @@ import Extension from '../extension';
* This extensions soft resets the ZNP after a certain timeout.
*/
export default class SoftReset extends Extension {
private timer: NodeJS.Timeout = null;
private timer?: NodeJS.Timeout;
private timeout = utils.seconds(settings.get().advanced.soft_reset_timeout);
override async start(): Promise<void> {
@@ -23,10 +23,8 @@ export default class SoftReset extends Extension {
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
clearTimeout(this.timer);
this.timer = undefined;
}
private resetTimer(): void {
@@ -45,7 +43,7 @@ export default class SoftReset extends Extension {
await this.zigbee.reset('soft');
logger.warning('Soft reset ZNP due to timeout');
} catch (error) {
logger.warning(`Soft reset failed, trying stop/start (${error.message})`);
logger.warning(`Soft reset failed, trying stop/start (${(error as Error).message})`);
await this.zigbee.stop();
logger.warning('Zigbee stopped');
@@ -53,7 +51,7 @@ export default class SoftReset extends Extension {
try {
await this.zigbee.start();
} catch (error) {
logger.error(`Failed to restart! (${error.message})`);
logger.error(`Failed to restart! (${(error as Error).message})`);
}
}
+18 -19
View File
@@ -25,11 +25,11 @@ interface Topology {
friendlyName: string;
type: string;
networkAddress: number;
manufacturerName: string;
modelID: string;
manufacturerName: string | undefined;
modelID: string | undefined;
failed: string[];
lastSeen: number;
definition: {model: string; vendor: string; supports: string; description: string};
lastSeen: number | undefined;
definition?: {model: string; vendor: string; supports: string; description: string};
}[];
links: Link[];
}
@@ -42,15 +42,14 @@ export default class NetworkMap extends Extension {
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/networkmap`;
private legacyTopicRoutes = `${settings.get().mqtt.base_topic}/bridge/networkmap/routes`;
private topic = `${settings.get().mqtt.base_topic}/bridge/request/networkmap`;
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string};
private supportedFormats: {[s: string]: (topology: Topology) => KeyValue | string} = {
raw: this.raw,
graphviz: this.graphviz,
plantuml: this.plantuml,
};
override async start(): Promise<void> {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
this.supportedFormats = {
raw: this.raw,
graphviz: this.graphviz,
plantuml: this.plantuml,
};
}
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
@@ -76,9 +75,9 @@ export default class NetworkMap extends Extension {
const routes = typeof message === 'object' && message.routes;
const topology = await this.networkScan(routes);
const value = this.supportedFormats[type](topology);
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value}, null)));
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {routes, type, value})));
} catch (error) {
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, error.message)));
await this.mqtt.publish('bridge/response/networkmap', stringify(utils.getResponse(message, {}, (error as Error).message)));
}
}
}
@@ -238,9 +237,9 @@ export default class NetworkMap extends Extension {
lqis.set(device, result);
logger.debug(`LQI succeeded for '${device.name}'`);
} catch (error) {
failed.get(device).push('lqi');
failed.get(device)!.push('lqi'); // set above
logger.error(`Failed to execute LQI for '${device.name}'`);
logger.debug(error.stack);
logger.debug((error as Error).stack!);
}
if (includeRoutes) {
@@ -249,9 +248,9 @@ export default class NetworkMap extends Extension {
routingTables.set(device, result);
logger.debug(`Routing table succeeded for '${device.name}'`);
} catch (error) {
failed.get(device).push('routingTable');
failed.get(device)!.push('routingTable'); // set above
logger.error(`Failed to execute routing table for '${device.name}'`);
logger.debug(error.stack);
logger.debug((error as Error).stack!);
}
}
}
@@ -275,12 +274,12 @@ export default class NetworkMap extends Extension {
supports: Array.from(
new Set(
device.exposes().map((e) => {
return e.name ?? `${e.type} (${e.features.map((f) => f.name).join(', ')})`;
return e.name ?? `${e.type} (${e.features?.map((f) => f.name).join(', ')})`;
}),
),
).join(', '),
}
: null;
: undefined;
topology.nodes.push({
ieeeAddr: device.ieeeAddr,
@@ -289,7 +288,7 @@ export default class NetworkMap extends Extension {
networkAddress: device.zh.networkAddress,
manufacturerName: device.zh.manufacturerName,
modelID: device.zh.modelID,
failed: failed.get(device),
failed: failed.get(device)!,
lastSeen: device.zh.lastSeen,
definition,
});
+33 -21
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import path from 'path';
@@ -86,8 +87,8 @@ export default class OTAUpdate extends Extension {
logger.debug(`Device '${data.device.name}' requested OTA`);
const automaticOTACheckDisabled = settings.get().ota.disable_automatic_update_check;
let supportsOTA = !!data.device.definition.ota;
if (supportsOTA && !automaticOTACheckDisabled) {
if (data.device.definition.ota && !automaticOTACheckDisabled) {
// When a device does a next image request, it will usually do it a few times after each other
// with only 10 - 60 seconds inbetween. It doesn't make sense to check for a new update
// each time, so this interval can be set by the user. The default is 1,440 minutes (one day).
@@ -98,13 +99,12 @@ export default class OTAUpdate extends Extension {
if (!check) return;
this.lastChecked[data.device.ieeeAddr] = Date.now();
let availableResult: zhc.OtaUpdateAvailableResult = null;
let availableResult: zhc.OtaUpdateAvailableResult | undefined;
try {
availableResult = await data.device.definition.ota.isUpdateAvailable(data.device.zh, data.data as zhc.ota.ImageInfo);
} catch (e) {
supportsOTA = false;
logger.debug(`Failed to check if update available for '${data.device.name}' (${e.message})`);
logger.debug(e.stack);
} catch (error) {
logger.debug(`Failed to check if update available for '${data.device.name}' (${error})`);
}
const payload = this.getEntityPublishPayload(data.device, availableResult ?? 'idle');
@@ -134,21 +134,25 @@ export default class OTAUpdate extends Extension {
logger.debug(`Responded to OTA request of '${data.device.name}' with 'NO_IMAGE_AVAILABLE'`);
}
private async readSoftwareBuildIDAndDateCode(device: Device, sendPolicy?: 'immediate'): Promise<{softwareBuildID: string; dateCode: string}> {
private async readSoftwareBuildIDAndDateCode(
device: Device,
sendPolicy?: 'immediate',
): Promise<{softwareBuildID: string; dateCode: string} | undefined> {
try {
const endpoint = device.zh.endpoints.find((e) => e.supportsInputCluster('genBasic'));
assert(endpoint);
const result = await endpoint.read('genBasic', ['dateCode', 'swBuildId'], {sendPolicy});
return {softwareBuildID: result.swBuildId, dateCode: result.dateCode};
} catch {
return null;
return undefined;
}
}
private getEntityPublishPayload(
device: Device,
state: zhc.OtaUpdateAvailableResult | UpdateState,
progress: number = null,
remaining: number = null,
progress?: number,
remaining?: number,
): UpdatePayload {
const deviceUpdateState = this.state.get(device).update;
const payload: UpdatePayload = {
@@ -158,8 +162,14 @@ export default class OTAUpdate extends Extension {
latest_version: typeof state === 'string' ? deviceUpdateState?.latest_version : state.otaFileVersion,
},
};
if (progress !== null) payload.update.progress = progress;
if (remaining !== null) payload.update.remaining = Math.round(remaining);
if (progress != undefined) {
payload.update.progress = progress;
}
if (remaining != undefined) {
payload.update.remaining = Math.round(remaining);
}
/* istanbul ignore else */
if (this.legacyApi) {
@@ -171,7 +181,7 @@ export default class OTAUpdate extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
if ((!this.legacyApi || !data.topic.match(legacyTopicRegex)) && !data.topic.match(topicRegex)) {
return null;
return;
}
const message = utils.parseJSON(data.message, data.message);
@@ -179,8 +189,8 @@ export default class OTAUpdate extends Extension {
const device = this.zigbee.resolveEntity(ID);
const type = data.topic.substring(data.topic.lastIndexOf('/') + 1);
const responseData: {id: string; updateAvailable?: boolean; from?: string; to?: string} = {id: ID};
let error = null;
let errorStack = null;
let error: string | undefined;
let errorStack: string | undefined;
if (!(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
@@ -208,7 +218,7 @@ export default class OTAUpdate extends Extension {
}
try {
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, null);
const availableResult = await device.definition.ota.isUpdateAvailable(device.zh, undefined);
const msg = `${availableResult.available ? 'Update' : 'No update'} available for '${device.name}'`;
logger.info(msg);
@@ -226,8 +236,8 @@ export default class OTAUpdate extends Extension {
this.lastChecked[device.ieeeAddr] = Date.now();
responseData.updateAvailable = availableResult.available;
} catch (e) {
error = `Failed to check if update available for '${device.name}' (${e.message})`;
errorStack = e.stack;
error = `Failed to check if update available for '${device.name}' (${(e as Error).message})`;
errorStack = (e as Error).stack;
/* istanbul ignore else */
if (settings.get().advanced.legacy_api) {
@@ -294,8 +304,8 @@ export default class OTAUpdate extends Extension {
}
} catch (e) {
logger.debug(`Update of '${device.name}' failed (${e})`);
error = `Update of '${device.name}' failed (${e.message})`;
errorStack = e.stack;
error = `Update of '${device.name}' failed (${(e as Error).message})`;
errorStack = (e as Error).stack;
this.removeProgressAndRemainingFromState(device);
const payload = this.getEntityPublishPayload(device, 'available');
@@ -313,6 +323,7 @@ export default class OTAUpdate extends Extension {
}
const triggeredViaLegacyApi = data.topic.match(legacyTopicRegex);
if (!triggeredViaLegacyApi) {
const response = utils.getResponse(message, responseData, error);
await this.mqtt.publish(`bridge/response/device/ota_update/${type}`, stringify(response));
@@ -320,6 +331,7 @@ export default class OTAUpdate extends Extension {
if (error) {
logger.error(error);
if (errorStack) {
logger.debug(errorStack);
}
+71 -41
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import stringify from 'json-stable-stringify-without-jsonify';
import * as zhc from 'zigbee-herdsman-converters';
@@ -17,11 +18,11 @@ export const loadTopicGetSetRegex = (): void => {
};
loadTopicGetSetRegex();
const stateValues = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
const sceneConverterKeys = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
const STATE_VALUES: ReadonlyArray<string> = ['on', 'off', 'toggle', 'open', 'close', 'stop', 'lock', 'unlock'];
const SCENE_CONVERTER_KEYS: ReadonlyArray<string> = ['scene_store', 'scene_add', 'scene_remove', 'scene_remove_all', 'scene_rename'];
// Legacy: don't provide default converters anymore, this is required by older z2m installs not saving group members
const defaultGroupConverters = [
const DEFAULT_GROUP_CONVERTERS: ReadonlyArray<zhc.Tz.Converter> = [
zhc.toZigbee.light_onoff_brightness,
zhc.toZigbee.light_color_colortemp,
philips.tz.effect, // Support Hue effects for groups
@@ -39,7 +40,7 @@ const defaultGroupConverters = [
interface ParsedTopic {
ID: string;
endpoint: string;
endpoint: string | undefined;
attribute: string;
type: 'get' | 'set';
}
@@ -49,7 +50,7 @@ export default class Publish extends Extension {
this.eventBus.onMQTTMessage(this, this.onMQTTMessage);
}
parseTopic(topic: string): ParsedTopic | null {
parseTopic(topic: string): ParsedTopic | undefined {
// The function supports the following topic formats (below are for 'set'. 'get' will look the same):
// - <base_topic>/device_name/set (endpoint and attribute is defined in the payload)
// - <base_topic>/device_name/set/attribute (default endpoint used)
@@ -60,7 +61,10 @@ export default class Publish extends Extension {
// Before the get/set is the device name and optional endpoint name.
// After it there will be an optional attribute name.
const match = topic.match(topicGetSetRegex);
if (!match) return null;
if (!match) {
return undefined;
}
const deviceNameAndEndpoint = match[1];
const attribute = match[3];
@@ -70,7 +74,7 @@ export default class Publish extends Extension {
return {ID: entity.ID, endpoint: entity.endpointID, type: match[2] as 'get' | 'set', attribute: attribute};
}
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | null {
parseMessage(parsedTopic: ParsedTopic, data: eventdata.MQTTMessage): KeyValue | undefined {
if (parsedTopic.attribute) {
try {
return {[parsedTopic.attribute]: JSON.parse(data.message)};
@@ -81,10 +85,10 @@ export default class Publish extends Extension {
try {
return JSON.parse(data.message);
} catch {
if (stateValues.includes(data.message.toLowerCase())) {
if (STATE_VALUES.includes(data.message.toLowerCase())) {
return {state: data.message};
} else {
return null;
return undefined;
}
}
}
@@ -114,7 +118,9 @@ export default class Publish extends Extension {
// Only do this when the retrieve_state option is enabled for this device.
// retrieve_state == deprecated
if (re instanceof Device && result && result.hasOwnProperty('readAfterWriteTime') && re.options.retrieve_state) {
setTimeout(() => converter.convertGet(target, key, meta), result.readAfterWriteTime);
const convertGet = converter.convertGet;
assert(convertGet !== undefined, 'Converter has `readAfterWriteTime` but no `convertGet`');
setTimeout(() => convertGet(target, key, meta), result.readAfterWriteTime);
}
}
@@ -138,48 +144,63 @@ export default class Publish extends Extension {
@bind async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const parsedTopic = this.parseTopic(data.topic);
if (!parsedTopic) return;
if (!parsedTopic) {
return;
}
const re = this.zigbee.resolveEntity(parsedTopic.ID);
if (re == null) {
if (!re) {
await this.legacyLog({type: `entity_not_found`, message: {friendly_name: parsedTopic.ID}});
logger.error(`Entity '${parsedTopic.ID}' is unknown`);
return;
}
// Get entity details
const definition = re instanceof Device ? re.definition : re.membersDefinitions();
let definition: zhc.Definition | zhc.Definition[];
if (re instanceof Device) {
if (!re.definition) {
logger.error(`Cannot publish to unsupported device '${re.name}'`);
return;
}
definition = re.definition;
} else {
definition = re.membersDefinitions();
}
const target = re instanceof Group ? re.zh : re.endpoint(parsedTopic.endpoint);
if (target == null) {
if (!target) {
logger.error(`Device '${re.name}' has no endpoint '${parsedTopic.endpoint}'`);
return;
}
const device = re instanceof Device ? re.zh : null;
// Convert the MQTT message to a Zigbee message.
const message = this.parseMessage(parsedTopic, data);
if (!message) {
logger.error(`Invalid message '${message}', skipping...`);
return;
}
const device = re instanceof Device ? re.zh : undefined;
const entitySettings = re.options;
const entityState = this.state.get(re);
const membersState =
re instanceof Group
? Object.fromEntries(
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr))]),
re.zh.members.map((e) => [e.getDevice().ieeeAddr, this.state.get(this.zigbee.resolveEntity(e.getDevice().ieeeAddr)!)]),
)
: null;
let converters: zhc.Tz.Converter[];
{
if (Array.isArray(definition)) {
const c = new Set(definition.map((d) => d.toZigbee).flat());
if (c.size == 0) converters = defaultGroupConverters;
else converters = Array.from(c);
} else {
converters = definition.toZigbee;
}
: undefined;
let converters: ReadonlyArray<zhc.Tz.Converter>;
if (Array.isArray(definition)) {
const c = new Set(definition.map((d) => d.toZigbee).flat());
converters = c.size === 0 ? DEFAULT_GROUP_CONVERTERS : Array.from(c);
} else {
converters = definition?.toZigbee;
}
// Convert the MQTT message to a Zigbee message.
const message = this.parseMessage(parsedTopic, data);
if (message == null) {
logger.error(`Invalid message '${message}', skipping...`);
return;
}
this.updateMessageHomeAssistant(message, entityState);
/**
@@ -201,29 +222,35 @@ export default class Publish extends Extension {
const toPublishEntity: {[s: number | string]: Device | Group} = {};
const addToToPublish = (entity: Device | Group, payload: KeyValue): void => {
const ID = entity.ID;
if (!(ID in toPublish)) {
toPublish[ID] = {};
toPublishEntity[ID] = entity;
}
toPublish[ID] = {...toPublish[ID], ...payload};
};
const endpointNames = re instanceof Device ? re.getEndpointNames() : [];
const propertyEndpointRegex = new RegExp(`^(.*?)_(${endpointNames.join('|')})$`);
let scenesChanged = false;
for (const entry of entries) {
let key = entry[0];
const value = entry[1];
let endpointName = parsedTopic.endpoint;
let localTarget = target;
let endpointOrGroupID = utils.isEndpoint(target) ? target.ID : target.groupID;
let endpointOrGroupID = utils.isZHEndpoint(target) ? target.ID : target.groupID;
// When the key has a endpointName included (e.g. state_right), this will override the target.
const propertyEndpointMatch = key.match(propertyEndpointRegex);
if (re instanceof Device && propertyEndpointMatch) {
endpointName = propertyEndpointMatch[2];
key = propertyEndpointMatch[1];
localTarget = re.endpoint(endpointName);
// endpointName is always matched to an existing endpoint of the device
// since `propertyEndpointRegex` only contains valid endpoints for this device.
localTarget = re.endpoint(endpointName)!;
endpointOrGroupID = localTarget.ID;
}
@@ -232,7 +259,7 @@ export default class Publish extends Extension {
// Match any key if the toZigbee converter defines no key.
const converter = converters.find((c) => (!c.key || c.key.includes(key)) && (!c.endpoint || c.endpoint == endpointName));
if (parsedTopic.type === 'set' && usedConverters[endpointOrGroupID].includes(converter)) {
if (parsedTopic.type === 'set' && converter && usedConverters[endpointOrGroupID].includes(converter)) {
// Use a converter for set only once
// (e.g. light_onoff_brightness converters can convert state and brightness)
continue;
@@ -244,17 +271,16 @@ export default class Publish extends Extension {
}
// If the endpoint_name name is a number, try to map it to a friendlyName
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isEndpoint(localTarget) && re.endpointName(localTarget)) {
if (!isNaN(Number(endpointName)) && re.isDevice() && utils.isZHEndpoint(localTarget) && re.endpointName(localTarget)) {
endpointName = re.endpointName(localTarget);
}
// Converter didn't return a result, skip
const entitySettingsKeyValue: KeyValue = entitySettings;
const meta = {
const meta: zhc.Tz.Meta = {
endpoint_name: endpointName,
options: entitySettingsKeyValue,
message: {...message},
logger,
device,
state: entityState,
membersState,
@@ -277,6 +303,7 @@ export default class Publish extends Extension {
logger.debug(`Publishing '${parsedTopic.type}' '${key}' to '${re.name}'`);
const result = await converter.convertSet(localTarget, key, value, meta);
const optimistic = !entitySettings.hasOwnProperty('optimistic') || entitySettings.optimistic;
if (result && result.state && optimistic) {
const msg = result.state;
@@ -295,7 +322,7 @@ export default class Publish extends Extension {
if (result && result.membersState && optimistic) {
for (const [ieeeAddr, state] of Object.entries(result.membersState)) {
addToToPublish(this.zigbee.resolveEntity(ieeeAddr), state);
addToToPublish(this.zigbee.resolveEntity(ieeeAddr)!, state);
}
}
@@ -310,11 +337,15 @@ export default class Publish extends Extension {
} catch (error) {
const message = `Publish '${parsedTopic.type}' '${key}' to '${re.name}' failed: '${error}'`;
logger.error(message);
logger.debug(error.stack);
logger.debug((error as Error).stack!);
await this.legacyLog({type: `zigbee_publish_error`, message, meta: {friendly_name: re.name}});
}
usedConverters[endpointOrGroupID].push(converter);
if (!scenesChanged && converter.key) {
scenesChanged = converter.key.some((k) => SCENE_CONVERTER_KEYS.includes(k));
}
}
for (const [ID, payload] of Object.entries(toPublish)) {
@@ -323,7 +354,6 @@ export default class Publish extends Extension {
}
}
const scenesChanged = Object.values(usedConverters).some((cl) => cl.some((c) => c.key?.some((k) => sceneConverterKeys.includes(k))));
if (scenesChanged) {
this.eventBus.emitScenesChanged({entity: re});
}
+8 -14
View File
@@ -1,3 +1,4 @@
import assert from 'assert';
import bind from 'bind-decorator';
import debounce from 'debounce';
import stringify from 'json-stable-stringify-without-jsonify';
@@ -37,7 +38,7 @@ export default class Receive extends Extension {
}
}
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[]): void {
publishDebounce(device: Device, payload: KeyValue, time: number, debounceIgnore: string[] | undefined): void {
if (!this.debouncers[device.ieeeAddr]) {
this.debouncers[device.ieeeAddr] = {
payload: {},
@@ -69,7 +70,7 @@ export default class Receive extends Extension {
// then all newPayload values with key present in debounce_ignore
// should equal or be undefined in oldPayload
// otherwise payload is conflicted
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | null): boolean {
isPayloadConflicted(newPayload: KeyValue, oldPayload: KeyValue, debounceIgnore: string[] | undefined): boolean {
let result = false;
Object.keys(oldPayload)
.filter((key) => (debounceIgnore || []).includes(key))
@@ -82,20 +83,12 @@ export default class Receive extends Extension {
return result;
}
shouldProcess(data: eventdata.DeviceMessage): boolean {
if (!data.device.definition || data.device.zh.interviewing) {
logger.debug(`Skipping message, still interviewing`);
return false;
}
return true;
}
@bind async onDeviceMessage(data: eventdata.DeviceMessage): Promise<void> {
/* istanbul ignore next */
if (!data.device) return;
if (!this.shouldProcess(data)) {
if (!data.device.definition || data.device.zh.interviewing) {
logger.debug(`Skipping message, still interviewing`);
await utils.publishLastSeen({device: data.device, reason: 'messageEmitted'}, settings.get(), true, this.publishEntityState);
return;
}
@@ -122,6 +115,7 @@ export default class Receive extends Extension {
// - If NO payload is returned do nothing. This is for non-standard behaviour
// for e.g. click switches where we need to count number of clicks and detect long presses.
const publish = async (payload: KeyValue): Promise<void> => {
assert(data.device.definition);
const options: KeyValue = data.device.options;
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);
@@ -158,8 +152,8 @@ export default class Receive extends Extension {
payload = {...payload, ...converted};
}
} catch (error) /* istanbul ignore next */ {
logger.error(`Exception while calling fromZigbee converter: ${error.message}}`);
logger.debug(error.stack);
logger.error(`Exception while calling fromZigbee converter: ${(error as Error).message}}`);
logger.debug((error as Error).stack!);
}
}
+41 -17
View File
@@ -1,4 +1,5 @@
/* eslint-disable brace-style */
import assert from 'assert';
import {CustomClusters} from 'zigbee-herdsman/dist/zspec/zcl/definition/tstype';
import * as zhc from 'zigbee-herdsman-converters';
@@ -6,8 +7,8 @@ import * as settings from '../util/settings';
export default class Device {
public zh: zh.Device;
public definition: zhc.Definition;
private _definitionModelID: string;
public definition?: zhc.Definition;
private _definitionModelID?: string;
get ieeeAddr(): string {
return this.zh.ieeeAddr;
@@ -15,14 +16,15 @@ export default class Device {
get ID(): string {
return this.zh.ieeeAddr;
}
get options(): DeviceOptions {
return {...settings.get().device_options, ...settings.getDevice(this.ieeeAddr)};
get options(): DeviceOptionsWithId {
const deviceOptions = settings.getDevice(this.ieeeAddr) ?? {friendly_name: this.ieeeAddr, ID: this.ieeeAddr};
return {...settings.get().device_options, ...deviceOptions};
}
get name(): string {
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name || this.ieeeAddr;
return this.zh.type === 'Coordinator' ? 'Coordinator' : this.options?.friendly_name;
}
get isSupported(): boolean {
return this.zh.type === 'Coordinator' || (this.definition && !this.definition.generated);
return this.zh.type === 'Coordinator' || Boolean(this.definition && !this.definition.generated);
}
get customClusters(): CustomClusters {
return this.zh.customClusters;
@@ -33,6 +35,7 @@ export default class Device {
}
exposes(): zhc.Expose[] {
assert(this.definition, 'Cannot retreive exposes before definition is resolved');
/* istanbul ignore if */
if (typeof this.definition.exposes == 'function') {
const options: KeyValue = this.options;
@@ -55,28 +58,40 @@ export default class Device {
}
}
endpoint(key?: string | number): zh.Endpoint {
let endpoint: zh.Endpoint;
if (key == null || key == '') key = 'default';
endpoint(key?: string | number): zh.Endpoint | undefined {
let endpoint: zh.Endpoint | undefined;
if (key == null || key == '') {
key = 'default';
}
if (!isNaN(Number(key))) {
endpoint = this.zh.getEndpoint(Number(key));
} else if (this.definition?.endpoint) {
const ID = this.definition?.endpoint?.(this.zh)[key];
if (ID) endpoint = this.zh.getEndpoint(ID);
else if (key === 'default') endpoint = this.zh.endpoints[0];
else return null;
if (ID) {
endpoint = this.zh.getEndpoint(ID);
} else if (key === 'default') {
endpoint = this.zh.endpoints[0];
} else {
return undefined;
}
} else {
/* istanbul ignore next */
if (key !== 'default') return null;
if (key !== 'default') {
return undefined;
}
endpoint = this.zh.endpoints[0];
}
return endpoint;
}
endpointName(endpoint: zh.Endpoint): string {
let epName = null;
endpointName(endpoint: zh.Endpoint): string | undefined {
let epName = undefined;
if (this.definition?.endpoint) {
const mapping = this.definition?.endpoint(this.zh);
for (const [name, id] of Object.entries(mapping)) {
@@ -85,12 +100,21 @@ export default class Device {
}
}
}
/* istanbul ignore next */
return epName === 'default' ? null : epName;
return epName === 'default' ? undefined : epName;
}
getEndpointNames(): string[] {
return Object.keys(this.definition?.endpoint?.(this.zh) ?? {}).filter((name) => name !== 'default');
const names: string[] = [];
for (const name in this.definition?.endpoint?.(this.zh) ?? {}) {
if (name !== 'default') {
names.push(name);
}
}
return names;
}
isIkeaTradfri(): boolean {
+15 -8
View File
@@ -1,23 +1,23 @@
/* eslint-disable brace-style */
import * as zhc from 'zigbee-herdsman-converters';
import * as settings from '../util/settings';
export default class Group {
public zh: zh.Group;
private resolveDevice: (ieeeAddr: string) => Device;
private resolveDevice: (ieeeAddr: string) => Device | undefined;
get ID(): number {
return this.zh.groupID;
}
get options(): GroupOptions {
return {...settings.getGroup(this.ID)};
// XXX: Group always exists in settings
return {...settings.getGroup(this.ID)!};
}
get name(): string {
return this.options?.friendly_name || this.ID.toString();
}
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device) {
constructor(group: zh.Group, resolveDevice: (ieeeAddr: string) => Device | undefined) {
this.zh = group;
this.resolveDevice = resolveDevice;
}
@@ -27,13 +27,20 @@ export default class Group {
}
membersDevices(): Device[] {
return this.zh.members.map((e) => this.resolveDevice(e.getDevice().ieeeAddr)).filter((d) => d);
return this.zh.members.map((d) => this.resolveDevice(d.getDevice().ieeeAddr)!);
}
membersDefinitions(): zhc.Definition[] {
return this.membersDevices()
.map((d) => d.definition)
.filter((d) => d);
const definitions: zhc.Definition[] = [];
for (const member of this.membersDevices()) {
/* istanbul ignore else */
if (member.definition) {
definitions.push(member.definition);
}
}
return definitions;
}
isDevice(): this is Device {
+5 -3
View File
@@ -12,11 +12,12 @@ const NS = 'z2m:mqtt';
export default class MQTT {
private publishedTopics: Set<string> = new Set();
private connectionTimer: NodeJS.Timeout;
private connectionTimer?: NodeJS.Timeout;
// @ts-expect-error initialized in `connect`
private client: mqtt.MqttClient;
private eventBus: EventBus;
private initialConnect = true;
private republishRetainedTimer: NodeJS.Timeout;
private republishRetainedTimer?: NodeJS.Timeout;
public retainedMessages: {
[s: string]: {payload: string; options: MQTTOptions; skipLog: boolean; skipReceive: boolean; topic: string; base: string};
} = {};
@@ -126,6 +127,7 @@ export default class MQTT {
async disconnect(): Promise<void> {
clearTimeout(this.connectionTimer);
clearTimeout(this.republishRetainedTimer);
await this.publish('bridge/state', utils.availabilityPayload('offline', settings.get()), {retain: true, qos: 0});
this.eventBus.removeListeners(this);
logger.info('Disconnecting from MQTT server');
@@ -150,7 +152,7 @@ export default class MQTT {
if (this.republishRetainedTimer && topic === `${settings.get().mqtt.base_topic}/bridge/info`) {
clearTimeout(this.republishRetainedTimer);
this.republishRetainedTimer = null;
this.republishRetainedTimer = undefined;
}
}
+5 -5
View File
@@ -33,7 +33,7 @@ const dontCacheProperties = [
class State {
private state: {[s: string | number]: KeyValue} = {};
private file = data.joinPath('state.json');
private timer: NodeJS.Timeout = null;
private timer?: NodeJS.Timeout;
constructor(
private readonly eventBus: EventBus,
@@ -66,7 +66,7 @@ class State {
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
logger.debug(`Loaded state from file ${this.file}`);
} catch (error) {
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${error.message})`);
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
}
} else {
logger.debug(`Can't load state from file ${this.file} (doesn't exist)`);
@@ -79,8 +79,8 @@ class State {
const json = JSON.stringify(this.state, null, 4);
try {
fs.writeFileSync(this.file, json, 'utf8');
} catch (e) {
logger.error(`Failed to write state to '${this.file}' (${e.message})`);
} catch (error) {
logger.error(`Failed to write state to '${this.file}' (${error})`);
}
} else {
logger.debug(`Not saving state`);
@@ -95,7 +95,7 @@ class State {
return this.state[entity.ID] || {};
}
set(entity: Group | Device, update: KeyValue, reason: string = null): KeyValue {
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
const fromState = this.state[entity.ID] || {};
const toState = objectAssignDeep({}, fromState, update);
const newCache = {...toState};
+20 -15
View File
@@ -21,6 +21,8 @@ import type * as zhc from 'zigbee-herdsman-converters';
import {LogLevel} from 'lib/util/settings';
type OptionalProps<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
declare global {
// Define some class types as global
type EventBus = TypeEventBus;
@@ -80,7 +82,7 @@ declare global {
entity: Device | Group;
from: KeyValue;
to: KeyValue;
reason: string | null;
reason?: string;
update: KeyValue;
};
type PermitJoinChanged = ZHEvents.PermitJoinChangedPayload;
@@ -97,7 +99,7 @@ declare global {
type Reconfigure = {device: Device};
type DeviceLeave = {ieeeAddr: string; name: string};
type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean};
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason: StateChangeReason; payload: KeyValue};
type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue};
type DeviceMessage = {
type: ZHEvents.MessagePayloadType;
device: Device;
@@ -120,7 +122,7 @@ declare global {
legacy_entity_attributes: boolean;
legacy_triggers: boolean;
};
permit_join?: boolean;
permit_join: boolean;
availability?: {
active: {timeout: number};
passive: {timeout: number};
@@ -179,13 +181,13 @@ declare global {
frontend?: {
auth_token?: string;
host?: string;
port?: number;
port: number;
url?: string;
ssl_cert?: string;
ssl_key?: string;
};
devices?: {[s: string]: DeviceOptions};
groups?: {[s: string]: GroupOptions};
devices: {[s: string]: DeviceOptions};
groups: {[s: string]: OptionalProps<Omit<GroupOptions, 'ID'>, 'devices'>};
device_options: KeyValue;
advanced: {
legacy_api: boolean;
@@ -203,8 +205,8 @@ declare global {
pan_id: number | 'GENERATE';
ext_pan_id: number[] | 'GENERATE';
channel: number;
adapter_concurrent: number | null;
adapter_delay: number | null;
adapter_concurrent?: number;
adapter_delay?: number;
cache_state: boolean;
cache_state_persistent: boolean;
cache_state_send_on_startup: boolean;
@@ -216,17 +218,16 @@ declare global {
transmit_power?: number;
// Everything below is deprecated
availability_timeout?: number;
availability_blocklist?: string[];
availability_passlist?: string[];
availability_blacklist?: string[];
availability_whitelist?: string[];
availability_blocklist: string[];
availability_passlist: string[];
availability_blacklist: string[];
availability_whitelist: string[];
soft_reset_timeout: number;
report: boolean;
};
}
interface DeviceOptions {
ID?: string;
disabled?: boolean;
retention?: number;
availability?: boolean | {timeout: number};
@@ -245,9 +246,13 @@ declare global {
qos?: 0 | 1 | 2;
}
interface DeviceOptionsWithId extends DeviceOptions {
ID: string;
}
interface GroupOptions {
devices?: string[];
ID?: number;
devices: string[];
ID: number;
optimistic?: boolean;
off_state?: 'all_members_off' | 'last_member_state';
filtered_attributes?: string[];
+6 -14
View File
@@ -1,17 +1,10 @@
import path from 'path';
let dataPath: string = null;
function load(): void {
if (process.env.ZIGBEE2MQTT_DATA) {
dataPath = process.env.ZIGBEE2MQTT_DATA;
} else {
dataPath = path.join(__dirname, '..', '..', 'data');
dataPath = path.normalize(dataPath);
}
function setPath(): string {
return process.env.ZIGBEE2MQTT_DATA ? process.env.ZIGBEE2MQTT_DATA : path.normalize(path.join(__dirname, '..', '..', 'data'));
}
load();
let dataPath = setPath();
function joinPath(file: string): string {
return path.resolve(dataPath, file);
@@ -21,9 +14,8 @@ function getPath(): string {
return dataPath;
}
// eslint-disable-next-line camelcase
function testingOnlyReload(): void {
load();
function _testReload(): void {
dataPath = setPath();
}
export default {joinPath, getPath, testingOnlyReload};
export default {joinPath, getPath, _testReload};
+7
View File
@@ -11,13 +11,20 @@ import * as settings from './settings';
const NAMESPACE_SEPARATOR = ':';
class Logger {
// @ts-expect-error initalized in `init`
private level: settings.LogLevel;
// @ts-expect-error initalized in `init`
private output: string[];
// @ts-expect-error initalized in `init`
private directory: string;
// @ts-expect-error initalized in `init`
private logger: winston.Logger;
// @ts-expect-error initalized in `init`
private fileTransport: winston.transports.FileTransportInstance;
private debugNamespaceIgnoreRegex?: RegExp;
// @ts-expect-error initalized in `init`
private namespacedLevels: Record<string, settings.LogLevel>;
// @ts-expect-error initalized in `init`
private cachedNamespacedLevels: Record<string, settings.LogLevel>;
public init(): void {
+120 -71
View File
@@ -5,9 +5,9 @@ import path from 'path';
import data from './data';
import schemaJson from './settings.schema.json';
import utils from './utils';
import yaml from './yaml';
export let schema = schemaJson;
// @ts-expect-error
import yaml, {YAMLFileException} from './yaml';
export let schema: KeyValue = schemaJson;
schema = {};
objectAssignDeep(schema, schemaJson);
@@ -23,8 +23,8 @@ objectAssignDeep(schema, schemaJson);
delete schema.properties.advanced.properties.rtscts;
delete schema.properties.advanced.properties.ikea_ota_use_test_url;
delete schema.properties.experimental;
delete schemaJson.properties.whitelist;
delete schemaJson.properties.ban;
delete (schemaJson as KeyValue).properties.whitelist;
delete (schemaJson as KeyValue).properties.ban;
}
/** NOTE: by order of priority, lower index is lower level (more important) */
@@ -96,8 +96,8 @@ const defaults: RecursivePartial<Settings> = {
pan_id: 0x1a62,
ext_pan_id: [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd],
channel: 11,
adapter_concurrent: null,
adapter_delay: null,
adapter_concurrent: undefined,
adapter_delay: undefined,
cache_state: true,
cache_state_persistent: true,
cache_state_send_on_startup: true,
@@ -116,10 +116,13 @@ const defaults: RecursivePartial<Settings> = {
},
};
let _settings: Partial<Settings>;
let _settingsWithDefaults: Settings;
let _settings: Partial<Settings> | undefined;
let _settingsWithDefaults: Settings | undefined;
function loadSettingsWithDefaults(): void {
if (!_settings) {
_settings = read();
}
_settingsWithDefaults = objectAssignDeep({}, defaults, getInternalSettings()) as Settings;
if (!_settingsWithDefaults.devices) {
@@ -151,6 +154,7 @@ function loadSettingsWithDefaults(): void {
const s = typeof _settingsWithDefaults.homeassistant === 'object' ? _settingsWithDefaults.homeassistant : {};
// @ts-expect-error
_settingsWithDefaults.homeassistant = {};
// @ts-expect-error
objectAssignDeep(_settingsWithDefaults.homeassistant, defaults, sLegacy, s);
}
@@ -159,13 +163,16 @@ function loadSettingsWithDefaults(): void {
const s = typeof _settingsWithDefaults.availability === 'object' ? _settingsWithDefaults.availability : {};
// @ts-expect-error
_settingsWithDefaults.availability = {};
// @ts-expect-error
objectAssignDeep(_settingsWithDefaults.availability, defaults, s);
}
if (_settingsWithDefaults.frontend) {
const defaults = {port: 8080, auth_token: false};
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
// @ts-expect-error
_settingsWithDefaults.frontend = {};
// @ts-expect-error
objectAssignDeep(_settingsWithDefaults.frontend, defaults, s);
}
@@ -259,12 +266,12 @@ function write(): void {
// If an array, only write to first file and only devices which are not in the other files.
if (Array.isArray(actual[type])) {
actual[type]
.filter((f: string, i: number) => i !== 0)
.map((f: string) => yaml.readIfExists(data.joinPath(f), {}))
.map((c: KeyValue) => Object.keys(c))
// @ts-expect-error
.forEach((k: string) => delete content[k]);
// skip i==0
for (let i = 1; i < actual[type].length; i++) {
for (const key in yaml.readIfExists(data.joinPath(actual[type][i]))) {
delete content[key];
}
}
}
yaml.writeIfChanged(data.joinPath(fileToWrite), content);
@@ -285,18 +292,20 @@ export function validate(): string[] {
try {
getInternalSettings();
} catch (error) {
if (error.name === 'YAMLException') {
if (error instanceof YAMLFileException) {
return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
}
return [error.message];
return [`${error}`];
}
if (!ajvSetting(_settings)) {
return ajvSetting.errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
// When `ajvSetting()` return false it always has `errors`.
return ajvSetting.errors!.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
}
const errors = [];
if (
_settings.advanced &&
_settings.advanced.network_key &&
@@ -405,7 +414,7 @@ function read(): Settings {
const files: string[] = Array.isArray(s[type]) ? s[type] : [s[type]];
s[type] = {};
for (const file of files) {
const content = yaml.readIfExists(data.joinPath(file), {});
const content = yaml.readIfExists(data.joinPath(file));
/* eslint-disable-line */ // @ts-expect-error
s[type] = objectAssignDeep.noMutate(s[type], content);
}
@@ -420,35 +429,41 @@ function read(): Settings {
function applyEnvironmentVariables(settings: Partial<Settings>): void {
const iterate = (obj: KeyValue, path: string[]): void => {
Object.keys(obj).forEach((key) => {
for (const key in obj) {
if (key !== 'type') {
if (key !== 'properties' && obj[key]) {
const type = (obj[key].type || 'object').toString();
const envPart = path.reduce((acc, val) => `${acc}${val}_`, '');
const envVariableName = `ZIGBEE2MQTT_CONFIG_${envPart}${key}`.toUpperCase();
if (process.env[envVariableName]) {
const envVariable = process.env[envVariableName];
if (envVariable) {
const setting = path.reduce((acc, val) => {
/* eslint-disable-line */ // @ts-expect-error
// @ts-expect-error
acc[val] = acc[val] || {};
/* eslint-disable-line */ // @ts-expect-error
// @ts-expect-error
return acc[val];
}, settings);
if (type.indexOf('object') >= 0 || type.indexOf('array') >= 0) {
try {
setting[key] = JSON.parse(process.env[envVariableName]);
// @ts-expect-error
setting[key] = JSON.parse(envVariable);
} catch {
setting[key] = process.env[envVariableName];
// @ts-expect-error
setting[key] = envVariable;
}
} else if (type.indexOf('number') >= 0) {
/* eslint-disable-line */ // @ts-expect-error
setting[key] = process.env[envVariableName] * 1;
// @ts-expect-error
setting[key] = (envVariable as unknown as number) * 1;
} else if (type.indexOf('boolean') >= 0) {
setting[key] = process.env[envVariableName].toLowerCase() === 'true';
// @ts-expect-error
setting[key] = envVariable.toLowerCase() === 'true';
} else {
/* istanbul ignore else */
if (type.indexOf('string') >= 0) {
setting[key] = process.env[envVariableName];
// @ts-expect-error
setting[key] = envVariable;
}
}
}
@@ -456,14 +471,17 @@ function applyEnvironmentVariables(settings: Partial<Settings>): void {
if (typeof obj[key] === 'object' && obj[key]) {
const newPath = [...path];
if (key !== 'properties' && key !== 'oneOf' && !Number.isInteger(Number(key))) {
newPath.push(key);
}
iterate(obj[key], newPath);
}
}
});
}
};
iterate(schemaJson.properties, []);
}
@@ -480,7 +498,7 @@ export function get(): Settings {
loadSettingsWithDefaults();
}
return _settingsWithDefaults;
return _settingsWithDefaults!;
}
export function set(path: string[], value: string | number | boolean | KeyValue): void {
@@ -505,7 +523,7 @@ export function set(path: string[], value: string | number | boolean | KeyValue)
export function apply(settings: Record<string, unknown>): boolean {
getInternalSettings(); // Ensure _settings is initialized.
/* eslint-disable-line */ // @ts-expect-error
// @ts-expect-error
const newSettings = objectAssignDeep.noMutate(_settings, settings);
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
ajvSetting(newSettings);
@@ -519,13 +537,16 @@ export function apply(settings: Record<string, unknown>): boolean {
write();
ajvRestartRequired(settings);
const restartRequired = ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart');
const restartRequired = Boolean(ajvRestartRequired.errors && !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'));
return restartRequired;
}
export function getGroup(IDorName: string | number): GroupOptions {
export function getGroup(IDorName: string | number): GroupOptions | undefined {
const settings = get();
const byID = settings.groups[IDorName];
if (byID) {
return {devices: [], ...byID, ID: Number(IDorName)};
}
@@ -536,11 +557,12 @@ export function getGroup(IDorName: string | number): GroupOptions {
}
}
return null;
return undefined;
}
export function getGroups(): GroupOptions[] {
const settings = get();
return Object.entries(settings.groups).map(([ID, group]) => {
return {devices: [], ...group, ID: Number(ID)};
});
@@ -548,6 +570,7 @@ export function getGroups(): GroupOptions[] {
function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
const group = getGroup(IDorName);
if (!group) {
throw new Error(`Group '${IDorName}' does not exist`);
}
@@ -555,9 +578,10 @@ function getGroupThrowIfNotExists(IDorName: string): GroupOptions {
return group;
}
export function getDevice(IDorName: string): DeviceOptions {
export function getDevice(IDorName: string): DeviceOptionsWithId | undefined {
const settings = get();
const byID = settings.devices[IDorName];
if (byID) {
return {...byID, ID: IDorName};
}
@@ -568,10 +592,10 @@ export function getDevice(IDorName: string): DeviceOptions {
}
}
return null;
return undefined;
}
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
function getDeviceThrowIfNotExists(IDorName: string): DeviceOptionsWithId {
const device = getDevice(IDorName);
if (!device) {
throw new Error(`Device '${IDorName}' does not exist`);
@@ -580,7 +604,7 @@ function getDeviceThrowIfNotExists(IDorName: string): DeviceOptions {
return device;
}
export function addDevice(ID: string): DeviceOptions {
export function addDevice(ID: string): DeviceOptionsWithId {
if (getDevice(ID)) {
throw new Error(`Device '${ID}' already exists`);
}
@@ -593,7 +617,8 @@ export function addDevice(ID: string): DeviceOptions {
settings.devices[ID] = {friendly_name: ID};
write();
return getDevice(ID);
return getDevice(ID)!; // valid from creation above
}
export function addDeviceToPasslist(ID: string): void {
@@ -623,13 +648,14 @@ export function blockDevice(ID: string): void {
export function removeDevice(IDorName: string): void {
const device = getDeviceThrowIfNotExists(IDorName);
const settings = getInternalSettings();
delete settings.devices[device.ID];
delete settings.devices?.[device.ID];
// Remove device from groups
if (settings.groups) {
const regex = new RegExp(`^(${device.friendly_name}|${device.ID})(/[^/]+)?$`);
for (const group of Object.values(settings.groups).filter((g) => g.devices)) {
group.devices = group.devices.filter((device) => !device.match(regex));
group.devices = group.devices?.filter((device) => !device.match(regex));
}
}
@@ -638,6 +664,7 @@ export function removeDevice(IDorName: string): void {
export function addGroup(name: string, ID?: string): GroupOptions {
utils.validateFriendlyName(name, true);
if (getGroup(name) || getDevice(name)) {
throw new Error(`friendly_name '${name}' is already in use`);
}
@@ -647,15 +674,17 @@ export function addGroup(name: string, ID?: string): GroupOptions {
settings.groups = {};
}
if (ID == null) {
if (ID == undefined) {
// look for free ID
ID = '1';
while (settings.groups.hasOwnProperty(ID)) {
ID = (Number.parseInt(ID) + 1).toString();
}
} else {
// ensure provided ID is not in use
ID = ID.toString();
if (settings.groups.hasOwnProperty(ID)) {
throw new Error(`Group ID '${ID}' is already in use`);
}
@@ -664,22 +693,25 @@ export function addGroup(name: string, ID?: string): GroupOptions {
settings.groups[ID] = {friendly_name: name};
write();
return getGroup(ID);
return getGroup(ID)!; // valid from creation above
}
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string {
function groupGetDevice(group: {devices?: string[]}, keys: string[]): string | undefined {
for (const device of group.devices ?? []) {
if (keys.includes(device)) return device;
if (keys.includes(device)) {
return device;
}
}
return null;
return undefined;
}
export function addDeviceToGroup(IDorName: string, keys: string[]): void {
const groupID = getGroupThrowIfNotExists(IDorName).ID;
const groupID = getGroupThrowIfNotExists(IDorName).ID!;
const settings = getInternalSettings();
const group = settings.groups[groupID];
const group = settings.groups![groupID];
if (!groupGetDevice(group, keys)) {
if (!group.devices) group.devices = [];
group.devices.push(keys[0]);
@@ -688,14 +720,16 @@ export function addDeviceToGroup(IDorName: string, keys: string[]): void {
}
export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
const groupID = getGroupThrowIfNotExists(IDorName).ID;
const groupID = getGroupThrowIfNotExists(IDorName).ID!;
const settings = getInternalSettings();
const group = settings.groups[groupID];
const group = settings.groups![groupID];
if (!group.devices) {
return;
}
const key = groupGetDevice(group, keys);
if (key) {
group.devices = group.devices.filter((d) => d != key);
write();
@@ -703,9 +737,10 @@ export function removeDeviceFromGroup(IDorName: string, keys: string[]): void {
}
export function removeGroup(IDorName: string | number): void {
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID;
const groupID = getGroupThrowIfNotExists(IDorName.toString()).ID!;
const settings = getInternalSettings();
delete settings.groups[groupID];
delete settings.groups![groupID];
write();
}
@@ -714,21 +749,29 @@ export function changeEntityOptions(IDorName: string, newOptions: KeyValue): boo
delete newOptions.friendly_name;
delete newOptions.devices;
let validator: ValidateFunction;
if (getDevice(IDorName)) {
objectAssignDeep(settings.devices[getDevice(IDorName).ID], newOptions);
utils.removeNullPropertiesFromObject(settings.devices[getDevice(IDorName).ID], NULLABLE_SETTINGS);
const device = getDevice(IDorName);
if (device) {
objectAssignDeep(settings.devices![device.ID], newOptions);
utils.removeNullPropertiesFromObject(settings.devices![device.ID], NULLABLE_SETTINGS);
validator = ajvRestartRequiredDeviceOptions;
} else if (getGroup(IDorName)) {
objectAssignDeep(settings.groups[getGroup(IDorName).ID], newOptions);
utils.removeNullPropertiesFromObject(settings.groups[getGroup(IDorName).ID], NULLABLE_SETTINGS);
validator = ajvRestartRequiredGroupOptions;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
const group = getGroup(IDorName);
if (group) {
objectAssignDeep(settings.groups![group.ID], newOptions);
utils.removeNullPropertiesFromObject(settings.groups![group.ID], NULLABLE_SETTINGS);
validator = ajvRestartRequiredGroupOptions;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
}
}
write();
validator(newOptions);
const restartRequired = validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart');
const restartRequired = Boolean(validator.errors && !!validator.errors.find((e) => e.keyword === 'requiresRestart'));
return restartRequired;
}
@@ -739,29 +782,35 @@ export function changeFriendlyName(IDorName: string, newName: string): void {
}
const settings = getInternalSettings();
if (getDevice(IDorName)) {
settings.devices[getDevice(IDorName).ID].friendly_name = newName;
} else if (getGroup(IDorName)) {
settings.groups[getGroup(IDorName).ID].friendly_name = newName;
const device = getDevice(IDorName);
if (device) {
settings.devices![device.ID].friendly_name = newName;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
const group = getGroup(IDorName);
if (group) {
settings.groups![group.ID].friendly_name = newName;
} else {
throw new Error(`Device or group '${IDorName}' does not exist`);
}
}
write();
}
export function reRead(): void {
_settings = null;
_settings = undefined;
getInternalSettings();
_settingsWithDefaults = null;
_settingsWithDefaults = undefined;
get();
}
export const testing = {
write,
clear: (): void => {
_settings = null;
_settingsWithDefaults = null;
_settings = undefined;
_settingsWithDefaults = undefined;
},
defaults,
};
+41 -19
View File
@@ -1,5 +1,6 @@
import type * as zhc from 'zigbee-herdsman-converters';
import assert from 'assert';
import equals from 'fast-deep-equal/es6';
import fs from 'fs';
import humanizeDuration from 'humanize-duration';
@@ -43,19 +44,19 @@ function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1);
}
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash: string; version: string}> {
async function getZigbee2MQTTVersion(includeCommitHash = true): Promise<{commitHash?: string; version: string}> {
const git = await import('git-last-commit');
const packageJSON = await import('../..' + '/package.json');
if (!includeCommitHash) {
return {version: packageJSON.version, commitHash: null};
return {version: packageJSON.version, commitHash: undefined};
}
return new Promise((resolve) => {
const version = packageJSON.version;
git.getLastCommit((err: Error, commit: {shortHash: string}) => {
let commitHash = null;
let commitHash = undefined;
if (err) {
try {
@@ -122,12 +123,17 @@ function getObjectProperty(object: KeyValue, key: string, defaultValue: unknown)
return object && object.hasOwnProperty(key) ? object[key] : defaultValue;
}
function getResponse(request: KeyValue | string, data: KeyValue, error: string): MQTTResponse {
function getResponse(request: KeyValue | string, data: KeyValue, error?: string): MQTTResponse {
const response: MQTTResponse = {data, status: error ? 'error' : 'ok'};
if (error) response.error = error;
if (error) {
response.error = error;
}
if (typeof request === 'object' && request.hasOwnProperty('transaction')) {
response.transaction = request.transaction;
}
return response;
}
@@ -319,12 +325,12 @@ function isAvailabilityEnabledForEntity(entity: Device | Group, settings: Settin
return true;
}
function isEndpoint(obj: unknown): obj is zh.Endpoint {
return obj.constructor.name.toLowerCase() === 'endpoint';
function isZHEndpoint(obj: unknown): obj is zh.Endpoint {
return obj?.constructor.name.toLowerCase() === 'endpoint';
}
function flatten<Type>(arr: Type[][]): Type[] {
return [].concat(...arr);
return ([] as Type[]).concat(...arr);
}
function arrayUnique<Type>(arr: Type[]): Type[] {
@@ -332,7 +338,7 @@ function arrayUnique<Type>(arr: Type[]): Type[] {
}
function isZHGroup(obj: unknown): obj is zh.Group {
return obj.constructor.name.toLowerCase() === 'group';
return obj?.constructor.name.toLowerCase() === 'group';
}
function availabilityPayload(state: 'online' | 'offline', settings: Settings): string {
@@ -362,7 +368,7 @@ async function publishLastSeen(
}
}
function filterProperties(filter: string[], data: KeyValue): void {
function filterProperties(filter: string[] | undefined, data: KeyValue): void {
if (filter) {
for (const property of Object.keys(data)) {
if (filter.find((p) => property.match(`^${p}$`))) {
@@ -372,22 +378,38 @@ function filterProperties(filter: string[], data: KeyValue): void {
}
}
export function isNumericExposeFeature(feature: zhc.Expose): feature is zhc.Numeric {
return feature?.type === 'numeric';
export function isNumericExpose(expose: zhc.Expose): expose is zhc.Numeric {
return expose?.type === 'numeric';
}
export function isEnumExposeFeature(feature: zhc.Expose): feature is zhc.Enum {
return feature?.type === 'enum';
export function assertEnumExpose(expose: zhc.Expose): asserts expose is zhc.Enum {
assert(expose?.type === 'enum');
}
export function isBinaryExposeFeature(feature: zhc.Expose): feature is zhc.Binary {
return feature?.type === 'binary';
export function assertNumericExpose(expose: zhc.Expose): asserts expose is zhc.Numeric {
assert(expose?.type === 'numeric');
}
export function assertBinaryExpose(expose: zhc.Expose): asserts expose is zhc.Binary {
assert(expose?.type === 'binary');
}
export function isEnumExpose(expose: zhc.Expose): expose is zhc.Enum {
return expose?.type === 'enum';
}
export function isBinaryExpose(expose: zhc.Expose): expose is zhc.Binary {
return expose?.type === 'binary';
}
export function isLightExpose(expose: zhc.Expose): expose is zhc.Light {
return expose.type === 'light';
}
function getScenes(entity: zh.Endpoint | zh.Group): Scene[] {
const scenes: {[id: number]: Scene} = {};
const endpoints = isEndpoint(entity) ? [entity] : entity.members;
const groupID = isEndpoint(entity) ? 0 : entity.groupID;
const endpoints = isZHEndpoint(entity) ? [entity] : entity.members;
const groupID = isZHEndpoint(entity) ? 0 : entity.groupID;
for (const endpoint of endpoints) {
for (const [key, data] of Object.entries(endpoint.meta?.scenes || {})) {
@@ -426,7 +448,7 @@ export default {
removeNullPropertiesFromObject,
toNetworkAddressHex,
toSnakeCase,
isEndpoint,
isZHEndpoint,
isZHGroup,
hours,
minutes,
+20 -5
View File
@@ -1,26 +1,41 @@
import equals from 'fast-deep-equal/es6';
import fs from 'fs';
import yaml from 'js-yaml';
import yaml, {YAMLException} from 'js-yaml';
export class YAMLFileException extends YAMLException {
file: string;
constructor(error: YAMLException, file: string) {
super(error.reason, error.mark);
this.name = 'YAMLFileException';
this.cause = error.cause;
this.message = error.message;
this.stack = error.stack;
this.file = file;
}
}
function read(file: string): KeyValue {
try {
const result = yaml.load(fs.readFileSync(file, 'utf8'));
return (result as KeyValue) ?? {};
} catch (error) {
if (error.name === 'YAMLException') {
error.file = file;
if (error instanceof YAMLException) {
throw new YAMLFileException(error, file);
}
throw error;
}
}
function readIfExists(file: string, default_?: KeyValue): KeyValue {
return fs.existsSync(file) ? read(file) : default_;
function readIfExists(file: string, fallback: KeyValue = {}): KeyValue {
return fs.existsSync(file) ? read(file) : fallback;
}
function writeIfChanged(file: string, content: KeyValue): void {
const before = readIfExists(file);
if (!equals(before, content)) {
fs.writeFileSync(file, yaml.dump(content));
}
+32 -24
View File
@@ -14,6 +14,7 @@ import utils from './util/utils';
const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`);
export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
private eventBus: EventBus;
private groupLookup: {[s: number]: Group} = {};
@@ -73,18 +74,18 @@ export default class Zigbee {
this.herdsman.on('adapterDisconnected', () => this.eventBus.emitAdapterDisconnected());
this.herdsman.on('lastSeenChanged', (data: ZHEvents.LastSeenChangedPayload) => {
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr), reason: data.reason});
this.eventBus.emitLastSeenChanged({device: this.resolveDevice(data.device.ieeeAddr)!, reason: data.reason});
});
this.herdsman.on('permitJoinChanged', (data: ZHEvents.PermitJoinChangedPayload) => {
this.eventBus.emitPermitJoinChanged(data);
});
this.herdsman.on('deviceNetworkAddressChanged', (data: ZHEvents.DeviceNetworkAddressChangedPayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' changed network address`);
this.eventBus.emitDeviceNetworkAddressChanged({device});
});
this.herdsman.on('deviceAnnounce', (data: ZHEvents.DeviceAnnouncePayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
const device = this.resolveDevice(data.device.ieeeAddr)!;
logger.debug(`Device '${device.name}' announced itself`);
this.eventBus.emitDeviceAnnounce({device});
});
@@ -109,7 +110,7 @@ export default class Zigbee {
this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name});
});
this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => {
const device = this.resolveDevice(data.device.ieeeAddr);
const device = this.resolveDevice(data.device.ieeeAddr)!;
await device.resolveDefinition();
logger.debug(
`Received Zigbee message from '${device.name}', type '${data.type}', ` +
@@ -133,7 +134,7 @@ export default class Zigbee {
try {
await device.zh.removeFromNetwork();
} catch (error) {
logger.error(`Failed to remove '${device.ieeeAddr}' (${error.message})`);
logger.error(`Failed to remove '${device.ieeeAddr}' (${(error as Error).message})`);
}
};
@@ -157,7 +158,7 @@ export default class Zigbee {
logger.info(`Successfully interviewed '${name}', device has successfully been paired`);
if (data.device.isSupported) {
const {vendor, description, model} = data.device.definition;
const {vendor, description, model} = data.device.definition!;
logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`);
} else {
logger.warning(
@@ -206,7 +207,7 @@ export default class Zigbee {
async coordinatorCheck(): Promise<{missingRouters: Device[]}> {
const check = await this.herdsman.coordinatorCheck();
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr))};
return {missingRouters: check.missingRouters.map((d) => this.resolveDevice(d.ieeeAddr)!)};
}
async getNetworkParameters(): Promise<zh.NetworkParameters> {
@@ -227,11 +228,11 @@ export default class Zigbee {
return this.herdsman.getPermitJoin();
}
getPermitJoinTimeout(): number {
getPermitJoinTimeout(): number | undefined {
return this.herdsman.getPermitJoinTimeout();
}
async permitJoin(permit: boolean, device?: Device, time: number = undefined): Promise<void> {
async permitJoin(permit: boolean, device?: Device, time?: number): Promise<void> {
if (permit) {
logger.info(`Zigbee: allowing new devices to join${device ? ` via ${device.name}` : ''}.`);
} else {
@@ -245,7 +246,7 @@ export default class Zigbee {
}
}
@bind private resolveDevice(ieeeAddr: string): Device {
@bind private resolveDevice(ieeeAddr: string): Device | undefined {
if (!this.deviceLookup[ieeeAddr]) {
const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr);
if (device) {
@@ -269,16 +270,20 @@ export default class Zigbee {
return this.groupLookup[groupID];
}
resolveEntity(key: string | number | zh.Device): Device | Group {
resolveEntity(key: string | number | zh.Device): Device | Group | undefined {
if (typeof key === 'object') {
return this.resolveDevice(key.ieeeAddr);
} else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') {
return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr);
} else {
const settingsDevice = settings.getDevice(key.toString());
if (settingsDevice) return this.resolveDevice(settingsDevice.ID);
if (settingsDevice) {
return this.resolveDevice(settingsDevice.ID);
}
const groupSettings = settings.getGroup(key);
if (groupSettings) {
const group = this.resolveGroup(groupSettings.ID);
// If group does not exist, create it (since it's already in configuration.yaml)
@@ -287,7 +292,7 @@ export default class Zigbee {
}
}
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group; endpointID: string; endpoint: zh.Endpoint} {
resolveEntityAndEndpoint(ID: string): {ID: string; entity: Device | Group | undefined; endpointID?: string; endpoint?: zh.Endpoint} {
// This function matches the following entity formats:
// device_name (just device name)
// device_name/ep_name (device name and endpoint numeric ID or name)
@@ -297,25 +302,28 @@ export default class Zigbee {
// The function tries to find an exact match first
let entityName = ID;
let deviceOrGroup = this.resolveEntity(ID);
let endpointNameOrID = undefined;
let endpointNameOrID: string | undefined;
// If exact match did not happenc, try matching a device_name/endpoint pattern
// If exact match did not happen, try matching a device_name/endpoint pattern
if (!deviceOrGroup) {
// First split the input token by the latest slash
const match = ID.match(entityIDRegex);
// Get the resulting IDs from the match
entityName = match[1];
deviceOrGroup = this.resolveEntity(match[1]);
endpointNameOrID = match[2];
/* istanbul ignore else */
if (match) {
// Get the resulting IDs from the match
entityName = match[1];
deviceOrGroup = this.resolveEntity(entityName);
endpointNameOrID = match[2];
}
}
// If the function returns non-null endpoint name, but the endpoint field is null, then
// it means that endpoint was not matched because there is no such endpoint on the device
// (or the entity is a group)
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : null;
const endpoint = deviceOrGroup?.isDevice() ? deviceOrGroup.endpoint(endpointNameOrID) : undefined;
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint: endpoint};
return {ID: entityName, entity: deviceOrGroup, endpointID: endpointNameOrID, endpoint};
}
firstCoordinatorEndpoint(): zh.Endpoint {
@@ -327,7 +335,7 @@ export default class Zigbee {
groupPredicate?: (value: zh.Group) => boolean,
): Generator<Device | Group> {
for (const device of this.herdsman.getDevicesIterator(devicePredicate)) {
yield this.resolveDevice(device.ieeeAddr);
yield this.resolveDevice(device.ieeeAddr)!;
}
for (const group of this.herdsman.getGroupsIterator(groupPredicate)) {
@@ -343,7 +351,7 @@ export default class Zigbee {
*devicesIterator(predicate?: (value: zh.Device) => boolean): Generator<Device> {
for (const device of this.herdsman.getDevicesIterator(predicate)) {
yield this.resolveDevice(device.ieeeAddr);
yield this.resolveDevice(device.ieeeAddr)!;
}
}
@@ -397,7 +405,7 @@ export default class Zigbee {
return this.resolveGroup(ID);
}
deviceByNetworkAddress(networkAddress: number): Device {
deviceByNetworkAddress(networkAddress: number): Device | undefined {
const device = this.herdsman.getDeviceByNetworkAddress(networkAddress);
return device && this.resolveDevice(device.ieeeAddr);
}
+2 -2
View File
@@ -65,12 +65,12 @@ class ZStackNvMemEraser {
if (len != 0) {
console.log(`NVMEM item #${id} - deleting, size: ${len}`);
if (needOsal) {
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, null, null, [
await this.znp.request(Subsystem.SYS, 'osalNvDelete', {id: id, len: len}, undefined, undefined, [
ZnpCommandStatus.SUCCESS,
ZnpCommandStatus.NV_ITEM_INITIALIZED,
]);
} else {
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, null, null, [
await this.znp.request(Subsystem.SYS, 'nvDelete', {sysid: NvSystemIds.ZSTACK, itemid: id, subid: 0}, undefined, undefined, [
ZnpCommandStatus.SUCCESS,
ZnpCommandStatus.NV_ITEM_INITIALIZED,
]);
+12 -2
View File
@@ -13,7 +13,17 @@ import stringify from 'json-stable-stringify-without-jsonify';
const mocks = [MQTT.publish, logger.warning, logger.info];
const devices = zigbeeHerdsman.devices;
zigbeeHerdsman.returnDevices.push(
...[devices.bulb_color.ieeeAddr, devices.bulb_color_2.ieeeAddr, devices.coordinator.ieeeAddr, devices.remote.ieeeAddr],
...[
devices.bulb_color.ieeeAddr,
devices.bulb_color_2.ieeeAddr,
devices.coordinator.ieeeAddr,
devices.remote.ieeeAddr,
devices.TS0601_thermostat.ieeeAddr,
devices.bulb_2.ieeeAddr,
devices.ZNCZ02LM.ieeeAddr,
devices.GLEDOPTO_2ID.ieeeAddr,
devices.QBKG03LM.ieeeAddr,
],
);
describe('Availability', () => {
@@ -289,7 +299,7 @@ describe('Availability', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb_color', to: 'bulb_new_name'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', null, {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_color/availability', '', {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'online', {retain: true, qos: 1}, expect.any(Function));
await setTimeAndAdvanceTimers(utils.hours(12));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb_new_name/availability', 'offline', {retain: true, qos: 1}, expect.any(Function));
+14 -14
View File
@@ -92,8 +92,8 @@ describe('Bridge', () => {
commit: version.commitHash,
config: {
advanced: {
adapter_concurrent: null,
adapter_delay: null,
adapter_concurrent: undefined,
adapter_delay: undefined,
availability_blacklist: [],
availability_blocklist: [],
availability_passlist: [],
@@ -239,7 +239,7 @@ describe('Bridge', () => {
stringify([
{
date_code: null,
definition: null,
// definition: null,
disabled: false,
endpoints: {1: {bindings: [], clusters: {input: [], output: []}, configured_reportings: [], scenes: []}},
friendly_name: 'Coordinator',
@@ -2561,7 +2561,7 @@ describe('Bridge', () => {
it('Should republish bridge info when permit join changes', async () => {
MQTT.publish.mockClear();
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10});
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, timeout: 10});
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
});
@@ -2569,7 +2569,7 @@ describe('Bridge', () => {
it('Shouldnt republish bridge info when permit join changes and hersman is stopping', async () => {
MQTT.publish.mockClear();
zigbeeHerdsman.isStopping.mockImplementationOnce(() => true);
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10});
await zigbeeHerdsman.events.permitJoinChanged({permitted: false, timeout: 10});
await flushPromises();
expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), {retain: true, qos: 0}, expect.any(Function));
});
@@ -2706,7 +2706,7 @@ describe('Bridge', () => {
expect(controller.state[device.ieeeAddr]).toBeUndefined();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(device.removeFromDatabase).not.toHaveBeenCalled();
expect(settings.getDevice('bulb')).toBeNull();
expect(settings.getDevice('bulb')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bulb', '', {retain: true, qos: 0}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
@@ -2728,7 +2728,7 @@ describe('Bridge', () => {
await flushPromises();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(device.removeFromDatabase).not.toHaveBeenCalled();
expect(settings.getDevice('bulb')).toBeNull();
expect(settings.getDevice('bulb')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/remove',
@@ -2745,7 +2745,7 @@ describe('Bridge', () => {
await flushPromises();
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
expect(device.removeFromNetwork).not.toHaveBeenCalled();
expect(settings.getDevice('bulb')).toBeNull();
expect(settings.getDevice('bulb')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/remove',
@@ -2761,7 +2761,7 @@ describe('Bridge', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', stringify({id: 'bulb', block: true, force: true}));
await flushPromises();
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
expect(settings.getDevice('bulb')).toBeNull();
expect(settings.getDevice('bulb')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/remove',
@@ -2778,7 +2778,7 @@ describe('Bridge', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', 'group_1');
await flushPromises();
expect(group.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(settings.getGroup('group_1')).toBeNull();
expect(settings.getGroup('group_1')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/remove',
@@ -2794,7 +2794,7 @@ describe('Bridge', () => {
MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', stringify({id: 'group_1', force: true}));
await flushPromises();
expect(group.removeFromDatabase).toHaveBeenCalledTimes(1);
expect(settings.getGroup('group_1')).toBeNull();
expect(settings.getGroup('group_1')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/group/remove',
@@ -2858,7 +2858,7 @@ describe('Bridge', () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({from: 'bulb', to: 'bulb_new_name'}));
await flushPromises();
expect(settings.getDevice('bulb')).toBeNull();
expect(settings.getDevice('bulb')).toBeUndefined();
expect(settings.getDevice('bulb_new_name')).toStrictEqual({
ID: '0x000b57fffec6a5b2',
friendly_name: 'bulb_new_name',
@@ -2892,7 +2892,7 @@ describe('Bridge', () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/group/rename', stringify({from: 'group_1', to: 'group_new_name'}));
await flushPromises();
expect(settings.getGroup('group_1')).toBeNull();
expect(settings.getGroup('group_1')).toBeUndefined();
expect(settings.getGroup('group_new_name')).toStrictEqual({ID: 1, devices: [], friendly_name: 'group_new_name', retain: false});
expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith(
@@ -2932,7 +2932,7 @@ describe('Bridge', () => {
await zigbeeHerdsman.events.deviceJoined({device: zigbeeHerdsman.devices.bulb});
MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({last: true, to: 'bulb_new_name'}));
await flushPromises();
expect(settings.getDevice('bulb')).toBeNull();
expect(settings.getDevice('bulb')).toBeUndefined();
expect(settings.getDevice('bulb_new_name')).toStrictEqual({
ID: '0x000b57fffec6a5b2',
friendly_name: 'bulb_new_name',
+2 -2
View File
@@ -79,7 +79,7 @@ describe('Controller', () => {
databaseBackupPath: path.join(data.mockDir, 'database.db.backup'),
backupPath: path.join(data.mockDir, 'coordinator_backup.json'),
acceptJoiningDeviceHandler: expect.any(Function),
adapter: {concurrent: null, delay: null, disableLED: false, transmitPower: 14},
adapter: {concurrent: undefined, delay: undefined, disableLED: false, transmitPower: 14},
serialPort: {baudRate: undefined, rtscts: undefined, path: '/dev/dummy'},
});
expect(zigbeeHerdsman.start).toHaveBeenCalledTimes(1);
@@ -382,7 +382,7 @@ describe('Controller', () => {
it('Should add entities which are missing from configuration but are in database to configuration', async () => {
await controller.start();
const device = zigbeeHerdsman.devices.notInSettings;
expect(settings.getDevice(device.ieeeAddr)).not.toBeNull();
expect(settings.getDevice(device.ieeeAddr)).not.toBeUndefined();
});
it('On zigbee deviceJoined', async () => {
+2 -2
View File
@@ -15,13 +15,13 @@ describe('Data', () => {
it('Should return correct path when ZIGBEE2MQTT_DATA set', () => {
const expected = tmp.dirSync().name;
process.env.ZIGBEE2MQTT_DATA = expected;
data.testingOnlyReload();
data._testReload();
const actual = data.getPath();
expect(actual).toBe(expected);
expect(data.joinPath('test')).toStrictEqual(path.join(expected, 'test'));
expect(data.joinPath('/test')).toStrictEqual(path.resolve(expected, '/test'));
delete process.env.ZIGBEE2MQTT_DATA;
data.testingOnlyReload();
data._testReload();
});
});
});
+21 -26
View File
@@ -462,7 +462,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).not.toHaveBeenCalledWith(topic1, expect.anything(), expect.any(Object), expect.any(Function));
// Device automation should not be cleared
expect(MQTT.publish).not.toHaveBeenCalledWith(topic2, null, expect.any(Object), expect.any(Function));
expect(MQTT.publish).not.toHaveBeenCalledWith(topic2, '', expect.any(Object), expect.any(Function));
expect(logger.debug).toHaveBeenCalledWith(`Skipping discovery of 'sensor/0x0017880104e45522/humidity/config', already discovered`);
});
@@ -1413,31 +1413,31 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/temperature/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/humidity/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/pressure/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/battery/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/linkquality/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1450,7 +1450,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/1221051039810110150109113116116_9/light/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1502,7 +1502,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/temperature/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1587,7 +1587,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/1221051039810110150109113116116_9/light/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1603,7 +1603,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).not.toHaveBeenCalledWith(
'homeassistant/sensor/0x0017880104e45522/temperature/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -1995,7 +1995,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/1221051039810110150109113116116_91231/light/config',
null,
'',
{qos: 1, retain: true},
expect.any(Function),
);
@@ -2014,7 +2014,7 @@ describe('HomeAssistant extension', () => {
await MQTT.events.message('homeassistant/light/9/light/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/light/9/light/config', null, {qos: 1, retain: true}, expect.any(Function));
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/light/9/light/config', '', {qos: 1, retain: true}, expect.any(Function));
// Existing group, non existing config -> clear
MQTT.publish.mockClear();
@@ -2026,7 +2026,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/1221051039810110150109113116116_9/switch/config',
null,
'',
{qos: 1, retain: true},
expect.any(Function),
);
@@ -2036,12 +2036,7 @@ describe('HomeAssistant extension', () => {
await MQTT.events.message('homeassistant/sensor/0x123/temperature/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x123/temperature/config',
null,
{qos: 1, retain: true},
expect.any(Function),
);
expect(MQTT.publish).toHaveBeenCalledWith('homeassistant/sensor/0x123/temperature/config', '', {qos: 1, retain: true}, expect.any(Function));
// Existing device -> don't clear
MQTT.publish.mockClear();
@@ -2071,7 +2066,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x000b57fffec6a5b2/update_available/config',
null,
'',
{qos: 1, retain: true},
expect.any(Function),
);
@@ -2112,7 +2107,7 @@ describe('HomeAssistant extension', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/sensor/0x000b57fffec6a5b2/update_available/config',
null,
'',
{qos: 1, retain: true},
expect.any(Function),
);
@@ -2124,7 +2119,7 @@ describe('HomeAssistant extension', () => {
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/device_automation/0x000b57fffec6a5b2/action_button_3_single/config',
null,
'',
{qos: 1, retain: true},
expect.any(Function),
);
@@ -2406,7 +2401,7 @@ describe('HomeAssistant extension', () => {
// Discovery messages for scenes have been purged.
expect(MQTT.publish).toHaveBeenCalledWith(
`homeassistant/scene/0x000b57fffec6a5b4/scene_1/config`,
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -2450,7 +2445,7 @@ describe('HomeAssistant extension', () => {
// Discovery messages for scenes have been purged.
expect(MQTT.publish).toHaveBeenCalledWith(
`homeassistant/scene/1221051039810110150109113116116_9/scene_4/config`,
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
@@ -2514,7 +2509,7 @@ describe('HomeAssistant extension', () => {
});
await flushPromises();
expect(MQTT.publish).not.toHaveBeenCalledWith(topic, null, {retain: true, qos: 1}, expect.any(Function));
expect(MQTT.publish).not.toHaveBeenCalledWith(topic, '', {retain: true, qos: 1}, expect.any(Function));
});
it('Should discover bridge entities', async () => {
@@ -2737,7 +2732,7 @@ describe('HomeAssistant extension', () => {
expect(MQTT.publish).toHaveBeenCalledWith(
'homeassistant/light/0xf4ce368a38be56a1/light_l2/config',
null,
'',
{retain: true, qos: 1},
expect.any(Function),
);
+6 -6
View File
@@ -253,7 +253,7 @@ describe('Bridge legacy', () => {
expect(settings.getDevice('bulb_color')).toStrictEqual({ID: '0x000b57fffec6a5b3', friendly_name: 'bulb_color', retain: false});
MQTT.events.message('zigbee2mqtt/bridge/config/rename', stringify({old: 'bulb_color', new: 'bulb_color2'}));
await flushPromises();
expect(settings.getDevice('bulb_color')).toStrictEqual(null);
expect(settings.getDevice('bulb_color')).toBeUndefined();
expect(settings.getDevice('bulb_color2')).toStrictEqual(bulb_color2);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
@@ -374,7 +374,7 @@ describe('Bridge legacy', () => {
const group = zigbeeHerdsman.groups.group_1;
MQTT.events.message('zigbee2mqtt/bridge/config/remove_group', 'group_1');
await flushPromises();
expect(settings.getGroup('to_be_removed')).toStrictEqual(null);
expect(settings.getGroup('to_be_removed')).toBeUndefined();
expect(group.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
@@ -388,7 +388,7 @@ describe('Bridge legacy', () => {
const group = zigbeeHerdsman.groups.group_1;
MQTT.events.message('zigbee2mqtt/bridge/config/force_remove_group', 'group_1');
await flushPromises();
expect(settings.getGroup('to_be_removed')).toStrictEqual(null);
expect(settings.getGroup('to_be_removed')).toBeUndefined();
expect(group.removeFromDatabase).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
@@ -424,7 +424,7 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(controller.state[device.ieeeAddr]).toBeUndefined();
expect(settings.getDevice('bulb_color')).toBeNull();
expect(settings.getDevice('bulb_color')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_removed', message: 'bulb_color'}),
@@ -446,7 +446,7 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(device.removeFromDatabase).toHaveBeenCalledTimes(1);
expect(controller.state[device.ieeeAddr]).toBeUndefined();
expect(settings.getDevice('bulb_color')).toBeNull();
expect(settings.getDevice('bulb_color')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_force_removed', message: 'bulb_color'}),
@@ -467,7 +467,7 @@ describe('Bridge legacy', () => {
await flushPromises();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(controller.state[device.ieeeAddr]).toBeUndefined();
expect(settings.getDevice('bulb_color')).toBeNull();
expect(settings.getDevice('bulb_color')).toBeUndefined();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/log',
stringify({type: 'device_banned', message: 'bulb_color'}),
+4 -4
View File
@@ -187,7 +187,7 @@ describe('Networkmap', () => {
],
nodes: [
{
definition: null,
// definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
@@ -533,7 +533,7 @@ describe('Networkmap', () => {
],
nodes: [
{
definition: null,
// definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
@@ -717,7 +717,7 @@ describe('Networkmap', () => {
],
nodes: [
{
definition: null,
// definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
@@ -873,7 +873,7 @@ describe('Networkmap', () => {
],
nodes: [
{
definition: null,
// definition: null,
failed: [],
friendlyName: 'Coordinator',
ieeeAddr: '0x00124b00120144ae',
+9
View File
@@ -594,6 +594,15 @@ describe('Publish', () => {
expect(endpoint2.read).toHaveBeenCalledTimes(0);
});
it('Should log error when device has no definition', async () => {
const device = zigbeeHerdsman.devices.interviewing;
logger.error.mockClear();
await MQTT.events.message(`zigbee2mqtt/${device.ieeeAddr}/set`, stringify({state: 'OFF'}));
await flushPromises();
console.log(logger.error.mock.calls);
expect(logger.error).toHaveBeenCalledWith(`Cannot publish to unsupported device 'button_double_key_interviewing'`);
});
it('Should log error when device has no such endpoint (via property)', async () => {
const device = zigbeeHerdsman.devices.QBKG03LM;
const endpoint2 = device.getEndpoint(2);
+1 -1
View File
@@ -795,7 +795,7 @@ describe('Settings', () => {
it('Should throw error when yaml file does not exist', () => {
settings.testing.clear();
expect(settings.validate()[0].startsWith(`ENOENT: no such file or directory, open `)).toBeTruthy();
expect(settings.validate()[0]).toContain(`ENOENT: no such file or directory, open `);
});
it('Configuration shouldnt be valid when invalid QOS value is used', async () => {
+1 -1
View File
@@ -861,7 +861,7 @@ const mock = {
for (const key in devices) {
const device = devices[key];
if ((returnDevices.length === 0 || returnDevices.includes(device.ieeeAddr)) && (!predicate || predicate(device))) {
if ((returnDevices.length === 0 || returnDevices.includes(device.ieeeAddr)) && !device.isDeleted && (!predicate || predicate(device))) {
yield device;
}
}
+1
View File
@@ -5,6 +5,7 @@
"esModuleInterop": true,
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"moduleResolution": "node",