UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

402 lines 17.6 kB
// Homebridge plugin for Home Connect home appliances // Copyright © 2023-2025 Alexander Thoukydides import assert from 'node:assert'; import semver from 'semver'; import chalk from 'chalk'; import { MS, assertIsDefined, columns, formatList, plural } from './utils.js'; import { PLUGIN_VERSION } from './settings.js'; import { logError } from './log-error.js'; // Delay before summary log (longer than 1 minute rate-limit interval) const SUMMARY_DELAY = 2 * 60 * MS; // URL to create a new GitHub issue const NEW_ISSUE_URL = 'https://github.com/thoukydides/homebridge-homeconnect/issues/new?template=key_value.yml&labels=api+keys%2Fvalues'; // Comment to tag unrecognised/mismatched values const REPORT_COMMENT = '(unrecognised)'; // Colours for the report message const COLOUR_LO = chalk.green.dim; const COLOUR_HI = chalk.greenBright; // Mapping from Home Connect API (non-enum) types to Typescript equivalents const TYPE_MAP = new Map([ ['String', 'string'], ['Double', 'number'], ['Int', 'number'], ['Boolean', 'boolean'] ]); // Home Connect unrecognised/mismatched key-value logging export class APIKeyValuesLog { // Construct a key-value logger constructor(log, clientid, persist) { this.log = log; this.clientid = clientid; this.persist = persist; // Appliances this.appliances = new Map(); this.applianceReports = new Set(); // Known keys/values this.groups = {}; this.keys = {}; // Reported keys/values this.reported = new Set(); this.pending = new Set(); // Restore cache of previously seen keys and values this.persistKey = `key-value ${clientid}`; this.readPersist(); } // Update the dictionary of appliance descriptions setAppliances(appliances) { for (const appliance of appliances) this.appliances.set(appliance.haId, appliance); } // Add details of an key and its allowed values addDetail(detail) { var _a; // Only interested in the type name, if provided // (addValues() will be called for any value, default, or allowedvalues) if (!('type' in detail) || detail.type === undefined) return; const { key, type } = detail; // Find or create a record for this key const value = { type, values: {} }; (_a = this.keys)[key] ?? (_a[key] = { key, value, report: false }); } // Add a key that has been seen addKey(haid, group, subGroup, key, report, restored = false) { var _a, _b, _c; // Generate a simple PascalCase group name if (subGroup) group += subGroup.charAt(0).toUpperCase() + subGroup.slice(1).toLowerCase(); // Find or create records for this group and key const thisGroup = (_a = this.groups)[group] ?? (_a[group] = { keys: {} }); const thisKey = (_c = thisGroup.keys)[key] ?? (_c[key] = (_b = this.keys)[key] ?? (_b[key] = { key, report })); // Add to the pending report if (report) { thisKey.report = true; if (!restored) { this.applianceReports.add(haid); this.scheduleSummary(group, key); } } } // Add a value that has been seen (also used for ProgramKey values) addValue(haid, key, value, report, restored = false) { var _a, _b, _c; // Exclude null values if (value === null) return; // Find or create records for this key and value const thisKey = (_a = this.keys)[key] ?? (_a[key] = { key, report: false }); const thisValue = thisKey.value ?? (thisKey.value = { values: {} }); const thisLiteral = (_b = thisValue.values)[_c = `${value}`] ?? (_b[_c] = { value, report }); // Add to the pending report if (report) { thisKey.report = true; thisLiteral.report = true; if (!restored) { this.applianceReports.add(haid); this.scheduleSummary(key, `${value}`); } } } // Schedule a summary report for an unsupported key/value scheduleSummary(key, value) { // Check whether this has been reported already const id = `${key}.${value}`; if (this.reported.has(id)) return; // Add to the pending report this.pending.add(id); // Schedule (or reschedule) a summary report clearTimeout(this.pendingScheduled); this.pendingScheduled = setTimeout(() => { try { // Save the cache of known keys and values this.writePersist(); // Check whether the report is ready const unknownKeys = this.getUnknownTypes(); if (unknownKeys.length) { // Wait for more details before reporting this.log.info('Delaying report of unrecognised keys/values until these types are known:'); for (const key of unknownKeys) { this.log.info(` ${key}`); this.log.debug(` ${JSON.stringify(this.keys[key])}`); } } else { // Generate the report this.logSummary(); for (const id of this.pending) this.reported.add(id); this.pending.clear(); } } catch (err) { logError(this.log, 'Reporting unrecognised keys/values', err); } }, SUMMARY_DELAY); } // Generate a list of keys that currently have unknown types getUnknownTypes() { const unknownKeys = Object.values(this.keys).filter(key => key.report && this.getTypeof(key.value) === 'unknown'); return unknownKeys.map(key => key.key).sort(); } // Generate summary report logSummary() { // Merge values that have the same enum name this.mergeSameEnums(); // Construct enum or union types for values with unrecognised literals const lines = []; const reportValues = this.getValuesOfEnumType() .filter(value => Object.values(value.values).some(literal => literal.report)); const reportUnions = reportValues.filter(value => !this.isEnumPreferred(value)); if (reportUnions.length) { lines.push('', '// Union types'); for (const value of reportValues) lines.push(...this.makeValueUnion(value)); } const reportEnums = reportValues.filter(value => this.isEnumPreferred(value)); if (reportEnums.length) { lines.push('', '// Enumerated types'); for (const value of reportValues) lines.push(...this.makeValueEnum(value)); } // Construct interfaces for groups with unrecognised keys lines.push(''); for (const groupName of Object.keys(this.groups).sort()) { assertIsDefined(this.groups[groupName]); if (Object.values(this.groups[groupName].keys).some(key => key.report)) { lines.push(...this.makeGroupInterface(groupName), ''); } } // Construct the URL to create a new issue for this report const appliances = Array.from(this.applianceReports); const appliancesTitle = this.makeApplianceDescription(appliances, 'short'); const issueUrl = this.makeIssueURL(`HomeConnect API unexpected values (${appliancesTitle})`, this.makeApplianceDescription(appliances, 'long')); // Output the header text this.log.warn(COLOUR_HI('Home Connect API returned keys/values that are unrecognised by this plugin')); this.log.warn(COLOUR_HI('Please report these by creating a new GitHub issue using this link:')); this.log.warn(COLOUR_HI.bold(` ${issueUrl}`)); this.log.warn(COLOUR_HI('Most of the issue fields will be filled-in appropriately.')); this.log.warn(COLOUR_HI('Just paste the following into the "Log File" field and submit the issue:')); // Output the unrecognised keys/values with delimiter lines const maxLength = Math.max(...lines.map(line => line.length)); this.log.warn(COLOUR_HI('='.repeat(maxLength))); for (const line of lines) this.log.warn(COLOUR_LO(line)); this.log.warn(COLOUR_HI('='.repeat(maxLength))); } // Construct an interface for a group of keys makeGroupInterface(groupName) { // Generate the properties for this group const group = this.groups[groupName]; assertIsDefined(group); const properties = Object.keys(group.keys).sort().map(keyName => { assertIsDefined(group.keys[keyName]); const { key, value, report } = group.keys[keyName]; let line = `${this.makeType(value)};`; if (report) line += ` // ${REPORT_COMMENT}`; return [`'${key}'?:`, line]; }); // Return the interface for this group of keys return [ `// ${groupName}`, `export interface ${groupName}Values {`, ...columns(properties).map(line => ` ${line}`), '}' ]; } // Construct a union of literals for a value makeValueUnion(value) { // Generate the literal values const literals = Object.keys(value.values).sort(); const lines = literals.map((literal, index) => { let line = (index === 0 ? ' ' : ' | ') + `'${literal}'`; if (index === literals.length - 1) line += ';'; if (value.values[literal]?.report) line += ` // ${REPORT_COMMENT}`; return line; }); // Return the union of literals for this value return [ `export type ${this.makeType(value)} =`, ...lines ]; } // Construct an enum for a value makeValueEnum(value) { // Generate the enum values const literals = Object.keys(value.values).sort(); const values = literals.map((literal, index) => { let line = `= '${literal}'`; if (index !== literals.length - 1) line += ','; if (value.values[literal]?.report) line += ` // ${REPORT_COMMENT}`; return [literal.replace(/^.*\./, ''), line]; }); // Return the enum type for this value return [ `export enum ${this.makeType(value)} {`, ...columns(values).map(line => ` ${line}`), '}' ]; } // Construct a Typescript type for a value makeType(value) { // Use the type specified by the API, if provided if (value?.type) { return TYPE_MAP.get(value.type) ?? this.makeEnumName(value.type); } // If it appears to be a string type then try to base it on the values const literalsType = this.getTypeof(value); if (literalsType === 'string') { const literals = Object.keys(value?.values ?? {}); if (literals.every(literal => literal.includes('.Program.'))) return 'ProgramKey'; const isString = literals.some(literal => /[ :]/.test(literal)); const types = literals.map(literal => this.makeEnumName(literal.replace(/\.[^.]*$/, ''))); assertIsDefined(types[0]); const isEnum = types.every(type => type === types[0]); if (!isString && isEnum) return types[0]; } // Fallback to the 'typeof' value return literalsType; } // Test whether a value is of enum type isEnumType(value) { if (value.type) return !TYPE_MAP.has(value.type); if (/\b(boolean|number)\b/.test(this.getTypeof(value))) return false; return undefined; } // Typescript type based on the typeof literals getTypeof(value) { function assertIsLiteral(type) { assert.match(type, /^(string|number|boolean)$/); } const types = [...new Set(Object.values(value?.values ?? {}).map(literal => { const type = typeof literal.value; assertIsLiteral(type); return type; }))]; return types.length ? types.join(' | ') : 'unknown'; } // Merge values that have the same enum name mergeSameEnums() { const enums = {}; for (const key of Object.values(this.keys)) { const { value } = key; const name = this.makeType(value); if (value && !['string', 'number', 'boolean', 'unknown'].includes(name)) { const existing = enums[name] ?? (enums[name] = value); if (existing !== value) this.mergeEnumValue(existing, value); } } } // Merge two enum values mergeEnumValue(to, from) { var _a; // Sanity checks if (from.type) assert.doesNotMatch(from.type, /^(Double|Int|Boolean)$/); if (to.type) assert.doesNotMatch(to.type, /^(Double|Int|Boolean)$/); if (from.type && to.type) assert.strictEqual(from.type, to.type); // Merge the literals for (const [key, literal] of Object.entries(from.values)) { (_a = to.values)[key] ?? (_a[key] = literal); if (literal.report) to.values[key].report = true; } // Update all references to the old value for (const key of Object.values(this.keys)) { if (key.value === from) key.value = to; } } // Construct an enum type name makeEnumName(type) { return type // Remove any common prefix .replace(/^.*(EnumType|Option|Setting|Status)\./, '') .replace(/\./g, ''); // remove any remaining period separators } // Construct the issue URL for reporting this makeIssueURL(title, appliances) { const url = new URL(NEW_ISSUE_URL); url.searchParams.set('title', title); url.searchParams.set('version', PLUGIN_VERSION); url.searchParams.set('appliance', appliances); return url.href; } // Convert a list of appliance haids into a description makeApplianceDescription(haids, style = 'long') { if (haids.every(haid => this.appliances.has(haid))) { // All appliances are known, so use their types return formatList(haids.map(haid => { const appliance = this.appliances.get(haid); assertIsDefined(appliance); const { brand, type, enumber } = appliance; return style === 'short' ? enumber : `${brand} ${type} ${enumber}`; })); } else { // At least one of the appliances is unknown return style === 'short' ? plural(haids.length, 'appliance') : formatList([...haids].sort()); } } // Obtain a list of all values (that might be) of enum type getValuesOfEnumType() { // Construct the set of (possible) enum values const values = new Set(); for (const value of Object.values(this.keys).map(key => key.value)) { if (value && this.isEnumType(value) !== false && Object.values(value.values).length) values.add(value); } // Return the values, sorted by their type name const sortBy = (value) => this.makeType(value); return Array.from(values).sort((a, b) => sortBy(a).localeCompare(sortBy(b))); } // Decide whether an enum or union type is preferred isEnumPreferred(value) { const keys = Object.values(this.keys).filter(key => key.value === value); return keys.some(key => /\.(Event|Setting|State)\./.test(key.key)); } // Restore keys and values from previous sessions async readPersist() { try { const persist = await this.persist.getItem(this.persistKey); if (persist?.version && semver.satisfies(PLUGIN_VERSION, `^${persist.version}`)) { const haid = 'Restored from previous session'; for (const { group, key, report } of persist.keys) this.addKey(haid, group, undefined, key, report ?? false, true); for (const { key, value, report } of persist.values) this.addValue(haid, key, value, report ?? false, true); this.log.debug(`Restored ${plural(persist.keys.length, 'key')} and ${plural(persist.values.length, 'value')}`); } } catch (err) { logError(this.log, 'Read keys/values', err); } } // Save the keys and values that have been seen async writePersist() { try { await this.persist.setItem(this.persistKey, { keys: Object.entries(this.groups).flatMap(([group, groupDetail]) => Object.values(groupDetail.keys).map(({ key, report }) => ({ group, key, report }))), values: Object.values(this.keys).flatMap(key => Object.values(key.value?.values ?? {}).map(({ value, report }) => ({ key: key.key, value, report }))), version: PLUGIN_VERSION }); } catch (err) { logError(this.log, 'Write keys/values', err); } } } //# sourceMappingURL=api-value.log.js.map