Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Z-Wave Long Range #6401

Merged
merged 45 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
666257e
feat(workaround): zst39 workaround for corrupted soft reset ack
jtbraun Oct 15, 2023
acfb9ea
feat(longrange): add FunctionType values
jtbraun Oct 13, 2023
5f81ca9
fix(functype): add comment about source of FunctionType=0x28
jtbraun Oct 16, 2023
b4ddd89
feat(longrange): support encoding/parsing node information
jtbraun Oct 14, 2023
5eb0762
feat(longrange): handle node ids > 256 in S2 auth data
jtbraun Oct 16, 2023
fcaacb3
feat(longrange): add long range command class
jtbraun Oct 14, 2023
29ef9af
feat(squash): with other ZWaveLRProtocolCC.ts changes
jtbraun Oct 16, 2023
1b56690
feat(squash): with other index.ts changes
jtbraun Oct 16, 2023
63d142b
feat(squash): with other Constants.ts
jtbraun Oct 16, 2023
6005dfa
feat(squash): with other Protocols.ts
jtbraun Oct 16, 2023
cd27d02
feat(longrange): the bulk of long range support
jtbraun Oct 16, 2023
8f15e3a
fix(lint): lint errors
jtbraun Oct 16, 2023
d1e23fc
fix(lint): lint errors
jtbraun Oct 16, 2023
70038ab
Merge remote-tracking branch 'upstream/master' into feature/longrange
jtbraun Nov 15, 2023
9b679c3
feat(longrange): remove corrupted ack workaround
jtbraun Nov 15, 2023
9637127
feat(longrange): remove ZWaveLRProtocolCC
jtbraun Nov 15, 2023
8223ee9
feat(longrange): remove "Protocol" from the LR CC name
jtbraun Nov 15, 2023
e8f9023
feat(longrange): move NodeInfo parsing/encoding changes one function …
jtbraun Nov 15, 2023
02052ef
feat(longrange): expand LR to LongRange in smart start messages
jtbraun Nov 15, 2023
c9184ef
feat(longrange): remove comment about long range CC
jtbraun Nov 15, 2023
d00b084
feat(longrange): undo changes to GetSerialApiCapabilitiesRequest
jtbraun Nov 15, 2023
15bc808
feat(longrange): use segment for GetLongRangeNodes*
jtbraun Nov 15, 2023
7b268b4
feat(longrange): array.push, not array.concat to append
jtbraun Nov 15, 2023
1bb54b2
feat(longrange): move fetching LR nodes to a helper method
jtbraun Nov 15, 2023
9b5c7ba
feat(longrange): move LongRangeChannel to core..Protocols.ts
jtbraun Nov 15, 2023
32c0c34
feat(longrange): remove validate length comments
jtbraun Nov 15, 2023
25908de
feat(longrange): remove ResponseStatus
jtbraun Nov 15, 2023
e8c3134
feat(longrange): fix erronous boolean instead of bitwise and
jtbraun Nov 15, 2023
6e52505
feat(longrange): fix lint errors
jtbraun Nov 15, 2023
e55e479
feat(longrange): lint fixes
jtbraun Nov 15, 2023
f0d0dde
feat(longrange): change LR=>LongRange, condition some serial api setu…
jtbraun Nov 15, 2023
2e7971d
fix: only pass Z-Wave LR protocol flag to SmartStart inclusion
AlCalzone Jan 16, 2024
8715fce
refactor: cleanup, prevent managing routes for LR, highlight some FIXMEs
AlCalzone Jan 16, 2024
f9585f0
chore: more cleanup
AlCalzone Jan 16, 2024
fe446eb
fix: default to the first supported protocol
AlCalzone Jan 17, 2024
79b1a6a
chore: rework controller info message
AlCalzone Jan 17, 2024
8f22fd1
refactor: rework initialization sequence
AlCalzone Jan 17, 2024
a42243d
fix: restore original encoding of GetSerialApiInitDataResponse
AlCalzone Jan 17, 2024
683db90
fix: typo
AlCalzone Jan 17, 2024
ea170ce
fix: split GetLongRangeNodes into own file, fix query loop
AlCalzone Jan 17, 2024
f226a82
fix: node info parsing
AlCalzone Jan 17, 2024
1e09888
fix: remember node ID when requesting protocol info
AlCalzone Jan 18, 2024
098b8d1
fix: cleanup
AlCalzone Jan 19, 2024
c58c2fa
docs: add documentation
AlCalzone Jan 19, 2024
01039de
Merge branch 'master' into pr/jtbraun/6401-1
AlCalzone Jan 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [Our Philosophy](getting-started/philosophy.md)
- [Frequently Asked Questions](getting-started/faq.md)
- [Security S2](getting-started/security-s2.md)
- [Z-Wave Long Range](getting-started/long-range.md)
- [Migrating to v12](getting-started/migrating-to-v12.md)
- [Migrating to v11](getting-started/migrating-to-v11.md)
- [Migrating to v10](getting-started/migrating-to-v10.md)
Expand Down
1 change: 1 addition & 0 deletions docs/api/CCs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [Our Philosophy](getting-started/philosophy.md)
- [Frequently Asked Questions](getting-started/faq.md)
- [Security S2](getting-started/security-s2.md)
- [Z-Wave Long Range](getting-started/long-range.md)
- [Migrating to v12](getting-started/migrating-to-v12.md)
- [Migrating to v11](getting-started/migrating-to-v11.md)
- [Migrating to v10](getting-started/migrating-to-v10.md)
Expand Down
10 changes: 10 additions & 0 deletions docs/api/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ interface PlannedProvisioningEntry {
/** The device specific key (DSK) in the form aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-11111-22222 */
dsk: string;
securityClasses: SecurityClass[];
// ...other fields are irrelevant for this inclusion procedure
}
```

Expand Down Expand Up @@ -238,6 +239,7 @@ provisionSmartStartNode(entry: PlannedProvisioningEntry): void
```

