Skip to content

Commit

Permalink
Make devices appear and disappear, as if by magic
Browse files Browse the repository at this point in the history
This change adds support for re-scanning devices 30s after a DeviceHeard
message is received, and automatically removes Pico remotes from Homekit
after a plugin restart, if they've been removed from the hub.

Fixes #11
  • Loading branch information
thenewwazoo committed Jan 27, 2022
1 parent 3eb5077 commit 958be19
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 112 deletions.
66 changes: 37 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,6 @@ Because HomeKit control for dimmers and switches, etc, are natively supported by

This plugin makes use of the [lutron-leap-js](https://github.com/thenewwazoo/lutron-leap-js) library, which implements the Lutron LEAP protocol, used by the Lutron mobile apps and third-party integrations. It has been tested with the non-Pro and Pro bridges, and may also be able to work with RA2 (but has not been tested).

## Support

If you need find a bug, need help with this plugin, or have questions, the best way to reach me is via a Github Issue. Please don't be shy about opening one. You can also reach me via the email address in my Github profile.

This plugin doesn't often change, but when I add big features or make big changes, I will occasionally join the [`#lutron-caseta-leap`](https://discord.com/channels/432663330281226270/927991341923852389) channel on the [Homebridge Discord server](https://discord.gg/RcV7fa8).

## User Information

### Pico Remote Button Mapping

Pico buttons are mapped according to the following diagram:

![iOS Home App and Eve app screenshots showing arrows pointing from the button entries in the apps to the physical buttons on a picture of a pico remote](assets/buttons_map.png)

The iOS Home app doesn't actually show button names (e.g. "On"), but only shows "Button 1". Other, better, iOS Homekit apps do, though. In any case, buttons are mapped from top-to-bottom. The top-most physical button is button 1, and is shown at the top of the list in the app.

I only actually own the "3-button with raise/lower" remotes in my house, so I've had to make guesses about the other supported remotes, and defer implementing some others. Currently supported are:

* [2 Button](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-2B&Section=Documents)
* [2-Button with raise/lower](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-2BRL&Section=Documents) (*untested*)
* [3-Button](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-3B&Section=Documents) (*untested*)
* [3-Button with raise/lower](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-3BRL&Section=Documents)

I'd love to have complete, tested support of all remote types. If you have hardware that is partially- or un-supported and, adding support is fast and easy. I would also be happy to add support for hardware that is provided to me.

### Homekit and Lutron App collision

Right now, all known Pico remotes are shown in the Home app. This means their functionality is duplicated, in a sense. Configuration in Homekit has no effect on operation with paired accessories, or anything else in the Lutron app. Let me know if you'd like to hide remotes that are paired, as it's possible but not currently enabled.

## Preparation

### Get your bridge ID
Expand Down Expand Up @@ -116,6 +87,43 @@ The shape of the configuration is:

The authn strings are newline-escaped versions of the files you generated.

## User Information

### Adding and Removing Devices

In order to add a device to the hub, you must use the Lutron app to pair the device. This plugin will re-scan the known devices 30 seconds after you announce the device. This means you must **complete adding the device in less than 30 seconds** in order for the device to appear in HomeKit without restarting the plugin. If you _do_ miss that deadline, don't worry: the device will appear after you restart Homebridge.

If you remove a **Pico Remote** device from the hub, it will disappear from Homekit after the next time you restart Homebridge.

If you remove **any other device type** (by which, I suppose, I mean a Serena blind), you must [delete the cached accessory out of Homebridge manually](https://github.com/oznu/homebridge-config-ui-x/issues/525).

### Pico Remote Button Mapping

Pico buttons are mapped according to the following diagram:

![iOS Home App and Eve app screenshots showing arrows pointing from the button entries in the apps to the physical buttons on a picture of a pico remote](assets/buttons_map.png)

The iOS Home app doesn't actually show button names (e.g. "On"), but only shows "Button 1". Other, better, iOS Homekit apps do, though. In any case, buttons are mapped from top-to-bottom. The top-most physical button is button 1, and is shown at the top of the list in the app.

I don't own one of every remote type, so I've had to make guesses about the other supported remotes, and defer implementing some others. Currently supported are:

* [2 Button](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-2B&Section=Documents)
* [2-Button with raise/lower](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-2BRL&Section=Documents)
* [3-Button](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-3B&Section=Documents) (*untested*)
* [3-Button with raise/lower](https://www.lutron.com/en-US/pages/SupportCenter/support.aspx?modelNumber=PJ2-3BRL&Section=Documents)

I'd love to have complete, tested support of all remote types. If you have hardware that is partially- or un-supported and, adding support is fast and easy. I would also be happy to add support for hardware that is provided to me.

### Homekit and Lutron App collision

Right now, all known Pico remotes are shown in the Home app. This means their functionality is duplicated, in a sense. Configuration in Homekit has no effect on operation with paired accessories, or anything else in the Lutron app. Let me know if you'd like to hide remotes that are paired, as it's possible but not currently enabled.

## Support

If you need find a bug, need help with this plugin, or have questions, the best way to reach me is via a Github Issue. Please don't be shy about opening one. You can also reach me via the email address in my Github profile.

This plugin doesn't often change, but when I add big features or make big changes, I will occasionally join the [`#lutron-caseta-leap`](https://discord.com/channels/432663330281226270/927991341923852389) channel on the [Homebridge Discord server](https://discord.gg/RcV7fa8).

## Enabling debugging

In order to enable debugging, set the DEBUG environment variable in the Homebridge UI to `leap:*`. This will make this plugin, and its main library `lutron-leap-js`, noisier. Logging at this level is required for diagnosis and new hardware support.
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"displayName": "Lutron Caseta LEAP",
"name": "homebridge-lutron-caseta-leap",
"version": "2.1.1",
"version": "2.2.0",
"description": "Support for the Lutron Caseta Smart Bridge 2 (non-pro)",
"license": "Apache-2.0",
"repository": {
Expand Down Expand Up @@ -31,8 +31,8 @@
"lutron-smart-bridge"
],
"dependencies": {
"lutron-leap": "^1.2.0",
"typed-emitter": "^1.3.1"
"lutron-leap": "^1.3.2",
"typed-emitter": "^1.3.2"
},
"devDependencies": {
"@types/node": "^14.14.6",
Expand All @@ -41,9 +41,9 @@
"eslint": "^7.13.0",
"homebridge": "^1.2.3",
"nodemon": "^2.0.6",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"ts-node": "^9.0.0",
"prettier": "^2.2.1",
"typescript": "^4.0.5"
}
}
10 changes: 10 additions & 0 deletions src/BridgeManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { SmartBridge } from 'lutron-leap';

export class BridgeManager {
/* When restoring accessories from the cache, the mDNS-based bridge
* autodetection isn't yet running. This means we know the ID of a bridge
* that we _expect_ to discover. In order to defer the operations that
* require a connection to that bridge (such as subscribing to button
* events), getBridge returns a Promise for the bridge. We store its
* resolve and reject functions in the `pendingBridges` map. When it arrives,
* we resolve the promise and store the connected bridge in the `bridges`
* map. Because a bridge can be requested multiple times, we store an array
* of resolve/reject pairs, and resolve them all.
*/
private bridges: Map<string, SmartBridge> = new Map();
private pendingBridges: Map<string, Array<[(bridge: SmartBridge) => void, ReturnType<typeof setTimeout>]>> =
new Map(); // whew, that's a gnarly spec.
Expand Down
14 changes: 12 additions & 2 deletions src/PicoRemote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Service, PlatformAccessory } from 'homebridge';

import { LutronCasetaLeap } from './platform';
import { OneButtonStatusEvent, Response, SmartBridge } from 'lutron-leap';
import { PLUGIN_NAME, PLATFORM_NAME } from './settings';
import { ExceptionDetail, OneButtonStatusEvent, Response, SmartBridge } from 'lutron-leap';

import { inspect } from 'util';

Expand Down Expand Up @@ -103,6 +104,10 @@ export class PicoRemote {

const bg = bgs[0];

if (bg instanceof ExceptionDetail) {
throw new Error('device has been removed');
}

// TODO make this behavior optional. a user may want to
// hide remotes that are already associated with
// devices
Expand Down Expand Up @@ -165,7 +170,12 @@ export class PicoRemote {
this.platform.log.debug(`subscribing to ${button.href} events`);
bridge.client.subscribe(button.href + '/status/event', this.handleEvent.bind(this), 'SubscribeRequest');
}
})().then(() => this.platform.log.info('Finished setting up Pico remote', fullName));
})()
.then(() => this.platform.log.info('Finished setting up Pico remote', fullName))
.catch((e) => {
this.platform.log.error('Failed setting up Pico remote:', e);
platform.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
});

