Skip to content

Commit

Permalink
fix!: Improve bind/bridge/groups parsing and resolving consistency (#…
Browse files Browse the repository at this point in the history
…24432)

* Improve bind/bridge/groups parsing and resolving consistency

* Fix mqtt payload casing. Improve typing.

* Cleanup tests

* Add test for bind endpoint by ID.

* Match behavior for group response 'default' endpoint.
  • Loading branch information
Nerivec authored Oct 22, 2024
1 parent 18243a2 commit ad1908c
Show file tree
Hide file tree
Showing 7 changed files with 530 additions and 269 deletions.
269 changes: 174 additions & 95 deletions lib/extension/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,15 +195,23 @@ const POLL_ON_MESSAGE: Readonly<PollOnMessage> = [

interface ParsedMQTTMessage {
type: 'bind' | 'unbind';
sourceKey: string;
targetKey: string;
sourceKey?: string;
sourceEndpointKey?: string | number;
targetKey?: string;
targetEndpointKey?: string | number;
clusters?: string[];
skipDisableReporting: boolean;
resolvedSource?: Device;
resolvedTarget?: Device | Group | typeof DEFAULT_BIND_GROUP;
resolvedSourceEndpoint?: zh.Endpoint;
resolvedBindTarget?: number | zh.Endpoint | zh.Group;
}

interface DataMessage {
from: ParsedMQTTMessage['sourceKey'];
from_endpoint?: ParsedMQTTMessage['sourceEndpointKey'];
to: ParsedMQTTMessage['targetKey'];
to_endpoint: ParsedMQTTMessage['targetEndpointKey'];
clusters: ParsedMQTTMessage['clusters'];
skip_disable_reporting?: ParsedMQTTMessage['skipDisableReporting'];
}
Expand All @@ -217,128 +225,199 @@ export default class Bind extends Extension {
this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged);
}

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;

private parseMQTTMessage(
data: eventdata.MQTTMessage,
): [raw: KeyValue | undefined, parsed: ParsedMQTTMessage | undefined, error: string | undefined] {
if (data.topic.match(TOPIC_REGEX)) {
type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
const type = data.topic.endsWith('unbind') ? 'unbind' : 'bind';
let skipDisableReporting = false;
const message: DataMessage = JSON.parse(data.message);
sourceKey = message.from;
targetKey = message.to;
clusters = message.clusters;

if (typeof message !== 'object' || message.from == undefined || message.to == undefined) {
return [message, {type, skipDisableReporting}, `Invalid payload`];
}

const sourceKey = message.from;
const sourceEndpointKey = message.from_endpoint ?? 'default';
const targetKey = message.to;
const targetEndpointKey = message.to_endpoint;
const clusters = message.clusters;
skipDisableReporting = message.skip_disable_reporting != undefined ? message.skip_disable_reporting : false;
} else {
return undefined;
}
const resolvedSource = this.zigbee.resolveEntity(message.from) as Device;

return {type, sourceKey, targetKey, clusters, skipDisableReporting};
}
if (!resolvedSource || !(resolvedSource instanceof Device)) {
return [message, {type, skipDisableReporting}, `Source device '${message.from}' does not exist`];
}

@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const parsed = this.parseMQTTMessage(data);
const resolvedTarget = message.to === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : this.zigbee.resolveEntity(message.to);

if (!parsed || !parsed.type) {
return;
}
if (!resolvedTarget) {
return [message, {type, skipDisableReporting}, `Target device or group '${message.to}' does not exist`];
}

const {type, sourceKey, targetKey, clusters, skipDisableReporting} = parsed;
const message = utils.parseJSON(data.message, data.message);

let error: string | undefined;
const parsedSource = this.zigbee.resolveEntityAndEndpoint(sourceKey);
const parsedTarget = this.zigbee.resolveEntityAndEndpoint(targetKey);
const source = parsedSource.entity;
const target = targetKey === DEFAULT_BIND_GROUP.name ? DEFAULT_BIND_GROUP : parsedTarget.entity;
const responseData: KeyValue = {from: sourceKey, to: targetKey};

