homebridge-lutron-caseta-leap-fast
Version:
Support for the Lutron Caseta Smart Bridge 2
486 lines (427 loc) • 20.5 kB
HTML
<div class="card card-body text-center">
<p class="h2 card-title">Lutron Device Configuration</p>
<div id="options" class="card-body my-3 w-75 mx-auto">
<h4 class="card-title">Global Options</h4>
<form id='optionsForm' class="form-horizontal">
<div class="form-check row py-sm-3 mb-0">
<input class="form-check-input col-sm-1" type="checkbox" value="" id="filterPicoChk"/>
<label class="form-check-label col-sm-11 text-left" for="filterPicoChk">
Exclude Pico remotes that are associated in the Lutron app
</label>
<br/>
</div>
<div class="form-check row py-sm-3 mb-0">
<input class="form-check-input col-sm-1" type="checkbox" value="" id="filterBlindsChk"/>
<label class="form-check-label col-sm-11 text-left" for="filterBlindsChk">
Disable support for Serena wood blinds in this plugin
</label>
<br/>
</div>
<div class="form-group row py-sm-3 mb-0">
<label for='clickSpeedLongSelect' class='col-sm-5 col-form-label'>Long press speed</label>
<select class="form-control-sm custom-select col-sm-7" id="clickSpeedLongSelect">
<option value="quick">Quick</option>
<option selected value="default">Default</option>
<option value="relaxed">Relaxed</option>
<option value="disabled">Disabled</option>
</select>
</div>
<div class="form-group row py-sm-3 mb-0">
<label for='clickSpeedDoubleSelect' class='col-sm-5 col-form-label'>Double press speed</label>
<select class="form-control-sm custom-select col-sm-7" id="clickSpeedDoubleSelect">
<option value="quick">Quick</option>
<option value="default" selected>Default</option>
<option value="relaxed">Relaxed</option>
<option value="disabled">Disabled</option>
</select>
</div>
</form>
</div>
<div id="bridges">
<div class="card my-2" id="searching">Searching for bridges...</div>
</div>
<div id="schemapop">
<button type="button" class="btn btn-secondary" id="schemapopbtn">Use legacy view</button>
</div>
</div>
<script>
function bootstrapConfig(pluginConfig) {
if (pluginConfig.length === 0) {
console.log("no configuration detected. bootstrapping.");
pluginConfig = [{name: "Lutron", platform: "LutronCasetaLeap", options: { filterPico: false, filterBlinds: false, clickSpeedDouble: "default", clickSpeedLong: "default"}, secrets: []}];
}
if (pluginConfig[0].secrets === undefined) {
console.log("configuration with no secrets. bootstrapping.");
pluginConfig[0].secrets = new Array();
}
if (pluginConfig[0].options === undefined) {
console.log("configuration with no options. bootstrapping.");
pluginConfig[0].options = {
filterPico: false,
filterBlinds: false,
clickSpeedDouble: "default",
clickSpeedLong: "default",
};
}
return pluginConfig;
}
document.getElementById('schemapopbtn').addEventListener('click', () => {
homebridge.getPluginConfig().then((pluginConfig) => {
document.getElementById('bridges').innerHTML = '';
document.getElementById('schemapop').innerHTML = '';
pluginConfig = bootstrapConfig(pluginConfig);
homebridge.updatePluginConfig(pluginConfig).then(() => homebridge.showSchemaForm());
});
});
async function updateConfigFromMap(pluginConfig, secretsMap) {
pluginConfig = bootstrapConfig(pluginConfig);
pluginConfig[0].secrets = Array.from(secretsMap.values());
await homebridge.updatePluginConfig(pluginConfig);
}
(async () => {
let pluginConfig = await homebridge.getPluginConfig();
pluginConfig = bootstrapConfig(pluginConfig);
if (pluginConfig.length > 1) {
homebridge.toast.error('Too many config objects');
return;
}
console.log(pluginConfig[0]);
console.log(document.querySelector('#filterPicoChk'));
document.querySelector('#filterPicoChk').checked = pluginConfig[0].options.filterPico;
document.querySelector('#filterBlindsChk').checked = pluginConfig[0].options.filterBlinds;
document.querySelector('#clickSpeedLongSelect').value = pluginConfig[0].options.clickSpeedLong;
document.querySelector('#clickSpeedDoubleSelect').value = pluginConfig[0].options.clickSpeedDouble;
document.getElementById('optionsForm').addEventListener('input', () => {
console.log("options clicked");
pluginConfig = bootstrapConfig(pluginConfig);
pluginConfig[0].options.filterPico = document.querySelector('#filterPicoChk').checked;
pluginConfig[0].options.filterBlinds = document.querySelector('#filterBlindsChk').checked;
pluginConfig[0].options.clickSpeedLong = document.querySelector('#clickSpeedLongSelect').value;
pluginConfig[0].options.clickSpeedDouble = document.querySelector('#clickSpeedDoubleSelect').value;
console.log(pluginConfig[0].options);
homebridge.updatePluginConfig(pluginConfig);
});
let secretMap = new Map();
secretMap = new Map(pluginConfig[0].secrets.map((o) => [o.bridgeid.toUpperCase(), o]));
let bridgeMap = new Map();
homebridge.showSpinner();
try {
const bridgeList = document.getElementById('bridges');
bridgeList.innerHTML = '';
function handleDiscovered(bridgeInfo) {
bridgeInfo = bridgeInfo.data;
console.log('found bridge', bridgeInfo);
const bridge = new Bridge(bridgeInfo.systype, bridgeInfo.bridgeid, bridgeInfo.ipAddr);
bridgeList.append(bridge.getElem());
bridgeMap.set(bridgeInfo.bridgeid, bridge);
const s = secretMap.get(bridge.bridgeid);
if (s !== undefined) {
console.log('sending connect request for', bridge.bridgeid, bridge.ipAddr);
doConnect(bridge, secretMap);
} else {
console.log('unknown bridge', bridge.bridgeid, bridge.ipAddr);
bridge.toUnassociated(() => {
doAssociate(bridge);
});
}
homebridge.hideSpinner();
}
homebridge.addEventListener('discovered', handleDiscovered);
await homebridge.request('/search'); // begin searching for bridges
function handleConnected(event) {
console.log('success connected to bridge', event.data);
const b = bridgeMap.get(event.data);
if (b === undefined) {
console.error('failed trying to get connected bridge', event.data, 'from map');
console.dir(bridgeMap);
throw new Error("could not find connected bridge " + event.data + " in map");
}
b.toAssociated(() => {
deleteBridgeSecret(event.data, secretMap);
});
}
console.log('listening for connected');
homebridge.addEventListener('connected', handleConnected);
function handleFailed(event) {
console.log('failed connected to bridge', event.data);
const b = bridgeMap.get(event.data.bridgeid);
b.toFailed(
event.data.reason,
() => {
deleteBridgeSecret(event.data, secretMap);
},
() => {
doConnect(b, secretMap);
},
);
}
console.log('listening for failed');
homebridge.addEventListener('failed', handleFailed);
function handleAssociated(event) {
const { bridgeid, ipAddr, secrets } = event.data;
const b = bridgeMap.get(bridgeid);
console.log('associated with bridge', bridgeid);
secretMap.set(bridgeid, secrets);
homebridge
.getPluginConfig()
.then(async (config) => {
await updateConfigFromMap(config, secretMap);
})
.then(() => console.log('added secret'));
doConnect(b, secretMap);
}
console.log('listening for associated');
homebridge.addEventListener('associated', handleAssociated);
console.log('setup done');
} catch (e) {
console.log(e.error, e.message, e.stack);
homebridge.toast.error(e.error, e.message);
}
console.log('exiting');
})();
function doAssociate(bridge) {
homebridge
.request('/associate', {
bridgeid: bridge.bridgeid,
ipAddr: bridge.ipAddr,
})
.then(() => console.log('associate request complete for', bridge.bridgeid, bridge.ipAddr))
.catch((e) => {
// TODO inspect this to return something more user-friendly
// if it's a quasi-leap w/ a 401 code, say something about "didn't press button
// in time"
console.log('associate request failed', bridge.bridgeid, bridge.ipAddr, e);
homebridge.toast.error(e.message);
bridge.toUnassociated(() => {
doAssociate(bridge);
});
});
}
function doConnect(bridge, secretMap) {
homebridge
.request('/connect', {
secrets: secretMap.get(bridge.bridgeid),
bridgeid: bridge.bridgeid,
ipAddr: bridge.ipAddr,
})
.then(() => console.log('connect request complete for', bridge.bridgeid, bridge.ipAddr));
bridge.toConnecting();
}
function deleteBridgeSecret(bridgeid, secretMap) {
secretMap.delete(bridgeid);
homebridge
.getPluginConfig()
.then(async (config) => {
await updateConfigFromMap(config, secretMap);
})
.then(() => {
console.log('removed secret', bridgeid);
console.dir(secretMap);
});
}
class Bridge {
constructor(systype, bridgeid, ipAddr) {
console.log('new bridge', bridgeid, ipAddr);
this.systype = systype;
this.bridgeid = bridgeid;
this.ipAddr = ipAddr;
const bridgeElem = document.createElement('div');
bridgeElem.id = bridgeid;
bridgeElem.className = 'card mx-auto my-2';
bridgeElem.style.width = '24rem';
bridgeElem.innerHTML = bridgeStates.INITIAL(this.systype, this.bridgeid, this.ipAddr);
this.elem = bridgeElem;
this.pendTimeout = undefined;
this.connTimeout = undefined;
}
getElem() {
return this.elem;
}
// CONNECTING -> ASSOCIATED (external)
// CONNECTING -> FAILED (timeout)
// CONNECTING -> FAILED (external)
toConnecting(onTimeout) {
console.log('toConnecting', this.bridgeid);
this.elem.innerHTML = bridgeStates.CONNECTING(this.systype, this.bridgeid, this.ipAddr);
this.connTimeout = setTimeout(() => {
console.log('CONNECTING', this.bridgeid, 'timeout');
if (onTimeout !== undefined) {
console.log('calling CONNECTING timeout helper', this.bridgeid);
onTimeout();
}
this.toFailed('Connection timed out');
}, 5000);
}
// ASSOCIATED -> UNASSOCIATED (internal)
toAssociated(onResetClick) {
console.log('toAssociated', this.bridgeid);
this.elem.innerHTML = bridgeStates.ASSOCIATED(this.systype, this.bridgeid, this.ipAddr);
// clear the timeout set in CONNECTING state now that we're connected
if (this.connTimeout !== undefined) {
console.log('ASSOCIATED clearing CONNECTING timeout', this.bridgeid);
clearTimeout(this.connTimeout);
this.connTimeout = undefined;
}
// clear the timeout set in PENDING since we have creds now
if (this.pendTimeout !== undefined) {
console.log('clearing PENDING timeout', this.bridgeid);
clearTimeout(this.pendTimeout);
this.pendTimeout = undefined;
}
document.getElementById(this.bridgeid + 'reset').addEventListener(
'click',
(() => {
console.log('reset from ASSOCIATED clicked', this.bridgeid);
if (onResetClick !== undefined) {
console.log('calling reset ASSOCIATED helper', this.bridgeid);
onResetClick();
}
this.toUnassociated(() => {
doAssociate(this);
});
}).bind(this),
);
}
// UNASSOCIATED -> PENDING (internal)
toUnassociated(onAssociateClick) {
console.log('toUnassociated', this.bridgeid, this.ipAddr);
this.elem.innerHTML = bridgeStates.UNASSOCIATED(this.systype, this.bridgeid, this.ipAddr);
document.getElementById(this.bridgeid + 'assoc').addEventListener(
'click',
(() => {
console.log('associate from UNASSOCIATED clicked', this.bridgeid);
if (onAssociateClick !== undefined) {
console.log('calling associate from UNASSOCIATED helper', this.bridgeid);
onAssociateClick();
}
this.toPending(() => {
homebridge.toast.error('Association with bridge ' + this.bridgeid + ' timed out.');
});
}).bind(this),
);
}
// PENDING -> UNASSOCIATED (timeout)
// PENDING -> ASSOCIATED (external)
// PENDING -> UNASSOCIATED (internal)
toPending(onTimeout, onCancelClick) {
console.log('toPending', this.bridgeid);
this.elem.innerHTML = bridgeStates.PENDING(this.systype, this.bridgeid, this.ipAddr);
this.pendTimeout = setTimeout(() => {
console.log('PENDING', this.bridgeid, 'timeout');
if (onTimeout !== undefined) {
console.log('calling PENDING timeout helper', this.bridgeid);
onTimeout();
}
//show toast
this.toUnassociated(() => {
doAssociate(this);
});
}, 30000);
document.getElementById(this.bridgeid + 'cancel').addEventListener(
'click',
(() => {
console.log('cancel from PENDING clicked', this.bridgeid);
if (onCancelClick !== undefined) {
console.log('calling PENDING cancel helper', this.bridgeid);
onCancelClick();
}
this.toUnassociated(() => {
doAssociate(this);
});
}).bind(this),
);
}
// FAILED -> UNASSOCIATED (internal)
toFailed(reason, onResetClick, onRetryClick) {
console.log('toFailed', this.bridgeid, reason);
this.elem.innerHTML = bridgeStates.FAILED(this.systype, this.bridgeid, this.ipAddr);
homebridge.toast.error('Connection failed: ' + reason);
// clear the timeout set in CONNECTING state now that it's failed
if (this.connTimeout !== undefined) {
console.log('in FAILED clearing CONNECTING timeout', this.bridgeid);
clearTimeout(this.connTimeout);
this.connTimeout = undefined;
}
document.getElementById(this.bridgeid + 'retry').addEventListener(
'click',
(() => {
console.log('retry from FAILED clicked', this.bridgeid);
if (onRetryClick !== undefined) {
console.log('calling retry FAILED helper', this.bridgeid);
onRetryClick();
}
this.toConnecting();
}).bind(this),
);
document.getElementById(this.bridgeid + 'reset').addEventListener(
'click',
(() => {
console.log('reset from FAILED clicked', this.bridgeid);
if (onResetClick !== undefined) {
console.log('calling reset FAILED helper', this.bridgeid);
onResetClick();
}
this.toUnassociated(() => {
doAssociate(this);
});
}).bind(this),
);
}
}
const bridgeStates = Object.freeze({
INITIAL: (systype, bridgeid, ipAddr) =>
`<div class="card-body">
<h4 class="card-title">${systype} ${bridgeid}</h4>
<h6 class="card-subtitle">${ipAddr}</h6>
<div class="alert alert-light" role="alert">${systype} discovered!</div>
</div>`,
CONNECTING: (systype, bridgeid, ipAddr) =>
`<div class="card-body">
<h4 class="card-title">${systype} ${bridgeid}</h4>
<h6 class="card-subtitle">${ipAddr}</h6>
<div class="alert alert-light" role="alert">Connecting...</div>
<!--
<div class="card-footer text-muted">
<button type="button" class="btn btn-dark">Cancel</button>
</div>
-->
</div>`,
ASSOCIATED: (systype, bridgeid, ipAddr) =>
`<div class="card-body">
<h4 class="card-title">${systype} ${bridgeid}</h4>
<h6 class="card-subtitle">${ipAddr}</h6>
<div class="alert alert-success" role="alert">Connected!</div>
<div class="card-footer text-muted">
<button type="button" class="btn btn-danger" id="${bridgeid}reset">Reset</button>
</div>
</div>`,
FAILED: (systype, bridgeid, ipAddr) =>
`<div class="card-body">
<h4 class="card-title">${systype} ${bridgeid}</h4>
<h6 class="card-subtitle">${ipAddr}</h6>
<div class="alert alert-danger" role="alert">Connection failed!</div>
<div class="card-footer text-muted">
<button type="button" class="btn btn-primary" id="${bridgeid}retry">Retry</button>
<button type="button" class="btn btn-danger" id="${bridgeid}reset">Reset</button>
</div>
</div>`,
UNASSOCIATED: (systype, bridgeid, ipAddr) =>
`<div class="card-body">
<h4 class="card-title">${systype} ${bridgeid}</h4>
<h6 class="card-subtitle">${ipAddr}</h6>
<button type="button" class="btn btn-primary" id="${bridgeid}assoc">Associate</button>
</div>`,
PENDING: (systype, bridgeid, ipAddr) =>
`<div class="card-body">
<h4 class="card-title">${systype} ${bridgeid}</h4>
<h6 class="card-subtitle">${ipAddr}</h6>
<p class="card-text">Press the button on the back of the bridge within 30 seconds...</p>
<!--
<!-- TODO add the ability to cancel an association
<div class="card-footer text-muted">
<button type="button" class="btn btn-dark" id="${bridgeid}cancel">Cancel</button>
</div>
-->
</div>`,
});
</script>