UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

1,201 lines (1,122 loc) 34.1 kB
import { getBitMaskWidth, getIntegerLimits, getLegalRangeForBitMask, getMinimumShiftForBitMask, } from "@zwave-js/core"; import { reportProblem } from "@zwave-js/maintenance"; import { formatId, getErrorMessage, num2hex } from "@zwave-js/shared"; import { distinct } from "alcalzone-shared/arrays"; import { wait } from "alcalzone-shared/async"; import { isArray, isObject } from "alcalzone-shared/typeguards"; import { green, red, white } from "ansi-colors"; import levenshtein from "js-levenshtein"; import type { RulesLogic } from "json-logic-js"; import * as path from "path"; import { ConfigManager } from "../src/ConfigManager"; import { ConditionalDeviceConfig, DeviceConfig, } from "../src/devices/DeviceConfig"; import type { DeviceID } from "../src/devices/shared"; import { parseLogic } from "../src/Logic"; import { configDir, getDeviceEntryPredicate } from "../src/utils"; const configManager = new ConfigManager(); async function lintNotifications(): Promise<void> { await configManager.loadNotifications(); // TODO: Validate that all contents are semantically correct } async function lintManufacturers(): Promise<void> { await configManager.loadManufacturers(); // TODO: Validate that the file is semantically correct } async function lintIndicators(): Promise<void> { await configManager.loadIndicators(); const properties = configManager.indicatorProperties; if (!(properties.get(1)?.label === "Multilevel")) { throw new Error( `The indicator property Multilevel (0x01) is required!`, ); } if (!(properties.get(2)?.label === "Binary")) { throw new Error(`The indicator property Binary (0x02) is required!`); } } function getAllConditions( config: ConditionalDeviceConfig, ): Map<string, Set<string>> { const ret: Map<string, Set<string>> = new Map(); function addCondition(variable: string, value: string): void { if (!ret.has(variable)) ret.set(variable, new Set()); ret.get(variable)!.add(value); } // Recursively walks through a condition and extracts all variable comparisons function walkLogic(logic: RulesLogic) { if (!isObject(logic)) return; if ("or" in logic) { logic.or.forEach((rule) => walkLogic(rule)); } else if ("and" in logic) { logic.and.forEach((rule) => walkLogic(rule)); } else { for (const operator of [ "ver >=", "ver >", "ver <=", "ver <", "ver ===", ">=", ">", "<=", "<", "===", ] as const) { if (operator in logic) { const [lhs, rhs] = (logic as any)[operator] as [ RulesLogic, RulesLogic, ]; if ( isObject(lhs) && "var" in lhs && typeof lhs.var === "string" && typeof rhs === "string" ) { addCondition(lhs.var, rhs); } else if ( isObject(rhs) && "var" in rhs && typeof rhs.var === "string" && typeof lhs === "string" ) { addCondition(rhs.var, lhs); } } } } } for (const prop of ["manufacturer", "label", "description"] as const) { const value = config[prop]; if (isArray(value)) { for (const item of value) { if (item.condition) { const logic = parseLogic(item.condition); walkLogic(logic); } } } } if (config.associations) { for (const assoc of config.associations.values()) { if (assoc.condition) { const logic = parseLogic(assoc.condition); walkLogic(logic); } } } if (config.paramInformation) { for (const params of config.paramInformation.values()) { for (const param of params) { if (param.condition) { const logic = parseLogic(param.condition); walkLogic(logic); } for (const option of param.options) { if (option.condition) { const logic = parseLogic(option.condition); walkLogic(logic); } } } } } if (config.compat) { if (isArray(config.compat)) { for (const compat of config.compat) { if (compat.condition) { const logic = parseLogic(compat.condition); walkLogic(logic); } } } else if (config.compat.condition) { const logic = parseLogic(config.compat.condition); walkLogic(logic); } } if (config.metadata) { for (const prop of [ "wakeup", "inclusion", "exclusion", "reset", "manual", "comments", ] as const) { const value = config.metadata[prop]; if (!value || typeof value === "string") continue; if (isArray(value)) { for (const entry of value) { if (entry.condition) { const logic = parseLogic(entry.condition); walkLogic(logic); } } } else if (isObject(value) && value.condition) { const logic = parseLogic(value.condition); walkLogic(logic); } } } return ret; } function paramNoToString(parameter: number, valueBitMask?: number): string { const bitmaskString = valueBitMask != undefined ? `[${num2hex(valueBitMask)}]` : ""; return `Parameter #${parameter}${bitmaskString}`; } async function lintDevices(): Promise<void> { process.env.NODE_ENV = "test"; await configManager.loadDeviceIndex(); const index = configManager.getIndex()!; // Device config files are lazy-loaded, so we need to parse them all const uniqueFiles = distinct(index.map((e) => e.filename)).sort(); const errors = new Map<string, string[]>(); function addError( filename: string, error: string, variant?: DeviceID, ): void { if (variant) { filename += ` (Variant ${formatId( variant.manufacturerId, )}:${formatId(variant.productType)}:${formatId( variant.productId, )}:${variant.firmwareVersion})`; } if (!errors.has(filename)) errors.set(filename, []); errors.get(filename)!.push(error); } const warnings = new Map<string, string[]>(); function addWarning( filename: string, warning: string, variant?: DeviceID, ): void { if (variant) { filename += ` (Variant ${formatId( variant.manufacturerId, )}:${formatId(variant.productType)}:${formatId( variant.productId, )}:${variant.firmwareVersion})`; } if (!warnings.has(filename)) warnings.set(filename, []); warnings.get(filename)!.push(warning); } for (const file of uniqueFiles) { const rootDir = path.join(configDir, "devices"); const filePath = path.join(rootDir, file); // Try parsing the file let conditionalConfig: ConditionalDeviceConfig; try { conditionalConfig = await ConditionalDeviceConfig.from( filePath, true, { rootDir, }, ); } catch (e) { addError(file, getErrorMessage(e)); continue; } // Check which variants of the device config we need to lint const variants: (DeviceID | undefined)[] = []; const conditions = getAllConditions(conditionalConfig); if (conditions.size > 0) { // If there is at least one condition, check the firmware limits too. Otherwise the minimum is enough const fwVersions: Set<string> = conditions.get("firmwareVersion") ?? new Set(); if (fwVersions.size > 0) { fwVersions.add(conditionalConfig.firmwareVersion.min); fwVersions.add(conditionalConfig.firmwareVersion.max); } else { fwVersions.add(conditionalConfig.firmwareVersion.min); } // Combine each firmware version with every device ID defined in the file for (const deviceId of conditionalConfig.devices) { for (const firmwareVersion of fwVersions) { variants.push({ manufacturerId: conditionalConfig.manufacturerId, ...deviceId, firmwareVersion, }); } } } else { // There are no conditions, so we can just evaluate the file for the default case variants.push(undefined); } for (const variant of variants) { // Try evaluating the conditional config let config: DeviceConfig; try { config = conditionalConfig.evaluate(variant); } catch (e) { addError(file, getErrorMessage(e), variant); continue; } // Validate that the file is semantically correct // By evaluating conditionals, we may end up with a file without manufacturer, label or description if (config.manufacturer == undefined) { addError( file, "The manufacturer property is undefined", variant, ); } if (config.label == undefined) { addError(file, "The device label is undefined", variant); } if (config.description == undefined) { addError(file, "The device description is undefined", variant); } if (config.paramInformation?.size) { for (const [ { parameter, valueBitMask }, { label, description }, ] of config.paramInformation.entries()) { // Check if the description is too similar to the label if (description != undefined) { const normalizedDistance = levenshtein(label, description) / Math.max(label.length, description.length); if (normalizedDistance < 0.5) { addWarning( file, `${paramNoToString( parameter, valueBitMask, )} has a very similar label and description (normalized distance ${normalizedDistance.toFixed( 2, )}). Consider removing the description if it does not add any information: label: ${label} description: ${description}`, variant, ); } } } // Check if there are options when manual entry is forbidden for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { if ( !value.allowManualEntry && !value.readOnly && !value.options?.length ) { addError( file, `${paramNoToString( parameter, valueBitMask, )} must allow manual entry if there are no options defined!`, variant, ); } if (value.readOnly && value.writeOnly) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: readOnly and writeOnly are mutually exclusive!`, variant, ); } } // Check if there are readOnly parameters with allowManualEntry = true for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { // We can't actually distinguish between `false` and missing, but this is good enough if (value.readOnly && value.allowManualEntry) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: allowManualEntry must be omitted for readOnly parameters!`, variant, ); } } // Check if there are options where readOnly and writeOnly are unnecessarily specified for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { if ( !value.allowManualEntry && !value.readOnly && !value.options?.length ) { addError( file, `${paramNoToString( parameter, valueBitMask, )} must allow manual entry if there are no options defined!`, variant, ); } if (value.readOnly && value.writeOnly) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: readOnly and writeOnly are mutually exclusive!`, variant, ); } else if ( value.readOnly !== undefined && value.writeOnly !== undefined ) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: readOnly and writeOnly must not both be specified!`, variant, ); } } // Check if there are options with duplicate values for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { for (let i = 0; i < value.options.length; i++) { const option = value.options[i]; const firstIndex = value.options.findIndex( (o) => o.value === option.value, ); if (firstIndex !== i) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: option value ${ option.value } duplicated between "${ value.options[firstIndex].label }" and "${option.label}"!`, variant, ); } } } // Check if there are options where min/max values is not compatible with the valueSize for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { if (value.valueSize < 1 || value.valueSize > 4) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: valueSize must be in the range 1...4!`, variant, ); } else { if (value.minValue > value.maxValue) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: minValue must not be greater than maxValue!`, variant, ); } // All values are signed by the specs const limits = getIntegerLimits( value.valueSize as any, true, ); const unsignedLimits = getIntegerLimits( value.valueSize as any, false, ); if (!limits) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: cannot determine limits for valueSize ${ value.valueSize }!`, variant, ); } else { const fitsSignedLimits = value.minValue >= limits.min && value.minValue <= limits.max && value.maxValue >= limits.min && value.maxValue <= limits.max; const fitsUnsignedLimits = value.minValue >= unsignedLimits.min && value.minValue <= unsignedLimits.max && value.maxValue >= unsignedLimits.min && value.maxValue <= unsignedLimits.max; if (!value.unsigned && !fitsSignedLimits) { if (fitsUnsignedLimits) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: min/maxValue is incompatible with valueSize ${ value.valueSize } (min = ${limits.min}, max = ${ limits.max }). Consider converting this parameter to unsigned using ${white( `"unsigned": true`, )}!`, variant, ); } else { if (value.minValue < limits.min) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: minValue ${ value.minValue } is incompatible with valueSize ${ value.valueSize } (min = ${limits.min})!`, variant, ); } if (value.maxValue > limits.max) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: maxValue ${ value.maxValue } is incompatible with valueSize ${ value.valueSize } (max = ${limits.max})!`, variant, ); } } } else if (value.unsigned && !fitsUnsignedLimits) { if (value.minValue < unsignedLimits.min) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: minValue ${ value.minValue } is incompatible with valueSize ${ value.valueSize } (min = ${unsignedLimits.min})!`, variant, ); } if (value.maxValue > unsignedLimits.max) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: maxValue ${ value.maxValue } is incompatible with valueSize ${ value.valueSize } (max = ${unsignedLimits.max})!`, variant, ); } } } } } // Check if there are parameters with predefined options that are not compatible with min/maxValue for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { if (!value.options.length) continue; for (const option of value.options) { if ( option.value < value.minValue || option.value > value.maxValue ) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: The option value ${ option.value } must be in the range ${value.minValue}...${ value.maxValue }!`, variant, ); } } // Check if writable params without manual entry have unnecessarily wide min/max value ranges if (!value.readOnly && value.allowManualEntry === false) { const actualMin = Math.min( ...value.options.map((o) => o.value), ); const actualMax = Math.max( ...value.options.map((o) => o.value), ); if (value.minValue < actualMin) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: minValue ${ value.minValue } is less than the minimum option value ${actualMin}! If allowManualEntry is false, minValue must be omitted or match the option values.`, variant, ); } if (value.maxValue > actualMax) { addError( file, `${paramNoToString( parameter, valueBitMask, )} is invalid: maxValue ${ value.maxValue } is greater than the maximum option value ${actualMax}! If allowManualEntry is false, maxValue must be omitted or match the option values.`, variant, ); } } } // Check if there are parameters with identical labels const labelCounts = new Map< string, { parameter: number; valueBitMask?: number }[] >(); for (const [ param, { label }, ] of config.paramInformation.entries()) { if (!labelCounts.has(label)) labelCounts.set(label, []); labelCounts.get(label)!.push(param); } for (const [label, params] of labelCounts) { if (params.length === 1) continue; addWarning( file, `Label "${label}" is duplicated in the following parameters: ${params .map( (p) => `${p.parameter}${ p.valueBitMask ? `[${num2hex(p.valueBitMask)}]` : "" }`, ) .join(", ")}`, variant, ); } const partialParams = [ ...config.paramInformation.entries(), ].filter(([k]) => !!k.valueBitMask); // Checking if there are parameters with a single bit mask happens for the condional config, // not the evaluated one // Check if there are partial parameters and non-partials with the same number const duplicatedPartials = distinct( partialParams.map(([key]) => key.parameter), ).filter((parameter) => config.paramInformation!.has({ parameter }), ); if (duplicatedPartials.length) { addError( file, `The following non-partial parameters need to be removed because partial parameters with the same key exist: ${duplicatedPartials .map((p) => `#${p}`) .join(", ")}!`, variant, ); } // Check if there are partial parameters with incompatible min/max/default values for (const [key, param] of partialParams) { const bitMask = key.valueBitMask!; const shiftAmount = getMinimumShiftForBitMask(bitMask); const shiftedBitMask = bitMask >>> shiftAmount; const [minValue, maxValue] = getLegalRangeForBitMask( bitMask, !!param.unsigned, ); if (param.minValue < minValue) { addError( file, `Parameter #${key.parameter}[${num2hex( bitMask, )}]: minimum value ${ param.minValue } is incompatible with the bit mask (${bitMask}, aligned ${shiftedBitMask}). Minimum value expected to be >= ${minValue}.`, variant, ); } if (param.maxValue > maxValue) { addError( file, `Parameter #${key.parameter}[${num2hex( bitMask, )}]: maximum value ${ param.maxValue } is incompatible with the bit mask (${bitMask}, aligned ${shiftedBitMask}). Maximum value expected to be <= ${maxValue}.`, variant, ); } if ( param.defaultValue < minValue || param.defaultValue > maxValue ) { addError( file, `Parameter #${key.parameter}[${num2hex( bitMask, )}]: default value ${ param.defaultValue } is incompatible with the bit mask (${bitMask}, aligned ${shiftedBitMask}). Default value expected to be between ${minValue} and ${maxValue}.`, variant, ); } } // Check if there are partial parameters referencing the same parameter with different value sizes const checkedValueSize: number[] = []; for (const [key, param] of partialParams) { if (checkedValueSize.includes(key.parameter)) continue; checkedValueSize.push(key.parameter); const others = partialParams.filter( ([kk]) => key.parameter === kk.parameter && key.valueBitMask !== kk.valueBitMask, ); if ( others.some( ([, other]) => other.valueSize !== param.valueSize, ) ) { addError( file, `Parameter #${key.parameter}: All partial parameters must have the same valueSize!`, variant, ); } } // Check if there are partial parameters with incompatible options const partialParamsWithOptions = partialParams.filter( ([, p]) => p.options.length > 0, ); for (const [key, param] of partialParamsWithOptions) { const bitMask = key.valueBitMask!; const shiftAmount = getMinimumShiftForBitMask(bitMask); const shiftedBitMask = bitMask >>> shiftAmount; for (const opt of param.options) { const [minValue, maxValue] = getLegalRangeForBitMask( bitMask, !!param.unsigned, ); if (opt.value < minValue || opt.value > maxValue) { addError( file, `Parameter #${key.parameter}[${num2hex( bitMask, )}]: Option ${ opt.value } is incompatible with the bit mask (${bitMask}, aligned ${shiftedBitMask}). Value expected to be between ${minValue} and ${maxValue}`, variant, ); } } } // Check if there are partial parameters with a valueSize that is too small for the bitmask for (const [key, param] of partialParams) { if (key.valueBitMask! >= 256 ** param.valueSize) { addError( file, `Parameter #${key.parameter}[${num2hex( key.valueBitMask, )}]: valueSize ${ param.valueSize } is incompatible with the bit mask ${num2hex( key.valueBitMask, )}!`, variant, ); } } // Check if there are descriptions with common errors for (const [ { parameter, valueBitMask }, value, ] of config.paramInformation.entries()) { if (!value.description) continue; if (/default:?\s+\d+/i.test(value.description)) { addWarning( file, `${paramNoToString( parameter, valueBitMask, )}: The description mentions a default value which should be handled by the "defaultValue" property instead!`, variant, ); } // // Complain about parameters where the description mention the unit. We treat the mention of exactly one unit as a warning, // // because some parameters have changing units and need to explain them in the description // if ( // [ // "second", // "minute", // "hour", // "day", // "week", // "kwh", // "watt", // ].filter((unit) => // value.description!.toLowerCase().includes(unit), // ).length === 1 // ) { // // Exclude some false positives // if (!/rounded/i.test(value.description)) { // addWarning( // file, // `${paramNoToString( // parameter, // valueBitMask, // )}: The description mentions a unit which should be moved by the "unit" property instead!`, // variant, // ); // } // } } } // Validate firmware versions if (config.firmwareVersion.max === "255.0") { addWarning( file, `The maximum firmware version is 255.0. Did you mean 255.255?`, ); } else { // Check for invalid version parts const [minMajor, minMinor] = config.firmwareVersion.min .split(".", 2) .map((v) => parseInt(v, 10)); if ( minMajor < 0 || minMajor > 255 || minMinor < 0 || minMinor > 255 ) { addError( file, `The minimum firmware version ${config.firmwareVersion.min} is invalid. Each version part must be between 0 and 255.`, ); } const [maxMajor, maxMinor] = config.firmwareVersion.max .split(".", 2) .map((v) => parseInt(v, 10)); if ( maxMajor < 0 || maxMajor > 255 || maxMinor < 0 || maxMinor > 255 ) { addError( file, `The maximum firmware version ${config.firmwareVersion.max} is invalid. Each version part must be between 0 and 255.`, ); } // Check if one of the firmware versions has a leading zero const leadingZeroMajor = /^0\d+\./; const leadingZeroMinor = /\.0\d+$/; if ( leadingZeroMajor.test(config.firmwareVersion.min) || leadingZeroMinor.test(config.firmwareVersion.min) ) { addError( file, `The minimum firmware version ${config.firmwareVersion.min} is invalid. Leading zeroes are not permitted.`, ); } if ( leadingZeroMajor.test(config.firmwareVersion.max) || leadingZeroMinor.test(config.firmwareVersion.max) ) { addError( file, `The maximum firmware version ${config.firmwareVersion.max} is invalid. Leading zeroes are not permitted.`, ); } } } const unconditionalComesLast = ( definitions: { condition?: string }[], ): boolean => { return definitions.every( (d, index) => d.condition !== undefined || index === definitions.length - 1, ); }; // In all situations where one conditional gets selected from an array, // ensure that the one without a condition comes last // Device manufacturer/label/description if (isArray(conditionalConfig.manufacturer)) { if (!unconditionalComesLast(conditionalConfig.manufacturer)) { addError( file, `The device manufacturer is invalid: When there are multiple conditional definitions, every definition except the last one MUST have an "$if" condition!`, ); } } if (isArray(conditionalConfig.label)) { if (!unconditionalComesLast(conditionalConfig.label)) { addError( file, `The device label is invalid: When there are multiple conditional definitions, every definition except the last one MUST have an "$if" condition!`, ); } } if (isArray(conditionalConfig.description)) { if (!unconditionalComesLast(conditionalConfig.description)) { addError( file, `The device description is invalid: When there are multiple conditional definitions, every definition except the last one MUST have an "$if" condition!`, ); } } // Param information if (conditionalConfig.paramInformation) { for (const [ key, definitions, ] of conditionalConfig.paramInformation) { if (!unconditionalComesLast(definitions)) { addError( file, `${paramNoToString( key.parameter, key.valueBitMask, )} is either invalid or duplicated: When there are multiple definitions, every definition except the last one MUST have an "$if" condition!`, ); } } } // Compat flags if (isArray(conditionalConfig.compat)) { if (!unconditionalComesLast(conditionalConfig.compat)) { addError( file, `The compat description is invalid: When there are multiple conditional definitions, every definition except the last one MUST have an "$if" condition!`, ); } } // Metadata if (conditionalConfig.metadata) { for (const prop of [ "wakeup", "inclusion", "exclusion", "reset", "manual", ] as const) { const value = conditionalConfig.metadata[prop]; if (isArray(value) && !unconditionalComesLast(value)) { addError( file, `The ${prop} metadata is invalid: When there are multiple conditional definitions, every definition except the last one MUST have an "$if" condition!`, ); } } } // Check only the conditional configs for single bit masks, because they might be added for // separate variants if (conditionalConfig.paramInformation) { const partialParams = [ ...conditionalConfig.paramInformation.entries(), ].filter(([k]) => !!k.valueBitMask); const partialParamCounts = partialParams .map(([k]) => k) .reduce((map, key) => { if (!map.has(key.parameter)) map.set(key.parameter, 0); map.set(key.parameter, map.get(key.parameter)! + 1); return map; }, new Map<number, number>()); for (const [key, paramInfos] of partialParams) { if (partialParamCounts.get(key.parameter) == 1) { for (const param of paramInfos) { const bitMask = key.valueBitMask!; const shiftAmount = getMinimumShiftForBitMask(bitMask); const bitMaskWidth = getBitMaskWidth(bitMask); if ( shiftAmount === 0 && param.valueSize === bitMaskWidth / 8 ) { addError( file, `Parameter #${key.parameter} has a single bit mask defined which covers the entire value. Either add more, or delete the bit mask.`, ); } } } } } // Check that either `endpoints` or `associations` are specified, not both if (conditionalConfig.endpoints && conditionalConfig.associations) { addError( file, `The properties "endpoints" and "associations" cannot be used together. To define associations for the root endpoint, specify them under endpoint "0"!`, ); } } // Check for duplicate definitions for (let i = 0; i < index.length; i++) { const entry = index[i]; const firstIndex = index.findIndex( getDeviceEntryPredicate( parseInt(entry.manufacturerId, 16), parseInt(entry.productType, 16), parseInt(entry.productId, 16), ), ); if (firstIndex === i) continue; const other = index[firstIndex].firmwareVersion; const me = entry.firmwareVersion; if (typeof other === "boolean" || typeof me === "boolean") { if (other !== me) continue; } else { if (other.min !== me.min || other.max !== me.max) continue; } // This is a duplicate! addError( entry.filename, `Duplicate config file detected for device (manufacturer id = ${entry.manufacturerId}, product type = ${entry.productType}, product id = ${entry.productId}) The first occurrence of this device is in file config/devices/${index[firstIndex].filename}`, ); } if (warnings.size) { for (const [filename, fileWarnings] of warnings.entries()) { // console.warn(`config/devices/${filename}:`); for (const warning of fileWarnings) { const lines = warning .split("\n") .filter((line) => !line.endsWith(filename + ":")); reportProblem({ severity: "warn", filename: `packages/config/config/devices/${filename}`, message: lines.join("\n"), // We're likely to have LOTs of warnings - but GitHub only accepts a few annotations annotation: false, }); } console.warn(); } } if (errors.size) { for (const [filename, fileErrors] of errors.entries()) { // console.error(`config/devices/${filename}:`); for (const error of fileErrors) { const lines = error .split("\n") .filter((line) => !line.endsWith(filename + ":")); reportProblem({ severity: "error", filename: `packages/config/config/devices/${filename}`, message: lines.join("\n"), }); } console.log(); } } const numErrors = [...errors.values()] .map((e) => e.length) .reduce((cur, acc) => cur + acc, 0); const numWarnings = [...warnings.values()] .map((e) => e.length) .reduce((cur, acc) => cur + acc, 0); if (numErrors || numWarnings) { console.log( `Found ${numErrors} error${ numErrors !== 1 ? "s" : "" } and ${numWarnings} warning${numWarnings !== 1 ? "s" : ""}!`, ); console.log(); } if (errors.size) { throw new Error("At least one config file has errors!"); } } async function lintNamedScales(): Promise<void> { await configManager.loadNamedScales(); const definitions = configManager.namedScales; if (!definitions.has("temperature")) { throw new Error(`Named scale "temperature" is missing!`); } } async function lintSensorTypes(): Promise<void> { // The named scales must be loaded here so the parsing can work await configManager.loadNamedScales(); await configManager.loadSensorTypes(); // TODO: Validate that all contents are semantically correct } export async function lintConfigFiles(): Promise<void> { // Set NODE_ENV to test in order to trigger stricter checks process.env.NODE_ENV = "test"; try { await lintManufacturers(); await lintDevices(); await lintNotifications(); await lintNamedScales(); await lintSensorTypes(); await lintIndicators(); console.log(); console.log(green("The config files are valid!")); console.log(); console.log(" "); } catch (e: any) { if (typeof e.stack === "string") { const lines = (e.stack as string).split("\n"); if (lines[0].trim().toLowerCase() === "error:") { lines.shift(); } const message = lines.join("\n"); console.log(red(message)); } else { console.log(red(e.message)); } console.log(); // Github actions truncates our logs if we don't wait before exiting the process await wait(5000); return process.exit(1); } } if (require.main === module) void lintConfigFiles();