From 969b6ada1211c02a098fcb3446a80d0f585f92e0 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Wed, 29 Jan 2025 22:26:13 -0500 Subject: [PATCH] Added ability to manually manage thermostats --- api/ecobee_sensor.php | 221 +++++++++------------ api/ecobee_thermostat.php | 247 +++++++++++------------- js/beestat/user.js | 1 + js/component/card/manage_thermostats.js | 95 +++++++-- js/layer/detail.js | 9 - js/layer/settings.js | 12 +- 6 files changed, 292 insertions(+), 293 deletions(-) diff --git a/api/ecobee_sensor.php b/api/ecobee_sensor.php index fcab082..7a909fa 100644 --- a/api/ecobee_sensor.php +++ b/api/ecobee_sensor.php @@ -119,151 +119,110 @@ class ecobee_sensor extends cora\crud { */ ]; - try { - /** - * This will force the device sync to use the secondary method that uses - * the undocumented API calls instead of the normal GET "registered" - * thermostats. This fixes an issue where beestat never sees shared - * thermostats if there is at least one registered thermostat. - */ - $user = $this->api('user', 'get', $this->session->get_user_id()); - if( - isset($user['settings']['app']) === true && - isset($user['settings']['app']['prefer_secondary_device_sync']) === true && - $user['settings']['app']['prefer_secondary_device_sync'] === true - ) { - throw new cora\exception('No thermostats found.', 10511, false, null, false); - } - - $response = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'thermostat', - 'arguments' => [ - 'body' => json_encode([ - 'selection' => array_merge( - [ - 'selectionType' => 'registered', - 'selectionMatch' => '' - ], - $include - ) - ]) - ] + // Get a list of all registered thermostats. + $response = $this->api( + 'ecobee', + 'ecobee_api', + [ + 'method' => 'GET', + 'endpoint' => 'thermostat', + 'arguments' => [ + 'body' => json_encode([ + 'selection' => array_merge( + [ + 'selectionType' => 'registered', + 'selectionMatch' => '' + ], + $include + ) + ]) ] - ); - if(count($response['thermostatList']) === 0) { - throw new cora\exception('No thermostats found.', 10511, false, null, false); - } - } catch(cora\exception $e) { - // If no thermostats found (ie. not the owner of any homes that contain a thermostat) - if($e->getCode() === 10511) { - $homes = $this->api( + ] + ); + $registered_identifiers = []; + foreach($response['thermostatList'] as $api_thermostat) { + $registered_identifiers[] = $api_thermostat['identifier']; + } + + // Get a list of manually added thermostats. + $manual_ecobee_thermostats = $this->api( + 'ecobee_thermostat', + 'read', + [ + 'attributes' => [ + 'model_number' => null + ] + ] + ); + + // For each of the manually added ones, check and see if ecobee gives a + // result. If so, store that identifier as good. If not, inactivate the + // manually added ecobee_thermostat row. + $manual_identifiers = []; + foreach($manual_ecobee_thermostats as $manual_ecobee_thermostat) { + try { + $response = $this->api( 'ecobee', 'ecobee_api', [ 'method' => 'GET', - 'endpoint' => 'https://home.hm-prod.ecobee.com/homes', + 'endpoint' => 'thermostat', 'arguments' => [ + 'body' => json_encode([ + 'selection' => array_merge( + [ + 'selectionType' => 'thermostats', + 'selectionMatch' => $manual_ecobee_thermostat['identifier'] + ], + $include + ) + ]) ] ] ); - $home_ids = array_column($homes['homes'], 'homeID'); - - $serial_numbers = []; - foreach($home_ids as $home_id) { - $devices = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'https://home.hm-prod.ecobee.com/home/' . $home_id . '/devices', - 'arguments' => [ - ] + foreach($response['thermostatList'] as $api_thermostat) { + $manual_identifiers[] = $api_thermostat['identifier']; + } + } catch(\Exception $e) { + $this->api( + 'ecobee_thermostat', + 'update', + [ + 'attributes' => [ + 'ecobee_thermostat_id' => $manual_ecobee_thermostat['ecobee_thermostat_id'], + 'inactive' => 1 ] - ); - - /** - * This is a select distinct from ecobee_thermostat. Ideally it - * would be possible to send *all* serial numbers from the devices - * call to the GET->thermostat API call, but that throws an error if - * you include a serial number for something that's not a - * thermostat. So I have to keep this array to identify valid serial - * numbers. - */ - $model_numbers = [ - 'athenaSmart', - 'apolloSmart', - 'idtSmart', - 'nikeSmart', - 'siSmart', - 'corSmart', - 'vulcanSmart', - 'aresSmart', - 'artemisSmart' - ]; - - foreach($devices['devices'] as $device) { - if(in_array($device['modelNumber'], $model_numbers) === true) { - $serial_numbers[] = $device['serialNumber']; - } - } - } - - if(count($serial_numbers) > 0) { - try { - $response = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'thermostat', - 'arguments' => [ - 'body' => json_encode([ - 'selection' => array_merge( - [ - 'selectionType' => 'thermostats', - 'selectionMatch' => implode(',', $serial_numbers), - ], - $include - ) - ]) - ] - ] - ); - } catch(cora\exception $e) { - /** - * For some reason, I can get a serial number in the /homes data - * and still get no results from the /thermostat endpoint. Likely - * due to two data sources not being in sync. Catch that exception - * and let the code continue so any existing thermostats still get - * inactivated. - * - * Also have to fabricate the $response a bit. - */ - if($e->getCode() === 10511) { - $response = [ - 'thermostatList' => [] - ]; - } else { - throw new cora\exception($e->getMessage(), $e->getCode(), $e->getReportable(), $e->getExtraInfo(), $e->getRollback()); - } - } - - /** - * At this point, $response will either be populated with results, - * or have an empty thermostatList attribute. The code can continue - * on as it will inactivate any thermostats that were not found. - */ - } - } else { - throw new cora\exception($e->getMessage(), $e->getCode(), $e->getReportable(), $e->getExtraInfo(), $e->getRollback()); + ] + ); } } + // Get a unique list of identifiers. + $identifiers = array_unique(array_merge($registered_identifiers, $manual_identifiers)); + + // Get all of the thermostats from ecobee. + $response = $this->api( + 'ecobee', + 'ecobee_api', + [ + 'method' => 'GET', + 'endpoint' => 'thermostat', + 'arguments' => [ + 'body' => json_encode([ + 'selection' => array_merge( + [ + 'selectionType' => 'thermostats', + 'selectionMatch' => implode(',', $identifiers) + ], + $include + ) + ]) + ] + ] + ); + + // Loop over the returned sensors and create/update them as necessary. $sensor_ids_to_keep = []; foreach($response['thermostatList'] as $thermostat_api) { diff --git a/api/ecobee_thermostat.php b/api/ecobee_thermostat.php index 8c0ed38..793a5e7 100644 --- a/api/ecobee_thermostat.php +++ b/api/ecobee_thermostat.php @@ -12,9 +12,34 @@ class ecobee_thermostat extends cora\crud { 'private' => [ 'read_id' ], - 'public' => [] + 'public' => ['create', 'update'] ]; + /** + * If an ecobee_thermostat row with the same identifier already exists, + * reactivate it. This avoids creating duplicates or fixes if you + * accidentally remove your thermostat so it doesn't re-sync. + * + * @param array $attributes An array of attributes to set for this item + * + * @return mixed The id of the inserted row. + */ + public function create($attributes) { + $existing_ecobee_thermostat = $this->get([ + 'identifier' => $attributes['identifier'] + ]); + + if($existing_ecobee_thermostat !== null) { + return $this->update([ + 'ecobee_thermostat_id' => $existing_ecobee_thermostat['ecobee_thermostat_id'], + 'inactive' => 0 + ]); + } else { + return $this->create($attributes); + } + + } + /** * Sync thermostats. */ @@ -82,151 +107,109 @@ class ecobee_thermostat extends cora\crud { */ ]; - try { - /** - * This will force the device sync to use the secondary method that uses - * the undocumented API calls instead of the normal GET "registered" - * thermostats. This fixes an issue where beestat never sees shared - * thermostats if there is at least one registered thermostat. - */ - $user = $this->api('user', 'get', $this->session->get_user_id()); - if( - isset($user['settings']['app']) === true && - isset($user['settings']['app']['prefer_secondary_device_sync']) === true && - $user['settings']['app']['prefer_secondary_device_sync'] === true - ) { - throw new cora\exception('No thermostats found.', 10511, false, null, false); - } - - $response = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'thermostat', - 'arguments' => [ - 'body' => json_encode([ - 'selection' => array_merge( - [ - 'selectionType' => 'registered', - 'selectionMatch' => '' - ], - $include - ) - ]) - ] + // Get a list of all registered thermostats. + $response = $this->api( + 'ecobee', + 'ecobee_api', + [ + 'method' => 'GET', + 'endpoint' => 'thermostat', + 'arguments' => [ + 'body' => json_encode([ + 'selection' => array_merge( + [ + 'selectionType' => 'registered', + 'selectionMatch' => '' + ], + $include + ) + ]) ] - ); - if(count($response['thermostatList']) === 0) { - throw new cora\exception('No thermostats found.', 10511, false, null, false); - } - } catch(cora\exception $e) { - // If no thermostats found (ie. not the owner of any homes that contain a thermostat) - if($e->getCode() === 10511) { - $homes = $this->api( + ] + ); + $registered_identifiers = []; + foreach($response['thermostatList'] as $api_thermostat) { + $registered_identifiers[] = $api_thermostat['identifier']; + } + + // Get a list of manually added thermostats. + $manual_ecobee_thermostats = $this->api( + 'ecobee_thermostat', + 'read', + [ + 'attributes' => [ + 'model_number' => null + ] + ] + ); + + // For each of the manually added ones, check and see if ecobee gives a + // result. If so, store that identifier as good. If not, inactivate the + // manually added ecobee_thermostat row. + $manual_identifiers = []; + foreach($manual_ecobee_thermostats as $manual_ecobee_thermostat) { + try { + $response = $this->api( 'ecobee', 'ecobee_api', [ 'method' => 'GET', - 'endpoint' => 'https://home.hm-prod.ecobee.com/homes', + 'endpoint' => 'thermostat', 'arguments' => [ + 'body' => json_encode([ + 'selection' => array_merge( + [ + 'selectionType' => 'thermostats', + 'selectionMatch' => $manual_ecobee_thermostat['identifier'] + ], + $include + ) + ]) ] ] ); - $home_ids = array_column($homes['homes'], 'homeID'); - - $serial_numbers = []; - foreach($home_ids as $home_id) { - $devices = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'https://home.hm-prod.ecobee.com/home/' . $home_id . '/devices', - 'arguments' => [ - ] + foreach($response['thermostatList'] as $api_thermostat) { + $manual_identifiers[] = $api_thermostat['identifier']; + } + } catch(\Exception $e) { + $this->api( + 'ecobee_thermostat', + 'update', + [ + 'attributes' => [ + 'ecobee_thermostat_id' => $manual_ecobee_thermostat['ecobee_thermostat_id'], + 'inactive' => 1 ] - ); - - /** - * This is a select distinct from ecobee_thermostat. Ideally it - * would be possible to send *all* serial numbers from the devices - * call to the GET->thermostat API call, but that throws an error if - * you include a serial number for something that's not a - * thermostat. So I have to keep this array to identify valid serial - * numbers. - */ - $model_numbers = [ - 'athenaSmart', - 'apolloSmart', - 'idtSmart', - 'nikeSmart', - 'siSmart', - 'corSmart', - 'vulcanSmart', - 'aresSmart', - 'artemisSmart' - ]; - - foreach($devices['devices'] as $device) { - if(in_array($device['modelNumber'], $model_numbers) === true) { - $serial_numbers[] = $device['serialNumber']; - } - } - } - - if(count($serial_numbers) > 0) { - try { - $response = $this->api( - 'ecobee', - 'ecobee_api', - [ - 'method' => 'GET', - 'endpoint' => 'thermostat', - 'arguments' => [ - 'body' => json_encode([ - 'selection' => array_merge( - [ - 'selectionType' => 'thermostats', - 'selectionMatch' => implode(',', $serial_numbers), - ], - $include - ) - ]) - ] - ] - ); - } catch(cora\exception $e) { - /** - * For some reason, I can get a serial number in the /homes data - * and still get no results from the /thermostat endpoint. Likely - * due to two data sources not being in sync. Catch that exception - * and let the code continue so any existing thermostats still get - * inactivated. - * - * Also have to fabricate the $response a bit. - */ - if($e->getCode() === 10511) { - $response = [ - 'thermostatList' => [] - ]; - } else { - throw new cora\exception($e->getMessage(), $e->getCode(), $e->getReportable(), $e->getExtraInfo(), $e->getRollback()); - } - } - - /** - * At this point, $response will either be populated with results, - * or have an empty thermostatList attribute. The code can continue - * on as it will inactivate any thermostats that were not found. - */ - } - } else { - throw new cora\exception($e->getMessage(), $e->getCode(), $e->getReportable(), $e->getExtraInfo(), $e->getRollback()); + ] + ); } } + // Get a unique list of identifiers. + $identifiers = array_unique(array_merge($registered_identifiers, $manual_identifiers)); + + // Get all of the thermostats from ecobee. + $response = $this->api( + 'ecobee', + 'ecobee_api', + [ + 'method' => 'GET', + 'endpoint' => 'thermostat', + 'arguments' => [ + 'body' => json_encode([ + 'selection' => array_merge( + [ + 'selectionType' => 'thermostats', + 'selectionMatch' => implode(',', $identifiers) + ], + $include + ) + ]) + ] + ] + ); + // Loop over the returned thermostats and create/update them as necessary. $thermostat_ids_to_keep = []; $email_addresses = []; diff --git a/js/beestat/user.js b/js/beestat/user.js index 7b4c69c..a29f1a8 100644 --- a/js/beestat/user.js +++ b/js/beestat/user.js @@ -23,6 +23,7 @@ beestat.user.stripe_is_active = function() { for (let i = 0; i < stripe_events.length; i++) { if ( stripe_events[i].type === 'invoice.paid' && + // This is a bug. It counts anyone who has contributed ever via stripe as a supporter. moment.unix(stripe_events[i].data.period_end).isAfter(moment()) === false ) { return true; diff --git a/js/component/card/manage_thermostats.js b/js/component/card/manage_thermostats.js index de9b512..ed047d9 100644 --- a/js/component/card/manage_thermostats.js +++ b/js/component/card/manage_thermostats.js @@ -2,6 +2,19 @@ * Setting */ beestat.component.card.manage_thermostats = function() { + const self = this; + + var change_function = beestat.debounce(function() { + self.rerender(); + }, 10); + + beestat.dispatcher.addEventListener( + [ + 'cache.ecobee_thermostat' + ], + change_function + ); + beestat.component.card.apply(this, arguments); }; beestat.extend(beestat.component.card.manage_thermostats, beestat.component.card); @@ -12,6 +25,8 @@ beestat.extend(beestat.component.card.manage_thermostats, beestat.component.card * @param {rocket.Elements} parent Parent */ beestat.component.card.manage_thermostats.prototype.decorate_contents_ = function(parent) { + const self = this; + var p = document.createElement('p'); p.innerText = 'Thermostats directly connected to your ecobee account are automatically added and synced. In some cases, shared thermostats cannot be automatically detected. Add them here.'; parent.appendChild(p); @@ -19,22 +34,21 @@ beestat.component.card.manage_thermostats.prototype.decorate_contents_ = functio // Existing (new beestat.component.title('Existing Thermostats')).render(parent); - var sorted_thermostats = $.values(beestat.cache.thermostat) + var sorted_ecobee_thermostats = $.values(beestat.cache.ecobee_thermostat) .sort(function(a, b) { return a.name > b.name; }); const table = document.createElement('table'); - sorted_thermostats.forEach(function(thermostat) { - const ecobee_thermostat = beestat.cache.ecobee_thermostat[thermostat.ecobee_thermostat_id]; - + sorted_ecobee_thermostats.forEach(function(ecobee_thermostat) { const tr = document.createElement('tr'); const td_name = document.createElement('td'); + td_name.style.paddingRight = `${beestat.style.size.gutter}px`; const td_identifier = document.createElement('td'); const td_delete = document.createElement('td'); - td_name.innerText = thermostat.name; + td_name.innerText = ecobee_thermostat.name || '(Sync Queued)'; td_identifier.innerText = ecobee_thermostat.identifier; const tile_delete = new beestat.component.tile() @@ -44,7 +58,34 @@ beestat.component.card.manage_thermostats.prototype.decorate_contents_ = functio .render($(td_delete)); tile_delete.addEventListener('click', function() { - console.info('delete'); + self.show_loading_(); + + new beestat.api() + .add_call( + 'ecobee_thermostat', + 'update', + { + 'attributes': { + 'ecobee_thermostat_id': ecobee_thermostat.ecobee_thermostat_id, + 'inactive': 1 + } + }, + 'delete' + ) + .add_call( + 'ecobee_thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'read_id' + ) + .set_callback(function(response) { + beestat.cache.set('ecobee_thermostat', response.read_id); + }) + .send(); }) tr.appendChild(td_name); @@ -71,9 +112,10 @@ beestat.component.card.manage_thermostats.prototype.decorate_contents_ = functio const input_container = document.createElement('div'); container.appendChild(input_container); - new_identifier = new beestat.component.input.text() + const new_identifier = new beestat.component.input.text() .set_width(150) .set_maxlength(12) + .set_inputmode('numeric') .set_placeholder('Serial #') .render($(input_container)); @@ -85,15 +127,38 @@ beestat.component.card.manage_thermostats.prototype.decorate_contents_ = functio .set_background_hover_color(beestat.style.color.green.light) .set_text_color('#fff') .set_text('Add Thermostat') - .render($(button_container)); + .addEventListener('click', function() { + if (new_identifier.get_value()?.length === 12) { + self.show_loading_(); - // TODO: Add thermostat button needs to make an API call. That call should - // look for an existing inactive thermostat on the account and add it back. - // If it doesn't exist, it should do an API call to ecobee to "sync" that - // thermostat which should immediately grab all of that data and create the - // rows for me using my existing code. Throw a loading spinner over the - // manage thermostats card while this happens, then call get thermostats when - // that's done to update beestat's cache. + new beestat.api() + .add_call( + 'ecobee_thermostat', + 'create', + { + 'attributes': { + 'identifier': new_identifier.get_value() + } + }, + 'create' + ) + .add_call( + 'ecobee_thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'read_id' + ) + .set_callback(function(response) { + beestat.cache.set('ecobee_thermostat', response.read_id); + }) + .send(); + } + }) + .render($(button_container)); }; /** diff --git a/js/layer/detail.js b/js/layer/detail.js index bdab68d..2340d14 100644 --- a/js/layer/detail.js +++ b/js/layer/detail.js @@ -33,15 +33,6 @@ beestat.layer.detail.prototype.decorate_ = function(parent) { ]); } - // Manage Thermostats - // cards.push([ - // { - // 'card': new beestat.component.card.manage_thermostats(), - // 'card': new beestat.component.card.rookstack_survey_notification(), - // 'size': 12 - // } - // ]); - cards.push([ { 'card': new beestat.component.card.system(thermostat.thermostat_id), diff --git a/js/layer/settings.js b/js/layer/settings.js index 85cee1e..39d00fc 100644 --- a/js/layer/settings.js +++ b/js/layer/settings.js @@ -40,12 +40,12 @@ beestat.layer.settings.prototype.decorate_ = function(parent) { ]); // Manage Thermostats - // cards.push([ - // { - // 'card': new beestat.component.card.manage_thermostats(), - // 'size': 12 - // } - // ]); + cards.push([ + { + 'card': new beestat.component.card.manage_thermostats(), + 'size': 12 + } + ]); // Footer cards.push([