Beestat is now open source
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
api/cora/setting.php
|
||||
.internal/
|
10
.well-known/assetlinks.json
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
191
api/cora/crud.php
Normal 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
@ -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
@ -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
@ -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);
|
||||
}
|
||||
|
||||
}
|
227
api/cora/setting.example.php
Normal 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
@ -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
@ -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
@ -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
@ -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();
|
489
api/ecobee_runtime_thermostat.php
Normal 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
@ -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
@ -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
@ -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
@ -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);
|
||||
}
|
||||
}
|
17
api/external_api_cache.php
Normal 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
@ -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
@ -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
@ -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
@ -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;
|
||||
}
|
||||
}
|
8
api/mailchimp_api_cache.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Cache for these external API calls.
|
||||
*
|
||||
* @author Jon Ziebell
|
||||
*/
|
||||
class mailchimp_api_cache extends external_api_cache {}
|
8
api/mailchimp_api_log.php
Normal 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
@ -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
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
8
api/patreon_api_cache.php
Normal 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
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Log for these external API calls.
|
||||
*
|
||||
* @author Jon Ziebell
|
||||
*/
|
||||
class patreon_api_log extends external_api_log {}
|
23
api/patreon_initialize.php
Normal 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
@ -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
@ -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
@ -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;
|
||||
}
|
||||
}
|
8
api/smarty_streets_api_cache.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Cache for these external API calls.
|
||||
*
|
||||
* @author Jon Ziebell
|
||||
*/
|
||||
class smarty_streets_api_cache extends external_api_cache {}
|
8
api/smarty_streets_api_log.php
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
After Width: | Height: | Size: 5.3 KiB |
BIN
favicon.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
font/material_icon/material_icon.eot
Normal file
10491
font/material_icon/material_icon.svg
Normal file
After Width: | Height: | Size: 3.8 MiB |
BIN
font/material_icon/material_icon.ttf
Normal file
BIN
font/material_icon/material_icon.woff
Normal file
BIN
font/material_icon/material_icon.woff2
Normal file
BIN
font/montserrat/montserrat_100.eot
Normal file
BIN
font/montserrat/montserrat_100.otf
Normal file
BIN
font/montserrat/montserrat_100.ttf
Normal file
BIN
font/montserrat/montserrat_100.woff
Normal file
BIN
font/montserrat/montserrat_200.eot
Normal file
BIN
font/montserrat/montserrat_200.otf
Normal file
BIN
font/montserrat/montserrat_200.ttf
Normal file
BIN
font/montserrat/montserrat_200.woff
Normal file
BIN
font/montserrat/montserrat_300.eot
Normal file
BIN
font/montserrat/montserrat_300.otf
Normal file
BIN
font/montserrat/montserrat_300.ttf
Normal file
BIN
font/montserrat/montserrat_300.woff
Normal file
BIN
font/montserrat/montserrat_400.eot
Normal file
BIN
font/montserrat/montserrat_400.otf
Normal file
BIN
font/montserrat/montserrat_400.ttf
Normal file
BIN
font/montserrat/montserrat_400.woff
Normal file
BIN
font/montserrat/montserrat_500.eot
Normal file
BIN
font/montserrat/montserrat_500.otf
Normal file
BIN
font/montserrat/montserrat_500.ttf
Normal file
BIN
font/montserrat/montserrat_500.woff
Normal file
BIN
font/montserrat/montserrat_600.eot
Normal file
BIN
font/montserrat/montserrat_600.otf
Normal file
BIN
font/montserrat/montserrat_600.ttf
Normal file
BIN
font/montserrat/montserrat_600.woff
Normal file
BIN
font/montserrat/montserrat_700.eot
Normal file
BIN
font/montserrat/montserrat_700.otf
Normal file
BIN
font/montserrat/montserrat_700.ttf
Normal file
BIN
font/montserrat/montserrat_700.woff
Normal file
BIN
font/montserrat/montserrat_800.eot
Normal file
BIN
font/montserrat/montserrat_800.otf
Normal file
BIN
font/montserrat/montserrat_800.ttf
Normal file
BIN
font/montserrat/montserrat_800.woff
Normal file
BIN
font/montserrat/montserrat_900.eot
Normal file
BIN
font/montserrat/montserrat_900.otf
Normal file
BIN
font/montserrat/montserrat_900.ttf
Normal file
BIN
font/montserrat/montserrat_900.woff
Normal file
BIN
img/demo.png
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
img/demo2.png
Normal file
After Width: | Height: | Size: 147 KiB |
BIN
img/ecobee/connect.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
img/ecobee/ecobee_logo_colour2.jpg
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
img/ecobee/logo.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
img/nest/connect.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
img/nest/logo.png
Normal file
After Width: | Height: | Size: 72 KiB |
1
img/nest/logo.svg
Normal 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 |