1
0
mirror of https://github.com/beestat/app.git synced 2025-05-23 18:04:14 -04:00

Beestat is now open source

This commit is contained in:
Jon Ziebell 2019-05-22 21:22:24 -04:00
parent 8993336441
commit 1c79d3a773
179 changed files with 31136 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
api/cora/setting.php
.internal/

View File

@ -0,0 +1,10 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.beestat",
"sha256_cert_fingerprints": ["CD:96:DE:AD:E9:74:E6:B0:37:C4:D8:5A:D7:66:72:94:99:5E:14:22:53:29:0C:10:84:9E:0A:FD:F0:D6:FB:2F"]
}
}
]

106
api/address.php Normal file
View File

@ -0,0 +1,106 @@
<?php
/**
* An address is a discrete object that is normalized and verified using a
* third party service. In order to prevent duplication and extra API calls
* (which cost money), they are stored separately instead of simply as columns
* on a different table.
*
* @author Jon Ziebell
*/
class address extends cora\crud {
public static $exposed = [
'private' => [
'read_id'
],
'public' => []
];
public static $converged = [
'normalized' => [
'type' => 'json'
]
];
public static $user_locked = true;
/**
* Search for an address based on an address string. This will make an API
* call to Smarty Streets using that address string (after first checking
* the cache to see if we've done it before), then it will either create the
* address row for this user or return the existing one if it already
* exists.
*
* For example:
*
* 1. 123 Sesame St. (query smarty, insert row)
* 2. 123 Sesame Street (query smarty, return existing row)
* 3. 123 Sesame Street (query smarty (cached), return existing row)
*
* @param string $address_string Freeform address string
* @param string $country ISO 3 country code
*
* @return array The address row.
*/
public function search($address_string, $country) {
$normalized = $this->api(
'smarty_streets',
'smarty_streets_api',
[
'street' => $address_string,
'country' => $country
]
);
$key = $this->generate_key($normalized);
$existing_address = $this->get([
'key' => $key
]);
if($existing_address === null) {
return $this->create([
'key' => $key,
'normalized' => $normalized
]);
}
else {
return $existing_address;
}
}
/**
* Generate a key from the normalized address to see whether or not it's
* been stored before. Note that SmartyStreets does not recommend using the
* DPBC as a unique identifier. I am here, but the key is not intended to be
* a unique identifier for an address. It's meant to be a representation of
* the full details of an address. If the ZIP code changes for someone's
* house, I need to store that as a new address or the actual address will
* be incorrect.
*
* @link https://smartystreets.com/docs/addresses-have-unique-identifier
*
* @param string $normalized Normalized address as returned from
* SmartyStreets
*
* @return string
*/
private function generate_key($normalized) {
if(isset($normalized['delivery_point_barcode']) === true) {
return sha1($normalized['delivery_point_barcode']);
} else {
$string = '';
if(isset($normalized['address1']) === true) {
$string .= $normalized['address1'];
}
if(isset($normalized['address2']) === true) {
$string .= $normalized['address2'];
}
if(isset($normalized['address3']) === true) {
$string .= $normalized['address3'];
}
return sha1($string);
}
}
}

34
api/announcement.php Normal file
View File

@ -0,0 +1,34 @@
<?php
/**
* An address is a discrete object that is normalized and verified using a
* third party service. In order to prevent duplication and extra API calls
* (which cost money), they are stored separately instead of simply as columns
* on a different table.
*
* @author Jon Ziebell
*/
class announcement extends cora\crud {
public static $exposed = [
'private' => [],
'public' => [
'read_id'
]
];
public static $converged = [
'title' => [
'type' => 'string'
],
'text' => [
'type' => 'string'
],
'icon' => [
'type' => 'string'
]
];
public static $user_locked = false;
}

86
api/cora/api.php Normal file
View File

@ -0,0 +1,86 @@
<?php
namespace cora;
/**
* Base API class that everything extends.
*
* @author Jon Ziebell
*/
abstract class api {
/**
* The current resource.
*
* @var string
*/
protected $resource;
/**
* The database object.
*
* @var database
*/
protected $database;
/**
* Session object.
*
* @var session
*/
protected $session;
/**
* Setting object.
*
* @var setting
*/
protected $setting;
/**
* Cora object.
*
* @var cora
*/
protected $cora;
/**
* Construct and set the variables. The namespace is stripped from the
* resource variable. Anything that extends crud or API will use this
* constructor. This means that there should be no arguments or every time
* you want to use one of those resources you will have to find a way to
* pass in the arguments. Using a couple singletons here makes that a lot
* simpler.
*/
final function __construct() {
$this->resource = get_class($this);
$class_parts = explode('\\', $this->resource);
$this->table = end($class_parts);
$this->database = database::get_instance();
$this->cora = cora::get_instance();
$this->setting = setting::get_instance();
$this->session = session::get_instance();
}
/**
* Shortcut method for doing API calls within the API. This will create an
* instance of the resource you want and call the method you want with the
* arguments you want.
*
* @param string $resource The resource to use.
* @param string $method The method to call.
* @param mixed $arguments The arguments to send. If not an array then
* assumes a single argument.
*
* @return mixed
*/
public function api($resource, $method, $arguments = []) {
if(is_array($arguments) === false) {
$arguments = [$arguments];
}
$resource_instance = new $resource();
return call_user_func_array([$resource_instance, $method], $arguments);
}
}

115
api/cora/api_cache.php Normal file
View File

@ -0,0 +1,115 @@
<?php
namespace cora;
/**
* The API cache can be used to cache API calls for rapid response.
*
* @author Jon Ziebell
*/
class api_cache extends crud {
public static $converged = [];
public static $user_locked = true;
/**
* Insert an item into the current resource with the provided attributes.
* Setting of the primary key column is not allowed and will be overwritten
* if you try.
*
* @param array $attributes An array of attributes to set for this item
*
* @return mixed The id of the inserted row.
*/
public function create($attributes) {
unset($attributes['created_at']);
return parent::create($attributes);
}
/**
* Create an entry in the api_cache table. Generates a unique key based off
* of the resource, method, arguments, and session user_id if set.
*
* @param $api_call The API call to cache.
* @param $response_data Response to cache.
* @param $duration Duration in seconds to cache for.
*
* @return array The inserted row.
*/
public function cache($api_call, $response_data, $duration) {
$key = $this->generate_key($api_call);
$cache_hits = $this->read(['key' => $key]);
if(count($cache_hits) === 0) {
$attributes = [];
$attributes['key'] = $key;
$attributes['expires_at'] = date('Y-m-d H:i:s', time() + $duration);
$attributes['json_response_data'] = $response_data;
$attributes['request_resource'] = $api_call['resource'];
$attributes['request_method'] = $api_call['method'];
if(isset($api_call['arguments']) === true) {
$attributes['request_arguments'] = $api_call['arguments'];
}
else {
$attributes['request_arguments'] = null;
}
return $this->create($attributes);
}
else {
$cache_hit = $cache_hits[0];
$attributes = [];
$attributes['expires_at'] = date('Y-m-d H:i:s', time() + $duration);
$attributes['json_response_data'] = $response_data;
$attributes['api_cache_id'] = $cache_hit['api_cache_id'];
return $this->update($attributes);
}
}
/**
* Retrieve a cache entry with a matching key that is not expired.
*
* @param $api_call The API call to retrieve.
*
* @return mixed The api_cache row if found, else null.
*/
public function retrieve($api_call) {
$cache_hits = $this->read([
'key' => $this->generate_key($api_call)
]);
foreach($cache_hits as $cache_hit) {
if(time() < strtotime($cache_hit['expires_at'])) {
return $cache_hit;
}
}
return null;
}
/**
* Generate a cache key.
*
* @param $api_call The API call to generate the key for.
*
* @return string The cache key.
*/
private function generate_key($api_call) {
return sha1(
'resource=' . $api_call['resource'] .
'method=' . $api_call['method'] .
'arguments=' . (
isset($api_call['arguments']) === true ?
json_encode($api_call['arguments']) : ''
) .
'user_id=' . (
$this->session->get_user_id() !== null ?
$this->session->get_user_id() : ''
)
);
}
}

62
api/cora/api_log.php Normal file
View File

@ -0,0 +1,62 @@
<?php
namespace cora;
/**
* Stores a log of API requests and responses. Intended usage is to process
* the request to the end (exception or not) and then log it.
*
* @author Jon Ziebell
*/
class api_log extends crud {
public static $converged = [];
public static $user_locked = true;
/**
* Insert an item into the api_log resource. Force the IP to the request IP
* and disallow overriding the timestamp.
*
* @param array $attributes The attributes to insert.
*
* @return int The ID of the inserted row.
*/
public function create($attributes) {
$attributes['request_ip'] = ip2long($_SERVER['REMOTE_ADDR']);
unset($attributes['request_timestamp']);
return parent::create($attributes);
}
/**
* Get the number of requests since a given timestamp for a given IP
* address. Handy for rate limiting.
*
* @param string $request_ip The IP to look at.
* @param int $timestamp The timestamp to check from.
*
* @return int The number of requests on or after $timestamp.
*/
public function get_number_requests_since($request_ip, $timestamp) {
$request_ip_escaped = $this->database->escape(ip2long($request_ip));
$timestamp_escaped = $this->database->escape($timestamp);
$query = '
select
count(*) as number_requests_since
from
api_log
where
request_ip = ' . $request_ip_escaped . '
and request_timestamp >= from_unixtime(' . $timestamp_escaped . ')
';
// Getting the number of requests since a certain date is considered
// overhead since it's only used for rate limiting. See "Important" note in
// documentation.
$result = $this->database->query($query);
$row = $result->fetch_assoc();
return $row['number_requests_since'];
}
}

19
api/cora/api_user.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace cora;
/**
* Stuff related to API users. For now this is very basic, but this could be
* extended later on to allow creation and management of these users. At the
* very least, Cora needs to be able to see if the API user is valid based off
* of the API key.
*
* @author Jon Ziebell
*/
class api_user extends crud {
public static $converged = [];
public static $user_locked = false;
}

1054
api/cora/cora.php Normal file

File diff suppressed because it is too large Load Diff

191
api/cora/crud.php Normal file
View File

@ -0,0 +1,191 @@
<?php
namespace cora;
/**
* CRUD base class for most resources. Provides the ability to create
* (insert), read (select), update (update), and delete (update set
* deleted=1). There are also a few extra methods: read_id, get, and undelete.
*
* These methods can (and should) be overridden by child classes. The most
* basic override would simply call the parent function. More advanced
* overrides might set a value like created_by before creating.
*
* Child classes can, at any time, call the parent methods directly from any
* of their methods.
*
* @author Jon Ziebell
*/
abstract class crud extends api {
public static $converged = [];
public static $user_locked = true;
/**
* Insert an item into the current resource with the provided attributes.
* Setting of the primary key column is not allowed and will be overwritten
* if you try.
*
* @param array $attributes An array of attributes to set for this item
*
* @return mixed The id of the inserted row.
*/
public function create($attributes) {
unset($attributes[$this->table . '_id']);
if($this::$user_locked === true) {
$attributes['user_id'] = $this->session->get_user_id();
}
return $this->database->create($this->resource, $attributes);
}
/**
* Read items from the current resource according to the specified
* $attributes. Only undeleted items are selected by default. This can be
* altered by manually specifying deleted=1 or deleted=[0, 1] in
* $attributes.
*
* @param array $attributes An array of key value pairs to search by and can
* include arrays if you want to search in() something.
* @param array $columns The columns from the resource to return. If not
* specified, all columns are returned.
*
* @return array The requested items with the requested columns in a
* 0-indexed array.
*/
public function read($attributes = [], $columns = []) {
if($attributes === null) {
$attributes = [];
}
if($columns === null) {
$columns = [];
}
$attributes = $attributes + ['deleted' => 0];
if($this::$user_locked === true) {
$attributes['user_id'] = $this->session->get_user_id();
}
return $this->database->read($this->resource, $attributes, $columns);
}
/**
* See comment on crud->read() for more detail. The return array is
* indexed by the primary key of the resource items.
*
* @param array $attributes An array of key value pairs to search by and
* can include arrays if you want to search in() something.
* @param array $columns The columns from the resource to return. If not
* specified, all columns are returned.
*
* @return array The requested items with the requested colums in a primary-
* key-indexed array.
*/
public function read_id($attributes = [], $columns = []) {
if($attributes === null) {
$attributes = [];
}
if($columns === null) {
$columns = [];
}
// If no columns are specified to read, force the primary key column to be
// included. This will ensure that no error is thrown when the result of the
// query is converted into the ID array.
if(count($columns) > 0) {
$columns[] = $this->table . '_id';
}
$rows = $this->read($attributes, $columns);
$rows_id = [];
foreach($rows as $row) {
// Remove the *_id column and add in the row.
$rows_id[$row[$this->table . '_id']] = $row;
}
return $rows_id;
}
/**
* Get a single item, searching using whatever attributes you specify.
*
* @param array|int $attributes Search attributes or the ID of the row you
* want.
*
* @return array The found item.
*
* @throws \Exception If more than one item was found.
*/
public function get($attributes) {
// Doing this so I can call $this->get(#) which is pretty common.
if(is_array($attributes) === false) {
$id = $attributes;
$attributes = [];
$attributes[$this->table . '_id'] = $id;
}
$items = $this->read($attributes);
if(count($items) > 1) {
throw new \Exception('Tried to get but more than one item was returned.', 1100);
}
else if(count($items) === 0) {
return null;
}
else {
return $items[0];
}
}
/**
* Updates the current resource item with the provided id and sets the
* provided attributes.
*
* @param int $id The id of the item to update.
* @param array $attributes An array of attributes to set for this item.
*
* @return int The number of affected rows.
*/
public function update($attributes) {
// Get the item first to see if it exists. The get call will throw an
// exception if the ID you sent does not exist or cannot be read due to the
// user_locked setting.
$this->get($attributes[$this->table . '_id']);
return $this->database->update($this->resource, $attributes);
}
/**
* Deletes an item with the provided id from the current resource. Deletes
* always update the row to set deleted=1 instead of removing it from the
* database.
*
* @param int $id The id of the item to delete.
*
* @return array The deleted row.
*/
public function delete($id) {
$attributes = [];
$attributes[$this->table . '_id'] = $id;
$attributes['deleted'] = 1;
return $this->update($attributes);
}
/**
* Undeletes an item with the provided id from the current resource. This
* will update the row and set deleted = 0.
*
* @param int $id The id of the item to delete.
*
* @return array The undeleted row.
*/
public function undelete($id) {
$attributes = [];
$attributes[$this->table . '_id'] = $id;
$attributes['deleted'] = 0;
return $this->update($attributes);
}
}

798
api/cora/database.php Normal file
View File

