UNPKG

matterbridge-aeg-robot

Version:

A Matterbridge plugin that connects AEG RX 9 / Electrolux Pure i9 robot vacuums to the Matter smart home ecosystem.

147 lines 6.32 kB
// Matterbridge plugin for AEG RX9 / Electrolux Pure i9 robot vacuum // Copyright © 2025 Alexander Thoukydides import { AnsiLogger, nf, YELLOW } from 'matterbridge/logger'; import { assertIsDefined, formatList, MS } from './utils.js'; import { checkers } from './ti/token-types.js'; // Log colours export const RR = `\u001B[39;49m${nf}`; // Reset to normal text (light grey) export const RT = '\u001B[38;5;67m'; // Raw description (dim blue) export const RV = '\u001B[38;5;75m'; // Raw value (dim cyan) export const AN = '\u001B[38;5;87m'; // Attribute or event names (bright cyan) export const AV = YELLOW; // Attribute or event values export const CN = '\u001B[38;5;120m'; // Command cluster (bright green) export const CV = YELLOW; // Command name/value export const CC = '\u001B[30;43m'; // RX9CleaningCommand (yellow background) // Mapping of applianceId values to their names const applianceIds = new Map(); const LENGTH = { pnc: 9, sn: 8, ai: 24 }; // Regular expressions for different types of sensitive data const filters = [ [maskAPIKey, /\w_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/], [maskAccessToken, /\b[\w-]+\.[\w-]+\.[\w-]+\b/g], [maskRefreshToken, /(?<="refreshToken":\s*")[^"]+(?=")/gi] // (within JSON) ]; // A logger with filtering and support for an additional prefix export class PrefixLogger extends AnsiLogger { delegate; prefix; // Log level to be used for debug messages debugLevel = "debug" /* LogLevel.DEBUG */; // Create a new logger constructor(delegate, prefix) { super({ extLog: delegate, logTimestampFormat: delegate.logTimestampFormat, logCustomTimestampFormat: delegate.logCustomTimestampFormat }); this.delegate = delegate; this.prefix = prefix; } // Get and set the log level (in the delegate logger) get logLevel() { return this.delegate.logLevel; } set logLevel(logLevel) { this.delegate.logLevel = logLevel; } // Get and set the log name (in the delegate logger) get logName() { return this.delegate.logName; } set logName(logName) { this.delegate.logName = logName; } // Log a message with sensitive data filtered log(level, message, ...parameters) { // Allow debug messages to be logged as a different level if (level === "debug" /* LogLevel.DEBUG */) level = this.debugLevel; // Filter the log message and parameters const filteredMessage = PrefixLogger.filterSensitive(message); const filteredParameters = parameters.map(p => PrefixLogger.filterSensitive(p)); // Call the delegate directly (not super.log) to avoid double-logging const savedLogName = this.delegate.logName; try { if (this.prefix) this.delegate.logName = `${savedLogName} - ${this.prefix}`; this.delegate.log(level, filteredMessage, ...filteredParameters); } finally { this.delegate.logName = savedLogName; } } // Log all DEBUG messages as INFO to avoid being dropped by Homebridge logDebugAsInfo() { this.debugLevel = "info" /* LogLevel.INFO */; } // Attempt to filter sensitive data within the log message static filterSensitive(value) { const { filtered, redacted } = filterString(String(value)); let jsonRedacted = true; try { jsonRedacted = filterString(JSON.stringify(value)).redacted; } catch { /* empty */ } return redacted || jsonRedacted ? filtered : value; } // Add an applianceId to filter static addApplianceId(applianceId, name) { if (applianceIds.has(applianceId)) return; name ??= `SN${applianceIds.size + 1}`; const serialNumber = applianceId.slice(LENGTH.pnc, LENGTH.pnc + LENGTH.sn); applianceIds.set(applianceId, name); filters.push([maskSerialNumber.bind(null, name), new RegExp(`\\b${serialNumber}\\b`, 'g')], [maskApplianceId.bind(null, name), new RegExp(`\\b${applianceId}\\b`, 'g')]); } } // Filter sensitive data within a string function filterString(value) { const filtered = filters.reduce((value, [mask, regex]) => value.replace(regex, mask), value); return { filtered, redacted: filtered !== value }; } // Mask an Electrolux Group API Key function maskAPIKey(apiKey) { return maskToken('API_KEY', apiKey); } // Mask an Electrolux Group API refresh token function maskRefreshToken(token) { return maskToken('REFRESH_TOKEN', token); } // Mask a Home Connect access token function maskAccessToken(token) { try { const parts = token.split('.').map(part => decodeBase64URL(part)); assertIsDefined(parts[0]); assertIsDefined(parts[1]); const header = JSON.parse(parts[0]); const payload = JSON.parse(parts[1]); if (checkers.AccessTokenHeader.test(header) && checkers.AccessTokenPayload.test(payload)) { return maskToken('ACCESS_TOKEN', token, { issued: new Date(payload.iat * MS).toISOString(), expires: new Date(payload.exp * MS).toISOString(), scope: payload.scope }); } return maskToken('JSON_WEB_TOKEN', token); } catch { return token; } } // Mask a serial number function maskSerialNumber(name, _serialNumber) { return `<SERIAL_NUMBER: "${name}">`; } // Mask an applianceId function maskApplianceId(name, applianceId) { const pnc = applianceId.slice(0, LENGTH.pnc); return `<PRODUCT_ID: ${pnc}... "${name}">`; } // Mask a token, leaving just the first and final few characters function maskToken(type, token, details = {}) { let masked = `${token.slice(0, 4)}...${token.slice(-8)}`; const parts = Object.entries(details).map(([key, value]) => `${key}=${value}`); if (parts.length) masked += ` (${formatList(parts)})`; return `<${type}: ${masked}>`; } // Decode a Base64URL encoded string function decodeBase64URL(base64url) { const paddedLength = base64url.length + (4 - base64url.length % 4) % 4; const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/').padEnd(paddedLength, '='); return Buffer.from(base64, 'base64').toString(); } //# sourceMappingURL=logger.js.map