@homebridge-plugins/homebridge-roomba
Version:
homebridge-plugin for Roomba devices
210 lines • 9.81 kB
JavaScript
import { readFileSync } from 'node:fs';
import { RoboticVacuumCleaner } from './matterAccessory.js';
import { getRoombas } from './roomba.js';
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
/**
* Homebridge platform that registers Roomba devices as Homebridge Matter
* RoboticVacuumCleaner accessories. This platform is chosen automatically when
* Homebridge v2 is detected with Matter enabled, unless the user sets
* `enableMatter: false` in the plugin configuration.
*/
export default class RoombaMatterPlatform {
api;
log;
config;
matterAccessories = new Map();
roombaAccessories = new Map();
/**
* Cached HAP accessories restored by Homebridge on startup. These are
* accumulated in `configureAccessory` and then unregistered during
* `discoverDevices` because the Matter platform does not use HAP accessories.
*/
cachedHapAccessories = [];
version;
constructor(log, config, api) {
this.api = api;
this.config = config;
this.log = log;
const debug = !!config.debug;
try {
this.verifyConfig();
log.debug('Configuration:', JSON.stringify(this.config, null, 2));
}
catch (e) {
log.error('Error in configuration:', e.message ?? e);
return;
}
if (debug) {
this.log = Object.assign(log, { debug: (message, ...parameters) => { log.info(`DEBUG: ${message}`, ...parameters); } });
}
this.version = this.getVersion();
const matterApi = this.api.matter;
if (!matterApi) {
this.log.warn('Homebridge Matter API is not available. Please update Homebridge to v2 or later to use Matter support.');
return;
}
this.api.on('didFinishLaunching', () => {
void this.discoverDevices();
});
}
verifyConfig() {
if (this.config.disableDiscovery === undefined) {
this.config.disableDiscovery = false;
}
}
/**
* Required by DynamicPlatformPlugin. Called for each cached HAP accessory
* restored from disk at startup. The Matter platform does not use HAP
* accessories, so we track them here and unregister them during
* `discoverDevices` to avoid leaving stale HAP accessories registered.
*/
configureAccessory(accessory) {
this.log.debug('Caching restored HAP accessory for removal:', accessory.displayName);
this.cachedHapAccessories.push(accessory);
}
/**
* Called by Homebridge when a cached Matter accessory is restored from disk.
*/
configureMatterAccessory(accessory) {
this.log.debug('Loading cached Matter accessory:', accessory.displayName);
this.matterAccessories.set(accessory.UUID, accessory);
}
async discoveryMethod() {
if (this.config.email && this.config.password) {
const robots = await getRoombas(this.config.email, this.config.password, this.log, this.config);
return robots.map((robot) => {
const deviceConfig = this.config.devices?.find(device => device.blid === robot.blid) ?? {};
return {
...robot,
...deviceConfig,
};
});
}
else if (this.config.devices) {
return this.config.devices.map(device => ({ ...device }));
}
else {
this.log.error('No configuration provided for devices.');
return [];
}
}
async discoverDevices() {
const matterApi = this.api.matter;
if (!matterApi) {
this.log.warn('Matter API not available — skipping device registration.');
return;
}
const devices = (await this.discoveryMethod());
const configuredUUIDs = new Set();
const platformToRegister = [];
const externalToRegister = [];
let registrationSucceeded = true;
for (const device of devices) {
const roombaAcc = new RoboticVacuumCleaner(this.api, this.log, device, this.config, this.version);
const uuid = roombaAcc.UUID;
configuredUUIDs.add(uuid);
this.log.debug('Matter device: %s, UUID: %s', JSON.stringify(device), uuid);
const existingMatterAccessory = this.matterAccessories.get(uuid);
const matterAccessoryData = roombaAcc.toAccessory();
if (existingMatterAccessory) {
this.log.debug('Updating cached Matter accessory:', device.name);
// Re-attach handlers (not persisted across restarts) and update mutable
// fields. Cached accessories are automatically re-registered by
// Homebridge — do NOT push them to platformToRegister/externalToRegister.
existingMatterAccessory.displayName = matterAccessoryData.displayName;
existingMatterAccessory.context = matterAccessoryData.context;
existingMatterAccessory.clusters = matterAccessoryData.clusters;
existingMatterAccessory.handlers = matterAccessoryData.handlers;
// Persist context/display name changes
try {
await matterApi.updatePlatformAccessories([existingMatterAccessory]);
}
catch (e) {
this.log.warn('Failed to update cached Matter accessory:', e.message ?? e);
}
}
else {
this.log.info('Adding new Matter accessory:', device.name);
this.matterAccessories.set(uuid, matterAccessoryData);
// Only NEW accessories need to be explicitly registered; cached ones are
// automatically re-registered after configureMatterAccessory() is called.
const isExternal = device.externalAccessory ?? this.config.externalAccessory ?? false;
if (isExternal) {
externalToRegister.push(matterAccessoryData);
}
else {
platformToRegister.push(matterAccessoryData);
}
}
this.roombaAccessories.set(uuid, roombaAcc);
}
if (platformToRegister.length > 0) {
try {
await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformToRegister);
this.log.info(`Registered ${platformToRegister.length} Roomba Matter accessory(ies)`);
}
catch (e) {
this.log.error('Failed to register Matter accessories:', e.message ?? e);
registrationSucceeded = false;
}
}
if (registrationSucceeded && externalToRegister.length > 0) {
try {
if (matterApi.publishExternalAccessories) {
await matterApi.publishExternalAccessories(PLUGIN_NAME, externalToRegister);
this.log.info(`Published ${externalToRegister.length} Roomba Matter accessory(ies) as external`);
}
else {
// Fallback: register as platform accessories if the Matter API version
// does not yet expose publishExternalAccessories.
await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, externalToRegister);
this.log.info(`Registered ${externalToRegister.length} Roomba Matter accessory(ies) (external not supported by this Homebridge version, fell back to platform)`);
}
}
catch (e) {
this.log.error('Failed to publish external Matter accessories:', e.message ?? e);
registrationSucceeded = false;
}
}
if (!registrationSucceeded) {
this.log.warn('Matter registration did not complete successfully. Preserving cached HAP accessories for fallback.');
return;
}
// Only remove cached HAP accessories after Matter registration succeeds.
if (this.cachedHapAccessories.length > 0) {
this.log.info('Unregistering %d cached HAP accessory(ies) (switching to Matter)', this.cachedHapAccessories.length);
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, this.cachedHapAccessories);
this.cachedHapAccessories.length = 0;
}
// Remove stale accessories
const accessoriesToRemove = [];
for (const [uuid, accessory] of this.matterAccessories) {
if (!configuredUUIDs.has(uuid)) {
accessoriesToRemove.push(accessory);
this.matterAccessories.delete(uuid);
const roombaAcc = this.roombaAccessories.get(uuid);
roombaAcc?.stopPolling();
this.roombaAccessories.delete(uuid);
}
}
if (accessoriesToRemove.length > 0) {
this.log.info('Removing stale Matter accessories:', accessoriesToRemove.map((a) => a.displayName).join(', '));
try {
await matterApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRemove);
}
catch (e) {
this.log.warn('Failed to unregister stale Matter accessories:', e.message ?? e);
}
}
// Start polling for all active accessories
for (const roombaAcc of this.roombaAccessories.values()) {
roombaAcc.startPolling();
}
}
getVersion() {
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
this.log.debug(`Plugin Version: ${version}`);
return version;
}
}
//# sourceMappingURL=Platform.Matter.js.map