inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
561 lines (512 loc) • 15 kB
text/typescript
import type { JsonlDB } from "@alcalzone/jsonl-db";
import { TypedEventEmitter } from "@zwave-js/shared";
import type { CommandClasses } from "../capabilities/CommandClasses";
import { isZWaveError, ZWaveError, ZWaveErrorCodes } from "../error/ZWaveError";
import type { ValueMetadata } from "../values/Metadata";
import type {
MetadataUpdatedArgs,
SetValueOptions,
ValueAddedArgs,
ValueID,
ValueNotificationArgs,
ValueRemovedArgs,
ValueUpdatedArgs,
} from "./_Types";
type ValueAddedCallback = (args: ValueAddedArgs) => void;
type ValueUpdatedCallback = (args: ValueUpdatedArgs) => void;
type ValueRemovedCallback = (args: ValueRemovedArgs) => void;
type ValueNotificationCallback = (args: ValueNotificationArgs) => void;
type MetadataUpdatedCallback = (args: MetadataUpdatedArgs) => void;
interface ValueDBEventCallbacks {
"value added": ValueAddedCallback;
"value updated": ValueUpdatedCallback;
"value removed": ValueRemovedCallback;
"value notification": ValueNotificationCallback;
"metadata updated": MetadataUpdatedCallback;
}
type ValueDBEvents = Extract<keyof ValueDBEventCallbacks, string>;
export function isValueID(param: Record<any, any>): param is ValueID {
// commandClass is mandatory and must be numeric
if (typeof param.commandClass !== "number") return false;
// property is mandatory and must be a number or string
if (
typeof param.property !== "number" &&
typeof param.property !== "string"
) {
return false;
}
// propertyKey is optional and must be a number or string
if (
param.propertyKey != undefined &&
typeof param.propertyKey !== "number" &&
typeof param.propertyKey !== "string"
) {
return false;
}
// endpoint is optional and must be a number
if (param.endpoint != undefined && typeof param.endpoint !== "number") {
return false;
}
return true;
}
export function assertValueID(
param: Record<any, any>,
): asserts param is ValueID {
if (!isValueID(param)) {
throw new ZWaveError(
`Invalid ValueID passed!`,
ZWaveErrorCodes.Argument_Invalid,
);
}
}
/**
* Ensures all Value ID properties are in the same order and there are no extraneous properties.
* A normalized value ID can be used as a database key */
export function normalizeValueID(valueID: ValueID): ValueID {
// valueIdToString is used by all other methods of the Value DB.
// Since those may be called by unsanitized value IDs, we need
// to make sure we have a valid value ID at our hands
assertValueID(valueID);
const { commandClass, endpoint, property, propertyKey } = valueID;
const jsonKey: ValueID = {
commandClass,
endpoint: endpoint ?? 0,
property,
};
if (propertyKey != undefined) jsonKey.propertyKey = propertyKey;
return jsonKey;
}
export function valueIdToString(valueID: ValueID): string {
return JSON.stringify(normalizeValueID(valueID));
}
/**
* The value store for a single node
*/
export class ValueDB extends TypedEventEmitter<ValueDBEventCallbacks> {
// This is a wrapper around the driver's on-disk value and metadata key value stores
/**
* @param nodeId The ID of the node this Value DB belongs to
* @param valueDB The DB instance which stores values
* @param metadataDB The DB instance which stores metadata
* @param ownKeys An optional pre-created index of this ValueDB's own keys
*/
public constructor(
nodeId: number,
valueDB: JsonlDB,
metadataDB: JsonlDB<ValueMetadata>,
ownKeys?: Set<string>,
) {
super();
this.nodeId = nodeId;
this._db = valueDB;
this._metadata = metadataDB;
this._index = ownKeys ?? this.buildIndex();
}
private nodeId: number;
private _db: JsonlDB<unknown>;
private _metadata: JsonlDB<ValueMetadata>;
private _index: Set<string>;
private buildIndex(): Set<string> {
const ret = new Set<string>();
for (const key of this._db.keys()) {
if (compareDBKeyFast(key, this.nodeId)) ret.add(key);
}
for (const key of this._metadata.keys()) {
if (!ret.has(key) && compareDBKeyFast(key, this.nodeId))
ret.add(key);
}
return ret;
}
private valueIdToDBKey(valueID: ValueID): string {
return JSON.stringify({
nodeId: this.nodeId,
...normalizeValueID(valueID),
});
}
private dbKeyToValueId(key: string): { nodeId: number } & ValueID {
try {
// Try the dumb but fast way first
return dbKeyToValueIdFast(key);
} catch {
// Fall back to JSON.parse if anything went wrong
return JSON.parse(key);
}
}
/**
* Stores a value for a given value id
*/
public setValue(
valueId: ValueID,
value: unknown,
options: SetValueOptions = {},
): void {
let dbKey: string;
try {
dbKey = this.valueIdToDBKey(valueId);
} catch (e) {
if (
isZWaveError(e) &&
e.code === ZWaveErrorCodes.Argument_Invalid &&
options.noThrow === true
) {
// ignore invalid value IDs
return;
}
throw e;
}
if (options.stateful !== false) {
const cbArg: ValueAddedArgs | ValueUpdatedArgs = {
...valueId,
newValue: value,
};
let event: ValueDBEvents;
if (this._db.has(dbKey)) {
event = "value updated";
(cbArg as ValueUpdatedArgs).prevValue = this._db.get(dbKey);
if (options.source)
(cbArg as ValueUpdatedArgs).source = options.source;
} else {
event = "value added";
}
this._index.add(dbKey);
this._db.set(dbKey, value);
if (valueId.commandClass >= 0 && options.noEvent !== true) {
this.emit(event, cbArg);
}
} else if (valueId.commandClass >= 0) {
// For non-stateful values just emit a notification
this.emit("value notification", {
...valueId,
value,
});
}
}
/**
* Removes a value for a given value id
*/
public removeValue(
valueId: ValueID,
options: SetValueOptions = {},
): boolean {
const dbKey: string = this.valueIdToDBKey(valueId);
if (!this._metadata.has(dbKey)) {
this._index.delete(dbKey);
}
if (this._db.has(dbKey)) {
const prevValue = this._db.get(dbKey);
this._db.delete(dbKey);
if (valueId.commandClass >= 0 && options.noEvent !== true) {
const cbArg: ValueRemovedArgs = {
...valueId,
prevValue,
};
this.emit("value removed", cbArg);
}
return true;
}
return false;
}
/**
* Retrieves a value for a given value id
*/
public getValue<T = unknown>(valueId: ValueID): T | undefined {
const key = this.valueIdToDBKey(valueId);
return this._db.get(key) as T | undefined;
}
/**
* Checks if a value for a given value id exists in this ValueDB
*/
public hasValue(valueId: ValueID): boolean {
const key = this.valueIdToDBKey(valueId);
return this._db.has(key);
}
/** Returns all values whose id matches the given predicate */
public findValues(
predicate: (id: ValueID) => boolean,
): (ValueID & { value: unknown })[] {
const ret: ReturnType<ValueDB["findValues"]> = [];
for (const key of this._index) {
if (!this._db.has(key)) continue;
const { nodeId, ...valueId } = this.dbKeyToValueId(key);
if (predicate(valueId)) {
ret.push({ ...valueId, value: this._db.get(key) });
}
}
return ret;
}
/** Returns all values that are stored for a given CC */
public getValues(forCC: CommandClasses): (ValueID & { value: unknown })[] {
const ret: ReturnType<ValueDB["getValues"]> = [];
for (const key of this._index) {
if (
compareDBKeyFast(key, this.nodeId, { commandClass: forCC }) &&
this._db.has(key)
) {
const { nodeId, ...valueId } = this.dbKeyToValueId(key);
const value = this._db.get(key);
ret.push({ ...valueId, value });
}
}
return ret;
}
/** Clears all values from the value DB */
public clear(options: SetValueOptions = {}): void {
for (const key of this._index) {
const { nodeId, ...valueId } = this.dbKeyToValueId(key);
if (this._db.has(key)) {
const prevValue = this._db.get(key);
this._db.delete(key);
if (valueId.commandClass >= 0 && options.noEvent !== true) {
const cbArg: ValueRemovedArgs = {
...valueId,
prevValue,
};
this.emit("value removed", cbArg);
}
}
if (this._metadata.has(key)) {
this._metadata.delete(key);
if (valueId.commandClass >= 0 && options.noEvent !== true) {
const cbArg: MetadataUpdatedArgs = {
...valueId,
metadata: undefined,
};
this.emit("metadata updated", cbArg);
}
}
}
this._index.clear();
}
/**
* Stores metadata for a given value id
*/
public setMetadata(
valueId: ValueID,
metadata: ValueMetadata | undefined,
options: SetValueOptions = {},
): void {
let dbKey: string;
try {
dbKey = this.valueIdToDBKey(valueId);
} catch (e) {
if (
isZWaveError(e) &&
e.code === ZWaveErrorCodes.Argument_Invalid &&
options.noThrow === true
) {
// ignore invalid value IDs
return;
}
throw e;
}
if (metadata) {
this._index.add(dbKey);
this._metadata.set(dbKey, metadata);
} else {
if (!this._db.has(dbKey)) {
this._index.delete(dbKey);
}
this._metadata.delete(dbKey);
}
const cbArg: MetadataUpdatedArgs = {
...valueId,
metadata,
};
if (valueId.commandClass >= 0 && options.noEvent !== true) {
this.emit("metadata updated", cbArg);
}
}
/**
* Checks if metadata for a given value id exists in this ValueDB
*/
public hasMetadata(valueId: ValueID): boolean {
const key = this.valueIdToDBKey(valueId);
return this._metadata.has(key);
}
/**
* Retrieves metadata for a given value id
*/
public getMetadata(valueId: ValueID): ValueMetadata | undefined {
const key = this.valueIdToDBKey(valueId);
return this._metadata.get(key);
}
/** Returns all metadata that is stored for a given CC */
public getAllMetadata(forCC: CommandClasses): (ValueID & {
metadata: ValueMetadata;
})[] {
const ret: ReturnType<ValueDB["getAllMetadata"]> = [];
for (const key of this._index) {
if (
compareDBKeyFast(key, this.nodeId, { commandClass: forCC }) &&
this._metadata.has(key)
) {
const { nodeId, ...valueId } = this.dbKeyToValueId(key);
const metadata = this._metadata.get(key)!;
ret.push({ ...valueId, metadata });
}
}
return ret;
}
/** Returns all values whose id matches the given predicate */
public findMetadata(predicate: (id: ValueID) => boolean): (ValueID & {
metadata: ValueMetadata;
})[] {
const ret: ReturnType<ValueDB["findMetadata"]> = [];
for (const key of this._index) {
if (!this._metadata.has(key)) continue;
const { nodeId, ...valueId } = this.dbKeyToValueId(key);
if (predicate(valueId)) {
ret.push({ ...valueId, metadata: this._metadata.get(key)! });
}
}
return ret;
}
}
/**
* Really dumb but very fast way to parse one-lined JSON strings of the following schema
* {
* nodeId: number,
* commandClass: number,
* endpoint: number,
* property: string | number,
* propertyKey: string | number,
* }
*
* In benchmarks this was about 58% faster than JSON.parse
*/
export function dbKeyToValueIdFast(key: string): { nodeId: number } & ValueID {
let start = 10; // {"nodeId":
if (key.charCodeAt(start - 1) !== 58) {
console.error(key.slice(start - 1));
throw new Error("Invalid input format!");
}
let end = start + 1;
const len = key.length;
while (end < len && key.charCodeAt(end) !== 44) end++;
const nodeId = parseInt(key.slice(start, end));
start = end + 16; // ,"commandClass":
if (key.charCodeAt(start - 1) !== 58)
throw new Error("Invalid input format!");
end = start + 1;
while (end < len && key.charCodeAt(end) !== 44) end++;
const commandClass = parseInt(key.slice(start, end));
start = end + 12; // ,"endpoint":
if (key.charCodeAt(start - 1) !== 58)
throw new Error("Invalid input format!");
end = start + 1;
while (end < len && key.charCodeAt(end) !== 44) end++;
const endpoint = parseInt(key.slice(start, end));
start = end + 12; // ,"property":
if (key.charCodeAt(start - 1) !== 58)
throw new Error("Invalid input format!");
let property;
if (key.charCodeAt(start) === 34) {
start++; // skip leading "
end = start + 1;
while (end < len && key.charCodeAt(end) !== 34) end++;
property = key.slice(start, end);
end++; // skip trailing "
} else {
end = start + 1;
while (
end < len &&
key.charCodeAt(end) !== 44 &&
key.charCodeAt(end) !== 125
)
end++;
property = parseInt(key.slice(start, end));
}
if (key.charCodeAt(end) !== 125) {
let propertyKey;
start = end + 15; // ,"propertyKey":
if (key.charCodeAt(start - 1) !== 58)
throw new Error("Invalid input format!");
if (key.charCodeAt(start) === 34) {
start++; // skip leading "
end = start + 1;
while (end < len && key.charCodeAt(end) !== 34) end++;
propertyKey = key.slice(start, end);
end++; // skip trailing "
} else {
end = start + 1;
while (
end < len &&
key.charCodeAt(end) !== 44 &&
key.charCodeAt(end) !== 125
)
end++;
propertyKey = parseInt(key.slice(start, end));
}
return {
nodeId,
commandClass,
endpoint,
property,
propertyKey,
};
} else {
return {
nodeId,
commandClass,
endpoint,
property,
};
}
}
/** Used to filter DB entries without JSON parsing */
function compareDBKeyFast(
key: string,
nodeId: number,
valueId?: Partial<ValueID>,
): boolean {
if (-1 === key.indexOf(`{"nodeId":${nodeId},`)) return false;
if (!valueId) return true;
if ("commandClass" in valueId) {
if (-1 === key.indexOf(`,"commandClass":${valueId.commandClass},`))
return false;
}
if ("endpoint" in valueId) {
if (-1 === key.indexOf(`,"endpoint":${valueId.endpoint},`))
return false;
}
if (typeof valueId.property === "string") {
if (-1 === key.indexOf(`,"property":"${valueId.property}"`))
return false;
} else if (typeof valueId.property === "number") {
if (-1 === key.indexOf(`,"property":${valueId.property}`)) return false;
}
if (typeof valueId.propertyKey === "string") {
if (-1 === key.indexOf(`,"propertyKey":"${valueId.propertyKey}"`))
return false;
} else if (typeof valueId.propertyKey === "number") {
if (-1 === key.indexOf(`,"propertyKey":${valueId.propertyKey}`))
return false;
}
return true;
}
/** Extracts an index for each node from one or more JSONL DBs */
export function indexDBsByNode(databases: JsonlDB[]): Map<number, Set<string>> {
const indexes = new Map<number, Set<string>>();
for (const db of databases) {
for (const key of db.keys()) {
const nodeId = extractNodeIdFromDBKeyFast(key);
if (nodeId == undefined) continue;
if (!indexes.has(nodeId)) {
indexes.set(nodeId, new Set());
}
indexes.get(nodeId)!.add(key);
}
}
return indexes;
}
function extractNodeIdFromDBKeyFast(key: string): number | undefined {
const start = 10; // {"nodeId":
if (key.charCodeAt(start - 1) !== 58) {
// Invalid input format for a node value, assume it is for the driver
return undefined;
}
let end = start + 1;
const len = key.length;
while (end < len && key.charCodeAt(end) !== 44) end++;
return parseInt(key.slice(start, end));
}