@ -0,0 +1,798 @@
<?php
namespace cora;
/**
* This exception is thrown by database->query if the query failed due to a
* duplicate entry in the database.
*
* @author Jon Ziebell
*/
final class DuplicateEntryException extends \Exception {
};
/**
* This is a MySQLi database wrapper. It provides access to some basic
* functions like select, insert, and update. Those functions automatically
* escape table names, column names, and parameters for you using a number of
* the private functions defined here.
*
* Alternatively, you can write your own queries (and use the escape()
* function to help), and just call query() to run your own.
*
* @author Jon Ziebell
*/
final class database extends \mysqli {
/**
* The singleton.
*
* @var database
*/
private static $instance;
/**
* The second singleton...use sparingly. Used when a second connection to
* the database is needed to escape a transcation (ex: for writing tokens).
* It can be used for writing logs as well but that would open up two
* connections per API call which is bad.
*
* @var database
*/
private static $second_instance;
/**
* Whether or not a transaction has been started. Used to make sure only one
* is started at a time and it gets closed only if it's open.
*
* @var bool
*/
private $transaction_started = false;
/**
* The total number of queries executed.
*
* @var int
*/
private $query_count = 0;
/**
* The total time all queries have taken to execute.
*
* @var float;
*/
private $query_time = 0;
/**
* The cora object.
*
* @var cora
*/
private $cora;
/**
* The setting object.
*
* @var setting
*/
private $setting;
/**
* Create the mysql object used for the current API call and start a
* transaction. The same transaction is used for all queries on this
* connection, even in the case of a multi-api call. The transaction is
* auto- closed upon destruction of this class.
*
* This function is private because this class is a singleton and should be
* instantiated using the get_instance() function.
*
* @throws \Exception If failing to connect to the database.
*/
public function __construct() {
$this->cora = cora::get_instance();
$this->setting = setting::get_instance();
parent::__construct(
$this->setting->get('database_host'),
$this->setting->get('database_username'),
$this->setting->get('database_password')
);
// Have the database driver return ints and floats in PHP native types. PHP
// does not have a native type for decimals so that gets left behind.
parent::options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true);
if($this->connect_error !== null) {
$this->cora->set_error_extra_info(
[
'database_error' => $this->connect_error
]
);
throw new \Exception('Could not connect to database.', 1200);
}
$database_name = $this->setting->get('database_name');
if($database_name !== null) {
$success = $this->select_db($database_name);
if($success === false) {
$this->cora->set_error_extra_info(
[
'database_error' => $this->error
]
);
throw new \Exception('Could not select database.', 1208);
}
}
}
/**
* Upon destruction of this class, close the open transaction. I check to
* make sure one is open, but that should really always be the case since
* one gets opened regardless.
*/
public function __destruct() {
if($this->transaction_started === true) {
$this->commit_transaction();
}
}
/**
* Use this function to instantiate this class instead of calling new
* database() (which isn't allowed anyways). This avoids confusion from
* trying to use dependency injection by passing an instance of this class
* around everywhere. It also keeps a single connection open to the database
* for the current API call.
*
* @return database A new database object or the already created one.
*/
public static function get_instance() {
if(isset(self::$instance) === false) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Second instance; see comment on the class member declaration.
*
* @return database A new database object or the already created one.
*/
public static function get_second_instance() {
if(isset(self::$second_instance) === false) {
self::$second_instance = new self();
}
return self::$second_instance;
}
/**
* Start a transaction.
*
* @throws \Exception If the transaction fails to start.
*/
public function start_transaction() {
if($this->transaction_started === false) {
$result = $this->query('start transaction');
if($result === false) {
throw new \Exception('Failed to start database transaction.', 1201);
}
$this->transaction_started = true;
}
}
/**
* Commit a transaction.
*
* @throws \Exception If the transaction fails to commit.
*/
public function commit_transaction() {
if($this->transaction_started === true) {
$this->transaction_started = false;
$result = $this->query('commit');
if($result === false) {
throw new \Exception('Failed to commit database transaction.', 1202);
}
}
}
/**
* Rollback the current transaction. This is exposed because the exception
* handler needs to rollback the current transaction when it runs.
*
* @throws \Exception If the transaction fails to rollback.
*/
public function rollback_transaction() {
if($this->transaction_started === true) {
$this->transaction_started = false;
$result = $this->query('rollback');
if($result === false) {
throw new \Exception('Failed to rollback database transaction.', 1203);
}
}
}
/**
* Escape a value to be used in a query. Only necessary when doing custom
* queries. All helper functions like select, insert, and update escape
* values for you using this function.
*
* @param mixed $value The value to escape. Boolean true and false are
* converted to int 1 and 0 respectively.
* @param bool $basic If overridden to true, just return real_escape_string
* of $value. If left alone or set to false, return a value appropriate to
* be used like "set foo=$bar" as it will have single quotes around it if
* necessary.
*
* @return string The escaped value.
*/
public function escape($value, $basic = false) {
if($basic === true) {
return $this->real_escape_string($value);
}
if($value === null) {
return 'null';
}
else if($value === true) {
return '1';
}
else if($value === false) {
return '0';
}
else if(is_int($value) === true || ctype_digit($value) === true) {
return $value;
}
else {
return '"' . $this->real_escape_string($value) . '"';
}
}
/**
* Helper function to secure names of tables & columns passed to this class.
* First of all, these identifiers must be a valid word. Backticks are also
* placed around the identifier in all cases to allow the use of MySQL
* keywords as table and column names.
*
* @param string $identifier The identifier to escape
*
* @throws \Exception If the identifier does not match the character class
* [A-Za-z0-9_]. That would make it invalid for use in MySQL.
*
* @return string The escaped identifier.
*/
public function escape_identifier($identifier) {
if(preg_match('/^\w+$/', $identifier) === 1) {
return '`' . $identifier . '`';
}
else {
$this->cora->set_error_extra_info(
[
'identifier' => $identifier
]
);
throw new \Exception('Query identifier is invalid.', 1204);
}
}
/**
* Builds a properly escaped string for the 'where column=value' portion of
* a query.
*
* @param string $column The query column.
* @param mixed $value The value(s) to compare against. You can use null, an
* array, or any other value here and the appropriate comparison (is null,
* in, =) will be used.
*
* @throws \Exception If an invalid operator was specified.
*
* @return string The appropriate escaped string. Examples: `foo` is null
* `foo` in(1,2,3) `foo`='bar'
*/
private function column_equals_value_where($column, $value) {
if($value === null) {
return $this->escape_identifier($column) . ' is null';
}
else if(is_array($value) === true) {
if(isset($value['operator']) === true) {
if(in_array($value['operator'], ['>', '<', '=', '>=', '<=', 'between']) === false) {
throw new \Exception('Invalid operator', 1213);
}
if($value['operator'] === 'between') {
return $this->escape_identifier($column) . ' between ' . $this->escape($value['value'][0]) . ' and ' . $this->escape($value['value'][1]);
} else {
return $this->escape_identifier($column) . $value['operator'] . $this->escape($value['value']);
}
}
else {
return $this->escape_identifier($column) .
' in (' . implode(',', array_map([$this, 'escape'], $value)) . ')';
}
}
else {
return $this->escape_identifier($column) . '=' . $this->escape($value);
}
}
/**
* Builds a properly escaped string for the 'set column=value' portion of a
* query.
*
* @param string $column The query column.
* @param mixed $value The value to set.
*
* @return string The appropriate escaped string. Examples: `foo`='bar'
* `foo`=5
*/
private function column_equals_value_set($column, $value) {
return $this->escape_identifier($column) . '=' . $this->escape($value);
}
/**
* Performs a query on the database. This function is available publicly for
* the case when the standard select, insert, and update don't quite cut it.
*
* The exceptions are broken up somewhat by type to make it easier to catch
* and handle these exceptions if desired.
*
* This will start a transaction if the query begins with 'insert',
* 'update', or 'delete' and a transaction has not already been started.
*
* IMPORTANT: YOU MUST SANTIZE YOUR OWN DATABASE QUERY WHEN USING THIS
* FUNCTION DIRECTLY. THIS FUNCTION DOES NOT DO IT FOR YOU.
*
* @param string $query The query to execute.
*
* @throws DuplicateEntryException if the query failed due to a duplicate
* entry (unique key violation)
* @throws \Exception If the query failed and was not caught by any other
* exception types.
*
* @return mixed The result directly from $mysqli->query.
*/
public function query($query, $resultmode = NULL) {
// If this was an insert, update or delete, start a transaction
$query_type = substr(trim($query), 0, 6);
if(
in_array($query_type, ['insert', 'update', 'delete']) === true &&
$this->setting->get('use_transactions') === true
) {
$this->start_transaction();
}
$start = microtime(true);
$result = parent::query($query);
$stop = microtime(true);
if($result === false) {
$database_error = $this->error;
$this->rollback_transaction();
$this->cora->set_error_extra_info(
[
'database_error' => $database_error,
'query' => $query
]
);
if(stripos($database_error, 'duplicate entry') !== false) {
throw new DuplicateEntryException('Duplicate database entry.', 1205);
}
else {
throw new \Exception('Database query failed.', 1206);
}
}
// Don't log info about transactions...they're a wash
$this->query_count++;
$this->query_time += ($stop - $start);
return $result;
}
/**
* Select some columns from some table with some where clause.
*
* @param string $resource The resource to select from.
* @param array $attributes An array of key value pairs to search by and can
* include arrays if you want to search in() something.
* @param array $columns The columns to return. If not specified, all
* columns are returned.
*
* @return array An array of the database rows with the specified columns.
* Even a single result will still be returned in an array of size 1.
*/
public function read($resource, $attributes = [], $columns = []) {
$table = $this->get_table($resource);
// Build the column listing.
if(count($columns) === 0) {
$columns = '*';
}
else {
$columns = implode(
',',
array_map([$this, 'escape_identifier'], $columns)
);
}
// Remove empty arrays from the attributes to avoid query errors on the
// empty in() statement.
foreach($attributes as $key => $value) {
if(is_array($value) === true && count($value) === 0) {
unset($attributes[$key]);
}
}
// Build the where clause.
if(count($attributes) === 0) {
$where = '';
}
else {
$where = ' where ' .
implode(
' and ',
array_map(
[$this, 'column_equals_value_where'],
array_keys($attributes),
$attributes
)
);
}
// Put everything together and return the result.
$query = 'select ' . $columns . ' from ' .
$this->escape_identifier($table) . $where;
$result = $this->query($query);
/**
* Get a list of all fields that need to be cast to other data types. The
* MYSQLI_OPT_INT_AND_FLOAT_NATIVE flag handles ints and floats. This
* turns decimal into float and tinyint(1) into boolean.
*/
$float_fields = [];
$boolean_fields = [];
$json_fields = [];
while($field_info = $result->fetch_field()) {
if($field_info->type === 1 && $field_info->length === 1) {
$boolean_fields[] = $field_info->name;
}
else if($field_info->type === 246) {
$float_fields[] = $field_info->name;
}
else if($field_info->type === 245) {
$json_fields[] = $field_info->name;
}
else if(substr($field_info->name, 0, 5) === 'json_') {
// TODO This will go away as soon as I switch to json type columns.
$json_fields[] = $field_info->name;
}
}
$results = [];
while($row = $result->fetch_assoc()) {
// Cast if necessary.
foreach($float_fields as $float_field) {
$row[$float_field] = (float) $row[$float_field];
}
foreach($boolean_fields as $boolean_field) {
$row[$boolean_field] = (boolean) $row[$boolean_field];
}
foreach($json_fields as $json_field) {
$row[$json_field] = json_decode($row[$json_field], true);
}
// Diverge the converged column.
if(
isset($row['converged']) === true &&
class_exists($resource) === true && // This will also call the autoloader to make sure it's loaded
isset($resource::$converged) === true
) {
foreach($resource::$converged as $column => $column_properties) {
if(isset($row[$column]) === true) {
throw new \Exception('Column `' . $column . '` exists; cannot be overwritten by converged column.', 1212);
}
$row[$column] = (isset($row['converged'][$column]) === true) ? $row['converged'][$column] : null;
}
unset($row['converged']);
}
$results[] = $row;
}
return $results;
}
/**
* Converge attributes.
*
* @param string $resource
* @param int $id
* @param array $attributes
*
* @return array
*/
private function converge_attributes($resource, $id, $attributes) {
if(
class_exists($resource) === true && // This will also call the autoloader to make sure it's loaded
isset($resource::$converged) === true &&
empty($resource::$converged) === false
) {
$table = $this->get_table($resource);
if($id !== null) {
$existing_attributes = [];
$existing_attributes[$table . '_id'] = $id;
$existing_row = $this->read($resource, $existing_attributes)[0];
}
else {
$existing_row = [];
}
$converged = [];
foreach($resource::$converged as $column => $column_properties) {
if(isset($existing_row[$column]) === true) {
$converged[$column] = $existing_row[$column];
}
if(array_key_exists($column, $attributes) === true) {
if($attributes[$column] === null) {
unset($converged[$column]);
}
else {
switch($column_properties['type']) {
case 'int':
case 'float':
case 'string':
settype($attributes[$column], $column_properties['type']);
break;
}
$converged[$column] = $attributes[$column];
}
unset($attributes[$column]);
}
}
$attributes['converged'] = json_encode($converged);
}
return $attributes;
}
/**
* Update some columns in a table by the primary key. Doing updates without
* using the primary key are supported by writing your own queries and using
* the database->query() function. That should be a rare circumstance
* though.
*
* @param string $resource The resource to update.
* @param array $attributes The attributes to set.
*
* @throws \Exception If no attributes were specified.
*
* @return int The updated row.
*/
public function update($resource, $attributes) {
$table = $this->get_table($resource);
// TODO This will go away as soon as I switch to json type columns.
foreach($attributes as $key => $value) {
if(substr($key, 0, 5) === 'json_') {
if($value === null) {
$attributes[$key] = null;
}
else {
$attributes[$key] = json_encode($value);
}
}
}
// Check for errors
if(isset($attributes[$table . '_id']) === false) {
throw new \Exception('ID is required for update.', 1214);
}
// Extract the ID.
$id = $attributes[$table . '_id'];
unset($attributes[$table . '_id']);
// Check for errors
if(count($attributes) === 0) {
throw new \Exception('Updates require at least one attribute.', 1207);
}
// Converge the diverged attributes.
$attributes = $this->converge_attributes($resource, $id, $attributes);
// Build the column setting
$columns = implode(
',',
array_map(
[$this, 'column_equals_value_set'],
array_keys($attributes),
$attributes
)
);
// Build the where clause
$where_attributes = [$table . '_id' => $id];
$where = 'where ' .
implode(
' and ',
array_map(
[$this, 'column_equals_value_where'],
array_keys($where_attributes),
$where_attributes
)
);
$query = 'update ' . $this->escape_identifier($table) .
' set ' . $columns . ' ' . $where;
// Disallow updates in the demo.
if($this->setting->is_demo() === true) {
return $this->read($resource, $where_attributes)[0];
}
$this->query($query);
return $this->read($resource, $where_attributes)[0];
}
/**
* Actually delete a row from a table by the primary key.
*
* @param string $table The table to delete from.
* @param int $id The value of the primary key to delete.
*
* @return int The number of rows affected by the delete (could be 0).
*/
public function delete($table, $id) {
$query = 'delete from ' . $this->escape_identifier($table) .
' where ' . $this->escape_identifier($table . '_id') . ' = ' .
$this->escape($id);
$this->query($query);
return $this->affected_rows;
}
/**
* Insert a row into the specified table. This only supports single-row
* inserts.
*
* @param string $table The table to insert into.
* @param array $attributes The attributes to set on the row
*
* @return int The primary key of the inserted row.
*/
public function create($resource, $attributes) {
$table = $this->get_table($resource);
// TODO This will go away as soon as I switch to json type columns.
foreach($attributes as $key => $value) {
if(substr($key, 0, 5) === 'json_') {
if($value === null) {
$attributes[$key] = null;
}
else {
$attributes[$key] = json_encode($value);
}
}
}
$attributes = $this->converge_attributes($resource, null, $attributes);
$columns = implode(
',',
array_map([$this, 'escape_identifier'], array_keys($attributes))
);
$values = implode(
',',
array_map([$this, 'escape'], $attributes)
);
$query =
'insert into ' . $this->escape_identifier($table) .
'(' . $columns . ') values (' . $values . ')';
$this->query($query);
$read_attributes = [];
$read_attributes[$table . '_id'] = $this->insert_id;
return $this->read($resource, $read_attributes)[0];
// return $this->insert_id;
}
/**
* Gets the number of queries that have been executed.
*
* @return int The query count.
*/
public function get_query_count() {
return $this->query_count;
}
/**
* Gets the time taken to execute all of the queries.
*
* @return float The total execution time.
*/
public function get_query_time() {
return $this->query_time;
}
/**
* Turn a resource into a table.
*
* @param string $resource
*
* @return string
*/
private function get_table($resource) {
$class_parts = explode('\\', $resource);
return end($class_parts);
}
/**
* Attempt to get a database lock.
*
* @param string $lock_name The lock name.
* @param int $timeout How long to wait for the lock. Negative values are
* forever.
*
* @throws \Exception If the lock could not be obtained.
*/
public function get_lock($lock_name, $timeout = 0) {
$result = $this->query('
select
get_lock(' .
$this->escape($this->setting->get('database_name') . '_' . $lock_name) . ', ' .
$this->escape($timeout) .
') `lock`
');
$row = $result->fetch_assoc();
if($row['lock'] !== 1) {
throw new \Exception('Could not get lock.', 1209);
}
}
/**
* Attempt to release a database lock.
*
* @param string $lock_name The lock name.
*
* @throws \Exception If the lock was not established by this thread.
* @throws \Exception If the lock does not exist.
*/
public function release_lock($lock_name) {
$result = $this->query('
select
release_lock(' . $this->escape($this->setting->get('database_name') . '_' . $lock_name) . ') `lock`
');
$row = $result->fetch_assoc();
if($row['lock'] === 0) {
throw new \Exception('Lock not established by this thread.', 1210);
}
else if($row['lock'] === null) {
throw new \Exception('Lock does not exist.', 1211);
}
}
/**
* Set the time zone. Useful for querying in a certain time zone so MySQL
* will just handle the conversion.
*
* @param int $time_zone_offset Offset in minutes.
*/
public function set_time_zone($time_zone_offset) {
// $time_zone_offset = -360;
$operator = $time_zone_offset < 0 ? '-' : '+';
$time_zone_offset = abs($time_zone_offset);
$offset_hours = floor($time_zone_offset / 60);
$offset_minutes = $time_zone_offset % 60;
// var_dump($offset_hours);
// var_dump($offset_minutes);
// var_dump('SET time_zone = "' . $operator . sprintf('%d', $offset_hours) . ':' . str_pad($offset_minutes, 2, STR_PAD_LEFT) . '"');
// die();
$this->query('SET time_zone = "' . $operator . sprintf('%d', $offset_hours) . ':' . str_pad($offset_minutes, 2, STR_PAD_LEFT) . '"');
}
}

68
api/cora/json_path.php Normal file
View File

@ -0,0 +1,68 @@
<?php
namespace cora;
/**
* For lack of a better name, I'm calling this the json_path class. It does
* not provide a full implementation of JSONPath and may never do so as a lot
* of the functionality is overkill or not appropriate. This provides super
* basic functionality only.
*
* @author Jon Ziebell
*/
final class json_path {
/**
* Given a data array and a path, return the requested data. Example paths:
* foo.bar
* foo.0.bar
* foo.bar.baz
* foo.bar.baz[] To get all baz inside bar array.
*
* @param mixed $data The data referenced by $json_path.
* @param string $json_path The path.
*
* @return mixed The requested data.
*/
public function evaluate($data, $json_path) {
// Split up the path into an array.
$key_array = explode('.', $json_path);
return $this->extract_key($data, $key_array);
}
/**
* Recursively extract keys from the data array. Basically, $data.foo.bar is
* represented by sending $data, ['foo', 'bar']. If you end a key with
* [] it will get every instance of that value inside the current array.
*
* @param mixed $data The data to traverse. You can send anything here but
* don't expect to not get an exception if you try to traverse things like
* non-existent indices.
* @param array $key_array The array keys to use to traverse $data.
*
* @throws \Exception If any of the provided keys do not exist in the data.
*
* @return mixed The requested data.
*/
private function extract_key($data, $key_array) {
$key = array_shift($key_array);
if($key === null) {
return $data;
}
else {
if(substr($key, -2) === '[]') {
return array_column($data, substr($key, 0, strlen($key) - 2));
}
else {
if(array_key_exists($key, $data) === false) {
throw new \Exception('Invalid path string.', 1500);
}
else {
return $this->extract_key($data[$key], $key_array);
}
}
}
}
}

324
api/cora/session.php Normal file
View File

@ -0,0 +1,324 @@
<?php
namespace cora;
/**
* Offers session-related functions.
*
* @author Jon Ziebell
*/
final class session {
public static $converged = [];
public static $user_locked = true;
/**
* The session_key for this session.
*
* @var string
*/
private $session_key = null;
/**
* The user_id for this session.
*
* @var int
*/
private $user_id = null;
/**
* The singleton.
*
* @var session
*/
private static $instance;
/**
* Constructor
*/
private function __construct() {}
/**
* Use this function to instantiate this class instead of calling new
* session() (which isn't allowed anyways). This avoids confusion from
* trying to use dependency injection by passing an instance of this class
* around everywhere.
*
* @return session A new session object or the already created one.
*/
public static function get_instance() {
if(isset(self::$instance) === false) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Return whether or not this class has been instantiated.
*
* @return bool
*/
public static function has_instance() {
return isset(self::$instance);
}
/**
* Request a session. This method sets a couple cookies and returns the
* session key. By default, all cookies except those set in
* $additional_cookie_values are marked as httponly, which means only the
* server can access them. Use the following table to determine when the
* local cookie will be set to expire.
*
* timeout | life | expire
* -------------------------------
* null | null | never expires due to inactivity, never expires due to time limit
* null | set | never expires due to inactivity, expires in life seconds
* set | null | expires after timeout inactivity, never expires due to time limit
* set | set | expires after timeout inactivity, expires in life seconds
*
* @param int $timeout How long, in seconds, until the session expires due
* to inactivity. Set to null for no timeout.
* @param int $life How long, in seconds, until the session expires. Set to
* null for no expiration.
* @param int $user_id An optional external integer pointer to another
* table. This will most often be user.user_id, but could be something like
* person.person_id or player.player_id.
* @param array $additional_cookie_values Set additional values in the
* cookie by setting this value. Doing this is generally discouraged as
* cookies add state to the application, but something like a username for a
* "remember me" checkbox is reasonable.
*
* @return string The generated session key.
*/
public function request($timeout = null, $life = null, $user_id = null, $additional_cookie_values = null) {
$database = database::get_instance();
$session_key = $this->generate_session_key();
$database->create(
'cora\session',
[
'session_key' => $session_key,
'timeout' => $timeout,
'life' => $life,
'user_id' => $user_id,
'created_by' => ip2long($_SERVER['REMOTE_ADDR']),
'last_used_by' => ip2long($_SERVER['REMOTE_ADDR']),
'last_used_at' => date('Y-m-d H:i:s')
]
);
// Set the local cookie expiration.
if($life !== null) {
$expire = time() + $life;
}
else {
if($timeout === null) {
$expire = 4294967295; // 2038
}
else {
$expire = 0; // Browser close
}
}
// Set all of the necessary cookies. Both *_session_key and *_user_id are
// read every API request and made available to the API.
$this->set_cookie('session_key', $session_key, $expire);
$this->set_cookie('session_user_id', $user_id, $expire);
if(isset($additional_cookie_values) === true) {
foreach($additional_cookie_values as $key => $value) {
$this->set_cookie($key, $value, $expire, false);
}
}
$this->session_key = $session_key;
$this->user_id = $user_id;
return $session_key;
}
/**
* Similar to the Linux touch command, this method "touches" the session and
* updates last_used_at and last_used_by. This is executed every time a
* request that requires a session is sent to the API. Note that this uses
* the cookie sent by the client directly so there is no default way to
* touch a session unless you are the one logged in to it.
*
* @param $string session_key The session_key to use. If not set, will use
* $_COOKIE['session_key'].
*
* @return bool True if it was successfully updated, false if the session
* does not exist or is expired. Basically, return bool whether or not the
* sesion is valid.
*/
public function touch($session_key = null) {
if($session_key === null && isset($_COOKIE['session_key']) === true) {
$session_key = $_COOKIE['session_key'];
}
if($session_key === null) {
$this->delete_cookie('session_key');
$this->delete_cookie('session_user_id');
return false;
}
$database = database::get_instance();
$sessions = $database->read(
'cora\session',
[
'session_key' => $session_key,
'deleted' => 0
]
);
if(count($sessions) === 1) {
$session = $sessions[0];
// Check for expired session.
if(
(
$session['timeout'] !== null &&
(strtotime($session['last_used_at']) + strtotime($session['timeout'])) < time()
) ||
(
$session['life'] !== null &&
(strtotime($session['last_used_at']) + strtotime($session['life'])) < time()
)
) {
$this->delete_cookie('session_key');
$this->delete_cookie('session_user_id');
return false;
}
$database->update(
'cora\session',
[
'session_id' => $session['session_id'],
'last_used_at' => date('Y-m-d H:i:s'),
'last_used_by' => ip2long($_SERVER['REMOTE_ADDR'])
]
);
$this->session_key = $session['session_key'];
$this->user_id = $session['user_id'];
}
else {
$this->delete_cookie('session_key');
$this->delete_cookie('session_user_id');
return false;
}
}
private function invalidate_cookies() {
}
/**
* Delete the session with the provided session_key. If no session_key is
* provided, delete the current session. This function is provided to aid
* session management. Call it with no parameters for something like
* user->log_out(), or set $session_key to end a specific session. You would
* typically want to have your own permission layer on top of that to enable
* only admins to do that.
*
* @param string $session_key The session key of the session to delete.
*
* @return bool True if it was successfully deleted. Could return false for
* a non-existent session key or if it was already deleted.
*/
public function delete($session_key = null) {
$database = database::get_instance();
if($session_key === null) {
$session_key = $this->session_key;
}
$sessions = $database->read('cora\session', ['session_key' => $session_key]);
if(count($sessions) === 1) {
$database->update(
'cora\session',
[
'session_id' => $sessions[0]['session_id'],
'deleted' => 1
]
);
// Remove these if the current session got logged out.
if($session_key === $this->session_key) {
$this->session_key = null;
$this->user_id = null;
}
return true;
}
return false;
}
/**
* Get the user_id on this session. Useful for getting things like the
* user_id for the currently logged in user.
*
* @return int The current user_id.
*/
public function get_user_id() {
return $this->user_id;
}
public function delete_user_id() {
$this->user_id = null;
}
/**
* Generate a random (enough) session key.
*
* @return string The generated session key.
*/
private function generate_session_key() {
return strtolower(sha1(uniqid(mt_rand(), true)));
}
/**
* Sets a cookie. If you want to set custom cookies, use the
* $additional_cookie_valeus argument on $session->create().
*
* @param string $name The name of the cookie.
* @param mixed $value The value of the cookie.
* @param int $expire When the cookie should expire.
* @param bool $httponly True if the cookie should only be accessible on the
* server.
*
* @throws \Exception If The cookie fails to set.
*/
private function set_cookie($name, $value, $expire, $httponly = true) {
$this->setting = setting::get_instance();
$path = '/'; // The current directory that the cookie is being set in.
$secure = $this->setting->get('force_ssl');
$domain = null;
if($domain === null) { // See setting documentation for more info.
$domain = '';
}
$cookie_success = setcookie(
$name,
$value,
$expire,
$path,
$domain,
$secure,
$httponly
);
if($cookie_success === false) {
throw new \Exception('Failed to set cookie.', 1400);
}
}
/**
* Delete a cookie. This will remove the cookie value and set it to expire 1
* day ago.
*
* @param string $name The name of the cookie to delete.
*/
private function delete_cookie($name) {
$this->set_cookie($name, '', time() - 86400);
}
}

View File

@ -0,0 +1,227 @@
<?php
namespace cora;
/**
* All of the settings used in Cora. This file should never be committed to
* source control and should contain all of the configuration, API keys, etc.
*
* @author Jon Ziebell
*/
final class setting {
/**
* The singleton.
*/
private static $instance;
/**
* Constructor.
*/
private function __construct() {}
/**
* Use this function to instantiate this class instead of calling new
* setting() (which isn't allowed anyways). This is necessary so that the
* API class can have access to Setting.
*
* @return setting A new setting object or the already created one.
*/
public static function get_instance() {
if(isset(self::$instance) === false) {
self::$instance = new self();
}
return self::$instance;
}
private $settings = [
/**
* Which environment context the code is running in. Every setting can
* either be a single value or an array of values, where the key is the
* environment. Allows you to use a single configuration file for
* dev/stage/live.
*
* Setting this to dev will make it easier to debug issues as many error
* details are hidden in live mode.
*
* Valid values are:
* dev: Development environment, points to all development resources.
* stage: Staging environment, typically points to all live resources.
* live: Live environment, points to all live resources.
*/
'environment' => 'dev',
/**
* Used to uniquely identify this particular commit.
*/
'commit' => null,
/**
* The beestat API key for when ecobee makes an API call to beestat.
*
* Example: 2hFpGKrsS586hHaU9g6vZZdQS586hHaUwY9kdctx
*/
'ecobee_api_key_local' => '',
/**
* Your ecobee Client ID; provided to you when you create an app as an
* ecobee developer.
*
* Example: 5tEd6Fdhw8HebcS7pD8gKtgMvuczqp88
*/
'ecobee_client_id' => '',
/**
* URI to redirect to after you authorize your app to access your ecobee
* account. Set this here and when creating your ecobee app.
*
* Example: https://beestat.io/api/ecobee_initialize.php
*/
'ecobee_redirect_uri' => '',
/**
* The Patreon API key for when Patreon makes an API call to beestat.
*
* Example: 2hFpGKrsS586hHaA9g6vZZdQS586hHaUwY9kdctx
*/
'ecobee_api_key_local' => '',
/**
* Your Patreon Client ID; provided to you when you create an app as a
* Patreon developer.
*
* Example: 8HebcS7pD8_d6Fdhw8Heb-ebcS7pD8gKtgMvuczq-tEd6Fdhw8Heb_S7pD8gKtgMv
*/
'patreon_client_id' => '',
/**
* URI to redirect to after you authorize your app to access your ecobee
* account. Set this here and when creating your ecobee app.
*
* Example: https://beestat.io/api/patreon_initialize.php
*/
'patreon_redirect_uri' => '',
/**
* Used anytime the API needs to know where the site is at. Don't forget
* the trailing slash.
*
* Example: https://beestat.io/
*/
'beestat_root_uri' => '',
/**
* Your Mailchimp API Key; provided to you when you create a Mailchimp
* developer account.
*
* Example: hcU74TJgGS5k7vuw3NSzkRMSWNPkv8Af-us18
*/
'mailchimp_api_key' => '',
/**
* ID of the mailing list to send emails to.
*
* Example: uw3NSzkRMS
*/
'mailchimp_list_id' => '',
/**
* Auth ID for Smarty Streets address verification.
*
* Example: 7vuw3NSz-TJgG-v8Af-7vuw-4TJgGS5k7vuw
*/
'smarty_streets_auth_id' => '',
/**
* Auth Token for Smarty Streets address verification.
*
* Example: gGS5k7vuw3NSzkRMSWNP
*/
'smarty_streets_auth_token' => '',
/**
* Whether or not debugging is enabled. Debugging will produce additional
* output in the API response.
*/
'debug' => true,
/**
* Primary database connection information. Must be a MySQL database.
*/
'database_host' => '',
'database_username' => '',
'database_password' => '',
'database_name' => '',
/**
* Influx database connection information. This is where most logging is
* sent to.
*/
'influx_database_host' => '',
'influx_database_port' => 8086,
'influx_database_name' => '',
'influx_database_username' => '',
'influx_database_password' => '',
/**
* Whether or not SSL is required.
*/
'force_ssl' => true,
/**
* The number of requests allowed from a given IP address per minute. Set
* to null to disable.
*/
'requests_per_minute' => null,
/**
* The number of requests allowed in a single batch API call. Set to null
* to disable.
*/
'batch_limit' => null,
/**
* Whether or not to wrap each individual or batch API call in a single
* transaction. When disabled, transactions are available but not used
* automatically.
*
* This must be set to false for now.
*/
'use_transactions' => false
];
/**
* Get a setting. Will return the setting for the current environment if it
* exists.
*
* @param string $setting The setting name.
*
* @throws \Exception If the setting does not exist.
*
* @return mixed The setting
*/
public function get($setting) {
if(isset($this->settings[$setting]) === true) {
if(isset($this->settings[$setting][$this->settings['environment']]) === true) {
return $this->settings[$setting][$this->settings['environment']];
}
else {
return $this->settings[$setting];
}
}
else {
throw new \Exception('Setting does not exist.', 1300);
}
}
/**
* Whether or not the current configuration is running the demo.
*
* @return boolean
*/
public function is_demo() {
return false;
}
}

249
api/ecobee.php Normal file
View File

@ -0,0 +1,249 @@
<?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_influx = true;
protected static $log_mysql = 'error';
protected static $cache = false;
protected static $cache_for = 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');
die();
}
/**
* 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]);
// Get the thermostat list from ecobee.
$response = $this->ecobee_api(
'GET',
'thermostat',
[
'body' => json_encode([
'selection' => [
'selectionType' => 'registered',
'selectionMatch' => '',
'includeRuntime' => true,
'includeNotificationSettings' => true
]
])
],
false,
$ecobee_token
);
$guids = [];
$email_addresses = [];
foreach($response['thermostatList'] as $thermostat) {
$runtime = $thermostat['runtime'];
$guid = sha1($thermostat['identifier'] . $runtime['firstConnected']);
$guids[] = $guid;
$notification_settings = $thermostat['notificationSettings'];
$email_addresses = array_merge($email_addresses, $notification_settings['emailAddresses']);
}
// Look to see if any of the returned thermostats exist. This does not use
// CRUD because it needs to bypass the user_id restriction (also I don't
// think you're logged in yet)
$existing_ecobee_thermostats = $this->database->read(
'ecobee_thermostat',
[
'guid' => $guids
]
);
// 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_ecobee_thermostats) > 0 &&
count(array_unique(array_column($existing_ecobee_thermostats, 'user_id'))) === 1
) {
$this->api(
'user',
'force_log_in',
['user_id' => $existing_ecobee_thermostats[0]['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]);
if(count($email_addresses) > 0) {
try {
$this->api(
'mailchimp',
'subscribe',
[
'email_address' => $email_addresses[0]
]
);
} catch(Exception $e) {
// Ignore failed subscribe exceptions since it's not critical to the
// success of this. Everything is logged regardless.
}
}
}
// Redirect to the proper location.
header('Location: ' . $this->setting->get('beestat_root_uri') . 'dashboard/');
}
else if(isset($error) === true) {
throw new Exception($error_description);
}
else {
throw new Exception('Unhandled error');
}
}
/**
* 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.
* @param string $ecobee_token Force-use a specific token.
*
* @return array The response of this API call.
*/
public function ecobee_api($method, $endpoint, $arguments, $auto_refresh_token = true, $ecobee_token = null) {
$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($ecobee_token === null) {
$ecobee_tokens = $this->api(
'ecobee_token',
'read',
[]
);
if(count($ecobee_tokens) !== 1) {
throw new Exception('No token for this user');
}
$ecobee_token = $ecobee_tokens[0];
}
$curl['header'] = [
'Authorization: Bearer ' . $ecobee_token['access_token']
];
}
else {
$full_endpoint = '/' . $full_endpoint;
}
$curl['url'] = 'https://api.ecobee.com' . $full_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);
}
throw new Exception('Invalid JSON');
}
// 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) {
$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 Exception($response['status']['message']);
}
}
else if (isset($response['status']) === true && $response['status']['code'] !== 0) {
// Any other error
if($this::$log_mysql !== 'all') {
$this->log_mysql($curl_response);
}
throw new Exception($response['status']['message']);
}
else {
return $response;
}
}
}

