2394 lines
68 KiB
TypeScript
2394 lines
68 KiB
TypeScript
/*!
|
|
* This script is used to import the Z-Wave device database from
|
|
* https://www.cd-jackson.com/zwave_device_database/zwave-database-json.gz.tar
|
|
* and translate the information into a form this library expects
|
|
*/
|
|
|
|
process.on("unhandledRejection", (r) => {
|
|
throw r;
|
|
});
|
|
|
|
import { CommandClasses, getIntegerLimits } from "@zwave-js/core";
|
|
import {
|
|
enumFilesRecursive,
|
|
formatId,
|
|
getErrorMessage,
|
|
num2hex,
|
|
padVersion,
|
|
readJSON,
|
|
stringify,
|
|
} from "@zwave-js/shared";
|
|
import { isArray, isObject } from "alcalzone-shared/typeguards";
|
|
import * as JSONC from "comment-json";
|
|
import * as JSON5 from "json5";
|
|
import { AssertionError, ok } from "node:assert";
|
|
import * as child from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { compare } from "semver";
|
|
import xml2js from "xml2js";
|
|
import xml2js_parsers from "xml2js/lib/processors.js";
|
|
import yargs from "yargs";
|
|
import { hideBin } from "yargs/helpers";
|
|
import { ConfigManager } from "../src/ConfigManager.js";
|
|
import { type DeviceConfigIndexEntry } from "../src/devices/DeviceConfig.js";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const execPromise = promisify(child.exec);
|
|
const yargsInstance = yargs(hideBin(process.argv));
|
|
|
|
const program = yargsInstance
|
|
.option("source", {
|
|
description: "source of the import",
|
|
alias: "s",
|
|
type: "array",
|
|
choices: ["oh", "ozw", "zwa"], // oh: openhab, ozw: openzwave; zwa: zWave Alliance
|
|
default: ["zwa"],
|
|
})
|
|
.option("ids", {
|
|
description:
|
|
"devices ids to download. In ozw the format is '<manufacturer>-<productId>-<productType>'. Ex: '0x0086-0x0075-0x0004'",
|
|
type: "array",
|
|
array: true,
|
|
})
|
|
.option("download", {
|
|
alias: "D",
|
|
description: "Download devices DB from <source>",
|
|
type: "boolean",
|
|
default: false,
|
|
})
|
|
.option("clean", {
|
|
alias: "C",
|
|
description: "Clean temporary directory",
|
|
type: "boolean",
|
|
default: false,
|
|
})
|
|
.option("manufacturers", {
|
|
alias: "m",
|
|
description: "Parse and update manufacturers.json",
|
|
type: "boolean",
|
|
default: false,
|
|
})
|
|
.option("manufacturer_folder", {
|
|
alias: "M",
|
|
description:
|
|
"Download all Z-Wave alliance files for the specified manufacturer (using the zwa website manufacturer ID)",
|
|
type: "array",
|
|
array: true,
|
|
})
|
|
.option("devices", {
|
|
alias: "d",
|
|
description: "Parse and update devices configurations",
|
|
type: "boolean",
|
|
default: false,
|
|
})
|
|
.option("parse", {
|
|
alias: "p",
|
|
description: "Run custom parse routines -- maintenance",
|
|
type: "boolean",
|
|
default: false,
|
|
})
|
|
.example(
|
|
"import -s ozw -Dmd",
|
|
"Download and parse OpenZwave db (manufacturers, devices) and update the index",
|
|
)
|
|
.example(
|
|
"import -s oh -Dmd",
|
|
"Download and parse openhab db (manufacturers, devices) and update the index",
|
|
)
|
|
.example(
|
|
"import -s oh -D --ids 1234 5678",
|
|
"Download openhab devices with ids `1234` and `5678`",
|
|
)
|
|
.help()
|
|
.version(false)
|
|
.alias("h", "help")
|
|
.parseSync();
|
|
|
|
// Where the files are located
|
|
const processedDir = path.join(
|
|
__dirname,
|
|
"../../../packages/config",
|
|
"config/devices",
|
|
);
|
|
|
|
const configManager = new ConfigManager();
|
|
|
|
const ozwTempDir = path.join(__dirname, "../../../.tmpozw");
|
|
const ozwTarName = "openzwave.tar.gz";
|
|
const ozwTarUrl =
|
|
"https://github.com/OpenZWave/open-zwave/archive/master.tar.gz";
|
|
const ozwConfigFolder = path.join(ozwTempDir, "./config");
|
|
|
|
const zwaTempDir = path.join(__dirname, "../../../.tmpzwa");
|
|
|
|
const ohTempDir = path.join(__dirname, "../../../.tmpoh");
|
|
const importedManufacturersPath = path.join(ohTempDir, "manufacturers.json");
|
|
|
|
// Where all the information can be found
|
|
const ohUrlManufacturers =
|
|
"https://opensmarthouse.org/dmxConnect/api/zwavedatabase/manufacturers/list.php?sort=label&limit=99999";
|
|
const ohUrlIDs =
|
|
"https://opensmarthouse.org/dmxConnect/api/zwavedatabase/device/list.php?filter=&manufacturer=-1&limit=100000";
|
|
const ohUrlDevice = (id: number) =>
|
|
`https://opensmarthouse.org/dmxConnect/api/zwavedatabase/device/read.php?device_id=${id}`;
|
|
const zwaUrlDevice = (id: number) =>
|
|
`https://products.z-wavealliance.org/Products/${id}/json`;
|
|
|
|
function isNullishOrEmptyString(
|
|
value: number | string | null | undefined,
|
|
): value is "" | null | undefined {
|
|
return value == undefined || value === "";
|
|
}
|
|
|
|
const xmlParserOptions_default: xml2js.ParserOptions = {
|
|
// Don't separate xml attributes from children
|
|
mergeAttrs: true,
|
|
// We normalize to arrays where necessary, no need to do it globally
|
|
explicitArray: false,
|
|
};
|
|
|
|
const xmlParserOptions_coerce: xml2js.ParserOptions = {
|
|
// Coerce strings to numbers and booleans where it makes sense
|
|
attrValueProcessors: [
|
|
xml2js_parsers.parseBooleans,
|
|
xml2js_parsers.parseNumbers,
|
|
],
|
|
valueProcessors: [
|
|
xml2js_parsers.parseBooleans,
|
|
xml2js_parsers.parseNumbers,
|
|
],
|
|
};
|
|
|
|
/** Updates a numeric value with a new value, sanitizing the input. Falls back to the previous value (if it exists) or a default one */
|
|
function updateNumberOrDefault(
|
|
newN: number | string,
|
|
oldN: number | string,
|
|
defaultN: number,
|
|
): number | undefined {
|
|
// Try new value first
|
|
let ret = sanitizeNumber(newN);
|
|
if (typeof ret === "number") return ret;
|
|
|
|
// Fallback to old value
|
|
ret = sanitizeNumber(oldN);
|
|
if (typeof ret === "number") return ret;
|
|
|
|
return defaultN;
|
|
}
|
|
|
|
/** Retrieves the list of database IDs from the OpenSmartHouse DB */
|
|
async function fetchIDsOH(): Promise<number[]> {
|
|
const { got } = await import("got");
|
|
const data = (await got.get(ohUrlIDs).json()) as any;
|
|
return data.devices.map((d: any) => d.id);
|
|
}
|
|
|
|
/** Retrieves the definition for a specific device from the OpenSmartHouse DB */
|
|
async function fetchDeviceOH(id: number): Promise<string> {
|
|
const { got } = await import("got");
|
|
const source = (await got.get(ohUrlDevice(id)).json()) as any;
|
|
return stringify(source, "\t");
|
|
}
|
|
|
|
/** Retrieves the definition for a specific device from the Z-Wave Alliance DB */
|
|
async function fetchDeviceZWA(id: number): Promise<string> {
|
|
const { got } = await import("got");
|
|
const source = (await got.get(zwaUrlDevice(id)).json()) as any;
|
|
return stringify(source, "\t");
|
|
}
|
|
|
|
/** Downloads ozw master archive and store it on `tmpDir` */
|
|
async function downloadOZWConfig(): Promise<string> {
|
|
console.log("downloading ozw archive...");
|
|
const { got } = await import("got");
|
|
|
|
// create tmp directory if missing
|
|
await fs.mkdir(ozwTempDir, { recursive: true });
|
|
|
|
// this will return a stream in `data` that we pipe into write stream
|
|
// to store the file in `tmpDir`
|
|
const data = got.stream.get(ozwTarUrl);
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
const fileDest = path.join(ozwTempDir, ozwTarName);
|
|
const handle = await fs.open(fileDest, "w");
|
|
const stream = handle.createWriteStream();
|
|
data.pipe(stream);
|
|
let hasError = false;
|
|
stream.on("error", (err) => {
|
|
hasError = true;
|
|
stream.close();
|
|
reject(err);
|
|
});
|
|
|
|
stream.on("close", () => {
|
|
if (!hasError) {
|
|
resolve(fileDest);
|
|
console.log("ozw archive stored in temporary directory");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Extract `config` folder from ozw archive in `tmpDir` */
|
|
async function extractConfigFromTar(): Promise<void> {
|
|
console.log("extracting config folder from ozw archive...");
|
|
await execPromise(
|
|
`tar -xzf ${ozwTarName} open-zwave-master/config --strip-components=1`,
|
|
{ cwd: ozwTempDir },
|
|
);
|
|
}
|
|
|
|
/** Delete all files in `tmpDir` */
|
|
async function cleanTmpDirectory(): Promise<void> {
|
|
await fs.rm(ozwTempDir, { recursive: true, force: true });
|
|
await fs.rm(ohTempDir, { recursive: true, force: true });
|
|
await fs.rm(zwaTempDir, { recursive: true, force: true });
|
|
console.log("temporary directories cleaned");
|
|
}
|
|
|
|
function matchId(
|
|
manufacturer: string,
|
|
prodType: string,
|
|
prodId: string,
|
|
): boolean {
|
|
return !!program.ids?.includes(
|
|
`${formatId(manufacturer)}-${formatId(prodType)}-${formatId(prodId)}`,
|
|
);
|
|
}
|
|
|
|
/** Reads OZW `manufacturer_specific.xml` */
|
|
async function parseOZWConfig(): Promise<void> {
|
|
// The manufacturer_specific.xml is OZW's index file and contains all devices, their type, ID and name (label)
|
|
const manufacturerFile = path.join(
|
|
ozwConfigFolder,
|
|
"manufacturer_specific.xml",
|
|
);
|
|
const manufacturerJson: Record<string, any> = await xml2js
|
|
.parseStringPromise(
|
|
await fs.readFile(manufacturerFile, "utf8"),
|
|
xmlParserOptions_default,
|
|
);
|
|
|
|
// Load our existing config files to cross-reference
|
|
await configManager.loadManufacturers();
|
|
if (program.devices) {
|
|
await configManager.loadDeviceIndex();
|
|
}
|
|
|
|
for (const man of manufacturerJson.ManufacturerSpecificData.Manufacturer) {
|
|
// <Manufacturer id="012A" name="ManufacturerName">... products ...</Manufacturer>
|
|
const manufacturerId = parseInt(man.id, 16);
|
|
let manufacturerName = configManager.lookupManufacturer(manufacturerId);
|
|
|
|
// Add the manufacturer to our manufacturers.json if it is missing
|
|
if (manufacturerName === undefined && man.name !== undefined) {
|
|
console.log(`Adding missing manufacturer: ${man.name}`);
|
|
// let this here, if program.manufacturers is false it will not
|
|
// write the manufacturers to file
|
|
configManager.setManufacturer(manufacturerId, man.name);
|
|
}
|
|
manufacturerName = man.name;
|
|
|
|
if (program.devices) {
|
|
// Import all device config files of this manufacturer if requested
|
|
const products = ensureArray(man.Product);
|
|
for (const product of products) {
|
|
if (product.config !== undefined) {
|
|
if (
|
|
!program.ids
|
|
|| matchId(man.id, product.id, product.type)
|
|
) {
|
|
await parseOZWProduct(
|
|
product,
|
|
manufacturerId,
|
|
manufacturerName,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (program.manufacturers) {
|
|
await configManager.saveManufacturers();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When using xml2json some fields expected as array are parsed as objects
|
|
* when there is only one element. This method ensures that they are arrays
|
|
*/
|
|
function ensureArray(json: any): any[] {
|
|
json = json ?? [];
|
|
return isArray(json) ? json : [json];
|
|
}
|
|
|
|
function normalizeUnits(unit: string) {
|
|
if (!unit) return undefined;
|
|
|
|
if (/minutes/i.test(unit)) {
|
|
return "minutes";
|
|
} else if (/seconds/i.test(unit)) {
|
|
return "seconds";
|
|
} else if (/fahrenheit|\bf\b/i.test(unit)) {
|
|
return "°F";
|
|
} else if (/degrees celsius|celsius|\bc\b/i.test(unit)) {
|
|
return "°C";
|
|
} else if (/\bwatt/i.test(unit)) {
|
|
return "W";
|
|
} else if (/\bvolt/i.test(unit)) {
|
|
return "V";
|
|
} else if (/percent|dimmer level|%/i.test(unit)) {
|
|
return "%";
|
|
} else if (/degrees/i.test(unit)) {
|
|
return "°";
|
|
}
|
|
return unit;
|
|
}
|
|
|
|
/**
|
|
* Normalize a device JSON configuration to ensure all keys have the same order
|
|
* and fix some parameters if needed
|
|
*
|
|
* @param config Device JSON configuration
|
|
*/
|
|
function normalizeConfig(config: Record<string, any>): Record<string, any> {
|
|
// Top-level key order (comments are not preserved between top-level keys)
|
|
const topOrder = [
|
|
"manufacturer",
|
|
"manufacturerId",
|
|
"label",
|
|
"description",
|
|
"devices",
|
|
"firmwareVersion",
|
|
"associations",
|
|
"paramInformation",
|
|
"compat",
|
|
"metadata",
|
|
];
|
|
|
|
// Parameter key order (comments preserved)
|
|
const paramOrder = [
|
|
"$if",
|
|
"$import",
|
|
"label",
|
|
"description",
|
|
"valueSize",
|
|
"unit",
|
|
"minValue",
|
|
"maxValue",
|
|
"defaultValue",
|
|
"unsigned",
|
|
"readOnly",
|
|
"writeOnly",
|
|
"allowManualEntry",
|
|
"options",
|
|
];
|
|
|
|
// Potentially empty arrays and objects to remove
|
|
const disallowEmpty = [
|
|
"associations",
|
|
"paramInformation",
|
|
"compat",
|
|
"metadata",
|
|
];
|
|
|
|
/*******************
|
|
* Standardize things
|
|
********************/
|
|
|
|
// Sort parameters in only new files
|
|
if (config.isNewFile) {
|
|
if (config.paramInformation) {
|
|
config.paramInformation = config.paramInformation.sort(
|
|
(a: Record<string, any>, b: Record<string, any>) => {
|
|
const aNum = parseInt(a["#"], 10);
|
|
const bNum = parseInt(b["#"], 10);
|
|
return aNum - bNum;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
delete config.isNewFile;
|
|
|
|
// Enforce top-level order
|
|
for (const l of topOrder) {
|
|
if (typeof config[l] === "undefined") {
|
|
continue;
|
|
} else if (config[l] === "") {
|
|
delete config[l];
|
|
}
|
|
|
|
const temp = config[l];
|
|
delete config[l];
|
|
config[l] = temp;
|
|
}
|
|
|
|
// Remove empty arrays and objects
|
|
for (const prop of Object.keys(disallowEmpty)) {
|
|
if (prop in config) {
|
|
// Key exists
|
|
if (
|
|
isObject(config[prop])
|
|
&& Object.keys(config[prop]).length === 0
|
|
) {
|
|
delete config[prop];
|
|
} else if (isArray(config[prop]) && config[prop].length === 0) {
|
|
delete config[prop];
|
|
} else if (!config[prop]) {
|
|
delete config[prop];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize labels
|
|
config.label = sanitizeText(config.label) ?? "";
|
|
config.description = sanitizeText(config.description) ?? "";
|
|
|
|
// Sort devices by productType, then productId
|
|
config.devices.sort((a: any, b: any) => {
|
|
if (a.productType < b.productType) return -1;
|
|
if (a.productType > b.productType) return +1;
|
|
if (a.productId < b.productId) return -1;
|
|
if (a.productId > b.productId) return +1;
|
|
return 0;
|
|
});
|
|
|
|
// Standardize parameters
|
|
if (config.paramInformation?.length) {
|
|
// Filter out duplicates between partial and non-partial params
|
|
const allowedKeys = (config.paramInformation as any[])
|
|
.filter(
|
|
(param, _, arr) =>
|
|
// Allow partial params
|
|
!/^\d+$/.test(param["#"])
|
|
// and non-partial params...
|
|
// either with a condition
|
|
|| "$if" in param
|
|
// or without a corresponding partial param
|
|
|| !arr.some((other) =>
|
|
other["#"].startsWith(`${param["#"]}[`)
|
|
),
|
|
)
|
|
.map((e) => e["#"]);
|
|
|
|
config.paramInformation = config.paramInformation.filter((param: any) =>
|
|
allowedKeys.includes(param["#"])
|
|
);
|
|
for (const original of config.paramInformation) {
|
|
original.unit = normalizeUnits(original.unit);
|
|
|
|
if (original.readOnly) {
|
|
original.allowManualEntry = undefined;
|
|
original.writeOnly = undefined;
|
|
} else if (original.writeOnly) {
|
|
original.readOnly = undefined;
|
|
} else {
|
|
original.readOnly = undefined;
|
|
original.writeOnly = undefined;
|
|
}
|
|
|
|
if (original.allowManualEntry === true) {
|
|
original.allowManualEntry = undefined;
|
|
}
|
|
|
|
// Remove undefined keys while preserving comments
|
|
for (const l of paramOrder) {
|
|
if (original[l] == undefined || original[l] === "") {
|
|
delete original[l];
|
|
continue;
|
|
}
|
|
|
|
const temp = original[l];
|
|
delete original[l];
|
|
original[l] = temp;
|
|
}
|
|
|
|
// Delete empty options arrays
|
|
if (original.options?.length === 0) {
|
|
delete original.options;
|
|
} else if (program.source.includes("ozw")) {
|
|
const values = original.options.map((o: any) => o.value);
|
|
original.minValue = Math.min(...values);
|
|
original.maxValue = Math.max(...values);
|
|
}
|
|
}
|
|
} else {
|
|
delete config.paramInformation;
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Read and parse the product xml, add it to index if missing,
|
|
* create/update device json config and validate the newly added
|
|
* device
|
|
*
|
|
* @param product the parsed product json entry from manufacturer.xml
|
|
*/
|
|
async function parseOZWProduct(
|
|
product: any,
|
|
manufacturerId: number,
|
|
manufacturer: string | undefined,
|
|
): Promise<void> {
|
|
const productFile = await fs.readFile(
|
|
path.join(ozwConfigFolder, product.config),
|
|
"utf8",
|
|
);
|
|
|
|
// TODO: Parse the label from XML metadata, e.g.
|
|
// <MetaDataItem id="0100" name="Identifier" type="2002">CT32 </MetaDataItem>
|
|
const productLabel = path
|
|
.basename(product.config, ".xml")
|
|
.toLocaleUpperCase();
|
|
|
|
// any products descriptions have productName in it, remove it
|
|
const productName = product.name.replace(productLabel, "");
|
|
|
|
// for some reasons some products already have the prefix `0x`, remove it
|
|
product.id = product.id.replace(/^0x/, "");
|
|
product.type = product.type.replace(/^0x/, "");
|
|
|
|
// Format the device IDs like we expect them
|
|
const productId = formatId(product.id);
|
|
const productType = formatId(product.type);
|
|
const manufacturerIdHex = formatId(manufacturerId);
|
|
|
|
const deviceConfigs = configManager
|
|
.getIndex()
|
|
?.filter(
|
|
(f: DeviceConfigIndexEntry) =>
|
|
f.manufacturerId === manufacturerIdHex
|
|
&& f.productType === productType
|
|
&& f.productId === productId,
|
|
) ?? [];
|
|
const latestConfig = getLatestConfigVersion(deviceConfigs);
|
|
|
|
// Determine where the config file should be
|
|
const fileNameRelative = latestConfig?.filename
|
|
?? `${manufacturerIdHex}/${labelToFilename(productLabel)}.json`;
|
|
const fileNameAbsolute = path.join(processedDir, fileNameRelative);
|
|
|
|
// Load the existing config so we can merge it with the updated information
|
|
let existingDevice: Record<string, any> | undefined;
|
|
const existingDeviceFileContents = await fs.readFile(
|
|
fileNameAbsolute,
|
|
"utf8",
|
|
).catch(() => undefined);
|
|
if (existingDeviceFileContents) {
|
|
existingDevice = JSON5.parse(existingDeviceFileContents);
|
|
}
|
|
|
|
// Parse the OZW xml file
|
|
const json = (
|
|
await xml2js.parseStringPromise(productFile, {
|
|
...xmlParserOptions_default,
|
|
...xmlParserOptions_coerce,
|
|
})
|
|
).Product as Record<string, any>;
|
|
|
|
// const metadata = ensureArray(json.MetaData?.MetaDataItem);
|
|
// const name = metadata.find((m: any) => m.name === "Name")?.$t;
|
|
// const description = metadata.find((m: any) => m.name === "Description")?.$t;
|
|
|
|
const devices = existingDevice?.devices ?? [];
|
|
|
|
if (
|
|
!devices.some(
|
|
(d: { productType: string; productId: string }) =>
|
|
d.productType === productType && d.productId === productId,
|
|
)
|
|
) {
|
|
devices.push({ productType, productId });
|
|
}
|
|
|
|
const newConfig: Record<string, any> = {
|
|
manufacturer,
|
|
manufacturerId: manufacturerIdHex,
|
|
label: productLabel,
|
|
description: existingDevice?.description ?? productName, // don't override the description
|
|
devices: devices,
|
|
firmwareVersion: {
|
|
min: existingDevice?.firmwareVersion.min ?? "0.0",
|
|
max: existingDevice?.firmwareVersion.max ?? "255.255",
|
|
},
|
|
associations: existingDevice?.associations ?? {},
|
|
paramInformation: existingDevice?.paramInformation ?? [],
|
|
compat: existingDevice?.compat,
|
|
};
|
|
|
|
// Merge the devices array with a potentially existing one
|
|
if (existingDevice) {
|
|
for (const device of existingDevice.devices) {
|
|
if (
|
|
!newConfig.devices.some(
|
|
(d: any) =>
|
|
d.productType === device.productType
|
|
&& d.productId === device.productId,
|
|
)
|
|
) {
|
|
newConfig.devices.push(device);
|
|
}
|
|
}
|
|
}
|
|
|
|
const commandClasses = ensureArray(json.CommandClass);
|
|
|
|
// parse config params: <CommandClass id="112"> ...values... </CommandClass>
|
|
const parameters = ensureArray(
|
|
commandClasses.find((c: any) => c.id === CommandClasses.Configuration)
|
|
?.Value,
|
|
);
|
|
for (const param of parameters) {
|
|
if (isNaN(param.index)) continue;
|
|
|
|
const isBitSet = param.type === "bitset";
|
|
|
|
if (isBitSet) {
|
|
// BitSets are split into multiple partial parameters
|
|
const bitSetIds = ensureArray(param.BitSet);
|
|
const defaultValue = typeof param.value === "number"
|
|
? param.value
|
|
: 0;
|
|
const valueSize = param.size || 1;
|
|
|
|
// Partial params share the first part of the label
|
|
param.label = ensureArray(param.label)[0];
|
|
const paramLabel = param.label ? `${param.label}. ` : "";
|
|
|
|
for (const bitSet of bitSetIds) {
|
|
// OZW has 1-based bit indizes, we are 0-based
|
|
const bit = (bitSet.id || 1) - 1;
|
|
const mask = 2 ** bit;
|
|
const id = `${param.index}[${num2hex(mask)}]`;
|
|
|
|
// Parse the label for this bit
|
|
const label = ensureArray(bitSet.Label)[0] ?? "";
|
|
const desc = ensureArray(bitSet.Help)[0] ?? "";
|
|
|
|
const found = newConfig.paramInformation.find(
|
|
(p: any) => p["#"] === id.toString(),
|
|
);
|
|
const parsedParam = found ?? {};
|
|
|
|
parsedParam.label = `${paramLabel}${label}`;
|
|
parsedParam.description = desc;
|
|
parsedParam.valueSize = valueSize; // The partial param must have the same value size as the original param
|
|
// OZW only supports single-bit "partial" params, so we only have 0 and 1 as possible values
|
|
parsedParam.minValue = 0;
|
|
parsedParam.maxValue = 1;
|
|
parsedParam.defaultValue = !!(defaultValue & mask) ? 1 : 0;
|
|
parsedParam.readOnly = undefined;
|
|
parsedParam.writeOnly = undefined;
|
|
parsedParam.allowManualEntry = undefined;
|
|
|
|
if (!found) newConfig.paramInformation.push(parsedParam);
|
|
}
|
|
} else {
|
|
const found = newConfig.paramInformation.find(
|
|
(p: any) => p["#"] === param.index.toString(),
|
|
);
|
|
const parsedParam = found ?? {};
|
|
|
|
// By default, update existing properties with new descriptions
|
|
// OZW's config fields could be empty strings, so we need to use || instead of ??
|
|
parsedParam.label = ensureArray(param.label)[0]
|
|
|| parsedParam.label;
|
|
parsedParam.description = ensureArray(param.Help)[0]
|
|
|| parsedParam.description;
|
|
parsedParam.valueSize = updateNumberOrDefault(
|
|
param.size,
|
|
parsedParam.valueSize,
|
|
1,
|
|
);
|
|
parsedParam.minValue = updateNumberOrDefault(
|
|
param.min,
|
|
parsedParam.min,
|
|
0,
|
|
);
|
|
try {
|
|
parsedParam.maxValue = updateNumberOrDefault(
|
|
param.max,
|
|
parsedParam.max,
|
|
getIntegerLimits(parsedParam.valueSize, false).max, // choose the biggest possible number if no max is given
|
|
);
|
|
} catch {
|
|
// some config params have absurd value sizes, ignore them
|
|
parsedParam.maxValue = parsedParam.minValue;
|
|
}
|
|
if (param.read_only === true || param.read_only === "true") {
|
|
parsedParam.readOnly = true;
|
|
} else if (
|
|
param.write_only === true
|
|
|| param.write_only === "true"
|
|
) {
|
|
parsedParam.writeOnly = true;
|
|
}
|
|
parsedParam.allowManualEntry = !parsedParam.readOnly
|
|
&& param.type !== "list";
|
|
|
|
parsedParam.defaultValue = updateNumberOrDefault(
|
|
param.value,
|
|
parsedParam.value,
|
|
parsedParam.minValue, // choose the smallest possible number if no default is given
|
|
);
|
|
parsedParam.unsigned = true; // ozw values are all unsigned
|
|
|
|
if (param.units) {
|
|
parsedParam.unit = param.units;
|
|
}
|
|
|
|
// could have multiple translations, if so it's an array, the first is the english one
|
|
if (isArray(parsedParam.description)) {
|
|
parsedParam.description = parsedParam.description[0];
|
|
}
|
|
|
|
if (typeof parsedParam.description !== "string") {
|
|
parsedParam.description = "";
|
|
}
|
|
|
|
const items = ensureArray(param.Item);
|
|
|
|
// Parse options list
|
|
// <Item label="Option 1" value="1"/>
|
|
// <Item label="Option 2" value="2"/>
|
|
if (param.type === "list" && items.length > 0) {
|
|
parsedParam.options = [];
|
|
for (const item of items) {
|
|
if (
|
|
!parsedParam.options.some(
|
|
(v: any) => v.value === item.value,
|
|
)
|
|
) {
|
|
const opt = {
|
|
label: item.label.toString(),
|
|
value: parseInt(item.value),
|
|
};
|
|
parsedParam.options.push(opt);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!found) newConfig.paramInformation.push(parsedParam);
|
|
}
|
|
}
|
|
|
|
// parse associations contained in command class 133 and 142
|
|
const associations = [
|
|
...ensureArray(
|
|
commandClasses.find((c: any) => c.id === CommandClasses.Association)
|
|
?.Associations?.Group,
|
|
),
|
|
...ensureArray(
|
|
commandClasses.find(
|
|
(c: any) =>
|
|
c.id === CommandClasses["Multi Channel Association"],
|
|
)?.Associations?.Group,
|
|
),
|
|
];
|
|
|
|
if (associations.length > 0) {
|
|
newConfig.associations ??= {};
|
|
for (const ass of associations) {
|
|
const parsedAssociation = newConfig.associations[ass.index] ?? {};
|
|
|
|
parsedAssociation.label = ass.label;
|
|
parsedAssociation.maxNodes = ass.max_associations;
|
|
// Only set the isLifeline key if its true
|
|
const isLifeline = /lifeline/i.test(ass.label)
|
|
|| ass.auto === "true"
|
|
|| ass.auto === true;
|
|
if (isLifeline) parsedAssociation.isLifeline = true;
|
|
|
|
newConfig.associations[ass.index] = parsedAssociation;
|
|
}
|
|
}
|
|
|
|
// Some devices report other CCs than they support, add this information to the compat field
|
|
const toAdd = commandClasses
|
|
.filter((c) => c.action === "add")
|
|
.map((c) => c.id);
|
|
const toRemove = commandClasses
|
|
.filter((c) => c.action === "remove")
|
|
.map((c) => c.id);
|
|
|
|
if (toAdd.length > 0 || toRemove.length > 0) {
|
|
newConfig.compat ??= {};
|
|
newConfig.compat.cc ??= {};
|
|
|
|
if (toAdd.length > 0) {
|
|
newConfig.compat.cc.add = toAdd;
|
|
}
|
|
if (toRemove.length > 0) {
|
|
newConfig.compat.cc.remove = toRemove;
|
|
}
|
|
}
|
|
// create the target dir for this config file if doesn't exists
|
|
const manufacturerDir = path.join(processedDir, manufacturerIdHex);
|
|
await fs.mkdir(manufacturerDir, { recursive: true });
|
|
|
|
// write the updated configuration file
|
|
const output = stringify(normalizeConfig(newConfig), "\t") + "\n";
|
|
await fs.writeFile(fileNameAbsolute, output, "utf8");
|
|
}
|
|
|
|
/*********************************************************
|
|
* *
|
|
* zWave Alliance Processing Section *
|
|
* *
|
|
* *******************************************************/
|
|
|
|
/**
|
|
* Parse a directory of zWave Alliance device xmls
|
|
*/
|
|
async function parseZWAFiles(): Promise<void> {
|
|
// Parse json files in the zwaTempDir
|
|
let jsonData = [];
|
|
|
|
const configFiles = await enumFilesRecursive(
|
|
zwaTempDir,
|
|
(file) => file.endsWith(".json"),
|
|
);
|
|
|
|
for (const file of configFiles) {
|
|
const j = await fs.readFile(file, "utf8");
|
|
|
|
/**
|
|
* zWave Alliance numbering isn't always continuous and an html page is
|
|
returned when a device number doesn't. Test for and delete such files.
|
|
*/
|
|
if (j.charAt(0) === "{") {
|
|
jsonData.push(JSON.parse(j));
|
|
} else {
|
|
void fs.unlink(file);
|
|
}
|
|
}
|
|
|
|
// Check for missing fields and add placeholders if missing
|
|
for (const device of jsonData) {
|
|
if (!device.ManufacturerId) {
|
|
device.ManufacturerId = "0x9999";
|
|
}
|
|
|
|
if (!device.Brand) {
|
|
device.Brand = "Unknown";
|
|
}
|
|
|
|
if (!device.ProductTypeId) {
|
|
device.ProductTypeId = "0x9999";
|
|
}
|
|
|
|
if (!device.ProductId) {
|
|
device.ProductId = "0x9999";
|
|
}
|
|
}
|
|
|
|
// Combine provided files within models
|
|
jsonData = combineDeviceFiles(jsonData);
|
|
|
|
// Sanitize text fields for all files we'll use
|
|
jsonData = sanitizeFields(jsonData);
|
|
|
|
// Load our existing config files to cross-reference
|
|
await configManager.loadManufacturers();
|
|
if (program.devices) {
|
|
await configManager.loadDeviceIndex();
|
|
}
|
|
|
|
for (const file of jsonData) {
|
|
// Lookup the manufacturer
|
|
const manufacturerId = parseInt(file.ManufacturerId, 16);
|
|
const manufacturerName = configManager.lookupManufacturer(
|
|
manufacturerId,
|
|
);
|
|
|
|
// Add the manufacturer to our manufacturers.json if it is missing
|
|
if (
|
|
!Number.isNaN(manufacturerId)
|
|
&& file.ManufacturerId !== "0x9999"
|
|
&& manufacturerName === undefined
|
|
&& file.Brand !== undefined
|
|
) {
|
|
console.log(`Adding missing manufacturer: ${file.Brand}`);
|
|
configManager.setManufacturer(manufacturerId, file.Brand);
|
|
}
|
|
|
|
/**
|
|
* Process and write the device files, if called with program.devices
|
|
*/
|
|
|
|
if (program.devices && file.ProductId) {
|
|
await parseZWAProduct(file, manufacturerId, manufacturerName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the manufacturer.json file, if called with program.manufacturers
|
|
*/
|
|
if (program.manufacturers) {
|
|
await configManager.saveManufacturers();
|
|
}
|
|
}
|
|
|
|
/***
|
|
* Combine zWave Alliance Device Files
|
|
*/
|
|
function combineDeviceFiles(json: Record<string, any>[]) {
|
|
for (const file of json) {
|
|
const identifier = file.Identifier || "Unknown";
|
|
const normalizedIdentifier = normalizeIdentifier(identifier);
|
|
file.Identifier = normalizedIdentifier[0];
|
|
file.OriginalIdentifier = normalizedIdentifier[1];
|
|
}
|
|
|
|
for (const file of json) {
|
|
const testManufactuer: number = file.ManufacturerId;
|
|
const testCertification: string = file.CertificationNumber;
|
|
const testParameters = file.ConfigurationParameters;
|
|
const testIdentifier = file.Identifier;
|
|
|
|
// Don't process if we've already seen this file
|
|
if (!file.ProductId) {
|
|
continue;
|
|
}
|
|
|
|
// Only deal with formatted IDs
|
|
file.ProductId = file.ProductId.replace(/^0x/, "");
|
|
file.ProductId = formatId(file.ProductId);
|
|
file.ProductTypeId = file.ProductTypeId.replace(/^0x/, "");
|
|
file.ProductTypeId = formatId(file.ProductTypeId);
|
|
|
|
for (const test_file of json) {
|
|
// Don't reprocess test files we've already seen
|
|
if (!test_file.ProductId) {
|
|
continue;
|
|
}
|
|
|
|
// Only deal with formatted IDs, but have to test as these will be undefined on subsequent visits
|
|
test_file.ProductId = test_file.ProductId.replace(/^0x/, "");
|
|
test_file.ProductId = formatId(test_file.ProductId);
|
|
test_file.ProductTypeId = test_file.ProductTypeId.replace(
|
|
/^0x/,
|
|
"",
|
|
);
|
|
test_file.ProductTypeId = formatId(test_file.ProductTypeId);
|
|
|
|
if (
|
|
test_file.ManufacturerId === testManufactuer
|
|
&& test_file.ProductId
|
|
) {
|
|
// Add the current file being tested
|
|
if (
|
|
test_file.Identifier === testIdentifier
|
|
&& test_file.CertificationNumber === testCertification
|
|
) {
|
|
file.combinedDevices = createOrUpdateArray(
|
|
file.combinedDevices,
|
|
{
|
|
ProductId: test_file.ProductId,
|
|
ProductTypeId: test_file.ProductTypeId,
|
|
Id: test_file.Id,
|
|
Brand: test_file.Brand,
|
|
Identifier: test_file.Identifier,
|
|
},
|
|
);
|
|
} // Duplicate of file tested, so add the ID and remove the duplicate
|
|
else if (
|
|
test_file.ProductId === file.ProductId
|
|
&& test_file.ProductTypeId === file.ProductTypeId
|
|
&& isEquivalentParameters(
|
|
testParameters,
|
|
test_file?.ConfigurationParameters,
|
|
"ParameterNumber",
|
|
)
|
|
) {
|
|
// Add the device
|
|
file.combinedDevices = createOrUpdateArray(
|
|
file.combinedDevices,
|
|
{
|
|
ProductId: test_file.ProductId,
|
|
ProductTypeId: test_file.ProductTypeId,
|
|
Id: test_file.Id,
|
|
Brand: test_file.Brand,
|
|
Identifier: test_file.Identifier,
|
|
},
|
|
);
|
|
delete test_file.Identifier;
|
|
delete test_file.ProductId;
|
|
|
|
// Merge the files themselves
|
|
file.SupportedCommandClasses = keepLongest(
|
|
file.SupportedCommandClasses,
|
|
test_file.SupportedCommandClasses,
|
|
);
|
|
file.AssociationGroups = keepLongest(
|
|
file.AssociationGroups,
|
|
test_file.AssociationGroups,
|
|
);
|
|
file.Documents = keepLongest(
|
|
file.Documents,
|
|
test_file.Documents,
|
|
);
|
|
file.Texts = keepLongest(file.Texts, test_file.Texts);
|
|
file.Features = keepLongest(
|
|
file.Features,
|
|
test_file.Features,
|
|
);
|
|
} // Combine devices with similar identifiers AND equivalent parameters
|
|
else if (
|
|
test_file.Identifier === testIdentifier
|
|
&& testIdentifier !== "Unknown"
|
|
&& testIdentifier.length > 3
|
|
&& isEquivalentParameters(
|
|
testParameters,
|
|
test_file?.ConfigurationParameters,
|
|
"ParameterNumber",
|
|
)
|
|
) {
|
|
file.combinedDevices = createOrUpdateArray(
|
|
file.combinedDevices,
|
|
{
|
|
ProductId: test_file.ProductId,
|
|
ProductTypeId: test_file.ProductTypeId,
|
|
Id: test_file.Id,
|
|
Brand: test_file.Brand,
|
|
Identifier: test_file.Identifier,
|
|
},
|
|
);
|
|
delete test_file.Identifier;
|
|
delete test_file.ProductId;
|
|
|
|
// Merge the files themselves
|
|
// If they aren't both zwave plus, we need to strike the command classes
|
|
if (
|
|
bothZwavePlus(
|
|
file.SupportedCommandClasses,
|
|
test_file.SupportedCommandClasses,
|
|
)
|
|
) {
|
|
file.SupportedCommandClasses = keepLongest(
|
|
file.SupportedCommandClasses,
|
|
test_file.SupportedCommandClasses,
|
|
);
|
|
} else {
|
|
file.SupportedCommandClasses = [];
|
|
}
|
|
file.AssociationGroups = keepLongest(
|
|
file.AssociationGroups,
|
|
test_file.AssociationGroups,
|
|
);
|
|
file.Documents = keepLongest(
|
|
file.Documents,
|
|
test_file.Documents,
|
|
);
|
|
file.Texts = keepLongest(file.Texts, test_file.Texts);
|
|
file.Features = keepLongest(
|
|
file.Features,
|
|
test_file.Features,
|
|
);
|
|
} // Show an error if the device parameters should match, but they don't
|
|
// TODO add error handling if a FW changes parameters
|
|
else if (
|
|
test_file.ProductId === file.ProductId
|
|
&& test_file.ProductTypeId === file.ProductTypeId
|
|
&& isEquivalentParameters(
|
|
testParameters,
|
|
test_file?.ConfigurationParameters,
|
|
"ParameterNumber",
|
|
) == false
|
|
) {
|
|
console.log(
|
|
`WARNING - Detected possible firmware parameter change ${file.Identifier} -- ${file.Id} and ${test_file.Id}`,
|
|
);
|
|
} // We were wrong to change the identifier because the params don't match, restore the tested file as it is different
|
|
else if (
|
|
test_file.Identifier === testIdentifier
|
|
&& isEquivalentParameters(
|
|
testParameters,
|
|
test_file?.ConfigurationParameters,
|
|
"ParameterNumber",
|
|
) === false
|
|
) {
|
|
test_file.Identifier = test_file.OriginalIdentifier;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function keepLongest(current_group: any, test_group: any) {
|
|
if (current_group.length >= test_group.length) {
|
|
return current_group;
|
|
} else {
|
|
return test_group;
|
|
}
|
|
}
|
|
|
|
function bothZwavePlus(current_group: any, test_group: any) {
|
|
for (const z of current_group) {
|
|
for (const class2 of test_group) {
|
|
if (
|
|
(z.Identifier.includes("ZWAVEPLUS")
|
|
|| z.Identifier.includes("ASSOCIATION_GRP_INFO"))
|
|
&& (class2.Identifier.includes("ZWAVEPLUS")
|
|
|| class2.Identifier.includes("ASSOCIATION_GRP_INFO"))
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return json;
|
|
}
|
|
|
|
/***
|
|
* Combine zWave Alliance Device Files
|
|
*/
|
|
function sanitizeFields(json: Record<string, any>[]) {
|
|
for (const file of json) {
|
|
if (file.ProductId) {
|
|
file.Identifier = file.Identifier
|
|
? sanitizeString(file.Identifier)
|
|
: "";
|
|
file.Brand = file.Brand ? sanitizeString(file.Brand) : "";
|
|
}
|
|
if (file.AssociationGroups) {
|
|
for (const assoc of file.AssociationGroups) {
|
|
assoc.Description = assoc.Description
|
|
? sanitizeString(assoc.Description)
|
|
: "";
|
|
assoc.group_name = assoc.group_name
|
|
? sanitizeString(assoc.group_name)
|
|
: "";
|
|
}
|
|
}
|
|
if (file.ConfigurationParameters) {
|
|
for (const param of file.ConfigurationParameters) {
|
|
param.Name = param.Name ? sanitizeString(param.Name) : "";
|
|
param.Name = param.Name
|
|
? param.Name.replaceAll(".\"", "\"")
|
|
: "";
|
|
param.Name = param.Name
|
|
? param.Name.replace(/[\,\.\:]$/, "\"")
|
|
: "";
|
|
param.Name = param.Name
|
|
? param.Name.replaceAll(":\"", "\"")
|
|
: "";
|
|
param.Description = param.Description
|
|
? sanitizeString(param.Description)
|
|
: "";
|
|
if (param.ConfigurationParameterValues) {
|
|
for (const value of param.ConfigurationParameterValues) {
|
|
value.Description = value.Description
|
|
? sanitizeString(value.Description)
|
|
: "";
|
|
value.Description = value.Description
|
|
? value.Description.replace(/[\,\.\:]$/, "\"")
|
|
: "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (file.Texts) {
|
|
for (const text of file.Texts) {
|
|
text.description = text.description
|
|
? sanitizeString(text.description)
|
|
: "";
|
|
text.value = text.value ? sanitizeString(text.value) : "";
|
|
}
|
|
}
|
|
}
|
|
return json;
|
|
}
|
|
|
|
/**
|
|
* Read and parse the product xml, add it to index if missing,
|
|
* create/update device json config and validate the newly added
|
|
* device
|
|
*
|
|
* @param product the parsed product json entry from manufacturer.xml
|
|
*/
|
|
async function parseZWAProduct(
|
|
product: any,
|
|
manufacturerId: number,
|
|
manufacturer: string | undefined,
|
|
): Promise<void> {
|
|
const productLabel = product.Identifier;
|
|
|
|
// any products descriptions have productName in it, remove it
|
|
const productName = product.Name.replace(productLabel, "");
|
|
|
|
// Format the manufacturer IDs like we expect them
|
|
|
|
let manufacturerIdHex = product.ManufacturerId.replace(/^0x/, "");
|
|
manufacturerIdHex = formatId(manufacturerIdHex);
|
|
|
|
/*************************************
|
|
* Load the device configurations *
|
|
*************************************/
|
|
let deviceConfigs: any;
|
|
for (const device of product.combinedDevices) {
|
|
deviceConfigs = configManager
|
|
.getIndex()
|
|
?.filter(
|
|
(f: DeviceConfigIndexEntry) =>
|
|
f.manufacturerId === manufacturerIdHex
|
|
&& f.productType === device.ProductTypeId
|
|
&& f.productId === device.ProductId,
|
|
) ?? [];
|
|
if (deviceConfigs) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Determine where the config file should be
|
|
const latestConfig = getLatestConfigVersion(deviceConfigs);
|
|
let fileNameRelative: string;
|
|
if (latestConfig?.filename) {
|
|
fileNameRelative = latestConfig?.filename;
|
|
} else {
|
|
fileNameRelative = latestConfig?.filename
|
|
?? `${manufacturerIdHex}/${labelToFilename(productLabel)}.json`;
|
|
}
|
|
const fileNameAbsolute = path.join(processedDir, fileNameRelative);
|
|
|
|
// Load the existing config so we can merge it with the updated information
|
|
let existingDevice: Record<string, any> | undefined;
|
|
const existingDeviceFileContents = await fs.readFile(
|
|
fileNameAbsolute,
|
|
"utf8",
|
|
).catch(() => undefined);
|
|
|
|
try {
|
|
if (existingDeviceFileContents) {
|
|
existingDevice = JSONC.parse(existingDeviceFileContents) as any;
|
|
}
|
|
} catch (e) {
|
|
console.log(
|
|
`Error processing: ${fileNameAbsolute} - ${
|
|
getErrorMessage(
|
|
e,
|
|
true,
|
|
)
|
|
}`,
|
|
);
|
|
}
|
|
|
|
/********************************
|
|
* Build the device lists *
|
|
********************************/
|
|
const devices = existingDevice?.devices ?? [];
|
|
|
|
for (const dev of product.combinedDevices) {
|
|
// Append the zwa device ID to existing devices
|
|
for (const eDevice of devices) {
|
|
if (
|
|
eDevice.productType == dev.ProductTypeId
|
|
&& eDevice.productId == dev.ProductId
|
|
) {
|
|
eDevice.zwaveAllianceId = dev.Id;
|
|
}
|
|
}
|
|
|
|
// Add new devices
|
|
if (
|
|
!devices.some(
|
|
(d: { productType: string; productId: string }) =>
|
|
d.productType === dev.ProductTypeId
|
|
&& d.productId === dev.ProductId,
|
|
)
|
|
) {
|
|
devices.push({
|
|
productType: dev.ProductTypeId,
|
|
productId: dev.ProductId,
|
|
zwaveAllianceId: dev.Id,
|
|
});
|
|
}
|
|
}
|
|
|
|
/***************************************
|
|
* Setup the initial configuration *
|
|
***************************************/
|
|
|
|
const inclusion = product?.Texts?.find(
|
|
(document: any) => document.Type === 1,
|
|
)?.value;
|
|
const exclusion = product?.Texts?.find(
|
|
(document: any) => document.Type === 2,
|
|
)?.value;
|
|
const reset = product?.Texts?.find(
|
|
(document: any) => document.Type === 5,
|
|
)?.value;
|
|
let manual = product?.Documents?.find(
|
|
(document: any) => document.Type === 1,
|
|
)?.value;
|
|
const website_root =
|
|
"https://products.z-wavealliance.org/ProductManual/File?folder=&filename=";
|
|
if (manual) {
|
|
manual = manual.replaceAll(" ", "%20");
|
|
manual = website_root.concat(manual);
|
|
}
|
|
|
|
const newConfig: Record<string, any> = {
|
|
isNewFile: typeof existingDevice === "undefined",
|
|
manufacturer,
|
|
manufacturerId: manufacturerIdHex,
|
|
label: productLabel,
|
|
description: existingDevice?.description ?? productName, // don't override the description
|
|
devices: devices,
|
|
firmwareVersion: {
|
|
min: existingDevice?.firmwareVersion.min ?? "0.0",
|
|
max: existingDevice?.firmwareVersion.max ?? "255.255",
|
|
},
|
|
associations: existingDevice?.associations ?? {},
|
|
paramInformation: existingDevice?.paramInformation ?? [],
|
|
compat: existingDevice?.compat,
|
|
};
|
|
if (inclusion || exclusion || reset || manual) {
|
|
newConfig.metadata = {
|
|
inclusion: inclusion,
|
|
exclusion: exclusion,
|
|
reset: reset,
|
|
manual: manual,
|
|
};
|
|
}
|
|
|
|
/***************************
|
|
* Clean up values *
|
|
***************************/
|
|
|
|
newConfig.description = sanitizeString(newConfig.description);
|
|
|
|
/**********************
|
|
* Parameters *
|
|
**********************/
|
|
const parameters = product.ConfigurationParameters;
|
|
|
|
for (const param of parameters) {
|
|
const found = newConfig.paramInformation.find(
|
|
(p: any) => p["#"] === param.ParameterNumber.toString(),
|
|
);
|
|
|
|
const parsedParam = found ?? {};
|
|
|
|
// Skip parameter if already a template import
|
|
if (parsedParam.$import) {
|
|
continue;
|
|
}
|
|
|
|
// Skip parameter if a bitmask has already been defined
|
|
if (
|
|
newConfig.paramInformation.some((p: any) =>
|
|
p["#"]?.startsWith(`${param.ParameterNumber}[`)
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// By default, update existing properties with new descriptions
|
|
parsedParam["#"] = param.ParameterNumber.toString();
|
|
parsedParam.label = param.Name || parsedParam.label;
|
|
parsedParam.label = normalizeLabel(parsedParam.label);
|
|
parsedParam.description = param.ConfigurationParameterValues.length > 1 // Sometimes values options are described and not presented as options
|
|
? param.Description
|
|
: param.ConfigurationParameterValues[0].Description;
|
|
parsedParam.description = normalizeDescription(parsedParam.description);
|
|
parsedParam.valueSize = updateNumberOrDefault(
|
|
param.Size,
|
|
parsedParam.valueSize,
|
|
1,
|
|
);
|
|
parsedParam.minValue = param.minValue;
|
|
parsedParam.maxValue = param.maxValue;
|
|
if (param.flagReadOnly === true) {
|
|
parsedParam.readOnly = true;
|
|
} else if (param.Description.toLowerCase().includes("write")) {
|
|
// zWave Alliance typically puts (write only) in the description
|
|
parsedParam.writeOnly = true;
|
|
}
|
|
parsedParam.allowManualEntry = !parsedParam.readOnly
|
|
&& param.ConfigurationParameterValues.length <= 1;
|
|
parsedParam.defaultValue = updateNumberOrDefault(
|
|
param.DefaultValue,
|
|
parsedParam.value,
|
|
parsedParam.minValue, // choose the smallest possible number if no default is given
|
|
);
|
|
|
|
// Setup the unit
|
|
if (/hours?/i.test(parsedParam.description)) {
|
|
parsedParam.unit = "hours";
|
|
} else if (/minutes?/i.test(parsedParam.description)) {
|
|
parsedParam.unit = "minutes";
|
|
} else if (/seconds?/i.test(parsedParam.description)) {
|
|
parsedParam.unit = "seconds";
|
|
} else if (/percent(age)?/i.test(parsedParam.description)) {
|
|
parsedParam.unit = "%";
|
|
} else if (/centigrade|celsius/i.test(parsedParam.description)) {
|
|
parsedParam.unit = "°C";
|
|
} else if (/fahrenheit/i.test(parsedParam.description)) {
|
|
parsedParam.unit = "°F";
|
|
}
|
|
|
|
// Sanity check some values
|
|
parsedParam.minValue = parsedParam.minValue <= parsedParam.defaultValue
|
|
? parsedParam.minValue
|
|
: parsedParam.defaultValue;
|
|
parsedParam.maxValue = parsedParam.maxValue >= parsedParam.defaultValue
|
|
? parsedParam.maxValue
|
|
: parsedParam.defaultValue;
|
|
|
|
// Setup unsigned
|
|
if (parsedParam.minValue >= 0) {
|
|
parsedParam.unsigned = true;
|
|
} else {
|
|
delete parsedParam.unsigned;
|
|
}
|
|
|
|
if (typeof parsedParam.description !== "string") {
|
|
parsedParam.description = "";
|
|
}
|
|
|
|
// Parse options list if manual entry is disallowed (i.e. options picker)
|
|
if (
|
|
parsedParam.allowManualEntry !== true
|
|
|| (parsedParam.minValue === 0 && parsedParam.maxValue === 0)
|
|
) {
|
|
parsedParam.options = [];
|
|
for (const item of param.ConfigurationParameterValues) {
|
|
// Values are given as options
|
|
if (item.From === item.To) {
|
|
const opt = {
|
|
label: normalizeDescription(item.Description),
|
|
value: item.To,
|
|
};
|
|
parsedParam.options.push(opt);
|
|
parsedParam.minValue = Math.min(
|
|
parsedParam.minValue,
|
|
item.From,
|
|
);
|
|
parsedParam.maxValue = Math.max(
|
|
parsedParam.maxValue,
|
|
item.To,
|
|
);
|
|
} else {
|
|
parsedParam.allowManualEntry = true;
|
|
parsedParam.minValue = Math.min(
|
|
parsedParam.minValue,
|
|
item.From,
|
|
);
|
|
parsedParam.maxValue = Math.max(
|
|
parsedParam.maxValue,
|
|
item.To,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (!found) newConfig.paramInformation.push(parsedParam);
|
|
}
|
|
|
|
/********************************
|
|
* Associations *
|
|
********************************/
|
|
|
|
// If Z-Wave+ is supported, we don't usually need the association information to determine the lifeline, but we still set it up in case we do
|
|
|
|
let zwavePlus = false;
|
|
zwavePlus =
|
|
product?.SupportedCommandClasses?.find((document: any) =>
|
|
document.Identifier.includes("ZWAVEPLUS")
|
|
)
|
|
? true
|
|
: zwavePlus;
|
|
zwavePlus =
|
|
product?.SupportedCommandClasses?.find((document: any) =>
|
|
document.Identifier.includes("ASSOCIATION_GRP_INFO")
|
|
)
|
|
? true
|
|
: zwavePlus;
|
|
zwavePlus =
|
|
product?.AssociationGroups?.find((document: any) =>
|
|
document.Description.includes("Z-Wave Plus")
|
|
)
|
|
? true
|
|
: zwavePlus;
|
|
zwavePlus = existingDevice?.supportsZWavePlus ? true : zwavePlus;
|
|
|
|
const newAssociations: Record<string, any> = newConfig.associations || {};
|
|
let addCompat = false;
|
|
for (const ass of product.AssociationGroups) {
|
|
let label: string = ass.group_name.length > 0
|
|
? ass.group_name
|
|
: `Group ${ass.GroupNumber}`;
|
|
const maxNodes = ass.MaximumNodes;
|
|
const groupName = ass.group_name.toLowerCase();
|
|
const description = ass.Description.toLowerCase();
|
|
let lifeline = false;
|
|
if (
|
|
groupName.includes("lifeline")
|
|
|| description.includes("lifeline")
|
|
) {
|
|
lifeline = true;
|
|
|
|
// Lifeline reporting on other than #1, so we need associations even if zWave Plus
|
|
if (ass.GroupNumber !== 1) {
|
|
zwavePlus = false;
|
|
}
|
|
}
|
|
|
|
// Add double tap support if supported by the device
|
|
if (groupName.includes("double") || description.includes("double")) {
|
|
label = "Double Tap";
|
|
lifeline = true; // Required to receive Basic Set notifications
|
|
zwavePlus = false; // Required
|
|
addCompat = true;
|
|
|
|
newConfig.compat ??= {};
|
|
newConfig.compat.mapBasicSet = "event";
|
|
}
|
|
|
|
newAssociations[ass.GroupNumber] = {
|
|
label: label,
|
|
maxNodes: maxNodes,
|
|
};
|
|
if (lifeline) {
|
|
newAssociations[ass.GroupNumber].isLifeline = true;
|
|
}
|
|
}
|
|
|
|
// Overwrite the existing associations if we need to add the compat flag
|
|
if (Object.keys(newConfig.associations).length !== 0 && addCompat) {
|
|
newConfig.associations = newAssociations;
|
|
} // Add the associations if the originals are blank AND the device is not zWavePlus.
|
|
else if (
|
|
Object.keys(newConfig.associations).length === 0
|
|
&& zwavePlus === false
|
|
) {
|
|
newConfig.associations = newAssociations;
|
|
}
|
|
|
|
/*************************************
|
|
* Write the configuration file *
|
|
*************************************/
|
|
|
|
// Create the dir if necessary
|
|
const manufacturerDir = path.join(processedDir, manufacturerIdHex);
|
|
await fs.mkdir(manufacturerDir, { recursive: true });
|
|
|
|
let output = JSONC.stringify(normalizeConfig(newConfig), null, "\t") + "\n";
|
|
|
|
// Insert a TODO comment if necessary
|
|
if (
|
|
newConfig.devices.some(
|
|
(d) => d.productType === "0x9999" || d.productId === "0x9999",
|
|
)
|
|
|| newConfig.manufacturerIdHex === "0x9999"
|
|
) {
|
|
output =
|
|
"// TODO: This file contains a placeholder for a productType, productID, or manufacturerId (0x9999) that must be corrected.\n"
|
|
+ output;
|
|
}
|
|
|
|
// Write the file
|
|
await fs.writeFile(fileNameAbsolute, output, "utf8");
|
|
}
|
|
|
|
async function maintenanceParse(): Promise<void> {
|
|
// Parse json files in the zwaTempDir
|
|
const zwaData = [];
|
|
|
|
// Load the zwa files
|
|
await fs.mkdir(zwaTempDir, { recursive: true });
|
|
const zwaFiles = await enumFilesRecursive(
|
|
zwaTempDir,
|
|
(file) => file.endsWith(".json"),
|
|
);
|
|
for (const file of zwaFiles) {
|
|
// zWave Alliance numbering isn't always continuous and an html page is
|
|
// returned when a device number doesn't. Test for and delete such files.
|
|
try {
|
|
zwaData.push(await readJSON(file));
|
|
} catch {
|
|
await fs.unlink(file);
|
|
}
|
|
}
|
|
|
|
// Build the list of device files
|
|
const configFiles = await enumFilesRecursive(
|
|
processedDir,
|
|
(file) => file.endsWith(".json"),
|
|
);
|
|
for (const file of configFiles) {
|
|
const j = await fs.readFile(file, "utf8");
|
|
|
|
let jsonData;
|
|
try {
|
|
jsonData = JSONC.parse(j);
|
|
} catch (e) {
|
|
console.log(
|
|
`Error processing: ${file} - ${getErrorMessage(e, true)}`,
|
|
);
|
|
}
|
|
|
|
const includedZwaFiles: number[] = [];
|
|
|
|
try {
|
|
for (const device of jsonData.devices) {
|
|
if (isArray(device.zwaveAllianceId)) {
|
|
includedZwaFiles.push(...device.zwaveAllianceId);
|
|
} else if (device.zwaveAllianceId) {
|
|
includedZwaFiles.push(device.zwaveAllianceId);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log(
|
|
`Error iterating: ${file} - ${getErrorMessage(e, true)}`,
|
|
);
|
|
}
|
|
|
|
includedZwaFiles.sort(function(a, b) {
|
|
return a - b;
|
|
});
|
|
|
|
for (const referenceDevice of includedZwaFiles) {
|
|
for (const zwafile of zwaData) {
|
|
if (zwafile.Id === referenceDevice) {
|
|
let manual = zwafile?.Documents?.find(
|
|
(document: any) => document.Type === 1,
|
|
)?.value;
|
|
|
|
const website_root =
|
|
"https://products.z-wavealliance.org/ProductManual/File?folder=&filename=";
|
|
|
|
if (manual) {
|
|
manual = manual.replaceAll(" ", "%20");
|
|
manual = website_root.concat(manual);
|
|
|
|
if (jsonData.metadata) {
|
|
jsonData.metadata.manual = manual;
|
|
break;
|
|
} else {
|
|
jsonData.metadata = {};
|
|
jsonData.metadata.manual = manual;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (jsonData.metadata) {
|
|
/*************************************
|
|
* Write the configuration file *
|
|
*************************************/
|
|
const output =
|
|
JSONC.stringify(normalizeConfig(jsonData), null, "\t") + "\n";
|
|
await fs.writeFile(file, output, "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve ZWA device IDs, either the highest (most recent) device ID or all device IDs for the specified manufacturer
|
|
* Note: ZWA's search uses different manufacturer IDs than devices
|
|
*/
|
|
|
|
async function retrieveZWADeviceIds(
|
|
highestDeviceOnly: boolean = true,
|
|
manufacturer: number[] = [-1],
|
|
): Promise<number[]> {
|
|
const { got } = await import("got");
|
|
const deviceIdsSet = new Set<number>();
|
|
|
|
for (const manu of manufacturer) {
|
|
let page = 1;
|
|
// Page 1
|
|
let currentUrl =
|
|
`https://products.z-wavealliance.org/search/DoAdvancedSearch?productName=&productIdentifier=&productDescription=&category=-1&brand=${manu}®ionId=-1&order=&page=${page}`;
|
|
const firstPage = await got.get(currentUrl).text();
|
|
for (const i of firstPage.match(/(?<=productId=).*?(?=[\&\"])/g)!) {
|
|
deviceIdsSet.add(i);
|
|
}
|
|
const pageNumbers = /(?<=page=\d+">).*?(?=\<)/g.test(firstPage)
|
|
? firstPage.match(/(?<=page=\d+">).*?(?=\<)/g)!
|
|
: [1];
|
|
const lastPage = Math.max(...pageNumbers);
|
|
|
|
process.stdout.write(`Processing Page 1 of ${lastPage}...`);
|
|
// Delete the last line
|
|
process.stdout.write("\r\x1b[K");
|
|
|
|
if (!highestDeviceOnly) {
|
|
page++;
|
|
while (page <= lastPage) {
|
|
process.stdout.write(
|
|
`Processing Page ${page} of ${lastPage}...`,
|
|
);
|
|
currentUrl =
|
|
`https://products.z-wavealliance.org/search/DoAdvancedSearch?productName=&productIdentifier=&productDescription=&category=-1&brand=${manu}®ionId=-1&order=&page=${page}`;
|
|
const nextPage = await got.get(currentUrl).text();
|
|
const nextPageIds = nextPage.match(
|
|
/(?<=productId=).*?(?=[\&\"])/g,
|
|
)!;
|
|
|
|
for (const i of nextPageIds) {
|
|
deviceIdsSet.add(i);
|
|
}
|
|
page++;
|
|
// Delete the last line
|
|
process.stdout.write("\r\x1b[K");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (highestDeviceOnly) {
|
|
const deviceIds: number[] = [...deviceIdsSet];
|
|
deviceIds.sort(function(a, b) {
|
|
return b - a;
|
|
});
|
|
console.log(`Highest Device Found: ${deviceIds[0]}`);
|
|
return [deviceIds[0]];
|
|
} else {
|
|
const deviceIds: number[] = [...deviceIdsSet];
|
|
console.log(`Identified ${deviceIds.length} device files`);
|
|
return deviceIds;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads the given device configurations from ZWA
|
|
* @param IDs If given, only these IDs are downloaded
|
|
*/
|
|
async function downloadDevicesZWA(IDs: number[]): Promise<void> {
|
|
await fs.mkdir(zwaTempDir, { recursive: true });
|
|
for (let i = 0; i < IDs.length; i++) {
|
|
process.stdout.write(
|
|
`Fetching device config ${i + 1} of ${IDs.length}...`,
|
|
);
|
|
const content = await fetchDeviceZWA(IDs[i]);
|
|
await fs.writeFile(
|
|
path.join(zwaTempDir, `${IDs[i]}.json`),
|
|
content,
|
|
"utf8",
|
|
);
|
|
// Delete the last line
|
|
process.stdout.write("\r\x1b[K");
|
|
}
|
|
console.log("done!");
|
|
}
|
|
|
|
/**
|
|
* Downloads all device information from the OpenSmartHouse DB
|
|
* @param IDs If given, only these IDs are downloaded
|
|
*/
|
|
async function downloadDevicesOH(IDs?: number[]): Promise<void> {
|
|
if (!isArray(IDs) || !IDs.length) {
|
|
process.stdout.write("Fetching database IDs...");
|
|
IDs = await fetchIDsOH();
|
|
// Delete the last line
|
|
process.stdout.write("\r\x1b[K");
|
|
}
|
|
|
|
await fs.mkdir(ohTempDir, { recursive: true });
|
|
for (let i = 0; i < IDs.length; i++) {
|
|
process.stdout.write(
|
|
`Fetching device config ${i + 1} of ${IDs.length}...`,
|
|
);
|
|
const content = await fetchDeviceOH(IDs[i]);
|
|
await fs.writeFile(
|
|
path.join(ohTempDir, `${IDs[i]}.json`),
|
|
content,
|
|
"utf8",
|
|
);
|
|
// Delete the last line
|
|
process.stdout.write("\r\x1b[K");
|
|
}
|
|
console.log("done!");
|
|
}
|
|
|
|
/** Downloads all manufacturer information from the OpenSmartHouse DB */
|
|
async function downloadManufacturersOH(): Promise<void> {
|
|
process.stdout.write("Fetching manufacturers...");
|
|
|
|
const { got } = await import("got");
|
|
const data = await got.get(ohUrlManufacturers).json();
|
|
|
|
// Delete the last line
|
|
process.stdout.write("\r\x1b[K");
|
|
|
|
const manufacturers = Object.fromEntries(
|
|
// @ts-expect-error
|
|
data.manufacturers.data.map(({ id, label }) => [
|
|
label
|
|
.replace("</a>", "")
|
|
.replaceAll(""", `"`)
|
|
.replaceAll("&", "&")
|
|
.trim(),
|
|
formatId(id),
|
|
]),
|
|
);
|
|
|
|
await fs.mkdir(ohTempDir, { recursive: true });
|
|
await fs.writeFile(
|
|
importedManufacturersPath,
|
|
stringify(manufacturers, "\t"),
|
|
"utf8",
|
|
);
|
|
|
|
console.log("done!");
|
|
}
|
|
|
|
/** Ensures an input file is valid */
|
|
function assertValid(json: any) {
|
|
ok(
|
|
isObject(json.manufacturer)
|
|
&& typeof json.manufacturer.reference === "number"
|
|
&& typeof json.manufacturer.label === "string",
|
|
);
|
|
ok(typeof json.description === "string");
|
|
ok(typeof json.label === "string");
|
|
ok(typeof json.device_ref === "string");
|
|
ok(typeof json.version_min === "string");
|
|
ok(typeof json.version_max === "string");
|
|
}
|
|
|
|
/** Removes unnecessary whitespace from imported text */
|
|
function sanitizeText(text: string): string | undefined {
|
|
return text ? text.trim().replaceAll(/[\t\r\n]+/g, " ") : undefined;
|
|
}
|
|
|
|
/** Tries to coerce the input value into an integer */
|
|
function sanitizeNumber(
|
|
value: number | string | null | undefined,
|
|
): number | undefined {
|
|
if (typeof value === "number") return value;
|
|
if (isNullishOrEmptyString(value)) return undefined;
|
|
|
|
let ret = Number(value);
|
|
if (Number.isNaN(ret)) {
|
|
value = value.replaceAll(/[^0-9-\.\,]/g, "");
|
|
ret = Number(value);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/** Converts a device label to a valid filename */
|
|
function labelToFilename(label: string): string {
|
|
return label
|
|
.trim()
|
|
.replaceAll(/[^a-zA-Z0-9\-_]+/g, "_")
|
|
.replace(/^_/, "")
|
|
.replace(/_$/, "")
|
|
.toLowerCase();
|
|
}
|
|
|
|
/** Parses a downloaded config file into something we understand */
|
|
async function parseOHConfigFile(
|
|
filename: string,
|
|
): Promise<Record<string, any>> {
|
|
const content = await fs.readFile(filename, "utf8");
|
|
const json = JSON.parse(content);
|
|
assertValid(json);
|
|
|
|
const ret: Record<string, any> = {
|
|
manufacturer: json.manufacturer.label,
|
|
manufacturerId: formatId(json.manufacturer.reference),
|
|
label: sanitizeText(json.label),
|
|
description: sanitizeText(json.description),
|
|
devices: json.device_ref
|
|
.split(",")
|
|
.filter(Boolean)
|
|
.map((ref: string) => {
|
|
const [productType, productId] = ref
|
|
.trim()
|
|
.split(":")
|
|
.map((str) => formatId(str));
|
|
return { productType, productId };
|
|
}),
|
|
firmwareVersion: {
|
|
min: json.version_min.replaceAll("000", "0"),
|
|
max: json.version_max,
|
|
},
|
|
};
|
|
|
|
// If Z-Wave+ is supported, we don't need the association information to determine the lifeline
|
|
try {
|
|
const supportsZWavePlus = !!json.endpoints
|
|
?.find((ep: any) => ep.number === "0")
|
|
?.commandClasses?.find(
|
|
(cc: any) => cc.commandclass.cmdclass_id === 94,
|
|
);
|
|
if (!supportsZWavePlus) {
|
|
if (json.associations?.length) {
|
|
ret.associations = {};
|
|
for (const assoc of json.associations) {
|
|
const sanitizedDescription = sanitizeText(
|
|
assoc.description,
|
|
);
|
|
ret.associations[assoc.group_id] = {
|
|
label: sanitizeText(assoc.label),
|
|
...(sanitizedDescription
|
|
? { description: sanitizedDescription }
|
|
: undefined),
|
|
maxNodes: parseInt(assoc.max_nodes),
|
|
// isLifeline must be either true or left out
|
|
isLifeline: assoc.controller === "1" ? true : undefined,
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
// The supportsZwavePlus key is obsolete
|
|
// ret.supportsZWavePlus = true;
|
|
}
|
|
} catch {
|
|
console.error(filename);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (json.parameters?.length) {
|
|
ret.paramInformation = [];
|
|
for (const param of json.parameters) {
|
|
let key: string = param.param_id.toString();
|
|
if (param.bitmask !== "0" && param.bitmask !== 0) {
|
|
const bitmask = parseInt(param.bitmask);
|
|
key += `[${num2hex(bitmask)}]`;
|
|
}
|
|
const sanitizedDescription = sanitizeText(param.description);
|
|
const sanitizedUnits = sanitizeText(param.units);
|
|
const paramInfo: Record<string, unknown> = {
|
|
"#": key,
|
|
label: sanitizeText(param.label),
|
|
...(sanitizedDescription
|
|
? { description: sanitizedDescription }
|
|
: undefined),
|
|
...(sanitizedUnits ? { unit: sanitizedUnits } : undefined),
|
|
valueSize: parseInt(param.size, 10),
|
|
minValue: parseInt(param.minimum, 10),
|
|
maxValue: parseInt(param.maximum, 10),
|
|
defaultValue: parseInt(param.default, 10),
|
|
readOnly: param.read_only === "1" ? true : undefined,
|
|
writeOnly: param.write_only === "1" ? true : undefined,
|
|
allowManualEntry: param.limit_options === "1"
|
|
? false
|
|
: undefined,
|
|
};
|
|
if (param.options?.length) {
|
|
paramInfo.options = param.options.map((opt: any) => ({
|
|
label: sanitizeText(opt.label),
|
|
value: parseInt(opt.value, 10),
|
|
}));
|
|
}
|
|
ret.paramInformation.push(paramInfo);
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/** Translates all downloaded config files */
|
|
async function importConfigFilesOH(): Promise<void> {
|
|
const configFiles = (await fs.readdir(ohTempDir)).filter(
|
|
(file) =>
|
|
file.endsWith(".json")
|
|
&& !file.startsWith("_")
|
|
&& file !== "manufacturers.json",
|
|
);
|
|
|
|
for (const file of configFiles) {
|
|
const inPath = path.join(ohTempDir, file);
|
|
let parsed: Record<string, any>;
|
|
try {
|
|
parsed = await parseOHConfigFile(inPath);
|
|
if (!parsed.manufacturerId) {
|
|
console.error(`${file} has no manufacturer ID!`);
|
|
}
|
|
if (!parsed.label) {
|
|
console.error(`${file} has no label, ignoring it!`);
|
|
continue;
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof AssertionError) {
|
|
console.error(`${file} is not valid, ignoring!`);
|
|
continue;
|
|
}
|
|
throw e;
|
|
}
|
|
// Config files are named like
|
|
// config/devices/<manufacturerId>/label[_fwmin[-fwmax]].json
|
|
let outFilename = path.join(
|
|
processedDir,
|
|
parsed.manufacturerId,
|
|
labelToFilename(parsed.label),
|
|
);
|
|
if (
|
|
parsed.firmwareVersion.min !== "0.0"
|
|
|| parsed.firmwareVersion.max !== "255.255"
|
|
) {
|
|
outFilename += `_${parsed.firmwareVersion.min}`;
|
|
if (parsed.firmwareVersion.max !== "255.255") {
|
|
outFilename += `-${parsed.firmwareVersion.max}`;
|
|
}
|
|
}
|
|
outFilename += ".json";
|
|
await fs.ensureDir(path.dirname(outFilename));
|
|
|
|
const output = stringify(parsed, "\t") + "\n";
|
|
await fs.writeFile(outFilename, output, "utf8");
|
|
}
|
|
}
|
|
|
|
/****************************************************************************
|
|
* Normalize Identifier function *
|
|
* Strips out common model number variations representing different *
|
|
* jurisdicitons, which typically share firmware and parameter settings *
|
|
* *
|
|
* Note: Returns an array of the new identifier, and the original *
|
|
****************************************************************************/
|
|
function normalizeIdentifier(originalIdentifier: string) {
|
|
// Cleanup current Identifier and store in case of later duplicate files
|
|
let newIdentifier = originalIdentifier;
|
|
|
|
const country_codes = [
|
|
"(US)",
|
|
"us",
|
|
"US",
|
|
"(EU)",
|
|
"eu",
|
|
"EU",
|
|
"(RU)",
|
|
"ru",
|
|
"RU",
|
|
"(AU)",
|
|
"au",
|
|
"AU",
|
|
"(CM)",
|
|
"cm",
|
|
"CM",
|
|
];
|
|
|
|
for (const code of country_codes) {
|
|
const regex = new RegExp(code, "g");
|
|
newIdentifier = newIdentifier.replace(regex, "");
|
|
}
|
|
|
|
const suffixToRemove = [
|
|
"-1",
|
|
"-2",
|
|
"-3",
|
|
"-4",
|
|
"a",
|
|
"A",
|
|
"b",
|
|
"B",
|
|
"c",
|
|
"C",
|
|
"d",
|
|
"D",
|
|
"e",
|
|
"i",
|
|
"I",
|
|
"j",
|
|
"J",
|
|
];
|
|
for (const suffix of suffixToRemove) {
|
|
if (newIdentifier.slice(-suffix.length) == suffix) {
|
|
newIdentifier = newIdentifier.slice(0, -suffix.length);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const prohibitedEndChars = ["-", ".", "_", ",", " "];
|
|
for (const endChar of prohibitedEndChars) {
|
|
if (newIdentifier.slice(-1) === endChar) {
|
|
newIdentifier = newIdentifier.slice(0, -1);
|
|
break;
|
|
}
|
|
}
|
|
newIdentifier = newIdentifier.trim();
|
|
newIdentifier = newIdentifier.toLocaleUpperCase();
|
|
return [newIdentifier, originalIdentifier];
|
|
}
|
|
/****************************************************************************
|
|
* Normalize label function *
|
|
* Capitilze each word in a label *
|
|
* *
|
|
****************************************************************************/
|
|
function normalizeLabel(originalString: string) {
|
|
originalString = sanitizeString(originalString);
|
|
originalString = originalString.replaceAll("\n", " ");
|
|
originalString = originalString.replaceAll("\\\"", "");
|
|
let splitStr = originalString.toLowerCase().split(" ");
|
|
for (let i = 0; i < splitStr.length; i++) {
|
|
splitStr[i] = splitStr[i].charAt(0).toUpperCase()
|
|
+ splitStr[i].slice(1);
|
|
}
|
|
originalString = splitStr.join(" ");
|
|
|
|
splitStr = originalString.split("/");
|
|
for (let i = 0; i < splitStr.length; i++) {
|
|
splitStr[i] = splitStr[i].charAt(0).toUpperCase()
|
|
+ splitStr[i].slice(1);
|
|
}
|
|
originalString = splitStr.join("/");
|
|
|
|
originalString = originalString.replaceAll(" Led ", " LED ");
|
|
originalString = originalString.replaceAll(" Rgb ", " RGB ");
|
|
originalString = originalString.replaceAll(" Pir ", " PIR ");
|
|
originalString = originalString.replaceAll("Z-wave", "Z-Wave");
|
|
originalString = originalString.replaceAll("basic set", "Basic Set");
|
|
originalString = originalString.replaceAll(
|
|
"multi-level switch",
|
|
"Multi-Level Switch",
|
|
);
|
|
originalString = originalString.replaceAll("Multi-level", "Multi-Level");
|
|
originalString = originalString.replaceAll(" Of ", " of ");
|
|
originalString = originalString.replaceAll(" To ", " to ");
|
|
originalString = originalString.replaceAll(" A ", " a ");
|
|
originalString = originalString.replaceAll(" An ", " an ");
|
|
originalString = originalString.replaceAll(" Is ", " is ");
|
|
originalString = originalString.replaceAll(" In ", " in ");
|
|
|
|
const prohibitedEndChars = ["-", ".", "_", ",", " "];
|
|
// Clean-up the end of labels
|
|
for (const endChar of prohibitedEndChars) {
|
|
if (originalString.slice(-1) === endChar) {
|
|
originalString = originalString.slice(0, -1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Clean-up the beginning of labels
|
|
for (const endChar of prohibitedEndChars) {
|
|
if (originalString.slice(0) === endChar) {
|
|
originalString = originalString.slice(1, 0);
|
|
break;
|
|
}
|
|
}
|
|
|
|
originalString = originalString.trim();
|
|
|
|
return originalString;
|
|
}
|
|
|
|
/****************************************************************************
|
|
* Normalize description *
|
|
* Capitilze each word in a description *
|
|
* *
|
|
****************************************************************************/
|
|
function normalizeDescription(originalString: string) {
|
|
originalString = sanitizeString(originalString)
|
|
.toLocaleLowerCase()
|
|
.replaceAll("\n", " ")
|
|
.replaceAll("\\\"", "")
|
|
.replaceAll(" led ", " LED ")
|
|
.replaceAll(" rgb ", " RGB ")
|
|
.replaceAll(" pir ", " PIR ")
|
|
.replaceAll("basic set", "Basic Set")
|
|
.replaceAll("multi-level", "Multi-Level")
|
|
.replaceAll(/z-?wave/g, "Z-Wave");
|
|
originalString = originalString.charAt(0).toUpperCase()
|
|
+ originalString.slice(1);
|
|
originalString = originalString.replaceAll(
|
|
"multi-level switch",
|
|
"Multi-Level Switch",
|
|
);
|
|
originalString = originalString.replaceAll("multi-level", "Multi-Level");
|
|
|
|
// Clean-up the end of labels
|
|
originalString = originalString.replace(/[._, -]+$/, "");
|
|
|
|
// Clean-up the beginning of labels
|
|
originalString = originalString.replace(/^[._, -]+/, "");
|
|
return originalString;
|
|
}
|
|
/****************************************************************************
|
|
* Sanitize String function *
|
|
* Strips out common mistakes in strings *
|
|
* *
|
|
****************************************************************************/
|
|
function sanitizeString(originalString: string) {
|
|
return originalString
|
|
.replaceAll("\r\n", "\n")
|
|
.replaceAll("\r", "\n")
|
|
.replaceAll("\n\n\n\n", "\n\n")
|
|
.replaceAll("\t", " ")
|
|
.replaceAll("\"\"", "\"")
|
|
.replaceAll(/ {2,}/g, " ")
|
|
.replaceAll(/\,s*$/g, "")
|
|
.replaceAll(/\„s*$/g, "")
|
|
.replaceAll(/\.s*$/g, "")
|
|
.replaceAll(/\:s*$/g, "")
|
|
.trim();
|
|
}
|
|
/****************************************************************************
|
|
* Parameter Comparison function *
|
|
* Compare two sets of parameters and return true if the numbers match *
|
|
* *
|
|
* Note: Accepts as a string the name of the parameter key in the object *
|
|
****************************************************************************/
|
|
|
|
function isEquivalentParameters(
|
|
tested_device: any = [],
|
|
compared_device: any = [],
|
|
parameterKey: string,
|
|
) {
|
|
const testParameters = new Set();
|
|
// tested_device = tested_device ?? [];
|
|
// compared_device = compared_device ?? [];
|
|
for (const tp of tested_device) {
|
|
const temp = tp[parameterKey];
|
|
testParameters.add(temp);
|
|
}
|
|
|
|
const compareParameters = new Set();
|
|
for (const cp of compared_device) {
|
|
const temp = cp[parameterKey];
|
|
compareParameters.add(temp);
|
|
}
|
|
|
|
const setDifference = new Set(
|
|
[...compareParameters].filter((x) => !testParameters.has(x)),
|
|
);
|
|
return setDifference.size === 0;
|
|
}
|
|
|
|
/****************************************************************************
|
|
* Create array, or update array if it exists, *
|
|
* append ZWA Ids *
|
|
****************************************************************************/
|
|
|
|
function createOrUpdateArray(potentialArray: any, update: any) {
|
|
potentialArray = potentialArray ?? [];
|
|
const newArray = potentialArray;
|
|
for (const arr of newArray) {
|
|
if (
|
|
(arr.ProductId === update.ProductId
|
|
&& arr.ProductTypeId === update.ProductTypeId)
|
|
|| (arr.ProductId === formatId(update.ProductId)
|
|
&& arr.ProductTypeId === formatId(update.ProductTypeId))
|
|
) {
|
|
if (Array.isArray(arr.Id)) {
|
|
arr.Id.push(update.Id);
|
|
} else {
|
|
const temp = arr.Id;
|
|
arr.Id = [];
|
|
arr.Id.push(temp);
|
|
arr.Id.push(update.Id);
|
|
}
|
|
return newArray;
|
|
}
|
|
}
|
|
newArray.push(update);
|
|
return newArray;
|
|
}
|
|
|
|
/**
|
|
* Get latest device configuration file version
|
|
* @param configs list of device config index entries
|
|
*/
|
|
function getLatestConfigVersion(
|
|
configs: DeviceConfigIndexEntry[],
|
|
): DeviceConfigIndexEntry | undefined {
|
|
configs.sort((a, b) => {
|
|
const vA = padVersion(a.firmwareVersion.max);
|
|
const vB = padVersion(b.firmwareVersion.max);
|
|
|
|
return compare(vA, vB);
|
|
});
|
|
|
|
return configs.at(-1);
|
|
}
|
|
|
|
/** Changes the manufacturer names in all device config files to match manufacturers.json */
|
|
async function updateManufacturerNames(): Promise<void> {
|
|
const configFiles = await enumFilesRecursive(
|
|
processedDir,
|
|
(file) => file.endsWith(".json") && !file.endsWith("index.json"),
|
|
);
|
|
await configManager.loadManufacturers();
|
|
|
|
for (const file of configFiles) {
|
|
let fileContents = await fs.readFile(file, "utf8");
|
|
const id = parseInt(
|
|
/"manufacturerId"\: "0x([0-9a-fA-F]+)"/.exec(fileContents)![1],
|
|
16,
|
|
);
|
|
const name = configManager.lookupManufacturer(id);
|
|
const oldName = /"manufacturer"\: "([^\"]+)"/.exec(fileContents)![1];
|
|
if (oldName && name && name !== oldName) {
|
|
fileContents = fileContents.replace(
|
|
`// ${oldName} `,
|
|
`// ${name} `,
|
|
);
|
|
fileContents = fileContents.replace(
|
|
`"manufacturer": "${oldName}"`,
|
|
`"manufacturer": "${name}"`,
|
|
);
|
|
await fs.writeFile(file, fileContents, "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
void (async () => {
|
|
if (program.clean) {
|
|
await cleanTmpDirectory();
|
|
} else {
|
|
if (program.source.includes("ozw")) {
|
|
if (program.download) {
|
|
await downloadOZWConfig();
|
|
await extractConfigFromTar();
|
|
}
|
|
|
|
if (program.manufacturers || program.devices) {
|
|
await parseOZWConfig();
|
|
}
|
|
}
|
|
|
|
if (program.source.includes("zwa")) {
|
|
if (program.manufacturer_folder) {
|
|
const deviceIds = await retrieveZWADeviceIds(
|
|
false,
|
|
program.manufacturer_folder
|
|
?.map((manu) => parseInt(manu as any))
|
|
.filter((num) => !Number.isNaN(num)),
|
|
);
|
|
await downloadDevicesZWA(deviceIds);
|
|
} else if (program.download && program.ids) {
|
|
await downloadDevicesZWA(
|
|
program.ids
|
|
?.map((id) => parseInt(id as any))
|
|
.filter((num) => !Number.isNaN(num)),
|
|
);
|
|
}
|
|
|
|
if (program.manufacturers || program.devices) await parseZWAFiles();
|
|
}
|
|
|
|
if (program.source.includes("oh")) {
|
|
if (program.download) {
|
|
await downloadManufacturersOH();
|
|
await downloadDevicesOH(
|
|
program.ids
|
|
?.map((id) => parseInt(id as any))
|
|
.filter((num) => !Number.isNaN(num)),
|
|
);
|
|
}
|
|
|
|
if (program.manufacturers) {
|
|
await updateManufacturerNames();
|
|
}
|
|
|
|
if (program.devices) {
|
|
await importConfigFilesOH();
|
|
}
|
|
}
|
|
|
|
if (program.parse) {
|
|
await maintenanceParse();
|
|
}
|
|
}
|
|
})();
|