mirror of
https://github.com/beestat/app.git
synced 2025-05-23 18:04:14 -04:00
323 lines
12 KiB
PHP
323 lines
12 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);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|