8
api/ecobee_api_cache.php Normal file
View File

@ -0,0 +1,8 @@
<?php
/**
* Cache for these external API calls.
*
* @author Jon Ziebell
*/
class ecobee_api_cache extends external_api_cache {}

8
api/ecobee_api_log.php Normal file
View File

@ -0,0 +1,8 @@
<?php
/**
* Log for these external API calls.
*
* @author Jon Ziebell
*/
class ecobee_api_log extends external_api_log {}

29
api/ecobee_initialize.php Normal file
View File

@ -0,0 +1,29 @@
<?php
/**
* Ecobee hits this file after authorizing. Ecobee (or maybe oauth) does not
* support URL parameters in the redirect_uri, so redirecting here and then
* redirecting again to the API. ¯\_()_/¯
*
* @author Jon Ziebell
*/
require 'cora/setting.php';
$arguments = [];
if(isset($_GET['code']) === true) {
$arguments['code'] = $_GET['code'];
}
if(isset($_GET['error']) === true) {
$arguments['error'] = $_GET['error'];
}
if(isset($_GET['error_description']) === true) {
$arguments['error_description'] = $_GET['error_description'];
}
$setting = cora\setting::get_instance();
header('Location: ' . $setting->get('beestat_root_uri') . 'api/index.php?resource=ecobee&method=initialize&arguments=' . json_encode($arguments) . '&api_key=' . $setting->get('ecobee_api_key_local'));
die();

View File

@ -0,0 +1,489 @@
<?php
/**
* All of the raw thermostat data sits here. Many millions of rows.
*
* @author Jon Ziebell
*/
class ecobee_runtime_thermostat extends cora\crud {
public static $exposed = [
'private' => [
'get_recent_activity',
'get_aggregate_runtime',
'sync'
],
'public' => []
];
public static $cache = [
'sync' => 3600, // 1 Hour
'get_recent_activity' => 300, // 5 Minutes
'get_aggregate_runtime' => 3600, // 1 Hour
];
public static $converged = [];
public static $user_locked = true;
/**
* Main function for syncing thermostat data. Looks at the current state of
* things and decides which direction (forwards or backwards) makes the most
* sense.
*
* @param int $thermostat_id Optional thermostat_id to sync. If not set will
* sync all thermostats attached to this user.
*/
public function sync($thermostat_id = null) {
// Skip this for the demo
if($this->setting->is_demo() === true) {
return;
}
set_time_limit(0);
if($thermostat_id === null) {
$thermostat_ids = array_keys(
$this->api(
'thermostat',
'read_id',
[
'attributes' => [
'inactive' => 0
]
]
)
);
} else {
$thermostat_ids = [$thermostat_id];
}
foreach($thermostat_ids as $thermostat_id) {
// Get a lock to ensure that this is not invoked more than once at a time
// per thermostat.
$lock_name = 'ecobee_runtime_thermostat->sync(' . $thermostat_id . ')';
$this->database->get_lock($lock_name);
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
if($thermostat['sync_begin'] === null) {
$this->sync_backwards($thermostat_id);
} else {
$this->sync_forwards($thermostat_id);
}
// TODO: If only syncing one thermostat this will delay the sync of the
// other thermostat. Not a huge deal, just FYI.
$this->api(
'user',
'update_sync_status',
[
'key' => 'ecobee_runtime_thermostat'
]
);
$this->database->release_lock($lock_name);
}
}
/**
* Sync backwards from now until thermostat.first_connected. This should
* only be used when syncing for the first time.
*
* @param int $ecobee_thermostat_id
*/
private function sync_backwards($thermostat_id) {
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
if($thermostat['sync_begin'] !== null) {
throw new \Exception('Full sync already performed; must call sync_forwards() now.');
}
// Sync from when the thermostat was first connected until now.
$sync_begin = strtotime($thermostat['first_connected']);
$sync_end = time();
$chunk_begin = $sync_end;
$chunk_end = $sync_end;
// Loop over the dates and do the actual sync. Each chunk is wrapped in a
// transaction for a little bit of protection against exceptions introducing
// bad data and causing the whole sync to fail.
do {
$this->database->start_transaction();
$chunk_begin = strtotime('-1 week', $chunk_end);
$chunk_begin = max($chunk_begin, $sync_begin);
$this->sync_($thermostat['ecobee_thermostat_id'], $chunk_begin, $chunk_end);
// Update the thermostat with the current sync range
$this->api(
'thermostat',
'update',
[
'attributes' => [
'thermostat_id' => $thermostat['thermostat_id'],
'sync_begin' => date('Y-m-d H:i:s', $chunk_begin),
'sync_end' => date('Y-m-d H:i:s', $sync_end),
]
]
);
// Because I am doing day-level syncing this will end up fetching an
// overlapping day of data every time. But if I properly switch this to
// interval-level syncing this should be correct or at the very least
// return a minimal one extra row of data.
$chunk_end = $chunk_begin;
$this->database->commit_transaction();
} while ($chunk_begin > $sync_begin);
}
/**
* Sync forwards from thermostat.sync_end until now. This should be used for
* all syncs except the first one.
*
* @param int $thermostat_id
*/
private function sync_forwards($thermostat_id) {
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
// Sync from the last sync time until now.
$sync_begin = strtotime($thermostat['sync_end']);
$sync_end = time();
$chunk_begin = $sync_begin;
$chunk_end = $sync_begin;
// Loop over the dates and do the actual sync. Each chunk is wrapped in a
// transaction for a little bit of protection against exceptions introducing
// bad data and causing the whole sync to fail.
do {
$this->database->start_transaction();
$chunk_end = strtotime('+1 week', $chunk_begin);
$chunk_end = min($chunk_end, $sync_end);
$this->sync_($thermostat['ecobee_thermostat_id'], $chunk_begin, $chunk_end);
// Update the thermostat with the current sync range
$this->api(
'thermostat',
'update',
[
'attributes' => [
'thermostat_id' => $thermostat['thermostat_id'],
'sync_end' => date('Y-m-d H:i:s', $chunk_end)
]
]
);
$chunk_begin = strtotime('+1 day', $chunk_end);
$this->database->commit_transaction();
} while ($chunk_end < $sync_end);
}
/**
* Get the runtime report data for a specified thermostat.
*
* @param int $ecobee_thermostat_id
* @param int $begin
* @param int $end
*/
private function sync_($ecobee_thermostat_id, $begin, $end) {
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $ecobee_thermostat_id);
/**
* TODO: There is some issue related to the sync where we can miss small
* chunks of time if begin/end are the same day or something like that. It
* seems to happen around UTC 00:00:00 so 7:00pm or so local time. This
* happens to fix it by forcing sycing backwards by an extra day so that
* chunk of time can't be missed. Need to properly fix...maybe next time I
* take a pass at the syncing...
*/
if(date('Y-m-d', $begin) === date('Y-m-d', $end)) {
$begin = strtotime('-1 day', $begin);
}
$begin = date('Y-m-d', $begin);
$end = date('Y-m-d', $end);
$columns = [
'auxHeat1' => 'auxiliary_heat_1',
'auxHeat2' => 'auxiliary_heat_2',
'auxHeat3' => 'auxiliary_heat_3',
'compCool1' => 'compressor_cool_1',
'compCool2' => 'compressor_cool_2',
'compHeat1' => 'compressor_heat_1',
'compHeat2' => 'compressor_heat_2',
'dehumidifier' => 'dehumidifier',
'dmOffset' => 'demand_management_offset',
'economizer' => 'economizer',
'fan' => 'fan',
'humidifier' => 'humidifier',
'hvacMode' => 'hvac_mode',
'outdoorHumidity' => 'outdoor_humidity',
'outdoorTemp' => 'outdoor_temperature',
'sky' => 'sky',
'ventilator' => 'ventilator',
'wind' => 'wind',
'zoneAveTemp' => 'zone_average_temperature',
'zoneCalendarEvent' => 'zone_calendar_event',
'zoneClimate' => 'zone_climate',
'zoneCoolTemp' => 'zone_cool_temperature',
'zoneHeatTemp' => 'zone_heat_temperature',
'zoneHumidity' => 'zone_humidity',
'zoneHumidityHigh' => 'zone_humidity_high',
'zoneHumidityLow' => 'zone_humidity_low',
'zoneHvacMode' => 'zone_hvac_mode',
'zoneOccupancy' => 'zone_occupancy'
];
$response = $this->api(
'ecobee',
'ecobee_api',
[
'method' => 'GET',
'endpoint' => 'runtimeReport',
'arguments' => [
'body' => json_encode([
'selection' => [
'selectionType' => 'thermostats',
'selectionMatch' => $ecobee_thermostat['identifier']
],
'startDate' => $begin,
'endDate' => $end,
'columns' => implode(',', array_keys($columns)),
'includeSensors' => false
])
]
]
);
$time_zone_offset = $ecobee_thermostat['json_location']['timeZoneOffsetMinutes'];
foreach($response['reportList'][0]['rowList'] as $row) {
// Prepare the row!
$row = substr($row, 0, -1); // Strip the trailing comma,
$row = explode(',', $row);
$row = array_map('trim', $row);
// Date and time are first two columns of the returned data. It is
// returned in thermostat time, so convert it to UTC first.
list($date, $time) = array_splice($row, 0, 2);
$timestamp = date(
'Y-m-d H:i:s',
strtotime(
$date . ' ' . $time . ' ' . ($time_zone_offset < 0 ? '+' : '-') . abs($time_zone_offset) . ' minute'
)
);
$data = [
'ecobee_thermostat_id' => $ecobee_thermostat_id,
'timestamp' => $timestamp
];
$i = 0;
foreach($columns as $ecobee_key => $database_key) {
$data[$database_key] = ($row[$i] === '' ? null : $row[$i]);
$i++;
}
$existing_rows = $this->read([
'ecobee_thermostat_id' => $ecobee_thermostat_id,
'timestamp' => $timestamp
]);
if(count($existing_rows) > 0) {
$data['ecobee_runtime_thermostat_id'] = $existing_rows[0]['ecobee_runtime_thermostat_id'];
$this->update($data);
}
else {
$this->create($data);
}
}
}
/**
* Query thermostat data and aggregate the results.
*
* @param int $ecobee_thermostat_id Thermostat to get data for.
* @param string $time_period day|week|month|year|all
* @param string $group_by hour|day|week|month|year
* @param int $time_count How many time periods to include.
*
* @return array The aggregate runtime data.
*/
public function get_aggregate_runtime($ecobee_thermostat_id, $time_period, $group_by, $time_count) {
if(in_array($time_period, ['day', 'week', 'month', 'year', 'all']) === false) {
throw new Exception('Invalid time period');
}
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $ecobee_thermostat_id);
$this->database->set_time_zone($ecobee_thermostat['json_location']['timeZoneOffsetMinutes']);
$select = [];
$group_by_order_by = [];
switch($group_by) {
case 'hour':
$select[] = 'hour(`timestamp`) `hour`';
$group_by_order_by[] = 'hour(`timestamp`)';
case 'day':
$select[] = 'day(`timestamp`) `day`';
$group_by_order_by[] = 'day(`timestamp`)';
case 'week':
$select[] = 'week(`timestamp`) `week`';
$group_by_order_by[] = 'week(`timestamp`)';
case 'month':
$select[] = 'month(`timestamp`) `month`';
$group_by_order_by[] = 'month(`timestamp`)';
case 'year':
$select[] = 'year(`timestamp`) `year`';
$group_by_order_by[] = 'year(`timestamp`)';
break;
}
$group_by_order_by = array_reverse($group_by_order_by);
// This is a smidge sloppy but it gets the job done. Basically need to
// subtract all higher tier heat/cool modes from the lower ones to avoid
// double-counting.
$select[] = 'count(*) `count`';
$select[] = 'cast(avg(`outdoor_temperature`) as decimal(4,1)) `outdoor_temperature`';
$select[] = 'cast(avg(`zone_average_temperature`) as decimal(4,1)) `zone_average_temperature`';
$select[] = 'cast(avg(`zone_heat_temperature`) as decimal(4,1)) `zone_heat_temperature`';
$select[] = 'cast(avg(`zone_cool_temperature`) as decimal(4,1)) `zone_cool_temperature`';
$select[] = 'cast(sum(greatest(0, (cast(`compressor_heat_1` as signed) - cast(`compressor_heat_2` as signed)))) as unsigned) `compressor_heat_1`';
$select[] = 'cast(sum(`compressor_heat_2`) as unsigned) `compressor_heat_2`';
$select[] = 'cast(sum(greatest(0, (cast(`auxiliary_heat_1` as signed) - cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed)))) as unsigned) `auxiliary_heat_1`';
$select[] = 'cast(sum(greatest(0, (cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed)))) as unsigned) `auxiliary_heat_2`';
$select[] = 'cast(sum(`auxiliary_heat_3`) as unsigned) `auxiliary_heat_3`';
$select[] = 'cast(sum(greatest(0, (cast(`compressor_cool_1` as signed) - cast(`compressor_cool_2` as signed)))) as unsigned) `compressor_cool_1`';
$select[] = 'cast(sum(`compressor_cool_2`) as unsigned) `compressor_cool_2`';
// The zone_average_temperature check is for if data exists in the table but
// is otherwise likely to be all null (like the bad data from February
// 2019).
$query = '
select ' .
implode(',', $select) . ' ' . '
from
`ecobee_runtime_thermostat`
where
`user_id` = ' . $this->session->get_user_id() . '
and `ecobee_thermostat_id` = "' . $this->database->escape($ecobee_thermostat_id) . '" ' .
($time_period !== 'all' ? ('and `timestamp` > now() - interval ' . intval($time_count) . ' ' . $time_period) : '') . '
and `timestamp` <= now()
and `zone_average_temperature` is not null
';
if(count($group_by_order_by) > 0) {
$query .= 'group by ' .
implode(', ', $group_by_order_by) . '
order by ' .
implode(', ', $group_by_order_by);
}
$result = $this->database->query($query);
$return = [];
while($row = $result->fetch_assoc()) {
// Cast to floats for nice responses. The database normally handles this
// in regular read operations.
foreach(['outdoor_temperature', 'zone_average_temperature', 'zone_heat_temperature', 'zone_cool_temperature'] as $key) {
if($row[$key] !== null) {
$row[$key] = (float) $row[$key];
}
}
$return[] = $row;
}
$this->database->set_time_zone(0);
return $return;
}
/**
* Get recent thermostat activity. Max range is 30 days.
*
* @param int $ecobee_thermostat_id Thermostat to get data for.
* @param string $begin Begin date/time.
* @param string $end End date/time.
*
* @return array The rows in the desired date range.
*/
public function get_recent_activity($ecobee_thermostat_id, $begin, $end) {
$thermostat = $this->api(
'thermostat',
'get',
[
'attributes' => [
'ecobee_thermostat_id' => $ecobee_thermostat_id
]
]
);
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $thermostat['ecobee_thermostat_id']);
$this->database->set_time_zone($ecobee_thermostat['json_location']['timeZoneOffsetMinutes']);
$offset = $ecobee_thermostat['json_location']['timeZoneOffsetMinutes'];
$end = ($end === null ? (time() + ($offset * 60)) : strtotime($end));
$begin = ($begin === null ? strtotime('-14 day', $end) : strtotime($begin));
if(($end - $begin) > 2592000) {
throw new Exception('Date range exceeds maximum of 30 days.');
}
$query = '
select
`ecobee_thermostat_id`,
`timestamp`,
cast(greatest(0, (cast(`compressor_heat_1` as signed) - cast(`compressor_heat_2` as signed))) as unsigned) `compressor_heat_1`,
`compressor_heat_2`,
cast(greatest(0, (cast(`auxiliary_heat_1` as signed) - cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed))) as unsigned) `auxiliary_heat_1`,
cast(greatest(0, (cast(`auxiliary_heat_2` as signed) - cast(`auxiliary_heat_3` as signed))) as unsigned) `auxiliary_heat_2`,
`auxiliary_heat_3`,
cast(greatest(0, (cast(`compressor_cool_1` as signed) - cast(`compressor_cool_2` as signed))) as unsigned) `compressor_cool_1`,
`compressor_cool_2`,
`fan`,
`dehumidifier`,
`economizer`,
`humidifier`,
`ventilator`,
`hvac_mode`,
`outdoor_temperature`,
`zone_average_temperature`,
`zone_heat_temperature`,
`zone_cool_temperature`,
`zone_humidity`,
`outdoor_humidity`,
`zone_calendar_event`,
`zone_climate`
from
`ecobee_runtime_thermostat`
where
`user_id` = ' . $this->database->escape($this->session->get_user_id()) . '
and `ecobee_thermostat_id` = ' . $this->database->escape($ecobee_thermostat_id) . '
and `timestamp` >= ' . $this->database->escape(date('Y-m-d H:i:s', $begin)) . '
and `timestamp` <= ' . $this->database->escape(date('Y-m-d H:i:s', $end)) . '
order by
timestamp
';
$result = $this->database->query($query);
$return = [];
while($row = $result->fetch_assoc()) {
$return[] = $row;
}
$this->database->set_time_zone(0);
return $return;
}
}

262
api/ecobee_sensor.php Normal file
View File

@ -0,0 +1,262 @@
<?php
/**
* An ecobee sensor. This just has a few simple properties like name,
* temperature, humidity, etc.
*
* @author Jon Ziebell
*/
class ecobee_sensor extends cora\crud {
public static $exposed = [
'private' => [
'read_id'
],
'public' => []
];
public static $converged = [];
public static $user_locked = true;
/**
* Sync sensors.
*/
public function sync() {
// Get the thermostat list from ecobee with sensors. Keep this identical to
// ecobee_thermostat->sync() to leverage caching.
$response = $this->api(
'ecobee',
'ecobee_api',
[
'method' => 'GET',
'endpoint' => 'thermostat',
'arguments' => [
'body' => json_encode([
'selection' => [
'selectionType' => 'registered',
'selectionMatch' => '',
'includeRuntime' => true,
'includeExtendedRuntime' => true,
'includeElectricity' => true,
'includeSettings' => true,
'includeLocation' => true,
'includeProgram' => true,
'includeEvents' => true,
'includeDevice' => true,
'includeTechnician' => true,
'includeUtility' => true,
'includeManagement' => true,
'includeAlerts' => true,
'includeWeather' => true,
'includeHouseDetails' => true,
'includeOemCfg' => true,
'includeEquipmentStatus' => true,
'includeNotificationSettings' => true,
'includeVersion' => true,
'includePrivacy' => true,
'includeAudio' => true,
'includeSensors' => true
/**
* 'includeReminders' => true
*
* While documented, this is not available for general API use
* unless you are a technician user.
*
* The reminders and the includeReminders flag are something extra
* for ecobee Technicians. It allows them to set and receive
* reminders with more detail than the usual alert reminder type.
* These reminders are only available to Technician users, which
* is why you aren't seeing any new information when you set that
* flag to true. Thanks for pointing out the lack of documentation
* regarding this. We'll get this updated as soon as possible.
*
*
* https://getsatisfaction.com/api/topics/what-does-includereminders-do-when-calling-get-thermostat?rfm=1
*/
/**
* 'includeSecuritySettings' => true
*
* While documented, this is not made available for general API
* use unless you are a utility. If you try to include this an
* "Authentication failed" error will be returned.
*
* Special accounts such as Utilities are permitted an alternate
* method of authorization using implicit authorization. This
* method permits the Utility application to authorize against
* their own specific account without the requirement of a PIN.
* This method is limited to special contractual obligations and
* is not available for 3rd party applications who are not
* Utilities.
*
* https://www.ecobee.com/home/developer/api/documentation/v1/objects/SecuritySettings.shtml
* https://www.ecobee.com/home/developer/api/documentation/v1/auth/auth-intro.shtml
*
*/
]
])
]
]
);
// Loop over the returned sensors and create/update them as necessary.
$ecobee_sensor_ids_to_keep = [];
foreach($response['thermostatList'] as $thermostat_api) {
$guid = sha1($thermostat_api['identifier'] . $thermostat_api['runtime']['firstConnected']);
$ecobee_thermostat = $this->api(
'ecobee_thermostat',
'get',
[
'attributes' => [
'guid' => $guid
]
]
);
$thermostat = $this->api(
'thermostat',
'get',
[
'attributes' => [
'ecobee_thermostat_id' => $ecobee_thermostat['ecobee_thermostat_id']
]
]
);
foreach($thermostat_api['remoteSensors'] as $api_sensor) {
$ecobee_sensor = $this->get(
[
'ecobee_thermostat_id' => $ecobee_thermostat['ecobee_thermostat_id'],
'identifier' => $api_sensor['id']
]
);
if ($ecobee_sensor !== null) {
// Sensor exists.
$sensor = $this->api(
'sensor',
'get',
[
'attributes' => [
'ecobee_sensor_id' => $ecobee_sensor['ecobee_sensor_id']
]
]
);
}
else {
// Sensor does not exist.
$ecobee_sensor = $this->create([
'ecobee_thermostat_id' => $ecobee_thermostat['ecobee_thermostat_id'],
'identifier' => $api_sensor['id']
]);
$sensor = $this->api(
'sensor',
'create',
[
'attributes' => [
'ecobee_sensor_id' => $ecobee_sensor['ecobee_sensor_id'],
'thermostat_id' => $thermostat['thermostat_id']
]
]
);
}
$ecobee_sensor_ids_to_keep[] = $ecobee_sensor['ecobee_sensor_id'];
$this->update(
[
'ecobee_sensor_id' => $ecobee_sensor['ecobee_sensor_id'],
'name' => $api_sensor['name'],
'type' => $api_sensor['type'],
'code' => (isset($api_sensor['code']) === true ? $api_sensor['code'] : null),
'in_use' => ($api_sensor['inUse'] === true ? 1 : 0),
'json_capability' => $api_sensor['capability'],
'inactive' => 0
]
);
$attributes = [];
$attributes['name'] = $api_sensor['name'];
$attributes['type'] = $api_sensor['type'];
$attributes['in_use'] = $api_sensor['inUse'];
$attributes['temperature'] = null;
$attributes['humidity'] = null;
$attributes['occupancy'] = null;
foreach($api_sensor['capability'] as $capability) {
switch($capability['type']) {
case 'temperature':
if(
is_numeric($capability['value']) === true &&
$capability['value'] <= 999.99 &&
$capability['value'] >= -999.99
) {
$attributes['temperature'] = $capability['value'] / 10;
} else {
$attributes['temperature'] = null;
}
break;
case 'humidity':
if(
is_numeric($capability['value']) === true &&
$capability['value'] <= 100 &&
$capability['value'] >= 0
) {
$attributes['humidity'] = $capability['value'] / 10;
} else {
$attributes['humidity'] = null;
}
break;
case 'occupancy':
$attributes['occupancy'] = $capability['value'] === "true";
break;
}
// Update the sensor.
$this->api(
'sensor',
'update',
[
'attributes' => array_merge(
['sensor_id' => $sensor['sensor_id']],
$attributes
)
]
);
}
}
}
// Inactivate any sensors that were no longer returned.
$ecobee_sensors = $this->read();
foreach($ecobee_sensors as $ecobee_sensor) {
if(in_array($ecobee_sensor['ecobee_sensor_id'], $ecobee_sensor_ids_to_keep) === false) {
$this->update(
[
'ecobee_sensor_id' => $ecobee_sensor['ecobee_sensor_id'],
'inactive' => 1
]
);
$this->api(
'sensor',
'update',
[
'attributes' => [
'sensor_id' => $sensor['sensor_id'],
'inactive' => 1
]
]
);
}
}
$return = $this->read_id(['ecobee_sensor_id' => $ecobee_sensor_ids_to_keep]);
return $return;
}
}

