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 + + + + + + + + + + +
+
+
+
+
+ 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.} RGB color array + */ +beestat.component.chart.generate_gradient = function(begin, end, steps) { + var gradient = []; + for (var i = 0; i < steps; i++) { + var n = i / (steps - 1); + gradient.push({ + 'r': Math.round(begin.r * (1 - n) + end.r * n), + 'g': Math.round(begin.g * (1 - n) + end.g * n), + 'b': Math.round(begin.b * (1 - n) + end.b * n) + }); + } + return gradient; +}; + +beestat.component.chart.tooltip_positioner = function( + chart, + tooltip_width, + tooltip_height, + point +) { + var plot_width = chart.plotWidth; + + var fits_on_left = (point.plotX - tooltip_width) > 0; + var fits_on_right = (point.plotX + tooltip_width) < plot_width; + + var x; + var y = 60; + if (fits_on_left === true) { + x = point.plotX - tooltip_width + chart.plotLeft; + } else if (fits_on_right === true) { + x = point.plotX + chart.plotLeft; + } else { + x = chart.plotLeft; + } + + return { + 'x': x, + 'y': y + }; +}; + +/** + * Get the HTML needed to render a tooltip. + * + * @param {string} title The tooltip title. + * @param {array} sections Data inside the tooltip. + * + * @return {string} The tooltip HTML. + */ +beestat.component.chart.tooltip_formatter = function(title, sections) { + var tooltip = $.createElement('div') + .style({ + 'background-color': beestat.style.color.bluegray.dark, + 'padding': beestat.style.size.gutter / 2 + }); + + var title_div = $.createElement('div') + .style({ + 'font-weight': beestat.style.font_weight.bold, + 'font-size': beestat.style.font_size.large, + 'margin-bottom': beestat.style.size.gutter / 4, + 'color': beestat.style.color.gray.light + }) + .innerText(title); + tooltip.appendChild(title_div); + + var table = $.createElement('table') + .setAttribute({ + 'cellpadding': '0', + 'cellspacing': '0' + }); + tooltip.appendChild(table); + + sections.forEach(function(section, i) { + if (section.length > 0) { + section.forEach(function(item) { + var tr = $.createElement('tr').style('color', item.color); + table.appendChild(tr); + + var td_label = $.createElement('td') + .style({ + 'min-width': '115px', + 'font-weight': beestat.style.font_weight.bold + }) + .innerText(item.label); + tr.appendChild(td_label); + + var td_value = $.createElement('td').innerText(item.value); + tr.appendChild(td_value); + }); + + if (i < sections.length) { + var spacer_tr = $.createElement('tr'); + table.appendChild(spacer_tr); + + var spacer_td = $.createElement('td') + .style('padding-bottom', beestat.style.size.gutter / 4); + spacer_tr.appendChild(spacer_td); + } + } + }); + + return tooltip[0].outerHTML; +}; + +beestat.component.chart.get_outdoor_temperature_zones = function() { + + /* + * This will get me one color for every degree on a nice gradient without + * using the multicolor series plugin. Very cool. + */ + var zone_definitions = [ + { + 'value': beestat.temperature(-20), + 'color': beestat.style.hex_to_rgb(beestat.style.color.lightblue.base) + }, + { + 'value': beestat.temperature(30), + 'color': beestat.style.hex_to_rgb(beestat.style.color.lightblue.base) + }, + { + 'value': beestat.temperature(60), + 'color': beestat.style.hex_to_rgb(beestat.style.color.green.base) + }, + { + 'value': beestat.temperature(75), + 'color': beestat.style.hex_to_rgb(beestat.style.color.yellow.base) + }, + { + 'value': beestat.temperature(90), + 'color': beestat.style.hex_to_rgb(beestat.style.color.red.base) + }, + { + 'value': beestat.temperature(120), + 'color': beestat.style.hex_to_rgb(beestat.style.color.red.base) + } + ]; + + var zones = []; + var zone_divisor = 1; // Increase this to like 2 or 3 if there are performance issues with this series. + for (var i = 0; i < zone_definitions.length - 1; i++) { + var gradient = beestat.component.chart.generate_gradient( + zone_definitions[i].color, + zone_definitions[i + 1].color, + Math.ceil((zone_definitions[i + 1].value - zone_definitions[i].value) / zone_divisor) + ); + for (var j = 0; j < gradient.length; j++) { + zones.push({ + 'value': zone_definitions[i].value + j, + 'color': 'rgb(' + gradient[j].r + ',' + gradient[j].g + ',' + gradient[j].b + ')' + }); + } + } + + return zones; +}; + +/** + * Wrap the highcharts SVG function with this to embed Montserrat 300 so the + * graph downloads don't look like garbage. + */ +Highcharts.wrap(Highcharts, 'downloadSVGLocal', function( + p, + svg, + options, + failCallback, + successCallback +) { + p( + svg.replace(/<\/svg/, '$&'), + options, + failCallback, + successCallback + ); +}); diff --git a/js/component/header.js b/js/component/header.js new file mode 100644 index 0000000..60d4502 --- /dev/null +++ b/js/component/header.js @@ -0,0 +1,162 @@ +/** + * Header component for all of the layers. + * + * @param {string} active_layer The currently active layer. + */ +beestat.component.header = function(active_layer) { + var self = this; + + this.active_layer_ = active_layer; + + beestat.dispatcher.addEventListener('view_announcements', function() { + self.rerender(); + }); + + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.header, beestat.component); + +beestat.component.header.prototype.rerender_on_breakpoint_ = true; + +beestat.component.header.prototype.decorate_ = function(parent) { + var self = this; + + var pages; + pages = [ + { + 'layer': 'dashboard', + 'text': 'Dashboard', + 'icon': 'gauge' + }, + { + 'layer': 'home_comparisons', + 'text': 'Home Comparisons', + 'icon': 'home' + } + ]; + + var gutter = beestat.style.size.gutter; + + var row = $.createElement('div').style({ + 'display': 'flex', + 'align-items': 'center', + 'flex-grow': '1', + 'margin': '-' + gutter + 'px 0 0 -' + gutter + 'px' + }); + parent.appendChild(row); + + // Logo + var column_logo = $.createElement('div').style({'padding': gutter + 'px 0 0 ' + gutter + 'px'}); + row.appendChild(column_logo); + if (beestat.width > 600) { + column_logo.style({'flex': '0 0 160px'}); + (new beestat.component.logo()).render(column_logo); + } else { + column_logo.style({'flex': '0 0 32px'}); + var img = $.createElement('img') + .setAttribute('src', '/favicon.png') + .style({ + 'width': '32px', + 'height': '32px', + 'margin-top': '11px', + 'margin-bottom': '6px' + }); + column_logo.appendChild(img); + } + + // Navigation + var column_navigation = $.createElement('div').style({ + 'flex': '1', + 'padding': gutter + 'px 0 0 ' + gutter + 'px' + }); + row.appendChild(column_navigation); + + var button_group = new beestat.component.button_group(); + pages.forEach(function(page) { + var button = new beestat.component.button() + .set_icon(page.icon) + .set_text_color(beestat.style.color.bluegray.dark); + + if (beestat.width > 500) { + button.set_text(page.text); + } + + if (self.active_layer_ === page.layer) { + button + .set_background_color('#fff') + .set_text_color(beestat.style.color.bluegray.dark); + } else { + button + .set_text_color('#fff') + .set_background_hover_color('#fff') + .set_text_hover_color(beestat.style.color.bluegray.dark); + + button.addEventListener('click', function() { + (new beestat.layer[page.layer]()).render(); + }); + } + + button_group.add_button(button); + }); + + button_group.render(column_navigation); + + // Menu + + var last_read_announcement_id = beestat.setting('last_read_announcement_id'); + var unread_announcement_count = Object.keys(beestat.cache.announcement) + .filter(function(announcement_id) { + return announcement_id > last_read_announcement_id; + }).length; + + var column_menu = $.createElement('div').style({ + 'flex': '0 0 50px', + 'padding': gutter + 'px 0 0 ' + gutter + 'px', + 'text-align': 'right' + }); + row.appendChild(column_menu); + var menu = new beestat.component.menu(); + if (unread_announcement_count > 0) { + menu + .set_bubble_text(unread_announcement_count) + .set_bubble_color(beestat.style.color.red.base); + } + menu.render(column_menu); + + if (Object.keys(beestat.cache.ecobee_thermostat).length > 1) { + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Switch Thermostat') + .set_icon('swap_horizontal') + .set_callback(function() { + (new beestat.component.modal.change_thermostat()).render(); + })); + } + + var announcements_menu_item = new beestat.component.menu_item() + .set_text('Announcements') + .set_icon('bullhorn') + .set_callback(function() { + (new beestat.component.modal.announcements()).render(); + }); + + if (unread_announcement_count > 0) { + announcements_menu_item + .set_bubble_text(unread_announcement_count) + .set_bubble_color(beestat.style.color.red.base); + } + menu.add_menu_item(announcements_menu_item); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Log Out') + .set_icon('exit_to_app') + .set_callback(function() { + beestat.api( + 'user', + 'log_out', + {'all': false}, + function() { + window.location.reload(); + } + ); + })); +}; diff --git a/js/component/icon.js b/js/component/icon.js new file mode 100644 index 0000000..c16896c --- /dev/null +++ b/js/component/icon.js @@ -0,0 +1,182 @@ +beestat.component.icon = function(icon_name) { + this.icon_name_ = icon_name; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.icon, beestat.component); + +beestat.component.icon.prototype.rerender_on_breakpoint_ = false; + +beestat.component.icon.prototype.decorate_ = function(parent) { + var self = this; + + // TODO This works but really icons need to be put into their own containers if I want this + parent.style('display', 'inline-block'); + + var container = $.createElement('div') + .style({ + 'display': 'flex', + 'align-items': 'center' + }); + parent.appendChild(container); + + var icon = $.createElement('div') + .style('position', 'relative') + .addClass([ + 'icon', + this.icon_name_ + ]); + container.appendChild(icon); + + if (this.size_ !== undefined && this.size_ !== 24) { + icon.addClass('f' + this.size_); + } + + if (this.color_ !== undefined) { + container.style('color', this.color_); + } + + if (this.text_ !== undefined) { + var text = $.createElement('span') + .style({ + 'margin-left': (beestat.style.size.gutter / 2) + }) + .innerText(this.text_); + + container.appendChild(text); + } + + // Hover + if (this.hover_color_ !== undefined) { + container.style({ + 'cursor': 'pointer', + 'transition': 'color 200ms ease' + }); + + container.addEventListener('mouseenter', function() { + container.style('color', self.hover_color_); + }); + container.addEventListener('mouseleave', function() { + container.style('color', self.color_ || ''); + }); + } + + container.addEventListener('click', function() { + self.dispatchEvent('click'); + }); + + // Bubble + if (this.bubble_text_ !== undefined) { + var bubble = $.createElement('div') + .style({ + 'background': this.bubble_color_ || beestat.style.color.blue.base, + 'position': 'absolute', + 'top': 0, + 'right': 0, + 'border-radius': '6px', + 'height': '12px', + 'line-height': '12px', + 'min-width': '12px', + 'text-align': 'center', + 'color': '#fff', + 'font-size': '10px' + }) + .innerText(this.bubble_text_); + icon.appendChild(bubble); + } + + this.parent_ = icon; +}; + +/** + * Set the color of the icon. + * + * @param {string} color Any supported CSS color string. + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.set_color = function(color) { + this.color_ = color; + return this; +}; + +/** + * Set the hover color of the icon + * + * @param {string} hover_color Any supported CSS color string + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.set_hover_color = function(hover_color) { + this.hover_color_ = hover_color; + return this; +}; + +/** + * Set the text of the icon. + * + * @param {string} text + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.set_text = function(text) { + this.text_ = text; + return this; +}; + +/** + * Set the size of the icon + * + * @param {number} size + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.set_size = function(size) { + this.size_ = size; + return this; +}; + +/** + * Set the text of the bubble. + * + * @param {string} bubble_text + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.set_bubble_text = function(bubble_text) { + this.bubble_text_ = bubble_text; + return this; +}; + +/** + * Set the color of the bubble. + * + * @param {string} bubble_color + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.set_bubble_color = function(bubble_color) { + this.bubble_color_ = bubble_color; + return this; +}; + +/** + * Do the normal event listener stuff, but also wrap the icon in an tag to + * automatically take advantage of link styles. + * + * Note: Must call after the icon is rendered. + * + * @return {beestat.component.icon} This. + */ +beestat.component.icon.prototype.addEventListener = function() { + rocket.EventTarget.prototype.addEventListener.apply(this, arguments); + return this; +}; + +/** + * Get the icon element. + * + * @return {rocket.Elements} The icon element. + */ +beestat.component.icon.prototype.get_element = function() { + return this.parent_; +}; diff --git a/js/component/input.js b/js/component/input.js new file mode 100644 index 0000000..6d0079b --- /dev/null +++ b/js/component/input.js @@ -0,0 +1,52 @@ +/** + * Input parent class. + */ +beestat.component.input = function() { + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.input, beestat.component); + +beestat.component.input.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate + * + * @param {rocket.Elements} parent + * + * @return {beestat.component.input} This. + */ +beestat.component.input.prototype.decorate_ = function(parent) {}; + +beestat.component.input.prototype.focus = function() { + this.input_.focus(); + this.input_[0].setSelectionRange(0, this.input_.value().length); + return this; +}; + +beestat.component.input.prototype.disable = function() { + this.input_[0].disabled = true; + return this; +}; + +beestat.component.input.prototype.enable = function() { + this.input_[0].disabled = false; + return this; +}; + +/** + * Generic setter that sets a key to a value, rerenders if necessary, and + * returns this. + * + * @param {string} key + * @param {string} value + * + * @return {beestat.component.input} This. + */ +beestat.component.input.prototype.set_ = function(key, value) { + this[key + '_'] = value; + if (this.rendered_ === true) { + this.rerender(); + } + + return this; +}; diff --git a/js/component/input/text.js b/js/component/input/text.js new file mode 100644 index 0000000..37823b5 --- /dev/null +++ b/js/component/input/text.js @@ -0,0 +1,130 @@ +/** + * Input parent class. + */ +beestat.component.input.text = function() { + this.input_ = $.createElement('input'); + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.input.text, beestat.component.input); + +beestat.component.input.text.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.input.text.prototype.decorate_ = function(parent) { + var self = this; + + this.input_ + .setAttribute('type', 'text') + .style({ + 'border': 'none', + 'background': beestat.style.color.bluegray.light, + 'border-radius': beestat.style.size.border_radius, + // 'border-bottom': ('1px solid ' + beestat.style.color.gray.dark), + 'padding': (beestat.style.size.gutter / 2), + // 'background': 'none', + 'color': '#fff', + 'outline': 'none', + 'transition': 'background 200ms ease' + }); + + if (this.style_ !== undefined) { + this.input_.style(this.style_); + } + + if (this.attribute_ !== undefined) { + this.input_.setAttribute(this.attribute_); + } + + // If we want an icon just drop one on top of the input and add some padding. + if (this.icon_ !== undefined) { + var icon_container = $.createElement('div') + .style({ + 'position': 'absolute', + 'top': '7px', + 'left': '6px' + }); + parent.appendChild(icon_container); + + this.input_.style({ + 'padding-left': '24px' + }); + + (new beestat.component.icon(this.icon_).set_size(16).set_color('#fff')).render(icon_container); + } + + this.input_.addEventListener('focus', function() { + self.input_.style({ + 'background': beestat.style.color.bluegray.dark + }); + }); + + this.input_.addEventListener('blur', function() { + self.dispatchEvent('blur'); + self.input_.style({ + 'background': beestat.style.color.bluegray.light + }); + }); + + if (this.value_ !== undefined) { + this.input_.value(this.value_); + } + + parent.appendChild(this.input_); +}; + +/** + * Set the value in the input field. + * + * @param {string} value + * + * @return {beestat.component.input.text} This. + */ +beestat.component.input.text.prototype.set_value = function(value) { + return this.set_('value', value); +}; + +/** + * Get the value in the input field. + * + * @return {string} The value in the input field. + */ +beestat.component.input.text.prototype.get_value = function() { + return this.input_.value(); +}; + +/** + * Set the style of the input field. Overrides any default styles. + * + * @param {object} style + * + * @return {beestat.component.input.text} This. + */ +beestat.component.input.text.prototype.set_style = function(style) { + return this.set_('style', style); +}; + +/** + * Set the attributes of the input field. Overrides any default attributes. + * + * @param {object} attribute + * + * @return {beestat.component.input.text} This. + */ +beestat.component.input.text.prototype.set_attribute = function(attribute) { + return this.set_('attribute', attribute); +}; + +/** + * Set the icon of the input field. + * + * @param {string} icon + * + * @return {beestat.component.input.text} This. + */ +beestat.component.input.text.prototype.set_icon = function(icon) { + return this.set_('icon', icon); +}; diff --git a/js/component/layout.js b/js/component/layout.js new file mode 100644 index 0000000..845dd9f --- /dev/null +++ b/js/component/layout.js @@ -0,0 +1,38 @@ +/** + * Takes a bunch of rows/columns and lays them out nicely on the page. + * + * @param {Array} rows + */ +beestat.component.layout = function(rows) { + this.rows_ = rows; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.layout, beestat.component); + +beestat.component.layout.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate. Not much thinking to be done here; all the grid layout stuff is + * built in CSS. + * + * @param {rocket.Elements} parent + */ +beestat.component.layout.prototype.decorate_ = function(parent) { + this.rows_.forEach(function(row) { + var row_element = $.createElement('div').addClass('row'); + parent.appendChild(row_element); + + // Create the columns + row.forEach(function(column) { + var column_element = $.createElement('div') + .addClass('column') + .addClass('column_' + column.size); + row_element.appendChild(column_element); + + column.card.render(column_element); + if (column.global !== undefined) { + beestat.cards[column.global] = column.card; + } + }); + }); +}; diff --git a/js/component/loading.js b/js/component/loading.js new file mode 100644 index 0000000..1be23c4 --- /dev/null +++ b/js/component/loading.js @@ -0,0 +1,42 @@ +/** + * Loading bar + */ +beestat.component.loading = function(text) { + this.text_ = text; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.loading, beestat.component); + +beestat.component.loading.prototype.rerender_on_breakpoint_ = false; + +beestat.component.loading.prototype.decorate_ = function(parent) { + if (this.text_ !== undefined) { + this.text_block_ = $.createElement('div') + .style({ + 'margin-bottom': beestat.style.size.gutter, + 'color': beestat.style.color.yellow.base, + 'font-weight': beestat.style.font_weight.bold + }) + .innerHTML(this.text_); + + parent.appendChild(this.text_block_); + } + + var loading_wrapper = $.createElement('div').addClass('loading_wrapper'); + parent.appendChild(loading_wrapper); + + loading_wrapper.appendChild($.createElement('div').addClass('loading_1')); + loading_wrapper.appendChild($.createElement('div').addClass('loading_2')); +}; + +/** + * Set the text of the loading container. If you call this after it's rendered + * it will change the existing text. It will not add text to a loader that was + * rendered without text, though. + * + * @param {string} text + */ +beestat.component.loading.prototype.set_text = function(text) { + this.text_ = text; + this.text_block_.innerHTML(this.text_); +}; diff --git a/js/component/logo.js b/js/component/logo.js new file mode 100644 index 0000000..c68ac43 --- /dev/null +++ b/js/component/logo.js @@ -0,0 +1,36 @@ +/** + * Beestat two-color text logo. + */ +beestat.component.logo = function() { + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.logo, beestat.component); + +beestat.component.logo.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.logo.prototype.decorate_ = function(parent) { + var logo = $.createElement('div'); + logo.style({ + 'font-weight': beestat.style.font_weight.light, + 'font-size': '40px', + 'font-family': 'Montserrat' + }); + + var bee = $.createElement('span'); + bee.innerHTML('bee'); + bee.style('color', beestat.style.color.yellow.light); + + var stat = $.createElement('span'); + stat.innerHTML('stat'); + stat.style('color', beestat.style.color.green.light); + + logo.appendChild(bee); + logo.appendChild(stat); + + parent.appendChild(logo); +}; diff --git a/js/component/menu.js b/js/component/menu.js new file mode 100644 index 0000000..0321021 --- /dev/null +++ b/js/component/menu.js @@ -0,0 +1,178 @@ +/** + * Menu + */ +beestat.component.menu = function() { + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.menu, beestat.component); + +beestat.component.menu.prototype.rerender_on_breakpoint_ = false; + +beestat.component.menu.prototype.decorate_ = function(parent) { + var self = this; + + this.menu_items_ = []; + + this.icon_ = new beestat.component.button() + .set_type('pill') + .set_icon('dots_vertical') + .set_bubble_text(this.bubble_text_) + .set_bubble_color(this.bubble_color_) + .set_text_color('#fff') + // .set_background_hover_color(beestat.style.color.bluegray.light) + .set_background_hover_color('rgba(255, 255, 255, 0.1') + .addEventListener('click', function() { + // Did I just try to open the same menu as last time? + var same_as_last = (beestat.component.menu.open_menu === self); + + // Close any open menus (this deletes beestat.component.menu.open_menu) + if (beestat.component.menu.open_menu !== undefined) { + beestat.component.menu.open_menu.dispose(); + } + + if (same_as_last === false) { + self.open_(); + } + }) + .render(parent); +}; + +/** + * Close this menu by hiding the container and removing the event listeners. + */ +beestat.component.menu.prototype.dispose = function() { + if (beestat.component.menu.open_menu !== undefined) { + var container = beestat.component.menu.open_menu.container_; + container.style('transform', 'scale(0)'); + + delete beestat.component.menu.open_menu; + setTimeout(function() { + container.parentNode().removeChild(container); + }, 200); + } + + $('html').removeEventListener('click.menu'); + $(window).removeEventListener('keydown.menu'); + $(window).removeEventListener('resize.menu'); +}; + +/** + * Open the menu. + */ +beestat.component.menu.prototype.open_ = function() { + var self = this; + + var position = this.icon_.getBoundingClientRect(); + + var container = $.createElement('div') + .style({ + 'background': '#fff', + 'color': '#444', + 'position': 'absolute', + 'top': position.bottom + 'px', + 'right': (window.innerWidth - position.right) + 'px', + 'transition': 'all 200ms ease', + 'transform': 'scale(0)', + 'transform-origin': 'top right', + 'padding': (beestat.style.size.gutter / 2) + 'px 0', + 'box-shadow': '0 2px 4px rgba(0,0,0,0.16), 0 2px 4px rgba(0,0,0,0.23)', + 'user-select': 'none', + 'border-radius': beestat.style.size.border_radius + }); + + $('body').appendChild(container); + this.container_ = container; + beestat.component.menu.open_menu = this; + + this.menu_items_.forEach(function(menu_item) { + menu_item.render(container); + }); + + // Transition the element in after it's been placed on the page. + setTimeout(function() { + container.style('transform', 'scale(1)'); + + /* + * Close the element when clicking outside of it. For now I'm relying on + * contains where possible, and falling back to saying "if you click on the + * html document then close it too". If this starts to breakdown probably + * just need to switch to checking against the bounding box. + */ + $('html').addEventListener('click.menu', function(e) { + if ( + ( + e.target.contains(container[0]) === false && + container[0].contains(e.target) === false + ) || + e.target.nodeName === 'HTML' + ) { + self.dispose(); + } + }); + + $(window).addEventListener('keydown.menu', function(e) { + if (e.which === 27) { + self.dispose(); + } + }); + + $(window).addEventListener('resize.menu', function() { + self.dispose(); + }); + }, 0); +}; + +/** + * Set the text of the bubble. + * + * @param {string} bubble_text + * + * @return {beestat.component.menu} This. + */ +beestat.component.menu.prototype.set_bubble_text = function(bubble_text) { + this.bubble_text_ = bubble_text; + return this; +}; + +/** + * Set the color of the bubble. + * + * @param {string} bubble_color + * + * @return {beestat.component.menu} This. + */ +beestat.component.menu.prototype.set_bubble_color = function(bubble_color) { + this.bubble_color_ = bubble_color; + return this; +}; + +/** + * Add an item to the menu. + * + * @param {beestat.component.menu_item} menu_item + * + * @return {beestat.component.menu} This. + */ +beestat.component.menu.prototype.add_menu_item = function(menu_item) { + this.menu_items_.push(menu_item); + + menu_item.set_menu(this); + + return this; +}; + +/** + * Remove an item from the menu. + * + * @param {beestat.component.menu_item} menu_item + * + * @return {beestat.component.menu} This. + */ +beestat.component.menu.prototype.remove_menu_item = function(menu_item) { + this.menu_items_.splice( + this.menu_items_.indexOf(menu_item), + 1 + ); + + return this; +}; diff --git a/js/component/menu_item.js b/js/component/menu_item.js new file mode 100644 index 0000000..32ecb93 --- /dev/null +++ b/js/component/menu_item.js @@ -0,0 +1,148 @@ +/** + * Menu item + */ +beestat.component.menu_item = function() { + this.hidden_ = false; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.menu_item, beestat.component); + +beestat.component.menu_item.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.menu_item.prototype.decorate_ = function(parent) { + var self = this; + + if (this.hidden_ === false) { + parent + .style({ + 'padding': (beestat.style.size.gutter / 4) + 'px ' + (beestat.style.size.gutter) + 'px', + 'transition': 'background 200ms ease, color 200ms ease', + 'cursor': 'pointer' + }); + this.parent_ = parent; + + (new beestat.component.icon(this.icon_)) + .set_size(24) + .set_text(this.text_) + .set_bubble_text(this.bubble_text_) + .set_bubble_color(this.bubble_color_) + .render(parent); + + // Events + parent.addEventListener('mouseenter', function() { + parent.style({ + 'background': beestat.style.color.blue.light, + 'color': '#fff' + }); + }); + parent.addEventListener('mouseleave', function() { + parent.style({ + 'background': 'none', + 'color': '' + }); + }); + parent.addEventListener('click', function() { + self.menu_.dispose(); + if(self.callback_ !== undefined) { + self.callback_(); + } + }); + } +}; + +/** + * Set the text of the menu item. + * + * @param {string} text + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.set_text = function(text) { + this.text_ = text; + return this; +}; + +/** + * Set the icon of the menu item. + * + * @param {string} icon + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.set_icon = function(icon) { + this.icon_ = icon; + return this; +}; + +/** + * Set the callback of clicking the menu item. + * + * @param {Function} callback + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.set_callback = function(callback) { + this.callback_ = callback; + return this; +}; + +/** + * Set the text of the bubble. + * + * @param {string} bubble_text + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.set_bubble_text = function(bubble_text) { + this.bubble_text_ = bubble_text; + return this; +}; + +/** + * Set the color of the bubble. + * + * @param {string} bubble_color + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.set_bubble_color = function(bubble_color) { + this.bubble_color_ = bubble_color; + return this; +}; + +/** + * Hide the menu item. + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.hide = function() { + this.hidden_ = true; + return this; +}; + +/** + * Show the menu item. + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.show = function() { + this.hidden_ = false; + return this; +}; + +/** + * Set the menu component so the item can interact with it as necessary. + * + * @param {beestat.component.menu} menu + * + * @return {beestat.component.menu_item} This. + */ +beestat.component.menu_item.prototype.set_menu = function(menu) { + this.menu_ = menu; + return this; +}; diff --git a/js/component/modal.js b/js/component/modal.js new file mode 100644 index 0000000..759106e --- /dev/null +++ b/js/component/modal.js @@ -0,0 +1,289 @@ +/** + * Modal + */ +beestat.component.modal = function() { + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.modal, beestat.component); + +beestat.component.modal.prototype.rerender_on_breakpoint_ = false; + +beestat.component.modal.prototype.decorate_ = function() { + var self = this; + + var mask = $.createElement('div') + .style({ + 'position': 'fixed', + 'top': '0', + 'left': '0', + 'width': '100%', + 'height': '100%', + 'vertical-align': 'middle', + 'transition': 'background 200ms ease' + }); + $('body').appendChild(mask); + + var modal = $.createElement('div'); + beestat.style.set( + modal, + { + 'max-width': '700px', + 'padding': beestat.style.size.gutter, + 'position': 'absolute', + 'top': '100px', + 'left': '50%', + 'transform': 'translateX(-50%)', + 'width': '100%', + 'transition': 'transform 200ms ease' + }, + { + '(max-width: 900px)': { + 'top': '0px' + } + } + ); + + modal.style('transform', 'translateX(-50%) scale(0)'); + + mask.appendChild(modal); + + this.modal_content_ = $.createElement('div'); + beestat.style.set( + this.modal_content_, + { + 'background': '#fff', + 'padding': beestat.style.size.gutter, + 'color': beestat.style.color.bluegray.dark, + 'max-height': 'calc(100vh - ' + (200 + (beestat.style.size.gutter * 2)) + 'px)', + 'overflow': 'auto', + 'border-radius': beestat.style.size.border_radius, + 'min-height': '200px' + }, + { + '(max-width: 900px)': { + 'max-height': 'calc(100vh - ' + (beestat.style.size.gutter * 2) + 'px)', + } + } + ); + + modal.appendChild(this.modal_content_); + + this.mask_ = mask; + this.modal_ = modal; + + /* + * Blur the body + * Fade in the mask + * Overpop the modal + */ + setTimeout(function() { + $('body').firstElementChild() + .style('filter', 'blur(3px)'); + mask.style('background', 'rgba(0, 0, 0, 0.5)'); + modal.style('transform', 'translateX(-50%) scale(1.05)'); + }, 0); + + // Pop the modal back to normal size + setTimeout(function() { + modal.style('transform', 'translateX(-50%) scale(1)'); + }, 200); + + // Escape to close + $(window).addEventListener('keydown.modal', function(e) { + if (e.which === 27) { + self.dispose(); + } + }); + + // Click the mask to close + $(window).addEventListener('click.modal', function(e) { + if (e.target === mask[0]) { + self.dispose(); + } + }); + + this.decorate_header_(this.modal_content_); + this.decorate_contents_(this.modal_content_); + this.decorate_buttons_(this.modal_content_); +}; + +/** + * Close the currently open modal. + */ +/* + * beestat.component.modal.close = function() { + * beestat.component.modal.open_modal.style('transform', 'translateX(-50%) scale(0)'); + * beestat.component.modal.open_mask.style('background', 'rgba(0, 0, 0, 0)'); + * $('body').firstElementChild().style('filter', ''); + */ + +/* + * setTimeout(function() { + * beestat.component.modal.open_modal.parentNode().removeChild( + * beestat.component.modal.open_modal + * ); + * beestat.component.modal.open_mask.parentNode().removeChild( + * beestat.component.modal.open_mask + * ); + */ + +/* + * delete beestat.component.modal.open_mask; + * delete beestat.component.modal.open_modal; + * }, 200); + */ + +/* + * $(window).removeEventListener('keydown.modal'); + * $(window).removeEventListener('click.modal'); + * }; + */ + +/** + * Close the currently open modal. + */ +beestat.component.modal.prototype.dispose = function() { + var self = this; + + this.modal_.style('transform', 'translateX(-50%) scale(0)'); + this.mask_.style('background', 'rgba(0, 0, 0, 0)'); + $('body').firstElementChild() + .style('filter', ''); + + setTimeout(function() { + self.modal_.parentNode().removeChild(self.modal_); + self.mask_.parentNode().removeChild(self.mask_); + + delete self.mask_; + delete self.modal_; + }, 200); + + $(window).removeEventListener('keydown.modal'); + $(window).removeEventListener('click.modal'); +}; + +/** + * Overridden rerender function which just brazenly deletes content and writes + * it again. RIP event listeners. Had to do this since modal rendering does + * all the fancy animation and that is not desirable when rerendering. + */ +beestat.component.modal.prototype.rerender = function() { + this.modal_content_.innerHTML(''); + this.decorate_header_(this.modal_content_); + this.decorate_contents_(this.modal_content_); + this.decorate_buttons_(this.modal_content_); +}; + +/** + * Decorate the header bar with the title and close icon. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.prototype.decorate_header_ = function(parent) { + var row = $.createElement('div') + .style({ + 'display': 'flex', + 'align-items': 'center' + }); + parent.appendChild(row); + + var column_title = $.createElement('div') + .style({ + 'flex': '1' + }); + row.appendChild(column_title); + this.decorate_title_(column_title); + + var column_close = $.createElement('div') + .style({ + 'flex': '0 0 50px', + 'text-align': 'right' + }); + row.appendChild(column_close); + this.decorate_close_(column_close); +}; + +/** + * Decorate the title of the modal. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.prototype.decorate_title_ = function(parent) { + var title = this.get_title_(); + if (title !== undefined) { + parent.appendChild($.createElement('div') + .innerHTML(title) + .style({ + 'font-weight': beestat.style.font_weight.bold, + 'font-size': beestat.style.font_size.extra_large, + 'white-space': 'nowrap', + 'overflow': 'hidden', + 'text-overflow': 'ellipsis' + })); + } +}; + +/** + * Decorate the close button. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.prototype.decorate_close_ = function(parent) { + var self = this; + + var close = new beestat.component.button() + .set_type('pill') + .set_icon('close') + .set_text_color(beestat.style.color.gray.dark) + .set_background_hover_color(beestat.style.color.gray.light) + .addEventListener('click', function() { + self.dispose(); + }); + close.render(parent); +}; + +/** + * Decorate the contents of the modal. + */ +beestat.component.modal.prototype.decorate_contents_ = function() { + // Stub +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.prototype.get_buttons_ = function() { + return []; +}; + +/** + * Decorate the buttons on the bottom right of the modal. + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.prototype.decorate_buttons_ = function(parent) { + var buttons = this.get_buttons_(); + if (buttons.length > 0) { + var container = $.createElement('div') + .style({ + 'margin-top': beestat.style.size.gutter, + 'text-align': 'right' + }); + parent.appendChild(container); + + var button_group = new beestat.component.button_group(); + buttons.forEach(function(button) { + button_group.add_button(button); + }); + button_group.render(container); + } +}; + +/** + * Get the title of the modal. + */ +beestat.component.modal.prototype.get_title_ = function() { + // Stub +}; diff --git a/js/component/modal/aggregate_runtime_custom.js b/js/component/modal/aggregate_runtime_custom.js new file mode 100644 index 0000000..975cbb7 --- /dev/null +++ b/js/component/modal/aggregate_runtime_custom.js @@ -0,0 +1,198 @@ +/** + * Custom date range for the aggregate runtime chart. + */ +beestat.component.modal.aggregate_runtime_custom = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.aggregate_runtime_custom, beestat.component.modal); + +beestat.component.modal.aggregate_runtime_custom.prototype.decorate_contents_ = function(parent) { + var self = this; + + parent.appendChild($.createElement('p').innerHTML('Choose a custom range to display on the Aggregate Runtime chart.')); + + // Time count + var time_count = new beestat.component.input.text() + .set_style({ + 'width': 75, + 'text-align': 'center', + 'border-bottom': '2px solid ' + beestat.style.color.lightblue.base + }) + .set_attribute({ + 'maxlength': 10 + }) + .set_icon('pound') + .set_value(beestat.setting('aggregate_runtime_time_count')); + + self.state_.aggregate_runtime_time_count = + beestat.setting('aggregate_runtime_time_count'); + + time_count.addEventListener('blur', function() { + self.state_.aggregate_runtime_time_count = + parseInt(this.get_value(), 10) || 1; + }); + + // Button groups + var options = { + 'aggregate_runtime_time_period': [ + 'day', + 'week', + 'month', + 'year', + 'all' + ], + 'aggregate_runtime_group_by': [ + 'day', + 'week', + 'month', + 'year' + ] + }; + + var button_groups = {}; + + this.selected_buttons_ = {}; + for (let key in options) { + let current_type = beestat.setting(key); + + let button_group = new beestat.component.button_group(); + options[key].forEach(function(value) { + let text = value.replace('aggregate_runtime_', '') + .charAt(0) + .toUpperCase() + + value.slice(1) + + ( + ( + key === 'aggregate_runtime_time_period' && + value !== 'all' + ) ? 's' : '' + ); + + let button = new beestat.component.button() + .set_background_hover_color(beestat.style.color.lightblue.base) + .set_text_color('#fff') + .set_text(text) + .addEventListener('click', function() { + if (key === 'aggregate_runtime_time_period') { + if (value === 'all') { + time_count.set_value('∞').disable(); + } else if (time_count.get_value() === '∞') { + time_count + .set_value(self.state_.aggregate_runtime_time_count || '1') + .enable(); + } + } + + if (current_type !== value) { + this.set_background_color(beestat.style.color.lightblue.base); + if (self.selected_buttons_[key] !== undefined) { + self.selected_buttons_[key] + .set_background_color(beestat.style.color.bluegray.base); + } + self.selected_buttons_[key] = this; + self.state_[key] = value; + current_type = value; + } + }); + + if (current_type === value) { + if ( + key === 'aggregate_runtime_time_period' && + value === 'all' + ) { + time_count.set_value('∞').disable(); + } + + button.set_background_color(beestat.style.color.lightblue.base); + self.state_[key] = value; + self.selected_buttons_[key] = button; + } else { + button.set_background_color(beestat.style.color.bluegray.base); + } + + button_group.add_button(button); + }); + button_groups[key] = button_group; + } + + // Display it all + var row; + var column; + + (new beestat.component.title('Time Period')).render(parent); + row = $.createElement('div').addClass('row'); + parent.appendChild(row); + column = $.createElement('div').addClass(['column column_2']); + row.appendChild(column); + time_count.render(column); + column = $.createElement('div').addClass(['column column_10']); + row.appendChild(column); + button_groups.aggregate_runtime_time_period.render(column); + (new beestat.component.title('Group By')).render(parent); + row = $.createElement('div').addClass('row'); + parent.appendChild(row); + column = $.createElement('div').addClass(['column column_12']); + row.appendChild(column); + button_groups.aggregate_runtime_group_by.render(column); +}; + +/** + * Get title. + * + * @return {string} Title + */ +beestat.component.modal.aggregate_runtime_custom.prototype.get_title_ = function() { + return 'Aggregate Runtime - Custom Range'; +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.aggregate_runtime_custom.prototype.get_buttons_ = function() { + var self = this; + + var cancel = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.red.base) + .set_text('Cancel') + .addEventListener('click', function() { + self.dispose(); + }); + + var save = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Save') + .addEventListener('click', function() { + this + .set_background_color(beestat.style.color.gray.base) + .set_background_hover_color() + .removeEventListener('click'); + + beestat.setting( + { + 'aggregate_runtime_time_count': + self.state_.aggregate_runtime_time_period === 'all' ? + 0 : + self.state_.aggregate_runtime_time_count, + 'aggregate_runtime_time_period': + self.state_.aggregate_runtime_time_period, + 'aggregate_runtime_group_by': + self.state_.aggregate_runtime_group_by + }, + undefined, + function() { + self.dispose(); + } + ); + }); + + return [ + cancel, + save + ]; +}; diff --git a/js/component/modal/announcements.js b/js/component/modal/announcements.js new file mode 100644 index 0000000..54f88c6 --- /dev/null +++ b/js/component/modal/announcements.js @@ -0,0 +1,49 @@ +/** + * Announcements + */ +beestat.component.modal.announcements = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.announcements, beestat.component.modal); + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.announcements.prototype.decorate_contents_ = function(parent) { + var announcements = $.values(beestat.cache.announcement).reverse(); + announcements.forEach(function(announcement) { + parent.appendChild($.createElement('div').style({ + 'border-bottom': '1px solid #eee', + 'margin-left': (beestat.style.size.gutter * -1) + 'px', + 'margin-right': (beestat.style.size.gutter * -1) + 'px', + 'margin-top': (beestat.style.size.gutter) + 'px', + 'margin-bottom': (beestat.style.size.gutter) + 'px' + })); + + var icon = new beestat.component.icon(announcement.icon) + .set_text(announcement.title + + ' • ' + + moment.utc(announcement.created_at).fromNow()); + + icon.render(parent); + + beestat.setting( + 'last_read_announcement_id', + announcements[0].announcement_id + ); + beestat.dispatcher.dispatchEvent('view_announcements'); + + parent.appendChild($.createElement('p').innerHTML(announcement.text)); + }); +}; + +/** + * Get the title. + * + * @return {string} The title. + */ +beestat.component.modal.announcements.prototype.get_title_ = function() { + return 'Announcements'; +}; diff --git a/js/component/modal/change_system_type.js b/js/component/modal/change_system_type.js new file mode 100644 index 0000000..22fa66f --- /dev/null +++ b/js/component/modal/change_system_type.js @@ -0,0 +1,163 @@ +/** + * Change system type. + */ +beestat.component.modal.change_system_type = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.change_system_type, beestat.component.modal); + +beestat.component.modal.change_system_type.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 + ]; + + parent.appendChild($.createElement('p').innerHTML('What type of HVAC system do you have? This applies to all thermostats in this group. System types that beestat detected are indicated.')); + + var options = { + 'heat': [ + 'gas', + 'compressor', + 'electric', + 'boiler', + 'geothermal', + 'oil', + 'none' + ], + 'heat_auxiliary': [ + 'gas', + 'electric', + 'oil', + 'none' + ], + 'cool': [ + 'compressor', + 'geothermal', + 'none' + ] + }; + + var titles = { + 'heat': 'Heat', + 'heat_auxiliary': 'Auxiliary Heat', + 'cool': 'Cool' + }; + + var colors = { + 'heat': beestat.style.color.orange.base, + 'heat_auxiliary': beestat.style.color.red.dark, + 'cool': beestat.style.color.blue.light + }; + + this.selected_types_ = {}; + this.selected_buttons_ = {}; + for (let key in options) { + (new beestat.component.title(titles[key])).render(parent); + + let current_type = thermostat_group['system_type_' + key]; + + let button_group = new beestat.component.button_group(); + options[key].forEach(function(system_type) { + let text = system_type.charAt(0).toUpperCase() + system_type.slice(1); + if (thermostat.system_type.detected[key] === system_type) { + text += ' [Detected]'; + } + + let button = new beestat.component.button() + .set_background_hover_color(colors[key]) + .set_text_color('#fff') + .set_text(text) + .addEventListener('click', function() { + if (current_type !== system_type) { + this.set_background_color(colors[key]); + if (self.selected_buttons_[key] !== undefined) { + self.selected_buttons_[key].set_background_color(beestat.style.color.bluegray.base); + } + self.selected_buttons_[key] = this; + self.selected_types_[key] = system_type; + current_type = system_type; + } + }); + + if (current_type === system_type) { + button.set_background_color(colors[key]); + self.selected_types_[key] = system_type; + self.selected_buttons_[key] = button; + } else { + button.set_background_color(beestat.style.color.bluegray.base); + } + + button_group.add_button(button); + }); + button_group.render(parent); + } +}; + +beestat.component.modal.change_system_type.prototype.get_title_ = function() { + return 'Change System Type'; +}; + +/** + * Get the buttons that go on the bottom of this modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.change_system_type.prototype.get_buttons_ = function() { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + var self = this; + + var cancel = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.red.base) + .set_text('Cancel') + .addEventListener('click', function() { + self.dispose(); + }); + + var save = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('Save') + .addEventListener('click', function() { + this + .set_background_color(beestat.style.color.gray.base) + .set_background_hover_color() + .removeEventListener('click'); + + new beestat.api2() + .add_call( + 'thermostat_group', + 'update_system_types', + { + 'thermostat_group_id': thermostat.thermostat_group_id, + 'system_types': self.selected_types_ + }, + 'update_system_types' + ) + .add_call('thermostat', 'read_id', {}, 'thermostat') + .add_call('thermostat_group', 'read_id', {}, 'thermostat_group') + .set_callback(function(response) { + // Update the cache. + beestat.cache.set('thermostat', response.thermostat); + beestat.cache.set('thermostat_group', response.thermostat_group); + + // Re-run comparison scores as they are invalid for the new system + // type. + beestat.get_comparison_scores(); + + // Close the modal. + self.dispose(); + }) + .send(); + }); + + return [ + cancel, + save + ]; +}; diff --git a/js/component/modal/change_thermostat.js b/js/component/modal/change_thermostat.js new file mode 100644 index 0000000..eed39e2 --- /dev/null +++ b/js/component/modal/change_thermostat.js @@ -0,0 +1,118 @@ +/** + * Change thermostat + */ +beestat.component.modal.change_thermostat = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.change_thermostat, beestat.component.modal); + +beestat.component.modal.change_thermostat.prototype.decorate_contents_ = function(parent) { + var self = this; + + var container = $.createElement('div') + .style({ + 'display': 'grid', + 'grid-template-columns': 'repeat(auto-fill, minmax(200px, 1fr))', + 'margin': '0 0 16px -16px' + }); + parent.appendChild(container); + + var sorted_thermostats = $.values(beestat.cache.thermostat) + .sort(function(a, b) { + return a.name > b.name; + }); + + sorted_thermostats.forEach(function(thermostat) { + var div = $.createElement('div') + .style({ + 'padding': '16px 0 0 16px' + }); + container.appendChild(div); + + self.decorate_thermostat_(div, thermostat.thermostat_id); + }); +}; + +beestat.component.modal.change_thermostat.prototype.decorate_thermostat_ = function(parent, thermostat_id) { + var thermostat = beestat.cache.thermostat[thermostat_id]; + var ecobee_thermostat = beestat.cache.ecobee_thermostat[ + thermostat.ecobee_thermostat_id + ]; + + var container_height = 60; + var gutter = beestat.style.size.gutter / 2; + var thermostat_height = container_height - (gutter * 2) + + var container = $.createElement('div') + .style({ + 'height': container_height, + 'border-radius': container_height, + 'padding-right': (beestat.style.size.gutter / 2), + 'transition': 'background 200ms ease', + 'user-select': 'none' + }); + + if(thermostat_id == beestat.cache.thermostat[beestat.setting('thermostat_id')].thermostat_id) { + container.style({ + 'background': '#4b6584', + 'color': '#fff' + }); + } else { + container.style({ + 'cursor': 'pointer' + }); + + container + .addEventListener('mouseover', function() { + container.style('background', beestat.style.color.gray.base); + }) + .addEventListener('mouseout', function() { + container.style('background', ''); + }) + .addEventListener('click', function() { + container.removeEventListener(); + beestat.setting('thermostat_id', thermostat_id, function() { + window.location.reload(); + }); + }); + } + + parent.appendChild(container); + + var temperature = beestat.temperature({ + 'temperature': thermostat.temperature, + 'round': 0 + }); + + var left = $.createElement('div') + .style({ + 'background': beestat.get_thermostat_color(thermostat_id), + 'font-weight': beestat.style.font_weight.light, + 'border-radius': '50%', + 'width': thermostat_height, + 'height': thermostat_height, + 'line-height': thermostat_height, + 'color': '#fff', + 'font-size': '20px', + 'text-align': 'center', + 'float': 'left', + 'margin': gutter + }) + .innerHTML(temperature); + container.appendChild(left); + + var right = $.createElement('div') + .style({ + 'line-height': container_height, + 'font-weight': beestat.style.font_weight.bold, + 'white-space': 'nowrap', + 'overflow': 'hidden', + 'text-overflow': 'ellipsis' + }) + .innerHTML(thermostat.name); + container.appendChild(right); +}; + +beestat.component.modal.change_thermostat.prototype.get_title_ = function() { + return 'Change Thermostat'; +}; diff --git a/js/component/modal/error.js b/js/component/modal/error.js new file mode 100644 index 0000000..597f71b --- /dev/null +++ b/js/component/modal/error.js @@ -0,0 +1,53 @@ +/** + * Error modal. + */ +beestat.component.modal.error = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.error, beestat.component.modal); + +beestat.component.modal.error.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML(this.message_)); + + if (this.detail_ !== undefined) { + parent.appendChild($.createElement('p').innerHTML('Sorry about that! This error has been logged and will be investigated and appropriately punished. Please reach out to contact@beestat.io if it persists.')); + parent.appendChild($.createElement('p') + .style({ + 'padding': beestat.style.size.gutter / 2, + 'background': beestat.style.color.bluegray.dark, + 'color': beestat.style.color.gray.light, + 'font-family': 'Courier New, Monospace', + 'max-height': '200px', + 'overflow-y': 'auto', + 'font-size': beestat.style.font_size.normal, + 'white-space': 'pre' + }) + .innerHTML(this.detail_)); + } +}; + +beestat.component.modal.error.prototype.set_message = function(message) { + this.message_ = message; +}; + +beestat.component.modal.error.prototype.set_detail = function(detail) { + this.detail_ = detail; +}; + +beestat.component.modal.error.prototype.get_title_ = function() { + var titles = [ + 'Looks like you broke it again.', + 'Yep, it\'s broken.', + 'Something went wrong.', + 'You have died of dysentry.', + 'What a happy accident.', + 'Witty title for an error.', + 'Greedo shot first!', + 'We can\'t all be winners.', + 'Don\'t panic!', + 'Hello. It\'s me.', + '¯\\_(ツ)_/¯' + ]; + + return titles[Math.floor(Math.random() * titles.length)]; +}; diff --git a/js/component/modal/filter_info.js b/js/component/modal/filter_info.js new file mode 100644 index 0000000..e58cd43 --- /dev/null +++ b/js/component/modal/filter_info.js @@ -0,0 +1,168 @@ +/** + * Air filter info. + */ +beestat.component.modal.filter_info = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.filter_info, beestat.component.modal); + +beestat.component.modal.filter_info.prototype.decorate_contents_ = function(parent) { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + for (var type in thermostat.filters) { + this.decorate_single_(parent, type); + } +}; + +/** + * Decorate a single filter row. + * + * @param {rocket.Elements} parent + * @param {string} type The type of filter. + */ +beestat.component.modal.filter_info.prototype.decorate_single_ = function(parent, type) { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + var icon; + var title; + switch (type) { + case 'furnace': + icon = 'fire'; + title = 'Furnace'; + break; + case 'uv_lamp': + icon = 'wb_incandescent'; + title = 'UV Lamp'; + break; + case 'humidifier': + icon = 'water_percent'; + title = 'Humidifier'; + break; + case 'dehumidifier': + icon = 'format_color_reset'; + title = 'Dehumidifier'; + break; + case 'ventilator': + icon = 'toys'; + title = 'Ventilator'; + break; + } + + var title_container = $.createElement('div') + .style({ + 'display': 'inline-flex', + 'align-items': 'center' + }); + parent.appendChild(title_container); + + (new beestat.component.icon(icon) + .set_size(24) + ).render(title_container); + + title_container.appendChild($.createElement('span') + .innerHTML(title) + .style('margin-left', beestat.style.size.gutter / 4)); + + var outer_container = $.createElement('div') + .style({ + 'background': beestat.style.color.gray.light, + 'padding': (beestat.style.size.gutter / 2), + 'margin-bottom': beestat.style.size.gutter, + 'border-radius': beestat.style.size.border_radius + }); + parent.appendChild(outer_container); + + var inner_container = $.createElement('div') + .style({ + 'display': 'grid', + 'grid-template-columns': 'repeat(auto-fill, minmax(150px, 1fr))', + 'margin': '0 0 16px -16px' + }); + outer_container.appendChild(inner_container); + + var last_changed = moment(thermostat.filters[type].last_changed); + + var runtime_hours = Math.round(thermostat.filters[type].runtime / 3600); + + var replace_in; + if (thermostat.filters[type].life_units === 'month') { + var replace_on = moment(thermostat.filters[type].last_changed) + .add(thermostat.filters[type].life, thermostat.filters[type].life_units); + replace_in = replace_on.diff(moment(), 'days'); + + if (replace_in < 0) { + replace_in = Math.abs(replace_in) + 'd overdue'; + } else if (replace_in === 0) { + replace_in = 'Now'; + } else if (replace_in < 35) { + replace_in += 'd'; + } else { + var duration = moment.duration(replace_in, 'days'); + replace_in = Math.floor(duration.asMonths()) + + 'mo ' + + duration.get('days') + + 'd'; + } + } else if (thermostat.filters[type].life_units === 'hour') { + replace_in = thermostat.filters[type].life - runtime_hours; + + if (replace_in < 0) { + replace_in = Math.abs(replace_in) + 'h overdue'; + } else if (replace_in === 0) { + replace_in = 'Now'; + } else { + replace_in += 'h'; + } + } else { + throw new Error('Unsupported thermostat filter life units.'); + } + + var lifespan; + lifespan = thermostat.filters[type].life; + if (thermostat.filters[type].life_units === 'hour') { + lifespan += ' runtime'; + } + lifespan += ' ' + thermostat.filters[type].life_units; + if (thermostat.filters[type].life > 1) { + lifespan += 's'; + } + + var fields = [ + { + 'name': 'Last Changed', + 'value': last_changed.format('ddd, MMM D, YYYY') + }, + { + 'name': 'Lifespan', + 'value': lifespan + }, + { + 'name': 'Replace In', + 'value': replace_in + }, + { + 'name': 'Runtime', + 'value': runtime_hours + 'h' + } + ]; + + fields.forEach(function(field) { + var div = $.createElement('div') + .style({ + 'padding': '16px 0 0 16px' + }); + inner_container.appendChild(div); + + div.appendChild($.createElement('div') + .style({ + 'font-weight': beestat.style.font_weight.bold, + 'margin-bottom': (beestat.style.size.gutter / 4) + }) + .innerHTML(field.name)); + div.appendChild($.createElement('div').innerHTML(field.value)); + }); +}; + +beestat.component.modal.filter_info.prototype.get_title_ = function() { + return 'Filter Info'; +}; diff --git a/js/component/modal/help_aggregate_runtime.js b/js/component/modal/help_aggregate_runtime.js new file mode 100644 index 0000000..fc77b19 --- /dev/null +++ b/js/component/modal/help_aggregate_runtime.js @@ -0,0 +1,55 @@ +/** + * Help for the aggregate runtime card. + */ +beestat.component.modal.help_aggregate_runtime = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_aggregate_runtime, beestat.component.modal); + +beestat.component.modal.help_aggregate_runtime.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('View HVAC usage trends over large periods of time. This can help you identify problems or visualize effeciency gains from new equipment, insulation, etc. Compare to the Home IQ Weather Impact chart.')); + parent.appendChild($.createElement('p').innerHTML('If you have Gap Fill enabled (on by default), this data may not match the ecobee website exactly. Ecobee displays total runtime as stored, while beestat will intelligently fill in missing data to produce a more accurate result.')); + + var table = $.createElement('table'); + table.style('color', beestat.style.color.blue.base); + parent.appendChild(table); + + var tr; + var td; + + tr = $.createElement('tr'); + table.appendChild(tr); + + td = $.createElement('td'); + td.setAttribute('valign', 'top'); + tr.appendChild(td); + + (new beestat.component.icon('information') + .set_color(beestat.style.color.blue.base) + ).render(td); + + td = $.createElement('td'); + td.setAttribute('valign', 'top'); + tr.appendChild(td); + td.innerHTML('Ecobee purged weather data prior to April 2018 as a result of switching weather providers. If you joined beestat after this happened you will not have access to this historical data.'); + + tr = $.createElement('tr'); + table.appendChild(tr); + + td = $.createElement('td'); + td.setAttribute('valign', 'top'); + tr.appendChild(td); + + (new beestat.component.icon('information') + .set_color(beestat.style.color.blue.base) + ).render(td); + + td = $.createElement('td'); + td.setAttribute('valign', 'top'); + tr.appendChild(td); + td.innerHTML('Ecobee typically purges data after about a year. Beestat currently stores all historical data even though ecobee does not.'); +}; + +beestat.component.modal.help_aggregate_runtime.prototype.get_title_ = function() { + return 'Aggregate Runtime - Help'; +}; diff --git a/js/component/modal/help_alerts.js b/js/component/modal/help_alerts.js new file mode 100644 index 0000000..ff35715 --- /dev/null +++ b/js/component/modal/help_alerts.js @@ -0,0 +1,19 @@ +/** + * Help for the alerts card. + */ +beestat.component.modal.help_alerts = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_alerts, beestat.component.modal); + +beestat.component.modal.help_alerts.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Shows alerts currently displayed on your thermostat and also custom beestat alerts. You may dismiss alerts at any time from beestat and it will not affect the alerts on your ecobee. Custom alerts include:')); + var ul = $.createElement('ul'); + parent.appendChild(ul); + ul.appendChild($.createElement('li').innerHTML('Cool differential too low')); + ul.appendChild($.createElement('li').innerHTML('Heat differential too low')); +}; + +beestat.component.modal.help_alerts.prototype.get_title_ = function() { + return 'Alerts - Help'; +}; diff --git a/js/component/modal/help_comparison_settings.js b/js/component/modal/help_comparison_settings.js new file mode 100644 index 0000000..1bf9065 --- /dev/null +++ b/js/component/modal/help_comparison_settings.js @@ -0,0 +1,34 @@ +/** + * Comparison settings help. + */ +beestat.component.modal.help_comparison_settings = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_comparison_settings, beestat.component.modal); + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.help_comparison_settings.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerText('Comparison settings allow you to customize how your home is compared to the homes of other beestat users. All thermostats at the same physical address are compared together.')); + + (new beestat.component.title('Date')).render(parent); + parent.appendChild($.createElement('p').innerText('This is the date your home\'s score is calculated on. Make some energy-saving improvements lately? Set this date back a few months and see what difference they made. Note that even though you\'re looking at your home in the past, beestat always compares to all other homes in the present.')); + + (new beestat.component.title('Region')).render(parent); + parent.appendChild($.createElement('p').innerText('Compare your home to other homes within 250 miles (400km) or expand this to all homes globally.')); + + (new beestat.component.title('Property')).render(parent); + parent.appendChild($.createElement('p').innerHTML('The Very Similar option will compare with other homes with similar physical characteristics. This typically makes the most sense. The second option will compare with other homes of the same structure type (ex: Detached, Apartment). You may also compare with all other homes regardless of type, although this isn\'t generally very meaningful.')); +}; + +/** + * Get the title. + * + * @return {string} The title. + */ +beestat.component.modal.help_comparison_settings.prototype.get_title_ = function() { + return 'Comparison Settings - Help'; +}; diff --git a/js/component/modal/help_home_efficiency.js b/js/component/modal/help_home_efficiency.js new file mode 100644 index 0000000..c6f968b --- /dev/null +++ b/js/component/modal/help_home_efficiency.js @@ -0,0 +1,17 @@ +/** + * Help for the home efficiency card. + */ +beestat.component.modal.help_home_efficiency = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_home_efficiency, beestat.component.modal); + +beestat.component.modal.help_home_efficiency.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Describes how quickly your home loses or gains heat compared to the temperature outside. The flatter this line the better your home keeps temperature without needing your heating or air conditioning to run.')); + parent.appendChild($.createElement('p').innerHTML('The data is sourced from your past year of history, looking at the rate of temperature change when your HVAC system is completely off. It is recalculated once a week.')); + parent.appendChild($.createElement('p').innerHTML('This feature is still in beta, and as such is not perfect. For example, this graph does not account for homes with multiple thermostats or secondary sources of heating/cooling such as window A/C units and space heaters.')); +}; + +beestat.component.modal.help_home_efficiency.prototype.get_title_ = function() { + return 'Home Efficiency - Help'; +}; diff --git a/js/component/modal/help_my_home.js b/js/component/modal/help_my_home.js new file mode 100644 index 0000000..3c546b6 --- /dev/null +++ b/js/component/modal/help_my_home.js @@ -0,0 +1,24 @@ +/** + * Help for the sensors card. + */ +beestat.component.modal.help_my_home = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_my_home, beestat.component.modal); + +beestat.component.modal.help_my_home.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('These are all the properties of your home and HVAC system that are used in Home Comparisons.')); + + new beestat.component.title('System').render(parent); + parent.appendChild($.createElement('p').innerHTML('Type of heating/cooling systems; detected automatically but are not always completely accurate due to lack of available data. They can be overridden by clicking on the My Home menu, then selecting Change System Type.')); + + new beestat.component.title('Region').render(parent); + parent.appendChild($.createElement('p').innerHTML('Geographical region; determined automatically based on the address on the thermostat account.')); + + new beestat.component.title('Property').render(parent); + parent.appendChild($.createElement('p').innerHTML('Physical property characteristics; determined automatically based on the data on the thermostat account.')); +}; + +beestat.component.modal.help_my_home.prototype.get_title_ = function() { + return 'My Home - Help'; +}; diff --git a/js/component/modal/help_recent_activity.js b/js/component/modal/help_recent_activity.js new file mode 100644 index 0000000..22b3c7b --- /dev/null +++ b/js/component/modal/help_recent_activity.js @@ -0,0 +1,29 @@ +/** + * Help for the recent activity card. + */ +beestat.component.modal.help_recent_activity = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_recent_activity, beestat.component.modal); + +beestat.component.modal.help_recent_activity.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('View up to the past 7 days of thermostat activity in 5-minute resolution. This can help you visualize daily runtime trends and identify acute system issues. Compare to the Home IQ System & Follow Me charts.')); + + var table = $.createElement('table'); + table.style('color', beestat.style.color.blue.base); + parent.appendChild(table); + + var tr; + var td; + + tr = $.createElement('tr'); + table.appendChild(tr); + + td = $.createElement('td'); + td.setAttribute('valign', 'top'); + tr.appendChild(td); +}; + +beestat.component.modal.help_recent_activity.prototype.get_title_ = function() { + return 'Recent Activity - Help'; +}; diff --git a/js/component/modal/help_score.js b/js/component/modal/help_score.js new file mode 100644 index 0000000..0516025 --- /dev/null +++ b/js/component/modal/help_score.js @@ -0,0 +1,138 @@ +/** + * Score help + * + * @param {string} type heat|cool|resist + */ +beestat.component.modal.help_score = function(type) { + this.type_ = type; + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_score, beestat.component.modal); + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.help_score.prototype.decorate_contents_ = function(parent) { + (new beestat.component.title('What is this value?')).render(parent); + var what_is; + switch (this.type_) { + case 'heat': + what_is = 'Your heat score represents how well your home heats compared to other homes. The most important factor is the rate at which temperature increases. However, you also receive a bonus to this score for having longer cycle times. Aux heating is not used when generating this score.'; + break; + case 'cool': + what_is = 'Your cool score represents how well your home cool compared to other homes. The most important factor is the rate at which temperature decreases. However, you also receive a bonus to this score for having longer cycle times.'; + break; + case 'resist': + what_is = 'Your resist score represents how well your home is able to maintain a consistent temperature without the help of your HVAC system. For example, if you have a very drafty home that loses heat quickly in the winter, this score will be low. If you have a home with good insulation, this score will be high.'; + break; + } + parent.appendChild($.createElement('p').innerText(what_is)); + + (new beestat.component.title('How is my ' + this.type_ + ' score calculated?')).render(parent); + parent.appendChild($.createElement('p').innerText('The currently displayed score was calculated using the following parameters:')); + + var strings = []; + + var comparison_attributes = beestat.get_comparison_attributes(this.type_); + + if (comparison_attributes.system_type_heat !== undefined) { + strings.push('Heat Type: ' + this.get_comparison_string_(comparison_attributes.system_type_heat)); + } else { + strings.push('Heat Type: Not considered'); + } + + if (comparison_attributes.system_type_heat_auxiliary !== undefined) { + strings.push('Aux Heat Type: ' + this.get_comparison_string_(comparison_attributes.system_type_heat_auxiliary)); + } else { + strings.push('Aux Heat Type: Not considered'); + } + + if (comparison_attributes.system_type_cool !== undefined) { + strings.push('Cool Type: ' + this.get_comparison_string_(comparison_attributes.system_type_cool)); + } else { + strings.push('Cool Type: Not considered'); + } + + if (comparison_attributes.property_structure_type !== undefined) { + strings.push('Property Type: ' + this.get_comparison_string_(comparison_attributes.property_structure_type)); + } else { + strings.push('Property Type: Not considered'); + } + + if (comparison_attributes.property_age !== undefined) { + strings.push(this.get_comparison_string_(comparison_attributes.property_age, 'years old')); + } else { + strings.push('Property age not considered'); + } + + if (comparison_attributes.property_square_feet !== undefined) { + strings.push(this.get_comparison_string_(comparison_attributes.property_square_feet, 'sqft')); + } else { + strings.push('Square footage not considered'); + } + + if (comparison_attributes.property_stories !== undefined) { + strings.push(this.get_comparison_string_(comparison_attributes.property_stories, 'stories')); + } else { + strings.push('Number of stories not considered'); + } + + if (comparison_attributes.address_radius !== undefined) { + strings.push('Within ' + comparison_attributes.address_radius + ' miles of your location'); + } else { + strings.push('Region not considered'); + } + + var ul = $.createElement('ul'); + parent.appendChild(ul); + strings.forEach(function(string) { + var li = $.createElement('li'); + li.innerText(string); + if (string.match('considered') !== null) { + li.style({'color': beestat.style.color.gray.base}); + } + ul.appendChild(li); + }); +}; + +/** + * Get the title. + * + * @return {string} The title. + */ +beestat.component.modal.help_score.prototype.get_title_ = function() { + return this.type_.charAt(0).toUpperCase() + this.type_.slice(1) + ' Score - Help'; +}; + +/** + * Helper function to display various comparison strings in a human-readable + * way. + * + * @param {mixed} comparison_attribute The attribute + * @param {string} suffix If a suffix (ex: "years") should be placed on the + * end. + * + * @return {string} The human-readable string. + */ +beestat.component.modal.help_score.prototype.get_comparison_string_ = function(comparison_attribute, suffix) { + var s = (suffix !== undefined ? (' ' + suffix) : ''); + if (comparison_attribute.operator !== undefined) { + if (comparison_attribute.operator === 'between') { + return 'Between ' + comparison_attribute.value[0] + ' and ' + comparison_attribute.value[1] + s; + } else if (comparison_attribute.operator === '>=') { + return 'At least ' + comparison_attribute.value + s; + } else if (comparison_attribute.operator === '<=') { + return 'Less than or equal than ' + comparison_attribute.value + s; + } else if (comparison_attribute.operator === '>') { + return 'Greater than ' + comparison_attribute.value + s; + } else if (comparison_attribute.operator === '<') { + return 'Less than' + comparison_attribute.value + s; + } + return comparison_attribute.operator + ' ' + comparison_attribute.value + s; + } else if (Array.isArray(comparison_attribute.value) === true) { + return 'One of ' + comparison_attribute.value.join(', ') + s; + } + return comparison_attribute + s; +}; diff --git a/js/component/modal/help_sensors.js b/js/component/modal/help_sensors.js new file mode 100644 index 0000000..53eec43 --- /dev/null +++ b/js/component/modal/help_sensors.js @@ -0,0 +1,22 @@ +/** + * Help for the sensors card. + */ +beestat.component.modal.help_sensors = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_sensors, beestat.component.modal); + +beestat.component.modal.help_sensors.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Shows a list of all connected sensors. The thermostat itself counts as a sensor and is displayed at the top of the list. Includes the following information:')); + var ul = $.createElement('ul'); + parent.appendChild(ul); + ul.appendChild($.createElement('li').innerHTML('Name')); + ul.appendChild($.createElement('li').innerHTML('Temperature')); + ul.appendChild($.createElement('li').innerHTML('Whether the temperature is above or below the average')); + ul.appendChild($.createElement('li').innerHTML('Occupancy')); + ul.appendChild($.createElement('li').innerHTML('Included in average')); +}; + +beestat.component.modal.help_sensors.prototype.get_title_ = function() { + return 'Sensors - Help'; +}; diff --git a/js/component/modal/help_system.js b/js/component/modal/help_system.js new file mode 100644 index 0000000..9979cd4 --- /dev/null +++ b/js/component/modal/help_system.js @@ -0,0 +1,24 @@ +/** + * Help for the system card. + */ +beestat.component.modal.help_system = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_system, beestat.component.modal); + +beestat.component.modal.help_system.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerHTML('Shows the current system status it would be displayed on the thermostat itself. Includes the following information:')); + var ul = $.createElement('ul'); + parent.appendChild(ul); + ul.appendChild($.createElement('li').innerHTML('System Mode')); + ul.appendChild($.createElement('li').innerHTML('Schedule or Override')); + ul.appendChild($.createElement('li').innerHTML('Setpoint')); + ul.appendChild($.createElement('li').innerHTML('Temperature')); + ul.appendChild($.createElement('li').innerHTML('Humidity')); + ul.appendChild($.createElement('li').innerHTML('Running equipment')); + ul.appendChild($.createElement('li').innerHTML('Active comfort setting')); +}; + +beestat.component.modal.help_system.prototype.get_title_ = function() { + return 'System - Help'; +}; diff --git a/js/component/modal/help_temperature_profiles.js b/js/component/modal/help_temperature_profiles.js new file mode 100644 index 0000000..551ae31 --- /dev/null +++ b/js/component/modal/help_temperature_profiles.js @@ -0,0 +1,41 @@ +/** + * Temperature Profiles help. + * + * @param {string} type heat|cool|resist + * @param {array} comparison_attributes + */ +beestat.component.modal.help_temperature_profiles = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.help_temperature_profiles, beestat.component.modal); + +/* +Describes how quickly your home loses or gains heat compared to the temperature outside. The flatter this line the better your home keeps temperature without needing your heating or air conditioning to run. + +The data is sourced from your past year of history, looking at the rate of temperature change when your HVAC system is completely off. It is recalculated once a week. + +This feature is still in beta, and as such is not perfect. For example, this graph does not account for homes with multiple thermostats or secondary sources of heating/cooling such as window A/C units and space heaters.*/ + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.help_temperature_profiles.prototype.decorate_contents_ = function(parent) { + parent.appendChild($.createElement('p').innerText('Temperature profiles tell you how fast or slow your indoor temperature changes. This is powerful information that can tell you a lot about your home and help you make informed decisions.')); + + (new beestat.component.title('Heat / Cool')).render(parent); + parent.appendChild($.createElement('p').innerText('The orange and blue lines represent the rate at which your home heats or cools for any given outdoor temperature. The dotted lines are the raw data, and the solid line is a trendline for that data. For heat pump owners, the outdoor temperature where the orange line crosses the y-axis is called your balance point and tells you when you need an auxiliary source of heat to keep your home warm.')); + + (new beestat.component.title('Resist')).render(parent); + parent.appendChild($.createElement('p').innerText('The gray line represents the rate at which your home gains or loses heat when your HVAC system is completely off. The dotted lines are the raw data, and the solid line is a trendline for that data.')); +}; + +/** + * Get the title. + * + * @return {string} The title. + */ +beestat.component.modal.help_temperature_profiles.prototype.get_title_ = function() { + return 'Temperature Profiles - Help'; +}; diff --git a/js/component/modal/patreon_hide.js b/js/component/modal/patreon_hide.js new file mode 100644 index 0000000..a196cbd --- /dev/null +++ b/js/component/modal/patreon_hide.js @@ -0,0 +1,149 @@ +/** + * Options for hiding the patreon card. + */ +beestat.component.modal.patreon_hide = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.patreon_hide, beestat.component.modal); + +beestat.component.modal.patreon_hide.poll_interval_ = 5000; + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.modal.patreon_hide.prototype.decorate_contents_ = function(parent) { + switch (this.is_active_patron_()) { + case false: + parent.appendChild($.createElement('p').innerText('Your Patreon account is connected but you\'re not currently a supporter. If you recently became a supporter it could take up to 24 hours to update.')); + break; + case null: + parent.appendChild($.createElement('p').innerText('Hey there! If you didn\'t notice, beestat doesn\'t run ads, cost money, or sell your data. If you find beestat useful, please consider supporting the project. Your contribution helps pay for servers, storage, and other cool things. Thanks!')); + break; + } +}; + +/** + * Get the title. + * + * @return {string} The title. + */ +beestat.component.modal.patreon_hide.prototype.get_title_ = function() { + return 'Don\'t want to see this anymore?'; +}; + +/** + * Close the modal but run some special code first to make sure any running + * interval gets stopped. + */ +beestat.component.modal.patreon_hide.prototype.dispose = function() { + if (this.is_polling_ === true) { + beestat.remove_poll_interval(beestat.component.modal.patreon_hide.poll_interval_); + beestat.dispatcher.removeEventListener('poll.patreon_hide'); + } + + beestat.component.modal.prototype.dispose.apply(this, arguments); +}; + +/** + * Hide the Patreon card for some amount of time. + * + * @param {number} amount How long. + * @param {string} unit The unit (day, month, etc). + */ +beestat.component.modal.patreon_hide.prototype.hide_patreon_card_for_ = function(amount, unit) { + beestat.setting( + 'patreon_hide_until', + moment().utc() + .add(amount, unit) + .format('YYYY-MM-DD HH:mm:ss') + ); + beestat.cards.patreon.dispose(); +}; + +/** + * Determine whether or not the current user is an active Patron. + * + * @return {boolean} true if yes, false if no, null if not connected. + */ +beestat.component.modal.patreon_hide.prototype.is_active_patron_ = function() { + var user = beestat.get_user(); + if (user.json_patreon_status !== null) { + return (user.json_patreon_status.patron_status === 'active_patron'); + } + return null; +}; + +/** + * Get the buttons on the modal. + * + * @return {[beestat.component.button]} The buttons. + */ +beestat.component.modal.patreon_hide.prototype.get_buttons_ = function() { + var self = this; + + var hide = new beestat.component.button() + .set_background_color('#fff') + .set_text_color(beestat.style.color.gray.base) + .set_text_hover_color(beestat.style.color.bluegray.base) + .set_text('Hide for one month') + .addEventListener('click', function() { + self.hide_patreon_card_for_(1, 'month'); + self.dispose(); + }); + + if (self.is_active_patron_() === null) { + var link = new beestat.component.button() + .set_text('Link Patreon to hide forever') + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .addEventListener('click', function() { + this + .set_background_color(beestat.style.color.gray.base) + .set_background_hover_color() + .set_text('Waiting for Patreon...') + .removeEventListener('click'); + + beestat.add_poll_interval(beestat.component.modal.patreon_hide.poll_interval_); + self.is_polling_ = true; + + beestat.dispatcher.addEventListener('poll.patreon_hide', function() { + switch (self.is_active_patron_()) { + case true: + // Connected and is Patron + beestat.cards.patreon.dispose(); + self.dispose(); + break; + case false: + // Connected but isn't Patron + self.hide_patreon_card_for_(3, 'day'); + self.dispose(); + break; + } + }); + + window.open('../api/?resource=patreon&method=authorize&arguments={}&api_key=ER9Dz8t05qUdui0cvfWi5GiVVyHP6OB8KPuSisP2'); + }); + + return [ + hide, + link + ]; + } + + var ok = new beestat.component.button() + .set_background_color(beestat.style.color.green.base) + .set_background_hover_color(beestat.style.color.green.light) + .set_text_color('#fff') + .set_text('OK') + .addEventListener('click', function() { + self.dispose(); + }); + + return [ + hide, + ok + ]; +}; diff --git a/js/component/modal/thermostat_info.js b/js/component/modal/thermostat_info.js new file mode 100644 index 0000000..e5c1790 --- /dev/null +++ b/js/component/modal/thermostat_info.js @@ -0,0 +1,67 @@ +/** + * Thermostat Details + */ +beestat.component.modal.thermostat_info = function() { + beestat.component.modal.apply(this, arguments); +}; +beestat.extend(beestat.component.modal.thermostat_info, beestat.component.modal); + +beestat.component.modal.thermostat_info.prototype.decorate_contents_ = function(parent) { + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + + var ecobee_thermostat = beestat.cache.ecobee_thermostat[ + thermostat.ecobee_thermostat_id + ]; + + var container = $.createElement('div') + .style({ + 'display': 'grid', + 'grid-template-columns': 'repeat(auto-fill, minmax(150px, 1fr))', + 'margin': '0 0 16px -16px' + }); + parent.appendChild(container); + + var fields = [ + { + 'name': 'Model', + 'value': beestat.ecobee_thermostat_models[ecobee_thermostat.model_number] || 'Unknown' + }, + { + 'name': 'Serial Number', + 'value': ecobee_thermostat.identifier + }, + { + 'name': 'Firmware Revision', + 'value': ecobee_thermostat.json_version.thermostatFirmwareVersion + }, + { + 'name': 'Weather Station', + 'value': ecobee_thermostat.json_weather.weatherStation + }, + { + 'name': 'First Connected', + 'value': moment.utc(ecobee_thermostat.json_runtime.firstConnected).local() + .format('MMM Do, YYYY') + } + ]; + + fields.forEach(function(field) { + var div = $.createElement('div') + .style({ + 'padding': '16px 0 0 16px' + }); + container.appendChild(div); + + div.appendChild($.createElement('div') + .style({ + 'font-weight': beestat.style.font_weight.bold, + 'margin-bottom': (beestat.style.size.gutter / 4) + }) + .innerHTML(field.name)); + div.appendChild($.createElement('div').innerHTML(field.value)); + }); +}; + +beestat.component.modal.thermostat_info.prototype.get_title_ = function() { + return 'Thermostat Info'; +}; diff --git a/js/component/title.js b/js/component/title.js new file mode 100644 index 0000000..3a7ebfd --- /dev/null +++ b/js/component/title.js @@ -0,0 +1,29 @@ +/** + * Simple bolded title text with a margin. + * + * @param {string} title The title. + */ +beestat.component.title = function(title) { + this.title_ = title; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.title, beestat.component); + +beestat.component.title.prototype.rerender_on_breakpoint_ = false; + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.title.prototype.decorate_ = function(parent) { + var title = $.createElement('div') + .style({ + 'font-size': beestat.style.font_size.normal, + 'font-weight': beestat.style.font_weight.bold, + 'margin-top': (beestat.style.size.gutter), + 'margin-bottom': (beestat.style.size.gutter / 2) + }) + .innerText(this.title_); + parent.appendChild(title); +}; diff --git a/js/js.php b/js/js.php new file mode 100644 index 0000000..ffd171d --- /dev/null +++ b/js/js.php @@ -0,0 +1,94 @@ +window.environment = \'' . $setting->get('environment') . '\';'; +echo ''; + +if($setting->get('environment') === 'dev' || $setting->get('environment') === 'dev_live') { + // External libraries + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + + // Beestat + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + + // Layer + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + + // Component + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; +} +else { + echo '' . PHP_EOL; +} + diff --git a/js/layer.js b/js/layer.js new file mode 100644 index 0000000..e107c6d --- /dev/null +++ b/js/layer.js @@ -0,0 +1,53 @@ +beestat.layer = function() { + this.loaders_ = []; +}; + +/** + * Render this layer onto the body. First put everything in a container, then + * clear the body, then append the new container. This prevents the child + * layers from having to worry about multiple redraws since they aren't doing + * anything directly on the body. + */ +beestat.layer.prototype.render = function() { + rocket.EventTarget.removeAllEventListeners(); + + beestat.current_layer = this; + + var body = $(document.body); + + var container = $.createElement('div'); + this.decorate_(container); + + this.run_loaders_(); + + body.innerHTML(''); + body.appendChild(container); +}; + +beestat.layer.prototype.decorate_ = function(parent) { + // Left for the sublcass to implement. +}; + +/** + * Register a loader. Components do this. If the same function reference is + * passed by multiple components, the duplicates will be removed. + * + * @param {Function} loader A function to call when all of the components have + * been added to the layer. + */ +beestat.layer.prototype.register_loader = function(loader) { + if (this.loaders_.indexOf(loader) === -1) { + this.loaders_.push(loader); + } +}; + +/** + * Execute all of the loaders. This is run once the decorate function has + * completed and thus all of the components in the layer have had a chance to + * add their loaders. + */ +beestat.layer.prototype.run_loaders_ = function() { + this.loaders_.forEach(function(loader) { + loader(); + }); +}; diff --git a/js/layer/dashboard.js b/js/layer/dashboard.js new file mode 100644 index 0000000..90ba278 --- /dev/null +++ b/js/layer/dashboard.js @@ -0,0 +1,100 @@ +/** + * Dashboard layer. + */ +beestat.layer.dashboard = function() { + beestat.layer.apply(this, arguments); +}; +beestat.extend(beestat.layer.dashboard, beestat.layer); + +beestat.layer.dashboard.prototype.decorate_ = function(parent) { + /* + * Set the overflow on the body so the scrollbar is always present so + * highcharts graphs render properly. + */ + $('body').style({ + 'overflow-y': 'scroll', + 'background': beestat.style.color.bluegray.light, + 'padding': '0 ' + beestat.style.size.gutter + 'px' + }); + + (new beestat.component.header('dashboard')).render(parent); + + // All the cards + var cards = []; + + if (window.is_demo === true) { + cards.push([ + { + 'card': new beestat.component.card.demo(), + 'size': 12 + } + ]); + } + + cards.push([ + { + 'card': new beestat.component.card.system(), + 'size': 4 + }, + { + 'card': new beestat.component.card.sensors(), + 'size': 4 + }, + { + 'card': new beestat.component.card.alerts(), + 'size': 4 + } + ]); + + // Show the Patreon card by default; look for reasons to hide it. + var show_patreon_card = true; + + var user = beestat.get_user(); + if ( + user.json_patreon_status !== null && + user.json_patreon_status.patron_status === 'active_patron' + ) { + show_patreon_card = false; + } + + if ( + ( + beestat.setting('patreon_hide_until') !== undefined && + moment.utc(beestat.setting('patreon_hide_until')).isAfter(moment.utc()) + ) || + window.is_demo === true + ) { + show_patreon_card = false; + } + + if (show_patreon_card === true) { + cards.push([ + { + 'card': new beestat.component.card.patreon(), + 'size': 12, + 'global': 'patreon' // TODO REMOVE THIS + } + ]); + } + + cards.push([ + { + 'card': new beestat.component.card.recent_activity(), + 'size': 12 + } + ]); + cards.push([ + { + 'card': new beestat.component.card.aggregate_runtime(), + 'size': 12 + } + ]); + cards.push([ + { + 'card': new beestat.component.card.footer(), + 'size': 12 + } + ]); + + (new beestat.component.layout(cards)).render(parent); +}; diff --git a/js/layer/home_comparisons.js b/js/layer/home_comparisons.js new file mode 100644 index 0000000..6eb28dd --- /dev/null +++ b/js/layer/home_comparisons.js @@ -0,0 +1,100 @@ +/** + * Home comparisons layer. + */ +beestat.layer.home_comparisons = function() { + beestat.layer.apply(this, arguments); +}; +beestat.extend(beestat.layer.home_comparisons, beestat.layer); + +beestat.layer.home_comparisons.prototype.decorate_ = function(parent) { + /* + * Set the overflow on the body so the scrollbar is always present so + * highcharts graphs render properly. + */ + $('body').style({ + 'overflow-y': 'scroll', + 'background': beestat.style.color.bluegray.light, + 'padding': '0 ' + beestat.style.size.gutter + 'px' + }); + + (new beestat.component.header('home_comparisons')).render(parent); + + var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')]; + var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id]; + + // All the cards + var cards = []; + + if (window.is_demo === true) { + cards.push([ + { + 'card': new beestat.component.card.demo(), + 'size': 12 + } + ]); + } + + cards.push([ + { + 'card': new beestat.component.card.comparison_settings(), + 'size': 8 + }, + { + 'card': new beestat.component.card.my_home(), + 'size': 4 + } + ]); + + // Scores and graph + if (thermostat_group.temperature_profile !== null) { + cards.push([ + { + 'card': new beestat.component.card.score.heat(), + 'size': 4 + }, + { + 'card': new beestat.component.card.score.cool(), + 'size': 4 + }, + { + 'card': new beestat.component.card.score.resist(), + 'size': 4 + } + ]); + + if ( + ( + thermostat_group.temperature_profile.heat !== undefined && + thermostat_group.temperature_profile.heat.linear_trendline.slope < 0 + ) || + ( + thermostat_group.temperature_profile.cool !== undefined && + thermostat_group.temperature_profile.cool.linear_trendline.slope < 0 + ) + ) { + cards.push([ + { + 'card': new beestat.component.card.comparison_issue(), + 'size': 12 + } + ]); + } + + cards.push([ + { + 'card': new beestat.component.card.temperature_profiles(), + 'size': 12 + } + ]); + } + + // Footer + cards.push([ + { + 'card': new beestat.component.card.footer(), + 'size': 12 + } + ]); + + (new beestat.component.layout(cards)).render(parent); +}; diff --git a/js/layer/load.js b/js/layer/load.js new file mode 100644 index 0000000..4c2fffe --- /dev/null +++ b/js/layer/load.js @@ -0,0 +1,226 @@ +/** + * Load layer. + */ +beestat.layer.load = function() { + beestat.layer.apply(this, arguments); +}; +beestat.extend(beestat.layer.load, beestat.layer); + +beestat.layer.load.prototype.decorate_ = function(parent) { + $('body').style({ + 'overflow-y': 'scroll', + 'background': beestat.style.color.bluegray.light, + 'padding': '0 ' + beestat.style.size.gutter + 'px' + }); + + var logo_container = $.createElement('div'); + logo_container.style({ + 'margin': '100px auto 32px auto', + 'text-align': 'center' + }); + parent.appendChild(logo_container); + + (new beestat.component.logo()).render(logo_container); + + var loading_text = $.createElement('div'); + loading_text.style({ + 'font-weight': '500', + 'margin': '0 auto 16px auto', + 'text-align': 'center' + }); + parent.appendChild(loading_text); + + (new beestat.component.loading()).render(loading_text); + + var batch = [ + { + 'resource': 'thermostat', + 'method': 'sync', + 'alias': 'thermostat_sync' + }, + { + 'resource': 'sensor', + 'method': 'sync', + 'alias': 'sensor_sync' + }, + { + 'resource': 'user', + 'method': 'read_id', + 'alias': 'user' + }, + { + 'resource': 'thermostat', + 'method': 'read_id', + 'alias': 'thermostat', + 'arguments': { + 'attributes': { + 'inactive': 0 + } + } + }, + { + 'resource': 'thermostat_group', + 'method': 'read_id', + 'alias': 'thermostat_group' + }, + { + 'resource': 'sensor', + 'method': 'read_id', + 'alias': 'sensor', + 'arguments': { + 'attributes': { + 'inactive': 0 + } + } + }, + { + 'resource': 'ecobee_thermostat', + 'method': 'read_id', + 'alias': 'ecobee_thermostat', + 'arguments': { + 'attributes': { + 'inactive': 0 + } + } + }, + { + 'resource': 'ecobee_sensor', + 'method': 'read_id', + 'alias': 'ecobee_sensor', + 'arguments': { + 'attributes': { + 'inactive': 0 + } + } + }, + { + 'resource': 'address', + 'method': 'read_id', + 'alias': 'address' + }, + { + 'resource': 'announcement', + 'method': 'read_id', + 'alias': 'announcement' + } + ]; + + // First up, sync all thermostats and sensors. + beestat.api( + 'api', + 'batch', + batch, + function(response) { + beestat.cache.set('user', response.user); + + // Rollbar isn't defined on dev. + if (window.Rollbar !== undefined) { + Rollbar.configure({ + 'payload': { + 'person': { + 'id': beestat.get_user().user_id + }, + 'beestat': { + 'user_id': beestat.get_user().user_id + } + } + }); + } + + beestat.cache.set('thermostat', response.thermostat); + beestat.cache.set('thermostat_group', response.thermostat_group); + beestat.cache.set('sensor', response.sensor); + + beestat.cache.set('ecobee_thermostat', response.ecobee_thermostat); + beestat.cache.set('ecobee_sensor', response.ecobee_sensor); + beestat.cache.set('address', response.address); + beestat.cache.set('announcement', response.announcement); + beestat.cache.set('ecobee_runtime_thermostat', []); + beestat.cache.set('aggregate_runtime', []); + + // Set the active thermostat_id if this is your first time visiting. + if (beestat.setting('thermostat_id') === undefined) { + beestat.setting( + 'thermostat_id', + $.values(beestat.cache.thermostat)[0].thermostat_id + ); + } + + // Change the active thermostat_id if the one you have is no longer valid. + if (response.thermostat[beestat.setting('thermostat_id')] === undefined) { + beestat.setting('thermostat_id', Object.keys(response.thermostat)[0]); + } + + var thermostat = beestat.cache.thermostat[ + beestat.setting('thermostat_id') + ]; + var ecobee_thermostat = beestat.cache.ecobee_thermostat[ + thermostat.ecobee_thermostat_id + ]; + + // Rename series if only one stage is available. + if (ecobee_thermostat.json_settings.coolStages === 1) { + beestat.series.compressor_cool_1.name = 'Cool'; + } + if (ecobee_thermostat.json_settings.heatStages === 1) { + beestat.series.compressor_heat_1.name = 'Heat'; + } + + // Fix some other stuff for non-heat-pump. + if (ecobee_thermostat.json_settings.hasHeatPump === false) { + beestat.series.auxiliary_heat_1.name = + beestat.series.compressor_heat_1.name; + beestat.series.auxiliary_heat_1.color = + beestat.series.compressor_heat_1.color; + beestat.series.auxiliary_heat_2.name = + beestat.series.compressor_heat_2.name; + beestat.series.auxiliary_heat_2.color = + beestat.series.compressor_heat_2.color; + beestat.series.auxiliary_heat_3.name = 'Heat 3'; + beestat.series.auxiliary_heat_3.color = '#d35400'; + } + + /* + * Fire off an API call to sync. The cron job will eventually run but this + * ensures things get moving quicker. + */ + beestat.api( + 'ecobee_runtime_thermostat', + 'sync', + { + 'thermostat_id': thermostat.thermostat_id + } + ); + + // Enable polling for live updates + beestat.enable_poll(); + + (new beestat.layer.dashboard()).render(); + + /* + * If never seen an announcement, or if there is an unread important + * announcement, show the modal. + */ + var last_read_announcement_id = beestat.setting('last_read_announcement_id'); + + var most_recent_important_announcement_id; + var announcements = $.values(beestat.cache.announcement).reverse(); + for (var i = 0; i < announcements.length; i++) { + if (announcements[i].important === true) { + most_recent_important_announcement_id = announcements[i].announcement_id; + break; + } + } + + if ( + last_read_announcement_id === undefined || + ( + most_recent_important_announcement_id !== undefined && + last_read_announcement_id < most_recent_important_announcement_id + ) + ) { + (new beestat.component.modal.announcements()).render(); + } + } + ); +}; diff --git a/js/lib/highcharts/highcharts.js b/js/lib/highcharts/highcharts.js new file mode 100644 index 0000000..c711dc5 --- /dev/null +++ b/js/lib/highcharts/highcharts.js @@ -0,0 +1,428 @@ +/* + Highcharts JS vv7.0.3 custom build (2019-02-27) + + (c) 2009-2016 Torstein Honsi + + License: www.highcharts.com/license +*/ +(function(Q,L){"object"===typeof module&&module.exports?(L["default"]=L,module.exports=Q.document?L(Q):L):"function"===typeof define&&define.amd?define(function(){return L(Q)}):Q.Highcharts=L(Q)})("undefined"!==typeof window?window:this,function(Q){var L=function(){var a="undefined"===typeof Q?"undefined"!==typeof window?window:{}:Q,E=a.document,I=a.navigator&&a.navigator.userAgent||"",C=E&&E.createElementNS&&!!E.createElementNS("http://www.w3.org/2000/svg","svg").createSVGRect,e=/(edge|msie|trident)/i.test(I)&& +!a.opera,g=-1!==I.indexOf("Firefox"),n=-1!==I.indexOf("Chrome"),t=g&&4>parseInt(I.split("Firefox/")[1],10);return a.Highcharts?a.Highcharts.error(16,!0):{product:"Highcharts",version:"v7.0.3 custom build",deg2rad:2*Math.PI/360,doc:E,hasBidiBug:t,hasTouch:E&&void 0!==E.documentElement.ontouchstart,isMS:e,isWebKit:-1!==I.indexOf("AppleWebKit"),isFirefox:g,isChrome:n,isSafari:!n&&-1!==I.indexOf("Safari"),isTouchDevice:/(Mobile|Android|Windows Phone)/.test(I),SVG_NS:"http://www.w3.org/2000/svg",chartCount:0, +seriesTypes:{},symbolSizes:{},svg:C,win:a,marginNames:["plotTop","marginRight","marginBottom","plotLeft"],noop:function(){},charts:[]}}();(function(a){a.timers=[];var E=a.charts,I=a.doc,C=a.win;a.error=function(e,g,n){var t=a.isNumber(e)?"Highcharts error #"+e+": www.highcharts.com/errors/"+e:e;n&&a.fireEvent(n,"displayError",{code:e});if(g)throw Error(t);C.console&&console.log(t)};a.Fx=function(a,g,n){this.options=g;this.elem=a;this.prop=n};a.Fx.prototype={dSetter:function(){var a=this.paths[0], +g=this.paths[1],n=[],t=this.now,u=a.length;if(1===t)n=this.toD;else if(u===g.length&&1>t)for(;u--;){var w=parseFloat(a[u]);n[u]=isNaN(w)?g[u]:t*parseFloat(g[u]-w)+w}else n=g;this.elem.attr("d",n,null,!0)},update:function(){var a=this.elem,g=this.prop,n=this.now,t=this.options.step;if(this[g+"Setter"])this[g+"Setter"]();else a.attr?a.element&&a.attr(g,n,null,!0):a.style[g]=n+this.unit;t&&t.call(a,n,this)},run:function(e,g,n){var t=this,u=t.options,w=function(a){return w.stopped?!1:t.step(a)},d=C.requestAnimationFrame|| +function(a){setTimeout(a,13)},b=function(){for(var c=0;c=w+this.startTime){this.now=this.end;this.pos=1;this.update();var b=d[this.prop]=!0;a.objectEach(d,function(a){!0!==a&&(b=!1)});b&&u&&u.call(t);e=!1}else this.pos=n.easing((g-this.startTime)/w),this.now=this.start+(this.end-this.start)*this.pos,this.update(),e=!0;return e},initPath:function(e,g,n){function t(a){for(r=a.length;r--;){var c="M"===a[r]||"L"===a[r];var h=/[a-zA-Z]/.test(a[r+3]);c&&h&&a.splice(r+1,0,a[r+1],a[r+ +2],a[r+1],a[r+2])}}function u(a,c){for(;a.lengtha&&-Infinity=n&&(g=[1/n])));for(t=0;t=e||!u&&d<=(g[t]+(g[t+1]||g[t]))/2);t++);return w=a.correctFloat(w* +n,-Math.round(Math.log(.001)/Math.LN10))};a.stableSort=function(a,g){var e=a.length,t,u;for(u=0;un&&(n=a[e]);return n};a.destroyObjectProperties=function(e,g){a.objectEach(e,function(a,t){a&&a!==g&&a.destroy&&a.destroy();delete e[t]})};a.discardElement= +function(e){var g=a.garbageBin;g||(g=a.createElement("div"));e&&g.appendChild(e);g.innerHTML=""};a.correctFloat=function(a,g){return parseFloat(a.toPrecision(g||14))};a.setAnimation=function(e,g){g.renderer.globalAnimation=a.pick(e,g.options.chart.animation,!0)};a.animObject=function(e){return a.isObject(e)?a.merge(e):{duration:e?500:0}};a.timeUnits={millisecond:1,second:1E3,minute:6E4,hour:36E5,day:864E5,week:6048E5,month:24192E5,year:314496E5};a.numberFormat=function(e,g,n,t){e=+e||0;g=+g;var u= +a.defaultOptions.lang,w=(e.toString().split(".")[1]||"").split("e")[0].length,d=e.toString().split("e");if(-1===g)g=Math.min(w,20);else if(!a.isNumber(g))g=2;else if(g&&d[1]&&0>d[1]){var b=g+ +d[1];0<=b?(d[0]=(+d[0]).toExponential(b).split("e")[0],g=b):(d[0]=d[0].split(".")[0]||0,e=20>g?(d[0]*Math.pow(10,d[1])).toFixed(g):0,d[1]=0)}var c=(Math.abs(d[1]?d[0]:e)+Math.pow(10,-Math.max(g,w)-1)).toFixed(g);w=String(a.pInt(c));b=3e?"-":"")+(b?w.substr(0,b)+t:"");e+=w.substr(b).replace(/(\d{3})(?=\d)/g,"$1"+t);g&&(e+=n+c.slice(-g));d[1]&&0!==+e&&(e+="e"+d[1]);return e};Math.easeInOutSine=function(a){return-.5*(Math.cos(Math.PI*a)-1)};a.getStyle=function(e,g,n){if("width"===g)return Math.max(0,Math.min(e.offsetWidth,e.scrollWidth,e.getBoundingClientRect&&"none"===a.getStyle(e,"transform",!1)?Math.floor(e.getBoundingClientRect().width):Infinity)-a.getStyle(e,"padding-left")-a.getStyle(e,"padding-right"));if("height"===g)return Math.max(0, +Math.min(e.offsetHeight,e.scrollHeight)-a.getStyle(e,"padding-top")-a.getStyle(e,"padding-bottom"));C.getComputedStyle||a.error(27,!0);if(e=C.getComputedStyle(e,void 0))e=e.getPropertyValue(g),a.pick(n,"opacity"!==g)&&(e=a.pInt(e));return e};a.inArray=function(a,g,n){return g.indexOf(a,n)};a.find=Array.prototype.find?function(a,g){return a.find(g)}:function(a,g){var e,t=a.length;for(e=0;e>16,(e&65280)>>8,e&255,1]:4===t&&(g=[(e&3840)>>4|(e&3840)>>8,(e&240)>>4|e&240,(e&15)<<4|e&15,1])}if(!g)for(n=this.parsers.length;n--&&!g;){var u= +this.parsers[n];(t=u.regex.exec(e))&&(g=u.parse(t))}}this.rgba=g||[]},get:function(a){var e=this.input,n=this.rgba;if(this.stops){var t=I(e);t.stops=[].concat(t.stops);this.stops.forEach(function(e,g){t.stops[g]=[t.stops[g][0],e.get(a)]})}else t=n&&E(n[0])?"rgb"===a||!a&&1===n[3]?"rgb("+n[0]+","+n[1]+","+n[2]+")":"a"===a?n[3]:"rgba("+n.join(",")+")":e;return t},brighten:function(a){var e,n=this.rgba;if(this.stops)this.stops.forEach(function(e){e.brighten(a)});else if(E(a)&&0!==a)for(e=0;3>e;e++)n[e]+= +C(255*a),0>n[e]&&(n[e]=0),255c?"AM":"PM",P:12>c?"am":"pm",S:H(b.getSeconds()),L:H(Math.floor(g%1E3),3)},a.dateFormats);a.objectEach(b,function(a,c){for(;-1!==e.indexOf("%"+c);)e=e.replace("%"+c,"function"===typeof a?a.call(d,g):a)});return n?e.substr(0,1).toUpperCase()+e.substr(1):e},resolveDTLFormat:function(e){return a.isObject(e,!0)?e:(e=a.splat(e), +{main:e[0],from:e[1],to:e[2]})},getTimeTicks:function(a,n,w,d){var b=this,c=[],m={};var x=new b.Date(n);var r=a.unitRange,p=a.count||1,q;d=e(d,1);if(E(n)){b.set("Milliseconds",x,r>=g.second?0:p*Math.floor(b.get("Milliseconds",x)/p));r>=g.second&&b.set("Seconds",x,r>=g.minute?0:p*Math.floor(b.get("Seconds",x)/p));r>=g.minute&&b.set("Minutes",x,r>=g.hour?0:p*Math.floor(b.get("Minutes",x)/p));r>=g.hour&&b.set("Hours",x,r>=g.day?0:p*Math.floor(b.get("Hours",x)/p));r>=g.day&&b.set("Date",x,r>=g.month? +1:Math.max(1,p*Math.floor(b.get("Date",x)/p)));if(r>=g.month){b.set("Month",x,r>=g.year?0:p*Math.floor(b.get("Month",x)/p));var D=b.get("FullYear",x)}r>=g.year&&b.set("FullYear",x,D-D%p);r===g.week&&(D=b.get("Day",x),b.set("Date",x,b.get("Date",x)-D+d+(D4*g.month||b.getTimezoneOffset(n)!==b.getTimezoneOffset(w));n=x.getTime();for(x=1;nc.length&&c.forEach(function(a){0===a%18E5&&"000000000"===b.dateFormat("%H%M%S%L",a)&&(m[a]="day")})}c.info=I(a,{higherRanks:m,totalRange:r*p});return c}}})(L);(function(a){var E=a.color,I=a.merge;a.defaultOptions={colors:"#7cb5ec #434348 #90ed7d #f7a35c #8085e9 #f15c80 #e4d354 #2b908f #f45b5b #91e8e1".split(" "),symbols:["circle", +"diamond","square","triangle","triangle-down"],lang:{loading:"Loading...",months:"January February March April May June July August September October November December".split(" "),shortMonths:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),weekdays:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),decimalPoint:".",numericSymbols:"kMGTPE".split(""),resetZoom:"Reset zoom",resetZoomTitle:"Reset zoom level 1:1",thousandsSep:" "},global:{},time:a.Time.prototype.defaultOptions, +chart:{styledMode:!1,borderRadius:0,colorCount:10,defaultSeriesType:"line",ignoreHiddenSeries:!0,spacing:[10,10,15,10],resetZoomButton:{theme:{zIndex:6},position:{align:"right",x:-10,y:10}},width:null,height:null,borderColor:"#335cad",backgroundColor:"#ffffff",plotBorderColor:"#cccccc"},title:{text:"Chart title",align:"center",margin:15,widthAdjust:-44},subtitle:{text:"",align:"center",widthAdjust:-44},plotOptions:{},labels:{style:{position:"absolute",color:"#333333"}},legend:{enabled:!0,align:"center", +alignColumns:!0,layout:"horizontal",labelFormatter:function(){return this.name},borderColor:"#999999",borderRadius:0,navigation:{activeColor:"#003399",inactiveColor:"#cccccc"},itemStyle:{color:"#333333",cursor:"pointer",fontSize:"12px",fontWeight:"bold",textOverflow:"ellipsis"},itemHoverStyle:{color:"#000000"},itemHiddenStyle:{color:"#cccccc"},shadow:!1,itemCheckboxStyle:{position:"absolute",width:"13px",height:"13px"},squareSymbol:!0,symbolPadding:5,verticalAlign:"bottom",x:0,y:0,title:{style:{fontWeight:"bold"}}}, +loading:{labelStyle:{fontWeight:"bold",position:"relative",top:"45%"},style:{position:"absolute",backgroundColor:"#ffffff",opacity:.5,textAlign:"center"}},tooltip:{enabled:!0,animation:a.svg,borderRadius:3,dateTimeLabelFormats:{millisecond:"%A, %b %e, %H:%M:%S.%L",second:"%A, %b %e, %H:%M:%S",minute:"%A, %b %e, %H:%M",hour:"%A, %b %e, %H:%M",day:"%A, %b %e, %Y",week:"Week from %A, %b %e, %Y",month:"%B %Y",year:"%Y"},footerFormat:"",padding:8,snap:a.isTouchDevice?25:10,headerFormat:'{point.key}
', +pointFormat:'\u25cf {series.name}: {point.y}
',backgroundColor:E("#f7f7f7").setOpacity(.85).get(),borderWidth:1,shadow:!0,style:{color:"#333333",cursor:"default",fontSize:"12px",pointerEvents:"none",whiteSpace:"nowrap"}},credits:{enabled:!0,href:"https://www.highcharts.com?credits",position:{align:"right",x:-10,verticalAlign:"bottom",y:-5},style:{cursor:"pointer",color:"#999999",fontSize:"9px"},text:"Highcharts.com"}};a.setOptions=function(C){a.defaultOptions= +I(!0,a.defaultOptions,C);a.time.update(I(a.defaultOptions.global,a.defaultOptions.time),!1);return a.defaultOptions};a.getOptions=function(){return a.defaultOptions};a.defaultPlotOptions=a.defaultOptions.plotOptions;a.time=new a.Time(I(a.defaultOptions.global,a.defaultOptions.time));a.dateFormat=function(C,e,g){return a.time.dateFormat(C,e,g)}})(L);(function(a){var E=a.addEvent,I=a.css,C=a.discardElement,e=a.defined,g=a.fireEvent,n=a.isFirefox,t=a.marginNames,u=a.merge,w=a.pick,d=a.setAnimation,b= +a.stableSort,c=a.win,m=a.wrap;a.Legend=function(a,c){this.init(a,c)};a.Legend.prototype={init:function(a,c){this.chart=a;this.setOptions(c);c.enabled&&(this.render(),E(this.chart,"endResize",function(){this.legend.positionCheckboxes()}),this.proximate?this.unchartrender=E(this.chart,"render",function(){this.legend.proximatePositions();this.legend.positionItems()}):this.unchartrender&&this.unchartrender())},setOptions:function(a){var c=w(a.padding,8);this.options=a;this.chart.styledMode||(this.itemStyle= +a.itemStyle,this.itemHiddenStyle=u(this.itemStyle,a.itemHiddenStyle));this.itemMarginTop=a.itemMarginTop||0;this.padding=c;this.initialItemY=c-5;this.symbolWidth=w(a.symbolWidth,16);this.pages=[];this.proximate="proximate"===a.layout&&!this.chart.inverted},update:function(a,c){var b=this.chart;this.setOptions(u(!0,this.options,a));this.destroy();b.isDirtyLegend=b.isDirtyBox=!0;w(c,!0)&&b.redraw();g(this,"afterUpdate")},colorizeItem:function(a,c){a.legendGroup[c?"removeClass":"addClass"]("highcharts-legend-item-hidden"); +if(!this.chart.styledMode){var b=this.options,x=a.legendItem,r=a.legendLine,m=a.legendSymbol,d=this.itemHiddenStyle.color;b=c?b.itemStyle.color:d;var e=c?a.color||d:d,k=a.options&&a.options.marker,h={fill:e};x&&x.css({fill:b,color:b});r&&r.attr({stroke:e});m&&(k&&m.isMarker&&(h=a.pointAttribs(),c||(h.stroke=h.fill=d)),m.attr(h))}g(this,"afterColorizeItem",{item:a,visible:c})},positionItems:function(){this.allItems.forEach(this.positionItem,this);this.chart.isResizing||this.positionCheckboxes()},positionItem:function(a){var c= +this.options,b=c.symbolPadding;c=!c.rtl;var x=a._legendItemPos,d=x[0];x=x[1];var m=a.checkbox;if((a=a.legendGroup)&&a.element)a[e(a.translateY)?"animate":"attr"]({translateX:c?d:this.legendWidth-d-2*b-4,translateY:x});m&&(m.x=d,m.y=x)},destroyItem:function(a){var c=a.checkbox;["legendItem","legendLine","legendSymbol","legendGroup"].forEach(function(c){a[c]&&(a[c]=a[c].destroy())});c&&C(a.checkbox)},destroy:function(){function a(a){this[a]&&(this[a]=this[a].destroy())}this.getAllItems().forEach(function(c){["legendItem", +"legendGroup"].forEach(a,c)});"clipRect up down pager nav box title group".split(" ").forEach(a,this);this.display=null},positionCheckboxes:function(){var a=this.group&&this.group.alignAttr,c=this.clipHeight||this.legendHeight,b=this.titleHeight;if(a){var q=a.translateY;this.allItems.forEach(function(p){var x=p.checkbox;if(x){var r=q+b+x.y+(this.scrollOffset||0)+3;I(x,{left:a.translateX+p.checkboxOffset+x.x-20+"px",top:r+"px",display:this.proximate||r>q-6&&rk?this.maxItemWidth:a.itemWidth;q&&this.itemX-b+c>k&&(this.itemX=b,this.itemY+=m+this.lastLineHeight+d,this.lastLineHeight=0);this.lastItemY=m+this.itemY+d;this.lastLineHeight=Math.max(x,this.lastLineHeight);a._legendItemPos=[this.itemX,this.itemY];q?this.itemX+=c:(this.itemY+= +m+x+d,this.lastLineHeight=x);this.offsetWidth=this.widthOption||Math.max((q?this.itemX-b-(a.checkbox?0:e):c)+b,this.offsetWidth)},getAllItems:function(){var a=[];this.chart.series.forEach(function(c){var b=c&&c.options;c&&w(b.showInLegend,e(b.linkedTo)?!1:void 0,!0)&&(a=a.concat(c.legendItems||("point"===b.legendType?c.data:c)))});g(this,"afterGetAllItems",{allItems:a});return a},getAlignment:function(){var a=this.options;return this.proximate?a.align.charAt(0)+"tv":a.floating?"":a.align.charAt(0)+ +a.verticalAlign.charAt(0)+a.layout.charAt(0)},adjustMargins:function(a,c){var b=this.chart,q=this.options,d=this.getAlignment(),m=void 0!==b.options.title.margin?b.titleOffset+b.options.title.margin:0;d&&[/(lth|ct|rth)/,/(rtv|rm|rbv)/,/(rbh|cb|lbh)/,/(lbv|lm|ltv)/].forEach(function(p,x){p.test(d)&&!e(a[x])&&(b[t[x]]=Math.max(b[t[x]],b.legend[(x+1)%2?"legendHeight":"legendWidth"]+[1,-1,-1,1][x]*q[x%2?"x":"y"]+w(q.margin,12)+c[x]+(0===x&&(0===b.titleOffset?0:m))))})},proximatePositions:function(){var c= +this.chart,b=[],p="left"===this.options.align;this.allItems.forEach(function(q){var d=p;if(q.xAxis&&q.points){q.xAxis.options.reversed&&(d=!d);d=a.find(d?q.points:q.points.slice(0).reverse(),function(c){return a.isNumber(c.plotY)});var m=q.legendGroup.getBBox().height;b.push({target:q.visible?(d?d.plotY:q.xAxis.height)-.3*m:c.plotHeight,size:m,item:q})}},this);a.distribute(b,c.plotHeight);b.forEach(function(a){a.item._legendItemPos[1]=c.plotTop-c.spacing[0]+a.pos})},render:function(){var c=this.chart, +d=c.renderer,p=this.group,m,e=this.box,y=this.options,n=this.padding;this.itemX=n;this.itemY=this.initialItemY;this.lastItemY=this.offsetWidth=0;this.widthOption=a.relativeLength(y.width,c.spacingBox.width-n);var z=c.spacingBox.width-2*n-y.x;-1<["rm","lm"].indexOf(this.getAlignment().substring(0,2))&&(z/=2);this.maxLegendWidth=this.widthOption||z;p||(this.group=p=d.g("legend").attr({zIndex:7}).add(),this.contentGroup=d.g().attr({zIndex:1}).add(p),this.scrollGroup=d.g().add(this.contentGroup));this.renderTitle(); +z=this.getAllItems();b(z,function(a,c){return(a.options&&a.options.legendIndex||0)-(c.options&&c.options.legendIndex||0)});y.reversed&&z.reverse();this.allItems=z;this.display=m=!!z.length;this.itemHeight=this.totalItemWidth=this.maxItemWidth=this.lastLineHeight=0;z.forEach(this.renderItem,this);z.forEach(this.layoutItem,this);z=(this.widthOption||this.offsetWidth)+n;var k=this.lastItemY+this.lastLineHeight+this.titleHeight;k=this.handleOverflow(k);k+=n;e||(this.box=e=d.rect().addClass("highcharts-legend-box").attr({r:y.borderRadius}).add(p), +e.isNew=!0);c.styledMode||e.attr({stroke:y.borderColor,"stroke-width":y.borderWidth||0,fill:y.backgroundColor||"none"}).shadow(y.shadow);0e&&!1!==v.enabled?(this.clipHeight=k=Math.max(e-20-this.titleHeight-x,0),this.currentPage=w(this.currentPage,1),this.fullHeight=a,A.forEach(function(a,f){var h=a._legendItemPos[1],G=Math.round(a.legendItem.getBBox().height),c=l.length;if(!c||h-l[c-1]>k&&(J||h)!== +l[c-1])l.push(J||h),c++;a.pageIx=c-1;J&&(A[f-1].pageIx=c-1);f===A.length-1&&h+G-l[c-1]>k&&h!==J&&(l.push(h),a.pageIx=c);h!==J&&(J=h)}),h||(h=c.clipRect=d.clipRect(0,x,9999,0),c.contentGroup.clip(h)),N(k),f||(this.nav=f=d.g().attr({zIndex:1}).add(this.group),this.up=d.symbol("triangle",0,0,M,M).on("click",function(){c.scroll(-1,F)}).add(f),this.pager=d.text("",15,10).addClass("highcharts-legend-navigation"),b.styledMode||this.pager.css(v.style),this.pager.add(f),this.down=d.symbol("triangle-down", +0,0,M,M).on("click",function(){c.scroll(1,F)}).add(f)),c.scroll(0),a=e):f&&(N(),this.nav=f.destroy(),this.scrollGroup.attr({translateY:1}),this.clipHeight=0);return a},scroll:function(a,c){var b=this.pages,m=b.length,e=this.currentPage+a,x=this.clipHeight,r=this.options.navigation,g=this.pager,k=this.padding;e>m&&(e=m);0q&&(d=typeof c[0],"string"===d?b.name=c[0]:"number"===d&&(b.x=c[0]),g++);y=e.value;)e=b[++d];this.nonZonedColor||(this.nonZonedColor=this.color);this.color= +e&&e.color&&!this.options.color?e.color:this.nonZonedColor;return e},destroy:function(){var a=this.series.chart,d=a.hoverPoints,e;a.pointCount--;d&&(this.setState(),C(d,this),d.length||(a.hoverPoints=null));if(this===a.hoverPoint)this.onMouseOut();if(this.graphic||this.dataLabel||this.dataLabels)b(this),this.destroyElements();this.legendItem&&a.legend.destroyItem(this);for(e in this)this[e]=null},destroyElements:function(){for(var a=["graphic","dataLabel","dataLabelUpper","connector","shadowGroup"], +b,d=6;d--;)b=a[d],this[b]&&(this[b]=this[b].destroy());this.dataLabels&&(this.dataLabels.forEach(function(a){a.element&&a.destroy()}),delete this.dataLabels);this.connectors&&(this.connectors.forEach(function(a){a.element&&a.destroy()}),delete this.connectors)},getLabelConfig:function(){return{x:this.category,y:this.y,color:this.color,colorIndex:this.colorIndex,key:this.name||this.category,series:this.series,point:this,percentage:this.percentage,total:this.total||this.stackTotal}},tooltipFormatter:function(a){var c= +this.series,b=c.tooltipOptions,d=u(b.valueDecimals,""),e=b.valuePrefix||"",q=b.valueSuffix||"";c.chart.styledMode&&(a=c.chart.tooltip.styledModeFormat(a));(c.pointArrayMap||["y"]).forEach(function(c){c="{point."+c;if(e||q)a=a.replace(RegExp(c+"}","g"),e+c+"}"+q);a=a.replace(RegExp(c+"}","g"),c+":,."+d+"f}")});return g(a,{point:this,series:this.series},c.chart.time)},firePointEvent:function(a,b,d){var c=this,m=this.series.options;(m.point.events[a]||c.options&&c.options.events&&c.options.events[a])&& +this.importEvents();"click"===a&&m.allowPointSelect&&(d=function(a){c.select&&c.select(null,a.ctrlKey||a.metaKey||a.shiftKey)});e(this,a,b,d)},visible:!0}})(L);(function(a){var E=a.addEvent,I=a.animate,C=a.attr,e=a.charts,g=a.color,n=a.css,t=a.createElement,u=a.defined,w=a.deg2rad,d=a.destroyObjectProperties,b=a.doc,c=a.extend,m=a.erase,x=a.hasTouch,r=a.isArray,p=a.isFirefox,q=a.isMS,D=a.isObject,y=a.isString,H=a.isWebKit,z=a.merge,k=a.noop,h=a.objectEach,v=a.pick,F=a.pInt,M=a.removeEvent,f=a.splat, +l=a.stop,J=a.svg,A=a.SVG_NS,N=a.symbolSizes,B=a.win;var K=a.SVGElement=function(){return this};c(K.prototype,{opacity:1,SVG_NS:A,textProps:"direction fontSize fontWeight fontFamily fontStyle color lineHeight width textAlign textDecoration textOverflow textOutline cursor".split(" "),init:function(f,c){this.element="span"===c?t(c):b.createElementNS(this.SVG_NS,c);this.renderer=f;a.fireEvent(this,"afterInit")},animate:function(f,c,l){var G=a.animObject(v(c,this.renderer.globalAnimation,!0));v(b.hidden, +b.msHidden,b.webkitHidden,!1)&&(G.duration=0);0!==G.duration?(l&&(G.complete=l),I(this,f,G)):(this.attr(f,null,l),a.objectEach(f,function(a,f){G.step&&G.step.call(this,a,{prop:f,pos:1})},this));return this},complexColor:function(f,c,l){var G=this.renderer,b,v,B,d,F,A,J,e,P,m,k,p=[],q;a.fireEvent(this.renderer,"complexColor",{args:arguments},function(){f.radialGradient?v="radialGradient":f.linearGradient&&(v="linearGradient");v&&(B=f[v],F=G.gradients,J=f.stops,m=l.radialReference,r(B)&&(f[v]=B={x1:B[0], +y1:B[1],x2:B[2],y2:B[3],gradientUnits:"userSpaceOnUse"}),"radialGradient"===v&&m&&!u(B.gradientUnits)&&(d=B,B=z(B,G.getRadialAttr(m,d),{gradientUnits:"userSpaceOnUse"})),h(B,function(a,f){"id"!==f&&p.push(f,a)}),h(J,function(a){p.push(a)}),p=p.join(","),F[p]?k=F[p].attr("id"):(B.id=k=a.uniqueKey(),F[p]=A=G.createElement(v).attr(B).add(G.defs),A.radAttr=d,A.stops=[],J.forEach(function(f){0===f[1].indexOf("rgba")?(b=a.color(f[1]),e=b.get("rgb"),P=b.get("a")):(e=f[1],P=1);f=G.createElement("stop").attr({offset:f[0], +"stop-color":e,"stop-opacity":P}).add(A);A.stops.push(f)})),q="url("+G.url+"#"+k+")",l.setAttribute(c,q),l.gradient=p,f.toString=function(){return q})})},applyTextOutline:function(f){var G=this.element,c,l;-1!==f.indexOf("contrast")&&(f=f.replace(/contrast/g,this.renderer.getContrast(G.style.fill)));f=f.split(" ");var h=f[f.length-1];if((c=f[0])&&"none"!==c&&a.svg){this.fakeTS=!0;f=[].slice.call(G.getElementsByTagName("tspan"));this.ySetter=this.xSetter;c=c.replace(/(^[\d\.]+)(.*?)$/g,function(a, +f,G){return 2*f+G});for(l=f.length;l--;){var b=f[l];"highcharts-text-outline"===b.getAttribute("class")&&m(f,G.removeChild(b))}var v=G.firstChild;f.forEach(function(a,f){0===f&&(a.setAttribute("x",G.getAttribute("x")),f=G.getAttribute("y"),a.setAttribute("y",f||0),null===f&&G.setAttribute("y",0));var l=a.cloneNode(1);C(l,{"class":"highcharts-text-outline",fill:h,stroke:h,"stroke-width":c,"stroke-linejoin":"round"});G.insertBefore(l,v)})}},symbolCustomAttribs:"x y width height r start end innerR anchorX anchorY rounded".split(" "), +attr:function(f,c,b,v){var G=this.element,B,d=this,F,A,J=this.symbolCustomAttribs;if("string"===typeof f&&void 0!==c){var e=f;f={};f[e]=c}"string"===typeof f?d=(this[f+"Getter"]||this._defaultGetter).call(this,f,G):(h(f,function(c,h){F=!1;v||l(this,h);this.symbolName&&-1!==a.inArray(h,J)&&(B||(this.symbolAttr(f),B=!0),F=!0);!this.rotation||"x"!==h&&"y"!==h||(this.doTransform=!0);F||(A=this[h+"Setter"]||this._defaultSetter,A.call(this,c,h,G),!this.styledMode&&this.shadows&&/^(width|height|visibility|x|y|d|transform|cx|cy|r)$/.test(h)&& +this.updateShadows(h,c,A))},this),this.afterSetters());b&&b.call(this);return d},afterSetters:function(){this.doTransform&&(this.updateTransform(),this.doTransform=!1)},updateShadows:function(a,f,c){for(var G=this.shadows,l=G.length;l--;)c.call(G[l],"height"===a?Math.max(f-(G[l].cutHeight||0),0):"d"===a?this.d:f,a,G[l])},addClass:function(a,f){var G=this.attr("class")||"";-1===G.indexOf(a)&&(f||(a=(G+(G?" ":"")+a).replace(" "," ")),this.attr("class",a));return this},hasClass:function(a){return-1!== +(this.attr("class")||"").split(" ").indexOf(a)},removeClass:function(a){return this.attr("class",(this.attr("class")||"").replace(a,""))},symbolAttr:function(a){var f=this;"x y r start end width height innerR anchorX anchorY".split(" ").forEach(function(G){f[G]=v(a[G],f[G])});f.attr({d:f.renderer.symbols[f.symbolName](f.x,f.y,f.width,f.height,f)})},clip:function(a){return this.attr("clip-path",a?"url("+this.renderer.url+"#"+a.id+")":"none")},crisp:function(a,f){f=f||a.strokeWidth||0;var G=Math.round(f)% +2/2;a.x=Math.floor(a.x||this.x||0)+G;a.y=Math.floor(a.y||this.y||0)+G;a.width=Math.floor((a.width||this.width||0)-2*G);a.height=Math.floor((a.height||this.height||0)-2*G);u(a.strokeWidth)&&(a.strokeWidth=f);return a},css:function(a){var f=this.styles,G={},l=this.element,b="",v=!f,B=["textOutline","textOverflow","width"];a&&a.color&&(a.fill=a.color);f&&h(a,function(a,l){a!==f[l]&&(G[l]=a,v=!0)});if(v){f&&(a=c(f,G));if(a)if(null===a.width||"auto"===a.width)delete this.textWidth;else if("text"===l.nodeName.toLowerCase()&& +a.width)var d=this.textWidth=F(a.width);this.styles=a;d&&!J&&this.renderer.forExport&&delete a.width;if(l.namespaceURI===this.SVG_NS){var A=function(a,f){return"-"+f.toLowerCase()};h(a,function(a,f){-1===B.indexOf(f)&&(b+=f.replace(/([A-Z])/g,A)+":"+a+";")});b&&C(l,"style",b)}else n(l,a);this.added&&("text"===this.element.nodeName&&this.renderer.buildText(this),a&&a.textOutline&&this.applyTextOutline(a.textOutline))}return this},getStyle:function(a){return B.getComputedStyle(this.element||this,"").getPropertyValue(a)}, +strokeWidth:function(){if(!this.renderer.styledMode)return this["stroke-width"]||0;var a=this.getStyle("stroke-width");if(a.indexOf("px")===a.length-2)a=F(a);else{var f=b.createElementNS(A,"rect");C(f,{width:a,"stroke-width":0});this.element.parentNode.appendChild(f);a=f.getBBox().width;f.parentNode.removeChild(f)}return a},on:function(a,f){var G=this,l=G.element;x&&"click"===a?(l.ontouchstart=function(a){G.touchEventFired=Date.now();a.preventDefault();f.call(l,a)},l.onclick=function(a){(-1===B.navigator.userAgent.indexOf("Android")|| +1100l.width)l={width:0,height:0}}else l=this.htmlGetBBox();G.isSVG&&(G=l.width,p=l.height,J&&(l.height=p={"11px,17":14,"13px,20":16}[b&&b.fontSize+","+Math.round(p)]||p),e&& +(l.width=Math.abs(p*Math.sin(m))+Math.abs(G*Math.cos(m)),l.height=Math.abs(p*Math.cos(m))+Math.abs(G*Math.sin(m))));if(k&&0]*>/g,"").replace(/</g,"<").replace(/>/g,">")))},textSetter:function(a){a!==this.textStr&&(delete this.bBox,this.textStr=a,this.added&&this.renderer.buildText(this))},fillSetter:function(a,f,l){"string"===typeof a?l.setAttribute(f,a):a&&this.complexColor(a,f,l)},visibilitySetter:function(a,f,l){"inherit"===a?l.removeAttribute(f):this[f]!==a&&l.setAttribute(f,a); +this[f]=a},zIndexSetter:function(a,f){var l=this.renderer,c=this.parentGroup,h=(c||l).element||l.box,b=this.element;l=h===l.box;var v=this.added;var G;u(a)?(b.setAttribute("data-z-index",a),a=+a,this[f]===a&&(v=!1)):u(this[f])&&b.removeAttribute("data-z-index");this[f]=a;if(v){(a=this.zIndex)&&c&&(c.handleZ=!0);c=h.childNodes;for(G=c.length-1;0<=G&&!A;G--){v=c[G];var B=v.getAttribute("data-z-index");var d=!u(B);if(v!==b)if(0>a&&d&&!l&&!G){h.insertBefore(b,c[G]);var A=!0}else if(F(B)<=a||d&&(!u(a)|| +0<=a))h.insertBefore(b,c[G+1]||null),A=!0}A||(h.insertBefore(b,c[l?3:0]||null),A=!0)}return A},_defaultSetter:function(a,f,l){l.setAttribute(f,a)}});K.prototype.yGetter=K.prototype.xGetter;K.prototype.translateXSetter=K.prototype.translateYSetter=K.prototype.rotationSetter=K.prototype.verticalAlignSetter=K.prototype.rotationOriginXSetter=K.prototype.rotationOriginYSetter=K.prototype.scaleXSetter=K.prototype.scaleYSetter=K.prototype.matrixSetter=function(a,f){this[f]=a;this.doTransform=!0};K.prototype["stroke-widthSetter"]= +K.prototype.strokeSetter=function(a,f,l){this[f]=a;this.stroke&&this["stroke-width"]?(K.prototype.fillSetter.call(this,this.stroke,"stroke",l),l.setAttribute("stroke-width",this["stroke-width"]),this.hasStroke=!0):"stroke-width"===f&&0===a&&this.hasStroke&&(l.removeAttribute("stroke"),this.hasStroke=!1)};var O=a.SVGRenderer=function(){this.init.apply(this,arguments)};c(O.prototype,{Element:K,SVG_NS:A,init:function(a,f,l,c,h,v,d){var G=this.createElement("svg").attr({version:"1.1","class":"highcharts-root"}); +d||G.css(this.getStyle(c));c=G.element;a.appendChild(c);C(a,"dir","ltr");-1===a.innerHTML.indexOf("xmlns")&&C(c,"xmlns",this.SVG_NS);this.isSVG=!0;this.box=c;this.boxWrapper=G;this.alignedObjects=[];this.url=(p||H)&&b.getElementsByTagName("base").length?B.location.href.split("#")[0].replace(/<[^>]*>/g,"").replace(/([\('\)])/g,"\\$1").replace(/ /g,"%20"):"";this.createElement("desc").add().element.appendChild(b.createTextNode("Created with Highcharts v7.0.3 custom build"));this.defs=this.createElement("defs").add(); +this.allowHTML=v;this.forExport=h;this.styledMode=d;this.gradients={};this.cache={};this.cacheKeys=[];this.imgCount=0;this.setSize(f,l,!1);var F;p&&a.getBoundingClientRect&&(f=function(){n(a,{left:0,top:0});F=a.getBoundingClientRect();n(a,{left:Math.ceil(F.left)-F.left+"px",top:Math.ceil(F.top)-F.top+"px"})},f(),this.unSubPixelFix=E(B,"resize",f))},definition:function(a){function l(a,v){var G;f(a).forEach(function(a){var f=c.createElement(a.tagName),B={};h(a,function(a,f){"tagName"!==f&&"children"!== +f&&"textContent"!==f&&(B[f]=a)});f.attr(B);f.add(v||c.defs);a.textContent&&f.element.appendChild(b.createTextNode(a.textContent));l(a.children||[],f);G=f});return G}var c=this;return l(a)},getStyle:function(a){return this.style=c({fontFamily:'"Lucida Grande", "Lucida Sans Unicode", Arial, Helvetica, sans-serif',fontSize:"12px"},a)},setStyle:function(a){this.boxWrapper.css(this.getStyle(a))},isHidden:function(){return!this.boxWrapper.getBBox().width},destroy:function(){var a=this.defs;this.box=null; +this.boxWrapper=this.boxWrapper.destroy();d(this.gradients||{});this.gradients=null;a&&(this.defs=a.destroy());this.unSubPixelFix&&this.unSubPixelFix();return this.alignedObjects=null},createElement:function(a){var f=new this.Element;f.init(this,a);return f},draw:k,getRadialAttr:function(a,f){return{cx:a[0]-a[2]/2+f.cx*a[2],cy:a[1]-a[2]/2+f.cy*a[2],r:f.r*a[2]}},truncate:function(a,f,l,c,h,v,B){var d=this,F=a.rotation,G,A=c?1:0,J=(l||c).length,e=J,m=[],k=function(a){f.firstChild&&f.removeChild(f.firstChild); +a&&f.appendChild(b.createTextNode(a))},p=function(b,v){var G=v||b;if(void 0===m[G])if(f.getSubStringLength)try{m[G]=h+f.getSubStringLength(0,c?G+1:G)}catch(Y){}else d.getSpanWidth&&(k(B(l||c,b)),m[G]=h+d.getSpanWidth(a,f));return m[G]},q;a.rotation=0;var K=p(f.textContent.length);if(q=h+K>v){for(;A<=J;)e=Math.ceil((A+J)/2),c&&(G=B(c,e)),K=p(e,G&&G.length-1),A===J?A=J+1:K>v?J=e-1:A=e;0===J?k(""):l&&J===l.length-1||k(G||B(l||c,e))}c&&c.splice(0,e);a.actualWidth=K;a.rotation=F;return q},escapes:{"&":"&", +"<":"<",">":">","'":"'",'"':"""},buildText:function(a){var f=a.element,l=this,c=l.forExport,B=v(a.textStr,"").toString(),d=-1!==B.indexOf("<"),G=f.childNodes,e,m=C(f,"x"),k=a.styles,p=a.textWidth,q=k&&k.lineHeight,K=k&&k.textOutline,g=k&&"ellipsis"===k.textOverflow,x=k&&"nowrap"===k.whiteSpace,N=k&&k.fontSize,M,y=G.length;k=p&&!a.added&&this.box;var r=function(a){var c;l.styledMode||(c=/(px|em)$/.test(a&&a.style.fontSize)?a.style.fontSize:N||l.style.fontSize||12);return q?F(q):l.fontMetrics(c, +a.getAttribute("style")?a:f).h},O=function(a,f){h(l.escapes,function(l,c){f&&-1!==f.indexOf(l)||(a=a.toString().replace(new RegExp(l,"g"),c))});return a},z=function(a,f){var l=a.indexOf("<");a=a.substring(l,a.indexOf(">")-l);l=a.indexOf(f+"=");if(-1!==l){l=l+f.length+1;var c=a.charAt(l);if('"'===c||"'"===c)return a=a.substring(l+1),a.substring(0,a.indexOf(c))}};var D=[B,g,x,q,K,N,p].join();if(D!==a.textCache){for(a.textCache=D;y--;)f.removeChild(G[y]);d||K||g||p||-1!==B.indexOf(" ")?(k&&k.appendChild(f), +d?(B=l.styledMode?B.replace(/<(b|strong)>/g,'').replace(/<(i|em)>/g,''):B.replace(/<(b|strong)>/g,'').replace(/<(i|em)>/g,''),B=B.replace(/
/g,"").split(//g)):B=[B],B=B.filter(function(a){return""!==a}),B.forEach(function(h,v){var B=0,d=0;h=h.replace(/^\s+|\s+$/g,"").replace(//g, +"|||");var F=h.split("|||");F.forEach(function(h){if(""!==h||1===F.length){var G={},k=b.createElementNS(l.SVG_NS,"tspan"),q,K;(q=z(h,"class"))&&C(k,"class",q);if(q=z(h,"style"))q=q.replace(/(;| |^)color([ :])/,"$1fill$2"),C(k,"style",q);(K=z(h,"href"))&&!c&&(C(k,"onclick",'location.href="'+K+'"'),C(k,"class","highcharts-anchor"),l.styledMode||n(k,{cursor:"pointer"}));h=O(h.replace(/<[a-zA-Z\/](.|\n)*?>/g,"")||" ");if(" "!==h){k.appendChild(b.createTextNode(h));B?G.dx=0:v&&null!==m&&(G.x=m); +C(k,G);f.appendChild(k);!B&&M&&(!J&&c&&n(k,{display:"block"}),C(k,"dy",r(k)));if(p){var y=h.replace(/([^\^])-/g,"$1- ").split(" ");G=!x&&(1Math.abs(h.end-h.start-2*Math.PI));var A=Math.cos(b),J=Math.sin(b),e=Math.cos(F);F=Math.sin(F);h=.001>h.end-b-Math.PI?0:1;B=["M",a+B*A,f+d*J,"A",B,d,0,h,1,a+B*e,f+d*F];u(l)&&B.push(c?"M":"L",a+l*e,f+l*F,"A",l,l,0,h,0,a+ +l*A,f+l*J);B.push(c?"":"Z");return B},callout:function(a,f,l,c,h){var b=Math.min(h&&h.r||0,l,c),B=b+6,v=h&&h.anchorX;h=h&&h.anchorY;var d=["M",a+b,f,"L",a+l-b,f,"C",a+l,f,a+l,f,a+l,f+b,"L",a+l,f+c-b,"C",a+l,f+c,a+l,f+c,a+l-b,f+c,"L",a+b,f+c,"C",a,f+c,a,f+c,a,f+c-b,"L",a,f+b,"C",a,f,a,f,a+b,f];v&&v>l?h>f+B&&hv?h>f+B&&hc&&v>a+B&&vh&&v>a+B&&va?a+3:Math.round(1.2*a);return{h:l,b:Math.round(.8* +l),f:a}},rotCorr:function(a,f,l){var c=a;f&&l&&(c=Math.max(c*Math.cos(f*w),4));return{x:-a/3*Math.sin(f*w),y:c}},label:function(f,l,h,b,v,B,d,F,A){var J=this,e=J.styledMode,k=J.g("button"!==A&&"label"),p=k.text=J.text("",0,0,d).attr({zIndex:1}),m,q,G=0,g=3,x=0,N,y,r,O,n,D={},w,H,t=/^url\((.*?)\)$/.test(b),P=e||t,U=function(){return e?m.strokeWidth()%2/2:(w?parseInt(w,10):0)%2/2};A&&k.addClass("highcharts-"+A);var C=function(){var a=p.element.style,f={};q=(void 0===N||void 0===y||n)&&u(p.textStr)&& +p.getBBox();k.width=(N||q.width||0)+2*g+x;k.height=(y||q.height||0)+2*g;H=g+Math.min(J.fontMetrics(a&&a.fontSize,p).b,q?q.height:Infinity);P&&(m||(k.box=m=J.symbols[b]||t?J.symbol(b):J.rect(),m.addClass(("button"===A?"":"highcharts-label-box")+(A?" highcharts-"+A+"-box":"")),m.add(k),a=U(),f.x=a,f.y=(F?-H:0)+a),f.width=Math.round(k.width),f.height=Math.round(k.height),m.attr(c(f,D)),D={})};var S=function(){var a=x+g;var f=F?0:H;u(N)&&q&&("center"===n||"right"===n)&&(a+={center:.5,right:1}[n]*(N-q.width)); +if(a!==p.x||f!==p.y)p.attr("x",a),p.hasBoxWidthChanged&&(q=p.getBBox(!0),C()),void 0!==f&&p.attr("y",f);p.x=a;p.y=f};var R=function(a,f){m?m.attr(a,f):D[a]=f};k.onAdd=function(){p.add(k);k.attr({text:f||0===f?f:"",x:l,y:h});m&&u(v)&&k.attr({anchorX:v,anchorY:B})};k.widthSetter=function(f){N=a.isNumber(f)?f:null};k.heightSetter=function(a){y=a};k["text-alignSetter"]=function(a){n=a};k.paddingSetter=function(a){u(a)&&a!==g&&(g=k.padding=a,S())};k.paddingLeftSetter=function(a){u(a)&&a!==x&&(x=a,S())}; +k.alignSetter=function(a){a={left:0,center:.5,right:1}[a];a!==G&&(G=a,q&&k.attr({x:r}))};k.textSetter=function(a){void 0!==a&&p.textSetter(a);C();S()};k["stroke-widthSetter"]=function(a,f){a&&(P=!0);w=this["stroke-width"]=a;R(f,a)};e?k.rSetter=function(a,f){R(f,a)}:k.strokeSetter=k.fillSetter=k.rSetter=function(a,f){"r"!==f&&("fill"===f&&a&&(P=!0),k[f]=a);R(f,a)};k.anchorXSetter=function(a,f){v=k.anchorX=a;R(f,Math.round(a)-U()-r)};k.anchorYSetter=function(a,f){B=k.anchorY=a;R(f,a-O)};k.xSetter=function(a){k.x= +a;G&&(a-=G*((N||q.width)+2*g),k["forceAnimate:x"]=!0);r=Math.round(a);k.attr("translateX",r)};k.ySetter=function(a){O=k.y=Math.round(a);k.attr("translateY",O)};var E=k.css;d={css:function(a){if(a){var f={};a=z(a);k.textProps.forEach(function(l){void 0!==a[l]&&(f[l]=a[l],delete a[l])});p.css(f);"width"in f&&C();"fontSize"in f&&(C(),S())}return E.call(k,a)},getBBox:function(){return{width:q.width+2*g,height:q.height+2*g,x:q.x-g,y:q.y-g}},destroy:function(){M(k.element,"mouseenter");M(k.element,"mouseleave"); +p&&(p=p.destroy());m&&(m=m.destroy());K.prototype.destroy.call(k);k=J=C=S=R=null}};e||(d.shadow=function(a){a&&(C(),m&&m.shadow(a));return k});return c(k,d)}});a.Renderer=O})(L);(function(a){var E=a.addEvent,I=a.animObject,C=a.arrayMax,e=a.arrayMin,g=a.correctFloat,n=a.defaultOptions,t=a.defaultPlotOptions,u=a.defined,w=a.erase,d=a.extend,b=a.fireEvent,c=a.isArray,m=a.isNumber,x=a.isString,r=a.merge,p=a.objectEach,q=a.pick,D=a.removeEvent,y=a.splat,H=a.SVGElement,z=a.syncTimeout,k=a.win;a.Series= +a.seriesType("line",null,{lineWidth:2,allowPointSelect:!1,showCheckbox:!1,animation:{duration:1E3},events:{},marker:{lineWidth:0,lineColor:"#ffffff",enabledThreshold:2,radius:4,states:{normal:{animation:!0},hover:{animation:{duration:50},enabled:!0,radiusPlus:2,lineWidthPlus:1},select:{fillColor:"#cccccc",lineColor:"#000000",lineWidth:2}}},point:{events:{}},dataLabels:{align:"center",formatter:function(){return null===this.y?"":a.numberFormat(this.y,-1)},style:{fontSize:"11px",fontWeight:"bold",color:"contrast", +textOutline:"1px contrast"},verticalAlign:"bottom",x:0,y:0,padding:5},cropThreshold:300,pointRange:0,softThreshold:!0,states:{normal:{animation:!0},hover:{animation:{duration:50},lineWidthPlus:1,marker:{},halo:{size:10,opacity:.25}},select:{animation:{duration:0}}},stickyTracking:!0,turboThreshold:1E3,findNearestPointBy:"x"},{isCartesian:!0,pointClass:a.Point,sorted:!0,requireSorting:!0,directTouch:!1,axisTypes:["xAxis","yAxis"],colorCounter:0,parallelArrays:["x","y"],coll:"series",cropShoulder:1, +init:function(a,c){b(this,"init",{options:c});var h=this,v=a.series,f;h.chart=a;h.options=c=h.setOptions(c);h.linkedSeries=[];h.bindAxes();d(h,{name:c.name,state:"",visible:!1!==c.visible,selected:!0===c.selected});var l=c.events;p(l,function(a,f){h.hcEvents&&h.hcEvents[f]&&-1!==h.hcEvents[f].indexOf(a)||E(h,f,a)});if(l&&l.click||c.point&&c.point.events&&c.point.events.click||c.allowPointSelect)a.runTrackerClick=!0;h.getColor();h.getSymbol();h.parallelArrays.forEach(function(a){h[a+"Data"]=[]});h.setData(c.data, +!1);h.isCartesian&&(a.hasCartesianSeries=!0);v.length&&(f=v[v.length-1]);h._i=q(f&&f._i,-1)+1;a.orderSeries(this.insert(v));b(this,"afterInit")},insert:function(a){var c=this.options.index,h;if(m(c)){for(h=a.length;h--;)if(c>=q(a[h].options.index,a[h]._i)){a.splice(h+1,0,this);break}-1===h&&a.unshift(this);h+=1}else a.push(this);return q(h,a.length-1)},bindAxes:function(){var c=this,v=c.options,d=c.chart,k;b(this,"bindAxes",null,function(){(c.axisTypes||[]).forEach(function(f){d[f].forEach(function(a){k= +a.options;if(v[f]===k.index||void 0!==v[f]&&v[f]===k.id||void 0===v[f]&&0===k.index)c.insert(a.series),c[f]=a,a.isDirty=!0});c[f]||c.optionalAxis===f||a.error(18,!0,d)})})},updateParallelArrays:function(a,c){var h=a.series,b=arguments,f=m(c)?function(f){var l="y"===f&&h.toYData?h.toYData(a):a[f];h[f+"Data"][c]=l}:function(a){Array.prototype[c].apply(h[a+"Data"],Array.prototype.slice.call(b,2))};h.parallelArrays.forEach(f)},autoIncrement:function(){var a=this.options,c=this.xIncrement,b,d=a.pointIntervalUnit, +f=this.chart.time;c=q(c,a.pointStart,0);this.pointInterval=b=q(this.pointInterval,a.pointInterval,1);d&&(a=new f.Date(c),"day"===d?f.set("Date",a,f.get("Date",a)+b):"month"===d?f.set("Month",a,f.get("Month",a)+b):"year"===d&&f.set("FullYear",a,f.get("FullYear",a)+b),b=a.getTime()-c);this.xIncrement=c+b;return c},setOptions:function(a){var c=this.chart,h=c.options,d=h.plotOptions,f=(c.userOptions||{}).plotOptions||{},l=d[this.type],k=r(a);a=c.styledMode;b(this,"setOptions",{userOptions:k});this.userOptions= +k;c=r(l,d.series,k);this.tooltipOptions=r(n.tooltip,n.plotOptions.series&&n.plotOptions.series.tooltip,n.plotOptions[this.type].tooltip,h.tooltip.userOptions,d.series&&d.series.tooltip,d[this.type].tooltip,k.tooltip);this.stickyTracking=q(k.stickyTracking,f[this.type]&&f[this.type].stickyTracking,f.series&&f.series.stickyTracking,this.tooltipOptions.shared&&!this.noSharedTooltip?!0:c.stickyTracking);null===l.marker&&delete c.marker;this.zoneAxis=c.zoneAxis;h=this.zones=(c.zones||[]).slice();!c.negativeColor&& +!c.negativeFillColor||c.zones||(d={value:c[this.zoneAxis+"Threshold"]||c.threshold||0,className:"highcharts-negative"},a||(d.color=c.negativeColor,d.fillColor=c.negativeFillColor),h.push(d));h.length&&u(h[h.length-1].value)&&h.push(a?{}:{color:this.color,fillColor:this.fillColor});b(this,"afterSetOptions",{options:c});return c},getName:function(){return q(this.options.name,"Series "+(this.index+1))},getCyclic:function(a,c,b){var h=this.chart,f=this.userOptions,l=a+"Index",d=a+"Counter",v=b?b.length: +q(h.options.chart[a+"Count"],h[a+"Count"]);if(!c){var k=q(f[l],f["_"+l]);u(k)||(h.series.length||(h[d]=0),f["_"+l]=k=h[d]%v,h[d]+=1);b&&(c=b[k])}void 0!==k&&(this[l]=k);this[a]=c},getColor:function(){this.chart.styledMode?this.getCyclic("color"):this.options.colorByPoint?this.options.color=null:this.getCyclic("color",this.options.color||t[this.type].color,this.chart.options.colors)},getSymbol:function(){this.getCyclic("symbol",this.options.marker.symbol,this.chart.options.symbols)},drawLegendSymbol:a.LegendSymbolMixin.drawLineMarker, +updateData:function(c){var h=this.options,b=this.points,d=[],f,l,k=this.requireSorting;this.xIncrement=null;c.forEach(function(c){var B=a.defined(c)&&this.pointClass.prototype.optionsToObject.call({series:this},c)||{};var v=B.x;if((B=B.id)||m(v)){if(B)var A=(A=this.chart.get(B))&&A.index;void 0===A&&m(v)&&(A=this.xData.indexOf(v,l));-1!==A&&void 0!==A&&this.cropped&&(A=A>=this.cropStart?A-this.cropStart:A);-1===A||void 0===A||b[A]&&b[A].touched?d.push(c):c!==h.data[A]?(b[A].update(c,!1,null,!1),b[A].touched= +!0,k&&(l=A+1)):b[A]&&(b[A].touched=!0);f=!0}},this);if(f)for(c=b.length;c--;){var A=b[c];A.touched||A.remove(!1);A.touched=!1}else if(c.length===b.length)c.forEach(function(a,f){b[f].update&&a!==h.data[f]&&b[f].update(a,!1,null,!1)});else return!1;d.forEach(function(a){this.addPoint(a,!1)},this);return!0},setData:function(h,b,d,k){var f=this,l=f.points,v=l&&l.length||0,A,e=f.options,B=f.chart,p=null,F=f.xAxis,g=e.turboThreshold,y=this.xData,r=this.yData,n=(A=f.pointArrayMap)&&A.length,M=e.keys,z= +0,D=1,w;h=h||[];A=h.length;b=q(b,!0);!1!==k&&A&&v&&!f.cropped&&!f.hasGroupedData&&f.visible&&!f.isSeriesBoosting&&(w=this.updateData(h));if(!w){f.xIncrement=null;f.colorCounter=0;this.parallelArrays.forEach(function(a){f[a+"Data"].length=0});if(g&&A>g){for(d=0;null===p&&dA||this.forceCrop))if(h[d-1]g)h=[],b=[];else if(this.yData&&(h[0]g)){f=this.cropData(this.xData,this.yData,q,g);h=f.xData;b=f.yData; +f=f.start;var x=!0}for(A=h.length||1;--A;)if(d=p?k(h[A])-k(h[A-1]):h[A]-h[A-1],0d&&m&&(a.error(15,!1,this.chart),m=!1);this.cropped=x;this.cropStart=f;this.processedXData=h;this.processedYData=b;this.closestPointRange=y},cropData:function(a,c,b,d,f){var l=a.length,h=0,v=l,k;f=q(f,this.cropShoulder);for(k=0;k=b){h=Math.max(0,k-f);break}for(b=k;bd){v=b+f;break}return{xData:a.slice(h,v),yData:c.slice(h,v),start:h,end:v}},generatePoints:function(){var a= +this.options,c=a.data,k=this.data,e,f=this.processedXData,l=this.processedYData,p=this.pointClass,A=f.length,m=this.cropStart||0,B=this.hasGroupedData;a=a.keys;var q=[],g;k||B||(k=[],k.length=c.length,k=this.data=k);a&&B&&(this.options.keys=!1);for(g=0;g=p&&(d[B-q]||g)<=A;if(G&&g)if(G=x.length)for(;G--;)"number"===typeof x[G]&&(k[f++]=x[G]);else k[f++]=x}this.dataMin=e(k);this.dataMax=C(k);b(this,"afterGetExtremes")},translate:function(){this.processedXData||this.processData();this.generatePoints();var a=this.options,c=a.stacking,d=this.xAxis,k=d.categories,f=this.yAxis,l=this.points,e=l.length,A=!!this.modifyValue, +p,B=this.pointPlacementToXValue(),x=m(B),y=a.threshold,G=a.startFromThreshold?y:0,r,n=this.zoneAxis||"y",z=Number.MAX_VALUE;for(p=0;p=H&&(D.isNull=!0);D.plotX=r=g(Math.min(Math.max(-1E5,d.translate(w,0,0,0,1,B,"flags"===this.type)),1E5));if(c&&this.visible&&!D.isNull&&C&&C[w]){var E=this.getStackIndicator(E,w,this.index);var I=C[w];H=I.points[E.key];t=H[0]; +H=H[1];t===G&&E.key===C[w].base&&(t=q(m(y)&&y,f.min));f.positiveValuesOnly&&0>=t&&(t=null);D.total=D.stackTotal=I.total;D.percentage=I.total&&D.y/I.total*100;D.stackY=H;I.setOffset(this.pointXOffset||0,this.barW||0)}D.yBottom=u(t)?Math.min(Math.max(-1E5,f.translate(t,0,1,0,1)),1E5):null;A&&(H=this.modifyValue(H,D));D.plotY=t="number"===typeof H&&Infinity!==H?Math.min(Math.max(-1E5,f.translate(H,0,1,0,1)),1E5):void 0;D.isInside=void 0!==t&&0<=t&&t<=f.len&&0<=r&&r<=d.len;D.clientX=x?g(d.translate(w, +0,0,0,1,B)):r;D.negative=D[n]<(a[n+"Threshold"]||y||0);D.category=k&&void 0!==k[D.x]?k[D.x]:D.x;if(!D.isNull){void 0!==V&&(z=Math.min(z,Math.abs(r-V)));var V=r}D.zone=this.zones.length&&D.getZone()}this.closestPointRangePx=z;b(this,"afterTranslate")},getValidPoints:function(a,c,b){var h=this.chart;return(a||this.points||[]).filter(function(a){return c&&!h.isInsidePlot(a.plotX,a.plotY,h.inverted)?!1:b||!a.isNull})},setClip:function(a){var c=this.chart,b=this.options,h=c.renderer,f=c.inverted,l=this.clipBox, +d=l||c.clipBox,k=this.sharedClipKey||["_sharedClip",a&&a.duration,a&&a.easing,d.height,b.xAxis,b.yAxis].join(),e=c[k],B=c[k+"m"];e||(a&&(d.width=0,f&&(d.x=c.plotSizeX),c[k+"m"]=B=h.clipRect(f?c.plotSizeX+99:-99,f?-c.plotLeft:-c.plotTop,99,f?c.chartWidth:c.chartHeight)),c[k]=e=h.clipRect(d),e.count={length:0});a&&!e.count[this.index]&&(e.count[this.index]=!0,e.count.length+=1);!1!==b.clip&&(this.group.clip(a||l?e:c.clipRect),this.markerGroup.clip(B),this.sharedClipKey=k);a||(e.count[this.index]&&(delete e.count[this.index], +--e.count.length),0===e.count.length&&k&&c[k]&&(l||(c[k]=c[k].destroy()),c[k+"m"]&&(c[k+"m"]=c[k+"m"].destroy())))},animate:function(a){var c=this.chart,b=I(this.options.animation);if(a)this.setClip(b);else{var h=this.sharedClipKey;(a=c[h])&&a.animate({width:c.plotSizeX,x:0},b);c[h+"m"]&&c[h+"m"].animate({width:c.plotSizeX+99,x:0},b);this.animate=null}},afterAnimate:function(){this.setClip();b(this,"afterAnimate");this.finishedAnimating=!0},drawPoints:function(){var a=this.points,c=this.chart,b=this.options.marker, +d=this[this.specialGroup]||this.markerGroup;var f=this.xAxis;var l=q(b.enabled,!f||f.isRadial?!0:null,this.closestPointRangePx>=b.enabledThreshold*b.radius);if(!1!==b.enabled||this._hasPointMarkers)for(f=0;fh&&c.shadow)));k&&(k.startX=b.xMap,k.isArea=b.isArea)})},getZonesGraphs:function(a){this.zones.forEach(function(c,b){var h=["zone-graph-"+ +b,"highcharts-graph highcharts-zone-graph-"+b+" "+(c.className||"")];this.chart.styledMode||h.push(c.color||this.color,c.dashStyle||this.options.dashStyle);a.push(h)},this);return a},applyZones:function(){var a=this,c=this.chart,b=c.renderer,d=this.zones,f,l,k=this.clips||[],e,p=this.graph,B=this.area,m=Math.max(c.chartWidth,c.chartHeight),g=this[(this.zoneAxis||"y")+"Axis"],x=c.inverted,y,r,n,D=!1;if(d.length&&(p||B)&&g&&void 0!==g.min){var z=g.reversed;var w=g.horiz;p&&!this.showLine&&p.hide(); +B&&B.hide();var H=g.getExtremes();d.forEach(function(h,d){f=z?w?c.plotWidth:0:w?0:g.toPixels(H.min)||0;f=Math.min(Math.max(q(l,f),0),m);l=Math.min(Math.max(Math.round(g.toPixels(q(h.value,H.max),!0)||0),0),m);D&&(f=l=g.toPixels(H.max));y=Math.abs(f-l);r=Math.min(f,l);n=Math.max(f,l);g.isXAxis?(e={x:x?n:r,y:0,width:y,height:m},w||(e.x=c.plotHeight-e.x)):(e={x:0,y:x?n:r,width:m,height:y},w&&(e.y=c.plotWidth-e.y));x&&b.isVML&&(e=g.isXAxis?{x:0,y:z?r:n,height:e.width,width:c.chartWidth}:{x:e.y-c.plotLeft- +c.spacingBox.x,y:0,width:e.height,height:c.chartHeight});k[d]?k[d].animate(e):(k[d]=b.clipRect(e),p&&a["zone-graph-"+d].clip(k[d]),B&&a["zone-area-"+d].clip(k[d]));D=h.value>H.max;a.resetZones&&0===l&&(l=void 0)});this.clips=k}},invertGroups:function(a){function c(){["group","markerGroup"].forEach(function(f){b[f]&&(h.renderer.isVML&&b[f].attr({width:b.yAxis.len,height:b.xAxis.len}),b[f].width=b.yAxis.len,b[f].height=b.xAxis.len,b[f].invert(a))})}var b=this,h=b.chart;if(b.xAxis){var f=E(h,"resize", +c);E(b,"destroy",f);c(a);b.invertGroups=c}},plotGroup:function(a,c,b,d,f){var l=this[a],h=!l;h&&(this[a]=l=this.chart.renderer.g().attr({zIndex:d||.1}).add(f));l.addClass("highcharts-"+c+" highcharts-series-"+this.index+" highcharts-"+this.type+"-series "+(u(this.colorIndex)?"highcharts-color-"+this.colorIndex+" ":"")+(this.options.className||"")+(l.hasClass("highcharts-tracker")?" highcharts-tracker":""),!0);l.attr({visibility:b})[h?"attr":"animate"](this.getPlotBox());return l},getPlotBox:function(){var a= +this.chart,c=this.xAxis,b=this.yAxis;a.inverted&&(c=b,b=this.xAxis);return{translateX:c?c.left:a.plotLeft,translateY:b?b.top:a.plotTop,scaleX:1,scaleY:1}},render:function(){var a=this,c=a.chart,d=a.options,k=!!a.animate&&c.renderer.isSVG&&I(d.animation).duration,f=a.visible?"inherit":"hidden",l=d.zIndex,e=a.hasRendered,p=c.seriesGroup,m=c.inverted;b(this,"render");var B=a.plotGroup("group","series",f,l,p);a.markerGroup=a.plotGroup("markerGroup","markers",f,l,p);k&&a.animate(!0);B.inverted=a.isCartesian? +m:!1;a.drawGraph&&(a.drawGraph(),a.applyZones());a.drawDataLabels&&a.drawDataLabels();a.visible&&a.drawPoints();a.drawTracker&&!1!==a.options.enableMouseTracking&&a.drawTracker();a.invertGroups(m);!1===d.clip||a.sharedClipKey||e||B.clip(c.clipRect);k&&a.animate();e||(a.animationTimeout=z(function(){a.afterAnimate()},k));a.isDirty=!1;a.hasRendered=!0;b(a,"afterRender")},redraw:function(){var a=this.chart,c=this.isDirty||this.isDirtyData,b=this.group,d=this.xAxis,f=this.yAxis;b&&(a.inverted&&b.attr({width:a.plotWidth, +height:a.plotHeight}),b.animate({translateX:q(d&&d.left,a.plotLeft),translateY:q(f&&f.top,a.plotTop)}));this.translate();this.render();c&&delete this.kdTree},kdAxisArray:["clientX","plotY"],searchPoint:function(a,c){var b=this.xAxis,d=this.yAxis,f=this.chart.inverted;return this.searchKDTree({clientX:f?b.len-a.chartY+b.pos:a.chartX-b.pos,plotY:f?d.len-a.chartX+d.pos:a.chartY-d.pos},c,a)},buildKDTree:function(a){function c(a,l,d){var f;if(f=a&&a.length){var h=b.kdAxisArray[l%d];a.sort(function(a,f){return a[h]- +f[h]});f=Math.floor(f/2);return{point:a[f],left:c(a.slice(0,f),l+1,d),right:c(a.slice(f+1),l+1,d)}}}this.buildingKdTree=!0;var b=this,d=-1p?"left":"right";m=0>p?"right":"left";c[q]&&(q=d(a,c[q],b+1,e),A=q[k]q&&c-n*yg&&(h=Math.round((e-c)/Math.cos(q*u)));else if(e=c+(1-n)*y,c-n*yg&&(z=g-a.x+z*n,k=-1),z=Math.min(H,z),zz||d.autoRotation&&(p.styles||{}).width)h=z;h&&(this.shortenLabel?this.shortenLabel():(v.width=Math.floor(h),(b.style||{}).textOverflow||(v.textOverflow="ellipsis"),p.css(v)))},getPosition:function(g,d,b,c){var m=this.axis,x=m.chart,r=c&&x.oldChartHeight||x.chartHeight;g={x:g?a.correctFloat(m.translate(d+b,null,null,c)+m.transB):m.left+m.offset+(m.opposite?(c&&x.oldChartWidth||x.chartWidth)- +m.right-m.left:0),y:g?r-m.bottom+m.offset-(m.opposite?m.height:0):a.correctFloat(r-m.translate(d+b,null,null,c)-m.transB)};e(this,"afterGetPosition",{pos:g});return g},getLabelPosition:function(a,d,b,c,m,g,r,p){var q=this.axis,x=q.transA,y=q.reversed,n=q.staggerLines,z=q.tickRotCorr||{x:0,y:0},k=m.y,h=c||q.reserveSpaceDefault?0:-q.labelOffset*("center"===q.labelAlign?.5:1),v={};I(k)||(k=0===q.side?b.rotation?-8:-b.getBBox().height:2===q.side?z.y+8:Math.cos(b.rotation*u)*(z.y-b.getBBox(!1,0).height/ +2));a=a+m.x+h+z.x-(g&&c?g*x*(y?-1:1):0);d=d+k-(g&&!c?g*x*(y?1:-1):0);n&&(b=r/(p||1)%n,q.opposite&&(b=n-b-1),d+=q.labelOffset/n*b);v.x=a;v.y=Math.round(d);e(this,"afterGetLabelPosition",{pos:v,tickmarkOffset:g,index:r});return v},getMarkPath:function(a,d,b,c,e,g){return g.crispLine(["M",a,d,"L",a+(e?0:-b),d+(e?b:0)],c)},renderGridLine:function(a,d,b){var c=this.axis,e=c.options,g=this.gridLine,r={},p=this.pos,q=this.type,n=t(this.tickmarkOffset,c.tickmarkOffset),y=c.chart.renderer,H=q?q+"Grid":"grid", +z=e[H+"LineWidth"],k=e[H+"LineColor"];e=e[H+"LineDashStyle"];g||(c.chart.styledMode||(r.stroke=k,r["stroke-width"]=z,e&&(r.dashstyle=e)),q||(r.zIndex=1),a&&(d=0),this.gridLine=g=y.path().attr(r).addClass("highcharts-"+(q?q+"-":"")+"grid-line").add(c.gridGroup));if(g&&(b=c.getPlotLinePath(p+n,g.strokeWidth()*b,a,"pass")))g[a||this.isNew?"attr":"animate"]({d:b,opacity:d})},renderMark:function(a,d,b){var c=this.axis,e=c.options,g=c.chart.renderer,r=this.type,p=r?r+"Tick":"tick",q=c.tickSize(p),n=this.mark, +y=!n,H=a.x;a=a.y;var z=t(e[p+"Width"],!r&&c.isXAxis?1:0);e=e[p+"Color"];q&&(c.opposite&&(q[0]=-q[0]),y&&(this.mark=n=g.path().addClass("highcharts-"+(r?r+"-":"")+"tick").add(c.axisGroup),c.chart.styledMode||n.attr({stroke:e,"stroke-width":z})),n[y?"attr":"animate"]({d:this.getMarkPath(H,a,q[0],n.strokeWidth()*b,c.horiz,g),opacity:d}))},renderLabel:function(a,d,b,c){var e=this.axis,x=e.horiz,r=e.options,p=this.label,q=r.labels,n=q.step;e=t(this.tickmarkOffset,e.tickmarkOffset);var y=!0,H=a.x;a=a.y; +p&&g(H)&&(p.xy=a=this.getLabelPosition(H,a,p,x,q,e,c,n),this.isFirst&&!this.isLast&&!t(r.showFirstLabel,1)||this.isLast&&!this.isFirst&&!t(r.showLastLabel,1)?y=!1:!x||q.step||q.rotation||d||0===b||this.handleOverflow(a),n&&c%n&&(y=!1),y&&g(a.y)?(a.opacity=b,p[this.isNewLabel?"attr":"animate"](a),this.isNewLabel=!1):(p.attr("y",-9999),this.isNewLabel=!0))},render:function(e,d,b){var c=this.axis,m=c.horiz,g=this.pos,n=t(this.tickmarkOffset,c.tickmarkOffset);g=this.getPosition(m,g,n,d);n=g.x;var p=g.y; +c=m&&n===c.pos+c.len||!m&&p===c.pos?-1:1;b=t(b,1);this.isActive=!0;this.renderGridLine(d,b,c);this.renderMark(g,b,c);this.renderLabel(g,d,b,e);this.isNew=!1;a.fireEvent(this,"afterRender")},destroy:function(){C(this,this.axis)}}})(L);T=function(a){var E=a.addEvent,I=a.animObject,C=a.arrayMax,e=a.arrayMin,g=a.color,n=a.correctFloat,t=a.defaultOptions,u=a.defined,w=a.deg2rad,d=a.destroyObjectProperties,b=a.extend,c=a.fireEvent,m=a.format,x=a.getMagnitude,r=a.isArray,p=a.isNumber,q=a.isString,D=a.merge, +y=a.normalizeTickInterval,H=a.objectEach,z=a.pick,k=a.removeEvent,h=a.splat,v=a.syncTimeout,F=a.Tick,M=function(){this.init.apply(this,arguments)};a.extend(M.prototype,{defaultOptions:{dateTimeLabelFormats:{millisecond:{main:"%H:%M:%S.%L",range:!1},second:{main:"%H:%M:%S",range:!1},minute:{main:"%H:%M",range:!1},hour:{main:"%H:%M",range:!1},day:{main:"%e. %b"},week:{main:"%e. %b"},month:{main:"%b '%y"},year:{main:"%Y"}},endOnTick:!1,labels:{enabled:!0,indentation:10,x:0,style:{color:"#666666",cursor:"default", +fontSize:"11px"}},maxPadding:.01,minorTickLength:2,minorTickPosition:"outside",minPadding:.01,startOfWeek:1,startOnTick:!1,tickLength:10,tickPixelInterval:100,tickmarkPlacement:"between",tickPosition:"outside",title:{align:"middle",style:{color:"#666666"}},type:"linear",minorGridLineColor:"#f2f2f2",minorGridLineWidth:1,minorTickColor:"#999999",lineColor:"#ccd6eb",lineWidth:1,gridLineColor:"#e6e6e6",tickColor:"#ccd6eb"},defaultYAxisOptions:{endOnTick:!0,maxPadding:.05,minPadding:.05,tickPixelInterval:72, +showLastLabel:!0,labels:{x:-8},startOnTick:!0,title:{rotation:270,text:"Values"},stackLabels:{allowOverlap:!1,enabled:!1,formatter:function(){return a.numberFormat(this.total,-1)},style:{color:"#000000",fontSize:"11px",fontWeight:"bold",textOutline:"1px contrast"}},gridLineWidth:1,lineWidth:0},defaultLeftAxisOptions:{labels:{x:-15},title:{rotation:270}},defaultRightAxisOptions:{labels:{x:15},title:{rotation:90}},defaultBottomAxisOptions:{labels:{autoRotation:[-45],x:0},margin:15,title:{rotation:0}}, +defaultTopAxisOptions:{labels:{autoRotation:[-45],x:0},margin:15,title:{rotation:0}},init:function(a,b){var f=b.isX,l=this;l.chart=a;l.horiz=a.inverted&&!l.isZAxis?!f:f;l.isXAxis=f;l.coll=l.coll||(f?"xAxis":"yAxis");c(this,"init",{userOptions:b});l.opposite=b.opposite;l.side=b.side||(l.horiz?l.opposite?0:2:l.opposite?1:3);l.setOptions(b);var d=this.options,k=d.type;l.labelFormatter=d.labels.formatter||l.defaultLabelFormatter;l.userOptions=b;l.minPixelPadding=0;l.reversed=d.reversed;l.visible=!1!== +d.visible;l.zoomEnabled=!1!==d.zoomEnabled;l.hasNames="category"===k||!0===d.categories;l.categories=d.categories||l.hasNames;l.names||(l.names=[],l.names.keys={});l.plotLinesAndBandsGroups={};l.isLog="logarithmic"===k;l.isDatetimeAxis="datetime"===k;l.positiveValuesOnly=l.isLog&&!l.allowNegativeLog;l.isLinked=u(d.linkedTo);l.ticks={};l.labelEdge=[];l.minorTicks={};l.plotLinesAndBands=[];l.alternateBands={};l.len=0;l.minRange=l.userMinRange=d.minRange||d.maxZoom;l.range=d.range;l.offset=d.offset|| +0;l.stacks={};l.oldStacks={};l.stacksTouched=0;l.max=null;l.min=null;l.crosshair=z(d.crosshair,h(a.options.tooltip.crosshairs)[f?0:1],!1);k=l.options.events;-1===a.axes.indexOf(l)&&(f?a.axes.splice(a.xAxis.length,0,l):a.axes.push(l),a[l.coll].push(l));l.series=l.series||[];a.inverted&&!l.isZAxis&&f&&void 0===l.reversed&&(l.reversed=!0);H(k,function(a,f){E(l,f,a)});l.lin2log=d.linearToLogConverter||l.lin2log;l.isLog&&(l.val2lin=l.log2lin,l.lin2val=l.lin2log);c(this,"afterInit")},setOptions:function(a){this.options= +D(this.defaultOptions,"yAxis"===this.coll&&this.defaultYAxisOptions,[this.defaultTopAxisOptions,this.defaultRightAxisOptions,this.defaultBottomAxisOptions,this.defaultLeftAxisOptions][this.side],D(t[this.coll],a));c(this,"afterSetOptions",{userOptions:a})},defaultLabelFormatter:function(){var f=this.axis,c=this.value,b=f.chart.time,d=f.categories,h=this.dateTimeLabelFormat,k=t.lang,e=k.numericSymbols;k=k.numericSymbolMagnitude||1E3;var p=e&&e.length,g=f.options.labels.format;f=f.isLog?Math.abs(c): +f.tickInterval;if(g)var q=m(g,this,b);else if(d)q=c;else if(h)q=b.dateFormat(h,c);else if(p&&1E3<=f)for(;p--&&void 0===q;)b=Math.pow(k,p+1),f>=b&&0===10*c%b&&null!==e[p]&&0!==c&&(q=a.numberFormat(c/b,-1)+e[p]);void 0===q&&(q=1E4<=Math.abs(c)?a.numberFormat(c,-1):a.numberFormat(c,-1,void 0,""));return q},getSeriesExtremes:function(){var a=this,l=a.chart;c(this,"getSeriesExtremes",null,function(){a.hasVisibleSeries=!1;a.dataMin=a.dataMax=a.threshold=null;a.softThreshold=!a.isXAxis;a.buildStacks&&a.buildStacks(); +a.series.forEach(function(f){if(f.visible||!l.options.chart.ignoreHiddenSeries){var c=f.options,b=c.threshold;a.hasVisibleSeries=!0;a.positiveValuesOnly&&0>=b&&(b=null);if(a.isXAxis){if(c=f.xData,c.length){f=e(c);var d=C(c);p(f)||f instanceof Date||(c=c.filter(p),f=e(c),d=C(c));c.length&&(a.dataMin=Math.min(z(a.dataMin,c[0],f),f),a.dataMax=Math.max(z(a.dataMax,c[0],d),d))}}else if(f.getExtremes(),d=f.dataMax,f=f.dataMin,u(f)&&u(d)&&(a.dataMin=Math.min(z(a.dataMin,f),f),a.dataMax=Math.max(z(a.dataMax, +d),d)),u(b)&&(a.threshold=b),!c.softThreshold||a.positiveValuesOnly)a.softThreshold=!1}})});c(this,"afterGetSeriesExtremes")},translate:function(a,c,b,d,h,k){var f=this.linkedParent||this,l=1,e=0,B=d?f.oldTransA:f.transA;d=d?f.oldMin:f.min;var g=f.minPixelPadding;h=(f.isOrdinal||f.isBroken||f.isLog&&h)&&f.lin2val;B||(B=f.transA);b&&(l*=-1,e=f.len);f.reversed&&(l*=-1,e-=l*(f.sector||f.len));c?(a=(a*l+e-g)/B+d,h&&(a=f.lin2val(a))):(h&&(a=f.val2lin(a)),a=p(d)?l*(a-d)*B+e+l*g+(p(k)?B*k:0):void 0);return a}, +toPixels:function(a,c){return this.translate(a,!1,!this.horiz,null,!0)+(c?0:this.pos)},toValue:function(a,c){return this.translate(a-(c?0:this.pos),!0,!this.horiz,null,!0)},getPlotLinePath:function(a,l,b,d,h){var f=this,k=f.chart,e=f.left,g=f.top,q,m,v,A,J=b&&k.oldChartHeight||k.chartHeight,x=b&&k.oldChartWidth||k.chartWidth,y,n=f.transB,r=function(a,f,c){if("pass"!==d&&ac)d?a=Math.min(Math.max(f,a),c):y=!0;return a};var F={value:a,lineWidth:l,old:b,force:d,translatedValue:h};c(this,"getPlotLinePath", +F,function(c){h=z(h,f.translate(a,null,null,b));h=Math.min(Math.max(-1E5,h),1E5);q=v=Math.round(h+n);m=A=Math.round(J-h-n);p(h)?f.horiz?(m=g,A=J-f.bottom,q=v=r(q,e,e+f.width)):(q=e,v=x-f.right,m=A=r(m,g,g+f.height)):(y=!0,d=!1);c.path=y&&!d?null:k.renderer.crispLine(["M",q,m,"L",v,A],l||1)});return F.path},getLinearTickPositions:function(a,c,b){var f=n(Math.floor(c/a)*a);b=n(Math.ceil(b/a)*a);var l=[],d;n(f+a)===f&&(d=20);if(this.single)return[c];for(c=f;c<=b;){l.push(c);c=n(c+a,d);if(c===h)break; +var h=c}return l},getMinorTickInterval:function(){var a=this.options;return!0===a.minorTicks?z(a.minorTickInterval,"auto"):!1===a.minorTicks?null:a.minorTickInterval},getMinorTickPositions:function(){var a=this,c=a.options,b=a.tickPositions,d=a.minorTickInterval,h=[],k=a.pointRangePadding||0,e=a.min-k;k=a.max+k;var p=k-e;if(p&&p/d=this.minRange;var m=this.minRange;var v=(m-b+c)/2;v=[c-v,z(a.min,c-v)];q&&(v[2]=this.isLog?this.log2lin(this.dataMin):this.dataMin);c=C(v);b=[c+m,z(a.max,c+m)];q&&(b[2]=this.isLog?this.log2lin(this.dataMax):this.dataMax);b=e(b);b-c=H)C=H,v=0;else if(b.dataMax<=H){var E=H;q=0}b.min=z(M,C,b.dataMin);b.max=z(w,E,b.dataMax)}k&&(b.positiveValuesOnly&&!f&&0>=Math.min(b.min,z(b.dataMin,b.min))&&a.error(10,1,d),b.min=n(b.log2lin(b.min),15),b.max=n(b.log2lin(b.max),15));b.range&&u(b.max)&&(b.userMin=b.min=M=Math.max(b.dataMin,b.minFromRange()),b.userMax=w=b.max,b.range=null);c(b,"foundExtremes");b.beforePadding&& +b.beforePadding();b.adjustForMinRange();!(D||b.axisPointRange||b.usePercentage||m)&&u(b.min)&&u(b.max)&&(d=b.max-b.min)&&(!u(M)&&v&&(b.min-=d*v),!u(w)&&q&&(b.max+=d*q));p(h.softMin)&&!p(b.userMin)&&(b.min=Math.min(b.min,h.softMin));p(h.softMax)&&!p(b.userMax)&&(b.max=Math.max(b.max,h.softMax));p(h.floor)&&(b.min=Math.min(Math.max(b.min,h.floor),Number.MAX_VALUE));p(h.ceiling)&&(b.max=Math.max(Math.min(b.max,h.ceiling),z(b.userMax,-Number.MAX_VALUE)));t&&u(b.dataMin)&&(H=H||0,!u(M)&&b.min= +H?b.min=H:!u(w)&&b.max>H&&b.dataMax<=H&&(b.max=H));b.tickInterval=b.min===b.max||void 0===b.min||void 0===b.max?1:m&&!r&&F===b.linkedParent.options.tickPixelInterval?r=b.linkedParent.tickInterval:z(r,this.tickAmount?(b.max-b.min)/Math.max(this.tickAmount-1,1):void 0,D?1:(b.max-b.min)*F/Math.max(b.len,F));g&&!f&&b.series.forEach(function(a){a.processData(b.min!==b.oldMin||b.max!==b.oldMax)});b.setAxisTranslation(!0);b.beforeSetTickPositions&&b.beforeSetTickPositions();b.postProcessTickInterval&&(b.tickInterval= +b.postProcessTickInterval(b.tickInterval));b.pointRange&&!r&&(b.tickInterval=Math.max(b.pointRange,b.tickInterval));f=z(h.minTickInterval,b.isDatetimeAxis&&b.closestPointRange);!r&&b.tickIntervalb.tickInterval&&1E3b.max)),!!this.tickAmount));this.tickAmount||(b.tickInterval=b.unsquish());this.setTickPositions()},setTickPositions:function(){var f=this.options, +b=f.tickPositions;var d=this.getMinorTickInterval();var h=f.tickPositioner,k=f.startOnTick,e=f.endOnTick;this.tickmarkOffset=this.categories&&"between"===f.tickmarkPlacement&&1===this.tickInterval?.5:0;this.minorTickInterval="auto"===d&&this.tickInterval?this.tickInterval/5:d;this.single=this.min===this.max&&u(this.min)&&!this.tickAmount&&(parseInt(this.min,10)===this.min||!1!==f.allowDecimals);this.tickPositions=d=b&&b.slice();!d&&(!this.ordinalPositions&&(this.max-this.min)/this.tickInterval>Math.max(2* +this.len,200)?(d=[this.min,this.max],a.error(19,!1,this.chart)):d=this.isDatetimeAxis?this.getTimeTicks(this.normalizeTimeTickInterval(this.tickInterval,f.units),this.min,this.max,f.startOfWeek,this.ordinalPositions,this.closestPointRange,!0):this.isLog?this.getLogTickPositions(this.tickInterval,this.min,this.max):this.getLinearTickPositions(this.tickInterval,this.min,this.max),d.length>this.len&&(d=[d[0],d.pop()],d[0]===d[1]&&(d.length=1)),this.tickPositions=d,h&&(h=h.apply(this,[this.min,this.max])))&& +(this.tickPositions=d=h);this.paddedTicks=d.slice(0);this.trimTicks(d,k,e);this.isLinked||(this.single&&2>d.length&&(this.min-=.5,this.max+=.5),b||h||this.adjustTickAmount());c(this,"afterSetTickPositions")},trimTicks:function(a,b,d){var f=a[0],h=a[a.length-1],l=this.minPointOffset||0;c(this,"trimTicks");if(!this.isLinked){if(b&&-Infinity!==f)this.min=f;else for(;this.min-l>a[0];)a.shift();if(d)this.max=h;else for(;this.max+lc&&(this.finalTickAmt=c,c=5);this.tickAmount=c},adjustTickAmount:function(){var a=this.options,c=this.tickInterval,b=this.tickPositions,d=this.tickAmount,h=this.finalTickAmt,k=b&&b.length,e=z(this.threshold,this.softThreshold?0:null),p;if(this.hasData()){if(kd&&(this.tickInterval*=2,this.setTickPositions());if(u(h)){for(c=a=b.length;c--;)(3===h&&1===c%2||2>=h&&0l&&(c=l)),u(d)&&(bl&&(b=l))),this.displayBtn=void 0!==c||void 0!==b,this.setExtremes(c,b,!1,void 0,{trigger:"zoom"});a.zoomed=!0});return h.zoomed}, +setAxisSize:function(){var f=this.chart,c=this.options,b=c.offsets||[0,0,0,0],d=this.horiz,h=this.width=Math.round(a.relativeLength(z(c.width,f.plotWidth-b[3]+b[1]),f.plotWidth)),k=this.height=Math.round(a.relativeLength(z(c.height,f.plotHeight-b[0]+b[2]),f.plotHeight)),e=this.top=Math.round(a.relativeLength(z(c.top,f.plotTop+b[0]),f.plotHeight,f.plotTop));c=this.left=Math.round(a.relativeLength(z(c.left,f.plotLeft+b[3]),f.plotWidth,f.plotLeft));this.bottom=f.chartHeight-k-e;this.right=f.chartWidth- +h-c;this.len=Math.max(d?h:k,0);this.pos=d?c:e},getExtremes:function(){var a=this.isLog;return{min:a?n(this.lin2log(this.min)):this.min,max:a?n(this.lin2log(this.max)):this.max,dataMin:this.dataMin,dataMax:this.dataMax,userMin:this.userMin,userMax:this.userMax}},getThreshold:function(a){var f=this.isLog,c=f?this.lin2log(this.min):this.min;f=f?this.lin2log(this.max):this.max;null===a||-Infinity===a?a=c:Infinity===a?a=f:c>a?a=c:ff?a.align="right":195f&&(a.align="left")});return a.align},tickSize:function(a){var f=this.options,b=f[a+"Length"],d=z(f[a+"Width"],"tick"===a&&this.isXAxis?1:0);if(d&&b){"inside"===f[a+"Position"]&&(b=-b);var h=[b,d]}a={tickSize:h};c(this,"afterTickSize",a);return a.tickSize},labelMetrics:function(){var a=this.tickPositions&&this.tickPositions[0]||0;return this.chart.renderer.fontMetrics(this.options.labels.style&& +this.options.labels.style.fontSize,this.ticks[a]&&this.ticks[a].label)},unsquish:function(){var a=this.options.labels,c=this.horiz,b=this.tickInterval,d=b,h=this.len/(((this.categories?1:0)+this.max-this.min)/b),k,e=a.rotation,p=this.labelMetrics(),m,g=Number.MAX_VALUE,q,v=this.max-this.min,y=function(a){var f=a/(h||1);f=1v&&Infinity!==a&&Infinity!==h&&(f=Math.ceil(v/b));return n(f*b)};c?(q=!a.staggerLines&&!a.step&&(u(e)?[e]:h=a){m=y(Math.abs(p.h/Math.sin(w*a)));var f=m+Math.abs(a/360);f(b.step||0)&&!b.rotation&&(this.staggerLines||1)*this.len/d||!c&&(b.style&&parseInt(b.style.width,10)||h&&h-f.spacing[3]||.33*f.chartWidth)},renderUnsquish:function(){var a= +this.chart,c=a.renderer,b=this.tickPositions,d=this.ticks,h=this.options.labels,k=h&&h.style||{},e=this.horiz,p=this.getSlotWidth(),m=Math.max(1,Math.round(p-2*(h.padding||5))),g={},v=this.labelMetrics(),y=h.style&&h.style.textOverflow,x=0;q(h.rotation)||(g.rotation=h.rotation||0);b.forEach(function(a){(a=d[a])&&a.label&&a.label.textPxLength>x&&(x=a.label.textPxLength)});this.maxLabelLength=x;if(this.autoRotation)x>m&&x>v.h?g.rotation=this.labelRotation:this.labelRotation=0;else if(p){var n=m;if(!y){var r= +"clip";for(m=b.length;!e&&m--;){var F=b[m];if(F=d[F].label)F.styles&&"ellipsis"===F.styles.textOverflow?F.css({textOverflow:"clip"}):F.textPxLength>p&&F.css({width:p+"px"}),F.getBBox().height>this.len/b.length-(v.h-v.f)&&(F.specificTextOverflow="ellipsis")}}}g.rotation&&(n=x>.5*a.chartHeight?.33*a.chartHeight:x,y||(r="ellipsis"));if(this.labelAlign=h.align||this.autoLabelAlign(this.labelRotation))g.align=this.labelAlign;b.forEach(function(a){var f=(a=d[a])&&a.label,c=k.width,b={};f&&(f.attr(g),a.shortenLabel? +a.shortenLabel():n&&!c&&"nowrap"!==k.whiteSpace&&(n=this.min&&a<=this.max)f[a]||(f[a]=new F(this,a)),d&&f[a].isNew&&f[a].render(b,!0,-1),f[a].render(b)},render:function(){var b=this,d=b.chart,h=b.options, +k=b.isLog,e=b.isLinked,m=b.tickPositions,g=b.axisTitle,q=b.ticks,y=b.minorTicks,x=b.alternateBands,n=h.stackLabels,r=h.alternateGridColor,z=b.tickmarkOffset,D=b.axisLine,t=b.showAxis,M=I(d.renderer.globalAnimation),u,w;b.labelEdge.length=0;b.overlap=!1;[q,y,x].forEach(function(a){H(a,function(a){a.isActive=!1})});if(b.hasData()||e)b.minorTickInterval&&!b.categories&&b.getMinorTickPositions().forEach(function(a){b.renderMinorTick(a)}),m.length&&(m.forEach(function(a,c){b.renderTick(a,c)}),z&&(0=== +b.min||b.single)&&(q[-1]||(q[-1]=new F(b,-1,null,!0)),q[-1].render(-1))),r&&m.forEach(function(c,f){w=void 0!==m[f+1]?m[f+1]+z:b.max-z;0===f%2&&cm-p?m:m-p);else if(l)g[a]=Math.max(d,f+p+c>b?f:f+p);else return!1},t=function(a,b,c,f){var d;fb-e?d=!1:g[a]=fb-c/2?b-c-2:f-c/2;return d},f=function(a){var b=k;k=h;h=b;q=a},l=function(){!1!==F.apply(0,k)?!1!==t.apply(0,h)||q||(f(!0),l()):q?g.x=g.y=0:(f(!0),l())};(d.inverted||1l&&(p=!1);f=(h.series&&h.series.yAxis&&h.series.yAxis.pos)+(h.plotY||0);f-=z;h.isHeader&&(f=y?-D:g.plotHeight+D);e.push({target:f,rank:h.isHeader?1:0,size:k.tt.getBBox().height+1,point:h,x:l,tt:m})}});this.cleanSplit();q.positioner&&e.forEach(function(a){var b=q.positioner.call(c,a.tt.getBBox().width,a.size,a.point);a.x=b.x;a.align=0;a.target=b.y;a.rank=n(b.rank,a.rank)});a.distribute(e,g.plotHeight+D);e.forEach(function(a){var b=a.point, +d=b.series;a.tt.attr({visibility:void 0===a.pos?"hidden":"inherit",x:p||b.isHeader||q.positioner?a.x:b.plotX+g.plotLeft+c.distance,y:a.pos+z,anchorX:b.isHeader?b.plotX+g.plotLeft:b.plotX+d.xAxis.pos,anchorY:b.isHeader?g.plotTop+g.plotHeight/2:b.plotY+d.yAxis.pos})})},updatePosition:function(a){var b=this.chart,c=this.getLabel(),d=(this.options.positioner||this.getPosition).call(this,c.width,c.height,a),e=a.plotX+b.plotLeft;a=a.plotY+b.plotTop;if(this.outside){var g=(this.options.borderWidth||0)+2* +this.distance;this.renderer.setSize(c.width+g,c.height+g,!1);e+=b.pointer.chartPosition.left-d.x;a+=b.pointer.chartPosition.top-d.y}this.move(Math.round(d.x),Math.round(d.y||0),e,a)},getDateFormat:function(a,b,c,e){var d=this.chart.time,g=d.dateFormat("%m-%d %H:%M:%S.%L",b),p={millisecond:15,second:12,minute:9,hour:6,day:3},q="millisecond";for(m in w){if(a===w.week&&+d.dateFormat("%w",b)===c&&"00:00:00.000"===g.substr(6)){var m="week";break}if(w[m]>a){m=q;break}if(p[m]&&g.substr(p[m])!=="01-01 00:00:00.000".substr(p[m]))break; +"week"!==m&&(q=m)}if(m)var y=d.resolveDTLFormat(e[m]).main;return y},getXDateFormat:function(a,b,c){b=b.dateTimeLabelFormats;var d=c&&c.closestPointRange;return(d?this.getDateFormat(d,a.x,c.options.startOfWeek,b):b.day)||b.year},tooltipFooterHeaderFormatter:function(d,b){var c=b?"footer":"header",g=d.series,n=g.tooltipOptions,r=n.xDateFormat,p=g.xAxis,q=p&&"datetime"===p.options.type&&e(d.key),D=n[c+"Format"];c={isFooter:b,labelConfig:d};a.fireEvent(this,"headerFormatter",c,function(a){q&&!r&&(r= +this.getXDateFormat(d,n,p));q&&r&&(d.point&&d.point.tooltipDateKeys||["key"]).forEach(function(a){D=D.replace("{point."+a+"}","{point."+a+":"+r+"}")});g.chart.styledMode&&(D=this.styledModeFormat(D));a.text=C(D,{point:d,series:g},this.chart.time)});return c.text},bodyFormatter:function(a){return a.map(function(a){var b=a.series.tooltipOptions;return(b[(a.point.formatPrefix||"point")+"Formatter"]||a.point.tooltipFormatter).call(a.point,b[(a.point.formatPrefix||"point")+"Format"]||"")})},styledModeFormat:function(a){return a.replace('style="font-size: 10px"', +'class="highcharts-header"').replace(/style="color:{(point|series)\.color}"/g,'class="highcharts-color-{$1.colorIndex}"')}}})(L);(function(a){var E=a.addEvent,I=a.attr,C=a.charts,e=a.color,g=a.css,n=a.defined,t=a.extend,u=a.find,w=a.fireEvent,d=a.isNumber,b=a.isObject,c=a.offset,m=a.pick,x=a.splat,r=a.Tooltip;a.Pointer=function(a,b){this.init(a,b)};a.Pointer.prototype={init:function(a,b){this.options=b;this.chart=a;this.runChartClick=b.chart.events&&!!b.chart.events.click;this.pinchDown=[];this.lastValidTouch= +{};r&&(a.tooltip=new r(a,b.tooltip),this.followTouchMove=m(b.tooltip.followTouchMove,!0));this.setDOMEvents()},zoomOption:function(a){var b=this.chart,c=b.options.chart,d=c.zoomType||"";b=b.inverted;/touch/.test(a.type)&&(d=m(c.pinchType,d));this.zoomX=a=/x/.test(d);this.zoomY=d=/y/.test(d);this.zoomHor=a&&!b||d&&b;this.zoomVert=d&&!b||a&&b;this.hasZoom=a||d},normalize:function(a,b){var d=a.touches?a.touches.length?a.touches.item(0):a.changedTouches[0]:a;b||(this.chartPosition=b=c(this.chart.container)); +return t(a,{chartX:Math.round(d.pageX-b.left),chartY:Math.round(d.pageY-b.top)})},getCoordinates:function(a){var b={xAxis:[],yAxis:[]};this.chart.axes.forEach(function(c){b[c.isXAxis?"xAxis":"yAxis"].push({axis:c,value:c.toValue(a[c.horiz?"chartX":"chartY"])})});return b},findNearestKDPoint:function(a,c,d){var e;a.forEach(function(a){var g=!(a.noSharedTooltip&&c)&&0>a.options.findNearestPointBy.indexOf("y");a=a.searchPoint(d,g);if((g=b(a,!0))&&!(g=!b(e,!0))){g=e.distX-a.distX;var k=e.dist-a.dist, +h=(a.series.group&&a.series.group.zIndex)-(e.series.group&&e.series.group.zIndex);g=0<(0!==g&&c?g:0!==k?k:0!==h?h:e.series.index>a.series.index?-1:1)}g&&(e=a)});return e},getPointFromEvent:function(a){a=a.target;for(var b;a&&!b;)b=a.point,a=a.parentNode;return b},getChartCoordinatesFromPoint:function(a,b){var c=a.series,d=c.xAxis;c=c.yAxis;var e=m(a.clientX,a.plotX),g=a.shapeArgs;if(d&&c)return b?{chartX:d.len+d.pos-e,chartY:c.len+c.pos-a.plotY}:{chartX:e+d.pos,chartY:a.plotY+c.pos};if(g&&g.x&&g.y)return{chartX:g.x, +chartY:g.y}},getHoverData:function(a,c,d,e,g,n){var k,h=[];e=!(!e||!a);var p=c&&!c.stickyTracking?[c]:d.filter(function(a){return a.visible&&!(!g&&a.directTouch)&&m(a.options.enableMouseTracking,!0)&&a.stickyTracking});c=(k=e?a:this.findNearestKDPoint(p,g,n))&&k.series;k&&(g&&!c.noSharedTooltip?(p=d.filter(function(a){return a.visible&&!(!g&&a.directTouch)&&m(a.options.enableMouseTracking,!0)&&!a.noSharedTooltip}),p.forEach(function(a){var c=u(a.points,function(a){return a.x===k.x&&!a.isNull});b(c)&& +(a.chart.isBoosting&&(c=a.getPoint(c)),h.push(c))})):h.push(k));return{hoverPoint:k,hoverSeries:c,hoverPoints:h}},runPointActions:function(b,c){var d=this.chart,e=d.tooltip&&d.tooltip.options.enabled?d.tooltip:void 0,g=e?e.shared:!1,p=c||d.hoverPoint,k=p&&p.series||d.hoverSeries;k=this.getHoverData(p,k,d.series,"touchmove"!==b.type&&(!!c||k&&k.directTouch&&this.isDirectTouch),g,b);p=k.hoverPoint;var h=k.hoverPoints;var q=(k=k.hoverSeries)&&k.tooltipOptions.followPointer;g=g&&k&&!k.noSharedTooltip; +if(p&&(p!==d.hoverPoint||e&&e.isHidden)){(d.hoverPoints||[]).forEach(function(a){-1===h.indexOf(a)&&a.setState()});(h||[]).forEach(function(a){a.setState("hover")});if(d.hoverSeries!==k)k.onMouseOver();d.hoverPoint&&d.hoverPoint.firePointEvent("mouseOut");if(!p.series)return;p.firePointEvent("mouseOver");d.hoverPoints=h;d.hoverPoint=p;e&&e.refresh(g?h:p,b)}else q&&e&&!e.isHidden&&(p=e.getAnchor([{}],b),e.updatePosition({plotX:p[0],plotY:p[1]}));this.unDocMouseMove||(this.unDocMouseMove=E(d.container.ownerDocument, +"mousemove",function(b){var c=C[a.hoverChartIndex];if(c)c.pointer.onDocumentMouseMove(b)}));d.axes.forEach(function(c){var d=m(c.crosshair.snap,!0),f=d?a.find(h,function(a){return a.series[c.coll]===c}):void 0;f||!d?c.drawCrosshair(b,f):c.hideCrosshair()})},reset:function(a,b){var c=this.chart,d=c.hoverSeries,e=c.hoverPoint,g=c.hoverPoints,k=c.tooltip,h=k&&k.shared?g:e;a&&h&&x(h).forEach(function(b){b.series.isCartesian&&void 0===b.plotX&&(a=!1)});if(a)k&&h&&x(h).length&&(k.refresh(h),k.shared&&g? +g.forEach(function(a){a.setState(a.state,!0);a.series.isCartesian&&(a.series.xAxis.crosshair&&a.series.xAxis.drawCrosshair(null,a),a.series.yAxis.crosshair&&a.series.yAxis.drawCrosshair(null,a))}):e&&(e.setState(e.state,!0),c.axes.forEach(function(a){a.crosshair&&a.drawCrosshair(null,e)})));else{if(e)e.onMouseOut();g&&g.forEach(function(a){a.setState()});if(d)d.onMouseOut();k&&k.hide(b);this.unDocMouseMove&&(this.unDocMouseMove=this.unDocMouseMove());c.axes.forEach(function(a){a.hideCrosshair()}); +this.hoverX=c.hoverPoints=c.hoverPoint=null}},scaleGroups:function(a,b){var c=this.chart,d;c.series.forEach(function(e){d=a||e.getPlotBox();e.xAxis&&e.xAxis.zoomEnabled&&e.group&&(e.group.attr(d),e.markerGroup&&(e.markerGroup.attr(d),e.markerGroup.clip(b?c.clipRect:null)),e.dataLabelsGroup&&e.dataLabelsGroup.attr(d))});c.clipRect.attr(b||c.clipBox)},dragStart:function(a){var b=this.chart;b.mouseIsDown=a.type;b.cancelClick=!1;b.mouseDownX=this.mouseDownX=a.chartX;b.mouseDownY=this.mouseDownY=a.chartY}, +drag:function(a){var b=this.chart,c=b.options.chart,d=a.chartX,g=a.chartY,p=this.zoomHor,k=this.zoomVert,h=b.plotLeft,m=b.plotTop,n=b.plotWidth,r=b.plotHeight,f=this.selectionMarker,l=this.mouseDownX,x=this.mouseDownY,A=c.panKey&&a[c.panKey+"Key"];if(!f||!f.touch)if(dh+n&&(d=h+n),gm+r&&(g=m+r),this.hasDragged=Math.sqrt(Math.pow(l-d,2)+Math.pow(x-g,2)),10(.*?$)/);c&&c[1]&&(c=''+c[1]+"",a=a.replace("",c+""))}a=a.replace(/zIndex="[^"]+"/g,"").replace(/symbolName="[^"]+"/g,"").replace(/jQuery[0-9]+="[^"]+"/g,"").replace(/url\(("|")(\S+)("|")\)/g, +"url($2)").replace(/url\([^#]+#/g,"url(#").replace(/.*?$/,"").replace(/(fill|stroke)="rgba\(([ 0-9]+,[ 0-9]+,[ 0-9]+),([ 0-9\.]+)\)"/g,'$1="rgb($2)" $1-opacity="$3"').replace(/ /g,"\u00a0").replace(/­/g,"\u00ad");this.ieSanitizeSVG&&(a=this.ieSanitizeSVG(a));return a},getChartHTML:function(){this.styledMode&&this.inlineStyles();return this.container.innerHTML}, +getSVG:function(c){var d,e=b(this.options,c);var k=u("div",null,{position:"absolute",top:"-9999em",width:this.chartWidth+"px",height:this.chartHeight+"px"},C.body);var g=this.renderTo.style.width;var f=this.renderTo.style.height;g=e.exporting.sourceWidth||e.chart.width||/px$/.test(g)&&parseInt(g,10)||(e.isGantt?800:600);f=e.exporting.sourceHeight||e.chart.height||/px$/.test(f)&&parseInt(f,10)||400;x(e.chart,{animation:!1,renderTo:k,forExport:!0,renderer:"SVGRenderer",width:g,height:f});e.exporting.enabled= +!1;delete e.data;e.series=[];this.series.forEach(function(a){d=b(a.userOptions,{animation:!1,enableMouseTracking:!1,showCheckbox:!1,visible:a.visible});d.isInternal||e.series.push(d)});this.axes.forEach(function(b){b.userOptions.internalKey||(b.userOptions.internalKey=a.uniqueKey())});var l=new a.Chart(e,this.callback);c&&["xAxis","yAxis","series"].forEach(function(a){var b={};c[a]&&(b[a]=c[a],l.update(b))});this.axes.forEach(function(b){var c=a.find(l.axes,function(a){return a.options.internalKey=== +b.userOptions.internalKey}),f=b.getExtremes(),d=f.userMin;f=f.userMax;c&&(void 0!==d&&d!==c.min||void 0!==f&&f!==c.max)&&c.setExtremes(d,f,!0,!1)});g=l.getChartHTML();t(this,"getSVG",{chartCopy:l});g=this.sanitizeSVG(g,e);e=null;l.destroy();w(k);return g},getSVGForExport:function(a,c){var d=this.options.exporting;return this.getSVG(b({chart:{borderRadius:0}},d.chartOptions,c,{exporting:{sourceWidth:a&&a.sourceWidth||d.sourceWidth,sourceHeight:a&&a.sourceHeight||d.sourceHeight}}))},getFilename:function(){var a= +this.userOptions.title&&this.userOptions.title.text,b=this.options.exporting.filename;if(b)return b;"string"===typeof a&&(b=a.toLowerCase().replace(/<\/?[^>]+(>|$)/g,"").replace(/[\s_]+/g,"-").replace(/[^a-z0-9\-]/g,"").replace(/^[\-]+/g,"").replace(/[\-]+/g,"-").substr(0,24).replace(/[\-]+$/g,""));if(!b||5>b.length)b="chart";return b},exportChart:function(c,d){var h=this.getSVGForExport(c,d);c=b(this.options.exporting,c);a.post(c.url,{filename:c.filename||this.getFilename(),type:c.type,width:c.width|| +0,scale:c.scale,svg:h},c.formAttributes)},print:function(){function a(a){(b.fixedDiv?[b.fixedDiv,b.scrollingContainer]:[b.container]).forEach(function(b){a.appendChild(b)})}var b=this,c=[],d=C.body,e=d.childNodes,f=b.options.exporting.printMaxWidth,g;if(!b.isPrinting){b.isPrinting=!0;b.pointer.reset(null,0);t(b,"beforePrint");if(g=f&&b.chartWidth>f){var m=[b.options.chart.width,void 0,!1];b.setSize(f,void 0,!1)}e.forEach(function(a,b){1===a.nodeType&&(c[b]=a.style.display,a.style.display="none")}); +a(d);setTimeout(function(){p.focus();p.print();setTimeout(function(){a(b.renderTo);e.forEach(function(a,b){1===a.nodeType&&(a.style.display=c[b])});b.isPrinting=!1;g&&b.setSize.apply(b,m);t(b,"afterPrint")},1E3)},1)}},contextMenu:function(b,c,e,m,p,f,l){var h=this,k=h.options.navigation,q=h.chartWidth,v=h.chartHeight,n="cache-"+b,r=h[n],y=Math.max(p,f);if(!r){h.exportContextMenu=h[n]=r=u("div",{className:b},{position:"absolute",zIndex:1E3,padding:y+"px",pointerEvents:"auto"},h.fixedDiv||h.container); +var F=u("div",{className:"highcharts-menu"},null,r);h.styledMode||d(F,x({MozBoxShadow:"3px 3px 10px #888",WebkitBoxShadow:"3px 3px 10px #888",boxShadow:"3px 3px 10px #888"},k.menuStyle));r.hideMenu=function(){d(r,{display:"none"});l&&l.setState(0);h.openMenu=!1;a.clearTimeout(r.hideTimer)};h.exportEvents.push(g(r,"mouseleave",function(){r.hideTimer=setTimeout(r.hideMenu,500)}),g(r,"mouseenter",function(){a.clearTimeout(r.hideTimer)}),g(C,"mouseup",function(a){h.pointer.inClass(a.target,b)||r.hideMenu()}), +g(r,"click",function(){h.openMenu&&r.hideMenu()}));c.forEach(function(b){"string"===typeof b&&(b=h.options.exporting.menuItemDefinitions[b]);if(a.isObject(b,!0)){if(b.separator)var c=u("hr",null,null,F);else c=u("div",{className:"highcharts-menu-item",onclick:function(a){a&&a.stopPropagation();r.hideMenu();b.onclick&&b.onclick.apply(h,arguments)},innerHTML:b.text||h.options.lang[b.textKey]},null,F),h.styledMode||(c.onmouseover=function(){d(this,k.menuItemHoverStyle)},c.onmouseout=function(){d(this, +k.menuItemStyle)},d(c,x({cursor:"pointer"},k.menuItemStyle)));h.exportDivElements.push(c)}});h.exportDivElements.push(F,r);h.exportMenuWidth=r.offsetWidth;h.exportMenuHeight=r.offsetHeight}c={display:"block"};e+h.exportMenuWidth>q?c.right=q-e-p-y+"px":c.left=e-y+"px";m+f+h.exportMenuHeight>v&&"top"!==l.alignOptions.verticalAlign?c.bottom=v-m-y+"px":c.top=m+f-y+"px";d(r,c);h.openMenu=!0},addButton:function(a){var d=this,e=d.renderer,k=b(d.options.navigation.buttonOptions,a),g=k.onclick,f=k.menuItems, +l=k.symbolSize||12;d.btnCount||(d.btnCount=0);d.exportDivElements||(d.exportDivElements=[],d.exportSVGElements=[]);if(!1!==k.enabled){var m=k.theme,p=m.states,q=p&&p.hover;p=p&&p.select;var n;d.styledMode||(m.fill=c(m.fill,"#ffffff"),m.stroke=c(m.stroke,"none"));delete m.states;g?n=function(a){a&&a.stopPropagation();g.call(d,a)}:f&&(n=function(a){a&&a.stopPropagation();d.contextMenu(r.menuClassName,f,r.translateX,r.translateY,r.width,r.height,r);r.setState(2)});k.text&&k.symbol?m.paddingLeft=c(m.paddingLeft, +25):k.text||x(m,{width:k.width,height:k.height,padding:0});d.styledMode||(m["stroke-linecap"]="round",m.fill=c(m.fill,"#ffffff"),m.stroke=c(m.stroke,"none"));var r=e.button(k.text,0,0,n,m,q,p).addClass(a.className).attr({title:c(d.options.lang[k._titleKey||k.titleKey],"")});r.menuClassName=a.menuClassName||"highcharts-menu-"+d.btnCount++;if(k.symbol){var y=e.symbol(k.symbol,k.symbolX-l/2,k.symbolY-l/2,l,l,{width:l,height:l}).addClass("highcharts-button-symbol").attr({zIndex:1}).add(r);d.styledMode|| +y.attr({stroke:k.symbolStroke,fill:k.symbolFill,"stroke-width":k.symbolStrokeWidth||1})}r.add(d.exportingGroup).align(x(k,{width:r.width,x:c(k.x,d.buttonOffset)}),!0,"spacingBox");d.buttonOffset+=(r.width+k.buttonSpacing)*("right"===k.align?-1:1);d.exportSVGElements.push(r,y)}},destroyExport:function(b){var c=b?b.target:this;b=c.exportSVGElements;var d=c.exportDivElements,e=c.exportEvents,k;b&&(b.forEach(function(a,b){a&&(a.onclick=a.ontouchstart=null,k="cache-"+a.menuClassName,c[k]&&delete c[k], +c.exportSVGElements[b]=a.destroy())}),b.length=0);c.exportingGroup&&(c.exportingGroup.destroy(),delete c.exportingGroup);d&&(d.forEach(function(b,d){a.clearTimeout(b.hideTimer);n(b,"mouseleave");c.exportDivElements[d]=b.onmouseout=b.onmouseover=b.ontouchstart=b.onclick=null;w(b)}),d.length=0);e&&(e.forEach(function(a){a()}),e.length=0)}});D.prototype.inlineToAttributes="fill stroke strokeLinecap strokeLinejoin strokeWidth textAnchor x y".split(" ");D.prototype.inlineBlacklist=[/-/,/^(clipPath|cssText|d|height|width)$/, +/^font$/,/[lL]ogical(Width|Height)$/,/perspective/,/TapHighlightColor/,/^transition/,/^length$/];D.prototype.unstyledElements=["clipPath","defs","desc"];e.prototype.inlineStyles=function(){function a(a){return a.replace(/([A-Z])/g,function(a,b){return"-"+b.toLowerCase()})}function c(d){function h(b,c){k=v=!1;if(f){for(y=f.length;y--&&!v;)v=f[y].test(c);k=!v}"transform"===c&&"none"===b&&(k=!0);for(y=q.length;y--&&!k;)k=q[y].test(c)||"function"===typeof b;k||t[c]===b&&"svg"!==d.nodeName||n[d.nodeName][c]=== +b||(-1!==g.indexOf(c)?d.setAttribute(a(c),b):e+=a(c)+":"+b+";")}var e="",k,v,y;if(1===d.nodeType&&-1===l.indexOf(d.nodeName)){var F=p.getComputedStyle(d,null);var t="svg"===d.nodeName?{}:p.getComputedStyle(d.parentNode,null);if(!n[d.nodeName]){r=x.getElementsByTagName("svg")[0];var u=x.createElementNS(d.namespaceURI,d.nodeName);r.appendChild(u);n[d.nodeName]=b(p.getComputedStyle(u,null));"text"===d.nodeName&&delete n.text.fill;r.removeChild(u)}if(z||H)for(var w in F)h(F[w],w);else m(F,h);e&&(F=d.getAttribute("style"), +d.setAttribute("style",(F?F+";":"")+e));"svg"===d.nodeName&&d.setAttribute("stroke-width","1px");"text"!==d.nodeName&&[].forEach.call(d.children||d.childNodes,c)}}var e=this.renderer,g=e.inlineToAttributes,q=e.inlineBlacklist,f=e.inlineWhitelist,l=e.unstyledElements,n={},r;e=C.createElement("iframe");d(e,{width:"1px",height:"1px",visibility:"hidden"});C.body.appendChild(e);var x=e.contentWindow.document;x.open();x.write('');x.close();c(this.container.querySelector("svg")); +r.parentNode.removeChild(r)};y.menu=function(a,b,c,d){return["M",a,b+2.5,"L",a+c,b+2.5,"M",a,b+d/2+.5,"L",a+c,b+d/2+.5,"M",a,b+d-1.5,"L",a+c,b+d-1.5]};y.menuball=function(a,b,c,d){a=[];d=d/3-2;return a=a.concat(this.circle(c-d,b,d,d),this.circle(c-d,b+d+4,d,d),this.circle(c-d,b+2*(d+4),d,d))};e.prototype.renderExporting=function(){var a=this,b=a.options.exporting,c=b.buttons,d=a.isDirtyExporting||!a.exportSVGElements;a.buttonOffset=0;a.isDirtyExporting&&a.destroyExport();d&&!1!==b.enabled&&(a.exportEvents= +[],a.exportingGroup=a.exportingGroup||a.renderer.g("exporting-group").attr({zIndex:3}).add(),m(c,function(b){a.addButton(b)}),a.isDirtyExporting=!1);g(a,"destroy",a.destroyExport)};g(e,"init",function(){var a=this;a.exporting={update:function(d,e){a.isDirtyExporting=!0;b(!0,a.options.exporting,d);c(e,!0)&&a.redraw()}};E.addUpdate(function(d,e){a.isDirtyExporting=!0;b(!0,a.options.navigation,d);c(e,!0)&&a.redraw()},a)});e.prototype.callbacks.push(function(a){a.renderExporting();g(a,"redraw",a.renderExporting)})})(L, +W);(function(a){var E=a.win,I=E.navigator,C=E.document,e=E.URL||E.webkitURL||E,g=/Edge\/\d+/.test(I.userAgent);a.dataURLtoBlob=function(a){if((a=a.match(/data:([^;]*)(;base64)?,([0-9A-Za-z+/]+)/))&&3g.userAgent.indexOf("Chrome");try{if(!b&&0>g.userAgent.toLowerCase().indexOf("firefox"))return t.createObjectURL(new e.Blob([a],{type:"image/svg+xml;charset-utf-16"}))}catch(c){}return"data:image/svg+xml;charset=UTF-8,"+ +encodeURIComponent(a)};a.imageToDataUrl=function(a,b,c,g,x,r,p,q,t){var d=new e.Image,m=function(){setTimeout(function(){var e=n.createElement("canvas"),m=e.getContext&&e.getContext("2d");try{if(m){e.height=d.height*g;e.width=d.width*g;m.drawImage(d,0,0,e.width,e.height);try{var q=e.toDataURL(b);x(q,b,c,g)}catch(M){k(a,b,c,g)}}else p(a,b,c,g)}finally{t&&t(a,b,c,g)}},w)},z=function(){q(a,b,c,g);t&&t(a,b,c,g)};var k=function(){d=new e.Image;k=r;d.crossOrigin="Anonymous";d.onload=m;d.onerror=z;d.src= +a};d.onload=m;d.onerror=z;d.src=a};a.downloadSVGLocal=function(d,b,c,m){function x(a,b){var c=new e.jsPDF("l","pt",[a.width.baseVal.value+2*b,a.height.baseVal.value+2*b]);[].forEach.call(a.querySelectorAll('*[visibility="hidden"]'),function(a){a.parentNode.removeChild(a)});e.svg2pdf(a,c,{removeInvalid:!0});return c.output("datauristring")}function r(){u.innerHTML=d;var b=u.getElementsByTagName("text"),e;[].forEach.call(b,function(a){["font-family","font-size"].forEach(function(b){for(var c=a;c&&c!== +u;){if(c.style[b]){a.style[b]=c.style[b];break}c=c.parentNode}});a.style["font-family"]=a.style["font-family"]&&a.style["font-family"].split(" ").splice(-1);e=a.getElementsByTagName("title");[].forEach.call(e,function(b){a.removeChild(b)})});b=x(u.firstChild,0);try{a.downloadURL(b,w),m&&m()}catch(f){c(f)}}var p=!0,q=b.libURL||a.getOptions().exporting.libURL,u=n.createElement("div"),y=b.type||"image/png",w=(b.filename||"chart")+"."+("image/svg+xml"===y?"svg":y.split("/")[1]),z=b.scale||1;q="/"!==q.slice(-1)? +q+"/":q;if("image/svg+xml"===y)try{if(g.msSaveOrOpenBlob){var k=new MSBlobBuilder;k.append(d);var h=k.getBlob("image/svg+xml")}else h=a.svgToDataUrl(d);a.downloadURL(h,w);m&&m()}catch(F){c(F)}else if("application/pdf"===y)e.jsPDF&&e.svg2pdf?r():(p=!0,E(q+"jspdf.js",function(){E(q+"svg2pdf.js",function(){r()})}));else{h=a.svgToDataUrl(d);var v=function(){try{t.revokeObjectURL(h)}catch(F){}};a.imageToDataUrl(h,y,{},z,function(b){try{a.downloadURL(b,w),m&&m()}catch(M){c(M)}},function(){var b=n.createElement("canvas"), +h=b.getContext("2d"),f=d.match(/^]*width\s*=\s*"?(\d+)"?[^>]*>/)[1]*z,k=d.match(/^]*height\s*=\s*"?(\d+)"?[^>]*>/)[1]*z,r=function(){h.drawSvg(d,0,0,f,k);try{a.downloadURL(g.msSaveOrOpenBlob?b.msToBlob():b.toDataURL(y),w),m&&m()}catch(A){c(A)}finally{v()}};b.width=f;b.height=k;e.canvg?r():(p=!0,E(q+"rgbcolor.js",function(){E(q+"canvg.js",function(){r()})}))},c,c,function(){p&&v()})}};a.Chart.prototype.getSVGForLocalExport=function(d,b,c,e){var g=this,m=0,p,q,n,y=function(a,b,c){++m; +c.imageElement.setAttributeNS("http://www.w3.org/1999/xlink","href",a);m===t.length&&e(g.sanitizeSVG(p.innerHTML,q))};g.unbindGetSVG=I(g,"getSVG",function(a){q=a.chartCopy.options;p=a.chartCopy.container.cloneNode(!0)});g.getSVGForExport(d,b);var t=p.getElementsByTagName("image");try{if(!t.length){e(g.sanitizeSVG(p.innerHTML,q));return}var z=0;for(n=t.length;z=Math.abs(c)&&.5a.closestPointRange*a.xAxis.transA;g=a.borderWidth=t(d.borderWidth,g?0:1);var n=a.yAxis,p=d.threshold,q=a.translatedThreshold=n.getThreshold(p),w=t(d.minPointLength, +5),y=a.getColumnMetrics(),H=y.width,z=a.barW=Math.max(H,1+2*g),k=a.pointXOffset=y.offset;c.inverted&&(q-=.5);d.pointPadding&&(z=Math.ceil(z));u.prototype.translate.apply(a);a.points.forEach(function(b){var d=t(b.yBottom,q),h=999+Math.abs(d),g=H;h=Math.min(Math.max(-h,b.plotY),n.len+h);var f=b.plotX+k,l=z,m=Math.min(h,d),r=Math.max(h,d)-m;if(w&&Math.abs(r)w?d-w:q-(y?w:0)}e(b.options.pointWidth)&& +(g=l=Math.ceil(b.options.pointWidth),f-=Math.round((g-H)/2));b.barX=f;b.pointWidth=g;b.tooltipPos=c.inverted?[n.len+n.pos-c.plotLeft-h,a.xAxis.len-f-l/2,r]:[f+l/2,h+n.pos-c.plotTop,r];b.shapeType=b.shapeType||"rect";b.shapeArgs=a.crispCol.apply(a,b.isNull?[f,q,l,0]:[f,m,l,r])})},getSymbol:a.noop,drawLegendSymbol:a.LegendSymbolMixin.drawRectangle,drawGraph:function(){this.group[this.dense?"addClass":"removeClass"]("highcharts-dense-data")},pointAttribs:function(a,c){var b=this.options,d=this.pointAttrToOptions|| +{};var e=d.stroke||"borderColor";var g=d["stroke-width"]||"borderWidth",q=a&&a.color||this.color,t=a&&a[e]||b[e]||this.color||q,y=a&&a[g]||b[g]||this[g]||0;d=b.dashStyle;a&&this.zones.length&&(q=a.getZone(),q=a.options.color||q&&q.color||this.color);if(c){b=n(b.states[c],a.options.states&&a.options.states[c]||{});var w=b.brightness;q=b.color||void 0!==w&&I(q).brighten(b.brightness).get()||q;t=b[e]||t;y=b[g]||y;d=b.dashStyle||d}e={fill:q,stroke:t,"stroke-width":y};d&&(e.dashstyle=d);return e},drawPoints:function(){var a= +this,c=this.chart,d=a.options,e=c.renderer,r=d.animationLimit||250,p;a.points.forEach(function(b){var m=b.graphic,q=m&&c.pointCountv;)r--;this.updateParallelArrays(q,"splice",r,0,0);this.updateParallelArrays(q,r);g&&q.name&&(g[v]=q.name);k.splice(r,0,a);m&&(this.data.splice(r,0,null),this.processData());"point"=== +e.legendType&&this.generatePoints();c&&(f[0]&&f[0].remove?f[0].remove(!1):(f.shift(),this.updateParallelArrays(q,"shift"),k.shift()));this.isDirtyData=this.isDirty=!0;b&&h.redraw(d)},removePoint:function(a,b,c){var d=this,e=d.data,f=e[a],h=d.points,g=d.chart,k=function(){h&&h.length===e.length&&h.splice(a,1);e.splice(a,1);d.options.data.splice(a,1);d.updateParallelArrays(f||{series:d},"splice",a,1);f&&f.destroy();d.isDirty=!0;d.isDirtyData=!0;b&&g.redraw()};H(c,g);b=p(b,!0);f?f.firePointEvent("remove", +null,k):k()},remove:function(a,b,c,e){function h(){f.destroy(e);f.remove=null;g.isDirtyLegend=g.isDirtyBox=!0;g.linkSeries();p(a,!0)&&g.redraw(b)}var f=this,g=f.chart;!1!==c?d(f,"remove",null,h):h()},update:function(b,c){b=a.cleanRecursively(b,this.userOptions);var e=this,h=e.chart,g=e.userOptions,f=e.initialType||e.type,k=b.type||g.type||h.options.chart.type,m=y[f].prototype,n,q=["group","markerGroup","dataLabelsGroup"],r=["navigatorSeries","baseSeries"],t=e.finishedAnimating&&{animation:!1},z=["data", +"name","turboThreshold"],u=Object.keys(b),H=0this.oldTextWidth)&&((u=this.textPxLength)||(C(b,{width:"",whiteSpace:h||"nowrap"}),u=b.offsetWidth),u=u>v);u&&(/[ \-]/.test(b.textContent||b.innerText)||"ellipsis"===b.style.textOverflow)?(C(b,{width:v+"px",display:"block",whiteSpace:h||"normal"}),this.oldTextWidth=v, +this.hasBoxWidthChanged=!0):this.hasBoxWidthChanged=!1;w!==this.cTT&&(h=a.fontMetrics(b.style.fontSize,b).b,!e(k)||k===(this.oldRotation||0)&&t===this.oldAlign||this.setSpanRotation(k,z,h),this.getSpanCorrection(!e(k)&&this.textPxLength||b.offsetWidth,h,z,k,t));C(b,{left:m+(this.xCorr||0)+"px",top:n+(this.yCorr||0)+"px"});this.cTT=w;this.oldRotation=k;this.oldAlign=t}}else this.alignOnAdd=!0},setSpanRotation:function(a,b,c){var d={},e=this.renderer.getTransformKey();d[e]=d.transform="rotate("+a+"deg)"; +d[e+(n?"Origin":"-origin")]=d.transformOrigin=100*b+"% "+c+"px";C(this.element,d)},getSpanCorrection:function(a,b,c){this.xCorr=-a*c;this.yCorr=-b}});g(c.prototype,{getTransformKey:function(){return t&&!/Edge/.test(m.navigator.userAgent)?"-ms-transform":u?"-webkit-transform":n?"MozTransform":m.opera?"-o-transform":""},html:function(c,d,e){var m=this.createElement("span"),p=m.element,n=m.renderer,r=n.isSVG,t=function(a,c){["opacity","visibility"].forEach(function(d){a[d+"Setter"]=function(a,f,e){b.prototype[d+ +"Setter"].call(this,a,f,e);c[f]=a}});a.addedSetters=!0},k=a.charts[n.chartIndex];k=k&&k.styledMode;m.textSetter=function(a){a!==p.innerHTML&&delete this.bBox;this.textStr=a;p.innerHTML=w(a,"");m.doTransform=!0};r&&t(m,m.element.style);m.xSetter=m.ySetter=m.alignSetter=m.rotationSetter=function(a,b){"align"===b&&(b="textAlign");m[b]=a;m.doTransform=!0};m.afterSetters=function(){this.doTransform&&(this.htmlUpdateTransform(),this.doTransform=!1)};m.attr({text:c,x:Math.round(d),y:Math.round(e)}).css({position:"absolute"}); +k||m.css({fontFamily:this.style.fontFamily,fontSize:this.style.fontSize});p.style.whiteSpace="nowrap";m.css=m.htmlCss;r&&(m.add=function(a){var b=n.box.parentNode,c=[];if(this.parentGroup=a){var d=a.div;if(!d){for(;a;)c.push(a),a=a.parentGroup;c.reverse().forEach(function(a){function f(b,c){a[c]=b;"translateX"===c?h.left=b+"px":h.top=b+"px";a.doTransform=!0}var e=E(a.element,"class");e&&(e={className:e});d=a.div=a.div||I("div",e,{position:"absolute",left:(a.translateX||0)+"px",top:(a.translateY|| +0)+"px",display:a.display,opacity:a.opacity,pointerEvents:a.styles&&a.styles.pointerEvents},d||b);var h=d.style;g(a,{classSetter:function(a){return function(b){this.element.setAttribute("class",b);a.className=b}}(d),on:function(){c[0].div&&m.on.apply({element:c[0].div},arguments);return a},translateXSetter:f,translateYSetter:f});a.addedSetters||t(a,h)})}}else d=b;d.appendChild(p);m.added=!0;m.alignOnAdd&&m.htmlUpdateTransform();return m});return m}})})(L);(function(a){var E=a.addEvent,I=a.Chart,C= +a.createElement,e=a.css,g=a.defaultOptions,n=a.defaultPlotOptions,t=a.extend,u=a.fireEvent,w=a.hasTouch,d=a.isObject,b=a.Legend,c=a.merge,m=a.pick,x=a.Point,r=a.Series,p=a.seriesTypes,q=a.svg;var D=a.TrackerMixin={drawTrackerPoint:function(){var a=this,b=a.chart,c=b.pointer,d=function(a){var b=c.getPointFromEvent(a);void 0!==b&&(c.isDirectTouch=!0,b.onMouseOver(a))};a.points.forEach(function(a){a.graphic&&(a.graphic.element.point=a);a.dataLabel&&(a.dataLabel.div?a.dataLabel.div.point=a:a.dataLabel.element.point= +a)});a._hasTracking||(a.trackerGroups.forEach(function(h){if(a[h]){a[h].addClass("highcharts-tracker").on("mouseover",d).on("mouseout",function(a){c.onTrackerMouseOut(a)});if(w)a[h].on("touchstart",d);!b.styledMode&&a.options.cursor&&a[h].css(e).css({cursor:a.options.cursor})}}),a._hasTracking=!0);u(this,"afterDrawTracker")},drawTrackerGraph:function(){var a=this,b=a.options,c=b.trackByArea,d=[].concat(c?a.areaPath:a.graphPath),e=d.length,g=a.chart,m=g.pointer,p=g.renderer,f=g.options.tooltip.snap, +l=a.tracker,n,r=function(){if(g.hoverSeries!==a)a.onMouseOver()},t="rgba(192,192,192,"+(q?1E-4:.002)+")";if(e&&!c)for(n=e+1;n--;)"M"===d[n]&&d.splice(n+1,0,d[n+1]-f,d[n+2],"L"),(n&&"M"===d[n]||n===e)&&d.splice(n,0,"L",d[n-2]+f,d[n-1]);l?l.attr({d:d}):a.graph&&(a.tracker=p.path(d).attr({visibility:a.visible?"visible":"hidden",zIndex:2}).addClass(c?"highcharts-tracker-area":"highcharts-tracker-line").add(a.group),g.styledMode||a.tracker.attr({"stroke-linejoin":"round",stroke:t,fill:c?t:"none","stroke-width":a.graph.strokeWidth()+ +(c?0:2*f)}),[a.tracker,a.markerGroup].forEach(function(a){a.addClass("highcharts-tracker").on("mouseover",r).on("mouseout",function(a){m.onTrackerMouseOut(a)});b.cursor&&!g.styledMode&&a.css({cursor:b.cursor});if(w)a.on("touchstart",r)}));u(this,"afterDrawTracker")}};p.column&&(p.column.prototype.drawTracker=D.drawTrackerPoint);p.pie&&(p.pie.prototype.drawTracker=D.drawTrackerPoint);p.scatter&&(p.scatter.prototype.drawTracker=D.drawTrackerPoint);t(b.prototype,{setItemEvents:function(a,b,d){var e= +this,h=e.chart.renderer.boxWrapper,g="highcharts-legend-"+(a instanceof x?"point":"series")+"-active",m=e.chart.styledMode;(d?b:a.legendGroup).on("mouseover",function(){a.setState("hover");h.addClass(g);m||b.css(e.options.itemHoverStyle)}).on("mouseout",function(){e.styledMode||b.css(c(a.visible?e.itemStyle:e.itemHiddenStyle));h.removeClass(g);a.setState()}).on("click",function(b){var c=function(){a.setVisible&&a.setVisible()};h.removeClass(g);b={browserEvent:b};a.firePointEvent?a.firePointEvent("legendItemClick", +b,c):u(a,"legendItemClick",b,c)})},createCheckboxForItem:function(a){a.checkbox=C("input",{type:"checkbox",className:"highcharts-legend-checkbox",checked:a.selected,defaultChecked:a.selected},this.options.itemCheckboxStyle,this.chart.container);E(a.checkbox,"click",function(b){u(a.series||a,"checkboxClick",{checked:b.target.checked,item:a},function(){a.select()})})}});t(I.prototype,{showResetZoom:function(){function a(){b.zoomOut()}var b=this,c=g.lang,d=b.options.chart.resetZoomButton,e=d.theme,m= +e.states,p="chart"===d.relativeTo?null:"plotBox";u(this,"beforeShowResetZoom",null,function(){b.resetZoomButton=b.renderer.button(c.resetZoom,null,null,a,e,m&&m.hover).attr({align:d.position.align,title:c.resetZoomTitle}).addClass("highcharts-reset-zoom").add().align(d.position,!1,p)})},zoomOut:function(){u(this,"selection",{resetSelection:!0},this.zoom)},zoom:function(a){var b,c=this.pointer,e=!1;!a||a.resetSelection?(this.axes.forEach(function(a){b=a.zoom()}),c.initiated=!1):a.xAxis.concat(a.yAxis).forEach(function(a){var d= +a.axis;c[d.isXAxis?"zoomX":"zoomY"]&&(b=d.zoom(a.min,a.max),d.displayBtn&&(e=!0))});var h=this.resetZoomButton;e&&!h?this.showResetZoom():!e&&d(h)&&(this.resetZoomButton=h.destroy());b&&this.redraw(m(this.options.chart.animation,a&&a.animation,100>this.pointCount))},pan:function(a,b){var c=this,d=c.hoverPoints,h;u(this,"pan",{originalEvent:a},function(){d&&d.forEach(function(a){a.setState()});("xy"===b?[1,0]:[1]).forEach(function(b){b=c[b?"xAxis":"yAxis"][0];var d=b.horiz,e=a[d?"chartX":"chartY"]; +d=d?"mouseDownX":"mouseDownY";var f=c[d],g=(b.pointRange||0)/2,k=b.reversed&&!c.inverted||!b.reversed&&c.inverted?-1:1,m=b.getExtremes(),p=b.toValue(f-e,!0)+g*k;k=b.toValue(f+b.len-e,!0)-g*k;var n=kthis.max&&d>this.max;if(c&&b){if(p){var q=c.toString()===b.toString();n=0}for(p=0;pa&&b>n?(b=Math.max(a,n),c=2*n-b):be&&c>n?(c=Math.max(e,n),b=2*n-c):ch.max&&(e=h.max-y,I=!0);I?(E-=.8*(E-b[n][0]),v||(l-=.8*(l-b[n][1])),t()):b[n]=[E,l];k||(d[n]=C-D,d[q]=y);d=k?1/z:z;w[q]=y;w[n]=e;u[k?a?"scaleY":"scaleX":"scale"+r]=z;u["translate"+r]=d*D+(E-d*F)},pinch:function(a){var g=this,t=g.chart,u=g.pinchDown,w=a.touches,d=w.length,b=g.lastValidTouch,c=g.hasZoom,m=g.selectionMarker,x={},r=1===d&&(g.inClass(a.target,"highcharts-tracker")&&t.runTrackerClick|| +g.runChartClick),p={};1>>0,s=0;sDe(e)?(r=e+1,a=o-De(e)):(r=e,a=o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(De(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),H("week","w"),H("isoWeek","W"),L("week",5),L("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=k(e)});I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),H("day","d"),H("weekday","e"),H("isoWeekday","E"),L("day",11),L("weekday",11),L("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=k(e)});var je="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var Ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var ze="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var $e=ae;var qe=ae;var Je=ae;function Be(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=de(o[t]),u[t]=de(u[t]),l[t]=de(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Qe(){return this.hours()%12||12}function Xe(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function Ke(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Qe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Qe.apply(this)+U(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Qe.apply(this)+U(this.minutes(),2)+U(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+U(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+U(this.minutes(),2)+U(this.seconds(),2)}),Xe("a",!0),Xe("A",!1),H("hour","h"),L("hour",13),ue("a",Ke),ue("A",Ke),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=k(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=k(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s,2)),t[ve]=k(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s,2)),t[ve]=k(e.substr(i))});var et,tt=Te("Hours",!0),nt={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:He,monthsShort:Re,week:{dow:0,doy:6},weekdays:je,weekdaysMin:ze,weekdaysShort:Ze,meridiemParse:/[ap]\.?m?\.?/i},st={},it={};function rt(e){return e?e.toLowerCase().replace("_","-"):e}function at(e){var t=null;if(!st[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=et._abbr,require("./locale/"+e),ot(t)}catch(e){}return st[e]}function ot(e,t){var n;return e&&((n=l(t)?lt(e):ut(e,t))?et=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),et._abbr}function ut(e,t){if(null!==t){var n,s=nt;if(t.abbr=e,null!=st[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=st[e]._config;else if(null!=t.parentLocale)if(null!=st[t.parentLocale])s=st[t.parentLocale]._config;else{if(null==(n=at(t.parentLocale)))return it[t.parentLocale]||(it[t.parentLocale]=[]),it[t.parentLocale].push({name:e,config:t}),null;s=n._config}return st[e]=new P(b(s,t)),it[e]&&it[e].forEach(function(e){ut(e.name,e.config)}),ot(e),st[e]}return delete st[e],null}function lt(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return et;if(!o(e)){if(t=at(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return et}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ht(e._a[me],s[me]),(e._dayOfYear>De(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[pe]&&0===e._a[ve]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o=new Date(e,t,n,s,i,r,a);return e<100&&0<=e&&isFinite(o.getFullYear())&&o.setFullYear(e),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var ft=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,mt=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/Z|[+-]\d\d(?::?\d\d)?/,yt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],gt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function vt(e){var t,n,s,i,r,a,o=e._i,u=ft.exec(o)||mt.exec(o);if(u){for(g(e).iso=!0,t=0,n=yt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},ln.isLocal=function(){return!!this.isValid()&&!this._isUTC},ln.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},ln.isUtc=Vt,ln.isUTC=Vt,ln.zoneAbbr=function(){return this._isUTC?"UTC":""},ln.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},ln.dates=n("dates accessor is deprecated. Use date instead.",nn),ln.months=n("months accessor is deprecated. Use month instead",Fe),ln.years=n("years accessor is deprecated. Use year instead",Oe),ln.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),ln.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Yt(e))._a){var t=e._isUTC?y(e._a):Tt(e._a);this._isDSTShifted=this.isValid()&&0. +*/ +var rocket={};rocket.reduceRight=function(a,b,c){rocket.reduceRight=Array.prototype.reduceRight?function(a,b,c){return 3===arguments.length?Array.prototype.reduceRight.call(a,b,c):Array.prototype.reduceRight.call(a,b)}:function(a,b,c){var g,h=a.length-1;3===arguments.length?(g=h,h=c):(g=h-1,h=a[h]);for(;-1>g;--g)h=b(h,a[g],g,a);return h};return 3===arguments.length?rocket.reduceRight(a,b,c):rocket.reduceRight(a,b)};rocket.reduce=function(a,b,c){rocket.reduce=Array.prototype.reduce?function(a,b,c){return 3===arguments.length?Array.prototype.reduce.call(a,b,c):Array.prototype.reduce.call(a,b)}:function(a,b,c){var g,h=a.length,k;3===arguments.length?(g=0,k=c):(g=1,k=a[0]);for(;ga;if(0>d){var l=Math.pow(10,Math.abs(d));a=Math.floor(a/l)*l}else a=a.toFixed(d);l=(""+a).split("");"-"===l[0]&&l.splice(0,1);var m=l.length-1-d;0=d||"A"<=d&&"Z">=d||"$"===d||"_"===d){do d=a.charAt(b++);while("a"<=d&&"z">=d||"A"<=d&&"Z">=d||"$"===d||"_"===d||"0"<=d&&"9">=d);d="identifier";--b}else if("/"===d)if("*"===a.charAt(b)){do d=a.charAt(b++);while(b=d||"."===d&&"0"<=a.charAt(b+1)&&"9">=a.charAt(b+1)){do d=a.charAt(b++);while("0"<=d&&"9">=d||"."===d||"x"===d||"e"===d);d="number"}else{do d=a.charAt(b++);while(" "===d||"\t"===d||"\r"===d||"\n"===d);d="whitespace"}--b}f= +a.substr(f-1,b-f+1);e.push({type:"identifier"===d&&f in rocket.lexeme.words_?"word":d,value:f})}return e};rocket.lexeme.operators_={"!":1,"#":1,"%":1,"&":1,"(":1,")":1,"*":1,"+":1,",":1,"-":1,".":1,"/":1,":":1,";":1,"<":1,"=":1,">":1,"?":1,"@":1,"[":1,"]":1,"^":1,"{":1,"|":1,"}":1}; +rocket.lexeme.words_={"true":1,"false":1,"break":1,"case":1,"catch":1,"continue":1,"debugger":1,"default":1,"delete":1,"do":1,"else":1,"finally":1,"for":1,"function":1,"if":1,"in":1,"instanceof":1,"new":1,"null":1,"return":1,"switch":1,"this":1,"throw":1,"try":1,"typeof":1,"var":1,"void":1,"while":1,"with":1,undefined:1,prototype:1,arguments:1};rocket.lastIndexOf=function(a,b,c){rocket.lastIndexOf=Array.prototype.lastIndexOf?function(a,b,c){return Array.prototype.lastIndexOf.apply(a,Array.prototype.slice.call(arguments,1))}:function(a,b,c){var g;g=a.length-1;for(g=2===arguments.length?g:Math.min(g,c);-1b.right&&(d.style("left",d.style("left")-f.right+b.right),f=d.getBoundingClientRect());f.leftb.bottom&&(d.style("top",d.style("top")-f.bottom+b.bottom),f=d.getBoundingClientRect());f.tope&&(e=0));b.scroll_top_= +c;c=e+b.query_length_-b.length_;0e&&(e=0);c=e+b.query_length_-b.length_;c=b.query_length_-(0a;++a)this.up()};rocket.Input.prototype.pageDownInternal=function(){for(var a=0;5>a;++a)this.down()};rocket.DateInput=function(){};rocket.inherits(rocket.DateInput,rocket.Input); +rocket.DateInput.prototype.showInternal=function(){var a=this.getInputElement().getBoundingClientRect(),b,c,e,d=this;this.container_=rocket.createElement("div").style({"border-radius":3,position:"absolute",border:"1px solid #888888",cursor:"pointer",width:300,left:a.left,top:a.bottom-1}).preventSelect().addEventListener(["mousedown","touchstart"],function(a){a.stopPropagation()}).live("td","mouseover",function(){b&&(b.style.backgroundColor=c,b.style.color=e);c=this.style.backgroundColor;e=this.style.color; +this.style.backgroundColor="#D5E2FF";this.style.color="";b=this}).live("td","click",function(){if("<<"===this.innerHTML)--d.calendar_year_,d.draw_calendar_();else if("<"===this.innerHTML)d.calendar_month_?--d.calendar_month_:(d.calendar_month_=11,--d.calendar_year_),d.draw_calendar_();else if(">>"===this.innerHTML)++d.calendar_year_,d.draw_calendar_();else if(">"===this.innerHTML)11>d.calendar_month_?++d.calendar_month_:(d.calendar_month_=0,++d.calendar_year_),d.draw_calendar_(); +else{var a=+this.innerHTML;if(a){var b=d.calendar_year_,c=d.calendar_month_;1===this.parentNode.rowIndex&&6a?(11===c&&++b,c=(c+2)%12||12):++c;d.getInputElement().value(rocket.padLeft(c,2,"0")+"/"+rocket.padLeft(this.innerHTML,2,"0")+"/"+b).setSelectionRange(0,10).focus();d.hide()}}});this.changeInternal();(new rocket.Elements([document.body])).appendChild(this.container_);this.container_.fit()}; +rocket.DateInput.prototype.enterInternal=function(){var a=rocket.strToDate(this.getInputElement().value());a&&this.getInputElement().value(rocket.padLeft(a.getMonth()+1,2,"0")+"/"+rocket.padLeft(a.getDate(),2,"0")+"/"+a.getFullYear()).setSelectionRange(0,10)}; +rocket.DateInput.prototype.hideInternal=function(){var a=rocket.strToDate(this.getInputElement().value());a&&this.getInputElement().value(rocket.padLeft(a.getMonth()+1,2,"0")+"/"+rocket.padLeft(a.getDate(),2,"0")+"/"+a.getFullYear());this.container_.removeEventListener();(new rocket.Elements([document.body])).removeChild(this.container_);delete this.container_}; +rocket.DateInput.prototype.changeInternal=function(){var a=(this.entered_date_=rocket.strToDate(this.getInputElement().value()))||new Date;this.calendar_year_=a.getFullYear();this.calendar_month_=a.getMonth();this.draw_calendar_()}; +rocket.DateInput.prototype.draw_calendar_=function(){var a=rocket.createElement("table"),b=rocket.createElement("tbody");a.setAttribute({width:"100%",cellpadding:"5",cellspacing:"1",border:"0"}).style({"table-layout":"fixed","background-color":"#CCCCCC"});b.style({"background-color":"#FFFFFF"});var c=rocket.createElement("tr");this.draw_calendar_header_(c);b.appendChild(c);this.draw_calendar_contents_(b);a.appendChild(b);this.container_.innerHTML("").appendChild(a)}; +rocket.DateInput.prototype.draw_calendar_header_=function(a){var b;b=rocket.createElement("td");b.innerHTML("<<").setAttribute({align:"center"});a.appendChild(b);b=rocket.createElement("td");b.innerHTML("<").setAttribute({align:"center"});a.appendChild(b);b=rocket.createElement("td");b.innerHTML("January February March April May June July August September October November December".split(" ")[this.calendar_month_]+" "+this.calendar_year_).setAttribute({colspan:3,align:"center"});a.appendChild(b); +b=rocket.createElement("td");b.innerHTML(">").setAttribute({align:"center"});a.appendChild(b);b=rocket.createElement("td");b.innerHTML(">>").setAttribute({align:"center"});a.appendChild(b)}; +rocket.DateInput.prototype.draw_calendar_contents_=function(a){for(var b=32-(new Date(this.calendar_year_,this.calendar_month_,32)).getDate(),c=32-(new Date(this.calendar_year_,this.calendar_month_-1,32)).getDate(),e=(new Date(this.calendar_year_,this.calendar_month_)).getDay(),d=new Date,f=d.getFullYear(),g=d.getMonth(),d=d.getDate(),e=c-(e||7),h=0,k=0,l=0;6>l;++l){for(var m=rocket.createElement("tr"),n=0;7>n;++n){var p=rocket.createElement("td");p.setAttribute({align:"center"});ec?c:a};rocket.chunk=function(a,b){for(var c=[],e=0,d=a.length;eb[d])return 1}return 0});var b=this;this.query_=function(c){var e=[];c=c.toLowerCase();for(var d=0;a[d];++d)for(var f=0;a[d][f];++f)if(-1!==a[d][f].toLowerCase().replace(/<[^>]+>/g,"").indexOf(c)){e.push(a[d]);break}b.setResults(e)}}; +rocket.AutoSuggest.prototype.highlight_=function(a,b){this.highlighted_.style({backgroundColor:""});this.highlighted_=a;this.highlighted_.style({backgroundColor:"#D5E2FF"});if(b){var c=this.highlighted_.getBoundingClientRect(),e=this.scroller_.getBoundingClientRect();c.bottom>e.bottom?this.scroller_.setAttribute({scrollTop:this.scroller_.getAttribute("scrollTop")+c.bottom-e.bottom}):c.top]+>/g,""))};rocket.AutoSuggest.prototype.getResult=function(){return this.result_}; +rocket.AutoSuggest.prototype.enterInternal=function(){var a;this.highlighted_.length?a=this.results_[this.highlighted_.getAttribute("rowIndex")]:1===this.results_.length&&(a=this.results_[0]);a&&(this.setResult(a),(new rocket.Elements([document.body])).contains(this.getInputElement())&&this.getInputElement().setSelectionRange(0,a[0].length).focus(),this.dispatchEvent("select"))}; +rocket.AutoSuggest.prototype.hideInternal=function(){this.container_.removeEventListener();(new rocket.Elements([document.body])).removeChild(this.container_);delete this.container_};rocket.AutoSuggest.prototype.upInternal=function(){var a=this.tbody_.children(),b=this.highlighted_.getAttribute("rowIndex");b?this.highlight_(a.i(b-1),!0):this.highlight_(a.i(a.length-1),!0)}; +rocket.AutoSuggest.prototype.downInternal=function(){var a=this.tbody_.children(),b=this.highlighted_.getAttribute("rowIndex");a[b+1]?this.highlight_(a.i(b+1),!0):this.highlight_(a.i(0),!0)};rocket.AutoSelect=function(){this.addEventListener("show",function(){if(this.getResult()){this.place_holder_=rocket.createElement("div");this.getInputElement().value("");var a=this.getInputElement().getBoundingClientRect();this.place_holder_.style({"border-radius":3,position:"absolute","background-color":"#FFFFFF",border:"1px solid #888888",top:0,width:a.width-2,left:a.left}).innerHTML(this.getResult()[0]);(new rocket.Elements([document.body])).appendChild(this.place_holder_);var b=this.place_holder_.getBoundingClientRect(); +this.place_holder_.style({top:a.top-b.height+1})}});this.addEventListener("hide",function(){this.place_holder_&&(this.getInputElement().value(this.getResult()[0]),(new rocket.Elements([document.body])).removeChild(this.place_holder_),delete this.place_holder_)})};rocket.inherits(rocket.AutoSelect,rocket.AutoSuggest);rocket.AutoSelect.prototype.getPlaceHolder=function(){return this.place_holder_};rocket.arrayify=function(a){if("string"!==typeof a)return a||[];a=rocket.trim(a);return""===a?[]:-1===a.indexOf(",")?-1===a.indexOf(" ")&&-1===a.indexOf("\r")&&-1===a.indexOf("\n")&&-1===a.indexOf("\t")?a.split(" "):a.split(/\s+/):a.split(/[\s,]+/)};rocket.$=function(a,b){var c;if("string"===typeof a)c=1===arguments.length?"#"===a.charAt(0)&&a.match(/^#[\w\d]+$/)?(c=document.getElementById(a.substr(1)))?[c]:[]:rocket.querySelectorAll(a,document):"string"===typeof b?rocket.querySelectorAll(a,rocket.querySelectorAll(b)[0]):b.nodeType?rocket.querySelectorAll(a,b):rocket.querySelectorAll(a,b[0]);else if(a)if(a.nodeType||a===window)c=[a];else if(rocket.isArray(a[0])){c=[];for(var e=0;a[e];++e)for(var d=0;a[e][d];++d)c.push(a[e][d])}else c=a;else c= +[];return new rocket.Elements(c)};rocket.round=function(a,b){if(0>b){var c=Math.pow(10,Math.abs(b));return Math.floor(a/c)*c}return parseFloat((+a).toFixed(b))};rocket.setInterval=function(a,b,c){rocket.setInterval=function(a,b,c){if(3>arguments.length)return setInterval(a,b);var g=Array.prototype.slice.call(arguments,2);return setInterval(function(){a.apply(window,g)},b)};window.setTimeout(function(a){!0===a&&(rocket.setInterval=setInterval)},0,!0);return rocket.setInterval.apply(window,arguments)};rocket.setTimeout=function(a,b,c){rocket.setTimeout=function(a,b,c){if(3>arguments.length)return setTimeout(a,b);var g=Array.prototype.slice.call(arguments,2);return setTimeout(function(){a.apply(window,g)},b)};window.setTimeout(function(a){!0===a&&(rocket.setTimeout=setTimeout)},0,!0);return rocket.setTimeout.apply(window,arguments)};rocket.some=function(a,b,c){rocket.some=Array.prototype.some?function(a,b,c){return Array.prototype.some.call(a,b,c)}:function(a,b,c){for(var g=0,h=a.length;gn)return d.desc?-1:1;if(mf?e=a:3===f||5===f||7===f?(c=a.substr(0,1),e=a.substr(1,2),b=a.substr(3,4)):9>f&&(c=a.substr(0,2),e=a.substr(2,2),b=a.substr(4,4)):(f=a.replace(/^\D+|\D+$/g,"").split(/\D+/),4>f.length&&(-1===a.indexOf("-")?(c=f[0],e=f[1],b=f[2]):(b=f[0],c=f[1],e=f[2])));if(!e)return null;b?100>b&&(b=bb?"pm":"am"),b=rocket.padLeft(""+(b%12||12),2,"0"),c=rocket.padLeft(""+c,2,"0"),b+":"+c+" "+e):null};rocket.sum=function(a){for(var b=arguments.length,c=0;b--;)c+=arguments[b];return c};rocket.table=function(a,b){var c,e;2===arguments.length?(c=a,e=b):(e=1,c=1===arguments.length?a:1);var d=rocket.createElement("table"),f=rocket.createElement("tbody");d.setAttribute({width:"100%",cellpadding:"0",cellspacing:"0",border:"0"});d.style({"table-layout":"fixed"});d.trs=[];d.tds=[];for(var g=0;gd;++d)e=rocket.createElement("td"),e.setAttribute({align:"center"}),e.innerHTML(""+(d||12)),c.appendChild(e);b.appendChild(c);c=rocket.createElement("tr");for(d= +0;4>d;++d)e=rocket.createElement("td"),e.setAttribute({align:"center",colspan:3}),e.innerHTML(""+(15*d||"00")),c.appendChild(e);b.appendChild(c);c=rocket.createElement("tr");e=rocket.createElement("td");e.setAttribute({align:"center",colspan:6});e.innerHTML("am");c.appendChild(e);e=rocket.createElement("td");e.setAttribute({align:"center",colspan:6});e.innerHTML("pm");c.appendChild(e);b.appendChild(c);a.appendChild(b);this.container_.appendChild(a)}; +rocket.TimeInput.prototype.enterInternal=function(){var a=rocket.strToTime(this.getInputElement().value());a&&this.getInputElement().value(a)};rocket.TimeInput.prototype.hideInternal=function(){var a=rocket.strToTime(this.getInputElement().value());a&&this.getInputElement().value(a);this.container_.removeEventListener();(new rocket.Elements([document.body])).removeChild(this.container_);delete this.container_};rocket.trim=function(a){rocket.trim=String.trim?String.trim:function(a){return a&&a.replace(/^\s+|\s+$/g,"")};return rocket.trim(a)};rocket.trimLeft=function(a){rocket.trimLeft=String.trimLeft?String.trimLeft:function(a){return a&&a.replace(/^\s+/,"")};return rocket.trimLeft(a)};rocket.trimRight=function(a){rocket.trimRight=String.trimRight?String.trimRight:function(a){return a&&a.replace(/\s+$/,"")};return rocket.trimRight(a)};rocket.unique=function(a){for(var b=[],c=0,e=a.length;c + + + beestat + + + + + + + + + + +
+
+
+ beestat +
+ +
+
+

Privacy

+

+ Beestat exists to provide useful data to people about their HVAC usage; we have no interest in selling your data. +

+

Data we store

+
    +
  • Your email address
  • +
  • Your physical address and other details about your home
  • +
  • Your thermostat and sensor names
  • +
  • Your thermostat settings
  • +
  • Your entire runtime history
  • +
  • Your beestat usage logs
  • +
+

Data we share

+

+ In no circumstances will any of your data ever be shared with a third party; this includes de-identified data. Your data will only be used in aggregate to show comparisons, trends, and other useful information to other beestat users. This will never include any identifiable information. +

+

Third parties

+

+ Some information is shared with the following third parties in order to provide a meaningful service. +

+
    +
  • MailChimp receives your email address so we can send you product news and updates.
  • +
  • Rollbar receives your IP address, browser information, and generic information about network requests to beestat when an error occurs.
  • +
  • Google Analytics receives your IP address, browser information, and pages you visit
  • +
+

Access to your thermostats

+

+ Beestat will have continued access to all of the thermostats and their data unless you manually revoke it. For ecobee, you may do this by logging into their web portal, clicking the menu button in the top right, selecting My Apps, selecting beestat, then selecting Remove App. +

+

Data retention

+

+ All data stored in the beestat database is kept indefinitely regardless of your continued usage of beestat or if you revoke beestat's access to your data. To have your data deleted, submit a request to ziebelje@gmail.com. +

+
+
+
+ + diff --git a/service_worker.js b/service_worker.js new file mode 100644 index 0000000..d33530f --- /dev/null +++ b/service_worker.js @@ -0,0 +1 @@ +self.addEventListener('fetch', function(event) {});