homebridge
Version:
HomeKit support for the impatient
870 lines • 50.9 kB
JavaScript
/**
* Accessory Manager
*
* Handles registering/unregistering accessories, building custom behaviors,
* detecting cluster features, creating endpoint options, creating accessory parts,
* and restoring cached state.
*/
import { EventEmitter } from 'node:events';
import process from 'node:process';
import { Endpoint } from '@matter/main';
import { BasicInformationServer, BridgedDeviceBasicInformationServer, DescriptorServer, FixedLabelServer } from '@matter/main/behaviors';
import { PowerSourceServer } from '@matter/node/behaviors';
import { Logger } from '../../logger.js';
import { setRegistryManager } from '../behaviors/EndpointContext.js';
import { HomebridgeRvcCleanModeServer, HomebridgeServiceAreaServer } from '../behaviors/index.js';
import { applyElectricalMeasurementClusters, applyElectricalMeasurementDefaults, applyLevelControlLightingFloor, applySmokeCoAlarmFeatures, applyThermostatFeatures, applyWindowCoveringFeatures, CLUSTER_IDS, detectBehaviorFeatures, detectElectricalMeasurementClusters, detectSmokeCoAlarmFeatures, detectThermostatFeatures, detectWindowCoveringFeatures, determineColorControlFeaturesFromClusters, determineColorControlFeaturesFromHandlers, extractColorControlFeatures, extractDeclaredFeatures, extractLevelControlFeatures, extractThermostatFeatures, validateAccessoryRequiredFields, } from '../serverHelpers.js';
import { devices, MatterDeviceError, } from '../types.js';
import { stripVendorFromLabel } from '../utils.js';
import { CORE_CLUSTER_BEHAVIOR_MAP } from './BehaviorMap.js';
// The parts-list update and ConfigurationVersion bump run inside matter.js
// transactions that acquire their resource lock synchronously. A controller
// re-establishing its subscription (matter.js-internal "offline" transactions)
// can briefly hold that lock, making the synchronous acquisition throw. Those
// transactions release almost immediately, so retry with a short yield rather
// than dropping the structure-change notification (#3970).
const PARTS_LIST_NOTIFY_ATTEMPTS = 5;
const PARTS_LIST_NOTIFY_RETRY_DELAY_MS = 50;
const log = Logger.withPrefix('Matter/Server');
export class AccessoryManager {
/**
* Register a single Matter accessory
* The first two arguments are unused, but kept to keep consistency with the HAP accessory registration function signature.
*/
async registerAccessory(_pluginIdentifier, _platformName, accessory, deps) {
const serverNode = deps.getServerNode();
const aggregator = deps.getAggregator();
if (!serverNode || (!deps.config.externalAccessory && !aggregator)) {
throw new MatterDeviceError('Matter server not started');
}
validateAccessoryRequiredFields(accessory);
if (deps.accessories.has(accessory.UUID)) {
const existing = deps.accessories.get(accessory.UUID);
// A cache-restored accessory keeps its endpoint (so the parts list never
// churns); the plugin's registration attaches its handlers and metadata
// in place. Structural changes fall through to a fresh registration.
if (existing?._restoredFromCache) {
const partIds = (list) => JSON.stringify((list ?? []).map(part => part.id).sort());
// ⚠️ The name is not enough. The cache stores a device type as
// {name, code}, so a restore rebuilds the BASE type - anything the
// plugin composed itself (via api.matter.deviceRequirements) is gone,
// while the name still matches. Attaching in place then kept the
// restored endpoint and silently reverted the plugin's own feature
// choice to the detected one on every restart, which is exactly the
// case deviceRequirements exists to serve. Compare what is composed.
const behaviorKeys = (deviceType) => Object.keys(deviceType?.behaviors ?? {}).sort().join(',');
const sameShape = existing.deviceType?.name === accessory.deviceType?.name
&& partIds(existing._parts ?? existing.parts) === partIds(accessory.parts)
&& behaviorKeys(existing.deviceType) === behaviorKeys(accessory.deviceType);
if (sameShape) {
log.info(`Attached plugin registration to restored accessory ${accessory.displayName} (${accessory.UUID})`);
deps.accessories.set(accessory.UUID, {
...existing,
...accessory,
endpoint: existing.endpoint,
_parts: existing._parts,
registered: true,
_restoredFromCache: false,
});
this.registerAccessoryHandlers(accessory, deps);
for (const part of accessory.parts ?? []) {
if (!part.handlers) {
continue;
}
const partEndpointId = `${accessory.UUID}-part-${part.id}`;
deps.behaviorRegistry.registerPartEndpoint(partEndpointId, accessory.UUID, part.id);
for (const [clusterName, handlers] of Object.entries(part.handlers)) {
for (const [commandName, handler] of Object.entries(handlers)) {
deps.behaviorRegistry.registerHandler(partEndpointId, clusterName, commandName, handler);
}
}
}
if (deps.accessoryCache) {
deps.accessoryCache.requestSave(deps.accessories);
}
return;
}
log.info(`Restored accessory ${accessory.displayName} changed structure - re-registering`);
await this.unregisterAccessory(accessory.UUID, deps);
}
else {
throw new MatterDeviceError(`Matter accessory with UUID "${accessory.UUID}" is already registered.\n`
+ `Existing accessory: "${existing?.displayName}"\n`
+ `New accessory: "${accessory.displayName}"\n`
+ 'Each accessory must have a unique UUID. Use api.hap.uuid.generate() with a unique string.');
}
}
this.restoreCachedState(accessory, deps.accessoryCache);
if (deps.accessories.size >= 1000) {
throw new MatterDeviceError(`Cannot register Matter accessory "${accessory.displayName}": `
+ 'Maximum device limit reached (1000 devices).\n'
+ `Current registered devices: ${deps.accessories.size}`);
}
try {
const prepared = await this.prepareDeviceType(accessory);
const { hasElectrical } = prepared;
let { deviceType } = prepared;
if (!deps.config.externalAccessory) {
// Skip if device type already includes BridgedDeviceBasicInformation
// (e.g., BridgedNodeEndpoint used as a composed device container)
const hasBridgedInfo = deviceType.behaviors?.bridgedDeviceBasicInformation !== undefined;
if (!hasBridgedInfo) {
deviceType = deviceType.with(BridgedDeviceBasicInformationServer);
log.debug(`Added BridgedDeviceBasicInformationServer to ${accessory.displayName}`);
}
}
const endpointOptions = this.createEndpointOptions(accessory, deps.config);
// Composed parents carry a FixedLabel marking the composition, matching
// known-good bridges - Apple's controller needs it to bind child endpoints
// to the composed accessory. Without it (plus the PowerSource below),
// homed never finishes its per-accessory session setup: commands fail
// silently ("No Response") from ~30s after pairing while reads keep working.
if (accessory.parts && accessory.parts.length > 0) {
if (deviceType.behaviors?.fixedLabel === undefined) {
deviceType = deviceType.with(FixedLabelServer);
endpointOptions.fixedLabel = {
labelList: [{ label: 'composed', value: 'true' }],
};
}
// Known-good bridges also expose a wired PowerSource on composed
// parents; Apple's controller appears to expect it. Only synthesize it
// when the accessory has not declared its own PowerSource — otherwise a
// plugin-provided battery PowerSource (added below) would be overwritten.
if (!accessory.clusters?.powerSource) {
deviceType = deviceType.with(PowerSourceServer.with('Wired'));
endpointOptions.powerSource = {
status: 1, // Active
order: 0,
description: 'AC Power',
endpointList: [],
wiredCurrentType: 0, // AC (PowerSource.WiredCurrentType.Ac)
};
}
}
const endpoint = new Endpoint(deviceType, endpointOptions);
setRegistryManager(endpoint, deps.registryManager);
if (deps.config.debugModeEnabled) {
log.debug(`Created endpoint for ${accessory.displayName} with initial cluster states`);
}
if (deps.config.externalAccessory) {
await serverNode.add(endpoint);
log.debug(`Added ${accessory.displayName} as external accessory to ServerNode`);
}
else {
await aggregator.add(endpoint);
if (deps.config.debugModeEnabled) {
log.debug(`Added endpoint for ${accessory.displayName} to aggregator`);
}
}
if (hasElectrical) {
await this.advertiseUtilityDeviceType(endpoint, 'ElectricalSensor', accessory.displayName);
}
// ⚠️ A battery is only visible to a controller once the endpoint also
// advertises the PowerSource device type. Composing the cluster is not
// enough - exactly as with ElectricalSensor above - because a controller
// that cannot see the type in the DeviceTypeList has no reason to read
// the cluster. Without this, Apple Home showed no battery anywhere while
// every attribute was present and correct (homebridge-sharkiq#88).
//
// Deliberately only for a power source the plugin declared. The wired one
// synthesized for composed parents above is left alone: it exists to
// satisfy Apple's controller in a case that already works, and this area
// fails silently and badly when disturbed.
if (accessory.clusters?.powerSource) {
await this.advertiseUtilityDeviceType(endpoint, 'PowerSource', accessory.displayName);
}
this.registerAccessoryHandlers(accessory, deps);
const internalParts = await this.createAccessoryParts(accessory, endpoint, deps);
await this.finalizeAccessoryRegistration(accessory, endpoint, internalParts, deps);
}
catch (error) {
log.error(`Failed to register Matter accessory ${accessory.displayName}:`, error);
throw new MatterDeviceError(`Failed to register accessory: ${error}`);
}
}
/**
* Unregister a Matter accessory
*/
async unregisterAccessory(uuid, deps) {
const accessory = deps.accessories.get(uuid);
if (!accessory) {
log.debug(`Accessory ${uuid} not found or not registered`);
if (deps.accessoryCache && deps.accessoryCache.getCached(uuid)) {
log.debug(`Removing ${uuid} from cache`);
deps.accessoryCache.removeCached(uuid);
deps.accessoryCache.requestSave(deps.accessories);
}
return;
}
try {
if (accessory.endpoint && deps.getAggregator()) {
await accessory.endpoint.close();
log.debug(`Removed endpoint for ${accessory.displayName}`);
}
deps.accessories.delete(uuid);
// Drop the handler table for this accessory + its parts so we don't
// retain plugin closures past the accessory's lifetime. removeEndpoint
// returns the accessory's own endpoint id plus any part endpoint ids it
// swept, so we can drop the matching endpoint→registry mappings too —
// RegistryManager has no parent-aware sweep, and without this its map
// would leak an entry per accessory/part across register/unregister cycles.
const removedEndpoints = deps.behaviorRegistry.removeEndpoint(uuid);
for (const endpointId of removedEndpoints) {
deps.registryManager.unregisterEndpoint(endpointId);
}
log.info(`Unregistered Matter accessory: ${accessory.displayName} (${uuid})`);
await this.notifyPartsListChanged(deps);
if (deps.accessoryCache) {
deps.accessoryCache.removeCached(uuid);
deps.accessoryCache.requestSave(deps.accessories);
}
if (deps.getMonitoringEnabled() && process.send) {
const event = {
type: 'accessoryRemoved',
data: { uuid },
};
process.send({
id: "matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */,
data: event,
});
}
}
catch (error) {
log.error(`Failed to unregister Matter accessory ${uuid}:`, error);
throw new MatterDeviceError(`Failed to unregister accessory: ${error}`);
}
}
/**
* Restore cached state for an accessory
*/
restoreCachedState(accessory, accessoryCache) {
if (accessoryCache && accessoryCache.hasCached(accessory.UUID)) {
const cached = accessoryCache.getCached(accessory.UUID);
if (cached?.clusters && accessory.clusters) {
for (const [clusterName, cachedAttrs] of Object.entries(cached.clusters)) {
if (!accessory.clusters[clusterName]) {
// Skip clusters that the accessory no longer declares
continue;
}
// Only restore attributes that the accessory's current definition includes
const currentAttrs = accessory.clusters[clusterName];
const filteredCached = {};
for (const key of Object.keys(cachedAttrs)) {
if (key in currentAttrs) {
filteredCached[key] = cachedAttrs[key];
}
}
accessory.clusters[clusterName] = {
...currentAttrs,
...filteredCached,
};
}
if (cached.context) {
accessory.context = cached.context;
}
log.info(`Restored cached state for Matter accessory: ${accessory.displayName}`);
}
}
}
/**
* Work out the endpoint's final device type: compose the feature-gated
* clusters it needs, fix up any state that would fail conformance, and
* attach the Homebridge behaviors that route commands to plugin handlers.
*
* ⚠️ Shared by the parent accessory AND by every part. Child endpoints used
* to run a cut-down version of this that only looked up behaviors by name,
* so a composed part quietly lost anything decided here — its battery was
* never composed, a thermostat part had no thermostat cluster at all
* (matter.js gates it behind features, so the base device type carries
* none), and a dimmable part declaring the once-documented `minLevel: 0`
* still failed to register. Anything added here must therefore stay free of
* parent-only assumptions; the caller handles what genuinely differs
* (bridged info, the composed-parent labels, the child tag list).
*/
async prepareDeviceType(accessory) {
let deviceType = accessory.deviceType;
// WindowCovering is feature-gated too, and detection reads the declared
// lift/tilt attributes. A plugin that composed the cluster itself has
// already said what the device does, so leave its choice alone - matching
// SmokeCoAlarm and Thermostat below.
//
// ⚠️ The skip flag still has to be set. It is what stops the behavior loop
// adding the custom WindowCovering server *without* features on top; only
// skipping the call here would clobber the plugin's composition further
// down instead of here.
const hasWindowCovering = deviceType.behaviors?.windowCovering !== undefined;
const windowCoveringFeatures = detectWindowCoveringFeatures(accessory);
if (hasWindowCovering) {
if (windowCoveringFeatures.length > 0) {
log.debug(`${accessory.displayName} composed its own WindowCovering cluster - keeping its features`);
}
if (!accessory.context) {
accessory.context = {};
}
accessory.context._skipWindowCoveringBehavior = true;
}
else if (windowCoveringFeatures.length > 0) {
deviceType = applyWindowCoveringFeatures(deviceType, accessory, windowCoveringFeatures);
}
// SmokeCoAlarm is feature-gated in matter.js, so the base SmokeCoAlarmDevice
// carries no SmokeCoAlarm cluster. Add it with features detected from the
// accessory's declared attributes — unless the plugin already composed a
// device type that includes it.
const hasSmokeCoAlarm = deviceType.behaviors?.smokeCoAlarm !== undefined;
if (deviceType.deviceType === devices.SmokeCoAlarmDevice.deviceType && !hasSmokeCoAlarm) {
deviceType = applySmokeCoAlarmFeatures(deviceType, accessory, detectSmokeCoAlarmFeatures(accessory));
}
// Thermostat is feature-gated in the same way, so the base ThermostatDevice
// carries no thermostat cluster. Add it with features detected from the
// declared setpoints, so a heating-only thermostat is not forced to claim
// cooling - unless the plugin already composed the cluster itself.
const hasThermostat = deviceType.behaviors?.thermostat !== undefined;
if (deviceType.deviceType === devices.ThermostatDevice?.deviceType && !hasThermostat) {
deviceType = applyThermostatFeatures(deviceType, accessory, detectThermostatFeatures(accessory));
}
// Electrical measurement clusters (power/energy metering) are feature-gated
// in matter.js and not part of any base device type. Detect them from the
// accessory's declared cluster state so any device type - outlets included -
// can report power/energy (surfaced by e.g. the iOS 27+ Home app energy view).
const electricalDetection = detectElectricalMeasurementClusters(accessory);
const hasElectrical = electricalDetection.hasPowerMeasurement || electricalDetection.energyFeatures.length > 0;
if (hasElectrical) {
applyElectricalMeasurementDefaults(accessory, electricalDetection);
deviceType = applyElectricalMeasurementClusters(deviceType, accessory, electricalDetection);
}
else if (deviceType.deviceType === devices.ElectricalSensorEndpoint?.deviceType) {
log.warn(`${accessory.displayName} uses the ElectricalSensor device type but declares no `
+ 'electricalPowerMeasurement or electricalEnergyMeasurement cluster state - '
+ 'the endpoint will expose no measurement clusters.');
}
const features = this.detectClusterFeatures(accessory, deviceType);
// Now that we know whether LevelControl keeps its Lighting feature, make
// sure the declared levels are legal for it.
applyLevelControlLightingFloor(accessory, features.levelControlFeatures);
const customBehaviors = await this.buildCustomBehaviors(accessory, deviceType, features);
if (customBehaviors.length > 0) {
deviceType = deviceType.with(...customBehaviors);
log.info(`Applied ${customBehaviors.length} custom behavior(s) to device type`);
}
return { deviceType, hasElectrical };
}
/**
* Present a part as an accessory so it can go through {@link prepareDeviceType}.
*
* Parts carry the same `deviceType`/`clusters`/`handlers` shape as their
* parent, just without the bridge-level identity fields — which nothing in
* the preparation pipeline reads. `clusters` is passed by reference on
* purpose: the pipeline edits it (electrical defaults, the battery charge
* state seed, the LevelControl floor) and those edits have to land on the
* part object that is about to be registered.
*/
partAsAccessory(part, partEndpointId) {
return {
UUID: partEndpointId,
displayName: part.displayName || part.id,
deviceType: part.deviceType,
serialNumber: '',
manufacturer: '',
model: '',
clusters: part.clusters,
handlers: part.handlers,
};
}
/**
* Detect cluster features for an accessory
*/
detectClusterFeatures(accessory, deviceType) {
const windowCoveringFeatures = detectWindowCoveringFeatures(accessory);
let serviceAreaFeatures = null;
if (accessory.clusters?.serviceArea) {
const features = [];
if (accessory.clusters.serviceArea.supportedMaps) {
features.push('Maps');
}
if (accessory.clusters.serviceArea.progress !== undefined) {
features.push('ProgressReporting');
}
if (features.length > 0) {
serviceAreaFeatures = features;
log.info(`ServiceArea features will be enabled for ${accessory.displayName}: ${features.join(', ')}`);
}
}
let colorControlFeatures = null;
if (accessory.handlers?.colorControl) {
colorControlFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.COLOR_CONTROL, extractColorControlFeatures);
if (colorControlFeatures) {
// Handlers first: they are the authority on what the plugin can actually do.
// On a cache restore they are empty stubs though (functions cannot be cached),
// so fall back to the cluster's own attributes - otherwise ColorControl is
// built with no features and the persisted colorTemperatureMireds/currentHue
// fail Matter's conformance check, taking the whole accessory down with them.
const fromHandlers = determineColorControlFeaturesFromHandlers(accessory.handlers.colorControl);
colorControlFeatures = fromHandlers.length > 0
? fromHandlers
: determineColorControlFeaturesFromClusters(accessory.clusters);
}
}
let thermostatFeatures = null;
if (accessory.handlers?.thermostat) {
thermostatFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.THERMOSTAT, extractThermostatFeatures);
}
// ClosureControl and MediaPlayback are feature-gated too, and the device
// type is where the choice was made - Closure composes Positioning, and a
// plugin may have composed more. Read whatever is there so replacing the
// base behavior with the handler-calling one carries it across instead of
// resetting the cluster to its featureless form.
let closureControlFeatures = null;
if (accessory.handlers?.closureControl) {
closureControlFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.CLOSURE_CONTROL, extractDeclaredFeatures);
}
let mediaPlaybackFeatures = null;
if (accessory.handlers?.mediaPlayback) {
mediaPlaybackFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.MEDIA_PLAYBACK, extractDeclaredFeatures);
}
// LevelControl: matter.js's public `LevelControlServer` inherits the
// Lighting+OnOff feature set from its internal `LevelControlBase` (see
// LevelControlServer.ts line ~20: `LevelControlBehavior.with(OnOff, Lighting)`).
// The `.for(LevelControl)` pattern that's supposed to reset features is a
// no-op because the raw `LevelControl` cluster doesn't declare
// `supportedFeatures`, so `syncFeatures` returns the base schema unchanged.
//
// Consequence: on a Pump endpoint (whose device-type requirements put
// LevelControl in `optional`, not `SupportedBehaviors`), attaching our
// behavior as-is leaves `features.lighting === true` at runtime. matter.js
// then picks the spec's `[LT]` branch for MinLevel ("constraint 1 to 254")
// and rejects `minLevel: 0` with a ValidationError, plus
// `initializeLighting()` emits "currentLevel/minLevel invalid" warnings.
//
// Fix: detect the device type's declared LevelControl features and apply
// them via `.with(...)`. When the device type declares nothing (Pump and
// similar), fall back to an EMPTY feature set so `.with()` explicitly
// strips the inherited Lighting/OnOff — the `[!LT]` branch then applies,
// `minLevel: 0` is valid, and `initializeLighting()` is skipped.
//
// See homebridge#3905 and the matter.js Device Library spec § 5 (Pump).
let levelControlFeatures = null;
if (accessory.handlers?.levelControl) {
levelControlFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.LEVEL_CONTROL, extractLevelControlFeatures);
if (levelControlFeatures === null) {
levelControlFeatures = [];
log.debug(`[${accessory.displayName}] Device type declares no LevelControl requirement; stripping inherited Lighting via .with()`);
}
}
return {
windowCoveringFeatures,
serviceAreaFeatures,
colorControlFeatures,
thermostatFeatures,
closureControlFeatures,
mediaPlaybackFeatures,
levelControlFeatures,
};
}
/**
* Build custom behaviors for an accessory based on handlers
*/
async buildCustomBehaviors(accessory, deviceType, features) {
const customBehaviors = [];
// PowerSource (battery) is a general utility cluster, not device-type
// specific — like the ElectricalPowerMeasurement/ElectricalEnergyMeasurement
// clusters, it should be composed for any device type. Compose it up front,
// before the no-handlers early return below: battery-powered sensors
// (contact/leak/occupancy, etc.) are read-only and declare no handlers at
// all, so gating this on handlers would silently drop their battery.
if (accessory.clusters?.powerSource) {
// Any battery attribute at all marks this as a battery source. Reading
// only the live values (batPercentRemaining/batChargeLevel) reintroduced
// #3982 one level up: a plugin that declares the static battery facts it
// knows at startup - batReplaceability, batPresent, batQuantity - but has
// no reading yet from a sleeping device would compose a PowerSource with
// no Battery feature at all, and then its first real reading would be
// rejected for the life of the process. Static facts are the declaration
// of intent, so they count. Nothing here invents a reading: a source that
// declares no battery attribute whatsoever is indistinguishable from a
// wired one and is left alone.
const powerSource = accessory.clusters.powerSource;
const hasBattery = Object.keys(powerSource).some(key => /^(?:bat|activeBat)/.test(key) && powerSource[key] !== undefined);
let powerSourceBehavior = PowerSourceServer;
if (hasBattery) {
// The feature set is decided once, here, but a device can start
// reporting batChargeState later (e.g. a vacuum asleep on its dock at
// startup). If the feature were gated on the attribute being present
// now, that first report would be rejected by conformance, and the
// rollback discards every attribute in the same update - the battery
// freezes at its startup value until a restart happens to catch the
// device awake (#3982). So every battery carries Rechargeable, seeded
// with Unknown: the endpoint options spread this same object, so the
// seed is also the registration-time value the feature's conformance
// requires.
//
// The trade is deliberate: a non-rechargeable battery ends up
// advertising Rechargeable with a charge state of Unknown, which is not
// strictly true. Determinism is worth more here than that precision -
// the alternative decides a device's feature set on whether it happened
// to be awake when homebridge last restarted, and fails invisibly.
if (accessory.clusters.powerSource.batChargeState === undefined) {
accessory.clusters.powerSource.batChargeState = 0; // PowerSource.BatChargeState.Unknown
}
// Rechargeable also makes batFunctionalWhileCharging mandatory. matter.js
// happens to default it to false, so registration succeeds without it -
// but seeding it here means the feature and BOTH of its required
// attributes are introduced together, rather than resting on a default
// in someone else's package that could change.
if (accessory.clusters.powerSource.batFunctionalWhileCharging === undefined) {
accessory.clusters.powerSource.batFunctionalWhileCharging = false;
}
powerSourceBehavior = PowerSourceServer.with('Battery', 'Rechargeable');
log.debug('Adding PowerSource server with battery and rechargeable features');
}
else {
log.debug('Adding base PowerSource server');
}
customBehaviors.push(powerSourceBehavior);
}
if (!accessory.handlers) {
return customBehaviors;
}
log.debug(`[${accessory.displayName}] Has handlers: ${Object.keys(accessory.handlers).join(', ')}`);
// Handle RoboticVacuumCleaner optional clusters
if (deviceType.deviceType === devices.RoboticVacuumCleanerDevice.deviceType) {
const { RvcCleanModeServer, ServiceAreaServer } = devices.RoboticVacuumCleanerRequirements;
if (accessory.clusters?.rvcCleanMode) {
if (accessory.handlers?.rvcCleanMode) {
customBehaviors.push(HomebridgeRvcCleanModeServer);
log.info('Adding custom RvcCleanMode behavior with handlers');
}
else {
customBehaviors.push(RvcCleanModeServer);
log.info('Adding base RvcCleanMode server');
}
}
if (accessory.clusters?.serviceArea) {
if (accessory.handlers?.serviceArea) {
let behaviorClass = HomebridgeServiceAreaServer;
if (features.serviceAreaFeatures && features.serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.serviceAreaFeatures);
log.info(`ServiceArea custom behavior will have features: ${features.serviceAreaFeatures.join(', ')}`);
}
customBehaviors.push(behaviorClass);
log.info('Adding custom ServiceArea behavior with handlers');
}
else {
let behaviorClass = ServiceAreaServer;
if (features.serviceAreaFeatures && features.serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.serviceAreaFeatures);
log.info(`ServiceArea base server will have features: ${features.serviceAreaFeatures.join(', ')}`);
}
customBehaviors.push(behaviorClass);
log.info('Adding base ServiceArea server');
}
}
}
for (const clusterName of Object.keys(accessory.handlers || {})) {
const skipWindowCoveringBehavior = accessory.context?._skipWindowCoveringBehavior;
if (clusterName === 'windowCovering' && skipWindowCoveringBehavior) {
log.debug('Skipping custom WindowCovering behavior (using base server with features instead)');
continue;
}
if (clusterName === 'rvcCleanMode' || clusterName === 'serviceArea' || clusterName === 'powerSource') {
continue;
}
let behaviorClass = CORE_CLUSTER_BEHAVIOR_MAP[clusterName];
if (clusterName === 'colorControl' && behaviorClass && features.colorControlFeatures && features.colorControlFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.colorControlFeatures);
log.info(`ColorControl custom behavior will preserve features: ${features.colorControlFeatures.join(', ')}`);
}
if (clusterName === 'thermostat' && behaviorClass && features.thermostatFeatures && features.thermostatFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.thermostatFeatures);
log.info(`Thermostat custom behavior will preserve features: ${features.thermostatFeatures.join(', ')}`);
}
// LevelControl: unlike the branches above, we apply `.with(...)` even when
// the feature array is empty. That's deliberate — an empty feature set is
// what strips the Lighting/OnOff features HomebridgeLevelControlServer
// inherits from matter.js's LevelControlBase. See detectClusterFeatures()
// above for the full explanation.
if (clusterName === 'levelControl' && behaviorClass && features.levelControlFeatures !== null) {
behaviorClass = behaviorClass.with(...features.levelControlFeatures);
if (features.levelControlFeatures.length > 0) {
log.info(`LevelControl custom behavior will preserve features: ${features.levelControlFeatures.join(', ')}`);
}
else {
log.debug('LevelControl custom behavior applied with empty feature set (strips inherited Lighting)');
}
}
if (clusterName === 'closureControl' && behaviorClass && features.closureControlFeatures && features.closureControlFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.closureControlFeatures);
log.info(`ClosureControl custom behavior will preserve features: ${features.closureControlFeatures.join(', ')}`);
}
if (clusterName === 'mediaPlayback' && behaviorClass && features.mediaPlaybackFeatures && features.mediaPlaybackFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.mediaPlaybackFeatures);
log.info(`MediaPlayback custom behavior will preserve features: ${features.mediaPlaybackFeatures.join(', ')}`);
}
if (clusterName === 'serviceArea' && behaviorClass && features.serviceAreaFeatures && features.serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.serviceAreaFeatures);
log.info(`ServiceArea custom behavior will preserve features: ${features.serviceAreaFeatures.join(', ')}`);
}
if (clusterName === 'windowCovering') {
log.debug(`WindowCovering handler found: behaviorClass=${!!behaviorClass}, windowCoveringFeatures=${features.windowCoveringFeatures}, length=${features.windowCoveringFeatures?.length}`);
if (behaviorClass && features.windowCoveringFeatures && features.windowCoveringFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.windowCoveringFeatures);
log.debug(`WindowCovering custom behavior will have features: ${features.windowCoveringFeatures.join(', ')}`);
}
else {
log.debug(`Skipping WindowCovering feature application: behaviorClass=${!!behaviorClass}, features=${features.windowCoveringFeatures}`);
}
}
if (behaviorClass) {
customBehaviors.push(behaviorClass);
log.info(`Will use ${behaviorClass.name} for ${accessory.displayName}`);
}
else if (Object.keys(accessory.handlers?.[clusterName] ?? {}).length === 0) {
// No handlers, so there is nothing for a custom behavior to route and nothing
// to warn about. This is the normal shape for a read-only cluster the plugin
// only pushes state into, and it is every cluster on a cache restore: the
// restore synthesizes an empty stub per cached cluster, so warning here put a
// line in the log for clusters the plugin never intended to handle.
log.debug(`No handlers supplied for cluster '${clusterName}', no custom behavior needed`);
}
else {
log.warn(`No custom behavior class available for cluster '${clusterName}' - handlers will be registered but may not be called`);
}
}
return customBehaviors;
}
/**
* Create endpoint options for an accessory
*/
createEndpointOptions(accessory, config) {
const endpointOptions = {
id: accessory.UUID,
...accessory.clusters,
};
if (!config.externalAccessory) {
endpointOptions.bridgedDeviceBasicInformation = {
vendorName: accessory.manufacturer,
nodeLabel: accessory.displayName,
productName: accessory.model,
// productLabel SHALL NOT include the vendor name per the Matter spec.
// Fall back to model or "Device" when stripping consumes the whole name.
productLabel: stripVendorFromLabel(accessory.displayName, accessory.manufacturer)
|| accessory.model || 'Device',
serialNumber: accessory.serialNumber,
reachable: true,
};
// Surface the device's firmware version to controllers (shown in the
// accessory details of e.g. Apple Home). The numeric companion is
// derived from a leading semver triplet when the string has one and
// the parts fit the 16/8/8-bit encoding - otherwise only the string
// is set (both attributes are independently optional per spec, and a
// failed uint32 validation would block the accessory registering).
// The spec caps SoftwareVersionString at 64 characters.
if (typeof accessory.firmwareRevision === 'string' && accessory.firmwareRevision.length > 0) {
endpointOptions.bridgedDeviceBasicInformation.softwareVersionString = accessory.firmwareRevision.slice(0, 64);
const semver = accessory.firmwareRevision.match(/^(\d+)\.(\d+)\.(\d+)/);
if (semver) {
const [major, minor, patch] = [Number(semver[1]), Number(semver[2]), Number(semver[3])];
if (major <= 0xFFFF && minor <= 0xFF && patch <= 0xFF) {
// >>> 0 keeps the value unsigned - a signed << would go negative
// for majors >= 0x8000 and fail matter.js's uint32 validation.
endpointOptions.bridgedDeviceBasicInformation.softwareVersion = ((major << 16) | (minor << 8) | patch) >>> 0;
}
}
}
}
return endpointOptions;
}
/**
* Register command handlers for an accessory
*/
registerAccessoryHandlers(accessory, deps) {
if (!accessory.handlers) {
return;
}
log.info(`Setting up handlers for accessory ${accessory.UUID}`);
deps.registryManager.registerEndpoint(accessory.UUID, deps.behaviorRegistry);
for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
log.info(` Processing cluster: ${clusterName}`);
for (const [commandName, handler] of Object.entries(handlers)) {
deps.behaviorRegistry.registerHandler(accessory.UUID, clusterName, commandName, handler);
}
}
}
/**
* Create and register child endpoints (parts) for an accessory
*
* Parts are added as sub-endpoints of the parent endpoint, creating a composed
* device per the Matter spec. Children are plain device types with no
* BridgedDeviceBasicInformation — only the parent has that.
* See: https://github.com/matter-js/matter.js/blob/main/docs/MIGRATION_GUIDE_08.md
*/
async createAccessoryParts(accessory, parentEndpoint, deps) {
const internalParts = [];
if (!accessory.parts || accessory.parts.length === 0) {
return internalParts;
}
log.info(`Creating ${accessory.parts.length} child endpoint(s) for ${accessory.displayName}`);
for (const [partIndex, part] of accessory.parts.entries()) {
const partEndpointId = `${accessory.UUID}-part-${part.id}`;
deps.behaviorRegistry.registerPartEndpoint(partEndpointId, accessory.UUID, part.id);
// Parts go through the same preparation as their parent — feature-gated
// clusters, the battery PowerSource, conformance fix-ups and the
// behaviors that route commands to handlers. See prepareDeviceType().
const partPrepared = await this.prepareDeviceType(this.partAsAccessory(part, partEndpointId));
const partHasElectrical = partPrepared.hasElectrical;
let partDeviceType = partPrepared.deviceType;
// Tag each child with a semantic Number tag so controllers can stably
// re-map otherwise-identical children of a composed device; without it
// they are distinguishable only by transient endpoint number, which
// Apple Home mishandles across hub changes.
partDeviceType = partDeviceType.with(DescriptorServer.with('TagList'));
const partEndpointOptions = {
id: partEndpointId,
descriptor: {
tagList: [{
mfgCode: null,
namespaceId: 7, // Number namespace
tag: partIndex,
label: (part.displayName || part.id).slice(0, 64),
}],
},
...part.clusters,
};
const partEndpoint = new Endpoint(partDeviceType, partEndpointOptions);
setRegistryManager(partEndpoint, deps.registryManager);
await parentEndpoint.add(partEndpoint);
if (partHasElectrical) {
await this.advertiseUtilityDeviceType(partEndpoint, 'ElectricalSensor', part.displayName || part.id);
}
// Same rule as the parent: composing the battery cluster is not enough,
// the endpoint has to advertise the PowerSource device type or no
// controller has a reason to read it.
if (part.clusters?.powerSource) {
await this.advertiseUtilityDeviceType(partEndpoint, 'PowerSource', part.displayName || part.id);
}
log.info(` Created part endpoint: ${part.displayName || part.id} (${partEndpointId}) as child of ${accessory.displayName}`);
if (part.handlers) {
deps.registryManager.registerEndpoint(partEndpointId, deps.behaviorRegistry);
for (const [clusterName, handlers] of Object.entries(part.handlers)) {
for (const [commandName, handler] of Object.entries(handlers)) {
deps.behaviorRegistry.registerHandler(partEndpointId, clusterName, commandName, handler);
}
}
log.debug(` Registered ${Object.keys(part.handlers).length} handler(s) for part ${part.id}`);
}
internalParts.push({
...part,
endpoint: partEndpoint,
});
}
return internalParts;
}
/**
* Advertise the ElectricalSensor utility device type (0x0510) in the
* endpoint's descriptor. Controllers discover power/energy metering through
* the device type list, not just the clusters, so an outlet that carries
* the measurement clusters must also list ElectricalSensor. matter.js
* dedupes the entry, so this is safe when the base device type already is
* an ElectricalSensor.
*/
async advertiseUtilityDeviceType(endpoint, deviceTypeName, displayName) {
try {
await endpoint.act(agent => agent.get(DescriptorServer).addDeviceTypes(deviceTypeName));
log.debug(`Advertised ${deviceTypeName} device type for ${displayName}`);
}
catch (error) {
log.warn(`Could not advertise ${deviceTypeName} device type for ${displayName}:`, error);
}
}
/**
* Finalize accessory registration (store, emit events, save cache)
*/
async finalizeAccessoryRegistration(accessory, endpoint, internalParts, deps) {
const internalAccessory = {
...accessory,
endpoint,
registered: true,
_parts: internalParts.length > 0 ? internalParts : undefined,
_eventEmitter: new EventEmitter(),
};
deps.accessories.set(accessory.UUID, internalAccessory);
log.info(`Registered Matter accessory: ${accessory.displayName} (${accessory.UUID})`);
if (deps.config.debugModeEnabled) {
log.debug(`Total registered accessories: ${deps.accessories.size}/1000`);
}
await this.notifyPartsListChanged(deps);
if (deps.accessoryCache) {
deps.accessoryCache.requestSave(deps.accessories);
}
if (deps.getMonitoringEnabled() && process.send) {
const event = {
type: 'accessoryAdded',
data: { uuid: accessory.UUID },
};
process.send({
id: "matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */,
data: event,
});
}
}
/**
* Notify controllers that the parts list has changed
*/
async notifyPartsListChanged(deps) {
const aggregator = deps.getAggregator();
if (!aggregator || !deps.isCommissioned()) {
return;
}
for (let attempt = 1; attempt <= PARTS_LIST_NOTIFY_ATTEMPTS; attempt++) {
try {
await this.applyPartsListNotification(aggregator, deps);
return;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// matter.js reports synchronous lock contention with "Cannot lock ...
// synchronously" / "acquire locks asynchronously". The blocking
// transaction releases almost immediately, so yield and retry rather
// than dropping the notification.
const isLockContention = errorMessage.includes('Cannot lock') || errorMessage.includes('acquire locks');
if (isLockContention && attempt < PARTS_LIST_NOTIFY_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, PARTS_LIST_NOTIFY_RETRY_DELAY_MS));
continue;
}
log.warn(`Failed to notify controllers of parts list change: ${errorMessage}`);
return;
}
}
}
/**
* A single attempt at pushing the parts-list change to controllers: update the
* aggregator's parts list and bump the bridge ConfigurationVersion. Both bump
* once per successful call, so retrying the whole thing after a mid-way lock
* failure does not double-count (the parts-list set is idempotent and only a
* successful `increaseConfigurationVersion()` mutates the version).
*/
async applyPartsListNotification(aggregator, deps) {
const aggregatorState = aggregator;
if (aggregatorState.state?.descriptor) {
const partsList = aggregatorState.state.descriptor.partsList || [];
if (deps.config.debugModeEnabled) {
log.debug(`Parts list changed: ${partsList.length} devices (endpoints: ${partsList.join(', ')})`);
}
await aggregator.set({
descriptor: {
partsList,
},
});
}
// Matter 1.6 signals bridge structure changes to controllers via
// BasicInformation's ConfigurationVersion. matter.js seeds the attribute
// but does not bump it when endpoints are added or removed — that is the
// bridge's job.
const serverNode = deps.getServerNode();
if (serverNode) {
await serverNode.act(agent => agent.get(BasicInformationServer).increaseConfigurationVersion());
log.debug('Increased bridge configuration version');
}
log.info(`Notified controllers of parts list change (${deps.accessories.size} devices)`);
}
}
//# sourceMappingURL=AccessoryManager.js.map