[], 'public' => [ 'authorize', 'initialize' ] ]; protected static $log_mysql = 'all'; protected static $log_mysql_verbose = false; protected static $cache = false; protected static $cache_for = null; /** * If the original API call fails, the ecobee token is updated outside of * the current transaction to ensure it doesn't get rolled back due to an * exception. * * The problem with that is that all subsequent API calls need this * value...so it's stored here statically on the class and used instead of * the old database value. */ protected static $ecobee_token = null; /** * Redirect to ecobee to do the oAuth. */ public function authorize() { header('Location: https://api.ecobee.com/authorize?response_type=code&client_id=' . $this->setting->get('ecobee_client_id') . '&redirect_uri=' . $this->setting->get('ecobee_redirect_uri') . '&scope=smartRead'); } /** * Obtain the first set of tokens for an ecobee user. * * @param string $code The code used to get tokens from ecobee with. * @param string $error Error short description. * @param string $error_description Error long description. */ public function initialize($code = null, $error = null, $error_description = null) { if($code !== null) { // This is returned, not created in the database because the user may not // exist yet. $ecobee_token = $this->api('ecobee_token', 'obtain', ['code' => $code]); /** * I registered an ecobee account (demo@beestat.io). It doesn't have any * thermostats, so if we run into this account just go to the demo page. * Presently this is just for Google Play so they can log in with fake * credentials and approve the app. */ if($ecobee_token['ecobee_account_id'] === 'd90d2785-890b-4743-8b51-477020a7f6e9') { header('Location: https://demo.beestat.io/'); die(); } $existing_user = $this->database->read( 'user', [ 'ecobee_account_id' => $ecobee_token['ecobee_account_id'], 'deleted' => false ] ); // If at least one of the thermostats from the ecobee API call already // exists and all of them have matching user_ids, log in as that user. // Otherwise create a new user and save the tokens to it. if( count($existing_user) > 0 ) { $this->api( 'user', 'force_log_in', ['user_id' => $existing_user[count($existing_user) - 1]['user_id']] ); // Look for existing tokens (in case access was revoked and then re- // granted). Include deleted tokens and revive that row since each user // is limited to one token row. $existing_ecobee_token = $this->api( 'ecobee_token', 'read', [ 'attributes' => [ 'deleted' => [0, 1] ] ] )[0]; $this->api( 'ecobee_token', 'update', [ 'attributes' => array_merge( ['ecobee_token_id' => $existing_ecobee_token['ecobee_token_id']], $ecobee_token ) ] ); } else { $this->api('user', 'create_anonymous_user'); $this->api('ecobee_token', 'create', ['attributes' => $ecobee_token]); } // Redirect to the proper location. header('Location: ' . $this->setting->get('beestat_root_uri')); } else if(isset($error) === true) { throw new cora\exception($error_description, 10506, false); } else { throw new cora\exception('Unhandled error', 10507, false); } } /** * Send an API call to ecobee and return the response. * * @param string $method GET or POST * @param string $endpoint The API endpoint * @param array $arguments POST or GET parameters * @param boolean $auto_refresh_token Whether or not to automatically get a * new token if the old one is expired. * * @return array The response of this API call. */ public function ecobee_api($method, $endpoint, $arguments, $auto_refresh_token = true) { $curl = [ 'method' => $method ]; // Attach the client_id to all requests. $arguments['client_id'] = $this->setting->get('ecobee_client_id'); // Authorize/token endpoints don't use the /1/ in the URL. Everything else // does. $full_endpoint = $endpoint; if ($full_endpoint !== 'authorize' && $full_endpoint !== 'token') { $full_endpoint = '/1/' . $full_endpoint; // For non-authorization endpoints, add the access_token header. Will use // provided token if set, otherwise will get the one for the logged in // user. if(self::$ecobee_token === null) { $ecobee_tokens = $this->api( 'ecobee_token', 'read', [] ); if(count($ecobee_tokens) !== 1) { $this->api('user', 'log_out'); throw new cora\exception('No ecobee access for this user.', 10501, false); } $ecobee_token = $ecobee_tokens[0]; } else { $ecobee_token = self::$ecobee_token; } $curl['header'] = [ 'Authorization: Bearer ' . $ecobee_token['access_token'] ]; } else { $full_endpoint = '/' . $full_endpoint; } $curl['url'] = 'https://api.ecobee.com' . $full_endpoint; // Allow a completely custom endpoint if desired. if(str_starts_with($endpoint, 'https://') === true) { $curl['url'] = $endpoint; } if ($method === 'GET') { $curl['url'] .= '?' . http_build_query($arguments); } if ($method === 'POST') { $curl['post_fields'] = http_build_query($arguments); } $curl_response = $this->curl($curl); $response = json_decode($curl_response, true); if ($response === null) { // If this hasn't already been logged, log the error. if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception( 'Ecobee returned invalid JSON.', 10502, true, [ 'curl_response' => $curl_response ] ); } // If the token was expired, refresh it and try again. Trying again sets // auto_refresh_token to false to prevent accidental infinite refreshing if // something bad happens. if (isset($response['status']) === true && $response['status']['code'] === 14) { // Authentication token has expired. Refresh your tokens. if ($auto_refresh_token === true) { self::$ecobee_token = $this->api('ecobee_token', 'refresh'); return $this->ecobee_api($method, $endpoint, $arguments, false); } else { if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response); } throw new cora\exception($response['status']['message'], 10503); } } else if (isset($response['status']) === true && $response['status']['code'] === 16) { // Token has been deauthorized by user. You must re-request authorization. if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } $this->api('ecobee_token', 'delete', $ecobee_token['ecobee_token_id']); throw new cora\exception('Ecobee access was revoked by user.', 10500, false, null, false); } else if (isset($response['status']) === true && $response['status']['code'] === 2) { // Not authorized. if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception('Ecobee access was revoked by user.', 10508, false, $response['status']['message'], false); } else if (isset($response['status']) === true && $response['status']['code'] === 9) { // Invalid selection. No thermostats in selection. Ensure permissions and selection. if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception('No thermostats found.', 10511, false, null, false); } else if (isset($response['status']) === true && $response['status']['code'] === 3) { if ( isset($response['status']['message']) === true && stripos($response['status']['message'], 'Illegal instant due to time zone offset transition') !== false ) { // Processing error. Illegal instant due to time zone offset transition (daylight savings time 'gap'): ... // Happens when you try to use a time that doesn't exist due to daylight savings spring forward if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception('Illegal instant due to time zone offset transition.', 10509, false, null, false); } else if ( isset($response['status']['message']) === true && stripos($response['status']['message'], 'User cannot access thermostat with id') !== false ) { // Processing error. User cannot access thermostat with id ... // Not sure why this happens if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception('User cannot access thermostat.', 10510, false, null, false); } else if ( isset($response['status']['message']) === true && stripos($response['status']['message'], 'Processing error. Error populating API thermostats.') !== false ) { // Processing error. Error populating API thermostats. ... // Appears to happen when specifying "includeNotificationSettings" in // the /thermostat API call when using a thermostat serial number from // the /homes endpoint. I believe the /homes endpoint is out of date // and errornously returning the thermostat, then asking for the // thermostat, specifically the notification settings, breaks the // ecobee API. if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception('No thermostats found.', 10511, false, null, false); } else if ( isset($response['status']['message']) === true && stripos($response['status']['message'], 'Processing error.') !== false ) { // Processing error. Generic error...this started happening in August // 2024 when attempting to sync any date range that included // the "missing hour" from daylight savings. if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception('Generic processing error.', 10512, false, null, false); } } else if (isset($response['status']) === true && $response['status']['code'] !== 0) { // Any other error if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception($response['status']['message'], 10504); } else if (isset($response['error']) === true) { // Authorization errors are a bit different // https://www.ecobee.com/home/developer/api/documentation/v1/auth/auth-req-resp.shtml if($response['error'] === 'invalid_grant') { $ecobee_token = $this->api('ecobee_token', 'read')[0]; $this->api('ecobee_token', 'delete', $ecobee_token['ecobee_token_id']); } if($this::$log_mysql !== 'all') { $this->log_mysql($curl_response, true); } throw new cora\exception(isset($response['error_description']) === true ? $response['error_description'] : $response['error'], 10505, true, null, false); } else { return $response; } } }