Skip to content

Commit

Permalink
fix: add missing Association command, expose failure through Supervision
Browse files Browse the repository at this point in the history
  • Loading branch information
AlCalzone committed May 21, 2024
1 parent 579c489 commit 6a3472f
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 49 deletions.
92 changes: 88 additions & 4 deletions packages/cc/src/cc/AssociationCC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,9 @@ export class AssociationCCAPI extends PhysicalCCAPI {
case AssociationCommand.SupportedGroupingsGet:
case AssociationCommand.SupportedGroupingsReport:
return true;
// Not implemented:
// case AssociationCommand.SpecificGroupGet:
// return this.version >= 2;
// This is mandatory
case AssociationCommand.SpecificGroupGet:
case AssociationCommand.SpecificGroupReport:
return this.version >= 2;
}
return super.supportsCommand(cmd);
}
Expand Down Expand Up @@ -237,6 +236,49 @@ export class AssociationCCAPI extends PhysicalCCAPI {
}
}
}

/**
* Request the association group that represents the most recently detected button press
*/
@validateArgs()
public async getSpecificGroup(): Promise<number | undefined> {
this.assertSupportsCommand(
AssociationCommand,
AssociationCommand.SpecificGroupGet,
);

const cc = new AssociationCCSpecificGroupGet(this.applHost, {
nodeId: this.endpoint.nodeId,
endpoint: this.endpoint.index,
});
const response = await this.applHost.sendCommand<
AssociationCCSpecificGroupReport
>(
cc,
this.commandOptions,
);
return response?.group;
}

/**
* Report the association group that represents the most recently detected button press
*/
@validateArgs()
public async reportSpecificGroup(
group: number,
): Promise<void> {
this.assertSupportsCommand(
AssociationCommand,
AssociationCommand.SpecificGroupReport,
);

const cc = new AssociationCCSpecificGroupReport(this.applHost, {
nodeId: this.endpoint.nodeId,
endpoint: this.endpoint.index,
group,
});
await this.applHost.sendCommand(cc, this.commandOptions);
}
}