this.platform.on('unsolicited', this.handleUnsolicited.bind(this));
}
Expand Down
146 changes: 79 additions & 67 deletions src/platform.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from 'events';
import { BridgeFinder, Device, Response, SmartBridge, SecretStorage } from 'lutron-leap';
import { BridgeFinder, Device, OneDeviceStatus, Response, SmartBridge, SecretStorage } from 'lutron-leap';

import { API, APIEvent, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig } from 'homebridge';

Expand Down Expand Up @@ -103,11 +103,7 @@ export class LutronCasetaLeap
'on bridge',
accessory.context.bridgeID,
);
try {
new PicoRemote(this, accessory, bridge);
} catch (e) {
this.log.error('Failed to set up cached Pico remote as expected:', e);
}
new PicoRemote(this, accessory, bridge);
break;
}
default:
Expand All @@ -127,80 +123,96 @@ export class LutronCasetaLeap
return;
}
this.bridgeMgr.addBridge(bridge);
this.processAllDevices(bridge);
}

bridge.getDeviceInfo().then(async (devices: Device[]) => {
for (const d of devices) {
const uuid = this.api.hap.uuid.generate(d.SerialNumber.toString());
if (this.accessories.has(uuid)) {
this.log.info(
'Accessory',
d.DeviceType,
uuid,
d.FullyQualifiedName.join(' '),
'already set up. Skipping.',
);
continue;
private processAllDevices(bridge: SmartBridge) {
bridge
.getDeviceInfo()
.then(async (devices: Device[]) => {
for (const d of devices) {
try {
this.processDevice(bridge, d);
} catch (e) {
this.log.error('Failed to process device', d.FullyQualifiedName.join(' '));
}
}
})
.catch((e) => {
this.log.error(`Failed to process devices on new bridge ${bridge.bridgeID}: ${e}`);
});

const fullName = d.FullyQualifiedName.join(' ');
bridge.on('unsolicited', this.handleUnsolicitedMessage.bind(this));
}

const accessory = new this.api.platformAccessory(fullName, uuid);
accessory.context.device = d;
accessory.context.bridgeID = bridge.bridgeID;
processDevice(bridge: SmartBridge, d: Device) {
const fullName = d.FullyQualifiedName.join(' ');
const uuid = this.api.hap.uuid.generate(d.SerialNumber.toString());

switch (d.DeviceType) {
case 'SerenaTiltOnlyWoodBlind': {
this.log.info('Found a new Serena blind:', fullName);
if (this.accessories.has(uuid)) {
this.log.info('Accessory', d.DeviceType, uuid, fullName, 'already set up. Skipping.');
return;
}

// SIDE EFFECT: this constructor mutates the accessory object
new SerenaTiltOnlyWoodBlinds(this, accessory, this.bridgeMgr.getBridge(bridge.bridgeID));
const accessory = new this.api.platformAccessory(fullName, uuid);
accessory.context.device = d;
accessory.context.bridgeID = bridge.bridgeID;

break;
}
switch (d.DeviceType) {
case 'SerenaTiltOnlyWoodBlind': {
this.log.info('Found a new Serena blind:', fullName);

case 'Pico2Button':
case 'Pico2ButtonRaiseLower':
case 'Pico3Button':
case 'Pico3ButtonRaiseLower': {
this.log.info('Found a new', d.DeviceType, 'remote', fullName);

// SIDE EFFECT: this constructor mutates the accessory object
try {
new PicoRemote(this, accessory, this.bridgeMgr.getBridge(bridge.bridgeID));
} catch (e) {
this.log.error('Failed to set up Pico', fullName, e);
continue;
}

break;
}
// SIDE EFFECT: this constructor mutates the accessory object
new SerenaTiltOnlyWoodBlinds(this, accessory, this.bridgeMgr.getBridge(bridge.bridgeID));

// TODO
case 'Pico4Button':
case 'Pico4ButtonScene':
case 'Pico4ButtonZone':
case 'Pico4Button2Group':
case 'FourGroupRemote':
default:
this.log.info('Device type', d.DeviceType, 'not yet supported, skipping setup');
continue;
}
try {
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
} catch (e) {
this.log.error(`Could not register ${d.DeviceType} named ${fullName} with uuid ${uuid}: ${e}`);
continue;
}
this.accessories.set(uuid, accessory);
break;
}
});

bridge.on('unsolicited', this.handleUnsolicitedMessage.bind(this));
case 'Pico2Button':
case 'Pico2ButtonRaiseLower':
case 'Pico3Button':
case 'Pico3ButtonRaiseLower': {
this.log.info('Found a new', d.DeviceType, 'remote', fullName);

// SIDE EFFECT: this constructor mutates the accessory object
new PicoRemote(this, accessory, this.bridgeMgr.getBridge(bridge.bridgeID));

break;
}

// TODO
case 'Pico4Button':
case 'Pico4ButtonScene':
case 'Pico4ButtonZone':
case 'Pico4Button2Group':
case 'FourGroupRemote':
default:
this.log.info('Device type', d.DeviceType, 'not yet supported, skipping setup');
return;
}

try {
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
} catch (e) {
this.log.error(`Could not register ${d.DeviceType} named ${fullName} with uuid ${uuid}: ${e}`);
return;
}

this.accessories.set(uuid, accessory);
}

handleUnsolicitedMessage(bridgeID: string, response: Response): void {
handleUnsolicitedMessage(bridgeID: string, response: Response) {
this.log.debug('bridge', bridgeID, 'got unsolicited message', response);
// publish the message, and let the accessories figure out who it's for
this.emit('unsolicited', response);

if (response.CommuniqueType === 'UpdateResponse' && response.Header.Url === '/device/status/deviceheard') {
const heardDevice = (response.Body! as OneDeviceStatus).DeviceStatus.DeviceHeard;
this.log.info(`New ${heardDevice.DeviceType} s/n ${heardDevice.SerialNumber}. Triggering refresh in 30s.`);
this.bridgeMgr
.getBridge(bridgeID)
.then((bridge: SmartBridge) => setTimeout(() => this.processAllDevices(bridge), 30000))
.catch((e) => this.log.error('Failed to trigger device refresh due to newly-heard device:', e));
} else {
this.emit('unsolicited', response);
}
}
}

0 comments on commit 958be19

Please sign in to comment.