721
api/ecobee_thermostat.php Normal file
View File

@ -0,0 +1,721 @@
<?php
/**
* An ecobee thermostat. This has many properties which are all synced from the
* ecobee API.
*
* @author Jon Ziebell
*/
class ecobee_thermostat extends cora\crud {
public static $exposed = [
'private' => [
'read_id'
],
'public' => []
];
public static $converged = [];
public static $user_locked = true;
/**
* Sync thermostats.
*/
public function sync() {
// Get the thermostat list from ecobee with sensors. Keep this identical to
// ecobee_sensor->sync() to leverage caching.
$response = $this->api(
'ecobee',
'ecobee_api',
[
'method' => 'GET',
'endpoint' => 'thermostat',
'arguments' => [
'body' => json_encode([
'selection' => [
'selectionType' => 'registered',
'selectionMatch' => '',
'includeRuntime' => true,
'includeExtendedRuntime' => true,
'includeElectricity' => true,
'includeSettings' => true,
'includeLocation' => true,
'includeProgram' => true,
'includeEvents' => true,
'includeDevice' => true,
'includeTechnician' => true,
'includeUtility' => true,
'includeManagement' => true,
'includeAlerts' => true,
'includeWeather' => true,
'includeHouseDetails' => true,
'includeOemCfg' => true,
'includeEquipmentStatus' => true,
'includeNotificationSettings' => true,
'includeVersion' => true,
'includePrivacy' => true,
'includeAudio' => true,
'includeSensors' => true
/**
* 'includeReminders' => true
*
* While documented, this is not available for general API use
* unless you are a technician user.
*
* The reminders and the includeReminders flag are something extra
* for ecobee Technicians. It allows them to set and receive
* reminders with more detail than the usual alert reminder type.
* These reminders are only available to Technician users, which
* is why you aren't seeing any new information when you set that
* flag to true. Thanks for pointing out the lack of documentation
* regarding this. We'll get this updated as soon as possible.
*
*
* https://getsatisfaction.com/api/topics/what-does-includereminders-do-when-calling-get-thermostat?rfm=1
*/
/**
* 'includeSecuritySettings' => true
*
* While documented, this is not made available for general API
* use unless you are a utility. If you try to include this an
* "Authentication failed" error will be returned.
*
* Special accounts such as Utilities are permitted an alternate
* method of authorization using implicit authorization. This
* method permits the Utility application to authorize against
* their own specific account without the requirement of a PIN.
* This method is limited to special contractual obligations and
* is not available for 3rd party applications who are not
* Utilities.
*
* https://www.ecobee.com/home/developer/api/documentation/v1/objects/SecuritySettings.shtml
* https://www.ecobee.com/home/developer/api/documentation/v1/auth/auth-intro.shtml
*
*/
]
])
]
]
);
// Loop over the returned thermostats and create/update them as necessary.
$ecobee_thermostat_ids_to_keep = [];
foreach($response['thermostatList'] as $api_thermostat) {
$guid = sha1($api_thermostat['identifier'] . $api_thermostat['runtime']['firstConnected']);
$ecobee_thermostat = $this->get(
[
'guid' => $guid
]
);
if ($ecobee_thermostat !== null) {
// Thermostat exists.
$thermostat = $this->api(
'thermostat',
'get',
[
'attributes' => [
'ecobee_thermostat_id' => $ecobee_thermostat['ecobee_thermostat_id']
]
]
);
}
else {
// Thermostat does not exist.
$ecobee_thermostat = $this->create([
'guid' => $guid
]);
$thermostat = $this->api(
'thermostat',
'create',
[
'attributes' => [
'ecobee_thermostat_id' => $ecobee_thermostat['ecobee_thermostat_id'],
'json_alerts' => []
]
]
);
}
// $ecobee_thermostat_ids_to_keep[] = $ecobee_thermostat['ecobee_thermostat_id'];
$thermostat_ids_to_keep[] = $thermostat['thermostat_id'];
$ecobee_thermostat = $this->update(
[
'ecobee_thermostat_id' => $ecobee_thermostat['ecobee_thermostat_id'],
'name' => $api_thermostat['name'],
'identifier' => $api_thermostat['identifier'],
'utc_time' => $api_thermostat['utcTime'],
'model_number' => $api_thermostat['modelNumber'],
'json_runtime' => $api_thermostat['runtime'],
'json_extended_runtime' => $api_thermostat['extendedRuntime'],
'json_electricity' => $api_thermostat['electricity'],
'json_settings' => $api_thermostat['settings'],
'json_location' => $api_thermostat['location'],
'json_program' => $api_thermostat['program'],
'json_events' => $api_thermostat['events'],
'json_device' => $api_thermostat['devices'],
'json_technician' => $api_thermostat['technician'],
'json_utility' => $api_thermostat['utility'],
'json_management' => $api_thermostat['management'],
'json_alerts' => $api_thermostat['alerts'],
'json_weather' => $api_thermostat['weather'],
'json_house_details' => $api_thermostat['houseDetails'],
'json_oem_cfg' => $api_thermostat['oemCfg'],
'json_equipment_status' => trim($api_thermostat['equipmentStatus']) !== '' ? explode(',', $api_thermostat['equipmentStatus']) : [],
'json_notification_settings' => $api_thermostat['notificationSettings'],
'json_privacy' => $api_thermostat['privacy'],
'json_version' => $api_thermostat['version'],
'json_remote_sensors' => $api_thermostat['remoteSensors'],
'json_audio' => $api_thermostat['audio'],
'inactive' => 0
]
);
// Grab a bunch of attributes from the ecobee_thermostat and attach them
// to the thermostat.
$attributes = [];
$attributes['name'] = $api_thermostat['name'];
$attributes['inactive'] = 0;
// There are some instances where ecobee gives invalid temperature values.
if(
($api_thermostat['runtime']['actualTemperature'] / 10) > 999.9 ||
($api_thermostat['runtime']['actualTemperature'] / 10) < -999.9
) {
$attributes['temperature'] = null;
} else {
$attributes['temperature'] = ($api_thermostat['runtime']['actualTemperature'] / 10);
}
$attributes['temperature_unit'] = $api_thermostat['settings']['useCelsius'] === true ? '°C' : '°F';
// There are some instances where ecobee gives invalid humidity values.
if(
$api_thermostat['runtime']['actualHumidity'] > 100 ||
$api_thermostat['runtime']['actualHumidity'] < 0
) {
$attributes['humidity'] = null;
} else {
$attributes['humidity'] = $api_thermostat['runtime']['actualHumidity'];
}
$attributes['first_connected'] = $api_thermostat['runtime']['firstConnected'];
$address = $this->get_address($thermostat, $ecobee_thermostat);
$attributes['address_id'] = $address['address_id'];
$attributes['property'] = $this->get_property($thermostat, $ecobee_thermostat);
$attributes['filters'] = $this->get_filters($thermostat, $ecobee_thermostat);
$attributes['json_alerts'] = $this->get_alerts($thermostat, $ecobee_thermostat);
$detected_system_type = $this->get_detected_system_type($thermostat, $ecobee_thermostat);
if($thermostat['system_type'] === null) {
$attributes['system_type'] = [
'reported' => [
'heat' => null,
'heat_auxiliary' => null,
'cool' => null
],
'detected' => $detected_system_type
];
} else {
$attributes['system_type'] = [
'reported' => $thermostat['system_type']['reported'],
'detected' => $detected_system_type
];
}
$thermostat_group = $this->get_thermostat_group(
$thermostat,
$ecobee_thermostat,
$attributes['property'],
$address
);
$attributes['thermostat_group_id'] = $thermostat_group['thermostat_group_id'];
$this->api(
'thermostat',
'update',
[
'attributes' => array_merge(
['thermostat_id' => $thermostat['thermostat_id']],
$attributes
)
]
);
// Update the thermostat_group system type and property type columns with
// the merged data from all of the thermostats in it.
$this->api(
'thermostat_group',
'sync_attributes',
[
'thermostat_group_id' => $thermostat_group['thermostat_group_id']
]
);
}
// Inactivate any ecobee_thermostats that were no longer returned.
$thermostats = $this->api('thermostat', 'read');
foreach($thermostats as $thermostat) {
if(in_array($thermostat['thermostat_id'], $thermostat_ids_to_keep) === false) {
$this->update(
[
'ecobee_thermostat_id' => $thermostat['ecobee_thermostat_id'],
'inactive' => 1
]
);
$this->api(
'thermostat',
'update',
[
'attributes' => [
'thermostat_id' => $thermostat['thermostat_id'],
'inactive' => 1
],
]
);
}
}
return $this->read_id(['ecobee_thermostat_id' => $ecobee_thermostat_ids_to_keep]);
}
/**
* Get the address for the given thermostat.
*
* @param array $thermostat
* @param array $ecobee_thermostat
*
* @return array
*/
private function get_address($thermostat, $ecobee_thermostat) {
$address_parts = [];
if(isset($ecobee_thermostat['json_location']['streetAddress']) === true) {
$address_parts[] = $ecobee_thermostat['json_location']['streetAddress'];
}
if(isset($ecobee_thermostat['json_location']['city']) === true) {
$address_parts[] = $ecobee_thermostat['json_location']['city'];
}
if(isset($ecobee_thermostat['json_location']['provinceState']) === true) {
$address_parts[] = $ecobee_thermostat['json_location']['provinceState'];
}
if(isset($ecobee_thermostat['json_location']['postalCode']) === true) {
$address_parts[] = $ecobee_thermostat['json_location']['postalCode'];
}
if(
isset($ecobee_thermostat['json_location']['country']) === true &&
trim($ecobee_thermostat['json_location']['country']) !== ''
) {
if(preg_match('/(^USA?$)|(united.?states)/i', $ecobee_thermostat['json_location']['country']) === 1) {
$country = 'USA';
}
else {
$country = $ecobee_thermostat['json_location']['country'];
}
}
else {
// If all else fails, assume USA.
$country = 'USA';
}
return $this->api(
'address',
'search',
[
'address_string' => implode(', ', $address_parts),
'country' => $country
]
);
}
/**
* Get details about the thermostat's property.
*
* @param array $thermostat
* @param array $ecobee_thermostat
*
* @return array
*/
private function get_property($thermostat, $ecobee_thermostat) {
$property = [];
/**
* Example values from ecobee: "0", "apartment", "Apartment", "Condo",
* "condominium", "detached", "Detached", "I don't know", "loft", "Multi
* Plex", "multiPlex", "other", "Other", "rowHouse", "Semi-Detached",
* "semiDetached", "townhouse", "Townhouse"
*/
$property['structure_type'] = null;
if(isset($ecobee_thermostat['json_house_details']['style']) === true) {
$structure_type = $ecobee_thermostat['json_house_details']['style'];
if(preg_match('/^detached$/i', $structure_type) === 1) {
$property['structure_type'] = 'detached';
}
else if(preg_match('/apartment/i', $structure_type) === 1) {
$property['structure_type'] = 'apartment';
}
else if(preg_match('/^condo/i', $structure_type) === 1) {
$property['structure_type'] = 'condominium';
}
else if(preg_match('/^loft/i', $structure_type) === 1) {
$property['structure_type'] = 'loft';
}
else if(preg_match('/multi[^a-z]?plex/i', $structure_type) === 1) {
$property['structure_type'] = 'multiplex';
}
else if(preg_match('/(town|row)(house|home)/i', $structure_type) === 1) {
$property['structure_type'] = 'townhouse';
}
else if(preg_match('/semi[^a-z]?detached/i', $structure_type) === 1) {
$property['structure_type'] = 'semi-detached';
}
}
/**
* Example values from ecobee: "0", "1", "2", "3", "4", "5", "8", "9", "10"
*/
$property['stories'] = null;
if(isset($ecobee_thermostat['json_house_details']['numberOfFloors']) === true) {
$stories = $ecobee_thermostat['json_house_details']['numberOfFloors'];
if(ctype_digit((string) $stories) === true && $stories > 0) {
$property['stories'] = (int) $stories;
}
}
/**
* Example values from ecobee: "0", "5", "500", "501", "750", "1000",
* "1001", "1050", "1200", "1296", "1400", "1500", "1501", "1600", "1750",
* "1800", "1908", "2000", "2400", "2450", "2500", "2600", "2750", "2800",
* "2920", "3000", "3200", "3437", "3500", "3600", "4000", "4500", "5000",
* "5500", "5600", "6000", "6500", "6800", "7000", "7500", "7800", "8000",
* "9000", "9500", "10000"
*/
$property['square_feet'] = null;
if(isset($ecobee_thermostat['json_house_details']['size']) === true) {
$square_feet = $ecobee_thermostat['json_house_details']['size'];
if(ctype_digit((string) $square_feet) === true && $square_feet > 0) {
$property['square_feet'] = (int) $square_feet;
}
}
/**
* Example values from ecobee: "0", "1", "2", "3", "5", "6", "7", "8",
* "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20",
* "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32",
* "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44",
* "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56",
* "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68",
* "69", "70", "71", "72", "73", "75", "76", "77", "78", "79", "80", "81",
* "82", "83", "86", "87", "88", "89", "90", "91", "92", "93", "95", "96",
* "97", "98", "99", "100", "101", "102", "103", "104", "105", "106",
* "107", "108", "109", "111", "112", "116", "117", "118", "119", "120",
* "121", "122", "123", "124"
*/
$property['age'] = null;
if(isset($ecobee_thermostat['json_house_details']['age']) === true) {
$age = $ecobee_thermostat['json_house_details']['age'];
if(ctype_digit((string) $age) === true) {
$property['age'] = (int) $age;
}
}
return $property;
}
/**
* Get details about the different filters and things.
*
* @param array $thermostat
* @param array $ecobee_thermostat
*
* @return array
*/
private function get_filters($thermostat, $ecobee_thermostat) {
$filters = [];
$supported_types = [
'furnaceFilter' => [
'key' => 'furnace',
'sum_column' => 'fan'
],
'humidifierFilter' => [
'key' => 'humidifier',
'sum_column' => 'humidifier'
],
'dehumidifierFilter' => [
'key' => 'dehumidifier',
'sum_column' => 'dehumidifier'
],
'ventilator' => [
'key' => 'ventilator',
'sum_column' => 'ventilator'
],
'uvLamp' => [
'key' => 'uv_lamp',
'sum_column' => 'fan'
]
];
$sums = [];
$min_timestamp = INF;
if(isset($ecobee_thermostat['json_notification_settings']['equipment']) === true) {
foreach($ecobee_thermostat['json_notification_settings']['equipment'] as $notification) {
if($notification['enabled'] === true && isset($supported_types[$notification['type']]) === true) {
$key = $supported_types[$notification['type']]['key'];
$sum_column = $supported_types[$notification['type']]['sum_column'];
$filters[$key] = [
'last_changed' => $notification['filterLastChanged'],
'life' => $notification['filterLife'],
'life_units' => $notification['filterLifeUnits']
];
$sums[] = 'sum(case when `timestamp` > "' . $notification['filterLastChanged'] . '" then `' . $sum_column . '` else 0 end) `' . $key . '`';
$min_timestamp = min($min_timestamp, strtotime($notification['filterLastChanged']));
}
}
}
if(count($filters) > 0) {
$query = '
select
' . implode(',', $sums) . '
from
ecobee_runtime_thermostat
where
`user_id` = "' . $this->session->get_user_id() . '"
and `ecobee_thermostat_id` = "' . $ecobee_thermostat['ecobee_thermostat_id'] . '"
and `timestamp` > "' . date('Y-m-d', $min_timestamp) . '"
';
$result = $this->database->query($query);
$row = $result->fetch_assoc();
foreach($row as $key => $value) {
$filters[$key]['runtime'] = (int) $value;
}
}
return $filters;
}
/**
* Get whatever the alerts should be set to.
*
* @param array $thermostat
* @param array $ecobee_thermostat
*
* @return array
*/
private function get_alerts($thermostat, $ecobee_thermostat) {
// Get a list of all ecobee thermostat alerts
$new_alerts = [];
foreach($ecobee_thermostat['json_alerts'] as $ecobee_thermostat_alert) {
$alert = [];
$alert['timestamp'] = date(
'Y-m-d H:i:s',
strtotime($ecobee_thermostat_alert['date'] . ' ' . $ecobee_thermostat_alert['time'])
);
$alert['text'] = $ecobee_thermostat_alert['text'];
$alert['code'] = $ecobee_thermostat_alert['alertNumber'];
$alert['details'] = 'N/A';
$alert['source'] = 'thermostat';
$alert['dismissed'] = false;
$alert['guid'] = $this->get_alert_guid($alert);
$new_alerts[$alert['guid']] = $alert;
}
// Cool Differential Temperature
if($ecobee_thermostat['json_settings']['stage1CoolingDifferentialTemp'] / 10 === 0.5) {
$alert = [
'timestamp' => date('Y-m-d H:i:s'),
'text' => 'Cool Differential Temperature is set to 0.5°F; we recommend at least 1.0°F',
'details' => 'Low values for this setting will generally not cause any harm, but they do contribute to short cycling and decreased efficiency.',
'code' => 100000,
'source' => 'beestat',
'dismissed' => false
];
$alert['guid'] = $this->get_alert_guid($alert);
$new_alerts[$alert['guid']] = $alert;
}
// Heat Differential Temperature
if($ecobee_thermostat['json_settings']['stage1HeatingDifferentialTemp'] / 10 === 0.5) {
$alert = [
'timestamp' => date('Y-m-d H:i:s'),
'text' => 'Heat Differential Temperature is set to 0.5°F; we recommend at least 1.0°F',
'details' => 'Low values for this setting will generally not cause any harm, but they do contribute to short cycling and decreased efficiency.',
'code' => 100001,
'source' => 'beestat',
'dismissed' => false
];
$alert['guid'] = $this->get_alert_guid($alert);
$new_alerts[$alert['guid']] = $alert;
}
// Get the guids for easy comparison
$new_guids = array_column($new_alerts, 'guid');
$existing_guids = array_column($thermostat['json_alerts'], 'guid');
$guids_to_add = array_diff($new_guids, $existing_guids);
$guids_to_remove = array_diff($existing_guids, $new_guids);
// Remove any removed alerts
$final_alerts = $thermostat['json_alerts'];
foreach($final_alerts as $key => $thermostat_alert) {
if(in_array($thermostat_alert['guid'], $guids_to_remove) === true) {
unset($final_alerts[$key]);
}
}
// Add any new alerts
foreach($guids_to_add as $guid) {
$final_alerts[] = $new_alerts[$guid];
}
return array_values($final_alerts);
}
/**
* Get the GUID for an alert. Basically if the text and the source are the
* same then it's considered the same alert. Timestamp could be included for
* ecobee alerts but since beestat alerts are constantly re-generated the
* timestamp always changes.
*
* @param array $alert
*
* @return string
*/
private function get_alert_guid($alert) {
return sha1($alert['text'] . $alert['source']);
}
/**
* Figure out which group this thermostat belongs in based on the address.
*
* @param array $thermostat
* @param array $ecobee_thermostat
* @param array $property
* @param array $address
*
* @return array
*/
private function get_thermostat_group($thermostat, $ecobee_thermostat, $property, $address) {
$thermostat_group = $this->api(
'thermostat_group',
'get',
[
'attributes' => [
'address_id' => $address['address_id']
]
]
);
if($thermostat_group === null) {
$thermostat_group = $this->api(
'thermostat_group',
'create',
[
'attributes' => [
'address_id' => $address['address_id']
]
]
);
}
return $thermostat_group;
}
/**
* Try and detect the type of HVAC system.
*
* @param array $thermostat
* @param array $ecobee_thermostat
*
* @return array System type for each of heat, cool, and aux.
*/
private function get_detected_system_type($thermostat, $ecobee_thermostat) {
$detected_system_type = [];
$settings = $ecobee_thermostat['json_settings'];
$devices = $ecobee_thermostat['json_device'];
// Get a list of all outputs. These get their type set when they get
// connected to a wire so it's a pretty reliable way to see what's hooked
// up.
$outputs = [];
foreach($devices as $device) {
foreach($device['outputs'] as $output) {
if($output['type'] !== 'none') {
$outputs[] = $output['type'];
}
}
}
// Heat
if($settings['heatPumpGroundWater'] === true) {
$detected_system_type['heat'] = 'geothermal';
} else if($settings['hasHeatPump'] === true) {
$detected_system_type['heat'] = 'compressor';
} else if($settings['hasBoiler'] === true) {
$detected_system_type['heat'] = 'boiler';
} else if(in_array('heat1', $outputs) === true) {
// This is the fastest way I was able to determine this. The further north
// you are the less likely you are to use electric heat.
if($thermostat['address_id'] !== null) {
$address = $this->api('address', 'get', $thermostat['address_id']);
if(
isset($address['normalized']['metadata']['latitude']) === true &&
$address['normalized']['metadata']['latitude'] > 30
) {
$detected_system_type['heat'] = 'gas';
} else {
$detected_system_type['heat'] = 'electric';
}
} else {
$detected_system_type['heat'] = 'electric';
}
} else {
$detected_system_type['heat'] = 'none';
}
// Rudimentary aux heat guess. It's pretty good overall but not as good as
// heat/cool.
if(
$detected_system_type['heat'] === 'gas' ||
$detected_system_type['heat'] === 'boiler' ||
$detected_system_type['heat'] === 'oil' ||
$detected_system_type['heat'] === 'electric'
) {
$detected_system_type['heat_auxiliary'] = 'none';
} else if($detected_system_type['heat'] === 'compressor') {
$detected_system_type['heat_auxiliary'] = 'electric';
} else {
$detected_system_type['heat_auxiliary'] = null;
}
// Cool
if($settings['heatPumpGroundWater'] === true) {
$detected_system_type['cool'] = 'geothermal';
} else if(in_array('compressor1', $outputs) === true) {
$detected_system_type['cool'] = 'compressor';
} else {
$detected_system_type['cool'] = 'none';
}
return $detected_system_type;
}
}

144
api/ecobee_token.php Normal file
View File