@commandClass(CommandClasses.Association)
Expand Down Expand Up @@ -737,3 +779,45 @@ export class AssociationCCSupportedGroupingsReport extends AssociationCC {
@CCCommand(AssociationCommand.SupportedGroupingsGet)
@expectedCCResponse(AssociationCCSupportedGroupingsReport)
export class AssociationCCSupportedGroupingsGet extends AssociationCC {}

// @publicAPI
export interface AssociationCCSpecificGroupReportOptions {
group: number;
}

@CCCommand(AssociationCommand.SpecificGroupReport)
export class AssociationCCSpecificGroupReport extends AssociationCC {
public constructor(
host: ZWaveHost,
options:
| CommandClassDeserializationOptions
| (AssociationCCSpecificGroupReportOptions & CCCommandOptions),
) {
super(host, options);

if (gotDeserializationOptions(options)) {
validatePayload(this.payload.length >= 1);
this.group = this.payload[0];
} else {
this.group = options.group;
}
}

public group: number;

public serialize(): Buffer {
this.payload = Buffer.from([this.group]);
return super.serialize();
}

public toLogEntry(applHost: ZWaveApplicationHost): MessageOrCCLogEntry {
return {
...super.toLogEntry(applHost),
message: { group: this.group },
};
}
}

@CCCommand(AssociationCommand.SpecificGroupGet)
@expectedCCResponse(AssociationCCSpecificGroupReport)
export class AssociationCCSpecificGroupGet extends AssociationCC {}
15 changes: 2 additions & 13 deletions packages/cc/src/lib/_Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,8 @@ export enum AssociationCommand {
Remove = 0x04,
SupportedGroupingsGet = 0x05,
SupportedGroupingsReport = 0x06,
// TODO: These two commands are V2. I have no clue how this is supposed to function:
// SpecificGroupGet = 0x0b,
// SpecificGroupReport = 0x0c,

// Here's what the docs have to say:
// This functionality allows a supporting multi-button device to detect a key press and subsequently advertise
// the identity of the key. The following sequence of events takes place:
// * The user activates a special identification sequence and pushes the button to be identified
// * The device issues a Node Information frame (NIF)
// * The NIF allows the portable controller to determine the NodeID of the multi-button device
// * The portable controller issues an Association Specific Group Get Command to the multi-button device
// * The multi-button device returns an Association Specific Group Report Command that advertises the
// association group that represents the most recently detected button
SpecificGroupGet = 0x0b,
SpecificGroupReport = 0x0c,
}

export enum AssociationGroupInfoCommand {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/error/ZWaveError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export enum ZWaveErrorCodes {
CC_NotSupported,
CC_NotImplemented,
CC_NoAPI,
/** Used to communicate that a given operation triggered by another node was not successful */
CC_OperationFailed,

Deserialization_NotImplemented = 320,
Arithmetic,
Expand Down
40 changes: 29 additions & 11 deletions packages/zwave-js/src/lib/driver/Driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4468,21 +4468,24 @@ ${handlers.length} left`,

const encapsulationFlags = msg.command.encapsulationFlags;

let reply: (success: boolean) => Promise<void>;
let reply: (
status:
| SupervisionStatus.Success
| SupervisionStatus.Fail
| SupervisionStatus.NoSupport,
) => Promise<void>;
if (supervisionSessionId != undefined) {
// The command was supervised, and we must respond with a Supervision Report
const endpoint = node.getEndpoint(msg.command.endpointIndex)
?? node;
reply = (success) =>
reply = (status) =>
endpoint
.createAPI(CommandClasses.Supervision, false)
.withOptions({ s2MulticastOutOfSync })
.sendReport({
sessionId: supervisionSessionId,
moreUpdatesFollow: false,
status: success
? SupervisionStatus.Success
: SupervisionStatus.Fail,
status,
requestWakeUpOnDemand: this
.shouldRequestWakeupOnDemand(node),
encapsulationFlags,
Expand Down Expand Up @@ -4516,21 +4519,36 @@ ${handlers.length} left`,
entry.handler(msg.command);

// and possibly reply to a supervised command
await reply(true);
await reply(SupervisionStatus.Success);
return;
}
}

// No one is waiting, dispatch the command to the node itself
try {
await node.handleCommand(msg.command);
await reply(true);
await reply(SupervisionStatus.Success);
} catch (e) {
await reply(false);
let handled = false;
if (isZWaveError(e)) {
if (e.code === ZWaveErrorCodes.CC_OperationFailed) {
// The sending node tried to do something that didn't work
await reply(SupervisionStatus.Fail);
handled = true;
} else if (e.code === ZWaveErrorCodes.CC_NotSupported) {
// The sending node sent a command we could not handle
await reply(SupervisionStatus.NoSupport);
handled = true;
}
}

// We only caught the error to be able to respond to supervised requests.
// Re-Throw so it can be handled accordingly
throw e;
if (!handled) {
// Something unexpected happened.
// Report failure, then re-throw the error, so it can be handled accordingly
await reply(SupervisionStatus.Fail);

throw e;
}
}
}

Expand Down
111 changes: 90 additions & 21 deletions packages/zwave-js/src/lib/node/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
AssociationCCGet,
AssociationCCRemove,
AssociationCCSet,
AssociationCCSpecificGroupGet,
AssociationCCSupportedGroupingsGet,
AssociationCCValues,
} from "@zwave-js/cc/AssociationCC";
Expand Down Expand Up @@ -3135,6 +3136,8 @@ protocol version: ${this.protocolVersion}`;
return this.handleAssociationSet(command);
} else if (command instanceof AssociationCCRemove) {
return this.handleAssociationRemove(command);
} else if (command instanceof AssociationCCSpecificGroupGet) {
return this.handleAssociationSpecificGroupGet(command);
} else if (
command instanceof MultiChannelAssociationCCSupportedGroupingsGet
) {
Expand Down Expand Up @@ -3194,6 +3197,14 @@ protocol version: ${this.protocolVersion}`;
message: `TODO: no handler for application command`,
direction: "inbound",
});

if (command.encapsulationFlags & EncapsulationFlags.Supervision) {
// Report no support for supervised commands we cannot handle
throw new ZWaveError(
"No handler for application command",
ZWaveErrorCodes.CC_NotSupported,
);
}
}