if (!source || !(source instanceof Device)) {
error = `Source device '${sourceKey}' does not exist`;
} else if (parsedSource.endpointID && !parsedSource.endpoint) {
error = `Source device '${parsedSource.ID}' does not have endpoint '${parsedSource.endpointID}'`;
} else if (!target) {
error = `Target device or group '${targetKey}' does not exist`;
} else if (target instanceof Device && parsedTarget.endpointID && !parsedTarget.endpoint) {
error = `Target device '${parsedTarget.ID}' does not have endpoint '${parsedTarget.endpointID}'`;
} else {
const successfulClusters: string[] = [];
const failedClusters = [];
const attemptedClusters = [];
const resolvedSourceEndpoint = resolvedSource.endpoint(sourceEndpointKey);

const bindSource = parsedSource.endpoint;
const bindTarget = target instanceof Device ? parsedTarget.endpoint : target instanceof Group ? target.zh : Number(target.ID);
if (!resolvedSourceEndpoint) {
return [
message,
{type, skipDisableReporting},
`Source device '${resolvedSource.name}' does not have endpoint '${sourceEndpointKey}'`,
];
}

assert(bindSource != undefined && bindTarget != undefined);
// resolves to 'default' endpoint if targetEndpointKey is invalid (used by frontend for 'Coordinator')
const resolvedBindTarget =
resolvedTarget instanceof Device
? resolvedTarget.endpoint(targetEndpointKey)
: resolvedTarget instanceof Group
? resolvedTarget.zh
: Number(resolvedTarget.ID);

if (resolvedTarget instanceof Device && !resolvedBindTarget) {
return [
message,
{type, skipDisableReporting},
`Target device '${resolvedTarget.name}' does not have endpoint '${targetEndpointKey}'`,
];
}

// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters.
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;
return [
message,
{
type,
sourceKey,
sourceEndpointKey,
targetKey,
targetEndpointKey,
clusters,
skipDisableReporting,
resolvedSource,
resolvedTarget,
resolvedSourceEndpoint,
resolvedBindTarget,
},
undefined,
];
} else {
return [undefined, undefined, undefined];
}
}