Adds the given entry (DSK and security classes) to the controller's SmartStart provisioning list or replaces an existing entry. The node will be included out of band when it powers up.
If the `protocol` field is set to `Protocols.ZWaveLongRange`, the node will be included using Z-Wave Long Range instead of Z-Wave Classic.

> [!ATTENTION] This method will throw when SmartStart is not supported by the controller!

Expand All @@ -256,6 +258,14 @@ interface PlannedProvisioningEntry {
/** The device specific key (DSK) in the form aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-11111-22222 */
dsk: string;

/** Which protocol to use for inclusion. Default: Z-Wave Classic */
protocol?: Protocols;
/**
* The protocols that are **supported** by the device.
* When this is not set, applications should default to Z-Wave classic.
*/
supportedProtocols?: readonly Protocols[];

/** The security classes that have been **granted** by the user */
securityClasses: SecurityClass[];
/**
Expand Down
11 changes: 11 additions & 0 deletions docs/getting-started/long-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Supporting Z-Wave Long Range in Applications

Z-Wave Long Range (ZWLR) is an addition to Z-Wave, that allows for a massively increased transmission range and up to 4000 nodes in a single network. Z-Wave Long Range uses a star topology, where all nodes communicate directly with the controller. This means that ZWLR nodes cannot be used to route messages for non-ZWLR nodes.

There are a few things applications need to be aware of to support Long Range using Z-Wave JS.

1. ZWLR node IDs start at 256. This can be used to distinguish between ZWLR and classic Z-Wave nodes.
2. ZWLR inclusion works exclusively through [Smart Start](getting-started/security-s2#smartstart).
\
ZWLR nodes advertise support for Long Range in the `supportedProtocols` field of the `QRProvisioningInformation` object (see [here](api/utils#other-qr-codes)). When this field is present, the user **MUST** have the choice between the advertised protocols. Currently this means deciding between including the node via Z-Wave Classic (mesh) or Z-Wave Long Range (no mesh).\
To include a node via ZWLR, set the `protocol` field of the `PlannedProvisioningEntry` to `Protocols.ZWaveLongRange` when [provisioning the node](api/controller#provisionsmartstartnode).
24 changes: 18 additions & 6 deletions packages/cc/src/cc/Security2CC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
encryptAES128CCM,
getCCName,
highResTimestamp,
isLongRangeNodeId,
isTransmissionError,
isZWaveError,
parseBitMask,
Expand Down Expand Up @@ -94,13 +95,24 @@ function getAuthenticationData(
commandLength: number,
unencryptedPayload: Buffer,
): Buffer {
const ret = Buffer.allocUnsafe(8 + unencryptedPayload.length);
ret[0] = sendingNodeId;
ret[1] = destination;
ret.writeUInt32BE(homeId, 2);
ret.writeUInt16BE(commandLength, 6);
const nodeIdSize =
isLongRangeNodeId(sendingNodeId) || isLongRangeNodeId(destination)
? 2
: 1;
const ret = Buffer.allocUnsafe(
2 * nodeIdSize + 6 + unencryptedPayload.length,
);
let offset = 0;
ret.writeUIntBE(sendingNodeId, offset, nodeIdSize);
offset += nodeIdSize;
ret.writeUIntBE(destination, offset, nodeIdSize);
offset += nodeIdSize;
ret.writeUInt32BE(homeId, offset);
offset += 4;
ret.writeUInt16BE(commandLength, offset);
offset += 2;
// This includes the sequence number and all unencrypted extensions
unencryptedPayload.copy(ret, 8, 0);
unencryptedPayload.copy(ret, offset, 0);
return ret;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/capabilities/CommandClasses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,9 @@ export enum CommandClasses {
"Z/IP ND" = 0x58,
"Z/IP Portal" = 0x61,
"Z-Wave Plus Info" = 0x5e,
// Internal CC which is not used directly by applications
// Internal CCs which are not used directly by applications
"Z-Wave Protocol" = 0x01,
"Z-Wave Long Range" = 0x04,
}

export function getCCName(cc: number): string {
Expand Down
110 changes: 83 additions & 27 deletions packages/core/src/capabilities/NodeInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,26 +215,36 @@ export type NodeInformationFrame =
export function parseNodeProtocolInfo(
buffer: Buffer,
offset: number,
isLongRange: boolean = false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where did you find the information you used to update this method? I'm not sure a change is necessary, as the controller should forward a properly formatted NIF, no matter if the protocol-level frame it received is using the Long Range CC or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See specs\Z-Wave Stack Specifications\Z-Wave and Z-Wave Long Range Network Layer Specification.pdf. Long Range added their own Node Information Frame Command in section 6.3.1.2. The non-long-range frame (section 4.3.2.1) has 3 bytes for Basic/Generic/Specific device classes, and then a list of command classes for the remainder of the packet.

The long range frame has Generic and Specific device class bytes (no basic), and then a byte that is the "command list length" (in bytes, as I recall), and then the list of command classes.

Why they felt the need to replace these fields and add the seemingly redundant length, I'm not sure.

If we don't need/want the long range command class in this change (it wasn't needed to get pairing and comms to work). I think this stuff can be backed out.

): NodeProtocolInfo {
validatePayload(buffer.length >= offset + 3);

const isListening = !!(buffer[offset] & 0b10_000_000);
const isRouting = !!(buffer[offset] & 0b01_000_000);

const supportedDataRates: DataRate[] = [];
const maxSpeed = buffer[offset] & 0b00_011_000;
const speedExtension = buffer[offset + 2] & 0b111;
if (maxSpeed & 0b00_010_000) {
supportedDataRates.push(40000);
}
if (maxSpeed & 0b00_001_000) {
supportedDataRates.push(9600);
}
if (speedExtension & 0b001) {
supportedDataRates.push(100000);
}
if (supportedDataRates.length === 0) {
supportedDataRates.push(9600);
const speed = buffer[offset] & 0b00_011_000;
const speedExt = buffer[offset + 2] & 0b111;
if (isLongRange) {
// In the LR NIF, the speed bitmask is reserved and contains no information
// The speedExt bitmask is used instead, but for some reason the bitmask
// is different from a classic NIF...
if (speedExt & 0b010) {
supportedDataRates.push(100000);
}
} else {
if (speed & 0b00_010_000) {
supportedDataRates.push(40000);
}
if (speed & 0b00_001_000) {
supportedDataRates.push(9600);
}
if (speedExt & 0b001) {
supportedDataRates.push(100000);
}
if (supportedDataRates.length === 0) {
supportedDataRates.push(9600);
}
}

const protocolVersion = buffer[offset] & 0b111;
Expand Down Expand Up @@ -282,14 +292,23 @@ export function parseNodeProtocolInfo(
};
}

export function encodeNodeProtocolInfo(info: NodeProtocolInfo): Buffer {
export function encodeNodeProtocolInfo(
info: NodeProtocolInfo,
isLongRange: boolean = false,
): Buffer {
// Technically a lot of these fields are reserved/unused in Z-Wave Long Range,
// but the only thing where it really matters is the speed bitmask.
const ret = Buffer.alloc(3, 0);
// Byte 0 and 2
if (info.isListening) ret[0] |= 0b10_000_000;
if (info.isRouting) ret[0] |= 0b01_000_000;
if (info.supportedDataRates.includes(40000)) ret[0] |= 0b00_010_000;
if (info.supportedDataRates.includes(9600)) ret[0] |= 0b00_001_000;
if (info.supportedDataRates.includes(100000)) ret[2] |= 0b001;
if (isLongRange) {
if (info.supportedDataRates.includes(100000)) ret[2] |= 0b010;
} else {
if (info.supportedDataRates.includes(40000)) ret[0] |= 0b00_010_000;
if (info.supportedDataRates.includes(9600)) ret[0] |= 0b00_001_000;
if (info.supportedDataRates.includes(100000)) ret[2] |= 0b001;
}
ret[0] |= info.protocolVersion & 0b111;

// Byte 1
Expand All @@ -307,12 +326,20 @@ export function encodeNodeProtocolInfo(info: NodeProtocolInfo): Buffer {
return ret;
}

export function parseNodeProtocolInfoAndDeviceClass(buffer: Buffer): {
export function parseNodeProtocolInfoAndDeviceClass(
buffer: Buffer,
isLongRange: boolean = false,
): {
info: NodeProtocolInfoAndDeviceClass;
bytesRead: number;
} {
validatePayload(buffer.length >= 5);
const protocolInfo = parseNodeProtocolInfo(buffer, 0);
// The specs are a bit confusing here. We parse the response to GetNodeProtocolInfo,
// which always includes the basic device class, unlike the NIF that was received by
// the end device. However, the meaning of the flags in the first 3 bytes may change
// depending on the protocol in use.
const protocolInfo = parseNodeProtocolInfo(buffer, 0, isLongRange);

let offset = 3;
const basic = buffer[offset++];
const generic = buffer[offset++];
Expand All @@ -334,9 +361,13 @@ export function parseNodeProtocolInfoAndDeviceClass(buffer: Buffer): {

export function encodeNodeProtocolInfoAndDeviceClass(
info: NodeProtocolInfoAndDeviceClass,
isLongRange: boolean = false,
): Buffer {
return Buffer.concat([
encodeNodeProtocolInfo({ ...info, hasSpecificDeviceClass: true }),
encodeNodeProtocolInfo(
{ ...info, hasSpecificDeviceClass: true },
isLongRange,
),
Buffer.from([
info.basicDeviceClass,
info.genericDeviceClass,
Expand All @@ -347,23 +378,48 @@ export function encodeNodeProtocolInfoAndDeviceClass(

export function parseNodeInformationFrame(
buffer: Buffer,
isLongRange: boolean = false,
): NodeInformationFrame {
const { info, bytesRead: offset } = parseNodeProtocolInfoAndDeviceClass(
const result = parseNodeProtocolInfoAndDeviceClass(
buffer,
isLongRange,
);
const supportedCCs = parseCCList(buffer.subarray(offset)).supportedCCs;
const info = result.info;
let offset = result.bytesRead;

let ccList: Buffer;
if (isLongRange) {
const ccListLength = buffer[offset];
offset += 1;
validatePayload(buffer.length >= offset + ccListLength);
ccList = buffer.subarray(offset, offset + ccListLength);
} else {
ccList = buffer.subarray(offset);
}

const supportedCCs = parseCCList(ccList).supportedCCs;

return {
...info,
supportedCCs,
};
}

export function encodeNodeInformationFrame(info: NodeInformationFrame): Buffer {
return Buffer.concat([
encodeNodeProtocolInfoAndDeviceClass(info),
encodeCCList(info.supportedCCs, []),
]);
export function encodeNodeInformationFrame(
info: NodeInformationFrame,
isLongRange: boolean = false,
): Buffer {
const protocolInfo = encodeNodeProtocolInfoAndDeviceClass(
info,
isLongRange,
);

let ccList = encodeCCList(info.supportedCCs, []);
if (isLongRange) {
ccList = Buffer.concat([Buffer.from([ccList.length]), ccList]);
}

return Buffer.concat([protocolInfo, ccList]);
}

export function parseNodeID(
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/capabilities/Protocols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,14 @@ export function isEmptyRoute(route: Route): boolean {
&& route.routeSpeed === ZWaveDataRate["9k6"]
);
}

export enum LongRangeChannel {
Unknown = 0x00, // Reserved
A = 0x01,
B = 0x02,
// 0x03..0xFF are reserved and must not be used
}

export function isLongRangeNodeId(nodeId: number): boolean {
return nodeId > 255;
}
6 changes: 6 additions & 0 deletions packages/core/src/consts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export const NODE_ID_MAX = MAX_NODES;
/** The number of bytes in a node bit mask */
export const NUM_NODEMASK_BYTES = MAX_NODES / 8;

/** The number of node ids in a long range "segment" (GetLongRangeNodes response) */
export const NUM_LR_NODES_PER_SEGMENT = 128;

/** The number of bytes in a long range node bit mask segment */
export const NUM_LR_NODEMASK_SEGMENT_BYTES = NUM_LR_NODES_PER_SEGMENT / 8;
jtbraun marked this conversation as resolved.
Show resolved Hide resolved

export enum NodeIDType {
Short = 0x01,
Long = 0x02,
Expand Down
40 changes: 32 additions & 8 deletions packages/core/src/values/Primitive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { MAX_NODES, NUM_NODEMASK_BYTES } from "../consts";
import {
MAX_NODES,
NUM_LR_NODES_PER_SEGMENT,
NUM_NODEMASK_BYTES,
} from "../consts";
import { ZWaveError, ZWaveErrorCodes } from "../error/ZWaveError";
import {
getBitMaskWidth,
Expand Down Expand Up @@ -231,15 +235,17 @@ export function encodeFloatWithScale(
}

/** Parses a bit mask into a numeric array */
export function parseBitMask(mask: Buffer, startValue: number = 1): number[] {
const numBits = mask.length * 8;

export function parseBitMask(
mask: Buffer,
startValue: number = 1,
numBits: number = mask.length * 8,
): number[] {
const ret: number[] = [];
for (let index = 1; index <= numBits; index++) {
const byteNum = (index - 1) >>> 3; // id / 8
const bitNum = (index - 1) % 8;
for (let index = 0; index < numBits; index++) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally found this confusing... we do 1 based indexing in the for statement, only to subtract off 1 everywhere below. I just removed the 1 everywhere.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why I did it this way. Maybe this was more in line with some texts in the specs.

const byteNum = index >>> 3; // id / 8
const bitNum = index % 8;
if ((mask[byteNum] & (2 ** bitNum)) !== 0) {
ret.push(index + startValue - 1);
ret.push(index + startValue);
}
}
return ret;
Expand All @@ -266,10 +272,28 @@ export function parseNodeBitMask(mask: Buffer): number[] {
return parseBitMask(mask.subarray(0, NUM_NODEMASK_BYTES));
}

export function parseLongRangeNodeBitMask(
mask: Buffer,
startValue: number,
): number[] {
return parseBitMask(mask, startValue);
}

export function encodeNodeBitMask(nodeIDs: readonly number[]): Buffer {
return encodeBitMask(nodeIDs, MAX_NODES);
}

export function encodeLongRangeNodeBitMask(
nodeIDs: readonly number[],
startValue: number,
): Buffer {
return encodeBitMask(
nodeIDs,
startValue + NUM_LR_NODES_PER_SEGMENT - 1,
startValue,
);
}

/**
* Parses a partial value from a "full" value. Example:
* ```txt
Expand Down
8 changes: 7 additions & 1 deletion packages/serial/src/message/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export enum FunctionType {
UNKNOWN_FUNC_MEMORY_PUT_BUFFER = 0x24,

EnterBootloader = 0x27, // Leave Serial API and enter bootloader (700+ series only). Enter Auto-Programming mode (500 series only).
UNKNOWN_FUNC_UNKNOWN_0x28 = 0x28, // ??
UNKNOWN_FUNC_UNKNOWN_0x28 = 0x28, // ZW_NVRGetValue(offset, length) => NVRdata[], see INS13954-13

GetNVMId = 0x29, // Returns information about the external NVM
ExtNVMReadLongBuffer = 0x2a, // Reads a buffer from the external NVM
Expand Down Expand Up @@ -174,6 +174,12 @@ export enum FunctionType {

Shutdown = 0xd9, // Instruct the Z-Wave API to shut down in order to safely remove the power

// Long range controller support
GetLongRangeNodes = 0xda, // Used after GetSerialApiInitData to get the nodes with IDs > 0xFF
GetLongRangeChannel = 0xdb,
SetLongRangeChannel = 0xdc,
SetLongRangeShadowNodeIDs = 0xdd,

UNKNOWN_FUNC_UNKNOWN_0xEF = 0xef, // ??

// Special commands for Z-Wave.me sticks
Expand Down
Loading