private hasLoggedNoNetworkKey = false;
Expand Down Expand Up @@ -4152,18 +4163,37 @@ protocol version: ${this.protocolVersion}`;

private handleAssociationSet(command: AssociationCCSet): void {
if (command.groupId !== 1) {
// We only "support" the lifeline group
return;
// We only "support" the lifeline group.
throw new ZWaveError(
`Association group ${command.groupId} is not supported.`,
ZWaveErrorCodes.CC_OperationFailed,
);
}

const controllerNode = this.driver.controller.nodes.get(
this.driver.controller.ownNodeId!,
);
if (!controllerNode) return;

// Ignore associations that already exist
const newAssociations = command.nodeIds.filter((newNodeId) =>
!controllerNode.associations.some(
({ nodeId, endpoint }) =>
endpoint === undefined && nodeId === newNodeId,
)
).map((nodeId) => ({ nodeId }));

const associations = [...controllerNode.associations];
associations.push(...command.nodeIds.map((nodeId) => ({ nodeId })));
controllerNode.associations = associations.slice(0, MAX_ASSOCIATIONS);
associations.push(...newAssociations);

// Report error if the association group is already full
if (associations.length > MAX_ASSOCIATIONS) {
throw new ZWaveError(
`Association group ${command.groupId} is full`,
ZWaveErrorCodes.CC_OperationFailed,
);
}
controllerNode.associations = associations;
}

private handleAssociationRemove(command: AssociationCCRemove): void {
Expand All @@ -4190,6 +4220,27 @@ protocol version: ${this.protocolVersion}`;
}
}

private async handleAssociationSpecificGroupGet(
command: AssociationCCSpecificGroupGet,
): Promise<void> {
const endpoint = this.getEndpoint(command.endpointIndex) ?? this;

// We are being queried, so the device may actually not support the CC, just control it.
// Using the commandClasses property would throw in that case
const api = endpoint
.createAPI(CommandClasses.Association, false)
.withOptions({
// Answer with the same encapsulation as asked, but omit
// Supervision as it shouldn't be used for Get-Report flows
encapsulationFlags: command.encapsulationFlags
& ~EncapsulationFlags.Supervision,
});

// We don't support this feature.
// It is RECOMMENDED that the value 0 is returned by non-supporting devices.
await api.reportSpecificGroup(0);
}

private async handleMultiChannelAssociationSupportedGroupingsGet(
command: MultiChannelAssociationCCSupportedGroupingsGet,
): Promise<void> {
Expand Down Expand Up @@ -4257,32 +4308,50 @@ protocol version: ${this.protocolVersion}`;
command: MultiChannelAssociationCCSet,
): void {
if (command.groupId !== 1) {
// We only "support" the lifeline group
return;
// We only "support" the lifeline group.
throw new ZWaveError(
`Multi Channel Association group ${command.groupId} is not supported.`,
ZWaveErrorCodes.CC_OperationFailed,
);
}

const controllerNode = this.driver.controller.nodes.get(
this.driver.controller.ownNodeId!,
);
if (!controllerNode) return;

const associations = [...controllerNode.associations];
associations.push(...command.nodeIds.map((nodeId) => ({ nodeId })));
for (const destination of command.endpoints) {
if (typeof destination.endpoint === "number") {
associations.push({
nodeId: destination.nodeId,
endpoint: destination.endpoint,
});
} else {
for (const endpoint of destination.endpoint) {
associations.push({
nodeId: destination.nodeId,
endpoint,
});
// Ignore associations that already exists
const newNodeIdAssociations = command.nodeIds.filter((newNodeId) =>
!controllerNode.associations.some(
({ nodeId, endpoint }) =>
endpoint === undefined && nodeId === newNodeId,
)
).map((nodeId) => ({ nodeId }));
const newEndpointAssociations = command.endpoints.flatMap(
({ nodeId, endpoint }) => {
if (typeof endpoint === "number") {
return { nodeId, endpoint };
} else {
return endpoint.map((e) => ({ nodeId, endpoint: e }));
}
}
},
).filter(({ nodeId: newNodeId, endpoint: newEndpoint }) =>
!controllerNode.associations.some(({ nodeId, endpoint }) =>
nodeId === newNodeId && endpoint === newEndpoint
)
);

const associations = [...controllerNode.associations];
associations.push(...newNodeIdAssociations, ...newEndpointAssociations);

// Report error if the association group is already full
if (associations.length > MAX_ASSOCIATIONS) {
throw new ZWaveError(
`Multi Channel Association group ${command.groupId} is full`,
ZWaveErrorCodes.CC_OperationFailed,
);
}

controllerNode.associations = associations.slice(0, MAX_ASSOCIATIONS);
}

Expand Down

0 comments on commit 6a3472f

Please sign in to comment.