@ -0,0 +1,144 @@
<?php
/**
* Tokens for authorizing access to ecobee accounts.
*
* @author Jon Ziebell
*/
class ecobee_token extends cora\crud {
public static $converged = [];
public static $user_locked = true;
/**
* This should be called when connecting a new user. Get the access/refresh
* tokens, then attach them to a brand new anonymous user.
*
* @param string $code The code from ecobee used to obtain the
* access/refresh tokens.
*
* @return array The access/refresh tokens.
*/
public function obtain($code) {
// Obtain the access and refresh tokens from the authorization code.
$response = $this->api(
'ecobee',
'ecobee_api',
[
'method' => 'POST',
'endpoint' => 'token',
'arguments' => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->setting->get('ecobee_redirect_uri')
]
]
);
// Make sure we got the expected result.
if (
isset($response['access_token']) === false ||
isset($response['refresh_token']) === false
) {
throw new Exception('Could not get first token.', 10001);
}
return [
'access_token' => $response['access_token'],
'refresh_token' => $response['refresh_token'],
'timestamp' => date('Y-m-d H:i:s'),
'deleted' => 0
];
}
/**
* Get some new tokens. A database lock is obtained prior to getting a token
* so that no other API call can attempt to get a token at the same time.
* This way if two API calls fire off to ecobee at the same time, then
* return at the same time, then call token->refresh() at the same time,
* only one can run and actually refresh at a time. If the second one runs
* after that's fine as it will look up the token prior to refreshing.
*
* Also this creates a new database connection. If a token is written to the
* database, then the transaction gets rolled back, the token will be
* erased. I originally tried to avoid this by not using transactions except
* when syncing, but there are enough sync errors that happen where this
* causes a problem. The extra overhead of a second database connection
* every now and then shouldn't be that bad.
*/
public function refresh() {
$database = cora\database::get_second_instance();
$lock_name = 'ecobee_token->refresh(' . $this->session->get_user_id() . ')';
$database->get_lock($lock_name, 3);
// $ecobee_tokens = $this->read();
$ecobee_tokens = $database->read(
'ecobee_token',
[
'user_id' => $this->session->get_user_id()
]
);
if(count($ecobee_tokens) === 0) {
throw new Exception('Could not refresh ecobee token; no token found.', 10002);
}
$ecobee_token = $ecobee_tokens[0];
$response = $this->api(
'ecobee',
'ecobee_api',
[
'method' => 'POST',
'endpoint' => 'token',
'arguments' => [
'grant_type' => 'refresh_token',
'refresh_token' => $ecobee_token['refresh_token']
]
]
);
if (
isset($response['access_token']) === false ||
isset($response['refresh_token']) === false
) {
$this->delete($ecobee_token['ecobee_token_id']);
$database->release_lock($lock_name);
throw new Exception('Could not refresh ecobee token; ecobee returned no token.', 10003);
}
$database->update(
'ecobee_token',
[
'ecobee_token_id' => $ecobee_token['ecobee_token_id'],
'access_token' => $response['access_token'],
'refresh_token' => $response['refresh_token'],
'timestamp' => date('Y-m-d H:i:s')
]
);
$database->release_lock($lock_name);
}
/**
* Delete an ecobee token. If this happens immediately log out all of this
* user's currently logged in sessions.
*
* @param int $id
*
* @return int
*/
public function delete($id) {
$database = database::get_second_instance();
// Need to delete the token before logging out or else the delete fails.
$return = $database->delete('ecobee_token', $id);
// $return = parent::delete($id);
// Log out
$this->api('user', 'log_out', ['all' => true]);
return $return;
}
}

269
api/external_api.php Normal file
View File

@ -0,0 +1,269 @@
<?php
/**
* All external APIs (ecobee, SmartyStreets, Patreon, MailChimp) extend this
* class. This provides a generic cURL function with a couple basic arguments,
* and also logging.
*
* @author Jon Ziebell
*/
class external_api extends cora\api {
/**
* Whether or not to log the API call to Influx. This will only log the
* event and basic timing information; no detail.
*/
protected static $log_influx = true;
/**
* Whether or not to log the API call to MySQL. This will log the entire
* request and response in full detail. Valid values are "error", "all", and
* false.
*/
protected static $log_mysql = 'error';
/**
* Whether or not to cache API calls. This will store a hash of the request
* and the response in the database and check there before performing the
* API call again.
*/
protected static $cache = false;
/**
* How long to cache the API call for. Set to null for infinite, otherwise
* define in seconds.
*/
protected static $cache_for = null;
/**
* Fire off a cURL request to an external API.
*
* @param array $arguments
* url (string) The URL to send the request to
* method (string) Set to POST to POST, else it will GET
* header (array) Any headers you want to set
* post_fields Fields to send
*
* @return string Result on success, false on failure.
*/
protected function curl($arguments) {
$this->request_timestamp = time();
$this->request_timestamp_microtime = $this->microtime();
$curl_handle = curl_init();
curl_setopt($curl_handle, CURLOPT_URL, $arguments['url']);
curl_setopt($curl_handle, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($curl_handle, CURLOPT_TIMEOUT, 60);
curl_setopt($curl_handle, CURLOPT_RETURNTRANSFER, true);
// curl_setopt($curl_handle, CURLOPT_HEADER, true);
if(isset($arguments['method']) === true && $arguments['method'] === 'POST') {
curl_setopt($curl_handle, CURLOPT_POST, true);
}
if(isset($arguments['header']) === true) {
curl_setopt($curl_handle, CURLOPT_HTTPHEADER, $arguments['header']);
}
if(isset($arguments['post_fields']) === true) {
curl_setopt($curl_handle, CURLOPT_POSTFIELDS, $arguments['post_fields']);
}
if($this::$log_influx !== false || $this::$log_mysql !== false) {
curl_setopt($curl_handle, CURLINFO_HEADER_OUT, true);
}
$should_cache = (
$this::$cache === true &&
$this::should_cache($arguments) === true
);
// Check the cache
if ($should_cache === true) {
$cache_key = $this->generate_cache_key($arguments);
$cache_entry = $this->get_cache_entry($cache_key);
} else {
$cache_entry = null;
}
if($cache_entry === null) {
$curl_response = curl_exec($curl_handle);
$this->curl_info = curl_getinfo($curl_handle);
if($curl_response === false || curl_errno($curl_handle) !== 0) {
$this->cora->set_error_extra_info([
'curl_error' => curl_error($curl_handle)
]);
// Error logging
if($this::$log_influx === true) {
$this->log_influx(
$this->resource . '_api_log',
true
);
}
if($this::$log_mysql === 'all' || $this::$log_mysql === 'error') {
$this->log_mysql($curl_response);
}
throw new Exception('Could not connect to ' . $this->resource . '.');
}
// General (success) logging
if($this::$log_influx === true) {
$this->log_influx(
$this->resource . '_api_log',
false
);
}
if($this::$log_mysql === 'all') {
$this->log_mysql($curl_response);
}
if($should_cache === true) {
$this->create_update_cache_entry($cache_key, $curl_response);
}
curl_close($curl_handle);
}
else {
$curl_response = $cache_entry['response'];
}
return $curl_response;
}
/**
* Create an entry in the cache table. If one exists, update it.
*
* @param string $key
* @param string $response
*
* @return array The created or updated entry.
*/
private function create_update_cache_entry($key, $response) {
$cache_entry = $this->api(
($this->resource . '_api_cache'),
'get',
[
'attributes' => ['key' => $key]
]
);
if($cache_entry === null) {
return $this->api(
($this->resource . '_api_cache'),
'create',
[
'attributes' => [
'key' => $key,
'created_at' => date('Y-m-d H:i:s'),
'response' => $response
]
]
);
}
else {
$attributes = [
'created_at' => date('Y-m-d H:i:s'),
'response' => $response
];
$attributes[$this->resource . '_api_cache_id'] = $cache_entry[$this->resource . '_api_cache_id'];
return $this->api(
($this->resource . '_api_cache'),
'update',
[
'attributes' => $attributes
]
);
}
}
/**
* Get an entry in the cache table.
*
* @param string $key
*
* @return array The found cache entry, or null if none found.
*/
private function get_cache_entry($key) {
$attributes = [
'key' => $key
];
if($this::$cache_for !== null) {
$attributes['created_at'] = [
'operator' => '>',
'value' => date('Y-m-d H:i:s', strtotime('- ' . $this::$cache_for . ' seconds'))
];
}
return $this->api(
($this->resource . '_api_cache'),
'get',
[
'attributes' => $attributes
]
);
}
/**
* Log to InfluxDB/Grafana.
*
* @param array $measurement Which measurement to log as.
* @param boolean $exception Whether or not this was an exception (failure
* to connect, etc).
*/
private function log_influx($measurement, $exception) {
$this->api(
'logger',
'log_influx',
[
'measurement' => $measurement,
'tags' => [
'user_id' => $this->session->get_user_id(),
'api_user_id' => $this->cora->get_api_user()['api_user_id'],
'exception' => $exception === true ? '1' : '0'
],
'fields' => [
'http_code' => (int) $this->curl_info['http_code'],
'connect_time' => round($this->curl_info['connect_time'], 4)
],
'timestamp' => $this->request_timestamp_microtime
]
);
}
/**
* Log to MySQL with the complete details.
*
* @param array $curl_response The response of the cURL request.
*/
protected function log_mysql($curl_response) {
$this->api(
($this->resource . '_api_log'),
'create',
[
'attributes' => [
'api_user_id' => $this->cora->get_api_user()['api_user_id'],
'request_timestamp' => date('Y-m-d H:i:s', $this->request_timestamp),
'request' => $this->curl_info,
'response' => $curl_response,
]
]
);
}
/**
* Get microtime for influx.
*
* @link https://github.com/influxdata/influxdb-php
*
* @return string
*/
private function microtime() {
list($usec, $sec) = explode(' ', microtime());
return sprintf('%d%06d', $sec, $usec * 1000000);
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Cache for these external API calls.
*
* @author Jon Ziebell
*/
class external_api_cache extends cora\crud {
public static $converged = [];
public static $user_locked = false;
public function delete($id) {
throw new Exception('This method is not allowed.');
}
}

28
api/external_api_log.php Normal file
View File

@ -0,0 +1,28 @@
<?php
/**
* Log for these external API calls.
*
* @author Jon Ziebell
*/
class external_api_log extends cora\crud {
public static $converged = [
'request' => [
'type' => 'json'
],
'response' => [
'type' => 'string'
]
];
public static $user_locked = true;
public function read($attributes = [], $columns = []) {
throw new Exception('This method is not allowed.');
}
public function update($attributes) {
throw new Exception('This method is not allowed.');
}
}

53
api/index.php Normal file
View File

@ -0,0 +1,53 @@
<?php
/**
* Entry point for the API. This sets up cora, the error/exception handlers,
* and then sends the request off for processing. All requests should start
* here.
*
* @author Jon Ziebell
*/
// Compress output.
ob_start('ob_gzhandler');
// Set a reasonable time limit.
set_time_limit(5);
// Turn on all error reporting but disable displaying errors.
error_reporting(-1);
ini_set('display_errors', '0');
date_default_timezone_set('UTC');
// Autoload classes as necessary so there are no includes/requires. Note that
// calling spl_autoload_register() with no arguments is actually faster than
// this. The only reason I'm defining this function is because the default
// autoloader lowercases everything which tends to break other libraries.
spl_autoload_register(function($class) {
include str_replace('\\', '/', $class) . '.php';
});
// Construct cora and set up error handlers.
$cora = cora\cora::get_instance();
set_error_handler([$cora, 'error_handler']);
set_exception_handler([$cora, 'exception_handler']);
// The shutdown handler will output the response.
register_shutdown_function([$cora, 'shutdown_handler']);
// Useful function
function array_median($array) {
$count = count($array);
$middle = floor($count / 2);
sort($array, SORT_NUMERIC);
$median = $array[$middle]; // assume an odd # of items
// Handle the even case by averaging the middle 2 items
if ($count % 2 == 0) {
$median = ($median + $array[$middle - 1]) / 2;
}
return $median;
}
// Go!
$cora->process_request($_REQUEST);

136
api/logger.php Normal file
View File

@ -0,0 +1,136 @@
<?php
/**
* Log stuff to an Influx database. Does all the hard work so you don't have
* to.
*
* @author Jon Ziebell
*/
class logger extends cora\api {
/**
* Log something to InfluxDB. This will fire off the insert query as a
* background process and allow PHP to continue without blocking.
*
* @param string $measurement The measurement.
* @param array $tags Zero to many tags. These are indexed and searchable.
* Use these for things that need where clauses outside of timestamp.
* @param array $fields At least one field. These are not indexed.
* @param string $timestamp The timestamp in microseconds.
*/
public function log_influx($measurement, $tags, $fields, $timestamp) {
// If this is not configured, do not log.
if(
$this->setting->get('influx_database_host') === null ||
$this->setting->get('influx_database_port') === null ||
$this->setting->get('influx_database_name') === null ||
$this->setting->get('influx_database_username') === null ||
$this->setting->get('influx_database_password') === null
) {
return;
}
$tag_string = $this->get_tag_string($tags);
$field_string = $this->get_field_string($fields);
$data_binary =
$measurement .
($tag_string !== '' ? ',' : '') .
$tag_string . ' ' .
$field_string . ' ' .
$timestamp;
$url =
$this->setting->get('influx_database_host') .
':' .
$this->setting->get('influx_database_port') .
'/write' .
'?db=' . $this->setting->get('influx_database_name') .
'&precision=u';
exec(
'curl ' .
'-u ' . $this->setting->get('influx_database_username') . ':' . $this->setting->get('influx_database_password') . ' ' .
'-POST "' . $url . '" ' .
'--max-time 10 ' .
'--connect-timeout 5 ' .
'--data-binary \'' . $data_binary . '\' > /dev/null &'
);
}
/**
* Convert an array into a key/value string.
*
* @param array $array The input array. Null values are removed.
*
* @return string A string like "k1=v1,k2=v2". If no non-null values are
* present this will be an empty string.
*/
private function get_field_string($fields) {
$parts = [];
foreach($fields as $key => $value) {
if($value === null) {
continue;
} else if(is_bool($value) === true) {
$value = ($value === true) ? 'true' : 'false';
} else if(is_int($value) === true) {
$value = $value . 'i';
} else if(is_float($value) === true) {
$value = $value;
} else {
$value = $this->escape_field_value($value);
}
$parts[] = $key . '=' . $value;
}
return implode(',', $parts);
}
/**
* Convert a tag array into a key/value string. Tags are always strings in
* Influx.
*
* @param array $array The input array. Null values are removed.
*
* @return string A string like "k1=v1,k2=v2". If no non-null values are
* present this will be an empty string.
*/
private function get_tag_string($tags) {
$parts = [];
foreach($tags as $key => $value) {
if($value === null) {
continue;
} else {
$parts[] = $this->escape_tag_key_value($key) . '=' . $this->escape_tag_key_value($value);
}
}
return implode(',', $parts);
}
/**
* Add slashes where necessary to prevent injection attacks. Tag values just
* sit there unquoted (you can't quote them or the quote gets included as
* part of the value) so we have to escape other special characters in that
* context.
*
* @param string $value The value to escape.
*/
private function escape_tag_key_value($value) {
return str_replace([' ', ',', '='], ['\ ', '\,', '\='], $value);
}
/**
* Add slashes where necessary to prevent injection attacks. Field values
* sit inside of "", so escape any " characters. At a higher level they sit
* inside of a ' from the cURL body. Escape these as well.
*
* @param string $value The value to escape.
*/
private function escape_field_value($value) {
return '"' . str_replace(['"', "'"], ['\"', "'\''"], $value) . '"';
}
}

81
api/mailchimp.php Normal file
View File

@ -0,0 +1,81 @@
<?php
/**
* High level functionality for interacting with the Mailchimp API.
*
* @author Jon Ziebell
*/
class mailchimp extends external_api {
protected static $log_influx = true;
protected static $log_mysql = 'all';
protected static $cache = false;
protected static $cache_for = null;
/**
* Send an API call off to MailChimp
*
* @param string $method HTTP Method.
* @param string $endpoint API Endpoint.
* @param array $data API request data.
*
* @throws Exception If MailChimp did not return valid JSON.
*
* @return array The MailChimp response.
*/
private function mailchimp_api($method, $endpoint, $data) {
$curl_response = $this->curl([
'url' => 'https://us18.api.mailchimp.com/3.0/' . $endpoint,
'post_fields' => json_encode($data, JSON_FORCE_OBJECT),
'method' => $method,
'header' => [
'Authorization: Basic ' . base64_encode(':' . $this->setting->get('mailchimp_api_key')),
'Content-Type: application/x-www-form-urlencoded'
]
]);
$response = json_decode($curl_response, true);
if ($response === null) {
throw new Exception('Invalid JSON');
}
return $response;
}
/**
* Subscribe an email address to the mailing list. This will only mark you
* as "pending" so you have to click a link in the email to actually
* subscribe.
*
* @param string $email_address The email address to subscribe.
*
* @throws Exception If subscribing to the mailing list fails for some
* reason. For example, if already subscribed.
*
* @return array The MailChimp response.
*/
public function subscribe($email_address) {
$method = 'POST';
$endpoint =
'lists/' .
$this->setting->get('mailchimp_list_id') .
'/members/'
;
$data = [
'email_address' => $email_address,
'status' => 'pending'
];
$response = $this->mailchimp_api($method, $endpoint, $data);
if(isset($response['id']) === false) {
throw new Exception('Could not subscribe to mailing list.');
}
return $response;
}
}

View File

@ -0,0 +1,8 @@
<?php
/**
* Cache for these external API calls.
*
* @author Jon Ziebell
*/
class mailchimp_api_cache extends external_api_cache {}

View File

@ -0,0 +1,8 @@
<?php
/**
* Log for these external API calls.
*
* @author Jon Ziebell
*/
class mailchimp_api_log extends external_api_log {}

14
api/nest_sensor.php Normal file
View File

@ -0,0 +1,14 @@
<?php
/**
* Just a placeholder for now.
*
* @author Jon Ziebell
*/
class nest_sensor extends cora\crud {
public static $converged = [];
public static $user_locked = true;
}

14
api/nest_thermostat.php Normal file
View File

@ -0,0 +1,14 @@
<?php
/**
* Just a placeholder for now.
*
* @author Jon Ziebell
*/
class nest_thermostat extends cora\crud {
public static $converged = [];
public static $user_locked = true;
}

137
api/patreon.php Normal file
View File

@ -0,0 +1,137 @@
<?php
/**
* High level functionality for interacting with the Patreon API.
*
* @author Jon Ziebell
*/
class patreon extends external_api {
public static $exposed = [
'private' => [
'authorize',
'initialize'
],
'public' => []
];
protected static $log_influx = true;
protected static $log_mysql = 'all';
protected static $cache = false;
protected static $cache_for = null;
/**
* Redirect to Patreon to do the oAuth. Note: Put a space between scopes and
* urlencode the whole thing if it includes special characters.
*/
public function authorize() {
header('Location: https://www.patreon.com/oauth2/authorize?response_type=code&client_id=' . $this->setting->get('patreon_client_id') . '&redirect_uri=' . $this->setting->get('patreon_redirect_uri') . '&scope=identity');
die();
}
/**
* Obtain the first set of tokens for a a patreon user, then sync that
* user's Patreon settings, then return code that closes the window.
*
* @param string $code The code used to get tokens from patreon with.
*/
public function initialize($code = null) {
if($code !== null) {
$this->api('patreon_token', 'obtain', ['code' => $code]);
$this->api('user', 'sync_patreon_status');
}
echo '<html><head><title></title></head><body><script type="text/javascript">window.close();</script></body>';
die();
}
/**
* Send an API call to patreon 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.
* @param string $patreon_token Force-use a specific token.
*
* @return array The response of this API call.
*/
public function patreon_api($method, $endpoint, $arguments, $auto_refresh_token = true, $patreon_token = null) {
$curl = [
'method' => $method
];
// 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 = '/v2/' . $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($patreon_token === null) {
$patreon_tokens = $this->api(
'patreon_token',
'read',
[]
);
if(count($patreon_tokens) !== 1) {
throw new Exception('No token for this user');
}
$patreon_token = $patreon_tokens[0];
}
$curl['header'] = [
'Authorization: Bearer ' . $patreon_token['access_token'],
'Content-Type: application/x-www-form-urlencoded'
];
}
else {
$full_endpoint = '/' . $full_endpoint;
}
$curl['url'] = 'https://www.patreon.com/api/oauth2' . $full_endpoint;
if ($method === 'GET') {
$curl['url'] .= '?' . http_build_query($arguments);
}
if ($method === 'POST') {
// Attach the client_id to all POST requests. It errors if you include it
// in a GET.
$arguments['client_id'] = $this->setting->get('patreon_client_id');
$curl['post_fields'] = http_build_query($arguments);
}
$curl_response = $this->curl($curl);
$response = json_decode($curl_response, true);
if ($response === null) {
throw new Exception('Invalid JSON');
}
// 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) {
$this->api('patreon_token', 'refresh');
return $this->patreon_api($method, $endpoint, $arguments, false);
}
else {
throw new Exception($response['status']['message']);
}
}
else if (isset($response['status']) === true && $response['status']['code'] !== 0) {
// Any other error
throw new Exception($response['status']['message']);
}
else {
return $response;
}
}
}

View File

@ -0,0 +1,8 @@
<?php
/**
* Cache for these external API calls.
*
* @author Jon Ziebell
*/
class patreon_api_cache extends external_api_cache {}

8
api/patreon_api_log.php Normal file
View File

@ -0,0 +1,8 @@
<?php
/**
* Log for these external API calls.
*
* @author Jon Ziebell
*/
class patreon_api_log extends external_api_log {}

View File

@ -0,0 +1,23 @@
<?php
/**
* Patreon hits this file after authorizing. Patreon (or maybe oauth) does not
* support URL parameters in the redirect_uri, so redirecting here and then
* redirecting again to the API. ¯\_()_/¯
*
* @author Jon Ziebell
*/
require 'cora/setting.php';
$arguments = [];
if(isset($_GET['code']) === true) {
$arguments['code'] = $_GET['code'];
}
$setting = cora\setting::get_instance();
header('Location: ' . $setting->get('beestat_root_uri') . 'api/index.php?resource=patreon&method=initialize&arguments=' . json_encode($arguments) . '&api_key=' . $setting->get('patreon_api_key_local'));
die();

147
api/patreon_token.php Normal file
View File

@ -0,0 +1,147 @@
<?php
/**
* Tokens for authorizing access to Patreon accounts.
*
* @author Jon Ziebell
*/
class patreon_token extends cora\crud {
public static $converged = [];
public static $user_locked = true;
/**
* Obtain Patreon access & refresh tokens. If a token already exists for
* this user, overwrite it.
*
* @param string $code The code from patreon used to obtain the
* access/refresh tokens.
*
* @return array The patreon_token row.
*/
public function obtain($code) {
// Obtain the access and refresh tokens from the authorization code.
$response = $this->api(
'patreon',
'patreon_api',
[
'method' => 'POST',
'endpoint' => 'token',
'arguments' => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->setting->get('patreon_redirect_uri')
]
]
);
// Make sure we got the expected result.
if (
isset($response['access_token']) === false ||
isset($response['refresh_token']) === false
) {
throw new Exception('Could not get first token');
}
$new_patreon_token = [
'access_token' => $response['access_token'],
'refresh_token' => $response['refresh_token']
];
$existing_patreon_tokens = $this->read();
if(count($existing_patreon_tokens) > 0) {
$new_patreon_token['patreon_token_id'] = $existing_patreon_tokens[0]['patreon_token_id'];
$this->update(
$new_patreon_token
);
}
else {
$this->create($new_patreon_token);
}
return $this->read()[0];
}
/**
* Get some new tokens. A database lock is obtained prior to getting a token
* so that no other API call can attempt to get a token at the same time.
* This way if two API calls fire off to patreon at the same time, then
* return at the same time, then call token->refresh() at the same time,
* only one can run and actually refresh at a time. If the second one runs
* after that's fine as it will look up the token prior to refreshing.
*
* Also this creates a new database connection. If a token is written to the
* database, then the transaction gets rolled back, the token will be
* erased. I originally tried to avoid this by not using transactions except
* when syncing, but there are enough sync errors that happen where this
* causes a problem. The extra overhead of a second database connection
* every now and then shouldn't be that bad.
*/
public function refresh() {
$database = cora\database::get_second_instance();
$lock_name = 'patreon_token->refresh(' . $this->session->get_user_id() . ')';
$database->get_lock($lock_name, 3);
// $patreon_tokens = $this->read();
$patreon_tokens = $database->read(
'patreon_token',
[
'user_id' => $this->session->get_user_id()
]
);
if(count($patreon_tokens) === 0) {
throw new Exception('Could not refresh patreon token; no token found.', 10002);
}
$patreon_token = $patreon_tokens[0];
$response = $this->api(
'patreon',
'patreon_api',
[
'method' => 'POST',
'endpoint' => 'token',
'arguments' => [
'grant_type' => 'refresh_token',
'refresh_token' => $patreon_token['refresh_token']
]
]
);
if (
isset($response['access_token']) === false ||
isset($response['refresh_token']) === false
) {
$this->delete($patreon_token['patreon_token_id']);
$database->release_lock($lock_name);
throw new Exception('Could not refresh patreon token; patreon returned no token.', 10003);
}
$database->update(
'patreon_token',
[
'patreon_token_id' => $patreon_token['patreon_token_id'],
'access_token' => $response['access_token'],
'refresh_token' => $response['refresh_token'],
'timestamp' => date('Y-m-d H:i:s')
]
);
$database->release_lock($lock_name);
}
/**
* Delete an patreon token.
*
* @param int $id
*
* @return int
*/
public function delete($id) {
$database = database::get_second_instance();
$return = $database->delete('patreon_token', $id);
return $return;
}
}

