diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3cccc4d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+api/cora/setting.php
+.internal/
diff --git a/.well-known/assetlinks.json b/.well-known/assetlinks.json
new file mode 100644
index 0000000..ad773fe
--- /dev/null
+++ b/.well-known/assetlinks.json
@@ -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"]
+ }
+ }
+]
diff --git a/api/address.php b/api/address.php
new file mode 100644
index 0000000..b85ebc1
--- /dev/null
+++ b/api/address.php
@@ -0,0 +1,106 @@
+ [
+ '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);
+ }
+ }
+
+}
diff --git a/api/announcement.php b/api/announcement.php
new file mode 100644
index 0000000..38a7d25
--- /dev/null
+++ b/api/announcement.php
@@ -0,0 +1,34 @@
+ [],
+ 'public' => [
+ 'read_id'
+ ]
+ ];
+
+ public static $converged = [
+ 'title' => [
+ 'type' => 'string'
+ ],
+ 'text' => [
+ 'type' => 'string'
+ ],
+ 'icon' => [
+ 'type' => 'string'
+ ]
+ ];
+
+ public static $user_locked = false;
+
+}
diff --git a/api/cora/api.php b/api/cora/api.php
new file mode 100644
index 0000000..e980190
--- /dev/null
+++ b/api/cora/api.php
@@ -0,0 +1,86 @@
+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);
+ }
+
+}
diff --git a/api/cora/api_cache.php b/api/cora/api_cache.php
new file mode 100644
index 0000000..0024c0d
--- /dev/null
+++ b/api/cora/api_cache.php
@@ -0,0 +1,115 @@
+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() : ''
+ )
+ );
+ }
+
+}
diff --git a/api/cora/api_log.php b/api/cora/api_log.php
new file mode 100644
index 0000000..8558183
--- /dev/null
+++ b/api/cora/api_log.php
@@ -0,0 +1,62 @@
+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'];
+ }
+
+}
diff --git a/api/cora/api_user.php b/api/cora/api_user.php
new file mode 100644
index 0000000..ecd83f8
--- /dev/null
+++ b/api/cora/api_user.php
@@ -0,0 +1,19 @@
+start_timestamp = microtime(true);
+ $this->start_timestamp_microtime = $this->microtime();
+
+ // See class variable documentation for reasoning.
+ $this->current_working_directory = getcwd();
+ }
+
+ /**
+ * Use this function to instantiate this class instead of calling new cora()
+ * (which isn't allowed anyways). This is necessary so that the API class
+ * can have access to Cora.
+ *
+ * @return cora A new cora object or the already created one.
+ */
+ public static function get_instance() {
+ if(isset(self::$instance) === false) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Execute the request. It is run through the rate limiter, checked for
+ * errors, then processed. Requests sent after the rate limit is reached are
+ * not logged.
+ *
+ * @param array $request Basically just $_REQUEST or a slight mashup of it
+ * for batch requests.
+ *
+ * @throws \Exception If the rate limit threshhold is reached.
+ * @throws \Exception If SSL is required but not used.
+ * @throws \Exception If the API key is not provided.
+ * @throws \Exception If the API key is invalid.
+ * @throws \Exception If the session is expired.
+ * @throws \Exception If a resource is not provided.
+ * @throws \Exception If a method is not provided.
+ * @throws \Exception If the requested method does not exist.
+ */
+ public function process_request($request) {
+ // This is necessary in order for the shutdown handler/log function to have
+ // access to this data, but it's not used anywhere else.
+ $this->request = $request;
+
+ // Setting class for getting settings. Anything that extends cora\api gets
+ // this automatically.
+ $this->setting = setting::get_instance();
+
+ // Used to have this in the constructor, but the database uses this class
+ // which causes a dependency loop in the constructors.
+ $this->database = database::get_instance();
+
+ // A couple quick error checks
+ if($this->is_over_rate_limit() === true) {
+ throw new \Exception('Rate limit reached.', 1005);
+ }
+ if($this->setting->get('force_ssl') === true && empty($_SERVER['HTTPS']) === true) {
+ throw new \Exception('Request must be sent over HTTPS.', 1006);
+ }
+
+ // Make sure the API key that was sent is present and valid.
+ if(isset($request['api_key']) === false) {
+ throw new \Exception('API Key is required.', 1000);
+ }
+
+ $api_user_resource = new api_user();
+ $api_users = $api_user_resource->read(['api_key' => $request['api_key']]);
+ if(count($api_users) !== 1) {
+ throw new \Exception('Invalid API key.', 1003);
+ } else {
+ $this->api_user = $api_users[0];
+ }
+
+ // Build a list of API calls.
+ $this->build_api_call_list($request);
+
+ // Check the API request for errors.
+ $this->check_api_request_for_errors();
+
+ // Set the default headers as a catch-all. Most API calls won't touch these,
+ // but it is possible for them to override headers as desired.
+ $this->set_default_headers();
+
+ // Get this every time. It's only used for session API calls. Non-session
+ // API calls don't bother with this.
+ $session = session::get_instance();
+ $session_is_valid = $session->touch($this->api_user['session_key']);
+
+ // Process each request.
+ foreach($this->api_calls as $api_call) {
+ // Store the currently running API call for tracking if an error occurs.
+ $this->current_api_call = $api_call;
+
+ // These are required before we can move on with any more processing or
+ // error checking.
+ if(isset($api_call['resource']) === false) {
+ throw new \Exception('Resource is required.', 1001);
+ }
+ if(isset($api_call['method']) === false) {
+ throw new \Exception('Method is required.', 1002);
+ }
+
+ // Sets $call_type to 'public' or 'private'
+ $call_type = $this->get_api_call_type($api_call);
+
+ // If the request requires a session, make sure it's valid.
+ if($call_type === 'private') {
+ if($session_is_valid === false) {
+ throw new \Exception('Session is expired.', 1004);
+ }
+ }
+
+ // If the resource doesn't exist, spl_autoload_register() will throw a
+ // fatal error. The shutdown handler will "catch" it. It is not possible
+ // to catch exceptions directly from the autoloader using try/catch.
+ $resource_instance = new $api_call['resource']();
+
+ // If the method doesn't exist
+ if(method_exists($resource_instance, $api_call['method']) === false) {
+ throw new \Exception('Method does not exist.', 1009);
+ }
+
+ $arguments = $this->get_arguments($api_call);
+
+ // Process the request and save some statistics.
+ $start_time = microtime(true);
+ $start_query_count = $this->database->get_query_count();
+ $start_query_time = $this->database->get_query_time();
+
+ if(isset($api_call['alias']) === true) {
+ $index = $api_call['alias'];
+ }
+ else {
+ $index = count($this->response_data);
+ }
+
+ // Caching! If this API call is configured for caching,
+ // $cache_config = $this->setting->get('cache');
+ if( // Is cacheable
+ isset($api_call['resource']::$cache) === true &&
+ isset($api_call['resource']::$cache[$api_call['method']]) === true
+ ) {
+ $api_cache_instance = new api_cache();
+ $api_cache = $api_cache_instance->retrieve($api_call);
+
+ if($api_cache !== null) {
+ // If there was a cache entry available, use that.
+ $this->response_data[$index] = $api_cache['json_response_data'];
+ $this->from_cache[$index] = true;
+ $this->cached_until[$index] = date('c', strtotime($api_cache['expires_at']));
+ } else {
+ // Else just run the API call, then cache it.
+ $this->response_data[$index] = call_user_func_array(
+ [$resource_instance, $api_call['method']],
+ $arguments
+ );
+ $this->from_cache[$index] = false;
+
+ $api_cache = $api_cache_instance->cache(
+ $api_call,
+ $this->response_data[$index],
+ $api_call['resource']::$cache[$api_call['method']]
+ );
+ $this->cached_until[$index] = date('c', strtotime($api_cache['expires_at']));
+ }
+ }
+ else { // Not cacheable
+ $this->response_data[$index] = call_user_func_array(
+ [$resource_instance, $api_call['method']],
+ $arguments
+ );
+ $this->from_cache[$index] = false;
+ }
+
+ $this->response_times[$index] = (microtime(true) - $start_time);
+ $this->response_query_counts[$index] = $this->database->get_query_count() - $start_query_count;
+ $this->response_query_times[$index] = $this->database->get_query_time() - $start_query_time;
+ }
+
+ $this->set_cached_until_header();
+ }
+
+ /**
+ * Build a list of API calls from the request. For a single request, it's
+ * just the request. For batch requests, add each item in the batch
+ * parameter to this array.
+ *
+ * @param array $request The original request.
+ *
+ * @throws \Exception If this is a batch request and the batch data is not
+ * valid JSON
+ * @throws \Exception If this is a batch request and it exceeds the maximum
+ * number of api calls allowed in one batch.
+ */
+ private function build_api_call_list($request) {
+ $this->api_calls = [];
+ if(isset($request['batch']) === true) {
+ $batch = json_decode($request['batch'], true);
+ if($batch === null) {
+ throw new \Exception('Batch is not valid JSON.', 1012);
+ }
+ $batch_limit = $this->setting->get('batch_limit');
+ if($batch_limit !== null && count($batch) > $batch_limit) {
+ throw new \Exception('Batch limit exceeded.', 1013);
+ }
+ foreach($batch as $api_call) {
+ // Put this on each API call for logging.
+ $api_call['api_key'] = $request['api_key'];
+ $this->api_calls[] = $api_call;
+ }
+ }
+ else {
+ $this->api_calls[] = $request;
+ }
+ }
+
+ /**
+ * Check the API request for various errors.
+ *
+ * @throws \Exception If something other than ALL or NO aliases are set.
+ * @throws \Exception If Any duplicate aliases are used.
+ */
+ private function check_api_request_for_errors() {
+ $aliases = [];
+ foreach($this->api_calls as $api_call) {
+ if(isset($api_call['alias']) === true) {
+ $aliases[] = $api_call['alias'];
+ }
+ }
+
+ // Check to make sure either all or none are set.
+ $number_aliases = count($aliases);
+ if(count($this->api_calls) !== $number_aliases && $number_aliases !== 0) {
+ throw new \Exception('All API calls must have an alias if at least one is set.', 1017);
+ }
+
+ // Check for duplicates.
+ $number_unique_aliases = count(array_unique($aliases));
+ if($number_aliases !== $number_unique_aliases) {
+ throw new \Exception('Duplicate alias on API call.', 1018);
+ }
+ }
+
+ /**
+ * Returns 'session' or 'non_session' depending on where the API method is
+ * located at. Session methods require a valid session in order to execute.
+ *
+ * @param array $api_call The API call to get the type for.
+ *
+ * @throws \Exception If the method was not found in the map.
+ *
+ * @return string The type.
+ */
+ private function get_api_call_type($api_call) {
+ if(in_array($api_call['method'], $api_call['resource']::$exposed['private'])) {
+ return 'private';
+ }
+ else if(in_array($api_call['method'], $api_call['resource']::$exposed['public'])) {
+ return 'public';
+ }
+ else {
+ throw new \Exception('Requested method is not mapped.', 1008);
+ }
+ }
+
+ /**
+ * Check to see if the request from the current IP address needs to be rate
+ * limited. If $requests_per_minute is null then there is no rate limiting.
+ *
+ * @return bool If this request puts us over the rate threshold.
+ */
+ private function is_over_rate_limit() {
+ $requests_per_minute = $this->setting->get('requests_per_minute');
+ if($requests_per_minute === null) {
+ return false;
+ }
+ $api_log_resource = new api_log();
+ $requests_this_minute = $api_log_resource->get_number_requests_since(
+ $_SERVER['REMOTE_ADDR'],
+ time() - 60
+ );
+ return ($requests_this_minute >= $requests_per_minute);
+ }
+
+ /**
+ * Fetches a list of arguments when passed an array of keys. Since the
+ * arguments are passed from JS to PHP in JSON, I don't need to cast any of
+ * the values as the data types are preserved. Since the argument order from
+ * the client doesn't matter, this makes sure that the arguments are placed
+ * in the correct order for calling the function.
+ *
+ * @param array $api_call The API call.
+ *
+ * @throws \Exception If the arguments in the api_call were not valid JSON.
+ *
+ * @return array The requested arguments.
+ */
+ private function get_arguments($api_call) {
+ $arguments = [];
+
+ // Arguments are not strictly required. If a method requires them then you
+ // will still get an error, but they are not required by the API.
+ if(isset($api_call['arguments']) === true) {
+ // All arguments are sent in the "arguments" key as JSON.
+ $api_call_arguments = json_decode($api_call['arguments'], true);
+
+ if($api_call_arguments === null) {
+ throw new \Exception('Arguments are not valid JSON.', 1011);
+ }
+
+ $reflection_method = new \ReflectionMethod(
+ $api_call['resource'],
+ $api_call['method']
+ );
+ $parameters = $reflection_method->getParameters();
+
+ foreach($parameters as $parameter) {
+ if(isset($api_call_arguments[$parameter->getName()]) === true) {
+ $argument = $api_call_arguments[$parameter->getName()];
+
+ // If this is a batch request, look for JSONPath arguments.
+ if(isset($this->request['batch']) === true) {
+ $argument = $this->evaluate_json_path_argument($argument);
+ }
+ }
+ else {
+ $argument = null;
+ }
+ $arguments[] = $argument;
+ }
+ }
+ return $arguments;
+ }
+
+ /**
+ * Recursively check all values in an argument. If any of them are JSON
+ * path, evaluate them.
+ *
+ * @param mixed $argument The argument to check.
+ *
+ * @return mixed The argument with the evaluated path.
+ */
+ private function evaluate_json_path_argument($argument) {
+ if(is_array($argument) === true) {
+ foreach($argument as $key => $value) {
+ $argument[$key] = $this->evaluate_json_path_argument($value);
+ }
+ }
+ else if(preg_match('/^{=(.*)}$/', $argument, $matches) === 1) {
+ $json_path_resource = new json_path();
+ $json_path = $matches[1];
+ $argument = $json_path_resource->evaluate($this->response_data, $json_path);
+ }
+ return $argument;
+ }
+
+ /**
+ * Sets error_extra_info.
+ *
+ * @param mixed $error_extra_info Whatever you want the extra info to be.
+ */
+ public function set_error_extra_info($error_extra_info) {
+ $this->error_extra_info = $error_extra_info;
+ }
+
+ /**
+ * Get error_extra_info.
+ *
+ * @return mixed
+ */
+ public function get_error_extra_info() {
+ return $this->error_extra_info;
+ }
+
+ /**
+ * Get the current API user.
+ *
+ * @return array
+ */
+ public function get_api_user() {
+ return $this->api_user;
+ }
+
+ /**
+ * Sets the headers that should be used for this API call. This is useful
+ * for doing things like returning files from the API where the content-type
+ * is no longer application/json. This replaces all headers; headers are not
+ * outputted to the browser until all API calls have completed, so the last
+ * call to this function will win.
+ *
+ * @param array $headers The headers to output.
+ * @param bool $custom_response Whether or not to wrap the response with the
+ * Cora data or just output the API call's return value.
+ *
+ * @throws \Exception If this is a batch request and a custom response was
+ * requested.
+ * @throws \Exception If this is a batch request and the content type was
+ * altered from application/json
+ * @throws \Exception If this is not a batch request and the content type
+ * was altered from application/json without a custom response.
+ */
+ // public function set_headers($headers, $custom_response = false) {
+ // if(isset($this->request['batch']) === true) {
+ // if($custom_response === true) {
+ // throw new \Exception('Batch API requests can not use a custom response.', 1015);
+ // }
+ // if($this->content_type_is_json($headers) === false) {
+ // throw new \Exception('Batch API requests must return JSON.', 1014);
+ // }
+ // }
+ // else {
+ // // Not a batch request
+ // if($custom_response === false && $this->content_type_is_json($headers) === false) {
+ // throw new \Exception('Non-custom responses must return JSON.', 1016);
+ // }
+ // }
+ // $this->headers = $headers;
+ // $this->custom_response = $custom_response;
+ // }
+
+ /**
+ * Return whether or not the current output headers indicate that the
+ * content type is JSON. This is mostly just used to make sure that batch
+ * API calls output JSON.
+ *
+ * @param array $headers The headers to look at.
+ *
+ * @return bool Whether or not the output has a content type of
+ * application/json
+ */
+ private function content_type_is_json($headers) {
+ return isset($headers['Content-type']) === true
+ && stristr($headers['Content-type'], 'application/json') !== false;
+ }
+
+ /**
+ * Override of the default PHP error handler. Grabs the error info and sends
+ * it to the exception handler which returns a JSON response.
+ *
+ * @param int $error_code The error number from PHP.
+ * @param string $error_message The error message.
+ * @param string $error_file The file the error happend in.
+ * @param int $error_line The line of the file the error happened on.
+ *
+ * @return string The JSON response with the error details.
+ */
+ public function error_handler($error_code, $error_message, $error_file, $error_line) {
+ $this->set_error_response(
+ $error_message,
+ $error_code,
+ $error_file,
+ $error_line,
+ debug_backtrace(false)
+ );
+ die(); // Do not continue execution; shutdown handler will now run.
+ }
+
+ /**
+ * Override of the default PHP exception handler. All unhandled exceptions
+ * go here.
+ *
+ * @param Exception $e The exception.
+ */
+ public function exception_handler($e) {
+ $this->set_error_response(
+ $e->getMessage(),
+ $e->getCode(),
+ $e->getFile(),
+ $e->getLine(),
+ $e->getTrace()
+ );
+ die(); // Do not continue execution; shutdown handler will now run.
+ }
+
+ /**
+ * Handle all exceptions by generating a JSON response with the error
+ * details. If debugging is enabled, a bunch of other information is sent
+ * back to help out.
+ *
+ * @param string $error_message The error message.
+ * @param mixed $error_code The supplied error code.
+ * @param string $error_file The file the error happened in.
+ * @param int $error_line The line of the file the error happened on.
+ * @param array $error_trace The stack trace for the error.
+ */
+ public function set_error_response($error_message, $error_code, $error_file, $error_line, $error_trace) {
+ // There are a few places that call this function to set an error response,
+ // so this can't just be done in the exception handler alone. If an error
+ // occurs, rollback the current transaction. Also only attempt to roll back
+ // the transaction if the database was successfully created/connected to.
+ if($this->database !== null) {
+ $this->database->rollback_transaction();
+ }
+
+ $this->response = [
+ 'success' => false,
+ 'data' => [
+ 'error_message' => $error_message,
+ 'error_code' => $error_code,
+ 'error_file' => $error_file,
+ 'error_line' => $error_line,
+ 'error_trace' => $error_trace,
+ 'error_extra_info' => $this->error_extra_info
+ ]
+ ];
+ }
+
+ /**
+ * Executes when the script finishes. If there was an error that somehow
+ * didn't get caught, then this will find it with error_get_last and return
+ * appropriately. Note that error_get_last() will only get something when an
+ * error wasn't caught by my error/exception handlers. The default PHP error
+ * handler fills this in. Doesn't do anything if an exception was thrown due
+ * to the rate limit.
+ *
+ * @throws \Exception If a this was a batch request but one of the api calls
+ * changed the content-type to anything but the default.
+ */
+ public function shutdown_handler() {
+ // Since the shutdown handler is rather verbose in what it has to check for
+ // and do, it's possible it will fail or detect an error that needs to be
+ // handled. For example, someone could return binary data from an API call
+ // which will fail a json_encode, or someone could change the headers in a
+ // batch API call, which isn't allowed. I can't throw an exception since I'm
+ // already in the shutdown handler...it will be caught but it won't execute
+ // a new shutdown handler and no output will be sent to the client. I just
+ // have to handle all problems manually.
+ try {
+ // Fix the current working directory. See documentation on this class
+ // variable for details.
+ chdir($this->current_working_directory);
+
+ // If I didn't catch an error/exception with my handlers, look here...this
+ // will catch fatal errors that I can't.
+ $error = error_get_last();
+ if($error !== null) {
+ $this->set_error_response(
+ $error['message'],
+ $error['type'],
+ $error['file'],
+ $error['line'],
+ debug_backtrace(false)
+ );
+ }
+
+ // If the response has already been set by one of the error handlers, end
+ // execution here and just log & output the response.
+ if(isset($this->response) === true) {
+ // Don't log anything for rate limit breaches.
+ if($this->response['data']['error_code'] !== 1005) {
+ $this->log();
+ }
+
+ // Override whatever headers might have already been set.
+ $this->set_default_headers();
+ $this->output_headers();
+ die($this->get_json_response());
+ }
+ else {
+ // If we got here, no errors have occurred.
+
+ // For non-custom responses, build the response, log it, and output it.
+ $this->response = ['success' => true];
+
+ if(isset($this->request['batch']) === true) {
+ $this->response['data'] = $this->response_data;
+ }
+ else {
+ // $this->response['data'] = $this->response_data[0];
+ $this->response['data'] = reset($this->response_data);
+ }
+
+ // Log all of the API calls that were made.
+ $this->log();
+
+ // Output the response
+ $this->output_headers();
+ die($this->get_json_response());
+ }
+ }
+ catch(\Exception $e) {
+ $this->set_error_response(
+ $e->getMessage(),
+ $e->getCode(),
+ $e->getFile(),
+ $e->getLine(),
+ $e->getTrace()
+ );
+ $this->set_default_headers();
+ $this->output_headers();
+ die($this->get_json_response());
+ }
+ }
+
+ /**
+ * Gets the json_encoded response. This is called from the shutdown handler
+ * and removes debug information if debugging is disabled and then
+ * json_encodes the data.
+ *
+ * @return string The JSON encoded response.
+ */
+ private function get_json_response() {
+ $response = $this->response;
+ if($this->setting->get('debug') === false && $response['success'] === false) {
+ unset($response['data']['error_file']);
+ unset($response['data']['error_line']);
+ unset($response['data']['error_trace']);
+ unset($response['data']['error_extra_info']);
+ }
+ return json_encode($response);
+ }
+
+ /**
+ * Output whatever the headers are currently set to.
+ */
+ private function output_headers() {
+ foreach($this->headers as $key => $value) {
+ header($key . ': ' . $value);
+ }
+ }
+
+ /**
+ * Resets the headers to default. Have to do this in case one of the API
+ * calls changes them and there was an error to handle.
+ */
+ private function set_default_headers() {
+ $this->headers['Content-type'] = 'application/json; charset=UTF-8';
+ }
+
+ /**
+ * Set the beestat-cached-until header.
+ */
+ private function set_cached_until_header() {
+ if(isset($this->request['batch']) === true) { // Batch
+ $beestat_cached_until = [];
+ foreach($this->cached_until as $index => $cached_until) {
+ $beestat_cached_until[$index] = $cached_until;
+ }
+ if(count($beestat_cached_until) > 0) {
+ $this->headers['beestat-cached-until'] = json_encode($beestat_cached_until);
+ }
+ } else { // Single
+ if(count($this->cached_until) === 1) {
+ $this->headers['beestat-cached-until'] = reset($this->cached_until);
+ }
+ }
+ }
+
+ /**
+ * Returns true for all loggable content types. Mostly JSON, XML, and other
+ * text-based types.
+ *
+ * @return bool Whether or not the output has a content type that can be
+ * logged.
+ */
+ private function content_type_is_loggable() {
+ if(isset($this->headers['Content-type']) === false) {
+ return false;
+ }
+ else {
+ $loggable_content_types = [
+ 'application/json',
+ 'application/xml',
+ 'application/javascript',
+ 'text/html',
+ 'text/xml',
+ 'text/plain',
+ 'text/css'
+ ];
+ foreach($loggable_content_types as $loggable_content_type) {
+ if(stristr($this->headers['Content-type'], $loggable_content_type) !== false) {
+ return true;
+ }
+ }
+ }
+ }
+
+ /**
+ * Log the request and response to the database. The logged response is
+ * truncated to 16kb for sanity.
+ */
+ private function log() {
+ $api_log_resource = new api_log();
+ $session = session::get_instance();
+
+ $user_id = $session->get_user_id();
+
+ // If exception. This is lenghty because I have to check to make sure
+ // everything was set or else use null.
+ if(isset($this->response['data']['error_code']) === true) {
+ if(isset($this->request['api_key']) === true) {
+ $api_user_resource = new api_user();
+ $api_users = $api_user_resource->read(['api_key' => $this->request['api_key']]);
+ $request_api_user_id = $api_users[0]['api_user_id'];
+ }
+ else {
+ $request_api_user_id = null;
+ }
+
+ $request_resource = null;
+ $request_method = null;
+ $request_arguments = null;
+ if($this->current_api_call !== null) {
+ if(isset($this->current_api_call['resource']) === true) {
+ $request_resource = $this->current_api_call['resource'];
+ }
+ if(isset($this->current_api_call['method']) === true) {
+ $request_method = $this->current_api_call['method'];
+ }
+ if(isset($this->current_api_call['arguments']) === true) {
+ $request_arguments = $this->current_api_call['arguments'];
+ }
+ }
+ $response_error_code = $this->response['data']['error_code'];
+ $response_time = null;
+ $response_query_count = null;
+ $response_query_time = null;
+ $response_data = substr(json_encode($this->response['data']), 0, 16384);
+ $from_cache = null;
+
+ $api_log_resource->create(
+ [
+ 'user_id' => $user_id,
+ 'request_api_user_id' => $request_api_user_id,
+ 'request_resource' => $request_resource,
+ 'request_method' => $request_method,
+ 'request_arguments' => preg_replace('/"(password)":".*"/', '"$1":"[removed]"', $request_arguments),
+ 'response_error_code' => $response_error_code,
+ 'response_data' => preg_replace('/"(password)":".*"/', '"$1":"[removed]"', $response_data),
+ 'response_time' => $response_time,
+ 'response_query_count' => $response_query_count,
+ 'response_query_time' => $response_query_time,
+ 'from_cache' => $from_cache
+ ]
+ );
+
+ $this->log_influx(
+ [
+ 'user_id' => $user_id,
+ 'request_api_user_id' => $request_api_user_id,
+ 'request_resource' => $request_resource,
+ 'request_method' => $request_method,
+ 'request_timestamp' => $this->start_timestamp_microtime,
+ 'response_error_code' => $response_error_code,
+ 'response_time' => $response_time,
+ 'response_query_count' => $response_query_count,
+ 'response_query_time' => $response_query_time,
+ 'from_cache' => $from_cache
+ ]
+ );
+
+ }
+ else {
+ $response_error_code = null;
+ $count_api_calls = count($this->api_calls);
+ for($i = 0; $i < $count_api_calls; $i++) {
+ $api_call = $this->api_calls[$i];
+
+ $api_user_resource = new api_user();
+ $api_users = $api_user_resource->read(['api_key' => $api_call['api_key']]);
+ $request_api_user_id = $api_users[0]['api_user_id'];
+
+ $request_resource = $api_call['resource'];
+ $request_method = $api_call['method'];
+ if(isset($api_call['arguments']) === true) {
+ $request_arguments = $api_call['arguments'];
+ }
+ else {
+ $request_arguments = null;
+ }
+
+ if(isset($api_call['alias']) === true) {
+ $index = $api_call['alias'];
+ }
+ else {
+ $index = $i;
+ }
+
+ $response_time = $this->response_times[$index];
+ $response_query_count = $this->response_query_counts[$index];
+ $response_query_time = $this->response_query_times[$index];
+
+ // The data could be an integer, an XML string, an array, etc, but let's
+ // just always json_encode it to keep things simple and standard.
+ if($this->content_type_is_loggable() === true) {
+ $response_data = substr(json_encode($this->response_data[$index]), 0, 16384);
+ }
+ else {
+ $response_data = null;
+ }
+
+ $from_cache = $this->from_cache[$index];
+
+ $api_log_resource->create(
+ [
+ 'user_id' => $user_id,
+ 'request_api_user_id' => $request_api_user_id,
+ 'request_resource' => $request_resource,
+ 'request_method' => $request_method,
+ 'request_arguments' => preg_replace('/"(password)":".*"/', '"$1":"[removed]"', $request_arguments),
+ 'response_error_code' => $response_error_code,
+ 'response_data' => null, // Can't store this; uses too much disk.
+ // 'response_data' => preg_replace('/"(password)":".*"/', '"$1":"[removed]"', $response_data),
+ 'response_time' => $response_time,
+ 'response_query_count' => $response_query_count,
+ 'response_query_time' => $response_query_time,
+ 'from_cache' => $from_cache
+ ]
+ );
+
+ $this->log_influx(
+ [
+ 'user_id' => $user_id,
+ 'request_api_user_id' => $request_api_user_id,
+ 'request_resource' => $request_resource,
+ 'request_method' => $request_method,
+ 'request_timestamp' => $this->start_timestamp_microtime,
+ 'response_error_code' => $response_error_code,
+ 'response_time' => $response_time,
+ 'response_query_count' => $response_query_count,
+ 'response_query_time' => $response_query_time,
+ 'from_cache' => $from_cache,
+ ]
+ );
+ }
+ }
+ }
+
+ /**
+ * Log to InfluxDB/Grafana.
+ *
+ * @param array $data
+ */
+ private function log_influx($data) {
+ $logger_resource = new \logger();
+ $logger_resource->log_influx(
+ 'api_log',
+ [
+ 'request_api_user_id' => (string) $data['request_api_user_id'],
+ 'exception' => $data['response_error_code'] === null ? '0' : '1',
+ 'from_cache' => $data['from_cache'] === false ? '0' : '1'
+ ],
+ [
+ 'user_id' => (int) $data['user_id'],
+ 'request_resource' => (string) $data['request_resource'],
+ 'request_method' => (string) $data['request_method'],
+ 'response_time' => round($data['response_time'], 4),
+ 'response_query_count' => (int) $data['response_query_count'],
+ 'response_error_code' => $data['response_error_code'] === null ? null : (int) $data['response_error_code'],
+ 'response_query_time' => round($data['response_query_time'], 4)
+ ],
+ $data['request_timestamp']
+ );
+ }
+
+ /**
+ * 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);
+ }
+
+
+}
diff --git a/api/cora/crud.php b/api/cora/crud.php
new file mode 100644
index 0000000..50844f7
--- /dev/null
+++ b/api/cora/crud.php
@@ -0,0 +1,191 @@
+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);
+ }
+}
diff --git a/api/cora/database.php b/api/cora/database.php
new file mode 100644
index 0000000..5fcd5d0
--- /dev/null
+++ b/api/cora/database.php
@@ -0,0 +1,798 @@
+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) . '"');
+ }
+
+}
diff --git a/api/cora/json_path.php b/api/cora/json_path.php
new file mode 100644
index 0000000..96c200d
--- /dev/null
+++ b/api/cora/json_path.php
@@ -0,0 +1,68 @@
+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);
+ }
+ }
+ }
+ }
+
+}
diff --git a/api/cora/session.php b/api/cora/session.php
new file mode 100644
index 0000000..536d125
--- /dev/null
+++ b/api/cora/session.php
@@ -0,0 +1,324 @@
+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);
+ }
+
+}
diff --git a/api/cora/setting.example.php b/api/cora/setting.example.php
new file mode 100644
index 0000000..c25b876
--- /dev/null
+++ b/api/cora/setting.example.php
@@ -0,0 +1,227 @@
+ '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;
+ }
+
+}
+
diff --git a/api/ecobee.php b/api/ecobee.php
new file mode 100644
index 0000000..70bb36f
--- /dev/null
+++ b/api/ecobee.php
@@ -0,0 +1,249 @@
+ [],
+ '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;
+ }
+ }
+}
diff --git a/api/ecobee_api_cache.php b/api/ecobee_api_cache.php
new file mode 100644
index 0000000..a7b4b75
--- /dev/null
+++ b/api/ecobee_api_cache.php
@@ -0,0 +1,8 @@
+get('beestat_root_uri') . 'api/index.php?resource=ecobee&method=initialize&arguments=' . json_encode($arguments) . '&api_key=' . $setting->get('ecobee_api_key_local'));
+
+die();
diff --git a/api/ecobee_runtime_thermostat.php b/api/ecobee_runtime_thermostat.php
new file mode 100644
index 0000000..fea71b5
--- /dev/null
+++ b/api/ecobee_runtime_thermostat.php
@@ -0,0 +1,489 @@
+ [
+ '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;
+ }
+
+}
diff --git a/api/ecobee_sensor.php b/api/ecobee_sensor.php
new file mode 100644
index 0000000..3e21d0a
--- /dev/null
+++ b/api/ecobee_sensor.php
@@ -0,0 +1,262 @@
+ [
+ '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;
+ }
+
+}
diff --git a/api/ecobee_thermostat.php b/api/ecobee_thermostat.php
new file mode 100644
index 0000000..eaaaea5
--- /dev/null
+++ b/api/ecobee_thermostat.php
@@ -0,0 +1,721 @@
+ [
+ '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;
+ }
+
+}
diff --git a/api/ecobee_token.php b/api/ecobee_token.php
new file mode 100644
index 0000000..63de065
--- /dev/null
+++ b/api/ecobee_token.php
@@ -0,0 +1,144 @@
+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;
+ }
+
+}
diff --git a/api/external_api.php b/api/external_api.php
new file mode 100644
index 0000000..626e760
--- /dev/null
+++ b/api/external_api.php
@@ -0,0 +1,269 @@
+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);
+ }
+}
diff --git a/api/external_api_cache.php b/api/external_api_cache.php
new file mode 100644
index 0000000..7d83bed
--- /dev/null
+++ b/api/external_api_cache.php
@@ -0,0 +1,17 @@
+ [
+ '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.');
+ }
+}
diff --git a/api/index.php b/api/index.php
new file mode 100644
index 0000000..8ab68d6
--- /dev/null
+++ b/api/index.php
@@ -0,0 +1,53 @@
+process_request($_REQUEST);
diff --git a/api/logger.php b/api/logger.php
new file mode 100644
index 0000000..e2ac85e
--- /dev/null
+++ b/api/logger.php
@@ -0,0 +1,136 @@
+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) . '"';
+ }
+}
diff --git a/api/mailchimp.php b/api/mailchimp.php
new file mode 100644
index 0000000..6534cf5
--- /dev/null
+++ b/api/mailchimp.php
@@ -0,0 +1,81 @@
+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;
+ }
+}
diff --git a/api/mailchimp_api_cache.php b/api/mailchimp_api_cache.php
new file mode 100644
index 0000000..35f5d28
--- /dev/null
+++ b/api/mailchimp_api_cache.php
@@ -0,0 +1,8 @@
+ [
+ '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 '
';
+ 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;
+ }
+ }
+}
diff --git a/api/patreon_api_cache.php b/api/patreon_api_cache.php
new file mode 100644
index 0000000..6682698
--- /dev/null
+++ b/api/patreon_api_cache.php
@@ -0,0 +1,8 @@
+get('beestat_root_uri') . 'api/index.php?resource=patreon&method=initialize&arguments=' . json_encode($arguments) . '&api_key=' . $setting->get('patreon_api_key_local'));
+
+die();
diff --git a/api/patreon_token.php b/api/patreon_token.php
new file mode 100644
index 0000000..77ae7b9
--- /dev/null
+++ b/api/patreon_token.php
@@ -0,0 +1,147 @@
+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;
+ }
+
+}
diff --git a/api/sensor.php b/api/sensor.php
new file mode 100644
index 0000000..c0b1225
--- /dev/null
+++ b/api/sensor.php
@@ -0,0 +1,53 @@
+ [
+ '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);
+ }
+
+}
diff --git a/api/smarty_streets.php b/api/smarty_streets.php
new file mode 100644
index 0000000..a2ec746
--- /dev/null
+++ b/api/smarty_streets.php
@@ -0,0 +1,99 @@
+ $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;
+ }
+}
diff --git a/api/smarty_streets_api_cache.php b/api/smarty_streets_api_cache.php
new file mode 100644
index 0000000..d718238
--- /dev/null
+++ b/api/smarty_streets_api_cache.php
@@ -0,0 +1,8 @@
+ [],
+ '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);
+ }
+
+}
diff --git a/api/thermostat.php b/api/thermostat.php
new file mode 100644
index 0000000..4486246
--- /dev/null
+++ b/api/thermostat.php
@@ -0,0 +1,107 @@
+ [
+ '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']
+ ]
+ );
+ }
+}
diff --git a/api/thermostat_group.php b/api/thermostat_group.php
new file mode 100644
index 0000000..ada083d
--- /dev/null
+++ b/api/thermostat_group.php
@@ -0,0 +1,427 @@
+ [
+ '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);
+ }
+}
diff --git a/api/user.php b/api/user.php
new file mode 100644
index 0000000..18370a4
--- /dev/null
+++ b/api/user.php
@@ -0,0 +1,298 @@
+ [
+ '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);
+ }
+
+}
diff --git a/css/dashboard.css b/css/dashboard.css
new file mode 100644
index 0000000..b034c0d
--- /dev/null
+++ b/css/dashboard.css
@@ -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; }
diff --git a/css/index.css b/css/index.css
new file mode 100644
index 0000000..6269fa1
--- /dev/null
+++ b/css/index.css
@@ -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;
+ }
+}
diff --git a/css/privacy.css b/css/privacy.css
new file mode 100644
index 0000000..fb1f793
--- /dev/null
+++ b/css/privacy.css
@@ -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;
+}
diff --git a/dashboard.php b/dashboard.php
new file mode 100644
index 0000000..1ad58fb
--- /dev/null
+++ b/dashboard.php
@@ -0,0 +1,15 @@
+
+
+
+ beestat
+
+
+
+
+
+
+
+
+
+
+
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..8ab0276
Binary files /dev/null and b/favicon.ico differ
diff --git a/favicon.png b/favicon.png
new file mode 100644
index 0000000..51d6d99
Binary files /dev/null and b/favicon.png differ
diff --git a/font/material_icon/material_icon.eot b/font/material_icon/material_icon.eot
new file mode 100644
index 0000000..601bd68
Binary files /dev/null and b/font/material_icon/material_icon.eot differ
diff --git a/font/material_icon/material_icon.svg b/font/material_icon/material_icon.svg
new file mode 100644
index 0000000..9807d07
--- /dev/null
+++ b/font/material_icon/material_icon.svg
@@ -0,0 +1,10491 @@
+
+
+
diff --git a/font/material_icon/material_icon.ttf b/font/material_icon/material_icon.ttf
new file mode 100644
index 0000000..31a801b
Binary files /dev/null and b/font/material_icon/material_icon.ttf differ
diff --git a/font/material_icon/material_icon.woff b/font/material_icon/material_icon.woff
new file mode 100644
index 0000000..8615de3
Binary files /dev/null and b/font/material_icon/material_icon.woff differ
diff --git a/font/material_icon/material_icon.woff2 b/font/material_icon/material_icon.woff2
new file mode 100644
index 0000000..2371b5e
Binary files /dev/null and b/font/material_icon/material_icon.woff2 differ
diff --git a/font/montserrat/montserrat_100.eot b/font/montserrat/montserrat_100.eot
new file mode 100644
index 0000000..c390b12
Binary files /dev/null and b/font/montserrat/montserrat_100.eot differ
diff --git a/font/montserrat/montserrat_100.otf b/font/montserrat/montserrat_100.otf
new file mode 100644
index 0000000..e76a363
Binary files /dev/null and b/font/montserrat/montserrat_100.otf differ
diff --git a/font/montserrat/montserrat_100.ttf b/font/montserrat/montserrat_100.ttf
new file mode 100644
index 0000000..7861c7f
Binary files /dev/null and b/font/montserrat/montserrat_100.ttf differ
diff --git a/font/montserrat/montserrat_100.woff b/font/montserrat/montserrat_100.woff
new file mode 100644
index 0000000..3f1089b
Binary files /dev/null and b/font/montserrat/montserrat_100.woff differ
diff --git a/font/montserrat/montserrat_200.eot b/font/montserrat/montserrat_200.eot
new file mode 100644
index 0000000..18689bc
Binary files /dev/null and b/font/montserrat/montserrat_200.eot differ
diff --git a/font/montserrat/montserrat_200.otf b/font/montserrat/montserrat_200.otf
new file mode 100644
index 0000000..5485a49
Binary files /dev/null and b/font/montserrat/montserrat_200.otf differ
diff --git a/font/montserrat/montserrat_200.ttf b/font/montserrat/montserrat_200.ttf
new file mode 100644
index 0000000..f923eac
Binary files /dev/null and b/font/montserrat/montserrat_200.ttf differ
diff --git a/font/montserrat/montserrat_200.woff b/font/montserrat/montserrat_200.woff
new file mode 100644
index 0000000..a924458
Binary files /dev/null and b/font/montserrat/montserrat_200.woff differ
diff --git a/font/montserrat/montserrat_300.eot b/font/montserrat/montserrat_300.eot
new file mode 100644
index 0000000..e76496d
Binary files /dev/null and b/font/montserrat/montserrat_300.eot differ
diff --git a/font/montserrat/montserrat_300.otf b/font/montserrat/montserrat_300.otf
new file mode 100644
index 0000000..5c5752d
Binary files /dev/null and b/font/montserrat/montserrat_300.otf differ
diff --git a/font/montserrat/montserrat_300.ttf b/font/montserrat/montserrat_300.ttf
new file mode 100644
index 0000000..0619e4d
Binary files /dev/null and b/font/montserrat/montserrat_300.ttf differ
diff --git a/font/montserrat/montserrat_300.woff b/font/montserrat/montserrat_300.woff
new file mode 100644
index 0000000..5f0a78a
Binary files /dev/null and b/font/montserrat/montserrat_300.woff differ
diff --git a/font/montserrat/montserrat_400.eot b/font/montserrat/montserrat_400.eot
new file mode 100644
index 0000000..4b09dbc
Binary files /dev/null and b/font/montserrat/montserrat_400.eot differ
diff --git a/font/montserrat/montserrat_400.otf b/font/montserrat/montserrat_400.otf
new file mode 100644
index 0000000..59f736d
Binary files /dev/null and b/font/montserrat/montserrat_400.otf differ
diff --git a/font/montserrat/montserrat_400.ttf b/font/montserrat/montserrat_400.ttf
new file mode 100644
index 0000000..4c1dfe2
Binary files /dev/null and b/font/montserrat/montserrat_400.ttf differ
diff --git a/font/montserrat/montserrat_400.woff b/font/montserrat/montserrat_400.woff
new file mode 100644
index 0000000..693f033
Binary files /dev/null and b/font/montserrat/montserrat_400.woff differ
diff --git a/font/montserrat/montserrat_500.eot b/font/montserrat/montserrat_500.eot
new file mode 100644
index 0000000..379a339
Binary files /dev/null and b/font/montserrat/montserrat_500.eot differ
diff --git a/font/montserrat/montserrat_500.otf b/font/montserrat/montserrat_500.otf
new file mode 100644
index 0000000..b0687df
Binary files /dev/null and b/font/montserrat/montserrat_500.otf differ
diff --git a/font/montserrat/montserrat_500.ttf b/font/montserrat/montserrat_500.ttf
new file mode 100644
index 0000000..bb533bd
Binary files /dev/null and b/font/montserrat/montserrat_500.ttf differ
diff --git a/font/montserrat/montserrat_500.woff b/font/montserrat/montserrat_500.woff
new file mode 100644
index 0000000..b14189a
Binary files /dev/null and b/font/montserrat/montserrat_500.woff differ
diff --git a/font/montserrat/montserrat_600.eot b/font/montserrat/montserrat_600.eot
new file mode 100644
index 0000000..9a82579
Binary files /dev/null and b/font/montserrat/montserrat_600.eot differ
diff --git a/font/montserrat/montserrat_600.otf b/font/montserrat/montserrat_600.otf
new file mode 100644
index 0000000..9d19734
Binary files /dev/null and b/font/montserrat/montserrat_600.otf differ
diff --git a/font/montserrat/montserrat_600.ttf b/font/montserrat/montserrat_600.ttf
new file mode 100644
index 0000000..5a215fc
Binary files /dev/null and b/font/montserrat/montserrat_600.ttf differ
diff --git a/font/montserrat/montserrat_600.woff b/font/montserrat/montserrat_600.woff
new file mode 100644
index 0000000..3dd5015
Binary files /dev/null and b/font/montserrat/montserrat_600.woff differ
diff --git a/font/montserrat/montserrat_700.eot b/font/montserrat/montserrat_700.eot
new file mode 100644
index 0000000..5236bb1
Binary files /dev/null and b/font/montserrat/montserrat_700.eot differ
diff --git a/font/montserrat/montserrat_700.otf b/font/montserrat/montserrat_700.otf
new file mode 100644
index 0000000..badc1ca
Binary files /dev/null and b/font/montserrat/montserrat_700.otf differ
diff --git a/font/montserrat/montserrat_700.ttf b/font/montserrat/montserrat_700.ttf
new file mode 100644
index 0000000..55b9792
Binary files /dev/null and b/font/montserrat/montserrat_700.ttf differ
diff --git a/font/montserrat/montserrat_700.woff b/font/montserrat/montserrat_700.woff
new file mode 100644
index 0000000..5c8c5fa
Binary files /dev/null and b/font/montserrat/montserrat_700.woff differ
diff --git a/font/montserrat/montserrat_800.eot b/font/montserrat/montserrat_800.eot
new file mode 100644
index 0000000..d5a9790
Binary files /dev/null and b/font/montserrat/montserrat_800.eot differ
diff --git a/font/montserrat/montserrat_800.otf b/font/montserrat/montserrat_800.otf
new file mode 100644
index 0000000..2fa7a8a
Binary files /dev/null and b/font/montserrat/montserrat_800.otf differ
diff --git a/font/montserrat/montserrat_800.ttf b/font/montserrat/montserrat_800.ttf
new file mode 100644
index 0000000..0403500
Binary files /dev/null and b/font/montserrat/montserrat_800.ttf differ
diff --git a/font/montserrat/montserrat_800.woff b/font/montserrat/montserrat_800.woff
new file mode 100644
index 0000000..9cb5a20
Binary files /dev/null and b/font/montserrat/montserrat_800.woff differ
diff --git a/font/montserrat/montserrat_900.eot b/font/montserrat/montserrat_900.eot
new file mode 100644
index 0000000..6d71e69
Binary files /dev/null and b/font/montserrat/montserrat_900.eot differ
diff --git a/font/montserrat/montserrat_900.otf b/font/montserrat/montserrat_900.otf
new file mode 100644
index 0000000..bd1e01c
Binary files /dev/null and b/font/montserrat/montserrat_900.otf differ
diff --git a/font/montserrat/montserrat_900.ttf b/font/montserrat/montserrat_900.ttf
new file mode 100644
index 0000000..444f402
Binary files /dev/null and b/font/montserrat/montserrat_900.ttf differ
diff --git a/font/montserrat/montserrat_900.woff b/font/montserrat/montserrat_900.woff
new file mode 100644
index 0000000..9e8ec14
Binary files /dev/null and b/font/montserrat/montserrat_900.woff differ
diff --git a/img/demo.png b/img/demo.png
new file mode 100644
index 0000000..fa8758a
Binary files /dev/null and b/img/demo.png differ
diff --git a/img/demo2.png b/img/demo2.png
new file mode 100644
index 0000000..e1611cc
Binary files /dev/null and b/img/demo2.png differ
diff --git a/img/ecobee/connect.png b/img/ecobee/connect.png
new file mode 100644
index 0000000..0984c8e
Binary files /dev/null and b/img/ecobee/connect.png differ
diff --git a/img/ecobee/ecobee_logo_colour2.jpg b/img/ecobee/ecobee_logo_colour2.jpg
new file mode 100644
index 0000000..9fed1a7
Binary files /dev/null and b/img/ecobee/ecobee_logo_colour2.jpg differ
diff --git a/img/ecobee/logo.png b/img/ecobee/logo.png
new file mode 100644
index 0000000..5859f4b
Binary files /dev/null and b/img/ecobee/logo.png differ
diff --git a/img/nest/connect.png b/img/nest/connect.png
new file mode 100644
index 0000000..0d7310a
Binary files /dev/null and b/img/nest/connect.png differ
diff --git a/img/nest/logo.png b/img/nest/logo.png
new file mode 100644
index 0000000..6dcff16
Binary files /dev/null and b/img/nest/logo.png differ
diff --git a/img/nest/logo.svg b/img/nest/logo.svg
new file mode 100644
index 0000000..f1fcbaf
--- /dev/null
+++ b/img/nest/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/img/waveform.png b/img/waveform.png
new file mode 100644
index 0000000..c0ae8e3
Binary files /dev/null and b/img/waveform.png differ
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..bba1167
--- /dev/null
+++ b/index.php
@@ -0,0 +1,77 @@
+is_demo() === true) {
+ setcookie(
+ 'session_key',
+ 'd31d3ef451fe65885928e5e1bdf4af321f702009',
+ 4294967295,
+ '/',
+ null,
+ $setting->get('force_ssl'),
+ true
+ );
+ header('Location: dashboard/');
+ die();
+ } else {
+ // Skip this page entirely if you're logged in.
+ if(isset($_COOKIE['session_key']) === true) {
+ header('Location: dashboard/');
+ die();
+ }
+ }
+?>
+
+
+
+ beestat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connect your thermostat
+
+
+
+
+
+
+
+
+
diff --git a/js/.eslintrc.json b/js/.eslintrc.json
new file mode 100644
index 0000000..c4fa3ac
--- /dev/null
+++ b/js/.eslintrc.json
@@ -0,0 +1,105 @@
+{
+ "env": {
+ "browser": true,
+ "es6": true
+ },
+ "globals": {
+ "rocket": true,
+ "$": true,
+ "beestat": true,
+ "moment": true,
+ "Rollbar": true,
+ "Highcharts": true,
+ "ga": true
+ },
+ "extends": "eslint:all",
+ "rules": {
+ "camelcase": "off",
+ "no-lonely-if": "off",
+ "capitalized-comments": "off",
+ "complexity": "off",
+ "no-loop-func": "off",
+ "consistent-this": ["error", "self"],
+ "dot-location": ["error", "property"],
+ "default-case": "off",
+ "func-names": ["error", "never"],
+ "id-length": "off",
+ "indent": ["error", 2],
+ "init-declarations": "off",
+ "linebreak-style": "off",
+ "max-len": ["error", {"ignoreUrls": true, "ignoreStrings": true}],
+ "max-lines": "off",
+ "max-depth": "off",
+ "max-statements": "off",
+ "max-params": ["error", 5],
+ "new-cap": ["error", {"newIsCap": false}],
+ "newline-after-var": "off",
+ "no-extra-parens": "off",
+ "no-magic-numbers": "off",
+ "no-multiple-empty-lines": ["warn", {"max": 1, "maxEOF": 1, "maxBOF": 0}],
+ "no-plusplus": "off",
+ "no-undefined": "off",
+ "no-underscore-dangle": "off",
+ "one-var": ["error", "never"],
+ "padded-blocks": ["warn", {"blocks": "never", "classes": "never", "switches": "never"}],
+ "quotes": ["error", "single"],
+ "sort-keys": "off",
+ "space-before-function-paren": ["error", "never"],
+ "strict": "off",
+ "valid-jsdoc": ["error", {"requireReturn": false, "requireParamDescription": false}],
+ "vars-on-top": "off",
+ "guard-for-in": "off",
+ "multiline-ternary": "off",
+ "no-ternary": "off",
+ "no-trailing-spaces": "warn",
+ "object-curly-newline": "warn",
+ "max-len": "warn",
+ "no-negated-condition": "off",
+ "max-lines-per-function": "off",
+
+ // Node.js and CommonJS
+ "callback-return": "off",
+ "global-require": "off",
+ "handle-callback-err": "off",
+ "no-mixed-requires": "off",
+ "no-new-require": "off",
+ "no-path-concat": "off",
+ "no-process-env": "off",
+ "no-process-exit": "off",
+ "no-restricted-modules": "off",
+ "no-sync": "off",
+
+ // ES6
+ "arrow-body-style": "off",
+ "arrow-parens": "off",
+ "arrow-spacing": "off",
+ "constructor-super": "off",
+ "generator-star-spacing": "off",
+ "no-class-assign": "off",
+ "no-confusing-arrow": "off",
+ "no-const-assign": "off",
+ "no-dupe-class-members": "off",
+ "no-duplicate-imports": "off",
+ "no-new-symbol": "off",
+ "no-restricted-imports": "off",
+ "no-this-before-super": "off",
+ "no-useless-computed-key": "off",
+ "no-useless-constructor": "off",
+ "no-useless-rename": "off",
+ "no-var": "off",
+ "object-shorthand": "off",
+ "prefer-arrow-callback": "off",
+ "prefer-const": "off",
+ "prefer-destructuring": "off",
+ "prefer-numeric-literals": "off",
+ "prefer-rest-params": "off",
+ "prefer-spread": "off",
+ "prefer-template": "off",
+ "require-yield": "off",
+ "rest-spread-spacing": "off",
+ "sort-imports": "off",
+ "symbol-description": "off",
+ "template-curly-spacing": "off",
+ "yield-star-spacing": "off"
+ }
+}
diff --git a/js/beestat.js b/js/beestat.js
new file mode 100644
index 0000000..5f68a7c
--- /dev/null
+++ b/js/beestat.js
@@ -0,0 +1,186 @@
+/**
+ * Top-level namespace.
+ */
+var beestat = {};
+
+beestat.cards = {};
+
+beestat.ecobee_thermostat_models = {
+ 'apolloEms': 'apolloEms',
+ 'apolloSmart': 'ecobee4',
+ 'athenaEms': 'ecobee3 EMS',
+ 'athenaSmart': 'ecobee3',
+ 'corSmart': 'Côr',
+ 'idtEms': 'Smart EMS',
+ 'idtSmart': 'Smart',
+ 'nikeEms': 'ecobee3 lite EMS',
+ 'nikeSmart': 'ecobee3 lite',
+ 'siEms': 'Smart Si EMS',
+ 'siSmart': 'Smart Si',
+ 'vulcanSmart': 'vulcanSmart'
+};
+
+
+/**
+ * Get a default value for an argument if it is not currently set.
+ *
+ * @param {mixed} argument The argument to check.
+ * @param {mixed} default_value The value to use if argument is undefined.
+ *
+ * @return {mixed}
+ */
+beestat.default_value = function(argument, default_value) {
+ return (argument === undefined) ? default_value : argument;
+};
+
+/**
+ * Get the climate for whatever climate_ref is specified.
+ *
+ * @param {string} climate_ref The ecobee climateRef
+ *
+ * @return {object} The climate
+ */
+beestat.get_climate = function(climate_ref) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var ecobee_thermostat = beestat.cache.ecobee_thermostat[
+ thermostat.ecobee_thermostat_id
+ ];
+
+ var climates = ecobee_thermostat.json_program.climates;
+
+ for (var i = 0; i < climates.length; i++) {
+ if (climates[i].climateRef === climate_ref) {
+ return climates[i];
+ }
+ }
+};
+
+/**
+ * Get the color a thermostat should be based on what equipment is running.
+ *
+ * @return {string}
+ */
+beestat.get_thermostat_color = function(thermostat_id) {
+ var thermostat = beestat.cache.thermostat[thermostat_id];
+ var ecobee_thermostat = beestat.cache.ecobee_thermostat[
+ thermostat.ecobee_thermostat_id
+ ];
+
+ if (
+ ecobee_thermostat.json_equipment_status.indexOf('compCool2') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('compCool1') !== -1
+ ) {
+ return beestat.style.color.blue.light;
+ } else if (
+ ecobee_thermostat.json_settings.hasHeatPump === true &&
+ (
+ ecobee_thermostat.json_equipment_status.indexOf('auxHeat3') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('auxHeat2') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('auxHeat1') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('auxHotWater') !== -1
+ )
+ ) {
+ return beestat.style.color.red.base;
+ } else if (
+ (
+ ecobee_thermostat.json_settings.hasHeatPump === false &&
+ (
+ ecobee_thermostat.json_equipment_status.indexOf('auxHeat3') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('auxHeat2') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('auxHeat1') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('compHotWater') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('auxHotWater') !== -1
+ )
+ ) ||
+ (
+ ecobee_thermostat.json_settings.hasHeatPump === true &&
+ (
+ ecobee_thermostat.json_equipment_status.indexOf('heatPump1') !== -1 ||
+ ecobee_thermostat.json_equipment_status.indexOf('heatPump2') !== -1
+ )
+ )
+ ) {
+ return beestat.style.color.orange.base;
+ }
+ return beestat.style.color.bluegray.dark;
+};
+
+/**
+ * Get the current user.
+ *
+ * @return {object}
+ */
+beestat.get_user = function() {
+ var user_id = Object.keys(beestat.cache.user)[0];
+ return beestat.cache.user[user_id];
+};
+
+// Register service worker
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', function() {
+ navigator.serviceWorker.register('/service_worker.js').then(function(registration) {
+
+ /*
+ * Registration was successful
+ * console.log('ServiceWorker registration successful with scope: ', registration.scope);
+ */
+ }, function(error) {
+
+ /*
+ * registration failed :(
+ * console.log('ServiceWorker registration failed: ', err);
+ */
+ });
+ });
+}
+
+/**
+ * Dispatch a breakpoint event every time a browser resize crosses one of the
+ * breakpoints. Typically a component will use this event to rerender itself
+ * when CSS breakpoints are not feasible or appropriate.
+ */
+beestat.width = window.innerWidth;
+window.addEventListener('resize', rocket.throttle(100, function() {
+ var breakpoints = [
+ 500,
+ 600
+ ];
+
+ breakpoints.forEach(function(breakpoint) {
+ if (
+ (
+ beestat.width > breakpoint &&
+ window.innerWidth <= breakpoint
+ ) ||
+ (
+ beestat.width < breakpoint &&
+ window.innerWidth >= breakpoint
+ )
+ ) {
+ beestat.width = window.innerWidth;
+ beestat.dispatcher.dispatchEvent('breakpoint');
+ }
+ });
+}));
+
+/**
+ * Whether or not the current user gets access to early release features.
+ *
+ * @return {boolean} Early access or not.
+ */
+beestat.has_early_access = function() {
+ var user = beestat.get_user();
+ return user.user_id === 1 ||
+ (
+ user.json_patreon_status !== null &&
+ user.json_patreon_status.patron_status === 'active_patron' &&
+ user.json_patreon_status.currently_entitled_amount_cents >= 500
+ );
+};
+
+// First run
+var $ = rocket.extend(rocket.$, rocket);
+$.ready(function() {
+ (new beestat.layer.load()).render();
+});
diff --git a/js/beestat/api.js b/js/beestat/api.js
new file mode 100644
index 0000000..4702fa2
--- /dev/null
+++ b/js/beestat/api.js
@@ -0,0 +1,332 @@
+beestat.api2 = function() {
+ this.api_calls_ = [];
+};
+
+/**
+ * Stores cached responses statically across all API calls.
+ *
+ * @type {Object}
+ */
+beestat.api2.cache = {};
+
+// if (window.localStorage.getItem('api_cache') !== null) {
+// beestat.api2.cache = JSON.parse(window.localStorage.getItem('api_cache'));
+// }
+
+/**
+ * Beestat's local API key.
+ *
+ * @type {string}
+ */
+beestat.api2.api_key = 'ER9Dz8t05qUdui0cvfWi5GiVVyHP6OB8KPuSisP2';
+
+/**
+ * Send an API call. If the api_call parameter is specified it will send that.
+ * If not, it will check the cache, then construct and send the appropriate
+ * API call if necessary.
+ *
+ * @param {Object=} opt_api_call The API call object.
+ *
+ * @return {beestat.api2} This.
+ */
+beestat.api2.prototype.send = function(opt_api_call) {
+ var self = this;
+
+ this.xhr_ = new XMLHttpRequest();
+
+ // If passing an actual API call, fire it off!
+ if (opt_api_call !== undefined) {
+ // Add in the API key
+ opt_api_call.api_key = beestat.api2.api_key;
+
+ // Build the query string
+ var query_string = Object.keys(opt_api_call)
+ .map(function(k) {
+ return encodeURIComponent(k) + '=' + encodeURIComponent(opt_api_call[k]);
+ })
+ .join('&');
+
+ this.xhr_.addEventListener('load', function() {
+ self.load_(this.responseText);
+ });
+ this.xhr_.open('POST', '../api/?' + query_string);
+ this.xhr_.send();
+ } else {
+ if (this.is_batch_() === true) {
+ // Only run uncached API calls.
+ var uncached_batch_api_calls = [];
+ this.cached_batch_api_calls_ = {};
+ this.api_calls_.forEach(function(api_call) {
+ var cached = self.get_cached_(api_call);
+ if (cached === undefined) {
+ uncached_batch_api_calls.push(api_call);
+ } else {
+ self.cached_batch_api_calls_[api_call.alias] = cached.data;
+ }
+ });
+
+ if (uncached_batch_api_calls.length === 0) {
+ // If no API calls left, just fire off the callback with the data.
+ if (this.callback_ !== undefined) {
+ this.callback_(this.cached_batch_api_calls_);
+ }
+ } else {
+ // If more than one API call left, fire off a batch API call.
+ this.send({'batch': JSON.stringify(uncached_batch_api_calls)});
+ }
+ } else {
+ var single_api_call = this.api_calls_[0];
+
+ var cached = this.get_cached_(single_api_call);
+ if (cached !== undefined) {
+ if (this.callback_ !== undefined) {
+ this.callback_(cached.data);
+ }
+ } else {
+ this.send(single_api_call);
+ }
+ }
+ }
+
+ return this;
+};
+
+/**
+ * Add an API call.
+ *
+ * @param {string} resource The resource.
+ * @param {string} method The method.
+ * @param {Object=} opt_args Optional arguments.
+ * @param {string=} opt_alias Optional alias (required for batch API calls).
+ *
+ * @return {beestat.api2} This.
+ */
+beestat.api2.prototype.add_call = function(resource, method, opt_args, opt_alias) {
+ var api_call = {
+ 'resource': resource,
+ 'method': method,
+ 'arguments': JSON.stringify(beestat.default_value(opt_args, {}))
+ };
+ if (opt_alias !== undefined) {
+ api_call.alias = opt_alias;
+ }
+
+ this.api_calls_.push(api_call);
+
+ return this;
+};
+
+/**
+ * Set a callback function to be called once the API call completes.
+ *
+ * @param {Function} callback The callback function.
+ *
+ * @return {beestat.api2} This.
+ */
+beestat.api2.prototype.set_callback = function(callback) {
+ this.callback_ = callback;
+
+ return this;
+};
+
+/**
+ * Fires after an XHR request returns.
+ *
+ * @param {string} response_text Whatever the XHR request returned.
+ */
+beestat.api2.prototype.load_ = function(response_text) {
+ var response;
+ try {
+ response = window.JSON.parse(response_text);
+ } catch (e) {
+ beestat.error('API returned invalid response.', response_text);
+ return;
+ }
+
+ // Error handling
+ if (
+ response.data &&
+ (
+ response.data.error_code === 1004 || // Session is expired.
+ response.data.error_code === 10001 || // Could not get first token.
+ response.data.error_code === 10002 || // Could not refresh ecobee token; no token found.
+ response.data.error_code === 10003 // Could not refresh ecobee token; ecobee returned no token.
+ )
+ ) {
+ window.location.href = '/';
+ return;
+ } else if (response.data && response.data.error_code === 1209) {
+ // Could not get lock; safe to ignore as that means sync is running.
+ } else if (response.success !== true) {
+ beestat.error(
+ 'API call failed: ' + response.data.error_message,
+ JSON.stringify(response, null, 2)
+ );
+ }
+
+ // Cach responses
+ var cached_until_header = this.xhr_.getResponseHeader('beestat-cached-until');
+
+ if (this.is_batch_() === true) {
+ var cached_untils = window.JSON.parse(cached_until_header);
+ for (var alias in cached_untils) {
+ for (var i = 0; i < this.api_calls_.length; i++) {
+ if (this.api_calls_[i].alias === alias) {
+ this.cache_(
+ this.api_calls_[i],
+ response.data[alias],
+ cached_untils[alias]
+ );
+ }
+ }
+ }
+ } else {
+ if (cached_until_header !== null) {
+ this.cache_(this.api_calls_[0], response.data, cached_until_header);
+ }
+ }
+
+ /*
+ * For batch API calls, add in any responses that were pulled out earlier
+ * because they were cached.
+ */
+ if (this.is_batch_() === true) {
+ for (var cached_alias in this.cached_batch_api_calls_) {
+ response.data[cached_alias] =
+ this.cached_batch_api_calls_[cached_alias];
+ }
+ }
+
+ // Callback
+ if (this.callback_ !== undefined) {
+ this.callback_(response.data);
+ }
+};
+
+/**
+ * Is this a batch API call? Determined by looking at the number of API calls
+ * added. If more than one, batch.
+ *
+ * @return {boolean} Whether or not this is a batch API call.
+ */
+beestat.api2.prototype.is_batch_ = function() {
+ return this.api_calls_.length > 1;
+};
+
+/**
+ * Cache an API call.
+ *
+ * @param {Object} api_call The API call object.
+ * @param {*} data The data to cache.
+ * @param {string} until Timestamp to cache until.
+ */
+beestat.api2.prototype.cache_ = function(api_call, data, until) {
+ var server_date = moment(this.xhr_.getResponseHeader('date'));
+ var duration = moment.duration(moment(until).diff(server_date));
+
+ beestat.api2.cache[this.get_key_(api_call)] = {
+ 'data': data,
+ 'until': moment().add(duration.asSeconds(), 'seconds')
+ };
+
+ /**
+ * Save the cache to localStorage to persist across reloads. It just happens
+ * to be annoying that localStorage only supports strings so I prefer to
+ * deal with myself.
+ */
+ // window.localStorage.setItem(
+ // 'api_cache',
+ // window.JSON.stringify(beestat.api2.cache)
+ // );
+};
+
+/**
+ * Look for cached data for an API call and return it if not expired.
+ *
+ * @param {Object} api_call The API call object.
+ *
+ * @return {*} The cached data, or undefined if none.
+ */
+beestat.api2.prototype.get_cached_ = function(api_call) {
+ var cached = beestat.api2.cache[this.get_key_(api_call)];
+ if (
+ cached !== undefined &&
+ moment().isAfter(cached.until) === false
+ ) {
+ return cached;
+ }
+ return undefined;
+};
+
+/**
+ * Get a cache key for an API call. There's a lack of hash options in
+ * JavaScript so this just concatenates a bunch of stuff together.
+ *
+ * @param {Object} api_call The API call object.
+ *
+ * @return {string} The cache key.
+ */
+beestat.api2.prototype.get_key_ = function(api_call) {
+ return api_call.resource + '.' + api_call.method + '.' + api_call.arguments;
+};
+
+// TODO OLD DELETE THIS
+beestat.api = function(resource, method, args, callback) {
+ var xhr = new XMLHttpRequest();
+
+ var load = function() {
+ var response;
+ try {
+ response = window.JSON.parse(this.responseText);
+ } catch (e) {
+ beestat.error('API returned invalid response.', this.responseText);
+ return;
+ }
+
+ if (
+ response.data &&
+ (
+ response.data.error_code === 1004 || // Session is expired.
+ response.data.error_code === 10001 || // Could not get first token.
+ response.data.error_code === 10002 || // Could not refresh ecobee token; no token found.
+ response.data.error_code === 10003 // Could not refresh ecobee token; ecobee returned no token.
+ )
+ ) {
+ window.location.href = '/';
+ } else if (response.data && response.data.error_code === 1209) {
+ // Could not get lock; safe to ignore as that means sync is running.
+ } else if (response.success !== true) {
+ beestat.error(
+ 'API call failed: ' + response.data.error_message,
+ JSON.stringify(response, null, 2)
+ );
+ } else if (callback !== undefined) {
+ callback(response.data);
+ }
+ };
+ xhr.addEventListener('load', load);
+
+ var api_key = 'ER9Dz8t05qUdui0cvfWi5GiVVyHP6OB8KPuSisP2';
+ if (resource === 'api' && method === 'batch') {
+ args.forEach(function(api_call, i) {
+ if (args[i].arguments !== undefined) {
+ args[i].arguments = JSON.stringify(args[i].arguments);
+ }
+ });
+ xhr.open(
+ 'POST',
+ '../api/?batch=' + JSON.stringify(args) +
+ '&api_key=' + api_key
+ );
+ } else {
+ xhr.open(
+ 'POST',
+ '../api/?resource=' + resource +
+ '&method=' + method +
+ (args === undefined ? '' : '&arguments=' + JSON.stringify(args)) +
+ '&api_key=' + api_key
+ );
+ }
+
+ xhr.send();
+};
diff --git a/js/beestat/cache.js b/js/beestat/cache.js
new file mode 100644
index 0000000..a640c63
--- /dev/null
+++ b/js/beestat/cache.js
@@ -0,0 +1,21 @@
+beestat.cache = {
+ 'data': {}
+};
+
+beestat.cache.set = function(key, value) {
+ if (key.substring(0, 5) === 'data.') {
+ beestat.cache.data[key.substring(5)] = value;
+ } else {
+ beestat.cache[key] = value;
+ }
+ beestat.dispatcher.dispatchEvent('cache.' + key);
+};
+
+beestat.cache.delete = function(key) {
+ if (key.substring(0, 5) === 'data.') {
+ delete beestat.cache.data[key.substring(5)];
+ } else {
+ delete beestat.cache[key];
+ }
+ beestat.dispatcher.dispatchEvent('cache.' + key);
+};
diff --git a/js/beestat/debounce.js b/js/beestat/debounce.js
new file mode 100644
index 0000000..89275a8
--- /dev/null
+++ b/js/beestat/debounce.js
@@ -0,0 +1,33 @@
+/**
+ * Returns a function, that, as long as it continues to be invoked, will not
+ * be triggered.
+ *
+ * @param {Function} func The function to call.
+ * @param {number} wait The function will be called after it stops being
+ * called for N milliseconds.
+ * @param {boolean} immediate Trigger the function on the leading edge,
+ * instead of the trailing.
+ *
+ * @link https://davidwalsh.name/javascript-debounce-function
+ *
+ * @return {Function} The debounced function.
+ */
+beestat.debounce = function(func, wait, immediate) {
+ var timeout;
+ return function() {
+ var self = this;
+ var args = arguments;
+ var later = function() {
+ timeout = null;
+ if (!immediate) {
+ func.apply(self, args);
+ }
+ };
+ var callNow = immediate && !timeout;
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ if (callNow) {
+ func.apply(self, args);
+ }
+ };
+};
diff --git a/js/beestat/dispatcher.js b/js/beestat/dispatcher.js
new file mode 100644
index 0000000..b47693d
--- /dev/null
+++ b/js/beestat/dispatcher.js
@@ -0,0 +1,28 @@
+/**
+ * Simple global event dispatcher. Anything can use this to dispatch events by
+ * calling beestat.dispatcher.dispatchEvent('{{event_name}}') which anything
+ * else can be listening for.
+ */
+beestat.dispatcher_ = function() {
+ // Class only exists so it can extend rocket.EventTarget.
+};
+beestat.extend(beestat.dispatcher_, rocket.EventTarget);
+beestat.dispatcher = new beestat.dispatcher_();
+
+/**
+ * Do the normal event listener stuff. Extends the rocket version just a bit
+ * to allow passing multiple event types at once for brevity.
+ *
+ * @param {string|array} type The event type or an array of event types.
+ * @param {Function} listener Event Listener.
+ */
+beestat.dispatcher_.prototype.addEventListener = function(type, listener) {
+ if (typeof type === 'object') {
+ for (var i = 0; i < type.length; i++) {
+ rocket.EventTarget.prototype.addEventListener.apply(this, [type[i], listener]);
+ }
+ } else {
+ rocket.EventTarget.prototype.addEventListener.apply(this, arguments);
+ }
+ return this;
+};
diff --git a/js/beestat/error.js b/js/beestat/error.js
new file mode 100644
index 0000000..9f1625a
--- /dev/null
+++ b/js/beestat/error.js
@@ -0,0 +1,215 @@
+/**
+ * Pop up a modal error message.
+ *
+ * @param {string} message The human-readable message.
+ * @param {string} detail Technical error details.
+ */
+beestat.error = function(message, detail) {
+ var exception_modal = new beestat.component.modal.error();
+ exception_modal.set_message(message);
+ exception_modal.set_detail(detail);
+ exception_modal.render();
+};
+
+/*
+ * Rollbar
+ * Define my own error handler first, then Rollbar's (which will call mine after it does it's thing)
+ */
+window.onerror = function(message) {
+ beestat.error('Script error.', message);
+ return false;
+};
+var _rollbarConfig = {
+ 'accessToken': '5400fd8650264977a97f3ae7fcd226af',
+ 'captureUncaught': true,
+ 'captureUnhandledRejections': true,
+ 'payload': {
+ 'environment': window.environment
+ }
+};
+
+if (window.environment === 'live') {
+ !(function(r) {
+ function o(n) {
+ if (e[n]) {
+ return e[n].exports;
+ } var t = e[n] = {'exports': {},
+ 'id': n,
+ 'loaded': !1}; return r[n].call(t.exports, t, t.exports, o), t.loaded = !0, t.exports;
+ } var e = {}; return o.m = r, o.c = e, o.p = '', o(0);
+ }([
+ function(r, o, e) {
+ 'use strict'; var n = e(1); var t = e(4); _rollbarConfig = _rollbarConfig || {}, _rollbarConfig.rollbarJsUrl = _rollbarConfig.rollbarJsUrl || 'https://cdnjs.cloudflare.com/ajax/libs/rollbar.js/2.5.2/rollbar.min.js', _rollbarConfig.async = void 0 === _rollbarConfig.async || _rollbarConfig.async; var a = n.setupShim(window, _rollbarConfig); var l = t(_rollbarConfig); window.rollbar = n.Rollbar, a.loadFull(window, document, !_rollbarConfig.async, _rollbarConfig, l);
+ },
+ function(r, o, e) {
+ 'use strict'; function n(r) {
+ return function() {
+ try {
+ return r.apply(this, arguments);
+ } catch (r) {
+ try {
+ console.error('[Rollbar]: Internal error', r);
+ } catch (r) {}
+ }
+ };
+ } function t(r, o) {
+ this.options = r, this._rollbarOldOnError = null; var e = s++; this.shimId = function() {
+ return e;
+ }, typeof window !== 'undefined' && window._rollbarShims && (window._rollbarShims[e] = {'handler': o,
+ 'messages': []});
+ } function a(r, o) {
+ if (r) {
+ var e = o.globalAlias || 'Rollbar'; if (typeof r[e] === 'object') {
+ return r[e];
+ } r._rollbarShims = {}, r._rollbarWrappedError = null; var t = new p(o); return n(function() {
+ o.captureUncaught && (t._rollbarOldOnError = r.onerror, i.captureUncaughtExceptions(r, t, !0), i.wrapGlobals(r, t, !0)), o.captureUnhandledRejections && i.captureUnhandledRejections(r, t, !0); var n = o.autoInstrument; return o.enabled !== !1 && (void 0 === n || n === !0 || typeof n === 'object' && n.network) && r.addEventListener && (r.addEventListener('load', t.captureLoad.bind(t)), r.addEventListener('DOMContentLoaded', t.captureDomContentLoaded.bind(t))), r[e] = t, t;
+ })();
+ }
+ } function l(r) {
+ return n(function() {
+ var o = this; var e = Array.prototype.slice.call(arguments, 0); var n = {'shim': o,
+ 'method': r,
+ 'args': e,
+ 'ts': new Date()}; window._rollbarShims[this.shimId()].messages.push(n);
+ });
+ } var i = e(2); var s = 0; var d = e(3); var c = function(r, o) {
+ return new t(r, o);
+ }; var p = function(r) {
+ return new d(c, r);
+ }; t.prototype.loadFull = function(r, o, e, t, a) {
+ var l = function() {
+ var o; if (void 0 === r._rollbarDidLoad) {
+ o = new Error('rollbar.js did not load'); for (var e, i = 0, l, n, t; e = r._rollbarShims[i++];) {
+ for (e = e.messages || []; n = e.shift();) {
+ for (t = n.args || [], i = 0; i < t.length; ++i) {
+ if (l = t[i], typeof l === 'function') {
+ l(o); break;
+ }
+ }
+ }
+ }
+ } typeof a === 'function' && a(o);
+ }; var i = !1; var s = o.createElement('script'); var d = o.getElementsByTagName('script')[0]; var c = d.parentNode; s.crossOrigin = '', s.src = t.rollbarJsUrl, e || (s.async = !0), s.onload = s.onreadystatechange = n(function() {
+ if (!(i || this.readyState && this.readyState !== 'loaded' && this.readyState !== 'complete')) {
+ s.onload = s.onreadystatechange = null; try {
+ c.removeChild(s);
+ } catch (r) {}i = !0, l();
+ }
+ }), c.insertBefore(s, d);
+ }, t.prototype.wrap = function(r, o, e) {
+ try {
+ var n; if (n = typeof o === 'function' ? o : function() {
+ return o || {};
+ }, typeof r !== 'function') {
+ return r;
+ } if (r._isWrap) {
+ return r;
+ } if (!r._rollbar_wrapped && (r._rollbar_wrapped = function() {
+ e && typeof e === 'function' && e.apply(this, arguments); try {
+ return r.apply(this, arguments);
+ } catch (e) {
+ var o = e; throw o && (typeof o === 'string' && (o = new String(o)), o._rollbarContext = n() || {}, o._rollbarContext._wrappedSource = r.toString(), window._rollbarWrappedError = o), o;
+ }
+ }, r._rollbar_wrapped._isWrap = !0, r.hasOwnProperty)) {
+ for (var t in r) {
+ r.hasOwnProperty(t) && (r._rollbar_wrapped[t] = r[t]);
+ }
+ } return r._rollbar_wrapped;
+ } catch (o) {
+ return r;
+ }
+ }; for (var u = 'log,debug,info,warn,warning,error,critical,global,configure,handleUncaughtException,handleUnhandledRejection,captureEvent,captureDomContentLoaded,captureLoad'.split(','), f = 0; f < u.length; ++f) {
+ t.prototype[u[f]] = l(u[f]);
+ }r.exports = {'setupShim': a,
+ 'Rollbar': p};
+ },
+ function(r, o) {
+ 'use strict'; function e(r, o, e) {
+ if (r) {
+ var t; if (typeof o._rollbarOldOnError === 'function') {
+ t = o._rollbarOldOnError;
+ } else if (r.onerror) {
+ for (t = r.onerror; t._rollbarOldOnError;) {
+ t = t._rollbarOldOnError;
+ }o._rollbarOldOnError = t;
+ } var a = function() {
+ var e = Array.prototype.slice.call(arguments, 0); n(r, o, t, e);
+ }; e && (a._rollbarOldOnError = t), r.onerror = a;
+ }
+ } function n(r, o, e, n) {
+ r._rollbarWrappedError && (n[4] || (n[4] = r._rollbarWrappedError), n[5] || (n[5] = r._rollbarWrappedError._rollbarContext), r._rollbarWrappedError = null), o.handleUncaughtException.apply(o, n), e && e.apply(r, n);
+ } function t(r, o, e) {
+ if (r) {
+ typeof r._rollbarURH === 'function' && r._rollbarURH.belongsToShim && r.removeEventListener('unhandledrejection', r._rollbarURH); var n = function(r) {
+ var e; var n; var t; try {
+ e = r.reason;
+ } catch (r) {
+ e = void 0;
+ } try {
+ n = r.promise;
+ } catch (r) {
+ n = '[unhandledrejection] error getting `promise` from event';
+ } try {
+ t = r.detail, !e && t && (e = t.reason, n = t.promise);
+ } catch (r) {}e || (e = '[unhandledrejection] error getting `reason` from event'), o && o.handleUnhandledRejection && o.handleUnhandledRejection(e, n);
+ }; n.belongsToShim = e, r._rollbarURH = n, r.addEventListener('unhandledrejection', n);
+ }
+ } function a(r, o, e) {
+ if (r) {
+ var n; var t; var a = 'EventTarget,Window,Node,ApplicationCache,AudioTrackList,ChannelMergerNode,CryptoOperation,EventSource,FileReader,HTMLUnknownElement,IDBDatabase,IDBRequest,IDBTransaction,KeyOperation,MediaController,MessagePort,ModalWindow,Notification,SVGElementInstance,Screen,TextTrack,TextTrackCue,TextTrackList,WebSocket,WebSocketWorker,Worker,XMLHttpRequest,XMLHttpRequestEventTarget,XMLHttpRequestUpload'.split(','); for (n = 0; n < a.length; ++n) {
+ t = a[n], r[t] && r[t].prototype && l(o, r[t].prototype, e);
+ }
+ }
+ } function l(r, o, e) {
+ if (o.hasOwnProperty && o.hasOwnProperty('addEventListener')) {
+ for (var n = o.addEventListener; n._rollbarOldAdd && n.belongsToShim;) {
+ n = n._rollbarOldAdd;
+ } var t = function(o, e, t) {
+ n.call(this, o, r.wrap(e), t);
+ }; t._rollbarOldAdd = n, t.belongsToShim = e, o.addEventListener = t; for (var a = o.removeEventListener; a._rollbarOldRemove && a.belongsToShim;) {
+ a = a._rollbarOldRemove;
+ } var l = function(r, o, e) {
+ a.call(this, r, o && o._rollbar_wrapped || o, e);
+ }; l._rollbarOldRemove = a, l.belongsToShim = e, o.removeEventListener = l;
+ }
+ }r.exports = {'captureUncaughtExceptions': e,
+ 'captureUnhandledRejections': t,
+ 'wrapGlobals': a};
+ },
+ function(r, o) {
+ 'use strict'; function e(r, o) {
+ this.impl = r(o, this), this.options = o, n(e.prototype);
+ } function n(r) {
+ for (var o = function(r) {
+ return function() {
+ var o = Array.prototype.slice.call(arguments, 0); if (this.impl[r]) {
+ return this.impl[r].apply(this.impl, o);
+ }
+ };
+ }, e = 'log,debug,info,warn,warning,error,critical,global,configure,handleUncaughtException,handleUnhandledRejection,_createItem,wrap,loadFull,shimId,captureEvent,captureDomContentLoaded,captureLoad'.split(','), n = 0; n < e.length; n++) {
+ r[e[n]] = o(e[n]);
+ }
+ }e.prototype._swapAndProcessMessages = function(r, o) {
+ this.impl = r(this.options); for (var e, n, t; e = o.shift();) {
+ n = e.method, t = e.args, this[n] && typeof this[n] === 'function' && (n === 'captureDomContentLoaded' || n === 'captureLoad' ? this[n].apply(this, [
+ t[0],
+ e.ts
+ ]) : this[n].apply(this, t));
+ } return this;
+ }, r.exports = e;
+ },
+ function(r, o) {
+ 'use strict'; r.exports = function(r) {
+ return function(o) {
+ if (!o && !window._rollbarInitialized) {
+ r = r || {}; for (var e, n, t = r.globalAlias || 'Rollbar', a = window.rollbar, l = function(r) {
+ return new a(r);
+ }, i = 0; e = window._rollbarShims[i++];) {
+ n || (n = e.handler), e.handler._swapAndProcessMessages(l, e.messages);
+ }window[t] = n, window._rollbarInitialized = !0;
+ }
+ };
+ };
+ }
+ ]));
+}
diff --git a/js/beestat/extend.js b/js/beestat/extend.js
new file mode 100644
index 0000000..d899c78
--- /dev/null
+++ b/js/beestat/extend.js
@@ -0,0 +1,16 @@
+/**
+ * Extends one class with another.
+ *
+ * @link https://oli.me.uk/2013/06/01/prototypical-inheritance-done-right/
+ *
+ * @param {Function} destination The class that should be inheriting things.
+ * @param {Function} source The parent class that should be inherited from.
+ *
+ * @return {Object} The prototype of the parent.
+ */
+beestat.extend = function(destination, source) {
+ destination.prototype = Object.create(source.prototype);
+ destination.prototype.constructor = destination;
+
+ return source.prototype;
+};
diff --git a/js/beestat/google_analytics.js b/js/beestat/google_analytics.js
new file mode 100644
index 0000000..6042992
--- /dev/null
+++ b/js/beestat/google_analytics.js
@@ -0,0 +1,13 @@
+/*
+ * Google Analytics
+ * https://developers.google.com/analytics/devguides/collection/analyticsjs/tracking-snippet-reference
+ */
+if (window.environment === 'live') {
+ (function(i, s, o, g, r, a, m) {
+ i.GoogleAnalyticsObject = r; i[r] = i[r] || function() {
+ (i[r].q = i[r].q || []).push(arguments);
+ }, i[r].l = Number(new Date()); a = s.createElement(o), m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m);
+ }(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga'));
+ ga('create', 'UA-10019370-7', 'auto');
+ ga('send', 'pageview');
+}
diff --git a/js/beestat/poll.js b/js/beestat/poll.js
new file mode 100644
index 0000000..d82ac22
--- /dev/null
+++ b/js/beestat/poll.js
@@ -0,0 +1,80 @@
+beestat.add_poll_interval = function(poll_interval) {
+ beestat.poll_intervals.push(poll_interval);
+ beestat.enable_poll();
+};
+
+beestat.remove_poll_interval = function(poll_interval) {
+ var index = beestat.poll_intervals.indexOf(poll_interval);
+ if (index !== -1) {
+ beestat.poll_intervals.splice(index, 1);
+ }
+ beestat.enable_poll();
+};
+
+beestat.reset_poll_interval = function() {
+ beestat.poll_intervals = [beestat.default_poll_interval];
+ beestat.enable_poll();
+};
+
+beestat.enable_poll = function() {
+ clearTimeout(beestat.poll_timeout);
+ if (beestat.poll_intervals.length > 0) {
+ beestat.poll_timeout = setTimeout(
+ beestat.poll,
+ Math.min.apply(null, beestat.poll_intervals)
+ );
+ }
+};
+
+beestat.disable_poll = function() {
+ clearTimeout(beestat.poll_timeout);
+};
+
+/**
+ * Poll the database for changes and update the cache.
+ */
+beestat.poll = function() {
+ beestat.api(
+ 'api',
+ 'batch',
+ [
+ {
+ 'resource': 'user',
+ 'method': 'read_id',
+ 'alias': 'user'
+ },
+ {
+ 'resource': 'thermostat',
+ 'method': 'read_id',
+ 'alias': 'thermostat'
+ },
+ {
+ 'resource': 'sensor',
+ 'method': 'read_id',
+ 'alias': 'sensor'
+ },
+ {
+ 'resource': 'ecobee_thermostat',
+ 'method': 'read_id',
+ 'alias': 'ecobee_thermostat'
+ },
+ {
+ 'resource': 'ecobee_sensor',
+ 'method': 'read_id',
+ 'alias': 'ecobee_sensor'
+ }
+ ],
+ function(response) {
+ beestat.cache.set('user', response.user);
+ beestat.cache.set('thermostat', response.thermostat);
+ beestat.cache.set('sensor', response.sensor);
+ beestat.cache.set('ecobee_thermostat', response.ecobee_thermostat);
+ beestat.cache.set('ecobee_sensor', response.ecobee_sensor);
+ beestat.enable_poll();
+ beestat.dispatcher.dispatchEvent('poll');
+ }
+ );
+};
+
+beestat.default_poll_interval = 300000; // 5 minutes
+beestat.poll_intervals = [beestat.default_poll_interval];
diff --git a/js/beestat/setting.js b/js/beestat/setting.js
new file mode 100644
index 0000000..ad82e70
--- /dev/null
+++ b/js/beestat/setting.js
@@ -0,0 +1,82 @@
+/**
+ * Get or set a setting.
+ *
+ * @param {mixed} key If a string, get/set that specific key. If an object, set all the specified keys in the object.
+ * @param {mixed} opt_value If a string, set the specified key to this value.
+ * @param {mixed} opt_callback Optional callback.
+ *
+ * @return {mixed} The setting if requesting (undefined if not set), undefined
+ * otherwise.
+ */
+beestat.setting = function(key, opt_value, opt_callback) {
+ var user = beestat.get_user();
+
+ var defaults = {
+ 'recent_activity_time_period': 'day',
+ 'recent_activity_time_count': 3,
+ 'aggregate_runtime_time_period': 'month',
+ 'aggregate_runtime_time_count': 2,
+ 'aggregate_runtime_group_by': 'day',
+ 'aggregate_runtime_gap_fill': true,
+ 'comparison_region': 'global',
+ 'comparison_property_type': 'similar',
+ 'comparison_period': 0,
+ 'comparison_period_custom': moment().format('M/D/YYYY')
+ };
+
+ if (user.json_settings === null) {
+ user.json_settings = {};
+ }
+
+ /*
+ * TODO This is temporary until I get all the setting data types under
+ * control. Just doing this so other parts of the application can be built out
+ * properly.
+ */
+ if (user.json_settings.thermostat_id !== undefined) {
+ user.json_settings.thermostat_id = parseInt(
+ user.json_settings.thermostat_id,
+ 10
+ );
+ }
+
+ if (opt_value === undefined && typeof key !== 'object') {
+ if (user.json_settings[key] !== undefined) {
+ return user.json_settings[key];
+ } else if (defaults[key] !== undefined) {
+ return defaults[key];
+ }
+ return undefined;
+ }
+ var settings;
+ if (typeof key === 'object') {
+ settings = key;
+ } else {
+ settings = {};
+ settings[key] = opt_value;
+ }
+
+ var api_calls = [];
+ for (var k in settings) {
+ if (user.json_settings[k] !== settings[k]) {
+ user.json_settings[k] = settings[k];
+
+ beestat.dispatcher.dispatchEvent('setting.' + k);
+
+ api_calls.push({
+ 'resource': 'user',
+ 'method': 'update_setting',
+ 'arguments': {
+ 'key': k,
+ 'value': settings[k]
+ }
+ });
+ }
+ }
+
+ if (api_calls.length > 0) {
+ beestat.api('api', 'batch', api_calls, opt_callback);
+ } else if (opt_callback !== undefined) {
+ opt_callback();
+ }
+};
diff --git a/js/beestat/style.js b/js/beestat/style.js
new file mode 100644
index 0000000..6536087
--- /dev/null
+++ b/js/beestat/style.js
@@ -0,0 +1,237 @@
+beestat.style = function() {};
+
+beestat.style.color = {
+ 'red': {
+ 'light': '#fc5c65',
+ 'base': '#eb3b5a',
+ 'dark': '#d63652'
+ },
+ 'orange': {
+ 'light': '#fd9644',
+ 'base': '#fa8231',
+ 'dark': '#f97218'
+ },
+ 'yellow': {
+ 'light': '#fed330',
+ 'base': '#f7b731',
+ 'dark': '#f6ae18'
+ },
+ 'green': {
+ 'light': '#26de81',
+ 'base': '#20bf6b',
+ 'dark': '#20b364'
+ },
+ 'bluegreen': {
+ 'light': '#2bcbba',
+ 'base': '#0fb9b1',
+ 'dark': ''
+ },
+ 'lightblue': {
+ 'light': '#45aaf2',
+ 'base': '#2d98da',
+ 'dark': ''
+ },
+ 'blue': {
+ 'light': '#4b7bec',
+ 'base': '#3867d6',
+ 'dark': ''
+ },
+ 'purple': {
+ 'light': '#a55eea',
+ 'base': '#8854d0',
+ 'dark': ''
+ },
+ 'gray': {
+ 'light': '#d1d8e0',
+ 'base': '#a5b1c2',
+ 'dark': '#8c9bb1'
+ },
+ 'bluegray': {
+ 'light': '#37474f',
+ 'base': '#2f3d44',
+ 'dark': '#263238'
+ }
+};
+
+beestat.style.size = {
+ 'gutter': 16,
+ 'border_radius': 4
+};
+
+beestat.style.font_weight = {
+ 'light': '200',
+ 'normal': '300',
+ 'bold': '400'
+};
+
+beestat.style.font_size = {
+ 'small': '10px',
+ 'normal': '13px',
+ 'large': '14px',
+ 'extra_large': '16px'
+};
+
+/**
+ * Style an element with media queries. This is limited to a single media
+ * query per element; multiple breakpoints are not yet supported. This is also
+ * very inefficient as it adds an event listener/handler for every element. It
+ * would be more efficient to combine matching media query handlers.
+ *
+ * @param {rocket.Elements} element The element to style.
+ * @param {Object} base_style The base style to apply.
+ * @param {Object} media_style The media styles to apply.
+ */
+beestat.style.set = function(element, base_style, media_style) {
+ element.style(base_style);
+
+ for(var media in media_style) {
+ var media_query_list = window.matchMedia(media);
+
+ var handler = function(e) {
+ if(e.matches === true) {
+ element.style(media_style[e.media]);
+ }
+ else {
+ element.style(base_style);
+ }
+ };
+ handler(media_query_list);
+
+ media_query_list.addListener(handler);
+ }
+};
+
+/**
+ * Convert a hex string to RGB components.
+ *
+ * @param {string} hex
+ *
+ * @return {object} RGB
+ */
+beestat.style.hex_to_rgb = function(hex) {
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ } : null;
+};
+
+// Can't put these in beestat.js because of the dependency issues with color.
+beestat.series = {
+ 'compressor_cool_1': {
+ 'name': 'Cool 1',
+ 'color': beestat.style.color.lightblue.light
+ },
+ 'compressor_cool_2': {
+ 'name': 'Cool 2',
+ 'color': beestat.style.color.lightblue.base
+ },
+ 'compressor_heat_1': {
+ 'name': 'Heat 1',
+ 'color': beestat.style.color.orange.light
+ },
+ 'compressor_heat_2': {
+ 'name': 'Heat 2',
+ 'color': beestat.style.color.orange.dark
+ },
+ 'auxiliary_heat_1': {
+ 'name': 'Aux',
+ 'color': beestat.style.color.red.dark
+ },
+ 'auxiliary_heat_2': {
+ 'name': 'Aux 2',
+ 'color': beestat.style.color.red.dark
+ },
+ 'auxiliary_heat_3': {
+ 'name': 'Aux 3',
+ 'color': beestat.style.color.red.dark
+ },
+ 'fan': {
+ 'name': 'Fan',
+ 'color': beestat.style.color.gray.base
+ },
+
+ 'dehumidifier': {
+ 'name': 'Dehumidifier',
+ 'color': beestat.style.color.gray.light
+ },
+ 'economizer': {
+ 'name': 'Economizer',
+ 'color': beestat.style.color.gray.light
+ },
+ 'humidifier': {
+ 'name': 'Humidifier',
+ 'color': beestat.style.color.gray.light
+ },
+ 'ventilator': {
+ 'name': 'Ventilator',
+ 'color': beestat.style.color.gray.light
+ },
+
+ 'indoor_temperature': {
+ 'name': 'Indoor Temp',
+ 'color': beestat.style.color.gray.light
+ },
+ 'outdoor_temperature': {
+ 'name': 'Outdoor Temp',
+ 'color': beestat.style.color.gray.light
+ },
+ 'setpoint_heat': {
+ 'name': 'Setpoint',
+ 'color': beestat.style.color.orange.light
+ },
+ 'setpoint_cool': {
+ 'name': 'Setpoint',
+ 'color': beestat.style.color.lightblue.light
+ },
+ 'indoor_humidity': {
+ 'name': 'Indoor Humidity',
+ 'color': beestat.style.color.bluegreen.base
+ },
+ 'outdoor_humidity': {
+ 'name': 'Outdoor Humidity',
+ 'color': beestat.style.color.bluegreen.base
+ },
+
+ 'calendar_event_home': {
+ 'name': 'Home',
+ 'color': beestat.style.color.green.dark
+ },
+ 'calendar_event_smarthome': {
+ 'name': 'Smart Home',
+ 'color': beestat.style.color.green.dark
+ },
+ 'calendar_event_smartrecovery': {
+ 'name': 'Smart Recovery',
+ 'color': beestat.style.color.green.dark
+ },
+ 'calendar_event_away': {
+ 'name': 'Away',
+ 'color': beestat.style.color.gray.dark
+ },
+ 'calendar_event_smartaway': {
+ 'name': 'Smart Away',
+ 'color': beestat.style.color.gray.dark
+ },
+ 'calendar_event_vacation': {
+ 'name': 'Vacation',
+ 'color': beestat.style.color.gray.dark
+ },
+ 'calendar_event_sleep': {
+ 'name': 'Sleep',
+ 'color': beestat.style.color.purple.light
+ },
+ 'calendar_event_hold': {
+ 'name': 'Hold',
+ 'color': beestat.style.color.gray.base
+ },
+ 'calendar_event_quicksave': {
+ 'name': 'QuickSave',
+ 'color': beestat.style.color.gray.base
+ },
+ 'calendar_event_other': {
+ 'name': 'Other',
+ 'color': beestat.style.color.gray.base
+ }
+};
diff --git a/js/beestat/temperature.js b/js/beestat/temperature.js
new file mode 100644
index 0000000..a1a2bc3
--- /dev/null
+++ b/js/beestat/temperature.js
@@ -0,0 +1,72 @@
+/**
+ * Format a temperature in a number of different ways. Default settings will
+ * return a number converted to Celcius if necessary and rounded to one decimal
+ * place.
+ *
+ * @param {object} args Instructions on how to format:
+ * temperature (required) - Temperature to work with
+ * convert (optional, default true) - Whether or not to convert to Celcius if necessary
+ * delta (optional, default false) - Whether or not the convert action is for a delta instead of a normal value
+ * round (optional, default 1) - Number of decimal points to round to
+ * units (optional, default false) - Whether or not to include units in the result
+ * type (optional, default number) - Type of value to return (string|number)
+ *
+ * @return {string} The formatted temperature.
+ */
+// beestat.temperature = function(temperature, convert, round, include_units) {
+beestat.temperature = function(args) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ // Allow passing a single argument of temperature for convenience.
+ if (typeof args !== 'object' || args === null) {
+ args = {
+ 'temperature': args
+ };
+ }
+
+ var convert = beestat.default_value(args.convert, true);
+ var delta = beestat.default_value(args.delta, false);
+ var round = beestat.default_value(args.round, 1);
+ var units = beestat.default_value(args.units, false);
+ var type = beestat.default_value(args.type, 'number');
+
+ var temperature = parseFloat(args.temperature);
+
+ // Check for invalid values.
+ if (isNaN(temperature) === true || isFinite(temperature) === false) {
+ return null;
+ }
+
+ // Convert to Celcius if necessary and asked for.
+ if (convert === true && thermostat.temperature_unit === '°C') {
+ if (delta === true) {
+ temperature *= (5 / 9);
+ } else {
+ temperature = (temperature - 32) * (5 / 9);
+ }
+ }
+
+ /*
+ * Get to the appropriate number of decimal points. This will turn the number
+ * into a string. Then do a couple silly operations to fix -0.02 from showing
+ * up as -0.0 in string form.
+ */
+ temperature = temperature.toFixed(round);
+ temperature = parseFloat(temperature);
+ temperature = temperature.toFixed(round);
+
+ /*
+ * Convert the previous string back to a number if requested. Format matters
+ * because HighCharts doesn't accept strings in some cases.
+ */
+ if (type === 'number' && units === false) {
+ temperature = Number(temperature);
+ }
+
+ // Append units if asked for.
+ if (units === true) {
+ temperature += thermostat.temperature_unit;
+ }
+
+ return temperature;
+};
diff --git a/js/beestat/thermostat_group.js b/js/beestat/thermostat_group.js
new file mode 100644
index 0000000..c22065d
--- /dev/null
+++ b/js/beestat/thermostat_group.js
@@ -0,0 +1,236 @@
+/**
+ * Fire off an API call to get the temperature profile using the currently
+ * defined settings. Updates the cache with the response which fires off the
+ * event for anything bound to that data.
+ *
+ * @param {Function} callback Optional callback to fire when the API call
+ * completes.
+ */
+beestat.generate_temperature_profile = function(callback) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[
+ thermostat.thermostat_group_id
+ ];
+
+ var comparison_period = beestat.setting('comparison_period');
+
+ // Fire off the API call to get the temperature profile.
+ var begin;
+ var end;
+ if (comparison_period === 'custom') {
+ end = moment(beestat.setting('comparison_period_custom'));
+
+ // If you picked today just set these to null to utilize the API cache.
+ if (end.isSame(moment(), 'date') === true) {
+ begin = null;
+ end = null;
+ } else {
+ begin = moment(beestat.setting('comparison_period_custom'));
+ begin.subtract(1, 'year');
+
+ end = end.format('YYYY-MM-DD');
+ begin = begin.format('YYYY-MM-DD');
+ }
+ } else if (comparison_period !== 0) {
+ begin = moment();
+ end = moment();
+
+ begin.subtract(comparison_period, 'month');
+ begin.subtract(1, 'year');
+
+ end.subtract(comparison_period, 'month');
+
+ end = end.format('YYYY-MM-DD');
+ begin = begin.format('YYYY-MM-DD');
+ } else {
+ // Set to null for "today" so the API cache gets used.
+ begin = null;
+ end = null;
+ }
+
+ /*
+ * If begin/end were null, just use what's already stored on the
+ * thermostat_group. This will take advantage of the cached data for a week
+ * and let the cron job update it as necessary.
+ */
+ if (begin === null && end === null) {
+ beestat.cache.set(
+ 'data.comparison_temperature_profile',
+ thermostat_group.temperature_profile
+ );
+
+ if (callback !== undefined) {
+ callback();
+ }
+ } else {
+ beestat.cache.delete('data.comparison_temperature_profile');
+ new beestat.api2()
+ .add_call(
+ 'thermostat_group',
+ 'generate_temperature_profile',
+ {
+ 'thermostat_group_id': thermostat.thermostat_group_id,
+ 'begin': begin,
+ 'end': end
+ }
+ )
+ .set_callback(function(data) {
+ beestat.cache.set('data.comparison_temperature_profile', data);
+
+ if (callback !== undefined) {
+ callback();
+ }
+ })
+ .send();
+ }
+};
+
+/**
+ * Fire off an API call to get the comparison scores using the currently
+ * defined settings. Updates the cache with the response which fires off the
+ * vent for anything bound to that data.
+ *
+ * Note that this fires off a batch API call for heat, cool, and resist
+ * scores. So if you *only* had the resist card on the dashboard you would
+ * still get all three. I think the most common use case is showing all three
+ * scores, so the layer loader will be able to optimize away the duplicate
+ * requests and do one multi API call instead of three distinct API calls.
+ *
+ * @param {Function} callback Optional callback to fire when the API call
+ * completes.
+ */
+beestat.get_comparison_scores = function(callback) {
+ var types = [
+ 'heat',
+ 'cool',
+ 'resist'
+ ];
+
+ var api = new beestat.api2();
+ types.forEach(function(type) {
+ beestat.cache.delete('data.comparison_scores_' + type);
+ api.add_call(
+ 'thermostat_group',
+ 'get_scores',
+ {
+ 'type': type,
+ 'attributes': beestat.get_comparison_attributes(type)
+ },
+ type
+ );
+ });
+
+ api.set_callback(function(data) {
+ types.forEach(function(type) {
+ beestat.cache.set('data.comparison_scores_' + type, data[type]);
+ });
+
+ if (callback !== undefined) {
+ callback();
+ }
+ });
+
+ api.send();
+};
+
+/**
+ * Based on the comparison settings chosen in the GUI, get the proper broken
+ * out comparison attributes needed to make an API call.
+ *
+ * @param {string} type heat|cool|resist
+ *
+ * @return {Object} The comparison attributes.
+ */
+beestat.get_comparison_attributes = function(type) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group =
+ beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+
+ var attributes = {};
+
+ if (beestat.setting('comparison_property_type') === 'similar') {
+ // Match structure type exactly.
+ if (thermostat_group.property_structure_type !== null) {
+ attributes.property_structure_type =
+ thermostat_group.property_structure_type;
+ }
+
+ // Always a 10 year age delta on both sides.
+ if (thermostat_group.property_age !== null) {
+ var property_age_delta = 10;
+ var min_property_age = Math.max(
+ 0,
+ thermostat_group.property_age - property_age_delta
+ );
+ var max_property_age = thermostat_group.property_age + property_age_delta;
+ attributes.property_age = {
+ 'operator': 'between',
+ 'value': [
+ min_property_age,
+ max_property_age
+ ]
+ };
+ }
+
+ // Always a 1000sqft size delta on both sides (total 2000 sqft).
+ if (thermostat_group.property_square_feet !== null) {
+ var property_square_feet_delta = 1000;
+ var min_property_square_feet = Math.max(
+ 0,
+ thermostat_group.property_square_feet - property_square_feet_delta
+ );
+ var max_property_square_feet =
+ thermostat_group.property_square_feet +
+ property_square_feet_delta;
+ attributes.property_square_feet = {
+ 'operator': 'between',
+ 'value': [
+ min_property_square_feet,
+ max_property_square_feet
+ ]
+ };
+ }
+
+ /*
+ * If 0 or 1 stories, then 1 story, else just more than one story.
+ * Apartments ignore this.
+ */
+ if (
+ thermostat_group.property_stories !== null &&
+ thermostat_group.property_structure_type !== 'apartment'
+ ) {
+ if (thermostat_group.property_stories < 2) {
+ attributes.property_stories = thermostat_group.property_stories;
+ } else {
+ attributes.property_stories = {
+ 'operator': '>=',
+ 'value': thermostat_group.property_stories
+ };
+ }
+ }
+ } else if (beestat.setting('comparison_property_type') === 'same_structure') {
+ // Match structure type exactly.
+ if (thermostat_group.property_structure_type !== null) {
+ attributes.property_structure_type =
+ thermostat_group.property_structure_type;
+ }
+ }
+
+ if (
+ thermostat_group.address_latitude !== null &&
+ thermostat_group.address_longitude !== null &&
+ beestat.setting('comparison_region') !== 'global'
+ ) {
+ attributes.address_latitude = thermostat_group.address_latitude;
+ attributes.address_longitude = thermostat_group.address_longitude;
+ attributes.address_radius = 250;
+ }
+
+ if (type === 'heat') {
+ attributes.system_type_heat = thermostat_group.system_type_heat;
+ } else if (type === 'cool') {
+ attributes.system_type_cool = thermostat_group.system_type_cool;
+ }
+
+ return attributes;
+};
diff --git a/js/beestat/time.js b/js/beestat/time.js
new file mode 100644
index 0000000..1515a51
--- /dev/null
+++ b/js/beestat/time.js
@@ -0,0 +1,31 @@
+/**
+ * Get a nice resresentation of a time duration.
+ *
+ * @param {number} seconds
+ * @param {string} opt_unit Any unit that moment supports when creating
+ * durations. If left out defaults to seconds.
+ *
+ * @return {string} A humanized duration string.
+ */
+beestat.time = function(seconds, opt_unit) {
+ var duration = moment.duration(seconds, opt_unit || 'seconds');
+
+ /*
+ * // Used to work this way; switched this to return more consistent results.
+ *
+ * var days = duration.get('days');
+ * var hours = duration.get('hours');
+ * var minutes = duration.get('minutes');
+ *
+ * if (days >= 1) {
+ * return days + 'd ' + hours + 'h'
+ * } else {
+ * return hours + 'h ' + minutes + 'm'
+ * }
+ */
+
+ var hours = Math.floor(duration.asHours());
+ var minutes = duration.get('minutes');
+
+ return hours + 'h ' + minutes + 'm';
+};
diff --git a/js/component.js b/js/component.js
new file mode 100644
index 0000000..0c3c7a1
--- /dev/null
+++ b/js/component.js
@@ -0,0 +1,92 @@
+beestat.component = function() {
+ var self = this;
+
+ this.rendered_ = false;
+
+ // Give every component a state object to use for storing data.
+ this.state_ = {};
+
+ // this.render_count_ = 0;
+
+ this.layer_ = beestat.current_layer;
+
+ if (this.rerender_on_breakpoint_ === true) {
+ beestat.dispatcher.addEventListener('breakpoint', function() {
+ self.rerender();
+ });
+ }
+};
+beestat.extend(beestat.component, rocket.EventTarget);
+
+/**
+ * First put everything in a container, then append the new container. This
+ * prevents the child from having to worry about multiple redraws since they
+ * aren't doing anything directly on the body.
+ *
+ * @param {rocket.Elements} parent
+ *
+ * @return {beestat.component} This
+ */
+beestat.component.prototype.render = function(parent) {
+ var self = this;
+
+ if (parent !== undefined) {
+ this.component_container_ = $.createElement('div')
+ .style('position', 'relative');
+ this.decorate_(this.component_container_);
+ parent.appendChild(this.component_container_);
+ } else {
+ this.decorate_();
+ }
+
+ // The element should now exist on the DOM.
+ setTimeout(function() {
+ self.dispatchEvent('render');
+ }, 0);
+
+ // The render function was called.
+ this.rendered_ = true;
+ // this.render_count_++;
+
+ return this;
+};
+
+/**
+ * First put everything in a container, then append the new container. This
+ * prevents the child from having to worry about multiple redraws since they
+ * aren't doing anything directly on the body.
+ *
+ * @return {beestat.component} This
+ */
+beestat.component.prototype.rerender = function() {
+ if (this.rendered_ === true) {
+ var new_container = $.createElement('div')
+ .style('position', 'relative');
+ this.decorate_(new_container);
+ this.component_container_
+ .parentNode().replaceChild(new_container, this.component_container_);
+ this.component_container_ = new_container;
+
+ var self = this;
+ setTimeout(function() {
+ self.dispatchEvent('render');
+ }, 0);
+
+ // this.render_count_++;
+
+ return this;
+ }
+};
+
+/**
+ * Remove this component from the page.
+ */
+beestat.component.prototype.dispose = function() {
+ var child = this.component_container_.parentNode();
+ var parent = child.parentNode();
+ parent.removeChild(child);
+};
+
+beestat.component.prototype.decorate_ = function() {
+ // Left for the subclass to implement.
+};
diff --git a/js/component/alert.js b/js/component/alert.js
new file mode 100644
index 0000000..e1cf6a5
--- /dev/null
+++ b/js/component/alert.js
@@ -0,0 +1,446 @@
+/**
+ * Single alert.
+ */
+beestat.component.alert = function(alert) {
+ this.alert_ = alert;
+ beestat.component.apply(this, arguments);
+};
+beestat.extend(beestat.component.alert, beestat.component);
+
+beestat.component.alert.prototype.rerender_on_breakpoint_ = false;
+
+beestat.component.alert.prototype.decorate_ = function(parent) {
+ this.decorate_main_(parent);
+ this.decorate_detail_(parent);
+};
+
+/**
+ * Decorate the main alert icon and text.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.alert.prototype.decorate_main_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ this.alert_main_ = $.createElement('div')
+ .style({
+ 'display': 'flex',
+ 'cursor': 'pointer',
+ 'transition': 'all 200ms ease',
+ 'margin': '0 -' + beestat.style.size.gutter + 'px',
+ 'padding-left': beestat.style.size.gutter,
+ 'padding-right': beestat.style.size.gutter,
+ 'overflow': 'hidden'
+ });
+ parent.appendChild(this.alert_main_);
+
+ if (this.should_show() === true) {
+ this.show();
+ } else {
+ /*
+ * This causes flicker but ensures that everything actually gets a height.
+ * Not perfect but trying it for now.
+ */
+ this.hide();
+ }
+
+ this.add_event_listeners_();
+
+ var icon_name;
+ switch (this.alert_.code) {
+ case 100000:
+ case 100001:
+ icon_name = 'tune';
+ break;
+ case 611: // Invalid registration password
+ icon_name = 'key';
+ break;
+ case 1000: // Cold temp alert
+ case 1006: // Problem with cooling
+ case 1013: // Sensor activated disabling AC
+ icon_name = 'snowflake';
+ break;
+ case 1001: // Hot temp alert
+ case 1003: // Problem with furnace/boiler heating
+ case 1004: // Problem with heatpump heating
+ case 1005: // Problem with heatpump heating
+ case 1009: // Problem with aux heat, running too much
+ case 1010: // Aux heat used with high outdoor temp
+ case 1018: // Sensor activated shutting down aux heat
+ case 1022: // Sensor activated shutting down heat
+ icon_name = 'fire';
+ break;
+ case 1007: // Communication to EI failed
+ case 1028: // Remote sensor not communicating
+ icon_name = 'wifi_strength_1_alert';
+ break;
+ case 6301: // Network join failed
+ icon_name = 'close_network';
+ break;
+ case 1002: // Sensor activated shutting down compressor
+ case 1011: // Sensor activated switching to occupied
+ icon_name = 'eye';
+ break;
+ case 1012: // Sensor activated switching to unoccupied
+ icon_name = 'eye_off';
+ break;
+ case 1017: // Sensor activated turning on fan
+ icon_name = 'fan';
+ break;
+ case 1020: // Low humidity alert
+ case 1021: // High humidity alert
+ case 1024: // Sensor activated humidifier
+ case 1032: // Faulty humidity sensor
+ case 1033: // Faulty humidity sensor
+ case 1025: // Sensor activated dehumidifier
+ icon_name = 'water_percent';
+ break;
+ case 1026: // Low battery
+ icon_name = 'battery_10';
+ break;
+ case 1029: // Remote sensor re-established
+ case 6300: // Network join successful
+ icon_name = 'wifi_strength_4';
+ break;
+ case 3130: // Furnace maintenance reminder
+ case 3131: // Humidifier maintenance reminder
+ case 3132: // Ventilator maintenance reminder
+ case 3133: // Dehumidifier maintenance reminder
+ case 3134: // Economizer maintenance reminder
+ case 3135: // UV maintenance reminder
+ case 3136: // AC maintenance reminder
+ case 3137: // Air filter reminder (ClimateMaster only)
+ case 3138: // Air cleaner reminder (ClimateMaster only)
+ case 3140: // Hvac maintenance reminder
+ icon_name = 'calendar_alert';
+ break;
+ case 6200: // Monthly cost exceeded
+ case 6201: // Monthly projected cost exceeded
+ icon_name = 'cash';
+ break;
+ case 1034: // Incorrect Zigbee module installed
+ icon_name = 'zigbee';
+ break;
+ case 7002: // Web initiated messages - such as Utility welcome message or similar
+ icon_name = 'message';
+ break;
+ default:
+
+ /*
+ * 1014 Sensor activated setting temp up/down
+ * 1015 Sensor activated
+ * 1016 Sensor activated opening/closing relay
+ * 1019 Sensor activated shutting down heating/cooling
+ * 1027 Remote sensor detected
+ * 1030 Invalid current temp reading
+ * 1031 Current temp reading restored
+ * 4000 ClimateTalk
+ * 6000 DR voluntary alert
+ * 6001 DR voluntary utility message
+ * 6100 DR mandatory alert
+ * 6101 DR mandatory message
+ * 6102 DR mandatory alert
+ * 7000 Registration confirmation
+ * 7001 Registration Remind me alert
+ * 9000 ClimateMaster fault
+ * 9255 ClimateMaster fault max
+ * 9500 ClimateMaster disconnected
+ * 9755 ClimateMaster disconnected max
+ * 8300 - 8599 ClimateMaster Heatpump/hardware Unit Alerts
+ * 4100 - 4199 ClimateTalk device alert major/minor fault codes
+ * 4200 - 4299 ClimateTalk device lost communications
+ * 4300 - 4399 ClimateTalk system message from device
+ * 6002 - 6005 DR voluntary alerts
+ * 8000 - 8299 Daikin Indoor/Outdoor Unit Alerts
+ */
+ icon_name = 'bell';
+ break;
+ }
+
+ (new beestat.component.icon(icon_name)).render(this.alert_main_);
+
+ /*
+ * Since all other temperature conversions are done client-side, do this one
+ * here too...
+ */
+ if (thermostat.temperature_unit === '°C') {
+ if (this.alert_.code === 100000 || this.alert_.code === 100001) {
+ this.alert_.text = this.alert_.text.replace('0.5°F', '0.3°C');
+ this.alert_.text = this.alert_.text.replace('1.0°F', '0.8°C');
+ }
+ }
+
+ var text = $.createElement('div')
+ .style({
+ 'padding-left': (beestat.style.size.gutter / 2)
+ })
+ .innerHTML(this.alert_.text);
+ this.alert_main_.appendChild(text);
+};
+
+/**
+ * Decorate the detail that appears when you click on an alert.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.alert.prototype.decorate_detail_ = function(parent) {
+ var self = this;
+
+ // Detail
+ this.alert_detail_ = $.createElement('div')
+ .style({
+ 'max-height': '0',
+ 'overflow': 'hidden'
+ });
+ parent.appendChild(this.alert_detail_);
+
+ var row;
+
+ row = $.createElement('div').style({
+ 'display': 'flex',
+ 'margin-top': (beestat.style.size.gutter / 2),
+ 'margin-bottom': (beestat.style.size.gutter / 2)
+ });
+ this.alert_detail_.appendChild(row);
+
+ var source = $.createElement('div').style('width', '50%');
+ row.appendChild(source);
+ source.appendChild($.createElement('div')
+ .innerHTML('Source')
+ .style('font-weight', beestat.style.font_weight.bold));
+ source.appendChild($.createElement('div').innerHTML(this.alert_.source));
+
+ var date = $.createElement('div').style('width', '50%');
+ row.appendChild(date);
+ date.appendChild($.createElement('div')
+ .innerHTML('Date')
+ .style('font-weight', beestat.style.font_weight.bold));
+ var timestamp = moment(this.alert_.timestamp);
+ date.appendChild($.createElement('div').innerHTML(timestamp.format('MMM M')));
+ // date.appendChild($.createElement('div').innerHTML(timestamp.format('MMM M @ h:mm a')));
+
+ var details = $.createElement('div');
+ this.alert_detail_.appendChild(details);
+ details.appendChild($.createElement('div')
+ .innerHTML('Details')
+ .style('font-weight', beestat.style.font_weight.bold));
+ details.appendChild(
+ $.createElement('div')
+ .style('margin-bottom', beestat.style.size.gutter)
+ .innerText(this.alert_.details)
+ );
+
+ // Actions
+ var button_container = $.createElement('div')
+ .style({
+ 'text-align': 'center'
+ });
+ details.appendChild(button_container);
+
+ // Dismiss
+ var dismiss_container = $.createElement('div')
+ .style({
+ 'display': 'inline-block'
+ });
+ button_container.appendChild(dismiss_container);
+
+ (new beestat.component.button())
+ .set_icon('bell_off')
+ .set_text('Dismiss')
+ .set_background_color(beestat.style.color.red.dark)
+ .set_background_hover_color(beestat.style.color.red.light)
+ .render(dismiss_container)
+ .addEventListener('click', function() {
+ beestat.api(
+ 'thermostat',
+ 'dismiss_alert',
+ {
+ 'thermostat_id': beestat.setting('thermostat_id'),
+ 'guid': self.alert_.guid
+ }
+ );
+
+ beestat.cache.thermostat[beestat.setting('thermostat_id')].json_alerts.forEach(function(alert) {
+ if (alert.guid === self.alert_.guid) {
+ alert.dismissed = true;
+ }
+ });
+
+ restore_container.style('display', 'inline-block');
+ dismiss_container.style('display', 'none');
+
+ self.dispatchEvent('dismiss');
+ });
+
+ // Restore
+ var restore_container = $.createElement('div')
+ .style({
+ 'display': 'inline-block'
+ });
+ button_container.appendChild(restore_container);
+
+ (new beestat.component.button())
+ .set_icon('bell')
+ .set_text('Restore')
+ .set_background_color(beestat.style.color.red.dark)
+ .set_background_hover_color(beestat.style.color.red.light)
+ .render(restore_container)
+ .addEventListener('click', function() {
+ beestat.api(
+ 'thermostat',
+ 'restore_alert',
+ {
+ 'thermostat_id': beestat.setting('thermostat_id'),
+ 'guid': self.alert_.guid
+ }
+ );
+
+ beestat.cache.thermostat[beestat.setting('thermostat_id')].json_alerts.forEach(function(alert) {
+ if (alert.guid === self.alert_.guid) {
+ alert.dismissed = false;
+ }
+ });
+
+ restore_container.style('display', 'none');
+ dismiss_container.style('display', 'inline-block');
+
+ self.dispatchEvent('restore');
+ });
+
+ if (this.alert_.dismissed === true) {
+ dismiss_container.style('display', 'none');
+ } else {
+ restore_container.style('display', 'none');
+ }
+};
+
+/**
+ * Add the appropriate event listeners.
+ */
+beestat.component.alert.prototype.add_event_listeners_ = function() {
+ var self = this;
+
+ this.alert_main_.addEventListener('mouseover', function() {
+ self.alert_main_.style({
+ 'background': beestat.style.color.red.dark
+ });
+ });
+
+ this.alert_main_.addEventListener('mouseout', function() {
+ self.alert_main_.style({
+ 'background': ''
+ });
+ });
+
+ this.alert_main_.addEventListener('click', function() {
+ self.dispatchEvent('click');
+ });
+};
+
+/**
+ * Show the alert. After the transition runs to restore the original height,
+ * the height gets set to auto to fix any problems. For example, if you
+ * collapse all the elements, then shrink your browser, then go back,
+ * everything will get restored to the original (now wrong) heights.
+ */
+beestat.component.alert.prototype.show = function() {
+ var self = this;
+
+ if (
+ this.should_show() === true
+ ) {
+ this.alert_main_.style({
+ 'height': this.height_,
+ 'padding-top': (beestat.style.size.gutter / 2),
+ 'padding-bottom': (beestat.style.size.gutter / 2)
+ });
+
+ setTimeout(function() {
+ self.alert_main_.style('height', 'auto');
+ }, 200);
+ }
+};
+
+/**
+ * Whether or not this alert is marked as dismissed.
+ *
+ * @return {boolean}
+ */
+/*
+ * beestat.component.alert.prototype.is_dismissed = function() {
+ * return this.alert_.dismissed;
+ * };
+ */
+
+/**
+ * Whether or not the alert should be shown based on it's properties and the
+ * current settings.
+ *
+ * @return {boolean}
+ */
+beestat.component.alert.prototype.should_show = function() {
+ return (
+ this.alert_.dismissed === false ||
+ beestat.setting('show_dismissed_alerts') === true
+ );
+};
+
+/**
+ * Hide the alert. This gets the element's current height, sets that height,
+ * then changes the height to 0. This is because you can't run a transition on
+ * a height of auto and I don't have a fixed height for this element.
+ */
+beestat.component.alert.prototype.hide = function() {
+ var self = this;
+
+ this.height_ = this.alert_main_.getBoundingClientRect().height;
+ this.alert_main_.style('height', this.height_);
+
+ setTimeout(function() {
+ self.alert_main_.style({
+ 'height': '0',
+ 'padding-top': '0',
+ 'padding-bottom': '0'
+ });
+ }, 0);
+};
+
+/**
+ * Expand the alert; removes event listeners basically so it's static. Also
+ * changes the transition speed on alert detail. When pinning something want
+ * the expand to go slower to help prevent a jumpy resize of the parent.
+ */
+beestat.component.alert.prototype.expand = function() {
+ this.alert_main_
+ .style({
+ 'background': beestat.style.color.red.dark,
+ 'cursor': 'default'
+ })
+ .removeEventListener('mouseover')
+ .removeEventListener('mouseout')
+ .removeEventListener('click');
+
+ this.alert_detail_.style({
+ 'transition': 'all 400ms ease',
+ 'max-height': '250px'
+ });
+};
+
+/**
+ * Collapse the alert; re-adds the event listeners. Also changes the
+ * transition speed on alert detail. When unpinning something want the
+ * collapse to go faster to help prevent a jumpy resize of the parent.
+ */
+beestat.component.alert.prototype.collapse = function() {
+ this.alert_main_
+ .style({
+ 'background': '',
+ 'cursor': 'pointer'
+ });
+ this.add_event_listeners_();
+
+ this.alert_detail_.style({
+ 'transition': 'all 100ms ease',
+ 'max-height': '0'
+ });
+};
diff --git a/js/component/button.js b/js/component/button.js
new file mode 100644
index 0000000..eb1adcb
--- /dev/null
+++ b/js/component/button.js
@@ -0,0 +1,257 @@
+/**
+ * A button-shaped component with text, an icon, and a background color.
+ */
+beestat.component.button = function() {
+ beestat.component.apply(this, arguments);
+};
+beestat.extend(beestat.component.button, beestat.component);
+
+beestat.component.button.prototype.rerender_on_breakpoint_ = false;
+
+/**
+ * Decorate
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.button.prototype.decorate_ = function(parent) {
+ var self = this;
+
+ var border_radius;
+ if (this.type_ === 'pill') {
+ border_radius = '32px';
+ } else {
+ border_radius = beestat.style.size.border_radius;
+ }
+
+ this.button_ = $.createElement('div')
+ .style({
+ 'display': 'inline-block',
+ 'background': this.background_color_,
+ 'color': this.text_color_,
+ 'user-select': 'none',
+ 'border-radius': border_radius,
+ 'padding-top': '3px',
+ 'padding-bottom': '3px',
+ 'transition': 'color 200ms ease, background 200ms ease'
+ });
+ parent.appendChild(this.button_);
+
+ if (this.icon_ !== undefined && this.text_ !== undefined) {
+ // Text + Icon
+ this.button_.style({
+ 'padding-left': (beestat.style.size.gutter / 2),
+ 'padding-right': (beestat.style.size.gutter / 2)
+ });
+
+ (new beestat.component.icon(this.icon_))
+ .set_text(this.text_)
+ .set_bubble_text(this.bubble_text_)
+ .set_bubble_color(this.bubble_color_)
+ .render(this.button_);
+ } else if (this.icon_ === undefined && this.text_ !== undefined) {
+ // Text only
+ this.button_.style({
+ 'padding': '0px ' + (beestat.style.size.gutter / 2) + 'px',
+ 'line-height': '32px'
+ });
+ this.button_.innerText(this.text_);
+ } else {
+ // Icon only
+ this.button_.style({
+ 'width': '32px',
+ 'text-align': 'center'
+ });
+
+ (new beestat.component.icon(this.icon_))
+ .set_text(this.text_)
+ .set_bubble_text(this.bubble_text_)
+ .set_bubble_color(this.bubble_color_)
+ .render(this.button_);
+ }
+
+ if (
+ this.text_hover_color_ !== undefined ||
+ this.background_hover_color_ !== undefined
+ ) {
+ this.button_.style({'cursor': 'pointer'});
+
+ var mouseenter_style = {};
+ if (this.text_hover_color_ !== undefined) {
+ mouseenter_style.color = this.text_hover_color_;
+ }
+ if (this.background_hover_color_ !== undefined) {
+ mouseenter_style.background = this.background_hover_color_;
+ }
+
+ var mouseleave_style = {};
+ mouseleave_style.color =
+ (this.text_color_ !== undefined) ? this.text_color_ : '';
+ mouseleave_style.background =
+ (this.background_color_ !== undefined) ? this.background_color_ : '';
+
+ this.button_.addEventListener('mouseenter', function() {
+ self.button_.style(mouseenter_style);
+ });
+ this.button_.addEventListener('mouseleave', function() {
+ self.button_.style(mouseleave_style);
+ });
+ }
+
+ this.button_.addEventListener('click', function() {
+ self.dispatchEvent('click');
+ });
+};
+
+/**
+ * Set the text.
+ *
+ * @param {string} text
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_text = function(text) {
+ this.text_ = text;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the icon.
+ *
+ * @param {string} icon
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_icon = function(icon) {
+ this.icon_ = icon;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the background color.
+ *
+ * @param {string} background_color
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_background_color = function(background_color) {
+ this.background_color_ = background_color;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the text color.
+ *
+ * @param {string} text_color
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_text_color = function(text_color) {
+ this.text_color_ = text_color;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the background hover color.
+ *
+ * @param {string} background_hover_color
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_background_hover_color = function(background_hover_color) {
+ this.background_hover_color_ = background_hover_color;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the text hover color.
+ *
+ * @param {string} text_hover_color
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_text_hover_color = function(text_hover_color) {
+ this.text_hover_color_ = text_hover_color;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the type.
+ *
+ * @param {string} type Valid value is "pill" for now.
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_type = function(type) {
+ this.type_ = type;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the text of the bubble.
+ *
+ * @param {string} bubble_text
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_bubble_text = function(bubble_text) {
+ this.bubble_text_ = bubble_text;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Set the color of the bubble.
+ *
+ * @param {string} bubble_color
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.set_bubble_color = function(bubble_color) {
+ this.bubble_color_ = bubble_color;
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+ return this;
+};
+
+/**
+ * Do the normal event listener stuff.
+ *
+ * @return {beestat.component.button} This.
+ */
+beestat.component.button.prototype.addEventListener = function() {
+ rocket.EventTarget.prototype.addEventListener.apply(this, arguments);
+ return this;
+};
+
+/**
+ * Get the bounding box for the button.
+ *
+ * @return {array} The bounding box.
+ */
+beestat.component.button.prototype.getBoundingClientRect = function() {
+ return this.button_.getBoundingClientRect();
+};
diff --git a/js/component/button_group.js b/js/component/button_group.js
new file mode 100644
index 0000000..1724131
--- /dev/null
+++ b/js/component/button_group.js
@@ -0,0 +1,57 @@
+/**
+ * A button-shaped component with text, an icon, and a background color.
+ */
+beestat.component.button_group = function() {
+ this.buttons_ = [];
+ beestat.component.apply(this, arguments);
+};
+beestat.extend(beestat.component.button_group, beestat.component);
+
+beestat.component.button_group.prototype.rerender_on_breakpoint_ = false;
+
+/**
+ * Decorate
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.button_group.prototype.decorate_ = function(parent) {
+ var self = this;
+
+ // Only exists so that there can be spacing between wrapped elements.
+ var outer_container = $.createElement('div')
+ .style({
+ 'margin-top': (beestat.style.size.gutter / -2)
+ });
+ parent.appendChild(outer_container);
+
+ this.buttons_.forEach(function(button, i) {
+ var container = $.createElement('div').style({
+ 'display': 'inline-block',
+ 'margin-right': (i < self.buttons_.length) ? (beestat.style.size.gutter / 2) : 0,
+ 'margin-top': (beestat.style.size.gutter / 2)
+ });
+ button.render(container);
+ outer_container.appendChild(container);
+ });
+};
+
+/**
+ * Add a button to this group.
+ *
+ * @param {beestat.component.button} button The button to add.
+ */
+beestat.component.button_group.prototype.add_button = function(button) {
+ this.buttons_.push(button);
+ if (this.rendered_ === true) {
+ this.rerender();
+ }
+};
+
+/**
+ * Get all of the buttons in this button group.
+ *
+ * @return {[beestat.component.button]} The buttons in this group.
+ */
+beestat.component.button_group.prototype.get_buttons = function() {
+ return this.buttons_;
+};
diff --git a/js/component/card.js b/js/component/card.js
new file mode 100644
index 0000000..dd9bbb1
--- /dev/null
+++ b/js/component/card.js
@@ -0,0 +1,236 @@
+/**
+ * Card
+ */
+beestat.component.card = function() {
+
+ /**
+ * For now just load up all the cards this way. In the future will probably
+ * need to allow arrays of certain cards for custom dashboards.
+ */
+ // beestat.cards[
+ // this.get_class_name_recursive_(beestat.component.card).join('.')
+ // ] = this;
+
+ beestat.component.apply(this, arguments);
+};
+beestat.extend(beestat.component.card, beestat.component);
+
+beestat.component.card.prototype.rerender_on_breakpoint_ = false;
+
+/**
+ * [get_class_name_recursive_ description]
+ *
+ * @param {[type]} parent [description]
+ * @param {[type]} opt_prefix [description]
+ *
+ * @return {[type]} [description]
+ */
+beestat.component.card.prototype.get_class_name_recursive_ = function(parent, opt_prefix) {
+ for (var i in parent) {
+ if (
+ (parent[i]) &&
+ (parent[i].prototype) &&
+ (this instanceof parent[i])
+ ) {
+ var name = opt_prefix ? rocket.clone(opt_prefix) : [];
+ name.push(i);
+ if (parent[i] === this.constructor) {
+ return name;
+ }
+ name = this.get_class_name_recursive_(parent[i], name);
+ if (name) {
+ return name;
+ }
+ }
+ }
+};
+
+beestat.component.card.prototype.decorate_ = function(parent) {
+ this.hide_loading_();
+
+ this.parent_ = parent;
+
+ /*
+ * Unfortunate but necessary to get the card to fill the height of the flex
+ * container. Everything leading up to the card has to be 100% height.
+ */
+ parent.style('height', '100%');
+
+ this.contents_ = $.createElement('div')
+ .style({
+ 'padding': (beestat.style.size.gutter),
+ 'height': '100%',
+ 'background': beestat.style.color.bluegray.base
+ });
+ parent.appendChild(this.contents_);
+
+ this.decorate_back_(this.contents_);
+
+ var top_right = $.createElement('div').style('float', 'right');
+ this.contents_.appendChild(top_right);
+ this.decorate_top_right_(top_right);
+
+ this.decorate_title_(this.contents_);
+ this.decorate_subtitle_(this.contents_);
+ this.decorate_contents_(this.contents_);
+};
+
+/**
+ * Decorate the title of the card.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.prototype.decorate_title_ = function(parent) {
+ var title = this.get_title_();
+ var margin_bottom = (this.get_subtitle_() !== null)
+ ? (beestat.style.size.gutter / 4)
+ : (beestat.style.size.gutter);
+ if (title !== null) {
+ parent.appendChild($.createElement('div')
+ .innerHTML(title)
+ .style({
+ 'font-weight': beestat.style.font_weight.bold,
+ 'font-size': beestat.style.font_size.large,
+ 'margin-bottom': margin_bottom,
+ 'line-height': '24px',
+ 'white-space': 'nowrap',
+ 'overflow': 'hidden',
+ 'text-overflow': 'ellipsis'
+ }));
+ }
+};
+
+/**
+ * Decorate the subtitle of the card.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.prototype.decorate_subtitle_ = function(parent) {
+ var subtitle = this.get_subtitle_();
+ if (subtitle !== null) {
+ parent.appendChild($.createElement('div')
+ .innerHTML(subtitle)
+ .style({
+ 'font-weight': beestat.style.font_weight.light,
+ 'margin-bottom': (beestat.style.size.gutter / 4)
+ }));
+ }
+};
+
+/**
+ * Decorate the contents of the card.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.prototype.decorate_contents_ = function(parent) {};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.prototype.decorate_top_right_ = function(parent) {};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.prototype.get_title_ = function() {
+ return null;
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.prototype.get_subtitle_ = function() {
+ return null;
+};
+
+/**
+ * Go back. Does the internal stuff then dispatches a back event that can be
+ * listened for.
+ */
+beestat.component.card.prototype.back_ = function() {
+ this.hide_back_();
+ this.dispatchEvent('back');
+};
+
+/**
+ * Decorate the back button.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.prototype.decorate_back_ = function(parent) {
+ var self = this;
+
+ var back_button = $.createElement('div')
+ .style({
+ 'float': 'left',
+ 'transition': 'width 200ms ease',
+ 'width': '0',
+ 'overflow': 'hidden',
+ 'margin-top': '-2px'
+ });
+ parent.appendChild(back_button);
+
+ var icon = (new beestat.component.icon('arrow_left'))
+ .set_hover_color('#fff')
+ .addEventListener('click', function() {
+ self.back_();
+ });
+ icon.render(back_button);
+
+ this.back_button_ = back_button;
+};
+
+/**
+ * Show the back button.
+ */
+beestat.component.card.prototype.show_back_ = function() {
+ this.back_button_.style({'width': (24 + (beestat.style.size.gutter / 2))});
+};
+
+/**
+ * Hide the back button.
+ */
+beestat.component.card.prototype.hide_back_ = function() {
+ this.back_button_.style({'width': '0'});
+};
+
+beestat.component.card.prototype.show_loading_ = function(text) {
+ if (this.loading_mask_ === undefined) {
+ this.contents_.style('filter', 'blur(3px)');
+
+ this.loading_mask_ = $.createElement('div');
+ this.loading_mask_.style({
+ 'position': 'absolute',
+ 'top': 0,
+ 'left': 0,
+ 'width': '100%',
+ 'height': '100%',
+ 'background': 'rgba(0, 0, 0, 0.4)',
+ 'display': 'flex',
+ 'flex-direction': 'column',
+ 'justify-content': 'center',
+ 'text-align': 'center'
+ });
+ this.parent_.appendChild(this.loading_mask_);
+
+ this.loading_component_ = new beestat.component.loading(text);
+ this.loading_component_.render(this.loading_mask_);
+ } else {
+ this.loading_component_.set_text(text);
+ }
+};
+
+beestat.component.card.prototype.hide_loading_ = function() {
+ if (this.loading_mask_ !== undefined) {
+ this.parent_.removeChild(this.loading_mask_);
+ this.contents_.style('filter', '');
+ delete this.loading_mask_;
+ delete this.loading_component_;
+ }
+};
diff --git a/js/component/card/aggregate_runtime.js b/js/component/card/aggregate_runtime.js
new file mode 100644
index 0000000..c03a0e0
--- /dev/null
+++ b/js/component/card/aggregate_runtime.js
@@ -0,0 +1,710 @@
+/**
+ * Recent activity card. Shows a graph similar to what ecobee shows with the
+ * runtime info for a recent period of time.
+ */
+beestat.component.card.aggregate_runtime = function() {
+ var self = this;
+
+ /*
+ * Debounce so that multiple setting changes don't re-trigger the same
+ * event. This fires on the trailing edge so that all changes are accounted
+ * for when rerendering.
+ */
+ var setting_change_function = beestat.debounce(function() {
+ beestat.cache.set('aggregate_runtime', []);
+ self.rerender();
+ }, 10);
+
+ beestat.dispatcher.addEventListener(
+ [
+ 'setting.aggregate_runtime_time_count',
+ 'setting.aggregate_runtime_time_period',
+ 'setting.aggregate_runtime_group_by',
+ 'setting.aggregate_runtime_gap_fill'
+ ],
+ setting_change_function
+ );
+
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.aggregate_runtime, beestat.component.card);
+
+beestat.component.card.aggregate_runtime.equipment_series = [
+ 'compressor_cool_1',
+ 'compressor_cool_2',
+ 'compressor_heat_1',
+ 'compressor_heat_2',
+ 'auxiliary_heat_1',
+ 'auxiliary_heat_2',
+ 'auxiliary_heat_3'
+];
+
+beestat.component.card.aggregate_runtime.prototype.decorate_contents_ = function(parent) {
+ var self = this;
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ this.chart_ = new beestat.component.chart();
+ var series = this.get_series_();
+
+ this.chart_.options.chart.backgroundColor = beestat.style.color.bluegray.base;
+ this.chart_.options.exporting.filename = thermostat.name + ' - Aggregate Runtime';
+ this.chart_.options.exporting.chartOptions.title.text = this.get_title_();
+ this.chart_.options.exporting.chartOptions.subtitle.text = this.get_subtitle_();
+
+ var current_day;
+ var current_hour;
+ var current_week;
+ var current_month;
+ var current_year;
+
+ this.chart_.options.xAxis = {
+ 'categories': series.x.chart_data,
+ 'lineColor': beestat.style.color.bluegray.light,
+ 'tickLength': 0,
+ 'labels': {
+ 'style': {
+ 'color': beestat.style.color.gray.base
+ },
+ 'formatter': function() {
+ var date_parts = this.value.match(/(?:h(\d+))?(?:d(\d+))?(?:w(\d+))?(?:m(\d+))?(?:y(\d+))?/);
+ var hour = moment(date_parts[1], 'H').format('ha');
+ var day = date_parts[2];
+ var week = date_parts[3];
+ var month = moment(date_parts[4], 'M').format('MMM');
+ var year = date_parts[5];
+
+ var label_parts = [];
+ switch (beestat.setting('aggregate_runtime_group_by')) {
+ case 'year':
+ label_parts.push(year);
+ break;
+ case 'month':
+ label_parts.push(month);
+ if (year !== current_year) {
+ label_parts.push(year);
+ }
+ break;
+ case 'week':
+ if (month !== current_month) {
+ label_parts.push(month);
+ }
+ if (year !== current_year) {
+ label_parts.push(year);
+ }
+ break;
+ case 'day':
+ if (month !== current_month) {
+ label_parts.push(month);
+ }
+ label_parts.push(day);
+ if (year !== current_year) {
+ label_parts.push(year);
+ }
+ break;
+ case 'hour':
+ if (month !== current_month) {
+ label_parts.push(month);
+ }
+ if (day !== current_day) {
+ label_parts.push(day);
+ }
+ if (year !== current_year) {
+ label_parts.push(year);
+ }
+ label_parts.push(hour);
+ break;
+ }
+
+ current_hour = hour;
+ current_day = day;
+ current_week = week;
+ current_month = month;
+ current_year = year;
+
+ return label_parts.join(' ');
+ }
+ }
+ };
+
+ var y_max_hours;
+ var tick_interval;
+ switch (beestat.setting('aggregate_runtime_group_by')) {
+ case 'month':
+ y_max_hours = 672;
+ tick_interval = 168;
+ break;
+ case 'week':
+ y_max_hours = 168;
+ tick_interval = 24;
+ break;
+ case 'day':
+ y_max_hours = 24;
+ tick_interval = 6;
+ break;
+ }
+
+ this.chart_.options.yAxis = [
+ {
+ 'alignTicks': false,
+ 'min': 0,
+ 'softMax': y_max_hours,
+ 'tickInterval': tick_interval,
+ 'reversedStacks': false,
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'title': {
+ 'text': ''
+ },
+ 'labels': {
+ 'style': {
+ 'color': beestat.style.color.gray.base
+ },
+ 'formatter': function() {
+ return this.value + 'h';
+ }
+ }
+ },
+ {
+ 'alignTicks': false,
+ 'gridLineColor': null,
+ 'gridLineDashStyle': 'longdash',
+ 'opposite': true,
+ 'allowDecimals': false,
+ 'title': {
+ 'text': ''
+ },
+ 'labels': {
+ 'style': {
+ 'color': beestat.style.color.gray.base
+ },
+ 'formatter': function() {
+ return this.value + thermostat.temperature_unit;
+ }
+ }
+ }
+ ];
+
+ this.chart_.options.tooltip = {
+ 'shared': true,
+ 'useHTML': true,
+ 'borderWidth': 0,
+ 'shadow': false,
+ 'backgroundColor': null,
+ 'followPointer': true,
+ 'crosshairs': {
+ 'width': 1,
+ 'zIndex': 100,
+ 'color': beestat.style.color.gray.light,
+ 'dashStyle': 'shortDot',
+ 'snap': false
+ },
+ 'positioner': function(tooltip_width, tooltip_height, point) {
+ return beestat.component.chart.tooltip_positioner(
+ self.chart_.get_chart(),
+ tooltip_width,
+ tooltip_height,
+ point
+ );
+ },
+ 'formatter': function() {
+ var date_parts = this.x.match(/(?:h(\d+))?(?:d(\d+))?(?:w(\d+))?(?:m(\d+))?(?:y(\d+))?/);
+ var hour = moment(date_parts[1], 'H').format('ha');
+ var day = date_parts[2];
+ var week = date_parts[3];
+ var month = moment(date_parts[4], 'M').format('MMM');
+ var year = date_parts[5];
+
+ var label_parts = [];
+ switch (beestat.setting('aggregate_runtime_group_by')) {
+ case 'year':
+ label_parts.push(year);
+ break;
+ case 'month':
+ label_parts.push(month);
+ label_parts.push(year);
+ break;
+ case 'week':
+ label_parts.push(month);
+ label_parts.push(year);
+ break;
+ case 'day':
+ label_parts.push(month);
+ label_parts.push(day);
+ break;
+ case 'hour':
+ label_parts.push(hour);
+ break;
+ }
+
+ var sections = [];
+ var section = [];
+ for (var series_code in series) {
+ var value = series[series_code].data[this.x];
+
+ // Don't show in tooltip if there was no runtime to report.
+ if (value === 0) {
+ continue;
+ }
+
+ switch (series_code) {
+ case 'x':
+ continue;
+ break;
+ case 'outdoor_temperature':
+ value = beestat.temperature({
+ 'temperature': value,
+ 'convert': false,
+ 'units': true,
+ 'round': 0
+ });
+ break;
+ default:
+ value = beestat.time(value, 'hours');
+ break;
+ }
+
+ if (value !== null) {
+ section.push({
+ 'label': beestat.series[series_code].name,
+ 'value': value,
+ 'color': beestat.series[series_code].color
+ });
+ }
+ }
+ sections.push(section);
+
+ return beestat.component.chart.tooltip_formatter(
+ label_parts.join(' '),
+ sections
+ );
+ }
+ };
+
+ this.chart_.options.series = [];
+
+ beestat.component.card.aggregate_runtime.equipment_series.forEach(function(series_code) {
+ if (series[series_code].enabled === true) {
+ self.chart_.options.series.push({
+ 'data': series[series_code].chart_data,
+ 'yAxis': 0,
+ 'groupPadding': 0,
+ 'name': beestat.series[series_code].name,
+ 'type': 'column',
+ 'color': beestat.series[series_code].color
+ });
+ }
+ });
+
+ this.chart_.options.series.push({
+ 'name': beestat.series.outdoor_temperature.name,
+ 'data': series.outdoor_temperature.chart_data,
+ 'color': beestat.style.color.blue.light,
+ 'type': 'spline',
+ 'yAxis': 1,
+ 'dashStyle': 'ShortDash',
+ 'lineWidth': 1,
+ 'zones': beestat.component.chart.get_outdoor_temperature_zones()
+ });
+
+ this.chart_.render(parent);
+
+ /*
+ * If the data is available, then get the data if we don't already have it
+ * loaded. If the data is not available, poll until it becomes available.
+ */
+ if (this.data_available_() === true) {
+ if (beestat.cache.aggregate_runtime.length === 0) {
+ this.get_data_();
+ } else {
+ this.hide_loading_();
+ }
+ } else {
+ var poll_interval = 10000;
+
+ beestat.add_poll_interval(poll_interval);
+ beestat.dispatcher.addEventListener('poll.aggregate_runtime_load', function() {
+ if (self.data_available_() === true) {
+ beestat.remove_poll_interval(poll_interval);
+ beestat.dispatcher.removeEventListener('poll.aggregate_runtime_load');
+ self.get_data_();
+ }
+ });
+ }
+};
+
+/**
+ * Get all of the series data.
+ *
+ * @return {object} The series data.
+ */
+beestat.component.card.aggregate_runtime.prototype.get_series_ = function() {
+ // TODO: Auto-generate these where possible like I did in recent_activity
+ var series = {
+ 'x': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'outdoor_temperature': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'compressor_heat_1': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'compressor_heat_2': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'compressor_cool_1': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'compressor_cool_2': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'auxiliary_heat_1': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'auxiliary_heat_2': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'auxiliary_heat_3': {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ }
+ };
+
+ beestat.cache.aggregate_runtime.forEach(function(aggregate, i) {
+
+ /*
+ * Generate a custom x value that I can use to build the custom axis for
+ * later. I thought about sending a timestamp back from the API instead of
+ * these discrete values but it's not possible due to the grouping. I could
+ * try to convert this to a timestamp or moment value but I'll just have to
+ * break it back out anyways so there's not much point to that.
+ */
+ var x_parts = [];
+ [
+ 'hour',
+ 'day',
+ 'week',
+ 'month',
+ 'year'
+ ].forEach(function(period) {
+ if (aggregate[period] !== undefined) {
+ x_parts.push(period[0] + aggregate[period]);
+ }
+ });
+ var x = x_parts.join('');
+
+ series.x.chart_data.push(x);
+
+ /*
+ * Used to estimate values when data is missing. These magic numbers are the
+ * number of expected data points in a group when that group represents a
+ * year, month, etc.
+ */
+ var adjustment_factor;
+ switch (beestat.setting('aggregate_runtime_group_by')) {
+ case 'year':
+ var year = x_parts[0].match(/\d+/)[0];
+ var is_leap_year = moment(year, 'YYYY').isLeapYear();
+ var days_in_year = is_leap_year === true ? 366 : 365;
+ adjustment_factor = days_in_year * 288;
+ break;
+ case 'month':
+ var month = x_parts[0].match(/\d+/)[0];
+ var year = x_parts[1].match(/\d+/)[0];
+ var days_in_month = moment(year + '-' + month, 'YYYY-MM').daysInMonth();
+ adjustment_factor = days_in_month * 288;
+ break;
+ case 'week':
+ adjustment_factor = 2016;
+ break;
+ case 'day':
+ adjustment_factor = 288;
+ break;
+ case 'hour':
+ adjustment_factor = 12;
+ break;
+ default:
+ console.error('Adjustment factor not available.');
+ break;
+ }
+
+ beestat.component.card.aggregate_runtime.equipment_series.forEach(function(series_code) {
+ var value = aggregate[series_code];
+
+ // Account for missing data in all but the last x value.
+ if (
+ beestat.setting('aggregate_runtime_gap_fill') === true &&
+ i < (beestat.cache.aggregate_runtime.length - 1)
+ ) {
+ var a = value;
+ value = value *
+ adjustment_factor /
+ aggregate.count;
+ }
+
+ // The value (in hours).
+ value /= 3600;
+
+ // Enable the series if it has data.
+ if (value > 0) {
+ series[series_code].enabled = true;
+ }
+
+ series[series_code].chart_data.push([
+ x,
+ value
+ ]);
+ series[series_code].data[x] = value;
+ });
+
+ // Outdoor temperature.
+ var outdoor_temperature_value = beestat.temperature({
+ 'temperature': aggregate.outdoor_temperature
+ });
+
+ series.outdoor_temperature.data[x] = outdoor_temperature_value;
+ series.outdoor_temperature.chart_data.push([
+ x,
+ outdoor_temperature_value
+ ]);
+ });
+
+ return series;
+};
+
+/**
+ * Decorate the menu
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.aggregate_runtime.prototype.decorate_top_right_ = function(parent) {
+ var self = this;
+
+ var menu = (new beestat.component.menu()).render(parent);
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Past 2 Months')
+ .set_icon('calendar_range')
+ .set_callback(function() {
+ if (
+ beestat.setting('aggregate_runtime_time_count') !== 2 ||
+ beestat.setting('aggregate_runtime_time_period') !== 'month' ||
+ beestat.setting('aggregate_runtime_group_by') !== 'day'
+ ) {
+ beestat.setting({
+ 'aggregate_runtime_time_count': 2,
+ 'aggregate_runtime_time_period': 'month',
+ 'aggregate_runtime_group_by': 'day'
+ });
+ }
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Past 12 Months')
+ .set_icon('calendar_range')
+ .set_callback(function() {
+ if (
+ beestat.setting('aggregate_runtime_time_count') !== 12 ||
+ beestat.setting('aggregate_runtime_time_period') !== 'month' ||
+ beestat.setting('aggregate_runtime_group_by') !== 'week'
+ ) {
+ beestat.setting({
+ 'aggregate_runtime_time_count': 12,
+ 'aggregate_runtime_time_period': 'month',
+ 'aggregate_runtime_group_by': 'week'
+ });
+ }
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('All Time')
+ .set_icon('calendar_range')
+ .set_callback(function() {
+ if (
+ beestat.setting('aggregate_runtime_time_count') !== 0 ||
+ beestat.setting('aggregate_runtime_time_period') !== 'all' ||
+ beestat.setting('aggregate_runtime_group_by') !== 'month'
+ ) {
+ beestat.setting({
+ 'aggregate_runtime_time_count': 0,
+ 'aggregate_runtime_time_period': 'all',
+ 'aggregate_runtime_group_by': 'month'
+ });
+ }
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Custom')
+ .set_icon('calendar_edit')
+ .set_callback(function() {
+ (new beestat.component.modal.aggregate_runtime_custom()).render();
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Download Chart')
+ .set_icon('download')
+ .set_callback(function() {
+ self.chart_.get_chart().exportChartLocal();
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Reset Zoom')
+ .set_icon('magnify_minus')
+ .set_callback(function() {
+ self.chart_.get_chart().zoomOut();
+ }));
+
+ if (beestat.setting('aggregate_runtime_gap_fill') === true) {
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Disable Gap-Fill')
+ .set_icon('basket_unfill')
+ .set_callback(function() {
+ beestat.setting('aggregate_runtime_gap_fill', false);
+ }));
+ } else {
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Enable Gap-Fill')
+ .set_icon('basket_fill')
+ .set_callback(function() {
+ beestat.setting('aggregate_runtime_gap_fill', true);
+ }));
+ }
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_aggregate_runtime()).render();
+ }));
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.aggregate_runtime.prototype.get_title_ = function() {
+ return 'Aggregate Runtime';
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.aggregate_runtime.prototype.get_subtitle_ = function() {
+ var s = (beestat.setting('aggregate_runtime_time_count') > 1) ? 's' : '';
+
+ var string = '';
+
+ if (beestat.setting('aggregate_runtime_time_period') === 'all') {
+ string = 'All time';
+ } else {
+ string = 'Past ' +
+ beestat.setting('aggregate_runtime_time_count') +
+ ' ' +
+ beestat.setting('aggregate_runtime_time_period') +
+ s;
+ }
+
+ string += ', ' +
+ ' grouped by ' +
+ beestat.setting('aggregate_runtime_group_by');
+
+ return string;
+};
+
+/**
+ * Determine whether or not enough data is currently available to render this
+ * card.
+ *
+ * @return {boolean}
+ */
+beestat.component.card.aggregate_runtime.prototype.data_available_ = function() {
+ // Demo can juse grab whatever data is there.
+ if (window.is_demo === true) {
+ this.show_loading_('Loading Aggregate Runtime');
+ return true;
+ }
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var current_sync_begin = moment.utc(thermostat.sync_begin);
+ var current_sync_end = moment.utc(thermostat.sync_end);
+
+ var required_sync_begin;
+ if (beestat.setting('aggregate_runtime_time_period') === 'all') {
+ required_sync_begin = moment(thermostat.first_connected);
+ } else {
+ required_sync_begin = moment().subtract(moment.duration(
+ beestat.setting('aggregate_runtime_time_count'),
+ beestat.setting('aggregate_runtime_time_period')
+ ));
+ }
+ required_sync_begin = moment.max(required_sync_begin, moment(thermostat.first_connected));
+ var required_sync_end = moment().subtract(1, 'hour');
+
+ // Percentage
+ var denominator = required_sync_end.diff(required_sync_begin, 'day');
+ var numerator_begin = moment.max(current_sync_begin, required_sync_begin);
+ var numerator_end = moment.min(current_sync_end, required_sync_end);
+ var numerator = numerator_end.diff(numerator_begin, 'day');
+ var percentage = numerator / denominator * 100;
+ if (isNaN(percentage) === true || percentage < 0) {
+ percentage = 0;
+ }
+
+ if (percentage >= 95) {
+ this.show_loading_('Loading Aggregate Runtime');
+ } else {
+ this.show_loading_('Syncing Data (' +
+ Math.round(percentage) +
+ '%)');
+ }
+
+ return (
+ current_sync_begin.isSameOrBefore(required_sync_begin) &&
+ current_sync_end.isSameOrAfter(required_sync_end)
+ );
+};
+
+/**
+ * Get the data needed to render this card.
+ */
+beestat.component.card.aggregate_runtime.prototype.get_data_ = function() {
+ var self = this;
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ beestat.api(
+ 'ecobee_runtime_thermostat',
+ 'get_aggregate_runtime',
+ {
+ 'ecobee_thermostat_id': thermostat.ecobee_thermostat_id,
+ 'time_period': beestat.setting('aggregate_runtime_time_period'),
+ 'group_by': beestat.setting('aggregate_runtime_group_by'),
+ 'time_count': beestat.setting('aggregate_runtime_time_count')
+ },
+ function(response) {
+ beestat.cache.set('aggregate_runtime', response);
+ self.rerender();
+ }
+ );
+};
diff --git a/js/component/card/alerts.js b/js/component/card/alerts.js
new file mode 100644
index 0000000..ce07c5a
--- /dev/null
+++ b/js/component/card/alerts.js
@@ -0,0 +1,204 @@
+/**
+ * Alerts
+ */
+beestat.component.card.alerts = function() {
+ beestat.component.card.apply(this, arguments);
+ this.alert_components_ = [];
+};
+beestat.extend(beestat.component.card.alerts, beestat.component.card);
+
+/**
+ * Decorate all of the individual alert components.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.alerts.prototype.decorate_contents_ = function(parent) {
+ var self = this;
+
+ parent.style({
+ 'transition': 'background 200ms ease',
+ 'position': 'relative',
+ 'min-height': '100px' // Gives the thumbs up a bit of space
+ });
+ this.parent_ = parent;
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ // No alerts
+ this.no_alerts_ = $.createElement('div')
+ .style({
+ 'position': 'absolute',
+ 'top': '50%',
+ 'left': '50%',
+ 'transform': 'translate(-50%, -50%) scale(0)',
+ 'transition': 'transform 200ms ease'
+ });
+ parent.appendChild(this.no_alerts_);
+
+ (new beestat.component.icon('thumb_up')
+ .set_size(64)
+ .set_color(beestat.style.color.bluegray.light)
+ ).render(this.no_alerts_);
+
+ thermostat.json_alerts.forEach(function(alert) {
+ var alert_component = new beestat.component.alert(alert);
+
+ alert_component.addEventListener('click', function() {
+ self.pin_(alert_component);
+ });
+
+ alert_component.addEventListener('dismiss', function() {
+ if (beestat.setting('show_dismissed_alerts') === false) {
+ this.hide();
+ }
+ self.back_();
+ });
+
+ alert_component.addEventListener('restore', function() {
+ self.back_();
+ });
+
+ alert_component.render(parent);
+ self.alert_components_.push(alert_component);
+ });
+
+ self.show_or_hide_();
+
+ // Back handler
+ this.addEventListener('back', function() {
+ self.unpin_();
+ });
+};
+
+/**
+ * Expand the passed alert component, then hide the rest.
+ *
+ * @param {beestat.component.alert} alert_component
+ */
+beestat.component.card.alerts.prototype.pin_ = function(alert_component) {
+ var self = this;
+
+ this.show_back_();
+
+ this.pinned_alert_component_ = alert_component;
+ this.pinned_alert_component_.expand();
+
+ this.alert_components_.forEach(function(this_alert_component) {
+ if (this_alert_component !== self.pinned_alert_component_) {
+ this_alert_component.hide();
+ }
+ });
+};
+
+/**
+ * Collapse the open alert component, then show the hidden ones.
+ */
+beestat.component.card.alerts.prototype.unpin_ = function() {
+ var self = this;
+
+ if (this.pinned_alert_component_ !== undefined) {
+ this.pinned_alert_component_.collapse();
+ }
+
+ this.alert_components_.forEach(function(this_alert_component) {
+ if (this_alert_component !== self.pinned_alert_component_) {
+ this_alert_component.show();
+ }
+ });
+
+ this.show_or_hide_();
+
+ delete this.pinned_alert_component_;
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.alerts.prototype.get_title_ = function() {
+ return 'Alerts';
+};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.alerts.prototype.decorate_top_right_ = function(parent) {
+ var self = this;
+
+ var menu = (new beestat.component.menu()).render(parent);
+
+ var menu_item_show = new beestat.component.menu_item()
+ .set_text('Show dismissed')
+ .set_icon('bell')
+ .set_callback(function() {
+ menu_item_hide.show();
+ menu_item_show.hide();
+
+ beestat.setting('show_dismissed_alerts', true);
+
+ self.show_or_hide_();
+ self.unpin_();
+ });
+ menu.add_menu_item(menu_item_show);
+
+ var menu_item_hide = new beestat.component.menu_item()
+ .set_text('Hide dismissed')
+ .set_icon('bell_off')
+ .set_callback(function() {
+ menu_item_hide.hide();
+ menu_item_show.show();
+
+ beestat.setting('show_dismissed_alerts', false);
+
+ self.show_or_hide_();
+ self.unpin_();
+ });
+ menu.add_menu_item(menu_item_hide);
+
+ if (beestat.setting('show_dismissed_alerts') === true) {
+ menu_item_hide.show();
+ menu_item_show.hide();
+ } else {
+ menu_item_hide.hide();
+ menu_item_show.show();
+ }
+
+ var menu_item_help = new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_alerts()).render();
+ });
+ menu.add_menu_item(menu_item_help);
+};
+
+/**
+ * Look at all of the existing alerts and determine if any UI changes need to
+ * be made (show/hide, background colors, etc).
+ */
+beestat.component.card.alerts.prototype.show_or_hide_ = function() {
+ var has_alerts = false;
+
+ this.alert_components_.forEach(function(alert_component) {
+ var should_show = alert_component.should_show();
+
+ has_alerts = has_alerts || should_show;
+
+ if (should_show === true) {
+ alert_component.show();
+ } else {
+ alert_component.hide();
+ }
+ });
+
+ if (has_alerts === true) {
+ this.parent_.style('background', beestat.style.color.red.base);
+ this.no_alerts_.style('transform', 'translate(-50%, -50%) scale(0)');
+ } else {
+ this.parent_.style('background', beestat.style.color.bluegray.base);
+ this.no_alerts_.style('transform', 'translate(-50%, -50%) scale(1)');
+ }
+};
diff --git a/js/component/card/comparison_issue.js b/js/component/card/comparison_issue.js
new file mode 100644
index 0000000..cf3ebff
--- /dev/null
+++ b/js/component/card/comparison_issue.js
@@ -0,0 +1,28 @@
+/**
+ * Possible issue with your comparison.
+ */
+beestat.component.card.comparison_issue = function() {
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.comparison_issue, beestat.component.card);
+
+/**
+ * Decorate
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.comparison_issue.prototype.decorate_contents_ = function(parent) {
+ parent.style('background', beestat.style.color.red.dark);
+ parent.appendChild($.createElement('p').innerText('Notice how one or more of the lines below slopes down or is very flat? The expectation is that these slope upwards. This may affect the accuracy of your scores.'));
+ parent.appendChild($.createElement('p').innerText('I\'ll be investigating these situations and improving the algorithm as much as possible to provide as accurate results as I can. Thank you!'));
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title of the card.
+ */
+beestat.component.card.comparison_issue.prototype.get_title_ = function() {
+ return 'Possible issue with your temperature profiles!';
+};
+
diff --git a/js/component/card/comparison_settings.js b/js/component/card/comparison_settings.js
new file mode 100644
index 0000000..0b3bfed
--- /dev/null
+++ b/js/component/card/comparison_settings.js
@@ -0,0 +1,443 @@
+/**
+ * Home comparison settings.
+ */
+beestat.component.card.comparison_settings = function() {
+ var self = this;
+
+ // If the thermostat_group changes that means the property_type could change
+ // and thus need to rerender.
+ beestat.dispatcher.addEventListener('cache.thermostat_group', function() {
+ self.rerender();
+ });
+
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.comparison_settings, beestat.component.card);
+
+/**
+ * Decorate
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.comparison_settings.prototype.decorate_contents_ = function(parent) {
+ var self = this;
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+
+ var row;
+
+ // Row
+ row = $.createElement('div').addClass('row');
+ parent.appendChild(row);
+
+ var column_date = $.createElement('div').addClass([
+ 'column',
+ 'column_12'
+ ]);
+ row.appendChild(column_date);
+ this.decorate_date_(column_date);
+
+ // Row
+ row = $.createElement('div').addClass('row');
+ parent.appendChild(row);
+
+ var column_region = $.createElement('div').addClass([
+ 'column',
+ 'column_4'
+ ]);
+ row.appendChild(column_region);
+ this.decorate_region_(column_region);
+
+ var column_property = $.createElement('div').addClass([
+ 'column',
+ 'column_8'
+ ]);
+ row.appendChild(column_property);
+ this.decorate_property_(column_property);
+
+ /*
+ * If the data is available, then get the data if we don't already have it
+ * loaded. If the data is not available, poll until it becomes available.
+ */
+ if (thermostat_group.temperature_profile === null) {
+ // This will show the loading screen.
+ self.data_available_();
+
+ var poll_interval = 10000;
+
+ beestat.add_poll_interval(poll_interval);
+ beestat.dispatcher.addEventListener('poll.home_comparisons_load', function() {
+ if (self.data_available_() === true) {
+ beestat.remove_poll_interval(poll_interval);
+ beestat.dispatcher.removeEventListener('poll.home_comparisons_load');
+
+ new beestat.api2()
+ .add_call(
+ 'thermostat_group',
+ 'generate_temperature_profiles',
+ {},
+ 'generate_temperature_profiles'
+ )
+ .add_call(
+ 'thermostat_group',
+ 'read_id',
+ {},
+ 'thermostat_group'
+ )
+ .set_callback(function(response) {
+ beestat.cache.set('thermostat_group', response.thermostat_group);
+ (new beestat.layer.home_comparisons()).render();
+ })
+ .send();
+ }
+ });
+ }
+};
+
+/**
+ * Decorate the date options.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.comparison_settings.prototype.decorate_date_ = function(parent) {
+ var self = this;
+
+ (new beestat.component.title('Date')).render(parent);
+
+ var periods = [
+ {
+ 'value': 0,
+ 'text': 'Today'
+ },
+ {
+ 'value': 1,
+ 'text': '1 Month Ago'
+ },
+ {
+ 'value': 6,
+ 'text': '6 Months Ago'
+ },
+ {
+ 'value': 12,
+ 'text': '12 Months Ago'
+ },
+ {
+ 'value': 'custom',
+ 'text': 'Custom'
+ }
+ ];
+
+ var current_comparison_period = beestat.setting('comparison_period');
+
+ var color = beestat.style.color.lightblue.base;
+
+ var input = new beestat.component.input.text()
+ .set_style({
+ 'width': 110,
+ 'text-align': 'center',
+ 'border-bottom': '2px solid ' + color
+ // 'background-color': color
+ })
+ .set_attribute({
+ 'maxlength': 10
+ })
+ .set_icon('calendar')
+ .set_value(beestat.setting('comparison_period_custom'));
+
+ var button_group = new beestat.component.button_group();
+ periods.forEach(function(period) {
+ var button = new beestat.component.button()
+ .set_background_hover_color(color)
+ .set_text_color('#fff')
+ .set_text(period.text);
+
+ if (current_comparison_period === period.value) {
+ button.set_background_color(color);
+ } else {
+ button
+ .set_background_color(beestat.style.color.bluegray.light)
+ .addEventListener('click', function() {
+ // Update the setting
+ beestat.setting('comparison_period', period.value);
+
+ // Update the input to reflect the actual date.
+ input.set_value(period.value);
+
+ // Rerender real quick to change the selected button
+ self.rerender();
+
+ // Open up the loading window.
+ if (period.value === 'custom') {
+ self.show_loading_('Calculating Score for ' + beestat.setting('comparison_period_custom'));
+ } else {
+ self.show_loading_('Calculating Score for ' + period.text);
+ }
+
+ if (period.value === 'custom') {
+ self.focus_input_ = true;
+ // self.rerender();
+
+ beestat.generate_temperature_profile(function() {
+ // Rerender to get rid of the loader.
+ self.rerender();
+ });
+ } else {
+ beestat.generate_temperature_profile(function() {
+ // Rerender to get rid of the loader.
+ self.rerender();
+ });
+ }
+ });
+ }
+
+ button_group.add_button(button);
+ });
+ button_group.render(parent);
+
+ if (current_comparison_period === 'custom') {
+ var input_container = $.createElement('div')
+ .style('margin-top', beestat.style.size.gutter);
+ parent.appendChild(input_container);
+ input.render(input_container);
+
+ input.addEventListener('blur', function() {
+ var m = moment(input.get_value());
+ if (m.isValid() === false) {
+ beestat.error('That\'s not a valid date. Most any proper date format will work here.');
+ } else if (m.isAfter()) {
+ beestat.error('That\'s in the future. Fresh out of flux capacitors over here, sorry.');
+ } else if (m.isSame(moment(beestat.setting('comparison_period_custom')), 'date') === false) {
+ beestat.setting('comparison_period_custom', input.get_value());
+ beestat.generate_temperature_profile(function() {
+ // Rerender to get rid of the loader.
+ self.rerender();
+ });
+ }
+ });
+ }
+};
+
+/**
+ * Decorate the region options.
+ *
+ * @param {rocket.ELements} parent
+ */
+beestat.component.card.comparison_settings.prototype.decorate_region_ = function(parent) {
+ var self = this;
+
+ (new beestat.component.title('Region')).render(parent);
+
+ var regions = [
+ 'nearby',
+ 'global'
+ ];
+
+ var current_region = beestat.setting('comparison_region');
+
+ var color = beestat.style.color.green.base;
+
+ var button_group = new beestat.component.button_group();
+ regions.forEach(function(region) {
+ var button = new beestat.component.button()
+ .set_background_hover_color(color)
+ .set_text_color('#fff')
+ .set_text(region.charAt(0).toUpperCase() + region.slice(1));
+
+ if (current_region === region) {
+ button.set_background_color(color);
+ } else {
+ button
+ .set_background_color(beestat.style.color.bluegray.light)
+ .addEventListener('click', function() {
+ // Update the setting
+ beestat.setting('comparison_region', region);
+
+ // Rerender real quick to change the selected button
+ self.rerender();
+
+ // Open up the loading window.
+ self.show_loading_('Calculating Score for ' + region + ' region');
+
+ beestat.get_comparison_scores(function() {
+ // Rerender to get rid of the loader.
+ self.rerender();
+ });
+ });
+ }
+
+ button_group.add_button(button);
+ });
+ button_group.render(parent);
+};
+
+/**
+ * Decorate the property type options.
+ *
+ * @param {rocket.ELements} parent
+ */
+beestat.component.card.comparison_settings.prototype.decorate_property_ = function(parent) {
+ var self = this;
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[
+ thermostat.thermostat_group_id
+ ];
+
+ (new beestat.component.title('Property')).render(parent);
+
+ var property_types = [];
+ property_types.push({
+ 'value': 'similar',
+ 'text': 'Very Similar'
+ });
+
+ if (thermostat_group.property_structure_type !== null) {
+ property_types.push({
+ 'value': 'same_structure',
+ 'text': 'Type: '
+ + thermostat_group.property_structure_type.charAt(0).toUpperCase()
+ + thermostat_group.property_structure_type.slice(1)
+ });
+ }
+
+ property_types.push({
+ 'value': 'all',
+ 'text': 'All'
+ });
+
+ var current_property_type = beestat.setting('comparison_property_type');
+
+ var color = beestat.style.color.purple.base;
+
+ var button_group = new beestat.component.button_group();
+ property_types.forEach(function(property_type) {
+ var button = new beestat.component.button()
+ .set_background_hover_color(color)
+ .set_text_color('#fff')
+ .set_text(property_type.text);
+
+ if (current_property_type === property_type.value) {
+ button.set_background_color(color);
+ } else {
+ button
+ .set_background_color(beestat.style.color.bluegray.light)
+ .addEventListener('click', function() {
+ // Update the setting
+ beestat.setting('comparison_property_type', property_type.value);
+
+ // Rerender real quick to change the selected button
+ self.rerender();
+
+ // Open up the loading window.
+ self.show_loading_('Calculating Score for ' + property_type.text);
+
+ beestat.get_comparison_scores(function() {
+ // Rerender to get rid of the loader.
+ self.rerender();
+ });
+ });
+ }
+
+ button_group.add_button(button);
+ });
+ button_group.render(parent);
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title of the card.
+ */
+beestat.component.card.comparison_settings.prototype.get_title_ = function() {
+ return 'Comparison Settings';
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string} The subtitle of the card.
+ */
+beestat.component.card.comparison_settings.prototype.get_subtitle_ = function() {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[
+ thermostat.thermostat_group_id
+ ];
+ var address = beestat.cache.address[thermostat_group.address_id];
+
+ var string = '';
+
+ if (address.normalized !== null && address.normalized.delivery_line_1 !== undefined) {
+ string = address.normalized.delivery_line_1;
+ } else if (address.normalized !== null && address.normalized.address1 !== undefined) {
+ string = address.normalized.address1;
+ } else {
+ string = 'Unknown Address';
+ }
+
+ var count = 0;
+ $.values(beestat.cache.thermostat).forEach(function(t) {
+ if (t.thermostat_group_id === thermostat_group.thermostat_group_id) {
+ count++;
+ }
+ });
+
+ string += ' (' + count + ' Thermostat' + (count > 1 ? 's' : '') + ')';
+
+ return string;
+};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.comparison_settings.prototype.decorate_top_right_ = function(parent) {
+ var menu = (new beestat.component.menu()).render(parent);
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_comparison_settings()).render();
+ }));
+};
+
+/**
+ * Determine whether or not all of the data has been loaded so the scores can
+ * be generated.
+ *
+ * @return {boolean} Whether or not all of the data has been loaded.
+ */
+beestat.component.card.comparison_settings.prototype.data_available_ = function() {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var current_sync_begin = moment.utc(thermostat.sync_begin);
+ var current_sync_end = moment.utc(thermostat.sync_end);
+
+ var required_sync_begin = moment(thermostat.first_connected);
+ var required_sync_end = moment().subtract(1, 'hour');
+
+ // Percentage
+ var denominator = required_sync_end.diff(required_sync_begin, 'day');
+ var numerator_begin = moment.max(current_sync_begin, required_sync_begin);
+ var numerator_end = moment.min(current_sync_end, required_sync_end);
+ var numerator = numerator_end.diff(numerator_begin, 'day');
+ var percentage = numerator / denominator * 100;
+ if (isNaN(percentage) === true || percentage < 0) {
+ percentage = 0;
+ }
+
+ if (percentage >= 95) {
+ this.show_loading_('Calculating Scores');
+ } else {
+ this.show_loading_('Syncing Data (' +
+ Math.round(percentage) +
+ '%)');
+ }
+
+ return (
+ current_sync_begin.isSameOrBefore(required_sync_begin) &&
+ current_sync_end.isSameOrAfter(required_sync_end)
+ );
+};
diff --git a/js/component/card/demo.js b/js/component/card/demo.js
new file mode 100644
index 0000000..9c5805d
--- /dev/null
+++ b/js/component/card/demo.js
@@ -0,0 +1,13 @@
+/**
+ * Make sure people know they're in the demo.
+ */
+beestat.component.card.demo = function() {
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.demo, beestat.component.card);
+
+beestat.component.card.demo.prototype.decorate_contents_ = function(parent) {
+ parent.style('background', beestat.style.color.lightblue.base);
+
+ parent.appendChild($.createElement('p').innerText('This is a demo of beestat; it works exactly like the real thing. Changes you make will not be saved.'));
+};
diff --git a/js/component/card/footer.js b/js/component/card/footer.js
new file mode 100644
index 0000000..a54e67b
--- /dev/null
+++ b/js/component/card/footer.js
@@ -0,0 +1,50 @@
+/**
+ * Helpful footer stuff.
+ */
+beestat.component.card.footer = function() {
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.footer, beestat.component.card);
+
+beestat.component.card.footer.prototype.decorate_contents_ = function(parent) {
+ parent.style('background', beestat.style.color.bluegray.light);
+
+ var footer = $.createElement('div')
+ .style({
+ 'text-align': 'center'
+ });
+ parent.appendChild(footer);
+
+ var footer_links = $.createElement('div');
+ footer.appendChild(footer_links);
+
+ footer_links.appendChild(
+ $.createElement('a')
+ .setAttribute('href', 'mailto:contact@beestat.io')
+ .innerHTML('Contact')
+ );
+ footer_links.appendChild($.createElement('span').innerHTML(' • '));
+
+ footer_links.appendChild(
+ $.createElement('a')
+ .setAttribute('href', '/privacy/')
+ .setAttribute('target', '_blank')
+ .innerHTML('Privacy')
+ );
+ footer_links.appendChild($.createElement('span').innerHTML(' • '));
+
+ footer_links.appendChild(
+ $.createElement('a')
+ .setAttribute('href', 'http://eepurl.com/dum59r')
+ .setAttribute('target', '_blank')
+ .innerHTML('Mailing List')
+ );
+ footer_links.appendChild($.createElement('span').innerHTML(' • '));
+
+ footer_links.appendChild(
+ $.createElement('a')
+ .setAttribute('href', 'https://github.com/beestat/app/issues')
+ .setAttribute('target', '_blank')
+ .innerHTML('Report Issue')
+ );
+};
diff --git a/js/component/card/my_home.js b/js/component/card/my_home.js
new file mode 100644
index 0000000..3cb9f8e
--- /dev/null
+++ b/js/component/card/my_home.js
@@ -0,0 +1,216 @@
+/**
+ * Home properties.
+ */
+beestat.component.card.my_home = function() {
+ var self = this;
+ beestat.dispatcher.addEventListener('cache.thermostat_group', function() {
+ self.rerender();
+ });
+
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.my_home, beestat.component.card);
+
+beestat.component.card.my_home.prototype.decorate_contents_ = function(parent) {
+ this.decorate_system_type_(parent);
+ this.decorate_region_(parent);
+ this.decorate_property_(parent);
+};
+
+/**
+ * Decorate the heating and cooling system types.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.my_home.prototype.decorate_system_type_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[
+ thermostat.thermostat_group_id
+ ];
+
+ (new beestat.component.title('System')).render(parent);
+
+ var heat = thermostat_group.system_type_heat !== null
+ ? thermostat_group.system_type_heat
+ : 'unknown';
+
+ var heat_auxiliary = thermostat_group.system_type_heat_auxiliary !== null
+ ? thermostat_group.system_type_heat_auxiliary
+ : 'unknown';
+
+ var cool = thermostat_group.system_type_cool !== null
+ ? thermostat_group.system_type_cool
+ : 'unknown';
+
+ var button_group = new beestat.component.button_group();
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.series.compressor_heat_1.color)
+ .set_text_color('#fff')
+ .set_icon('fire')
+ .set_text(heat.charAt(0).toUpperCase() + heat.slice(1)));
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.series.auxiliary_heat_1.color)
+ .set_text_color('#fff')
+ .set_icon('fire')
+ .set_text(heat_auxiliary.charAt(0).toUpperCase() + heat_auxiliary.slice(1)));
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.series.compressor_cool_1.color)
+ .set_text_color('#fff')
+ .set_icon('snowflake')
+ .set_text(cool.charAt(0).toUpperCase() + cool.slice(1)));
+
+ button_group.render(parent);
+};
+
+/**
+ * Decorate the geographical region.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.my_home.prototype.decorate_region_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+ var address = beestat.cache.address[thermostat_group.address_id];
+
+ (new beestat.component.title('Region')).render(parent);
+
+ var region;
+ if (address.normalized !== null) {
+ region =
+ address.normalized.components.state_abbreviation ||
+ address.normalized.components.locality ||
+ '';
+
+ if (region !== '') {
+ region += ', ';
+ }
+
+ region += address.normalized.components.country_iso_3;
+ } else {
+ region = null;
+ }
+
+ var button_group = new beestat.component.button_group();
+ if (region !== null) {
+ var button = new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.green.base)
+ .set_text_color('#fff')
+ .set_icon('map_marker')
+ .set_text(region);
+ button_group.add_button(button);
+ } else {
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.gray.dark)
+ .set_text_color('#fff')
+ .set_icon('border_none_variant')
+ .set_text('No Data'));
+ }
+ button_group.render(parent);
+};
+
+/**
+ * Decorate the property characteristics.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.my_home.prototype.decorate_property_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+
+ (new beestat.component.title('Property')).render(parent);
+
+ var button_group = new beestat.component.button_group();
+
+ if (thermostat_group.property_structure_type !== null) {
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.purple.base)
+ .set_text_color('#fff')
+ .set_icon('home_floor_a')
+ .set_text(thermostat_group.property_structure_type.charAt(0).toUpperCase() +
+ thermostat_group.property_structure_type.slice(1)));
+ }
+
+ if (
+ thermostat_group.property_stories !== null &&
+ (
+ thermostat_group.property_structure_type === 'detached' ||
+ thermostat_group.property_structure_type === 'townhouse' ||
+ thermostat_group.property_structure_type === 'semi-detached'
+ )
+ ) {
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.purple.base)
+ .set_text_color('#fff')
+ .set_icon('layers')
+ .set_text(thermostat_group.property_stories +
+ (thermostat_group.property_stories === 1 ? ' Story' : ' Stories')));
+ }
+
+ if (thermostat_group.property_square_feet !== null) {
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.purple.base)
+ .set_text_color('#fff')
+ .set_icon('view_quilt')
+ .set_text(Number(thermostat_group.property_square_feet).toLocaleString() + ' sqft'));
+ }
+
+ if (thermostat_group.property_age !== null) {
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.purple.base)
+ .set_text_color('#fff')
+ .set_icon('clock_outline')
+ .set_text(thermostat_group.property_age + ' Years'));
+ }
+
+ if (button_group.get_buttons().length === 0) {
+ button_group.add_button(new beestat.component.button()
+ .set_type('pill')
+ .set_background_color(beestat.style.color.gray.dark)
+ .set_text_color('#fff')
+ .set_icon('border_none_variant')
+ .set_text('No Data'));
+ }
+
+ button_group.render(parent);
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title of the card.
+ */
+beestat.component.card.my_home.prototype.get_title_ = function() {
+ return 'My Home';
+};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.my_home.prototype.decorate_top_right_ = function(parent) {
+ var menu = (new beestat.component.menu()).render(parent);
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Change System Type')
+ .set_icon('tune')
+ .set_callback(function() {
+ (new beestat.component.modal.change_system_type()).render();
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_my_home()).render();
+ }));
+};
diff --git a/js/component/card/patreon.js b/js/component/card/patreon.js
new file mode 100644
index 0000000..89bc8d6
--- /dev/null
+++ b/js/component/card/patreon.js
@@ -0,0 +1,47 @@
+/**
+ * Green Patreon banner asking people for money. $_$
+ */
+beestat.component.card.patreon = function() {
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.patreon, beestat.component.card);
+
+beestat.component.card.patreon.prototype.decorate_contents_ = function(parent) {
+ parent.style('background', beestat.style.color.green.base);
+
+ new beestat.component.button()
+ .set_icon('heart')
+ .set_text('Support this project on Patreon!')
+ .set_background_color(beestat.style.color.green.dark)
+ .set_background_hover_color(beestat.style.color.green.light)
+ .addEventListener('click', function() {
+ window.open('https://www.patreon.com/beestat');
+ })
+ .render(parent);
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title.
+ */
+beestat.component.card.patreon.prototype.get_title_ = function() {
+ return 'Enjoy beestat?';
+};
+
+/**
+ * Decorate the close button.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.patreon.prototype.decorate_top_right_ = function(parent) {
+ new beestat.component.button()
+ .set_type('pill')
+ .set_icon('close')
+ .set_text_color('#fff')
+ .set_background_hover_color(beestat.style.color.green.light)
+ .addEventListener('click', function() {
+ (new beestat.component.modal.patreon_hide()).render();
+ })
+ .render(parent);
+};
diff --git a/js/component/card/recent_activity.js b/js/component/card/recent_activity.js
new file mode 100644
index 0000000..6b82151
--- /dev/null
+++ b/js/component/card/recent_activity.js
@@ -0,0 +1,1359 @@
+/**
+ * Recent activity card. Shows a graph similar to what ecobee shows with the
+ * runtime info for a recent period of time.
+ */
+beestat.component.card.recent_activity = function() {
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.recent_activity, beestat.component.card);
+
+beestat.component.card.recent_activity.optional_series = [
+ 'compressor_heat_1',
+ 'compressor_heat_2',
+ 'compressor_cool_1',
+ 'compressor_cool_2',
+ 'auxiliary_heat_1',
+ 'auxiliary_heat_2',
+ 'auxiliary_heat_3',
+ 'fan',
+ 'dehumidifier',
+ 'economizer',
+ 'humidifier',
+ 'ventilator'
+];
+
+beestat.component.card.recent_activity.calendar_events = [
+ 'calendar_event_home',
+ 'calendar_event_away',
+ 'calendar_event_sleep',
+ 'calendar_event_vacation',
+ 'calendar_event_smarthome',
+ 'calendar_event_smartaway',
+ 'calendar_event_smartrecovery',
+ 'calendar_event_hold',
+ 'calendar_event_quicksave',
+ 'calendar_event_other'
+];
+
+/**
+ * Decorate
+ *
+ * @param {rocket.ELements} parent
+ */
+beestat.component.card.recent_activity.prototype.decorate_contents_ = function(parent) {
+ var self = this;
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ this.chart_ = new beestat.component.chart();
+ var series = this.get_series_();
+
+ this.chart_.options.chart.backgroundColor = beestat.style.color.bluegray.base;
+ this.chart_.options.exporting.filename = thermostat.name + ' - Recent Activity';
+ this.chart_.options.exporting.chartOptions.title.text = this.get_title_();
+ this.chart_.options.exporting.chartOptions.subtitle.text = this.get_subtitle_();
+
+ var current_day;
+ var current_hour;
+ this.chart_.options.xAxis = {
+ 'categories': series.x.chart_data,
+ 'type': 'datetime',
+ 'lineColor': beestat.style.color.bluegray.light,
+ 'min': series.x.chart_data[0],
+ 'max': series.x.chart_data[series.x.chart_data.length - 1],
+ 'minRange': 21600000,
+ 'tickLength': 0,
+ 'gridLineWidth': 0,
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ var m = moment(this.value);
+ var hour = m.format('ha');
+ var day = m.format('ddd');
+
+ var label_parts = [];
+ if (day !== current_day) {
+ label_parts.push(day);
+ }
+ if (hour !== current_hour) {
+ label_parts.push(hour);
+ }
+
+ current_hour = hour;
+ current_day = day;
+
+ return label_parts.join(' ');
+ }
+ }
+ };
+
+ // Add some space for the top of the graph.
+ this.y_max_ += 30;
+
+ // Because higcharts isn't respecting the tickInterval parameter...seems to
+ // have to do with the secondary axis; as removing it makes it work a lot
+ // better.
+ var tick_positions = [];
+ var tick_interval = (thermostat.temperature_unit === '°F') ? 10 : 5;
+ var current_tick_position =
+ Math.floor(this.y_min_ / tick_interval) * tick_interval;
+ while (current_tick_position <= this.y_max_) {
+ tick_positions.push(current_tick_position);
+ current_tick_position += tick_interval;
+ }
+
+ this.chart_.options.yAxis = [
+ // Temperature
+ {
+ // 'alignTicks': false, // Uncommenting this will allow the humidity series to line up but it will also force the y-axis to be a bit larger. For example, a y min of 17 will get set to a min of 0 instead of 15 because the spacing is set to 20.
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'title': {'text': null},
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ return this.value + thermostat.temperature_unit;
+ }
+ },
+ 'tickPositions': tick_positions
+ },
+
+ // Top bars
+ {
+ 'height': 100,
+ 'min': 0,
+ 'max': 100,
+ 'gridLineWidth': 0,
+ 'title': {'text': null},
+ 'labels': {'enabled': false}
+ },
+
+ // Humidity
+ {
+ 'alignTicks': false,
+ 'gridLineColor': null,
+ 'tickInterval': 10,
+ // 'gridLineDashStyle': 'longdash',
+ 'opposite': true,
+ 'title': {'text': null},
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ return this.value + '%';
+ }
+ },
+
+ /*
+ * If you set a min/max highcharts always shows the axis. Setting these
+ * attributes prevents the "always show" logic and the 0-100 is achieved
+ * with this set of parameters.
+ * https://github.com/highcharts/highcharts/issues/3403
+ */
+ 'min': 0,
+ 'minRange': 100,
+ 'ceiling': 100
+ }
+ ];
+
+ this.chart_.options.tooltip = {
+ 'shared': true,
+ 'useHTML': true,
+ 'borderWidth': 0,
+ 'shadow': false,
+ 'backgroundColor': null,
+ 'followPointer': true,
+ 'crosshairs': {
+ 'width': 1,
+ 'zIndex': 100,
+ 'color': beestat.style.color.gray.light,
+ 'dashStyle': 'shortDot',
+ 'snap': false
+ },
+ 'positioner': function(tooltip_width, tooltip_height, point) {
+ return beestat.component.chart.tooltip_positioner(
+ self.chart_.get_chart(),
+ tooltip_width,
+ tooltip_height,
+ point
+ );
+ },
+ 'formatter': function() {
+ var self = this;
+
+ var sections = [];
+
+ // HVAC Mode
+ var hvac_mode;
+ var hvac_mode_color;
+
+ switch (series.hvac_mode.data[self.x]) {
+ case 'auto':
+ hvac_mode = 'Auto';
+ hvac_mode_color = beestat.style.color.gray.base;
+ break;
+ case 'heat':
+ hvac_mode = 'Heat';
+ hvac_mode_color = beestat.series.compressor_heat_1.color;
+ break;
+ case 'cool':
+ hvac_mode = 'Cool';
+ hvac_mode_color = beestat.series.compressor_cool_1.color;
+ break;
+ case 'off':
+ hvac_mode = 'Off';
+ hvac_mode_color = beestat.style.color.gray.base;
+ break;
+ case 'auxHeatOnly':
+ hvac_mode = 'Aux';
+ hvac_mode_color = beestat.series.auxiliary_heat_1.color;
+ break;
+ }
+
+ var section_1 = [];
+ sections.push(section_1);
+
+ if (hvac_mode !== undefined) {
+ section_1.push({
+ 'label': 'Mode',
+ 'value': hvac_mode,
+ 'color': hvac_mode_color
+ });
+ }
+
+ // Calendar Event / Comfort Profile
+ var event;
+ var event_color;
+
+ for (var i = 0; i < beestat.component.card.recent_activity.calendar_events.length; i++) {
+ var calendar_event = beestat.component.card.recent_activity.calendar_events[i];
+ if (series[calendar_event].data[self.x] !== null) {
+ event = beestat.series[calendar_event].name;
+ event_color = beestat.series[calendar_event].color;
+ break;
+ }
+ }
+
+ if (event !== undefined) {
+ section_1.push({
+ 'label': 'Comfort Profile',
+ 'value': event,
+ 'color': event_color
+ });
+ }
+
+ var section_2 = [];
+ sections.push(section_2);
+
+ [
+ 'setpoint_heat',
+ 'setpoint_cool',
+ 'indoor_temperature',
+ 'outdoor_temperature',
+ 'indoor_humidity',
+ 'outdoor_humidity'
+ ].forEach(function(series_code) {
+ var value;
+
+ if (series_code === 'setpoint_cool') {
+ return; // Grab it when doing setpoint_heat
+ } else if (series_code === 'setpoint_heat') {
+ if (
+ series[series_code].data[self.x] === null
+ ) {
+ return;
+ }
+
+ switch (series.hvac_mode.data[self.x]) {
+ case 'heat':
+ if (series.setpoint_heat.data[self.x] === null) {
+ return;
+ }
+ value = beestat.temperature({
+ 'temperature': series.setpoint_heat.data[self.x],
+ 'convert': false,
+ 'units': true
+ });
+ break;
+ case 'cool':
+ if (series.setpoint_cool.data[self.x] === null) {
+ return;
+ }
+ value = beestat.temperature({
+ 'temperature': series.setpoint_cool.data[self.x],
+ 'convert': false,
+ 'units': true
+ });
+ break;
+ case 'auto':
+ if (
+ series.setpoint_heat.data[self.x] === null ||
+ series.setpoint_cool.data[self.x] === null
+ ) {
+ return;
+ }
+ value = beestat.temperature({
+ 'temperature': series.setpoint_heat.data[self.x],
+ 'convert': false,
+ 'units': true
+ });
+ value += ' - ';
+ value += beestat.temperature({
+ 'temperature': series.setpoint_cool.data[self.x],
+ 'convert': false,
+ 'units': true
+ });
+ break;
+ default:
+ return;
+ break;
+ }
+ } else if (
+ series_code === 'indoor_humidity' ||
+ series_code === 'outdoor_humidity'
+ ) {
+ if (series[series_code].data[self.x] === null) {
+ return;
+ }
+ value = series[series_code].data[self.x] + '%';
+ } else {
+ if (series[series_code].data[self.x] === null) {
+ return;
+ }
+ value = beestat.temperature({
+ 'temperature': series[series_code].data[self.x],
+ 'convert': false,
+ 'units': true
+ });
+ }
+
+ section_2.push({
+ 'label': beestat.series[series_code].name,
+ 'value': value,
+ 'color': beestat.style.color.gray.light
+ });
+ });
+
+ var section_3 = [];
+ sections.push(section_3);
+
+ beestat.component.card.recent_activity.optional_series.forEach(function(series_code) {
+ if (
+ series[series_code].data[self.x] !== undefined &&
+ series[series_code].data[self.x] !== null
+ ) {
+ section_3.push({
+ 'label': beestat.series[series_code].name,
+ 'value': beestat.time(series[series_code].durations[self.x].seconds),
+ 'color': beestat.series[series_code].color
+ });
+ }
+ });
+
+ return beestat.component.chart.tooltip_formatter(
+ moment(this.x).format('ddd, MMM D @ h:mma'),
+ sections
+ );
+ }
+ };
+
+ this.chart_.options.series = [];
+
+ beestat.component.card.recent_activity.calendar_events.forEach(function(calendar_event) {
+ self.chart_.options.series.push({
+ 'id': calendar_event,
+ 'linkedTo': (calendar_event !== 'calendar_event_home') ? 'calendar_event_home' : undefined,
+ 'data': series[calendar_event].chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': 'Comfort Profile',
+ 'type': 'line',
+ 'color': beestat.series[calendar_event].color,
+ 'lineWidth': 5,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ });
+
+ if (series.compressor_cool_1.enabled === true) {
+ this.chart_.options.series.push({
+ 'id': 'compressor_cool_1',
+ 'data': series.compressor_cool_1.chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': 'Cool',
+ 'type': 'line',
+ 'color': beestat.series.compressor_cool_1.color,
+ 'lineWidth': 10,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+
+ if (series.compressor_cool_2.enabled === true) {
+ this.chart_.options.series.push({
+ 'data': series.compressor_cool_2.chart_data,
+ 'linkedTo': 'compressor_cool_1',
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series.compressor_cool_2.name,
+ 'type': 'line',
+ 'color': beestat.series.compressor_cool_2.color,
+ 'lineWidth': 10,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+
+ if (series.compressor_heat_1.enabled === true) {
+ this.chart_.options.series.push({
+ 'id': 'compressor_heat_1',
+ 'data': series.compressor_heat_1.chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': 'Heat',
+ 'type': 'line',
+ 'color': beestat.series.compressor_heat_1.color,
+ 'lineWidth': 10,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+
+ if (series.compressor_heat_2.enabled === true) {
+ this.chart_.options.series.push({
+ 'linkedTo': 'compressor_heat_1',
+ 'data': series.compressor_heat_2.chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series.compressor_heat_2.name,
+ 'type': 'line',
+ 'color': beestat.series.compressor_heat_2.color,
+ 'lineWidth': 10,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+
+ [
+ 'auxiliary_heat_1',
+ 'auxiliary_heat_2',
+ 'auxiliary_heat_3'
+ ].forEach(function(equipment) {
+ if (series[equipment].enabled === true) {
+ self.chart_.options.series.push({
+ 'data': series[equipment].chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series[equipment].name,
+ 'type': 'line',
+ 'color': beestat.series[equipment].color,
+ 'lineWidth': 10,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+ });
+
+ if (series.fan.enabled === true) {
+ this.chart_.options.series.push({
+ 'data': series.fan.chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series.fan.name,
+ 'type': 'line',
+ 'color': beestat.series.fan.color,
+ 'lineWidth': 5,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+
+ [
+ 'dehumidifier',
+ 'economizer',
+ 'humidifier',
+ 'ventilator'
+ ].forEach(function(equipment) {
+ if (series[equipment].enabled === true) {
+ self.chart_.options.series.push({
+ 'data': series[equipment].chart_data,
+ 'yAxis': 1,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series[equipment].name,
+ 'type': 'line',
+ 'color': beestat.series[equipment].color,
+ 'lineWidth': 5,
+ 'linecap': 'square',
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+ }
+ });
+
+ this.chart_.options.series.push({
+ 'id': 'indoor_humidity',
+ 'data': series.indoor_humidity.chart_data,
+ 'yAxis': 2,
+ 'name': beestat.series.indoor_humidity.name,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'spline',
+ 'dashStyle': 'DashDot',
+ 'visible': false,
+ 'lineWidth': 1,
+ 'color': beestat.series.indoor_humidity.color,
+ 'states': {'hover': {'lineWidthPlus': 0}},
+
+ /*
+ * Weird HighCharts bug...
+ * https://stackoverflow.com/questions/48374093/highcharts-highstock-line-change-to-area-bug
+ * https://github.com/highcharts/highcharts/issues/766
+ */
+ 'linecap': 'square'
+ });
+
+ this.chart_.options.series.push({
+ 'data': series.indoor_temperature.chart_data,
+ 'yAxis': 0,
+ 'name': beestat.series.indoor_temperature.name,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'spline',
+ 'lineWidth': 2,
+ 'color': beestat.series.indoor_temperature.color,
+ 'states': {'hover': {'lineWidthPlus': 0}},
+
+ /*
+ * Weird HighCharts bug...
+ * https://stackoverflow.com/questions/48374093/highcharts-highstock-line-change-to-area-bug
+ * https://github.com/highcharts/highcharts/issues/766
+ */
+ 'linecap': 'square'
+ });
+
+ this.chart_.options.series.push({
+ 'data': series.outdoor_temperature.chart_data,
+ 'zones': beestat.component.chart.get_outdoor_temperature_zones(),
+ 'yAxis': 0,
+ 'name': beestat.series.outdoor_temperature.name,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'spline',
+ 'dashStyle': 'ShortDash',
+ 'lineWidth': 1,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ this.chart_.options.series.push({
+ 'data': series.setpoint_heat.chart_data,
+ 'id': 'setpoint_heat',
+ 'yAxis': 0,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series.setpoint_heat.name,
+ 'type': 'line',
+ 'color': beestat.series.setpoint_heat.color,
+ 'lineWidth': 1,
+ 'dashStyle': 'ShortDash',
+ 'states': {'hover': {'lineWidthPlus': 0}},
+ 'step': 'right'
+ });
+
+ this.chart_.options.series.push({
+ 'data': series.setpoint_cool.chart_data,
+ 'linkedTo': 'setpoint_heat',
+ 'yAxis': 0,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'name': beestat.series.setpoint_cool.name,
+ 'type': 'line',
+ 'color': beestat.series.setpoint_cool.color,
+ 'lineWidth': 1,
+ 'dashStyle': 'ShortDash',
+ 'states': {'hover': {'lineWidthPlus': 0}},
+ 'step': 'right'
+ });
+
+ this.chart_.render(parent);
+
+ this.show_loading_('Syncing Recent Activity');
+
+ /*
+ * If the data is available, then get the data if we don't already have it
+ * loaded. If the data is not available, poll until it becomes available.
+ */
+ if (this.data_available_() === true) {
+ if (beestat.cache.ecobee_runtime_thermostat.length === 0) {
+ this.get_data_();
+ } else {
+ this.hide_loading_();
+ }
+ } else {
+ var poll_interval = 10000;
+
+ beestat.add_poll_interval(poll_interval);
+ beestat.dispatcher.addEventListener('poll.recent_activity_load', function() {
+ if (self.data_available_() === true) {
+ beestat.remove_poll_interval(poll_interval);
+ beestat.dispatcher.removeEventListener('poll.recent_activity_load');
+ self.get_data_();
+ }
+ });
+ }
+};
+
+/**
+ * Decorate the menu
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.recent_activity.prototype.decorate_top_right_ = function(parent) {
+ var self = this;
+
+ var menu = (new beestat.component.menu()).render(parent);
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Past 1 Day')
+ .set_icon('numeric_1_box')
+ .set_callback(function() {
+ if (
+ beestat.setting('recent_activity_time_count') !== 1 ||
+ beestat.setting('recent_activity_time_period') !== 'day'
+ ) {
+ beestat.setting({
+ 'recent_activity_time_count': 1,
+ 'recent_activity_time_period': 'day'
+ });
+
+ /*
+ * Rerender; the timeout lets the menu close immediately without being
+ * blocked by the time it takes to rerender the chart.
+ */
+ setTimeout(function() {
+ self.rerender();
+ }, 0);
+ }
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Past 3 Days')
+ .set_icon('numeric_3_box')
+ .set_callback(function() {
+ if (
+ beestat.setting('recent_activity_time_count') !== 3 ||
+ beestat.setting('recent_activity_time_period') !== 'day'
+ ) {
+ beestat.setting({
+ 'recent_activity_time_count': 3,
+ 'recent_activity_time_period': 'day'
+ });
+
+ setTimeout(function() {
+ self.rerender();
+ }, 0);
+ }
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Past 7 Days')
+ .set_icon('numeric_7_box')
+ .set_callback(function() {
+ if (
+ beestat.setting('recent_activity_time_count') !== 7 ||
+ beestat.setting('recent_activity_time_period') !== 'day'
+ ) {
+ beestat.setting({
+ 'recent_activity_time_count': 7,
+ 'recent_activity_time_period': 'day'
+ });
+ setTimeout(function() {
+ self.rerender();
+ }, 0);
+ }
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Download Chart')
+ .set_icon('download')
+ .set_callback(function() {
+ self.chart_.get_chart().exportChartLocal();
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Reset Zoom')
+ .set_icon('magnify_minus')
+ .set_callback(function() {
+ self.chart_.get_chart().zoomOut();
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_recent_activity()).render();
+ }));
+};
+
+/**
+ * Get all of the series data.
+ *
+ * @return {object} The series data.
+ */
+beestat.component.card.recent_activity.prototype.get_series_ = function() {
+ var self = this;
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ /*
+ * The more data that gets shown the larger the smoothing factor should be
+ * (less points, smoother graph).
+ */
+ var smoothing_factor = beestat.setting('recent_activity_time_count') * 3;
+
+ this.y_min_ = Infinity;
+ this.y_max_ = -Infinity;
+
+ /*
+ * The chart_data property is what Highcharts uses. The data property is the
+ * same data indexed by the x value to make it easy to access.
+ */
+ var series = {
+ 'x': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'setpoint_heat': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'setpoint_cool': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'outdoor_temperature': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'indoor_temperature': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'indoor_humidity': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'outdoor_humidity': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ },
+ 'hvac_mode': {
+ 'enabled': true,
+ 'chart_data': [],
+ 'data': {}
+ }
+ };
+
+ // Initialize the optional series.
+ beestat.component.card.recent_activity.optional_series.forEach(function(optional_series) {
+ series[optional_series] = {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {},
+ 'durations': {}
+ };
+ });
+
+ // Initialize the calendar event series.
+ beestat.component.card.recent_activity.calendar_events.forEach(function(calendar_event) {
+ series[calendar_event] = {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {}
+ };
+ });
+
+ /*
+ * Overrides the %10 smoothing for when there is missing data. Basically just
+ * ensures that the graph starts back up right away instead of waiting for a
+ * 10th data point.
+ */
+ var previous_indoor_temperature_value = null;
+ var previous_outdoor_temperature_value = null;
+ var previous_indoor_humidity_value = null;
+ var previous_outdoor_humidity_value = null;
+
+ var min_x = moment()
+ .subtract(
+ beestat.setting('recent_activity_time_count'),
+ beestat.setting('recent_activity_time_period')
+ )
+ .valueOf();
+
+ /*
+ * This creates a distinct object for each chunk of runtime so the total on
+ * time can be computed for any given segment.
+ */
+ var durations = {};
+
+ beestat.cache.ecobee_runtime_thermostat.forEach(function(ecobee_runtime_thermostat, i) {
+ if (ecobee_runtime_thermostat.ecobee_thermostat_id !== thermostat.ecobee_thermostat_id) {
+ return;
+ }
+
+ var x = moment(ecobee_runtime_thermostat.timestamp).valueOf();
+ if (x < min_x) {
+ return;
+ }
+
+ series.x.chart_data.push(x);
+
+ var original_durations = {};
+ if (ecobee_runtime_thermostat.compressor_heat_2 > 0) {
+ original_durations.compressor_heat_1 = ecobee_runtime_thermostat.compressor_heat_1;
+ ecobee_runtime_thermostat.compressor_heat_1 = ecobee_runtime_thermostat.compressor_heat_2;
+ }
+ // TODO DO THIS FOR AUX
+ // TODO DO THIS FOR COOL
+
+ beestat.component.card.recent_activity.optional_series.forEach(function(series_code) {
+ if (durations[series_code] === undefined) {
+ durations[series_code] = [{'seconds': 0}];
+ }
+
+ // if (series_code === 'compressor_heat_1') {
+ // ecobee_runtime_thermostat
+ // }
+
+ if (
+ ecobee_runtime_thermostat[series_code] !== null &&
+ ecobee_runtime_thermostat[series_code] > 0
+ ) {
+ var value;
+ switch (series_code) {
+ case 'fan':
+ value = 70;
+ break;
+ case 'dehumidifier':
+ case 'economizer':
+ case 'humidifier':
+ case 'ventilator':
+ value = 62;
+ break;
+ default:
+ value = 80;
+ break;
+ }
+
+ series[series_code].enabled = true;
+ series[series_code].chart_data.push([
+ x,
+ value
+ ]);
+ series[series_code].data[x] = value;
+
+ var duration = original_durations[series_code] !== undefined
+ ? original_durations[series_code]
+ : ecobee_runtime_thermostat[series_code];
+
+ durations[series_code][durations[series_code].length - 1].seconds += duration;
+ // durations[series_code][durations[series_code].length - 1].seconds += ecobee_runtime_thermostat[series_code];
+ series[series_code].durations[x] = durations[series_code][durations[series_code].length - 1];
+ } else {
+ series[series_code].chart_data.push([
+ x,
+ null
+ ]);
+ series[series_code].data[x] = null;
+
+ if (durations[series_code][durations[series_code].length - 1].seconds > 0) {
+ durations[series_code].push({'seconds': 0});
+ }
+ }
+ });
+
+ /*
+ * This is the ecobee code.
+ *
+ * var normalizedString = eventString;
+ * var vacationPattern = /(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i;
+ * var smartRecoveryPattern = /smartRecovery/i;
+ * var smartAwayPattern = /smartAway/i;
+ * var smartHomePattern = /smartHome/i;
+ * var quickSavePattern = /quickSave/i;
+ *
+ * if (typeof eventString === 'string') {
+ * eventString = eventString.toLowerCase();
+ * normalizedString = eventString;
+ *
+ * if (eventString === 'auto' || eventString === 'today' || eventString === 'hold' || typeof thermostatClimates.climates[eventString] !== 'undefined') {
+ * normalizedString = 'hold';
+ * } else if (vacationPattern.test(eventString) || eventString.toLowerCase().indexOf('vacation') === 0) {
+ * normalizedString = 'vacation';
+ * } else if(smartRecoveryPattern.test(eventString)) {
+ * normalizedString = 'smartRecovery';
+ * } else if(smartHomePattern.test(eventString)) {
+ * normalizedString = 'smartHome';
+ * } else if(smartAwayPattern.test(eventString)) {
+ * normalizedString = 'smartAway';
+ * } else if(quickSavePattern.test(eventString)) {
+ * normalizedString = 'quickSave';
+ * } else {
+ * normalizedString = 'customEvent';
+ * }
+ * }
+ */
+
+ /*
+ * Here are some examples of what I get in the database and what they map to
+ *
+ * calendar_event_home home
+ * calendar_event_away away
+ * calendar_event_smartrecovery (SmartRecovery)
+ * calendar_event_smartrecovery smartAway(SmartRecovery)
+ * calendar_event_smartrecovery auto(SmartRecovery)
+ * calendar_event_smartrecovery hold(SmartRecovery)
+ * calendar_event_smartrecovery 149831444185(SmartRecovery)
+ * calendar_event_smartrecovery Vacation(SmartRecovery)
+ * calendar_event_smartrecovery 152304757299(SmartRecovery)
+ * calendar_event_smartrecovery Apr 29 2016(SmartRecovery)
+ * calendar_event_smarthome smartHome
+ * calendar_event_smartaway smartAway
+ * calendar_event_hold hold
+ * calendar_event_vacation Vacation
+ * calendar_event_quicksave QuickSave
+ * calendar_event_vacation 151282889098
+ * calendar_event_vacation May 14 2016
+ * calendar_event_hold auto
+ * calendar_event_other NULL
+ * calendar_event_other HKhold
+ * calendar_event_other 8915FC00B0DA
+ * calendar_event_other 769347151
+ */
+
+ /*
+ * Thanks, ecobee...I more or less copied this code from the ecobee Follow
+ * Me graph to make sure it's as accurate as possible.
+ */
+ var this_calendar_event;
+
+ /*
+ * Display a fixed schedule in demo mode.
+ */
+ if (window.is_demo === true) {
+ var m = moment(ecobee_runtime_thermostat.timestamp);
+
+ // Moment and ecobee use different indexes for the days of the week
+ var day_of_week_index = (m.day() + 6) % 7;
+
+ // Ecobee splits the schedule up into 30 minute chunks; find the right one
+ var m_midnight = m.clone().startOf('day');
+ var minute_of_day = m.diff(m_midnight, 'minutes');
+ var chunk_of_day_index = Math.floor(minute_of_day / 30); // max 47
+
+ var ecobee_thermostat = beestat.cache.ecobee_thermostat[
+ thermostat.ecobee_thermostat_id
+ ];
+
+ this_calendar_event = 'calendar_event_' + ecobee_thermostat.json_program.schedule[day_of_week_index][chunk_of_day_index];
+ } else {
+ if (ecobee_runtime_thermostat.zone_calendar_event === null) {
+ if (ecobee_runtime_thermostat.zone_climate === null) {
+ this_calendar_event = 'calendar_event_other';
+ } else {
+ this_calendar_event = 'calendar_event_' + ecobee_runtime_thermostat.zone_climate.toLowerCase();
+ }
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/SmartRecovery/i) !== null) {
+ this_calendar_event = 'calendar_event_smartrecovery';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^home$/i) !== null) {
+ this_calendar_event = 'calendar_event_home';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^away$/i) !== null) {
+ this_calendar_event = 'calendar_event_away';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^smarthome$/i) !== null) {
+ this_calendar_event = 'calendar_event_smarthome';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^smartaway$/i) !== null) {
+ this_calendar_event = 'calendar_event_smartaway';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^auto$/i) !== null) {
+ this_calendar_event = 'calendar_event_hold';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^today$/i) !== null) {
+ this_calendar_event = 'calendar_event_hold';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^hold$/i) !== null) {
+ this_calendar_event = 'calendar_event_hold';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^vacation$/i) !== null) {
+ this_calendar_event = 'calendar_event_vacation';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/(\S\S\S\s\d+\s\d\d\d\d)|(\d{12})/i) !== null) {
+ this_calendar_event = 'calendar_event_vacation';
+ } else if (ecobee_runtime_thermostat.zone_calendar_event.match(/^quicksave$/i) !== null) {
+ this_calendar_event = 'calendar_event_quicksave';
+ } else {
+ this_calendar_event = 'calendar_event_other';
+ }
+ }
+
+
+ // Dynamically add new calendar events for custom climates.
+ if (
+ beestat.component.card.recent_activity.calendar_events.indexOf(this_calendar_event) === -1
+ ) {
+ beestat.component.card.recent_activity.calendar_events.push(this_calendar_event);
+
+ series[this_calendar_event] = {
+ 'enabled': false,
+ 'chart_data': [],
+ 'data': {},
+ 'durations': {}
+ };
+
+ beestat.series[this_calendar_event] = {
+ 'name': ecobee_runtime_thermostat.zone_climate,
+ 'color': beestat.style.color.bluegreen.base
+ };
+ }
+
+ beestat.component.card.recent_activity.calendar_events.forEach(function(calendar_event) {
+ if (calendar_event === this_calendar_event && this_calendar_event !== 'calendar_event_other') {
+ var value = 95;
+ series[calendar_event].enabled = true;
+ series[calendar_event].chart_data.push([
+ x,
+ value
+ ]);
+ series[calendar_event].data[x] = value;
+ } else {
+ series[calendar_event].chart_data.push([
+ x,
+ null
+ ]);
+ series[calendar_event].data[x] = null;
+ }
+ });
+
+ /*
+ * HVAC Mode. This isn't graphed but it's available for the tooltip.
+ * series.hvac_mode.chart_data.push([x, ecobee_runtime_thermostat.hvac_mode]);
+ */
+ series.hvac_mode.data[x] = ecobee_runtime_thermostat.hvac_mode;
+
+ // Setpoints
+ var setpoint_value_heat = beestat.temperature({'temperature': ecobee_runtime_thermostat.zone_heat_temperature});
+ var setpoint_value_cool = beestat.temperature({'temperature': ecobee_runtime_thermostat.zone_cool_temperature});
+
+ // NOTE: At one point I was also factoring in your heat/cool differential
+ // plus the extra degree offset ecobee adds when you are "away". That made
+ // the graph very exact but it wasn't really "setpoint" so I felt that would
+ // be confusing.
+
+ if (
+ ecobee_runtime_thermostat.hvac_mode === 'auto' ||
+ ecobee_runtime_thermostat.hvac_mode === 'heat' ||
+ ecobee_runtime_thermostat.hvac_mode === 'auxHeatOnly' ||
+ ecobee_runtime_thermostat.hvac_mode === null // Need this for the explicit null to remove from the graph.
+ ) {
+ series.setpoint_heat.data[x] = setpoint_value_heat;
+ series.setpoint_heat.chart_data.push([
+ x,
+ setpoint_value_heat
+ ]);
+
+ if (setpoint_value_heat !== null) {
+ self.y_min_ = Math.min(self.y_min_, setpoint_value_heat);
+ self.y_max_ = Math.max(self.y_max_, setpoint_value_heat);
+ }
+ }
+
+ if (
+ ecobee_runtime_thermostat.hvac_mode === 'auto' ||
+ ecobee_runtime_thermostat.hvac_mode === 'cool' ||
+ ecobee_runtime_thermostat.hvac_mode === null // Need this for the explicit null to remove from the graph.
+ ) {
+ series.setpoint_cool.data[x] = setpoint_value_cool;
+ series.setpoint_cool.chart_data.push([
+ x,
+ setpoint_value_cool
+ ]);
+
+ if (setpoint_value_cool !== null) {
+ self.y_min_ = Math.min(self.y_min_, setpoint_value_cool);
+ self.y_max_ = Math.max(self.y_max_, setpoint_value_cool);
+ }
+ }
+
+ // Indoor temperature
+ var indoor_temperature_value = beestat.temperature(ecobee_runtime_thermostat.zone_average_temperature);
+ series.indoor_temperature.data[x] = indoor_temperature_value;
+
+ /*
+ * Draw a data point if:
+ * It's one of the nth data points (smoothing) OR
+ * The previous value is null (forces data point right when null data stops instead of on the 10th) OR
+ * The current value is null (forces null data to display as a blank section) PR
+ * The next value is null (forces data point right when null data starts instead of on the 10th)
+ * The current value is the last value (forces data point right at the end)
+ */
+ if (
+ i % smoothing_factor === 0 ||
+ (
+ previous_indoor_temperature_value === null &&
+ indoor_temperature_value !== null
+ ) ||
+ indoor_temperature_value === null ||
+ (
+ beestat.cache.ecobee_runtime_thermostat[i + 1] !== undefined &&
+ beestat.cache.ecobee_runtime_thermostat[i + 1].zone_average_temperature === null
+ ) ||
+ i === (beestat.cache.ecobee_runtime_thermostat.length - 1)
+ ) {
+ series.indoor_temperature.enabled = true;
+ series.indoor_temperature.chart_data.push([
+ x,
+ indoor_temperature_value
+ ]);
+
+ if (indoor_temperature_value !== null) {
+ self.y_min_ = Math.min(self.y_min_, indoor_temperature_value);
+ self.y_max_ = Math.max(self.y_max_, indoor_temperature_value);
+ }
+ }
+
+ // Outdoor temperature
+ var outdoor_temperature_value = beestat.temperature(ecobee_runtime_thermostat.outdoor_temperature);
+ series.outdoor_temperature.data[x] = outdoor_temperature_value;
+
+ /*
+ * Draw a data point if:
+ * It's one of the 10th data points (smoothing) OR
+ * The previous value is null (forces data point right when null data stops instead of on the 10th) OR
+ * The current value is null (forces null data to display as a blank section) PR
+ * The next value is null (forces data point right when null data starts instead of on the 10th)
+ * The current value is the last value (forces data point right at the end)
+ */
+ if (
+ i % smoothing_factor === 0 ||
+ (
+ previous_outdoor_temperature_value === null &&
+ outdoor_temperature_value !== null
+ ) ||
+ outdoor_temperature_value === null ||
+ (
+ beestat.cache.ecobee_runtime_thermostat[i + 1] !== undefined &&
+ beestat.cache.ecobee_runtime_thermostat[i + 1].outdoor_temperature === null
+ ) ||
+ i === (beestat.cache.ecobee_runtime_thermostat.length - 1)
+ ) {
+ series.outdoor_temperature.enabled = true;
+ series.outdoor_temperature.chart_data.push([
+ x,
+ outdoor_temperature_value
+ ]);
+
+ if (outdoor_temperature_value !== null) {
+ self.y_min_ = Math.min(self.y_min_, outdoor_temperature_value);
+ self.y_max_ = Math.max(self.y_max_, outdoor_temperature_value);
+ }
+ }
+
+ // Indoor humidity
+ var indoor_humidity_value;
+ if (ecobee_runtime_thermostat.zone_humidity !== null) {
+ indoor_humidity_value = parseInt(
+ ecobee_runtime_thermostat.zone_humidity,
+ 10
+ );
+ } else {
+ indoor_humidity_value = null;
+ }
+ series.indoor_humidity.data[x] = indoor_humidity_value;
+
+ /*
+ * Draw a data point if:
+ * It's one of the 10th data points (smoothing) OR
+ * The previous value is null (forces data point right when null data stops instead of on the 10th) OR
+ * The current value is null (forces null data to display as a blank section) PR
+ * The next value is null (forces data point right when null data starts instead of on the 10th)
+ * The current value is the last value (forces data point right at the end)
+ */
+ if (
+ i % smoothing_factor === 0 ||
+ (
+ previous_indoor_humidity_value === null &&
+ indoor_humidity_value !== null
+ ) ||
+ indoor_humidity_value === null ||
+ (
+ beestat.cache.ecobee_runtime_thermostat[i + 1] !== undefined &&
+ beestat.cache.ecobee_runtime_thermostat[i + 1].zone_humidity === null
+ ) ||
+ i === (beestat.cache.ecobee_runtime_thermostat.length - 1)
+ ) {
+ series.indoor_humidity.enabled = true;
+ series.indoor_humidity.chart_data.push([
+ x,
+ indoor_humidity_value
+ ]);
+ }
+
+ // Outdoor humidity
+ var outdoor_humidity_value;
+ if (ecobee_runtime_thermostat.outdoor_humidity !== null) {
+ outdoor_humidity_value = parseInt(
+ ecobee_runtime_thermostat.outdoor_humidity,
+ 10
+ );
+ } else {
+ outdoor_humidity_value = null;
+ }
+ series.outdoor_humidity.data[x] = outdoor_humidity_value;
+
+ /*
+ * Draw a data point if:
+ * It's one of the 10th data points (smoothing) OR
+ * The previous value is null (forces data point right when null data stops instead of on the 10th) OR
+ * The current value is null (forces null data to display as a blank section) PR
+ * The next value is null (forces data point right when null data starts instead of on the 10th)
+ * The current value is the last value (forces data point right at the end)
+ */
+ if (
+ i % smoothing_factor === 0 ||
+ (
+ previous_outdoor_humidity_value === null &&
+ outdoor_humidity_value !== null
+ ) ||
+ outdoor_humidity_value === null ||
+ (
+ beestat.cache.ecobee_runtime_thermostat[i + 1] !== undefined &&
+ beestat.cache.ecobee_runtime_thermostat[i + 1].outdoor_humidity === null
+ ) ||
+ i === (beestat.cache.ecobee_runtime_thermostat.length - 1)
+ ) {
+ series.outdoor_humidity.enabled = true;
+ series.outdoor_humidity.chart_data.push([
+ x,
+ outdoor_humidity_value
+ ]);
+ }
+
+ previous_indoor_temperature_value = indoor_temperature_value;
+ previous_outdoor_temperature_value = outdoor_temperature_value;
+ previous_indoor_humidity_value = indoor_humidity_value;
+ previous_outdoor_humidity_value = outdoor_humidity_value;
+ });
+
+ return series;
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} Title
+ */
+beestat.component.card.recent_activity.prototype.get_title_ = function() {
+ return 'Recent Activity';
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string} Subtitle
+ */
+beestat.component.card.recent_activity.prototype.get_subtitle_ = function() {
+ var s = (beestat.setting('recent_activity_time_count') > 1) ? 's' : '';
+
+ return 'Past ' +
+ beestat.setting('recent_activity_time_count') +
+ ' ' +
+ beestat.setting('recent_activity_time_period') +
+ s;
+};
+
+/**
+ * Determine whether or not enough data is currently available to render this
+ * card. In this particular case require data from 7 days to an hour ago to be synced.
+ *
+ * @return {boolean} Whether or not the data is available.
+ */
+beestat.component.card.recent_activity.prototype.data_available_ = function() {
+ // Demo can juse grab whatever data is there.
+ if (window.is_demo === true) {
+ return true;
+ }
+
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var current_sync_begin = moment.utc(thermostat.sync_begin);
+ var current_sync_end = moment.utc(thermostat.sync_end);
+
+ var required_sync_begin = moment().subtract(7, 'day');
+ required_sync_begin = moment.max(
+ required_sync_begin,
+ moment(thermostat.first_connected)
+ );
+ var required_sync_end = moment().subtract(1, 'hour');
+
+ return (
+ current_sync_begin.isSameOrBefore(required_sync_begin) &&
+ current_sync_end.isSameOrAfter(required_sync_end)
+ );
+};
+
+/**
+ * Get the data needed to render this card.
+ */
+beestat.component.card.recent_activity.prototype.get_data_ = function() {
+ var self = this;
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ new beestat.api2()
+ .add_call(
+ 'ecobee_runtime_thermostat',
+ 'get_recent_activity',
+ {
+ 'ecobee_thermostat_id': thermostat.ecobee_thermostat_id,
+ 'begin': null,
+ 'end': null
+ }
+ )
+ .set_callback(function(response) {
+ beestat.cache.set('ecobee_runtime_thermostat', response);
+ self.rerender();
+ })
+ .send();
+};
diff --git a/js/component/card/score.js b/js/component/card/score.js
new file mode 100644
index 0000000..1d4d4f8
--- /dev/null
+++ b/js/component/card/score.js
@@ -0,0 +1,629 @@
+/**
+ * Parent score card.
+ */
+beestat.component.card.score = function() {
+ var self = this;
+
+ /*
+ * Debounce so that multiple setting changes don't re-trigger the same
+ * event. This fires on the trailing edge so that all changes are accounted
+ * for when rerendering.
+ */
+ var data_change_function = beestat.debounce(function() {
+ self.rerender();
+ }, 10);
+
+ beestat.dispatcher.addEventListener(
+ [
+ 'cache.data.comparison_temperature_profile',
+ 'cache.data.comparison_scores_' + this.type_
+ ],
+ data_change_function
+ );
+
+ beestat.component.card.apply(this, arguments);
+
+ this.layer_.register_loader(beestat.generate_temperature_profile);
+ this.layer_.register_loader(beestat.get_comparison_scores);
+};
+beestat.extend(beestat.component.card.score, beestat.component.card);
+
+/**
+ * Decorate
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.score.prototype.decorate_contents_ = function(parent) {
+ // this.view_detail_ = true;
+
+ if (this.view_detail_ === true) {
+ this.decorate_detail_(parent);
+ } else {
+ this.decorate_score_(parent);
+ }
+};
+
+/**
+ * Decorate the score with the circle.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.score.prototype.decorate_score_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+ var thermostat_group = beestat.cache.thermostat_group[
+ thermostat.thermostat_group_id
+ ];
+
+ if (
+ beestat.cache.data.comparison_temperature_profile === undefined ||
+ beestat.cache.data['comparison_scores_' + this.type_] === undefined
+ ) {
+ // Height buffer so the cards don't resize after they load.
+ parent.appendChild($.createElement('div')
+ .style('height', '166px')
+ .innerHTML(' '));
+ this.show_loading_('Calculating');
+ } else {
+ var percentile;
+ if (
+ thermostat_group.temperature_profile[this.type_] !== undefined &&
+ beestat.cache.data['comparison_scores_' + this.type_].length > 2 &&
+ beestat.cache.data.comparison_temperature_profile[this.type_] !== null
+ ) {
+ percentile = this.get_percentile_(
+ beestat.cache.data.comparison_temperature_profile[this.type_].score,
+ beestat.cache.data['comparison_scores_' + this.type_]
+ );
+ } else {
+ percentile = null;
+ }
+
+ var color;
+ if (percentile > 70) {
+ color = beestat.style.color.green.base;
+ } else if (percentile > 50) {
+ color = beestat.style.color.yellow.base;
+ } else if (percentile > 25) {
+ color = beestat.style.color.orange.base;
+ } else if (percentile !== null) {
+ color = beestat.style.color.red.base;
+ } else {
+ color = '#fff';
+ }
+
+ var container = $.createElement('div')
+ .style({
+ 'text-align': 'center',
+ 'position': 'relative',
+ 'margin-top': beestat.style.size.gutter
+ });
+ parent.appendChild(container);
+
+ var percentile_text;
+ var percentile_font_size;
+ var percentile_color;
+ if (percentile !== null) {
+ percentile_text = '';
+ percentile_font_size = 48;
+ percentile_color = '#fff';
+ } else if (
+ thermostat_group['system_type_' + this.type_] === null ||
+ thermostat_group['system_type_' + this.type_] === 'none'
+ ) {
+ percentile_text = 'None';
+ percentile_font_size = 16;
+ percentile_color = beestat.style.color.gray.base;
+ } else {
+ percentile_text = 'Insufficient data';
+ percentile_font_size = 16;
+ percentile_color = beestat.style.color.yellow.base;
+ }
+
+ var percentile_div = $.createElement('div')
+ .innerText(percentile_text)
+ .style({
+ 'position': 'absolute',
+ 'top': '50%',
+ 'left': '50%',
+ 'transform': 'translate(-50%, -50%)',
+ 'font-size': percentile_font_size,
+ 'font-weight': beestat.style.font_weight.light,
+ 'color': percentile_color
+ });
+ container.appendChild(percentile_div);
+
+ var stroke = 3;
+ var size = 150;
+ var diameter = size - stroke;
+ var radius = diameter / 2;
+ var circumference = Math.PI * diameter;
+
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('height', size);
+ svg.setAttribute('width', size);
+ svg.style.transform = 'rotate(-90deg)';
+ container.appendChild(svg);
+
+ var background = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ background.setAttribute('cx', (size / 2));
+ background.setAttribute('cy', (size / 2));
+ background.setAttribute('r', radius);
+ background.setAttribute('stroke', beestat.style.color.bluegray.dark);
+ background.setAttribute('stroke-width', stroke);
+ background.setAttribute('fill', 'none');
+ svg.appendChild(background);
+
+ var stroke_dasharray = circumference;
+ var stroke_dashoffset_initial = stroke_dasharray;
+ var stroke_dashoffset_final = stroke_dasharray * (1 - (percentile / 100));
+
+ var foreground = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ foreground.style.transition = 'stroke-dashoffset 1s ease';
+ foreground.setAttribute('cx', (size / 2));
+ foreground.setAttribute('cy', (size / 2));
+ foreground.setAttribute('r', radius);
+ foreground.setAttribute('stroke', color);
+ foreground.setAttribute('stroke-width', stroke);
+ foreground.setAttribute('stroke-linecap', 'round');
+ foreground.setAttribute('stroke-dasharray', stroke_dasharray);
+ foreground.setAttribute('stroke-dashoffset', stroke_dashoffset_initial);
+ foreground.setAttribute('fill', 'none');
+ svg.appendChild(foreground);
+
+ /*
+ * For some reason the render event (which is timeout 0) doesn't work well
+ * here.
+ */
+ setTimeout(function() {
+ foreground.setAttribute('stroke-dashoffset', stroke_dashoffset_final);
+
+ if (percentile !== null) {
+ $.step(
+ function(percentage, sine) {
+ var calculated_percentile = Math.round(percentile * sine);
+ percentile_div.innerText(calculated_percentile);
+ },
+ 1000,
+ null,
+ 30
+ );
+ }
+ }, 100);
+ }
+};
+
+/**
+ * Decorate the detail bell curve.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.score.prototype.decorate_detail_ = function(parent) {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ // var self = this;
+
+ this.chart_ = new beestat.component.chart();
+ this.chart_.options.chart.height = 166;
+
+ // if (
+ // beestat.cache.data.comparison_temperature_profile === undefined
+ // ) {
+ // this.chart_.render(parent);
+ // this.show_loading_('Calculating');
+ // } else {
+ // var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ // var x_categories = [];
+ // var trendlines = {};
+ // var raw = {};
+
+ // Global x range.
+/* var x_min = Infinity;
+ var x_max = -Infinity;
+
+ var y_min = Infinity;
+ var y_max = -Infinity;
+ for (var type in beestat.cache.data.comparison_temperature_profile) {
+ var profile = beestat.cache.data.comparison_temperature_profile[type];
+
+ if (profile !== null) {
+ // Convert the data to Celsius if necessary
+ var deltas_converted = {};
+ for (var key in profile.deltas) {
+ deltas_converted[beestat.temperature({'temperature': key})] =
+ beestat.temperature({
+ 'temperature': profile.deltas[key],
+ 'delta': true,
+ 'round': 3
+ });
+ }
+
+ profile.deltas = deltas_converted;
+ var linear_trendline = this.get_linear_trendline_(profile.deltas);
+
+ var min_max_keys = Object.keys(profile.deltas);
+
+ // This specific trendline x range.
+ var this_x_min = Math.min.apply(null, min_max_keys);
+ var this_x_max = Math.max.apply(null, min_max_keys);
+
+ // Global x range.
+ x_min = Math.min(x_min, this_x_min);
+ x_max = Math.max(x_max, this_x_max);
+
+ trendlines[type] = [];
+ raw[type] = [];
+
+ *
+ * Data is stored internally as °F with 1 value per degree. That data
+ * gets converted to °C which then requires additional precision
+ * (increment).
+ *
+ * The additional precision introduces floating point error, so
+ * convert the x value to a fixed string.
+ *
+ * The string then needs converted to a number for highcharts, so
+ * later on use parseFloat to get back to that.
+ *
+ * Stupid Celsius.
+
+ var increment;
+ var fixed;
+ if (thermostat.temperature_unit === '°F') {
+ increment = 1;
+ fixed = 0;
+ } else {
+ increment = 0.1;
+ fixed = 1;
+ }
+ for (var x = this_x_min; x <= this_x_max; x += increment) {
+ var x_fixed = x.toFixed(fixed);
+ var y = (linear_trendline.slope * x_fixed) +
+ linear_trendline.intercept;
+
+ trendlines[type].push([
+ parseFloat(x_fixed),
+ y
+ ]);
+ if (profile.deltas[x_fixed] !== undefined) {
+ raw[type].push([
+ parseFloat(x_fixed),
+ profile.deltas[x_fixed]
+ ]);
+ y_min = Math.min(y_min, profile.deltas[x_fixed]);
+ y_max = Math.max(y_max, profile.deltas[x_fixed]);
+ }
+ }
+ }
+ }
+
+ // Set y_min and y_max to be equal but opposite so the graph is always
+ // centered.
+ var absolute_y_max = Math.max(Math.abs(y_min), Math.abs(y_max));
+ y_min = absolute_y_max * -1;
+ y_max = absolute_y_max;*/
+
+ // y_min = -5;
+ // y_max = 5;
+ // x_min = Math.min(x_min, 0);
+ // x_max = Math.max(x_max, 100);
+
+ // Chart
+ // this.chart_.options.exporting.chartOptions.title.text = this.get_title_();
+ // this.chart_.options.exporting.chartOptions.subtitle.text = this.get_subtitle_();
+
+ // this.chart_.options.chart.backgroundColor = beestat.style.color.bluegray.base;
+ // this.chart_.options.exporting.filename = this.get_title_();
+ this.chart_.options.chart.zoomType = null;
+ // this.chart_.options.plotOptions.series.connectNulls = true;
+ this.chart_.options.legend = {'enabled': false};
+
+/* this.chart_.options.xAxis = {
+ 'lineWidth': 0,
+ 'tickLength': 0,
+ 'tickInterval': 5,
+ 'gridLineWidth': 1,
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ return this.value + thermostat.temperature_unit;
+ }
+ }
+ };*/
+
+
+ this.chart_.options.xAxis = {
+ 'title': { 'text': null },
+ 'plotLines': [
+ {
+ 'color': 'white',
+ 'width': 2,
+ 'value': beestat.cache.data.comparison_temperature_profile[this.type_].score
+
+ }
+ ]
+ // alignTicks: false
+ };
+ this.chart_.options.yAxis = {
+ 'title': { 'text': null }
+ };
+
+/* this.chart_.options.yAxis = [
+ {
+ 'alignTicks': false,
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'title': {'text': null},
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ return this.value + thermostat.temperature_unit;
+ }
+ },
+ 'min': y_min,
+ 'max': y_max,
+ 'plotLines': [
+ {
+ 'color': beestat.style.color.bluegray.light,
+ 'dashStyle': 'solid',
+ 'width': 3,
+ 'value': 0,
+ 'zIndex': 1
+ }
+ ]
+ }
+ ];*/
+
+/* this.chart_.options.tooltip = {
+ 'shared': true,
+ 'useHTML': true,
+ 'borderWidth': 0,
+ 'shadow': false,
+ 'backgroundColor': null,
+ 'followPointer': true,
+ 'crosshairs': {
+ 'width': 1,
+ 'zIndex': 100,
+ 'color': beestat.style.color.gray.light,
+ 'dashStyle': 'shortDot',
+ 'snap': false
+ },
+ 'positioner': function(tooltip_width, tooltip_height, point) {
+ return beestat.component.chart.tooltip_positioner(
+ self.chart_.get_chart(),
+ tooltip_width,
+ tooltip_height,
+ point
+ );
+ },
+ 'formatter': function() {
+ var sections = [];
+ var section = [];
+ this.points.forEach(function(point) {
+ var series = point.series;
+
+ var value = beestat.temperature({
+ 'temperature': point.y,
+ 'units': true,
+ 'convert': false,
+ 'delta': true,
+ 'type': 'string'
+ }) + ' / hour';
+
+ // if (series.name.indexOf('Raw') === -1) {
+ section.push({
+ 'label': series.name,
+ 'value': value,
+ 'color': series.color
+ });
+ // }
+ });
+ sections.push(section);
+
+ return beestat.component.chart.tooltip_formatter(
+ 'Outdoor Temp: ' +
+ beestat.temperature({
+ 'temperature': this.x,
+ 'round': 0,
+ 'units': true,
+ 'convert': false
+ }),
+ sections
+ );
+ }
+ };*/
+
+ // beestat.cache.data['comparison_scores_' + this.type_] = [ 0.4, 0.6, 0.6, 0.7, 0.8, 0.8, 0.8, 0.9, 0.9, 1, 1, 1, 1, 1, 1.1, 1.1, 1.1, 1.1, 1.2, 1.2, 1.2, 1.4, 1.4, 1.5, 1.5, 1.5, 1.5, 1.5, 1.6, 1.7, 1.8, 1.9, 2.3, 2.6, 2.7, 3.3, 3.3, 3.6, 5.9]
+
+
+ console.log(beestat.cache.data['comparison_scores_' + this.type_]);
+
+ var color = this.type_ === 'resist' ? beestat.style.color.gray.base : beestat.series['compressor_' + this.type_ + '_1'].color;
+
+ this.chart_.options.series = [
+
+
+ {
+ // 'data': trendlines.heat,
+ // 'name': 'Indoor Heat Δ',
+ // 'color': beestat.series.compressor_heat_1.color,
+ // 'marker': {
+ // 'enabled': false,
+ // 'states': {'hover': {'enabled': false}}
+ // },
+ 'type': 'bellcurve',
+ 'baseSeries': 1,
+ 'color': color,
+
+ // Histogram
+ // 'type': 'histogram',
+ // 'binWidth': 0.1,
+ // 'borderWidth': 0,
+
+ // 'data': beestat.cache.data['comparison_scores_' + this.type_]
+ // 'lineWidth': 2,
+ // 'states': {'hover': {'lineWidthPlus': 0}}
+ },
+{
+ 'data': beestat.cache.data['comparison_scores_' + this.type_],
+ 'visible': false
+ },
+
+ ];
+
+ console.log(parent);
+ // return;
+
+ // Trendline data
+ // this.chart_.options.series.push({
+ // 'data': trendlines.heat,
+ // 'name': 'Indoor Heat Δ',
+ // 'color': beestat.series.compressor_heat_1.color,
+ // 'marker': {
+ // 'enabled': false,
+ // 'states': {'hover': {'enabled': false}}
+ // },
+ // 'type': 'bellcurve',
+ // 'data': beestat.cache.data['comparison_scores_' + this.type_]
+ // 'lineWidth': 2,
+ // 'states': {'hover': {'lineWidthPlus': 0}}
+ // });
+
+ console.log('render chart');
+ this.chart_.render(parent);
+ // }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+};
+
+/**
+ * Get the percentile rank of a score in a set of scores.
+ *
+ * @param {number} score
+ * @param {array} scores
+ *
+ * @return {number} The percentile rank.
+ */
+beestat.component.card.score.prototype.get_percentile_ = function(score, scores) {
+ var n = scores.length;
+ var below = 0;
+ scores.forEach(function(s) {
+ if (s < score) {
+ below++;
+ }
+ });
+
+ return Math.round(below / n * 100);
+};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.score.prototype.decorate_top_right_ = function(parent) {
+ var self = this;
+
+ var menu = (new beestat.component.menu()).render(parent);
+
+ // menu.add_menu_item(new beestat.component.menu_item()
+ // .set_text('View Detail')
+ // .set_icon('chart_bell_curve')
+ // .set_callback(function() {
+ // self.view_detail_ = true;
+ // self.rerender();
+ // }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_score(self.type_)).render();
+ }));
+};
+
+/**
+ * Get subtitle.
+ *
+ * @return {string} The subtitle.
+ */
+beestat.component.card.score.prototype.get_subtitle_ = function() {
+ if (this.view_detail_ === true) {
+ if (
+ // beestat.cache.data['comparison_scores_' + this.type_] !== undefined &&
+ // beestat.cache.data['comparison_scores_' + this.type_].length > 2 &&
+ beestat.cache.data.comparison_temperature_profile !== undefined &&
+ beestat.cache.data.comparison_temperature_profile[this.type_] !== null
+ ) {
+ return 'Your raw score: ' + beestat.cache.data.comparison_temperature_profile[this.type_].score;
+ }
+
+ return 'N/A';
+ } else {
+ if (
+ beestat.cache.data['comparison_scores_' + this.type_] !== undefined &&
+ beestat.cache.data['comparison_scores_' + this.type_].length > 2 &&
+ beestat.cache.data.comparison_temperature_profile !== undefined &&
+ beestat.cache.data.comparison_temperature_profile[this.type_] !== null
+ ) {
+ return 'Comparing to ' + Number(beestat.cache.data['comparison_scores_' + this.type_].length).toLocaleString() + ' Homes';
+ }
+
+ return 'N/A';
+ }
+
+};
diff --git a/js/component/card/score/cool.js b/js/component/card/score/cool.js
new file mode 100644
index 0000000..19130b8
--- /dev/null
+++ b/js/component/card/score/cool.js
@@ -0,0 +1,26 @@
+/**
+ * Cool score card.
+ */
+beestat.component.card.score.cool = function() {
+ this.type_ = 'cool';
+ beestat.component.card.score.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.score.cool, beestat.component.card.score);
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title of the card.
+ */
+beestat.component.card.score.cool.prototype.get_title_ = function() {
+ return 'Cool Score';
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string} The title of the card.
+ */
+// beestat.component.card.score.cool.prototype.get_subtitle_ = function() {
+ // return '#hype';
+// };
diff --git a/js/component/card/score/heat.js b/js/component/card/score/heat.js
new file mode 100644
index 0000000..6a1043f
--- /dev/null
+++ b/js/component/card/score/heat.js
@@ -0,0 +1,26 @@
+/**
+ * Cool score card.
+ */
+beestat.component.card.score.heat = function() {
+ this.type_ = 'heat';
+ beestat.component.card.score.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.score.heat, beestat.component.card.score);
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title of the card.
+ */
+beestat.component.card.score.heat.prototype.get_title_ = function() {
+ return 'Heat Score';
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string} The title of the card.
+ */
+// beestat.component.card.score.heat.prototype.get_subtitle_ = function() {
+ // return '#hype';
+// };
diff --git a/js/component/card/score/resist.js b/js/component/card/score/resist.js
new file mode 100644
index 0000000..bac740d
--- /dev/null
+++ b/js/component/card/score/resist.js
@@ -0,0 +1,28 @@
+/**
+ * Resist score card.
+ */
+beestat.component.card.score.resist = function() {
+ this.type_ = 'resist';
+ beestat.component.card.score.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.score.resist, beestat.component.card.score);
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title of the card.
+ */
+beestat.component.card.score.resist.prototype.get_title_ = function() {
+ return 'Resist Score';
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string} The title of the card.
+ */
+/*
+ * beestat.component.card.score.resist.prototype.get_subtitle_ = function() {
+ * return '#hype';
+ * };
+ */
diff --git a/js/component/card/sensors.js b/js/component/card/sensors.js
new file mode 100644
index 0000000..643b7bb
--- /dev/null
+++ b/js/component/card/sensors.js
@@ -0,0 +1,182 @@
+/**
+ * Sensors
+ */
+beestat.component.card.sensors = function() {
+ var self = this;
+ beestat.dispatcher.addEventListener('poll', function() {
+ self.rerender();
+ });
+
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.sensors, beestat.component.card);
+
+beestat.component.card.sensors.prototype.decorate_contents_ = function(parent) {
+ var self = this;
+
+ var sensors = [];
+ var internal_sensor;
+ $.values(beestat.cache.sensor).forEach(function(sensor) {
+ if (sensor.thermostat_id === beestat.setting('thermostat_id')) {
+ if (sensor.type === 'thermostat') {
+ internal_sensor = sensor;
+ } else if (
+ sensor.type === 'ecobee3_remote_sensor' ||
+ sensor.type === 'switch_plus'
+ ) {
+ sensors.push(sensor);
+ }
+ }
+ });
+
+ sensors.sort(function(a, b) {
+ return a.name.localeCompare(b.name, 'en', {'sensitivity': 'base'});
+ });
+
+ /*
+ * Decorate the thermostat's internal sensor, if it has one. The Cor
+ * thermostats, for example, do not.
+ */
+ if (internal_sensor !== undefined) {
+ var internal_sensor_container = $.createElement('div');
+ parent.appendChild(internal_sensor_container);
+ this.decorate_sensor_(internal_sensor_container, internal_sensor.sensor_id);
+ }
+
+ // Decorate the rest of the sensors
+ if (sensors.length > 0) {
+ var sensor_container = $.createElement('div')
+ .style({
+ 'display': 'grid',
+ 'grid-template-columns': 'repeat(auto-fit, minmax(160px, 1fr))',
+ 'margin': '0 0 ' + beestat.style.size.gutter + 'px -' + beestat.style.size.gutter + 'px'
+ });
+ parent.appendChild(sensor_container);
+
+ sensors.forEach(function(sensor) {
+ var div = $.createElement('div')
+ .style({
+ 'padding': beestat.style.size.gutter + 'px 0 0 ' + beestat.style.size.gutter + 'px'
+ });
+ sensor_container.appendChild(div);
+
+ self.decorate_sensor_(div, sensor.sensor_id);
+ });
+ }
+};
+
+/**
+ * Decorate an individual sensor.
+ *
+ * @param {rocket.Elements} parent
+ * @param {number} sensor_id
+ */
+beestat.component.card.sensors.prototype.decorate_sensor_ = function(parent, sensor_id) {
+ var sensor = beestat.cache.sensor[sensor_id];
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var container = $.createElement('div')
+ .style({
+ 'background': beestat.style.color.bluegray.dark,
+ 'padding': (beestat.style.size.gutter / 2)
+ });
+ parent.appendChild(container);
+
+ var display_name = sensor.name;
+ if (sensor.type === 'thermostat') {
+ display_name += ' (Thermostat)';
+ }
+
+ var name = $.createElement('div')
+ .style({
+ 'font-weight': beestat.style.font_weight.bold,
+ 'margin-bottom': (beestat.style.size.gutter / 4),
+ 'white-space': 'nowrap',
+ 'overflow': 'hidden',
+ 'text-overflow': 'ellipsis'
+ })
+ .innerHTML(display_name);
+ container.appendChild(name);
+
+ // Construct the table
+ var table = $.createElement('table').style('width', '100%');
+ var tr = $.createElement('tr');
+ var td_temperature = $.createElement('td')
+ .style({
+ 'font-size': '18px',
+ 'width': '40px'
+ })
+ .innerHTML((sensor.temperature === null) ? '???' : beestat.temperature({
+ 'temperature': sensor.temperature,
+ 'type': 'string'
+ }));
+ var td_above_below = $.createElement('td')
+ .style({
+ 'width': '24px'
+ });
+ var td_icons = $.createElement('td')
+ .style({
+ 'text-align': 'right'
+ });
+
+ if (sensor.temperature < thermostat.temperature && sensor.temperature !== null) {
+ (new beestat.component.icon('menu_down'))
+ .set_color(beestat.style.color.blue.base)
+ .render(td_above_below);
+ } else if (sensor.temperature > thermostat.temperature && sensor.temperature !== null) {
+ (new beestat.component.icon('menu_up'))
+ .set_color(beestat.style.color.red.base)
+ .render(td_above_below);
+ }
+
+ if (sensor.occupancy === true) {
+ (new beestat.component.icon('eye')).render(td_icons);
+ } else {
+ (new beestat.component.icon('eye_off'))
+ .set_color(beestat.style.color.bluegray.light)
+ .render(td_icons);
+ }
+
+ td_icons.appendChild($.createElement('span').style({
+ 'display': 'inline-block',
+ 'width': (beestat.style.size.gutter / 4)
+ }));
+
+ if (sensor.in_use === true) {
+ (new beestat.component.icon('check')).render(td_icons);
+ } else {
+ (new beestat.component.icon('check'))
+ .set_color(beestat.style.color.bluegray.light)
+ .render(td_icons);
+ }
+
+ table.appendChild(tr);
+ tr.appendChild(td_temperature);
+ tr.appendChild(td_above_below);
+ tr.appendChild(td_icons);
+ container.appendChild(table);
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.sensors.prototype.get_title_ = function() {
+ return 'Sensors';
+};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.sensors.prototype.decorate_top_right_ = function(parent) {
+ var menu = (new beestat.component.menu()).render(parent);
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_sensors()).render();
+ }));
+};
diff --git a/js/component/card/system.js b/js/component/card/system.js
new file mode 100644
index 0000000..d5dbbd4
--- /dev/null
+++ b/js/component/card/system.js
@@ -0,0 +1,383 @@
+/**
+ * System card. Shows a big picture of your thermostat, it's sensors, and lets
+ * you switch between thermostats.
+ */
+beestat.component.card.system = function() {
+ var self = this;
+
+ beestat.dispatcher.addEventListener('poll', function() {
+ self.rerender();
+ });
+
+ beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.system, beestat.component.card);
+
+beestat.component.card.system.prototype.decorate_contents_ = function(parent) {
+ this.decorate_circle_(parent);
+ this.decorate_equipment_(parent);
+ this.decorate_climate_(parent);
+};
+
+/**
+ * Decorate the circle containing temperature and humidity.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.system.prototype.decorate_circle_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var temperature = beestat.temperature(thermostat.temperature);
+ var temperature_whole = Math.floor(temperature);
+ var temperature_fractional = (temperature % 1).toFixed(1).substring(2);
+
+ var circle = $.createElement('div')
+ .style({
+ 'padding': (beestat.style.size.gutter * 3),
+ 'border-radius': '50%',
+ 'background': beestat.get_thermostat_color(beestat.setting('thermostat_id')),
+ 'height': '180px',
+ 'width': '180px',
+ 'margin': beestat.style.size.gutter + 'px auto ' + beestat.style.size.gutter + 'px auto',
+ 'text-align': 'center',
+ 'text-shadow': '1px 1px 1px rgba(0, 0, 0, 0.2)'
+ });
+ parent.appendChild(circle);
+
+ var temperature_container = $.createElement('div');
+ circle.appendChild(temperature_container);
+
+ var temperature_whole_container = $.createElement('span')
+ .style({
+ 'font-size': '48px',
+ 'font-weight': beestat.style.font_weight.light
+ })
+ .innerHTML(temperature_whole);
+ temperature_container.appendChild(temperature_whole_container);
+
+ var temperature_fractional_container = $.createElement('span')
+ .style({
+ 'font-size': '24px'
+ })
+ .innerHTML('.' + temperature_fractional);
+ temperature_container.appendChild(temperature_fractional_container);
+
+ var humidity_container = $.createElement('div')
+ .style({
+ 'display': 'inline-flex',
+ 'align-items': 'center'
+ });
+ circle.appendChild(humidity_container);
+
+ (new beestat.component.icon('water_percent')
+ .set_size(24)
+ ).render(humidity_container);
+
+ humidity_container.appendChild($.createElement('span').innerHTML(thermostat.humidity + '%'));
+};
+
+/**
+ * Decorate the running equipment list on the bottom left.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.system.prototype.decorate_equipment_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var ecobee_thermostat = beestat.cache.ecobee_thermostat[
+ thermostat.ecobee_thermostat_id
+ ];
+
+ var running_equipment = [];
+
+ if (ecobee_thermostat.json_equipment_status.indexOf('fan') !== -1) {
+ running_equipment.push('fan');
+ }
+
+ if (ecobee_thermostat.json_equipment_status.indexOf('ventilator') !== -1) {
+ running_equipment.push('ventilator');
+ }
+ if (ecobee_thermostat.json_equipment_status.indexOf('humidifier') !== -1) {
+ running_equipment.push('humidifier');
+ }
+ if (ecobee_thermostat.json_equipment_status.indexOf('dehumidifier') !== -1) {
+ running_equipment.push('dehumidifier');
+ }
+ if (ecobee_thermostat.json_equipment_status.indexOf('economizer') !== -1) {
+ running_equipment.push('economizer');
+ }
+
+ if (ecobee_thermostat.json_equipment_status.indexOf('compCool2') !== -1) {
+ running_equipment.push('cool_2');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('compCool1') !== -1) {
+ running_equipment.push('cool_1');
+ }
+
+ if (ecobee_thermostat.json_settings.hasHeatPump === true) {
+ if (ecobee_thermostat.json_equipment_status.indexOf('heatPump3') !== -1) {
+ running_equipment.push('heat_3');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('heatPump2') !== -1) {
+ running_equipment.push('heat_2');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('heatPump') !== -1) {
+ running_equipment.push('heat_1');
+ }
+ if (ecobee_thermostat.json_equipment_status.indexOf('auxHeat3') !== -1) {
+ running_equipment.push('aux_3');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('auxHeat2') !== -1) {
+ running_equipment.push('aux_2');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('auxHeat1') !== -1) {
+ running_equipment.push('aux_1');
+ }
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('auxHeat3') !== -1) {
+ running_equipment.push('heat_3');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('auxHeat2') !== -1) {
+ running_equipment.push('heat_2');
+ } else if (ecobee_thermostat.json_equipment_status.indexOf('auxHeat1') !== -1) {
+ running_equipment.push('heat_1');
+ }
+
+ if (ecobee_thermostat.json_equipment_status.indexOf('compHotWater') !== -1) {
+ running_equipment.push('heat_1');
+ }
+ if (ecobee_thermostat.json_equipment_status.indexOf('auxHotWater') !== -1) {
+ running_equipment.push('aux_1');
+ }
+
+ var render_icon = function(parent, icon, color, text) {
+ (new beestat.component.icon(icon)
+ .set_size(24)
+ .set_color(color)
+ ).render(parent);
+
+ if (text !== undefined) {
+ var sub = $.createElement('sub')
+ .style({
+ 'font-size': '10px',
+ 'font-weight': beestat.style.font_weight.bold,
+ 'color': color
+ })
+ .innerHTML(text);
+ parent.appendChild(sub);
+ } else {
+ // A little spacer to help things look less uneven.
+ parent.appendChild($.createElement('span').style('margin-right', beestat.style.size.gutter / 4));
+ }
+ };
+
+ if (running_equipment.length === 0) {
+ running_equipment.push('nothing');
+ }
+
+ running_equipment.forEach(function(equipment) {
+ switch (equipment) {
+ case 'nothing':
+ render_icon(parent, 'cancel', beestat.style.color.gray.base, 'none');
+ break;
+ case 'fan':
+ render_icon(parent, 'fan', beestat.style.color.gray.light);
+ break;
+ case 'cool_1':
+ render_icon(parent, 'snowflake', beestat.style.color.blue.light, '1');
+ break;
+ case 'cool_2':
+ render_icon(parent, 'snowflake', beestat.style.color.blue.light, '2');
+ break;
+ case 'heat_1':
+ render_icon(parent, 'fire', beestat.style.color.orange.base, '1');
+ break;
+ case 'heat_2':
+ render_icon(parent, 'fire', beestat.style.color.orange.base, '2');
+ break;
+ case 'heat_3':
+ render_icon(parent, 'fire', beestat.style.color.orange.base, '3');
+ break;
+ case 'aux_1':
+ render_icon(parent, 'fire', beestat.style.color.red.base, '1');
+ break;
+ case 'aux_2':
+ render_icon(parent, 'fire', beestat.style.color.red.base, '2');
+ break;
+ case 'aux_3':
+ render_icon(parent, 'fire', beestat.style.color.red.base, '3');
+ break;
+ case 'humidifier':
+ render_icon(parent, 'water_percent', beestat.style.color.gray.base, '');
+ break;
+ case 'dehumidifier':
+ render_icon(parent, 'water_off', beestat.style.color.gray.base, '');
+ break;
+ case 'ventilator':
+ render_icon(parent, 'air_purifier', beestat.style.color.gray.base, 'v');
+ break;
+ case 'economizer':
+ render_icon(parent, 'cash', beestat.style.color.gray.base, '');
+ break;
+ }
+ });
+};
+
+/**
+ * Decorate the climate text on the bottom right.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.system.prototype.decorate_climate_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var ecobee_thermostat = beestat.cache.ecobee_thermostat[
+ thermostat.ecobee_thermostat_id
+ ];
+
+ var climate = beestat.get_climate(ecobee_thermostat.json_program.currentClimateRef);
+
+ var climate_container = $.createElement('div')
+ .style({
+ 'display': 'inline-flex',
+ 'align-items': 'center',
+ 'float': 'right'
+ });
+ parent.appendChild(climate_container);
+
+ var icon;
+ if (climate.climateRef === 'home') {
+ icon = 'home';
+ } else if (climate.climateRef === 'away') {
+ icon = 'update';
+ } else if (climate.climateRef === 'sleep') {
+ icon = 'alarm_snooze';
+ } else {
+ icon = (climate.isOccupied === true) ? 'home' : 'update';
+ }
+
+ (new beestat.component.icon(icon)
+ .set_size(24)
+ ).render(climate_container);
+
+ climate_container.appendChild($.createElement('span')
+ .innerHTML(climate.name)
+ .style('margin-left', beestat.style.size.gutter / 4));
+};
+
+/**
+ * Decorate the menu
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.system.prototype.decorate_top_right_ = function(parent) {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var menu = (new beestat.component.menu()).render(parent);
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Thermostat Info')
+ .set_icon('thermostat')
+ .set_callback(function() {
+ (new beestat.component.modal.thermostat_info()).render();
+ }));
+
+ if ($.values(thermostat.filters).length > 0) {
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Filter Info')
+ .set_icon('air_filter')
+ .set_callback(function() {
+ (new beestat.component.modal.filter_info()).render();
+ }));
+ }
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_system()).render();
+ }));
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.system.prototype.get_title_ = function() {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ return 'System - ' + thermostat.name;
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string}
+ */
+beestat.component.card.system.prototype.get_subtitle_ = function() {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ var ecobee_thermostat = beestat.cache.ecobee_thermostat[
+ thermostat.ecobee_thermostat_id
+ ];
+
+ var climate = beestat.get_climate(ecobee_thermostat.json_program.currentClimateRef);
+
+ // Is the temperature overridden?
+ var override = (
+ ecobee_thermostat.json_runtime.desiredHeat !== climate.heatTemp ||
+ ecobee_thermostat.json_runtime.desiredCool !== climate.coolTemp
+ );
+
+ // Get the heat/cool values to display.
+ var heat;
+ if (override === true) {
+ heat = ecobee_thermostat.json_runtime.desiredHeat / 10;
+ } else {
+ heat = climate.heatTemp / 10;
+ }
+
+ var cool;
+ if (override === true) {
+ cool = ecobee_thermostat.json_runtime.desiredCool / 10;
+ } else {
+ cool = climate.coolTemp / 10;
+ }
+
+ // Translate ecobee strings to GUI strings.
+ var hvac_modes = {
+ 'off': 'Off',
+ 'auto': 'Auto',
+ 'auxHeatOnly': 'Aux',
+ 'cool': 'Cool',
+ 'heat': 'Heat'
+ };
+
+ var hvac_mode = hvac_modes[ecobee_thermostat.json_settings.hvacMode];
+
+ var heat = beestat.temperature({
+ 'temperature': heat
+ });
+ var cool = beestat.temperature({
+ 'temperature': cool
+ });
+
+ var subtitle = hvac_mode;
+
+ if (ecobee_thermostat.json_settings.hvacMode !== 'off') {
+ if (override === true) {
+ subtitle += ' / Overridden';
+ } else {
+ subtitle += ' / Schedule';
+ }
+ }
+
+ if (ecobee_thermostat.json_settings.hvacMode === 'auto') {
+ subtitle += ' / ' + heat + ' - ' + cool;
+ } else if (
+ ecobee_thermostat.json_settings.hvacMode === 'heat' ||
+ ecobee_thermostat.json_settings.hvacMode === 'auxHeatOnly'
+ ) {
+ subtitle += ' / ' + heat;
+ } else if (
+ ecobee_thermostat.json_settings.hvacMode === 'cool'
+ ) {
+ subtitle += ' / ' + cool;
+ }
+
+ return subtitle;
+};
diff --git a/js/component/card/temperature_profiles.js b/js/component/card/temperature_profiles.js
new file mode 100644
index 0000000..c435bc0
--- /dev/null
+++ b/js/component/card/temperature_profiles.js
@@ -0,0 +1,409 @@
+/**
+ * Temperature profiles.
+ */
+beestat.component.card.temperature_profiles = function() {
+ var self = this;
+
+ beestat.dispatcher.addEventListener('cache.data.comparison_temperature_profile', function() {
+ self.rerender();
+ });
+
+ beestat.component.card.apply(this, arguments);
+
+ this.layer_.register_loader(beestat.generate_temperature_profile);
+};
+beestat.extend(beestat.component.card.temperature_profiles, beestat.component.card);
+
+/**
+ * Decorate card.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.temperature_profiles.prototype.decorate_contents_ = function(parent) {
+ var self = this;
+
+ this.chart_ = new beestat.component.chart();
+ this.chart_.options.chart.height = 300;
+
+ if (
+ beestat.cache.data.comparison_temperature_profile === undefined
+ ) {
+ this.chart_.render(parent);
+ this.show_loading_('Calculating');
+ } else {
+ var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+
+ // var x_categories = [];
+ var trendlines = {};
+ var raw = {};
+
+ // Global x range.
+ var x_min = Infinity;
+ var x_max = -Infinity;
+
+ var y_min = Infinity;
+ var y_max = -Infinity;
+ for (var type in beestat.cache.data.comparison_temperature_profile) {
+ var profile = beestat.cache.data.comparison_temperature_profile[type];
+
+ if (profile !== null) {
+ // Convert the data to Celsius if necessary
+ var deltas_converted = {};
+ for (var key in profile.deltas) {
+ deltas_converted[beestat.temperature({'temperature': key})] =
+ beestat.temperature({
+ 'temperature': profile.deltas[key],
+ 'delta': true,
+ 'round': 3
+ });
+ }
+
+ profile.deltas = deltas_converted;
+ var linear_trendline = this.get_linear_trendline_(profile.deltas);
+
+ var min_max_keys = Object.keys(profile.deltas);
+
+ // This specific trendline x range.
+ var this_x_min = Math.min.apply(null, min_max_keys);
+ var this_x_max = Math.max.apply(null, min_max_keys);
+
+ // Global x range.
+ x_min = Math.min(x_min, this_x_min);
+ x_max = Math.max(x_max, this_x_max);
+
+ trendlines[type] = [];
+ raw[type] = [];
+
+ /**
+ * Data is stored internally as °F with 1 value per degree. That data
+ * gets converted to °C which then requires additional precision
+ * (increment).
+ *
+ * The additional precision introduces floating point error, so
+ * convert the x value to a fixed string.
+ *
+ * The string then needs converted to a number for highcharts, so
+ * later on use parseFloat to get back to that.
+ *
+ * Stupid Celsius.
+ */
+ var increment;
+ var fixed;
+ if (thermostat.temperature_unit === '°F') {
+ increment = 1;
+ fixed = 0;
+ } else {
+ increment = 0.1;
+ fixed = 1;
+ }
+ for (var x = this_x_min; x <= this_x_max; x += increment) {
+ var x_fixed = x.toFixed(fixed);
+ var y = (linear_trendline.slope * x_fixed) +
+ linear_trendline.intercept;
+
+ trendlines[type].push([
+ parseFloat(x_fixed),
+ y
+ ]);
+ if (profile.deltas[x_fixed] !== undefined) {
+ raw[type].push([
+ parseFloat(x_fixed),
+ profile.deltas[x_fixed]
+ ]);
+ y_min = Math.min(y_min, profile.deltas[x_fixed]);
+ y_max = Math.max(y_max, profile.deltas[x_fixed]);
+ }
+ }
+ }
+ }
+
+ // Set y_min and y_max to be equal but opposite so the graph is always
+ // centered.
+ var absolute_y_max = Math.max(Math.abs(y_min), Math.abs(y_max));
+ y_min = absolute_y_max * -1;
+ y_max = absolute_y_max;
+
+ // y_min = -5;
+ // y_max = 5;
+ // x_min = Math.min(x_min, 0);
+ // x_max = Math.max(x_max, 100);
+
+ // Chart
+ this.chart_.options.exporting.chartOptions.title.text = this.get_title_();
+ this.chart_.options.exporting.chartOptions.subtitle.text = this.get_subtitle_();
+
+ this.chart_.options.chart.backgroundColor = beestat.style.color.bluegray.base;
+ this.chart_.options.exporting.filename = 'Temperature Profiles';
+ this.chart_.options.chart.zoomType = null;
+ this.chart_.options.plotOptions.series.connectNulls = true;
+ this.chart_.options.legend = {'enabled': false};
+
+ this.chart_.options.xAxis = {
+ // 'categories': x_categories,
+ // 'min': x_min,
+ // 'max': x_max,
+ 'lineWidth': 0,
+ 'tickLength': 0,
+ 'tickInterval': 5,
+ 'gridLineWidth': 1,
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ return this.value + thermostat.temperature_unit;
+ }
+ }
+ };
+
+ this.chart_.options.yAxis = [
+ {
+ 'alignTicks': false,
+ 'gridLineColor': beestat.style.color.bluegray.light,
+ 'gridLineDashStyle': 'longdash',
+ 'title': {'text': null},
+ 'labels': {
+ 'style': {'color': beestat.style.color.gray.base},
+ 'formatter': function() {
+ return this.value + thermostat.temperature_unit;
+ }
+ },
+ 'min': y_min,
+ 'max': y_max,
+ 'plotLines': [
+ {
+ 'color': beestat.style.color.bluegray.light,
+ 'dashStyle': 'solid',
+ 'width': 3,
+ 'value': 0,
+ 'zIndex': 1
+ }
+ ]
+ }
+ ];
+
+ this.chart_.options.tooltip = {
+ 'shared': true,
+ 'useHTML': true,
+ 'borderWidth': 0,
+ 'shadow': false,
+ 'backgroundColor': null,
+ 'followPointer': true,
+ 'crosshairs': {
+ 'width': 1,
+ 'zIndex': 100,
+ 'color': beestat.style.color.gray.light,
+ 'dashStyle': 'shortDot',
+ 'snap': false
+ },
+ 'positioner': function(tooltip_width, tooltip_height, point) {
+ return beestat.component.chart.tooltip_positioner(
+ self.chart_.get_chart(),
+ tooltip_width,
+ tooltip_height,
+ point
+ );
+ },
+ 'formatter': function() {
+ var sections = [];
+ var section = [];
+ this.points.forEach(function(point) {
+ var series = point.series;
+
+ var value = beestat.temperature({
+ 'temperature': point.y,
+ 'units': true,
+ 'convert': false,
+ 'delta': true,
+ 'type': 'string'
+ }) + ' / hour';
+
+ if (series.name.indexOf('Raw') === -1) {
+ section.push({
+ 'label': series.name,
+ 'value': value,
+ 'color': series.color
+ });
+ }
+ });
+ sections.push(section);
+
+ return beestat.component.chart.tooltip_formatter(
+ 'Outdoor Temp: ' +
+ beestat.temperature({
+ 'temperature': this.x,
+ 'round': 0,
+ 'units': true,
+ 'convert': false
+ }),
+ sections
+ );
+ }
+ };
+
+ this.chart_.options.series = [];
+
+ // Trendline data
+ this.chart_.options.series.push({
+ 'data': trendlines.heat,
+ 'name': 'Indoor Heat Δ',
+ 'color': beestat.series.compressor_heat_1.color,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'line',
+ 'lineWidth': 2,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ // Trendline data
+ this.chart_.options.series.push({
+ 'data': trendlines.cool,
+ 'name': 'Indoor Cool Δ',
+ 'color': beestat.series.compressor_cool_1.color,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'line',
+ 'lineWidth': 2,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ // Trendline data
+ this.chart_.options.series.push({
+ 'data': trendlines.resist,
+ 'name': 'Indoor Δ',
+ 'color': beestat.style.color.gray.dark,
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'line',
+ 'lineWidth': 2,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ // Raw data
+ this.chart_.options.series.push({
+ 'data': raw.heat,
+ 'name': 'Heat Raw',
+ 'color': beestat.series.compressor_heat_1.color,
+ 'dashStyle': 'ShortDot',
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'spline',
+ 'lineWidth': 1,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ // Raw data
+ this.chart_.options.series.push({
+ 'data': raw.cool,
+ 'name': 'Cool Raw',
+ 'color': beestat.series.compressor_cool_1.color,
+ 'dashStyle': 'ShortDot',
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'spline',
+ 'lineWidth': 1,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ // Raw data
+ this.chart_.options.series.push({
+ 'data': raw.resist,
+ 'name': 'Resist Raw',
+ 'color': beestat.style.color.gray.dark,
+ 'dashStyle': 'ShortDot',
+ 'marker': {
+ 'enabled': false,
+ 'states': {'hover': {'enabled': false}}
+ },
+ 'type': 'spline',
+ 'lineWidth': 1,
+ 'states': {'hover': {'lineWidthPlus': 0}}
+ });
+
+ this.chart_.render(parent);
+ }
+};
+
+/**
+ * Get a linear trendline from a set of data.
+ *
+ * @param {Object} data The data; at least two points required.
+ *
+ * @return {Object} The slope and intercept of the trendline.
+ */
+beestat.component.card.temperature_profiles.prototype.get_linear_trendline_ = function(data) {
+ // Requires at least two points.
+ if (Object.keys(data).length < 2) {
+ return null;
+ }
+
+ var sum_x = 0;
+ var sum_y = 0;
+ var sum_xy = 0;
+ var sum_x_squared = 0;
+ var n = 0;
+
+ for (var x in data) {
+ x = parseFloat(x);
+ var y = parseFloat(data[x]);
+
+ sum_x += x;
+ sum_y += y;
+ sum_xy += (x * y);
+ sum_x_squared += Math.pow(x, 2);
+ n++;
+ }
+
+ var slope = ((n * sum_xy) - (sum_x * sum_y)) /
+ ((n * sum_x_squared) - (Math.pow(sum_x, 2)));
+ var intercept = ((sum_y) - (slope * sum_x)) / (n);
+
+ return {
+ 'slope': slope,
+ 'intercept': intercept
+ };
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title.
+ */
+beestat.component.card.temperature_profiles.prototype.get_title_ = function() {
+ return 'Temperature Profiles';
+};
+
+/**
+ * Decorate the menu.
+ *
+ * @param {rocket.Elements} parent
+ */
+beestat.component.card.temperature_profiles.prototype.decorate_top_right_ = function(parent) {
+ var self = this;
+
+ var menu = (new beestat.component.menu()).render(parent);
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Download Chart')
+ .set_icon('download')
+ .set_callback(function() {
+ self.chart_.get_chart().exportChartLocal();
+ }));
+
+ menu.add_menu_item(new beestat.component.menu_item()
+ .set_text('Help')
+ .set_icon('help_circle')
+ .set_callback(function() {
+ (new beestat.component.modal.help_temperature_profiles()).render();
+ }));
+};
diff --git a/js/component/chart.js b/js/component/chart.js
new file mode 100644
index 0000000..f050c71
--- /dev/null
+++ b/js/component/chart.js
@@ -0,0 +1,329 @@
+/**
+ * A chart. Mostly just a wrapper for the Highcharts stuff so the defaults
+ * don't have to be set every single time.
+ */
+beestat.component.chart = function() {
+ var self = this;
+
+ this.options = {};
+
+ this.options.credits = false;
+
+ this.options.exporting = {
+ 'enabled': false,
+ 'sourceWidth': 980,
+ 'scale': 1,
+ 'filename': 'beestat',
+ 'chartOptions': {
+ 'credits': {
+ 'text': 'beestat.io'
+ },
+ 'title': {
+ 'align': 'left',
+ 'text': null,
+ 'margin': beestat.style.size.gutter,
+ 'style': {
+ 'color': '#fff',
+ 'font-weight': beestat.style.font_weight.bold,
+ 'font-size': beestat.style.font_size.large
+ }
+ },
+ 'subtitle': {
+ 'align': 'left',
+ 'text': null,
+ 'style': {
+ 'color': '#fff',
+ 'font-weight': beestat.style.font_weight.light,
+ 'font-size': beestat.style.font_size.normal
+ }
+ },
+ 'chart': {
+ 'style': {
+ 'fontFamily': 'Montserrat, Helvetica, Sans-Serif'
+ },
+ 'spacing': [
+ beestat.style.size.gutter,
+ beestat.style.size.gutter,
+ beestat.style.size.gutter,
+ beestat.style.size.gutter
+ ]
+ }
+ }
+ };
+
+ this.options.chart = {
+ 'style': {
+ 'fontFamily': 'Montserrat'
+ },
+ 'spacing': [
+ beestat.style.size.gutter,
+ 0,
+ 0,
+ 0
+ ],
+ 'zoomType': 'x',
+ 'panning': true,
+ 'panKey': 'ctrl',
+ 'backgroundColor': beestat.style.color.bluegray.base,
+ 'resetZoomButton': {
+ 'theme': {
+ 'display': 'none'
+ }
+ }
+ };
+
+ this.options.title = {
+ 'text': null
+ };
+ this.options.subtitle = {
+ 'text': null
+ };
+
+ this.options.legend = {
+ 'itemStyle': {
+ 'color': '#ecf0f1',
+ 'font-weight': '500'
+ },
+ 'itemHoverStyle': {
+ 'color': '#bdc3c7'
+ },
+ 'itemHiddenStyle': {
+ 'color': '#7f8c8d'
+ }
+ };
+
+ this.options.plotOptions = {
+ 'series': {
+ 'animation': false,
+ 'marker': {
+ 'enabled': false
+ },
+ 'states': {
+ 'hover': {
+ 'enabled': false
+ }
+ }
+ },
+ 'column': {
+ 'pointPadding': 0,
+ 'borderWidth': 0,
+ 'stacking': 'normal',
+ 'dataLabels': {
+ 'enabled': false
+ }
+ }
+ };
+
+ this.addEventListener('render', function() {
+ self.chart_.reflow();
+ });
+
+ beestat.component.apply(this, arguments);
+};
+beestat.extend(beestat.component.chart, beestat.component);
+
+beestat.component.chart.prototype.rerender_on_breakpoint_ = false;
+
+beestat.component.chart.prototype.decorate_ = function(parent) {
+ this.options.chart.renderTo = parent[0];
+ this.chart_ = Highcharts.chart(this.options);
+
+ // parent.style('position', 'relative');
+};
+
+/**
+ * Get the Highcharts chart object
+ *
+ * @return {object}
+ */
+beestat.component.chart.prototype.get_chart = function() {
+ return this.chart_;
+};
+
+/**
+ * Generate a number of colors between two points.
+ *
+ * @param {Object} begin RGB begin color
+ * @param {Object} end RGB end color
+ * @param {number} steps Number of colors to generate
+ *
+ * @see http://forums.codeguru.com/showthread.php?259953-Code-to-create-Color-Gradient-programatically&s=4710043a327ee6059da1f8433ad1e5d2&p=795289#post795289
+ *
+ * @private
+ *
+ * @return {Array.