mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-27 21:20:03 +00:00
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:
+29
-13
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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});
|
||||
}
|
||||
|
||||
+977
-908
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
Vendored
+20
-15
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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'}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"moduleResolution": "node",
|
||||
|
||||
Reference in New Issue
Block a user