53
api/sensor.php Normal file
View File

@ -0,0 +1,53 @@
<?php
/**
* Sensor for any thermostat type.
*
* @author Jon Ziebell
*/
class sensor extends cora\crud {
public static $exposed = [
'private' => [
'read_id',
'sync'
],
'public' => []
];
public static $cache = [
'sync' => 300 // 5 Minutes
];
public static $converged = [];
public static $user_locked = true;
/**
* Sync all sensors connected to this account. Once Nest support is
* added this will need to check for all connected accounts and run the
* appropriate ones.
*/
public function sync() {
// Skip this for the demo
if($this->setting->is_demo() === true) {
return;
}
$lock_name = 'sensor->sync(' . $this->session->get_user_id() . ')';
$this->database->get_lock($lock_name);
$this->api('ecobee_sensor', 'sync');
$this->api(
'user',
'update_sync_status',
[
'key' => 'sensor'
]
);
$this->database->release_lock($lock_name);
}
}

99
api/smarty_streets.php Normal file
View File

@ -0,0 +1,99 @@
<?php
/**
* High level functionality for interacting with the SmartyStreets API.
*
* @author Jon Ziebell
*/
/**
* Smarty Streets provides address lookup for normalizing address strings.
* This is useful for turning bad data into good data. For example, much of
* the data beestat gets has inconsistent naming (IN vs Indiana) between
* thermostats. This fixes those issues, provides geocoding, and increases the
* amount of trust I can put in locations for various reports.
*/
class smarty_streets extends external_api {
protected static $log_influx = true;
protected static $log_mysql = 'all';
protected static $cache = true;
protected static $cache_for = null;
/**
* Send an API call to smarty_streets and return the response.
*
* @param string $address_string The address to look up.
* @param array $country The country the address is in.
*
* @return array The response of this API call.
*/
public function smarty_streets_api($address_string, $country) {
// Smarty doesn't like this.
if(trim($address_string) === '') {
return null;
}
// Smarty has a different endpoint for USA vs International.
if ($country === 'USA') {
$url = 'https://us-street.api.smartystreets.com/street-address';
$url .= '?' . http_build_query([
'street' => $address_string,
'auth-id' => $this->setting->get('smarty_streets_auth_id'),
'auth-token' => $this->setting->get('smarty_streets_auth_token')
]);
} else {
$url = 'https://international-street.api.smartystreets.com/verify';
$url .= '?' . http_build_query([
'freeform' => $address_string,
'country' => $country,
'geocode' => 'true',
'auth-id' => $this->setting->get('smarty_streets_auth_id'),
'auth-token' => $this->setting->get('smarty_streets_auth_token')
]);
}
$curl_response = $this->curl([
'url' => $url
]);
$response = json_decode($curl_response, true);
if ($response === null) {
throw new Exception('Invalid JSON');
}
if (count($response) === 0) {
return null;
} else {
// Smarty doesn't return this but I want it.
if($country === 'USA') {
$response[0]['components']['country_iso_3'] = 'USA';
}
return $response[0];
}
}
/**
* Generate a cache key from a URL. Just hashes it.
*
* @param array $arguments
*
* @return string
*/
protected function generate_cache_key($arguments) {
return sha1($arguments['url']);
}
/**
* Determine whether or not a request should be cached. For this, just cache
* everything.
*
* @param array $arguments
*
* @return boolean
*/
protected function should_cache($arguments) {
return true;
}
}

View File

@ -0,0 +1,8 @@
<?php
/**
* Cache for these external API calls.
*
* @author Jon Ziebell
*/
class smarty_streets_api_cache extends external_api_cache {}

View File

@ -0,0 +1,8 @@
<?php
/**
* Log for these external API calls.
*
* @author Jon Ziebell
*/
class smarty_streets_api_log extends external_api_log {}

766
api/temperature_profile.php Normal file
View File

@ -0,0 +1,766 @@
<?php
/**
* Some functionality for generating and working with temperature profiles.
*
* @author Jon Ziebell
*/
class temperature_profile extends cora\api {
public static $exposed = [
'private' => [],
'public' => []
];
public static $cache = [
'generate' => 604800 // 7 Days
];
/**
* Generate a temperature profile for the specified thermostat.
*
* @param int $thermostat_id
* @param string $begin Begin date (local time).
* @param string $end End date (local time).
*
* @return array
*/
public function generate($thermostat_id, $begin, $end) {
set_time_limit(0);
$save = ($begin === null && $end === null);
// Begin and end are dates, not timestamps. Force that.
if($begin !== null) {
$begin = date('Y-m-d 00:00:00', strtotime($begin));
}
if($end !== null) {
$end = date('Y-m-d 23:59:59', strtotime($end));
}
/**
* This is an interesting thing to fiddle with. Basically, the longer the
* minimum sample duration, the better your score. For example, let's say
* I set this to 10m and my 30° delta is -1°. If I increase the time to
* 60m, I may find that my 30° delta decreases to -0.5°.
*
* Initially I thought something was wrong, but this makes logical sense.
* If I'm only utilizing datasets where the system was completely off for
* a longer period of time, then I can infer that the outdoor conditions
* were favorable to allowing that to happen. Higher minimums most likely
* only include sunny periods with low wind.
*
* For now this is set to 30m, which I feel is an appropriate requirement.
* I am not factoring in any variables outside of temperature for now.
* Note that 30m is a MINIMUM due to the zone_calendar_event logic that
* will go back in time by 30m to account for sensor changes if the
* calendar event changes.
*/
$minimum_sample_duration = [
'heat' => 300,
'cool' => 300,
'resist' => 1800
];
/**
* How long the system must be on/off for before starting a sample. Setting
* this to 5 minutes will use the very first sample which is fine if you
* assume the temperature in the sample is taken at the end of the 5m.
*/
$minimum_off_for = 300;
$minimum_on_for = 300;
/**
* Increasing this value will decrease the number of data points by
* allowing for larger outdoor temperature swings in a single sample. For
* example, a value of 1 will start a new sample if the temperature
* changes by 1°, and a value of 5 will start a new sample if the
* temperature changes by 5°.
*/
$smoothing = 1;
/**
* Require this many individual samples in a delta for a specific outdoor
* temperature. Increasing this basically cuts off the extremes where
* there are fewer samples.
*/
$required_samples = 2;
/**
* Require this many individual points before a valid temperature profile
* can be returned.
*/
$required_points = 5;
/**
* How far back to query for additional data. For example, when the
* zone_calendar_event changes I pull data from 30m ago. If that data is
* not available in the current runtime chunk, then it will fail. This
* will make sure that data is always included.
*/
$max_lookback = 1800; // 30 min
/**
* How far in the future to query for additional data. For example, if a
* sample ends 20 minutes prior to an event change, I need to look ahead
* to see if an event change is in the future. If so, I need to adjust for
* that because the sensor averages will already be wrong.
*/
$max_lookahead = 1800; // 30 min
// Get some stuff
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
$ecobee_thermostat_id = $thermostat['ecobee_thermostat_id'];
$ecobee_thermostat = $this->api('ecobee_thermostat', 'get', $thermostat['ecobee_thermostat_id']);
$this->database->set_time_zone($ecobee_thermostat['json_location']['timeZoneOffsetMinutes']);
// Figure out all the starting and ending times. Round begin/end to the
// nearest 5 minutes to help with the looping later on.
$offset = $ecobee_thermostat['json_location']['timeZoneOffsetMinutes'];
$end_timestamp = ($end === null ? (time() + ($offset * 60)) : strtotime($end));
$begin_timestamp = ($begin === null ? strtotime('-1 year', $end_timestamp) : strtotime($begin));
// Swap them if they are backwards.
if($end_timestamp < $begin_timestamp) {
$tmp = $end_timestamp;
$end_timestamp = $begin_timestamp;
$begin_timestamp = $tmp;
}
// Round to 5 minute intervals.
$begin_timestamp = floor($begin_timestamp / 300) * 300;
$end_timestamp = floor($end_timestamp / 300) * 300;
$group_thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat['thermostat_group_id'],
'inactive' => 0
]
]
);
// Get all of the relevant data
$ecobee_thermostat_ids = [];
foreach($group_thermostats as $thermostat) {
$ecobee_thermostat_ids[] = $thermostat['ecobee_thermostat_id'];
}
/**
* Get the largest possible chunk size given the number of thermostats I
* have to select data for. This is necessary to prevent the script from
* running out of memory. Also, as of PHP 7, structures have 6-7x of
* memory overhead.
*/
$memory_limit = 16; // mb
$memory_per_thermostat_per_day = 0.6; // mb
$days = (int) floor($memory_limit / ($memory_per_thermostat_per_day * count($ecobee_thermostat_ids)));
$chunk_size = $days * 86400;
if($chunk_size === 0) {
throw new Exception('Too many thermostats; cannot generate temperature profile.');
}
$current_timestamp = $begin_timestamp;
$chunk_end_timestamp = 0;
$five_minutes = 300;
$thirty_minutes = 1800;
$all_off_for = 0;
$cool_on_for = 0;
$heat_on_for = 0;
$samples = [];
$times = [
'heat' => [],
'cool' => [],
'resist' => []
];
$begin_runtime = [];
while($current_timestamp <= $end_timestamp) {
// Get a new chunk of data.
if($current_timestamp >= $chunk_end_timestamp) {
$chunk_end_timestamp = $current_timestamp + $chunk_size;
$query = '
select
`timestamp`,
`ecobee_thermostat_id`,
`zone_average_temperature`,
`outdoor_temperature`,
`compressor_heat_1`,
`compressor_heat_2`,
`auxiliary_heat_1`,
`auxiliary_heat_2`,
`auxiliary_heat_3`,
`compressor_cool_1`,
`compressor_cool_2`,
`zone_calendar_event`,
`zone_climate`
from
`ecobee_runtime_thermostat`
where
`user_id` = ' . $this->database->escape($this->session->get_user_id()) . '
and `ecobee_thermostat_id` in (' . implode(',', $ecobee_thermostat_ids) . ')
and `timestamp` >= "' . date('Y-m-d H:i:s', ($current_timestamp - $max_lookback)) . '"
and `timestamp` < "' . date('Y-m-d H:i:s', ($chunk_end_timestamp + $max_lookahead)) . '"
';
$result = $this->database->query($query);
$runtime = [];
while($row = $result->fetch_assoc()) {
if(
$thermostat['system_type']['detected']['heat'] === 'compressor' ||
$thermostat['system_type']['detected']['heat'] === 'geothermal'
) {
$row['heat'] = max(
$row['compressor_heat_1'],
$row['compressor_heat_2']
);
$row['auxiliary_heat'] = max(
$row['auxiliary_heat_1'],
$row['auxiliary_heat_2'],
$row['auxiliary_heat_3']
);
} else {
$row['heat'] = max(
$row['auxiliary_heat_1'],
$row['auxiliary_heat_2'],
$row['auxiliary_heat_3']
);
$row['auxiliary_heat'] = 0;
}
$row['cool'] = max(
$row['compressor_cool_1'],
$row['compressor_cool_2']
);
$timestamp = strtotime($row['timestamp']);
if (isset($runtime[$timestamp]) === false) {
$runtime[$timestamp] = [];
}
$runtime[$timestamp][$row['ecobee_thermostat_id']] = $row;
}
}
if(
isset($runtime[$current_timestamp]) === true && // Had data for at least one thermostat
isset($runtime[$current_timestamp][$ecobee_thermostat_id]) === true // Had data for the requested thermostat
) {
$current_runtime = $runtime[$current_timestamp][$ecobee_thermostat_id];
if($current_runtime['outdoor_temperature'] !== null) {
$current_runtime['outdoor_temperature'] = round($current_runtime['outdoor_temperature'] / $smoothing) * $smoothing;
}
/**
* OFF START
*/
$most_off = true;
$all_off = true;
if(
count($runtime[$current_timestamp]) < count($ecobee_thermostat_ids)
) {
// If I didn't get data at this timestamp for all thermostats in the
// group, all off can't be true.
$all_off = false;
$most_off = false;
}
else {
foreach($runtime[$current_timestamp] as $runtime_ecobee_thermostat_id => $thermostat_runtime) {
if(
$thermostat_runtime['compressor_heat_1'] !== 0 ||
$thermostat_runtime['compressor_heat_2'] !== 0 ||
$thermostat_runtime['auxiliary_heat_1'] !== 0 ||
$thermostat_runtime['auxiliary_heat_2'] !== 0 ||
$thermostat_runtime['auxiliary_heat_3'] !== 0 ||
$thermostat_runtime['compressor_cool_1'] !== 0 ||
$thermostat_runtime['compressor_cool_2'] !== 0 ||
$thermostat_runtime['outdoor_temperature'] === null ||
$thermostat_runtime['zone_average_temperature'] === null ||
(
// Wasn't syncing this until mid-November 2018. Just going with December to be safe.
$thermostat_runtime['zone_climate'] === null &&
$current_timestamp > 1543640400
)
) {
// If I did have data at this timestamp for all thermostats in the
// group, check and see if were fully off. Also if any of the
// things used in the algorithm are just missing, assume the
// system might have been running.
$all_off = false;
// If everything _but_ the requested thermostat is off. This is
// used for the heat/cool scores as I need to only gather samples
// when everything else is off.
if($runtime_ecobee_thermostat_id !== $ecobee_thermostat_id) {
$most_off = false;
}
}
}
}
// Assume that the runtime rows represent data at the end of that 5
// minutes.
if($all_off === true) {
$all_off_for += $five_minutes;
// Store the begin runtime row if the system has been off for the
// requisite length. This gives the temperatures a chance to settle.
if($all_off_for === $minimum_off_for) {
$begin_runtime['resist'] = $current_runtime;
}
}
else {
$all_off_for = 0;
}
/**
* HEAT START
*/
// Track how long the heat has been on for.
if($current_runtime['heat'] > 0) {
$heat_on_for += $current_runtime['heat'];
} else {
if($heat_on_for > 0) {
$times['heat'][] = $heat_on_for;
}
$heat_on_for = 0;
}
// Store the begin runtime for heat when the heat has been on for this
// thermostat only for the required minimum and everything else is off.
if(
$most_off === true &&
$heat_on_for >= $minimum_on_for &&
$current_runtime['auxiliary_heat'] === 0 &&
isset($begin_runtime['heat']) === false
) {
$begin_runtime['heat'] = $current_runtime;
}
/**
* COOL START
*/
// Track how long the cool has been on for.
if($current_runtime['cool'] > 0) {
$cool_on_for += $current_runtime['cool'];
} else {
if($cool_on_for > 0) {
$times['cool'][] = $cool_on_for;
}
$cool_on_for = 0;
}
// Store the begin runtime for cool when the cool has been on for this
// thermostat only for the required minimum and everything else is off.
if(
$most_off === true &&
$cool_on_for >= $minimum_on_for &&
isset($begin_runtime['cool']) === false
) {
$begin_runtime['cool'] = $current_runtime;
}
// Look for changes which would trigger a sample to be gathered.
if(
(
// Heat
// Gather a "heat" delta for one of the following reasons.
// - The outdoor temperature changed
// - The calendar event changed
// - The climate changed
// - One of the other thermostats in this group turned on
($sample_type = 'heat') &&
isset($begin_runtime['heat']) === true &&
isset($previous_runtime) === true &&
(
$current_runtime['outdoor_temperature'] !== $begin_runtime['heat']['outdoor_temperature'] ||
$current_runtime['zone_calendar_event'] !== $begin_runtime['heat']['zone_calendar_event'] ||
$current_runtime['zone_climate'] !== $begin_runtime['heat']['zone_climate'] ||
$most_off === false
)
) ||
(
// Cool
// Gather a "cool" delta for one of the following reasons.
// - The outdoor temperature changed
// - The calendar event changed
// - The climate changed
// - One of the other thermostats in this group turned on
($sample_type = 'cool') &&
isset($begin_runtime['cool']) === true &&
isset($previous_runtime) === true &&
(
$current_runtime['outdoor_temperature'] !== $begin_runtime['cool']['outdoor_temperature'] ||
$current_runtime['zone_calendar_event'] !== $begin_runtime['cool']['zone_calendar_event'] ||
$current_runtime['zone_climate'] !== $begin_runtime['cool']['zone_climate'] ||
$most_off === false
)
) ||
(
// Resist
// Gather an "off" delta for one of the following reasons.
// - The outdoor temperature changed
// - The calendar event changed
// - The climate changed
// - The system turned back on after being off
($sample_type = 'resist') &&
isset($begin_runtime['resist']) === true &&
isset($previous_runtime) === true &&
(
$current_runtime['outdoor_temperature'] !== $begin_runtime['resist']['outdoor_temperature'] ||
$current_runtime['zone_calendar_event'] !== $begin_runtime['resist']['zone_calendar_event'] ||
$current_runtime['zone_climate'] !== $begin_runtime['resist']['zone_climate'] ||
$all_off === false
)
)
) {
// By default the end sample is the previous sample (five minutes ago).
$offset = $five_minutes;
// If zone_calendar_event or zone_climate changes, need to ignore data
// from the previous 30 minutes as there are sensors changing during
// that time.
if(
$current_runtime['zone_calendar_event'] !== $begin_runtime[$sample_type]['zone_calendar_event'] ||
$current_runtime['zone_climate'] !== $begin_runtime[$sample_type]['zone_climate']
) {
$offset = $thirty_minutes;
} else {
// Start looking ahead into the next 30 minutes looking for changes
// to zone_calendar_event and zone_climate.
$lookahead = $five_minutes;
while($lookahead <= $thirty_minutes) {
if(
isset($runtime[$current_timestamp + $lookahead]) === true &&
isset($runtime[$current_timestamp + $lookahead][$ecobee_thermostat_id]) === true &&
(
$runtime[$current_timestamp + $lookahead][$ecobee_thermostat_id]['zone_calendar_event'] !== $current_runtime['zone_calendar_event'] ||
$runtime[$current_timestamp + $lookahead][$ecobee_thermostat_id]['zone_climate'] !== $current_runtime['zone_climate']
)
) {
$offset = ($thirty_minutes - $lookahead);
break;
}
$lookahead += $five_minutes;
}
}
// Now use the offset to set the proper end_runtime. This simply makes
// sure the data is present and then uses it. In the case where the
// desired data is missing, I *could* look back further but I'm not
// going to bother. It's pretty rare and adds some unwanted complexity
// to this.
if(
isset($runtime[$current_timestamp - $offset]) === true &&
isset($runtime[$current_timestamp - $offset][$ecobee_thermostat_id]) === true &&
($current_timestamp - $offset) > strtotime($begin_runtime[$sample_type]['timestamp'])
) {
$end_runtime = $runtime[$current_timestamp - $offset][$ecobee_thermostat_id];
} else {
$end_runtime = null;
}
if($end_runtime !== null) {
$delta = $end_runtime['zone_average_temperature'] - $begin_runtime[$sample_type]['zone_average_temperature'];
$duration = strtotime($end_runtime['timestamp']) - strtotime($begin_runtime[$sample_type]['timestamp']);
if($duration > 0) {
$sample = [
'type' => $sample_type,
'outdoor_temperature' => $begin_runtime[$sample_type]['outdoor_temperature'],
'delta' => $delta,
'duration' => $duration,
'delta_per_hour' => $delta / $duration * 3600,
];
$samples[] = $sample;
}
}
// If in this block of code a change in runtime was detected, so
// update $begin_runtime[$sample_type] to the current runtime.
$begin_runtime[$sample_type] = $current_runtime;
}
$previous_runtime = $current_runtime;
}
// After a change was detected it automatically moves begin to the
// current_runtime to start a new sample. This might be invalid so need to
// unset it if so.
if(
$heat_on_for === 0 ||
$current_runtime['outdoor_temperature'] === null ||
$current_runtime['zone_average_temperature'] === null ||
$current_runtime['auxiliary_heat'] > 0
) {
unset($begin_runtime['heat']);
}
if(
$cool_on_for === 0 ||
$current_runtime['outdoor_temperature'] === null ||
$current_runtime['zone_average_temperature'] === null
) {
unset($begin_runtime['cool']);
}
if($all_off_for === 0) {
unset($begin_runtime['resist']);
}
$current_timestamp += $five_minutes;
}
// Process the samples
$deltas_raw = [];
foreach($samples as $sample) {
$is_valid_sample = true;
if($sample['duration'] < $minimum_sample_duration[$sample['type']]) {
$is_valid_sample = false;
}
if($is_valid_sample === true) {
if(isset($deltas_raw[$sample['type']]) === false) {
$deltas_raw[$sample['type']] = [];
}
if(isset($deltas_raw[$sample['type']][$sample['outdoor_temperature']]) === false) {
$deltas_raw[$sample['type']][$sample['outdoor_temperature']] = [
'deltas_per_hour' => []
];
}
$deltas_raw[$sample['type']][$sample['outdoor_temperature']]['deltas_per_hour'][] = $sample['delta_per_hour'];
}
}
$deltas = [];
foreach($deltas_raw as $type => $raw) {
if(isset($deltas[$type]) === false) {
$deltas[$type] = [];
}
foreach($raw as $outdoor_temperature => $data) {
if(
isset($deltas[$type][$outdoor_temperature]) === false &&
count($data['deltas_per_hour']) >= $required_samples
) {
$deltas[$type][$outdoor_temperature] = round(array_median($data['deltas_per_hour']), 2);
}
}
}
// Generate the final temperature profile and save it.
$temperature_profile = [];
foreach($deltas as $type => $data) {
if(count($data) < $required_points) {
continue;
}
ksort($deltas[$type]);
// For heating/cooling, factor in cycle time.
if(count($times[$type]) > 0) {
$cycles_per_hour = round(60 / (array_median($times[$type]) / 60), 2);
} else {
$cycles_per_hour = null;
}
$linear_trendline = $this->api(
'temperature_profile',
'get_linear_trendline',
[
'data' => $deltas[$type]
]
);
$temperature_profile[$type] = [
'deltas' => $deltas[$type],
'linear_trendline' => $linear_trendline,
'cycles_per_hour' => $cycles_per_hour,
'metadata' => [
'generated_at' => date('Y-m-d H:i:s')
]
];
$temperature_profile[$type]['score'] = $this->api(
'temperature_profile',
'get_score',
[
'type' => $type,
'temperature_profile' => $temperature_profile[$type]
]
);
}
// Only actually save this profile to the thermostat if it was run with the
// default settings (aka the last year). Anything else is not valid to save.
if($save === true) {
$this->api(
'thermostat',
'update',
[
'attributes' => [
'thermostat_id' => $thermostat['thermostat_id'],
'temperature_profile' => $temperature_profile
]
]
);
}
$this->database->set_time_zone(0);
// Force these to actually return, but set them to null if there's no data.
foreach(['heat', 'cool', 'resist'] as $type) {
if(
isset($temperature_profile[$type]) === false ||
count($temperature_profile[$type]['deltas']) === 0
) {
$temperature_profile[$type] = null;
}
}
return $temperature_profile;
}
/**
* Get the properties of a linear trendline for a given set of data.
*
* @param array $data
*
* @return array [slope, intercept]
*/
public function get_linear_trendline($data) {
// Requires at least two points.
if(count($data) < 2) {
return null;
}
$sum_x = 0;
$sum_y = 0;
$sum_xy = 0;
$sum_x_squared = 0;
$n = 0;
foreach($data as $x => $y) {
$sum_x += $x;
$sum_y += $y;
$sum_xy += ($x * $y);
$sum_x_squared += pow($x, 2);
$n++;
}
$slope = (($n * $sum_xy) - ($sum_x * $sum_y)) / (($n * $sum_x_squared) - (pow($sum_x, 2)));
$intercept = (($sum_y) - ($slope * $sum_x)) / ($n);
return [
'slope' => round($slope, 2),
'intercept' => round($intercept, 2)
];
}
/**
* Get the score from a linear trendline. For heating and cooling the slope
* is most of the score. For resist it is all of the score.
*
* Slope score is calculated as a percentage between 0 and whatever 3
* standard deviations from the mean is. For example, if that gives a range
* from 0-5, a slope of 2.5 would give you a base score of 0.5 which is then
* weighted in with the rest of the factors.
*
* Cycles per hour score is calculated as a flat 0.25 base score for every
* CPH under 4. For example, a CPH of 1
*
* @param array $temperature_profile
*
* @return int
*/
public function get_score($type, $temperature_profile) {
if(
$temperature_profile['linear_trendline'] === null
) {
return null;
}
$weights = [
'heat' => [
'slope' => 0.6,
'cycles_per_hour' => 0.1,
'balance_point' => 0.3
],
'cool' => [
'slope' => 0.6,
'cycles_per_hour' => 0.1,
'balance_point' => 0.3
],
'resist' => [
'slope' => 1
]
];
// Slope score
switch($type) {
case 'heat':
$slope_mean = 0.042;
$slope_standard_deviation = 0.179;
$balance_point_mean = -12.235;
// This is arbitrary. The actual SD is really high due to what I think
// is poor data. Further investigating but for now this does a pretty
// good job.
$balance_point_standard_deviation = 20;
break;
case 'cool':
$slope_mean = 0.066;
$slope_standard_deviation = 0.29;
$balance_point_mean = 90.002;
// This is arbitrary. The actual SD is really high due to what I think
// is poor data. Further investigating but for now this does a pretty
// good job.
$balance_point_standard_deviation = 20;
break;
case 'resist':
$slope_mean = 0.034;
$slope_standard_deviation = 0.018;
break;
}
$parts = [];
$slope_max = $slope_mean + ($slope_standard_deviation * 3);
$parts['slope'] = ($slope_max - $temperature_profile['linear_trendline']['slope']) / $slope_max;
$parts['slope'] = max(0, min(1, $parts['slope']));
if($type === 'heat' || $type === 'cool') {
if($temperature_profile['linear_trendline']['slope'] == 0) {
$parts['balance_point'] = 1;
} else {
$balance_point_min = $balance_point_mean - ($balance_point_standard_deviation * 3);
$balance_point_max = $balance_point_mean + ($balance_point_standard_deviation * 3);
$balance_point = -$temperature_profile['linear_trendline']['intercept'] / $temperature_profile['linear_trendline']['slope'];
$parts['balance_point'] = ($balance_point - $balance_point_min) / ($balance_point_max - $balance_point_min);
$parts['balance_point'] = max(0, min(1, $parts['balance_point']));
}
}
// Cycles per hour score
if($temperature_profile['cycles_per_hour'] !== null) {
$parts['cycles_per_hour'] = (4 - $temperature_profile['cycles_per_hour']) * 0.25;
$parts['cycles_per_hour'] = max(0, min(1, $parts['cycles_per_hour']));
}
$score = 0;
foreach($parts as $key => $value) {
$score += $value * $weights[$type][$key];
}
return round($score * 10, 1);
}
}

