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
JavaScript
// 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