@homebridge-plugins/homebridge-rainbird
Version:
The Rainbird plugin allows you to access your Rainbird device(s) from HomeKit.
525 lines • 26.1 kB
JavaScript
import { LogLevel } from 'rainbird';
import { fromEvent } from 'rxjs';
import { RainbirdPlatform } from './Platform.HAP.js';
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
/**
* RainbirdMatterPlatform
* Extends RainbirdPlatform to add Homebridge Matter support.
* When Matter is available and enabled, devices are registered as Matter accessories
* instead of HAP accessories.
*/
export class RainbirdMatterPlatform extends RainbirdPlatform {
/** Map of Matter cached accessories restored from disk */
matterAccessories = new Map();
matterSubscriptions = new Map();
get matterApi() {
return this.api.matter;
}
/**
* Called when homebridge restores cached HAP accessories from disk.
* Delegate to the HAP platform implementation so fallback to HAP mode
* continues to use the restored cache without creating duplicates.
*/
configureAccessory(accessory) {
super.configureAccessory(accessory);
}
/**
* Called when homebridge restores cached Matter accessories from disk at startup.
*/
configureMatterAccessory(accessory) {
this.debugLog(`Loading cached Matter accessory: ${accessory.displayName}`);
this.matterAccessories.set(accessory.UUID, accessory);
}
/**
* Discover and register all RainBird devices as Matter accessories.
* Overrides the HAP implementation to use the Matter API.
*/
async discoverDevices() {
if (!this.matterApi || typeof this.matterApi.registerPlatformAccessories !== 'function') {
this.warnLog('Homebridge Matter API is not available. Falling back to HAP device registration.');
return super.discoverDevices();
}
for (const device of this.config.devices) {
try {
const { RainBirdService: RainBirdServiceCtor } = await import('rainbird');
const rainbird = new RainBirdServiceCtor({
address: device.ipaddress,
password: device.password,
refreshRate: this.config.options.refreshRate,
showRequestResponse: device.showRequestResponse,
syncTime: device.syncTime,
});
// Listen for log events
rainbird.on('log', (log) => {
switch (log.level) {
case LogLevel.ERROR:
this.errorLog(`From Rainbird Library: ${log.message}`);
break;
case LogLevel.WARN:
this.warnLog(`From Rainbird Library: ${log.message}`);
break;
case LogLevel.DEBUG:
this.debugLog(`From Rainbird Library: ${log.message}`);
break;
case LogLevel.INFO:
default:
this.infoLog(`From Rainbird Library: ${log.message}`);
}
});
const metaData = await rainbird.init();
this.debugLog(JSON.stringify(metaData));
const capabilities = await this.detectControllerCapabilities(rainbird);
// Display device details
this.infoLog(`Matter Mode - Model: ${metaData.model}, [Version: ${metaData.version}, Serial Number: ${metaData.serialNumber}, Zones: ${JSON.stringify(metaData.zones)}]`);
await this.registerMatterIrrigationSystem(device, rainbird);
await this.registerMatterLeakSensor(device, rainbird);
for (const zoneId of metaData.zones) {
await this.registerMatterZoneValve(device, rainbird, zoneId);
await this.registerMatterContactSensor(device, rainbird, zoneId);
await this.registerMatterTestZoneSwitch(device, rainbird, zoneId, capabilities.supportsTestZone);
}
for (const programId of ['A', 'B', 'C', 'D']) {
await this.registerMatterProgramSwitch(device, rainbird, programId);
}
await this.registerMatterStopIrrigationSwitch(device, rainbird);
await this.registerMatterDelayIrrigationSwitch(device, rainbird);
// Handle zone enable/disable
rainbird.on('zone_enable', async (zoneId, enabled) => {
if (enabled) {
await this.registerMatterContactSensor(device, rainbird, zoneId);
await this.registerMatterTestZoneSwitch(device, rainbird, zoneId, capabilities.supportsTestZone);
}
else {
this.unregisterMatterAccessory(`${device.ipaddress}-${rainbird.model}-${zoneId}-contact-${rainbird.serialNumber}`);
this.unregisterMatterAccessory(`${device.ipaddress}-${rainbird.model}-test-${zoneId}-${rainbird.serialNumber}`);
}
});
}
catch (e) {
this.errorLog(`Failed to connect to RainBird controller at ${device.ipaddress}: ${e.message}`);
this.errorLog(`Skipping device at ${device.ipaddress} and continuing with other devices...`);
continue;
}
}
}
/**
* Register or update a Matter accessory. Always rebuilds the accessory definition
* to apply config changes and re-attach handlers, merging into the cached instance if present.
* When device.external is true the accessory is published as an external Matter accessory.
*/
async registerOrUpdateMatterAccessory(device, uuidKey, buildAccessory) {
const uuid = this.matterApi.uuid.generate(uuidKey);
try {
const freshDef = buildAccessory();
const existing = this.matterAccessories.get(uuid);
if (existing) {
// Always apply fresh config/handlers to cached instance so config changes and
// handler re-attachment are not skipped on subsequent starts.
existing.displayName = freshDef.displayName;
existing.clusters = freshDef.clusters;
existing.handlers = freshDef.handlers;
existing.firmwareRevision = freshDef.firmwareRevision;
if (typeof this.matterApi.updatePlatformAccessories === 'function') {
await this.matterApi.updatePlatformAccessories([existing]);
}
this.debugLog(`Updated cached Matter accessory: ${existing.displayName}`);
}
else {
this.matterAccessories.set(uuid, freshDef);
await this.externalOrPlatformMatter(device, freshDef);
this.infoLog(`Registered Matter accessory: ${freshDef.displayName}`);
}
}
catch (e) {
this.errorLog(`Failed to register Matter accessory (key: ${uuidKey}): ${e.message}`);
}
}
/**
* Publish a Matter accessory as either an external accessory or a platform accessory,
* mirroring the HAP externalOrPlatform behaviour.
*/
async externalOrPlatformMatter(device, accessory) {
if (device.external) {
this.debugWarnLog(`${accessory.displayName} External Matter Accessory Mode`);
if (typeof this.matterApi.publishExternalAccessories === 'function') {
await this.matterApi.publishExternalAccessories(PLUGIN_NAME, [accessory]);
}
else {
this.warnLog(`${accessory.displayName} Matter API does not support publishExternalAccessories; registering as platform accessory`);
await this.matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
}
}
else {
this.debugLog(`${accessory.displayName} Platform Matter Accessory Mode`);
await this.matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
}
}
/**
* Ensure each Matter accessory has at most one event subscription attached.
*/
ensureMatterSubscription(uuidKey, createSubscription) {
const uuid = this.matterApi.uuid.generate(uuidKey);
if (this.matterSubscriptions.has(uuid)) {
return;
}
this.matterSubscriptions.set(uuid, createSubscription());
}
/**
* Unregister a Matter accessory by UUID key.
*/
unregisterMatterAccessory(uuidKey) {
const uuid = this.matterApi.uuid.generate(uuidKey);
const accessory = this.matterAccessories.get(uuid);
const subscription = this.matterSubscriptions.get(uuid);
subscription?.unsubscribe();
this.matterSubscriptions.delete(uuid);
if (accessory) {
try {
this.matterApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.matterAccessories.delete(uuid);
this.warnLog(`Removed Matter accessory: ${accessory.displayName}`);
}
catch (e) {
this.errorLog(`Failed to unregister Matter accessory: ${accessory.displayName}: ${e.message}`);
}
}
}
/**
* Update a Matter accessory cluster state safely.
*/
async updateMatterState(uuidKey, cluster, attributes) {
try {
const uuid = this.matterApi.uuid.generate(uuidKey);
await this.matterApi.updateAccessoryState(uuid, cluster, attributes);
}
catch (e) {
this.debugLog(`Matter state update failed (${cluster}): ${e.message}`);
}
}
// ─── Irrigation System ───────────────────────────────────────────────────────
async registerMatterIrrigationSystem(device, rainbird) {
if (device.hide_device) {
return;
}
const uuidKey = `${device.ipaddress}-${rainbird.model}-${rainbird.serialNumber}`;
const displayName = device.configDeviceName
? await this.validateAndCleanDisplayName(device.configDeviceName, 'configDeviceName', device.configDeviceName)
: await this.validateAndCleanDisplayName(rainbird.model, 'model', rainbird.model);
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.WaterValve,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model: rainbird.model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
valveConfigurationAndControl: {
currentState: rainbird.isInUse() ? 1 : 0,
targetState: rainbird.isInUse() ? 1 : 0,
},
},
handlers: {
valveConfigurationAndControl: {
open: async (request) => {
const zones = rainbird.zones;
if (zones.length > 0) {
rainbird.activateZone(zones[0], request?.openDuration ?? 300);
}
},
close: async () => {
await rainbird.stopIrrigation();
},
},
},
context: { deviceId: uuidKey },
}));
this.ensureMatterSubscription(uuidKey, () => fromEvent(rainbird, 'status').subscribe({
next: async () => {
await this.updateMatterState(uuidKey, 'valveConfigurationAndControl', {
currentState: rainbird.isInUse() ? 1 : 0,
targetState: rainbird.isInUse() ? 1 : 0,
});
},
}));
}
// ─── Leak Sensor ─────────────────────────────────────────────────────────────
async registerMatterLeakSensor(device, rainbird) {
if (device.hide_device || !device.showRainSensor) {
return;
}
const model = 'WR2';
const uuidKey = `${device.ipaddress}-${model}-${rainbird.serialNumber}`;
const rawName = device.configDeviceName ? `${device.configDeviceName} Leak Sensor` : 'Leak Sensor';
const displayName = await this.validateAndCleanDisplayName(rawName, 'configDeviceName Leak Sensor', rawName);
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.LeakSensor,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
booleanState: { stateValue: rainbird.rainSetPointReached },
},
context: { deviceId: uuidKey },
}));
this.ensureMatterSubscription(uuidKey, () => fromEvent(rainbird, 'rain_sensor_state').subscribe({
next: async () => {
await this.updateMatterState(uuidKey, 'booleanState', { stateValue: rainbird.rainSetPointReached });
},
}));
}
// ─── Zone Valve ──────────────────────────────────────────────────────────────
async registerMatterZoneValve(device, rainbird, zoneId) {
const includeZones = device.includeZones.split(',').map(Number);
const shouldRegister = !device.hide_device
&& device.showZoneValve
&& (includeZones.includes(0) || includeZones.includes(zoneId));
if (!shouldRegister) {
return;
}
const model = `${rainbird.model}-valve-${zoneId}`;
const uuidKey = `${device.ipaddress}-${model}-${rainbird.serialNumber}`;
const name = `Zone ${zoneId}`;
const rawName = device.configDeviceName ? `${device.configDeviceName} ${name}` : name;
const displayName = await this.validateAndCleanDisplayName(rawName, `configDeviceName ${name}`, rawName);
const durationSeconds = 300;
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.WaterValve,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
valveConfigurationAndControl: {
currentState: rainbird.isActive(zoneId) ? 1 : 0,
targetState: rainbird.isActive(zoneId) ? 1 : 0,
},
},
handlers: {
valveConfigurationAndControl: {
open: async (request) => {
rainbird.activateZone(zoneId, request?.openDuration ?? durationSeconds);
},
close: async () => {
await rainbird.deactivateZone(zoneId);
},
},
},
context: { deviceId: uuidKey, zoneId },
}));
this.ensureMatterSubscription(uuidKey, () => fromEvent(rainbird, 'status').subscribe({
next: async () => {
await this.updateMatterState(uuidKey, 'valveConfigurationAndControl', {
currentState: rainbird.isActive(zoneId) ? 1 : 0,
targetState: rainbird.isActive(zoneId) ? 1 : 0,
});
},
}));
}
// ─── Contact Sensor ──────────────────────────────────────────────────────────
async registerMatterContactSensor(device, rainbird, zoneId) {
if (device.hide_device || !device.showValveSensor) {
return;
}
const model = `${rainbird.model}-${zoneId}`;
const uuidKey = `${device.ipaddress}-${model}-contact-${rainbird.serialNumber}`;
const name = `Zone ${zoneId}`;
const rawName = device.configDeviceName ? `${device.configDeviceName} ${name}` : name;
const displayName = await this.validateAndCleanDisplayName(rawName, `configDeviceName ${name}`, rawName);
// Matter BooleanState: true = contact detected (zone NOT in use), false = contact not detected (zone in use)
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.ContactSensor,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
booleanState: { stateValue: !rainbird.isInUse(zoneId) },
},
context: { deviceId: uuidKey, zoneId },
}));
this.ensureMatterSubscription(uuidKey, () => fromEvent(rainbird, 'status').subscribe({
next: async () => {
await this.updateMatterState(uuidKey, 'booleanState', { stateValue: !rainbird.isInUse(zoneId) });
},
}));
}
// ─── Program Switch ──────────────────────────────────────────────────────────
async registerMatterProgramSwitch(device, rainbird, programId) {
const showProgramSwitch = device[`showProgram${programId}Switch`];
if (device.hide_device || !showProgramSwitch) {
return;
}
const model = `${rainbird.model}-pgm-${programId}`;
const uuidKey = `${device.ipaddress}-${model}-${rainbird.serialNumber}`;
const name = `Program ${programId}`;
const rawName = device.configDeviceName ? `${device.configDeviceName} ${name}` : name;
const displayName = await this.validateAndCleanDisplayName(rawName, `configDeviceName ${name}`, rawName);
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.OnOffSwitch,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
onOff: { onOff: rainbird.isProgramRunning(programId) ?? false },
},
handlers: {
onOff: {
on: async () => {
await rainbird.startProgram(programId);
},
off: async () => {
await rainbird.stopIrrigation();
},
},
},
context: { deviceId: uuidKey, programId },
}));
this.ensureMatterSubscription(uuidKey, () => fromEvent(rainbird, 'status').subscribe({
next: async () => {
const isRunning = rainbird.isProgramRunning(programId);
if (isRunning !== undefined) {
await this.updateMatterState(uuidKey, 'onOff', { onOff: isRunning });
}
},
}));
}
// ─── Stop Irrigation Switch ──────────────────────────────────────────────────
async registerMatterStopIrrigationSwitch(device, rainbird) {
if (device.hide_device || !device.showStopIrrigationSwitch) {
return;
}
const model = `${rainbird.model}-stop`;
const uuidKey = `${device.ipaddress}-${model}-${rainbird.serialNumber}`;
const rawName = device.configDeviceName ? `${device.configDeviceName} Stop Irrigation` : 'Stop Irrigation';
const displayName = await this.validateAndCleanDisplayName(rawName, 'configDeviceName Stop Irrigation', rawName);
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.OnOffSwitch,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
onOff: { onOff: false },
},
handlers: {
onOff: {
on: async () => {
rainbird.deactivateAllZones();
await rainbird.stopIrrigation();
},
off: async () => {
// No action needed for off
},
},
},
context: { deviceId: uuidKey },
}));
}
// ─── Delay Irrigation Switch ─────────────────────────────────────────────────
async registerMatterDelayIrrigationSwitch(device, rainbird) {
if (device.hide_device || !device.showDelayIrrigationSwitch) {
return;
}
const model = `${rainbird.model}-delay`;
const uuidKey = `${device.ipaddress}-${model}-${rainbird.serialNumber}`;
const rawName = device.configDeviceName ? `${device.configDeviceName} Delay Irrigation` : 'Delay Irrigation';
const displayName = await this.validateAndCleanDisplayName(rawName, 'configDeviceName Delay Irrigation', rawName);
const irrigationDelay = device.irrigationDelay ?? 1;
const initialDelay = await rainbird.getIrrigationDelay().catch((e) => {
this.debugLog(`Failed to get irrigation delay for ${device.ipaddress}: ${e.message}`);
return 0;
});
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.OnOffSwitch,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
onOff: { onOff: initialDelay > 0 },
},
handlers: {
onOff: {
on: async () => {
await rainbird.setIrrigationDelay(irrigationDelay);
},
off: async () => {
await rainbird.setIrrigationDelay(0);
},
},
},
context: { deviceId: uuidKey },
}));
}
// ─── Test Zone Switch ────────────────────────────────────────────────────────
async registerMatterTestZoneSwitch(device, rainbird, zoneId, supportsTestZone) {
if (device.hide_device || !device.showTestZoneSwitch) {
return;
}
if (!supportsTestZone) {
this.warnLog(`Skipping Test Zone switch for zone ${zoneId} on ${rainbird.model}: controller does not support the testZone command`);
return;
}
const model = `${rainbird.model}-test-${zoneId}`;
const uuidKey = `${device.ipaddress}-${model}-${rainbird.serialNumber}`;
const name = `Zone ${zoneId} Test`;
const rawName = device.configDeviceName ? `${device.configDeviceName} ${name}` : name;
const displayName = await this.validateAndCleanDisplayName(rawName, `configDeviceName ${name}`, rawName);
await this.registerOrUpdateMatterAccessory(device, uuidKey, () => ({
UUID: this.matterApi.uuid.generate(uuidKey),
displayName,
deviceType: this.matterApi.deviceTypes.OnOffSwitch,
serialNumber: rainbird.serialNumber,
manufacturer: 'RainBird',
model,
firmwareRevision: rainbird.version ?? this.version,
hardwareRevision: '1.0.0',
clusters: {
onOff: { onOff: false },
},
handlers: {
onOff: {
on: async () => {
try {
await rainbird.testZone(zoneId);
}
catch (e) {
this.errorLog(`testZone(${zoneId}) failed: ${e.message}`);
}
// Auto-turn off after test completes
setTimeout(async () => {
await this.updateMatterState(uuidKey, 'onOff', { onOff: false });
}, 500);
},
off: async () => {
// No action needed for off
},
},
},
context: { deviceId: uuidKey, zoneId },
}));
}
}
//# sourceMappingURL=Platform.Matter.js.map