107
api/thermostat.php Normal file
View File

@ -0,0 +1,107 @@
<?php
/**
* Any type of thermostat.
*
* @author Jon Ziebell
*/
class thermostat extends cora\crud {
public static $exposed = [
'private' => [
'read_id',
'sync',
'dismiss_alert',
'restore_alert'
],
'public' => []
];
public static $cache = [
'sync' => 300 // 5 Minutes
];
public static $converged = [
'filters' => [
'type' => 'json'
],
'temperature_profile' => [
'type' => 'json'
],
'property' => [
'type' => 'json'
],
'system_type' => [
'type' => 'json'
]
];
public static $user_locked = true;
/**
* Sync all thermostats for the current user with their associated service.
*/
public function sync() {
// Skip this for the demo
if($this->setting->is_demo() === true) {
return;
}
$lock_name = 'thermostat->sync(' . $this->session->get_user_id() . ')';
$this->database->get_lock($lock_name);
$this->api('ecobee_thermostat', 'sync');
$this->api(
'user',
'update_sync_status',
['key' => 'thermostat']
);
$this->database->release_lock($lock_name);
}
/**
* Dismiss an alert.
*
* @param int $thermostat_id
* @param string $guid
*/
public function dismiss_alert($thermostat_id, $guid) {
$thermostat = $this->get($thermostat_id);
foreach($thermostat['json_alerts'] as &$alert) {
if($alert['guid'] === $guid) {
$alert['dismissed'] = true;
break;
}
}
$this->update(
[
'thermostat_id' => $thermostat_id,
'json_alerts' => $thermostat['json_alerts']
]
);
}
/**
* Restore a dismissed alert.
*
* @param int $thermostat_id
* @param string $guid
*/
public function restore_alert($thermostat_id, $guid) {
$thermostat = $this->get($thermostat_id);
foreach($thermostat['json_alerts'] as &$alert) {
if($alert['guid'] === $guid) {
$alert['dismissed'] = false;
break;
}
}
$this->update(
[
'thermostat_id' => $thermostat_id,
'json_alerts' => $thermostat['json_alerts']
]
);
}
}

427
api/thermostat_group.php Normal file
View File

@ -0,0 +1,427 @@
<?php
/**
* A group of thermostats. Thermostats are grouped by address. A thermostat
* group has any number of thermostats and a single combined temperature
* profile.
*
* @author Jon Ziebell
*/
class thermostat_group extends cora\crud {
public static $exposed = [
'private' => [
'read_id',
'generate_temperature_profiles',
'generate_temperature_profile',
'get_scores',
'update_system_types'
],
'public' => []
];
public static $cache = [
'generate_temperature_profile' => 604800, // 7 Days
'generate_temperature_profiles' => 604800, // 7 Days
'get_scores' => 604800 // 7 Days
];
public static $converged = [
'temperature_profile' => [
'type' => 'json'
]
];
public static $user_locked = true;
/**
* Generate the group temperature profile.
*
* @param int $thermostat_group_id
* @param string $begin When to begin the temperature profile at.
* @param string $end When to end the temperature profile at.
*
* @return array
*/
public function generate_temperature_profile($thermostat_group_id, $begin, $end) {
if($begin === null && $end === null) {
$save = true;
} else {
$save = false;
}
// Get all thermostats in this group.
$thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat_group_id
]
]
);
// Generate a temperature profile for each thermostat in this group.
$temperature_profiles = [];
foreach($thermostats as $thermostat) {
$temperature_profiles[] = $this->api(
'temperature_profile',
'generate',
[
'thermostat_id' => $thermostat['thermostat_id'],
'begin' => $begin,
'end' => $end
]
);
}
// Get all of the individual deltas for averaging.
$group_temperature_profile = [];
foreach($temperature_profiles as $temperature_profile) {
foreach($temperature_profile as $type => $data) {
if($data !== null) {
foreach($data['deltas'] as $outdoor_temperature => $delta) {
$group_temperature_profile[$type]['deltas'][$outdoor_temperature][] = $delta;
}
if(isset($data['cycles_per_hour']) === true) {
$group_temperature_profile[$type]['cycles_per_hour'][] = $data['cycles_per_hour'];
}
// if(isset($data['generated_at']) === true) {
// $group_temperature_profile[$type]['generated_at'][] = $data['generated_at'];
// }
}
}
}
// Calculate the average deltas, then get the trendline and score.
foreach($group_temperature_profile as $type => $data) {
foreach($data['deltas'] as $outdoor_temperature => $delta) {
$group_temperature_profile[$type]['deltas'][$outdoor_temperature] =
array_sum($group_temperature_profile[$type]['deltas'][$outdoor_temperature]) /
count($group_temperature_profile[$type]['deltas'][$outdoor_temperature]);
}
ksort($group_temperature_profile[$type]['deltas']);
if(isset($data['cycles_per_hour']) === true) {
$group_temperature_profile[$type]['cycles_per_hour'] =
array_sum($data['cycles_per_hour']) / count($data['cycles_per_hour']);
} else {
$group_temperature_profile[$type]['cycles_per_hour'] = null;
}
$group_temperature_profile[$type]['linear_trendline'] = $this->api(
'temperature_profile',
'get_linear_trendline',
['data' => $group_temperature_profile[$type]['deltas']]
);
$group_temperature_profile[$type]['score'] = $this->api(
'temperature_profile',
'get_score',
[
'type' => $type,
'temperature_profile' => $group_temperature_profile[$type]
]
);
$group_temperature_profile[$type]['metadata'] = [
'generated_at' => date('Y-m-d H:i:s')
];
}
// Only actually save this profile to the thermostat if it was run with the
// default settings (aka the last year). Anything else is not valid to save.
if($save === true) {
$this->update(
[
'thermostat_group_id' => $thermostat_group_id,
'temperature_profile' => $group_temperature_profile
]
);
}
// Force these to actually return, but set them to null if there's no data.
foreach(['heat', 'cool', 'resist'] as $type) {
if(isset($group_temperature_profile[$type]) === false) {
$group_temperature_profile[$type] = null;
}
}
return $group_temperature_profile;
}
/**
* Generate temperature profiles for all thermostat_groups. This pretty much
* only exists for the cron job.
*/
public function generate_temperature_profiles() {
// Get all thermostat_groups.
$thermostat_groups = $this->read();
foreach($thermostat_groups as $thermostat_group) {
$this->generate_temperature_profile(
$thermostat_group['thermostat_group_id'],
null,
null
);
}
$this->api(
'user',
'update_sync_status',
['key' => 'thermostat_group.generate_temperature_profiles']
);
}
/**
* Compare this thermostat_group to all other matching ones.
*
* @param string $type resist|heat|cool
* @param array $attributes The attributes to compare to.
*
* @return array
*/
public function get_scores($type, $attributes) {
// All or none are required.
if(
(
isset($attributes['address_latitude']) === true ||
isset($attributes['address_longitude']) === true ||
isset($attributes['address_radius']) === true
) &&
(
isset($attributes['address_latitude']) === false ||
isset($attributes['address_longitude']) === false ||
isset($attributes['address_radius']) === false
)
) {
throw new Exception('If one of address_latitude, address_longitude, or address_radius are set, then all are required.');
}
// Pull these values out so they don't get queried; this comparison is done
// in PHP.
if(isset($attributes['address_radius']) === true) {
$address_latitude = $attributes['address_latitude'];
$address_longitude = $attributes['address_longitude'];
$address_radius = $attributes['address_radius'];
unset($attributes['address_latitude']);
unset($attributes['address_longitude']);
unset($attributes['address_radius']);
}
// Get all matching thermostat groups.
$other_thermostat_groups = $this->database->read(
'thermostat_group',
$attributes
);
// Get all the scores from the other thermostat groups
$scores = [];
foreach($other_thermostat_groups as $other_thermostat_group) {
if(
isset($other_thermostat_group['temperature_profile'][$type]) === true &&
isset($other_thermostat_group['temperature_profile'][$type]['score']) === true &&
$other_thermostat_group['temperature_profile'][$type]['score'] !== null &&
isset($other_thermostat_group['temperature_profile'][$type]['metadata']) === true &&
isset($other_thermostat_group['temperature_profile'][$type]['metadata']['generated_at']) === true &&
strtotime($other_thermostat_group['temperature_profile'][$type]['metadata']['generated_at']) > strtotime('-1 month')
) {
// Skip thermostat_groups that are too far away.
if(
isset($address_radius) === true &&
$this->haversine_great_circle_distance(
$address_latitude,
$address_longitude,
$other_thermostat_group['address_latitude'],
$other_thermostat_group['address_longitude']
) > $address_radius
) {
continue;
}
// Ignore profiles with too few datapoints. Ideally this would be time-
// based...so don't use a profile if it hasn't experienced a full year
// or heating/cooling system, but that isn't stored presently. A good
// approximation is to make sure there is a solid set of data driving
// the profile.
$required_delta_count = (($type === 'resist') ? 40 : 20);
if(count($other_thermostat_group['temperature_profile'][$type]['deltas']) < $required_delta_count) {
continue;
}
// Round the scores so they can be better displayed on a histogram or
// bell curve.
// TODO: Might be able to get rid of this? I don't think new scores are calculated at this level of detail anymore...
// $scores[] = round(
// $other_thermostat_group['temperature_profile'][$type]['score'],
// 1
// );
$scores[] = $other_thermostat_group['temperature_profile'][$type]['score'];
}
}
sort($scores);
return $scores;
}
/**
* Calculates the great-circle distance between two points, with the
* Haversine formula.
*
* @param float $latitude_from Latitude of start point in [deg decimal]
* @param float $longitude_from Longitude of start point in [deg decimal]
* @param float $latitude_to Latitude of target point in [deg decimal]
* @param float $longitude_to Longitude of target point in [deg decimal]
* @param float $earth_radius Mean earth radius in [mi]
*
* @link https://stackoverflow.com/a/10054282
*
* @return float Distance between points in [mi] (same as earth_radius)
*/
private function haversine_great_circle_distance($latitude_from, $longitude_from, $latitude_to, $longitude_to, $earth_radius = 3959) {
$latitude_from_radians = deg2rad($latitude_from);
$longitude_from_radians = deg2rad($longitude_from);
$latitude_to_radians = deg2rad($latitude_to);
$longitude_to_radians = deg2rad($longitude_to);
$latitude_delta = $latitude_to_radians - $latitude_from_radians;
$longitude_delta = $longitude_to_radians - $longitude_from_radians;
$angle = 2 * asin(sqrt(pow(sin($latitude_delta / 2), 2) +
cos($latitude_from_radians) * cos($latitude_to_radians) * pow(sin($longitude_delta / 2), 2)));
return $angle * $earth_radius;
}
/**
* Look at all the properties of individual thermostats in this group and
* apply them to the thermostat_group. This resolves issues where values are
* set on one thermostat but null on another.
*
* @param int $thermostat_group_id
*
* @return array The updated thermostat_group.
*/
public function sync_attributes($thermostat_group_id) {
$attributes = [
'system_type_heat',
'system_type_heat_auxiliary',
'system_type_cool',
'property_age',
'property_square_feet',
'property_stories',
'property_structure_type'
];
$thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat_group_id
]
]
);
$final_attributes = [];
foreach($attributes as $attribute) {
$final_attributes[$attribute] = null;
foreach($thermostats as $thermostat) {
switch($attribute) {
case 'property_age':
case 'property_square_feet':
case 'property_stories':
// Use max found age, square_feet, stories
$key = str_replace('property_', '', $attribute);
if($thermostat['property'][$key] !== null) {
$final_attributes[$attribute] = max($final_attributes[$attribute], $thermostat['property'][$key]);
}
break;
case 'property_structure_type':
// Use the first non-null structure_type
if(
$thermostat['property']['structure_type'] !== null &&
$final_attributes[$attribute] === null
) {
$final_attributes[$attribute] = $thermostat['property']['structure_type'];
}
break;
case 'system_type_heat':
case 'system_type_heat_auxiliary':
case 'system_type_cool':
$type = str_replace('system_type_', '', $attribute);
// Always prefer reported, otherwise fall back to detected.
if($thermostat['system_type']['reported'][$type] !== null) {
$system_type = $thermostat['system_type']['reported'][$type];
$reported = true;
} else {
$system_type = $thermostat['system_type']['detected'][$type];
$reported = false;
}
if($reported === true) {
// User-reported values always take precedence
$final_attributes[$attribute] = $system_type;
} else if(
$final_attributes[$attribute] === null ||
(
$final_attributes[$attribute] === 'none' &&
$system_type !== null
)
) {
// None beats null
$final_attributes[$attribute] = $system_type;
}
break;
}
}
}
$final_attributes['thermostat_group_id'] = $thermostat_group_id;
return $this->update($final_attributes);
}
/**
* Update all of the thermostats in this group to a specified system type,
* then sync that forwards into the thermostat_group.
*
* @param int $thermostat_group_id
* @param array $system_types
*
* @return array The updated thermostat_group.
*/
public function update_system_types($thermostat_group_id, $system_types) {
$thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat_group_id
]
]
);
foreach($thermostats as $thermostat) {
$current_system_types = $thermostat['system_type'];
foreach($system_types as $system_type => $value) {
$current_system_types['reported'][$system_type] = $value;
}
$this->api(
'thermostat',
'update',
[
'attributes' => [
'thermostat_id' => $thermostat['thermostat_id'],
'system_type' => $current_system_types
]
]
);
}
return $this->sync_attributes($thermostat_group_id);
}
}

298
api/user.php Normal file
View File

@ -0,0 +1,298 @@
<?php
/**
* A user. Users in beestat aren't much of a thing, but everyone still gets
* one as sessions are connected to them.
*
* @author Jon Ziebell
*/
class user extends cora\crud {
public static $exposed = [
'private' => [
'read_id',
'update_setting',
'log_out',
'sync_patreon_status'
],
'public' => []
];
public static $converged = [];
public static $user_locked = true;
/**
* Selects a user.
*
* @param array $attributes
* @param array $columns
*
* @return array
*/
public function read($attributes = [], $columns = []) {
$users = parent::read($attributes, $columns);
foreach($users as &$user) {
unset($user['password']);
}
return $users;
}
/**
* Creates a user. Username and password are both required. The password is
* hashed with bcrypt.
*
* @param array $attributes
*
* @return int
*/
public function create($attributes) {
$attributes['password'] = password_hash(
$attributes['password'],
PASSWORD_BCRYPT
);
return parent::create($attributes);
}
/**
* Create an anonymous user so we can log in and have access to everything
* without having to spend the time creating an actual user.
*/
public function create_anonymous_user() {
$username = strtolower(sha1(uniqid(mt_rand(), true)));
$password = strtolower(sha1(uniqid(mt_rand(), true)));
$user = $this->create([
'username' => $username,
'password' => $password,
'anonymous' => 1
]);
$this->force_log_in($user['user_id']);
}
/**
* Updates a user. If the password is changed then it is re-hashed with
* bcrypt and a new salt is generated.
*
* @param int $id
* @param array $attributes
*
* @return int
*/
public function update($attributes) {
if(isset($attributes['password']) === true) {
$attributes['password'] = password_hash($attributes['password'], PASSWORD_BCRYPT);
}
return parent::update($attributes);
}
/**
* Deletes a user.
*
* @param int $id
*
* @return int
*/
public function delete($id) {
return parent::delete($id);
}
/**
* Log in by checking the provided password against the stored password for
* the provided username. If it's a match, get a session key from Cora and
* set the cookie.
*
* @param string $username
* @param string $password
*
* @return bool True if success, false if failure.
*/
public function log_in($username, $password) {
$user = $this->read(['username' => $username], ['user_id', 'password']);
if(count($user) !== 1) {
return false;
}
else {
$user = $user[0];
}
if(password_verify($password, $user['password']) === true) {
$this->session->request(null, null, $user['user_id']);
return true;
}
else {
return false;
}
}
/**
* Force log in as a specific user. This is never public and is used as part
* of the user merging logic.
*
* @param int $user_id
*/
public function force_log_in($user_id) {
$this->session->request(null, null, $user_id);
}
/**
* Logs out the currently logged in user.
*
* @return bool True if it was successfully invalidated. Could return false
* for a non-existant session key or if it was already logged out. In the
* case of multiple sessions, return true if all open sessions were
* successfully deleted, false if not.
*/
public function log_out($all) {
if($this->setting->is_demo() === true) {
return;
}
if($all === true) {
$database = cora\database::get_instance();
$sessions = $database->read(
'cora\session',
[
'user_id' => $this->session->get_user_id(),
'api_user_id' => null
]
);
$success = true;
foreach($sessions as $session) {
$success &= $this->session->delete($session['session_key']);
}
return $success;
}
else {
return $this->session->delete();
}
}
/**
* Set a setting on a user.
*
* @param string $key
* @param string $value
*
* @return array The new settings list.
*/
public function update_setting($key, $value) {
$user = $this->get($this->session->get_user_id());
if($user['json_settings'] === null) {
$settings = [];
} else {
$settings = $user['json_settings'];
}
$settings[$key] = $value;
if($this->setting->is_demo() === false) {
$this->update(
[
'user_id' => $this->session->get_user_id(),
'json_settings' => $settings
]
);
}
return $settings;
}
/**
* Set a sync_status on a user to the current datetime.
*
* @param string $key
*
* @return array The new sync status.
*/
public function update_sync_status($key) {
$user = $this->get($this->session->get_user_id());
if($user['json_sync_status'] === null) {
$sync_status = [];
} else {
$sync_status = $user['json_sync_status'];
}
$sync_status[$key] = date('Y-m-d H:i:s');
$this->update(
[
'user_id' => $this->session->get_user_id(),
'json_sync_status' => $sync_status
]
);
return $sync_status;
}
/**
* Get the current user's Patreon status.
*/
public function sync_patreon_status() {
$lock_name = 'user->sync_patreon_status(' . $this->session->get_user_id() . ')';
$this->database->get_lock($lock_name);
$response = $this->api(
'patreon',
'patreon_api',
[
'method' => 'GET',
'endpoint' => 'identity',
'arguments' => [
'fields' => [
'member' => 'patron_status,is_follower,pledge_relationship_start,lifetime_support_cents,currently_entitled_amount_cents,last_charge_date,last_charge_status,will_pay_amount_cents',
],
'include' => 'memberships'
]
]
);
// Assuming all went well and we are connected to this user's Patreon
// account, see if they are actually a Patron. If they are or at the very
// least were at some point, mark it. Otherwise just mark them as connected
// but inactive.
if(
isset($response['data']) === true &&
isset($response['data']['relationships']) === true &&
isset($response['data']['relationships']['memberships']) === true &&
isset($response['data']['relationships']['memberships']['data']) === true &&
isset($response['data']['relationships']['memberships']['data'][0]) === true &&
isset($response['data']['relationships']['memberships']['data'][0]['id']) === true
) {
$id = $response['data']['relationships']['memberships']['data'][0]['id'];
foreach($response['included'] as $include) {
if($include['id'] === $id) {
$this->update(
[
'user_id' => $this->session->get_user_id(),
'json_patreon_status' => $include['attributes']
]
);
}
}
} else {
if(isset($response['errors']) === true) {
// Error like revoked access.
$this->update(
[
'user_id' => $this->session->get_user_id(),
'json_patreon_status' => null
]
);
} else {
// Worked but didn't get the expected response for "active_patron"
$this->update(
[
'user_id' => $this->session->get_user_id(),
'json_patreon_status' => [
'patron_status' => 'not_patron'
]
]
);
}
}
$this->update_sync_status('patreon');
$this->database->release_lock($lock_name);
}
}

386
css/dashboard.css Normal file
View File