for (const cluster of clusterCandidates) {
let matchingClusters = false;
@bind private async onMQTTMessage(data: eventdata.MQTTMessage): Promise<void> {
const [raw, parsed, error] = this.parseMQTTMessage(data);

const anyClusterValid = utils.isZHGroup(bindTarget) || typeof bindTarget === 'number' || (target as Device).zh.type === 'Coordinator';
if (!raw || !parsed) {
return;
}

if (!anyClusterValid && utils.isZHEndpoint(bindTarget)) {
matchingClusters =
(bindTarget.supportsInputCluster(cluster) && bindSource.supportsOutputCluster(cluster)) ||
(bindSource.supportsInputCluster(cluster) && bindTarget.supportsOutputCluster(cluster));
}
if (error) {
await this.publishResponse(parsed.type, raw, {}, error);
return;
}

const sourceValid = bindSource.supportsInputCluster(cluster) || bindSource.supportsOutputCluster(cluster);
const {
type,
sourceKey,
sourceEndpointKey,
targetKey,
targetEndpointKey,
clusters,
skipDisableReporting,
resolvedSource,
resolvedTarget,
resolvedSourceEndpoint,
resolvedBindTarget,
} = parsed;

assert(resolvedSource, '`resolvedSource` is missing');
assert(resolvedTarget, '`resolvedTarget` is missing');
assert(resolvedSourceEndpoint, '`resolvedSourceEndpoint` is missing');
assert(resolvedBindTarget != undefined, '`resolvedBindTarget` is missing');

const successfulClusters: string[] = [];
const failedClusters = [];
const attemptedClusters = [];
// Find which clusters are supported by both the source and target.
// Groups are assumed to support all clusters.
const clusterCandidates = clusters ?? ALL_CLUSTER_CANDIDATES;

for (const cluster of clusterCandidates) {
let matchingClusters = false;

const anyClusterValid =
utils.isZHGroup(resolvedBindTarget) ||
typeof resolvedBindTarget === 'number' ||
(resolvedTarget instanceof Device && resolvedTarget.zh.type === 'Coordinator');

if (!anyClusterValid && utils.isZHEndpoint(resolvedBindTarget)) {
matchingClusters =
(resolvedBindTarget.supportsInputCluster(cluster) && resolvedSourceEndpoint.supportsOutputCluster(cluster)) ||
(resolvedSourceEndpoint.supportsInputCluster(cluster) && resolvedBindTarget.supportsOutputCluster(cluster));
}

if (sourceValid && (anyClusterValid || matchingClusters)) {
logger.debug(`${type}ing cluster '${cluster}' from '${source.name}' to '${target.name}'`);
attemptedClusters.push(cluster);
const sourceValid = resolvedSourceEndpoint.supportsInputCluster(cluster) || resolvedSourceEndpoint.supportsOutputCluster(cluster);

try {
if (type === 'bind') {
await bindSource.bind(cluster, bindTarget);
} else {
await bindSource.unbind(cluster, bindTarget);
}
if (sourceValid && (anyClusterValid || matchingClusters)) {
logger.debug(`${type}ing cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
attemptedClusters.push(cluster);

successfulClusters.push(cluster);
logger.info(
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${source.name}' to '${target.name}'`,
);
} catch (error) {
failedClusters.push(cluster);
logger.error(`Failed to ${type} cluster '${cluster}' from '${source.name}' to '${target.name}' (${error})`);
try {
if (type === 'bind') {
await resolvedSourceEndpoint.bind(cluster, resolvedBindTarget);
} else {
await resolvedSourceEndpoint.unbind(cluster, resolvedBindTarget);
}
}
}

if (attemptedClusters.length === 0) {
logger.error(`Nothing to ${type} from '${source.name}' to '${target.name}'`);
error = `Nothing to ${type}`;
} else if (failedClusters.length === attemptedClusters.length) {
error = `Failed to ${type}`;
successfulClusters.push(cluster);
logger.info(
`Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}'`,
);
} catch (error) {
failedClusters.push(cluster);
logger.error(`Failed to ${type} cluster '${cluster}' from '${resolvedSource.name}' to '${resolvedTarget.name}' (${error})`);
}
}
}

responseData[`clusters`] = successfulClusters;
responseData[`failed`] = failedClusters;
if (attemptedClusters.length === 0) {
logger.error(`Nothing to ${type} from '${resolvedSource.name}' to '${resolvedTarget.name}'`);
await this.publishResponse(parsed.type, raw, {}, `Nothing to ${type}`);
return;
} else if (failedClusters.length === attemptedClusters.length) {
await this.publishResponse(parsed.type, raw, {}, `Failed to ${type}`);
return;
}

if (successfulClusters.length !== 0) {
if (type === 'bind') {
await this.setupReporting(bindSource.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === bindTarget));
} else if (typeof bindTarget !== 'number' && !skipDisableReporting) {
await this.disableUnnecessaryReportings(bindTarget);
}
const responseData: KeyValue = {
from: sourceKey,
from_endpoint: sourceEndpointKey,
to: targetKey,
to_endpoint: targetEndpointKey,
clusters: successfulClusters,
failed: failedClusters,
};

/* istanbul ignore else */
if (successfulClusters.length !== 0) {
if (type === 'bind') {
await this.setupReporting(
resolvedSourceEndpoint.binds.filter((b) => successfulClusters.includes(b.cluster.name) && b.target === resolvedBindTarget),
);
} else if (typeof resolvedBindTarget !== 'number' && !skipDisableReporting) {
await this.disableUnnecessaryReportings(resolvedBindTarget);
}
}

const response = utils.getResponse(message, responseData, error);
await this.publishResponse(parsed.type, raw, responseData);
this.eventBus.emitDevicesChanged();
}

await this.mqtt.publish(`bridge/response/device/${type}`, stringify(response));
private async publishResponse(type: ParsedMQTTMessage['type'], request: KeyValue, data: KeyValue, error?: string): Promise<void> {
const response = stringify(utils.getResponse(request, data, error));
await this.mqtt.publish(`bridge/response/device/${type}`, response);

if (error) {
logger.error(error);
} else {
this.eventBus.emitDevicesChanged();
}
}

Expand Down
23 changes: 10 additions & 13 deletions lib/extension/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ export default class Bridge extends Extension {
if (
typeof message !== 'object' ||
message.id === undefined ||
message.endpoint === undefined ||
message.cluster === undefined ||
message.maximum_report_interval === undefined ||
message.minimum_report_interval === undefined ||
Expand All @@ -440,14 +441,11 @@ export default class Bridge extends Extension {
throw new Error(`Invalid payload`);
}

const device = this.zigbee.resolveEntityAndEndpoint(message.id);
if (!device.entity) {
throw new Error(`Device '${message.id}' does not exist`);
}
const device = this.getEntity('device', message.id);
const endpoint = device.endpoint(message.endpoint);

const endpoint = device.endpoint;
if (!endpoint) {
throw new Error(`Device '${device.ID}' does not have endpoint '${device.endpointID}'`);
throw new Error(`Device '${device.ID}' does not have endpoint '${message.endpoint}'`);
}

const coordinatorEndpoint = this.zigbee.firstCoordinatorEndpoint();
Expand All @@ -472,6 +470,7 @@ export default class Bridge extends Extension {

return utils.getResponse(message, {
id: message.id,
endpoint: message.endpoint,
cluster: message.cluster,
maximum_report_interval: message.maximum_report_interval,
minimum_report_interval: message.minimum_report_interval,
Expand All @@ -485,7 +484,7 @@ export default class Bridge extends Extension {
throw new Error(`Invalid payload`);
}

const device = this.getEntity('device', message.id) as Device;
const device = this.getEntity('device', message.id);
logger.info(`Interviewing '${device.name}'`);

try {
Expand All @@ -508,12 +507,7 @@ export default class Bridge extends Extension {
throw new Error(`Invalid payload`);
}

const device = this.zigbee.resolveEntityAndEndpoint(message.id).entity as Device;

if (!device) {
throw new Error(`Device '${message.id}' does not exist`);
}

const device = this.getEntity('device', message.id);
const source = await zhc.generateExternalDefinitionSource(device.zh);

return utils.getResponse(message, {id: message.id, source});
Expand Down Expand Up @@ -634,6 +628,9 @@ export default class Bridge extends Extension {
}
}

getEntity(type: 'group', ID: string): Group;
getEntity(type: 'device', ID: string): Device;
getEntity(type: 'group' | 'device', ID: string): Device | Group;
getEntity(type: 'group' | 'device', ID: string): Device | Group {
const entity = this.zigbee.resolveEntity(ID);
if (!entity || entity.constructor.name.toLowerCase() !== type) {
Expand Down
Loading

0 comments on commit ad1908c

Please sign in to comment.