Skip to content

Commit

Permalink
feat: support Z-Wave Long Range (#6401)
Browse files Browse the repository at this point in the history
Co-authored-by: Dominic Griesel <[email protected]>
  • Loading branch information
jtbraun and AlCalzone authored Jan 19, 2024
1 parent fb93b59 commit 3fd27bc
Show file tree
Hide file tree
Showing 25 changed files with 1,067 additions and 264 deletions.
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,
): 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;

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++) {
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

0 comments on commit 3fd27bc

Please sign in to comment.