@ -0,0 +1,386 @@
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
.highcharts-container, .highcharts-container svg { width: 100% !important; }
body {
background: #111;
font-family: Montserrat;
font-weight: 300;
font-size: 13px;
color: #ecf0f1;
margin: 0;
padding: 0;
overflow-x: hidden;
}
/* lightblue.light */
::selection { background: #45aaf2; }
/* Fonts */
@font-face{
font-family:"Montserrat";
font-weight:100;
font-style:normal;
src:url("../font/montserrat/montserrat_100.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_100.woff") format("woff"),url("../font/montserrat/montserrat_100.ttf") format("truetype"),url("../font/montserrat/montserrat_100.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:200;
font-style:normal;
src:url("../font/montserrat/montserrat_200.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_200.woff") format("woff"),url("../font/montserrat/montserrat_200.ttf") format("truetype"),url("../font/montserrat/montserrat_200.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:300;
font-style:normal;
src:url("../font/montserrat/montserrat_300.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_300.woff") format("woff"),url("../font/montserrat/montserrat_300.ttf") format("truetype"),url("../font/montserrat/montserrat_300.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:400;
font-style:normal;
src:url("../font/montserrat/montserrat_400.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_400.woff") format("woff"),url("../font/montserrat/montserrat_400.ttf") format("truetype"),url("../font/montserrat/montserrat_400.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:500;
font-style:normal;
src:url("../font/montserrat/montserrat_500.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_500.woff") format("woff"),url("../font/montserrat/montserrat_500.ttf") format("truetype"),url("../font/montserrat/montserrat_500.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:600;
font-style:normal;
src:url("../font/montserrat/montserrat_600.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_600.woff") format("woff"),url("../font/montserrat/montserrat_600.ttf") format("truetype"),url("../font/montserrat/montserrat_600.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:700;
font-style:normal;
src:url("../font/montserrat/montserrat_700.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_700.woff") format("woff"),url("../font/montserrat/montserrat_700.ttf") format("truetype"),url("../font/montserrat/montserrat_700.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:800;
font-style:normal;
src:url("../font/montserrat/montserrat_800.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_800.woff") format("woff"),url("../font/montserrat/montserrat_800.ttf") format("truetype"),url("../font/montserrat/montserrat_800.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:900;
font-style:normal;
src:url("../font/montserrat/montserrat_900.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_900.woff") format("woff"),url("../font/montserrat/montserrat_900.ttf") format("truetype"),url("../font/montserrat/montserrat_900.svg#Montserrat") format("svg")
}
/* Beestat logo */
.beestat {
font-weight: 200;
font-size: 40px;
font-family: Montserrat;
}
.beestat > .bee {
color: #f7b731;
}
.beestat > .stat {
color: #20bf6b;
}
/* Link styles */
a {
cursor: pointer;
transition: color 200ms ease;
}
a:link {
color: #ecf0f1;
text-decoration: none;
}
a:visited {
color: #ecf0f1;
}
a:focus {
color: #ecf0f1;
}
a:hover {
color: #bdc3c7;
text-decoration: none;
}
a:active {
color: #ecf0f1;
}
a.inverted:link {
color: #2d98da;
text-decoration: none;
}
a.inverted:visited {
color: #2d98da;
}
a.inverted:focus {
color: #2d98da;
}
a.inverted:hover {
color: #45aaf2;
text-decoration: none;
}
a.inverted:active {
color: #2d98da;
}
.loading_wrapper {
display: flex;
justify-content: center;
height: 30px;
}
.loading_1, .loading_2 {
position: absolute;
border: 3px solid #f7b731;
border-radius: 50%;
width: 48px;
height: 48px;
opacity: 0;
display: inline-block;
animation: loading 1200ms cubic-bezier(0, 0.2, 0.8, 1);
animation-iteration-count: infinite;
}
.loading_2 {
border: 2px solid #f7b731;
animation-delay: 400ms;
}
@keyframes loading {
0% {
transform: scale(.1);
opacity: 1;
}
50% {
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
/**
* This is a stripped down version of https://flexgridlite.elliotdahl.com/
* For futher reading: https://davidwalsh.name/flexbox-layouts;
*/
.row {
display: flex;
flex: 0 1 auto;
margin: 0 -8px 16px -8px;
}
.column {
/*flex: 0 0 auto;*/
flex-grow: 1;
flex-shrink: 0;
flex-basis: 0;
max-width: 100%;
min-width: 0;
padding: 0 8px 0 8px;
}
.column_1 {
flex-basis: 8.33333%;
max-width: 8.33333%;
}
.column_2 {
flex-basis: 16.66667%;
max-width: 16.66667%;
}
.column_3 {
flex-basis: 25%;
max-width: 25%;
}
.column_4 {
flex-basis: 33.33333%;
max-width: 33.33333%;
}
.column_5 {
flex-basis: 41.66667%;
max-width: 41.66667%;
}
.column_6 {
flex-basis: 50%;
max-width: 50%;
}
.column_7 {
flex-basis: 58.33333%;
max-width: 58.33333%;
}
.column_8 {
flex-basis: 66.66667%;
max-width: 66.66667%;
}
.column_9 {
flex-basis: 75%;
max-width: 75%;
}
.column_10 {
flex-basis: 83.33333%;
max-width: 83.33333%;
}
.column_11 {
flex-basis: 91.66667%;
max-width: 91.66667%;
}
.column_12 {
flex-basis: 100%;
max-width: 100%;
}
@media only screen and (max-width: 800px) {
.row {
display: block;
}
.column {
max-width: 100%;
margin-bottom: 16px;
}
}
/* Icons (materialdesignicons.com) */
@font-face {
font-family: "Material Design Icons";
src: url("../font/material_icon/material_icon.eot?v=3.4.93");
src: url("../font/material_icon/material_icon.eot?#iefix&v=3.4.93") format("embedded-opentype"), url("../font/material_icon/material_icon.woff2?v=3.4.93") format("woff2"), url("../font/material_icon/material_icon.woff?v=3.4.93") format("woff"), url("../font/material_icon/material_icon.ttf?v=3.4.93") format("truetype"), url("../font/material_icon/material_icon.svg?v=3.4.93#materialdesigniconsregular") format("svg");
font-weight: normal;
font-style: normal;
}
.icon:before {
display: inline-block;
font: normal normal normal 24px/1 "Material Design Icons";
font-size: inherit;
text-rendering: auto;
line-height: inherit;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 24px;
}
.icon.air_filter:before { content: "\FD1F"; }
.icon.air_purifier:before { content: "\FD20"; }
.icon.alarm_snooze:before { content: "\F68D"; }
.icon.arrow_left:before { content: "\F04D"; }
.icon.battery_10:before { content: "\F07A"; }
.icon.bell:before { content: "\F09A"; }
.icon.bell_off:before { content: "\F09B"; }
.icon.bullhorn:before { content: "\F0E6"; }
.icon.border_none_variant:before { content: "\F8A3"; }
.icon.calendar:before { content: "\F0ED"; }
.icon.calendar_alert:before { content: "\FA30"; }
.icon.calendar_edit:before { content: "\F8A6"; }
.icon.calendar_range:before { content: "\F678"; }
.icon.cancel:before { content: "\F739"; }
.icon.cash:before { content: "\F114"; }
.icon.chart_bell_curve:before { content: "\FC2C"; }
.icon.chart_line:before { content: "\F12A"; }
.icon.check:before { content: "\F12C"; }
.icon.clock_outline:before { content: "\F150"; }
.icon.close:before { content: "\F156"; }
.icon.close_network:before { content: "\F15B"; }
.icon.dots_vertical:before { content: "\F1D9"; }
.icon.download:before { content: "\F1DA"; }
.icon.exit_to_app:before { content: "\F206"; }
.icon.eye:before { content: "\F208"; }
.icon.eye_off:before { content: "\F209"; }
.icon.fan:before { content: "\F210"; }
.icon.fire:before { content: "\F238"; }
.icon.gauge:before { content: "\F29A"; }
.icon.google_play:before { content: "\F2BC"; }
.icon.heart:before { content: "\F2D1"; }
.icon.help_circle:before { content: "\F2D7"; }
.icon.home:before { content: "\F2DC"; }
.icon.home_floor_a:before { content: "\FD5F"; }
.icon.information:before { content: "\F2FC"; }
.icon.key:before { content: "\F306"; }
.icon.layers:before { content: "\F328"; }
.icon.magnify_minus:before { content: "\F34A"; }
.icon.map_marker:before { content: "\F34E"; }
.icon.menu_down:before { content: "\F35D"; }
.icon.menu_up:before { content: "\F360"; }
.icon.message:before { content: "\F361"; }
.icon.numeric_1_box:before { content: "\F3A4"; }
.icon.numeric_3_box:before { content: "\F3AA"; }
.icon.numeric_7_box:before { content: "\F3B6"; }
.icon.patreon:before { content: "\F881"; }
.icon.pound:before { content: "\F423"; }
.icon.snowflake:before { content: "\F716"; }
.icon.swap_horizontal:before { content: "\F4E1"; }
.icon.thermostat:before { content: "\F393"; }
.icon.thumb_up:before { content: "\F513"; }
.icon.tune:before { content: "\F62E"; }
.icon.twitter:before { content: "\F544"; }
.icon.update:before { content: "\F6AF"; }
.icon.view_quilt:before { content: "\F574"; }
.icon.water_off:before { content: "\F58D"; }
.icon.water_percent:before { content: "\F58E"; }
.icon.wifi_strength_1_alert:before { content: "\F91F"; }
.icon.wifi_strength_4:before { content: "\F927"; }
.icon.zigbee:before { content: "\FD1D"; }
.icon.basket_fill:before { content: "\F077"; }
.icon.basket_unfill:before { content: "\F078"; }
.icon.f16:before { font-size: 16px; }
.icon.f24:before { font-size: 24px; }
.icon.f36:before { font-size: 36px; }
.icon.f48:before { font-size: 48px; }
.icon.f64:before { font-size: 64px; }

407
css/index.css Normal file
View File

@ -0,0 +1,407 @@
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
.highcharts-container, .highcharts-container svg { width: 100% !important; }
body {
background: #111;
font-family: Montserrat;
font-weight: 300;
font-size: 13px;
color: #ecf0f1;
margin: 0;
padding: 0;
overflow-x: hidden;
}
/* Fonts */
@font-face{
font-family:"Montserrat";
font-weight:100;
font-style:normal;
src:url("../font/montserrat/montserrat_100.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_100.woff") format("woff"),url("../font/montserrat/montserrat_100.ttf") format("truetype"),url("../font/montserrat/montserrat_100.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:200;
font-style:normal;
src:url("../font/montserrat/montserrat_200.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_200.woff") format("woff"),url("../font/montserrat/montserrat_200.ttf") format("truetype"),url("../font/montserrat/montserrat_200.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:300;
font-style:normal;
src:url("../font/montserrat/montserrat_300.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_300.woff") format("woff"),url("../font/montserrat/montserrat_300.ttf") format("truetype"),url("../font/montserrat/montserrat_300.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:400;
font-style:normal;
src:url("../font/montserrat/montserrat_400.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_400.woff") format("woff"),url("../font/montserrat/montserrat_400.ttf") format("truetype"),url("../font/montserrat/montserrat_400.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:500;
font-style:normal;
src:url("../font/montserrat/montserrat_500.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_500.woff") format("woff"),url("../font/montserrat/montserrat_500.ttf") format("truetype"),url("../font/montserrat/montserrat_500.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:600;
font-style:normal;
src:url("../font/montserrat/montserrat_600.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_600.woff") format("woff"),url("../font/montserrat/montserrat_600.ttf") format("truetype"),url("../font/montserrat/montserrat_600.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:700;
font-style:normal;
src:url("../font/montserrat/montserrat_700.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_700.woff") format("woff"),url("../font/montserrat/montserrat_700.ttf") format("truetype"),url("../font/montserrat/montserrat_700.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:800;
font-style:normal;
src:url("../font/montserrat/montserrat_800.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_800.woff") format("woff"),url("../font/montserrat/montserrat_800.ttf") format("truetype"),url("../font/montserrat/montserrat_800.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:900;
font-style:normal;
src:url("../font/montserrat/montserrat_900.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_900.woff") format("woff"),url("../font/montserrat/montserrat_900.ttf") format("truetype"),url("../font/montserrat/montserrat_900.svg#Montserrat") format("svg")
}
/*
@font-face{
font-family:"Droid Serif";
font-weight:400;
font-style:normal;
src:url("../font/droid_serif/droid_serif_400.woff") format("woff"),url("../font/droid_serif/droid_serif_400.ttf") format("truetype")
}*/
/* Icons */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(../font/material_icons/material_icons.eot); /* For IE6-8 */
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(../font/material_icons/material_icons.woff2) format('woff2'),
url(../font/material_icons/material_icons.woff) format('woff'),
url(../font/material_icons/material_icons.ttf) format('truetype');
}
.icon {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px; /* Preferred icon size */
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/*position: relative;*/
/*top: 2px;*/
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: 'liga';
}
/*.icon.f18 { font-size: 18px; }
.icon.f24 { font-size: 24px; }
.icon.f36 { font-size: 36px; }
.icon.f48 { font-size: 48px; }*/
/* Beestat logo */
.beestat {
font-weight: 200;
font-size: 40px;
font-family: Montserrat;
}
.beestat > .bee {
color: #f1c40f;
}
.beestat > .stat {
color: #2ecc71;
}
/* Link styles */
a {
cursor: pointer;
}
a:link {
color: #ecf0f1;
text-decoration: none;
}
a:visited {
color: #ecf0f1;
}
a:focus {
color: #ecf0f1;
}
a:hover {
color: #bdc3c7;
text-decoration: none;
}
a:active {
color: #ecf0f1;
}
h1 {
font-family: Montserrat;
font-weight: 200;
font-size: 24px;
margin: 0 0 8px 0;
color: #ecf0f1;
}
h2 {
font-family: Montserrat;
font-weight: 500;
font-size: 18px;
margin: 0 0 8px 0;
color: #bdc3c7;
}
.loading {
border: 5px solid #f7b731;
border-radius: 30px;
height: 30px;
/*left: 50%;*/
/*margin: -15px 0 0 -15px;*/
opacity: 0;
/*position: absolute;*/
/*top: 50%;*/
width: 30px;
display: inline-block;
animation: loading 1s ease-out;
animation-iteration-count: infinite;
}
@keyframes loading {
0% {
transform: scale(.1);
opacity: 0.0;
}
50% {
opacity: 1;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
@keyframes spin {
from {
transform:rotate(0deg);
}
to {
transform:rotate(360deg);
}
}
._index {
display: flex;
min-height: 100vh;
flex-direction: column;
}
._index main {
position: relative;
flex: 1;
width: 100%;
overflow-x: hidden;
}
._index .waveform {
position: absolute;
left: 0;
top: 195px;
right: -2548px;
background: url('../img/waveform.png') 0% 0% repeat-x;
z-index: -1;
height: 312px;
-webkit-animation: waveform_scroll linear 600s;
-webkit-animation-iteration-count: infinite;
-moz-animation: waveform_scroll linear 600s;
-moz-animation-iteration-count: infinite;
-o-animation: waveform_scroll linear 600s;
-o-animation-iteration-count: infinite;
-ms-animation: waveform_scroll linear 600s;
-ms-animation-iteration-count: infinite;
}
@keyframes waveform_scroll{
0% {
transform: translateX(0);
}
100% {
transform: translateX(-2548px);
}
}
@-moz-keyframes waveform_scroll{
0% {
-moz-transform: translateX(0);
}
100% {
-moz-transform: translateX(-2548px);
}
}
@-webkit-keyframes waveform_scroll {
0% {
-webkit-transform: translateX(0);
}
100% {
-webkit-transform: translateX(-2548px);
}
}
@-o-keyframes waveform_scroll {
0% {
-o-transform: translateX(0);
}
100% {
-o-transform: translateX(-2548px);
}
}
@-ms-keyframes waveform_scroll {
0% {
-ms-transform: translateX(0);
}
100% {
-ms-transform: translateX(-2548px);
}
}
._index .header {
padding: 16px;
overflow: auto;
}
._index .beestat {
float: left;
line-height: 55px;
}
._index .log_in {
display: none;
float: right;
line-height: 40px;
font-size: 16px;
font-weight: 500;
line-height: 55px;
}
._index .connect {
text-align: center;
margin-top: 140px;
}
._index .connect_text {
font-size: 24px;
font-weight: 500;
}
._index .ecobee, ._index .nest {
width: 120px;
height: 120px;
border-radius: 50%;
display: inline-block;
margin: 32px 16px 0 16px;
background-size: 100%;
transition: background-color 100ms ease;
}
._index .ecobee {
background-color: #27ae60;
background-image: url('../img/ecobee/connect.png');
}
._index .ecobee:hover {
background-color: #2ecc71;
}
._index .nest {
background-color: #bdc3c7;
background-image: url('../img/nest/connect.png');
}
._index .demo {
/*display: none;*/
text-align: center;
margin-top: 100px;
font-size: 18px;
color: #7f8c8d;
font-weight: 500;
}
._index footer {
/*margin-top: 200px;*/
background: #2c3e50;
padding: 32px 16px;
text-align: center;
font-size: 14px;
}
._index .footer_text {
font-weight: 500;
}
._index .footer_links {
margin-top: 32px;
font-weight: 200;
}
/* Less header space when the browser is short. */
@media (max-height: 680px) {
._index .connect {
margin-top: 40px;
}
._index .waveform {
top: 95px;
}
}

283
css/privacy.css Normal file
View File

@ -0,0 +1,283 @@
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
.highcharts-container, .highcharts-container svg { width: 100% !important; }
body {
background: #111;
font-family: Montserrat;
font-weight: 300;
font-size: 13px;
color: #ecf0f1;
margin: 0;
padding: 0;
overflow-x: hidden;
}
/* Fonts */
@font-face{
font-family:"Montserrat";
font-weight:100;
font-style:normal;
src:url("../font/montserrat/montserrat_100.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_100.woff") format("woff"),url("../font/montserrat/montserrat_100.ttf") format("truetype"),url("../font/montserrat/montserrat_100.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:200;
font-style:normal;
src:url("../font/montserrat/montserrat_200.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_200.woff") format("woff"),url("../font/montserrat/montserrat_200.ttf") format("truetype"),url("../font/montserrat/montserrat_200.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:300;
font-style:normal;
src:url("../font/montserrat/montserrat_300.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_300.woff") format("woff"),url("../font/montserrat/montserrat_300.ttf") format("truetype"),url("../font/montserrat/montserrat_300.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:400;
font-style:normal;
src:url("../font/montserrat/montserrat_400.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_400.woff") format("woff"),url("../font/montserrat/montserrat_400.ttf") format("truetype"),url("../font/montserrat/montserrat_400.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:500;
font-style:normal;
src:url("../font/montserrat/montserrat_500.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_500.woff") format("woff"),url("../font/montserrat/montserrat_500.ttf") format("truetype"),url("../font/montserrat/montserrat_500.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:600;
font-style:normal;
src:url("../font/montserrat/montserrat_600.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_600.woff") format("woff"),url("../font/montserrat/montserrat_600.ttf") format("truetype"),url("../font/montserrat/montserrat_600.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:700;
font-style:normal;
src:url("../font/montserrat/montserrat_700.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_700.woff") format("woff"),url("../font/montserrat/montserrat_700.ttf") format("truetype"),url("../font/montserrat/montserrat_700.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:800;
font-style:normal;
src:url("../font/montserrat/montserrat_800.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_800.woff") format("woff"),url("../font/montserrat/montserrat_800.ttf") format("truetype"),url("../font/montserrat/montserrat_800.svg#Montserrat") format("svg")
}
@font-face{
font-family:"Montserrat";
font-weight:900;
font-style:normal;
src:url("../font/montserrat/montserrat_900.eot?") format("embedded-opentype"),url("../font/montserrat/montserrat_900.woff") format("woff"),url("../font/montserrat/montserrat_900.ttf") format("truetype"),url("../font/montserrat/montserrat_900.svg#Montserrat") format("svg")
}
/*
@font-face{
font-family:"Droid Serif";
font-weight:400;
font-style:normal;
src:url("../font/droid_serif/droid_serif_400.woff") format("woff"),url("../font/droid_serif/droid_serif_400.ttf") format("truetype")
}*/
/* Beestat logo */
.beestat {
font-weight: 200;
font-size: 40px;
font-family: Montserrat;
}
.beestat > .bee {
color: #f1c40f;
}
.beestat > .stat {
color: #2ecc71;
}
/* Link styles */
a {
cursor: pointer;
}
a:link {
color: #ecf0f1;
text-decoration: none;
}
a:visited {
color: #ecf0f1;
}
a:focus {
color: #ecf0f1;
}
a:hover {
color: #bdc3c7;
text-decoration: none;
}
a:active {
color: #ecf0f1;
}
h1 {
font-family: Montserrat;
font-weight: 200;
font-size: 24px;
margin: 0 0 8px 0;
color: #ecf0f1;
}
h2 {
font-family: Montserrat;
font-weight: 500;
font-size: 18px;
margin: 0 0 8px 0;
color: #bdc3c7;
}
.loading {
border: 5px solid #f7b731;
border-radius: 30px;
height: 30px;
/*left: 50%;*/
/*margin: -15px 0 0 -15px;*/
opacity: 0;
/*position: absolute;*/
/*top: 50%;*/
width: 30px;
display: inline-block;
animation: loading 1s ease-out;
animation-iteration-count: infinite;
}
@keyframes loading {
0% {
transform: scale(.1);
opacity: 0.0;
}
50% {
opacity: 1;
}
100% {
transform: scale(1.2);
opacity: 0;
}
}
@keyframes spin {
from {
transform:rotate(0deg);
}
to {
transform:rotate(360deg);
}
}
/* Icons */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(../font/material_icons/material_icons.eot); /* For IE6-8 */
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(../font/material_icons/material_icons.woff2) format('woff2'),
url(../font/material_icons/material_icons.woff) format('woff'),
url(../font/material_icons/material_icons.ttf) format('truetype');
}
.icon {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px; /* Preferred icon size */
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: 'liga';
}
.icon.f18 { font-size: 18px; }
.icon.f24 { font-size: 24px; }
.icon.f36 { font-size: 36px; }
.icon.f48 { font-size: 48px; }
._privacy .header {
padding: 16px;
overflow: auto;
}
._privacy .beestat {
float: left;
cursor: pointer;
line-height: 55px;
}
._privacy .log_in {
display: none;
float: right;
line-height: 55px;
font-size: 16px;
font-weight: 500;
}
._privacy .text {
/*font-family: Droid Serif;*/
padding: 16px;
background: #34495e;
}
._privacy .footer {
/*margin-top: 200px;*/
background: #2c3e50;
padding: 48px 16px;
text-align: center;
font-size: 16px;
}
._privacy .footer_text {
font-weight: 500;
}
._privacy .footer_links {
margin-top: 32px;
font-weight: 200;
}

15
dashboard.php Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<title>beestat</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<!-- Chrome, Firefox OS and Opera -->
<meta name="theme-color" content="#222222">
<link rel="manifest" href="/manifest.json">
<link href="../css/dashboard.css" rel="stylesheet">
<?php require 'js/js.php'; ?>
</head>
<body></body>
</html>

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
img/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
img/demo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
img/ecobee/connect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
img/ecobee/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
img/nest/connect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
img/nest/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

1
img/nest/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215.09" height="97.268" viewBox="0.549 0 215.09 97.268" enable-background="new 0.549 0 215.09 97.268"><path fill="#A4ABB1" d="M215.64 31.792v-14.57h-11.993V0h-14.724v17.222h-29.658c-13.696.008-23.634 9.217-23.634 21.907 0 1.364.12 2.836.396 4.36-4.666-16.17-16.965-26.267-33.484-26.267-20.207 0-35.653 15.732-36.95 37.047v-4.53c0-17.93-14.59-32.517-32.522-32.517S.55 31.808.55 49.74v46.62h14.904V49.74c0-9.707 7.898-17.612 17.615-17.612 9.71 0 17.615 7.905 17.615 17.613v46.62H65.59V60.09c1.306 21.39 16.952 37.178 37.418 37.178 12.495 0 23.52-7.26 29.76-15.66 4.66 9.953 13.76 15.66 25.003 15.66 13.03 0 26.22-8.322 26.22-24.235 0-9.523-5.532-17.312-15.578-21.923-1.284-.587-2.322-1.076-3.314-1.548l-.136-.067c-1.124-.53-2.208-1.04-3.553-1.657-6.57-2.946-9.693-5.105-9.693-9.408 0-3.785 3.387-6.636 7.57-6.636h29.638V73.31c0 13.216 10.75 23.958 23.96 23.958V82.543c-5.092 0-9.236-4.143-9.236-9.235V31.793c5.887.002 10.426 0 11.99 0zm-113.212-.235c11.546 0 17.202 7.964 18.84 16.076H82.846c2.005-8.128 9.126-16.076 19.583-16.076zm58.85 33.84c4.15 1.774 6.626 5.112 6.626 8.916 0 5.04-3.876 8.04-10.368 8.04-6.893 0-10.095-6.068-10.912-7.923l-1.008-2.296H132.71l-.02-.012H120.73c-3.267 5.447-9.843 10.468-17.492 10.468-13.803 0-20.69-10.784-21.57-21.432h56.414v-2.335c0-4.415-.46-8.57-1.318-12.428 1.616 4.95 5.3 10.083 13.014 13.718 2.813 1.34 7.068 3.344 11.5 5.283z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More