mirror of
				https://github.com/beestat/app.git
				synced 2025-10-31 10:07:01 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			297 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			297 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| /**
 | |
|  * High level functionality for interacting with the ecobee API.
 | |
|  *
 | |
|  * @author Jon Ziebell
 | |
|  */
 | |
| class ecobee extends external_api {
 | |
| 
 | |
|   public static $exposed = [
 | |
|     'private' => [],
 | |
|     '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);
 | |
|       }
 | |
|       $this->api('ecobee_token', 'delete', $ecobee_token['ecobee_token_id']);
 | |
|       throw new cora\exception('Ecobee access was revoked by user.', 10508, false, null, 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']) === 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;
 | |
|     }
 | |
|   }
 | |
| }
 |